From 9fea0013687e1c6310e10eb669713272d53e9461 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Thu, 4 Jun 2026 11:43:35 +0200 Subject: [PATCH 1/4] refactor(keybindings): transition to native Mutter intercept and SRP architecture --- extension.js | 12 +++- lib/keybindings.js | 165 +++++++++++++++++++++++++++++++++++++++++++++ lib/signals.js | 122 --------------------------------- 3 files changed, 175 insertions(+), 124 deletions(-) create mode 100644 lib/keybindings.js diff --git a/extension.js b/extension.js index 1f9f0bc..f91cbe1 100644 --- a/extension.js +++ b/extension.js @@ -3,6 +3,7 @@ import { TilingController } from './lib/controller.js'; import { SignalListener } from './lib/signals.js'; import { SettingsManager } from './lib/settings.js'; import { Logger } from './lib/logger.js'; +import { KeybindingManager } from './lib/keybindings.js'; /** * Main extension class. Manages controller and signals. @@ -16,6 +17,7 @@ export default class WorkflowTilingExtension extends Extension { this._settings = new SettingsManager(this); this._controller = new TilingController(this._settings); this._signals = new SignalListener(this._controller); + this._keybindings = new KeybindingManager(this._controller); this._isActive = false; this._wasSuspended = false; @@ -27,7 +29,7 @@ export default class WorkflowTilingExtension extends Extension { }; this._settings.onKeybindingsChanged = () => { - if (this._isActive) this._signals.rebindKeybindings(); + if (this._isActive) this._keybindings.rebindAll(); } this._applyCustomLayouts(); @@ -46,6 +48,7 @@ export default class WorkflowTilingExtension extends Extension { Main.notifyError('Workflow Tiling', `Invalid layouts JSON. Suspending extension.\n${e.message}`); if (this._isActive) { this._signals.unbind(); + this._keybindings.unbindAll(); this._controller.clear(); this._isActive = false; this._wasSuspended = true; @@ -57,6 +60,7 @@ export default class WorkflowTilingExtension extends Extension { if (!this._isActive) { this._signals.bind(); + this._keybindings.bindAll(); this._isActive = true; if (this._wasSuspended) { Main.notify('Workflow Tiling', 'Valid layout provided. Extension resumed.'); @@ -68,10 +72,14 @@ export default class WorkflowTilingExtension extends Extension { disable() { Logger.info(`Disabling ${this.metadata.name}`); - if (this._isActive) this._signals.unbind(); + if (this._isActive) { + this._signals.unbind(); + this._keybindings.unbindAll(); + } if (this._settings) this._settings.destroy(); if (this._controller) this._controller.clear(); this._signals = null; + this._keybindings = null; this._settings = null; this._controller = null; this._isActive = false; diff --git a/lib/keybindings.js b/lib/keybindings.js new file mode 100644 index 0000000..f185024 --- /dev/null +++ b/lib/keybindings.js @@ -0,0 +1,165 @@ +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import { Logger } from './logger.js'; + +const NATIVE_CONFLICTS = { + 'move-window-left': 'toggle-tiled-left', + 'move-window-right': 'toggle-tiled-right', + 'move-window-up': 'maximize', + 'move-window-down': 'unmaximize' +}; + +export class KeybindingManager { + constructor(controller) { + this.controller = controller; + this.settings = controller.settings ? controller.settings.settings : null; + this._boundKeys = []; + this._activeConflicts = []; + this._definitions = this._buildDefinitions(); + } + + _buildDefinitions() { + const defs = []; + + // Move Directions + ['left', 'right', 'up', 'down'].forEach(dir => { + defs.push({ + defaultKey: `move-window-${dir}`, + customKey: `custom-move-window-${dir}`, + modeSetting: 'keybindings-mode', + action: (c, win) => c.moveWindowDirection(win, dir), + conflict: NATIVE_CONFLICTS[`move-window-${dir}`] || null + }); + }); + + // Focus Directions + ['left', 'right', 'up', 'down'].forEach(dir => { + defs.push({ + defaultKey: `focus-window-${dir}`, + customKey: `custom-focus-window-${dir}`, + modeSetting: 'focus-window-mode', + action: (c, win) => c.focusWindowDirection(win, dir), + conflict: null + }); + }); + + // Batch Utilities + const utilities = { + 'shortcut-close-monitor': (c) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), + 'shortcut-close-workspace': (c) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), + 'shortcut-switch-monitor': (c) => c.switchMonitors(global.display.get_current_monitor()), + 'shortcut-port-monitor-left': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), + 'shortcut-port-monitor-right': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), + 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) + }; + + for (const [key, action] of Object.entries(utilities)) { + defs.push({ + defaultKey: key, + action: (c) => action(c), + conflict: null + }); + } + + return defs; + } + + bindAll() { + if (!this.settings) return; + + const conflictsToHijack = []; + + for (const def of this._definitions) { + const { active, keyToBind, isCustom } = this._resolveBinding(def); + if (!active) continue; + + if (!isCustom && def.conflict) { + conflictsToHijack.push(def.conflict); + } + + this._bindExtensionShortcut(def, keyToBind); + } + + conflictsToHijack.forEach(conflictKey => this._hijackNativeShortcut(conflictKey)); + } + + _resolveBinding(def) { + let keyToBind = def.defaultKey; + let active = true; + let isCustom = false; + + if (def.modeSetting) { + const mode = this.settings.get_string(def.modeSetting); + if (mode === 'disabled') active = false; + if (mode === 'custom' && def.customKey) { + keyToBind = def.customKey; + isCustom = true; + } + } + + return { active, keyToBind, isCustom }; + } + + _bindExtensionShortcut(def, keyToBind) { + try { + Main.wm.addKeybinding( + keyToBind, + this.settings, + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL, + (display, window, binding) => { + const focusWindow = window || global.display.get_focus_window(); + def.action(this.controller, focusWindow); + } + ); + + this._boundKeys.push(keyToBind); + } catch (e) { + Logger.warn(`Failed to bind shortcut ${keyToBind}`, e); + } + } + + _hijackNativeShortcut(conflictKey) { + try { + Meta.keybindings_set_custom_handler(conflictKey, (display, window, binding) => { + const focusWindow = window || global.display.get_focus_window(); + const def = this._definitions.find(d => d.conflict === conflictKey); + if (def) def.action(this.controller, focusWindow); + }); + + if (!this._activeConflicts.includes(conflictKey)) { + this._activeConflicts.push(conflictKey); + } + } catch (e) { + Logger.warn(`Failed to set custom handler for ${conflictKey}`, e); + } + } + + unbindAll() { + // Clean up your extension's keybinding out of runtime + for (const key of this._boundKeys) { + try { + Main.wm.removeKeybinding(key); + } catch (e) { + Logger.warn(`Failed to unbind shortcut ${key}`, e); + } + } + this._boundKeys = []; + + // Restore GNOME's native C handling immediately by passing null + for (const conflictKey of this._activeConflicts) { + try { + Meta.keybindings_set_custom_handler(conflictKey, null); + } catch (e) { + Logger.warn(`Failed to restore native handler for ${conflictKey}`, e); + } + } + this._activeConflicts = []; + } + + rebindAll() { + this.unbindAll(); + this.bindAll(); + } +} diff --git a/lib/signals.js b/lib/signals.js index d314cec..cdaaf27 100644 --- a/lib/signals.js +++ b/lib/signals.js @@ -24,7 +24,6 @@ export class SignalListener { this.controller = controller; this._signals = []; - this._keybindings = []; } bind() { @@ -80,113 +79,10 @@ export class SignalListener { } }); - this._bindKeybindings(); - // Sort and tile tracked windows geometrically to preserve stable slots. this.controller.hydrate(); } - _bindKeybindings() { - const settings = this.controller.settings ? this.controller.settings.settings : null; - if (!settings) return; - - const isMoveCustom = settings.get_string('keybindings-mode') === 'custom'; - const isMoveDisabled = settings.get_string('keybindings-mode') === 'disabled'; - - const bindDirection = (defaultName, customName, direction) => { - if (isMoveDisabled) return; - const keyToBind = isMoveCustom ? customName : defaultName; - Main.wm.addKeybinding( - keyToBind, - settings, - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL, - (display, window, binding) => { - const focusWindow = window || global.display.get_focus_window(); - Logger.info(`Keybinding triggered: ${keyToBind}, direction: ${direction}, window: ${focusWindow ? focusWindow.get_title() : 'none'}`); - if (focusWindow) { - this.controller.moveWindowDirection(focusWindow, direction); - } - } - ); - this._keybindings.push(keyToBind); - }; - - bindDirection('move-window-left', 'custom-move-window-left', 'left'); - bindDirection('move-window-right', 'custom-move-window-right', 'right'); - bindDirection('move-window-up', 'custom-move-window-up', 'up'); - bindDirection('move-window-down', 'custom-move-window-down', 'down'); - - const isFocusCustom = settings.get_string('focus-window-mode') === 'custom'; - const isFocusDisabled = settings.get_string('focus-window-mode') === 'disabled'; - - const bindFocusDirection = (defaultName, customName, direction) => { - if (isFocusDisabled) return; - const keyToBind = isFocusCustom ? customName : defaultName; - Main.wm.addKeybinding( - keyToBind, - settings, - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL, - (display, window, binding) => { - const focusWindow = window || global.display.get_focus_window(); - Logger.info(`Focus keybinding triggered: ${keyToBind}, direction: ${direction}, window: ${focusWindow ? focusWindow.get_title() : 'none'}`); - if (focusWindow) { - this.controller.focusWindowDirection(focusWindow, direction); - } - } - ); - this._keybindings.push(keyToBind); - }; - - bindFocusDirection('focus-window-left', 'custom-focus-window-left', 'left'); - bindFocusDirection('focus-window-right', 'custom-focus-window-right', 'right'); - bindFocusDirection('focus-window-up', 'custom-focus-window-up', 'up'); - bindFocusDirection('focus-window-down', 'custom-focus-window-down', 'down'); - - const bindBatch = (settingName, callback) => { - Main.wm.addKeybinding( - settingName, - settings, - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL, - callback - ); - this._keybindings.push(settingName); - }; - - bindBatch('shortcut-close-monitor', () => { - const monitorIndex = global.display.get_current_monitor(); - const includeMin = settings.get_boolean('close-monitor-include-minimized'); - this.controller.closeMonitorWindows(monitorIndex, includeMin); - }); - - bindBatch('shortcut-close-workspace', () => { - const workspace = global.workspace_manager.get_active_workspace(); - this.controller.closeWorkspaceWindows(workspace); - }); - - bindBatch('shortcut-switch-monitor', () => { - const monitorIndex = global.display.get_current_monitor(); - this.controller.switchMonitors(monitorIndex); - }); - - bindBatch('shortcut-port-monitor-left', () => { - const monitorIndex = global.display.get_current_monitor(); - this.controller.portMonitorToWorkspace(monitorIndex, 'left'); - }); - - bindBatch('shortcut-port-monitor-right', () => { - const monitorIndex = global.display.get_current_monitor(); - this.controller.portMonitorToWorkspace(monitorIndex, 'right'); - }); - - bindBatch('shortcut-unminimize-workspace', () => { - const workspace = global.workspace_manager.get_active_workspace(); - this.controller.unminimizeWorkspace(workspace); - }); - } - _addWindow(window) { if (!window) return; GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { @@ -197,11 +93,6 @@ export class SignalListener { }); } - rebindKeybindings() { - this._unbindKeybindings(); - this._bindKeybindings(); - } - unbind() { this._signals.forEach(({ obj, id }) => { try { @@ -214,24 +105,11 @@ export class SignalListener { }); this._signals = []; - this._unbindKeybindings(); - if (SignalListener.activeInstance === this) { SignalListener.activeInstance = null; } } - _unbindKeybindings() { - this._keybindings.forEach(name => { - try { - Main.wm.removeKeybinding(name); - } catch (e) { - Logger.warn(`Failed to unbind keybinding ${name}`, e); - } - }); - this._keybindings = []; - } - _shouldTile(window) { if (!window) return false; From c503f121bc97989576f4a9327fecdb9c13619f07 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 7 Jun 2026 11:07:52 +0200 Subject: [PATCH 2/4] chore: update docs acccordingly --- docs/architecture.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6f92df2..d92ec8d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,9 +19,13 @@ Manages window evacuation during monitor removal. Binds GNOME Shell signals. Intercepts `window-created`, `window-entered-monitor`, `window-left-monitor`. Intercepts drag operations via `grab-op-begin` and `grab-op-end`. -Binds global keyboard shortcuts. Translates events to `TilingController` calls. +## KeybindingManager (`lib/keybindings.js`) +Binds global keyboard shortcuts. +Hijacks conflicting native GNOME shortcuts when necessary. +Translates keyboard events to `TilingController` actions. + ## WindowWrapper (`lib/window.js`) Encapsulates `Meta.Window`. Applies calculated geometry. From 655eed43451f21ac10980de7f728479cd28ee6d8 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Tue, 9 Jun 2026 11:08:23 +0200 Subject: [PATCH 3/4] feat: implement dynamic schema shadowing for custom keybindings --- lib/keybindings.js | 48 ++++++- lib/shadows.js | 133 ++++++++++++++++++ schemas/gschemas.compiled | Bin 2524 -> 2600 bytes ...ell.extensions.workflow-tiling.gschema.xml | 6 + 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 lib/shadows.js diff --git a/lib/keybindings.js b/lib/keybindings.js index f185024..f66c802 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -2,6 +2,7 @@ import Meta from 'gi://Meta'; import Shell from 'gi://Shell'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import { Logger } from './logger.js'; +import { ShadowManager } from './shadows.js'; const NATIVE_CONFLICTS = { 'move-window-left': 'toggle-tiled-left', @@ -17,6 +18,7 @@ export class KeybindingManager { this._boundKeys = []; this._activeConflicts = []; this._definitions = this._buildDefinitions(); + this._shadowManager = new ShadowManager(this.settings); } _buildDefinitions() { @@ -28,7 +30,10 @@ export class KeybindingManager { defaultKey: `move-window-${dir}`, customKey: `custom-move-window-${dir}`, modeSetting: 'keybindings-mode', - action: (c, win) => c.moveWindowDirection(win, dir), + action: (c, win) => { + Logger.debug(`Action triggered: move-window-${dir}`); + c.moveWindowDirection(win, dir); + }, conflict: NATIVE_CONFLICTS[`move-window-${dir}`] || null }); }); @@ -39,7 +44,10 @@ export class KeybindingManager { defaultKey: `focus-window-${dir}`, customKey: `custom-focus-window-${dir}`, modeSetting: 'focus-window-mode', - action: (c, win) => c.focusWindowDirection(win, dir), + action: (c, win) => { + Logger.debug(`Action triggered: focus-window-${dir}`); + c.focusWindowDirection(win, dir); + }, conflict: null }); }); @@ -57,7 +65,10 @@ export class KeybindingManager { for (const [key, action] of Object.entries(utilities)) { defs.push({ defaultKey: key, - action: (c) => action(c), + action: (c) => { + Logger.debug(`Action triggered: ${key}`); + action(c); + }, conflict: null }); } @@ -69,16 +80,35 @@ export class KeybindingManager { if (!this.settings) return; const conflictsToHijack = []; + const keysToShadow = []; for (const def of this._definitions) { const { active, keyToBind, isCustom } = this._resolveBinding(def); - if (!active) continue; + if (!active) { + Logger.debug(`Binding ${def.defaultKey} is inactive.`); + continue; + } + + Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, isCustom=${isCustom}, conflict=${def.conflict}`); if (!isCustom && def.conflict) { + Logger.debug(`Will hijack native conflict ${def.conflict} instead of binding extension shortcut.`); conflictsToHijack.push(def.conflict); + } else { + keysToShadow.push(keyToBind); } + } + + if (keysToShadow.length > 0) { + this._shadowManager.shadowShortcuts(keysToShadow); + } - this._bindExtensionShortcut(def, keyToBind); + // Bind extension shortcuts + for (const def of this._definitions) { + const { active, keyToBind, isCustom } = this._resolveBinding(def); + if (active && !(!isCustom && def.conflict)) { + this._bindExtensionShortcut(def, keyToBind); + } } conflictsToHijack.forEach(conflictKey => this._hijackNativeShortcut(conflictKey)); @@ -115,6 +145,7 @@ export class KeybindingManager { ); this._boundKeys.push(keyToBind); + Logger.debug(`Successfully bound extension shortcut: ${keyToBind}`); } catch (e) { Logger.warn(`Failed to bind shortcut ${keyToBind}`, e); } @@ -131,6 +162,7 @@ export class KeybindingManager { if (!this._activeConflicts.includes(conflictKey)) { this._activeConflicts.push(conflictKey); } + Logger.debug(`Successfully hijacked native shortcut: ${conflictKey}`); } catch (e) { Logger.warn(`Failed to set custom handler for ${conflictKey}`, e); } @@ -141,6 +173,7 @@ export class KeybindingManager { for (const key of this._boundKeys) { try { Main.wm.removeKeybinding(key); + Logger.debug(`Unbound extension shortcut: ${key}`); } catch (e) { Logger.warn(`Failed to unbind shortcut ${key}`, e); } @@ -151,11 +184,16 @@ export class KeybindingManager { for (const conflictKey of this._activeConflicts) { try { Meta.keybindings_set_custom_handler(conflictKey, null); + Logger.debug(`Restored native handler for ${conflictKey}`); } catch (e) { Logger.warn(`Failed to restore native handler for ${conflictKey}`, e); } } this._activeConflicts = []; + + if (this._shadowManager) { + this._shadowManager.restoreAll(); + } } rebindAll() { diff --git a/lib/shadows.js b/lib/shadows.js new file mode 100644 index 0000000..8903574 --- /dev/null +++ b/lib/shadows.js @@ -0,0 +1,133 @@ +import Gio from 'gi://Gio'; +import Gtk from 'gi://Gtk?version=4.0'; +import { Logger } from './logger.js'; + +export class ShadowManager { + constructor(extensionSettings) { + this.extensionSettings = extensionSettings; + this._nativeSchemas = []; + + const schemaIds = [ + 'org.gnome.desktop.wm.keybindings', + 'org.gnome.mutter.keybindings', + 'org.gnome.mutter.wayland.keybindings', + 'org.gnome.shell.keybindings' + ]; + + const schemaSource = Gio.SettingsSchemaSource.get_default(); + for (const id of schemaIds) { + const schema = schemaSource.lookup(id, true); + if (schema) { + this._nativeSchemas.push(new Gio.Settings({ schema_id: id })); + } + } + } + + _normalize(shortcut) { + if (!shortcut) return ''; + try { + const [valid, keyval, mods] = Gtk.accelerator_parse(shortcut); + if (valid) { + return Gtk.accelerator_name(keyval, mods); + } + } catch (e) { + // Ignore parse errors, fallback to raw string + } + return shortcut; + } + + shadowShortcuts(extensionKeys) { + // First restore everything to ensure a clean slate + this.restoreAll(); + + const stateJson = this.extensionSettings.get_string('shadowed-keybindings'); + let state = {}; + try { + if (stateJson) state = JSON.parse(stateJson); + } catch (e) { + Logger.warn('ShadowManager: Failed to parse shadowed-keybindings JSON', e); + } + + // Gather all target accelerators we want to bind + const targetAccels = new Set(); + for (const extKey of extensionKeys) { + const accels = this.extensionSettings.get_strv(extKey); + for (const a of accels) { + const norm = this._normalize(a); + if (norm) targetAccels.add(norm); + } + } + + if (targetAccels.size === 0) return; + + // Scan native schemas + for (const settings of this._nativeSchemas) { + const schemaId = settings.schema_id; + const keys = settings.list_keys(); + + for (const key of keys) { + try { + const value = settings.get_value(key); + if (value.get_type_string() !== 'as') continue; + + const accels = settings.get_strv(key); + let changed = false; + const newAccels = []; + + for (const accel of accels) { + const norm = this._normalize(accel); + if (targetAccels.has(norm)) { + changed = true; + Logger.debug(`ShadowManager: Conflicting native shortcut found: ${schemaId}.${key} -> ${accel}`); + } else { + newAccels.push(accel); + } + } + + if (changed) { + // Save original in state + if (!state[schemaId]) state[schemaId] = {}; + // Only save the very first time we modify this key, to not overwrite our backup with a partial array + if (!state[schemaId][key]) state[schemaId][key] = accels; + + settings.set_strv(key, newAccels); + Logger.debug(`ShadowManager: Shadowed ${schemaId}.${key} -> remaining: [${newAccels.join(', ')}]`); + } + } catch (e) { + // Ignore keys that fail to read or aren't string arrays + } + } + } + + this.extensionSettings.set_string('shadowed-keybindings', JSON.stringify(state)); + } + + restoreAll() { + const stateJson = this.extensionSettings.get_string('shadowed-keybindings'); + let state = {}; + try { + if (stateJson) state = JSON.parse(stateJson); + } catch (e) { + return; + } + + if (Object.keys(state).length === 0) return; + + for (const settings of this._nativeSchemas) { + const schemaId = settings.schema_id; + if (state[schemaId]) { + for (const [key, originalAccels] of Object.entries(state[schemaId])) { + try { + settings.set_strv(key, originalAccels); + Logger.debug(`ShadowManager: Restored native shortcut ${schemaId}.${key} -> [${originalAccels.join(', ')}]`); + } catch (e) { + Logger.warn(`ShadowManager: Failed to restore ${schemaId}.${key}`, e); + } + } + } + } + + // Clear the state + this.extensionSettings.set_string('shadowed-keybindings', '{}'); + } +} diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 082b79401cf6c364f56f82683bddabf361db30ab..a700a828b54016afad677e008f1432dfabf88666 100644 GIT binary patch literal 2600 zcmbtUU1%It7`;(bYm>HVO`A=B?6N9sYi4(wP?Z=$2to@sQd-o46gt_R-I-=~W}TUB z8kdzkh(xeYR!G4glv+|y31XrCAp0PpDA=MFkxI#9z=|z-P@nXiyEB{F*~J$ZPR^IJ z-+uSr@9*IAs$-~@C;U$ge7tTa@A0(`urvGJ4`hC~PaG1jg1he%qH~)N|KRiR1_YWDea7>S*~GQZfe?grt+pet$2oMSOxx0B;%5VxEGADqSMFy z;Pt=*5%4&o+s7twPXt@>e=o4zM<1B)+v#H$_#t2q5C>}2SGxEX`7Uu<#Bh;=z}VZh zw|EcQJD}^}BCtF+ah^Ku81y;tS)k*W+$?q4eb5)cUjXCJEIdJ-_7>>Z!7IQw7pzt4 zw6{a&<6}Usw>(arb{F&{SOJ2Z(`L>y;00jm@=1&Sw0q!x1^g|r@5YC(Q>V>)u7Jfl zA#OkZ(o@uF^FA@~V?cMY^JD6?H$Z<9JO+I7!Abcp+I-h>@G0Q?Te&;*r_FO`!EXT9 z?$)x@Y43)<2)+#b(R)GWfi};*3SI_QhbAu4pY|r`cfbO|>Tl!EQm4HWdK~;H@Y9|R|i*s8=u$CQK!xO&x79q4pia_b=q5@FM_WEKfn9F%o}aS zSqA?F%zt`L14+9VdJh`35BU4;k2k5)X5NzE4A6f&C+k9+`56Nr1iZ-YJuo(F#IxFqvNn|WIVe?|SXkNW9Pdo%Rw;1yu^__eR8)9#199zzrd zZoRkkJ9XO3a|X=$O_fwnht5wL1DuuvoR$NeUK`+aWPsB`xUOzHUbgHhC9qPoEyJ@N z#nkeR#gbIry$!FdX}g-jIBrSJYCMqNXUnc<7xDAc_|mX)*bza9kz?hO=8PU@#BS-F zx8WXj6C+0)qo_JFqa(+3BkzsssAwp3(~rs}(c(Yl`)iguX=+MAExEj%=n%=ty;%*D)1bMOid^`=)&xfD9rWXd;Ykg;smvCIiDCj}G{P;(uv{9zeE_<32WQ6Y$ zNuyoKcsSE{lUhGnhQnmYTL^$zEg z-Q~Y4G?$HM)rZ{AT6hS}`RNf~eO&kUS~swe8bAnWrlDMMfG literal 2524 zcmbtVZD<@-6n#-sYn!%dP1>eyY*-LBC9~TmR6`7*1i_-Igcd)b7BbnH-5oPKv(C(R z6T`}aB2^It6N8`!IU`y-jZ?GuN^OW@@@g=p^<;vYOq_dpO6 z;tKd0;Ci`K&T$K7%Fmm&oib+w)A23W@l$2jo5|X4Sqm)Na&ml6B=<@Zg5M+%TB04i zt^pfhcPMNEZ)t(8(C!7gfb9VH)C25N*bUwT^eXV%E5DCFz75Ph?G`6R=UqZ{14dVA zoI34|(EGsm1B;(eIn-%)LLUY{3S3>>^D=eX-O#7NMWCb5ex5q*4bZFLQ^40}ofYb| zw?ls)d;zFcUf9UI(r$-d1K$L`ziuqkpEkdXA4Lc7>!~AWsMFpFJqgx;%Qfe9>a?jJ z1|J7*Jn-B@)M;;pUIf1gTz_xz59+ixLq83E4F_=PRxLxFb}#e=@OfbAV(l&Jw0A)N z2K*h+JAUaa>a=^H{{|L!qc%SKq=!1~Ezmo`Nucc)qe`7N;~WAX1unmNZXI>nd!f&Q zD?nIxv{`pm@Snh;cWQ6bpY|@sUypMDR)&t8qfVRqN`i-hYi%DtNS!wGFbRGJ`1Hdm zxgOeF&n);=VBgY5uh5@1^SJ=70ppJ?$T+ka=L&ckI5^*@(VzA<=<9Je`hcI`{XoW{ z%{as0N#JJJSs8~mMU1c2Fte-c)r-7auhO9%{tcL~g7r>dPzWj^+v{_d_ zfO$v#yz2#-QlJ&VTETU!!1XlG%H>rHNa}vS>FCq8spa&d-zJjN{RQ`=sg*6qz@-Ho z#2r0WDw^Kd!~Cy9@TE-258MJmuKJdWq2CilteReKDKI@58A0%%jB)5z_;%JsnDA40 zU5rk8RzdgX#zv3jt!ywhgDf&ms;CEfyqZR%Ny|o)mW?L8+Gx^EjV6uV&zN)5*azB< zkChsxFwCr8va$E1-yh{BW4k^!j6UTT^^D0S-foLw=2Sk8%0zCG-i?_E-NJk{`l-a( z`W%wu%Z~{$pE!^hIX<75O^oaxNX*H<<;2K={rE4h*fL;LP7EXlTe%)w<2fM1QIFy^ zI}I}2(Ekm`s9fA&CH6_m*n|3J=rmM0QHiI&JSON string defining custom window layouts. + + + Shadowed Native Keybindings + Internal state tracking GNOME native keybindings that have been temporarily removed to prevent conflicts with custom shortcuts. + + From c34a33a38016c5dd609a1df19ad8f7ad10733271 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Tue, 9 Jun 2026 11:11:49 +0200 Subject: [PATCH 4/4] docs: add dynamic schema shadowing architecture documentation --- docs/architecture.md | 9 ++++++++- docs/keybindings.md | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 docs/keybindings.md diff --git a/docs/architecture.md b/docs/architecture.md index d92ec8d..a760552 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,9 +23,16 @@ Translates events to `TilingController` calls. ## KeybindingManager (`lib/keybindings.js`) Binds global keyboard shortcuts. -Hijacks conflicting native GNOME shortcuts when necessary. +Hijacks hardcoded native GNOME shortcuts via C-handlers (`default` mode). +Delegates custom shortcut conflict resolution to `ShadowManager`. Translates keyboard events to `TilingController` actions. +## ShadowManager (`lib/shadows.js`) +Implements Dynamic Schema Shadowing. +Scans GNOME native schemas for custom shortcut conflicts. +Temporarily unbinds conflicting native keys to allow `Main.wm.addKeybinding` to succeed. +Persists original keys in `shadowed-keybindings` state for perfect restoration on disable. + ## WindowWrapper (`lib/window.js`) Encapsulates `Meta.Window`. Applies calculated geometry. diff --git a/docs/keybindings.md b/docs/keybindings.md new file mode 100644 index 0000000..bebe522 --- /dev/null +++ b/docs/keybindings.md @@ -0,0 +1,26 @@ +# Keybindings & Schema Shadowing + +The extension uses two different strategies to bind shortcuts due to GNOME Mutter architecture limitations: + +## 1. Native C-Handler Hijacking (`Meta.keybindings_set_custom_handler`) +Used strictly in `Default` mode for known GNOME shortcuts (like `Left` which GNOME maps to `toggle-tiled-left`). +* **Why:** High priority. Safely intercepts the GNOME action at the C-level before Mutter can process it. +* **Limitation:** Requires knowing the hardcoded GNOME string action name (e.g. `toggle-tiled-left`). It cannot intercept an arbitrary custom keystroke without knowing what action it triggers. + +## 2. Extension Bindings (`Main.wm.addKeybinding`) +Used for all `Custom` modes and utility shortcuts. +* **Why:** Allows binding to custom `gsettings` schema keys that the user configures. +* **Limitation:** Extremely low priority. If the user picks a key (e.g., `Down`) that Mutter already listens to globally (e.g., `shift-overview-down`), Mutter consumes the event. The extension never fires. + +## Dynamic Schema Shadowing (`ShadowManager`) +To bypass the limitation of `Main.wm.addKeybinding`, `ShadowManager` temporarily deletes conflicting shortcuts from GNOME settings while the extension is active. + +### Execution Flow: +1. **Normalize:** Target custom keystrokes are parsed via `Gtk.accelerator_parse` to normalize modifier ordering (`` vs ``). +2. **Scan:** Iterates through native schemas (`wm.keybindings`, `mutter.keybindings`, `shell.keybindings`). +3. **Filter:** If a native array contains the normalized shortcut string, it is explicitly filtered out. +4. **Backup:** The *original* array is saved into `org.gnome.shell.extensions.workflow-tiling.shadowed-keybindings` (JSON string). +5. **Restore:** On `disable()` or `rebindAll()`, the JSON backup is read, and the native schema keys are written back to their exact original arrays. + +### Crash Resilience +Because the backup is written to standard `gsettings` before any keys are unbound, an unexpected shell crash will not permanently destroy native user shortcuts. The state is restored perfectly on the next instantiation.