diff --git a/prefs.js b/prefs.js index 55ebb59..85b1622 100644 --- a/prefs.js +++ b/prefs.js @@ -75,10 +75,30 @@ const ShortcutRowMixin = { const ShortcutRow = GObject.registerClass( class ShortcutRow extends Adw.ActionRow { - _init(settings, keyName, title) { + _init(settings, keyName, title, origin = '') { super._init({ title }); this.settings = settings; this.keyName = keyName; + this._onChangeCallback = null; + + if (origin === 'System') { + const badgeBox = new Gtk.Box({ orientation: 0, spacing: 4, valign: 3 }); // 0: HORIZONTAL, 3: CENTER + const icon = new Gtk.Image({ icon_name: 'preferences-system-symbolic' }); + icon.add_css_class('dim-label'); + const lbl = new Gtk.Label({ label: origin, css_classes: ['dim-label', 'caption'] }); + badgeBox.append(icon); + badgeBox.append(lbl); + badgeBox.margin_end = 12; + this.add_suffix(badgeBox); + } + + this.warningIcon = new Gtk.Image({ + icon_name: 'dialog-warning-symbolic', + valign: Gtk.Align.CENTER, + visible: false + }); + this.warningIcon.add_css_class('warning'); + this.add_suffix(this.warningIcon); this.shortcutLabel = new Gtk.ShortcutLabel({ disabled_text: 'Disabled', @@ -92,9 +112,19 @@ class ShortcutRow extends Adw.ActionRow { this.settings.connect(`changed::${this.keyName}`, () => { this.shortcutLabel.accelerator = this._getAccelerator(); + if (this._onChangeCallback) this._onChangeCallback(); }); } + setWarning(isWarning, tooltip = '') { + this.warningIcon.visible = isWarning; + this.warningIcon.tooltip_text = tooltip; + } + + setOnChange(cb) { + this._onChangeCallback = cb; + } + _getAccelerator() { const strv = this.settings.get_strv(this.keyName); return strv.length > 0 ? strv[0] : ''; @@ -108,6 +138,7 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { const settings = this.getSettings(); const page = new Adw.PreferencesPage({ title: 'General', icon_name: 'preferences-system-symbolic' }); + const shortcutsPage = new Adw.PreferencesPage({ title: 'Keyboard Shortcuts', icon_name: 'input-keyboard-symbolic' }); // --- Gaps Group --- const gapsGroup = new Adw.PreferencesGroup({ title: 'Gaps' }); @@ -131,10 +162,102 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { page.add(gapsGroup); - // --- Core Keybindings Group --- - const keysGroup = new Adw.PreferencesGroup({ title: 'Keybindings' }); + const wmSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.wm.keybindings' }); + const allRows = []; + const updateConflicts = () => { + const accelMap = {}; + const addAccel = (accel, source) => { + if (!accel) return; + if (!accelMap[accel]) accelMap[accel] = []; + accelMap[accel].push(source); + }; + + for (const row of allRows) { + if (!row.visible && row.keyName && row.keyName.startsWith('custom-')) { + continue; + } + const accel = row._getAccelerator(); + if (accel === 'disabled') continue; + addAccel(accel, row); + } + + const focusMode = settings.get_string('focus-window-mode'); + if (focusMode === 'default') { + ['focus-window-left', 'focus-window-right', 'focus-window-up', 'focus-window-down'].forEach(k => { + const val = settings.get_strv(k); + if (val.length > 0) addAccel(val[0], 'focus-default'); + }); + } + + const swapMode = settings.get_string('keybindings-mode'); + if (swapMode === 'default') { + ['move-window-left', 'move-window-right', 'move-window-up', 'move-window-down'].forEach(k => { + const val = settings.get_strv(k); + if (val.length > 0) addAccel(val[0], 'swap-default'); + }); + } + + for (const row of allRows) { + if (!row.visible) { + row.setWarning(false); + continue; + } + const accel = row._getAccelerator(); + if (accel && accel !== 'disabled' && accelMap[accel] && accelMap[accel].length > 1) { + row.setWarning(true, 'Shortcut conflicts with another active shortcut'); + } else { + row.setWarning(false); + } + } + }; + + const createRow = (st, id, label, origin = '') => { + const row = new ShortcutRow(st, id, label, origin); + row.setOnChange(updateConflicts); + allRows.push(row); + return row; + }; + + // --- Focus & Position Group --- + const focusPositionGroup = new Adw.PreferencesGroup({ title: 'Window Focus & Position' }); + + const focusModeRow = new Adw.ComboRow({ + title: 'Focus Mode', + subtitle: '', + model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) + }); + const focusMode = settings.get_string('focus-window-mode'); + focusModeRow.selected = focusMode === 'custom' ? 1 : (focusMode === 'disabled' ? 2 : 0); + focusModeRow.connect('notify::selected', () => { + let mode = 'default'; + if (focusModeRow.selected === 1) mode = 'custom'; + if (focusModeRow.selected === 2) mode = 'disabled'; + settings.set_string('focus-window-mode', mode); + }); + focusPositionGroup.add(focusModeRow); + + const focusRows = [ + { id: 'custom-focus-window-left', label: ' ↳ Focus Window Left' }, + { id: 'custom-focus-window-right', label: ' ↳ Focus Window Right' }, + { id: 'custom-focus-window-up', label: ' ↳ Focus Window Up' }, + { id: 'custom-focus-window-down', label: ' ↳ Focus Window Down' } + ].map(s => { + const row = createRow(settings, s.id, s.label); + focusPositionGroup.add(row); + return row; + }); + + const updateFocusVisibility = () => { + const mode = settings.get_string('focus-window-mode'); + focusModeRow.subtitle = mode === 'default' ? 'Default: + + ' : ''; + const showCustom = mode === 'custom'; + focusRows.forEach(r => r.visible = showCustom); + }; + settings.connect('changed::focus-window-mode', () => { updateFocusVisibility(); updateConflicts(); }); + updateFocusVisibility(); + const modeRow = new Adw.ComboRow({ - title: 'Mode', + title: 'Swap Mode', subtitle: '', model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) }); @@ -146,16 +269,16 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { if (modeRow.selected === 2) mode = 'disabled'; settings.set_string('keybindings-mode', mode); }); - keysGroup.add(modeRow); + focusPositionGroup.add(modeRow); const moveRows = [ - { id: 'custom-move-window-left', label: ' ↳ Move Window Left' }, - { id: 'custom-move-window-right', label: ' ↳ Move Window Right' }, - { id: 'custom-move-window-up', label: ' ↳ Move Window Up' }, - { id: 'custom-move-window-down', label: ' ↳ Move Window Down' } + { id: 'custom-move-window-left', label: ' ↳ Swap Window Left' }, + { id: 'custom-move-window-right', label: ' ↳ Swap Window Right' }, + { id: 'custom-move-window-up', label: ' ↳ Swap Window Up' }, + { id: 'custom-move-window-down', label: ' ↳ Swap Window Down' } ].map(s => { - const row = new ShortcutRow(settings, s.id, s.label); - keysGroup.add(row); + const row = createRow(settings, s.id, s.label); + focusPositionGroup.add(row); return row; }); @@ -165,56 +288,127 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { const showCustom = mode === 'custom'; moveRows.forEach(r => r.visible = showCustom); }; - settings.connect('changed::keybindings-mode', updateVisibility); + settings.connect('changed::keybindings-mode', () => { updateVisibility(); updateConflicts(); }); updateVisibility(); - page.add(keysGroup); + shortcutsPage.add(new Adw.PreferencesGroup()); + shortcutsPage.add(focusPositionGroup); - // --- Focus Window Group --- - const focusGroup = new Adw.PreferencesGroup({ title: 'Focus Keybindings' }); + // --- Window State --- + const stateGroup = new Adw.PreferencesGroup({ title: 'Window State' }); + [ + { id: 'close', label: 'Close Window', origin: 'System', st: wmSettings }, + { id: 'minimize', label: 'Minimize Window', origin: 'System', st: wmSettings }, + { id: 'maximize', label: 'Maximize Window', origin: 'System', st: wmSettings }, + { id: 'unmaximize', label: 'Unmaximize Window', origin: 'System', st: wmSettings }, + { id: 'toggle-fullscreen', label: 'Toggle Fullscreen', origin: 'System', st: wmSettings } + ].forEach(s => stateGroup.add(createRow(s.st, s.id, s.label, s.origin))); + shortcutsPage.add(stateGroup); + + // --- Workspace Operations --- + const wsOpsGroup = new Adw.PreferencesGroup({ title: 'Workspace Operations' }); + wsOpsGroup.add(createRow(settings, 'shortcut-close-workspace', 'Close Workspace Windows')); + wsOpsGroup.add(createRow(settings, 'shortcut-unminimize-workspace', 'Unminimize Workspace')); + shortcutsPage.add(wsOpsGroup); + + // --- Workspace Switching --- + const wsSwitchGroup = new Adw.PreferencesGroup({ title: 'Workspace Switching' }); + wsSwitchGroup.add(createRow(wmSettings, 'switch-to-workspace-left', 'Switch to Workspace Left', 'System')); + wsSwitchGroup.add(createRow(wmSettings, 'switch-to-workspace-right', 'Switch to Workspace Right', 'System')); - const focusModeRow = new Adw.ComboRow({ - title: 'Mode', + const wsSwitchModeRow = new Adw.ComboRow({ + title: 'Numbered Workspaces', subtitle: '', - model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) + model: Gtk.StringList.new(['System Default', 'Edit', 'Disabled']) }); - const focusMode = settings.get_string('focus-window-mode'); - focusModeRow.selected = focusMode === 'custom' ? 1 : (focusMode === 'disabled' ? 2 : 0); - focusModeRow.connect('notify::selected', () => { + const wsSwitchModeStr = settings.get_string('workspace-switch-mode'); + wsSwitchModeRow.selected = wsSwitchModeStr === 'custom' ? 1 : (wsSwitchModeStr === 'disabled' ? 2 : 0); + + const wsSwitchKeys = []; + for (let i = 1; i <= 4; i++) wsSwitchKeys.push(`switch-to-workspace-${i}`); + + wsSwitchModeRow.connect('notify::selected', () => { let mode = 'default'; - if (focusModeRow.selected === 1) mode = 'custom'; - if (focusModeRow.selected === 2) mode = 'disabled'; - settings.set_string('focus-window-mode', mode); + if (wsSwitchModeRow.selected === 1) mode = 'custom'; + if (wsSwitchModeRow.selected === 2) mode = 'disabled'; + settings.set_string('workspace-switch-mode', mode); + + if (mode === 'default') { + wsSwitchKeys.forEach(k => wmSettings.reset(k)); + } else if (mode === 'disabled') { + wsSwitchKeys.forEach(k => wmSettings.set_strv(k, ['disabled'])); + } }); - focusGroup.add(focusModeRow); + wsSwitchGroup.add(wsSwitchModeRow); - const focusRows = [ - { id: 'custom-focus-window-left', label: ' ↳ Focus Window Left' }, - { id: 'custom-focus-window-right', label: ' ↳ Focus Window Right' }, - { id: 'custom-focus-window-up', label: ' ↳ Focus Window Up' }, - { id: 'custom-focus-window-down', label: ' ↳ Focus Window Down' } - ].map(s => { - const row = new ShortcutRow(settings, s.id, s.label); - focusGroup.add(row); - return row; - }); + const wsSwitchRows = []; + for (let i = 1; i <= 4; i++) { + wsSwitchRows.push(createRow(wmSettings, `switch-to-workspace-${i}`, ` ↳ Switch to Workspace ${i}`, 'System')); + } + wsSwitchRows.forEach(r => wsSwitchGroup.add(r)); + shortcutsPage.add(wsSwitchGroup); - const updateFocusVisibility = () => { - const mode = settings.get_string('focus-window-mode'); - focusModeRow.subtitle = mode === 'default' ? 'Default: + + ' : ''; + const updateWsSwitchVisibility = () => { + const mode = settings.get_string('workspace-switch-mode'); + wsSwitchModeRow.subtitle = mode === 'default' ? 'System default keybindings' : ''; const showCustom = mode === 'custom'; - focusRows.forEach(r => r.visible = showCustom); + wsSwitchRows.forEach(r => r.visible = showCustom); + updateConflicts(); }; - settings.connect('changed::focus-window-mode', updateFocusVisibility); - updateFocusVisibility(); + settings.connect('changed::workspace-switch-mode', updateWsSwitchVisibility); + updateWsSwitchVisibility(); - page.add(focusGroup); + // --- Moving to Workspace --- + const wsMoveGroup = new Adw.PreferencesGroup({ title: 'Moving to Workspace' }); + wsMoveGroup.add(createRow(wmSettings, 'move-to-workspace-left', 'Move Window to Workspace Left', 'System')); + wsMoveGroup.add(createRow(wmSettings, 'move-to-workspace-right', 'Move Window to Workspace Right', 'System')); - // --- Additional Batch Shortcuts Group --- - const batchKeysGroup = new Adw.PreferencesGroup({ title: 'Batch Operations' }); + const wsMoveModeRow = new Adw.ComboRow({ + title: 'Numbered Workspaces', + subtitle: '', + model: Gtk.StringList.new(['System Default', 'Edit', 'Disabled']) + }); + const wsMoveModeStr = settings.get_string('workspace-move-mode'); + wsMoveModeRow.selected = wsMoveModeStr === 'custom' ? 1 : (wsMoveModeStr === 'disabled' ? 2 : 0); - const closeMonitorRow = new ShortcutRow(settings, 'shortcut-close-monitor', 'Close Monitor Windows'); - batchKeysGroup.add(closeMonitorRow); + const wsMoveKeys = []; + for (let i = 1; i <= 4; i++) wsMoveKeys.push(`move-to-workspace-${i}`); + + wsMoveModeRow.connect('notify::selected', () => { + let mode = 'default'; + if (wsMoveModeRow.selected === 1) mode = 'custom'; + if (wsMoveModeRow.selected === 2) mode = 'disabled'; + settings.set_string('workspace-move-mode', mode); + + if (mode === 'default') { + wsMoveKeys.forEach(k => wmSettings.reset(k)); + } else if (mode === 'disabled') { + wsMoveKeys.forEach(k => wmSettings.set_strv(k, ['disabled'])); + } + }); + wsMoveGroup.add(wsMoveModeRow); + + const wsMoveRows = []; + for (let i = 1; i <= 4; i++) { + wsMoveRows.push(createRow(wmSettings, `move-to-workspace-${i}`, ` ↳ Move Window to Workspace ${i}`, 'System')); + } + wsMoveRows.forEach(r => wsMoveGroup.add(r)); + shortcutsPage.add(wsMoveGroup); + + const updateWsMoveVisibility = () => { + const mode = settings.get_string('workspace-move-mode'); + wsMoveModeRow.subtitle = mode === 'default' ? 'System default keybindings' : ''; + const showCustom = mode === 'custom'; + wsMoveRows.forEach(r => r.visible = showCustom); + updateConflicts(); + }; + settings.connect('changed::workspace-move-mode', updateWsMoveVisibility); + updateWsMoveVisibility(); + + // --- Monitor Actions --- + const monitorGroup = new Adw.PreferencesGroup({ title: 'Monitor Actions' }); + const closeMonitorRow = createRow(settings, 'shortcut-close-monitor', 'Close Monitor Windows'); + monitorGroup.add(closeMonitorRow); const closeMinRow = new Adw.SwitchRow({ title: ' ↳ Include Minimized', subtitle: ' Also close minimized windows on monitor' }); settings.bind('close-monitor-include-minimized', closeMinRow, 'active', Gio.SettingsBindFlags.DEFAULT); @@ -224,19 +418,21 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { }; settings.connect('changed::shortcut-close-monitor', updateCloseMinRowVisibility); updateCloseMinRowVisibility(); - batchKeysGroup.add(closeMinRow); + monitorGroup.add(closeMinRow); [ - { id: 'shortcut-close-workspace', label: 'Close Workspace Windows' }, - { id: 'shortcut-switch-monitor', label: 'Switch Monitors' }, - { id: 'shortcut-port-monitor-left', label: 'Port Monitor to Left Workspace' }, - { id: 'shortcut-port-monitor-right', label: 'Port Monitor to Right Workspace' }, - { id: 'shortcut-unminimize-workspace', label: 'Unminimize Workspace' } - ].forEach(s => batchKeysGroup.add(new ShortcutRow(settings, s.id, s.label))); + { id: 'shortcut-switch-monitor', label: 'Switch Monitors', origin: '', st: settings }, + { id: 'shortcut-port-monitor-left', label: 'Port Monitor to Left Workspace', origin: '', st: settings }, + { id: 'shortcut-port-monitor-right', label: 'Port Monitor to Right Workspace', origin: '', st: settings } + ].forEach(s => monitorGroup.add(createRow(s.st, s.id, s.label, s.origin))); - page.add(batchKeysGroup); + shortcutsPage.add(monitorGroup); + + // Initial conflicts check + updateConflicts(); window.add(page); + window.add(shortcutsPage); // --- Custom Layouts (JSON debug) Page --- const layoutPage = new LayoutEditorPage(settings); diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index a700a82..7e7a828 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 3db5d1e..7ba9d88 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -62,6 +62,18 @@ Custom swap active window with the one below it. + + 'default' + Workspace Switch Mode + Mode for numbered workspace switching (default or disabled). + + + + 'default' + Workspace Move Mode + Mode for numbered workspace moving (default or disabled). + + 'default' Focus Window Keybindings Mode diff --git a/tests/prefs.test.js b/tests/prefs.test.js new file mode 100644 index 0000000..ae67d1e --- /dev/null +++ b/tests/prefs.test.js @@ -0,0 +1,587 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Gtk from 'gi://Gtk'; + +const { mockSettingsStore, mockListeners, mockSettings, BaseMockWidget, mockAdw } = vi.hoisted(() => { + const mockSettingsStore = { + 'enable-gaps': true, + 'inner-gaps': 6, + 'outer-gaps': 4, + 'keybindings-mode': 'default', + 'focus-window-mode': 'default', + 'shortcut-close-monitor': [], + 'close-monitor-include-minimized': false, + 'shortcut-close-workspace': [], + 'shortcut-switch-monitor': [], + 'shortcut-port-monitor-left': [], + 'shortcut-port-monitor-right': [], + 'shortcut-unminimize-workspace': [], + 'custom-layouts': '{}' + }; + + const mockListeners = {}; + + const mockSettings = { + get_boolean: (key) => mockSettingsStore[key] ?? false, + get_string: (key) => mockSettingsStore[key] ?? '', + set_string: (key, val) => { + mockSettingsStore[key] = val; + if (mockListeners[`changed::${key}`]) { + mockListeners[`changed::${key}`].forEach(cb => cb()); + } + }, + get_strv: (key) => mockSettingsStore[key] || [], + set_strv: (key, val) => { + mockSettingsStore[key] = val; + if (mockListeners[`changed::${key}`]) { + mockListeners[`changed::${key}`].forEach(cb => cb()); + } + }, + bind: (key, object, property, flags) => { + Object.defineProperty(object, property, { + get: () => mockSettingsStore[key], + set: (val) => { + mockSettingsStore[key] = val; + if (mockListeners[`changed::${key}`]) { + mockListeners[`changed::${key}`].forEach(cb => cb()); + } + }, + configurable: true + }); + }, + connect: (signal, callback) => { + if (!mockListeners[signal]) { + mockListeners[signal] = []; + } + mockListeners[signal].push(callback); + return signal; + } + }; + + class BaseMockWidget { + constructor(...args) { + this._listeners = {}; + if (typeof this._init === 'function') { + this._init(...args); + } + } + _init(params) { + Object.assign(this, params); + } + connect(signal, callback) { + if (!this._listeners[signal]) { + this._listeners[signal] = []; + } + this._listeners[signal].push(callback); + return signal; + } + emit(signal) { + if (this._listeners[signal]) { + this._listeners[signal].forEach(cb => cb()); + } + } + get_root() { + return { + get_surface() { + return { + inhibit_system_shortcuts() {}, + restore_system_shortcuts() {} + }; + }, + present() {} + }; + } + } + + class MockPreferencesPage extends BaseMockWidget { + _init(params) { + super._init(params); + this.groups = []; + } + add(group) { + this.groups.push(group); + } + } + + class MockPreferencesGroup extends BaseMockWidget { + _init(params) { + super._init(params); + this.rows = []; + } + add(row) { + this.rows.push(row); + } + } + + const mockAdw = { + PreferencesPage: MockPreferencesPage, + PreferencesGroup: MockPreferencesGroup, + SwitchRow: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.active = false; + } + }, + SpinRow: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.value = 0; + } + }, + ComboRow: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.selected = 0; + } + }, + ActionRow: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.suffixes = []; + } + add_suffix(widget) { + this.suffixes.push(widget); + } + }, + AlertDialog: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.responses = []; + this.controllers = []; + mockAdw.lastAlertDialog = this; + } + add_response(id, label) { + this.responses.push({ id, label }); + } + add_controller(controller) { + this.controllers.push(controller); + } + present(window) { + this.presented = true; + this.window = window; + } + close() { + this.closed = true; + } + } + }; + + return { mockSettingsStore, mockListeners, mockSettings, BaseMockWidget, mockAdw }; +}); + +vi.mock('gi://Gio', () => ({ + default: { + Settings: class { + constructor() { + return mockSettings; + } + }, + SettingsBindFlags: { + DEFAULT: 0 + } + } +})); + +vi.mock('gi://Adw', () => ({ + default: mockAdw +})); + +vi.mock('gi://Gtk', () => ({ + default: { + Adjustment: class {}, + Align: { + CENTER: 0, + START: 1, + END: 2 + }, + ShortcutLabel: class { + constructor(params) { + Object.assign(this, params); + } + }, + Label: class { + constructor(params) { + Object.assign(this, params); + } + add_css_class(cls) {} + }, + Image: class { + constructor(params) { + Object.assign(this, params); + } + add_css_class(cls) {} + }, + Box: class { + constructor(params) { + Object.assign(this, params); + } + append() {} + }, + StringList: { + new(strings) { + return { strings }; + } + }, + EventControllerKey: class { + connect(signal, callback) { + this.signal = signal; + this.callback = callback; + } + }, + accelerator_get_default_mod_mask() { + return 0; + }, + accelerator_name() { + return 'mock-accel'; + } + } +})); + +vi.mock('gi://Gdk', () => ({ + default: { + KEY_BackSpace: 8, + KEY_Shift_L: 0xffe1, + KEY_Hyper_R: 0xffed, + KEY_Alt_L: 0xffe9, + KEY_Alt_R: 0xffea, + KEY_Meta_L: 0xffe7, + KEY_Meta_R: 0xffe8, + KEY_Super_L: 0xffeb, + KEY_Super_R: 0xffec, + KEY_Control_L: 0xffe3, + KEY_Control_R: 0xffe4, + EVENT_STOP: true, + EVENT_PROPAGATE: false + } +})); + +vi.mock('gi://GObject', () => ({ + default: { + registerClass: (meta, cls) => { + const actualClass = cls || meta; + const wrapperClass = class extends actualClass { + constructor(...args) { + super(...args); + if (typeof this._init === 'function') { + this._init(...args); + } + } + }; + return wrapperClass; + } + } +})); + +vi.mock('resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js', () => ({ + ExtensionPreferences: class { + getSettings() { + return mockSettings; + } + } +})); + +vi.mock('../lib/editor/preview.js', () => ({ + LayoutPreviewPage: class extends BaseMockWidget { + _init(settings) { + this.settings = settings; + this.title = 'Layouts'; + this.groups = []; + } + add(group) { + this.groups.push(group); + } + } +})); + +vi.mock('../lib/editor/editor.js', () => ({ + LayoutEditorPage: class extends BaseMockWidget { + _init(settings) { + this.settings = settings; + this.title = 'Base JSON'; + } + } +})); + +// Import WorkflowTilingPreferences (relative to tests/ dir) +import WorkflowTilingPreferences from '../prefs.js'; + +describe('WorkflowTilingPreferences', () => { + let prefs; + let mockWindow; + let addedPages; + + beforeEach(() => { + // Reset settings + mockSettingsStore['enable-gaps'] = true; + mockSettingsStore['inner-gaps'] = 6; + mockSettingsStore['outer-gaps'] = 4; + mockSettingsStore['keybindings-mode'] = 'default'; + mockSettingsStore['focus-window-mode'] = 'default'; + mockSettingsStore['shortcut-close-monitor'] = []; + mockSettingsStore['close-monitor-include-minimized'] = false; + + Object.keys(mockListeners).forEach(k => delete mockListeners[k]); + + prefs = new WorkflowTilingPreferences(); + addedPages = []; + mockWindow = { + add: vi.fn((page) => { + addedPages.push(page); + }), + remove: vi.fn((page) => { + const idx = addedPages.indexOf(page); + if (idx > -1) addedPages.splice(idx, 1); + }) + }; + }); + + it('should create and add expected pages to preferences window', () => { + prefs.fillPreferencesWindow(mockWindow); + + expect(mockWindow.add).toHaveBeenCalled(); + const titles = addedPages.map(p => p.title); + expect(titles).toContain('General'); + expect(titles).toContain('Keyboard Shortcuts'); + expect(titles).toContain('Layouts'); + }); + + it('should place only Gaps group in General page', () => { + prefs.fillPreferencesWindow(mockWindow); + const generalPage = addedPages.find(p => p.title === 'General'); + + expect(generalPage).toBeDefined(); + expect(generalPage.groups).toHaveLength(1); + expect(generalPage.groups[0].title).toBe('Gaps'); + }); + + it('should place correct shortcut groups in Keyboard Shortcuts page', () => { + prefs.fillPreferencesWindow(mockWindow); + const shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); + + expect(shortcutsPage).toBeDefined(); + expect(shortcutsPage.groups).toHaveLength(7); + expect(shortcutsPage.groups[1].title).toBe('Window Focus & Position'); + expect(shortcutsPage.groups[2].title).toBe('Window State'); + expect(shortcutsPage.groups[3].title).toBe('Workspace Operations'); + expect(shortcutsPage.groups[4].title).toBe('Workspace Switching'); + expect(shortcutsPage.groups[5].title).toBe('Moving to Workspace'); + expect(shortcutsPage.groups[6].title).toBe('Monitor Actions'); + }); + + it('should toggle JSON Layout Editor page dynamically when active notify fires', () => { + prefs.fillPreferencesWindow(mockWindow); + const layoutsPage = addedPages.find(p => p.title === 'Layouts'); + const advancedGroup = layoutsPage.groups[0]; + const jsonToggle = advancedGroup.rows.find(r => r.title === 'Edit Base JSON Instead'); + + expect(jsonToggle).toBeDefined(); + expect(addedPages.some(p => p.title === 'Base JSON')).toBe(false); + + // Activate toggle and emit notify + jsonToggle.active = true; + jsonToggle.emit('notify::active'); + + expect(addedPages.some(p => p.title === 'Base JSON')).toBe(true); + + // Deactivate toggle and emit notify + jsonToggle.active = false; + jsonToggle.emit('notify::active'); + + expect(addedPages.some(p => p.title === 'Base JSON')).toBe(false); + }); + + describe('ShortcutRow & Key Recording', () => { + let shortcutsPage; + let swapGroup; + let testRow; + + beforeEach(() => { + prefs.fillPreferencesWindow(mockWindow); + shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); + swapGroup = shortcutsPage.groups.find(g => g.title === 'Window Focus & Position'); + testRow = swapGroup.rows.find(r => r.keyName === 'custom-move-window-left'); + }); + + it('should initialize ShortcutRow with accelerator from settings', () => { + mockSettingsStore['custom-move-window-left'] = ['Left']; + const row = new testRow.constructor(mockSettings, 'custom-move-window-left', 'Test Title'); + expect(row._getAccelerator()).toBe('Left'); + }); + + it('should handle empty accelerator settings', () => { + mockSettingsStore['custom-move-window-left'] = []; + const row = new testRow.constructor(mockSettings, 'custom-move-window-left', 'Test Title'); + expect(row._getAccelerator()).toBe(''); + }); + + it('should present shortcut dialog and handle key presses', () => { + testRow.emit('activated'); + + const dialog = mockAdw.lastAlertDialog; + expect(dialog).toBeDefined(); + expect(dialog.presented).toBe(true); + expect(dialog.window).toBeDefined(); + + expect(dialog.controllers).toHaveLength(1); + const controller = dialog.controllers[0]; + expect(controller.signal).toBe('key-pressed'); + + // Test modifier key press: Shift_L (0xffe1), modifiers = 0 + const propagateResult = controller.callback(controller, 0xffe1, 0, 0); + expect(propagateResult).toBe(false); // Gdk.EVENT_PROPAGATE is false + expect(mockSettingsStore['custom-move-window-left']).toEqual([]); + + // Test valid accelerator press: e.g. keyval = 65 ('a'), modifiers = 4 (Control) + const stopResult = controller.callback(controller, 65, 0, 4); + expect(stopResult).toBe(true); // Gdk.EVENT_STOP is true + expect(mockSettingsStore['custom-move-window-left']).toEqual(['mock-accel']); + expect(dialog.closed).toBe(true); + }); + + it('should clear shortcut when Backspace is pressed without modifiers', () => { + mockSettingsStore['custom-move-window-left'] = ['some-shortcut']; + testRow.emit('activated'); + + const dialog = mockAdw.lastAlertDialog; + const controller = dialog.controllers[0]; + + // Backspace keyval = 8, modifiers = 0 + const result = controller.callback(controller, 8, 0, 0); + expect(result).toBe(true); // Gdk.EVENT_STOP + expect(mockSettingsStore['custom-move-window-left']).toEqual([]); + expect(dialog.closed).toBe(true); + }); + + it('should propagate key event when accelerator is falsy', () => { + testRow.emit('activated'); + + const dialog = mockAdw.lastAlertDialog; + const controller = dialog.controllers[0]; + + // Spy on Gtk.accelerator_name to return empty string + vi.spyOn(Gtk, 'accelerator_name').mockReturnValueOnce(''); + + // Key press with keyval = 65, modifiers = 4 + const result = controller.callback(controller, 65, 0, 4); + expect(result).toBe(false); // Gdk.EVENT_PROPAGATE is false + expect(dialog.closed).toBeUndefined(); // Should not close + }); + + it('should handle missing surface or inhibit method without throwing', () => { + vi.spyOn(testRow, 'get_root').mockReturnValue({ + get_surface() { return null; } + }); + + expect(() => testRow.emit('activated')).not.toThrow(); + expect(mockAdw.lastAlertDialog.presented).toBe(true); + }); + + it('should handle surface inhibit/restore throwing errors without crashing', () => { + vi.spyOn(testRow, 'get_root').mockReturnValue({ + get_surface() { + return { + inhibit_system_shortcuts() { throw new Error('Inhibit failed'); }, + restore_system_shortcuts() { throw new Error('Restore failed'); } + }; + } + }); + + expect(() => testRow.emit('activated')).not.toThrow(); + const dialog = mockAdw.lastAlertDialog; + + // Trigger cleanup by emitting response on dialog + expect(() => dialog.connect).toBeDefined(); + + // Re-simulate a response event trigger + dialog.emit('response'); + }); + }); + + describe('Mode Switching and Visibility Stress Tests', () => { + let shortcutsPage; + let swapGroup; + let testRow; + + beforeEach(() => { + prefs.fillPreferencesWindow(mockWindow); + shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); + swapGroup = shortcutsPage.groups.find(g => g.title === 'Window Focus & Position'); + testRow = swapGroup.rows.find(r => r.keyName === 'custom-move-window-left'); + }); + + it('should update keybinding row visibilities when mode changes', () => { + const modeRow = swapGroup.rows.find(r => r instanceof mockAdw.ComboRow && r.title === 'Swap Mode'); + const moveRows = swapGroup.rows.filter(r => r.keyName && r.keyName.startsWith('custom-move-window')); // ShortcutRows + + // Initial: default mode. Move rows should be hidden + expect(moveRows.every(r => r.visible === false)).toBe(true); + expect(modeRow.subtitle).toContain('Default:'); + + // Switch to custom (notify selected = 1) + modeRow.selected = 1; + modeRow.emit('notify::selected'); + expect(mockSettingsStore['keybindings-mode']).toBe('custom'); + expect(moveRows.every(r => r.visible === true)).toBe(true); + expect(modeRow.subtitle).toBe(''); + + // Switch to disabled (notify selected = 2) + modeRow.selected = 2; + modeRow.emit('notify::selected'); + expect(mockSettingsStore['keybindings-mode']).toBe('disabled'); + expect(moveRows.every(r => r.visible === false)).toBe(true); + expect(modeRow.subtitle).toBe(''); + }); + + it('should handle external settings modifications for keybindings mode visibility', () => { + const moveRows = swapGroup.rows.filter(r => r.keyName && r.keyName.startsWith('custom-move-window')); + + // Set via settings directly + mockSettings.set_string('keybindings-mode', 'custom'); + expect(moveRows.every(r => r.visible === true)).toBe(true); + + mockSettings.set_string('keybindings-mode', 'disabled'); + expect(moveRows.every(r => r.visible === false)).toBe(true); + }); + + it('should handle invalid keybindings mode values gracefully', () => { + const moveRows = swapGroup.rows.filter(r => r.keyName && r.keyName.startsWith('custom-move-window')); + + // Set to invalid value + mockSettings.set_string('keybindings-mode', 'invalid-mode-value'); + + // Should hide custom rows and not crash + expect(moveRows.every(r => r.visible === false)).toBe(true); + }); + + it('should toggle gaps visibility when enable-gaps setting is changed', () => { + const generalPage = addedPages.find(p => p.title === 'General'); + const gapsGroup = generalPage.groups[0]; + const innerGapsRow = gapsGroup.rows.find(r => r.title.includes('Inner Gaps')); + const outerGapsRow = gapsGroup.rows.find(r => r.title.includes('Outer Gaps')); + + expect(innerGapsRow.visible).toBe(true); + expect(outerGapsRow.visible).toBe(true); + + // Disable gaps using setter on bound property + const enableGapsRow = gapsGroup.rows.find(r => r.title === 'Enable Gaps'); + enableGapsRow.active = false; + + expect(innerGapsRow.visible).toBe(false); + expect(outerGapsRow.visible).toBe(false); + }); + + it('should handle rapid toggling of JSON editor page', () => { + const layoutsPage = addedPages.find(p => p.title === 'Layouts'); + const advancedGroup = layoutsPage.groups[0]; + const jsonToggle = advancedGroup.rows.find(r => r.title === 'Edit Base JSON Instead'); + + for (let i = 0; i < 100; i++) { + jsonToggle.active = !jsonToggle.active; + jsonToggle.emit('notify::active'); + } + + // Since it was toggled 100 times starting from false, it ends at false (removed) + expect(addedPages.some(p => p.title === 'Base JSON')).toBe(false); + }); + }); +}); + diff --git a/vitest.config.js b/vitest.config.js index aa56510..5d26a09 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { setupFiles: ['./tests/setup.js'], + exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp,agents}/**', '**/{karma,rollup,webpack,vite,vitest}.config.*'], }, });