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>
This commit is contained in:
Merlijn
2025-10-29 07:10:38 +01:00
committed by GitHub
parent e6f3c538da
commit 6a2c6261df
12 changed files with 1080 additions and 68 deletions
+40
View File
@@ -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`
<sup>Since: next release</sup>
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`
<sup>Since: next release</sup>
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.
+73 -6
View File
@@ -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: [
+283 -3
View File
@@ -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;
+172
View File
@@ -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<f64>,
}
/// 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<usize>,
/// 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<Self, Self::Err> {
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<Self, Self::Err> {
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::*;
+1
View File
@@ -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)),
+379 -15
View File
@@ -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<niri_ipc::Mode> = 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<DrmMode> {
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<niri_ipc::ConfiguredMode>,
target: Option<niri_config::output::Mode>,
) -> 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,
),
}");
}
}
+1
View File
@@ -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)),
+8 -3
View File
@@ -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()
}),
+26 -8
View File
@@ -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}");
}
+2
View File
@@ -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()
+38 -2
View File
@@ -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 {
+57 -31
View File
@@ -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::<ZwlrOutputModeV1, _, State>(
&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");
if refresh == 0 {
warn!("SetCustomMode: refresh 0 requested, ignoring");
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");
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 });