Commit fe835aed6c8d
tarball7 files changed+796-0
@@ -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"@@ -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
@@ -8,3 +8,8 @@ publish.workspace = true
88
[lints]99
workspace = true1010
11
+[dependencies]12
+monolith-ipc.workspace = true13
+regex = "1"14
+serde.workspace = true15
+toml = "1"@@ -0,0 +1,90 @@
1
+# monolith's built-in config, used when ~/.config/monolith/config.toml is2
+# 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 that9
+# keeps super for itself.10
+mod = "super"11
+gaps = { inner = 8, outer = 8 }12
+13
+[keyboard]14
+layout = ""15
+repeat_delay = 20016
+repeat_rate = 2517
+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@@ -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 = raw221
+ .binds222
+ .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 = raw230
+ .rules231
+ .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
+ p267
+ }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
+}@@ -8,3 +8,5 @@ publish.workspace = true
88
[lints]99
workspace = true1010
11
+[dependencies]12
+serde.workspace = true@@ -1 +1,310 @@
1
+//! The control protocol. Newline-delimited JSON over a unix socket, one2
+//! message per line, each carrying the protocol version `v`.3
+//!4
+//! Commands also have a word form, `focus left` or `spawn foot`, shared by5
+//! 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 of20
+/// 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 = delta183
+ .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 = index204
+ .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
+}