diff --git a/extension.js b/extension.js index f91cbe1..989d445 100644 --- a/extension.js +++ b/extension.js @@ -1,4 +1,5 @@ import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import Gio from 'gi://Gio'; import { TilingController } from './lib/controller.js'; import { SignalListener } from './lib/signals.js'; import { SettingsManager } from './lib/settings.js'; @@ -22,6 +23,20 @@ export default class WorkflowTilingExtension extends Extension { this._isActive = false; this._wasSuspended = false; + this._isActive = false; + this._wasSuspended = false; + + try { + this._wmSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.wm.preferences' }); + let currentLayout = this._wmSettings.get_string('button-layout'); + if (currentLayout.includes('maximize')) { + let newLayout = currentLayout.replace(/maximize,?/g, '').replace(/,maximize/g, ''); + this._wmSettings.set_string('button-layout', newLayout); + } + } catch (e) { + Logger.warn('Failed to hide maximize button', e); + } + this._settings.onSettingsChanged = () => { if (this._applyCustomLayouts()) { this._controller.hydrate(); @@ -84,5 +99,25 @@ export default class WorkflowTilingExtension extends Extension { this._controller = null; this._isActive = false; this._wasSuspended = false; + + if (this._wmSettings) { + try { + let layout = this._wmSettings.get_string('button-layout'); + if (!layout.includes('maximize')) { + if (layout.includes('minimize,close')) { + layout = layout.replace('minimize,close', 'minimize,maximize,close'); + } else if (layout.includes('close')) { + layout = layout.replace('close', 'maximize,close'); + } else { + layout += ',maximize'; + } + this._wmSettings.set_string('button-layout', layout); + Gio.Settings.sync(); + } + } catch (e) { + Logger.error('Failed to restore maximize button', e); + } + this._wmSettings = null; + } } } diff --git a/lib/controller.js b/lib/controller.js index c345d2c..dad87f2 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -33,6 +33,7 @@ export class TilingController { this.monitorManager = new MonitorManager(this); this.workspaceManager = new WorkspaceManager(this); this.dragManager = new DragManager(this); + this._authorizedOverrides = new Set(); /** * When true, layout re-evaluations are deferred. @@ -78,6 +79,7 @@ export class TilingController { Logger.debug(`tilingRequest: Initiating for window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}")`); + const isNewWindow = !this._windowWrappers.has(window); let wrapper = this._ensureWrapper(window); if (!wrapper) { Logger.debug(`tilingRequest: Aborted. Wrapper creation rejected window.`); @@ -95,6 +97,11 @@ export class TilingController { } const { workspace, monitorIndex, monitorId, isRestoring, preferredSlot } = context; + + if (isNewWindow && !isRestoring) { + this._clearOverridesOnMonitor(monitorIndex); + } + Logger.debug(`tilingRequest: Context resolved -> Workspace: ${workspace.index ? workspace.index() : 'unknown'}, MonitorIndex: ${monitorIndex}, MonitorID: ${monitorId}, Restoring: ${isRestoring}`); if (this.monitorManager.checkEvacuation(window, wrapper, monitorId, workspace)) { @@ -200,6 +207,7 @@ export class TilingController { wrapper.destroy(); const { workspace, monitorIndex, monitorId } = wrapper; this._windowWrappers.delete(window); + this._authorizedOverrides.delete(window); try { if (workspace) { @@ -380,7 +388,37 @@ export class TilingController { this.workspaceManager.unminimizeWorkspace(workspace); } + toggleOverrideActiveWindow(type) { + const targetWindow = global.display.get_focus_window(); + if (!targetWindow || targetWindow.unmanaged) return; + + const isActive = (targetWindow.maximized_horizontally && targetWindow.maximized_vertically) || (targetWindow.is_fullscreen && targetWindow.is_fullscreen()); + if (isActive) { + this._authorizedOverrides.delete(targetWindow); + if (targetWindow.is_fullscreen && targetWindow.is_fullscreen()) targetWindow.unmake_fullscreen(); + if (targetWindow.maximized_horizontally && targetWindow.maximized_vertically) targetWindow.unmaximize(3); + } else { + this._authorizedOverrides.add(targetWindow); + if (type === 'maximize') targetWindow.maximize(3); + if (type === 'fullscreen') targetWindow.make_fullscreen(); + } + } + + _clearOverridesOnMonitor(monitorIndex) { + const activeWorkspace = global.workspace_manager.get_active_workspace(); + this._windowWrappers.forEach((wrapper, window) => { + if (wrapper.monitorIndex === monitorIndex && wrapper.workspace === activeWorkspace) { + this._authorizedOverrides.delete(window); + if (window.maximized_horizontally && window.maximized_vertically) { + window.unmaximize(3); + } + if (window.is_fullscreen && window.is_fullscreen()) { + window.unmake_fullscreen(); + } + } + }); + } clear() { this._retileTimeouts.forEach(id => global.compositor.get_laters().remove(id)); @@ -391,6 +429,7 @@ export class TilingController { this.workspaceManager.clearLayouts(); this._windowWrappers.clear(); this._restoringWindows.clear(); + this._authorizedOverrides.clear(); this.monitorManager.clear(); if (TilingController.activeInstance === this) { diff --git a/lib/keybindings.js b/lib/keybindings.js index f66c802..0a81110 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -59,7 +59,9 @@ export class KeybindingManager { '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()) + 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()), + 'shortcut-toggle-maximize': (c) => c.toggleOverrideActiveWindow('maximize'), + 'shortcut-toggle-fullscreen': (c) => c.toggleOverrideActiveWindow('fullscreen') }; for (const [key, action] of Object.entries(utilities)) { diff --git a/lib/settings.js b/lib/settings.js index a29ab4a..d834161 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -43,7 +43,9 @@ export class SettingsManager { 'shortcut-switch-monitor', 'shortcut-port-monitor-left', 'shortcut-port-monitor-right', - 'shortcut-unminimize-workspace' + 'shortcut-unminimize-workspace', + 'shortcut-toggle-maximize', + 'shortcut-toggle-fullscreen' ]; kbKeys.forEach(key => { this._changedIds.push(this.settings.connect(`changed::${key}`, () => { diff --git a/lib/signals.js b/lib/signals.js index cdaaf27..e330856 100644 --- a/lib/signals.js +++ b/lib/signals.js @@ -83,6 +83,7 @@ export class SignalListener { this.controller.hydrate(); } + _addWindow(window) { if (!window) return; GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { diff --git a/lib/window.js b/lib/window.js index 36b84a2..2978444 100644 --- a/lib/window.js +++ b/lib/window.js @@ -97,14 +97,25 @@ export class WindowWrapper { } } + if (this.isOverrideActive()) { + return; + } + if (this.window.maximized_horizontally || this.window.maximized_vertically) { - this.window.unmaximize(); + this.window.unmaximize(3); // Delay unmaximize via compositor. global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { if (this.unmanaged) return false; this._doResize(rect); return false; }); + } else if (this.window.is_fullscreen()) { + this.window.unmake_fullscreen(); + global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + if (this.unmanaged) return false; + this._doResize(rect); + return false; + }); } else { this._doResize(rect); } @@ -113,6 +124,11 @@ export class WindowWrapper { } } + isOverrideActive() { + if (this.unmanaged) return false; + return this.controller._authorizedOverrides && this.controller._authorizedOverrides.has(this.window); + } + _doResize(rect) { try { this._isResizing = true; diff --git a/prefs.js b/prefs.js index 55ebb59..b5b214c 100644 --- a/prefs.js +++ b/prefs.js @@ -231,7 +231,9 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { { 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' } + { id: 'shortcut-unminimize-workspace', label: 'Unminimize Workspace' }, + { id: 'shortcut-toggle-maximize', label: 'Toggle Maximize Override (Active)' }, + { id: 'shortcut-toggle-fullscreen', label: 'Toggle Fullscreen Override (Active)' } ].forEach(s => batchKeysGroup.add(new ShortcutRow(settings, s.id, s.label))); page.add(batchKeysGroup); diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index a700a82..e421396 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..d7bd094 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -161,6 +161,17 @@ Custom Layouts JSON JSON string defining custom window layouts. + + plus']]]> + Toggle Maximize Override + Persistent maximize for active window. + + + + + Toggle Fullscreen Override + Persistent fullscreen for active window. + diff --git a/tests/controller.test.js b/tests/controller.test.js index 89c026d..abb2b5d 100644 --- a/tests/controller.test.js +++ b/tests/controller.test.js @@ -46,6 +46,7 @@ describe('TilingController', () => { unmaximize: vi.fn(), maximized_horizontally: false, maximized_vertically: false, + is_fullscreen: vi.fn(() => false), minimized: false, connect: vi.fn(() => 123), disconnect: vi.fn(), @@ -344,7 +345,7 @@ describe('TilingController', () => { it('should gracefully handle errors in tilingRequest', () => { const win = createMockWindow(1, null, 0); // Force an error by mocking global.workspace_manager to throw - vi.mocked(global.workspace_manager.get_active_workspace).mockImplementation(() => { + vi.mocked(global.workspace_manager.get_active_workspace).mockImplementationOnce(() => { throw new Error('test error'); }); @@ -544,5 +545,60 @@ describe('TilingController', () => { expect(controller.dragManager._activeDrag.lastHoveredSlot).toBe(-1); }); }); + + describe('Overrides', () => { + it('should toggle override for maximize', () => { + const win = createMockWindow(1, { id: 'ws1' }, 0); + global.display.get_focus_window = vi.fn(() => win); + win.maximize = vi.fn(); + + controller.toggleOverrideActiveWindow('maximize'); + expect(controller._authorizedOverrides.has(win)).toBe(true); + expect(win.maximize).toHaveBeenCalledWith(3); + + // Toggle off + win.maximized_horizontally = true; + win.maximized_vertically = true; + controller.toggleOverrideActiveWindow('maximize'); + expect(controller._authorizedOverrides.has(win)).toBe(false); + expect(win.unmaximize).toHaveBeenCalledWith(3); + }); + + it('should toggle override for fullscreen', () => { + const win = createMockWindow(1, { id: 'ws1' }, 0); + global.display.get_focus_window = vi.fn(() => win); + win.make_fullscreen = vi.fn(); + win.unmake_fullscreen = vi.fn(); + + controller.toggleOverrideActiveWindow('fullscreen'); + expect(controller._authorizedOverrides.has(win)).toBe(true); + expect(win.make_fullscreen).toHaveBeenCalled(); + + // Toggle off + win.is_fullscreen = vi.fn(() => true); + controller.toggleOverrideActiveWindow('fullscreen'); + expect(controller._authorizedOverrides.has(win)).toBe(false); + expect(win.unmake_fullscreen).toHaveBeenCalled(); + }); + + it('should clear overrides on monitor', () => { + const ws = { id: 'ws1' }; + global.workspace_manager.get_active_workspace = vi.fn(() => ws); + const win = createMockWindow(1, ws, 0); + win.maximized_horizontally = true; + win.maximized_vertically = true; + win.is_fullscreen = vi.fn(() => true); + win.unmake_fullscreen = vi.fn(); + + controller.tilingRequest(win); + controller._authorizedOverrides.add(win); + + controller._clearOverridesOnMonitor(0); + + expect(controller._authorizedOverrides.has(win)).toBe(false); + expect(win.unmaximize).toHaveBeenCalledWith(3); + expect(win.unmake_fullscreen).toHaveBeenCalled(); + }); + }); }); diff --git a/tests/window.test.js b/tests/window.test.js index fcd4c35..75bfda9 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -19,6 +19,7 @@ describe('WindowWrapper', () => { unmaximize: vi.fn(), maximized_horizontally: false, maximized_vertically: false, + is_fullscreen: vi.fn(() => false), }; mockController = { @@ -118,4 +119,30 @@ describe('WindowWrapper', () => { const wrapper = new WindowWrapper(mockWindow, mockController); wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); // should not throw }); + + it('should correctly identify active override', () => { + mockController._authorizedOverrides = new Set([mockWindow]); + const wrapper = new WindowWrapper(mockWindow, mockController); + expect(wrapper.isOverrideActive()).toBe(true); + + mockController._authorizedOverrides = new Set(); + expect(wrapper.isOverrideActive()).toBe(false); + }); + + it('should skip applyGeometry if override is active', () => { + mockController._authorizedOverrides = new Set([mockWindow]); + const wrapper = new WindowWrapper(mockWindow, mockController); + wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); + expect(mockWindow.move_resize_frame).not.toHaveBeenCalled(); + }); + + it('should unmake fullscreen before applying geometry if fullscreen', () => { + mockWindow.is_fullscreen = vi.fn(() => true); + mockWindow.unmake_fullscreen = vi.fn(); + const wrapper = new WindowWrapper(mockWindow, mockController); + wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); + + expect(mockWindow.unmake_fullscreen).toHaveBeenCalled(); + expect(mockWindow.move_resize_frame).toHaveBeenCalledWith(false, 10, 10, 100, 100); + }); });