From 496fcca54a9402e8a26ed718f49c4b14419ee6a3 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Mon, 8 Jun 2026 20:34:57 +0200 Subject: [PATCH 1/2] first working iteration maximisation rework --- extension.js | 35 ++++++++++++++++ lib/controller.js | 39 ++++++++++++++++++ lib/keybindings.js | 4 +- lib/settings.js | 4 +- lib/signals.js | 1 + lib/window.js | 18 +++++++- prefs.js | 4 +- schemas/gschemas.compiled | Bin 2600 -> 2732 bytes ...ell.extensions.workflow-tiling.gschema.xml | 11 +++++ 9 files changed, 112 insertions(+), 4 deletions(-) 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 a700a828b54016afad677e008f1432dfabf88666..e421396cd2ebee064852e47983b7c4ef9729b3b5 100644 GIT binary patch literal 2732 zcmbtVU1%It7`;(bW0UkJrft$THr=PRWV89PDlvo_iUw?@H24Ej*vZc9&Y0Pmaelgq zA(97?3JL)$v>+NwEfuPWv=9+=A8Z~JtZ0js){N>yv3wDI+Q2V_y6c|Es{^~U-~K@6ce}-?cn!SoJ|XHi3Gp{RXV$_H z`vv(dV7rB$f@K%A9#7XyvqzisHOn(>%j+rG?nK_SOKIOQ4XeQ4iLR6+A^6OD0fd&Q z2UoBLnz$cm03HAuBQ$}Vf%OqKfLj2hCpIU*{cQ!d0Uf|Yzz(1j*coB(yXANI9PV$c zI3luO4Y>aP;_vjS*TJ6zPawIgi`!qPPrV8LEO-vMcJ6FDed_h_=fMlWrMFM7p-;U7 z{uS^qz~#@&$LLdUfqxhL7f`7&HEYZw%mPit`j5GudM(z^fsX?xp8w(x`qZ1@e+oVi zlxJRT};OkS?9s1PF=O}m{IR4o&jXrfN`~~n=z@N8&yh@*%`}!Vy1-RLI>QVaC z8{yvqH=q%&)qNy=YWkhvZs5ivFF(%p)b#g)$AIsytGDP=Z-B3ZeW31VRpw94{O7HjwJ41MZt@XO!}fb;P9oAjyKw@ct(f%Y4!%%7V1 zqo|?<*nRm!nGZFe+Y25Ac3xP_G7dHUEZ6}$MlOCypSm6X9GLgr({0z!1%BEAr;D~_ z__muiwLGUvm*VZp1)gsg)21?I2fhardrI}C249>`O{NBWyHiv0Zz(m{*Ngx3hz%8D z=5Tkazt++{t5$Z)aP;GND;M=M+`#`0$EZxMf2Qh@w6TZinUzHYGlyqThPVnv`-qk< z8J3C(7&t>?fup&@&pJ31mMU+fSj%6Fp@XhbRNSfIp)uXa`@<8sak8YQ?P+nX4J&5` zD*iVtqiDRMsVw~XuJ~ZQlHup{%5r%C3<7}>hO-Q3Am`sYfODR+l(Unwk~5ieb|bI_ z;9TXr<(%f6Z3Q@EIZGb|I9qWwoIAIo^u8>d}7VMhL9RrAa5c zA?$}J(W=N%3MjB8Qhof0_aR>7+l2xeI3Jj% zmvc4E5>NK^;ZAW;@^Nu_D(9D|cdbn)*8f*MqZEqDq?|-UPniC&V+LL{-)coq>}FMe JR_+(e^AF{XiqQZ7 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 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. + From 6a890129382f5b2b2530c04aca195a5491b0b40a Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 10 Jun 2026 18:14:59 +0200 Subject: [PATCH 2/2] test: add unit tests for override features and fix mocks --- tests/controller.test.js | 58 +++++++++++++++++++++++++++++++++++++++- tests/window.test.js | 27 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) 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); + }); });