aboutsummaryrefslogtreecommitdiff
path: root/niri-config/src
diff options
context:
space:
mode:
authorMerlijn <32853531+ToxicMushroom@users.noreply.github.com>2025-10-29 07:10:38 +0100
committerGitHub <noreply@github.com>2025-10-29 09:10:38 +0300
commit6a2c6261df130cccb5262eddf71d40b2fffcf8f9 (patch)
tree48639aef4ebddbc315234b925954c5cc768d0f1c /niri-config/src
parente6f3c538da0c646bda43fcde7ef7dc3b771e0c8b (diff)
downloadniri-6a2c6261df130cccb5262eddf71d40b2fffcf8f9.tar.gz
niri-6a2c6261df130cccb5262eddf71d40b2fffcf8f9.tar.bz2
niri-6a2c6261df130cccb5262eddf71d40b2fffcf8f9.zip
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 <meissl.christian@gmail.com> Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
Diffstat (limited to 'niri-config/src')
-rw-r--r--niri-config/src/lib.rs79
-rw-r--r--niri-config/src/output.rs286
2 files changed, 356 insertions, 9 deletions
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<Output>);
+#[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<Position>,
- #[knuffel(child, unwrap(argument, str))]
- pub mode: Option<ConfiguredMode>,
+ #[knuffel(child)]
+ pub mode: Option<Mode>,
+ #[knuffel(child)]
+ pub modeline: Option<Modeline>,
#[knuffel(child)]
pub variable_refresh_rate: Option<Vrr>,
#[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<S: ErrorSpan> knuffel::Decode<S> for Mode {
+ fn decode_node(node: &SpannedNode<S>, ctx: &mut Context<S>) -> Result<Self, DecodeError<S>> {
+ 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<bool> = 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<S: ErrorSpan> Decode<S> for Modeline {
+ fn decode_node(node: &SpannedNode<S>, ctx: &mut Context<S>) -> Result<Self, DecodeError<S>> {
+ 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;