Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions extension.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
}
}
39 changes: 39 additions & 0 deletions lib/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.`);
Expand All @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand All @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion lib/keybindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 3 additions & 1 deletion lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, () => {
Expand Down
1 change: 1 addition & 0 deletions lib/signals.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class SignalListener {
this.controller.hydrate();
}


_addWindow(window) {
if (!window) return;
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
Expand Down
18 changes: 17 additions & 1 deletion lib/window.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion prefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Binary file modified schemas/gschemas.compiled
Binary file not shown.
11 changes: 11 additions & 0 deletions schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@
<summary>Custom Layouts JSON</summary>
<description>JSON string defining custom window layouts.</description>
</key>
<key name="shortcut-toggle-maximize" type="as">
<default><![CDATA[['<Alt>plus']]]></default>
<summary>Toggle Maximize Override</summary>
<description>Persistent maximize for active window.</description>
</key>

<key name="shortcut-toggle-fullscreen" type="as">
<default><![CDATA[['F11']]]></default>
<summary>Toggle Fullscreen Override</summary>
<description>Persistent fullscreen for active window.</description>
</key>

<key name="shadowed-keybindings" type="s">
<default><![CDATA['{}']]></default>
Expand Down
58 changes: 57 additions & 1 deletion tests/controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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();
});
});
});

27 changes: 27 additions & 0 deletions tests/window.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('WindowWrapper', () => {
unmaximize: vi.fn(),
maximized_horizontally: false,
maximized_vertically: false,
is_fullscreen: vi.fn(() => false),
};

mockController = {
Expand Down Expand Up @@ -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);
});
});
Loading