atagen

monolith

public
Star0
Watch0

Commit 4e5afcada08b

tarball

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

11 files changed+937-73
monolith: place windows through the layout model

Toplevels enter the layout on their first commit rather than at creation, because clients set app_id and title in between and rules need both. Windows started by the spawn command land on the output and tag the user was on when it ran, matched by PID ancestry. Focusing a homeless window does not count as leaving the viewed tag.
Modified file: crates/layout/Cargo.toml+1 additions−1 deletions
@@ -10,7 +10,7 @@ workspace = true
1010
1111 [dependencies]
1212 monolith-planner.workspace = true
13-slotmap = "1"
13+slotmap.workspace = true
1414
1515 [dev-dependencies]
1616 proptest.workspace = true
Modified file: crates/layout/src/lib.rs+23 additions−6 deletions
@@ -366,6 +366,18 @@ impl Layout {
366366 }
367367 }
368368
369+ /// The tag a launch from `o` belongs to. The focused window's tag, else
370+ /// the first viewed tag, else homeless. Homeless is always on screen, so
371+ /// focusing one of its windows does not mean the user left their tag.
372+ pub fn launch_tag(&self, o: OutputId) -> TagId {
373+ self.focused_on(o)
374+ .map(|w| &self.windows[w])
375+ .filter(|w| w.scratchpad.is_none() && !w.sticky && w.tag != self.homeless)
376+ .map(|w| w.tag)
377+ .or_else(|| self.outputs.get(o)?.viewed.first().copied())
378+ .unwrap_or(self.homeless)
379+ }
380+
369381 pub fn view_everywhere(&mut self, tag: TagId) {
370382 for o in self.outputs.keys().collect::<Vec<_>>() {
371383 self.view(o, tag);
@@ -533,12 +545,7 @@ impl Layout {
533545 self.focus(w);
534546 return Some(w);
535547 }
536- let here = self.outputs[o].area;
537- let next = nearest(
538- here,
539- dir,
540- self.outputs.iter().map(|(id, out)| (id, out.area)),
541- )?;
548+ let next = self.output_towards(o, dir)?;
542549 self.active = Some(next);
543550 let w = self.focused_on(next);
544551 if let Some(w) = w {
@@ -547,6 +554,16 @@ impl Layout {
547554 w
548555 }
549556
557+ pub fn output_towards(&self, from: OutputId, dir: Dir) -> Option<OutputId> {
558+ let here = self.outputs.get(from)?.area;
559+ let others = self
560+ .outputs
561+ .iter()
562+ .filter(|&(id, _)| id != from)
563+ .map(|(id, out)| (id, out.area));
564+ nearest(here, dir, others)
565+ }
566+
550567 fn rect_of(&self, w: WindowId) -> Option<Rect> {
551568 let o = self.windows.get(w)?.output?;
552569 let arr = self.arrange(o);
Modified file: crates/monolith/Cargo.toml+5 additions
@@ -13,7 +13,12 @@ profile = ["dep:tracy-client", "tracy-client/enable"]
1313
1414 [dependencies]
1515 calloop-wayland-source = "0.4"
16+monolith-config.workspace = true
17+monolith-ipc.workspace = true
18+monolith-layout.workspace = true
1619 monolith-render-vk.workspace = true
20+serde_json.workspace = true
21+slotmap.workspace = true
1722 smithay = { workspace = true, features = ["desktop"] }
1823 tracing = "0.1"
1924 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Added file: crates/monolith/src/commands.rs+190 additions
@@ -0,0 +1,190 @@
1+use crate::state::State;
2+use monolith_config::{Config, Mods};
3+use monolith_ipc::Command;
4+use monolith_layout::{Axis, Dir, ExpandScope, WindowId};
5+use smithay::input::keyboard::{Keysym, ModifiersState, xkb};
6+use std::process::Stdio;
7+
8+pub struct Binds(Vec<(Mods, Keysym, Command)>);
9+
10+impl Binds {
11+ pub fn new(config: &Config) -> Self {
12+ let binds = config
13+ .binds
14+ .iter()
15+ .filter_map(|b| {
16+ let sym = xkb::keysym_from_name(&b.key, xkb::KEYSYM_CASE_INSENSITIVE);
17+ if sym.raw() == xkb::keysyms::KEY_NoSymbol {
18+ tracing::warn!("bind: unknown key {:?}", b.key);
19+ return None;
20+ }
21+ Some((b.mods, sym, b.command.clone()))
22+ })
23+ .collect();
24+ Self(binds)
25+ }
26+
27+ /// Matches on the unshifted keysyms so `Mod+Shift+1` means the 1 key.
28+ pub fn find(&self, mods: &ModifiersState, syms: &[Keysym]) -> Option<Command> {
29+ let held = Mods {
30+ logo: mods.logo,
31+ alt: mods.alt,
32+ ctrl: mods.ctrl,
33+ shift: mods.shift,
34+ };
35+ self.0
36+ .iter()
37+ .find(|(m, sym, _)| *m == held && syms.contains(sym))
38+ .map(|(_, _, c)| c.clone())
39+ }
40+}
41+
42+fn dir(d: monolith_ipc::Dir) -> Dir {
43+ match d {
44+ monolith_ipc::Dir::Left => Dir::Left,
45+ monolith_ipc::Dir::Right => Dir::Right,
46+ monolith_ipc::Dir::Up => Dir::Up,
47+ monolith_ipc::Dir::Down => Dir::Down,
48+ }
49+}
50+
51+impl State {
52+ pub fn run(&mut self, cmd: Command) -> Result<(), String> {
53+ let output = self.layout.active_output();
54+ let focused = self.layout.focused();
55+ let on_focused = |state: &mut Self, f: fn(&mut monolith_layout::Layout, WindowId)| {
56+ if let Some(w) = focused {
57+ f(&mut state.layout, w);
58+ }
59+ };
60+ match cmd {
61+ Command::Spawn { argv } => self.spawn(&argv)?,
62+ Command::Close => {
63+ if let Some(t) = focused
64+ .and_then(|w| self.windows.get(&w))
65+ .and_then(|w| w.toplevel())
66+ {
67+ t.send_close();
68+ }
69+ }
70+ Command::Quit => self.signal.stop(),
71+ Command::Reload => self.reload()?,
72+ Command::Focus { dir: d } => {
73+ self.layout.focus_dir(dir(d));
74+ }
75+ Command::Move { dir: d } => {
76+ self.layout.move_window(dir(d));
77+ }
78+ Command::MoveBlock { dir: d } => {
79+ self.layout.move_block(dir(d));
80+ }
81+ Command::Resize { axis, delta } => {
82+ let axis = match axis {
83+ monolith_ipc::Axis::Horizontal => Axis::Horizontal,
84+ monolith_ipc::Axis::Vertical => Axis::Vertical,
85+ };
86+ self.layout.resize(axis, delta);
87+ }
88+ Command::Expand { scope } => {
89+ self.layout.expand(match scope {
90+ monolith_ipc::ExpandScope::Window => ExpandScope::Window,
91+ monolith_ipc::ExpandScope::Block => ExpandScope::Block,
92+ monolith_ipc::ExpandScope::All => ExpandScope::All,
93+ });
94+ }
95+ Command::Flip => output.into_iter().for_each(|o| self.layout.flip(o)),
96+ Command::Float => on_focused(self, |l, w| l.toggle_floating(w)),
97+ Command::Fullscreen => on_focused(self, |l, w| l.toggle_fullscreen(w)),
98+ Command::Sticky => on_focused(self, |l, w| l.toggle_sticky(w)),
99+ Command::View { tag } => {
100+ let t = self.layout.tag(&tag);
101+ output.into_iter().for_each(|o| self.layout.view(o, t));
102+ }
103+ Command::ToggleView { tag } => {
104+ let t = self.layout.tag(&tag);
105+ output
106+ .into_iter()
107+ .for_each(|o| self.layout.toggle_view(o, t));
108+ }
109+ Command::ViewEverywhere { tag } => {
110+ let t = self.layout.tag(&tag);
111+ self.layout.view_everywhere(t);
112+ }
113+ Command::Tag { tag } => {
114+ let t = self.layout.tag(&tag);
115+ if let Some(w) = focused {
116+ self.layout.set_tag(w, t);
117+ }
118+ }
119+ Command::ReorderTag { tag, index } => {
120+ let t = self
121+ .layout
122+ .tag_id(&tag)
123+ .ok_or_else(|| format!("no tag {tag}"))?;
124+ self.layout.reorder_tag(t, index);
125+ }
126+ Command::FocusOutput { dir: d } => {
127+ if let Some(o) = output.and_then(|o| self.layout.output_towards(o, dir(d))) {
128+ self.layout.set_active_output(o);
129+ if let Some(w) = self.layout.focused_on(o) {
130+ self.layout.focus(w);
131+ }
132+ }
133+ }
134+ Command::SendToOutput { dir: d } => {
135+ if let (Some(w), Some(o)) = (
136+ focused,
137+ output.and_then(|o| self.layout.output_towards(o, dir(d))),
138+ ) {
139+ self.layout.send_to_output(w, o);
140+ }
141+ }
142+ Command::Scratchpad { name } => {
143+ if let Some(w) = focused {
144+ self.layout.scratchpad_add(w, &name);
145+ }
146+ }
147+ Command::Summon { name } => {
148+ if !self.layout.scratchpads().iter().any(|s| s.name == name) {
149+ return Err(format!("no scratchpad {name}"));
150+ }
151+ self.layout.summon(&name);
152+ }
153+ Command::Dismiss { name } => self.layout.dismiss(name.as_deref()),
154+ }
155+ self.apply_layout();
156+ Ok(())
157+ }
158+
159+ pub fn spawn(&mut self, argv: &[String]) -> Result<(), String> {
160+ let (program, args) = argv.split_first().ok_or("nothing to spawn")?;
161+ let mut cmd = std::process::Command::new(program);
162+ cmd.args(args)
163+ .env("WAYLAND_DISPLAY", &self.socket_name)
164+ .stdin(Stdio::null());
165+ if let Some(sock) = &self.ipc.path {
166+ cmd.env("MONOLITH_SOCKET", sock);
167+ }
168+ let mut child = cmd.spawn().map_err(|e| format!("{program}: {e}"))?;
169+ let output = self.layout.active_output();
170+ let tag = output.map_or(self.layout.homeless(), |o| self.layout.launch_tag(o));
171+ self.launches.record(child.id(), output, tag);
172+ std::thread::spawn(move || child.wait());
173+ Ok(())
174+ }
175+
176+ /// Swaps in a freshly parsed config. A broken file leaves the running
177+ /// config untouched.
178+ pub fn reload(&mut self) -> Result<(), String> {
179+ let config = Config::load(self.config_path.as_deref()).map_err(|e| e.to_string())?;
180+ let keyboard_changed = config.keyboard != self.config.keyboard;
181+ self.binds = Binds::new(&config);
182+ self.layout.gaps = crate::state::gaps(&config);
183+ self.config = config;
184+ if keyboard_changed {
185+ self.reconfigure_keyboard();
186+ }
187+ tracing::info!("config reloaded");
188+ Ok(())
189+ }
190+}
Modified file: crates/monolith/src/handlers.rs+60 additions−19 deletions
@@ -7,6 +7,8 @@ use smithay::input::dnd::{DndGrabHandler, GrabType, Source};
77 use smithay::input::pointer::CursorImageStatus;
88 use smithay::input::{Seat, SeatHandler, SeatState};
99 use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
10+use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode;
11+use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
1012 use smithay::reexports::wayland_server::protocol::wl_seat::WlSeat;
1113 use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
1214 use smithay::reexports::wayland_server::{Client, Resource};
@@ -21,6 +23,7 @@ use smithay::wayland::selection::SelectionHandler;
2123 use smithay::wayland::selection::data_device::{
2224 DataDeviceHandler, DataDeviceState, WaylandDndGrabHandler, set_data_device_focus,
2325 };
26+use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
2427 use smithay::wayland::shell::xdg::{
2528 PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState,
2629 };
@@ -46,22 +49,25 @@ impl CompositorHandler for State {
4649 root = parent;
4750 }
4851 if let Some(window) = self
49- .space
50- .elements()
52+ .windows
53+ .values()
54+ .chain(&self.unmapped)
5155 .find(|w| w.toplevel().is_some_and(|t| t.wl_surface() == &root))
5256 {
5357 window.on_commit();
5458 }
5559 }
56- if let Some(window) = self
57- .space
58- .elements()
59- .find(|w| w.toplevel().is_some_and(|t| t.wl_surface() == surface))
60- .cloned()
61- && let Some(toplevel) = window.toplevel()
62- && !toplevel.is_initial_configure_sent()
60+ if let Some(i) = self
61+ .unmapped
62+ .iter()
63+ .position(|w| w.toplevel().is_some_and(|t| t.wl_surface() == surface))
6364 {
64- toplevel.send_configure();
65+ let window = self.unmapped.remove(i);
66+ let toplevel = window.toplevel().cloned();
67+ self.map_window(window);
68+ if let Some(t) = toplevel {
69+ t.send_configure();
70+ }
6571 }
6672 self.popups.commit(surface);
6773 if let Some(PopupKind::Xdg(popup)) = self.popups.find_popup(surface)
@@ -108,17 +114,19 @@ impl XdgShellHandler for State {
108114 }
109115
110116 fn new_toplevel(&mut self, surface: ToplevelSurface) {
111- let focus = surface.wl_surface().clone();
112- self.space
113- .map_element(Window::new_wayland_window(surface), (0, 0), true);
114- self.relayout();
115- if let Some(keyboard) = self.seat.get_keyboard() {
116- keyboard.set_focus(self, Some(focus), Serial::from(0));
117- }
117+ self.unmapped.push(Window::new_wayland_window(surface));
118+ }
119+
120+ fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
121+ self.unmap_window(surface.wl_surface());
122+ }
123+
124+ fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<WlOutput>) {
125+ self.set_fullscreen(surface.wl_surface(), true);
118126 }
119127
120- fn toplevel_destroyed(&mut self, _surface: ToplevelSurface) {
121- self.relayout();
128+ fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
129+ self.set_fullscreen(surface.wl_surface(), false);
122130 }
123131
124132 fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) {
@@ -204,3 +212,36 @@ impl smithay::wayland::pointer_constraints::PointerConstraintsHandler for State
204212 }
205213
206214 smithay::delegate_dispatch2!(State);
215+
216+impl XdgDecorationHandler for State {
217+ fn new_decoration(&mut self, toplevel: ToplevelSurface) {
218+ server_side(&toplevel);
219+ }
220+
221+ fn request_mode(&mut self, toplevel: ToplevelSurface, _mode: DecorationMode) {
222+ server_side(&toplevel);
223+ }
224+
225+ fn unset_mode(&mut self, toplevel: ToplevelSurface) {
226+ server_side(&toplevel);
227+ }
228+}
229+
230+/// monolith draws no titlebars, and clients are told so whatever they ask for.
231+fn server_side(toplevel: &ToplevelSurface) {
232+ toplevel.with_pending_state(|s| s.decoration_mode = Some(DecorationMode::ServerSide));
233+ if toplevel.is_initial_configure_sent() {
234+ toplevel.send_pending_configure();
235+ }
236+}
237+
238+impl State {
239+ fn set_fullscreen(&mut self, surface: &WlSurface, on: bool) {
240+ if let Some(id) = self.window_id(surface)
241+ && self.layout.window(id).is_some_and(|w| w.fullscreen != on)
242+ {
243+ self.layout.toggle_fullscreen(id);
244+ self.apply_layout();
245+ }
246+ }
247+}
Modified file: crates/monolith/src/input.rs+36 additions−13 deletions
@@ -133,14 +133,37 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for State {
133133 _ => KeyState::Released,
134134 };
135135 let time = InputTime::now();
136- keyboard.input::<(), _>(
136+ let code = Keycode::new(key + 8);
137+ let action = keyboard.input(
137138 state,
138- Keycode::new(key + 8),
139+ code,
139140 key_state,
140141 SERIAL_COUNTER.next_serial(),
141142 time,
142- |_, _, _| FilterResult::Forward,
143+ |state, mods, handle| {
144+ if key_state == KeyState::Released {
145+ let before = state.suppressed.len();
146+ state.suppressed.retain(|k| *k != code);
147+ return if state.suppressed.len() < before {
148+ FilterResult::Intercept(None)
149+ } else {
150+ FilterResult::Forward
151+ };
152+ }
153+ match state.binds.find(mods, &handle.raw_syms()) {
154+ Some(cmd) => {
155+ state.suppressed.push(code);
156+ FilterResult::Intercept(Some(cmd))
157+ }
158+ None => FilterResult::Forward,
159+ }
160+ },
143161 );
162+ if let Some(Some(cmd)) = action
163+ && let Err(e) = state.run(cmd)
164+ {
165+ tracing::warn!("bind failed: {e}");
166+ }
144167 }
145168 }
146169 }
@@ -149,15 +172,15 @@ fn focus_under_pointer(state: &mut State) {
149172 let Some(pos) = state.host.as_ref().map(|h| h.pointer_pos) else {
150173 return;
151174 };
152- let Some((window, _)) = state.space.element_under(pos).map(|(w, l)| (w.clone(), l)) else {
153- return;
154- };
155- state.space.raise_element(&window, true);
156- if let (Some(keyboard), Some(toplevel)) = (state.seat.get_keyboard(), window.toplevel()) {
157- keyboard.set_focus(
158- state,
159- Some(toplevel.wl_surface().clone()),
160- SERIAL_COUNTER.next_serial(),
161- );
175+ let id = state
176+ .space
177+ .element_under(pos)
178+ .and_then(|(w, _)| w.toplevel())
179+ .and_then(|t| state.window_id(t.wl_surface()));
180+ if let Some(id) = id
181+ && state.layout.focused() != Some(id)
182+ {
183+ state.layout.focus(id);
184+ state.apply_layout();
162185 }
163186 }
Added file: crates/monolith/src/ipc.rs+272 additions
@@ -0,0 +1,272 @@
1+//! The JSONL control socket, and the `msg`, `get` and `subscribe` client
2+//! subcommands that talk to it.
3+
4+use crate::state::{State, identity};
5+use monolith_ipc::{Answer, Command, Envelope, Query, Reply, Request, VERSION};
6+use smithay::reexports::calloop::generic::Generic;
7+use smithay::reexports::calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction};
8+use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
9+use std::os::unix::net::{UnixListener, UnixStream};
10+use std::path::PathBuf;
11+use std::process::ExitCode;
12+
13+#[derive(Default)]
14+pub struct Server {
15+ pub path: Option<PathBuf>,
16+ handle: Option<LoopHandle<'static, State>>,
17+ subscribers: Vec<UnixStream>,
18+ last: Option<monolith_ipc::State>,
19+}
20+
21+impl Drop for Server {
22+ fn drop(&mut self) {
23+ if let Some(p) = &self.path {
24+ let _ = std::fs::remove_file(p);
25+ }
26+ }
27+}
28+
29+pub fn init(event_loop: &EventLoop<'static, State>, state: &mut State) -> std::io::Result<()> {
30+ let Some(path) = state
31+ .socket_name
32+ .to_str()
33+ .and_then(monolith_ipc::socket_path_for)
34+ else {
35+ tracing::warn!("no XDG_RUNTIME_DIR, the control socket is disabled");
36+ return Ok(());
37+ };
38+ let _ = std::fs::remove_file(&path);
39+ let listener = UnixListener::bind(&path)?;
40+ listener.set_nonblocking(true)?;
41+ tracing::info!(path = %path.display(), "control socket");
42+ state.ipc.path = Some(path);
43+ let handle = event_loop.handle();
44+ state.ipc.handle = Some(handle.clone());
45+ handle
46+ .insert_source(
47+ Generic::new(listener, Interest::READ, Mode::Level),
48+ |_, listener, state| {
49+ loop {
50+ match listener.accept() {
51+ Ok((stream, _)) => add_client(state, stream),
52+ Err(e) if e.kind() == ErrorKind::WouldBlock => break,
53+ Err(e) => return Err(e),
54+ }
55+ }
56+ Ok(PostAction::Continue)
57+ },
58+ )
59+ .map_err(|e| std::io::Error::other(e.to_string()))?;
60+ Ok(())
61+}
62+
63+fn add_client(state: &mut State, stream: UnixStream) {
64+ let Some(handle) = state.ipc.handle.clone() else {
65+ return;
66+ };
67+ if stream.set_nonblocking(true).is_err() {
68+ return;
69+ }
70+ let mut buf = Vec::new();
71+ let source = Generic::new(stream, Interest::READ, Mode::Level);
72+ let inserted = handle.insert_source(source, move |_, stream, state| {
73+ let mut chunk = [0u8; 4096];
74+ loop {
75+ match (&**stream).read(&mut chunk) {
76+ Ok(0) => return Ok(PostAction::Remove),
77+ Ok(n) => buf.extend_from_slice(&chunk[..n]),
78+ Err(e) if e.kind() == ErrorKind::WouldBlock => break,
79+ Err(_) => return Ok(PostAction::Remove),
80+ }
81+ }
82+ while let Some(i) = buf.iter().position(|&b| b == b'\n') {
83+ let line: Vec<u8> = buf.drain(..=i).collect();
84+ if let Some(reply) = handle_line(state, stream, &line[..i]) {
85+ send(stream, &reply);
86+ }
87+ }
88+ Ok(PostAction::Continue)
89+ });
90+ if let Err(e) = inserted {
91+ tracing::warn!("ipc client dropped: {e}");
92+ }
93+}
94+
95+fn handle_line(state: &mut State, stream: &UnixStream, line: &[u8]) -> Option<Envelope<Reply>> {
96+ let req: Envelope<Request> = match serde_json::from_slice(line) {
97+ Ok(r) => r,
98+ Err(e) => return Some(Envelope::new(None, Reply::Error(e.to_string()))),
99+ };
100+ let id = req.id;
101+ if req.v != VERSION {
102+ let msg = format!("protocol version {} is not {VERSION}", req.v);
103+ return Some(Envelope::new(id, Reply::Error(msg)));
104+ }
105+ let reply = match req.body {
106+ Request::Run(cmd) => match state.run(cmd) {
107+ Ok(()) => Reply::Ok(None),
108+ Err(e) => Reply::Error(e),
109+ },
110+ Request::Get(Query::State) => Reply::Ok(Some(Answer::State(snapshot(state)))),
111+ Request::Get(Query::Version) => {
112+ Reply::Ok(Some(Answer::Version(env!("CARGO_PKG_VERSION").into())))
113+ }
114+ Request::Subscribe {} => {
115+ let Ok(sub) = stream.try_clone() else {
116+ return Some(Envelope::new(id, Reply::Error("cannot subscribe".into())));
117+ };
118+ send(&sub, &Envelope::new(None, Reply::Event(snapshot(state))));
119+ state.ipc.subscribers.push(sub);
120+ return None;
121+ }
122+ };
123+ Some(Envelope::new(id, reply))
124+}
125+
126+fn send(mut stream: &UnixStream, msg: &Envelope<Reply>) -> bool {
127+ let Ok(mut line) = serde_json::to_vec(msg) else {
128+ return false;
129+ };
130+ line.push(b'\n');
131+ stream.write_all(&line).is_ok()
132+}
133+
134+/// Sends subscribers the new state whenever it differs from the last one.
135+pub fn broadcast(state: &mut State) {
136+ if state.ipc.subscribers.is_empty() {
137+ return;
138+ }
139+ let now = snapshot(state);
140+ if state.ipc.last.as_ref() == Some(&now) {
141+ return;
142+ }
143+ let msg = Envelope::new(None, Reply::Event(now.clone()));
144+ state.ipc.subscribers.retain(|s| send(s, &msg));
145+ state.ipc.last = Some(now);
146+}
147+
148+fn snapshot(state: &State) -> monolith_ipc::State {
149+ let l = &state.layout;
150+ let focused = l.focused();
151+ let tags = l
152+ .tags()
153+ .map(|(_, t)| monolith_ipc::Tag {
154+ name: t.name.clone(),
155+ urgent: t.urgent,
156+ })
157+ .collect();
158+ let outputs = l
159+ .outputs()
160+ .map(|(id, o)| monolith_ipc::Output {
161+ name: o.name.clone(),
162+ area: [o.area.x, o.area.y, o.area.w, o.area.h],
163+ viewed: o
164+ .viewed
165+ .iter()
166+ .filter_map(|&t| l.tags().find(|(id, _)| *id == t))
167+ .map(|(_, t)| t.name.clone())
168+ .collect(),
169+ active: l.active_output() == Some(id),
170+ })
171+ .collect();
172+ let mut windows: Vec<monolith_ipc::Window> = l
173+ .windows()
174+ .map(|(id, w)| {
175+ let (app_id, title) = state
176+ .windows
177+ .get(&id)
178+ .and_then(|w| w.toplevel())
179+ .map(identity)
180+ .unwrap_or_default();
181+ monolith_ipc::Window {
182+ id: slotmap::Key::data(&id).as_ffi(),
183+ app_id,
184+ title,
185+ tag: l
186+ .tags()
187+ .find(|(t, _)| *t == w.tag)
188+ .map(|(_, t)| t.name.clone())
189+ .unwrap_or_default(),
190+ output: w.output.and_then(|o| l.output(o)).map(|o| o.name.clone()),
191+ floating: w.floating,
192+ fullscreen: w.fullscreen,
193+ sticky: w.sticky,
194+ scratchpad: w
195+ .scratchpad
196+ .and_then(|s| l.scratchpads().get(s))
197+ .map(|s| s.name.clone()),
198+ visible: l.visible(id),
199+ focused: focused == Some(id),
200+ }
201+ })
202+ .collect();
203+ windows.sort_by_key(|w| w.id);
204+ monolith_ipc::State {
205+ tags,
206+ outputs,
207+ windows,
208+ }
209+}
210+
211+/// Runs a client subcommand, if `args` names one.
212+pub fn client(args: &[String]) -> Option<ExitCode> {
213+ let (sub, rest) = args.split_first()?;
214+ let request = match sub.as_str() {
215+ "msg" => match Command::parse(rest) {
216+ Ok(cmd) => Request::Run(cmd),
217+ Err(e) => return Some(fail(&e.to_string())),
218+ },
219+ "get" => match rest {
220+ [q] if q == "state" => Request::Get(Query::State),
221+ [q] if q == "version" => Request::Get(Query::Version),
222+ _ => return Some(fail("get takes state or version")),
223+ },
224+ "subscribe" => Request::Subscribe {},
225+ _ => return None,
226+ };
227+ Some(talk(request))
228+}
229+
230+fn fail(msg: &str) -> ExitCode {
231+ eprintln!("monolith: {msg}");
232+ ExitCode::FAILURE
233+}
234+
235+fn talk(request: Request) -> ExitCode {
236+ let Some(path) = monolith_ipc::socket_path() else {
237+ return fail("no socket, set MONOLITH_SOCKET or WAYLAND_DISPLAY");
238+ };
239+ let mut stream = match UnixStream::connect(&path) {
240+ Ok(s) => s,
241+ Err(e) => return fail(&format!("{}: {e}", path.display())),
242+ };
243+ let subscribe = matches!(request, Request::Subscribe {});
244+ let mut line =
245+ serde_json::to_vec(&Envelope::new(Some(1), request)).expect("requests serialize");
246+ line.push(b'\n');
247+ if let Err(e) = stream.write_all(&line) {
248+ return fail(&e.to_string());
249+ }
250+ for line in BufReader::new(stream).lines() {
251+ let Ok(line) = line else { break };
252+ let reply: Envelope<Reply> = match serde_json::from_str(&line) {
253+ Ok(r) => r,
254+ Err(e) => return fail(&format!("bad reply: {e}")),
255+ };
256+ match reply.body {
257+ Reply::Error(e) => return fail(&e),
258+ Reply::Ok(None) => {}
259+ Reply::Ok(Some(answer)) => match answer {
260+ Answer::Version(v) => println!("{v}"),
261+ Answer::State(s) => {
262+ println!("{}", serde_json::to_string_pretty(&s).unwrap_or_default())
263+ }
264+ },
265+ Reply::Event(s) => println!("{}", serde_json::to_string(&s).unwrap_or_default()),
266+ }
267+ if !subscribe {
268+ break;
269+ }
270+ }
271+ ExitCode::SUCCESS
272+}
Added file: crates/monolith/src/launch.rs+47 additions
@@ -0,0 +1,47 @@
1+//! Launch context. A window goes to the output and tag the user was on when
2+//! its launch began, found by matching its process or an ancestor against
3+//! the programs monolith spawned.
4+
5+use monolith_layout::{OutputId, TagId};
6+use std::time::{Duration, Instant};
7+
8+const KEEP: Duration = Duration::from_secs(120);
9+
10+struct Launch {
11+ pid: u32,
12+ output: Option<OutputId>,
13+ tag: TagId,
14+ at: Instant,
15+}
16+
17+#[derive(Default)]
18+pub struct Launches(Vec<Launch>);
19+
20+impl Launches {
21+ pub fn record(&mut self, pid: u32, output: Option<OutputId>, tag: TagId) {
22+ self.0.retain(|l| l.at.elapsed() < KEEP);
23+ self.0.push(Launch {
24+ pid,
25+ output,
26+ tag,
27+ at: Instant::now(),
28+ });
29+ }
30+
31+ pub fn find(&self, pid: u32) -> Option<(Option<OutputId>, TagId)> {
32+ let mut pid = pid;
33+ for _ in 0..32 {
34+ if let Some(l) = self.0.iter().find(|l| l.pid == pid) {
35+ return Some((l.output, l.tag));
36+ }
37+ pid = parent(pid).filter(|&p| p > 1)?;
38+ }
39+ None
40+ }
41+}
42+
43+fn parent(pid: u32) -> Option<u32> {
44+ let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
45+ let rest = stat.get(stat.rfind(')')? + 2..)?;
46+ rest.split(' ').nth(1)?.parse().ok()
47+}
Modified file: crates/monolith/src/main.rs+30 additions−9 deletions
@@ -1,32 +1,53 @@
1+mod commands;
12 mod handlers;
23 mod input;
4+mod ipc;
5+mod launch;
36 mod nested;
47 mod state;
58
69 use smithay::reexports::calloop::EventLoop;
710 use smithay::reexports::wayland_server::Display;
811 use state::State;
9-use std::process::Command;
12+use std::process::ExitCode;
1013 use tracing_subscriber::EnvFilter;
1114
12-fn main() -> Result<(), Box<dyn std::error::Error>> {
15+fn main() -> ExitCode {
16+ let args: Vec<String> = std::env::args().skip(1).collect();
17+ if let Some(code) = ipc::client(&args) {
18+ return code;
19+ }
1320 tracing_subscriber::fmt()
1421 .with_env_filter(
1522 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
1623 )
1724 .init();
18- let mut event_loop: EventLoop<State> = EventLoop::try_new()?;
25+ match run(&args) {
26+ Ok(()) => ExitCode::SUCCESS,
27+ Err(e) => {
28+ tracing::error!("{e}");
29+ ExitCode::FAILURE
30+ }
31+ }
32+}
33+
34+/// Runs the compositor. Any arguments are a program to start inside it.
35+fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
36+ let mut event_loop: EventLoop<'static, State> = EventLoop::try_new()?;
1937 let display: Display<State> = Display::new()?;
2038 let mut state = State::new(&mut event_loop, display)?;
2139 nested::init(&mut event_loop, &mut state)?;
40+ ipc::init(&event_loop, &mut state)?;
2241 tracing::info!(socket = ?state.socket_name, device = state.renderer.device_name(), "monolith running nested");
2342
24- let args: Vec<String> = std::env::args().skip(1).collect();
25- if let Some((program, rest)) = args.split_first() {
26- Command::new(program)
27- .args(rest)
28- .env("WAYLAND_DISPLAY", &state.socket_name)
29- .spawn()?;
43+ let startup = state.config.startup.clone();
44+ for argv in startup
45+ .iter()
46+ .chain((!args.is_empty()).then_some(&args.to_vec()))
47+ {
48+ if let Err(e) = state.spawn(argv) {
49+ tracing::warn!("startup: {e}");
50+ }
3051 }
3152
3253 event_loop.run(None, &mut state, |state| state.after_dispatch())?;
Modified file: crates/monolith/src/nested.rs+9 additions−1 deletions
@@ -5,6 +5,7 @@
55
66 use crate::state::State;
77 use calloop_wayland_source::WaylandSource;
8+use monolith_layout::Rect;
89 use monolith_render_vk::VulkanAllocator;
910 use smithay::backend::allocator::dmabuf::Dmabuf;
1011 use smithay::backend::allocator::{Format, Fourcc, Modifier, Slot, Swapchain};
@@ -107,6 +108,13 @@ pub fn init(
107108 );
108109 output.set_preferred(mode);
109110 state.space.map_output(&output, (0, 0));
111+ let area = Rect::new(
112+ 0.0,
113+ 0.0,
114+ f64::from(DEFAULT_SIZE.0),
115+ f64::from(DEFAULT_SIZE.1),
116+ );
117+ state.output_id = Some(state.layout.add_output("nested", area));
110118
111119 state.host = Some(Host {
112120 size: DEFAULT_SIZE.into(),
@@ -359,7 +367,7 @@ impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
359367 {
360368 host.pending_size = Some(size);
361369 host.size = size;
362- state.relayout();
370+ state.resize_output(size);
363371 }
364372 }
365373 xdg_toplevel::Event::Close => state.signal.stop(),
Modified file: crates/monolith/src/state.rs+264 additions−24 deletions
@@ -1,22 +1,30 @@
1+use crate::commands::Binds;
12 use crate::nested::Host;
3+use monolith_config::Config;
4+use monolith_layout::{Layout, OutputId, Rect, WindowId};
25 use monolith_render_vk::VulkanRenderer;
36 use smithay::backend::renderer::ImportDma;
47 use smithay::desktop::{PopupManager, Space, Window, WindowSurfaceType};
8+use smithay::input::keyboard::{Keycode, XkbConfig};
59 use smithay::input::{Seat, SeatState};
610 use smithay::reexports::calloop::generic::Generic;
711 use smithay::reexports::calloop::{EventLoop, Interest, LoopSignal, Mode, PostAction};
12+use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState;
813 use smithay::reexports::wayland_server::backend::{ClientData, ClientId, DisconnectReason};
914 use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
10-use smithay::reexports::wayland_server::{Display, DisplayHandle};
11-use smithay::utils::{Logical, Point, Size};
12-use smithay::wayland::compositor::{CompositorClientState, CompositorState};
15+use smithay::reexports::wayland_server::{Display, DisplayHandle, Resource};
16+use smithay::utils::{Logical, Point, SERIAL_COUNTER, Size};
17+use smithay::wayland::compositor::{CompositorClientState, CompositorState, with_states};
1318 use smithay::wayland::dmabuf::{DmabufGlobal, DmabufState};
1419 use smithay::wayland::output::OutputManagerState;
1520 use smithay::wayland::selection::data_device::DataDeviceState;
16-use smithay::wayland::shell::xdg::XdgShellState;
21+use smithay::wayland::shell::xdg::decoration::XdgDecorationState;
22+use smithay::wayland::shell::xdg::{XdgShellState, XdgToplevelSurfaceData};
1723 use smithay::wayland::shm::ShmState;
1824 use smithay::wayland::socket::ListeningSocketSource;
25+use std::collections::HashMap;
1926 use std::ffi::OsString;
27+use std::path::PathBuf;
2028 use std::sync::Arc;
2129 use std::time::Instant;
2230
@@ -31,8 +39,22 @@ pub struct State {
3139 pub host: Option<Host>,
3240 pub needs_redraw: bool,
3341
42+ pub layout: Layout,
43+ pub windows: HashMap<WindowId, Window>,
44+ /// Toplevels that have not committed yet. Rules need their app_id and
45+ /// title, which clients set after creating the toplevel.
46+ pub unmapped: Vec<Window>,
47+ pub output_id: Option<OutputId>,
48+ pub config: Config,
49+ pub config_path: Option<PathBuf>,
50+ pub binds: Binds,
51+ pub suppressed: Vec<Keycode>,
52+ pub ipc: crate::ipc::Server,
53+ pub launches: crate::launch::Launches,
54+
3455 pub compositor_state: CompositorState,
3556 pub xdg_shell_state: XdgShellState,
57+ pub _xdg_decoration_state: XdgDecorationState,
3658 pub shm_state: ShmState,
3759 pub dmabuf_state: DmabufState,
3860 pub _dmabuf_global: DmabufGlobal,
@@ -48,12 +70,18 @@ impl State {
4870 display: Display<State>,
4971 ) -> Result<Self, Box<dyn std::error::Error>> {
5072 let dh = display.handle();
73+ let config_path = Config::path();
74+ let config = Config::load(config_path.as_deref()).unwrap_or_else(|e| {
75+ tracing::error!("config: {e}, using the built-in default");
76+ Config::parse(monolith_config::DEFAULT).expect("the default config parses")
77+ });
5178 let renderer = VulkanRenderer::new()?;
5279 let mut dmabuf_state = DmabufState::new();
5380 let dmabuf_global = dmabuf_state.create_global::<State>(&dh, renderer.dmabuf_formats());
5481 let mut seat_state = SeatState::new();
5582 let mut seat = seat_state.new_wl_seat(&dh, "seat0");
56- seat.add_keyboard(Default::default(), 200, 25)?;
83+ let kb = &config.keyboard;
84+ seat.add_keyboard(xkb_config(kb), kb.repeat_delay, kb.repeat_rate)?;
5785 seat.add_pointer();
5886
5987 let listener = ListeningSocketSource::new_auto()?;
@@ -76,6 +104,9 @@ impl State {
76104 },
77105 )?;
78106
107+ let mut layout = Layout::default();
108+ layout.gaps = gaps(&config);
109+ let binds = Binds::new(&config);
79110 Ok(Self {
80111 start: Instant::now(),
81112 socket_name,
@@ -85,8 +116,19 @@ impl State {
85116 popups: PopupManager::default(),
86117 host: None,
87118 needs_redraw: true,
119+ layout,
120+ windows: HashMap::new(),
121+ unmapped: Vec::new(),
122+ output_id: None,
123+ config,
124+ config_path,
125+ binds,
126+ suppressed: Vec::new(),
127+ ipc: crate::ipc::Server::default(),
128+ launches: Default::default(),
88129 compositor_state: CompositorState::new::<State>(&dh),
89130 xdg_shell_state: XdgShellState::new::<State>(&dh),
131+ _xdg_decoration_state: XdgDecorationState::new::<State>(&dh),
90132 shm_state: ShmState::new::<State>(&dh, vec![]),
91133 dmabuf_state,
92134 _dmabuf_global: dmabuf_global,
@@ -103,37 +145,157 @@ impl State {
103145 self.space.refresh();
104146 self.popups.cleanup();
105147 crate::nested::render(self);
148+ crate::ipc::broadcast(self);
106149 let _ = self.display.flush_clients();
107150 }
108151
109- pub fn output_size(&self) -> Size<i32, Logical> {
110- self.host.as_ref().map_or((1280, 800).into(), |h| h.size)
152+ pub fn resize_output(&mut self, size: Size<i32, Logical>) {
153+ if let Some(o) = self.output_id {
154+ let area = Rect::new(0.0, 0.0, f64::from(size.w), f64::from(size.h));
155+ self.layout.set_output_area(o, area);
156+ self.apply_layout();
157+ }
111158 }
112159
113- /// Lays toplevels out side by side across the output. A stand-in until
114- /// the layout crate takes over.
115- pub fn relayout(&mut self) {
116- let windows: Vec<Window> = self.space.elements().cloned().collect();
117- let size = self.output_size();
118- let n = windows.len() as i32;
119- if n == 0 {
120- return;
160+ /// Adds a toplevel to the layout on its first commit, where its app_id
161+ /// and title are known and the initial configure can carry its size.
162+ pub fn map_window(&mut self, window: Window) {
163+ let (app_id, title) = window.toplevel().map(identity).unwrap_or_default();
164+ let rule = self.config.placement(&app_id, &title);
165+ let launch = window
166+ .toplevel()
167+ .and_then(|t| t.wl_surface().client())
168+ .and_then(|c| c.get_credentials(&self.display).ok())
169+ .and_then(|c| self.launches.find(c.pid as u32));
170+ let output = rule
171+ .output
172+ .as_deref()
173+ .and_then(|name| {
174+ self.layout
175+ .outputs()
176+ .find(|(_, o)| o.name == name)
177+ .map(|(id, _)| id)
178+ })
179+ .or(launch.and_then(|l| l.0));
180+ let tag = match rule.tag.as_deref() {
181+ Some(t) => Some(self.layout.tag(t)),
182+ None => launch.map(|l| l.1),
183+ };
184+ let id = self.layout.add_window(output, tag, None);
185+ if rule.floating == Some(true) {
186+ self.layout.toggle_floating(id);
187+ }
188+ if rule.sticky == Some(true) {
189+ self.layout.toggle_sticky(id);
121190 }
122- let gap = 8;
123- let w = (size.w - gap * (n + 1)) / n;
124- for (i, window) in windows.into_iter().enumerate() {
125- let loc = (gap + i as i32 * (w + gap), gap);
126- if let Some(toplevel) = window.toplevel() {
127- toplevel.with_pending_state(|s| s.size = Some((w, size.h - 2 * gap).into()));
128- if toplevel.is_initial_configure_sent() {
129- toplevel.send_pending_configure();
191+ if let Some(name) = &rule.scratchpad {
192+ self.layout.scratchpad_add(id, name);
193+ }
194+ self.windows.insert(id, window);
195+ if self.layout.visible(id) {
196+ self.layout.focus(id);
197+ }
198+ self.apply_layout();
199+ }
200+
201+ pub fn unmap_window(&mut self, surface: &WlSurface) {
202+ self.unmapped
203+ .retain(|w| w.toplevel().is_some_and(|t| t.wl_surface() != surface));
204+ if let Some(id) = self.window_id(surface) {
205+ if let Some(window) = self.windows.remove(&id) {
206+ self.space.unmap_elem(&window);
207+ }
208+ self.layout.remove_window(id);
209+ self.apply_layout();
210+ }
211+ }
212+
213+ pub fn window_id(&self, surface: &WlSurface) -> Option<WindowId> {
214+ self.windows
215+ .iter()
216+ .find(|(_, w)| w.toplevel().is_some_and(|t| t.wl_surface() == surface))
217+ .map(|(id, _)| *id)
218+ }
219+
220+ /// Pushes the layout's target geometry into the space and to clients.
221+ pub fn apply_layout(&mut self) {
222+ let mut shown = Vec::new();
223+ let outputs: Vec<OutputId> = self.layout.outputs().map(|(o, _)| o).collect();
224+ for o in outputs {
225+ let arr = self.layout.arrange(o);
226+ let area = self.layout.output(o).map(|o| o.area).unwrap_or_default();
227+ for p in &arr.tiled {
228+ if p.visible && arr.fullscreen != Some(p.window) {
229+ shown.push((p.window, p.rect, Kind::Tiled));
230+ }
231+ }
232+ for &(w, rect) in &arr.floating {
233+ if arr.fullscreen != Some(w) {
234+ shown.push((w, rect, Kind::Floating));
130235 }
131236 }
132- self.space.map_element(window, loc, false);
237+ if let Some(w) = arr.fullscreen {
238+ shown.push((w, area, Kind::Fullscreen));
239+ }
240+ }
241+ let mut mapped = Vec::new();
242+ for (id, rect, kind) in shown {
243+ if let Some(window) = self.windows.get(&id)
244+ && configure(window, rect, kind)
245+ {
246+ let loc = (rect.x.round() as i32, rect.y.round() as i32);
247+ let loc = Point::from(loc) - window.geometry().loc;
248+ self.space.map_element(window.clone(), loc, false);
249+ mapped.push(id);
250+ }
133251 }
252+ let hidden: Vec<Window> = self
253+ .windows
254+ .iter()
255+ .filter(|(id, _)| !mapped.contains(id))
256+ .map(|(_, w)| w.clone())
257+ .collect();
258+ for window in hidden {
259+ self.space.unmap_elem(&window);
260+ }
261+ for id in &mapped {
262+ self.space.raise_element(&self.windows[id], false);
263+ }
264+ self.sync_focus();
134265 self.needs_redraw = true;
135266 }
136267
268+ fn sync_focus(&mut self) {
269+ let focused = self.layout.focused();
270+ for (id, window) in &self.windows {
271+ if window.set_activated(Some(*id) == focused)
272+ && let Some(t) = window.toplevel()
273+ && t.is_initial_configure_sent()
274+ {
275+ t.send_pending_configure();
276+ }
277+ }
278+ let target = focused
279+ .and_then(|id| self.windows.get(&id))
280+ .and_then(|w| w.toplevel())
281+ .map(|t| t.wl_surface().clone());
282+ if let Some(keyboard) = self.seat.get_keyboard()
283+ && keyboard.current_focus() != target
284+ {
285+ keyboard.set_focus(self, target, SERIAL_COUNTER.next_serial());
286+ }
287+ }
288+
289+ pub fn reconfigure_keyboard(&mut self) {
290+ let kb = self.config.keyboard.clone();
291+ if let Some(keyboard) = self.seat.get_keyboard() {
292+ if let Err(e) = keyboard.set_xkb_config(self, xkb_config(&kb)) {
293+ tracing::warn!("keyboard config rejected: {e:?}");
294+ }
295+ keyboard.change_repeat_info(kb.repeat_rate, kb.repeat_delay);
296+ }
297+ }
298+
137299 pub fn surface_under(
138300 &self,
139301 pos: Point<f64, Logical>,
@@ -148,6 +310,84 @@ impl State {
148310 }
149311 }
150312
313+#[derive(Clone, Copy, PartialEq, Eq)]
314+enum Kind {
315+ Tiled,
316+ Floating,
317+ Fullscreen,
318+}
319+
320+/// Sets a window's pending size and states. Returns false for windows
321+/// squeezed to a line by an expanded sibling, which are not shown.
322+fn configure(window: &Window, rect: Rect, kind: Kind) -> bool {
323+ let (x, y) = (rect.x.round(), rect.y.round());
324+ let w = (rect.right().round() - x) as i32;
325+ let h = (rect.bottom().round() - y) as i32;
326+ if w < 1 || h < 1 {
327+ return false;
328+ }
329+ if let Some(t) = window.toplevel() {
330+ t.with_pending_state(|s| {
331+ s.size = Some((w, h).into());
332+ let tiled = [
333+ XdgState::TiledLeft,
334+ XdgState::TiledRight,
335+ XdgState::TiledTop,
336+ XdgState::TiledBottom,
337+ ];
338+ for st in tiled {
339+ if kind == Kind::Tiled {
340+ s.states.set(st);
341+ } else {
342+ s.states.unset(st);
343+ }
344+ }
345+ if kind == Kind::Fullscreen {
346+ s.states.set(XdgState::Fullscreen);
347+ } else {
348+ s.states.unset(XdgState::Fullscreen);
349+ }
350+ });
351+ if t.is_initial_configure_sent() {
352+ t.send_pending_configure();
353+ }
354+ }
355+ true
356+}
357+
358+pub fn identity(t: &smithay::wayland::shell::xdg::ToplevelSurface) -> (String, String) {
359+ with_states(t.wl_surface(), |s| {
360+ let data = s
361+ .data_map
362+ .get::<XdgToplevelSurfaceData>()
363+ .and_then(|d| d.lock().ok());
364+ data.map(|d| {
365+ (
366+ d.app_id.clone().unwrap_or_default(),
367+ d.title.clone().unwrap_or_default(),
368+ )
369+ })
370+ .unwrap_or_default()
371+ })
372+}
373+
374+pub fn gaps(config: &Config) -> monolith_layout::Gaps {
375+ monolith_layout::Gaps {
376+ inner: config.general.gaps.inner,
377+ outer: config.general.gaps.outer,
378+ }
379+}
380+
381+fn xkb_config(kb: &monolith_config::Keyboard) -> XkbConfig<'_> {
382+ XkbConfig {
383+ rules: &kb.rules,
384+ model: &kb.model,
385+ layout: &kb.layout,
386+ variant: &kb.variant,
387+ options: kb.options.clone(),
388+ }
389+}
390+
151391 #[derive(Default)]
152392 pub struct ClientState {
153393 pub compositor_state: CompositorClientState,