diff --git a/rs/Cargo.lock b/rs/Cargo.lock index 6fe5038b..4da72390 100644 --- a/rs/Cargo.lock +++ b/rs/Cargo.lock @@ -20,6 +20,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "askama" version = "0.15.1" @@ -202,6 +252,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -233,6 +329,27 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -309,6 +426,17 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -340,6 +468,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.0" @@ -428,6 +562,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyprlayoutctl" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "shellexpand", + "tempfile", + "thiserror", + "toml", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -478,6 +625,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -500,6 +653,16 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -597,6 +760,18 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -671,6 +846,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -822,6 +1008,15 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -870,6 +1065,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.114" @@ -894,7 +1095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1098,6 +1299,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/rs/Cargo.toml b/rs/Cargo.toml index fcf6460b..863f7b88 100644 --- a/rs/Cargo.toml +++ b/rs/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "3" -members = ["site"] +members = ["site", "hyprlayoutctl"] diff --git a/rs/hyprlayoutctl/Cargo.toml b/rs/hyprlayoutctl/Cargo.toml new file mode 100644 index 00000000..838c1ade --- /dev/null +++ b/rs/hyprlayoutctl/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "hyprlayoutctl" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Apply named floating layouts to Hyprland workspaces" +readme = "../../README.md" +repository = "https://github.com/rrvsh/tools" +keywords = ["hyprland", "layout", "cli"] +categories = ["command-line-utilities", "config"] + +[dependencies] +clap = { version = "4.5.48", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +shellexpand = "3.1.1" +thiserror = "2.0.17" +toml = "0.9.8" + +[dev-dependencies] +tempfile = "3.23.0" + +[lints.clippy] +correctness = "deny" +suspicious = "deny" +complexity = "deny" +perf = "deny" +style = "deny" +pedantic = "deny" +cargo = "deny" +nursery = "deny" diff --git a/rs/hyprlayoutctl/src/app.rs b/rs/hyprlayoutctl/src/app.rs new file mode 100644 index 00000000..588135f8 --- /dev/null +++ b/rs/hyprlayoutctl/src/app.rs @@ -0,0 +1,267 @@ +use crate::config::{Discovery, LayoutSource, ResolvedConfig, resolve_layout}; +use crate::engine::plan_layout; +use crate::error::{Error, Result}; +use crate::hypr::Runtime; + +#[derive(Debug, Clone, Copy)] +pub struct ApplyOptions { + pub dry_run: bool, + pub verbose: bool, +} + +pub struct App { + pub runtime: R, + pub options: ApplyOptions, +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::mpsc::{self, Receiver}; + use std::sync::{Arc, Mutex}; + + use crate::config::{Discovery, LayoutSource, ResolvedConfig}; + use crate::engine::{Client, HyprCommand, Scope}; + use crate::hypr::Runtime; + use crate::model::{ + AppsConfig, ConfigFile, ContentKind, ContentRule, Defaults, GeneralConfig, Layout, PaneDef, + PaneLayout, PaneLayoutKind, PxRect, RectDef, Scalar, + }; + + use super::*; + + #[derive(Clone)] + struct FakeRuntime { + scope: Scope, + clients_sequence: Arc>>>, + spawned: Arc>>, + dispatched: Arc>>>, + } + + impl Runtime for FakeRuntime { + fn scope(&self) -> Result { + Ok(self.scope.clone()) + } + + fn clients(&self) -> Result> { + let mut lock = self.clients_sequence.lock().unwrap(); + if lock.len() > 1 { + return Ok(lock.pop_front().unwrap()); + } + Ok(lock.front().cloned().unwrap_or_default()) + } + + fn dispatch(&self, commands: &[HyprCommand], _: bool, _: bool) -> Result<()> { + self.dispatched.lock().unwrap().push(commands.to_vec()); + Ok(()) + } + + fn spawn(&self, command: &str, _: bool, _: bool) -> Result<()> { + self.spawned.lock().unwrap().push(command.to_string()); + Ok(()) + } + + fn subscribe_events(&self) -> Result> { + let (_tx, rx) = mpsc::channel(); + Ok(rx) + } + } + + fn make_layout() -> Layout { + Layout { + name: "dev".to_string(), + id: "dev".to_string(), + defaults: Defaults { + gap_px: 0, + padding_px: 0, + tall_bias: 1.35, + }, + panes: vec![PaneDef { + name: "all".to_string(), + rect: RectDef { + x: Scalar::Text("0%".to_string()), + y: Scalar::Text("0%".to_string()), + w: Scalar::Text("100%".to_string()), + h: Scalar::Text("100%".to_string()), + }, + content: ContentRule { + kind: ContentKind::PickOne, + pick: vec!["ghostty".to_string()], + except_panes: vec![], + ensure: true, + ensure_min: None, + fallback: None, + match_terms: vec![], + }, + layout: PaneLayout { + kind: PaneLayoutKind::Single, + preference: None, + }, + }], + } + } + + fn make_resolved(layout: Layout) -> ResolvedConfig { + ResolvedConfig { + config_path: "config.toml".into(), + config: ConfigFile { + general: GeneralConfig { + layout_dirs: vec![], + }, + apps: AppsConfig { + ghostty_cmd: "ghostty --new-window".to_string(), + camera_classes: vec![], + }, + layouts: std::collections::BTreeMap::new(), + }, + layout_dirs: vec![], + discovered: vec![Discovery { + key: "dev".to_string(), + layout, + source: LayoutSource::Inline, + file_stem: None, + }], + } + } + + #[test] + fn apply_refreshes_clients_after_spawn() { + let scope = Scope { + monitor_id: 1, + workspace_id: 9, + monitor_rect: PxRect { + x: 0, + y: 0, + w: 1000, + h: 700, + }, + }; + + let runtime = FakeRuntime { + scope, + clients_sequence: Arc::new(Mutex::new(VecDeque::from([ + vec![], + vec![Client { + address: "0x1".to_string(), + class: "ghostty".to_string(), + title: "shell".to_string(), + monitor_id: 1, + workspace_id: 9, + floating: false, + x: 10, + y: 10, + w: 10, + h: 10, + }], + ]))), + spawned: Arc::new(Mutex::new(Vec::new())), + dispatched: Arc::new(Mutex::new(Vec::new())), + }; + + let app = App { + runtime: runtime.clone(), + options: ApplyOptions { + dry_run: false, + verbose: false, + }, + }; + let resolved = make_resolved(make_layout()); + + app.apply_named_layout(&resolved, "dev").unwrap(); + + assert_eq!( + runtime.spawned.lock().unwrap().as_slice(), + &["ghostty --new-window"] + ); + assert_eq!(runtime.dispatched.lock().unwrap().len(), 1); + assert!(!runtime.dispatched.lock().unwrap()[0].is_empty()); + } +} + +#[allow(clippy::items_after_test_module)] +impl App { + /// Applies a layout resolved by name or file path query. + /// + /// # Errors + /// Returns an error when layout resolution fails or Hyprland operations fail. + pub fn apply_named_layout(&self, config: &ResolvedConfig, query: &str) -> Result<()> { + let selected = resolve_layout(query, config)?; + self.apply_discovery(config, &selected) + } + + /// Applies a previously discovered layout to the active Hyprland scope. + /// + /// # Errors + /// Returns an error when querying scope/clients, spawning windows, or dispatching window + /// commands fails. + pub fn apply_discovery(&self, config: &ResolvedConfig, discovery: &Discovery) -> Result<()> { + if self.options.verbose { + let source = match &discovery.source { + LayoutSource::Inline => "inline".to_string(), + LayoutSource::File(path) => path.display().to_string(), + }; + println!("applying layout {} from {source}", discovery.layout.name); + } + + let scope = self.runtime.scope()?; + let clients = self.runtime.clients()?; + let mut plan = plan_layout(&discovery.layout, &config.config.apps, &clients, &scope); + + for spawn_command in &plan.spawn_commands { + self.runtime + .spawn(spawn_command, self.options.dry_run, self.options.verbose)?; + } + + if !plan.spawn_commands.is_empty() && !self.options.dry_run { + let refreshed_clients = self.runtime.clients()?; + plan = plan_layout( + &discovery.layout, + &config.config.apps, + &refreshed_clients, + &scope, + ); + } + + self.runtime + .dispatch(&plan.commands, self.options.dry_run, self.options.verbose) + } + + pub fn list_layouts(&self, config: &ResolvedConfig) { + for item in &config.discovered { + let source = match &item.source { + LayoutSource::Inline => "inline".to_string(), + LayoutSource::File(path) => format!("file:{}", path.display()), + }; + println!("{}\t{}\t{}", item.layout.name, item.layout.id, source); + } + } + + /// Validates one or all discovered layouts. + /// + /// # Errors + /// Returns an error when the target layout cannot be resolved or any layout is invalid. + pub fn validate_layouts( + &self, + config: &ResolvedConfig, + maybe_name: Option<&str>, + ) -> Result<()> { + if let Some(name) = maybe_name { + let item = resolve_layout(name, config)?; + item.layout.validate()?; + println!("ok: {}", item.layout.name); + return Ok(()); + } + + if config.discovered.is_empty() { + return Err(Error::Internal { + detail: "no layouts found".to_string(), + }); + } + + for item in &config.discovered { + item.layout.validate()?; + println!("ok: {}", item.layout.name); + } + Ok(()) + } +} diff --git a/rs/hyprlayoutctl/src/config.rs b/rs/hyprlayoutctl/src/config.rs new file mode 100644 index 00000000..4cf95fb7 --- /dev/null +++ b/rs/hyprlayoutctl/src/config.rs @@ -0,0 +1,286 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::{Error, Result}; +use crate::model::{ConfigFile, Layout, LayoutFile}; + +#[derive(Debug, Clone)] +pub enum LayoutSource { + Inline, + File(PathBuf), +} + +#[derive(Debug, Clone)] +pub struct Discovery { + pub key: String, + pub layout: Layout, + pub source: LayoutSource, + pub file_stem: Option, +} + +#[derive(Debug, Clone)] +pub struct ResolvedConfig { + pub config_path: PathBuf, + pub config: ConfigFile, + pub layout_dirs: Vec, + pub discovered: Vec, +} + +/// Returns the default config file path. +/// +/// # Errors +/// Returns an error when home-path expansion fails. +pub fn default_config_path() -> Result { + expand_home("~/.config/hyprlayoutctl/config.toml") +} + +/// Loads config/layout directories and discovers all valid layouts. +/// +/// # Errors +/// Returns an error when reading/parsing config or discovered layouts fails. +pub fn load_resolved( + config_path: Option, + cli_layout_dirs: &[PathBuf], +) -> Result { + let selected_config = if let Some(path) = config_path { + path + } else { + default_config_path()? + }; + + let config = if selected_config.exists() { + parse_config(&selected_config)? + } else { + ConfigFile::default() + }; + + let layout_dirs = if cli_layout_dirs.is_empty() { + config + .general + .layout_dirs + .iter() + .map(|item| expand_home(item)) + .collect::>>()? + } else { + cli_layout_dirs.to_vec() + }; + + let discovered = discover_layouts(&config, &layout_dirs)?; + + Ok(ResolvedConfig { + config_path: selected_config, + config, + layout_dirs, + discovered, + }) +} + +/// Resolves a layout query by direct path first, then by discovered names/stems. +/// +/// # Errors +/// Returns an error when no layout matches, multiple layouts match, or parsing fails. +pub fn resolve_layout(query: &str, resolved: &ResolvedConfig) -> Result { + let query_path = PathBuf::from(query); + if query_path.exists() { + return load_layout_file(&query_path, query).map(|layout| Discovery { + key: layout.name.clone(), + layout, + source: LayoutSource::File(query_path.clone()), + file_stem: query_path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(ToString::to_string), + }); + } + + let matches = resolved + .discovered + .iter() + .filter(|entry| { + entry.key == query + || entry.layout.name == query + || entry.file_stem.as_deref().is_some_and(|stem| stem == query) + }) + .cloned() + .collect::>(); + + match matches.len() { + 0 => Err(Error::LayoutNotFound { + name: query.to_string(), + }), + 1 => Ok(matches[0].clone()), + _ => { + let sources = matches + .iter() + .map(|entry| match &entry.source { + LayoutSource::Inline => format!("inline:{}", entry.key), + LayoutSource::File(path) => path.display().to_string(), + }) + .collect::>() + .join(", "); + Err(Error::AmbiguousLayout { + name: query.to_string(), + sources, + }) + } + } +} + +fn parse_config(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|source| Error::ReadFile { + path: path.to_path_buf(), + source, + })?; + toml::from_str::(&raw).map_err(|source| Error::ParseToml { + path: path.to_path_buf(), + source, + }) +} + +fn discover_layouts(config: &ConfigFile, layout_dirs: &[PathBuf]) -> Result> { + let mut output = Vec::new(); + + for layout in config.inline_layouts() { + layout.validate()?; + output.push(Discovery { + key: layout.name.clone(), + layout, + source: LayoutSource::Inline, + file_stem: None, + }); + } + + for directory in layout_dirs { + if !directory.exists() { + continue; + } + for entry in fs::read_dir(directory).map_err(|source| Error::ReadFile { + path: directory.clone(), + source, + })? { + let item = entry.map_err(|source| Error::ReadFile { + path: directory.clone(), + source, + })?; + let path = item.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("layout") { + continue; + } + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| Error::InvalidLayout { + name: "".to_string(), + reason: format!("invalid UTF-8 filename: {}", path.display()), + })? + .to_string(); + let layout = load_layout_file(&path, &stem)?; + output.push(Discovery { + key: layout.name.clone(), + layout, + source: LayoutSource::File(path), + file_stem: Some(stem), + }); + } + } + + Ok(output) +} + +fn load_layout_file(path: &Path, fallback_name: &str) -> Result { + let raw = fs::read_to_string(path).map_err(|source| Error::ReadFile { + path: path.to_path_buf(), + source, + })?; + let parsed = toml::from_str::(&raw).map_err(|source| Error::ParseToml { + path: path.to_path_buf(), + source, + })?; + let layout = parsed.into_layout(fallback_name); + layout.validate()?; + Ok(layout) +} + +fn expand_home(path: &str) -> Result { + let expanded = shellexpand::tilde(path); + let text = expanded.as_ref(); + if text.is_empty() { + return Err(Error::InvalidExpandedPath { + path: path.to_string(), + }); + } + Ok(PathBuf::from(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_file(path: &Path, content: &str) { + let mut handle = fs::File::create(path).unwrap(); + handle.write_all(content.as_bytes()).unwrap(); + } + + #[test] + fn resolves_layout_from_direct_file_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let layout_path = temp_dir.path().join("dev.layout"); + write_file( + &layout_path, + r#"meta.name = "dev" + +[[panes]] +name = "all" +rect = { x = "0%", y = "0%", w = "100%", h = "100%" } +content = { kind = "all" } +layout = { kind = "single" } +"#, + ); + let resolved = load_resolved(None, &[temp_dir.path().to_path_buf()]).unwrap(); + + let found = resolve_layout(layout_path.to_string_lossy().as_ref(), &resolved).unwrap(); + assert_eq!(found.layout.name, "dev"); + } + + #[test] + fn raises_ambiguity_for_duplicate_layout_names() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let layouts_dir = temp_dir.path().join("layouts"); + fs::create_dir_all(&layouts_dir).unwrap(); + + write_file( + &config_path, + &format!( + r#"[general] +layout_dirs = ["{}"] + +[layouts.dev] +[[layouts.dev.panes]] +name = "all" +rect = {{ x = "0%", y = "0%", w = "100%", h = "100%" }} +content = {{ kind = "all" }} +layout = {{ kind = "single" }} +"#, + layouts_dir.display() + ), + ); + + write_file( + &layouts_dir.join("dev.layout"), + r#"meta.name = "dev" + +[[panes]] +name = "all" +rect = { x = "0%", y = "0%", w = "100%", h = "100%" } +content = { kind = "all" } +layout = { kind = "single" } +"#, + ); + + let resolved = load_resolved(Some(config_path), &[]).unwrap(); + let err = resolve_layout("dev", &resolved).unwrap_err(); + assert!(format!("{err}").contains("ambiguous")); + } +} diff --git a/rs/hyprlayoutctl/src/engine.rs b/rs/hyprlayoutctl/src/engine.rs new file mode 100644 index 00000000..2f64ea72 --- /dev/null +++ b/rs/hyprlayoutctl/src/engine.rs @@ -0,0 +1,543 @@ +use std::collections::{HashMap, HashSet}; + +use crate::model::{ + AppsConfig, ContentKind, Defaults, GridPreference, Layout, PaneDef, PaneLayoutKind, PxRect, +}; + +#[derive(Debug, Clone)] +pub struct Scope { + pub monitor_id: i32, + pub workspace_id: i32, + pub monitor_rect: PxRect, +} + +#[derive(Debug, Clone)] +pub struct Client { + pub address: String, + pub class: String, + pub title: String, + pub monitor_id: i32, + pub workspace_id: i32, + pub floating: bool, + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +#[derive(Debug, Clone)] +pub enum HyprCommand { + SetFloating { address: String }, + Move { address: String, x: i32, y: i32 }, + Resize { address: String, w: i32, h: i32 }, +} + +#[derive(Debug, Clone)] +pub struct Plan { + pub commands: Vec, + pub spawn_commands: Vec, +} + +#[must_use] +pub fn plan_layout(layout: &Layout, apps: &AppsConfig, clients: &[Client], scope: &Scope) -> Plan { + let scoped_clients = clients + .iter() + .filter(|client| { + client.monitor_id == scope.monitor_id && client.workspace_id == scope.workspace_id + }) + .cloned() + .collect::>(); + + let assignments = assign_panes(layout, apps, &scoped_clients); + let commands = place_clients(layout, &scoped_clients, &assignments, scope); + + Plan { + commands, + spawn_commands: assignments.spawn_commands, + } +} + +#[derive(Debug, Clone)] +struct Assignments { + by_pane: HashMap>, + spawn_commands: Vec, +} + +fn assign_panes(layout: &Layout, apps: &AppsConfig, clients: &[Client]) -> Assignments { + let mut by_pane: HashMap> = HashMap::new(); + let mut assigned = HashSet::new(); + let mut spawn_commands = Vec::new(); + + for pane in &layout.panes { + let selected = select_for_pane(pane, apps, clients, &by_pane, &assigned); + let selected_set = selected.iter().copied().collect::>(); + assigned.extend(selected_set); + + let desired_min = pane + .content + .ensure_min + .unwrap_or_else(|| usize::from(pane.content.ensure)); + if selected.len() < desired_min { + let missing = desired_min - selected.len(); + if let Some(command) = spawn_command_for_pane(pane, apps) { + spawn_commands.extend(vec![command; missing]); + } + } + by_pane.insert(pane.name.clone(), selected); + } + + Assignments { + by_pane, + spawn_commands, + } +} + +fn select_for_pane( + pane: &PaneDef, + apps: &AppsConfig, + clients: &[Client], + by_pane: &HashMap>, + assigned: &HashSet, +) -> Vec { + match pane.content.kind { + ContentKind::PickOne => { + let mut result = Vec::new(); + for token in &pane.content.pick { + if let Some((index, _)) = clients.iter().enumerate().find(|(idx, client)| { + !assigned.contains(idx) && client_matches_token(client, token, apps) + }) { + result.push(index); + break; + } + } + result + } + ContentKind::All => clients + .iter() + .enumerate() + .filter_map(|(idx, _)| (!assigned.contains(&idx)).then_some(idx)) + .collect(), + ContentKind::AllExcept => { + let mut excluded = HashSet::new(); + for pane_name in &pane.content.except_panes { + if let Some(entries) = by_pane.get(pane_name) { + excluded.extend(entries.iter().copied()); + } + } + clients + .iter() + .enumerate() + .filter_map(|(idx, _)| { + (!assigned.contains(&idx) && !excluded.contains(&idx)).then_some(idx) + }) + .collect() + } + ContentKind::Match => clients + .iter() + .enumerate() + .filter_map(|(idx, client)| { + (!assigned.contains(&idx) + && pane + .content + .match_terms + .iter() + .any(|token| client_matches_token(client, token, apps))) + .then_some(idx) + }) + .collect(), + } +} + +fn client_matches_token(client: &Client, token: &str, apps: &AppsConfig) -> bool { + let token_lower = token.to_ascii_lowercase(); + let class_lower = client.class.to_ascii_lowercase(); + let title_lower = client.title.to_ascii_lowercase(); + + if token_lower == "camera" { + return apps + .camera_classes + .iter() + .map(|value| value.to_ascii_lowercase()) + .any(|value| class_lower.contains(&value)); + } + + class_lower.contains(&token_lower) || title_lower.contains(&token_lower) +} + +fn spawn_command_for_pane(pane: &PaneDef, apps: &AppsConfig) -> Option { + if let Some(fallback) = &pane.content.fallback { + return spawn_token_to_command(fallback, apps); + } + + if pane.content.kind == ContentKind::PickOne { + pane.content + .pick + .iter() + .find_map(|value| spawn_token_to_command(value, apps)) + } else { + None + } +} + +fn spawn_token_to_command(token: &str, apps: &AppsConfig) -> Option { + if token.eq_ignore_ascii_case("ghostty") { + Some(apps.ghostty_cmd.clone()) + } else if token.eq_ignore_ascii_case("camera") { + None + } else { + Some(token.to_string()) + } +} + +fn place_clients( + layout: &Layout, + clients: &[Client], + assignments: &Assignments, + scope: &Scope, +) -> Vec { + let defaults = layout.defaults; + let mut commands = Vec::new(); + + for pane in &layout.panes { + let Some(client_indexes) = assignments.by_pane.get(&pane.name) else { + continue; + }; + if client_indexes.is_empty() { + continue; + } + let pane_rect = pane_rect_in_scope(pane, defaults, scope.monitor_rect); + let target_rects = target_rects_for_pane(client_indexes.len(), pane, pane_rect, defaults); + + for (rect, client_idx) in target_rects.iter().zip(client_indexes.iter().copied()) { + let client = &clients[client_idx]; + + if !client.floating { + commands.push(HyprCommand::SetFloating { + address: client.address.clone(), + }); + } + if client.x != rect.x || client.y != rect.y { + commands.push(HyprCommand::Move { + address: client.address.clone(), + x: rect.x, + y: rect.y, + }); + } + if client.w != rect.w || client.h != rect.h { + commands.push(HyprCommand::Resize { + address: client.address.clone(), + w: rect.w, + h: rect.h, + }); + } + } + } + + commands +} + +fn pane_rect_in_scope(pane: &PaneDef, defaults: Defaults, monitor_rect: PxRect) -> PxRect { + let relative = pane + .rect + .to_px(monitor_rect.w, monitor_rect.h) + .unwrap_or(PxRect { + x: 0, + y: 0, + w: monitor_rect.w, + h: monitor_rect.h, + }); + + let mut x = monitor_rect.x + relative.x + defaults.padding_px; + let mut y = monitor_rect.y + relative.y + defaults.padding_px; + let mut w = relative.w - (defaults.padding_px * 2); + let mut h = relative.h - (defaults.padding_px * 2); + + if w < 1 { + w = 1; + } + if h < 1 { + h = 1; + } + if x < monitor_rect.x { + x = monitor_rect.x; + } + if y < monitor_rect.y { + y = monitor_rect.y; + } + PxRect { x, y, w, h } +} + +fn target_rects_for_pane( + count: usize, + pane: &PaneDef, + pane_rect: PxRect, + defaults: Defaults, +) -> Vec { + match pane.layout.kind { + PaneLayoutKind::Single => vec![pane_rect; count], + PaneLayoutKind::Grid => grid_rects( + count, + pane_rect, + pane.layout.preference.unwrap_or(GridPreference::Square), + defaults, + ), + } +} + +#[allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss +)] +fn grid_rects( + count: usize, + pane_rect: PxRect, + preference: GridPreference, + defaults: Defaults, +) -> Vec { + if count == 0 { + return Vec::new(); + } + + let count_f = count as f64; + let tall_bias = defaults.tall_bias.max(1.0); + let (rows, cols) = match preference { + GridPreference::Square => { + let cols = count_f.sqrt().ceil() as usize; + let rows = count.div_ceil(cols); + (rows, cols) + } + GridPreference::Tall => { + let rows = (count_f * tall_bias).sqrt().ceil() as usize; + let rows = rows.max(1); + let cols = count.div_ceil(rows); + (rows, cols) + } + GridPreference::Wide => { + let cols = (count_f * tall_bias).sqrt().ceil() as usize; + let cols = cols.max(1); + let rows = count.div_ceil(cols); + (rows, cols) + } + }; + + let gap = defaults.gap_px.max(0); + let cols_i32 = i32::try_from(cols).unwrap_or(1); + let rows_i32 = i32::try_from(rows).unwrap_or(1); + + let total_gap_w = gap * (cols_i32 - 1).max(0); + let total_gap_h = gap * (rows_i32 - 1).max(0); + let base_w = ((pane_rect.w - total_gap_w).max(1)) / cols_i32; + let base_h = ((pane_rect.h - total_gap_h).max(1)) / rows_i32; + + let mut rects = Vec::with_capacity(count); + for idx in 0..count { + let row = idx / cols; + let col = idx % cols; + + let col_idx_i32 = i32::try_from(col).unwrap_or(0); + let row_idx_i32 = i32::try_from(row).unwrap_or(0); + + let mut x = pane_rect.x + (col_idx_i32 * (base_w + gap)); + let mut y = pane_rect.y + (row_idx_i32 * (base_h + gap)); + let mut w = base_w; + let mut h = base_h; + + if col + 1 == cols { + w = (pane_rect.x + pane_rect.w - x).max(1); + } + if row + 1 == rows { + h = (pane_rect.y + pane_rect.h - y).max(1); + } + + if x < pane_rect.x { + x = pane_rect.x; + } + if y < pane_rect.y { + y = pane_rect.y; + } + + rects.push(PxRect { x, y, w, h }); + } + + rects +} + +#[cfg(test)] +mod tests { + use crate::model::{ContentRule, PaneLayout, RectDef, Scalar}; + + use super::*; + + fn test_layout() -> Layout { + Layout { + name: "dev".to_string(), + id: "dev".to_string(), + defaults: Defaults { + gap_px: 8, + padding_px: 8, + tall_bias: 1.35, + }, + panes: vec![ + PaneDef { + name: "left".to_string(), + rect: RectDef { + x: Scalar::Text("0%".to_string()), + y: Scalar::Text("0%".to_string()), + w: Scalar::Text("50%".to_string()), + h: Scalar::Text("100%".to_string()), + }, + content: ContentRule { + kind: ContentKind::PickOne, + pick: vec!["camera".to_string(), "ghostty".to_string()], + except_panes: vec![], + ensure: true, + ensure_min: None, + fallback: None, + match_terms: vec![], + }, + layout: PaneLayout { + kind: PaneLayoutKind::Single, + preference: None, + }, + }, + PaneDef { + name: "right".to_string(), + rect: RectDef { + x: Scalar::Text("50%".to_string()), + y: Scalar::Text("0%".to_string()), + w: Scalar::Text("50%".to_string()), + h: Scalar::Text("100%".to_string()), + }, + content: ContentRule { + kind: ContentKind::AllExcept, + pick: vec![], + except_panes: vec!["left".to_string()], + ensure: false, + ensure_min: Some(1), + fallback: Some("ghostty".to_string()), + match_terms: vec![], + }, + layout: PaneLayout { + kind: PaneLayoutKind::Grid, + preference: Some(GridPreference::Tall), + }, + }, + ], + } + } + + #[test] + fn plans_commands_with_minimal_churn() { + let layout = test_layout(); + let apps = AppsConfig { + ghostty_cmd: "ghostty".to_string(), + camera_classes: vec!["cheese".to_string()], + }; + let scope = Scope { + monitor_id: 0, + workspace_id: 1, + monitor_rect: PxRect { + x: 0, + y: 0, + w: 1200, + h: 800, + }, + }; + let clients = vec![ + Client { + address: "0x1".to_string(), + class: "cheese".to_string(), + title: "cam".to_string(), + monitor_id: 0, + workspace_id: 1, + floating: true, + x: 8, + y: 8, + w: 584, + h: 784, + }, + Client { + address: "0x2".to_string(), + class: "ghostty".to_string(), + title: "term".to_string(), + monitor_id: 0, + workspace_id: 1, + floating: false, + x: 0, + y: 0, + w: 100, + h: 100, + }, + ]; + + let plan = plan_layout(&layout, &apps, &clients, &scope); + assert!(plan.spawn_commands.is_empty()); + assert!( + plan.commands + .iter() + .any(|cmd| matches!(cmd, HyprCommand::SetFloating { address } if address == "0x2")) + ); + assert!( + plan.commands + .iter() + .all(|cmd| !matches!(cmd, HyprCommand::Move { address, .. } if address == "0x1")) + ); + assert!( + plan.commands + .iter() + .all(|cmd| !matches!(cmd, HyprCommand::Resize { address, .. } if address == "0x1")) + ); + } + + #[test] + fn plans_spawns_for_ensured_pane() { + let layout = test_layout(); + let scope = Scope { + monitor_id: 0, + workspace_id: 1, + monitor_rect: PxRect { + x: 0, + y: 0, + w: 1200, + h: 800, + }, + }; + + let apps = AppsConfig { + ghostty_cmd: "ghostty --new-window".to_string(), + camera_classes: vec!["obs".to_string()], + }; + let plan = plan_layout(&layout, &apps, &[], &scope); + + assert_eq!( + plan.spawn_commands, + vec!["ghostty --new-window", "ghostty --new-window"] + ); + } + + #[test] + fn grid_tall_pref_prefers_more_rows() { + let defaults = Defaults { + gap_px: 4, + padding_px: 0, + tall_bias: 1.35, + }; + let rects = grid_rects( + 5, + PxRect { + x: 0, + y: 0, + w: 1000, + h: 800, + }, + GridPreference::Tall, + defaults, + ); + + assert_eq!(rects.len(), 5); + let y_values = rects.iter().map(|item| item.y).collect::>(); + let x_values = rects.iter().map(|item| item.x).collect::>(); + assert!(y_values.len() >= x_values.len()); + } +} diff --git a/rs/hyprlayoutctl/src/error.rs b/rs/hyprlayoutctl/src/error.rs new file mode 100644 index 00000000..63f8c6ca --- /dev/null +++ b/rs/hyprlayoutctl/src/error.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to read file {path}: {source}")] + ReadFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to parse TOML {path}: {source}")] + ParseToml { + path: PathBuf, + #[source] + source: toml::de::Error, + }, + #[error("invalid layout `{name}`: {reason}")] + InvalidLayout { name: String, reason: String }, + #[error("layout `{name}` not found")] + LayoutNotFound { name: String }, + #[error("layout `{name}` is ambiguous: {sources}")] + AmbiguousLayout { name: String, sources: String }, + #[error("failed to parse shell-expanded path `{path}`")] + InvalidExpandedPath { path: String }, + #[error("hyprctl command failed: {detail}")] + Hyprctl { detail: String }, + #[error("hyprland environment is not ready: {detail}")] + HyprlandEnv { detail: String }, + #[error("internal error: {detail}")] + Internal { detail: String }, +} + +pub type Result = std::result::Result; diff --git a/rs/hyprlayoutctl/src/hypr.rs b/rs/hyprlayoutctl/src/hypr.rs new file mode 100644 index 00000000..8565dcc0 --- /dev/null +++ b/rs/hyprlayoutctl/src/hypr.rs @@ -0,0 +1,277 @@ +use std::io::{BufRead, BufReader}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::process::Command; +use std::sync::mpsc::{self, Receiver}; +use std::thread; + +use serde::Deserialize; + +use crate::engine::{Client, HyprCommand, Scope}; +use crate::error::{Error, Result}; +use crate::model::PxRect; + +pub trait Runtime { + /// Returns the currently focused monitor + active workspace scope. + /// + /// # Errors + /// Returns an error when Hyprland cannot provide monitor/workspace information. + fn scope(&self) -> Result; + /// Returns all known clients from Hyprland. + /// + /// # Errors + /// Returns an error when querying or parsing client data fails. + fn clients(&self) -> Result>; + /// Dispatches Hyprland commands. + /// + /// # Errors + /// Returns an error when command execution fails. + fn dispatch(&self, commands: &[HyprCommand], dry_run: bool, verbose: bool) -> Result<()>; + /// Spawns an application command. + /// + /// # Errors + /// Returns an error when process launch fails. + fn spawn(&self, command: &str, dry_run: bool, verbose: bool) -> Result<()>; + /// Subscribes to Hyprland event stream. + /// + /// # Errors + /// Returns an error when event socket connection fails. + fn subscribe_events(&self) -> Result>; +} + +#[derive(Debug, Default)] +pub struct HyprRuntime; + +impl Runtime for HyprRuntime { + fn scope(&self) -> Result { + let monitors = hyprctl_json::>(&["-j", "monitors"])?; + let focused = monitors + .into_iter() + .find(|item| item.focused) + .ok_or_else(|| Error::Hyprctl { + detail: "no focused monitor found".to_string(), + })?; + + Ok(Scope { + monitor_id: focused.id, + workspace_id: focused.active_workspace.id, + monitor_rect: PxRect { + x: focused.x, + y: focused.y, + w: focused.width, + h: focused.height, + }, + }) + } + + fn clients(&self) -> Result> { + let raw = hyprctl_json::>(&["-j", "clients"])?; + Ok(raw + .into_iter() + .map(|item| Client { + address: item.address, + class: item.class, + title: item.title, + monitor_id: item.monitor, + workspace_id: item.workspace.id, + floating: item.floating, + x: item.at.first().copied().unwrap_or_default(), + y: item.at.get(1).copied().unwrap_or_default(), + w: item.size.first().copied().unwrap_or(1), + h: item.size.get(1).copied().unwrap_or(1), + }) + .collect()) + } + + fn dispatch(&self, commands: &[HyprCommand], dry_run: bool, verbose: bool) -> Result<()> { + for command in commands { + let args = hypr_dispatch_args(command); + if verbose || dry_run { + println!("hyprctl {}", args.join(" ")); + } + if dry_run { + continue; + } + let status = Command::new("hyprctl") + .args(&args) + .status() + .map_err(|error| Error::Hyprctl { + detail: format!("failed to execute `hyprctl {}`: {error}", args.join(" ")), + })?; + if !status.success() { + return Err(Error::Hyprctl { + detail: format!("`hyprctl {}` failed with status {status}", args.join(" ")), + }); + } + } + Ok(()) + } + + fn spawn(&self, command: &str, dry_run: bool, verbose: bool) -> Result<()> { + if verbose || dry_run { + println!("spawn: {command}"); + } + if dry_run { + return Ok(()); + } + + let status = Command::new("sh") + .arg("-lc") + .arg(command) + .status() + .map_err(|error| Error::Hyprctl { + detail: format!("failed to spawn `{command}`: {error}"), + })?; + + if !status.success() { + return Err(Error::Hyprctl { + detail: format!("spawn command `{command}` failed with status {status}"), + }); + } + Ok(()) + } + + fn subscribe_events(&self) -> Result> { + let socket_path = hypr_event_socket_path()?; + let stream = UnixStream::connect(&socket_path).map_err(|error| Error::HyprlandEnv { + detail: format!( + "failed to connect to event socket {}: {error}", + socket_path.display() + ), + })?; + + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let reader = BufReader::new(stream); + for line in reader.lines() { + let Ok(line_text) = line else { + break; + }; + if tx.send(line_text).is_err() { + break; + } + } + }); + + Ok(rx) + } +} + +#[derive(Debug, Deserialize)] +struct MonitorRaw { + id: i32, + focused: bool, + x: i32, + y: i32, + width: i32, + height: i32, + #[serde(rename = "activeWorkspace")] + active_workspace: WorkspaceRaw, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceRaw { + id: i32, +} + +#[derive(Debug, Deserialize)] +struct ClientRaw { + address: String, + class: String, + title: String, + monitor: i32, + workspace: WorkspaceRaw, + floating: bool, + at: Vec, + size: Vec, +} + +fn hyprctl_json Deserialize<'de>>(args: &[&str]) -> Result { + let output = Command::new("hyprctl") + .args(args) + .output() + .map_err(|error| Error::Hyprctl { + detail: format!("failed to execute hyprctl {}: {error}", args.join(" ")), + })?; + + if !output.status.success() { + return Err(Error::Hyprctl { + detail: format!( + "hyprctl {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ), + }); + } + + serde_json::from_slice::(&output.stdout).map_err(|error| Error::Hyprctl { + detail: format!("failed to parse hyprctl json output: {error}"), + }) +} + +fn hypr_dispatch_args(command: &HyprCommand) -> Vec { + match command { + HyprCommand::SetFloating { address } => { + vec![ + "dispatch".to_string(), + "setfloating".to_string(), + format!("address:{address}"), + ] + } + HyprCommand::Move { address, x, y } => vec![ + "dispatch".to_string(), + "movewindowpixel".to_string(), + "exact".to_string(), + x.to_string(), + y.to_string(), + format!("address:{address}"), + ], + HyprCommand::Resize { address, w, h } => vec![ + "dispatch".to_string(), + "resizewindowpixel".to_string(), + "exact".to_string(), + w.to_string(), + h.to_string(), + format!("address:{address}"), + ], + } +} + +fn hypr_event_socket_path() -> Result { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR").map_err(|_| Error::HyprlandEnv { + detail: "XDG_RUNTIME_DIR is not set".to_string(), + })?; + let signature = + std::env::var("HYPRLAND_INSTANCE_SIGNATURE").map_err(|_| Error::HyprlandEnv { + detail: "HYPRLAND_INSTANCE_SIGNATURE is not set".to_string(), + })?; + Ok(PathBuf::from(runtime_dir) + .join("hypr") + .join(signature) + .join(".socket2.sock")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dispatch_serialization_is_correct() { + let move_cmd = HyprCommand::Move { + address: "0xabc".to_string(), + x: 100, + y: 200, + }; + assert_eq!( + hypr_dispatch_args(&move_cmd), + vec![ + "dispatch", + "movewindowpixel", + "exact", + "100", + "200", + "address:0xabc" + ] + ); + } +} diff --git a/rs/hyprlayoutctl/src/lib.rs b/rs/hyprlayoutctl/src/lib.rs new file mode 100644 index 00000000..d96d71f9 --- /dev/null +++ b/rs/hyprlayoutctl/src/lib.rs @@ -0,0 +1,11 @@ +pub mod app; +pub mod config; +pub mod engine; +pub mod error; +pub mod hypr; +pub mod model; +pub mod watch; + +pub use app::App; +pub use config::{Discovery, LayoutSource, ResolvedConfig}; +pub use error::{Error, Result}; diff --git a/rs/hyprlayoutctl/src/main.rs b/rs/hyprlayoutctl/src/main.rs new file mode 100644 index 00000000..fe429e93 --- /dev/null +++ b/rs/hyprlayoutctl/src/main.rs @@ -0,0 +1,83 @@ +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; + +use clap::{ArgAction, Parser, Subcommand}; + +use hyprlayoutctl::app::{App, ApplyOptions}; +use hyprlayoutctl::config::{load_resolved, resolve_layout}; +use hyprlayoutctl::error::Result; +use hyprlayoutctl::hypr::{HyprRuntime, Runtime}; +use hyprlayoutctl::watch::run_debounced; + +#[derive(Debug, Parser)] +#[command(author, version, about)] +struct Cli { + #[arg(long)] + config: Option, + #[arg(long = "layout-dir", action = ArgAction::Append)] + layout_dirs: Vec, + #[arg(long)] + dry_run: bool, + #[arg(long, short)] + verbose: bool, + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + Apply { layout: String }, + Watch { layout: String }, + List, + Validate { layout: Option }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +fn run(cli: Cli) -> Result<()> { + let resolved = load_resolved(cli.config, &cli.layout_dirs)?; + + let app = App { + runtime: HyprRuntime, + options: ApplyOptions { + dry_run: cli.dry_run, + verbose: cli.verbose, + }, + }; + + match cli.command { + Commands::Apply { layout } => app.apply_named_layout(&resolved, &layout), + Commands::List => { + app.list_layouts(&resolved); + Ok(()) + } + Commands::Validate { layout } => app.validate_layouts(&resolved, layout.as_deref()), + Commands::Watch { layout } => watch_mode(&app, &resolved, &layout), + } +} + +fn watch_mode( + app: &App, + resolved: &hyprlayoutctl::config::ResolvedConfig, + layout: &str, +) -> Result<()> { + let selected = resolve_layout(layout, resolved)?; + + app.apply_discovery(resolved, &selected)?; + let events = app.runtime.subscribe_events()?; + run_debounced(&events, Duration::from_millis(200), || { + app.apply_discovery(resolved, &selected) + .map_err(|error| error.to_string()) + }) + .map_err(|detail| hyprlayoutctl::error::Error::Internal { detail }) +} diff --git a/rs/hyprlayoutctl/src/model.rs b/rs/hyprlayoutctl/src/model.rs new file mode 100644 index 00000000..7ac34391 --- /dev/null +++ b/rs/hyprlayoutctl/src/model.rs @@ -0,0 +1,417 @@ +use serde::Deserialize; +use std::collections::{BTreeMap, HashSet}; + +use crate::error::{Error, Result}; + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ConfigFile { + #[serde(default)] + pub general: GeneralConfig, + #[serde(default)] + pub apps: AppsConfig, + #[serde(default)] + pub layouts: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LayoutFile { + #[serde(default)] + pub meta: LayoutMeta, + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub panes: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct LayoutMeta { + #[serde(default)] + pub name: String, + #[serde(default)] + pub id: String, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GeneralConfig { + #[serde(default = "default_layout_dirs")] + pub layout_dirs: Vec, +} + +fn default_layout_dirs() -> Vec { + vec!["~/.config/hyprlayoutctl/layouts".to_string()] +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct AppsConfig { + #[serde(default = "default_ghostty")] + pub ghostty_cmd: String, + #[serde(default)] + pub camera_classes: Vec, +} + +fn default_ghostty() -> String { + "ghostty".to_string() +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct LayoutBody { + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub panes: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PaneDef { + pub name: String, + pub rect: RectDef, + pub content: ContentRule, + pub layout: PaneLayout, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RectDef { + pub x: Scalar, + pub y: Scalar, + pub w: Scalar, + pub h: Scalar, +} + +#[derive(Debug, Clone, Copy, Deserialize, Default)] +pub struct Defaults { + #[serde(default)] + pub gap_px: i32, + #[serde(default)] + pub padding_px: i32, + #[serde(default = "default_tall_bias")] + pub tall_bias: f64, +} + +const fn default_tall_bias() -> f64 { + 1.35 +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ContentRule { + pub kind: ContentKind, + #[serde(default)] + pub pick: Vec, + #[serde(default)] + pub except_panes: Vec, + #[serde(default)] + pub ensure: bool, + #[serde(default)] + pub ensure_min: Option, + #[serde(default)] + pub fallback: Option, + #[serde(default, rename = "match")] + pub match_terms: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ContentKind { + PickOne, + All, + AllExcept, + Match, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PaneLayout { + pub kind: PaneLayoutKind, + #[serde(default)] + pub preference: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PaneLayoutKind { + Single, + Grid, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum GridPreference { + Tall, + Wide, + Square, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Scalar { + Int(i32), + Float(f64), + Text(String), +} + +#[derive(Debug, Clone)] +pub struct Layout { + pub name: String, + pub id: String, + pub defaults: Defaults, + pub panes: Vec, +} + +impl Layout { + /// Validates layout invariants. + /// + /// # Errors + /// Returns an error when pane names/rectangles/content rules are invalid. + pub fn validate(&self) -> Result<()> { + if self.panes.is_empty() { + return Err(Error::InvalidLayout { + name: self.name.clone(), + reason: "layout has no panes".to_string(), + }); + } + + let mut pane_names = HashSet::new(); + for pane in &self.panes { + if pane.name.trim().is_empty() { + return Err(Error::InvalidLayout { + name: self.name.clone(), + reason: "pane name must not be empty".to_string(), + }); + } + if !pane_names.insert(pane.name.clone()) { + return Err(Error::InvalidLayout { + name: self.name.clone(), + reason: format!("duplicate pane name `{}`", pane.name), + }); + } + if pane.content.kind == ContentKind::PickOne && pane.content.pick.is_empty() { + return Err(Error::InvalidLayout { + name: self.name.clone(), + reason: format!("pane `{}` pick_one requires non-empty `pick`", pane.name), + }); + } + if pane.content.kind == ContentKind::Match && pane.content.match_terms.is_empty() { + return Err(Error::InvalidLayout { + name: self.name.clone(), + reason: format!("pane `{}` match requires non-empty `match`", pane.name), + }); + } + pane.rect.validate(&self.name, &pane.name)?; + } + + let sample = self + .panes + .iter() + .map(|pane| { + pane.rect + .to_px(10_000, 10_000) + .map(|r| (pane.name.clone(), r)) + }) + .collect::>>()?; + + for (idx, (left_name, left)) in sample.iter().enumerate() { + for (right_name, right) in sample.iter().skip(idx + 1) { + if left.overlaps(*right) { + return Err(Error::InvalidLayout { + name: self.name.clone(), + reason: format!("panes `{left_name}` and `{right_name}` overlap"), + }); + } + } + } + Ok(()) + } +} + +impl RectDef { + /// Validates a rectangle definition in the context of a pane. + /// + /// # Errors + /// Returns an error when resulting rectangle dimensions are invalid. + pub fn validate(&self, layout_name: &str, pane_name: &str) -> Result<()> { + let rect = self.to_px(10_000, 10_000)?; + if rect.w <= 0 || rect.h <= 0 { + return Err(Error::InvalidLayout { + name: layout_name.to_string(), + reason: format!("pane `{pane_name}` has non-positive size"), + }); + } + Ok(()) + } + + /// Resolves a rectangle from relative/pixel units to absolute pixels. + /// + /// # Errors + /// Returns an error when any scalar is invalid. + pub fn to_px(&self, width: i32, height: i32) -> Result { + Ok(PxRect { + x: self.x.resolve(width)?, + y: self.y.resolve(height)?, + w: self.w.resolve(width)?, + h: self.h.resolve(height)?, + }) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct PxRect { + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +impl PxRect { + #[must_use] + pub const fn overlaps(self, other: Self) -> bool { + let self_right = self.x + self.w; + let self_bottom = self.y + self.h; + let other_right = other.x + other.w; + let other_bottom = other.y + other.h; + + self.x < other_right + && self_right > other.x + && self.y < other_bottom + && self_bottom > other.y + } +} + +impl Scalar { + /// Resolves scalar value against a total pixel size. + /// + /// # Errors + /// Returns an error when text scalar parsing fails. + #[allow(clippy::cast_possible_truncation)] + pub fn resolve(&self, total: i32) -> Result { + match self { + Self::Int(value) => Ok(*value), + Self::Float(value) => Ok(*value as i32), + Self::Text(text) => parse_scalar_text(text, total), + } + } +} + +#[allow(clippy::cast_possible_truncation)] +fn parse_scalar_text(text: &str, total: i32) -> Result { + let trimmed = text.trim(); + if let Some(raw_percent) = trimmed.strip_suffix('%') { + let percent = raw_percent + .trim() + .parse::() + .map_err(|_| Error::InvalidLayout { + name: "".to_string(), + reason: format!("invalid percentage value `{trimmed}`"), + })?; + Ok(((f64::from(total) * percent) / 100.0).round() as i32) + } else { + trimmed.parse::().map_err(|_| Error::InvalidLayout { + name: "".to_string(), + reason: format!("invalid numeric value `{trimmed}`"), + }) + } +} + +impl ConfigFile { + #[must_use] + pub fn inline_layouts(&self) -> Vec { + self.layouts + .iter() + .map(|(name, body)| Layout { + name: name.clone(), + id: name.clone(), + defaults: body.defaults, + panes: body.panes.clone(), + }) + .collect() + } +} + +impl LayoutFile { + #[must_use] + pub fn into_layout(self, fallback_name: &str) -> Layout { + let name = if self.meta.name.trim().is_empty() { + fallback_name.to_string() + } else { + self.meta.name + }; + let id = if self.meta.id.trim().is_empty() { + name.clone() + } else { + self.meta.id + }; + + Layout { + name, + id, + defaults: self.defaults, + panes: self.panes, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_percent_scalar() { + let value = Scalar::Text("25%".to_string()).resolve(800).unwrap(); + assert_eq!(value, 200); + } + + #[test] + fn rejects_overlapping_panes() { + let layout = Layout { + name: "test".to_string(), + id: "test".to_string(), + defaults: Defaults::default(), + panes: vec![ + PaneDef { + name: "a".to_string(), + rect: RectDef { + x: Scalar::Text("0%".to_string()), + y: Scalar::Text("0%".to_string()), + w: Scalar::Text("60%".to_string()), + h: Scalar::Text("100%".to_string()), + }, + content: ContentRule { + kind: ContentKind::All, + pick: vec![], + except_panes: vec![], + ensure: false, + ensure_min: None, + fallback: None, + match_terms: vec![], + }, + layout: PaneLayout { + kind: PaneLayoutKind::Single, + preference: None, + }, + }, + PaneDef { + name: "b".to_string(), + rect: RectDef { + x: Scalar::Text("50%".to_string()), + y: Scalar::Text("0%".to_string()), + w: Scalar::Text("50%".to_string()), + h: Scalar::Text("100%".to_string()), + }, + content: ContentRule { + kind: ContentKind::All, + pick: vec![], + except_panes: vec![], + ensure: false, + ensure_min: None, + fallback: None, + match_terms: vec![], + }, + layout: PaneLayout { + kind: PaneLayoutKind::Single, + preference: None, + }, + }, + ], + }; + + let error = layout.validate().unwrap_err(); + assert!(format!("{error}").contains("overlap")); + } +} diff --git a/rs/hyprlayoutctl/src/watch.rs b/rs/hyprlayoutctl/src/watch.rs new file mode 100644 index 00000000..ad052e01 --- /dev/null +++ b/rs/hyprlayoutctl/src/watch.rs @@ -0,0 +1,99 @@ +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +/// Runs a debounce loop and invokes `on_apply` once event bursts settle. +/// +/// # Errors +/// Returns an error when the callback reports one. +pub fn run_debounced( + events: &Receiver, + debounce: Duration, + mut on_apply: F, +) -> Result<(), String> +where + F: FnMut() -> Result<(), String>, +{ + let mut pending = false; + let mut last_signal: Option = None; + let poll_interval = std::cmp::min(Duration::from_millis(25), debounce); + + loop { + match events.recv_timeout(poll_interval) { + Ok(event) => { + if is_relevant_event(&event) { + pending = true; + last_signal = Some(Instant::now()); + } + } + Err(RecvTimeoutError::Timeout) => { + if pending && last_signal.is_some_and(|point| point.elapsed() >= debounce) { + pending = false; + on_apply()?; + } + } + Err(RecvTimeoutError::Disconnected) => { + if pending { + on_apply()?; + } + return Ok(()); + } + } + } +} + +#[must_use] +pub fn is_relevant_event(event: &str) -> bool { + [ + "openwindow>>", + "closewindow>>", + "movewindow>>", + "workspace>>", + "activewindow>>", + "changefloatingmode>>", + ] + .iter() + .any(|prefix| event.starts_with(prefix)) +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::Duration; + + use super::*; + + #[test] + fn debounce_coalesces_bursty_events() { + let (tx, rx) = mpsc::channel(); + let count = Arc::new(Mutex::new(0_usize)); + let count_clone = Arc::clone(&count); + + let worker = thread::spawn(move || { + run_debounced(&rx, Duration::from_millis(50), || { + { + let mut lock = count_clone.lock().unwrap(); + *lock += 1; + } + Ok(()) + }) + }); + + tx.send("openwindow>>abc".to_string()).unwrap(); + tx.send("movewindow>>abc".to_string()).unwrap(); + thread::sleep(Duration::from_millis(90)); + tx.send("workspace>>1".to_string()).unwrap(); + thread::sleep(Duration::from_millis(90)); + drop(tx); + + worker.join().unwrap().unwrap(); + assert_eq!(*count.lock().unwrap(), 2); + } + + #[test] + fn ignores_irrelevant_events() { + assert!(!is_relevant_event("monitoradded>>DP-1")); + assert!(is_relevant_event("workspace>>2")); + } +} diff --git a/sessions/2026-02-20-hyprlayoutctl-crate.md b/sessions/2026-02-20-hyprlayoutctl-crate.md new file mode 100644 index 00000000..1337dd28 --- /dev/null +++ b/sessions/2026-02-20-hyprlayoutctl-crate.md @@ -0,0 +1,46 @@ +## Task + +Implement `hyprlayoutctl` Rust crate with CLI, layout discovery/resolution, layout engine, Hyprland IPC adapter, watch mode debounce behavior, and thorough tests. + +## Work Log + +- Added a new workspace member crate at `rs/hyprlayoutctl` and wired it into `rs/Cargo.toml`. +- Implemented the CLI with subcommands: `apply`, `watch`, `list`, and `validate`. +- Implemented global flags: `--config`, repeatable `--layout-dir`, `--dry-run`, and `--verbose`. +- Added config parsing for inline layouts in `config.toml` and standalone `*.layout` files. +- Implemented layout resolution with path-first lookup and ambiguity detection. +- Implemented deterministic pane assignment and arrangement (`single` + `grid` with preference support). +- Implemented Hyprland runtime via `hyprctl` JSON/dispatch and event-socket subscription. +- Implemented debounced watch re-apply behavior for workspace/window events. +- Added extensive unit tests across parsing, resolution, planning/idempotence expectations, debounce behavior, and app flow. + +## Decisions and Reasoning + +- Chose an internal engine plan model (`Plan`, `HyprCommand`) to separate pure planning logic from side-effecting Hypr IPC. +- Chose path-first layout resolution exactly per spec, then searched inline/file-discovered layouts by key/name/stem. +- Ensured idempotence by only emitting move/resize/floating commands when current client state differs from desired state. +- Used scoped clients only (focused monitor + active workspace) in v1. +- Kept v1 floating-only and no-overlap validation. + +## Bugs Encountered and Fixes + +- Strict clippy profile failures (`missing_errors_doc`, cast lints, `items_after_test_module`, etc.) blocked `just check`: + - Added targeted rustdoc `# Errors` docs on public `Result` APIs. + - Added focused `#[allow(...)]` only for unavoidable float/int conversion and one style placement lint. + - Fixed one nursery lint by replacing `unwrap_or(...)` with `unwrap_or_else(...)`. +- Engine test expectation mismatch for spawn behavior: + - Updated spawn resolution so `camera` is treated as non-spawnable alias and `pick_one` selects the first spawnable token. +- Debounce test flakiness due coarse poll interval and disconnect timing: + - Reduced watch-loop poll interval to `min(25ms, debounce)`. + - Ensured pending changes flush once on channel disconnect. + +## Learning Points + +- Modeling assignment and arrangement as pure functions makes watch-mode re-application and testing straightforward. +- Re-running client discovery after spawn is required to make ensure/ensure_min behavior visible in the same apply cycle. + +## Validation Checklist + +- Completed: `just nice` +- Completed: `just check` +- Completed: `cargo test --manifest-path rs/Cargo.toml -p hyprlayoutctl`