From 6a2c6261df130cccb5262eddf71d40b2fffcf8f9 Mon Sep 17 00:00:00 2001 From: Merlijn <32853531+ToxicMushroom@users.noreply.github.com> Date: Wed, 29 Oct 2025 07:10:38 +0100 Subject: Add support for custom modes and modelines. (#2479) * Implement custom modes and modelines Co-authored-by: ToxicMushroom <32853531+ToxicMushroom@users.noreply.github.com> * fixes * refactor mode and modeline kdl parsers. * add IPC parse checks * refactor: address feedback * fix: add missing > 0 refresh rate check * move things around * fixes * wiki fixes --------- Co-authored-by: Christian Meissl Co-authored-by: Ivan Molodetskikh --- niri-config/src/lib.rs | 79 ++++++++++++- niri-config/src/output.rs | 286 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 356 insertions(+), 9 deletions(-) (limited to 'niri-config/src') diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index e939f0c3..dda7dfd6 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -663,6 +663,14 @@ mod tests { } } + output "eDP-2" { + mode custom=true "1920x1080@144" + } + + output "eDP-3" { + modeline 173.00 1920 2048 2248 2576 1080 1083 1088 1120 "-hsync" "+vsync" + } + layout { focus-ring { width 5 @@ -1035,14 +1043,18 @@ mod tests { }, ), mode: Some( - ConfiguredMode { - width: 1920, - height: 1080, - refresh: Some( - 144.0, - ), + Mode { + custom: false, + mode: ConfiguredMode { + width: 1920, + height: 1080, + refresh: Some( + 144.0, + ), + }, }, ), + modeline: None, variable_refresh_rate: Some( Vrr { on_demand: true, @@ -1069,6 +1081,61 @@ mod tests { ), layout: None, }, + Output { + off: false, + name: "eDP-2", + scale: None, + transform: Normal, + position: None, + mode: Some( + Mode { + custom: true, + mode: ConfiguredMode { + width: 1920, + height: 1080, + refresh: Some( + 144.0, + ), + }, + }, + ), + modeline: None, + variable_refresh_rate: None, + focus_at_startup: false, + background_color: None, + backdrop_color: None, + hot_corners: None, + layout: None, + }, + Output { + off: false, + name: "eDP-3", + scale: None, + transform: Normal, + position: None, + mode: None, + modeline: Some( + Modeline { + clock: 173.0, + hdisplay: 1920, + hsync_start: 2048, + hsync_end: 2248, + htotal: 2576, + vdisplay: 1080, + vsync_start: 1083, + vsync_end: 1088, + vtotal: 1120, + hsync_polarity: NHSync, + vsync_polarity: PVSync, + }, + ), + variable_refresh_rate: None, + focus_at_startup: false, + background_color: None, + backdrop_color: None, + hot_corners: None, + layout: None, + }, ], ), spawn_at_startup: [ diff --git a/niri-config/src/output.rs b/niri-config/src/output.rs index 62c14705..5d6565f5 100644 --- a/niri-config/src/output.rs +++ b/niri-config/src/output.rs @@ -1,4 +1,11 @@ -use niri_ipc::{ConfiguredMode, Transform}; +use std::str::FromStr; + +use knuffel::ast::SpannedNode; +use knuffel::decode::Context; +use knuffel::errors::DecodeError; +use knuffel::traits::ErrorSpan; +use knuffel::Decode; +use niri_ipc::{ConfiguredMode, HSyncPolarity, Transform, VSyncPolarity}; use crate::gestures::HotCorners; use crate::{Color, FloatOrInt, LayoutPart}; @@ -6,6 +13,40 @@ use crate::{Color, FloatOrInt, LayoutPart}; #[derive(Debug, Default, Clone, PartialEq)] pub struct Outputs(pub Vec); +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Mode { + pub custom: bool, + pub mode: ConfiguredMode, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Modeline { + /// The rate at which pixels are drawn in MHz. + pub clock: f64, + /// Horizontal active pixels. + pub hdisplay: u16, + /// Horizontal sync pulse start position in pixels. + pub hsync_start: u16, + /// Horizontal sync pulse end position in pixels. + pub hsync_end: u16, + /// Total horizontal number of pixels before resetting the horizontal drawing position to + /// zero. + pub htotal: u16, + + /// Vertical active pixels. + pub vdisplay: u16, + /// Vertical sync pulse start position in pixels. + pub vsync_start: u16, + /// Vertical sync pulse end position in pixels. + pub vsync_end: u16, + /// Total vertical number of pixels before resetting the vertical drawing position to zero. + pub vtotal: u16, + /// Horizontal sync polarity: "+hsync" or "-hsync". + pub hsync_polarity: niri_ipc::HSyncPolarity, + /// Vertical sync polarity: "+vsync" or "-vsync". + pub vsync_polarity: niri_ipc::VSyncPolarity, +} + #[derive(knuffel::Decode, Debug, Clone, PartialEq)] pub struct Output { #[knuffel(child)] @@ -18,8 +59,10 @@ pub struct Output { pub transform: Transform, #[knuffel(child)] pub position: Option, - #[knuffel(child, unwrap(argument, str))] - pub mode: Option, + #[knuffel(child)] + pub mode: Option, + #[knuffel(child)] + pub modeline: Option, #[knuffel(child)] pub variable_refresh_rate: Option, #[knuffel(child)] @@ -59,6 +102,7 @@ impl Default for Output { transform: Transform::Normal, position: None, mode: None, + modeline: None, variable_refresh_rate: None, background_color: None, backdrop_color: None, @@ -213,6 +257,242 @@ impl OutputName { } } +impl knuffel::Decode for Mode { + fn decode_node(node: &SpannedNode, ctx: &mut Context) -> Result> { + if let Some(type_name) = &node.type_name { + ctx.emit_error(DecodeError::unexpected( + type_name, + "type name", + "no type name expected for this node", + )); + } + + for child in node.children() { + ctx.emit_error(DecodeError::unexpected( + child, + "node", + format!("unexpected node `{}`", child.node_name.escape_default()), + )); + } + + let mut custom: Option = None; + for (name, val) in &node.properties { + match &***name { + "custom" => { + if custom.is_some() { + ctx.emit_error(DecodeError::unexpected( + name, + "property", + "unexpected duplicate property `custom`", + )) + } + custom = Some(knuffel::traits::DecodeScalar::decode(val, ctx)?) + } + name_str => ctx.emit_error(DecodeError::unexpected( + node, + "property", + format!("unexpected property `{}`", name_str.escape_default()), + )), + } + } + let custom = custom.unwrap_or(false); + + let mut arguments = node.arguments.iter(); + let mode = if let Some(mode_str) = arguments.next() { + let temp_mode: String = knuffel::traits::DecodeScalar::decode(mode_str, ctx)?; + + let res = ConfiguredMode::from_str(temp_mode.as_str()).and_then(|mode| { + if custom { + if mode.refresh.is_none() { + return Err("no refresh rate found; required for custom mode"); + } else if let Some(refresh) = mode.refresh { + if refresh <= 0. { + return Err("custom mode refresh rate must be > 0"); + } + } + } + Ok(mode) + }); + res.map_err(|err_msg| DecodeError::conversion(&mode_str.literal, err_msg))? + } else { + return Err(DecodeError::missing(node, "argument `mode` is required")); + }; + + if let Some(surplus) = arguments.next() { + ctx.emit_error(DecodeError::unexpected( + &surplus.literal, + "argument", + "unexpected argument", + )) + } + + Ok(Mode { custom, mode }) + } +} + +macro_rules! ensure { + ($cond:expr, $ctx:expr, $span:expr, $fmt:literal $($arg:tt)* ) => { + if !$cond { + $ctx.emit_error(DecodeError::Conversion { + source: format!($fmt $($arg)*).into(), + span: $span.literal.span().clone() + }); + } + }; +} + +impl Decode for Modeline { + fn decode_node(node: &SpannedNode, ctx: &mut Context) -> Result> { + if let Some(type_name) = &node.type_name { + ctx.emit_error(DecodeError::unexpected( + type_name, + "type name", + "no type name expected for this node", + )); + } + + for child in node.children() { + ctx.emit_error(DecodeError::unexpected( + child, + "node", + format!("unexpected node `{}`", child.node_name.escape_default()), + )); + } + + for span in node.properties.keys() { + ctx.emit_error(DecodeError::unexpected( + span, + "node", + format!("unexpected node `{}`", span.escape_default()), + )); + } + + let mut arguments = node.arguments.iter(); + + macro_rules! m_required { + // This could be one identifier if macro_metavar_expr_concat stabilizes + ($field:ident, $value_field:ident) => { + let $value_field = arguments.next().ok_or_else(|| { + DecodeError::missing(node, format!("missing {} argument", stringify!($value))) + })?; + let $field = knuffel::traits::DecodeScalar::decode($value_field, ctx)?; + }; + } + + m_required!(clock, clock_value); + m_required!(hdisplay, hdisplay_value); + m_required!(hsync_start, hsync_start_value); + m_required!(hsync_end, hsync_end_value); + m_required!(htotal, htotal_value); + m_required!(vdisplay, vdisplay_value); + m_required!(vsync_start, vsync_start_value); + m_required!(vsync_end, vsync_end_value); + m_required!(vtotal, vtotal_value); + m_required!(hsync_polarity, hsync_polarity_value); + let hsync_polarity = + HSyncPolarity::from_str(String::as_str(&hsync_polarity)).map_err(|msg| { + DecodeError::Conversion { + span: hsync_polarity_value.literal.span().clone(), + source: msg.into(), + } + })?; + + m_required!(vsync_polarity, vsync_polarity_value); + let vsync_polarity = + VSyncPolarity::from_str(String::as_str(&vsync_polarity)).map_err(|msg| { + DecodeError::Conversion { + span: vsync_polarity_value.literal.span().clone(), + source: msg.into(), + } + })?; + + ensure!( + hdisplay < hsync_start, + ctx, + hdisplay_value, + "hdisplay {} must be < hsync_start {}", + hdisplay, + hsync_start + ); + ensure!( + hsync_start < hsync_end, + ctx, + hsync_start_value, + "hsync_start {} must be < hsync_end {}", + hsync_start, + hsync_end, + ); + ensure!( + hsync_end < htotal, + ctx, + hsync_end_value, + "hsync_end {} must be < htotal {}", + hsync_end, + htotal, + ); + ensure!( + 0u16 < htotal, + ctx, + htotal_value, + "htotal {} must be > 0", + htotal + ); + ensure!( + vdisplay < vsync_start, + ctx, + vdisplay_value, + "vdisplay {} must be < vsync_start {}", + vdisplay, + vsync_start, + ); + ensure!( + vsync_start < vsync_end, + ctx, + vsync_start_value, + "vsync_start {} must be < vsync_end {}", + vsync_start, + vsync_end, + ); + ensure!( + vsync_end < vtotal, + ctx, + vsync_end_value, + "vsync_end {} must be < vtotal {}", + vsync_end, + vtotal, + ); + ensure!( + 0u16 < vtotal, + ctx, + vtotal_value, + "vtotal {} must be > 0", + vtotal + ); + + if let Some(extra) = arguments.next() { + ctx.emit_error(DecodeError::unexpected( + &extra.literal, + "argument", + "unexpected argument, all possible arguments were already provided", + )) + } + + Ok(Modeline { + clock, + hdisplay, + hsync_start, + hsync_end, + htotal, + vdisplay, + vsync_start, + vsync_end, + vtotal, + hsync_polarity, + vsync_polarity, + }) + } +} + #[cfg(test)] mod tests { use insta::assert_debug_snapshot; -- cgit