atagen

monolith

public
Star0
Watch0

Commit fe835aed6c8d

tarball

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

7 files changed+796-0
ipc, config: add the command protocol and TOML config

Commands have one word grammar shared by keybinds and the CLI, and travel as versioned JSONL on the socket, so scripts, binds and bars cannot drift apart. A broken config on reload is rejected whole and the running one is kept.
Modified file: Cargo.lock+81 additions
@@ -314,6 +314,12 @@ dependencies = [
314314 ]
315315
316316 [[package]]
317+name = "itoa"
318+version = "1.0.18"
319+source = "registry+https://github.com/rust-lang/crates.io-index"
320+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
321+
322+[[package]]
317323 name = "lazy_static"
318324 version = "1.5.1"
319325 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -389,7 +395,12 @@ name = "monolith"
389395 version = "0.0.0"
390396 dependencies = [
391397 "calloop-wayland-source",
398+ "monolith-config",
399+ "monolith-ipc",
400+ "monolith-layout",
392401 "monolith-render-vk",
402+ "serde_json",
403+ "slotmap",
393404 "smithay",
394405 "tracing",
395406 "tracing-subscriber",
@@ -401,10 +412,19 @@ dependencies = [
401412 [[package]]
402413 name = "monolith-config"
403414 version = "0.0.0"
415+dependencies = [
416+ "monolith-ipc",
417+ "regex",
418+ "serde",
419+ "toml",
420+]
404421
405422 [[package]]
406423 name = "monolith-ipc"
407424 version = "0.0.0"
425+dependencies = [
426+ "serde",
427+]
408428
409429 [[package]]
410430 name = "monolith-layout"
@@ -631,6 +651,18 @@ dependencies = [
631651 ]
632652
633653 [[package]]
654+name = "regex"
655+version = "1.13.1"
656+source = "registry+https://github.com/rust-lang/crates.io-index"
657+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
658+dependencies = [
659+ "aho-corasick",
660+ "memchr",
661+ "regex-automata",
662+ "regex-syntax",
663+]
664+
665+[[package]]
634666 name = "regex-automata"
635667 version = "0.4.18"
636668 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -727,6 +759,28 @@ dependencies = [
727759 ]
728760
729761 [[package]]
762+name = "serde_json"
763+version = "1.0.154"
764+source = "registry+https://github.com/rust-lang/crates.io-index"
765+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
766+dependencies = [
767+ "itoa",
768+ "memchr",
769+ "serde",
770+ "serde_core",
771+ "zmij",
772+]
773+
774+[[package]]
775+name = "serde_spanned"
776+version = "1.1.2"
777+source = "registry+https://github.com/rust-lang/crates.io-index"
778+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
779+dependencies = [
780+ "serde_core",
781+]
782+
783+[[package]]
730784 name = "sharded-slab"
731785 version = "0.1.7"
732786 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -879,6 +933,21 @@ dependencies = [
879933 ]
880934
881935 [[package]]
936+name = "toml"
937+version = "1.1.8+spec-1.1.0"
938+source = "registry+https://github.com/rust-lang/crates.io-index"
939+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
940+dependencies = [
941+ "indexmap",
942+ "serde_core",
943+ "serde_spanned",
944+ "toml_datetime",
945+ "toml_parser",
946+ "toml_writer",
947+ "winnow",
948+]
949+
950+[[package]]
882951 name = "toml_datetime"
883952 version = "1.1.2+spec-1.1.0"
884953 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -909,6 +978,12 @@ dependencies = [
909978 ]
910979
911980 [[package]]
981+name = "toml_writer"
982+version = "1.1.3+spec-1.1.0"
983+source = "registry+https://github.com/rust-lang/crates.io-index"
984+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
985+
986+[[package]]
912987 name = "tracing"
913988 version = "0.1.44"
914989 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1234,3 +1309,9 @@ name = "xkeysym"
12341309 version = "0.2.1"
12351310 source = "registry+https://github.com/rust-lang/crates.io-index"
12361311 checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
1312+
1313+[[package]]
1314+name = "zmij"
1315+version = "1.0.23"
1316+source = "registry+https://github.com/rust-lang/crates.io-index"
1317+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Modified file: Cargo.toml+3 additions
@@ -19,6 +19,9 @@ monolith-render-vk = { path = "crates/render-vk" }
1919 monolith-testkit = { path = "crates/testkit" }
2020
2121 smithay = { git = "https://github.com/Smithay/smithay", rev = "023b78b1c5a05ca512767cacc07efca55fae8be4", default-features = false, features = ["wayland_frontend"] }
22+serde = { version = "1", features = ["derive"] }
23+serde_json = "1"
24+slotmap = "1"
2225 tracy-client = { version = "0.19", default-features = false }
2326 proptest = "1"
2427
Modified file: crates/config/Cargo.toml+5 additions
@@ -8,3 +8,8 @@ publish.workspace = true
88 [lints]
99 workspace = true
1010
11+[dependencies]
12+monolith-ipc.workspace = true
13+regex = "1"
14+serde.workspace = true
15+toml = "1"
Added file: crates/config/default.toml+90 additions
@@ -0,0 +1,90 @@
1+# monolith's built-in config, used when ~/.config/monolith/config.toml is
2+# missing. Copy it there to change anything.
3+
4+# Programs started with the compositor, each an argv list.
5+startup = []
6+
7+[general]
8+# What Mod means in binds. Use alt when nesting inside a compositor that
9+# keeps super for itself.
10+mod = "super"
11+gaps = { inner = 8, outer = 8 }
12+
13+[keyboard]
14+layout = ""
15+repeat_delay = 200
16+repeat_rate = 25
17+
18+# Each value is a command in word form, or an argv-style list.
19+[binds]
20+"Mod+Return" = "spawn foot"
21+"Mod+q" = "close"
22+"Mod+Shift+e" = "quit"
23+"Mod+Shift+r" = "reload"
24+
25+"Mod+h" = "focus left"
26+"Mod+j" = "focus down"
27+"Mod+k" = "focus up"
28+"Mod+l" = "focus right"
29+"Mod+Shift+h" = "move left"
30+"Mod+Shift+j" = "move down"
31+"Mod+Shift+k" = "move up"
32+"Mod+Shift+l" = "move right"
33+"Mod+Ctrl+h" = "move-block left"
34+"Mod+Ctrl+j" = "move-block down"
35+"Mod+Ctrl+k" = "move-block up"
36+"Mod+Ctrl+l" = "move-block right"
37+"Mod+Alt+h" = "resize horizontal -0.05"
38+"Mod+Alt+l" = "resize horizontal 0.05"
39+"Mod+Alt+k" = "resize vertical -0.05"
40+"Mod+Alt+j" = "resize vertical 0.05"
41+
42+"Mod+e" = "expand window"
43+"Mod+Shift+w" = "expand block"
44+"Mod+w" = "expand all"
45+"Mod+o" = "flip"
46+"Mod+f" = "fullscreen"
47+"Mod+Shift+space" = "float"
48+"Mod+s" = "sticky"
49+
50+"Mod+comma" = "focus-output left"
51+"Mod+period" = "focus-output right"
52+"Mod+Shift+comma" = "send-to-output left"
53+"Mod+Shift+period" = "send-to-output right"
54+
55+"Mod+grave" = "summon term"
56+"Mod+Shift+grave" = "scratchpad term"
57+"Mod+Escape" = "dismiss"
58+
59+"Mod+1" = "view 1"
60+"Mod+2" = "view 2"
61+"Mod+3" = "view 3"
62+"Mod+4" = "view 4"
63+"Mod+5" = "view 5"
64+"Mod+6" = "view 6"
65+"Mod+7" = "view 7"
66+"Mod+8" = "view 8"
67+"Mod+9" = "view 9"
68+"Mod+Ctrl+1" = "toggle-view 1"
69+"Mod+Ctrl+2" = "toggle-view 2"
70+"Mod+Ctrl+3" = "toggle-view 3"
71+"Mod+Ctrl+4" = "toggle-view 4"
72+"Mod+Ctrl+5" = "toggle-view 5"
73+"Mod+Ctrl+6" = "toggle-view 6"
74+"Mod+Ctrl+7" = "toggle-view 7"
75+"Mod+Ctrl+8" = "toggle-view 8"
76+"Mod+Ctrl+9" = "toggle-view 9"
77+"Mod+Shift+1" = "tag 1"
78+"Mod+Shift+2" = "tag 2"
79+"Mod+Shift+3" = "tag 3"
80+"Mod+Shift+4" = "tag 4"
81+"Mod+Shift+5" = "tag 5"
82+"Mod+Shift+6" = "tag 6"
83+"Mod+Shift+7" = "tag 7"
84+"Mod+Shift+8" = "tag 8"
85+"Mod+Shift+9" = "tag 9"
86+
87+# Rules match app_id and title by regex, later rules overriding earlier ones.
88+# [[rules]]
89+# app_id = "^pavucontrol$"
90+# floating = true
Modified file: crates/config/src/lib.rs+306 additions
@@ -1 +1,307 @@
1+//! The TOML config. Parsing and validation only, the compositor applies it.
12
3+use monolith_ipc::Command;
4+use regex::Regex;
5+use serde::Deserialize;
6+use std::collections::BTreeMap;
7+use std::fmt;
8+use std::path::{Path, PathBuf};
9+
10+pub const DEFAULT: &str = include_str!("../default.toml");
11+
12+#[derive(Debug, Clone)]
13+pub struct Config {
14+ pub general: General,
15+ pub keyboard: Keyboard,
16+ pub binds: Vec<Bind>,
17+ pub rules: Vec<Rule>,
18+ pub startup: Vec<Vec<String>>,
19+}
20+
21+#[derive(Deserialize, Debug, Clone, PartialEq)]
22+#[serde(default, deny_unknown_fields)]
23+pub struct General {
24+ /// What `Mod` means in binds.
25+ #[serde(rename = "mod")]
26+ pub mod_key: Modifier,
27+ pub gaps: Gaps,
28+}
29+
30+impl Default for General {
31+ fn default() -> Self {
32+ Self {
33+ mod_key: Modifier::Super,
34+ gaps: Gaps::default(),
35+ }
36+ }
37+}
38+
39+#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
40+#[serde(default, deny_unknown_fields)]
41+pub struct Gaps {
42+ pub inner: f64,
43+ pub outer: f64,
44+}
45+
46+impl Default for Gaps {
47+ fn default() -> Self {
48+ Self {
49+ inner: 8.0,
50+ outer: 8.0,
51+ }
52+ }
53+}
54+
55+#[derive(Deserialize, Debug, Clone, PartialEq)]
56+#[serde(default, deny_unknown_fields)]
57+pub struct Keyboard {
58+ pub rules: String,
59+ pub model: String,
60+ pub layout: String,
61+ pub variant: String,
62+ pub options: Option<String>,
63+ pub repeat_delay: i32,
64+ pub repeat_rate: i32,
65+}
66+
67+impl Default for Keyboard {
68+ fn default() -> Self {
69+ Self {
70+ rules: String::new(),
71+ model: String::new(),
72+ layout: String::new(),
73+ variant: String::new(),
74+ options: None,
75+ repeat_delay: 200,
76+ repeat_rate: 25,
77+ }
78+ }
79+}
80+
81+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
82+#[serde(rename_all = "lowercase")]
83+pub enum Modifier {
84+ Super,
85+ Alt,
86+ Ctrl,
87+ Shift,
88+}
89+
90+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
91+pub struct Mods {
92+ pub logo: bool,
93+ pub alt: bool,
94+ pub ctrl: bool,
95+ pub shift: bool,
96+}
97+
98+impl Mods {
99+ fn set(&mut self, m: Modifier) {
100+ match m {
101+ Modifier::Super => self.logo = true,
102+ Modifier::Alt => self.alt = true,
103+ Modifier::Ctrl => self.ctrl = true,
104+ Modifier::Shift => self.shift = true,
105+ }
106+ }
107+}
108+
109+#[derive(Debug, Clone, PartialEq)]
110+pub struct Bind {
111+ pub mods: Mods,
112+ /// An xkb keysym name, resolved by the compositor.
113+ pub key: String,
114+ pub command: Command,
115+}
116+
117+#[derive(Debug, Clone)]
118+pub struct Rule {
119+ pub app_id: Option<Regex>,
120+ pub title: Option<Regex>,
121+ pub tag: Option<String>,
122+ pub output: Option<String>,
123+ pub floating: Option<bool>,
124+ pub sticky: Option<bool>,
125+ pub scratchpad: Option<String>,
126+}
127+
128+/// Everything the matching rules say about a window, later rules winning.
129+#[derive(Debug, Clone, Default, PartialEq)]
130+pub struct Placement {
131+ pub tag: Option<String>,
132+ pub output: Option<String>,
133+ pub floating: Option<bool>,
134+ pub sticky: Option<bool>,
135+ pub scratchpad: Option<String>,
136+}
137+
138+#[derive(Debug)]
139+pub enum Error {
140+ Io(PathBuf, std::io::Error),
141+ Toml(toml::de::Error),
142+ Bind(String, String),
143+ Regex(String, regex::Error),
144+}
145+
146+impl fmt::Display for Error {
147+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148+ match self {
149+ Error::Io(p, e) => write!(f, "{}: {e}", p.display()),
150+ Error::Toml(e) => write!(f, "{e}"),
151+ Error::Bind(k, e) => write!(f, "bind {k:?}: {e}"),
152+ Error::Regex(r, e) => write!(f, "rule pattern {r:?}: {e}"),
153+ }
154+ }
155+}
156+
157+impl std::error::Error for Error {}
158+
159+#[derive(Deserialize)]
160+#[serde(deny_unknown_fields)]
161+struct Raw {
162+ #[serde(default)]
163+ startup: Vec<Vec<String>>,
164+ #[serde(default)]
165+ general: General,
166+ #[serde(default)]
167+ keyboard: Keyboard,
168+ #[serde(default)]
169+ binds: BTreeMap<String, Words>,
170+ #[serde(default)]
171+ rules: Vec<RawRule>,
172+}
173+
174+#[derive(Deserialize)]
175+#[serde(untagged)]
176+enum Words {
177+ Line(String),
178+ List(Vec<String>),
179+}
180+
181+#[derive(Deserialize)]
182+#[serde(deny_unknown_fields)]
183+struct RawRule {
184+ app_id: Option<String>,
185+ title: Option<String>,
186+ tag: Option<String>,
187+ output: Option<String>,
188+ floating: Option<bool>,
189+ sticky: Option<bool>,
190+ scratchpad: Option<String>,
191+}
192+
193+impl Config {
194+ /// `$MONOLITH_CONFIG`, else `$XDG_CONFIG_HOME/monolith/config.toml`.
195+ pub fn path() -> Option<PathBuf> {
196+ if let Some(p) = std::env::var_os("MONOLITH_CONFIG") {
197+ return Some(p.into());
198+ }
199+ let base = std::env::var_os("XDG_CONFIG_HOME")
200+ .map(PathBuf::from)
201+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
202+ Some(base.join("monolith/config.toml"))
203+ }
204+
205+ /// Loads `path`, or the built-in default when it does not exist.
206+ pub fn load(path: Option<&Path>) -> Result<Self, Error> {
207+ match path {
208+ Some(p) => match std::fs::read_to_string(p) {
209+ Ok(text) => Self::parse(&text),
210+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::parse(DEFAULT),
211+ Err(e) => Err(Error::Io(p.to_owned(), e)),
212+ },
213+ None => Self::parse(DEFAULT),
214+ }
215+ }
216+
217+ pub fn parse(text: &str) -> Result<Self, Error> {
218+ let raw: Raw = toml::from_str(text).map_err(Error::Toml)?;
219+ let mod_key = raw.general.mod_key;
220+ let binds = raw
221+ .binds
222+ .into_iter()
223+ .map(|(combo, words)| parse_bind(&combo, words, mod_key))
224+ .collect::<Result<_, _>>()?;
225+ let regex = |p: Option<String>| {
226+ p.map(|p| Regex::new(&p).map_err(|e| Error::Regex(p, e)))
227+ .transpose()
228+ };
229+ let rules = raw
230+ .rules
231+ .into_iter()
232+ .map(|r| {
233+ Ok(Rule {
234+ app_id: regex(r.app_id)?,
235+ title: regex(r.title)?,
236+ tag: r.tag,
237+ output: r.output,
238+ floating: r.floating,
239+ sticky: r.sticky,
240+ scratchpad: r.scratchpad,
241+ })
242+ })
243+ .collect::<Result<_, Error>>()?;
244+ Ok(Self {
245+ general: raw.general,
246+ keyboard: raw.keyboard,
247+ binds,
248+ rules,
249+ startup: raw.startup,
250+ })
251+ }
252+
253+ pub fn placement(&self, app_id: &str, title: &str) -> Placement {
254+ let mut p = Placement::default();
255+ let hit = |re: &Option<Regex>, s: &str| re.as_ref().is_none_or(|re| re.is_match(s));
256+ for r in &self.rules {
257+ if !hit(&r.app_id, app_id) || !hit(&r.title, title) {
258+ continue;
259+ }
260+ p.tag = r.tag.clone().or(p.tag);
261+ p.output = r.output.clone().or(p.output);
262+ p.floating = r.floating.or(p.floating);
263+ p.sticky = r.sticky.or(p.sticky);
264+ p.scratchpad = r.scratchpad.clone().or(p.scratchpad);
265+ }
266+ p
267+ }
268+}
269+
270+fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Error> {
271+ let bad = |m: String| Error::Bind(combo.to_owned(), m);
272+ let parts: Vec<&str> = combo.split('+').map(str::trim).collect();
273+ let Some((&key, mods)) = parts.split_last().filter(|(k, _)| !k.is_empty()) else {
274+ return Err(bad("no key".into()));
275+ };
276+ let mut set = Mods::default();
277+ for m in mods {
278+ let m = match m.to_ascii_lowercase().as_str() {
279+ "mod" => mod_key,
280+ "super" | "logo" | "win" => Modifier::Super,
281+ "alt" => Modifier::Alt,
282+ "ctrl" | "control" => Modifier::Ctrl,
283+ "shift" => Modifier::Shift,
284+ other => return Err(bad(format!("unknown modifier {other}"))),
285+ };
286+ set.set(m);
287+ }
288+ let words: Vec<String> = match words {
289+ Words::Line(s) => s.split_whitespace().map(str::to_owned).collect(),
290+ Words::List(v) => v,
291+ };
292+ let command = Command::parse(&words).map_err(|e| bad(e.0))?;
293+ Ok(Bind {
294+ mods: set,
295+ key: key.to_owned(),
296+ command,
297+ })
298+}
299+
300+#[cfg(test)]
301+mod tests {
302+ #[test]
303+ fn the_default_config_parses() {
304+ let c = super::Config::parse(super::DEFAULT).unwrap();
305+ assert!(c.binds.len() > 40);
306+ }
307+}
Modified file: crates/ipc/Cargo.toml+2 additions
@@ -8,3 +8,5 @@ publish.workspace = true
88 [lints]
99 workspace = true
1010
11+[dependencies]
12+serde.workspace = true
Modified file: crates/ipc/src/lib.rs+309 additions
@@ -1 +1,310 @@
1+//! The control protocol. Newline-delimited JSON over a unix socket, one
2+//! message per line, each carrying the protocol version `v`.
3+//!
4+//! Commands also have a word form, `focus left` or `spawn foot`, shared by
5+//! the CLI and keybindings in the config.
16
7+use serde::{Deserialize, Serialize};
8+use std::fmt;
9+use std::path::PathBuf;
10+
11+pub const VERSION: u32 = 1;
12+
13+/// Where the compositor serving `display` listens.
14+pub fn socket_path_for(display: &str) -> Option<PathBuf> {
15+ let dir = std::env::var_os("XDG_RUNTIME_DIR")?;
16+ Some(PathBuf::from(dir).join(format!("monolith-{display}.sock")))
17+}
18+
19+/// Where a client should connect. `$MONOLITH_SOCKET`, else the socket of
20+/// the compositor serving `$WAYLAND_DISPLAY`.
21+pub fn socket_path() -> Option<PathBuf> {
22+ if let Some(p) = std::env::var_os("MONOLITH_SOCKET") {
23+ return Some(p.into());
24+ }
25+ socket_path_for(&std::env::var("WAYLAND_DISPLAY").ok()?)
26+}
27+
28+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
29+#[serde(rename_all = "kebab-case")]
30+pub enum Dir {
31+ Left,
32+ Right,
33+ Up,
34+ Down,
35+}
36+
37+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
38+#[serde(rename_all = "kebab-case")]
39+pub enum Axis {
40+ Horizontal,
41+ Vertical,
42+}
43+
44+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
45+#[serde(rename_all = "kebab-case")]
46+pub enum ExpandScope {
47+ #[default]
48+ Window,
49+ Block,
50+ All,
51+}
52+
53+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
54+#[serde(tag = "command", rename_all = "kebab-case", deny_unknown_fields)]
55+pub enum Command {
56+ Spawn {
57+ argv: Vec<String>,
58+ },
59+ Close,
60+ Quit,
61+ Reload,
62+ Focus {
63+ dir: Dir,
64+ },
65+ Move {
66+ dir: Dir,
67+ },
68+ MoveBlock {
69+ dir: Dir,
70+ },
71+ Resize {
72+ axis: Axis,
73+ delta: f64,
74+ },
75+ Expand {
76+ scope: ExpandScope,
77+ },
78+ Flip,
79+ Float,
80+ Fullscreen,
81+ Sticky,
82+ View {
83+ tag: String,
84+ },
85+ ToggleView {
86+ tag: String,
87+ },
88+ ViewEverywhere {
89+ tag: String,
90+ },
91+ /// Sends the focused window to a tag.
92+ Tag {
93+ tag: String,
94+ },
95+ ReorderTag {
96+ tag: String,
97+ index: usize,
98+ },
99+ FocusOutput {
100+ dir: Dir,
101+ },
102+ SendToOutput {
103+ dir: Dir,
104+ },
105+ /// Adds the focused window to a scratchpad.
106+ Scratchpad {
107+ name: String,
108+ },
109+ Summon {
110+ name: String,
111+ },
112+ Dismiss {
113+ name: Option<String>,
114+ },
115+}
116+
117+#[derive(Debug, Clone, PartialEq, Eq)]
118+pub struct ParseError(pub String);
119+
120+impl fmt::Display for ParseError {
121+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122+ f.write_str(&self.0)
123+ }
124+}
125+
126+impl std::error::Error for ParseError {}
127+
128+impl Command {
129+ /// Parses the word form, as in `["move-block", "left"]`.
130+ pub fn parse<S: AsRef<str>>(words: &[S]) -> Result<Self, ParseError> {
131+ let words: Vec<&str> = words.iter().map(AsRef::as_ref).collect();
132+ let Some((&name, args)) = words.split_first() else {
133+ return Err(ParseError("empty command".into()));
134+ };
135+ let err = |m: &str| ParseError(format!("{name}: {m}"));
136+ let one = || match args {
137+ [a] => Ok(a.to_string()),
138+ _ => Err(err("takes exactly one argument")),
139+ };
140+ let none = |c: Command| {
141+ if args.is_empty() {
142+ Ok(c)
143+ } else {
144+ Err(err("takes no arguments"))
145+ }
146+ };
147+ let dir = || -> Result<Dir, ParseError> {
148+ match one()?.as_str() {
149+ "left" | "h" => Ok(Dir::Left),
150+ "right" | "l" => Ok(Dir::Right),
151+ "up" | "k" => Ok(Dir::Up),
152+ "down" | "j" => Ok(Dir::Down),
153+ d => Err(err(&format!("unknown direction {d}"))),
154+ }
155+ };
156+ Ok(match name {
157+ "spawn" if !args.is_empty() => Command::Spawn {
158+ argv: args.iter().map(|a| a.to_string()).collect(),
159+ },
160+ "spawn" => return Err(err("needs a program")),
161+ "close" => none(Command::Close)?,
162+ "quit" => none(Command::Quit)?,
163+ "reload" => none(Command::Reload)?,
164+ "flip" => none(Command::Flip)?,
165+ "float" => none(Command::Float)?,
166+ "fullscreen" => none(Command::Fullscreen)?,
167+ "sticky" => none(Command::Sticky)?,
168+ "focus" => Command::Focus { dir: dir()? },
169+ "move" => Command::Move { dir: dir()? },
170+ "move-block" => Command::MoveBlock { dir: dir()? },
171+ "focus-output" => Command::FocusOutput { dir: dir()? },
172+ "send-to-output" => Command::SendToOutput { dir: dir()? },
173+ "resize" => {
174+ let [axis, delta] = args else {
175+ return Err(err("takes an axis and a delta"));
176+ };
177+ let axis = match *axis {
178+ "horizontal" | "h" => Axis::Horizontal,
179+ "vertical" | "v" => Axis::Vertical,
180+ a => return Err(err(&format!("unknown axis {a}"))),
181+ };
182+ let delta = delta
183+ .parse()
184+ .map_err(|_| err(&format!("bad delta {delta}")))?;
185+ Command::Resize { axis, delta }
186+ }
187+ "expand" => Command::Expand {
188+ scope: match args {
189+ [] | ["window"] => ExpandScope::Window,
190+ ["block"] => ExpandScope::Block,
191+ ["all"] => ExpandScope::All,
192+ _ => return Err(err("takes window, block or all")),
193+ },
194+ },
195+ "view" => Command::View { tag: one()? },
196+ "toggle-view" => Command::ToggleView { tag: one()? },
197+ "view-everywhere" => Command::ViewEverywhere { tag: one()? },
198+ "tag" => Command::Tag { tag: one()? },
199+ "reorder-tag" => {
200+ let [tag, index] = args else {
201+ return Err(err("takes a tag and an index"));
202+ };
203+ let index = index
204+ .parse()
205+ .map_err(|_| err(&format!("bad index {index}")))?;
206+ Command::ReorderTag {
207+ tag: tag.to_string(),
208+ index,
209+ }
210+ }
211+ "scratchpad" => Command::Scratchpad { name: one()? },
212+ "summon" => Command::Summon { name: one()? },
213+ "dismiss" => Command::Dismiss {
214+ name: match args {
215+ [] => None,
216+ [n] => Some(n.to_string()),
217+ _ => return Err(err("takes at most one name")),
218+ },
219+ },
220+ _ => return Err(ParseError(format!("unknown command {name}"))),
221+ })
222+ }
223+}
224+
225+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
226+#[serde(rename_all = "kebab-case")]
227+pub enum Request {
228+ Run(Command),
229+ Get(Query),
230+ /// Streams a `State` event after every change until the client hangs up.
231+ Subscribe {},
232+}
233+
234+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
235+#[serde(rename_all = "kebab-case")]
236+pub enum Query {
237+ State,
238+ Version,
239+}
240+
241+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
242+pub struct Envelope<T> {
243+ pub v: u32,
244+ #[serde(default, skip_serializing_if = "Option::is_none")]
245+ pub id: Option<u64>,
246+ #[serde(flatten)]
247+ pub body: T,
248+}
249+
250+impl<T> Envelope<T> {
251+ pub fn new(id: Option<u64>, body: T) -> Self {
252+ Self {
253+ v: VERSION,
254+ id,
255+ body,
256+ }
257+ }
258+}
259+
260+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
261+#[serde(rename_all = "kebab-case")]
262+pub enum Reply {
263+ Ok(Option<Answer>),
264+ Error(String),
265+ Event(State),
266+}
267+
268+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
269+#[serde(rename_all = "kebab-case")]
270+pub enum Answer {
271+ State(State),
272+ Version(String),
273+}
274+
275+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
276+pub struct State {
277+ pub tags: Vec<Tag>,
278+ pub outputs: Vec<Output>,
279+ pub windows: Vec<Window>,
280+}
281+
282+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
283+pub struct Tag {
284+ pub name: String,
285+ pub urgent: bool,
286+}
287+
288+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
289+pub struct Output {
290+ pub name: String,
291+ /// x, y, width, height in logical pixels.
292+ pub area: [f64; 4],
293+ pub viewed: Vec<String>,
294+ pub active: bool,
295+}
296+
297+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
298+pub struct Window {
299+ pub id: u64,
300+ pub app_id: String,
301+ pub title: String,
302+ pub tag: String,
303+ pub output: Option<String>,
304+ pub floating: bool,
305+ pub fullscreen: bool,
306+ pub sticky: bool,
307+ pub scratchpad: Option<String>,
308+ pub visible: bool,
309+ pub focused: bool,
310+}