atagen

monolith

public
Star0
Watch0

Commit 4444c779eb48

tarball

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

10 files changed+139-32
layout: add merge moves with a configurable default and a one-shot inversion

A layout like A [B/C] could only be reached by routing through the root and flipping, because moves could swap or step out of containers but never form a new stack around a neighbour. general.move picks whether a move swaps or merges, and invert-move flips that for the next move only, so no lingering mode changes moves silently. move-block becomes move-container since interspersed tags made it a container move all along, and expand all becomes maximize, setting the xdg maximized state.
Modified file: crates/config/default.toml+10 additions−6 deletions
@@ -9,6 +9,9 @@ startup = []
99 # keeps super for itself.
1010 mod = "super"
1111 gaps = { inner = 8, outer = 8 }
12+# What a window move does when it meets a neighbour. swap trades places,
13+# merge joins its lane. invert-move does the other one for the next move.
14+move = "swap"
1215
1316 [animation]
1417 enabled = true
@@ -37,18 +40,19 @@ repeat_rate = 25
3740 "Mod+Shift+j" = "move down"
3841 "Mod+Shift+k" = "move up"
3942 "Mod+Shift+l" = "move right"
40-"Mod+Ctrl+h" = "move-block left"
41-"Mod+Ctrl+j" = "move-block down"
42-"Mod+Ctrl+k" = "move-block up"
43-"Mod+Ctrl+l" = "move-block right"
43+"Mod+Ctrl+h" = "move-container left"
44+"Mod+Ctrl+j" = "move-container down"
45+"Mod+Ctrl+k" = "move-container up"
46+"Mod+Ctrl+l" = "move-container right"
47+"Mod+m" = "invert-move"
4448 "Mod+Alt+h" = "resize horizontal -0.05"
4549 "Mod+Alt+l" = "resize horizontal 0.05"
4650 "Mod+Alt+k" = "resize vertical -0.05"
4751 "Mod+Alt+j" = "resize vertical 0.05"
4852
4953 "Mod+e" = "expand window"
50-"Mod+Shift+w" = "expand block"
51-"Mod+w" = "expand all"
54+"Mod+Shift+w" = "expand container"
55+"Mod+w" = "maximize"
5256 "Mod+o" = "flip"
5357 "Mod+f" = "fullscreen"
5458 "Mod+Shift+space" = "float"
Modified file: crates/config/src/lib.rs+14 additions
@@ -26,6 +26,19 @@ pub struct General {
2626 #[serde(rename = "mod")]
2727 pub mod_key: Modifier,
2828 pub gaps: Gaps,
29+ /// What a window move does when it meets a neighbour.
30+ #[serde(rename = "move")]
31+ pub move_mode: MoveMode,
32+}
33+
34+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
35+#[serde(rename_all = "lowercase")]
36+pub enum MoveMode {
37+ /// Trade places with it.
38+ #[default]
39+ Swap,
40+ /// Join its lane, stacking with it if it is alone.
41+ Merge,
2942 }
3043
3144 impl Default for General {
@@ -33,6 +46,7 @@ impl Default for General {
3346 Self {
3447 mod_key: Modifier::Super,
3548 gaps: Gaps::default(),
49+ move_mode: MoveMode::Swap,
3650 }
3751 }
3852 }
Modified file: crates/ipc/src/lib.rs+15 additions−8 deletions
@@ -46,8 +46,7 @@ pub enum Axis {
4646 pub enum ExpandScope {
4747 #[default]
4848 Window,
49- Block,
50- All,
49+ Container,
5150 }
5251
5352 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
@@ -65,7 +64,7 @@ pub enum Command {
6564 Move {
6665 dir: Dir,
6766 },
68- MoveBlock {
67+ MoveContainer {
6968 dir: Dir,
7069 },
7170 Resize {
@@ -75,6 +74,11 @@ pub enum Command {
7574 Expand {
7675 scope: ExpandScope,
7776 },
77+ /// Expands every container above the focused window towards it.
78+ Maximize,
79+ /// Makes the next window move do the opposite of the configured default,
80+ /// swap or merge. Again before moving cancels it.
81+ InvertMove,
7882 Flip,
7983 Float,
8084 Fullscreen,
@@ -126,7 +130,7 @@ impl fmt::Display for ParseError {
126130 impl std::error::Error for ParseError {}
127131
128132 impl Command {
129- /// Parses the word form, as in `["move-block", "left"]`.
133+ /// Parses the word form, as in `["move-container", "left"]`.
130134 pub fn parse<S: AsRef<str>>(words: &[S]) -> Result<Self, ParseError> {
131135 let words: Vec<&str> = words.iter().map(AsRef::as_ref).collect();
132136 let Some((&name, args)) = words.split_first() else {
@@ -162,12 +166,14 @@ impl Command {
162166 "quit" => none(Command::Quit)?,
163167 "reload" => none(Command::Reload)?,
164168 "flip" => none(Command::Flip)?,
169+ "maximize" => none(Command::Maximize)?,
170+ "invert-move" => none(Command::InvertMove)?,
165171 "float" => none(Command::Float)?,
166172 "fullscreen" => none(Command::Fullscreen)?,
167173 "sticky" => none(Command::Sticky)?,
168174 "focus" => Command::Focus { dir: dir()? },
169175 "move" => Command::Move { dir: dir()? },
170- "move-block" => Command::MoveBlock { dir: dir()? },
176+ "move-container" => Command::MoveContainer { dir: dir()? },
171177 "focus-output" => Command::FocusOutput { dir: dir()? },
172178 "send-to-output" => Command::SendToOutput { dir: dir()? },
173179 "resize" => {
@@ -187,9 +193,8 @@ impl Command {
187193 "expand" => Command::Expand {
188194 scope: match args {
189195 [] | ["window"] => ExpandScope::Window,
190- ["block"] => ExpandScope::Block,
191- ["all"] => ExpandScope::All,
192- _ => return Err(err("takes window, block or all")),
196+ ["container"] => ExpandScope::Container,
197+ _ => return Err(err("takes window or container")),
193198 },
194199 },
195200 "view" => Command::View { tag: one()? },
@@ -274,6 +279,8 @@ pub enum Answer {
274279
275280 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
276281 pub struct State {
282+ /// The next window move does the opposite of the configured default.
283+ pub move_inverted: bool,
277284 pub tags: Vec<Tag>,
278285 pub outputs: Vec<Output>,
279286 pub windows: Vec<Window>,
Modified file: crates/layout/src/lib.rs+22 additions−4 deletions
@@ -24,7 +24,7 @@ pub const HOMELESS: &str = "homeless";
2424 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2525 pub enum ExpandScope {
2626 Window,
27- Block,
27+ Container,
2828 All,
2929 }
3030
@@ -594,16 +594,34 @@ impl Layout {
594594 moved
595595 }
596596
597- pub fn move_block(&mut self, dir: Dir) -> bool {
597+ pub fn move_container(&mut self, dir: Dir) -> bool {
598598 let Some((w, o)) = self.focused_tiled() else {
599599 return false;
600600 };
601601 let mut tree = std::mem::take(&mut self.outputs[o].tree);
602- let moved = tree.move_block(w, dir, self.vis_on(o));
602+ let moved = tree.move_container(w, dir, self.vis_on(o));
603603 self.outputs[o].tree = tree;
604604 moved
605605 }
606606
607+ pub fn merge_window(&mut self, dir: Dir) -> bool {
608+ let Some((w, o)) = self.focused_tiled() else {
609+ return false;
610+ };
611+ let mut tree = std::mem::take(&mut self.outputs[o].tree);
612+ let merged = tree.merge_window(w, dir, self.vis_on(o));
613+ self.outputs[o].tree = tree;
614+ merged
615+ }
616+
617+ /// Whether a tiled window fills its output through expansion.
618+ pub fn maximized(&self, w: WindowId) -> bool {
619+ let Some(o) = self.windows.get(w).and_then(|w| w.output) else {
620+ return false;
621+ };
622+ self.outputs[o].tree.maximized(w)
623+ }
624+
607625 pub fn resize(&mut self, axis: Axis, delta: f64) -> bool {
608626 let Some((w, o)) = self.focused_tiled() else {
609627 return false;
@@ -620,7 +638,7 @@ impl Layout {
620638 };
621639 let (skip, count) = match scope {
622640 ExpandScope::Window => (0, 1),
623- ExpandScope::Block => (1, 1),
641+ ExpandScope::Container => (1, 1),
624642 ExpandScope::All => (0, usize::MAX),
625643 };
626644 self.outputs[o].tree.toggle_expand(w, skip, count)
Modified file: crates/layout/src/tests.rs+13 additions−2 deletions
@@ -10,6 +10,7 @@ enum Op {
1010 Focus(u8),
1111 Move(Dir),
1212 Block(Dir),
13+ Merge(Dir),
1314 View(u8),
1415 ToggleView(u8),
1516 Retag(u8, u8),
@@ -39,6 +40,7 @@ fn op() -> impl Strategy<Value = Op> {
3940 3 => any::<u8>().prop_map(Op::Focus),
4041 4 => dir().prop_map(Op::Move),
4142 2 => dir().prop_map(Op::Block),
43+ 3 => dir().prop_map(Op::Merge),
4244 1 => (0u8..4).prop_map(Op::View),
4345 2 => (0u8..4).prop_map(Op::ToggleView),
4446 1 => (any::<u8>(), 0u8..4).prop_map(|(w, t)| Op::Retag(w, t)),
@@ -81,7 +83,10 @@ fn apply(l: &mut Layout, tags: &[TagId], op: &Op) {
8183 l.move_window(d);
8284 }
8385 Op::Block(d) => {
84- l.move_block(d);
86+ l.move_container(d);
87+ }
88+ Op::Merge(d) => {
89+ l.merge_window(d);
8590 }
8691 Op::View(t) => active.into_iter().for_each(|o| l.view(o, tags[t as usize])),
8792 Op::ToggleView(t) => active
@@ -92,7 +97,13 @@ fn apply(l: &mut Layout, tags: &[TagId], op: &Op) {
9297 .for_each(|w| l.set_tag(w, tags[t as usize])),
9398 Op::Float(i) => nth(l, i).into_iter().for_each(|w| l.toggle_floating(w)),
9499 Op::Expand(s) => {
95- l.expand([ExpandScope::Window, ExpandScope::Block, ExpandScope::All][s as usize]);
100+ l.expand(
101+ [
102+ ExpandScope::Window,
103+ ExpandScope::Container,
104+ ExpandScope::All,
105+ ][s as usize],
106+ );
96107 }
97108 Op::Flip => active.into_iter().for_each(|o| l.flip(o)),
98109 Op::Resize(h, d) => {
Modified file: crates/layout/src/tree.rs+40 additions−4 deletions
@@ -183,7 +183,7 @@ impl<W: Copy + Eq + Hash> Tree<W> {
183183
184184 /// Moves the container holding `w` as a rigid block. A window sitting
185185 /// directly in the root is its own block.
186- pub fn move_block(&mut self, w: W, dir: Dir, vis: impl Fn(W) -> bool) -> bool {
186+ pub fn move_container(&mut self, w: W, dir: Dir, vis: impl Fn(W) -> bool) -> bool {
187187 let Some(&n) = self.leaves.get(&w) else {
188188 return false;
189189 };
@@ -194,6 +194,43 @@ impl<W: Copy + Eq + Hash> Tree<W> {
194194 self.move_node(block, dir, &vis, false)
195195 }
196196
197+ /// Moves a window to the end of the next visible lane in `dir`, stepping
198+ /// up a level when its container has none that way. A lone window there
199+ /// becomes a stack with it.
200+ pub fn merge_window(&mut self, w: W, dir: Dir, vis: impl Fn(W) -> bool) -> bool {
201+ let Some(&n) = self.leaves.get(&w) else {
202+ return false;
203+ };
204+ let mut cur = n;
205+ while let Some((p, i)) = self.position(cur) {
206+ if self.axis(p) == Some(dir.axis())
207+ && let Some(j) = self.next_shown(p, i, dir, &vis)
208+ {
209+ self.join_lane(n, p, j);
210+ return true;
211+ }
212+ cur = p;
213+ }
214+ false
215+ }
216+
217+ /// Whether every container above `w` is expanded towards it.
218+ pub fn maximized(&self, w: W) -> bool {
219+ let Some(&leaf) = self.leaves.get(&w) else {
220+ return false;
221+ };
222+ let mut cur = leaf;
223+ let mut any = false;
224+ while let Some(p) = self.nodes[cur].parent {
225+ if self.expanded(p) != Some(cur) {
226+ return false;
227+ }
228+ any = true;
229+ cur = p;
230+ }
231+ any
232+ }
233+
197234 /// Transposes the whole tree.
198235 pub fn flip(&mut self) {
199236 self.stack = self.stack.other();
@@ -417,9 +454,8 @@ impl<W: Copy + Eq + Hash> Tree<W> {
417454 (j < self.children(p).len()).then_some(j)
418455 }
419456
420- /// Moves `n` to the end of the lane at `p`'s child `j`, which holds only
421- /// hidden windows, so it shares that lane when they come back instead of
422- /// opening a new one beside it. A lone window becomes a lane of its own.
457+ /// Moves `n` to the end of the lane at `p`'s child `j`. A lone window
458+ /// there becomes a lane of its own, perpendicular to `p`.
423459 fn join_lane(&mut self, n: NodeId, p: NodeId, j: usize) {
424460 let lane = self.children(p)[j].node;
425461 let lane = if self.axis(lane).is_some() {
Modified file: crates/monolith/src/commands.rs+13 additions−5 deletions
@@ -1,5 +1,5 @@
11 use crate::state::State;
2-use monolith_config::{Config, Mods};
2+use monolith_config::{Config, Mods, MoveMode};
33 use monolith_ipc::Command;
44 use monolith_layout::{Axis, Dir, ExpandScope, WindowId};
55 use smithay::input::keyboard::{Keysym, ModifiersState, xkb};
@@ -52,6 +52,8 @@ impl State {
5252 pub fn run(&mut self, cmd: Command) -> Result<(), String> {
5353 let output = self.layout.active_output();
5454 let focused = self.layout.focused();
55+ let inverted = std::mem::take(&mut self.move_inverted);
56+ let merge = (self.config.general.move_mode == MoveMode::Merge) != inverted;
5557 let on_focused = |state: &mut Self, f: fn(&mut monolith_layout::Layout, WindowId)| {
5658 if let Some(w) = focused {
5759 f(&mut state.layout, w);
@@ -72,12 +74,19 @@ impl State {
7274 Command::Focus { dir: d } => {
7375 self.layout.focus_dir(dir(d));
7476 }
77+ Command::Move { dir: d } if merge => {
78+ self.layout.merge_window(dir(d));
79+ }
7580 Command::Move { dir: d } => {
7681 self.layout.move_window(dir(d));
7782 }
78- Command::MoveBlock { dir: d } => {
79- self.layout.move_block(dir(d));
83+ Command::MoveContainer { dir: d } => {
84+ self.layout.move_container(dir(d));
85+ }
86+ Command::Maximize => {
87+ self.layout.expand(ExpandScope::All);
8088 }
89+ Command::InvertMove => self.move_inverted = !inverted,
8190 Command::Resize { axis, delta } => {
8291 let axis = match axis {
8392 monolith_ipc::Axis::Horizontal => Axis::Horizontal,
@@ -88,8 +97,7 @@ impl State {
8897 Command::Expand { scope } => {
8998 self.layout.expand(match scope {
9099 monolith_ipc::ExpandScope::Window => ExpandScope::Window,
91- monolith_ipc::ExpandScope::Block => ExpandScope::Block,
92- monolith_ipc::ExpandScope::All => ExpandScope::All,
100+ monolith_ipc::ExpandScope::Container => ExpandScope::Container,
93101 });
94102 }
95103 Command::Flip => output.into_iter().for_each(|o| self.layout.flip(o)),
Modified file: crates/monolith/src/ipc.rs+1 additions
@@ -202,6 +202,7 @@ fn snapshot(state: &State) -> monolith_ipc::State {
202202 .collect();
203203 windows.sort_by_key(|w| w.id);
204204 monolith_ipc::State {
205+ move_inverted: state.move_inverted,
205206 tags,
206207 outputs,
207208 windows,
Modified file: crates/monolith/src/state.rs+9 additions−2 deletions
@@ -54,6 +54,7 @@ pub struct State {
5454 pub config_path: Option<PathBuf>,
5555 pub binds: Binds,
5656 pub suppressed: Vec<Keycode>,
57+ pub move_inverted: bool,
5758 pub drag: Option<crate::input::Drag>,
5859 pub ipc: crate::ipc::Server,
5960 pub launches: crate::launch::Launches,
@@ -137,6 +138,7 @@ impl State {
137138 config_path,
138139 binds,
139140 suppressed: Vec::new(),
141+ move_inverted: false,
140142 drag: None,
141143 ipc: crate::ipc::Server::default(),
142144 launches: Default::default(),
@@ -359,7 +361,7 @@ impl State {
359361 let mut mapped = Vec::new();
360362 for (id, rect, kind, _) in shown {
361363 if let Some(window) = self.windows.get(&id)
362- && configure(window, rect, kind)
364+ && configure(window, rect, kind, self.layout.maximized(id))
363365 {
364366 let loc = (rect.x.round() as i32, rect.y.round() as i32);
365367 let loc = Point::from(loc) - window.geometry().loc;
@@ -470,7 +472,7 @@ pub enum Kind {
470472
471473 /// Sets a window's pending size and states. Returns false for windows
472474 /// squeezed to a line by an expanded sibling, which are not shown.
473-fn configure(window: &Window, rect: Rect, kind: Kind) -> bool {
475+fn configure(window: &Window, rect: Rect, kind: Kind, maximized: bool) -> bool {
474476 let (x, y) = (rect.x.round(), rect.y.round());
475477 let w = (rect.right().round() - x) as i32;
476478 let h = (rect.bottom().round() - y) as i32;
@@ -498,6 +500,11 @@ fn configure(window: &Window, rect: Rect, kind: Kind) -> bool {
498500 } else {
499501 s.states.unset(XdgState::Fullscreen);
500502 }
503+ if maximized && kind == Kind::Tiled {
504+ s.states.set(XdgState::Maximized);
505+ } else {
506+ s.states.unset(XdgState::Maximized);
507+ }
501508 });
502509 if t.is_initial_configure_sent() {
503510 t.send_pending_configure();
Modified file: design/decisions.md+2 additions−1 deletions
@@ -85,7 +85,8 @@ Running log of settled decisions and open questions. Research backing lives in `
8585 ### Planner (see `planner.html`)
8686 - Orthogonal style (one axis per window per phase) is the default and flagship look. Direct is a toggle.
8787 - Shared-separator rule, plans of 1 to 4 phases, the 4-phase grid guarantees a plan for any input. Pinch and lerp are safety nets only.
88-- Window moves and block moves both exist as commands. Tag reordering plans with rigid blocks.
88+- Window moves and container moves both exist as commands. A window move either swaps with its neighbour or merges into its lane, chosen by `general.move`, and `invert-move` does the other one for the next move only. The default lives in config rather than in a runtime toggle. Moving a tag directionally was rejected because interspersed tags have no single shape to move. Tag reordering only changes tag order.
89+- `expand all` became `maximize`, which also sets the xdg maximized state.
8990 - One curve per motion group.
9091 - Floating windows travel above the tiled layer, exempt from the overlap rule.
9192 - Seeds start as a line on the old boundary between neighbours.