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);
+ });
});