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 --- docs/wiki/Configuration:-Outputs.md | 40 ++++ niri-config/src/lib.rs | 79 +++++++- niri-config/src/output.rs | 286 +++++++++++++++++++++++++- niri-ipc/src/lib.rs | 172 ++++++++++++++++ src/backend/headless.rs | 1 + src/backend/tty.rs | 394 ++++++++++++++++++++++++++++++++++-- src/backend/winit.rs | 1 + src/dbus/mutter_display_config.rs | 11 +- src/ipc/client.rs | 34 +++- src/ipc/server.rs | 2 + src/niri.rs | 40 +++- src/protocols/output_management.rs | 88 +++++--- 12 files changed, 1080 insertions(+), 68 deletions(-) diff --git a/docs/wiki/Configuration:-Outputs.md b/docs/wiki/Configuration:-Outputs.md index b2b394bd..ecdee105 100644 --- a/docs/wiki/Configuration:-Outputs.md +++ b/docs/wiki/Configuration:-Outputs.md @@ -27,6 +27,10 @@ output "eDP-1" { layout { // ...layout settings for eDP-1... } + + // Custom modes. Caution: may damage your display. + // mode custom=true "1920x1080@100" + // modeline 173.00 1920 2048 2248 2576 1080 1083 1088 1120 "-hsync" "+vsync" } output "HDMI-A-1" { @@ -86,6 +90,42 @@ output "eDP-1" { } ``` +#### `mode custom=true` + +Since: next release + +You can configure a custom mode (not offered by the monitor) by setting `custom=true`. +In this case, the refresh rate is mandatory. + +> [!CAUTION] +> Custom modes may damage your monitor, especially if it's a CRT. +> Follow the maximum supported limits in your monitor's instructions. + +```kdl +// Use a custom mode for this display. +output "HDMI-A-1" { + mode custom=true "2560x1440@143.912" +} +``` + +### `modeline` + +Since: next release + +Directly configures the monitor's mode via a modeline, overriding any configured `mode`. +The modeline can be calculated via utilities such as [cvt](https://man.archlinux.org/man/cvt.1.en) or [gtf](https://man.archlinux.org/man/gtf.1.en). + +> [!CAUTION] +> Out of spec modelines may damage your monitor, especially if it's a CRT. +> Follow the maximum supported limits in your monitor's instructions. + +```kdl +// Use a modeline for this display. +output "eDP-3" { + modeline 173.00 1920 2048 2248 2576 1080 1083 1088 1120 "-hsync" "+vsync" +} +``` + ### `scale` Set the scale of the monitor. 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; diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index afe9a6d2..4a2e8996 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -1000,6 +1000,51 @@ pub enum OutputAction { #[cfg_attr(feature = "clap", arg())] mode: ModeToSet, }, + /// Set a custom output mode. + CustomMode { + /// Custom mode to set. + #[cfg_attr(feature = "clap", arg())] + mode: ConfiguredMode, + }, + /// Set a custom VESA CVT modeline. + #[cfg_attr(feature = "clap", arg())] + Modeline { + /// The rate at which pixels are drawn in MHz. + #[cfg_attr(feature = "clap", arg())] + clock: f64, + /// Horizontal active pixels. + #[cfg_attr(feature = "clap", arg())] + hdisplay: u16, + /// Horizontal sync pulse start position in pixels. + #[cfg_attr(feature = "clap", arg())] + hsync_start: u16, + /// Horizontal sync pulse end position in pixels. + #[cfg_attr(feature = "clap", arg())] + hsync_end: u16, + /// Total horizontal number of pixels before resetting the horizontal drawing position to + /// zero. + #[cfg_attr(feature = "clap", arg())] + htotal: u16, + + /// Vertical active pixels. + #[cfg_attr(feature = "clap", arg())] + vdisplay: u16, + /// Vertical sync pulse start position in pixels. + #[cfg_attr(feature = "clap", arg())] + vsync_start: u16, + /// Vertical sync pulse end position in pixels. + #[cfg_attr(feature = "clap", arg())] + vsync_end: u16, + /// Total vertical number of pixels before resetting the vertical drawing position to zero. + #[cfg_attr(feature = "clap", arg())] + vtotal: u16, + /// Horizontal sync polarity: "+hsync" or "-hsync". + #[cfg_attr(feature = "clap", arg(allow_hyphen_values = true))] + hsync_polarity: HSyncPolarity, + /// Vertical sync polarity: "+vsync" or "-vsync". + #[cfg_attr(feature = "clap", arg(allow_hyphen_values = true))] + vsync_polarity: VSyncPolarity, + }, /// Set the output scale. Scale { /// Scale factor to set, or "auto" for automatic selection. @@ -1048,6 +1093,26 @@ pub struct ConfiguredMode { pub refresh: Option, } +/// Modeline horizontal syncing polarity. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +pub enum HSyncPolarity { + /// Positive polarity. + PHSync, + /// Negative polarity. + NHSync, +} + +/// Modeline vertical syncing polarity. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +pub enum VSyncPolarity { + /// Positive polarity. + PVSync, + /// Negative polarity. + NVSync, +} + /// Output scale to set. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -1125,6 +1190,8 @@ pub struct Output { /// /// `None` if the output is disabled. pub current_mode: Option, + /// Whether the current_mode is a custom mode. + pub is_custom_mode: bool, /// Whether the output supports variable refresh rate. pub vrr_supported: bool, /// Whether variable refresh rate is enabled on the output. @@ -1680,6 +1747,30 @@ impl FromStr for ConfiguredMode { } } +impl FromStr for HSyncPolarity { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "+hsync" => Ok(Self::PHSync), + "-hsync" => Ok(Self::NHSync), + _ => Err(r#"invalid horizontal sync polarity, can be "+hsync" or "-hsync"#), + } + } +} + +impl FromStr for VSyncPolarity { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "+vsync" => Ok(Self::PVSync), + "-vsync" => Ok(Self::NVSync), + _ => Err(r#"invalid vertical sync polarity, can be "+vsync" or "-vsync"#), + } + } +} + impl FromStr for ScaleToSet { type Err = &'static str; @@ -1693,6 +1784,87 @@ impl FromStr for ScaleToSet { } } +macro_rules! ensure { + ($cond:expr, $fmt:literal $($arg:tt)* ) => { + if !$cond { + return Err(format!($fmt $($arg)*)); + } + }; +} + +impl OutputAction { + /// Validates some required constraints on the modeline and custom mode. + pub fn validate(&self) -> Result<(), String> { + match self { + OutputAction::Modeline { + hdisplay, + hsync_start, + hsync_end, + htotal, + vdisplay, + vsync_start, + vsync_end, + vtotal, + .. + } => { + ensure!( + hdisplay < hsync_start, + "hdisplay {} must be < hsync_start {}", + hdisplay, + hsync_start + ); + ensure!( + hsync_start < hsync_end, + "hsync_start {} must be < hsync_end {}", + hsync_start, + hsync_end + ); + ensure!( + hsync_end < htotal, + "hsync_end {} must be < htotal {}", + hsync_end, + htotal + ); + ensure!(0 < *htotal, "htotal {} must be > 0", htotal); + ensure!( + vdisplay < vsync_start, + "vdisplay {} must be < vsync_start {}", + vdisplay, + vsync_start + ); + ensure!( + vsync_start < vsync_end, + "vsync_start {} must be < vsync_end {}", + vsync_start, + vsync_end + ); + ensure!( + vsync_end < vtotal, + "vsync_end {} must be < vtotal {}", + vsync_end, + vtotal + ); + ensure!(0 < *vtotal, "vtotal {} must be > 0", vtotal); + Ok(()) + } + OutputAction::CustomMode { + mode: ConfiguredMode { refresh, .. }, + } => { + if refresh.is_none() { + return Err("refresh rate is required for custom modes".to_string()); + } + if let Some(refresh) = refresh { + if *refresh <= 0. { + return Err(format!("custom mode refresh rate {refresh} must be > 0")); + } + } + Ok(()) + } + _ => Ok(()), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/backend/headless.rs b/src/backend/headless.rs index 51381168..8b5ee827 100644 --- a/src/backend/headless.rs +++ b/src/backend/headless.rs @@ -105,6 +105,7 @@ impl Headless { is_preferred: true, }], current_mode: Some(0), + is_custom_mode: true, vrr_supported: false, vrr_enabled: false, logical: Some(logical_output(&output)), diff --git a/src/backend/tty.rs b/src/backend/tty.rs index 530280ab..bc638005 100644 --- a/src/backend/tty.rs +++ b/src/backend/tty.rs @@ -12,8 +12,11 @@ use std::{io, mem}; use anyhow::{anyhow, bail, ensure, Context}; use bytemuck::cast_slice_mut; +use drm_ffi::drm_mode_modeinfo; use libc::dev_t; +use niri_config::output::Modeline; use niri_config::{Config, OutputName}; +use niri_ipc::{HSyncPolarity, VSyncPolarity}; use smithay::backend::allocator::dmabuf::Dmabuf; use smithay::backend::allocator::format::FormatSet; use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}; @@ -907,21 +910,35 @@ impl Tty { trace!("{m:?}"); } - let (mode, fallback) = - pick_mode(&connector, config.mode).ok_or_else(|| anyhow!("no mode"))?; + let mut mode = None; + if let Some(modeline) = &config.modeline { + match calculate_drm_mode_from_modeline(modeline) { + Ok(x) => mode = Some(x), + Err(err) => { + warn!("invalid custom modeline; falling back to advertised modes: {err:?}"); + } + } + } + + let (mode, fallback) = match mode { + Some(x) => (x, false), + None => pick_mode(&connector, config.mode).ok_or_else(|| anyhow!("no mode"))?, + }; + if fallback { let target = config.mode.unwrap(); warn!( "configured mode {}x{}{} could not be found, falling back to preferred", - target.width, - target.height, - if let Some(refresh) = target.refresh { + target.mode.width, + target.mode.height, + if let Some(refresh) = target.mode.refresh { format!("@{refresh}") } else { String::new() }, ); } + debug!("picking mode: {mode:?}"); if let Ok(props) = ConnectorProperties::try_new(&device.drm, connector.handle()) { @@ -1711,8 +1728,9 @@ impl Tty { let surface = device.surfaces.get(&crtc); let current_crtc_mode = surface.map(|surface| surface.compositor.pending_mode()); let mut current_mode = None; + let mut is_custom_mode = false; - let modes = connector + let mut modes: Vec = connector .modes() .iter() .filter(|m| !m.flags().contains(ModeFlags::INTERLACE)) @@ -1732,6 +1750,21 @@ impl Tty { .collect(); if let Some(crtc_mode) = current_crtc_mode { + // Custom mode + if crtc_mode.mode_type().contains(ModeTypeFlags::USERDEF) { + modes.insert( + 0, + niri_ipc::Mode { + width: crtc_mode.size().0, + height: crtc_mode.size().1, + refresh_rate: Mode::from(crtc_mode).refresh as u32, + is_preferred: false, + }, + ); + current_mode = Some(0); + is_custom_mode = true; + } + if current_mode.is_none() { if crtc_mode.flags().contains(ModeFlags::INTERLACE) { warn!("connector mode list missing current mode (interlaced)"); @@ -1776,6 +1809,7 @@ impl Tty { physical_size, modes, current_mode, + is_custom_mode, vrr_supported, vrr_enabled, logical, @@ -1962,9 +1996,29 @@ impl Tty { continue; }; - let Some((mode, fallback)) = pick_mode(connector, config.mode) else { - warn!("couldn't pick mode for enabled connector"); - continue; + let mut mode = None; + if let Some(modeline) = &config.modeline { + match calculate_drm_mode_from_modeline(modeline) { + Ok(x) => mode = Some(x), + Err(err) => { + warn!( + "output {:?}: invalid custom modeline; \ + falling back to advertised modes: {err:?}", + surface.name.connector + ); + } + } + } + + let (mode, fallback) = match mode { + Some(x) => (x, false), + None => match pick_mode(connector, config.mode) { + Some(result) => result, + None => { + warn!("couldn't pick mode for enabled connector"); + continue; + } + }, }; let change_mode = surface.compositor.pending_mode() != mode; @@ -2017,9 +2071,9 @@ impl Tty { "output {:?}: configured mode {}x{}{} could not be found, \ falling back to preferred", surface.name.connector, - target.width, - target.height, - if let Some(refresh) = target.refresh { + target.mode.width, + target.mode.height, + if let Some(refresh) = target.mode.refresh { format!("@{refresh}") } else { String::new() @@ -2517,18 +2571,188 @@ fn queue_estimated_vblank_timer( output_state.redraw_state = RedrawState::WaitingForEstimatedVBlank(token); } +pub fn calculate_drm_mode_from_modeline(modeline: &Modeline) -> anyhow::Result { + ensure!( + modeline.hdisplay < modeline.hsync_start, + "hdisplay {} must be < hsync_start {}", + modeline.hdisplay, + modeline.hsync_start + ); + ensure!( + modeline.hsync_start < modeline.hsync_end, + "hsync_start {} must be < hsync_end {}", + modeline.hsync_start, + modeline.hsync_end + ); + ensure!( + modeline.hsync_end < modeline.htotal, + "hsync_end {} must be < htotal {}", + modeline.hsync_end, + modeline.htotal + ); + ensure!( + modeline.vdisplay < modeline.vsync_start, + "vdisplay {} must be < vsync_start {}", + modeline.vdisplay, + modeline.vsync_start + ); + ensure!( + modeline.vsync_start < modeline.vsync_end, + "vsync_start {} must be < vsync_end {}", + modeline.vsync_start, + modeline.vsync_end + ); + ensure!( + modeline.vsync_end < modeline.vtotal, + "vsync_end {} must be < vtotal {}", + modeline.vsync_end, + modeline.vtotal + ); + + let pixel_clock_kilo_hertz = modeline.clock * 1000.0; + // Calculated as documented in the CVT 1.2 standard: + // https://app.box.com/s/vcocw3z73ta09txiskj7cnk6289j356b/file/93518784646 + let vrefresh_hertz = (pixel_clock_kilo_hertz * 1000.0) + / (modeline.htotal as u64 * modeline.vtotal as u64) as f64; + ensure!( + vrefresh_hertz.is_finite(), + "calculated refresh rate is not finite" + ); + let vrefresh_rounded = vrefresh_hertz.round() as u32; + + let flags = match modeline.hsync_polarity { + HSyncPolarity::PHSync => ModeFlags::PHSYNC, + HSyncPolarity::NHSync => ModeFlags::NHSYNC, + } | match modeline.vsync_polarity { + VSyncPolarity::PVSync => ModeFlags::PVSYNC, + VSyncPolarity::NVSync => ModeFlags::NVSYNC, + }; + + let mode_name = format!( + "{}x{}@{:.2}", + modeline.hdisplay, modeline.vdisplay, vrefresh_hertz + ); + let name = modeinfo_name_slice_from_string(&mode_name); + + // https://www.kernel.org/doc/html/v6.17/gpu/drm-uapi.html#c.drm_mode_modeinfo + Ok(DrmMode::from(drm_mode_modeinfo { + clock: pixel_clock_kilo_hertz.round() as u32, + hdisplay: modeline.hdisplay, + hsync_start: modeline.hsync_start, + hsync_end: modeline.hsync_end, + htotal: modeline.htotal, + vdisplay: modeline.vdisplay, + vsync_start: modeline.vsync_start, + vsync_end: modeline.vsync_end, + vtotal: modeline.vtotal, + vrefresh: vrefresh_rounded, + flags: flags.bits(), + name, + // Defaults + type_: drm_ffi::DRM_MODE_TYPE_USERDEF, + hskew: 0, + vscan: 0, + })) +} + +pub fn calculate_mode_cvt(width: u16, height: u16, refresh: f64) -> DrmMode { + // Cross-checked with sway's implementation: + // https://gitlab.freedesktop.org/wlroots/wlroots/-/blob/22528542970687720556035790212df8d9bb30bb/backend/drm/util.c#L251 + + let options = libdisplay_info::cvt::Options { + red_blank_ver: libdisplay_info::cvt::ReducedBlankingVersion::None, + h_pixels: width as i32, + v_lines: height as i32, + ip_freq_rqd: refresh, + + // Defaults + video_opt: false, + vblank: 0f64, + additional_hblank: 0, + early_vsync_rqd: false, + int_rqd: false, + margins_rqd: false, + }; + let cvt_timing = libdisplay_info::cvt::Timing::compute(options); + + let hsync_start = width + cvt_timing.h_front_porch as u16; + let vsync_start = (cvt_timing.v_lines_rnd + cvt_timing.v_front_porch) as u16; + let hsync_end = hsync_start + cvt_timing.h_sync as u16; + let vsync_end = vsync_start + cvt_timing.v_sync as u16; + + let htotal = hsync_end + cvt_timing.h_back_porch as u16; + let vtotal = vsync_end + cvt_timing.v_back_porch as u16; + + let clock = f64::round(cvt_timing.act_pixel_freq * 1000f64) as u32; + let vrefresh = f64::round(cvt_timing.act_frame_rate) as u32; + + let flags = drm_ffi::DRM_MODE_FLAG_NHSYNC | drm_ffi::DRM_MODE_FLAG_PVSYNC; + + let mode_name = format!("{width}x{height}@{:.2}", cvt_timing.act_frame_rate); + let name = modeinfo_name_slice_from_string(&mode_name); + + let drm_ffi_mode = drm_ffi::drm_sys::drm_mode_modeinfo { + clock, + + hdisplay: width, + hsync_start, + hsync_end, + htotal, + + vdisplay: height, + vsync_start, + vsync_end, + vtotal, + + vrefresh, + + flags, + type_: drm_ffi::DRM_MODE_TYPE_USERDEF, + name, + + // Defaults + hskew: 0, + vscan: 0, + }; + + DrmMode::from(drm_ffi_mode) +} + +// Returns a c-string of maximally 31 Rust string chars + null terminator. Excess characters are +// dropped. +fn modeinfo_name_slice_from_string(mode_name: &str) -> [core::ffi::c_char; 32] { + let mut name: [core::ffi::c_char; 32] = [0; 32]; + + for (a, b) in zip(&mut name[..31], mode_name.as_bytes()) { + *a = *b as i8; + } + + name +} + fn pick_mode( connector: &connector::Info, - target: Option, + target: Option, ) -> Option<(control::Mode, bool)> { let mut mode = None; let mut fallback = false; if let Some(target) = target { - let refresh = target.refresh.map(|r| (r * 1000.).round() as i32); + let target_mode = target.mode; + + if target.custom { + if let Some(refresh) = target_mode.refresh { + let custom_mode = + calculate_mode_cvt(target_mode.width, target_mode.height, refresh); + return Some((custom_mode, false)); + } else { + warn!("ignoring custom mode without refresh rate"); + } + } + let refresh = target_mode.refresh.map(|r| (r * 1000.).round() as i32); for m in connector.modes() { - if m.size() != (target.width, target.height) { + if m.size() != (target.mode.width, target.mode.height) { continue; } @@ -2760,3 +2984,143 @@ fn make_output_name( serial: info.as_ref().and_then(|info| info.serial()), } } + +#[cfg(test)] +mod tests { + use insta::assert_debug_snapshot; + use niri_config::output::Modeline; + use niri_ipc::{HSyncPolarity, VSyncPolarity}; + + use crate::backend::tty::{calculate_drm_mode_from_modeline, calculate_mode_cvt}; + + #[test] + fn test_calculate_drmmode_from_modeline() { + let modeline1 = Modeline { + clock: 173.0, + hdisplay: 1920, + vdisplay: 1080, + hsync_start: 2048, + hsync_end: 2248, + htotal: 2576, + vsync_start: 1083, + vsync_end: 1088, + vtotal: 1120, + hsync_polarity: HSyncPolarity::NHSync, + vsync_polarity: VSyncPolarity::PVSync, + }; + assert_debug_snapshot!(calculate_drm_mode_from_modeline(&modeline1).unwrap(), @"Mode { + name: \"1920x1080@59.96\", + clock: 173000, + size: ( + 1920, + 1080, + ), + hsync: ( + 2048, + 2248, + 2576, + ), + vsync: ( + 1083, + 1088, + 1120, + ), + hskew: 0, + vscan: 0, + vrefresh: 60, + mode_type: ModeTypeFlags( + USERDEF, + ), +}"); + let modeline2 = Modeline { + clock: 452.5, + hdisplay: 1920, + vdisplay: 1080, + hsync_start: 2088, + hsync_end: 2296, + htotal: 2672, + vsync_start: 1083, + vsync_end: 1088, + vtotal: 1177, + hsync_polarity: HSyncPolarity::NHSync, + vsync_polarity: VSyncPolarity::PVSync, + }; + assert_debug_snapshot!(calculate_drm_mode_from_modeline(&modeline2).unwrap(), @"Mode { + name: \"1920x1080@143.88\", + clock: 452500, + size: ( + 1920, + 1080, + ), + hsync: ( + 2088, + 2296, + 2672, + ), + vsync: ( + 1083, + 1088, + 1177, + ), + hskew: 0, + vscan: 0, + vrefresh: 144, + mode_type: ModeTypeFlags( + USERDEF, + ), +}"); + } + + #[test] + fn test_calc_cvt() { + // Crosschecked with other calculators like the cvt commandline utility. + assert_debug_snapshot!(calculate_mode_cvt(1920, 1080, 60.0), @"Mode { + name: \"1920x1080@59.96\", + clock: 173000, + size: ( + 1920, + 1080, + ), + hsync: ( + 2048, + 2248, + 2576, + ), + vsync: ( + 1083, + 1088, + 1120, + ), + hskew: 0, + vscan: 0, + vrefresh: 60, + mode_type: ModeTypeFlags( + USERDEF, + ), +}"); + assert_debug_snapshot!(calculate_mode_cvt(1920, 1080, 144.0), @"Mode { + name: \"1920x1080@143.88\", + clock: 452500, + size: ( + 1920, + 1080, + ), + hsync: ( + 2088, + 2296, + 2672, + ), + vsync: ( + 1083, + 1088, + 1177, + ), + hskew: 0, + vscan: 0, + vrefresh: 144, + mode_type: ModeTypeFlags( + USERDEF, + ), +}"); + } +} diff --git a/src/backend/winit.rs b/src/backend/winit.rs index ebec64c9..1fb4019a 100644 --- a/src/backend/winit.rs +++ b/src/backend/winit.rs @@ -83,6 +83,7 @@ impl Winit { is_preferred: true, }], current_mode: Some(0), + is_custom_mode: true, vrr_supported: false, vrr_enabled: false, logical: Some(logical_output(&output)), diff --git a/src/dbus/mutter_display_config.rs b/src/dbus/mutter_display_config.rs index fe445f64..91429d9a 100644 --- a/src/dbus/mutter_display_config.rs +++ b/src/dbus/mutter_display_config.rs @@ -217,9 +217,14 @@ impl DisplayConfig { x: requested_config.x, y: requested_config.y, }), - mode: Some(niri_ipc::ConfiguredMode::from_str(&mode).map_err(|e| { - zbus::fdo::Error::Failed(format!("Could not parse mode '{mode}': {e}")) - })?), + mode: Some(niri_config::output::Mode { + custom: false, + mode: niri_ipc::ConfiguredMode::from_str(&mode).map_err(|e| { + zbus::fdo::Error::Failed(format!( + "Could not parse mode '{mode}': {e}" + )) + })?, + }), // FIXME: VRR ..Default::default() }), diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 3c7b27a9..8fea1d2b 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -526,6 +526,7 @@ fn print_output(output: Output) -> anyhow::Result<()> { physical_size, modes, current_mode, + is_custom_mode, vrr_supported, vrr_enabled, logical, @@ -534,6 +535,26 @@ fn print_output(output: Output) -> anyhow::Result<()> { let serial = serial.as_deref().unwrap_or("Unknown"); println!(r#"Output "{make} {model} {serial}" ({name})"#); + let print_qualifier = |is_preferred: bool, is_current: bool, is_custom_mode: bool| { + let mut qualifier = Vec::new(); + if is_current { + qualifier.push("current"); + if is_custom_mode { + qualifier.push("custom"); + }; + }; + + if is_preferred { + qualifier.push("preferred"); + }; + + if qualifier.is_empty() { + String::new() + } else { + format!(" ({})", qualifier.join(", ")) + } + }; + if let Some(current) = current_mode { let mode = *modes .get(current) @@ -545,8 +566,10 @@ fn print_output(output: Output) -> anyhow::Result<()> { is_preferred, } = mode; let refresh = refresh_rate as f64 / 1000.; - let preferred = if is_preferred { " (preferred)" } else { "" }; - println!(" Current mode: {width}x{height} @ {refresh:.3} Hz{preferred}"); + + // This is technically the current mode, but the println below already specifies that. + let qualifier = print_qualifier(is_preferred, false, is_custom_mode); + println!(" Current mode: {width}x{height} @ {refresh:.3} Hz{qualifier}"); } else { println!(" Disabled"); } @@ -601,12 +624,7 @@ fn print_output(output: Output) -> anyhow::Result<()> { let refresh = refresh_rate as f64 / 1000.; let is_current = Some(idx) == current_mode; - let qualifier = match (is_current, is_preferred) { - (true, true) => " (current, preferred)", - (true, false) => " (current)", - (false, true) => " (preferred)", - (false, false) => "", - }; + let qualifier = print_qualifier(is_preferred, is_current, is_custom_mode); println!(" {width}x{height}@{refresh:.3}{qualifier}"); } diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 7fdc81f5..ade57de1 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -399,6 +399,8 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { Response::Handled } Request::Output { output, action } => { + action.validate()?; + let ipc_outputs = ctx.ipc_outputs.lock().unwrap(); let found = ipc_outputs .values() diff --git a/src/niri.rs b/src/niri.rs index 5bc69c2e..551439c3 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -1777,8 +1777,44 @@ impl State { niri_ipc::OutputAction::Mode { mode } => { config.mode = match mode { niri_ipc::ModeToSet::Automatic => None, - niri_ipc::ModeToSet::Specific(mode) => Some(mode), - } + niri_ipc::ModeToSet::Specific(mode) => Some(niri_config::output::Mode { + custom: false, + mode, + }), + }; + config.modeline = None; + } + niri_ipc::OutputAction::CustomMode { mode } => { + config.mode = Some(niri_config::output::Mode { custom: true, mode }); + config.modeline = None; + } + niri_ipc::OutputAction::Modeline { + clock, + hdisplay, + hsync_start, + hsync_end, + htotal, + vdisplay, + vsync_start, + vsync_end, + vtotal, + hsync_polarity, + vsync_polarity, + } => { + // Do not reset config.mode to None since it's used as a fallback. + config.modeline = Some(niri_config::output::Modeline { + clock, + hdisplay, + hsync_start, + hsync_end, + htotal, + vdisplay, + vsync_start, + vsync_end, + vtotal, + hsync_polarity, + vsync_polarity, + }) } niri_ipc::OutputAction::Scale { scale } => { config.scale = match scale { diff --git a/src/protocols/output_management.rs b/src/protocols/output_management.rs index 4f1ac8c0..2b45588f 100644 --- a/src/protocols/output_management.rs +++ b/src/protocols/output_management.rs @@ -110,19 +110,47 @@ impl OutputManagementManagerState { } } - // TTY outputs can't change modes I think, however, winit and virtual outputs can. + // Winit and virtual outputs can change modes; on a TTY custom modes can add/remove + // a mode. let modes_changed = old.modes != conf.modes; if modes_changed { changed = true; - if old.modes.len() != conf.modes.len() { - error!("output's old mode count doesn't match new modes"); - } else { - for client in self.clients.values() { - if let Some((_, modes)) = client.heads.get(output) { - for (wl_mode, mode) in zip(modes, &conf.modes) { - wl_mode.size(i32::from(mode.width), i32::from(mode.height)); - if let Ok(refresh_rate) = mode.refresh_rate.try_into() { - wl_mode.refresh(refresh_rate); + for client in self.clients.values_mut() { + if let Some((head, modes)) = client.heads.get_mut(output) { + // Ends on the shortest iterator. + let zwlr_modes_with_modes = zip(modes.iter(), &conf.modes); + let least_modes_len = zwlr_modes_with_modes.len(); + + for (wl_mode, mode) in zwlr_modes_with_modes { + wl_mode.size(i32::from(mode.width), i32::from(mode.height)); + if let Ok(refresh_rate) = mode.refresh_rate.try_into() { + wl_mode.refresh(refresh_rate); + } + } + + if let Some(client) = client.manager.client() { + if conf.modes.len() > least_modes_len { + for mode in &conf.modes[least_modes_len..] { + // One or more modes were added. + let new_mode = client + .create_resource::( + &self.display, + head.version(), + (), + ) + .unwrap(); + head.mode(&new_mode); + new_mode + .size(i32::from(mode.width), i32::from(mode.height)); + if let Ok(refresh_rate) = mode.refresh_rate.try_into() { + new_mode.refresh(refresh_rate) + } + modes.push(new_mode); + } + } else if modes.len() > least_modes_len { + // One or more modes were removed. + for mode in modes.drain(least_modes_len..) { + mode.finished(); } } } @@ -619,18 +647,21 @@ where return; }; - new_config.mode = Some(niri_ipc::ConfiguredMode { - width: mode.width, - height: mode.height, - refresh: Some(mode.refresh_rate as f64 / 1000.), + new_config.mode = Some(niri_config::output::Mode { + custom: false, + mode: niri_ipc::ConfiguredMode { + width: mode.width, + height: mode.height, + refresh: Some(mode.refresh_rate as f64 / 1000.), + }, }); + new_config.modeline = None; } zwlr_output_configuration_head_v1::Request::SetCustomMode { width, height, refresh, } => { - // FIXME: Support custom mode let (width, height, refresh): (u16, u16, u32) = match (width.try_into(), height.try_into(), refresh.try_into()) { (Ok(width), Ok(height), Ok(refresh)) => (width, height, refresh), @@ -640,25 +671,20 @@ where } }; - let Some(current_config) = g_state.current_state.get(output_id) else { - warn!("SetMode: output missing from the current config"); - return; - }; - - let Some(mode) = current_config.modes.iter().find(|m| { - m.width == width - && m.height == height - && (refresh == 0 || m.refresh_rate == refresh) - }) else { - warn!("SetCustomMode: no matching mode"); + if refresh == 0 { + warn!("SetCustomMode: refresh 0 requested, ignoring"); return; - }; + } - new_config.mode = Some(niri_ipc::ConfiguredMode { - width: mode.width, - height: mode.height, - refresh: Some(mode.refresh_rate as f64 / 1000.), + new_config.mode = Some(niri_config::output::Mode { + custom: true, + mode: niri_ipc::ConfiguredMode { + width, + height, + refresh: Some(refresh as f64 / 1000.), + }, }); + new_config.modeline = None; } zwlr_output_configuration_head_v1::Request::SetPosition { x, y } => { new_config.position = Some(niri_config::Position { x, y }); -- cgit