srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/config/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/config/src/lib.rs')
-rw-r--r--crates/config/src/lib.rs208
1 files changed, 200 insertions, 8 deletions
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
index 6a0eeea..57fe582 100644
--- a/crates/config/src/lib.rs
+++ b/crates/config/src/lib.rs
@@ -96,6 +96,27 @@ impl Engine {
self.exec_file(&path)
}
+ /// Re-executes `init.lua` from scratch: clears keybindings, event
+ /// handlers and the repeat-key set first, so a binding or handler
+ /// removed from the edited config doesn't linger from the previous
+ /// load. `values` (`srd.set` keys) are deliberately left alone --
+ /// `platform.backend`/`platform.os` are published once by `main.rs`
+ /// before the *first* `load_init` and nothing in Lua ever re-sets them,
+ /// so clearing `values` here would silently break every
+ /// `if srd.get("platform.backend") == ...` branch in the reloaded
+ /// config.
+ ///
+ /// Does *not* re-grab/re-register the reloaded key set with the
+ /// platform backend - `main.rs` reads `bound_keys()` once, before
+ /// connecting, to build the X11 `XGrabKey` list / Wayland intercept
+ /// set. A binding whose *combo* is unchanged from startup picks up a
+ /// reload immediately; a config that adds a brand new combo needs a
+ /// real restart before the backend will ever hand that keypress to
+ /// srdwm instead of the focused client.
+ pub fn reload(&self) -> Result<()> {
+ do_reload(&self.lua, &self.state)
+ }
+
pub fn exec_file(&self, path: &Path) -> Result<()> {
let src = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { path: path.to_path_buf(), source })?;
self.lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?;
@@ -118,6 +139,17 @@ impl Engine {
self.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
}
+ /// Sets a config value from Rust rather than Lua - used by `main.rs`
+ /// to publish facts the *host* determined (which backend was picked,
+ /// which OS this is) before `load_init` runs `init.lua`, so config
+ /// files can read them back via `srd.get(key)` and branch on them
+ /// (`if srd.get("platform.backend") == "wayland" then ... end`).
+ /// Writing straight into `values` is the same thing `srd.set` does from
+ /// the Lua side, just without going through the interpreter.
+ pub fn set_string(&self, key: &str, value: impl Into<String>) {
+ self.state.borrow_mut().values.insert(key.to_string(), ConfigValue::String(value.into()));
+ }
+
/// Runs the Lua function bound to `combo` (e.g. `"Mod4+Return"`), if any.
/// Returns `true` if a binding existed and ran without erroring.
/// Runs the `srd.on(name, ...)` handler for a non-key event, if any.
@@ -180,6 +212,7 @@ impl Engine {
srd.set("spawn", self.fn_spawn()?)?;
srd.set("notify", self.fn_notify()?)?;
srd.set("quit", self.fn_quit()?)?;
+ srd.set("reload", self.fn_reload()?)?;
srd.set("validate_config", self.fn_validate_config()?)?;
let debug = lua.create_table()?;
@@ -207,6 +240,16 @@ impl Engine {
window.set("set_floating", self.fn_window_set_floating()?)?;
window.set("toggle_floating", self.fn_window_action(WindowAction::ToggleFloating)?)?;
window.set("is_floating", self.fn_window_is_floating()?)?;
+ // `srd.window.scratchpad()` moves the *focused* window into the
+ // scratchpad pool, hiding it (sway's `move scratchpad`).
+ // `srd.window.scratchpad_show()` toggles pool visibility and takes
+ // no target of its own - deliberately not routed through
+ // `fn_window_action`'s focused-window gate, since showing a hidden
+ // scratchpad window has to work even when nothing is currently
+ // focused (an empty workspace, or focus on a different monitor).
+ // See `WindowManager::scratchpad_show`'s doc comment.
+ window.set("scratchpad", self.fn_window_action(WindowAction::ScratchpadAdd)?)?;
+ window.set("scratchpad_show", self.fn_scratchpad_show()?)?;
srd.set("window", window)?;
let layout = lua.create_table()?;
@@ -310,11 +353,18 @@ impl Engine {
}
/// `srd.on("lid_closed", function() ... end)` - registers a handler for
- /// a non-key event. Currently `"lid_closed"` and `"lid_open"`.
+ /// a non-key event. `"ready"` fires once, after the platform backend has
+ /// connected (real Wayland/X11 display available, `WAYLAND_DISPLAY`/
+ /// `DISPLAY` set for anything `srd.spawn`ed from the handler to inherit)
+ /// - see `main.rs`. Config that starts background processes (a bar,
+ /// wallpaper daemon, clipboard watcher) belongs in a `"ready"` handler,
+ /// not at a config file's top level: top-level code runs during
+ /// `load_init`, which is *before* the platform connects, so anything
+ /// spawned there inherits no display socket to connect to at all.
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; 2] = ["lid_closed", "lid_open"];
+ const KNOWN: [&str; 3] = ["lid_closed", "lid_open", "ready"];
if !KNOWN.contains(&name.as_str()) {
return Err(mlua::Error::RuntimeError(format!(
"srd.on: unknown event '{name}' (known: {})",
@@ -333,6 +383,7 @@ impl Engine {
fn fn_bind_repeat(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
Ok(self.lua.create_function(move |lua, (combo, f): (String, mlua::Function)| {
+ let combo = srdwm_core::canonicalize_key_combo(&combo);
let key = lua.create_registry_value(f)?;
let mut s = state.borrow_mut();
s.repeat_keys.insert(combo.clone());
@@ -344,16 +395,35 @@ impl Engine {
fn fn_bind(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
Ok(self.lua.create_function(move |lua, (combo, f): (String, mlua::Function)| {
+ // `key_bindings` is keyed by whatever string dispatch builds
+ // from a real keypress (`srdwm_core::key_combo_string`, fixed
+ // Ctrl/Shift/Alt/Mod4 order) - storing the config's own
+ // literal string here (usually written Super-first,
+ // "Mod4+Shift+x") meant multi-modifier bindings could never be
+ // found at dispatch time even though the raw key was correctly
+ // grabbed/intercepted. See `parse_key_combo`'s doc comment.
+ let combo = srdwm_core::canonicalize_key_combo(&combo);
let key = lua.create_registry_value(f)?;
state.borrow_mut().key_bindings.insert(combo, key);
Ok(())
})?)
}
- /// `srd.rule({ title = "...", class = "..." }, { floating = true, workspace = 2,
- /// x = .., y = .., width = .., height = .., decorated = false,
- /// border_color = {r,g,b}, border_width = 2, maximized = true })`.
- /// At least one matcher field is required; unmatched rules apply nothing.
+ /// `srd.rule({ title = "...", class = "...", title_regex = "...",
+ /// class_regex = "...", instance = "..." }, { floating = true,
+ /// workspace = 2, x = .., y = .., width = .., height = ..,
+ /// decorated = false, border_color = {r,g,b}, border_width = 2,
+ /// maximized = true })`. At least one matcher field is required;
+ /// unmatched rules apply nothing.
+ ///
+ /// `title`/`class` are plain substring/exact match, cheap and cover
+ /// most rules with no regex syntax to get right. `title_regex`/
+ /// `class_regex` (Rust `regex` crate syntax, case-sensitive unless the
+ /// pattern starts with `(?i)`) and `instance` (X11 `WM_CLASS`'s
+ /// instance half, matched exactly - see `srdwm_core::WindowMatch`'s
+ /// doc comment) exist for the cases that need more precision, e.g.
+ /// disambiguating a specific dialog by title while leaving an app's
+ /// main window alone. Every field given is ANDed together.
fn fn_rule(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
Ok(self.lua.create_function(move |_, (matcher, actions): (Table, Table)| {
@@ -362,6 +432,15 @@ impl Engine {
Some(c) => Some(c),
None => matcher.get("app_id")?,
};
+ let instance: Option<String> = matcher.get("instance")?;
+ let title_regex = match matcher.get::<_, Option<String>>("title_regex")? {
+ Some(pat) => Some(srdwm_core::Regex::new(&pat).map_err(|e| mlua::Error::RuntimeError(format!("srd.rule: invalid title_regex '{pat}': {e}")))?),
+ None => None,
+ };
+ let class_regex = match matcher.get::<_, Option<String>>("class_regex")? {
+ Some(pat) => Some(srdwm_core::Regex::new(&pat).map_err(|e| mlua::Error::RuntimeError(format!("srd.rule: invalid class_regex '{pat}': {e}")))?),
+ None => None,
+ };
let border_color: Option<(u8, u8, u8)> = match actions.get::<_, Option<Table>>("border_color")? {
Some(t) => Some((t.get(1)?, t.get(2)?, t.get(3)?)),
@@ -379,7 +458,7 @@ impl Engine {
};
let rule = WindowRule {
- matcher: WindowMatch { title_contains, class },
+ matcher: WindowMatch { title_contains, class, title_regex, class_regex, instance },
actions: WindowRuleActions {
floating: actions.get("floating")?,
maximized: actions.get("maximized")?,
@@ -429,6 +508,21 @@ impl Engine {
})?)
}
+ fn fn_reload(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ // `create_function`'s closure is handed the `&Lua` it's being
+ // called from as its first argument - used directly here instead
+ // of capturing a cloned handle, since `mlua::Lua` isn't `Clone` in
+ // this version.
+ Ok(self.lua.create_function(move |lua, ()| {
+ match do_reload(lua, &state) {
+ Ok(()) => log::info!("srd.reload: config reloaded"),
+ Err(e) => log::error!("srd.reload: {e}"),
+ }
+ Ok(())
+ })?)
+ }
+
fn fn_notify(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
Ok(self.lua.create_function(move |_, (message, level): (String, Option<String>)| {
@@ -534,6 +628,7 @@ impl Engine {
t.set("floating", w.floating)?;
t.set("maximized", w.maximized)?;
t.set("minimized", w.minimized)?;
+ t.set("scratchpad", w.scratchpad)?;
Ok(Value::Table(t))
})?)
}
@@ -551,12 +646,22 @@ impl Engine {
WindowAction::Fullscreen => wm.toggle_fullscreen(id),
WindowAction::ToggleFloating => wm.toggle_floating(id),
WindowAction::TogglePin => wm.toggle_always_on_top(id),
+ WindowAction::ScratchpadAdd => wm.scratchpad_add(id),
}
}
Ok(())
})?)
}
+ fn fn_scratchpad_show(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ let wm = state.borrow().wm.clone();
+ wm.borrow_mut().scratchpad_show();
+ Ok(())
+ })?)
+ }
+
/// `srd.window.move("left")` - swap the focused window with its
/// neighbour in that direction (Hyprland's `movewindow l/r/u/d`).
fn fn_window_move_direction(&self) -> Result<mlua::Function<'_>> {
@@ -745,6 +850,24 @@ impl Engine {
}
}
+/// Shared between [`Engine::reload`] and `srd.reload()`'s Lua closure --
+/// 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.
+fn do_reload(lua: &Lua, state: &Rc<RefCell<SharedState>>) -> Result<()> {
+ let config_dir = {
+ let mut s = state.borrow_mut();
+ s.key_bindings.clear();
+ s.event_handlers.clear();
+ s.repeat_keys.clear();
+ s.config_dir.clone()
+ };
+ 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()?;
+ Ok(())
+}
+
/// Shared by `srd.window.focus` and `srd.window.move` so both accept
/// exactly the same direction names and report the same error.
fn parse_direction(name: &str, caller: &str) -> mlua::Result<Direction> {
@@ -765,6 +888,7 @@ enum WindowAction {
Fullscreen,
ToggleFloating,
TogglePin,
+ ScratchpadAdd,
}
/// Recursively flattens a Lua table into dotted config keys, e.g.
@@ -914,7 +1038,7 @@ fn default_config() -> HashMap<String, ConfigValue> {
set("layout.tiling.behavior.auto_balance", Bool(true));
set("layout.tiling.behavior.preserve_ratio", Bool(true));
- set("layout.dynamic.snap_threshold", Number(50.0));
+ set("layout.dynamic.snap_threshold", Number(20.0));
set("layout.dynamic.grid_size", Number(6.0));
set("layout.dynamic.cascade_offset", Number(30.0));
set("layout.dynamic.smart_placement", Bool(true));
@@ -1169,6 +1293,74 @@ mod tests {
}
#[test]
+ fn srd_window_scratchpad_hides_the_focused_window_and_show_brings_it_back() {
+ let dir = tempfile::tempdir().unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+ let id = {
+ let mut wm = wm.borrow_mut();
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "term"));
+ id
+ };
+ let engine = Engine::new(wm.clone(), dir.path()).unwrap();
+ engine.lua.load(r#"srd.window.scratchpad()"#).exec().unwrap();
+ assert!(wm.borrow().window(id).unwrap().minimized);
+ assert!(wm.borrow().window(id).unwrap().scratchpad);
+ engine.lua.load(r#"srd.window.scratchpad_show()"#).exec().unwrap();
+ assert!(!wm.borrow().window(id).unwrap().minimized);
+ assert_eq!(wm.borrow().focused_id(), Some(id));
+ }
+
+ #[test]
+ fn srd_rule_title_regex_matches_a_specific_dialog_not_the_main_window() {
+ let dir = tempfile::tempdir().unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+ let engine = Engine::new(wm.clone(), dir.path()).unwrap();
+ engine.lua.load(r#"srd.rule({ title_regex = "^Save File$" }, { floating = true })"#).exec().unwrap();
+ let dialog = {
+ let mut wm = wm.borrow_mut();
+ let id = wm.alloc_window_id();
+ wm.add_window(srdwm_core::Window::new(id, "Save File"));
+ id
+ };
+ let main = {
+ let mut wm = wm.borrow_mut();
+ let id = wm.alloc_window_id();
+ wm.add_window(srdwm_core::Window::new(id, "Save File - GIMP"));
+ id
+ };
+ assert!(wm.borrow().is_floating(dialog));
+ assert!(!wm.borrow().is_floating(main));
+ }
+
+ #[test]
+ fn srd_rule_instance_matches_independently_of_class() {
+ let dir = tempfile::tempdir().unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+ let engine = Engine::new(wm.clone(), dir.path()).unwrap();
+ engine.lua.load(r#"srd.rule({ instance = "firefox" }, { pinned = true })"#).exec().unwrap();
+ let id = {
+ let mut wm = wm.borrow_mut();
+ let id = wm.alloc_window_id();
+ let mut w = srdwm_core::Window::new(id, "Mozilla Firefox");
+ w.app_id = "Navigator".into();
+ w.instance = "firefox".into();
+ wm.add_window(w);
+ id
+ };
+ assert!(wm.borrow().window(id).unwrap().always_on_top);
+ }
+
+ #[test]
+ fn srd_rule_rejects_an_invalid_regex_with_a_lua_error() {
+ let dir = tempfile::tempdir().unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+ let engine = Engine::new(wm.clone(), dir.path()).unwrap();
+ let result = engine.lua.load(r#"srd.rule({ title_regex = "(unclosed" }, { floating = true })"#).exec();
+ assert!(result.is_err());
+ }
+
+ #[test]
fn load_init_runs_the_users_init_lua() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("init.lua"), r#"srd.set("general.window_gap", 4)"#).unwrap();