Skip to content

Commit 3f41051

Browse files
committed
Redesigned the Settings dialog and fixed issues with how the FirstOnly setting was being used.
1 parent d04a428 commit 3f41051

File tree

4 files changed

+498
-74
lines changed

4 files changed

+498
-74
lines changed

auto-move-windows@JeffHanna/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ Example title-based rule:
2626
- `width`, `height` (integer, optional): desired width/height in pixels.
2727
- `maximized` (boolean, optional): if `true`, the window will be maximized (fullscreen). This takes precedence over `x`, `y`, `width`, and `height`.
2828
- `maximizeVertically` (boolean, optional): if `true`, the window will be maximized vertically (full height) but will respect the `width` setting. The `height` value is ignored. Requires `width` to be specified.
29-
- `firstOnly` (boolean, optional): if `true`, the extension only acts on the first instance of the app; subsequent windows are ignored until that window closes.
29+
- `firstOnly` (boolean, optional):
30+
- If `true`: Only the first window instance is placed on the assigned workspace (initial placement only). Subsequent windows are ignored.
31+
- If `false`: All window instances are placed on the assigned workspace with **continuous enforcement**. The window will be automatically moved back if it's moved to a different workspace.
3032

3133
Example `app-rules` JSON:
3234

auto-move-windows@JeffHanna/files/auto-move-windows@JeffHanna/extension.js

Lines changed: 193 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
* Notes:
1717
* - WM_CLASS matching is performed case-insensitively by doing a substring match
1818
* (the rule's wmClass lowercased must appear anywhere inside the window's WM_CLASS).
19-
* - `firstOnly` prevents the rule from being applied to more than one window instance. The runtime tracks
20-
* the first-applied MetaWindow reference and frees the lock when that window is removed.
19+
* - `firstOnly: true` - Only the first window instance is placed on the assigned workspace (initial placement only).
20+
* - `firstOnly: false` - All window instances are placed on the assigned workspace with continuous enforcement.
21+
* The window will be automatically moved back if it's moved to a different workspace.
2122
* - `maximized` takes precedence over all geometry settings - if true, the window is fully maximized.
2223
* - `maximizeVertically` maximizes height only; requires `width` to be set, and `height` is ignored.
2324
*/
@@ -27,7 +28,6 @@ const Settings = imports.ui.settings;
2728
const SignalManager = imports.misc.signalManager;
2829
const Meta = imports.gi.Meta;
2930
const GLib = imports.gi.GLib;
30-
const Gio = imports.gi.Gio;
3131

3232
let extensionInstance = null;
3333

@@ -48,6 +48,7 @@ class AutoMoveWindows {
4848
this._settings = null;
4949
this._signals = null;
5050
this._firstAppliedMap = new Map();
51+
this._managedWindows = new Map();
5152
}
5253

5354

@@ -63,14 +64,116 @@ class AutoMoveWindows {
6364
this._signals.connect(global.screen, 'window-added', this._onWindowAdded, this);
6465
this._signals.connect(global.screen, 'window-removed', this._onWindowRemoved, this);
6566

66-
// Clear the firstOnly map whenever settings change to allow rules to be re-evaluated
67+
// Capture this context for settings change handler
68+
const self = this;
69+
70+
// Clear tracking maps whenever settings change to allow rules to be re-evaluated
6771
this._settings.connect('changed::app-rules', () => {
68-
this._firstAppliedMap.clear();
72+
try {
73+
self._firstAppliedMap.clear();
74+
self._managedWindows.clear();
75+
// Re-process all existing windows with new rules
76+
self._processExistingWindows();
77+
} catch (e) {
78+
global.logError('[auto-move-windows] Error in settings change handler: ' + e);
79+
}
6980
});
7081

71-
// Optionally connect to existing windows to track removals (not strictly required)
72-
// and ensure map is clean if extension is enabled after windows exist.
73-
let windows = global.display.list_windows(0);
82+
// Process existing windows to enable continuous enforcement for already-open windows
83+
this._processExistingWindows();
84+
}
85+
86+
87+
/**
88+
* Process all existing windows to set up continuous workspace enforcement.
89+
*
90+
* This is called when the extension is enabled or when settings change, to ensure
91+
* that windows matching rules with firstOnly=false are tracked for continuous enforcement.
92+
*/
93+
_processExistingWindows() {
94+
try {
95+
if (!this._settings || !this._signals) {
96+
return;
97+
}
98+
99+
const windows = global.display.list_windows(0);
100+
const rules = (this._settings.getValue('app-rules') || []).map(r => {
101+
if (r && r.wmClass) {
102+
let copy = Object.assign({}, r);
103+
copy._wmClassLower = String(r.wmClass).trim().toLowerCase();
104+
return copy;
105+
}
106+
return r;
107+
});
108+
109+
for (let metaWindow of windows) {
110+
if (!metaWindow)
111+
continue;
112+
113+
const wmClass = metaWindow.get_wm_class && metaWindow.get_wm_class();
114+
if (!wmClass)
115+
continue;
116+
117+
const type = metaWindow.get_window_type();
118+
if (type === Meta.WindowType.DESKTOP || type === Meta.WindowType.DOCK || type === Meta.WindowType.SPLASHSCREEN)
119+
continue;
120+
121+
const wmLower = String(wmClass).toLowerCase();
122+
const titleLower = String(metaWindow.get_title && metaWindow.get_title() || '').toLowerCase();
123+
124+
// Find matching rule
125+
const rule = rules.find(r => {
126+
if (!r || !r._wmClassLower)
127+
return false;
128+
const field = (r.matchField || 'wmClass');
129+
switch (field) {
130+
case 'title':
131+
return titleLower.indexOf(r._wmClassLower) !== -1;
132+
case 'wmClass':
133+
default:
134+
return wmLower.indexOf(r._wmClassLower) !== -1;
135+
}
136+
});
137+
138+
if (rule) {
139+
// If firstOnly is true, register this window so subsequent instances are ignored
140+
if (rule.firstOnly) {
141+
const ruleKey = rule._wmClassLower;
142+
if (!this._firstAppliedMap.has(ruleKey)) {
143+
this._firstAppliedMap.set(ruleKey, metaWindow);
144+
}
145+
}
146+
}
147+
148+
if (rule && !rule.firstOnly && Number.isInteger(rule.workspace) && rule.workspace >= 0) {
149+
// Move window to correct workspace if it's not already there
150+
const currentWorkspace = metaWindow.get_workspace();
151+
const currentIndex = currentWorkspace ? currentWorkspace.index() : -1;
152+
153+
if (currentIndex !== rule.workspace) {
154+
if (metaWindow.change_workspace_by_index) {
155+
metaWindow.change_workspace_by_index(rule.workspace, false);
156+
} else if (metaWindow.change_workspace) {
157+
const ws = global.workspace_manager.get_workspace_by_index(rule.workspace);
158+
if (ws)
159+
metaWindow.change_workspace(ws);
160+
}
161+
}
162+
163+
// Enable continuous enforcement for this window
164+
this._managedWindows.set(metaWindow, {
165+
workspace: rule.workspace,
166+
wmClass: rule.wmClass
167+
});
168+
169+
// Connect to workspace-changed signal
170+
this._signals.connect(metaWindow, 'workspace-changed',
171+
this._onWindowWorkspaceChanged.bind(this, metaWindow));
172+
}
173+
}
174+
} catch (e) {
175+
global.logError('[auto-move-windows] Error processing existing windows: ' + e);
176+
}
74177
}
75178

76179

@@ -81,14 +184,21 @@ class AutoMoveWindows {
81184
* is being disabled or reloaded.
82185
*/
83186
disable() {
187+
// Clear maps first to prevent handlers from accessing them during cleanup
188+
if (this._firstAppliedMap) {
189+
this._firstAppliedMap.clear();
190+
}
191+
if (this._managedWindows) {
192+
this._managedWindows.clear();
193+
}
194+
84195
if (this._signals) {
85196
this._signals.disconnectAllSignals();
86197
this._signals = null;
87198
}
88199
if (this._settings) {
89200
this._settings = null;
90201
}
91-
this._firstAppliedMap.clear();
92202
}
93203

94204

@@ -126,7 +236,6 @@ class AutoMoveWindows {
126236
const wmLower = String(wmClass).toLowerCase();
127237
const titleLower = String(metaWindow.get_title && metaWindow.get_title() || '').toLowerCase();
128238

129-
try { global.log('[auto-move-windows] Window WM_CLASS: ' + String(wmClass) + ' (normalized: ' + wmLower + ')'); } catch (e) {}
130239
const rule = rules.find(r => {
131240
if (!r || !r._wmClassLower)
132241
return false;
@@ -148,12 +257,15 @@ class AutoMoveWindows {
148257
return;
149258
}
150259

260+
// Capture this context for use in timeout callback
261+
const self = this;
262+
151263
// Wait a short time so the window has been fully mapped and its frame exists
152264
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
153265
// Register window in firstOnly map after processing starts
154266
if (rule.firstOnly) {
155267
const ruleKey = rule._wmClassLower;
156-
this._firstAppliedMap.set(ruleKey, metaWindow);
268+
self._firstAppliedMap.set(ruleKey, metaWindow);
157269
}
158270

159271
if (Number.isInteger(rule.workspace) && rule.workspace >= 0) {
@@ -164,6 +276,18 @@ class AutoMoveWindows {
164276
if (ws)
165277
metaWindow.change_workspace(ws);
166278
}
279+
280+
// If firstOnly is false, enable continuous workspace enforcement
281+
if (!rule.firstOnly) {
282+
self._managedWindows.set(metaWindow, {
283+
workspace: rule.workspace,
284+
wmClass: rule.wmClass
285+
});
286+
287+
// Connect to workspace-changed signal for this window
288+
self._signals.connect(metaWindow, 'workspace-changed',
289+
self._onWindowWorkspaceChanged.bind(self, metaWindow));
290+
}
167291
}
168292

169293
if (rule.maximized === true) {
@@ -172,7 +296,7 @@ class AutoMoveWindows {
172296
} catch (e) {
173297
global.logError(e);
174298
}
175-
} else if (rule.maximizeVertically === true && Number.isFinite(rule.width)) {
299+
} else if (rule.maximizeVertically === true && Number.isFinite(rule.width) && rule.width > 0) {
176300
try {
177301
const x = Number.isFinite(rule.x) ? rule.x : 0;
178302
const width = Math.max(1, rule.width);
@@ -190,7 +314,7 @@ class AutoMoveWindows {
190314
global.logError(e);
191315
}
192316
} else {
193-
const hasGeom = Number.isFinite(rule.width) && Number.isFinite(rule.height);
317+
const hasGeom = Number.isFinite(rule.width) && rule.width > 0 && Number.isFinite(rule.height) && rule.height > 0;
194318
if (hasGeom) {
195319
const x = Number.isFinite(rule.x) ? rule.x : 0;
196320
const y = Number.isFinite(rule.y) ? rule.y : 0;
@@ -210,11 +334,55 @@ class AutoMoveWindows {
210334
}
211335

212336

337+
/**
338+
* Handler for the `workspace-changed` signal on managed windows.
339+
*
340+
* When a window with continuous enforcement changes workspace, this moves it back
341+
* to its assigned workspace.
342+
*
343+
* @param {Meta.Window} metaWindow - the MetaWindow that changed workspace
344+
*/
345+
_onWindowWorkspaceChanged(metaWindow) {
346+
try {
347+
if (!metaWindow)
348+
return;
349+
350+
// Check if map still exists (extension might be disabling)
351+
if (!this._managedWindows)
352+
return;
353+
354+
if (!this._managedWindows.has(metaWindow))
355+
return;
356+
357+
const windowInfo = this._managedWindows.get(metaWindow);
358+
const currentWorkspace = metaWindow.get_workspace();
359+
const targetWorkspace = global.workspace_manager.get_workspace_by_index(windowInfo.workspace);
360+
361+
// If window moved to wrong workspace, move it back
362+
if (currentWorkspace && targetWorkspace && currentWorkspace !== targetWorkspace) {
363+
try {
364+
global.log('[auto-move-windows] Enforcing workspace ' + windowInfo.workspace +
365+
' for ' + windowInfo.wmClass);
366+
if (metaWindow.change_workspace_by_index) {
367+
metaWindow.change_workspace_by_index(windowInfo.workspace, false);
368+
} else if (metaWindow.change_workspace) {
369+
metaWindow.change_workspace(targetWorkspace);
370+
}
371+
} catch (e) {
372+
global.logError('[auto-move-windows] Error enforcing workspace: ' + e);
373+
}
374+
}
375+
} catch (e) {
376+
global.logError('[auto-move-windows] Error in workspace-changed handler: ' + e);
377+
}
378+
}
379+
380+
213381
/**
214382
* Handler for the `window-removed` signal.
215383
*
216384
* Frees any `firstOnly` locks that referenced the removed window so future windows
217-
* matching the same rule may be handled.
385+
* matching the same rule may be handled. Also removes continuous workspace enforcement.
218386
*
219387
* @param {object} screen - the Screen object that emitted the signal
220388
* @param {Meta.Window} metaWindow - the removed MetaWindow
@@ -225,13 +393,23 @@ class AutoMoveWindows {
225393
if (!metaWindow)
226394
return;
227395

396+
// Check if maps still exist (extension might be disabling)
397+
if (!this._firstAppliedMap || !this._managedWindows)
398+
return;
399+
400+
// Remove from firstOnly tracking
228401
for (let [key, tracked] of this._firstAppliedMap) {
229402
if (tracked === metaWindow) {
230403
this._firstAppliedMap.delete(key);
231404
}
232405
}
406+
407+
// Remove from continuous enforcement tracking
408+
if (this._managedWindows.has(metaWindow)) {
409+
this._managedWindows.delete(metaWindow);
410+
}
233411
} catch (e) {
234-
global.logError(e);
412+
global.logError('[auto-move-windows] Error in window-removed handler: ' + e);
235413
}
236414
}
237415
}
@@ -246,7 +424,6 @@ function init(metadata) {
246424
function enable() {
247425
try {
248426
extensionInstance.enable();
249-
return { create_sample_settings_file };
250427
} catch (e) {
251428
global.logError(e);
252429
disable();
@@ -265,51 +442,3 @@ function disable() {
265442
extensionInstance = null;
266443
}
267444
}
268-
269-
270-
/**
271-
* Create a sample settings JSON file in the user's home directory.
272-
*
273-
* The file is written as ~/auto-move-windows-sample-settings.json and contains a
274-
* minimal JSON object with a single entry in the `app-rules` array. After creating the file,
275-
* it is automatically opened in the user's preferred text/code editor.
276-
*/
277-
function create_sample_settings_file() {
278-
const home = GLib.get_home_dir();
279-
const target = GLib.build_filenamev([home, 'auto-move-windows-sample-settings.json']);
280-
const boilerplate = {
281-
"app-rules": [
282-
{
283-
"wmClass": "Firefox",
284-
"matchField": "title",
285-
"workspace": 2,
286-
"x": 640,
287-
"y": 0,
288-
"width": 640,
289-
"maximizeVertically": true,
290-
"firstOnly": true
291-
}
292-
]
293-
};
294-
const jsonString = JSON.stringify(boilerplate, null, 2);
295-
GLib.file_set_contents(target, jsonString);
296-
try { Main.notify('auto-move-windows', 'Created ' + target); } catch (e) {}
297-
298-
// Open the file in the user's preferred text editor
299-
try {
300-
const file = Gio.File.new_for_path(target);
301-
// Use the default text editor instead of the default for JSON files (which is often a browser)
302-
const textEditor = Gio.AppInfo.get_default_for_type('text/plain', false);
303-
if (textEditor) {
304-
textEditor.launch([file], null);
305-
} else {
306-
// Fallback to URI launcher if no text editor is set
307-
const uri = file.get_uri();
308-
Gio.AppInfo.launch_default_for_uri(uri, null);
309-
}
310-
} catch (e) {
311-
global.logError('[auto-move-windows] Failed to open file: ' + e);
312-
}
313-
314-
return true;
315-
}

0 commit comments

Comments
 (0)