feat: moving project to git

This commit is contained in:
Lucas Oskorep
2026-06-29 21:11:39 -04:00
commit 2c0a09efb0
19 changed files with 3255 additions and 0 deletions
+14
View File
@@ -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]';
+86
View File
@@ -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
View File
@@ -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);
}
});
}
}
+59
View File
@@ -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;
}
}
+132
View File
@@ -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();
}
}
+67
View File
@@ -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);
}
}
+78
View File
@@ -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();
}
}
+69
View File
@@ -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?.();
});
}
}