atagen

monolith

public
Star0
Watch0

Commit 86ef802692f5

tarball

atagen <boss@atagen.co>2026-10-19 07:52 UTC

7 files changed+183-5
layout: split the anchor along its longer side on insert

Joining the anchor's container always grew the row or column it sat in, so a run of new windows never produced the dwindling layout users expect from a tiler. Tree::insert_split divides the anchor's own space instead, picking the axis from its arranged rect in Layout::place. general.autotile turns it off.
Modified file: crates/config/default.toml+3 additions
@@ -9,6 +9,9 @@ startup = []
99 # keeps super for itself.
1010 mod = "super"
1111 gaps = { inner = 8, outer = 8 }
12+# A new window splits the one it opens beside along that window's longer side,
13+# so windows dwindle. When off it joins the container of that window instead.
14+autotile = true
1215 # What a window move does when it meets a neighbour. swap trades places,
1316 # merge joins its lane. invert-move does the other one for the next move.
1417 move = "swap"
Modified file: crates/config/src/lib.rs+4 additions
@@ -31,6 +31,9 @@ pub struct General {
3131 #[serde(rename = "mod")]
3232 pub mod_key: Modifier,
3333 pub gaps: Gaps,
34+ /// A new window splits the one it opens beside along that window's
35+ /// longer side, instead of joining its container.
36+ pub autotile: bool,
3437 /// What a window move does when it meets a neighbour.
3538 #[serde(rename = "move")]
3639 pub move_mode: MoveMode,
@@ -56,6 +59,7 @@ impl Default for General {
5659 Self {
5760 mod_key: Modifier::Super,
5861 gaps: Gaps::default(),
62+ autotile: true,
5963 move_mode: MoveMode::Swap,
6064 focus_follows_mouse: false,
6165 mouse_follows_focus: false,
Modified file: crates/layout/src/lib.rs+26 additions−3 deletions
@@ -130,6 +130,7 @@ pub struct Layout {
130130 last_scratchpad: Option<usize>,
131131 parked: HashMap<String, Parked>,
132132 pub gaps: Gaps,
133+ pub autotile: bool,
133134 }
134135
135136 impl Default for Layout {
@@ -151,6 +152,7 @@ impl Default for Layout {
151152 last_scratchpad: None,
152153 parked: HashMap::new(),
153154 gaps: Gaps::default(),
155+ autotile: false,
154156 }
155157 }
156158 }
@@ -395,13 +397,34 @@ impl Layout {
395397 .copied()
396398 .find(|&h| tree.contains(h) && self.windows[h].tag == tag)
397399 });
400+ let split = anchor
401+ .filter(|_| self.autotile)
402+ .and_then(|a| self.split_axis(o, a));
398403 let tree = &mut self.outputs[o].tree;
399- match anchor {
400- Some(a) => tree.insert_after(w, a),
401- None => tree.insert_row(w),
404+ match (anchor, split) {
405+ (Some(a), Some(axis)) => tree.insert_split(w, a, axis),
406+ (Some(a), None) => tree.insert_after(w, a),
407+ (None, _) => tree.insert_row(w),
402408 }
403409 }
404410
411+ /// The axis a window opening beside `anchor` should split it along, the
412+ /// way the anchor is laid out right now. Hidden anchors have no shape.
413+ fn split_axis(&self, o: OutputId, anchor: WindowId) -> Option<Axis> {
414+ let rect = self
415+ .arrange(o)
416+ .tiled
417+ .iter()
418+ .find(|p| p.window == anchor && p.visible)?
419+ .rect;
420+ let axis = if rect.w > rect.h {
421+ Axis::Horizontal
422+ } else {
423+ Axis::Vertical
424+ };
425+ (rect.w > 0.0 && rect.h > 0.0).then_some(axis)
426+ }
427+
405428 fn unplace(&mut self, w: WindowId) {
406429 let Some(o) = self.windows[w].output else {
407430 return;
Modified file: crates/layout/src/tests.rs+111 additions−2 deletions
@@ -188,8 +188,14 @@ proptest! {
188188 #![proptest_config(ProptestConfig::with_cases(2000))]
189189
190190 #[test]
191- fn random_commands_keep_the_model_sound(ops in prop::collection::vec(op(), 1..80)) {
192- let mut l = Layout::default();
191+ fn random_commands_keep_the_model_sound(
192+ ops in prop::collection::vec(op(), 1..80),
193+ autotile in any::<bool>(),
194+ ) {
195+ let mut l = Layout {
196+ autotile,
197+ ..Layout::default()
198+ };
193199 l.add_output("a", AREA);
194200 l.add_output("b", Rect::new(1000.0, 0.0, 800.0, 600.0));
195201 let tags = [l.homeless(), l.tag("1"), l.tag("2"), l.tag("3")];
@@ -502,3 +508,106 @@ fn fullscreen_leaves_its_transients_reachable() {
502508 assert!(!l.covered(stranger));
503509 assert_eq!(l.window(dialog).and_then(|w| w.parent), None);
504510 }
511+
512+fn rect_of(t: &Tree<u32>, w: u32, vis: impl Fn(u32) -> bool) -> Rect {
513+ t.arrange(AREA, 0.0, vis)
514+ .into_iter()
515+ .find(|p| p.0 == w)
516+ .unwrap()
517+ .1
518+}
519+
520+#[test]
521+fn splitting_a_lone_window_shares_its_space() {
522+ let (a, b, c) = (1, 2, 3);
523+ let mut t: Tree<u32> = Tree::default();
524+ t.insert_row(a);
525+ t.insert_split(b, a, Axis::Horizontal);
526+ t.check();
527+ let (ra, rb) = (rect_of(&t, a, |_| true), rect_of(&t, b, |_| true));
528+ assert_eq!((ra.w, rb.w, ra.h), (AREA.w / 2.0, AREA.w / 2.0, AREA.h));
529+ assert!(ra.x < rb.x);
530+
531+ t.insert_split(c, b, Axis::Vertical);
532+ t.check();
533+ let (rb, rc) = (rect_of(&t, b, |_| true), rect_of(&t, c, |_| true));
534+ assert_eq!(
535+ (rb.h, rc.h, rb.w),
536+ (AREA.h / 2.0, AREA.h / 2.0, AREA.w / 2.0)
537+ );
538+ assert!(rb.y < rc.y);
539+ assert_eq!(rect_of(&t, a, |_| true), ra, "the sibling is undisturbed");
540+}
541+
542+#[test]
543+fn splitting_along_the_parents_axis_appends_a_sibling() {
544+ let (a, b, c, d) = (1, 2, 3, 4);
545+ let mut t: Tree<u32> = Tree::default();
546+ t.insert_row(a);
547+ t.insert_split(b, a, Axis::Horizontal);
548+ t.insert_split(c, b, Axis::Vertical);
549+ let before = rect_of(&t, a, |_| true);
550+ t.insert_split(d, a, Axis::Horizontal);
551+ t.check();
552+ let all = |_| true;
553+ let (ra, rd, rb) = (
554+ rect_of(&t, a, all),
555+ rect_of(&t, d, all),
556+ rect_of(&t, b, all),
557+ );
558+ assert_eq!((ra.w, rd.w), (before.w / 2.0, before.w / 2.0));
559+ assert!(ra.x < rd.x && rd.x < rb.x);
560+ assert_eq!(rb.w, AREA.w / 2.0, "the neighbour keeps its share");
561+}
562+
563+#[test]
564+fn splitting_beside_hidden_windows_leaves_their_slots_alone() {
565+ let (a, b, c, d) = (1, 2, 3, 4);
566+ let mut t: Tree<u32> = Tree::default();
567+ t.insert_row(a);
568+ t.insert_after(b, a);
569+ t.insert_after(c, a);
570+ let tag_one = |w: u32| w == a || w == d;
571+ assert_eq!(rect_of(&t, a, tag_one).h, AREA.h);
572+
573+ t.insert_split(d, a, Axis::Vertical);
574+ t.check();
575+ let (ra, rd) = (rect_of(&t, a, tag_one), rect_of(&t, d, tag_one));
576+ assert_eq!((ra.h, rd.h, ra.w), (AREA.h / 2.0, AREA.h / 2.0, AREA.w));
577+ assert!(ra.y < rd.y);
578+
579+ t.insert_split(d + 1, a, Axis::Horizontal);
580+ t.check();
581+ let shown = |w: u32| w == a || w == d + 1;
582+ let (ra, re) = (rect_of(&t, a, shown), rect_of(&t, d + 1, shown));
583+ assert_eq!((ra.w, re.w), (AREA.w / 2.0, AREA.w / 2.0));
584+ assert!(ra.x < re.x);
585+ assert_eq!(t.windows().count(), 5, "hidden windows stay in the tree");
586+}
587+
588+#[test]
589+fn autotile_dwindles_by_the_anchors_shape() {
590+ let mut l = Layout {
591+ autotile: true,
592+ ..Layout::default()
593+ };
594+ let o = l.add_output("a", AREA);
595+ let tag = l.tag("1");
596+ l.view(o, tag);
597+ let mut ids = Vec::new();
598+ for _ in 0..4 {
599+ let w = l.add_window(Some(o), Some(tag), None);
600+ l.focus(w);
601+ ids.push(w);
602+ l.check();
603+ }
604+ let arr = l.arrange(o);
605+ let r = |w| arr.tiled.iter().find(|p| p.window == w).unwrap().rect;
606+ let [a, b, c, d] = [ids[0], ids[1], ids[2], ids[3]];
607+ assert!(
608+ r(a).x < r(b).x && r(a).h > r(b).h * 1.5,
609+ "{a:?} left, {b:?} right"
610+ );
611+ assert!(r(b).y < r(c).y && r(b).x == r(c).x);
612+ assert!(r(c).x < r(d).x && r(c).y == r(d).y);
613+}
Modified file: crates/layout/src/tree.rs+37 additions
@@ -147,6 +147,43 @@ impl<W: Copy + Eq + Hash> Tree<W> {
147147 }
148148 }
149149
150+ /// Inserts `w` after `anchor` by splitting `anchor` along `axis`. They
151+ /// share the space `anchor` had, and its siblings keep theirs.
152+ pub fn insert_split(&mut self, w: W, anchor: W, axis: Axis) {
153+ let Some(&a) = self.leaves.get(&anchor) else {
154+ return self.insert_row(w);
155+ };
156+ let size = self.size(a);
157+ let n = self.new_leaf(w, size);
158+ match self.position(a) {
159+ Some((p, i)) if self.axis(p) == Some(axis) => {
160+ self.halve(a, axis);
161+ self.halve(n, axis);
162+ self.attach(p, i + 1, n);
163+ }
164+ Some((p, i)) => {
165+ self.unlink(p, i);
166+ let c = self.new_container(axis);
167+ self.attach(c, 0, a);
168+ self.attach(c, 1, n);
169+ self.attach(p, i, c);
170+ }
171+ None => {
172+ let c = self.new_container(axis);
173+ self.attach(c, 0, a);
174+ self.attach(c, 1, n);
175+ self.root = Some(c);
176+ }
177+ }
178+ self.reveal(n, true);
179+ }
180+
181+ fn halve(&mut self, leaf: NodeId, axis: Axis) {
182+ if let Kind::Leaf(_, size) = &mut self.nodes[leaf].kind {
183+ size[axis.index()] /= 2.0;
184+ }
185+ }
186+
150187 /// Appends `w` as a row of its own along the stacking axis.
151188 pub fn insert_row(&mut self, w: W) {
152189 let size = self.root.map_or([1.0; 2], |r| self.size(r));
Modified file: crates/monolith/src/commands.rs+1 additions
@@ -221,6 +221,7 @@ impl State {
221221 let input_changed = config.input != self.settings.config.input;
222222 self.settings.binds = Binds::new(&config);
223223 self.wm.layout.gaps = crate::state::gaps(&config);
224+ self.wm.layout.autotile = config.general.autotile;
224225 self.wm.anim.settings = config.animation.clone();
225226 self.settings.config = config;
226227 if keyboard_changed {
Modified file: crates/monolith/src/state.rs+1 additions
@@ -150,6 +150,7 @@ impl State {
150150
151151 let mut layout = Layout::default();
152152 layout.gaps = gaps(&config);
153+ layout.autotile = config.general.autotile;
153154 let animation = config.animation.clone();
154155 let imports = crate::import::Imports::new(
155156 &handle,