From acaf1400d9db23e8b4076d80b890c5a77a7c436f Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Mon, 8 Jun 2026 20:34:57 +0200 Subject: [PATCH 1/3] 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 | 19 ++++++++- prefs.js | 4 +- schemas/gschemas.compiled | Bin 2796 -> 2940 bytes ...ell.extensions.workflow-tiling.gschema.xml | 11 +++++ 9 files changed, 113 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 748192c..1784822 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -34,6 +34,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. @@ -79,6 +80,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.`); @@ -101,6 +103,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)) { @@ -240,6 +247,7 @@ export class TilingController { wrapper.destroy(); const { workspace, monitorIndex, monitorId } = wrapper; this._windowWrappers.delete(window); + this._authorizedOverrides.delete(window); try { if (workspace) { @@ -422,7 +430,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)); @@ -433,6 +471,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 d49889a..1dbbe0e 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -59,7 +59,9 @@ export class KeybindingManager { 'shortcut-switch-monitor': (c, win) => c.switchMonitors(win ? win.get_monitor() : 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 2787d52..4806e14 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -48,7 +48,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 516f319..163f3dc 100644 --- a/lib/signals.js +++ b/lib/signals.js @@ -84,6 +84,7 @@ export class SignalListener { this.controller.hydrate(); } + _addWindow(window) { if (!window) return; const sourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { diff --git a/lib/window.js b/lib/window.js index ee6883c..eb64bb5 100644 --- a/lib/window.js +++ b/lib/window.js @@ -152,14 +152,26 @@ export class WindowWrapper { } } + if (this.isOverrideActive()) { + return; + } + if (this.window.maximized_horizontally || this.window.maximized_vertically) { - this.window.unmaximize(); + this.window.unmaximize(3); const laterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { if (this.unmanaged) return false; this._doResize(rect); return false; }); this._pendingLaters.push(laterId); + } else if (this.window.is_fullscreen()) { + this.window.unmake_fullscreen(); + const laterId2 = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + if (this.unmanaged) return false; + this._doResize(rect); + return false; + }); + this._pendingLaters.push(laterId2); } else { this._doResize(rect); } @@ -168,6 +180,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 a8c06e5..ece2fa9 100644 --- a/prefs.js +++ b/prefs.js @@ -320,7 +320,9 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { { 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 } + { id: 'toggle-fullscreen', label: 'Toggle Fullscreen', origin: 'System', st: wmSettings }, + { id: 'shortcut-toggle-maximize', label: 'Toggle Maximize Override (Active)', origin: '', st: settings }, + { id: 'shortcut-toggle-fullscreen', label: 'Toggle Fullscreen Override (Active)', origin: '', st: settings } ].forEach(s => stateGroup.add(createRow(s.st, s.id, s.label, s.origin))); shortcutsPage.add(stateGroup); diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 3544718ff6b655bbd6d26113a7d63abbaf2f0367..f56dc879c0c64f123ec6f506f817c5a1685afe65 100644 GIT binary patch literal 2940 zcmbtWZD?Cn7(P`y=UTUJ>pnVH*F^`}>}}T8sdS~(B3f`0amA(&st%roBmue+)c*o;-Pb z&U?PzbDnrswRP2WMex}U9`JSaw!o_a+Y>)tBJ;O>qF=lUp1ebd+ATu-gHNswhBzeh z;Fkc)PPL^>E2FhJY0WU&w7jdCj&7MwTh6jal7^L2T;0&k6#q{|_ek>ppjlBHU=4U} zfV;qzV7K+a-N1Ta6VM1CEwL?xCTL;@uoGwwunSDI0J{T3z{GvPQs?2b4PcISy%-he zz+VFU=FgmUFnH^DE0F9W}R@R7`un(Zut*J4sn9sBw( z##3*F-vZtPT>Eh55Bk&`?<3#`?i8Ztx5NZ}>TU4*!N-9_qt{2Dx(O*c@sr?b zVE(J(N&3{xa~Av)P@k#&ls@%3_?N*8K=#4mx9C%I{<*={0T*r-B+erW*;9@zf27&w^h8{%o9*c~Z+f!PCIf?|n}(p86idUj)wsKOB2Ut}pdw z_}9U=fMVh0I-WD?TKE`*hyj1!{N)PUq2{=H!TrF`*Ak2LshQ6JI04L@ACh@e)6aw7 z1m@m7yN2=9yWmfQ&jVL$K9>1YGyhre9B^f3=j)88=002mV*`k*UwqO;pLzrQ25=Pk zcA|HIJ~hYN4?YS^etuG;Pu&PV3uZ0JSfiSf)6E3xgqIbf`-qp-?4F}pj54Y^d*hzt zS{Ws2L6qVQE$MbYZR;7;9_#5olGc-MPa3weU5 zd$NY-l(!r1-qurTH|YNvY80EYCbMW-$ylcDTDHO@IR3JTxM4XM4ho->RpVOe_hmzI za=IH&qmhLC5o@I-RnI_6(bdN)lR`U{<28cut~NiIOS3Zm`O5k6Qw>HFj3a2wP{%CK zb(p;vkF-a+o*R$kBVGGjBV+P!F4EP$AK%ge>j{X$U~8mfwa^1qk*%^h`pfNA26eEx z&i~sSTV-+`g|aSbV+=u`rJ&BjU?IE=;jK|x?B)G=h^t>O7hf~gVQjRNn&phui0JU% zvfb*hVC8!J*ZRqGP_e&9g7ph$RoWX|r&ac?yzfx%kdqKBtGqXPe76EPdg69~cOmap zz6Uk}EdcLW-ixLCllSAj0Pooi06&|7`vH_GUVQ@BpuBfW_cGrUyqkFs<7yFQQ?fFh zRr|W?950_3dF=f2hTW;z{*1F<5!I{CebO@wCvIz+$p9f9Z*NDAWrx)>GrB2@L-@op zBsd4*x#pQ-JHb3Z#;^YRhoe>o7Q}`2N!bjPnyVUr)j2III-wP3o-1`lCc&x_t|x6- z)lD;Ld}4U0#pFXRCLe0Cm4{la;-MB3!qH8$G{gS?a}7DqBih(7sx{toj;vzg{=zd+ z8esK&l@%UEH{*tvK%EYjBH?$(&MMB4Uo*q~t+Gr!I`R|ryL^w7%|4>&$F6 zF|6dFN@@`bw$Mt^D7943Qi=sdkVP~P3O3XhE3GAu1tX^9!TO}%H+yF~`{R=XU%r!X z?w)(kx%Zwq&%LbJx?;MLc(#BKmhI?$LTdoqGe2G-^W9EqL^=#kJRnJp8zt!)gA^_JxEIsx5Guyz7ah)_F zO@faCyQVv2>a^EGp96mh+?e0?Hg(z?p)Y_Jftz1^)=r)FTIkE*CIqtb`|u0YX>Wxd z1W&q415AOb>Qp2sMFpA{Q~$B z@XH4u9idKp3-kr>ZQ$yAXID|Dy#spFD&!UT;lQyT>a;gN?*MlJ-<>g6sMDtZ5pV*y z+3@Mp)MzM?<1Kj#>{tu3) z{V?>C;G<2*?eOJqsMBtTehyp${%kwr#Y>ynu#KB#h9X&-q zv>DeB_*vj;$$XbOZR!cI1GF!x-uh{C{j=cr0sr37=Dj-&?!di!>&g*tzi9Iu?gsAx zTC$Cw(?4zIXCL?=aPs1DjXLc%=s9o!7=Heo7Z+{DH4B~thEA7Ga6IitpqIek11o)_ zXQ|U>-mZgL3$oUPCKq&5#eKK+bjB|<1N-wi%^rNwlh#O4#pz60$-GlNc3_{aXBB&D zaA1E%Pq~8zZXE|>sB2~Au={)t^9#Q(&$NJa1-oQ_t#eAT=n4PEJX3 ztoKyRlQb+xtC%Jnd_}^LU=t3i<9p4|SJ;!Rn@J2>Dy^I9=jz&n$73VIr&N?Sl z%uN?!{9dl69G4hxC{tG6b*3Xdk^WbwBa@N-?yks`_qPz~@9D;W8Ns>=qj;z*(pzun zu9}fuo;&J+zv@A~bl3NP-O(!-*ITTZk}_ftbNYk&iie8-xm!WWsNOHX4pf}4;MCOa zuYV6>ImOK&|8X=Zaj!v%dksqbUV{>^YEa^+#KFxk-^k#iVBz(Pk-zXdbuN5#f)i1D z9$2T#y$*XC_Au;q&~Bl=vR1SHmU|@jJM3>d0QN%co!B?A2jY2M3v2+`_wZq#^C++b zz-P(piP-<3HNm&4++(q~VviI=DhD;Mrmo?<`q#tr!TaH46zqnk%AuMFchYouuPe5V fNvPgm|2_tZuc*sG+RFJ4XH*mCustom 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 d9cbb3cb276791a4f16d1465720c66c54318d079 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 10 Jun 2026 18:14:59 +0200 Subject: [PATCH 2/3] 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 7ecafb2..2110329 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 88c52d2..94ce935 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 = { @@ -146,4 +147,30 @@ describe('WindowWrapper', () => { expect(() => wrapper.destroy()).not.toThrow(); }); }); + + 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); + }); }); From c3a22a24401a4368ce44a6f1998231d54a37168b Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 17 Jun 2026 08:02:18 +0200 Subject: [PATCH 3/3] Fix window.test.js test failures due to mock leaks --- tests/window.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/window.test.js b/tests/window.test.js index 94ce935..4b23c9a 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { WindowWrapper } from '../lib/window.js'; describe('WindowWrapper', () => { @@ -121,6 +121,14 @@ describe('WindowWrapper', () => { }); describe('_pendingLaters tracking', () => { + let originalGetLaters; + beforeEach(() => { + originalGetLaters = global.compositor.get_laters; + }); + afterEach(() => { + global.compositor.get_laters = originalGetLaters; + }); + it('should track and remove compositor laters on destroy', () => { const wrapper = new WindowWrapper(mockWindow, mockController); const mockLaters = { add: vi.fn(() => 42), remove: vi.fn() };