Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
@@ -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 `<Super>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., `<Super><Alt>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 (`<Alt><Super>` vs `<Super><Alt>`).
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.
12 changes: 10 additions & 2 deletions extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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.');
Expand All @@ -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;
Expand Down
203 changes: 203 additions & 0 deletions lib/keybindings.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading