atagen

monolith

public
Star0
Watch0

Commit 21e8e8e5aa3d

tarball

atagen <boss@atagen.co>2026-10-20 11:57 UTC

4 files changed+129-33
layout: move an extended window's group as a block

Moving an extended window broke it out of the container it was extended in, scattering windows the user could not see. The group now moves whole through move_window, merge_window and move_container, and attach turns an expanded container across rather than dissolving it into a parent on its own axis, which would have shown everything it covered. MoveContainer also honours the move mode and the invert latch, so blocks merge into the next lane when windows would.
Modified file: crates/layout/src/lib.rs+2 additions−2 deletions
@@ -624,12 +624,12 @@ impl Layout {
624624 moved
625625 }
626626
627- pub fn move_container(&mut self, dir: Dir) -> bool {
627+ pub fn move_container(&mut self, dir: Dir, merge: bool) -> bool {
628628 let Some((w, o)) = self.focused_tiled() else {
629629 return false;
630630 };
631631 let mut tree = std::mem::take(&mut self.outputs[o].tree);
632- let moved = tree.move_container(w, dir, self.vis_on(o));
632+ let moved = tree.move_container(w, dir, merge, self.vis_on(o));
633633 self.outputs[o].tree = tree;
634634 moved
635635 }
Modified file: crates/layout/src/tests.rs+38 additions−6 deletions
@@ -9,7 +9,7 @@ enum Op {
99 Remove(u8),
1010 Focus(u8),
1111 Move(Dir),
12- Block(Dir),
12+ Block(Dir, bool),
1313 Merge(Dir),
1414 View(u8),
1515 ToggleView(u8),
@@ -39,7 +39,7 @@ fn op() -> impl Strategy<Value = Op> {
3939 1 => any::<u8>().prop_map(Op::Remove),
4040 3 => any::<u8>().prop_map(Op::Focus),
4141 4 => dir().prop_map(Op::Move),
42- 2 => dir().prop_map(Op::Block),
42+ 2 => (dir(), any::<bool>()).prop_map(|(d, m)| Op::Block(d, m)),
4343 3 => dir().prop_map(Op::Merge),
4444 1 => (0u8..4).prop_map(Op::View),
4545 2 => (0u8..4).prop_map(Op::ToggleView),
@@ -82,8 +82,8 @@ fn apply(l: &mut Layout, tags: &[TagId], op: &Op) {
8282 Op::Move(d) => {
8383 l.move_window(d);
8484 }
85- Op::Block(d) => {
86- l.move_container(d);
85+ Op::Block(d, m) => {
86+ l.move_container(d, m);
8787 }
8888 Op::Merge(d) => {
8989 l.merge_window(d);
@@ -200,7 +200,7 @@ proptest! {
200200 l.add_output("b", Rect::new(1000.0, 0.0, 800.0, 600.0));
201201 let tags = [l.homeless(), l.tag("1"), l.tag("2"), l.tag("3")];
202202 for op in &ops {
203- let moving = matches!(op, Op::Move(_) | Op::Merge(_) | Op::Block(_));
203+ let moving = matches!(op, Op::Move(_) | Op::Merge(_) | Op::Block(..));
204204 let shown_before = l.focused().filter(|&w| tiled_area(&l, w) > 1.0);
205205 apply(&mut l, &tags, op);
206206 l.check();
@@ -318,6 +318,38 @@ fn merging_steps_out_of_a_stack_before_joining_another() {
318318 assert!(rect(a).bottom() <= rect(b).y && rect(b).y == rect(c).y);
319319 }
320320
321+/// An extended window's group moves whole, even into a container running
322+/// its own way, where it would otherwise dissolve and show what it covers.
323+#[test]
324+fn extended_windows_move_their_group() {
325+ let (a, b, c) = (1, 2, 3);
326+ let all = |_| true;
327+ let mut t: Tree<u32> = Tree::default();
328+ t.insert_row(a);
329+ t.insert_after(b, a);
330+ t.insert_after(c, b);
331+ assert!(t.merge_window(b, Dir::Left, all));
332+ assert!(t.toggle_expand(b, 0, 1, all));
333+ let rects = |t: &Tree<u32>| {
334+ t.check();
335+ assert!(t.grouped(b, all) && t.eclipsed(a, all));
336+ let placed = t.arrange(AREA, 0.0, all);
337+ move |w| placed.iter().find(|p: &&(u32, Rect)| p.0 == w).unwrap().1
338+ };
339+
340+ assert!(t.move_window(b, Dir::Right, all));
341+ let rect = rects(&t);
342+ assert!(rect(c).x < rect(b).x);
343+
344+ assert!(t.move_window(b, Dir::Down, all));
345+ let rect = rects(&t);
346+ assert!(rect(c).bottom() <= rect(b).y && rect(b).w == AREA.w);
347+
348+ assert!(t.merge_window(b, Dir::Up, all));
349+ let rect = rects(&t);
350+ assert!(rect(c).right() <= rect(b).x && rect(b).y == rect(c).y);
351+}
352+
321353 /// A window alone on screen in a container of hidden ones expands among the
322354 /// windows it is shown beside, not inside the container nobody can see.
323355 #[test]
@@ -465,7 +497,7 @@ fn moves_are_seen_or_undone() {
465497 let moved = match kind {
466498 1 => t.merge_window(w, d, vis),
467499 2 => t.move_window(w, d, vis),
468- 3 => t.move_container(w, d, vis),
500+ 3 => t.move_container(w, d, false, vis),
469501 _ => continue,
470502 };
471503 t.check();
Modified file: crates/layout/src/tree.rs+88 additions−24 deletions
@@ -222,52 +222,98 @@ impl<W: Copy + Eq + Hash> Tree<W> {
222222
223223 /// Moves a window one step in `dir`. Leaves swap slots with the next
224224 /// visible sibling, enter a sibling container, or step out beside their
225- /// container. Hidden windows never move.
225+ /// container. Hidden windows never move, and an extended window moves
226+ /// its group as a rigid block.
226227 pub fn move_window(&mut self, w: W, dir: Dir, vis: impl Fn(W) -> bool) -> bool {
227228 let Some(&n) = self.leaves.get(&w).filter(|_| vis(w)) else {
228229 return false;
229230 };
231+ let block = self.group(w, &vis).unwrap_or(n);
230232 self.until_seen(&vis, |t| {
231- let moved = t.move_node(n, dir, &vis, true);
233+ let moved = t.move_node(block, dir, &vis, block == n);
232234 t.reveal(n, moved)
233235 })
234236 }
235237
236- /// Moves the container holding `w` as a rigid block. A window sitting
237- /// directly in the root is its own block.
238- pub fn move_container(&mut self, w: W, dir: Dir, vis: impl Fn(W) -> bool) -> bool {
238+ /// Moves the group or container holding `w` as a block, merging it into
239+ /// the next lane in `dir` like `merge_window` when `merge` is set. A
240+ /// window sitting directly in the root is its own block.
241+ pub fn move_container(&mut self, w: W, dir: Dir, merge: bool, vis: impl Fn(W) -> bool) -> bool {
239242 let Some(&n) = self.leaves.get(&w).filter(|_| vis(w)) else {
240243 return false;
241244 };
245+ let group = self.group(w, &vis);
242246 self.until_seen(&vis, |t| {
243- let block = t.nodes[n]
244- .parent
245- .filter(|&p| Some(p) != t.root)
246- .unwrap_or(n);
247- let moved = t.move_node(block, dir, &vis, false);
248- t.reveal(n, moved)
247+ let block = group.unwrap_or_else(|| {
248+ t.nodes[n]
249+ .parent
250+ .filter(|&p| Some(p) != t.root)
251+ .unwrap_or(n)
252+ });
253+ if merge {
254+ t.merge_node(n, block, dir, &vis)
255+ } else {
256+ let moved = t.move_node(block, dir, &vis, false);
257+ t.reveal(n, moved)
258+ }
249259 })
250260 }
251261
252262 /// Moves a window to the end of the next visible lane in `dir` among its
253263 /// siblings. A lone window there becomes a stack with it. A window with no
254264 /// sibling lane that way leaves its stack as `move_window` would, so it
255- /// takes one press to step out and another to merge.
265+ /// takes one press to step out and another to merge. An extended window
266+ /// takes its group along.
256267 pub fn merge_window(&mut self, w: W, dir: Dir, vis: impl Fn(W) -> bool) -> bool {
257268 let Some(&n) = self.leaves.get(&w).filter(|_| vis(w)) else {
258269 return false;
259270 };
260- self.until_seen(&vis, |t| {
261- if let Some((p, i)) = t.position(n)
262- && t.axis(p) == Some(dir.axis())
263- && let Some(j) = t.next_shown(p, i, dir, &vis)
271+ let block = self.group(w, &vis).unwrap_or(n);
272+ self.until_seen(&vis, |t| t.merge_node(n, block, dir, &vis))
273+ }
274+
275+ fn merge_node(&mut self, leaf: NodeId, n: NodeId, dir: Dir, vis: &impl Fn(W) -> bool) -> bool {
276+ if let Some((p, i)) = self.position(n)
277+ && self.axis(p) == Some(dir.axis())
278+ && let Some(j) = self.next_shown(p, i, dir, vis)
279+ {
280+ self.join_lane(n, p, j);
281+ return self.reveal(leaf, true);
282+ }
283+ let moved = self.move_node(n, dir, vis, true);
284+ self.reveal(leaf, moved)
285+ }
286+
287+ /// The outermost container `w` is extended in, the block it moves as.
288+ fn group(&self, w: W, vis: &impl Fn(W) -> bool) -> Option<NodeId> {
289+ self.shown_path(w, vis)
290+ .into_iter()
291+ .take_while(|&(p, c)| self.expanded(p) == Some(c))
292+ .last()
293+ .map(|(p, _)| p)
294+ }
295+
296+ /// Whether `w` is extended over windows it shares a container with.
297+ pub fn grouped(&self, w: W, vis: impl Fn(W) -> bool) -> bool {
298+ self.group(w, &vis).is_some()
299+ }
300+
301+ /// Whether a visible window extended over `w` takes all its room.
302+ pub fn eclipsed(&self, w: W, vis: impl Fn(W) -> bool) -> bool {
303+ let Some(&leaf) = self.leaves.get(&w) else {
304+ return false;
305+ };
306+ let mut cur = leaf;
307+ while let Some(p) = self.nodes[cur].parent {
308+ if self
309+ .expanded(p)
310+ .is_some_and(|e| e != cur && self.visible(e, &vis))
264311 {
265- t.join_lane(n, p, j);
266- return t.reveal(n, true);
312+ return true;
267313 }
268- let moved = t.move_node(n, dir, &vis, true);
269- t.reveal(n, moved)
270- })
314+ cur = p;
315+ }
316+ false
271317 }
272318
273319 /// Repeats `step` until the visible layout changes, or undoes it all when
@@ -757,9 +803,16 @@ impl<W: Copy + Eq + Hash> Tree<W> {
757803 }
758804
759805 /// Places `node` among `p`'s children. A container running along `p`'s
760- /// axis is dissolved into `p` so axes keep alternating.
806+ /// axis is dissolved into `p` so axes keep alternating, unless it holds an
807+ /// extended window, whose group is turned across instead to survive.
761808 fn attach(&mut self, p: NodeId, at: usize, node: NodeId) {
762809 if self.axis(node).is_some() && self.axis(node) == self.axis(p) {
810+ if self.expanded(node).is_some() {
811+ self.transpose(node);
812+ self.nodes[node].parent = Some(p);
813+ self.children_mut(p).insert(at, node);
814+ return;
815+ }
763816 let children = self.children(node).to_vec();
764817 self.nodes.remove(node);
765818 for (k, &c) in children.iter().enumerate() {
@@ -787,6 +840,18 @@ impl<W: Copy + Eq + Hash> Tree<W> {
787840 }
788841 }
789842
843+ fn transpose(&mut self, n: NodeId) {
844+ match &mut self.nodes[n].kind {
845+ Kind::Container { axis, children, .. } => {
846+ *axis = axis.other();
847+ for c in children.clone() {
848+ self.transpose(c);
849+ }
850+ }
851+ Kind::Leaf(_, size) => size.reverse(),
852+ }
853+ }
854+
790855 /// Repairs a container that lost a child.
791856 fn normalize(&mut self, c: NodeId) {
792857 if self.children(c).len() >= 2 {
@@ -810,10 +875,9 @@ impl<W: Copy + Eq + Hash> Tree<W> {
810875 (None, None) => self.root = None,
811876 (Some(only), Some((p, i))) => {
812877 self.unlink_dead(p, i);
813- let leaf_like = self.axis(only) != self.axis(p);
814878 self.attach(p, i, only);
815879 if was_expanded
816- && leaf_like
880+ && self.nodes.contains_key(only)
817881 && let Kind::Container { expanded, .. } = &mut self.nodes[p].kind
818882 {
819883 *expanded = Some(only);
Modified file: crates/monolith/src/commands.rs+1 additions−1 deletions
@@ -87,7 +87,7 @@ impl State {
8787 self.wm.layout.move_window(d);
8888 }
8989 Command::MoveContainer { dir: d } => {
90- self.wm.layout.move_container(d);
90+ self.wm.layout.move_container(d, merge);
9191 }
9292 Command::Maximize => {
9393 self.wm.layout.expand(ExpandScope::All);