atagen

monolith

public
Star0
Watch0

Commit 944fc4782ac3

tarball

atagen <boss@atagen.co>2026-10-16 08:10 UTC

2 files changed+116-13
design: settle one-tree layout and add fullscreen, expand and lane extent demos

Hidden windows keep their tree slots instead of following visible neighbours, which keeps the record of every relative position exact across tag views.
Modified file: design/decisions.md+6 additions−4 deletions
@@ -16,16 +16,16 @@ Running log of settled decisions and open questions. Research backing lives in `
1616 - A "homeless" tag is immutable and always viewed. New windows land there unless a rule or launch context says otherwise. Hiding a window means moving it to an unviewed tag.
1717 - Windows are bound to an output. New homeless windows go to the output that was active when the spawn was requested.
1818 - Launch context. Windows are placed on the tag and output that were active when they were launched, not where focus is when they map. Child windows and windows of child processes inherit the parent's tag and output. A compositor `spawn` command is acceptable as the launch path.
19-- Layout state is per tag-on-output.
19+- Layout state is one tree per output, filtered by viewed tags (see Planner section).
2020 - Unplug moves windows to an output viewing their tag, otherwise the tag is disabled there. Replug restores windows that left because of the unplug.
2121 - Tag order is invocation order, remembered, with a reordering command.
2222 - Sticky flag exists. Scratchpads are floating sticky sets of windows. Addressing a scratchpad cycles on, next window, ..., off, with dimensions kept. A sticky scratchpad stays on the output it was last summoned to. A dismiss-scratchpad command takes an optional target, defaulting to the last active.
2323 - Tags are exposed through ext-workspace-v1, one group per output, one workspace per tag per output.
2424
2525 ### Layout
26-- Tree per tag-on-output, edited by the user, not derived from window count.
26+- One tree per output, edited by the user, not derived from window count. Each tag starts as its own row.
2727 - Child splits run on the axis opposite their parent. Flipping orientation transposes the whole tree. Orientation is presentation only.
28-- Viewing several tags stacks each tag's tree as a row (or column when flipped), in invocation order. Each tag keeps its own split ratios. No positional L/C/R merging.
28+- Viewing several tags shows the projection of the shared tree. Windows can be interspersed freely. No positional L/C/R merging.
2929 - An "expand" command at any tree level lets one window or subtree fill its slot. Autotiling on merge is a possible experiment.
3030 - Floating windows allowed. No pure floating mode.
3131 - A "deck" monocle (stacked windows with visible edges, selected window popped out) is a later container mode.
@@ -88,7 +88,9 @@ Running log of settled decisions and open questions. Research backing lives in `
8888 - One curve per motion group.
8989 - Floating windows travel above the tiled layer, exempt from the overlap rule.
9090 - Seeds start as a line on the old boundary between neighbours.
91-- Open. Floating choreography (slot opens last, direct glide, slot opens first), whether landing may resize both axes at once, lane extent, and the layout model for interspersed tags.
91+- Floating to tiled and fullscreen use a direct glide above the tiled layer. Expand is a tiled operation planned normally, siblings squeezed to zero-size lines.
92+- One layout tree per output, tags filter it. Hidden windows keep their slots, visible moves swap visible slots only, containers collapse by the normal one-child rule counting hidden children, and a hidden only child is demoted into the grandparent. This replaces per-tag layouts and the row-stacking merge rule, row stacking is just the default arrangement.
93+- Open. Lane extent.
9294 - Input hit-tests against final layout geometry, never animated geometry.
9395 - smithay is pinned by git rev. The repo uses jj.
9496
Modified file: design/planner.html+110 additions−9 deletions
@@ -107,6 +107,8 @@
107107 </ul>
108108 <p>Picking lanes is a small constrained colouring problem. For a pure reordering of a row, the lane count equals the length of the longest decreasing subsequence of the new order (Dilworth's theorem). Swapping two neighbours or rotating needs 2 lanes, and fully reversing four windows needs 4.</p>
109109 <div class="demo" id="d5"></div>
110+<p><strong>Lane extent.</strong> Below, T fills the top 70% and A, B, C share a short bottom row. Reversing the row needs 3 lanes. Spanning only the involved band, each lane is a third of the short row, so the windows get thin but T never moves. Spanning the whole output, the lanes are thicker, but T is in the way and has to squeeze into a lane too.</p>
111+<div class="demo" id="d12"></div>
110112
111113 <h2>7. Four phases, the grid</h2>
112114 <p>Some transitions reverse windows along both axes at once. In <code>(A over D) | (C over B)</code> to <code>(A over B over C) | D</code>, D has to pass B and C horizontally while B and C swap vertical order. Lanes along either axis contradict the other reversal. The grid handles it.</p>
@@ -140,14 +142,20 @@
140142 <p class="note">Not shown. Outputs at different scales draw the same window at different sizes, since the planner works in logical coordinates and each output scales its part. If two outputs aren't adjacent in the global space, the window travels through empty space.</p>
141143 <div class="demo" id="d7"></div>
142144
143-<h2>12. Floating to tiled</h2>
145+<h2>12. Floating, fullscreen and expand</h2>
144146 <p>A floating window joining the tiled layout travels above the tiled layer and is exempt from the overlap rule while it does. The mode button cycles three choreographies. Lifting out plays each one in reverse.</p>
145147 <ul>
146- <li><strong>Slot opens last</strong> (default). F travels one axis per phase until it hovers over where its slot will be. Only then does the gap open, and F lands into it as it widens, in the same final phase.</li>
147- <li><strong>Direct glide.</strong> F lerps straight into the slot while the gap opens, in one phase.</li>
148- <li><strong>Slot opens first.</strong> The gap opens while F is still travelling, then F lands. This was draft 2 behaviour.</li>
148+ <li><strong>Direct glide</strong> (default). F lerps straight into the slot while the gap opens, in one phase.</li>
149+ <li><strong>Slot opens last.</strong> F travels one axis per phase until it hovers over its slot, then the gap opens and F lands as it widens.</li>
150+ <li><strong>Slot opens first.</strong> The gap opens while F is still travelling, then F lands.</li>
149151 </ul>
150152 <div class="demo" id="d10"></div>
153+<h3>Fullscreen</h3>
154+<p>Fullscreen uses the same direct glide. The window lifts above the tiled layer and lerps to cover the output in one phase. Its tiled slot stays where it is underneath, so leaving fullscreen glides back into it and nothing else moves either way.</p>
155+<div class="demo" id="d13"></div>
156+<h3>Expand</h3>
157+<p>Expand is a tiled operation, not a glide. The expanded window or subtree grows to fill its slot while its siblings are squeezed to zero-size lines against the slot's edges, so it follows the normal planner rules, orthogonal included. Expanding a window inside its column is one vertical phase. Expanding it to the whole output changes both axes, so orthogonal style takes two.</p>
158+<div class="demo" id="d14"></div>
151159
152160 <h2>13. Fallbacks</h2>
153161 <p>The grid construction means no plan should ever need a fallback, and the stress test never reached one. Two remain wired in as safety nets for malformed input. <strong>Pinch</strong> shrinks a window to a point, jumps invisibly and regrows it. <strong>Lerp</strong> interpolates straight to the target and accepts overlap. Which one is the net is a matter of trying both.</p>
@@ -209,6 +217,16 @@
209217 <p>The last option is dwm's model (one client list, tags as a filter) with a tree in place of the list. The tree is the record of every relative position, permanently, because hidden windows never leave it. Nothing has to be restored. And for the planner, <strong>viewing or hiding any tag is always a single phase</strong>, because hidden windows are zero-weight seeds sitting at their own spot. Switching views never rearranges anything.</p>
210218 <p>Its cost is action at a distance. Intersperse A into tag 2's row below, then view tag 1 alone. A now sits under B and C, because in the tree it lives in the lower row. You changed tag 1's single view without ever viewing it alone.</p>
211219 <div class="demo" id="d8"></div>
220+<p><strong>Hidden windows keep their slot.</strong> They are not coupled to visible neighbours. My earlier phrasing was wrong, and your version is the rule.</p>
221+<ul>
222+ <li>The tree never moves a hidden leaf. Moves in a filtered view swap the slots of visible windows only. Swap A and B below with tag 2 hidden, then show it again, and D is still between them.</li>
223+ <li>Containers die by the normal rule, when one child is left, counting hidden children. A hidden only child is demoted into the grandparent at the container's position. Move C next to B below with tag 2 hidden. Row 2 is left holding only E, so it collapses and E becomes a full-width row of its own.</li>
224+ <li>A container holding only hidden children survives as long as it has two or more, invisible until they return.</li>
225+ <li>New windows insert next to the focused visible window.</li>
226+ <li>Hidden windows keep their share of a container's size when visible siblings are resized.</li>
227+</ul>
228+<p>Tick "show hidden slots" to see where hidden windows will reappear.</p>
229+<div class="demo" id="d11"></div>
212230
213231 <h2>17. Decisions</h2>
214232 <h3>Settled</h3>
@@ -217,13 +235,14 @@
217235 <li>Window moves and block moves both exist as commands. Tag reordering moves rigid blocks.</li>
218236 <li>One curve per motion group, bounded by phase time rather than amount of motion.</li>
219237 <li>Seeds start as a line on the old boundary.</li>
238+ <li>Floating to tiled and fullscreen use a direct glide. Expand is tiled and follows the planner.</li>
239+ <li>One tree per output, tags filter it. Hidden windows keep their slots.</li>
220240 <li>The planner must never need a fallback. Pinch and lerp stay as safety nets to try.</li>
221241 </ul>
222242 <h3>Open</h3>
223-<div class="q"><strong>Floating choreography.</strong> Slot opens last, direct glide, or slot opens first (section 12).</div>
224-<div class="q"><strong>Landing in orthogonal style.</strong> The final "falls into place" step resizes both width and height at once. Is that an accepted exception, or must it also split into two phases?</div>
243+
225244 <div class="q"><strong>Lane extent.</strong> Strips span only the band of the involved windows. Spanning the whole output gives thicker lanes for small windows, but disturbs more of the screen.</div>
226-<div class="q"><strong>The layout model (section 16).</strong> One tree per output with tags as a filter, or something else. You raised the case of windows being closed and moved while others are hidden. In the one-tree model, hidden windows keep their slot, closing removes only that leaf, and moves are made relative to visible neighbours, so hidden siblings stay attached to whichever visible neighbour they were next to. That rule is what needs discussing.</div>
245+<div class="q"><strong>Hidden-slot rules (section 16).</strong> Check the rules list against your intent, especially demotion and insertion next to the focused window.</div>
227246 </main>
228247
229248 <script id="planner">
@@ -231,6 +250,7 @@ const G = 10;
231250 const EPS = 0.5;
232251 let PHASE_MS = 450;
233252 let STYLE = 'orthogonal';
253+let LANE_REGION = null;
234254
235255 const R = (x, y, w, h) => ({ x, y, w, h });
236256 const clone = L => { const o = {}; for (const k in L) o[k] = { ...L[k] }; return o; };
@@ -415,6 +435,7 @@ function lanes(from, to, list, movers, ax) {
415435 if (n < 2) continue;
416436 let lo = Infinity, hi = -Infinity;
417437 for (const id of set) for (const r of [from[id], to[id]]) { lo = Math.min(lo, r[ax]); hi = Math.max(hi, r[ax] + r[size]); }
438+ if (LANE_REGION) { lo = LANE_REGION[ax]; hi = LANE_REGION[ax] + LANE_REGION[size]; }
418439 const diff = [], lt = [];
419440 for (let i = 0; i < n; i++) { diff.push(new Array(n).fill(false)); lt.push(new Array(n).fill(false)); }
420441 for (let i = 0; i < n; i++)
@@ -525,7 +546,7 @@ const edges = r => [r.x, r.x + r.w, r.y, r.y + r.h];
525546 </script>
526547
527548 <script>
528-const PAL = { A: '#e0774a', B: '#4aa3e0', C: '#7bc96f', D: '#c77be0', E: '#e0c24a', F: '#4ae0c2' };
549+const PAL = { T: '#9aa3b5', A: '#e0774a', B: '#4aa3e0', C: '#7bc96f', D: '#c77be0', E: '#e0c24a', F: '#4ae0c2' };
529550 const demos = [];
530551
531552 class Demo {
@@ -658,6 +679,7 @@ class Demo {
658679 }
659680 }
660681 for (const o2 of overlaps) { c.fillStyle = 'rgba(255,77,94,0.55)'; c.fillRect(o2.x, o2.y, o2.w, o2.h); c.strokeStyle = '#ff4d5e'; c.lineWidth = 2; c.strokeRect(o2.x, o2.y, o2.w, o2.h); }
682+ if (this.overlay) for (const r of this.overlay()) { c.setLineDash([6, 4]); c.strokeStyle = '#8a909c'; c.lineWidth = 1.5; c.strokeRect(r.x, r.y, r.w, r.h); c.setLineDash([]); c.fillStyle = '#8a909c'; c.font = '13px system-ui'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(r.id + ' (hidden)', r.x + r.w / 2, r.y + r.h / 2); }
661683 c.restore();
662684 }
663685 }
@@ -895,7 +917,7 @@ for (const [id, names] of [
895917 { // d10 floating to tiled
896918 const W = 640, H = 300, d = new Demo('d10', W, H, { floating: ['F'] }), I = inner(W, H);
897919 const FL = R(220, 40, 220, 130);
898- const modes = ['slot opens last', 'direct glide', 'slot opens first'];
920+ const modes = ['direct glide', 'slot opens last', 'slot opens first'];
899921 let tiled = false, mode = 0;
900922 const tiledL = () => splitX(I, ['A', 'B', 'F', 'C']);
901923 const floatL = () => ({ ...splitX(I, ['A', 'B', 'C']), F: FL });
@@ -949,6 +971,85 @@ for (const [id, names] of [
949971 const mb = d.button('mode: ' + modes[0], () => { mode = (mode + 1) % modes.length; mb.textContent = 'mode: ' + modes[mode]; });
950972 }
951973
974+{ // d11 one tree, hidden slots
975+ const W = 640, H = 320, d = new Demo('d11', W, H), I = inner(W, H);
976+ const tagOf = { A: 1, B: 1, C: 1, D: 2, E: 2 };
977+ const view = { 1: true, 2: true };
978+ let tree, ghosts = false;
979+ const reset = () => { tree = { dir: 'col', kids: [{ dir: 'row', kids: ['A', 'D', 'B'] }, { dir: 'row', kids: ['C', 'E'] }] }; view[2] = true; };
980+ reset();
981+ const L = () => { const o = {}; layoutTree(tree, I, id => view[tagOf[id]], o); return o; };
982+ const parentOf = (node, id, par = null) => {
983+ if (typeof node === 'string') return node === id ? par : null;
984+ for (const k of node.kids) { const r = parentOf(k, id, node); if (r) return r; }
985+ return null;
986+ };
987+ const collapse = node => {
988+ if (typeof node === 'string') return node;
989+ node.kids = node.kids.map(collapse).filter(k => typeof k === 'string' || k.kids.length);
990+ return node.kids.length === 1 ? node.kids[0] : node;
991+ };
992+ const swap = (a, b) => { const pa = parentOf(tree, a), pb = parentOf(tree, b); const ia = pa.kids.indexOf(a), ib = pb.kids.indexOf(b); pa.kids[ia] = b; pb.kids[ib] = a; };
993+ const moveAfter = (id, target) => {
994+ const p = parentOf(tree, id); p.kids.splice(p.kids.indexOf(id), 1);
995+ tree = collapse(tree);
996+ const pt = parentOf(tree, target); pt.kids.splice(pt.kids.indexOf(target) + 1, 0, id);
997+ };
998+ d.overlay = () => {
999+ if (!ghosts) return [];
1000+ const full = {}; layoutTree(tree, I, () => true, full);
1001+ return Object.keys(full).filter(id => !view[tagOf[id]]).map(id => ({ ...full[id], id }));
1002+ };
1003+ d.set(L());
1004+ d.button('Hide / show tag 2 (D, E)', () => { view[2] = !view[2]; d.go(L()); });
1005+ d.button('Swap A and B', () => { swap('A', 'B'); d.go(L()); });
1006+ d.button('Move C next to B', () => { if (parentOf(tree, 'C') !== parentOf(tree, 'B')) { moveAfter('C', 'B'); d.go(L()); } });
1007+ d.button('Reset', () => { reset(); d.go(L()); });
1008+ d.check('show hidden slots', false, v => ghosts = v);
1009+}
1010+
1011+{ // d12 lane extent
1012+ const W = 640, H = 320, d = new Demo('d12', W, H), I = inner(W, H);
1013+ let order = ['A', 'B', 'C'], whole = false;
1014+ const L = () => { const r = splitY(I, ['top', 'bot'], [7, 3]); return { T: r.top, ...splitX(r.bot, order) }; };
1015+ d.set(L());
1016+ d.button('Reverse bottom row', () => {
1017+ order = [...order].reverse();
1018+ const tgt = L();
1019+ d.go(tgt, { planFn: from => { LANE_REGION = whole ? I : null; try { return plan(from, tgt); } finally { LANE_REGION = null; } } });
1020+ });
1021+ const mb = d.button('lanes span: involved band', () => { whole = !whole; mb.textContent = 'lanes span: ' + (whole ? 'whole output' : 'involved band'); });
1022+}
1023+
1024+{ // d13 fullscreen
1025+ const W = 640, H = 300, d = new Demo('d13', W, H, { floating: ['B'] }), I = inner(W, H);
1026+ let full = false;
1027+ const L = () => { const o = splitX(I, ['A', 'B', 'C']); if (full) o.B = R(0, 0, W, H); return o; };
1028+ d.set(L());
1029+ d.button('Fullscreen B', () => {
1030+ full = !full;
1031+ const tgt = L();
1032+ d.go(tgt, { planFn: from => ({ kind: full ? 'glide to fullscreen' : 'glide back to slot', keys: [from, tgt], durs: [PHASE_MS] }) });
1033+ });
1034+}
1035+
1036+{ // d14 expand
1037+ const W = 640, H = 300, d = new Demo('d14', W, H), I = inner(W, H);
1038+ let col = false, root = false;
1039+ const L = () => {
1040+ const cols = splitX(I, ['A', 'R']);
1041+ const area = root ? I : cols.R;
1042+ const o = root ? { A: R(I.x, I.y, 0, I.h) } : { A: cols.A };
1043+ if (col) Object.assign(o, { B: R(area.x, area.y, area.w, 0), C: area, D: R(area.x, area.y + area.h, area.w, 0) });
1044+ else Object.assign(o, splitY(area, ['B', 'C', 'D']));
1045+ return o;
1046+ };
1047+ d.set(L());
1048+ d.button('Expand C in its column', () => { col = !col; d.go(L()); });
1049+ d.button('Expand the column to the output', () => { root = !root; d.go(L()); });
1050+ d.button('Expand C all the way', () => { const on = !(col && root); col = on; root = on; d.go(L()); });
1051+}
1052+
9521053 document.getElementById('phaseMs').oninput = e => { PHASE_MS = +e.target.value; document.getElementById('phaseMsOut').textContent = PHASE_MS + ' ms'; };
9531054 document.getElementById('style').onchange = e => { STYLE = e.target.value; };
9541055 const loop = now => { for (const d of demos) d.frame(now); requestAnimationFrame(loop); };