feat: moving project to git
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
export const ROLE = 'ddc-brightness';
|
||||
export const SCHEMA_ID = 'org.gnome.shell.extensions.ddcbrightness';
|
||||
|
||||
/** VCP feature code used when none is configured (10 = brightness). */
|
||||
export const DEFAULT_VCP_CODE = '10';
|
||||
|
||||
/**
|
||||
* Scales the mandatory I2C delays ddcutil inserts around DDC/CI transactions.
|
||||
* Lower = faster but less tolerant of flaky monitors. Tune per hardware.
|
||||
*/
|
||||
export const DDCUTIL_SLEEP_MULTIPLIER = '0.5';
|
||||
|
||||
/** Prefix for all console logging from this extension. */
|
||||
export const LOG_PREFIX = '[ddc-brightness]';
|
||||
@@ -0,0 +1,86 @@
|
||||
import Gio from 'gi://Gio';
|
||||
import { LOG_PREFIX, DDCUTIL_SLEEP_MULTIPLIER } from './constants.js';
|
||||
|
||||
/** A display as parsed from `ddcutil detect`, before any state is attached. */
|
||||
export interface ParsedDisplay {
|
||||
bus: string;
|
||||
name: string;
|
||||
connector: string;
|
||||
}
|
||||
|
||||
// Run a command asynchronously, capturing stdout. The callback always fires:
|
||||
// with stdout on success, or an empty string on failure.
|
||||
export function runCommandAsync(args: string[], callback: (stdout: string) => void): void {
|
||||
console.log(`${LOG_PREFIX} Running: ${args.join(' ')}`);
|
||||
try {
|
||||
const subprocess = new Gio.Subprocess({
|
||||
argv: args,
|
||||
flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
|
||||
});
|
||||
subprocess.init(null);
|
||||
|
||||
subprocess.communicate_utf8_async(null, null, (proc: Gio.Subprocess | null, result: Gio.AsyncResult) => {
|
||||
try {
|
||||
const [, stdout] = (proc ?? subprocess).communicate_utf8_finish(result);
|
||||
console.log(`${LOG_PREFIX} Done: ${stdout.trim().substring(0, 80)}`);
|
||||
callback(stdout);
|
||||
} catch (e) {
|
||||
console.error(`${LOG_PREFIX} Command failed: ${e}`);
|
||||
callback('');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`${LOG_PREFIX} Failed to spawn: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect connected DDC displays and return them parsed.
|
||||
export function detectDisplays(callback: (displays: ParsedDisplay[]) => void): void {
|
||||
runCommandAsync(['ddcutil', 'detect', '--brief'], (stdout: string) => {
|
||||
callback(parseDisplays(stdout));
|
||||
});
|
||||
}
|
||||
|
||||
// Parse the output of `ddcutil detect --brief` into a list of displays.
|
||||
export function parseDisplays(stdout: string): ParsedDisplay[] {
|
||||
const displays: ParsedDisplay[] = [];
|
||||
const blocks = stdout.split(/^Display\s+\d+/m);
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.trim()) continue;
|
||||
|
||||
const busMatch = block.match(/I2C bus:\s+\/dev\/i2c-(\d+)/);
|
||||
const monitorMatch = block.match(/Monitor:\s+(.+)/);
|
||||
const connectorMatch = block.match(/DRM connector:\s+card\d+-(\S+)/);
|
||||
|
||||
if (busMatch) {
|
||||
const bus = busMatch[1];
|
||||
const name = monitorMatch ? monitorMatch[1].trim() : `Display (bus ${bus})`;
|
||||
const connector = connectorMatch ? connectorMatch[1] : '';
|
||||
displays.push({ bus, name, connector });
|
||||
}
|
||||
}
|
||||
|
||||
return displays;
|
||||
}
|
||||
|
||||
// Read the current brightness (0-100) for a bus, or null if unreadable.
|
||||
export function readBrightness(bus: string, vcpCode: string, callback: (value: number | null) => void): void {
|
||||
runCommandAsync(
|
||||
['ddcutil', 'getvcp', vcpCode, '--bus', bus, '--brief', '--sleep-multiplier', DDCUTIL_SLEEP_MULTIPLIER],
|
||||
(stdout: string) => {
|
||||
const match = stdout.match(/VCP\s+\w+\s+\w+\s+(\d+)\s+(\d+)/);
|
||||
callback(match ? parseInt(match[1]) : null);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Set the brightness (0-100) for a bus. `callback` fires when the write
|
||||
// completes (success or failure), so callers can chain the next write.
|
||||
// `--noverify` skips ddcutil's post-write read-back for speed.
|
||||
export function setBrightness(bus: string, vcpCode: string, value: number, callback: () => void): void {
|
||||
runCommandAsync(
|
||||
['ddcutil', 'setvcp', vcpCode, String(value), '--bus', bus, '--noverify', '--sleep-multiplier', DDCUTIL_SLEEP_MULTIPLIER],
|
||||
() => callback(),
|
||||
);
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import Gio from 'gi://Gio';
|
||||
import St from 'gi://St';
|
||||
import * as Slider from 'resource:///org/gnome/shell/ui/slider.js';
|
||||
import { DEFAULT_VCP_CODE, LOG_PREFIX } from './constants.js';
|
||||
import * as Ddcutil from './ddcutil.js';
|
||||
|
||||
/** A detected display plus the UI refs and runtime state attached to it. */
|
||||
export interface DisplayInfo {
|
||||
bus: string;
|
||||
name: string;
|
||||
connector: string;
|
||||
monitorIndex: number;
|
||||
currentValue: number;
|
||||
slider: Slider.Slider | null;
|
||||
valueLabel: St.Label | null;
|
||||
// Reconciliation state for writes: `sentValue` is the value of the most
|
||||
// recent write handed to ddcutil; `inFlight` is true while that write runs.
|
||||
sentValue: number;
|
||||
inFlight: boolean;
|
||||
reading: boolean;
|
||||
updatingFromCode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the set of detected displays and all brightness operations on them
|
||||
* (detection, monitor mapping, reads, and writes via ddcutil).
|
||||
*
|
||||
* Writes use a reconciliation model rather than debouncing: `currentValue` is
|
||||
* the user's target and at most one write per display is ever in flight. When a
|
||||
* write finishes, if the target has since moved we immediately send the latest
|
||||
* value — so intermediate values are coalesced and we never queue stale writes.
|
||||
*/
|
||||
export class DisplayController {
|
||||
private _settings: Gio.Settings;
|
||||
private _displays: DisplayInfo[] = [];
|
||||
private _detectComplete = false;
|
||||
private _disposed = false;
|
||||
|
||||
/** Invoked after detection finishes so the UI can rebuild itself. */
|
||||
onDetectComplete: (() => void) | null = null;
|
||||
|
||||
constructor(settings: Gio.Settings) {
|
||||
this._settings = settings;
|
||||
}
|
||||
|
||||
get displays(): DisplayInfo[] {
|
||||
return this._displays;
|
||||
}
|
||||
|
||||
get detectComplete(): boolean {
|
||||
return this._detectComplete;
|
||||
}
|
||||
|
||||
private get _vcpCode(): string {
|
||||
return this._settings.get_string('vcp-code') || DEFAULT_VCP_CODE;
|
||||
}
|
||||
|
||||
/** Clear all state back to the pre-detection condition. */
|
||||
reset(): void {
|
||||
this._displays = [];
|
||||
this._detectComplete = false;
|
||||
}
|
||||
|
||||
/** Stop issuing further writes. Call before disposal. */
|
||||
cleanup(): void {
|
||||
this._disposed = true;
|
||||
}
|
||||
|
||||
/** Detect displays, map them to monitors, then read their brightness. */
|
||||
detect(): void {
|
||||
Ddcutil.detectDisplays((parsed) => {
|
||||
this._displays = parsed.map((p) => ({
|
||||
bus: p.bus,
|
||||
name: p.name,
|
||||
connector: p.connector,
|
||||
monitorIndex: -1,
|
||||
currentValue: 50,
|
||||
slider: null,
|
||||
valueLabel: null,
|
||||
sentValue: 50,
|
||||
inFlight: false,
|
||||
reading: false,
|
||||
updatingFromCode: false,
|
||||
}));
|
||||
this._detectComplete = true;
|
||||
console.log(`${LOG_PREFIX} Found ${this._displays.length} displays`);
|
||||
|
||||
this._mapMonitorsToDisplays();
|
||||
this.onDetectComplete?.();
|
||||
|
||||
for (const display of this._displays) {
|
||||
this.readBrightness(display);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _mapMonitorsToDisplays(): void {
|
||||
const backend = global.backend;
|
||||
const monitorManager = backend.get_monitor_manager();
|
||||
|
||||
for (const display of this._displays) {
|
||||
if (display.connector) {
|
||||
const idx = monitorManager.get_monitor_for_connector(display.connector);
|
||||
if (idx >= 0) {
|
||||
display.monitorIndex = idx;
|
||||
console.log(`${LOG_PREFIX} Mapped ${display.name} connector=${display.connector} -> monitor ${idx}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getFocusedMonitorIndex(): number {
|
||||
const focusWindow = global.display.focus_window;
|
||||
if (focusWindow) {
|
||||
return focusWindow.get_monitor();
|
||||
}
|
||||
return global.display.get_primary_monitor();
|
||||
}
|
||||
|
||||
/** The display to act on for global actions, honoring "link displays". */
|
||||
getActiveDisplay(): DisplayInfo | null {
|
||||
if (this._settings.get_boolean('link-displays')) {
|
||||
return this._displays[0] ?? null;
|
||||
}
|
||||
|
||||
const monitorIdx = this._getFocusedMonitorIndex();
|
||||
const display = this._displays.find((d) => d.monitorIndex === monitorIdx);
|
||||
return display ?? this._displays[0] ?? null;
|
||||
}
|
||||
|
||||
// Nudge brightness by `delta` steps (the step size comes from settings).
|
||||
adjustDelta(delta: number): void {
|
||||
const step = this._settings.get_int('step');
|
||||
const change = delta * step;
|
||||
|
||||
if (this._settings.get_boolean('link-displays')) {
|
||||
for (const display of this._displays) {
|
||||
this.applyChange(display, display.currentValue + change);
|
||||
}
|
||||
} else {
|
||||
const display = this.getActiveDisplay();
|
||||
if (display) {
|
||||
this.applyChange(display, display.currentValue + change);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set an absolute target value (clamped) on one display and reconcile.
|
||||
applyChange(display: DisplayInfo, rawValue: number): void {
|
||||
const newValue = Math.max(0, Math.min(100, rawValue));
|
||||
this._setTarget(display, newValue);
|
||||
this._requestSend(display);
|
||||
}
|
||||
|
||||
// Handle a user-driven slider change: update this display (and linked ones)
|
||||
// to the new target, then reconcile each.
|
||||
setFromSlider(display: DisplayInfo, pct: number): void {
|
||||
display.currentValue = pct;
|
||||
if (display.valueLabel) {
|
||||
display.valueLabel.set_text(`${pct}%`);
|
||||
}
|
||||
|
||||
if (this._settings.get_boolean('link-displays')) {
|
||||
for (const other of this._displays) {
|
||||
if (other === display) continue;
|
||||
this._setTarget(other, pct);
|
||||
this._requestSend(other);
|
||||
}
|
||||
}
|
||||
|
||||
this._requestSend(display);
|
||||
}
|
||||
|
||||
// Update a display's target value and reflect it in the slider/label.
|
||||
private _setTarget(display: DisplayInfo, value: number): void {
|
||||
display.currentValue = value;
|
||||
display.updatingFromCode = true;
|
||||
if (display.slider) {
|
||||
display.slider.value = value / 100;
|
||||
}
|
||||
if (display.valueLabel) {
|
||||
display.valueLabel.set_text(`${value}%`);
|
||||
}
|
||||
display.updatingFromCode = false;
|
||||
}
|
||||
|
||||
// Reconcile a display toward its target: if nothing is in flight and the
|
||||
// target differs from what we last sent, send it. On completion this is
|
||||
// re-run so any newer target is picked up. Guarantees one write at a time.
|
||||
private _requestSend(display: DisplayInfo): void {
|
||||
if (this._disposed) return;
|
||||
if (display.inFlight) return;
|
||||
if (display.currentValue === display.sentValue) return;
|
||||
|
||||
const value = display.currentValue;
|
||||
display.sentValue = value;
|
||||
display.inFlight = true;
|
||||
|
||||
Ddcutil.setBrightness(display.bus, this._vcpCode, value, () => {
|
||||
display.inFlight = false;
|
||||
this._requestSend(display);
|
||||
});
|
||||
}
|
||||
|
||||
// Read the current brightness for a display and reflect it in the UI.
|
||||
readBrightness(display: DisplayInfo): void {
|
||||
if (display.reading) return;
|
||||
display.reading = true;
|
||||
|
||||
Ddcutil.readBrightness(display.bus, this._vcpCode, (value) => {
|
||||
display.reading = false;
|
||||
if (value !== null) {
|
||||
display.sentValue = value;
|
||||
this._setTarget(display, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Gio from 'gi://Gio';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import {Extension, ExtensionMetadata} from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
import { ROLE, SCHEMA_ID, LOG_PREFIX } from './constants.js';
|
||||
import { DisplayController } from './displays.js';
|
||||
import { KeybindingManager } from './keybindings.js';
|
||||
import { BrightnessIndicator } from './indicator.js';
|
||||
|
||||
export default class DDCCBrightness extends Extension {
|
||||
private _settings: Gio.Settings;
|
||||
private _controller: DisplayController | null = null;
|
||||
private _indicator: BrightnessIndicator | null = null;
|
||||
private _keybindings: KeybindingManager | null = null;
|
||||
|
||||
constructor(metadata: ExtensionMetadata) {
|
||||
super(metadata);
|
||||
this._settings = this.getSettings(SCHEMA_ID);
|
||||
}
|
||||
|
||||
enable() {
|
||||
console.log(`${LOG_PREFIX} Enabling extension`);
|
||||
|
||||
this._controller = new DisplayController(this._settings);
|
||||
this._controller.onDetectComplete = () => this._indicator?.rebuildMenu();
|
||||
|
||||
this._indicator = new BrightnessIndicator({
|
||||
controller: this._controller,
|
||||
settings: this._settings,
|
||||
onRefresh: () => {
|
||||
this._controller!.reset();
|
||||
this._indicator!.rebuildMenu();
|
||||
this._controller!.detect();
|
||||
},
|
||||
onOpenPrefs: () => this.openPreferences(),
|
||||
});
|
||||
Main.panel.addToStatusArea(ROLE, this._indicator.button);
|
||||
|
||||
this._controller.detect();
|
||||
|
||||
this._keybindings = new KeybindingManager(this._settings, {
|
||||
'brightness-up': () => this._controller!.adjustDelta(1),
|
||||
'brightness-down': () => this._controller!.adjustDelta(-1),
|
||||
});
|
||||
this._keybindings.enable();
|
||||
}
|
||||
|
||||
disable() {
|
||||
console.log(`${LOG_PREFIX} Disabling extension`);
|
||||
|
||||
this._keybindings?.disable();
|
||||
this._keybindings = null;
|
||||
|
||||
this._controller?.cleanup();
|
||||
this._indicator?.destroy();
|
||||
|
||||
this._controller = null;
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Clutter from 'gi://Clutter';
|
||||
import Gio from 'gi://Gio';
|
||||
import St from 'gi://St';
|
||||
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||
import * as Slider from 'resource:///org/gnome/shell/ui/slider.js';
|
||||
import { DisplayController, DisplayInfo } from './displays.js';
|
||||
|
||||
interface IndicatorParams {
|
||||
controller: DisplayController;
|
||||
settings: Gio.Settings;
|
||||
onRefresh: () => void;
|
||||
onOpenPrefs: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel button and its popup menu: a brightness slider per display, a
|
||||
* "link displays" toggle, and refresh/settings actions.
|
||||
*/
|
||||
export class BrightnessIndicator {
|
||||
private _button: PanelMenu.Button;
|
||||
private _controller: DisplayController;
|
||||
private _settings: Gio.Settings;
|
||||
private _onRefresh: () => void;
|
||||
private _onOpenPrefs: () => void;
|
||||
|
||||
constructor(params: IndicatorParams) {
|
||||
this._controller = params.controller;
|
||||
this._settings = params.settings;
|
||||
this._onRefresh = params.onRefresh;
|
||||
this._onOpenPrefs = params.onOpenPrefs;
|
||||
|
||||
this._button = new PanelMenu.Button(0.5, 'DDC Brightness');
|
||||
|
||||
const icon = new St.Icon({
|
||||
iconName: 'display-brightness-symbolic',
|
||||
fallbackIconName: 'display-symbolic',
|
||||
styleClass: 'system-status-icon',
|
||||
});
|
||||
this._button.add_child(icon);
|
||||
|
||||
this.rebuildMenu();
|
||||
}
|
||||
|
||||
/** The underlying panel button, for adding to the status area. */
|
||||
get button(): PanelMenu.Button {
|
||||
return this._button;
|
||||
}
|
||||
|
||||
rebuildMenu(): void {
|
||||
const menu = this._button.menu as PopupMenu.PopupMenu;
|
||||
menu.removeAll();
|
||||
|
||||
if (!this._controller.detectComplete) {
|
||||
const loadingItem = new PopupMenu.PopupMenuItem('Detecting displays...');
|
||||
loadingItem.setSensitive(false);
|
||||
menu.addMenuItem(loadingItem);
|
||||
return;
|
||||
}
|
||||
|
||||
const displays = this._controller.displays;
|
||||
if (displays.length === 0) {
|
||||
const noDisplays = new PopupMenu.PopupMenuItem('No DDC displays found');
|
||||
noDisplays.setSensitive(false);
|
||||
menu.addMenuItem(noDisplays);
|
||||
} else {
|
||||
const linkRow = new PopupMenu.PopupSwitchMenuItem(
|
||||
'Link displays',
|
||||
this._settings.get_boolean('link-displays'),
|
||||
);
|
||||
linkRow.connect('toggled', (_item: PopupMenu.PopupSwitchMenuItem, state: boolean) => {
|
||||
this._settings.set_boolean('link-displays', state);
|
||||
});
|
||||
menu.addMenuItem(linkRow);
|
||||
|
||||
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
|
||||
for (const display of displays) {
|
||||
this._buildSliderForDisplay(menu, display);
|
||||
}
|
||||
}
|
||||
|
||||
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
|
||||
const refreshBtn = new PopupMenu.PopupMenuItem('Refresh Displays');
|
||||
refreshBtn.connect('activate', () => {
|
||||
this._onRefresh();
|
||||
});
|
||||
menu.addMenuItem(refreshBtn);
|
||||
|
||||
const prefsBtn = new PopupMenu.PopupMenuItem('Settings');
|
||||
prefsBtn.connect('activate', () => {
|
||||
this._onOpenPrefs();
|
||||
});
|
||||
menu.addMenuItem(prefsBtn);
|
||||
}
|
||||
|
||||
private _buildSliderForDisplay(menu: PopupMenu.PopupMenu, display: DisplayInfo): void {
|
||||
const labelRow = new PopupMenu.PopupMenuItem(display.name || `Display (bus ${display.bus})`);
|
||||
labelRow.setSensitive(false);
|
||||
menu.addMenuItem(labelRow);
|
||||
|
||||
const sliderRow = new PopupMenu.PopupBaseMenuItem({activate: false});
|
||||
|
||||
const slider = new Slider.Slider(display.currentValue / 100);
|
||||
|
||||
const valueLabel = new St.Label({
|
||||
text: `${display.currentValue}%`,
|
||||
yAlign: Clutter.ActorAlign.CENTER,
|
||||
style: 'min-width: 40px; text-align: right;',
|
||||
});
|
||||
|
||||
sliderRow.add_child(slider);
|
||||
sliderRow.add_child(valueLabel);
|
||||
menu.addMenuItem(sliderRow);
|
||||
|
||||
display.slider = slider;
|
||||
display.valueLabel = valueLabel;
|
||||
|
||||
slider.connect('notify::value', () => {
|
||||
if (display.updatingFromCode) return;
|
||||
const pct = Math.round(slider.value * 100);
|
||||
this._controller.setFromSlider(display, pct);
|
||||
});
|
||||
|
||||
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this._button.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Gio from 'gi://Gio';
|
||||
import Meta from 'gi://Meta';
|
||||
import Shell from 'gi://Shell';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
|
||||
/** Map of settings key -> callback to run when the shortcut fires. */
|
||||
export type KeybindingActions = Record<string, () => void>;
|
||||
|
||||
/**
|
||||
* Registers the extension's keyboard shortcuts with the window manager and
|
||||
* keeps them in sync as their settings change.
|
||||
*/
|
||||
export class KeybindingManager {
|
||||
private _settings: Gio.Settings;
|
||||
private _actions: KeybindingActions;
|
||||
private _bindings: Map<string, number> = new Map();
|
||||
|
||||
constructor(settings: Gio.Settings, actions: KeybindingActions) {
|
||||
this._settings = settings;
|
||||
this._actions = actions;
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
for (const [name, callback] of Object.entries(this._actions)) {
|
||||
this._bind(name, callback);
|
||||
}
|
||||
|
||||
for (const name of Object.keys(this._actions)) {
|
||||
this._settings.connect(`changed::${name}`, () => {
|
||||
this._refresh(name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this._bindings.forEach((_, key) => {
|
||||
Main.wm.removeKeybinding(key);
|
||||
});
|
||||
this._bindings.clear();
|
||||
}
|
||||
|
||||
private _bind(settingName: string, callback: () => void): void {
|
||||
const keyBindingSettings = this._settings.get_strv(settingName);
|
||||
if (keyBindingSettings.length === 0 || keyBindingSettings[0] === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = Main.wm.addKeybinding(
|
||||
settingName,
|
||||
this._settings,
|
||||
Meta.KeyBindingFlags.NONE,
|
||||
Shell.ActionMode.NORMAL,
|
||||
callback,
|
||||
);
|
||||
this._bindings.set(settingName, action);
|
||||
}
|
||||
|
||||
private _refresh(settingName: string): void {
|
||||
if (this._bindings.has(settingName)) {
|
||||
Main.wm.removeKeybinding(settingName);
|
||||
this._bindings.delete(settingName);
|
||||
}
|
||||
|
||||
const action = this._actions[settingName];
|
||||
if (action) this._bind(settingName, action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Adw from 'gi://Adw';
|
||||
import Gio from 'gi://Gio';
|
||||
import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
import { KeybindingEntryRow } from './prefs/keybindings.js';
|
||||
|
||||
const SCHEMA_ID = 'org.gnome.shell.extensions.ddcbrightness';
|
||||
|
||||
export default class DDCCBrightnessPreferences extends ExtensionPreferences {
|
||||
_settings?: Gio.Settings;
|
||||
|
||||
fillPreferencesWindow(window: Adw.PreferencesWindow): Promise<void> {
|
||||
this._settings = this.getSettings(SCHEMA_ID);
|
||||
|
||||
const page = new Adw.PreferencesPage({
|
||||
title: _('DDC Brightness Control'),
|
||||
iconName: 'display-symbolic',
|
||||
});
|
||||
|
||||
const ddcGroup = new Adw.PreferencesGroup({
|
||||
title: _('DDC Settings'),
|
||||
description: _('DDC/CI configuration'),
|
||||
});
|
||||
page.add(ddcGroup);
|
||||
|
||||
const vcpCode = new Adw.EntryRow({
|
||||
title: _('VCP Code'),
|
||||
});
|
||||
vcpCode.set_text(this._settings!.get_string('vcp-code') ?? '10');
|
||||
ddcGroup.add(vcpCode);
|
||||
|
||||
const step = new Adw.SpinRow({
|
||||
title: _('Step Size'),
|
||||
subtitle: _('Brightness change per key press (1-20%)'),
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 1,
|
||||
upper: 20,
|
||||
stepIncrement: 1,
|
||||
}),
|
||||
});
|
||||
ddcGroup.add(step);
|
||||
|
||||
const linkDisplays = new Adw.SwitchRow({
|
||||
title: _('Link Displays'),
|
||||
subtitle: _('Link brightness of all displays so they move together'),
|
||||
});
|
||||
ddcGroup.add(linkDisplays);
|
||||
|
||||
const keybindingGroup = new Adw.PreferencesGroup({
|
||||
title: _('Keyboard Shortcuts'),
|
||||
description: _('Syntax: <Super>h, <Shift>g, <Super><Shift>h\nLegend: <Super> - Windows key, <Primary> - Control key\nDelete text to unset. Press Return key to accept.'),
|
||||
});
|
||||
page.add(keybindingGroup);
|
||||
|
||||
keybindingGroup.add(
|
||||
new KeybindingEntryRow({
|
||||
title: _('Brightness Up'),
|
||||
settings: this._settings!,
|
||||
bind: 'brightness-up',
|
||||
}),
|
||||
);
|
||||
|
||||
keybindingGroup.add(
|
||||
new KeybindingEntryRow({
|
||||
title: _('Brightness Down'),
|
||||
settings: this._settings!,
|
||||
bind: 'brightness-down',
|
||||
}),
|
||||
);
|
||||
|
||||
this._settings!.bind('vcp-code', vcpCode, 'text', Gio.SettingsBindFlags.DEFAULT);
|
||||
this._settings!.bind('step', step, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
this._settings!.bind('link-displays', linkDisplays, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
|
||||
window.add(page);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Adw from 'gi://Adw';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Gio from 'gi://Gio';
|
||||
import GObject from 'gi://GObject';
|
||||
import { gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
|
||||
export class KeybindingEntryRow extends Adw.EntryRow {
|
||||
static {
|
||||
GObject.registerClass(this);
|
||||
}
|
||||
|
||||
constructor(params: {
|
||||
title: string,
|
||||
settings: Gio.Settings,
|
||||
bind: string,
|
||||
}) {
|
||||
super({ title: params.title });
|
||||
|
||||
const { settings, bind } = params;
|
||||
|
||||
this.connect('changed', () => {
|
||||
const text = this.get_text();
|
||||
if (typeof text === 'string') {
|
||||
if (text.trim() === '') {
|
||||
settings.set_strv(bind, []);
|
||||
} else {
|
||||
const parts = text.split(',').map(s => s.trim()).filter(s => s);
|
||||
settings.set_strv(bind, parts);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const current = settings.get_strv(bind).join(',');
|
||||
this.set_text(current ?? '');
|
||||
|
||||
this.add_suffix(
|
||||
new ResetButton({
|
||||
settings,
|
||||
bind,
|
||||
onReset: () => {
|
||||
this.set_text(settings.get_strv(bind).join(',') ?? '');
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ResetButton extends Gtk.Button {
|
||||
static {
|
||||
GObject.registerClass(this);
|
||||
}
|
||||
|
||||
constructor(params: {
|
||||
settings?: Gio.Settings,
|
||||
bind: string,
|
||||
onReset?: () => void,
|
||||
}) {
|
||||
super({
|
||||
icon_name: 'edit-clear-symbolic',
|
||||
tooltip_text: _('Reset'),
|
||||
valign: Gtk.Align.CENTER,
|
||||
});
|
||||
|
||||
this.connect('clicked', () => {
|
||||
params.settings?.reset(params.bind);
|
||||
params.onReset?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user