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 3544718..f56dc87 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 a762eb0..de46c91 100644
--- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml
@@ -179,6 +179,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 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..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', () => {
@@ -19,6 +19,7 @@ describe('WindowWrapper', () => {
unmaximize: vi.fn(),
maximized_horizontally: false,
maximized_vertically: false,
+ is_fullscreen: vi.fn(() => false),
};
mockController = {
@@ -120,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() };
@@ -146,4 +155,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);
+ });
});