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.
This commit is contained in:
Kasper Nyhus 2025-08-20 22:52:46 +02:00
parent 980bb139e9
commit ac08b1c312
7 changed files with 558 additions and 22 deletions

183
Cargo.lock generated
View file

@ -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"

View file

@ -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"

21
docs/example_layout.toml Normal file
View file

@ -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

View file

@ -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

260
src/config.rs Normal file
View file

@ -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<Column>,
pub rules: Vec<MatchRule>,
#[serde(default)]
pub defaults: Option<Defaults>,
}
#[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<String>,
}
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<Self> {
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<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
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::<Vec<_>>()
));
}
}
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<String>,
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());
}
}

View file

@ -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,

View file

@ -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<Option<Config>>,
}
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::<String>("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.