srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/config/src/engine
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-04-27 00:18:00 +0200
committersrdusr <[email protected]>2026-04-27 00:18:00 +0200
commitc4dc99cc3211c4dc1a461f228402be1593b883c9 (patch)
tree10a85d60acb863ca98b7e70118dec5cda357efa6 /crates/config/src/engine
parent436d42da6ef61a5ea20d5102c4baed7bf0993606 (diff)
downloadsrdwm-c4dc99cc3211c4dc1a461f228402be1593b883c9.tar.gz
srdwm-c4dc99cc3211c4dc1a461f228402be1593b883c9.zip
Build the eight asks recovered from the previous session's transcript
The punch list was not the whole record. These are the owner's own typed requests, read back out of the previous session's transcript rather than guessed at, then each checked against the code before being treated as open. Three they suspected were already done really were: dialogs already had a Close-only titlebar, inactive dimming already existed, and the corner resize hitbox had already been tuned. Snap layouts on drag, asked for twice. Edge snapping worked but committed silently on release with nothing shown first, so there was no way to know it would happen or where. A translucent drop-target preview now follows the drag, and throwing the pointer at a monitor's top edge drops down the existing six-cell grid to aim at. The preview calls the same snap_zone that end_drag does, so the two cannot disagree. Two defects found by screenshot before landing: moving down onto the flyout closed it, and its labels overflowed at a fixed cell width - the same "text goes out of view" fault already fixed once for the context menu. New File now offers real types, chosen by extension, with the de-duplication counter placed before the extension so the file stays what it says it is. Refresh re-reads init.lua and fires a new srd.on("refresh") handler instead of only re-scanning the icon grid. What refresh means beyond srdwm's own config stays the config's decision. general.config_reload_on_write (default on) applies an edited config on save, via an mtime sweep rather than an inotify watch: no new dependency, same behaviour on every target, and unaffected by editors that write through a temp file. A real bug behind "what happens when our config fails": you lost every keybinding. do_reload cleared the binding, handler and repeat tables before re-executing and never restored them, so a syntax error left neither the old config nor the new one, and the only key still working was the reload combo nobody thinks to press. The tables are now restored on any failure and config errors reach notify-send, not just the log. srd.lock() and a default Mod4+Ctrl+l binding: the built-in lock screen could not be reached from Lua at all. Native rather than shelling out, because a lock key that shells out fails silently when the binary is not on PATH. Default bindings added for srd.window.move and a dynamic/tiling toggle, both of which existed with no way to reach them, plus srd.layout.get() so the toggle reads the live workspace rather than the configured default. Dialogs open centred, and are excluded from remembered geometry in both directions - that table is keyed by app_id, which a dialog shares with the window that spawned it, so dialogs inherited an unrelated position and size and then overwrote it with their own. theme.decorations.title_bar.button_mode (dynamic by default, or fixed) drops the Maximize button on a window whose client pinned min == max size, where pressing it can do nothing. Maximize is removed from the slot list rather than skipped in place on both the render and hit-test sides, so the remaining buttons close the gap identically; three tests pin that agreement, which is what fails silently when it drifts. Also: the nested backend's screencopy pass now draws both menus, the flyout and the drag preview. Four investigations in one day started from a screenshot missing a tier, so that pass carries an explicit list of what it still omits and the on-screen loop points at it. 512 tests pass, clippy clean.
Diffstat (limited to 'crates/config/src/engine')
-rw-r--r--crates/config/src/engine/general.rs25
-rw-r--r--crates/config/src/engine/layout.rs19
-rw-r--r--crates/config/src/engine/register.rs2
-rw-r--r--crates/config/src/engine/support.rs58
-rw-r--r--crates/config/src/engine/tests.rs78
5 files changed, 174 insertions, 8 deletions
diff --git a/crates/config/src/engine/general.rs b/crates/config/src/engine/general.rs
index 6276854..c10ecf0 100644
--- a/crates/config/src/engine/general.rs
+++ b/crates/config/src/engine/general.rs
@@ -79,7 +79,7 @@ impl Engine {
pub(super) fn fn_on(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
Ok(self.lua.create_function(move |lua, (name, f): (String, mlua::Function)| {
- const KNOWN: [&str; 3] = ["lid_closed", "lid_open", "ready"];
+ const KNOWN: [&str; 4] = ["lid_closed", "lid_open", "ready", "refresh"];
if !KNOWN.contains(&name.as_str()) {
return Err(mlua::Error::RuntimeError(format!(
"srd.on: unknown event '{name}' (known: {})",
@@ -327,6 +327,29 @@ impl Engine {
})?)
}
+ /// `srd.lock()` - locks the session, the same request the control
+ /// CLI's own lock dispatch sends over IPC.
+ ///
+ /// Native rather than left to a `srd.spawn(...)` shelling out to that
+ /// CLI: a lock binding that shells out fails silently when the binary
+ /// is not on the config's own `PATH`, which is exactly the situation a
+ /// user is least able to diagnose - the screen simply does not lock.
+ /// This goes straight to `WindowManager::request_lock`, the same flag
+ /// the IPC dispatch sets, so the backend's existing drain handles it
+ /// with no second code path.
+ ///
+ /// There was no way to bind the built-in lock screen from Lua at all
+ /// before this; the shipped config's only lock key ran an external
+ /// script. Asked directly: "do we have a lockscreen binding?".
+ pub(super) fn fn_lock(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ let wm = state.borrow().wm.clone();
+ wm.borrow_mut().request_lock();
+ Ok(())
+ })?)
+ }
+
pub(super) fn fn_reload(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
// `create_function`'s closure is handed the `&Lua` it's being
diff --git a/crates/config/src/engine/layout.rs b/crates/config/src/engine/layout.rs
index ebdb317..c0d923b 100644
--- a/crates/config/src/engine/layout.rs
+++ b/crates/config/src/engine/layout.rs
@@ -16,6 +16,25 @@ impl Engine {
})?)
}
+ /// `srd.layout.get()` - the name of the layout the *current workspace*
+ /// is using right now.
+ ///
+ /// Deliberately not `srd.get("workspace.layout")`, which reads the
+ /// config values table: that holds the configured default layout for
+ /// new workspaces, not what the workspace in front of you switched to
+ /// since. A toggle written against that key works exactly once and
+ /// then sticks, because the value it reads never changes.
+ pub(super) fn fn_layout_get(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |lua, ()| {
+ let wm = state.borrow().wm.clone();
+ let wm = wm.borrow();
+ let ws = wm.current_workspace();
+ let name = wm.workspace(ws).map(|w| w.layout.clone()).unwrap_or_default();
+ Ok(Value::String(lua.create_string(&name)?))
+ })?)
+ }
+
pub(super) fn fn_layout_configure(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
Ok(self.lua.create_function(move |_, (name, table): (String, Table)| {
diff --git a/crates/config/src/engine/register.rs b/crates/config/src/engine/register.rs
index 4ef6844..77aede9 100644
--- a/crates/config/src/engine/register.rs
+++ b/crates/config/src/engine/register.rs
@@ -20,6 +20,7 @@ impl Engine {
srd.set("setenv", self.fn_setenv()?)?;
srd.set("notify", self.fn_notify()?)?;
srd.set("quit", self.fn_quit()?)?;
+ srd.set("lock", self.fn_lock()?)?;
srd.set("reload", self.fn_reload()?)?;
srd.set("validate_config", self.fn_validate_config()?)?;
@@ -65,6 +66,7 @@ impl Engine {
let layout = lua.create_table()?;
layout.set("set", self.fn_layout_set()?)?;
+ layout.set("get", self.fn_layout_get()?)?;
layout.set("configure", self.fn_layout_configure()?)?;
srd.set("layout", layout)?;
diff --git a/crates/config/src/engine/support.rs b/crates/config/src/engine/support.rs
index 5574bee..a2423d2 100644
--- a/crates/config/src/engine/support.rs
+++ b/crates/config/src/engine/support.rs
@@ -4,17 +4,58 @@ use super::*;
/// the closure can't capture `&Engine` itself (it isn't `Clone`/`Rc`, and
/// `mlua::Lua::create_function` needs a `'static` closure), so both go
/// through cloned `Lua`/state handles instead of one calling the other.
+/// Re-executes `init.lua`, and puts the previous config back if that fails.
+///
+/// The clear-then-execute order is required: a binding or handler deleted
+/// from the edited file has to actually disappear, which only clearing
+/// first achieves. The bug was that nothing ever undid the clear. A Lua
+/// syntax error - the single most likely thing to go wrong with a
+/// programmable config, and the thing a user is most likely to do by
+/// accident - left the compositor with **no keybindings at all**: not the
+/// old ones, not the new ones. The only key still working was the hardcoded
+/// reload combo `main.rs` handles before consulting Lua, which is the one
+/// key nobody thinks to press when their config has just stopped working,
+/// because nothing tells them that is the situation.
+///
+/// Now the three maps are moved out rather than cleared, and moved back on
+/// any failure, so a broken edit leaves the last *working* config running.
+/// That is the behaviour every mainstream programmable config has (tmux,
+/// Neovim, Hyprland): a bad reload is a no-op with an error, not a
+/// half-applied state.
+///
+/// Answers "what happens when our config fails/user does something wrong
+/// which can be expected since lua programmable config" - asked directly,
+/// and previously answered by the code with "you lose every keybinding".
pub(super) fn do_reload(lua: &Lua, state: &Rc<RefCell<SharedState>>) -> Result<()> {
- let config_dir = {
+ let (config_dir, previous) = {
let mut s = state.borrow_mut();
- s.key_bindings.clear();
- s.event_handlers.clear();
- s.repeat_keys.clear();
- s.config_dir.clone()
+ let previous = (
+ std::mem::take(&mut s.key_bindings),
+ std::mem::take(&mut s.event_handlers),
+ std::mem::take(&mut s.repeat_keys),
+ );
+ (s.config_dir.clone(), previous)
+ };
+ let restore = |state: &Rc<RefCell<SharedState>>, previous: (_, _, _)| {
+ let mut s = state.borrow_mut();
+ // Whatever the failed run managed to register before erroring is
+ // discarded, not merged: half of a broken config is not a config.
+ s.key_bindings = previous.0;
+ s.event_handlers = previous.1;
+ s.repeat_keys = previous.2;
};
let path = config_dir.join("init.lua");
- let src = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io { path: path.clone(), source })?;
- lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?;
+ let src = match std::fs::read_to_string(&path) {
+ Ok(src) => src,
+ Err(source) => {
+ restore(state, previous);
+ return Err(ConfigError::Io { path, source });
+ }
+ };
+ if let Err(e) = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec() {
+ restore(state, previous);
+ return Err(e.into());
+ }
Ok(())
}
@@ -138,6 +179,9 @@ pub(super) fn default_config() -> HashMap<String, ConfigValue> {
m.insert(k.to_string(), v);
};
set("general.default_layout", String("dynamic".into()));
+ // Re-read `init.lua` when it changes on disk, no reload key needed.
+ // See `main.rs`'s `config_mtime`/`CONFIG_POLL_INTERVAL`.
+ set("general.config_reload_on_write", Bool(true));
set("general.smart_placement", Bool(true));
set("general.window_gap", Number(8.0));
set("general.animations", Bool(true));
diff --git a/crates/config/src/engine/tests.rs b/crates/config/src/engine/tests.rs
index f4f011b..591c4c0 100644
--- a/crates/config/src/engine/tests.rs
+++ b/crates/config/src/engine/tests.rs
@@ -101,6 +101,84 @@
}
#[test]
+ fn a_reload_that_fails_leaves_the_previous_config_running() {
+ // The single most likely thing to go wrong with a programmable
+ // config: the user saves a syntax error. Before this, the clear
+ // that precedes re-execution was never undone, so a bad edit left
+ // the compositor with no keybindings at all.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end)"#).unwrap();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+ assert_eq!(engine.bound_keys().len(), 1, "the good config bound one key");
+
+ std::fs::write(dir.path().join("init.lua"), "this is not lua ((").unwrap();
+ assert!(engine.reload().is_err(), "a syntax error must be reported");
+ assert_eq!(engine.bound_keys().len(), 1, "the working config must survive a failed reload");
+ assert!(engine.dispatch_keybinding(&srdwm_core::canonicalize_key_combo("Mod4+t")), "and must still actually run");
+ }
+
+ #[test]
+ fn a_reload_whose_file_has_vanished_also_keeps_the_previous_config() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end)"#).unwrap();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+ std::fs::remove_file(dir.path().join("init.lua")).unwrap();
+ assert!(engine.reload().is_err());
+ assert_eq!(engine.bound_keys().len(), 1);
+ }
+
+ #[test]
+ fn a_reload_that_succeeds_still_drops_bindings_removed_from_the_file() {
+ // The restore must not turn into "bindings are never cleared".
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end) srd.bind("Mod4+y", function() end)"#).unwrap();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+ assert_eq!(engine.bound_keys().len(), 2);
+
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end)"#).unwrap();
+ engine.reload().unwrap();
+ assert_eq!(engine.bound_keys().len(), 1, "a deleted binding must really go away");
+ }
+
+ #[test]
+ fn a_half_applied_broken_config_is_discarded_not_merged() {
+ // The failing file registers a binding and *then* errors. Keeping
+ // it would leave a config that is neither the old one nor the new.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end)"#).unwrap();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+
+ std::fs::write(dir.path().join("init.lua"), "srd.bind(\"Mod4+z\", function() end)\nerror(\"boom\")").unwrap();
+ assert!(engine.reload().is_err());
+ let keys = engine.bound_keys();
+ assert_eq!(keys.len(), 1);
+ assert!(!keys.iter().any(|k| k.contains('z')), "the broken run's own binding must not survive: {keys:?}");
+ }
+
+ #[test]
+ fn refresh_is_a_known_event_name() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine.lua.load(r#"srd.on("refresh", function() srd.set("refreshed", true) end)"#).exec().unwrap();
+ assert!(engine.dispatch_event("refresh"), "a registered refresh handler must run");
+ assert_eq!(engine.get("refreshed"), Some(ConfigValue::Bool(true)));
+ }
+
+ #[test]
+ fn srd_lock_queues_a_lock_request_on_the_window_manager() {
+ let dir = tempfile::tempdir().unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+ let engine = Engine::new(wm.clone(), dir.path()).unwrap();
+ assert!(!wm.borrow_mut().drain_lock_request(), "nothing queued yet");
+ engine.lua.load("srd.lock()").exec().unwrap();
+ assert!(wm.borrow_mut().drain_lock_request(), "srd.lock() must queue a real request");
+ }
+
+ #[test]
fn srd_load_executes_module_relative_to_config_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("extra.lua"), r#"srd.set("from.extra", "yes")"#).unwrap();