Skip to content

Commit 3e6f9ea

Browse files
Merge pull request #6 from KonstantinMerkel/bugfix/phantom_retile
Bugfix/phantom retile
2 parents 20b0c7c + 0af2c4c commit 3e6f9ea

10 files changed

Lines changed: 204 additions & 33 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,8 @@ node_modules/
22
.DS_Store
33
*.log
44
extension.zip
5+
CLAUDE.md
6+
CODEX.md
7+
CURSOR.md
58
GEMINI.md
69
coverage/

AGENTS.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Workflow Tiling AI Development Guidelines
2+
3+
## Role & Mission
4+
You are an AI developer assisting with the Workflow Tiling GNOME Shell extension.
5+
6+
## General Mandates
7+
8+
### 1. Testing Mandate
9+
**ALWAYS** run unit tests before proposing a change.
10+
**ALWAYS** write unit tests for new features. A task is not considered "ready for testing" until unit tests are written and pass.
11+
```bash
12+
npm test
13+
```
14+
15+
### 2. Commentary & Documentation Rules
16+
Documentation focuses on the current state and behavior of the system.
17+
Avoid numbered lists for documentation or commentary.
18+
Exclude explanations of why alternative approaches were rejected.
19+
Do not use temporal language such as "new", "now", or "replaced".
20+
Refrain from "diary comments" that describe the history or process of changes.
21+
22+
**CRITICAL DOCS MANDATE**: Always keep documentation files (`architecture.md`, `layouts.md`, `README.md`, etc.) up-to-date when altering code functionality. If you change a class name, execution flow, or API, immediately update the relevant docs.
23+
24+
### 3. Logging Suggestion
25+
It is highly suggested to use debuggable logging (`Logger` in `lib/logger.js`). When debugging complex flows, include verbose logs for state sequences to aid troubleshooting.
26+
27+
### 4. Settings UI Design
28+
Follow a **"minimal clutter, expand only after needed"** design philosophy for `prefs.js`.
29+
Examples from the current codebase:
30+
- "Inner Gaps" and "Outer Gaps" spinrows only appear when "Enable Gaps" is toggled on.
31+
- Custom shortcuts rows only appear when "Mode" is set to "Custom".
32+
- The Advanced JSON Editor page only mounts when explicitly toggled.
33+
Always use `Adw` (libadwaita) components. Bind visibility state dynamically to reduce visual noise for the average user.
34+
35+
### 5. Gnome API Guidelines
36+
Always use the newest solutions and APIs for GNOME Shell.
37+
38+
### 6. Event-Driven Architecture
39+
Avoid arbitrary timeouts. Rely on GNOME Shell signals (`size-changed`, `window-created`, etc.) or frame-synced deferrals (`GLib.idle_add`, `Meta.LaterType.BEFORE_REDRAW`) instead of `GLib.timeout_add`.
40+
41+
## Cross References
42+
- **Architecture**: See `architecture.md` for discrete responsibilities and execution flow. **Note**: We rely on *insertion-order based slots* (historical tracking) rather than purely arbitrary visual spatial arrangements. `StateTracker` matches windows to slots via their internal IDs (`get_id()`).
43+
- **Layout JSONs**: See `layouts.md` for guidelines on how the Escalator layouts and transitions are formatted in JSON.
44+
- **Vision**: See `vision.md` for core philosophy and scaling design.

Makefile

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
UUID = workflow-tiling@konstantin.dev
2+
EXT_DIR = ~/.local/share/gnome-shell/extensions/$(UUID)
3+
FILES = extension.js metadata.json lib/ schemas/ prefs.js
4+
5+
.PHONY: all sync install test pack enable disable clean
6+
7+
all: sync
8+
9+
sync:
10+
mkdir -p $(EXT_DIR)
11+
cp -r $(FILES) $(EXT_DIR)/
12+
13+
install: sync enable
14+
15+
test:
16+
npm test
17+
18+
pack:
19+
glib-compile-schemas schemas/ 2>/dev/null || true
20+
zip -r extension.zip $(FILES)
21+
22+
enable:
23+
gnome-extensions enable $(UUID)
24+
25+
disable:
26+
gnome-extensions disable $(UUID)
27+
28+
clean:
29+
rm -f extension.zip

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ A deterministic customizable auto-tiler extension for GNOME Shell (GNOME 50+).
88
- **Workspace Isolation**: Tiling states are unique to each GNOME workspace.
99
- **Stability Focused**: Uses WindowWrapper object modeling and compositor-native synchronization (Meta.LaterType) to prevent race conditions and Shell crashes.
1010

11+
## Recommended Extensions
12+
Workflow Tiling does not natively draw an active window border. For visual indication of the focused window, it is highly recommended to use an extension like **P7 Border**
13+
1114
## Custom Layouts
1215
Layout transitions are configured via JSON string, supporting custom window counts and sizes.
1316

14-
Optional `id` integer properties (1-indexed) in the JSON structure define how windows transition between states:
17+
Optional `id` (1-indexed) integer properties in the JSON structure define how windows transition between states. It is required for all elements:
1518

1619
```json
1720
{
@@ -36,12 +39,11 @@ Optional `id` integer properties (1-indexed) in the JSON structure define how wi
3639
Unit tests are written using **Vitest**.
3740
```bash
3841
npm install
39-
npm test
42+
make test
4043
```
4144

4245
### Installation
43-
To link the extension to your local GNOME Shell directory:
46+
To deploy the extension to your local GNOME Shell directory:
4447
```bash
45-
ln -s $(pwd) ~/.local/share/gnome-shell/extensions/workflow-tiling@konstantin.dev
46-
gnome-extensions enable workflow-tiling@konstantin.dev
48+
make install
4749
```

architecture.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ Encapsulates `Meta.Window`.
2727
Applies calculated geometry.
2828
Binds single-shot `size-changed` signals to detect external resizing.
2929

30-
## WorkspaceGrid (`lib/workspace.js`)
31-
Tracks windows per workspace and monitor.
30+
## WorkspaceManager & WorkspaceLayout (`lib/workspace.js`)
31+
`WorkspaceManager` tracks multiple layouts across GNOME workspaces.
32+
`WorkspaceLayout` tracks windows per workspace and monitor.
3233
Calculates window slots based on insertion order.
3334
Provides window displacement and swapping logic.
3435

@@ -37,14 +38,27 @@ Maintains stable ordered list of windows.
3738
Handles track and untrack operations.
3839
Swaps window positions.
3940

41+
## DragManager (`lib/drag.js`)
42+
Tracks window drag-and-drop operations.
43+
Renders visual swap indicators.
44+
Triggers geometric swapping based on pointer intersections.
45+
46+
## SettingsManager (`lib/settings.js`)
47+
Loads configuration preferences.
48+
Parses layout JSON into valid escalator transitions.
49+
50+
## Logger (`lib/logger.js`)
51+
Provides debug and trace logging.
52+
Configurable output verbosity.
53+
4054
## Escalator (`lib/layout.js`)
4155
Generates tile geometries.
4256
Provides geometric estates based on current window count.
4357

4458
## Execution Flow
4559
Signal triggers event.
4660
Controller receives event.
47-
Controller updates WorkspaceGrid state.
61+
Controller updates WorkspaceLayout state.
4862
Controller schedules deferred retile.
4963
Retile queries Escalator for layouts.
5064
Retile invokes WindowWrapper to apply geometries.

layouts.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Layout Customization (JSON)
2+
3+
Workflow Tiling uses an "Escalator" system (`lib/layout.js`) to generate window estates dynamically.
4+
Custom layout transitions are defined via a JSON object.
5+
6+
## Structure
7+
The JSON keys represent the total number of windows on a monitor. The values are arrays of window geometry definitions for that count.
8+
9+
```json
10+
{
11+
"1": [
12+
{"x": 0, "y": 0, "w": 100, "h": 100, "id": 1}
13+
],
14+
"2": [
15+
{"x": 0, "y": 0, "w": 50, "h": 100, "id": 1},
16+
{"x": 50, "y": 0, "w": 50, "h": 100, "id": 2}
17+
]
18+
}
19+
```
20+
21+
## Properties
22+
- `x`: X percentage offset (0 to 100)
23+
- `y`: Y percentage offset (0 to 100)
24+
- `w`: Width percentage (0 to 100)
25+
- `h`: Height percentage (0 to 100)
26+
- `id` (Required): The logical 1-indexed ID this window occupies. The IDs map to the insertion order of windows. E.g., `id: 1` is the oldest window, `id: 2` is the second oldest. It MUST be unique and cover 1 to the window count.
27+
28+
## Fallback
29+
If the window count exceeds the highest key defined in the JSON, extra windows will fall back to floating mode (unmanaged by the auto-tiler).

lib/controller.js

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,21 +71,34 @@ export class TilingController {
7171
* Registers a window and initiates the One-Shot Signal sequence.
7272
*/
7373
tilingRequest(window) {
74-
if (window.unmanaged) return;
74+
if (window.unmanaged) {
75+
Logger.debug(`tilingRequest: Rejected unmanaged window`);
76+
return;
77+
}
78+
79+
Logger.debug(`tilingRequest: Initiating for window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}")`);
7580

7681
let wrapper = this._ensureWrapper(window);
77-
if (!wrapper) return;
82+
if (!wrapper) {
83+
Logger.debug(`tilingRequest: Aborted. Wrapper creation rejected window.`);
84+
return;
85+
}
7886

7987
wrapper.bindSignals();
8088
wrapper.bindSizeChanged();
8189

8290
try {
8391
const context = this._resolveTilingContext(window, wrapper);
84-
if (!context) return;
92+
if (!context) {
93+
Logger.debug(`tilingRequest: Aborted. No context resolved.`);
94+
return;
95+
}
8596

8697
const { workspace, monitorIndex, monitorId, isRestoring, preferredSlot } = context;
98+
Logger.debug(`tilingRequest: Context resolved -> Workspace: ${workspace.index ? workspace.index() : 'unknown'}, MonitorIndex: ${monitorIndex}, MonitorID: ${monitorId}, Restoring: ${isRestoring}`);
8799

88100
if (this.monitorManager.checkEvacuation(window, wrapper, monitorId, workspace)) {
101+
Logger.debug(`tilingRequest: Window evacuated. Updating cache and returning.`);
89102
this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId);
90103
return;
91104
}
@@ -94,6 +107,8 @@ export class TilingController {
94107
const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined);
95108
this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId);
96109
this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot);
110+
111+
Logger.debug(`tilingRequest: State applied. Scheduling retile.`);
97112
this._scheduleRetile(workspace, monitorId, monitorIndex);
98113
} catch (e) {
99114
Logger.warn(`Tiling attempt failed for "${wrapper ? wrapper.title : 'unknown'}"`, e);
@@ -198,7 +213,14 @@ export class TilingController {
198213

199214
_scheduleRetile(workspace, monitorId, monitorIndex) {
200215
if (this._batchMode) return;
201-
if (this.dragManager && this.dragManager._activeDrag) return;
216+
if (this.dragManager && this.dragManager._activeDrag) {
217+
this.dragManager._deferredRetiles = this.dragManager._deferredRetiles || [];
218+
const exists = this.dragManager._deferredRetiles.some(r => r.workspace === workspace && r.monitorId === monitorId);
219+
if (!exists) {
220+
this.dragManager._deferredRetiles.push({workspace, monitorId, monitorIndex});
221+
}
222+
return;
223+
}
202224

203225
const key = `${workspace}-${monitorId}`;
204226

@@ -251,7 +273,7 @@ export class TilingController {
251273
* This is useful during initialization or when the monitor layout drastically changes.
252274
*/
253275
hydrate(workspace = null) {
254-
Logger.info('Performing single-pass hydration sweep');
276+
Logger.info(`Performing single-pass hydration sweep for workspace ${workspace && workspace.index ? workspace.index() : 'ALL'}`);
255277

256278
let windows = [];
257279
if (workspace) {
@@ -260,22 +282,36 @@ export class TilingController {
260282
windows = global.display.list_all_windows();
261283
}
262284

263-
// Filter and sort active windows geometrically to preserve stable slots
264-
const activeWindows = windows.filter(w => w && !w.unmanaged);
265-
activeWindows.sort((a, b) => {
266-
const rectA = a.get_frame_rect ? a.get_frame_rect() : { x: 0, y: 0 };
267-
const rectB = b.get_frame_rect ? b.get_frame_rect() : { x: 0, y: 0 };
268-
if (Math.abs(rectA.x - rectB.x) > 5) {
269-
return rectA.x - rectB.x;
285+
Logger.debug(`Hydrate: Found ${windows.length} total windows from shell.`);
286+
287+
// Filter and sort active windows by ID to preserve historical insertion order slots
288+
const activeWindows = windows.filter(w => {
289+
if (!w) {
290+
Logger.debug(`Hydrate: Skipping null window object`);
291+
return false;
292+
}
293+
if (w.unmanaged) {
294+
Logger.debug(`Hydrate: Skipping unmanaged window ID ${w.get_id ? w.get_id() : 'unknown'}`);
295+
return false;
270296
}
271-
return rectA.y - rectB.y;
297+
return true;
272298
});
299+
300+
Logger.debug(`Hydrate: Filtered down to ${activeWindows.length} managed active windows.`);
273301

302+
activeWindows.sort((a, b) => {
303+
const idA = a.get_id ? a.get_id() : 0;
304+
const idB = b.get_id ? b.get_id() : 0;
305+
return idA - idB;
306+
});
274307
const restoringExtra = [...this._restoringWindows.keys()].filter(w => !windows.includes(w));
308+
Logger.debug(`Hydrate: Adding ${restoringExtra.length} restoring windows not present in list.`);
275309
const allWindows = [...activeWindows, ...restoringExtra];
310+
Logger.debug(`Hydrate: Final list sorted by ID. Sequence: ${allWindows.map(w => w.get_id ? w.get_id() : 'unknown').join(', ')}`);
276311

277312
allWindows.forEach(window => {
278313
if (window) {
314+
Logger.debug(`Hydrate: Issuing tiling request for window ID ${window.get_id ? window.get_id() : 'unknown'}`);
279315
this.tilingRequest(window);
280316
}
281317
});

lib/drag.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export class DragManager {
3030
this._handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator);
3131
});
3232

33-
this._activeDrag = { window, originalSlot, indicator, signalId, lastHoveredSlot: -1 };
33+
const origRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 };
34+
this._activeDrag = { window, originalSlot, indicator, signalId, lastHoveredSlot: -1, origRect };
3435
}
3536

3637
/**
@@ -136,8 +137,14 @@ export class DragManager {
136137
this._activeDrag.indicator.destroy();
137138
}
138139

140+
const origRect = this._activeDrag.origRect;
139141
this._activeDrag = null;
140142

143+
if (this._deferredRetiles && this._deferredRetiles.length > 0) {
144+
this._deferredRetiles.forEach(r => this.controller._scheduleRetile(r.workspace, r.monitorId, r.monitorIndex));
145+
this._deferredRetiles = [];
146+
}
147+
141148
const wrapper = this.controller._windowWrappers.get(window);
142149
if (!wrapper || !wrapper.workspace || !wrapper.monitorId) return;
143150

@@ -150,7 +157,13 @@ export class DragManager {
150157
const layout = this.controller.workspaceManager.getLayout(workspace);
151158

152159
const [x, y] = global.get_pointer();
153-
layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps);
154-
this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex);
160+
const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps);
161+
162+
const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 };
163+
const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height;
164+
165+
if (swapped || rectChanged) {
166+
this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex);
167+
}
155168
}
156169
}

lib/signals.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,13 @@ export class SignalListener {
188188
}
189189

190190
_addWindow(window) {
191-
if (window && this._shouldTile(window)) {
192-
this.controller.tilingRequest(window);
193-
}
191+
if (!window) return;
192+
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
193+
if (this._shouldTile(window)) {
194+
this.controller.tilingRequest(window);
195+
}
196+
return GLib.SOURCE_REMOVE;
197+
});
194198
}
195199

196200
rebindKeybindings() {

vision.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ Layout transitions are predictable, minimal, and fully customizable. The system
99
### Deterministic Layout Flow
1010

1111
- **Single Window**: Full screen layout covering all available space.
12-
- **Two Windows**: Vertical split dividing space equally.
13-
- **Three Windows**: Customizable split using equal proportions or master/stack splits.
12+
- **Multiple Windows**: Fully customizable geometric distributions via JSON configuration.
1413
- **Fallback**: Additional windows exceeding configured count run in floating mode.
1514

1615
## Implementation Standards
@@ -26,7 +25,5 @@ Layout transitions are predictable, minimal, and fully customizable. The system
2625
- **Workspace Isolation**: Tiling is scoped per GNOME workspace.
2726

2827
## Future Roadmap
29-
1. Custom layout creation via configuration.
30-
2. Keyboard and mouse shortcuts for manual re-ordering.
31-
3. Active window border, for now just use an extension like focus on active window/ P7 Border.
32-
4. Implement type safety (JSDoc + `@girs` types and `jsconfig.json`).
28+
1. Implement type safety
29+
2. Make some GNOME native shortcuts as remove active window editable in extension.

0 commit comments

Comments
 (0)