From ac08b1c31220d40c15d6aecb54e8dd99c72213f4 Mon Sep 17 00:00:00 2001 From: Kasper Nyhus Date: Wed, 20 Aug 2025 22:52:46 +0200 Subject: [PATCH] feat: add TOML configuration for node layout Add custom column based layout configuration using simple glob style pattern matching on node names. It will look for configuration files located in ~/.config/helvum or in the cwd (which takes precedence). The configuration is validated before being used. --- Cargo.lock | 183 ++++++++++++++++++++++-- Cargo.toml | 3 + docs/example_layout.toml | 21 +++ docs/layout_configuration.md | 76 ++++++++++ src/config.rs | 260 +++++++++++++++++++++++++++++++++++ src/main.rs | 3 +- src/ui/graph/graph_view.rs | 34 +++-- 7 files changed, 558 insertions(+), 22 deletions(-) create mode 100644 docs/example_layout.toml create mode 100644 docs/layout_configuration.md create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index eabb933..53123e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,27 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "either" version = "1.10.0" @@ -335,6 +356,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gio" version = "0.19.3" @@ -363,7 +395,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -560,6 +592,7 @@ name = "helvum" version = "0.5.1" dependencies = [ "async-channel", + "dirs", "glib", "libadwaita", "libc", @@ -567,6 +600,8 @@ dependencies = [ "natord", "once_cell", "pipewire", + "serde", + "toml 0.8.2", ] [[package]] @@ -634,9 +669,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.148" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libloading" @@ -648,6 +683,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "libspa" version = "0.8.0" @@ -736,6 +781,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "pango" version = "0.19.3" @@ -863,6 +914,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.9.5" @@ -994,7 +1056,7 @@ dependencies = [ "cfg-expr", "heck 0.4.1", "pkg-config", - "toml", + "toml 0.7.8", "version-compare", ] @@ -1036,6 +1098,18 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.20.2", +] + [[package]] name = "toml_datetime" version = "0.6.5" @@ -1058,6 +1132,19 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "toml_edit" version = "0.21.1" @@ -1099,6 +1186,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "winapi" version = "0.3.9" @@ -1121,13 +1214,37 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -1136,51 +1253,93 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.4" diff --git a/Cargo.toml b/Cargo.toml index a8d2599..431253f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ async-channel = "2.2" log = "0.4.11" once_cell = "1.19" +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +dirs = "5.0" libc = "0.2" natord = "1.0.9" diff --git a/docs/example_layout.toml b/docs/example_layout.toml new file mode 100644 index 0000000..6825016 --- /dev/null +++ b/docs/example_layout.toml @@ -0,0 +1,21 @@ +# Example Helvum Configuration + +[layout] +columns = [ + { x = 20, name = "sources" }, + { x = 500, name = "filters" }, + { x = 950, name = "apps" }, + { x = 1200, name = "sinks" } +] + +rules = [ + { pattern = "Firefox*", column = "sources" }, + { pattern = "vlc*", column = "sources" }, + { pattern = "Built-in*", column = "sources", node_type = "source" }, + { pattern = "Built-in*", column = "sinks", node_type = "sink" }, +] + +[layout.defaults] +source = 20 +other = 500 +sink = 1200 diff --git a/docs/layout_configuration.md b/docs/layout_configuration.md new file mode 100644 index 0000000..4271100 --- /dev/null +++ b/docs/layout_configuration.md @@ -0,0 +1,76 @@ +# Layout Configuration + +This feature allows you to customize node placement in Helvum using a configuration file with column-based layout and pattern matching. + +## Configuration File Format + +The configuration uses TOML format with three main sections: + +### 1. Columns Section +Define columns with their X positions: +```toml +[layout] +columns = [ + { x = 50.0, name = "sources" }, + { x = 300.0, name = "filters" }, + { x = 550.0, name = "apps" }, + { x = 800.0, name = "sinks" } +] +``` + +### 2. Rules Section +Pattern matching rules (processed in order, first match wins): +```toml +[[layout.rules]] +pattern = "Firefox*" # Simple glob patterns +column = "apps" +node_type = "filter" # Optional: restrict to specific node types + +[[layout.rules]] +pattern = "*Microphone*" +column = "sources" +node_type = "source" # Optional: only match source nodes + +[[layout.rules]] +pattern = "*sink*" # Case-insensitive matching +column = "sinks" +``` + +**Supported Patterns:** +- `*` - matches any number of characters +- `?` - matches exactly one character +- Case-insensitive matching + +**Node Type Constraints (optional):** +- `"source"` - matches NodeType::Output nodes only +- `"sink"` - matches NodeType::Input nodes only +- `"filter"` - matches nodes that are neither input nor output + +### 3. Defaults Section +Default column positions when no pattern matches: +```toml +[layout.defaults] +sink = 800.0 +source = 50.0 +other = 300.0 +``` + +## Usage + +### Standard Locations +The application will automatically look for configuration files in this order: +1. `./layout.toml` (current working directory where helvum is launched) +2. `~/.config/helvum/layout.toml` (user config directory) + + +## Example Configuration + +See `example_layout.toml` for a complete example with common audio application patterns. + +## Validation Requirements + +- Layout must contain at least one column +- Layout must contain at least one rule +- All rule column references must exist in the columns list +- Columns must be spaced at least `COLUMN_WIDTH` pixels apart to prevent overlap +- Invalid configurations will log warnings and fall back to original behavior diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..3682fca --- /dev/null +++ b/src/config.rs @@ -0,0 +1,260 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +use crate::NodeType; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Config { + pub layout: Layout, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Layout { + pub columns: Vec, + pub rules: Vec, + #[serde(default)] + pub defaults: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Defaults { + pub source: f32, + pub sink: f32, + pub other: f32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Column { + pub x: f32, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MatchRule { + pub pattern: String, + pub column: String, + pub node_type: Option, +} + +impl Config { + /// Create a new Config instance and load from default locations + pub fn new() -> Self { + match Self::load() { + Some(config) => config, + None => { + log::info!("No configuration file found"); + Config::default() + } + } + } + + pub fn load() -> Option { + let paths = [ + Some(std::path::PathBuf::from("layout.toml")), + dirs::config_dir().map(|mut p| { + p.push("helvum"); + p.push("layout.toml"); + p + }), + ]; + + for path in paths.into_iter().flatten() { + log::debug!( + "trying to load configuration from '{}'", + path.canonicalize().unwrap_or(path.clone()).display() + ); + if path.exists() { + match Self::load_from_file(&path) { + Ok(config) => { + log::info!( + "Loaded configuration from '{}'", + path.canonicalize().unwrap_or(path).display() + ); + return Some(config); + } + Err(e) => { + log::warn!( + "Failed to load configuration from {}: {}", + path.display(), + e + ); + } + } + } + } + None + } + + fn load_from_file>(path: P) -> Result> { + let content = fs::read_to_string(path)?; + let config: Config = toml::from_str(&content)?; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> Result<(), String> { + if self.layout.columns.is_empty() { + return Err("Layout must contain at least one column".to_string()); + } + + if self.layout.rules.is_empty() { + return Err( + "Layout must contain at least one rule to assign nodes to columns".to_string(), + ); + } + + let column_names: std::collections::HashSet<_> = + self.layout.columns.iter().map(|c| &c.name).collect(); + + for rule in &self.layout.rules { + if !column_names.contains(&rule.column) { + return Err(format!( + "Rule references unknown column '{}'. Available columns: {:?}", + rule.column, + column_names.iter().collect::>() + )); + } + } + + let mut sorted_columns = self.layout.columns.clone(); + sorted_columns.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal)); + + for window in sorted_columns.windows(2) { + let distance = (window[1].x - window[0].x).abs(); + if distance < 50.0 { + return Err(format!( + "Columns '{}' (x={}) and '{}' (x={}) are too close together. Minimum distance should be {} pixels.", + window[0].name, window[0].x, + window[1].name, window[1].x, + 50.0 + )); + } + } + Ok(()) + } + + /// Get column position for a node, trying rules first, then defaults + pub fn get_column( + &self, + node_name: &str, + node_type: Option<&NodeType>, + ) -> Option<(f32, String)> { + for rule in &self.layout.rules { + if glob_match(&rule.pattern, node_name) + && self.node_type_matches(&rule.node_type, node_type) + { + for column in &self.layout.columns { + if column.name == rule.column { + return Some((column.x, column.name.clone())); + } + } + } + } + self.get_defaults_column(node_type) + } + + fn get_defaults_column(&self, node_type: Option<&NodeType>) -> Option<(f32, String)> { + if let Some(defaults) = &self.layout.defaults { + match node_type { + Some(NodeType::Output) => Some((defaults.source, "source".to_string())), + Some(NodeType::Input) => Some((defaults.sink, "sink".to_string())), + None => Some((defaults.other, "other".to_string())), + } + } else { + None + } + } + + fn node_type_matches( + &self, + rule_node_type: &Option, + node_type: Option<&NodeType>, + ) -> bool { + match rule_node_type.as_deref() { + None => true, // Rule has no type constraint, matches any node + Some("source") => node_type == Some(&NodeType::Output), + Some("sink") => node_type == Some(&NodeType::Input), + Some("filter") => node_type.is_none(), + Some(unknown_type) => { + log::warn!("Unknown node type in rule: {}", unknown_type); + false + } + } + } +} + +fn glob_match(pattern: &str, text: &str) -> bool { + glob_match_recursive(pattern, text) +} + +fn glob_match_recursive(pattern: &str, text: &str) -> bool { + match (pattern.chars().next(), text.chars().next()) { + (None, None) => true, + (Some('*'), _) => { + if glob_match_recursive(&pattern[1..], text) { + return true; + } + if text.chars().next().is_some() { + glob_match_recursive(pattern, &text[1..]) + } else { + false + } + } + (Some('?'), Some(_)) => glob_match_recursive(&pattern[1..], &text[1..]), + (Some(p), Some(t)) if p.eq_ignore_ascii_case(&t) => { + glob_match_recursive(&pattern[1..], &text[1..]) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_glob_match() { + assert!(glob_match("hello", "hello")); + assert!(glob_match("hello*", "hello")); + assert!(glob_match("hello*", "hello world")); + assert!(glob_match("*world", "hello world")); + assert!(glob_match("*", "anything")); + assert!(glob_match("h?llo", "hello")); + assert!(glob_match("h?llo", "hallo")); + assert!(!glob_match("hello", "world")); + assert!(!glob_match("h?llo", "hllo")); + assert!(glob_match("FIREFOX*", "firefox browser")); + assert!(glob_match("firefox*", "FIREFOX BROWSER")); + } + + #[test] + fn test_config_validation() { + let config = Config { + layout: Layout { + columns: vec![ + Column { + x: 50.0, + name: "sources".to_string(), + }, + Column { + x: 300.0, + name: "apps".to_string(), + }, + ], + rules: vec![MatchRule { + pattern: "Firefox*".to_string(), + column: "apps".to_string(), + node_type: None, + }], + defaults: Some(Defaults { + source: 50.0, + sink: 300.0, + other: 550.0, + }), + }, + }; + + assert!(config.validate().is_ok()); + } +} diff --git a/src/main.rs b/src/main.rs index 2af5368..21cf90b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ // SPDX-License-Identifier: GPL-3.0-only mod application; +mod config; mod graph_manager; mod pipewire_connection; mod ui; @@ -86,7 +87,7 @@ pub enum PipewireMessage { Disconnected, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum NodeType { Input, Output, diff --git a/src/ui/graph/graph_view.rs b/src/ui/graph/graph_view.rs index 6d31149..3eecf60 100644 --- a/src/ui/graph/graph_view.rs +++ b/src/ui/graph/graph_view.rs @@ -29,7 +29,7 @@ use adw::{ use std::cmp::Ordering; use super::{Link, Node, Port}; -use crate::NodeType; +use crate::{config::Config, NodeType}; const CANVAS_SIZE: f64 = 5000.0; @@ -96,6 +96,9 @@ mod imp { // This keeps track of an ongoing move view gesture. pub move_view_state: Cell<(f64, f64)>, + + // Cache configuration from file + pub config: RefCell>, } impl Default for GraphView { @@ -112,6 +115,7 @@ mod imp { zoom_gesture_initial_zoom: Default::default(), zoom_gesture_anchor: Default::default(), move_view_state: Default::default(), + config: Default::default(), } } } @@ -731,6 +735,17 @@ impl GraphView { glib::Object::new() } + /// Get the cached configuration, creating it if not already cached + fn get_config(&self) -> Config { + let mut cached = self.imp().config.borrow_mut(); + + if cached.is_none() { + *cached = Some(Config::new()); + } + + cached.as_ref().unwrap().clone() + } + pub fn zoom_factor(&self) -> f64 { self.property("zoom-factor") } @@ -777,14 +792,15 @@ impl GraphView { let imp = self.imp(); node.set_parent(self); - // Place widgets in colums of 3, growing down - let x = if let Some(node_type) = node_type { - match node_type { - NodeType::Output => 20.0, - NodeType::Input => 820.0, - } - } else { - 420.0 + let node_name = node.property::("node-name"); + + let x = match self.get_config().get_column(&node_name, node_type.as_ref()) { + Some((x_from_config, _)) => x_from_config, + None => match node_type { + Some(NodeType::Output) => 20.0, + Some(NodeType::Input) => 820.0, + None => 420.0, + }, }; // FIXME: We are currently using a constant 120 for the height of the nodes, this could cause problems for nodes with a lot of ports.