srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-07-29 14:01:00 +0200
committersrdusr <[email protected]>2024-07-29 14:01:00 +0200
commit5fe599437ab9d1d9cfa749e861ce2f3acc02aa3c (patch)
tree2956b9b18af7b6c0741da8b68da18de66ed393e5
parent6fad44b3ecffb22f77a78661ce179b304e9a6ccc (diff)
downloadsrdwm-5fe599437ab9d1d9cfa749e861ce2f3acc02aa3c.tar.gz
srdwm-5fe599437ab9d1d9cfa749e861ce2f3acc02aa3c.zip
Split crates/config/src/lib.rs (1441 lines) into engine/
Pure reorganization, no behavior change - verified by diffing the function-name and struct/enum-name sets before/after (both identical) plus a full cargo test pass. lib.rs is now a thin shim (mod declarations + pub use) since a crate root can't itself become a directory; all the actual content moved into engine/, split along the Lua API's own srd.*/srd.window.*/srd.layout.*/srd.workspace.*/ srd.theme.* namespace groupings the file's own section comments already used: - mod.rs: SharedState, Engine, ConfigError, and Engine's core methods (new/get/set/dispatch/reload/...). - register.rs: register_srd_module, which wires every fn_* builder from every other file into the srd Lua table - the one place that genuinely needs to see all of them. - general.rs/window.rs/layout.rs/workspace.rs/theme.rs: the fn_* builder methods themselves, one file per srd.* sub-namespace. - support.rs: free functions shared across those (do_reload, parse_direction, flatten_table_into, validate, default_config) and the WindowAction enum. - tests.rs: the ~300-line test module, left unsplit for the same shared-helper reason manager/tests.rs and udev's tests were. ~40 fn_* methods and the support.rs free functions/enum went from private to pub(super): called across what are now sibling submodules, which Rust's privacy model doesn't let see each other's private items.
-rw-r--r--crates/config/src/engine/general.rs328
-rw-r--r--crates/config/src/engine/layout.rs32
-rw-r--r--crates/config/src/engine/mod.rs207
-rw-r--r--crates/config/src/engine/register.rs97
-rw-r--r--crates/config/src/engine/support.rs255
-rw-r--r--crates/config/src/engine/tests.rs316
-rw-r--r--crates/config/src/engine/theme.rs16
-rw-r--r--crates/config/src/engine/window.rs178
-rw-r--r--crates/config/src/engine/workspace.rs42
-rw-r--r--crates/config/src/lib.rs1438
10 files changed, 1475 insertions, 1434 deletions
diff --git a/crates/config/src/engine/general.rs b/crates/config/src/engine/general.rs
new file mode 100644
index 0000000..d1a762a
--- /dev/null
+++ b/crates/config/src/engine/general.rs
@@ -0,0 +1,328 @@
+use super::*;
+use super::support::{default_config, do_reload, validate};
+
+impl Engine {
+ // ---- srd.* -----------------------------------------------------------
+
+ pub(super) fn fn_set(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, (key, value): (String, Value)| {
+ if let Some(v) = ConfigValue::from_lua(&value) {
+ state.borrow_mut().values.insert(key, v);
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_get(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |lua, key: String| {
+ let v = state.borrow().values.get(&key).cloned();
+ Ok(match v {
+ Some(ConfigValue::String(s)) => Value::String(lua.create_string(&s)?),
+ Some(ConfigValue::Number(n)) => Value::Number(n),
+ Some(ConfigValue::Bool(b)) => Value::Boolean(b),
+ Some(ConfigValue::List(items)) => Value::Table(lua.create_sequence_from(items)?),
+ None => Value::Nil,
+ })
+ })?)
+ }
+
+ pub(super) fn fn_reset(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, key: String| {
+ let defaults = default_config();
+ let mut s = state.borrow_mut();
+ match defaults.get(&key) {
+ Some(v) => {
+ s.values.insert(key, v.clone());
+ }
+ None => {
+ s.values.remove(&key);
+ }
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_reset_all(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ state.borrow_mut().values = default_config();
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_reset_category(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, category: String| {
+ let defaults = default_config();
+ let mut s = state.borrow_mut();
+ let prefix = format!("{category}.");
+ s.values.retain(|k, _| !k.starts_with(&prefix));
+ for (k, v) in defaults.into_iter().filter(|(k, _)| k.starts_with(&prefix)) {
+ s.values.insert(k, v);
+ }
+ Ok(())
+ })?)
+ }
+
+ /// `srd.on("lid_closed", function() ... end)` - registers a handler for
+ /// 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.
+ 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"];
+ if !KNOWN.contains(&name.as_str()) {
+ return Err(mlua::Error::RuntimeError(format!(
+ "srd.on: unknown event '{name}' (known: {})",
+ KNOWN.join(", ")
+ )));
+ }
+ let key = lua.create_registry_value(f)?;
+ state.borrow_mut().event_handlers.insert(name, key);
+ Ok(())
+ })?)
+ }
+
+ /// `srd.bind_repeat(combo, fn)` - like `srd.bind`, but keeps firing
+ /// while the key is held (Hyprland's `binde`). For volume, brightness
+ /// and window-switcher cycling, where one step per press is unusable.
+ pub(super) 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());
+ s.key_bindings.insert(combo, key);
+ Ok(())
+ })?)
+ }
+
+ pub(super) 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 = "...", 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, opacity = 0.9 })`. 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.
+ pub(super) fn fn_rule(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, (matcher, actions): (Table, Table)| {
+ let title_contains: Option<String> = matcher.get("title")?;
+ let class: Option<String> = match matcher.get("class")? {
+ 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)?)),
+ None => None,
+ };
+ let geometry: Option<Rect> = {
+ let x: Option<i32> = actions.get("x")?;
+ let y: Option<i32> = actions.get("y")?;
+ let width: Option<u32> = actions.get("width")?;
+ let height: Option<u32> = actions.get("height")?;
+ match (x, y, width, height) {
+ (Some(x), Some(y), Some(width), Some(height)) => Some(Rect::new(x, y, width, height)),
+ _ => None,
+ }
+ };
+
+ let rule = WindowRule {
+ matcher: WindowMatch { title_contains, class, title_regex, class_regex, instance },
+ actions: WindowRuleActions {
+ floating: actions.get("floating")?,
+ maximized: actions.get("maximized")?,
+ workspace: actions.get("workspace")?,
+ geometry,
+ decorated: actions.get("decorated")?,
+ border_color,
+ border_width: actions.get("border_width")?,
+ pinned: actions.get("pinned")?,
+ opacity: actions.get("opacity")?,
+ },
+ };
+ state.borrow().wm.borrow_mut().add_rule(rule);
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_load(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |lua, module: String| {
+ let dir = state.borrow().config_dir.clone();
+ let path = dir.join(format!("{module}.lua"));
+ let src = std::fs::read_to_string(&path)
+ .map_err(|e| mlua::Error::RuntimeError(format!("srd.load('{module}'): {e} ({})", path.display())))?;
+ lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?;
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_spawn(&self) -> Result<mlua::Function<'_>> {
+ Ok(self.lua.create_function(move |_, command: String| {
+ #[cfg(unix)]
+ let result = std::process::Command::new("sh").arg("-c").arg(&command).spawn();
+ #[cfg(windows)]
+ let result = std::process::Command::new("cmd").arg("/C").arg(&command).spawn();
+ if let Err(e) = result {
+ log::warn!("srd.spawn('{command}') failed: {e}");
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_quit(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ state.borrow().running.set(false);
+ 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
+ // 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(())
+ })?)
+ }
+
+ pub(super) fn fn_notify(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, (message, level): (String, Option<String>)| {
+ let level = level.unwrap_or_else(|| "info".to_string());
+ #[cfg(unix)]
+ {
+ let sent = std::process::Command::new("notify-send").arg("srdwm").arg(&message).status();
+ if sent.map(|s| !s.success()).unwrap_or(true) {
+ log::info!("[{level}] {message}");
+ }
+ }
+ #[cfg(not(unix))]
+ {
+ log::info!("[{level}] {message}");
+ }
+ state.borrow_mut().log.push(format!("[{level}] {message}"));
+ Ok(())
+ })?)
+ }
+
+ /// Checks the numeric/string ranges documented in `docs/DEFAULTS.md`'s
+ /// "Validation Rules" section. Returns `(ok, errors)`; `errors` is an
+ /// empty table when `ok` is true.
+ pub(super) fn fn_validate_config(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |lua, ()| {
+ let s = state.borrow();
+ let errors = validate(&s);
+ let ok = errors.is_empty();
+ Ok((ok, lua.create_sequence_from(errors)?))
+ })?)
+ }
+
+ pub(super) fn fn_debug_config_status(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |lua, ()| {
+ let s = state.borrow();
+ let t = lua.create_table()?;
+ t.set("keys", s.values.len())?;
+ t.set("bound_keys", s.key_bindings.len())?;
+ t.set("log_entries", s.log.len())?;
+ t.set("config_dir", s.config_dir.to_string_lossy().into_owned())?;
+ Ok(t)
+ })?)
+ }
+
+ pub(super) fn fn_debug_show_settings(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |lua, ()| {
+ let s = state.borrow();
+ let mut keys: Vec<&String> = s.values.keys().collect();
+ keys.sort();
+ let t = lua.create_table()?;
+ for key in keys {
+ let v = &s.values[key];
+ log::info!("{key} = {v:?}");
+ let lua_v = match v {
+ ConfigValue::String(s) => Value::String(lua.create_string(s)?),
+ ConfigValue::Number(n) => Value::Number(*n),
+ ConfigValue::Bool(b) => Value::Boolean(*b),
+ ConfigValue::List(items) => Value::Table(lua.create_sequence_from(items.clone())?),
+ };
+ t.set(key.as_str(), lua_v)?;
+ }
+ Ok(t)
+ })?)
+ }
+
+ pub(super) fn fn_debug_profile_start(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ state.borrow_mut().profile_start = Some(std::time::Instant::now());
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_debug_profile_stop(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ let elapsed = state.borrow_mut().profile_start.take().map(|t| t.elapsed().as_secs_f64());
+ if let Some(secs) = elapsed {
+ log::info!("profile: {:.3}ms", secs * 1000.0);
+ }
+ Ok(elapsed)
+ })?)
+ }
+}
diff --git a/crates/config/src/engine/layout.rs b/crates/config/src/engine/layout.rs
new file mode 100644
index 0000000..ebdb317
--- /dev/null
+++ b/crates/config/src/engine/layout.rs
@@ -0,0 +1,32 @@
+use super::*;
+use super::support::flatten_table_into;
+
+impl Engine {
+ // ---- srd.layout.* ------------------------------------------------------
+
+ pub(super) fn fn_layout_set(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, name: String| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ let ws = wm.current_workspace();
+ wm.set_layout(ws, name);
+ wm.arrange_workspace(ws);
+ Ok(())
+ })?)
+ }
+
+ 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)| {
+ let mut s = state.borrow_mut();
+ flatten_table_into(&format!("layout.{name}"), &table, &mut s.values)?;
+ let master_ratio = s.values.get(&format!("layout.{name}.master_ratio")).and_then(|v| v.as_f64());
+ drop(s);
+ if let Some(ratio) = master_ratio {
+ state.borrow().wm.borrow_mut().tiling.master_ratio = ratio as f32;
+ }
+ Ok(())
+ })?)
+ }
+}
diff --git a/crates/config/src/engine/mod.rs b/crates/config/src/engine/mod.rs
new file mode 100644
index 0000000..ac7bdb8
--- /dev/null
+++ b/crates/config/src/engine/mod.rs
@@ -0,0 +1,207 @@
+//! The `srd` Lua scripting API: config values, keybindings, layout/theme
+//! setup, and window/workspace actions, all callable from `.lua` files.
+//!
+//! This targets the API surface documented in the legacy project's
+//! `docs/DEFAULTS.md` (see docs/PRIOR_ART.md for the full comparison), which
+//! is richer than what the C++ `lua_manager.cc` actually registered: that
+//! engine's `srd.window.focused()` returned a hardcoded placeholder table
+//! with no methods, even though the shipped example `keybindings.lua` called
+//! `window:close()` on it - a call that would have errored at runtime. Here,
+//! `srd.window.close()` / `.minimize()` / `.maximize()` / `.focus(direction)`
+//! act on the real focused window via a shared [`srdwm_core::WindowManager`],
+//! and `srd.bind` stores the actual Lua closure (via the registry) rather
+//! than just the key-combo string.
+
+use crate::value::ConfigValue;
+use support::{default_config, do_reload};
+
+use mlua::{Lua, RegistryKey, Table, Value};
+use srdwm_core::{Direction, Rect, WindowManager, WindowMatch, WindowRule, WindowRuleActions};
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+
+struct SharedState {
+ wm: Rc<RefCell<WindowManager>>,
+ values: HashMap<String, ConfigValue>,
+ key_bindings: HashMap<String, RegistryKey>,
+ /// Combos registered with `srd.bind_repeat`, which fire repeatedly while
+ /// held (Hyprland's `binde`). A subset of `key_bindings`.
+ repeat_keys: std::collections::HashSet<String>,
+ /// Handlers for non-key events (currently the lid switch), registered
+ /// via `srd.on(...)`. Kept separate from `key_bindings` because the
+ /// backends use that map to decide which *keypresses* to withhold from
+ /// clients - a pseudo-entry there would be grabbed as if it were a key.
+ event_handlers: HashMap<String, RegistryKey>,
+ config_dir: PathBuf,
+ log: Vec<String>,
+ running: Rc<std::cell::Cell<bool>>,
+ profile_start: Option<std::time::Instant>,
+}
+
+/// Owns the Lua interpreter and the `srd` module state. Cheap to keep around
+/// for the process lifetime; `reload` re-executes `init.lua` from scratch
+/// against a fresh Lua state so stale closures/globals can't linger.
+pub struct Engine {
+ lua: Lua,
+ state: Rc<RefCell<SharedState>>,
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum ConfigError {
+ #[error("lua error: {0}")]
+ Lua(#[from] mlua::Error),
+ #[error("io error reading {path}: {source}")]
+ Io { path: PathBuf, source: std::io::Error },
+}
+
+pub type Result<T> = std::result::Result<T, ConfigError>;
+
+impl Engine {
+ /// Builds a fresh interpreter wired to `wm` and loads defaults; call
+ /// [`Engine::load_init`] to run the user's `init.lua`.
+ pub fn new(wm: Rc<RefCell<WindowManager>>, config_dir: impl Into<PathBuf>) -> Result<Self> {
+ let lua = Lua::new();
+ let state = Rc::new(RefCell::new(SharedState {
+ wm,
+ values: default_config(),
+ key_bindings: HashMap::new(),
+ repeat_keys: std::collections::HashSet::new(),
+ event_handlers: HashMap::new(),
+ config_dir: config_dir.into(),
+ log: Vec::new(),
+ running: Rc::new(std::cell::Cell::new(true)),
+ profile_start: None,
+ }));
+ let engine = Self { lua, state };
+ engine.register_srd_module()?;
+ Ok(engine)
+ }
+
+ /// Shared flag `srd.quit()` clears; the main loop polls this to know
+ /// when to stop.
+ pub fn running_flag(&self) -> Rc<std::cell::Cell<bool>> {
+ self.state.borrow().running.clone()
+ }
+
+ pub fn config_dir(&self) -> PathBuf {
+ self.state.borrow().config_dir.clone()
+ }
+
+ /// Loads and executes `init.lua` from the config directory.
+ pub fn load_init(&self) -> Result<()> {
+ let path = self.config_dir().join("init.lua");
+ 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()?;
+ Ok(())
+ }
+
+ pub fn get(&self, key: &str) -> Option<ConfigValue> {
+ self.state.borrow().values.get(key).cloned()
+ }
+
+ pub fn get_string(&self, key: &str, default: &str) -> String {
+ self.get(key).and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| default.to_string())
+ }
+
+ pub fn get_bool(&self, key: &str, default: bool) -> bool {
+ self.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
+ }
+
+ pub fn get_f64(&self, key: &str, default: f64) -> f64 {
+ 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.
+ /// Returns false when nothing is registered, so callers can log it.
+ pub fn dispatch_event(&self, name: &str) -> bool {
+ let func = {
+ let state = self.state.borrow();
+ state.event_handlers.get(name).and_then(|key| self.lua.registry_value::<mlua::Function>(key).ok())
+ };
+ match func {
+ Some(f) => {
+ if let Err(e) = f.call::<_, ()>(()) {
+ log::error!("event handler '{name}' errored: {e}");
+ }
+ true
+ }
+ None => false,
+ }
+ }
+
+ pub fn dispatch_keybinding(&self, combo: &str) -> bool {
+ let func = {
+ let state = self.state.borrow();
+ state.key_bindings.get(combo).and_then(|key| self.lua.registry_value::<mlua::Function>(key).ok())
+ };
+ match func {
+ Some(f) => {
+ if let Err(e) = f.call::<_, ()>(()) {
+ log::error!("keybinding '{combo}' errored: {e}");
+ }
+ true
+ }
+ None => false,
+ }
+ }
+
+ pub fn bound_keys(&self) -> Vec<String> {
+ self.state.borrow().key_bindings.keys().cloned().collect()
+ }
+
+ /// Combos that should auto-repeat while held.
+ pub fn repeat_keys(&self) -> Vec<String> {
+ self.state.borrow().repeat_keys.iter().cloned().collect()
+ }
+}
+
+mod general;
+mod layout;
+mod register;
+mod support;
+mod theme;
+mod window;
+mod workspace;
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/config/src/engine/register.rs b/crates/config/src/engine/register.rs
new file mode 100644
index 0000000..d68d253
--- /dev/null
+++ b/crates/config/src/engine/register.rs
@@ -0,0 +1,97 @@
+use super::*;
+use super::support::WindowAction;
+
+impl Engine {
+ pub(super) fn register_srd_module(&self) -> Result<()> {
+ let lua = &self.lua;
+ let srd = lua.create_table()?;
+
+ srd.set("set", self.fn_set()?)?;
+ srd.set("get", self.fn_get()?)?;
+ srd.set("reset", self.fn_reset()?)?;
+ srd.set("reset_all", self.fn_reset_all()?)?;
+ srd.set("reset_category", self.fn_reset_category()?)?;
+ srd.set("bind", self.fn_bind()?)?;
+ srd.set("bind_repeat", self.fn_bind_repeat()?)?;
+ srd.set("on", self.fn_on()?)?;
+ srd.set("rule", self.fn_rule()?)?;
+ srd.set("load", self.fn_load()?)?;
+ 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()?;
+ debug.set("config_status", self.fn_debug_config_status()?)?;
+ debug.set("validate_config", self.fn_validate_config()?)?;
+ debug.set("show_settings", self.fn_debug_show_settings()?)?;
+ debug.set("profile_start", self.fn_debug_profile_start()?)?;
+ debug.set("profile_stop", self.fn_debug_profile_stop()?)?;
+ srd.set("debug", debug)?;
+
+ let window = lua.create_table()?;
+ window.set("focused", self.fn_window_focused()?)?;
+ window.set("close", self.fn_window_action(WindowAction::Close)?)?;
+ window.set("minimize", self.fn_window_action(WindowAction::Minimize)?)?;
+ window.set("maximize", self.fn_window_action(WindowAction::Maximize)?)?;
+ window.set("fullscreen", self.fn_window_action(WindowAction::Fullscreen)?)?;
+ window.set("toggle_pin", self.fn_window_action(WindowAction::TogglePin)?)?;
+ window.set("focus", self.fn_window_focus_direction()?)?;
+ window.set("move", self.fn_window_move_direction()?)?;
+ window.set("next", self.fn_window_cycle(true)?)?;
+ window.set("prev", self.fn_window_cycle(false)?)?;
+ window.set("set_decorations", self.fn_window_set_decorations()?)?;
+ window.set("set_border_color", self.fn_window_set_border_color()?)?;
+ window.set("set_border_width", self.fn_window_set_border_width()?)?;
+ window.set("set_opacity", self.fn_window_set_opacity()?)?;
+ 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()?;
+ layout.set("set", self.fn_layout_set()?)?;
+ layout.set("configure", self.fn_layout_configure()?)?;
+ srd.set("layout", layout)?;
+
+ let workspace = lua.create_table()?;
+ workspace.set("next", self.fn_workspace_cycle(true)?)?;
+ workspace.set("prev", self.fn_workspace_cycle(false)?)?;
+ workspace.set("switch", self.fn_workspace_switch()?)?;
+ workspace.set("move_window", self.fn_workspace_move_window()?)?;
+ srd.set("workspace", workspace)?;
+
+ let theme = lua.create_table()?;
+ theme.set("set_colors", self.fn_theme_set("theme.colors")?)?;
+ theme.set("set_decorations", self.fn_theme_set("theme.decorations")?)?;
+ srd.set("theme", theme)?;
+
+ // Expose `srd` both as a global and as a `require("srd")`-able
+ // module: `require` resolves through `package.preload`/`package.path`,
+ // never through globals, so config files that (reasonably) write
+ // `local srd = require("srd")` would otherwise get a "module not
+ // found" error despite `srd` existing as a global.
+ let package: Table = lua.globals().get("package")?;
+ let preload: Table = package.get("preload")?;
+ preload.set(
+ "srd",
+ lua.create_function(|lua, ()| {
+ let srd: Table = lua.globals().get("srd")?;
+ Ok(srd)
+ })?,
+ )?;
+ lua.globals().set("srd", srd)?;
+ Ok(())
+ }
+}
diff --git a/crates/config/src/engine/support.rs b/crates/config/src/engine/support.rs
new file mode 100644
index 0000000..160e27a
--- /dev/null
+++ b/crates/config/src/engine/support.rs
@@ -0,0 +1,255 @@
+use super::*;
+
+/// 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.
+pub(super) 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.
+pub(super) fn parse_direction(name: &str, caller: &str) -> mlua::Result<Direction> {
+ match name {
+ "left" => Ok(Direction::Left),
+ "right" => Ok(Direction::Right),
+ "up" => Ok(Direction::Up),
+ "down" => Ok(Direction::Down),
+ other => Err(mlua::Error::RuntimeError(format!("{caller}: unknown direction '{other}'"))),
+ }
+}
+
+#[derive(Clone, Copy)]
+pub(super) enum WindowAction {
+ Close,
+ Minimize,
+ Maximize,
+ Fullscreen,
+ ToggleFloating,
+ TogglePin,
+ ScratchpadAdd,
+}
+
+/// Recursively flattens a Lua table into dotted config keys, e.g.
+/// `{border = {width = 2}}` under prefix `"theme.decorations"` becomes
+/// `theme.decorations.border.width = 2`. Matches how `docs/DEFAULTS.md`
+/// documents nested `srd.theme.set_decorations{...}` / `srd.layout.configure`
+/// tables.
+pub(super) fn flatten_table_into(prefix: &str, table: &Table, out: &mut HashMap<String, ConfigValue>) -> mlua::Result<()> {
+ for pair in table.clone().pairs::<Value, Value>() {
+ let (k, v) = pair?;
+ let Value::String(k) = k else { continue };
+ let key = format!("{prefix}.{}", k.to_str()?);
+ if let Value::Table(t) = &v {
+ flatten_table_into(&key, t, out)?;
+ } else if let Some(cv) = ConfigValue::from_lua(&v) {
+ out.insert(key, cv);
+ }
+ }
+ Ok(())
+}
+
+/// Checks the numeric ranges, layout-name references, and hex-color strings
+/// documented in `docs/DEFAULTS.md`'s "Validation Rules" section against the
+/// current config values. Returns a human-readable error per violation.
+pub(super) fn validate(s: &SharedState) -> Vec<String> {
+ let mut errors = Vec::new();
+
+ let mut check_range = |key: &str, min: f64, max: f64| {
+ if let Some(v) = s.values.get(key).and_then(ConfigValue::as_f64) {
+ if v < min || v > max {
+ errors.push(format!("{key} = {v} is out of range [{min}, {max}]"));
+ }
+ }
+ };
+ check_range("general.window_gap", 0.0, 100.0);
+ check_range("layout.tiling.gaps.inner", 0.0, 100.0);
+ check_range("layout.tiling.gaps.outer", 0.0, 100.0);
+ check_range("layout.dynamic.gaps.inner", 0.0, 100.0);
+ check_range("layout.dynamic.gaps.outer", 0.0, 100.0);
+ check_range("layout.floating.gaps.inner", 0.0, 100.0);
+ check_range("layout.floating.gaps.outer", 0.0, 100.0);
+ check_range("general.border_width", 0.0, 20.0);
+ check_range("theme.decorations.border.width", 0.0, 20.0);
+ check_range("general.animation_duration", 0.0, 1000.0);
+ check_range("general.resize_margin", 1.0, 50.0);
+ check_range("performance.max_fps", 30.0, 240.0);
+ check_range("performance.window_cache_size", 10.0, 10000.0);
+
+ let layouts: Vec<String> = s.wm.borrow().available_layouts().iter().map(|l| l.to_string()).collect();
+ for key in ["general.default_layout", "monitor.primary_layout", "monitor.secondary_layout"] {
+ if let Some(name) = s.values.get(key).and_then(ConfigValue::as_str) {
+ if !layouts.iter().any(|l| l == name) {
+ errors.push(format!("{key} = '{name}' is not a registered layout {layouts:?}"));
+ }
+ }
+ }
+
+ let color_keys = [
+ "theme.colors.background",
+ "theme.colors.foreground",
+ "theme.colors.primary",
+ "theme.colors.secondary",
+ "theme.colors.accent",
+ "theme.colors.error",
+ "theme.colors.warning",
+ "theme.colors.success",
+ "theme.decorations.border.active_color",
+ "theme.decorations.border.inactive_color",
+ "theme.decorations.title_bar.background",
+ "theme.decorations.title_bar.foreground",
+ ];
+ for key in color_keys {
+ if let Some(v) = s.values.get(key).and_then(ConfigValue::as_str) {
+ if !is_valid_hex_color(v) {
+ errors.push(format!("{key} = '{v}' is not a valid hex color (expected '#rrggbb')"));
+ }
+ }
+ }
+
+ errors
+}
+
+fn is_valid_hex_color(s: &str) -> bool {
+ s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit())
+}
+
+/// The config surface documented in `docs/DEFAULTS.md`, seeded before
+/// `init.lua` runs so `srd.get(...)` returns sensible values even for keys
+/// the user's config never touches.
+pub(super) fn default_config() -> HashMap<String, ConfigValue> {
+ use ConfigValue::*;
+ let mut m = HashMap::new();
+ let mut set = |k: &str, v: ConfigValue| {
+ m.insert(k.to_string(), v);
+ };
+ set("general.default_layout", String("dynamic".into()));
+ set("general.smart_placement", Bool(true));
+ set("general.window_gap", Number(8.0));
+ set("general.border_width", Number(2.0));
+ set("general.animations", Bool(true));
+ set("general.animation_duration", Number(200.0));
+ set("general.shadows", Bool(true));
+ set("general.resize_margin", Number(6.0));
+ // Deliberately *not* seeded here, unlike every other `general.*` key --
+ // its actual default differs by backend (GLES/winit: on; udev/Pixman:
+ // off, an untested-on-real-hardware CPU cost too real to default to on
+ // - see `crates/wayland/src/rounded_corners.rs`), and neither backend
+ // is known yet at the point `default_config` runs. Leaving the key
+ // genuinely absent (rather than pre-seeded `true`/`false`) is what lets
+ // `main.rs`'s `apply_general_settings` tell "user never touched this"
+ // apart from "user explicitly chose a value" and hand the *unset* case
+ // to whichever backend ends up connecting instead of deciding for it.
+ set("general.focus_follows_mouse", Bool(false));
+ set("general.mouse_follows_focus", Bool(true));
+ set("general.auto_raise", Bool(false));
+ set("general.auto_focus", Bool(true));
+
+ set("monitor.primary_layout", String("dynamic".into()));
+ set("monitor.secondary_layout", String("tiling".into()));
+ set("monitor.auto_detect", Bool(true));
+ set("monitor.primary_workspace", Number(1.0));
+ set("monitor.workspace_count", Number(10.0));
+
+ set("window.focus_follows_mouse", Bool(false));
+ set("window.mouse_follows_focus", Bool(true));
+ set("window.auto_raise", Bool(false));
+ set("window.auto_focus", Bool(true));
+ set("window.raise_on_focus", Bool(true));
+ set("window.remember_position", Bool(true));
+ set("window.remember_size", Bool(true));
+ set("window.remember_state", Bool(true));
+
+ set("workspace.count", Number(10.0));
+ set("workspace.names", List(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"].map(|s| s.to_string()).to_vec()));
+ set("workspace.auto_switch", Bool(false));
+ set("workspace.persistent", Bool(true));
+ set("workspace.auto_back_and_forth", Bool(false));
+
+ set("performance.vsync", Bool(true));
+ set("performance.max_fps", Number(60.0));
+ set("performance.window_cache_size", Number(100.0));
+ set("performance.event_queue_size", Number(1000.0));
+ set("performance.layout_timeout", Number(16.0));
+ set("performance.enable_caching", Bool(true));
+
+ set("debug.logging", Bool(true));
+ set("debug.log_level", String("info".into()));
+ set("debug.profile", Bool(false));
+ set("debug.trace_events", Bool(false));
+ set("debug.show_layout_bounds", Bool(false));
+ set("debug.show_window_geometry", Bool(false));
+
+ set("layout.tiling.split_ratio", Number(0.5));
+ set("layout.tiling.master_ratio", Number(0.6));
+ set("layout.tiling.auto_swap", Bool(true));
+ set("layout.tiling.gaps.inner", Number(8.0));
+ set("layout.tiling.gaps.outer", Number(16.0));
+ set("layout.tiling.behavior.new_window_master", Bool(false));
+ set("layout.tiling.behavior.auto_balance", Bool(true));
+ set("layout.tiling.behavior.preserve_ratio", Bool(true));
+
+ 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));
+ set("layout.dynamic.gaps.inner", Number(8.0));
+ set("layout.dynamic.gaps.outer", Number(16.0));
+ set("layout.dynamic.behavior.remember_positions", Bool(true));
+ set("layout.dynamic.behavior.auto_arrange", Bool(true));
+ set("layout.dynamic.behavior.overlap_prevention", Bool(true));
+
+ set("layout.floating.default_position", String("center".into()));
+ set("layout.floating.remember_position", Bool(true));
+ set("layout.floating.always_on_top", Bool(false));
+ set("layout.floating.gaps.inner", Number(0.0));
+ set("layout.floating.gaps.outer", Number(16.0));
+ set("layout.floating.behavior.allow_resize", Bool(true));
+ set("layout.floating.behavior.allow_move", Bool(true));
+ set("layout.floating.behavior.snap_to_edges", Bool(true));
+
+ set("theme.colors.background", String("#2e3440".into()));
+ set("theme.colors.foreground", String("#eceff4".into()));
+ set("theme.colors.primary", String("#88c0d0".into()));
+ set("theme.colors.secondary", String("#81a1c1".into()));
+ set("theme.colors.accent", String("#5e81ac".into()));
+ set("theme.colors.error", String("#bf616a".into()));
+ set("theme.colors.warning", String("#ebcb8b".into()));
+ set("theme.colors.success", String("#a3be8c".into()));
+
+ set("theme.decorations.border.width", Number(2.0));
+ set("theme.decorations.border.active_color", String("#88c0d0".into()));
+ set("theme.decorations.border.inactive_color", String("#2e3440".into()));
+ set("theme.decorations.border.focused_style", String("solid".into()));
+ set("theme.decorations.border.unfocused_style", String("solid".into()));
+ set("theme.decorations.title_bar.height", Number(24.0));
+ set("theme.decorations.title_bar.show", Bool(true));
+ set("theme.decorations.title_bar.font", String("JetBrains Mono 10".into()));
+ set("theme.decorations.title_bar.background", String("#2e3440".into()));
+ set("theme.decorations.title_bar.foreground", String("#eceff4".into()));
+
+ set("platform.backend", String("auto".into()));
+ set("platform.x11.use_ewmh", Bool(true));
+ set("platform.x11.use_netwm", Bool(true));
+ set("platform.wayland.use_xdg_shell", Bool(true));
+ set("platform.wayland.use_layer_shell", Bool(true));
+ set("platform.windows.use_dwm", Bool(true));
+ set("platform.windows.use_win32", Bool(true));
+ set("platform.windows.global_hooks", Bool(true));
+ set("platform.macos.use_cocoa", Bool(true));
+ set("platform.macos.use_core_graphics", Bool(true));
+ set("platform.macos.accessibility_enabled", Bool(true));
+
+ m
+}
diff --git a/crates/config/src/engine/tests.rs b/crates/config/src/engine/tests.rs
new file mode 100644
index 0000000..521dc5a
--- /dev/null
+++ b/crates/config/src/engine/tests.rs
@@ -0,0 +1,316 @@
+ use super::*;
+ use srdwm_core::Window;
+
+ fn engine_in(dir: &std::path::Path) -> Engine {
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+ Engine::new(wm, dir).unwrap()
+ }
+
+ #[test]
+ fn srd_set_and_get_roundtrip_scalars() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine.lua.load(r#"srd.set("general.window_gap", 12)"#).exec().unwrap();
+ assert_eq!(engine.get("general.window_gap"), Some(ConfigValue::Number(12.0)));
+ }
+
+ #[test]
+ fn defaults_are_seeded_before_any_script_runs() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ assert_eq!(engine.get_string("general.default_layout", ""), "dynamic");
+ }
+
+ #[test]
+ fn reset_restores_default_value() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine.lua.load(r#"srd.set("general.window_gap", 99)"#).exec().unwrap();
+ engine.lua.load(r#"srd.reset("general.window_gap")"#).exec().unwrap();
+ assert_eq!(engine.get("general.window_gap"), Some(ConfigValue::Number(8.0)));
+ }
+
+ #[test]
+ fn bind_stores_real_closure_and_dispatch_runs_it() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine
+ .lua
+ .load(r#"srd.bind("Mod4+q", function() srd.set("test.marker", true) end)"#)
+ .exec()
+ .unwrap();
+ assert!(engine.dispatch_keybinding("Mod4+q"));
+ assert_eq!(engine.get("test.marker"), Some(ConfigValue::Bool(true)));
+ assert!(!engine.dispatch_keybinding("Mod4+nonexistent"));
+ }
+
+ #[test]
+ fn srd_is_requireable_not_just_a_global() {
+ // Every shipped example config opens with `local srd = require("srd")`;
+ // that must resolve through package.preload, not just exist as a global.
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine
+ .lua
+ .load(r#"local srd = require("srd"); srd.set("test.via_require", true)"#)
+ .exec()
+ .unwrap();
+ assert_eq!(engine.get("test.via_require"), Some(ConfigValue::Bool(true)));
+ }
+
+ #[test]
+ fn window_close_style_call_from_legacy_example_config_now_works() {
+ // The legacy C++ engine's `srd.window.focused()` returned a
+ // placeholder table with no methods, so `window:close()` in the
+ // shipped example config would have errored at runtime. Here
+ // `srd.window.close()` acts directly on the focused window.
+ let dir = tempfile::tempdir().unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+ {
+ let mut wm = wm.borrow_mut();
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "test"));
+ }
+ let engine = Engine::new(wm.clone(), dir.path()).unwrap();
+ engine
+ .lua
+ .load(
+ r#"
+ local w = srd.window.focused()
+ assert(w ~= nil, "expected a focused window")
+ srd.window.set_floating(true)
+ assert(srd.window.is_floating() == true)
+ "#,
+ )
+ .exec()
+ .unwrap();
+ }
+
+ #[test]
+ fn layout_configure_updates_master_ratio_live() {
+ 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.layout.configure("tiling", { master_ratio = 0.75 })"#)
+ .exec()
+ .unwrap();
+ assert_eq!(engine.get("layout.tiling.master_ratio"), Some(ConfigValue::Number(0.75)));
+ assert!((wm.borrow().tiling.master_ratio - 0.75).abs() < f32::EPSILON);
+ }
+
+ #[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();
+ let engine = engine_in(dir.path());
+ engine.lua.load(r#"srd.load("extra")"#).exec().unwrap();
+ assert_eq!(engine.get("from.extra"), Some(ConfigValue::String("yes".into())));
+ }
+
+ #[test]
+ fn validate_config_passes_on_untouched_defaults() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine
+ .lua
+ .load(r#"local ok, errs = srd.validate_config(); assert(ok, table.concat(errs, "; "))"#)
+ .exec()
+ .unwrap();
+ }
+
+ #[test]
+ fn validate_config_flags_out_of_range_gap_and_bad_color() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine
+ .lua
+ .load(
+ r#"
+ srd.set("general.window_gap", 500)
+ srd.set("theme.colors.background", "not-a-color")
+ local ok, errs = srd.validate_config()
+ assert(ok == false)
+ assert(#errs == 2, "expected 2 errors, got " .. #errs)
+ "#,
+ )
+ .exec()
+ .unwrap();
+ }
+
+ #[test]
+ fn validate_config_flags_unregistered_layout_name() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine
+ .lua
+ .load(
+ r#"
+ srd.set("general.default_layout", "nonexistent")
+ local ok, errs = srd.validate_config()
+ assert(ok == false)
+ "#,
+ )
+ .exec()
+ .unwrap();
+ }
+
+ #[test]
+ fn debug_namespace_reports_status_and_profiles() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine
+ .lua
+ .load(
+ r#"
+ local status = srd.debug.config_status()
+ assert(status.keys > 0)
+ srd.debug.profile_start()
+ local elapsed = srd.debug.profile_stop()
+ assert(type(elapsed) == "number")
+ local settings = srd.debug.show_settings()
+ assert(settings["general.window_gap"] == 8)
+ "#,
+ )
+ .exec()
+ .unwrap();
+ }
+
+ #[test]
+ fn srd_rule_floats_matching_window_on_creation() {
+ 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 = "calculator" }, { floating = true })"#)
+ .exec()
+ .unwrap();
+ let id = {
+ let mut wm = wm.borrow_mut();
+ let id = wm.alloc_window_id();
+ wm.add_window(srdwm_core::Window::new(id, "Calculator"));
+ id
+ };
+ assert!(wm.borrow().is_floating(id));
+ }
+
+ #[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();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+ assert_eq!(engine.get("general.window_gap"), Some(ConfigValue::Number(4.0)));
+ }
+
+ #[test]
+ fn bind_repeat_registers_the_binding_and_marks_it_repeating() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine
+ .lua
+ .load(r#"
+ srd.bind("Mod4+a", function() end)
+ srd.bind_repeat("XF86AudioRaiseVolume", function() end)
+ "#)
+ .exec()
+ .unwrap();
+
+ let bound = engine.bound_keys();
+ // A repeating bind is still a normal binding - it must be grabbed
+ // and dispatched like any other, or it would never fire at all.
+ assert!(bound.contains(&"Mod4+a".to_string()));
+ assert!(bound.contains(&"XF86AudioRaiseVolume".to_string()));
+
+ let repeat = engine.repeat_keys();
+ assert_eq!(repeat, vec!["XF86AudioRaiseVolume".to_string()]);
+ assert!(!repeat.contains(&"Mod4+a".to_string()), "a plain bind must not repeat");
+ }
+
+ #[test]
+ fn bind_repeat_dispatches_like_a_normal_binding() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine
+ .lua
+ .load(r#"
+ fired = 0
+ srd.bind_repeat("Mod4+z", function() fired = fired + 1 end)
+ "#)
+ .exec()
+ .unwrap();
+ assert!(engine.dispatch_keybinding("Mod4+z"));
+ assert!(engine.dispatch_keybinding("Mod4+z"));
+ let fired: i64 = engine.lua.globals().get("fired").unwrap();
+ assert_eq!(fired, 2);
+ }
diff --git a/crates/config/src/engine/theme.rs b/crates/config/src/engine/theme.rs
new file mode 100644
index 0000000..b178b10
--- /dev/null
+++ b/crates/config/src/engine/theme.rs
@@ -0,0 +1,16 @@
+use super::*;
+use super::support::flatten_table_into;
+
+impl Engine {
+ // ---- srd.theme.* -------------------------------------------------------
+
+ pub(super) fn fn_theme_set(&self, prefix: &str) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ let prefix = prefix.to_string();
+ Ok(self.lua.create_function(move |_, table: Table| {
+ let mut s = state.borrow_mut();
+ flatten_table_into(&prefix, &table, &mut s.values)?;
+ Ok(())
+ })?)
+ }
+}
diff --git a/crates/config/src/engine/window.rs b/crates/config/src/engine/window.rs
new file mode 100644
index 0000000..6193f81
--- /dev/null
+++ b/crates/config/src/engine/window.rs
@@ -0,0 +1,178 @@
+use super::*;
+use super::support::{parse_direction, WindowAction};
+
+impl Engine {
+ // ---- srd.window.* ------------------------------------------------------
+
+ pub(super) fn fn_window_focused(&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 Some(w) = wm.focused_window() else { return Ok(Value::Nil) };
+ let t = lua.create_table()?;
+ t.set("id", w.id)?;
+ t.set("title", w.title.clone())?;
+ t.set("x", w.geometry.x)?;
+ t.set("y", w.geometry.y)?;
+ t.set("width", w.geometry.width)?;
+ t.set("height", w.geometry.height)?;
+ t.set("floating", w.floating)?;
+ t.set("maximized", w.maximized)?;
+ t.set("minimized", w.minimized)?;
+ t.set("scratchpad", w.scratchpad)?;
+ Ok(Value::Table(t))
+ })?)
+ }
+
+ pub(super) fn fn_window_action(&self, action: WindowAction) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ if let Some(id) = wm.focused_id() {
+ match action {
+ WindowAction::Close => wm.close_window(id),
+ WindowAction::Minimize => wm.minimize_window(id),
+ WindowAction::Maximize => wm.toggle_maximize(id),
+ 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(())
+ })?)
+ }
+
+ pub(super) 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`).
+ pub(super) fn fn_window_move_direction(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, direction: String| {
+ let dir = parse_direction(&direction, "srd.window.move")?;
+ let wm = state.borrow().wm.clone();
+ wm.borrow_mut().move_window_direction(dir);
+ Ok(())
+ })?)
+ }
+
+ /// `srd.window.next()` / `srd.window.prev()` - cycle focus through the
+ /// windows on the current workspace (Hyprland's `cyclenext`).
+ pub(super) fn fn_window_cycle(&self, forward: bool) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ if forward {
+ wm.focus_next();
+ } else {
+ wm.focus_previous();
+ }
+ // Bring it to the top, matching the `bringactivetotop` the
+ // Hyprland binding pairs with `cyclenext`.
+ if let Some(id) = wm.focused_id() {
+ wm.raise_window(id);
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_window_focus_direction(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, direction: String| {
+ let dir = parse_direction(&direction, "srd.window.focus")?;
+ let wm = state.borrow().wm.clone();
+ wm.borrow_mut().focus_direction(dir);
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_window_set_decorations(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, enabled: bool| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ if let Some(id) = wm.focused_id() {
+ if let Some(w) = wm.window_mut(id) {
+ w.decorated = enabled;
+ }
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_window_set_border_color(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, (r, g, b): (u8, u8, u8)| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ if let Some(id) = wm.focused_id() {
+ if let Some(w) = wm.window_mut(id) {
+ w.border_color = (r, g, b);
+ }
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_window_set_border_width(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, width: u32| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ if let Some(id) = wm.focused_id() {
+ if let Some(w) = wm.window_mut(id) {
+ w.border_width = width;
+ }
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_window_set_opacity(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, opacity: f32| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ if let Some(id) = wm.focused_id() {
+ if let Some(w) = wm.window_mut(id) {
+ w.opacity = opacity.clamp(0.0, 1.0);
+ }
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_window_set_floating(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, floating: bool| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ if let Some(id) = wm.focused_id() {
+ if let Some(w) = wm.window_mut(id) {
+ w.floating = floating;
+ }
+ }
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_window_is_floating(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ let wm = state.borrow().wm.clone();
+ let wm = wm.borrow();
+ Ok(wm.focused_id().map(|id| wm.is_floating(id)).unwrap_or(false))
+ })?)
+ }
+}
diff --git a/crates/config/src/engine/workspace.rs b/crates/config/src/engine/workspace.rs
new file mode 100644
index 0000000..a51e108
--- /dev/null
+++ b/crates/config/src/engine/workspace.rs
@@ -0,0 +1,42 @@
+use super::*;
+
+impl Engine {
+ // ---- srd.workspace.* ---------------------------------------------------
+
+ pub(super) fn fn_workspace_cycle(&self, forward: bool) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect();
+ if ids.is_empty() {
+ return Ok(());
+ }
+ let cur = wm.current_workspace();
+ let pos = ids.iter().position(|&id| id == cur).unwrap_or(0);
+ let next = if forward { (pos + 1) % ids.len() } else { (pos + ids.len() - 1) % ids.len() };
+ wm.switch_workspace(ids[next]);
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_workspace_switch(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, id: usize| {
+ state.borrow().wm.borrow_mut().switch_workspace(id);
+ Ok(())
+ })?)
+ }
+
+ pub(super) fn fn_workspace_move_window(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, id: usize| {
+ let wm = state.borrow().wm.clone();
+ let mut wm = wm.borrow_mut();
+ if let Some(focused) = wm.focused_id() {
+ wm.move_window_to_workspace(focused, id);
+ }
+ Ok(())
+ })?)
+ }
+}
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
index 2c57b0a..fae16ec 100644
--- a/crates/config/src/lib.rs
+++ b/crates/config/src/lib.rs
@@ -1,1441 +1,11 @@
//! The `srd` Lua scripting API: config values, keybindings, layout/theme
//! setup, and window/workspace actions, all callable from `.lua` files.
//!
-//! This targets the API surface documented in the legacy project's
-//! `docs/DEFAULTS.md` (see docs/PRIOR_ART.md for the full comparison), which
-//! is richer than what the C++ `lua_manager.cc` actually registered: that
-//! engine's `srd.window.focused()` returned a hardcoded placeholder table
-//! with no methods, even though the shipped example `keybindings.lua` called
-//! `window:close()` on it - a call that would have errored at runtime. Here,
-//! `srd.window.close()` / `.minimize()` / `.maximize()` / `.focus(direction)`
-//! act on the real focused window via a shared [`srdwm_core::WindowManager`],
-//! and `srd.bind` stores the actual Lua closure (via the registry) rather
-//! than just the key-combo string.
+//! See [`engine`]'s module doc comment for the actual API surface and how
+//! it compares to the legacy C++ engine.
+mod engine;
mod value;
+pub use engine::{ConfigError, Engine, Result};
pub use value::ConfigValue;
-
-use mlua::{Lua, RegistryKey, Table, Value};
-use srdwm_core::{Direction, Rect, WindowManager, WindowMatch, WindowRule, WindowRuleActions};
-use std::cell::RefCell;
-use std::collections::HashMap;
-use std::path::{Path, PathBuf};
-use std::rc::Rc;
-
-struct SharedState {
- wm: Rc<RefCell<WindowManager>>,
- values: HashMap<String, ConfigValue>,
- key_bindings: HashMap<String, RegistryKey>,
- /// Combos registered with `srd.bind_repeat`, which fire repeatedly while
- /// held (Hyprland's `binde`). A subset of `key_bindings`.
- repeat_keys: std::collections::HashSet<String>,
- /// Handlers for non-key events (currently the lid switch), registered
- /// via `srd.on(...)`. Kept separate from `key_bindings` because the
- /// backends use that map to decide which *keypresses* to withhold from
- /// clients - a pseudo-entry there would be grabbed as if it were a key.
- event_handlers: HashMap<String, RegistryKey>,
- config_dir: PathBuf,
- log: Vec<String>,
- running: Rc<std::cell::Cell<bool>>,
- profile_start: Option<std::time::Instant>,
-}
-
-/// Owns the Lua interpreter and the `srd` module state. Cheap to keep around
-/// for the process lifetime; `reload` re-executes `init.lua` from scratch
-/// against a fresh Lua state so stale closures/globals can't linger.
-pub struct Engine {
- lua: Lua,
- state: Rc<RefCell<SharedState>>,
-}
-
-#[derive(thiserror::Error, Debug)]
-pub enum ConfigError {
- #[error("lua error: {0}")]
- Lua(#[from] mlua::Error),
- #[error("io error reading {path}: {source}")]
- Io { path: PathBuf, source: std::io::Error },
-}
-
-pub type Result<T> = std::result::Result<T, ConfigError>;
-
-impl Engine {
- /// Builds a fresh interpreter wired to `wm` and loads defaults; call
- /// [`Engine::load_init`] to run the user's `init.lua`.
- pub fn new(wm: Rc<RefCell<WindowManager>>, config_dir: impl Into<PathBuf>) -> Result<Self> {
- let lua = Lua::new();
- let state = Rc::new(RefCell::new(SharedState {
- wm,
- values: default_config(),
- key_bindings: HashMap::new(),
- repeat_keys: std::collections::HashSet::new(),
- event_handlers: HashMap::new(),
- config_dir: config_dir.into(),
- log: Vec::new(),
- running: Rc::new(std::cell::Cell::new(true)),
- profile_start: None,
- }));
- let engine = Self { lua, state };
- engine.register_srd_module()?;
- Ok(engine)
- }
-
- /// Shared flag `srd.quit()` clears; the main loop polls this to know
- /// when to stop.
- pub fn running_flag(&self) -> Rc<std::cell::Cell<bool>> {
- self.state.borrow().running.clone()
- }
-
- pub fn config_dir(&self) -> PathBuf {
- self.state.borrow().config_dir.clone()
- }
-
- /// Loads and executes `init.lua` from the config directory.
- pub fn load_init(&self) -> Result<()> {
- let path = self.config_dir().join("init.lua");
- 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()?;
- Ok(())
- }
-
- pub fn get(&self, key: &str) -> Option<ConfigValue> {
- self.state.borrow().values.get(key).cloned()
- }
-
- pub fn get_string(&self, key: &str, default: &str) -> String {
- self.get(key).and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| default.to_string())
- }
-
- pub fn get_bool(&self, key: &str, default: bool) -> bool {
- self.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
- }
-
- pub fn get_f64(&self, key: &str, default: f64) -> f64 {
- 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.
- /// Returns false when nothing is registered, so callers can log it.
- pub fn dispatch_event(&self, name: &str) -> bool {
- let func = {
- let state = self.state.borrow();
- state.event_handlers.get(name).and_then(|key| self.lua.registry_value::<mlua::Function>(key).ok())
- };
- match func {
- Some(f) => {
- if let Err(e) = f.call::<_, ()>(()) {
- log::error!("event handler '{name}' errored: {e}");
- }
- true
- }
- None => false,
- }
- }
-
- pub fn dispatch_keybinding(&self, combo: &str) -> bool {
- let func = {
- let state = self.state.borrow();
- state.key_bindings.get(combo).and_then(|key| self.lua.registry_value::<mlua::Function>(key).ok())
- };
- match func {
- Some(f) => {
- if let Err(e) = f.call::<_, ()>(()) {
- log::error!("keybinding '{combo}' errored: {e}");
- }
- true
- }
- None => false,
- }
- }
-
- pub fn bound_keys(&self) -> Vec<String> {
- self.state.borrow().key_bindings.keys().cloned().collect()
- }
-
- /// Combos that should auto-repeat while held.
- pub fn repeat_keys(&self) -> Vec<String> {
- self.state.borrow().repeat_keys.iter().cloned().collect()
- }
-
- fn register_srd_module(&self) -> Result<()> {
- let lua = &self.lua;
- let srd = lua.create_table()?;
-
- srd.set("set", self.fn_set()?)?;
- srd.set("get", self.fn_get()?)?;
- srd.set("reset", self.fn_reset()?)?;
- srd.set("reset_all", self.fn_reset_all()?)?;
- srd.set("reset_category", self.fn_reset_category()?)?;
- srd.set("bind", self.fn_bind()?)?;
- srd.set("bind_repeat", self.fn_bind_repeat()?)?;
- srd.set("on", self.fn_on()?)?;
- srd.set("rule", self.fn_rule()?)?;
- srd.set("load", self.fn_load()?)?;
- 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()?;
- debug.set("config_status", self.fn_debug_config_status()?)?;
- debug.set("validate_config", self.fn_validate_config()?)?;
- debug.set("show_settings", self.fn_debug_show_settings()?)?;
- debug.set("profile_start", self.fn_debug_profile_start()?)?;
- debug.set("profile_stop", self.fn_debug_profile_stop()?)?;
- srd.set("debug", debug)?;
-
- let window = lua.create_table()?;
- window.set("focused", self.fn_window_focused()?)?;
- window.set("close", self.fn_window_action(WindowAction::Close)?)?;
- window.set("minimize", self.fn_window_action(WindowAction::Minimize)?)?;
- window.set("maximize", self.fn_window_action(WindowAction::Maximize)?)?;
- window.set("fullscreen", self.fn_window_action(WindowAction::Fullscreen)?)?;
- window.set("toggle_pin", self.fn_window_action(WindowAction::TogglePin)?)?;
- window.set("focus", self.fn_window_focus_direction()?)?;
- window.set("move", self.fn_window_move_direction()?)?;
- window.set("next", self.fn_window_cycle(true)?)?;
- window.set("prev", self.fn_window_cycle(false)?)?;
- window.set("set_decorations", self.fn_window_set_decorations()?)?;
- window.set("set_border_color", self.fn_window_set_border_color()?)?;
- window.set("set_border_width", self.fn_window_set_border_width()?)?;
- window.set("set_opacity", self.fn_window_set_opacity()?)?;
- 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()?;
- layout.set("set", self.fn_layout_set()?)?;
- layout.set("configure", self.fn_layout_configure()?)?;
- srd.set("layout", layout)?;
-
- let workspace = lua.create_table()?;
- workspace.set("next", self.fn_workspace_cycle(true)?)?;
- workspace.set("prev", self.fn_workspace_cycle(false)?)?;
- workspace.set("switch", self.fn_workspace_switch()?)?;
- workspace.set("move_window", self.fn_workspace_move_window()?)?;
- srd.set("workspace", workspace)?;
-
- let theme = lua.create_table()?;
- theme.set("set_colors", self.fn_theme_set("theme.colors")?)?;
- theme.set("set_decorations", self.fn_theme_set("theme.decorations")?)?;
- srd.set("theme", theme)?;
-
- // Expose `srd` both as a global and as a `require("srd")`-able
- // module: `require` resolves through `package.preload`/`package.path`,
- // never through globals, so config files that (reasonably) write
- // `local srd = require("srd")` would otherwise get a "module not
- // found" error despite `srd` existing as a global.
- let package: Table = lua.globals().get("package")?;
- let preload: Table = package.get("preload")?;
- preload.set(
- "srd",
- lua.create_function(|lua, ()| {
- let srd: Table = lua.globals().get("srd")?;
- Ok(srd)
- })?,
- )?;
- lua.globals().set("srd", srd)?;
- Ok(())
- }
-
- // ---- srd.* -----------------------------------------------------------
-
- fn fn_set(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, (key, value): (String, Value)| {
- if let Some(v) = ConfigValue::from_lua(&value) {
- state.borrow_mut().values.insert(key, v);
- }
- Ok(())
- })?)
- }
-
- fn fn_get(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |lua, key: String| {
- let v = state.borrow().values.get(&key).cloned();
- Ok(match v {
- Some(ConfigValue::String(s)) => Value::String(lua.create_string(&s)?),
- Some(ConfigValue::Number(n)) => Value::Number(n),
- Some(ConfigValue::Bool(b)) => Value::Boolean(b),
- Some(ConfigValue::List(items)) => Value::Table(lua.create_sequence_from(items)?),
- None => Value::Nil,
- })
- })?)
- }
-
- fn fn_reset(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, key: String| {
- let defaults = default_config();
- let mut s = state.borrow_mut();
- match defaults.get(&key) {
- Some(v) => {
- s.values.insert(key, v.clone());
- }
- None => {
- s.values.remove(&key);
- }
- }
- Ok(())
- })?)
- }
-
- fn fn_reset_all(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, ()| {
- state.borrow_mut().values = default_config();
- Ok(())
- })?)
- }
-
- fn fn_reset_category(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, category: String| {
- let defaults = default_config();
- let mut s = state.borrow_mut();
- let prefix = format!("{category}.");
- s.values.retain(|k, _| !k.starts_with(&prefix));
- for (k, v) in defaults.into_iter().filter(|(k, _)| k.starts_with(&prefix)) {
- s.values.insert(k, v);
- }
- Ok(())
- })?)
- }
-
- /// `srd.on("lid_closed", function() ... end)` - registers a handler for
- /// 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; 3] = ["lid_closed", "lid_open", "ready"];
- if !KNOWN.contains(&name.as_str()) {
- return Err(mlua::Error::RuntimeError(format!(
- "srd.on: unknown event '{name}' (known: {})",
- KNOWN.join(", ")
- )));
- }
- let key = lua.create_registry_value(f)?;
- state.borrow_mut().event_handlers.insert(name, key);
- Ok(())
- })?)
- }
-
- /// `srd.bind_repeat(combo, fn)` - like `srd.bind`, but keeps firing
- /// while the key is held (Hyprland's `binde`). For volume, brightness
- /// and window-switcher cycling, where one step per press is unusable.
- 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());
- s.key_bindings.insert(combo, key);
- Ok(())
- })?)
- }
-
- 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 = "...", 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, opacity = 0.9 })`. 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)| {
- let title_contains: Option<String> = matcher.get("title")?;
- let class: Option<String> = match matcher.get("class")? {
- 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)?)),
- None => None,
- };
- let geometry: Option<Rect> = {
- let x: Option<i32> = actions.get("x")?;
- let y: Option<i32> = actions.get("y")?;
- let width: Option<u32> = actions.get("width")?;
- let height: Option<u32> = actions.get("height")?;
- match (x, y, width, height) {
- (Some(x), Some(y), Some(width), Some(height)) => Some(Rect::new(x, y, width, height)),
- _ => None,
- }
- };
-
- let rule = WindowRule {
- matcher: WindowMatch { title_contains, class, title_regex, class_regex, instance },
- actions: WindowRuleActions {
- floating: actions.get("floating")?,
- maximized: actions.get("maximized")?,
- workspace: actions.get("workspace")?,
- geometry,
- decorated: actions.get("decorated")?,
- border_color,
- border_width: actions.get("border_width")?,
- pinned: actions.get("pinned")?,
- opacity: actions.get("opacity")?,
- },
- };
- state.borrow().wm.borrow_mut().add_rule(rule);
- Ok(())
- })?)
- }
-
- fn fn_load(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |lua, module: String| {
- let dir = state.borrow().config_dir.clone();
- let path = dir.join(format!("{module}.lua"));
- let src = std::fs::read_to_string(&path)
- .map_err(|e| mlua::Error::RuntimeError(format!("srd.load('{module}'): {e} ({})", path.display())))?;
- lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?;
- Ok(())
- })?)
- }
-
- fn fn_spawn(&self) -> Result<mlua::Function<'_>> {
- Ok(self.lua.create_function(move |_, command: String| {
- #[cfg(unix)]
- let result = std::process::Command::new("sh").arg("-c").arg(&command).spawn();
- #[cfg(windows)]
- let result = std::process::Command::new("cmd").arg("/C").arg(&command).spawn();
- if let Err(e) = result {
- log::warn!("srd.spawn('{command}') failed: {e}");
- }
- Ok(())
- })?)
- }
-
- fn fn_quit(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, ()| {
- state.borrow().running.set(false);
- Ok(())
- })?)
- }
-
- 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>)| {
- let level = level.unwrap_or_else(|| "info".to_string());
- #[cfg(unix)]
- {
- let sent = std::process::Command::new("notify-send").arg("srdwm").arg(&message).status();
- if sent.map(|s| !s.success()).unwrap_or(true) {
- log::info!("[{level}] {message}");
- }
- }
- #[cfg(not(unix))]
- {
- log::info!("[{level}] {message}");
- }
- state.borrow_mut().log.push(format!("[{level}] {message}"));
- Ok(())
- })?)
- }
-
- /// Checks the numeric/string ranges documented in `docs/DEFAULTS.md`'s
- /// "Validation Rules" section. Returns `(ok, errors)`; `errors` is an
- /// empty table when `ok` is true.
- fn fn_validate_config(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |lua, ()| {
- let s = state.borrow();
- let errors = validate(&s);
- let ok = errors.is_empty();
- Ok((ok, lua.create_sequence_from(errors)?))
- })?)
- }
-
- fn fn_debug_config_status(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |lua, ()| {
- let s = state.borrow();
- let t = lua.create_table()?;
- t.set("keys", s.values.len())?;
- t.set("bound_keys", s.key_bindings.len())?;
- t.set("log_entries", s.log.len())?;
- t.set("config_dir", s.config_dir.to_string_lossy().into_owned())?;
- Ok(t)
- })?)
- }
-
- fn fn_debug_show_settings(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |lua, ()| {
- let s = state.borrow();
- let mut keys: Vec<&String> = s.values.keys().collect();
- keys.sort();
- let t = lua.create_table()?;
- for key in keys {
- let v = &s.values[key];
- log::info!("{key} = {v:?}");
- let lua_v = match v {
- ConfigValue::String(s) => Value::String(lua.create_string(s)?),
- ConfigValue::Number(n) => Value::Number(*n),
- ConfigValue::Bool(b) => Value::Boolean(*b),
- ConfigValue::List(items) => Value::Table(lua.create_sequence_from(items.clone())?),
- };
- t.set(key.as_str(), lua_v)?;
- }
- Ok(t)
- })?)
- }
-
- fn fn_debug_profile_start(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, ()| {
- state.borrow_mut().profile_start = Some(std::time::Instant::now());
- Ok(())
- })?)
- }
-
- fn fn_debug_profile_stop(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, ()| {
- let elapsed = state.borrow_mut().profile_start.take().map(|t| t.elapsed().as_secs_f64());
- if let Some(secs) = elapsed {
- log::info!("profile: {:.3}ms", secs * 1000.0);
- }
- Ok(elapsed)
- })?)
- }
-
- // ---- srd.window.* ------------------------------------------------------
-
- fn fn_window_focused(&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 Some(w) = wm.focused_window() else { return Ok(Value::Nil) };
- let t = lua.create_table()?;
- t.set("id", w.id)?;
- t.set("title", w.title.clone())?;
- t.set("x", w.geometry.x)?;
- t.set("y", w.geometry.y)?;
- t.set("width", w.geometry.width)?;
- t.set("height", w.geometry.height)?;
- t.set("floating", w.floating)?;
- t.set("maximized", w.maximized)?;
- t.set("minimized", w.minimized)?;
- t.set("scratchpad", w.scratchpad)?;
- Ok(Value::Table(t))
- })?)
- }
-
- fn fn_window_action(&self, action: WindowAction) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, ()| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- if let Some(id) = wm.focused_id() {
- match action {
- WindowAction::Close => wm.close_window(id),
- WindowAction::Minimize => wm.minimize_window(id),
- WindowAction::Maximize => wm.toggle_maximize(id),
- 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<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, direction: String| {
- let dir = parse_direction(&direction, "srd.window.move")?;
- let wm = state.borrow().wm.clone();
- wm.borrow_mut().move_window_direction(dir);
- Ok(())
- })?)
- }
-
- /// `srd.window.next()` / `srd.window.prev()` - cycle focus through the
- /// windows on the current workspace (Hyprland's `cyclenext`).
- fn fn_window_cycle(&self, forward: bool) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, ()| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- if forward {
- wm.focus_next();
- } else {
- wm.focus_previous();
- }
- // Bring it to the top, matching the `bringactivetotop` the
- // Hyprland binding pairs with `cyclenext`.
- if let Some(id) = wm.focused_id() {
- wm.raise_window(id);
- }
- Ok(())
- })?)
- }
-
- fn fn_window_focus_direction(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, direction: String| {
- let dir = parse_direction(&direction, "srd.window.focus")?;
- let wm = state.borrow().wm.clone();
- wm.borrow_mut().focus_direction(dir);
- Ok(())
- })?)
- }
-
- fn fn_window_set_decorations(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, enabled: bool| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- if let Some(id) = wm.focused_id() {
- if let Some(w) = wm.window_mut(id) {
- w.decorated = enabled;
- }
- }
- Ok(())
- })?)
- }
-
- fn fn_window_set_border_color(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, (r, g, b): (u8, u8, u8)| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- if let Some(id) = wm.focused_id() {
- if let Some(w) = wm.window_mut(id) {
- w.border_color = (r, g, b);
- }
- }
- Ok(())
- })?)
- }
-
- fn fn_window_set_border_width(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, width: u32| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- if let Some(id) = wm.focused_id() {
- if let Some(w) = wm.window_mut(id) {
- w.border_width = width;
- }
- }
- Ok(())
- })?)
- }
-
- fn fn_window_set_opacity(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, opacity: f32| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- if let Some(id) = wm.focused_id() {
- if let Some(w) = wm.window_mut(id) {
- w.opacity = opacity.clamp(0.0, 1.0);
- }
- }
- Ok(())
- })?)
- }
-
- fn fn_window_set_floating(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, floating: bool| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- if let Some(id) = wm.focused_id() {
- if let Some(w) = wm.window_mut(id) {
- w.floating = floating;
- }
- }
- Ok(())
- })?)
- }
-
- fn fn_window_is_floating(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, ()| {
- let wm = state.borrow().wm.clone();
- let wm = wm.borrow();
- Ok(wm.focused_id().map(|id| wm.is_floating(id)).unwrap_or(false))
- })?)
- }
-
- // ---- srd.layout.* ------------------------------------------------------
-
- fn fn_layout_set(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, name: String| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- let ws = wm.current_workspace();
- wm.set_layout(ws, name);
- wm.arrange_workspace(ws);
- Ok(())
- })?)
- }
-
- fn fn_layout_configure(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, (name, table): (String, Table)| {
- let mut s = state.borrow_mut();
- flatten_table_into(&format!("layout.{name}"), &table, &mut s.values)?;
- let master_ratio = s.values.get(&format!("layout.{name}.master_ratio")).and_then(|v| v.as_f64());
- drop(s);
- if let Some(ratio) = master_ratio {
- state.borrow().wm.borrow_mut().tiling.master_ratio = ratio as f32;
- }
- Ok(())
- })?)
- }
-
- // ---- srd.workspace.* ---------------------------------------------------
-
- fn fn_workspace_cycle(&self, forward: bool) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, ()| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect();
- if ids.is_empty() {
- return Ok(());
- }
- let cur = wm.current_workspace();
- let pos = ids.iter().position(|&id| id == cur).unwrap_or(0);
- let next = if forward { (pos + 1) % ids.len() } else { (pos + ids.len() - 1) % ids.len() };
- wm.switch_workspace(ids[next]);
- Ok(())
- })?)
- }
-
- fn fn_workspace_switch(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, id: usize| {
- state.borrow().wm.borrow_mut().switch_workspace(id);
- Ok(())
- })?)
- }
-
- fn fn_workspace_move_window(&self) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- Ok(self.lua.create_function(move |_, id: usize| {
- let wm = state.borrow().wm.clone();
- let mut wm = wm.borrow_mut();
- if let Some(focused) = wm.focused_id() {
- wm.move_window_to_workspace(focused, id);
- }
- Ok(())
- })?)
- }
-
- // ---- srd.theme.* -------------------------------------------------------
-
- fn fn_theme_set(&self, prefix: &str) -> Result<mlua::Function<'_>> {
- let state = self.state.clone();
- let prefix = prefix.to_string();
- Ok(self.lua.create_function(move |_, table: Table| {
- let mut s = state.borrow_mut();
- flatten_table_into(&prefix, &table, &mut s.values)?;
- Ok(())
- })?)
- }
-}
-
-/// 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> {
- match name {
- "left" => Ok(Direction::Left),
- "right" => Ok(Direction::Right),
- "up" => Ok(Direction::Up),
- "down" => Ok(Direction::Down),
- other => Err(mlua::Error::RuntimeError(format!("{caller}: unknown direction '{other}'"))),
- }
-}
-
-#[derive(Clone, Copy)]
-enum WindowAction {
- Close,
- Minimize,
- Maximize,
- Fullscreen,
- ToggleFloating,
- TogglePin,
- ScratchpadAdd,
-}
-
-/// Recursively flattens a Lua table into dotted config keys, e.g.
-/// `{border = {width = 2}}` under prefix `"theme.decorations"` becomes
-/// `theme.decorations.border.width = 2`. Matches how `docs/DEFAULTS.md`
-/// documents nested `srd.theme.set_decorations{...}` / `srd.layout.configure`
-/// tables.
-fn flatten_table_into(prefix: &str, table: &Table, out: &mut HashMap<String, ConfigValue>) -> mlua::Result<()> {
- for pair in table.clone().pairs::<Value, Value>() {
- let (k, v) = pair?;
- let Value::String(k) = k else { continue };
- let key = format!("{prefix}.{}", k.to_str()?);
- if let Value::Table(t) = &v {
- flatten_table_into(&key, t, out)?;
- } else if let Some(cv) = ConfigValue::from_lua(&v) {
- out.insert(key, cv);
- }
- }
- Ok(())
-}
-
-/// Checks the numeric ranges, layout-name references, and hex-color strings
-/// documented in `docs/DEFAULTS.md`'s "Validation Rules" section against the
-/// current config values. Returns a human-readable error per violation.
-fn validate(s: &SharedState) -> Vec<String> {
- let mut errors = Vec::new();
-
- let mut check_range = |key: &str, min: f64, max: f64| {
- if let Some(v) = s.values.get(key).and_then(ConfigValue::as_f64) {
- if v < min || v > max {
- errors.push(format!("{key} = {v} is out of range [{min}, {max}]"));
- }
- }
- };
- check_range("general.window_gap", 0.0, 100.0);
- check_range("layout.tiling.gaps.inner", 0.0, 100.0);
- check_range("layout.tiling.gaps.outer", 0.0, 100.0);
- check_range("layout.dynamic.gaps.inner", 0.0, 100.0);
- check_range("layout.dynamic.gaps.outer", 0.0, 100.0);
- check_range("layout.floating.gaps.inner", 0.0, 100.0);
- check_range("layout.floating.gaps.outer", 0.0, 100.0);
- check_range("general.border_width", 0.0, 20.0);
- check_range("theme.decorations.border.width", 0.0, 20.0);
- check_range("general.animation_duration", 0.0, 1000.0);
- check_range("general.resize_margin", 1.0, 50.0);
- check_range("performance.max_fps", 30.0, 240.0);
- check_range("performance.window_cache_size", 10.0, 10000.0);
-
- let layouts: Vec<String> = s.wm.borrow().available_layouts().iter().map(|l| l.to_string()).collect();
- for key in ["general.default_layout", "monitor.primary_layout", "monitor.secondary_layout"] {
- if let Some(name) = s.values.get(key).and_then(ConfigValue::as_str) {
- if !layouts.iter().any(|l| l == name) {
- errors.push(format!("{key} = '{name}' is not a registered layout {layouts:?}"));
- }
- }
- }
-
- let color_keys = [
- "theme.colors.background",
- "theme.colors.foreground",
- "theme.colors.primary",
- "theme.colors.secondary",
- "theme.colors.accent",
- "theme.colors.error",
- "theme.colors.warning",
- "theme.colors.success",
- "theme.decorations.border.active_color",
- "theme.decorations.border.inactive_color",
- "theme.decorations.title_bar.background",
- "theme.decorations.title_bar.foreground",
- ];
- for key in color_keys {
- if let Some(v) = s.values.get(key).and_then(ConfigValue::as_str) {
- if !is_valid_hex_color(v) {
- errors.push(format!("{key} = '{v}' is not a valid hex color (expected '#rrggbb')"));
- }
- }
- }
-
- errors
-}
-
-fn is_valid_hex_color(s: &str) -> bool {
- s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit())
-}
-
-/// The config surface documented in `docs/DEFAULTS.md`, seeded before
-/// `init.lua` runs so `srd.get(...)` returns sensible values even for keys
-/// the user's config never touches.
-fn default_config() -> HashMap<String, ConfigValue> {
- use ConfigValue::*;
- let mut m = HashMap::new();
- let mut set = |k: &str, v: ConfigValue| {
- m.insert(k.to_string(), v);
- };
- set("general.default_layout", String("dynamic".into()));
- set("general.smart_placement", Bool(true));
- set("general.window_gap", Number(8.0));
- set("general.border_width", Number(2.0));
- set("general.animations", Bool(true));
- set("general.animation_duration", Number(200.0));
- set("general.shadows", Bool(true));
- set("general.resize_margin", Number(6.0));
- // Deliberately *not* seeded here, unlike every other `general.*` key --
- // its actual default differs by backend (GLES/winit: on; udev/Pixman:
- // off, an untested-on-real-hardware CPU cost too real to default to on
- // - see `crates/wayland/src/rounded_corners.rs`), and neither backend
- // is known yet at the point `default_config` runs. Leaving the key
- // genuinely absent (rather than pre-seeded `true`/`false`) is what lets
- // `main.rs`'s `apply_general_settings` tell "user never touched this"
- // apart from "user explicitly chose a value" and hand the *unset* case
- // to whichever backend ends up connecting instead of deciding for it.
- set("general.focus_follows_mouse", Bool(false));
- set("general.mouse_follows_focus", Bool(true));
- set("general.auto_raise", Bool(false));
- set("general.auto_focus", Bool(true));
-
- set("monitor.primary_layout", String("dynamic".into()));
- set("monitor.secondary_layout", String("tiling".into()));
- set("monitor.auto_detect", Bool(true));
- set("monitor.primary_workspace", Number(1.0));
- set("monitor.workspace_count", Number(10.0));
-
- set("window.focus_follows_mouse", Bool(false));
- set("window.mouse_follows_focus", Bool(true));
- set("window.auto_raise", Bool(false));
- set("window.auto_focus", Bool(true));
- set("window.raise_on_focus", Bool(true));
- set("window.remember_position", Bool(true));
- set("window.remember_size", Bool(true));
- set("window.remember_state", Bool(true));
-
- set("workspace.count", Number(10.0));
- set("workspace.names", List(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"].map(|s| s.to_string()).to_vec()));
- set("workspace.auto_switch", Bool(false));
- set("workspace.persistent", Bool(true));
- set("workspace.auto_back_and_forth", Bool(false));
-
- set("performance.vsync", Bool(true));
- set("performance.max_fps", Number(60.0));
- set("performance.window_cache_size", Number(100.0));
- set("performance.event_queue_size", Number(1000.0));
- set("performance.layout_timeout", Number(16.0));
- set("performance.enable_caching", Bool(true));
-
- set("debug.logging", Bool(true));
- set("debug.log_level", String("info".into()));
- set("debug.profile", Bool(false));
- set("debug.trace_events", Bool(false));
- set("debug.show_layout_bounds", Bool(false));
- set("debug.show_window_geometry", Bool(false));
-
- set("layout.tiling.split_ratio", Number(0.5));
- set("layout.tiling.master_ratio", Number(0.6));
- set("layout.tiling.auto_swap", Bool(true));
- set("layout.tiling.gaps.inner", Number(8.0));
- set("layout.tiling.gaps.outer", Number(16.0));
- set("layout.tiling.behavior.new_window_master", Bool(false));
- set("layout.tiling.behavior.auto_balance", Bool(true));
- set("layout.tiling.behavior.preserve_ratio", Bool(true));
-
- 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));
- set("layout.dynamic.gaps.inner", Number(8.0));
- set("layout.dynamic.gaps.outer", Number(16.0));
- set("layout.dynamic.behavior.remember_positions", Bool(true));
- set("layout.dynamic.behavior.auto_arrange", Bool(true));
- set("layout.dynamic.behavior.overlap_prevention", Bool(true));
-
- set("layout.floating.default_position", String("center".into()));
- set("layout.floating.remember_position", Bool(true));
- set("layout.floating.always_on_top", Bool(false));
- set("layout.floating.gaps.inner", Number(0.0));
- set("layout.floating.gaps.outer", Number(16.0));
- set("layout.floating.behavior.allow_resize", Bool(true));
- set("layout.floating.behavior.allow_move", Bool(true));
- set("layout.floating.behavior.snap_to_edges", Bool(true));
-
- set("theme.colors.background", String("#2e3440".into()));
- set("theme.colors.foreground", String("#eceff4".into()));
- set("theme.colors.primary", String("#88c0d0".into()));
- set("theme.colors.secondary", String("#81a1c1".into()));
- set("theme.colors.accent", String("#5e81ac".into()));
- set("theme.colors.error", String("#bf616a".into()));
- set("theme.colors.warning", String("#ebcb8b".into()));
- set("theme.colors.success", String("#a3be8c".into()));
-
- set("theme.decorations.border.width", Number(2.0));
- set("theme.decorations.border.active_color", String("#88c0d0".into()));
- set("theme.decorations.border.inactive_color", String("#2e3440".into()));
- set("theme.decorations.border.focused_style", String("solid".into()));
- set("theme.decorations.border.unfocused_style", String("solid".into()));
- set("theme.decorations.title_bar.height", Number(24.0));
- set("theme.decorations.title_bar.show", Bool(true));
- set("theme.decorations.title_bar.font", String("JetBrains Mono 10".into()));
- set("theme.decorations.title_bar.background", String("#2e3440".into()));
- set("theme.decorations.title_bar.foreground", String("#eceff4".into()));
-
- set("platform.backend", String("auto".into()));
- set("platform.x11.use_ewmh", Bool(true));
- set("platform.x11.use_netwm", Bool(true));
- set("platform.wayland.use_xdg_shell", Bool(true));
- set("platform.wayland.use_layer_shell", Bool(true));
- set("platform.windows.use_dwm", Bool(true));
- set("platform.windows.use_win32", Bool(true));
- set("platform.windows.global_hooks", Bool(true));
- set("platform.macos.use_cocoa", Bool(true));
- set("platform.macos.use_core_graphics", Bool(true));
- set("platform.macos.accessibility_enabled", Bool(true));
-
- m
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use srdwm_core::Window;
-
- fn engine_in(dir: &std::path::Path) -> Engine {
- let wm = Rc::new(RefCell::new(WindowManager::new()));
- Engine::new(wm, dir).unwrap()
- }
-
- #[test]
- fn srd_set_and_get_roundtrip_scalars() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine.lua.load(r#"srd.set("general.window_gap", 12)"#).exec().unwrap();
- assert_eq!(engine.get("general.window_gap"), Some(ConfigValue::Number(12.0)));
- }
-
- #[test]
- fn defaults_are_seeded_before_any_script_runs() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- assert_eq!(engine.get_string("general.default_layout", ""), "dynamic");
- }
-
- #[test]
- fn reset_restores_default_value() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine.lua.load(r#"srd.set("general.window_gap", 99)"#).exec().unwrap();
- engine.lua.load(r#"srd.reset("general.window_gap")"#).exec().unwrap();
- assert_eq!(engine.get("general.window_gap"), Some(ConfigValue::Number(8.0)));
- }
-
- #[test]
- fn bind_stores_real_closure_and_dispatch_runs_it() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine
- .lua
- .load(r#"srd.bind("Mod4+q", function() srd.set("test.marker", true) end)"#)
- .exec()
- .unwrap();
- assert!(engine.dispatch_keybinding("Mod4+q"));
- assert_eq!(engine.get("test.marker"), Some(ConfigValue::Bool(true)));
- assert!(!engine.dispatch_keybinding("Mod4+nonexistent"));
- }
-
- #[test]
- fn srd_is_requireable_not_just_a_global() {
- // Every shipped example config opens with `local srd = require("srd")`;
- // that must resolve through package.preload, not just exist as a global.
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine
- .lua
- .load(r#"local srd = require("srd"); srd.set("test.via_require", true)"#)
- .exec()
- .unwrap();
- assert_eq!(engine.get("test.via_require"), Some(ConfigValue::Bool(true)));
- }
-
- #[test]
- fn window_close_style_call_from_legacy_example_config_now_works() {
- // The legacy C++ engine's `srd.window.focused()` returned a
- // placeholder table with no methods, so `window:close()` in the
- // shipped example config would have errored at runtime. Here
- // `srd.window.close()` acts directly on the focused window.
- let dir = tempfile::tempdir().unwrap();
- let wm = Rc::new(RefCell::new(WindowManager::new()));
- {
- let mut wm = wm.borrow_mut();
- let id = wm.alloc_window_id();
- wm.add_window(Window::new(id, "test"));
- }
- let engine = Engine::new(wm.clone(), dir.path()).unwrap();
- engine
- .lua
- .load(
- r#"
- local w = srd.window.focused()
- assert(w ~= nil, "expected a focused window")
- srd.window.set_floating(true)
- assert(srd.window.is_floating() == true)
- "#,
- )
- .exec()
- .unwrap();
- }
-
- #[test]
- fn layout_configure_updates_master_ratio_live() {
- 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.layout.configure("tiling", { master_ratio = 0.75 })"#)
- .exec()
- .unwrap();
- assert_eq!(engine.get("layout.tiling.master_ratio"), Some(ConfigValue::Number(0.75)));
- assert!((wm.borrow().tiling.master_ratio - 0.75).abs() < f32::EPSILON);
- }
-
- #[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();
- let engine = engine_in(dir.path());
- engine.lua.load(r#"srd.load("extra")"#).exec().unwrap();
- assert_eq!(engine.get("from.extra"), Some(ConfigValue::String("yes".into())));
- }
-
- #[test]
- fn validate_config_passes_on_untouched_defaults() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine
- .lua
- .load(r#"local ok, errs = srd.validate_config(); assert(ok, table.concat(errs, "; "))"#)
- .exec()
- .unwrap();
- }
-
- #[test]
- fn validate_config_flags_out_of_range_gap_and_bad_color() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine
- .lua
- .load(
- r#"
- srd.set("general.window_gap", 500)
- srd.set("theme.colors.background", "not-a-color")
- local ok, errs = srd.validate_config()
- assert(ok == false)
- assert(#errs == 2, "expected 2 errors, got " .. #errs)
- "#,
- )
- .exec()
- .unwrap();
- }
-
- #[test]
- fn validate_config_flags_unregistered_layout_name() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine
- .lua
- .load(
- r#"
- srd.set("general.default_layout", "nonexistent")
- local ok, errs = srd.validate_config()
- assert(ok == false)
- "#,
- )
- .exec()
- .unwrap();
- }
-
- #[test]
- fn debug_namespace_reports_status_and_profiles() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine
- .lua
- .load(
- r#"
- local status = srd.debug.config_status()
- assert(status.keys > 0)
- srd.debug.profile_start()
- local elapsed = srd.debug.profile_stop()
- assert(type(elapsed) == "number")
- local settings = srd.debug.show_settings()
- assert(settings["general.window_gap"] == 8)
- "#,
- )
- .exec()
- .unwrap();
- }
-
- #[test]
- fn srd_rule_floats_matching_window_on_creation() {
- 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 = "calculator" }, { floating = true })"#)
- .exec()
- .unwrap();
- let id = {
- let mut wm = wm.borrow_mut();
- let id = wm.alloc_window_id();
- wm.add_window(srdwm_core::Window::new(id, "Calculator"));
- id
- };
- assert!(wm.borrow().is_floating(id));
- }
-
- #[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();
- let engine = engine_in(dir.path());
- engine.load_init().unwrap();
- assert_eq!(engine.get("general.window_gap"), Some(ConfigValue::Number(4.0)));
- }
-
- #[test]
- fn bind_repeat_registers_the_binding_and_marks_it_repeating() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine
- .lua
- .load(r#"
- srd.bind("Mod4+a", function() end)
- srd.bind_repeat("XF86AudioRaiseVolume", function() end)
- "#)
- .exec()
- .unwrap();
-
- let bound = engine.bound_keys();
- // A repeating bind is still a normal binding - it must be grabbed
- // and dispatched like any other, or it would never fire at all.
- assert!(bound.contains(&"Mod4+a".to_string()));
- assert!(bound.contains(&"XF86AudioRaiseVolume".to_string()));
-
- let repeat = engine.repeat_keys();
- assert_eq!(repeat, vec!["XF86AudioRaiseVolume".to_string()]);
- assert!(!repeat.contains(&"Mod4+a".to_string()), "a plain bind must not repeat");
- }
-
- #[test]
- fn bind_repeat_dispatches_like_a_normal_binding() {
- let dir = tempfile::tempdir().unwrap();
- let engine = engine_in(dir.path());
- engine
- .lua
- .load(r#"
- fired = 0
- srd.bind_repeat("Mod4+z", function() fired = fired + 1 end)
- "#)
- .exec()
- .unwrap();
- assert!(engine.dispatch_keybinding("Mod4+z"));
- assert!(engine.dispatch_keybinding("Mod4+z"));
- let fired: i64 = engine.lua.globals().get("fired").unwrap();
- assert_eq!(fired, 2);
- }
-}