srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/platform/src/ipc/dispatch.rs
blob: c5e70bee089e1ac081ff3ecf020ec8116a1e8d73 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
//! Request dispatch: `handle_request` (every query/dispatch `cmd`) and
//! `handle_set` (the `"set"` cmd's own key/value sub-dispatch). Split out
//! of the original single `ipc.rs` purely by concern, no behavior change.

use srdwm_core::{WindowId, WindowManager};

use super::types::*;

/// Parses and applies one request line, returning the response body (no
/// trailing newline) and whether it changed window state.
pub(crate) fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<WindowManager>>) -> (Vec<u8>, bool) {
    let Ok(req) = serde_json::from_slice::<serde_json::Value>(line) else {
        return (err("invalid request"), false);
    };
    let cmd = req.get("cmd").and_then(|v| v.as_str()).unwrap_or("");
    let id = req.get("id").and_then(|v| v.as_u64()).map(|v| v as WindowId);

    match cmd {
        "clients" => (serde_json::to_vec(&ClientsResponse { clients: client_snapshot(wm) }).unwrap_or_default(), false),
        "workspaces" => (serde_json::to_vec(&WorkspacesResponse { workspaces: workspace_snapshot(wm) }).unwrap_or_default(), false),
        "settings" => {
            let wm = wm.borrow();
            let settings = SettingsResponse {
                shadows: wm.shadows_enabled,
                rounded_corners: wm.rounded_corners_enabled,
                animations: wm.animations_enabled,
                night_light: wm.color_filter == srdwm_core::ColorFilter::NightLight,
                reading_mode: wm.color_filter == srdwm_core::ColorFilter::ReadingMode,
                phone_mode: wm.phone_mode,
                multi_cursor: wm.multi_cursor_enabled,
                border_width: wm.theme.default_border_width,
                border_color: srdwm_core::format_hex_color(wm.theme.default_border_color),
                corner_radius: wm.theme.default_corner_radius,
                decoration_mode_server: wm.theme.default_decorated,
                gap_inner: wm.tiling.gap_inner,
                gap_outer: wm.tiling.gap_outer,
                master_ratio: wm.tiling.master_ratio,
                master_count: wm.tiling.master_count,
                per_monitor: wm.per_monitor_workspaces,
                button_style: if wm.theme.traffic_light_buttons { "traffic_lights" } else { "traditional" }.to_string(),
                button_side: if wm.theme.buttons_left { "left" } else { "right" }.to_string(),
                button_order: wm.theme.button_order.map(srdwm_core::format_button_order),
                title_centered: wm.theme.title_centered,
                button_glyph_always: wm.theme.button_glyph_always,
                desktop_icons: wm.desktop_icons_enabled,
                desktop_icons_all_monitors: wm.desktop_icons_all_monitors,
            };
            (serde_json::to_vec(&settings).unwrap_or_default(), false)
        }
        "monitors" => (serde_json::to_vec(&MonitorsResponse { monitors: monitor_snapshot(wm) }).unwrap_or_default(), false),
        // The connection is handed off to `IpcServer::subscribers` by the
        // caller (`poll`, which is the only place that can see the raw
        // `cmd` string this deep call already consumed) right after this
        // reply is written - this arm only has to produce that reply, in
        // the same `ClientsEvent` shape every later push uses.
        "subscribe" => {
            // Four JSON objects, not one: `poll` writes this response plus
            // one trailing `\n` verbatim, so an embedded `\n` between each
            // here is all it takes to hand a fresh subscriber every initial
            // snapshot as its own line - exactly the shape every later
            // push already uses, so there's nothing for a consumer to
            // special-case about the first few lines it reads.
            let clients = client_snapshot(wm);
            let workspaces = workspace_snapshot(wm);
            let layout = wm.borrow().keyboard_layout.clone();
            let monitors = monitor_snapshot(wm);
            let mut out = serde_json::to_vec(&ClientsEvent { event: "clients", clients: &clients }).unwrap_or_default();
            out.push(b'\n');
            out.extend(serde_json::to_vec(&WorkspacesEvent { event: "workspaces", workspaces: &workspaces }).unwrap_or_default());
            out.push(b'\n');
            out.extend(serde_json::to_vec(&KeyboardLayoutEvent { event: "keyboard_layout", layout: &layout }).unwrap_or_default());
            out.push(b'\n');
            out.extend(serde_json::to_vec(&MonitorsEvent { event: "monitors", monitors: &monitors }).unwrap_or_default());
            (out, false)
        }
        "keyboard_layout" => {
            (serde_json::to_vec(&KeyboardLayoutResponse { layout: wm.borrow().keyboard_layout.clone() }).unwrap_or_default(), false)
        }
        "cycle_keyboard_layout" => {
            wm.borrow_mut().request_keyboard_layout_cycle();
            (ok(), true)
        }
        "toggle_visibility" => {
            let Some(id) = id else { return (err("missing id"), false) };
            let mut wm = wm.borrow_mut();
            let current = wm.current_workspace();
            let Some(w) = wm.windows().find(|w| w.id == id) else {
                return (err("no such window"), false);
            };
            let now_hidden = w.minimized || w.workspace != current;
            if now_hidden {
                // Follows the caller to whichever workspace is current --
                // matches Hyprland's `special:scratchpad`/Sway's `scratchpad
                // show`, which is the behaviour the `scratchpad` script and
                // its keybindings are written against.
                wm.move_window_to_workspace(id, current);
                wm.restore_window(id);
                wm.focus_window(id);
            } else {
                wm.minimize_window(id);
            }
            (ok(), true)
        }
        "focus" => {
            let Some(id) = id else { return (err("missing id"), false) };
            wm.borrow_mut().focus_window(id);
            (ok(), true)
        }
        "close" => {
            let Some(id) = id else { return (err("missing id"), false) };
            wm.borrow_mut().close_window(id);
            (ok(), true)
        }
        // `srd dispatch lock` - no id, there's only ever one session to
        // lock. Core cannot lock the screen itself (real rendering/input-
        // routing, backend-owned); this just queues the request the same
        // way `set_output_position` queues one for whichever backend owns
        // real output hardware - see `WindowManager::request_lock`'s own
        // doc comment.
        "lock" => {
            wm.borrow_mut().request_lock();
            (ok(), true)
        }
        // `{"cmd":"pin_input","pid":<client pid>,"id":<window id>}` pins
        // every `zwlr_virtual_pointer_unstable_v1` object that client owns
        // to window `id` - Phase 2 of the multi-cursor plan (`docs/
        // TODO.md`), the primitive an agent-controlling tool needs to
        // operate one specific window without moving the human's own
        // cursor or stealing focus. Omitting `id` unpins instead (`{"cmd":
        // "pin_input","pid":<pid>}`) - one command for both directions,
        // since "pin" and "unpin" are really just "set the pin to Some or
        // None", the same shape `set_output_enabled`'s own boolean already
        // uses for two related actions on one dispatch. Keyed by pid, not
        // an opaque per-object id nothing outside the Wayland backend
        // could ever learn - a controlling tool already knows its own
        // pid (`std::process::id()`) for free. Queued via `request_pin_
        // input` and applied by the Wayland backend on its own next poll,
        // same one-poll-tick latency `set_output_position` already has.
        "pin_input" => {
            let Some(pid) = req.get("pid").and_then(|v| v.as_i64()) else {
                return (err("missing pid"), false);
            };
            wm.borrow_mut().request_pin_input(pid as i32, id);
            (ok(), true)
        }
        // `{"cmd":"pinned_inputs"}` - every pid currently pinned and which
        // window, read from `WindowManager::all_pinned_windows` (the
        // backend's own confirmation that a `pin_input` request was
        // genuinely applied, not the one-shot request queue itself).
        "pinned_inputs" => {
            let pinned: Vec<PinnedInputInfo> = wm.borrow().all_pinned_windows().map(|(pid, id)| PinnedInputInfo { pid, id }).collect();
            (serde_json::to_vec(&PinnedInputsResponse { pinned }).unwrap_or_default(), false)
        }
        // `{"cmd":"create_fake_monitor","name":<string>,"width":<u32>,
        // "height":<u32>}` - a fully virtual `wl_output` with no real
        // hardware behind it, applied by whichever backend owns real
        // output hardware (only the udev backend can; the winit/nested
        // backend has no headless render path to draw one with). See
        // `crates/wayland/src/udev/virtual_heads.rs`'s own module doc
        // comment for the full design and scope.
        "create_fake_monitor" => {
            let Some(name) = req.get("name").and_then(|v| v.as_str()) else { return (err("missing name"), false) };
            let (Some(width), Some(height)) = (req.get("width").and_then(|v| v.as_u64()), req.get("height").and_then(|v| v.as_u64())) else {
                return (err("missing width/height"), false);
            };
            wm.borrow_mut().request_create_fake_monitor(name.to_string(), width as u32, height as u32);
            (ok(), true)
        }
        // `{"cmd":"remove_fake_monitor","name":<string>}`.
        "remove_fake_monitor" => {
            let Some(name) = req.get("name").and_then(|v| v.as_str()) else { return (err("missing name"), false) };
            wm.borrow_mut().request_remove_fake_monitor(name.to_string());
            (ok(), true)
        }
        // `srd.window.maximize()`/`.fullscreen()`'s exact IPC-side
        // equivalents - lets an external script (or a live diagnostic
        // check, same as `toggle_visibility`/`focus`/`close` already allow)
        // drive either without needing a keybinding to already exist.
        "toggle_maximize" => {
            let Some(id) = id else { return (err("missing id"), false) };
            wm.borrow_mut().toggle_maximize(id);
            (ok(), true)
        }
        "toggle_fullscreen" => {
            let Some(id) = id else { return (err("missing id"), false) };
            wm.borrow_mut().toggle_fullscreen(id);
            (ok(), true)
        }
        // The four general compositor operations that have no standard
        // Wayland protocol to fall back on - confirmed with the AGS peer
        // session that `zwlr_foreign_toplevel_manager_v1` already covers
        // activate/close/maximize/minimize/fullscreen (so those stay
        // protocol-only, no bespoke verb here), but nothing in that
        // protocol or `ext-workspace-v1` can toggle floating, pin a
        // window, move one within the tiling order, or move one to a
        // specific workspace. Designed as plain general operations any
        // client can use (a panel, a script, a keybinding daemon), not
        // shaped around one particular shell's own IPC habits.
        "toggle_floating" => {
            let Some(id) = id else { return (err("missing id"), false) };
            wm.borrow_mut().toggle_floating(id);
            (ok(), true)
        }
        "toggle_pinned" => {
            let Some(id) = id else { return (err("missing id"), false) };
            wm.borrow_mut().toggle_always_on_top(id);
            (ok(), true)
        }
        // `{"cmd":"move_window","id":<window id>,"direction":"left"|"right"|"up"|"down"}`
        // - `WindowManager::move_window_direction` swaps the *focused*
        // window with its neighbour in that direction, so a caller asking
        // to move a window that isn't currently focused needs it focused
        // first; matches `movewindow` needing the target window active in
        // every tiling WM this gesture is modeled on.
        "move_window" => {
            let Some(id) = id else { return (err("missing id"), false) };
            let Some(dir) = req.get("direction").and_then(|v| v.as_str()).and_then(parse_direction) else {
                return (err("direction must be one of: left, right, up, down"), false);
            };
            let mut wm = wm.borrow_mut();
            if wm.focused_id() != Some(id) {
                wm.focus_window(id);
            }
            wm.move_window_direction(dir);
            (ok(), true)
        }
        // `{"cmd":"move_to_workspace","id":<window id>,"workspace":<workspace id>}`
        // - the operation the AGS peer's Overview needs for drag-a-window-
        // onto-another-workspace, which `ext-workspace-v1` (activation
        // only, no toplevel-to-workspace verb) and `zwlr-foreign-toplevel`
        // (no workspace concept at all) both lack entirely.
        "move_to_workspace" => {
            let Some(id) = id else { return (err("missing id"), false) };
            let Some(workspace) = req.get("workspace").and_then(|v| v.as_u64()) else {
                return (err("missing workspace"), false);
            };
            wm.borrow_mut().move_window_to_workspace(id, workspace as srdwm_core::WorkspaceId);
            (ok(), true)
        }
        // The workspace-side equivalent of `focus`: `id` here is a
        // `WorkspaceId`, not a `WindowId` - both are plain `usize`/`u64`
        // on the wire, so the same generic `id` field this whole match
        // already reads serves both, same as every other dispatch arm.
        "activate_workspace" => {
            let Some(id) = id else { return (err("missing id"), false) };
            // `switch_workspace_on_monitor` falls straight through to the
            // ordinary shared-mode `switch_workspace` when `workspace.
            // per_monitor` is off, so this is the one call site that works
            // correctly either way - no branching on the config flag
            // needed here. The monitor it applies to in per-monitor mode:
            // the focused window's own monitor, falling back to the
            // primary monitor if nothing is focused (an empty desktop) --
            // the same "whichever output a keybinding should apply to"
            // choice real per-output-aware WMs (Hyprland, niri) make.
            {
                let mut wm = wm.borrow_mut();
                let monitor = wm
                    .focused_id()
                    .and_then(|f| wm.window(f))
                    .map(|w| w.monitor)
                    .or_else(|| wm.primary_monitor().map(|m| m.id))
                    .unwrap_or(0);
                wm.switch_workspace_on_monitor(id as srdwm_core::WorkspaceId, monitor);
            }
            (ok(), true)
        }
        // `{"cmd":"set_output_position","id":<monitor id>,"x":<i32>,"y":<i32>}`
        // - the primitive an output-configuration UI (a display-settings
        // panel, concretely the monitor-mirroring toggle this was built
        // for) needs and had no way to reach before: `wlr-output-
        // management-v1` already supports repositioning an output
        // (`crates/wayland/src/output_management.rs`), but only to a
        // client willing to implement that whole protocol itself just to
        // move one output. This exposes the same capability over the
        // plain IPC socket every other `srd dispatch` action already
        // uses. Deliberately just "move this output" with no separate
        // "mirror" concept anywhere: positioning two outputs at the same
        // coordinates already shows the same desktop region on both (every
        // window/render decision downstream works in shared global space,
        // not per-output), so mirroring is something a caller *achieves*
        // with this primitive, not something srdwm needs to know about as
        // its own state.
        //
        // Not applied here, and deliberately not a `WindowId` on the wire
        // despite reusing the same `id` field every other dispatch already
        // reads (both are plain integers on the wire; only the Rust-side
        // type differs) - this crate has no real output handle to move,
        // only `WindowManager`'s passive mirror of whatever the backend
        // last reported. Queued via `request_output_position` and applied
        // by whichever backend actually owns the hardware on its own next
        // poll, the same one-poll-tick latency every other backend-owned
        // effect in this IPC layer already has (a redraw, a geometry
        // change) - `changed = true` still makes sense to return since
        // this genuinely will change what's on screen once the backend
        // catches up, just not synchronously within this call.
        "set_output_position" => {
            // Accepts a monitor `name` as well as the plain `id` every
            // other dispatch already reads - `srd monitors`/`wlr-output-
            // management-v1` both key on name first (`eDP-1`,
            // not an arbitrary index), and a display-arrangement UI
            // reasonably lists outputs by that name rather than making a
            // caller look its own id up first just to turn around and send
            // it straight back. `id` still wins if both are somehow given.
            let Some(monitor_id) = resolve_monitor_id(wm, id, req.get("name").and_then(|v| v.as_str())) else {
                return (err("missing id or a name matching a connected monitor"), false);
            };
            let (Some(x), Some(y)) = (req.get("x").and_then(|v| v.as_i64()), req.get("y").and_then(|v| v.as_i64())) else {
                return (err("missing x/y"), false);
            };
            wm.borrow_mut().request_output_position(monitor_id, x as i32, y as i32);
            (ok(), true)
        }
        // `{"cmd":"set_output_enabled","id"|"name":...,"enabled":<bool>}`
        // - "primary only"/a per-display toggle, the two AGS monitor-
        // layout panel rows gated pending this. Disabling and re-enabling
        // reuse this backend's own existing hotplug-removal/bring-up code
        // paths rather than a new mechanism (see the udev platform's own
        // drain site) - the same real, already-tested steps a genuine
        // unplug/replug already goes through, just triggered
        // administratively instead of by a real DRM event.
        //
        // Resolved to a *name* here, unlike `set_output_position` (which
        // stays on `resolve_monitor_id`/plain `MonitorId`) - see
        // `WindowManager::request_output_enabled`'s own doc comment for
        // why: disabling removes the output from `monitors()` entirely, so
        // its id has nothing left to mean by the time a caller wants to
        // *re-enable* it. `id` is still accepted, resolved against the
        // live list the same way `resolve_monitor_id` does, but that only
        // ever works for the disable direction (the output is still live
        // when you ask to turn it off) - re-enabling a currently-disabled
        // output needs its `name` given directly, since no live entry
        // exists to resolve an `id` against at that point.
        "set_output_enabled" => {
            let name = match req.get("name").and_then(|v| v.as_str()) {
                Some(name) => Some(name.to_string()),
                None => id.and_then(|id| wm.borrow().monitors().iter().find(|m| m.id == id as srdwm_core::MonitorId).map(|m| m.name.clone())),
            };
            let Some(name) = name else { return (err("missing name, or an id matching a currently-connected monitor"), false) };
            let Some(enabled) = req.get("enabled").and_then(|v| v.as_bool()) else {
                return (err("missing enabled"), false);
            };
            wm.borrow_mut().request_output_enabled(name, enabled);
            (ok(), true)
        }
        // `{"cmd":"set_monitor_split","id"|"name":...,"parts":<u32>,
        // "rows":<bool, optional, default false>}` - the live CLI/IPC path
        // for `srd.monitor.split(name, parts, direction)` (`crates/config/
        // src/engine/general.rs`'s own `fn_monitor_split`), which until now
        // only ever ran once at config load. Queued via `request_monitor_
        // split`, same cross-boundary "core has no way to trigger its own
        // requery" reasoning as `set_output_position` above - see
        // `WindowManager::monitor_split_requests`'s own doc comment for the
        // real, live-reproduced staleness bug that came from calling
        // `set_monitor_split` directly here on a first attempt. `parts` <=
        // 1 clears an existing split, same as the Lua function. Same
        // "resolve id to a name first" fallback `set_output_enabled` above
        // already uses, since a caller working from a numeric id shouldn't
        // have to look the name up itself first just to turn around and
        // split it.
        "set_monitor_split" => {
            let name = match req.get("name").and_then(|v| v.as_str()) {
                Some(name) => Some(name.to_string()),
                None => id.and_then(|id| wm.borrow().monitors().iter().find(|m| m.id == id as srdwm_core::MonitorId).map(|m| m.name.clone())),
            };
            let Some(name) = name else { return (err("missing name, or an id matching a currently-connected monitor"), false) };
            let Some(parts) = req.get("parts").and_then(|v| v.as_u64()) else {
                return (err("missing parts"), false);
            };
            let rows = req.get("rows").and_then(|v| v.as_bool()).unwrap_or(false);
            wm.borrow_mut().request_monitor_split(name, parts as u32, rows);
            (ok(), true)
        }
        // `{"cmd":"capture_workspace","id":<workspace id>,"path":<string>,
        // "width":<u32>,"height":<u32>}` - `width`/`height` are optional,
        // both or neither. Exists for a workspace switcher's thumbnail
        // previews (AGS's Overview): `wlr-screencopy` - what `grim` and
        // this compositor's own `screencopy.rs` both use - can only ever
        // capture what an output is currently *presenting*, so a workspace
        // that isn't the active one is structurally invisible to it. This
        // is the one thing screencopy can't do, queued the same
        // cross-boundary way `set_output_position` is (core has no
        // renderer of its own) and drained by whichever backend is
        // actually running on its own next poll. Same one-poll-tick
        // latency as every other backend-owned effect this IPC layer
        // already has - the file exists shortly after this call returns,
        // not necessarily before it.
        "capture_workspace" => {
            let Some(id) = id else { return (err("missing id"), false) };
            let Some(path) = req.get("path").and_then(|v| v.as_str()) else {
                return (err("missing path"), false);
            };
            let size = match (req.get("width").and_then(|v| v.as_u64()), req.get("height").and_then(|v| v.as_u64())) {
                (Some(w), Some(h)) => Some((w as u32, h as u32)),
                (None, None) => None,
                _ => return (err("width and height must both be given, or neither"), false),
            };
            wm.borrow_mut().request_capture_workspace(id as srdwm_core::WorkspaceId, path.to_string(), size);
            (ok(), true)
        }
        // Live theme values - an AGS peer session's equivalent of
        // Hyprland's `hyprctl keyword general:col.active_border ...`, the
        // mechanism their shell already uses to repaint window borders the
        // instant an accent palette/radius/etc changes in Settings. Was a
        // real, invisible gap before this: every one of these already had
        // a real, mutable `WindowManager` field (`theme.default_border_*`,
        // `tiling.gap_*`, `shadows_enabled`, `rounded_corners_enabled`),
        // set once from Lua config at startup and never touched again --
        // so a running session had no way to change any of it without a
        // full restart, unlike everything else `srd dispatch` already
        // covers live.
        //
        // No extra redraw call needed here: returning `changed = true`
        // (same as every other mutating command) is exactly what makes
        // `main.rs`'s `sync()` run its next tick, which already calls
        // `redraw_decoration`/`apply_geometry` for every visible window
        // unconditionally - this only has to mutate the right field and
        // let that existing machinery do the rest.
        "set" => handle_set(&req, wm),
        _ => (err("unknown command"), false),
    }
}

/// `{"cmd":"set","key":"border_width","value":3}` and the rest of `"set"`'s
/// keys - pulled out of `handle_request`'s match arm purely to keep that
/// match's per-arm bodies roughly the same size; no reuse motive.
///
/// A window's `border_color`/`border_width` are copied from `theme.
/// default_border_color`/`default_border_width` once, at creation
/// (`WindowManager::add_window`), and a rule's explicit `border_color`/
/// `border_width` action can overwrite that afterward - so a window
/// carrying the *old* default is, in practice, exactly the set of windows
/// that never had a rule override it (a rule-set colour coincidentally
/// equal to today's default is the only false positive, and updating it
/// to the new default too is a reasonable outcome, not a real bug). That
/// predicate is what the two colour/width arms below walk existing
/// windows with, rather than touching every window unconditionally.
fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc<std::cell::RefCell<WindowManager>>) -> (Vec<u8>, bool) {
    let key = req.get("key").and_then(|v| v.as_str()).unwrap_or("");
    let value = req.get("value");
    match key {
        "border_width" => {
            let Some(width) = value.and_then(|v| v.as_u64()) else { return (err("border_width needs a numeric value"), false) };
            let width = width as u32;
            let mut wm = wm.borrow_mut();
            let old = wm.theme.default_border_width;
            wm.theme.default_border_width = width;
            let matching: Vec<_> = wm.windows().filter(|w| w.border_width == old).map(|w| w.id).collect();
            for id in matching {
                if let Some(w) = wm.window_mut(id) {
                    w.border_width = width;
                }
            }
            (ok(), true)
        }
        "border_color" => {
            let Some(hex) = value.and_then(|v| v.as_str()) else { return (err("border_color needs a hex string value"), false) };
            let Some(rgb) = srdwm_core::parse_hex_color(hex) else { return (err("border_color must be a hex string like #cba6f7"), false) };
            let mut wm = wm.borrow_mut();
            let old = wm.theme.default_border_color;
            wm.theme.default_border_color = rgb;
            let matching: Vec<_> = wm.windows().filter(|w| w.border_color == old).map(|w| w.id).collect();
            for id in matching {
                if let Some(w) = wm.window_mut(id) {
                    w.border_color = rgb;
                }
            }
            (ok(), true)
        }
        // `border_width`'s exact twin, for the titlebar/border-strip corner
        // radius - same "only touch windows still carrying the old
        // default" predicate, so a window a rule already gave its own
        // explicit `corner_radius` isn't silently overwritten by a later
        // live-set.
        "corner_radius" => {
            let Some(radius) = value.and_then(|v| v.as_u64()) else { return (err("corner_radius needs a numeric value"), false) };
            let radius = radius as u32;
            let mut wm = wm.borrow_mut();
            let old = wm.theme.default_corner_radius;
            wm.theme.default_corner_radius = radius;
            let matching: Vec<_> = wm.windows().filter(|w| w.corner_radius == old).map(|w| w.id).collect();
            for id in matching {
                if let Some(w) = wm.window_mut(id) {
                    w.corner_radius = radius;
                }
            }
            (ok(), true)
        }
        // Live A/B-testing knob for `srdwm_core::ThemeConfig::
        // default_decorated` - see its own doc comment for the "which
        // desktop environment does what" reasoning behind making this
        // configurable at all. Deliberately only affects windows created
        // *after* this call, not existing ones - retroactively flipping
        // an already-mapped window's decoration needs the same redraw-buffer
        // + geometry-resync `set_decorated_from_mode` does on the Wayland
        // side (backend-specific, unreachable from this backend-agnostic
        // `crates/platform` code), and the actual use case here is testing
        // which default a freshly opened app gets, not live-migrating
        // windows already on screen.
        "decoration_mode" => {
            let Some(mode) = value.and_then(|v| v.as_str().map(str::to_string)) else {
                return (err("decoration_mode needs \"server\" or \"client\""), false);
            };
            if mode != "server" && mode != "client" {
                return (err("decoration_mode needs \"server\" or \"client\""), false);
            }
            wm.borrow_mut().theme.default_decorated = mode != "client";
            (ok(), true)
        }
        // `srd set button_style <traffic_lights|traditional>` - live
        // equivalent of `theme.decorations.title_bar.button_style`
        // (`ThemeConfig::traffic_light_buttons`), previously config-file/
        // restart-only. Same "only affects windows created (or
        // redecorated) after this call" scope as `decoration_mode` above
        // - retroactively repainting every already-open window's titlebar
        // is real, separate work (a redraw-buffer invalidation this
        // backend-agnostic crate has no way to trigger itself).
        "button_style" => {
            let Some(v) = value.and_then(|v| v.as_str()) else { return (err("button_style needs \"traffic_lights\" or \"traditional\""), false) };
            if v != "traffic_lights" && v != "traditional" {
                return (err("button_style needs \"traffic_lights\" or \"traditional\""), false);
            }
            wm.borrow_mut().theme.traffic_light_buttons = v == "traffic_lights";
            (ok(), true)
        }
        // `srd set button_side <left|right>` - live equivalent of `theme.
        // decorations.title_bar.button_side`. Same scope note as
        // `button_style` above.
        "button_side" => {
            let Some(v) = value.and_then(|v| v.as_str()) else { return (err("button_side needs \"left\" or \"right\""), false) };
            if v != "left" && v != "right" {
                return (err("button_side needs \"left\" or \"right\""), false);
            }
            wm.borrow_mut().theme.buttons_left = v == "left";
            (ok(), true)
        }
        // `srd set button_order "close,minimize,maximize"` - live
        // equivalent of `theme.decorations.title_bar.button_order`.
        // `None` (the built-in default order for whichever side `button_
        // side` selects) is not reachable through this live path - only
        // a config reload clears an explicit override back to that,
        // matching the same asymmetry `rounded_corners`'s own live toggle
        // already has (an explicit live `Some`/`Some` only, never back to
        // an unset default).
        "button_order" => {
            let Some(raw) = value.and_then(|v| v.as_str()) else { return (err("button_order needs a string value"), false) };
            let Some(order) = srdwm_core::parse_button_order(raw) else {
                return (err("button_order must name close, minimize and maximize exactly once each, comma-separated"), false);
            };
            wm.borrow_mut().theme.button_order = Some(order);
            (ok(), true)
        }
        // `srd set title_centered <bool>` - live equivalent of `theme.
        // decorations.title_bar.title_centered`.
        "title_centered" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("title_centered needs a boolean value"), false) };
            wm.borrow_mut().theme.title_centered = v;
            (ok(), true)
        }
        // `srd set button_glyph_always <bool>` - live equivalent of
        // `theme.decorations.title_bar.button_glyph_always` (GNOME/Adwaita's
        // "always visible" convention vs classic macOS's "hidden until
        // hover").
        "button_glyph_always" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("button_glyph_always needs a boolean value"), false) };
            wm.borrow_mut().theme.button_glyph_always = v;
            (ok(), true)
        }
        // `srd set desktop_icons <bool>` - live equivalent of `general.
        // desktop_icons`. Unlike the theme keys above, this one *is*
        // immediately visible either way: `ensure_desktop_icons`'s own
        // early `if !self.wm.borrow().desktop_icons_enabled { return }`
        // check runs on every dirty tick, so turning icons off actually
        // stops drawing them (and back on redraws them) on this compositor's
        // very next redraw, not just for icons created after the call.
        "desktop_icons" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("desktop_icons needs a boolean value"), false) };
            wm.borrow_mut().desktop_icons_enabled = v;
            (ok(), true)
        }
        // `srd set desktop_icons_all_monitors <bool>` - live equivalent
        // of `general.desktop_icons_all_monitors`. Same immediacy as
        // `desktop_icons` above - `desktop_icon_origins` (this session's
        // own split-screen fix) reads this fresh every call.
        "desktop_icons_all_monitors" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("desktop_icons_all_monitors needs a boolean value"), false) };
            wm.borrow_mut().desktop_icons_all_monitors = v;
            (ok(), true)
        }
        // Tiling-only: `arrange_workspace` skips floating/fullscreen
        // windows regardless, and under `"dynamic"` (the no-op default
        // layout) nothing reads `tiling.gap_*` at all - so setting these
        // is a correct no-op, visually, until a workspace actually runs
        // the `"tiling"` layout, exactly matching what Hyprland's own
        // `general:gaps_*` do under its own non-tiling/floating windows.
        "gap_inner" => {
            let Some(v) = value.and_then(|v| v.as_u64()) else { return (err("gap_inner needs a numeric value"), false) };
            wm.borrow_mut().tiling.gap_inner = v as u32;
            (ok(), true)
        }
        "gap_outer" => {
            let Some(v) = value.and_then(|v| v.as_u64()) else { return (err("gap_outer needs a numeric value"), false) };
            wm.borrow_mut().tiling.gap_outer = v as u32;
            (ok(), true)
        }
        // `srd set master_ratio <0.0..1.0>` / `srd set master_count <n>` --
        // the two `TilingConfig` fields a resize-drag on the master/stack
        // boundary (`WindowManager::adjust_master_ratio_for_drag`) already
        // live-adjusts interactively; this is the same thing for a
        // keybinding or script instead of a mouse drag, e.g. dwm's
        // `mod+h`/`mod+l` grow/shrink-master or `mod+i`/`mod+d` add/remove-
        // a-master-window conventions. Unlike `gap_inner`/`gap_outer`
        // above, which accept "takes effect whenever the workspace next
        // re-arranges anyway" (a cosmetic preference under no time
        // pressure), this re-arranges the current workspace immediately --
        // a keybind pressed to grow the master column is expected to show
        // the result at once, the same instant feedback the drag path
        // already gives.
        "master_ratio" => {
            let Some(v) = value.and_then(|v| v.as_f64()) else { return (err("master_ratio needs a numeric value"), false) };
            let mut wm = wm.borrow_mut();
            wm.tiling.master_ratio = (v as f32).clamp(0.1, 0.9);
            let current = wm.current_workspace();
            wm.arrange_workspace(current);
            (ok(), true)
        }
        "master_count" => {
            let Some(v) = value.and_then(|v| v.as_u64()) else { return (err("master_count needs a numeric value"), false) };
            let mut wm = wm.borrow_mut();
            wm.tiling.master_count = (v as usize).max(1);
            let current = wm.current_workspace();
            wm.arrange_workspace(current);
            (ok(), true)
        }
        "shadows" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("shadows needs a boolean value"), false) };
            wm.borrow_mut().shadows_enabled = v;
            (ok(), true)
        }
        // A bool, not a radius: the actual corner radius is a fixed
        // constant (`crates/wayland/src/decoration.rs::CORNER_RADIUS`),
        // not a per-session config value anywhere in the compositor yet --
        // this can only turn rounding on/off, matching `WindowManager::
        // rounded_corners_enabled`'s existing `Option<bool>` shape (also
        // config-settable at startup via `general.rounded_corners`, never
        // live until now). A live-settable numeric radius is real, separate
        // future work, not something to fake here with a value that's
        // silently ignored.
        "rounded_corners" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("rounded_corners needs a boolean value"), false) };
            wm.borrow_mut().rounded_corners_enabled = Some(v);
            (ok(), true)
        }
        // Same shape as `shadows` - `WindowManager::animations_enabled`
        // already existed (config-settable at startup via `general.
        // animations`) but had no live IPC toggle, unlike shadows/rounded
        // corners which did. Added specifically so a performance-profile
        // script (ported from a Hyprland one that used `hyprctl keyword
        // animations:enabled`) has something real to call instead of
        // silently no-op-ing under srdwm.
        "animations" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("animations needs a boolean value"), false) };
            wm.borrow_mut().animations_enabled = v;
            (ok(), true)
        }
        // `srd set phone_mode <bool>` - live equivalent of `general.
        // phone_mode`, same "config-settable at startup, also live via
        // `srd set`" shape as `animations`/`shadows`/`rounded_corners`
        // just above. Only ever changes how the *next* new window opens
        // (`WindowManager::add_window`'s own use of this) - `changed`
        // is still `true` since a subscriber (a shell panel adapting its
        // own chrome to this same signal) genuinely has something new to
        // read from `srd settings`, even though no *window* moves as a
        // direct result of this call alone.
        "phone_mode" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("phone_mode needs a boolean value"), false) };
            wm.borrow_mut().phone_mode = v;
            (ok(), true)
        }
        // `srd set per_monitor <bool>` - live equivalent of `workspace.
        // per_monitor`, previously restart-only (flagged in the same
        // capability survey monitor split/scale were). Safe to flip live
        // with no reconciliation step needed: `monitor_workspaces` (the
        // per-monitor override map) starts empty and a monitor with no
        // entry in it always falls back to `current_workspace` regardless
        // of mode (`WindowManager::workspace_for_monitor`'s own doc
        // comment) - so turning this *on* changes nothing visually until
        // a monitor's workspace is switched independently for the first
        // time, and turning it back *off* simply resumes every monitor
        // showing `current_workspace`, the same shared value they'd
        // already fall back to individually.
        "per_monitor" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("per_monitor needs a boolean value"), false) };
            wm.borrow_mut().per_monitor_workspaces = v;
            (ok(), true)
        }
        // `srd set multi_cursor <bool>` - live equivalent of `general.
        // multi_cursor`. See `WindowManager::multi_cursor_enabled`'s own
        // doc comment for why this is opt-in rather than always-on.
        "multi_cursor" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("multi_cursor needs a boolean value"), false) };
            wm.borrow_mut().multi_cursor_enabled = v;
            (ok(), true)
        }
        "blur" => (err("blur is not supported - no GPU shader path on this compositor's software renderer yet"), false),
        // The two ported Hyprland `decoration:screen_shader` scripts --
        // mutually exclusive by construction (`srdwm_core::ColorFilter` is
        // one enum, not two bools), matching the original scripts' own
        // "both point at the same single shader slot" behaviour: setting
        // either key `true` clears the other, and `false` always clears to
        // `None` regardless of which one (if any) was actually active.
        "night_light" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("night_light needs a boolean value"), false) };
            wm.borrow_mut().color_filter = if v { srdwm_core::ColorFilter::NightLight } else { srdwm_core::ColorFilter::None };
            (ok(), true)
        }
        "reading_mode" => {
            let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("reading_mode needs a boolean value"), false) };
            wm.borrow_mut().color_filter = if v { srdwm_core::ColorFilter::ReadingMode } else { srdwm_core::ColorFilter::None };
            (ok(), true)
        }
        _ => (err("unknown set key"), false),
    }
}