srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/core/src/lib.rs2
-rw-r--r--crates/core/src/window.rs32
-rw-r--r--crates/ctl/src/main.rs56
-rw-r--r--crates/platform/src/ipc/dispatch.rs105
-rw-r--r--crates/platform/src/ipc/tests.rs97
-rw-r--r--crates/platform/src/ipc/types.rs18
-rw-r--r--docs/TODO.md14
7 files changed, 319 insertions, 5 deletions
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 7b728d9..ed24e78 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -24,7 +24,7 @@ pub use regex::Regex;
pub use rules::{WindowMatch, WindowRule, WindowRuleActions};
pub use theme::{format_hex_color, parse_hex_color, ThemeConfig};
pub use window::{
- classify_menu_source, parse_button_order, ButtonOrder, GlobalMenu, MenuSource, ResizeEdge, TitlebarButton, TitlebarHit, Window, WindowId,
+ classify_menu_source, format_button_order, parse_button_order, ButtonOrder, GlobalMenu, MenuSource, ResizeEdge, TitlebarButton, TitlebarHit, Window, WindowId,
BUTTON_CLUSTER_MARGIN, BUTTON_PITCH, RESIZE_MARGIN, TITLEBAR_HEIGHT,
};
pub use workspace::{Workspace, WorkspaceId};
diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs
index a8de352..21faeff 100644
--- a/crates/core/src/window.rs
+++ b/crates/core/src/window.rs
@@ -838,6 +838,23 @@ pub fn parse_button_order(s: &str) -> Option<ButtonOrder> {
Some(order)
}
+/// [`parse_button_order`]'s exact inverse - `"close,minimize,maximize"`,
+/// lowercase, comma-separated in the order the buttons actually render.
+/// Exists for settings readback, same reasoning as `format_hex_color`:
+/// a caller reading `button_order` back over IPC should get the identical
+/// string shape `srd set button_order` itself accepts.
+pub fn format_button_order(order: ButtonOrder) -> String {
+ order
+ .iter()
+ .map(|b| match b {
+ TitlebarButton::Close => "close",
+ TitlebarButton::Minimize => "minimize",
+ TitlebarButton::Maximize => "maximize",
+ })
+ .collect::<Vec<_>>()
+ .join(",")
+}
+
#[cfg(test)]
mod button_order_tests {
use super::*;
@@ -869,6 +886,21 @@ mod button_order_tests {
}
#[test]
+ fn format_button_order_round_trips_through_parse_button_order() {
+ for order in [
+ [TitlebarButton::Close, TitlebarButton::Minimize, TitlebarButton::Maximize],
+ [TitlebarButton::Maximize, TitlebarButton::Minimize, TitlebarButton::Close],
+ ] {
+ assert_eq!(parse_button_order(&format_button_order(order)), Some(order));
+ }
+ }
+
+ #[test]
+ fn format_button_order_matches_the_exact_shape_set_accepts() {
+ assert_eq!(format_button_order([TitlebarButton::Close, TitlebarButton::Minimize, TitlebarButton::Maximize]), "close,minimize,maximize");
+ }
+
+ #[test]
fn rejects_an_unknown_token() {
assert_eq!(parse_button_order("close,minimize,help"), None);
}
diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs
index fb7196c..1751483 100644
--- a/crates/ctl/src/main.rs
+++ b/crates/ctl/src/main.rs
@@ -186,7 +186,7 @@ fn build_request(args: &[String]) -> Result<String, String> {
// as booleans at all, not a string it then has to reject.
Some("set") => {
let key = args.get(1).ok_or(
- "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/master_ratio/master_count/shadows/rounded_corners/animations/night_light/reading_mode/phone_mode/multi_cursor/decoration_mode)",
+ "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/master_ratio/master_count/shadows/rounded_corners/animations/night_light/reading_mode/phone_mode/multi_cursor/per_monitor/decoration_mode/button_style/button_side/button_order/title_centered/button_glyph_always/desktop_icons/desktop_icons_all_monitors)",
)?;
let raw = args.get(2).ok_or("set needs a value")?;
let value = match key.as_str() {
@@ -194,7 +194,8 @@ fn build_request(args: &[String]) -> Result<String, String> {
raw.parse::<u64>().map_err(|_| format!("{key} needs a numeric value"))?.to_string()
}
"master_ratio" => raw.parse::<f64>().map_err(|_| format!("{key} needs a numeric value"))?.to_string(),
- "shadows" | "rounded_corners" | "animations" | "night_light" | "reading_mode" | "phone_mode" | "multi_cursor" => match raw.as_str() {
+ "shadows" | "rounded_corners" | "animations" | "night_light" | "reading_mode" | "phone_mode" | "multi_cursor" | "per_monitor" | "title_centered" | "button_glyph_always"
+ | "desktop_icons" | "desktop_icons_all_monitors" => match raw.as_str() {
"true" | "false" => raw.clone(),
_ => return Err(format!("{key} needs 'true' or 'false'")),
},
@@ -202,7 +203,15 @@ fn build_request(args: &[String]) -> Result<String, String> {
"server" | "client" => format!("{:?}", raw),
_ => return Err(format!("{key} needs 'server' or 'client'")),
},
- "border_color" => format!("{:?}", raw),
+ "button_style" => match raw.as_str() {
+ "traffic_lights" | "traditional" => format!("{:?}", raw),
+ _ => return Err(format!("{key} needs 'traffic_lights' or 'traditional'")),
+ },
+ "button_side" => match raw.as_str() {
+ "left" | "right" => format!("{:?}", raw),
+ _ => return Err(format!("{key} needs 'left' or 'right'")),
+ },
+ "border_color" | "button_order" => format!("{:?}", raw),
_ => return Err(format!("unknown set key '{key}'")),
};
Ok(format!(r#"{{"cmd":"set","key":"{key}","value":{value}}}"#))
@@ -432,7 +441,17 @@ fn print_usage() {
eprintln!(" srd set reading_mode <true|false>");
eprintln!(" srd set phone_mode <true|false>");
eprintln!(" srd set multi_cursor <true|false>");
+ eprintln!(" srd set per_monitor <true|false>");
+ eprintln!(" srd set master_ratio <0.1-0.9>");
+ eprintln!(" srd set master_count <n>");
eprintln!(" srd set decoration_mode <server|client>");
+ eprintln!(" srd set button_style <traffic_lights|traditional>");
+ eprintln!(" srd set button_side <left|right>");
+ eprintln!(" srd set button_order <close,minimize,maximize order>");
+ eprintln!(" srd set title_centered <true|false>");
+ eprintln!(" srd set button_glyph_always <true|false>");
+ eprintln!(" srd set desktop_icons <true|false>");
+ eprintln!(" srd set desktop_icons_all_monitors <true|false>");
}
#[cfg(test)]
@@ -676,6 +695,37 @@ mod tests {
assert_eq!(build_request(&args(&["set", "master_count", "2"])).unwrap(), r#"{"cmd":"set","key":"master_count","value":2}"#);
assert!(build_request(&args(&["set", "master_count", "not-a-number"])).is_err());
}
+
+ #[test]
+ fn set_per_monitor_accepts_only_true_or_false() {
+ assert_eq!(build_request(&args(&["set", "per_monitor", "true"])).unwrap(), r#"{"cmd":"set","key":"per_monitor","value":true}"#);
+ assert_eq!(build_request(&args(&["set", "per_monitor", "false"])).unwrap(), r#"{"cmd":"set","key":"per_monitor","value":false}"#);
+ assert!(build_request(&args(&["set", "per_monitor", "maybe"])).is_err());
+ }
+
+ #[test]
+ fn set_button_style_accepts_only_the_two_known_values() {
+ assert_eq!(build_request(&args(&["set", "button_style", "traditional"])).unwrap(), r#"{"cmd":"set","key":"button_style","value":"traditional"}"#);
+ assert_eq!(build_request(&args(&["set", "button_style", "traffic_lights"])).unwrap(), r#"{"cmd":"set","key":"button_style","value":"traffic_lights"}"#);
+ assert!(build_request(&args(&["set", "button_style", "square"])).is_err());
+ }
+
+ #[test]
+ fn set_button_side_accepts_only_left_or_right() {
+ assert_eq!(build_request(&args(&["set", "button_side", "right"])).unwrap(), r#"{"cmd":"set","key":"button_side","value":"right"}"#);
+ assert!(build_request(&args(&["set", "button_side", "top"])).is_err());
+ }
+
+ #[test]
+ fn set_button_order_passes_the_string_through() {
+ assert_eq!(build_request(&args(&["set", "button_order", "close,minimize,maximize"])).unwrap(), r#"{"cmd":"set","key":"button_order","value":"close,minimize,maximize"}"#);
+ }
+
+ #[test]
+ fn set_desktop_icons_accepts_only_true_or_false() {
+ assert_eq!(build_request(&args(&["set", "desktop_icons", "false"])).unwrap(), r#"{"cmd":"set","key":"desktop_icons","value":false}"#);
+ assert!(build_request(&args(&["set", "desktop_icons", "maybe"])).is_err());
+ }
}
#[cfg(unix)]
diff --git a/crates/platform/src/ipc/dispatch.rs b/crates/platform/src/ipc/dispatch.rs
index 3dcca57..c5e70be 100644
--- a/crates/platform/src/ipc/dispatch.rs
+++ b/crates/platform/src/ipc/dispatch.rs
@@ -36,6 +36,14 @@ pub(crate) fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<Wi
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)
}
@@ -498,6 +506,86 @@ fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc<std::cell::RefCell<Windo
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
@@ -588,6 +676,23 @@ fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc<std::cell::RefCell<Windo
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.
diff --git a/crates/platform/src/ipc/tests.rs b/crates/platform/src/ipc/tests.rs
index 13ab4c2..c2a678c 100644
--- a/crates/platform/src/ipc/tests.rs
+++ b/crates/platform/src/ipc/tests.rs
@@ -751,7 +751,24 @@ fn settings_reports_the_readback_fields_flagged_as_missing() {
server.poll(&wm);
let line = read_line(&mut std::io::BufReader::new(client));
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
- for field in ["border_width", "border_color", "corner_radius", "decoration_mode_server", "gap_inner", "gap_outer", "master_ratio", "master_count"] {
+ for field in [
+ "border_width",
+ "border_color",
+ "corner_radius",
+ "decoration_mode_server",
+ "gap_inner",
+ "gap_outer",
+ "master_ratio",
+ "master_count",
+ "per_monitor",
+ "button_style",
+ "button_side",
+ "button_order",
+ "title_centered",
+ "button_glyph_always",
+ "desktop_icons",
+ "desktop_icons_all_monitors",
+ ] {
assert!(parsed.get(field).is_some(), "settings response is missing '{field}'");
}
assert_eq!(parsed["border_color"].as_str().unwrap().chars().next(), Some('#'), "border_color must be a hex string, matching what srd set border_color itself accepts");
@@ -791,6 +808,84 @@ fn set_master_ratio_clamps_to_a_sane_range() {
}
#[test]
+fn set_per_monitor_is_reflected_immediately_by_settings() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut server = IpcServer::bind_in(dir.path(), "test").unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+
+ let mut set_client = UnixStream::connect(&server.path).unwrap();
+ set_client.write_all(b"{\"cmd\":\"set\",\"key\":\"per_monitor\",\"value\":true}\n").unwrap();
+ server.poll(&wm);
+ let _ = read_line(&mut std::io::BufReader::new(set_client));
+
+ let mut settings_client = UnixStream::connect(&server.path).unwrap();
+ settings_client.write_all(b"{\"cmd\":\"settings\"}\n").unwrap();
+ server.poll(&wm);
+ let line = read_line(&mut std::io::BufReader::new(settings_client));
+ assert!(line.contains(r#""per_monitor":true"#));
+}
+
+#[test]
+fn button_and_desktop_icon_settings_are_readable_and_live_settable() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut server = IpcServer::bind_in(dir.path(), "test").unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+
+ for (key, value) in [
+ ("button_style", "\"traditional\""),
+ ("button_side", "\"left\""),
+ ("button_order", "\"maximize,minimize,close\""),
+ ("title_centered", "true"),
+ ("button_glyph_always", "true"),
+ ("desktop_icons", "false"),
+ ("desktop_icons_all_monitors", "false"),
+ ] {
+ let mut client = UnixStream::connect(&server.path).unwrap();
+ client.write_all(format!("{{\"cmd\":\"set\",\"key\":\"{key}\",\"value\":{value}}}\n").as_bytes()).unwrap();
+ server.poll(&wm);
+ let _ = read_line(&mut std::io::BufReader::new(client));
+ }
+
+ let mut settings_client = UnixStream::connect(&server.path).unwrap();
+ settings_client.write_all(b"{\"cmd\":\"settings\"}\n").unwrap();
+ server.poll(&wm);
+ let line = read_line(&mut std::io::BufReader::new(settings_client));
+ assert!(line.contains(r#""button_style":"traditional""#));
+ assert!(line.contains(r#""button_side":"left""#));
+ assert!(line.contains(r#""button_order":"maximize,minimize,close""#));
+ assert!(line.contains(r#""title_centered":true"#));
+ assert!(line.contains(r#""button_glyph_always":true"#));
+ assert!(line.contains(r#""desktop_icons":false"#));
+ assert!(line.contains(r#""desktop_icons_all_monitors":false"#));
+}
+
+#[test]
+fn button_order_is_null_until_explicitly_set() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut server = IpcServer::bind_in(dir.path(), "test").unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+
+ let mut client = UnixStream::connect(&server.path).unwrap();
+ client.write_all(b"{\"cmd\":\"settings\"}\n").unwrap();
+ server.poll(&wm);
+ let line = read_line(&mut std::io::BufReader::new(client));
+ assert!(line.contains(r#""button_order":null"#));
+}
+
+#[test]
+fn set_button_order_rejects_an_invalid_ordering() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut server = IpcServer::bind_in(dir.path(), "test").unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+
+ let mut client = UnixStream::connect(&server.path).unwrap();
+ client.write_all(b"{\"cmd\":\"set\",\"key\":\"button_order\",\"value\":\"close,close,maximize\"}\n").unwrap();
+ server.poll(&wm);
+ let line = read_line(&mut std::io::BufReader::new(client));
+ assert!(line.contains(r#""error""#));
+}
+
+#[test]
fn pinned_inputs_lists_nothing_before_any_pin_is_applied() {
let dir = tempfile::tempdir().unwrap();
let mut server = IpcServer::bind_in(dir.path(), "test").unwrap();
diff --git a/crates/platform/src/ipc/types.rs b/crates/platform/src/ipc/types.rs
index 24e3148..bf1526e 100644
--- a/crates/platform/src/ipc/types.rs
+++ b/crates/platform/src/ipc/types.rs
@@ -198,6 +198,24 @@ pub(crate) struct SettingsResponse {
/// `master_count`.
pub(crate) master_ratio: f32,
pub(crate) master_count: usize,
+ /// `WindowManager::per_monitor_workspaces`'s own doc comment - `srd
+ /// set per_monitor <bool>`'s readback.
+ pub(crate) per_monitor: bool,
+ /// `ThemeConfig::traffic_light_buttons`'s readback, as the same
+ /// `"traffic_lights"`/`"traditional"` string `srd set button_style`
+ /// itself accepts.
+ pub(crate) button_style: String,
+ /// `ThemeConfig::buttons_left`'s readback, as `"left"`/`"right"`.
+ pub(crate) button_side: String,
+ /// `ThemeConfig::button_order`'s readback - `null` when unset (the
+ /// built-in default for whichever side `button_side` selects), the
+ /// same `"close,minimize,maximize"` string shape `srd set button_
+ /// order` accepts otherwise.
+ pub(crate) button_order: Option<String>,
+ pub(crate) title_centered: bool,
+ pub(crate) button_glyph_always: bool,
+ pub(crate) desktop_icons: bool,
+ pub(crate) desktop_icons_all_monitors: bool,
}
/// `"keyboard_layout"`'s one-shot reply shape - the active XKB layout's
diff --git a/docs/TODO.md b/docs/TODO.md
index 2593728..06a2236 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,5 +1,19 @@
# TODO / planned features - master checklist
+## workspace.per_monitor, titlebar buttons, and desktop icons: live srd set + readback (2026-08-28)
+
+Closed the rest of the "config-file only" gaps named directly in the AGS capability survey.
+
+`workspace.per_monitor` (shared vs Hyprland/niri-style independent per-monitor workspace sets) now has a live `srd set per_monitor <bool>` path. Safe to flip live with no reconciliation step: `monitor_workspaces` (the per-monitor override map) starts empty and any monitor with no entry in it already falls back to `current_workspace` regardless of mode, so turning the mode on changes nothing visually until a monitor's workspace is independently switched for the first time, and turning it back off just resumes every monitor showing the one shared value they'd already fall back to individually.
+
+Titlebar button style/side/order (`theme.decorations.title_bar.button_style`/`button_side`/`button_order`) and desktop icons (`general.desktop_icons`/`desktop_icons_all_monitors`) all get the same treatment: `srd set button_style <traffic_lights|traditional>`, `button_side <left|right>`, `button_order "close,minimize,maximize"`, `title_centered`/`button_glyph_always <bool>`, `desktop_icons`/`desktop_icons_all_monitors <bool>`. The two desktop-icon ones are immediately visible either way (`ensure_desktop_icons`/`desktop_icon_origins` both read their fields fresh on every dirty tick); the theme/titlebar ones only affect windows created (or redecorated) after the call, same documented scope `decoration_mode` already has - retroactively repainting every already-open window's titlebar needs a redraw-buffer invalidation this backend-agnostic crate has no way to trigger itself.
+
+New `srdwm_core::format_button_order` is `parse_button_order`'s exact inverse, so `button_order`'s readback is the identical comma-separated string shape `srd set` itself accepts, not a different representation of the same three-button ordering.
+
+`SettingsResponse` gained all eight new fields (`per_monitor`, `button_style`, `button_side`, `button_order` - `null` until explicitly set, since there's no live path back to the built-in per-side default - `title_centered`, `button_glyph_always`, `desktop_icons`, `desktop_icons_all_monitors`), closing the survey's readback gap for every item it named as config-only.
+
+Full workspace build/test/clippy clean (233 core / 43 platform / 40 ctl tests, all up from before).
+
## Monitor scale: investigated a live path, parked rather than built (2026-08-28)
`srd.monitor.split` got a live IPC path this session; `srd.monitor.scale` was the obvious next candidate, also flagged PARTIAL in the earlier AGS-capability survey. Investigated what a live path would actually require: `WindowManager::monitor_scale` is read by exactly two functions, `disable_connector_by_name`/`enable_connector_by_name` (`udev/outputs.rs`) - the only two places that ever bring a head up or down at all. There is no third, lighter-weight "just reconfigure the mode in place" path; applying a changed scale live means calling both, back to back, which is a real disable-then-re-enable cycle on the actual physical connector - the same real screen blank a genuine unplug/replug already causes, not a silent in-place change.