diff --git a/docs/architecture.md b/docs/architecture.md index 6f92df2..a760552 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,9 +19,20 @@ 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 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. 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..f66c802 --- /dev/null +++ b/lib/keybindings.js @@ -0,0 +1,203 @@ +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', + '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(); + this._shadowManager = new ShadowManager(this.settings); + } + + _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) => { + Logger.debug(`Action triggered: move-window-${dir}`); + 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) => { + Logger.debug(`Action triggered: focus-window-${dir}`); + 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) => { + Logger.debug(`Action triggered: ${key}`); + action(c); + }, + conflict: null + }); + } + + return defs; + } + + bindAll() { + if (!this.settings) return; + + const conflictsToHijack = []; + const keysToShadow = []; + + for (const def of this._definitions) { + const { active, keyToBind, isCustom } = this._resolveBinding(def); + 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); + } + + // 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)); + } + + _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); + Logger.debug(`Successfully bound extension shortcut: ${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); + } + Logger.debug(`Successfully hijacked native shortcut: ${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); + Logger.debug(`Unbound extension shortcut: ${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); + 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() { + this.unbindAll(); + this.bindAll(); + } +} 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/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; diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 082b794..a700a82 100644 Binary files a/schemas/gschemas.compiled and b/schemas/gschemas.compiled differ diff --git a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml index 7bdfd23..3db5d1e 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -162,5 +162,11 @@ 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. + +