use std::path::PathBuf; use std::str::FromStr; use bitflags::bitflags; use directories::ProjectDirs; use miette::{miette, Context, IntoDiagnostic}; use smithay::input::keyboard::keysyms::KEY_NoSymbol; use smithay::input::keyboard::xkb::{keysym_from_name, KEYSYM_CASE_INSENSITIVE}; use smithay::input::keyboard::Keysym; #[derive(knuffel::Decode, Debug, PartialEq)] pub struct Config { #[knuffel(child, default)] pub input: Input, #[knuffel(children(name = "output"))] pub outputs: Vec, #[knuffel(children(name = "spawn-at-startup"))] pub spawn_at_startup: Vec, #[knuffel(child, default)] pub focus_ring: FocusRing, #[knuffel(child, default)] pub prefer_no_csd: bool, #[knuffel(child, default)] pub cursor: Cursor, #[knuffel(child, unwrap(children), default)] pub preset_column_widths: Vec, #[knuffel(child, unwrap(argument), default = 16)] pub gaps: u16, #[knuffel(child, default)] pub binds: Binds, #[knuffel(child, default)] pub debug: DebugConfig, } // FIXME: Add other devices. #[derive(knuffel::Decode, Debug, Default, PartialEq)] pub struct Input { #[knuffel(child, default)] pub keyboard: Keyboard, #[knuffel(child, default)] pub touchpad: Touchpad, #[knuffel(child, default)] pub tablet: Tablet, } #[derive(knuffel::Decode, Debug, Default, PartialEq, Eq)] pub struct Keyboard { #[knuffel(child, default)] pub xkb: Xkb, // The defaults were chosen to match wlroots and sway. #[knuffel(child, unwrap(argument), default = 600)] pub repeat_delay: u16, #[knuffel(child, unwrap(argument), default = 25)] pub repeat_rate: u8, } #[derive(knuffel::Decode, Debug, Default, PartialEq, Eq)] pub struct Xkb { #[knuffel(child, unwrap(argument), default)] pub rules: String, #[knuffel(child, unwrap(argument), default)] pub model: String, #[knuffel(child, unwrap(argument))] pub layout: Option, #[knuffel(child, unwrap(argument), default)] pub variant: String, #[knuffel(child, unwrap(argument))] pub options: Option, } // FIXME: Add the rest of the settings. #[derive(knuffel::Decode, Debug, Default, PartialEq)] pub struct Touchpad { #[knuffel(child)] pub tap: bool, #[knuffel(child)] pub natural_scroll: bool, #[knuffel(child, unwrap(argument), default)] pub accel_speed: f64, } #[derive(knuffel::Decode, Debug, Default, PartialEq)] pub struct Tablet { #[knuffel(child, unwrap(argument))] pub map_to_output: Option, } #[derive(knuffel::Decode, Debug, Clone, PartialEq)] pub struct Output { #[knuffel(argument)] pub name: String, #[knuffel(child, unwrap(argument), default = 1.)] pub scale: f64, #[knuffel(child)] pub position: Option, #[knuffel(child, unwrap(argument, str))] pub mode: Option, } impl Default for Output { fn default() -> Self { Self { name: String::new(), scale: 1., position: None, mode: None, } } } #[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)] pub struct Position { #[knuffel(property)] pub x: i32, #[knuffel(property)] pub y: i32, } #[derive(Debug, Clone, PartialEq)] pub struct Mode { pub width: u16, pub height: u16, pub refresh: Option, } #[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)] pub struct SpawnAtStartup { #[knuffel(arguments)] pub command: Vec, } #[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)] pub struct FocusRing { #[knuffel(child)] pub off: bool, #[knuffel(child, unwrap(argument), default = 4)] pub width: u16, #[knuffel(child, default = Color::new(0.5, 0.8, 1.0, 1.0))] pub active_color: Color, #[knuffel(child, default = Color::new(0.3, 0.3, 0.3, 1.0))] pub inactive_color: Color, } impl Default for FocusRing { fn default() -> Self { Self { off: false, width: 4, active_color: Color::new(0.5, 0.8, 1.0, 1.0), inactive_color: Color::new(0.3, 0.3, 0.3, 1.0), } } } #[derive(knuffel::Decode, Debug, Default, Clone, Copy, PartialEq)] pub struct Color { #[knuffel(argument)] pub r: f32, #[knuffel(argument)] pub g: f32, #[knuffel(argument)] pub b: f32, #[knuffel(argument)] pub a: f32, } impl Color { pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { Self { r, g, b, a } } } impl From for [f32; 4] { fn from(c: Color) -> Self { [c.r, c.g, c.b, c.a] } } #[derive(knuffel::Decode, Debug, PartialEq)] pub struct Cursor { #[knuffel(child, unwrap(argument), default = String::from("default"))] pub xcursor_theme: String, #[knuffel(child, unwrap(argument), default = 24)] pub xcursor_size: u8, } impl Default for Cursor { fn default() -> Self { Self { xcursor_theme: String::from("default"), xcursor_size: 24, } } } #[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)] pub enum PresetWidth { Proportion(#[knuffel(argument)] f64), Fixed(#[knuffel(argument)] i32), } #[derive(knuffel::Decode, Debug, Default, PartialEq)] pub struct Binds(#[knuffel(children)] pub Vec); #[derive(knuffel::Decode, Debug, PartialEq)] pub struct Bind { #[knuffel(node_name)] pub key: Key, #[knuffel(children)] pub actions: Vec, } #[derive(Debug, PartialEq, Eq)] pub struct Key { pub keysym: Keysym, pub modifiers: Modifiers, } bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Modifiers : u8 { const CTRL = 1; const SHIFT = 2; const ALT = 4; const SUPER = 8; const COMPOSITOR = 16; } } #[derive(knuffel::Decode, Debug, Clone, PartialEq)] pub enum Action { #[knuffel(skip)] None, Quit, #[knuffel(skip)] ChangeVt(i32), Suspend, ToggleDebugTint, Spawn(#[knuffel(arguments)] Vec), Screenshot, CloseWindow, FullscreenWindow, FocusColumnLeft, FocusColumnRight, FocusWindowDown, FocusWindowUp, MoveColumnLeft, MoveColumnRight, MoveWindowDown, MoveWindowUp, ConsumeWindowIntoColumn, ExpelWindowFromColumn, FocusWorkspaceDown, FocusWorkspaceUp, FocusWorkspace(#[knuffel(argument)] u8), MoveWindowToWorkspaceDown, MoveWindowToWorkspaceUp, MoveWindowToWorkspace(#[knuffel(argument)] u8), FocusMonitorLeft, FocusMonitorRight, FocusMonitorDown, FocusMonitorUp, MoveWindowToMonitorLeft, MoveWindowToMonitorRight, MoveWindowToMonitorDown, MoveWindowToMonitorUp, SwitchPresetColumnWidth, MaximizeColumn, SetColumnWidth(#[knuffel(argument, str)] SizeChange), } #[derive(Debug, Clone, Copy, PartialEq)] pub enum SizeChange { SetFixed(i32), SetProportion(f64), AdjustFixed(i32), AdjustProportion(f64), } #[derive(knuffel::Decode, Debug, PartialEq)] pub struct DebugConfig { #[knuffel(child, unwrap(argument), default = 1.)] pub animation_slowdown: f64, #[knuffel(child)] pub dbus_interfaces_in_non_session_instances: bool, #[knuffel(child)] pub wait_for_frame_completion_before_queueing: bool, #[knuffel(child)] pub enable_color_transformations_capability: bool, #[knuffel(child)] pub enable_overlay_planes: bool, } impl Default for DebugConfig { fn default() -> Self { Self { animation_slowdown: 1., dbus_interfaces_in_non_session_instances: false, wait_for_frame_completion_before_queueing: false, enable_color_transformations_capability: false, enable_overlay_planes: false, } } } impl Config { pub fn load(path: Option) -> miette::Result<(Self, PathBuf)> { let path = if let Some(path) = path { path } else { let mut path = ProjectDirs::from("", "", "niri") .ok_or_else(|| miette!("error retrieving home directory"))? .config_dir() .to_owned(); path.push("config.kdl"); path }; let contents = std::fs::read_to_string(&path) .into_diagnostic() .with_context(|| format!("error reading {path:?}"))?; let config = Self::parse("config.kdl", &contents).context("error parsing")?; debug!("loaded config from {path:?}"); Ok((config, path)) } pub fn parse(filename: &str, text: &str) -> Result { knuffel::parse(filename, text) } } impl Default for Config { fn default() -> Self { Config::parse( "default-config.kdl", include_str!("../resources/default-config.kdl"), ) .unwrap() } } impl FromStr for Mode { type Err = miette::Error; fn from_str(s: &str) -> Result { let Some((width, rest)) = s.split_once('x') else { return Err(miette!("no 'x' separator found")); }; let (height, refresh) = match rest.split_once('@') { Some((height, refresh)) => (height, Some(refresh)), None => (rest, None), }; let width = width .parse() .into_diagnostic() .context("error parsing width")?; let height = height .parse() .into_diagnostic() .context("error parsing height")?; let refresh = refresh .map(str::parse) .transpose() .into_diagnostic() .context("error parsing refresh rate")?; Ok(Self { width, height, refresh, }) } } impl FromStr for Key { type Err = miette::Error; fn from_str(s: &str) -> Result { let mut modifiers = Modifiers::empty(); let mut split = s.split('+'); let key = split.next_back().unwrap(); for part in split { let part = part.trim(); if part.eq_ignore_ascii_case("mod") { modifiers |= Modifiers::COMPOSITOR } else if part.eq_ignore_ascii_case("ctrl") || part.eq_ignore_ascii_case("control") { modifiers |= Modifiers::CTRL; } else if part.eq_ignore_ascii_case("shift") { modifiers |= Modifiers::SHIFT; } else if part.eq_ignore_ascii_case("alt") { modifiers |= Modifiers::ALT; } else if part.eq_ignore_ascii_case("super") || part.eq_ignore_ascii_case("win") { modifiers |= Modifiers::SUPER; } else { return Err(miette!("invalid modifier: {part}")); } } let keysym = keysym_from_name(key, KEYSYM_CASE_INSENSITIVE); if keysym.raw() == KEY_NoSymbol { return Err(miette!("invalid key: {key}")); } Ok(Key { keysym, modifiers }) } } impl FromStr for SizeChange { type Err = miette::Error; fn from_str(s: &str) -> Result { match s.split_once('%') { Some((value, empty)) => { if !empty.is_empty() { return Err(miette!("trailing characters after '%' are not allowed")); } match value.bytes().next() { Some(b'-' | b'+') => { let value = value .parse() .into_diagnostic() .context("error parsing value")?; Ok(Self::AdjustProportion(value)) } Some(_) => { let value = value .parse() .into_diagnostic() .context("error parsing value")?; Ok(Self::SetProportion(value)) } None => Err(miette!("value is missing")), } } None => { let value = s; match value.bytes().next() { Some(b'-' | b'+') => { let value = value .parse() .into_diagnostic() .context("error parsing value")?; Ok(Self::AdjustFixed(value)) } Some(_) => { let value = value .parse() .into_diagnostic() .context("error parsing value")?; Ok(Self::SetFixed(value)) } None => Err(miette!("value is missing")), } } } } } #[cfg(test)] mod tests { use miette::NarratableReportHandler; use super::*; #[track_caller] fn check(text: &str, expected: Config) { let _ = miette::set_hook(Box::new(|_| Box::new(NarratableReportHandler::new()))); let parsed = Config::parse("test.kdl", text) .map_err(miette::Report::new) .unwrap(); assert_eq!(parsed, expected); } #[test] fn parse() { check( r#" input { keyboard { repeat-delay 600 repeat-rate 25 xkb { layout "us,ru" options "grp:win_space_toggle" } } touchpad { tap accel-speed 0.2 } tablet { map-to-output "eDP-1" } } output "eDP-1" { scale 2.0 position x=10 y=20 mode "1920x1080@144" } spawn-at-startup "alacritty" "-e" "fish" focus-ring { width 5 active-color 0.0 0.25 0.5 1.0 inactive-color 1.0 0.5 0.25 0.0 } prefer-no-csd cursor { xcursor-theme "breeze_cursors" xcursor-size 16 } preset-column-widths { proportion 0.25 proportion 0.5 fixed 960 fixed 1280 } gaps 8 binds { Mod+T { spawn "alacritty"; } Mod+Q { close-window; } Mod+Shift+H { focus-monitor-left; } Mod+Ctrl+Shift+L { move-window-to-monitor-right; } Mod+Comma { consume-window-into-column; } Mod+1 { focus-workspace 1;} } debug { animation-slowdown 2.0 } "#, Config { input: Input { keyboard: Keyboard { xkb: Xkb { layout: Some("us,ru".to_owned()), options: Some("grp:win_space_toggle".to_owned()), ..Default::default() }, repeat_delay: 600, repeat_rate: 25, }, touchpad: Touchpad { tap: true, natural_scroll: false, accel_speed: 0.2, }, tablet: Tablet { map_to_output: Some("eDP-1".to_owned()), }, }, outputs: vec![Output { name: "eDP-1".to_owned(), scale: 2., position: Some(Position { x: 10, y: 20 }), mode: Some(Mode { width: 1920, height: 1080, refresh: Some(144.), }), }], spawn_at_startup: vec![SpawnAtStartup { command: vec!["alacritty".to_owned(), "-e".to_owned(), "fish".to_owned()], }], focus_ring: FocusRing { off: false, width: 5, active_color: Color { r: 0., g: 0.25, b: 0.5, a: 1., }, inactive_color: Color { r: 1., g: 0.5, b: 0.25, a: 0., }, }, prefer_no_csd: true, cursor: Cursor { xcursor_theme: String::from("breeze_cursors"), xcursor_size: 16, }, preset_column_widths: vec![ PresetWidth::Proportion(0.25), PresetWidth::Proportion(0.5), PresetWidth::Fixed(960), PresetWidth::Fixed(1280), ], gaps: 8, binds: Binds(vec![ Bind { key: Key { keysym: Keysym::t, modifiers: Modifiers::COMPOSITOR, }, actions: vec![Action::Spawn(vec!["alacritty".to_owned()])], }, Bind { key: Key { keysym: Keysym::q, modifiers: Modifiers::COMPOSITOR, }, actions: vec![Action::CloseWindow], }, Bind { key: Key { keysym: Keysym::h, modifiers: Modifiers::COMPOSITOR | Modifiers::SHIFT, }, actions: vec![Action::FocusMonitorLeft], }, Bind { key: Key { keysym: Keysym::l, modifiers: Modifiers::COMPOSITOR | Modifiers::SHIFT | Modifiers::CTRL, }, actions: vec![Action::MoveWindowToMonitorRight], }, Bind { key: Key { keysym: Keysym::comma, modifiers: Modifiers::COMPOSITOR, }, actions: vec![Action::ConsumeWindowIntoColumn], }, Bind { key: Key { keysym: Keysym::_1, modifiers: Modifiers::COMPOSITOR, }, actions: vec![Action::FocusWorkspace(1)], }, ]), debug: DebugConfig { animation_slowdown: 2., ..Default::default() }, }, ); } #[test] fn can_create_default_config() { let _ = Config::default(); } #[test] fn parse_mode() { assert_eq!( "2560x1600@165.004".parse::().unwrap(), Mode { width: 2560, height: 1600, refresh: Some(165.004), }, ); assert_eq!( "1920x1080".parse::().unwrap(), Mode { width: 1920, height: 1080, refresh: None, }, ); assert!("1920".parse::().is_err()); assert!("1920x".parse::().is_err()); assert!("1920x1080@".parse::().is_err()); assert!("1920x1080@60Hz".parse::().is_err()); } #[test] fn parse_size_change() { assert_eq!( "10".parse::().unwrap(), SizeChange::SetFixed(10), ); assert_eq!( "+10".parse::().unwrap(), SizeChange::AdjustFixed(10), ); assert_eq!( "-10".parse::().unwrap(), SizeChange::AdjustFixed(-10), ); assert_eq!( "10%".parse::().unwrap(), SizeChange::SetProportion(10.), ); assert_eq!( "+10%".parse::().unwrap(), SizeChange::AdjustProportion(10.), ); assert_eq!( "-10%".parse::().unwrap(), SizeChange::AdjustProportion(-10.), ); assert!("-".parse::().is_err()); assert!("10% ".parse::().is_err()); } }