diff options
Diffstat (limited to 'crates/config/src')
| -rw-r--r-- | crates/config/src/engine/general.rs | 25 | ||||
| -rw-r--r-- | crates/config/src/engine/layout.rs | 19 | ||||
| -rw-r--r-- | crates/config/src/engine/register.rs | 2 | ||||
| -rw-r--r-- | crates/config/src/engine/support.rs | 58 | ||||
| -rw-r--r-- | crates/config/src/engine/tests.rs | 78 |
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(); |