atagen

monolith

public
Star0
Watch0
ssh://brewery@pls.lobotomise.me:33/atagen/monolith.git
crates/ipc/src/lib.rsRust337 lines10.1 KB
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 beer`, shared by
5
//! the CLI and keybindings in the config.
6
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
pub use monolith_layout::{Axis, Dir};
29
30
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
31
#[serde(rename_all = "kebab-case")]
32
pub enum ExpandScope {
33
    #[default]
34
    Window,
35
    Container,
36
}
37
38
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
39
#[serde(tag = "command", rename_all = "kebab-case", deny_unknown_fields)]
40
pub enum Command {
41
    Spawn {
42
        argv: Vec<String>,
43
    },
44
    Close,
45
    Quit,
46
    Reload,
47
    Focus {
48
        dir: Dir,
49
    },
50
    /// Focuses the latest window on the other layer, floating or tiled.
51
    FocusLayer,
52
    Move {
53
        dir: Dir,
54
    },
55
    MoveContainer {
56
        dir: Dir,
57
    },
58
    Resize {
59
        axis: Axis,
60
        delta: f64,
61
    },
62
    /// Shares the focused window's block equally among its children.
63
    BalanceBlock,
64
    Expand {
65
        scope: ExpandScope,
66
    },
67
    /// Expands every container above the focused window towards it.
68
    Maximize,
69
    /// Makes the next window move do the opposite of the configured default,
70
    /// swap or merge. Again before moving cancels it.
71
    InvertMove,
72
    Flip,
73
    Float,
74
    Fullscreen,
75
    Sticky,
76
    View {
77
        tag: String,
78
    },
79
    ToggleView {
80
        tag: String,
81
    },
82
    ViewEverywhere {
83
        tag: String,
84
    },
85
    /// Sends the focused window to a tag.
86
    Tag {
87
        tag: String,
88
    },
89
    ReorderTag {
90
        tag: String,
91
        index: usize,
92
    },
93
    FocusOutput {
94
        dir: Dir,
95
    },
96
    SendToOutput {
97
        dir: Dir,
98
    },
99
    /// Adds the focused window to a scratchpad.
100
    Scratchpad {
101
        name: String,
102
    },
103
    Summon {
104
        name: String,
105
    },
106
    Dismiss {
107
        name: Option<String>,
108
    },
109
    /// Switches outputs on or off, all of them without a name.
110
    Power {
111
        on: bool,
112
        #[serde(default)]
113
        output: 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
    /// Whether a bind running this still fires on a locked session, which
130
    /// leaves out everything that moves focus, windows or tags, and quitting.
131
    pub fn allowed_while_locked(&self) -> bool {
132
        matches!(self, Command::Spawn { .. } | Command::Reload)
133
    }
134
135
    /// Parses the word form, as in `["move-container", "left"]`.
136
    pub fn parse<S: AsRef<str>>(words: &[S]) -> Result<Self, ParseError> {
137
        let words: Vec<&str> = words.iter().map(AsRef::as_ref).collect();
138
        let Some((&name, args)) = words.split_first() else {
139
            return Err(ParseError("empty command".into()));
140
        };
141
        let err = |m: &str| ParseError(format!("{name}: {m}"));
142
        let one = || match args {
143
            [a] => Ok(a.to_string()),
144
            _ => Err(err("takes exactly one argument")),
145
        };
146
        let none = |c: Command| {
147
            if args.is_empty() {
148
                Ok(c)
149
            } else {
150
                Err(err("takes no arguments"))
151
            }
152
        };
153
        let dir = || -> Result<Dir, ParseError> {
154
            match one()?.as_str() {
155
                "left" | "h" => Ok(Dir::Left),
156
                "right" | "l" => Ok(Dir::Right),
157
                "up" | "k" => Ok(Dir::Up),
158
                "down" | "j" => Ok(Dir::Down),
159
                d => Err(err(&format!("unknown direction {d}"))),
160
            }
161
        };
162
        Ok(match name {
163
            "spawn" if !args.is_empty() => Command::Spawn {
164
                argv: args.iter().map(|a| a.to_string()).collect(),
165
            },
166
            "spawn" => return Err(err("needs a program")),
167
            "close" => none(Command::Close)?,
168
            "quit" => none(Command::Quit)?,
169
            "reload" => none(Command::Reload)?,
170
            "flip" => none(Command::Flip)?,
171
            "maximize" => none(Command::Maximize)?,
172
            "invert-move" => none(Command::InvertMove)?,
173
            "balance-block" => none(Command::BalanceBlock)?,
174
            "float" => none(Command::Float)?,
175
            "fullscreen" => none(Command::Fullscreen)?,
176
            "sticky" => none(Command::Sticky)?,
177
            "focus" => Command::Focus { dir: dir()? },
178
            "focus-layer" => none(Command::FocusLayer)?,
179
            "move" => Command::Move { dir: dir()? },
180
            "move-container" => Command::MoveContainer { dir: dir()? },
181
            "focus-output" => Command::FocusOutput { dir: dir()? },
182
            "send-to-output" => Command::SendToOutput { dir: dir()? },
183
            "resize" => {
184
                let [axis, delta] = args else {
185
                    return Err(err("takes an axis and a delta"));
186
                };
187
                let axis = match *axis {
188
                    "horizontal" | "h" => Axis::Horizontal,
189
                    "vertical" | "v" => Axis::Vertical,
190
                    a => return Err(err(&format!("unknown axis {a}"))),
191
                };
192
                let delta = delta
193
                    .parse()
194
                    .map_err(|_| err(&format!("bad delta {delta}")))?;
195
                Command::Resize { axis, delta }
196
            }
197
            "expand" => Command::Expand {
198
                scope: match args {
199
                    [] | ["window"] => ExpandScope::Window,
200
                    ["container"] => ExpandScope::Container,
201
                    _ => return Err(err("takes window or container")),
202
                },
203
            },
204
            "view" => Command::View { tag: one()? },
205
            "toggle-view" => Command::ToggleView { tag: one()? },
206
            "view-everywhere" => Command::ViewEverywhere { tag: one()? },
207
            "tag" => Command::Tag { tag: one()? },
208
            "reorder-tag" => {
209
                let [tag, index] = args else {
210
                    return Err(err("takes a tag and an index"));
211
                };
212
                let index = index
213
                    .parse()
214
                    .map_err(|_| err(&format!("bad index {index}")))?;
215
                Command::ReorderTag {
216
                    tag: tag.to_string(),
217
                    index,
218
                }
219
            }
220
            "scratchpad" => Command::Scratchpad { name: one()? },
221
            "summon" => Command::Summon { name: one()? },
222
            "dismiss" => Command::Dismiss {
223
                name: match args {
224
                    [] => None,
225
                    [n] => Some(n.to_string()),
226
                    _ => return Err(err("takes at most one name")),
227
                },
228
            },
229
            "power" => {
230
                let (on, output) = match args {
231
                    ["on"] => (true, None),
232
                    ["off"] => (false, None),
233
                    ["on", o] => (true, Some(o.to_string())),
234
                    ["off", o] => (false, Some(o.to_string())),
235
                    _ => return Err(err("takes on or off and optionally an output")),
236
                };
237
                Command::Power { on, output }
238
            }
239
            _ => return Err(ParseError(format!("unknown command {name}"))),
240
        })
241
    }
242
}
243
244
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
245
#[serde(rename_all = "kebab-case")]
246
pub enum Request {
247
    Run(Command),
248
    Get(Query),
249
    /// Streams a `State` event after every change until the client hangs up.
250
    Subscribe {},
251
}
252
253
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
254
#[serde(rename_all = "kebab-case")]
255
pub enum Query {
256
    State,
257
    Version,
258
    Warnings,
259
}
260
261
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
262
pub struct Envelope<T> {
263
    pub v: u32,
264
    #[serde(default, skip_serializing_if = "Option::is_none")]
265
    pub id: Option<u64>,
266
    #[serde(flatten)]
267
    pub body: T,
268
}
269
270
impl<T> Envelope<T> {
271
    pub fn new(id: Option<u64>, body: T) -> Self {
272
        Self {
273
            v: VERSION,
274
            id,
275
            body,
276
        }
277
    }
278
}
279
280
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
281
#[serde(rename_all = "kebab-case")]
282
pub enum Reply {
283
    Ok(Option<Answer>),
284
    Error(String),
285
    Event(State),
286
}
287
288
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
289
#[serde(rename_all = "kebab-case")]
290
pub enum Answer {
291
    State(State),
292
    Version(String),
293
    Warnings(Vec<String>),
294
}
295
296
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
297
pub struct State {
298
    /// The next window move does the opposite of the configured default.
299
    pub move_inverted: bool,
300
    pub tags: Vec<Tag>,
301
    pub outputs: Vec<Output>,
302
    pub windows: Vec<Window>,
303
}
304
305
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
306
pub struct Tag {
307
    pub name: String,
308
    pub urgent: bool,
309
}
310
311
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
312
pub struct Output {
313
    pub name: String,
314
    /// x, y, width, height in logical pixels.
315
    pub area: [f64; 4],
316
    pub viewed: Vec<String>,
317
    pub active: bool,
318
}
319
320
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
321
pub struct Window {
322
    pub id: u64,
323
    pub app_id: String,
324
    pub title: String,
325
    pub tag: String,
326
    pub output: Option<String>,
327
    /// x, y, width, height in logical pixels, while placed on an output.
328
    #[serde(default)]
329
    pub area: Option<[f64; 4]>,
330
    pub floating: bool,
331
    pub fullscreen: bool,
332
    pub sticky: bool,
333
    pub scratchpad: Option<String>,
334
    pub visible: bool,
335
    pub focused: bool,
336
    pub urgent: bool,
337
}