config: Extract output

This commit is contained in:
Ivan Molodetskikh
2025-08-27 10:10:50 +03:00
parent ab52bb9521
commit 19c576cfd8
2 changed files with 361 additions and 350 deletions
+3 -350
View File
@@ -14,8 +14,7 @@ use bitflags::bitflags;
use knuffel::errors::DecodeError;
use miette::{miette, Context, IntoDiagnostic};
use niri_ipc::{
ColumnDisplay, ConfiguredMode, LayoutSwitchTarget, PositionChange, SizeChange, Transform,
WorkspaceReferenceArg,
ColumnDisplay, LayoutSwitchTarget, PositionChange, SizeChange, WorkspaceReferenceArg,
};
use smithay::backend::renderer::Color32F;
use smithay::input::keyboard::keysyms::KEY_NoSymbol;
@@ -28,6 +27,7 @@ pub const DEFAULT_BACKDROP_COLOR: Color = Color::from_array_unpremul([0.15, 0.15
pub mod animations;
pub mod input;
pub mod layer_rule;
pub mod output;
pub mod utils;
pub mod window_rule;
@@ -36,6 +36,7 @@ pub use crate::animations::{
};
pub use crate::input::{Input, ModKey, ScrollMethod, TrackLayout, WarpMouseToFocusMode, Xkb};
pub use crate::layer_rule::LayerRule;
pub use crate::output::{Output, OutputName, Outputs, Position, Vrr};
pub use crate::window_rule::{FloatingPosition, RelativeTo, WindowRule};
#[derive(knuffel::Decode, Debug, PartialEq)]
@@ -107,86 +108,6 @@ pub enum CenterFocusedColumn {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Percent(pub f64);
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Outputs(pub Vec<Output>);
#[derive(knuffel::Decode, Debug, Clone, PartialEq)]
pub struct Output {
#[knuffel(child)]
pub off: bool,
#[knuffel(argument)]
pub name: String,
#[knuffel(child, unwrap(argument))]
pub scale: Option<FloatOrInt<0, 10>>,
#[knuffel(child, unwrap(argument, str), default = Transform::Normal)]
pub transform: Transform,
#[knuffel(child)]
pub position: Option<Position>,
#[knuffel(child, unwrap(argument, str))]
pub mode: Option<ConfiguredMode>,
#[knuffel(child)]
pub variable_refresh_rate: Option<Vrr>,
#[knuffel(child)]
pub focus_at_startup: bool,
#[knuffel(child)]
pub background_color: Option<Color>,
#[knuffel(child)]
pub backdrop_color: Option<Color>,
}
impl Output {
pub fn is_vrr_always_on(&self) -> bool {
self.variable_refresh_rate == Some(Vrr { on_demand: false })
}
pub fn is_vrr_on_demand(&self) -> bool {
self.variable_refresh_rate == Some(Vrr { on_demand: true })
}
pub fn is_vrr_always_off(&self) -> bool {
self.variable_refresh_rate.is_none()
}
}
impl Default for Output {
fn default() -> Self {
Self {
off: false,
focus_at_startup: false,
name: String::new(),
scale: None,
transform: Transform::Normal,
position: None,
mode: None,
variable_refresh_rate: None,
background_color: None,
backdrop_color: None,
}
}
}
#[derive(Debug, Clone)]
pub struct OutputName {
pub connector: String,
pub make: Option<String>,
pub model: Option<String>,
pub serial: Option<String>,
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
#[knuffel(property)]
pub x: i32,
#[knuffel(property)]
pub y: i32,
}
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Default)]
pub struct Vrr {
#[knuffel(property, default = false)]
pub on_demand: bool,
}
// MIN and MAX generics are only used during parsing to check the value.
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct FloatOrInt<const MIN: i32, const MAX: i32>(pub f64);
@@ -2263,129 +2184,6 @@ fn expect_only_children<S>(
}
}
impl FromIterator<Output> for Outputs {
fn from_iter<T: IntoIterator<Item = Output>>(iter: T) -> Self {
Self(Vec::from_iter(iter))
}
}
impl Outputs {
pub fn find(&self, name: &OutputName) -> Option<&Output> {
self.0.iter().find(|o| name.matches(&o.name))
}
pub fn find_mut(&mut self, name: &OutputName) -> Option<&mut Output> {
self.0.iter_mut().find(|o| name.matches(&o.name))
}
}
impl OutputName {
pub fn from_ipc_output(output: &niri_ipc::Output) -> Self {
Self {
connector: output.name.clone(),
make: (output.make != "Unknown").then(|| output.make.clone()),
model: (output.model != "Unknown").then(|| output.model.clone()),
serial: output.serial.clone(),
}
}
/// Returns an output description matching what Smithay's `Output::new()` does.
pub fn format_description(&self) -> String {
format!(
"{} - {} - {}",
self.make.as_deref().unwrap_or("Unknown"),
self.model.as_deref().unwrap_or("Unknown"),
self.connector,
)
}
/// Returns an output name that will match by make/model/serial or, if they are missing, by
/// connector.
pub fn format_make_model_serial_or_connector(&self) -> String {
if self.make.is_none() && self.model.is_none() && self.serial.is_none() {
self.connector.to_string()
} else {
self.format_make_model_serial()
}
}
pub fn format_make_model_serial(&self) -> String {
let make = self.make.as_deref().unwrap_or("Unknown");
let model = self.model.as_deref().unwrap_or("Unknown");
let serial = self.serial.as_deref().unwrap_or("Unknown");
format!("{make} {model} {serial}")
}
pub fn matches(&self, target: &str) -> bool {
// Match by connector.
if target.eq_ignore_ascii_case(&self.connector) {
return true;
}
// If no other fields are available, don't try to match by them.
//
// This is used by niri msg output.
if self.make.is_none() && self.model.is_none() && self.serial.is_none() {
return false;
}
// Match by "make model serial" with Unknown if something is missing.
let make = self.make.as_deref().unwrap_or("Unknown");
let model = self.model.as_deref().unwrap_or("Unknown");
let serial = self.serial.as_deref().unwrap_or("Unknown");
let Some(target_make) = target.get(..make.len()) else {
return false;
};
let rest = &target[make.len()..];
if !target_make.eq_ignore_ascii_case(make) {
return false;
}
if !rest.starts_with(' ') {
return false;
}
let rest = &rest[1..];
let Some(target_model) = rest.get(..model.len()) else {
return false;
};
let rest = &rest[model.len()..];
if !target_model.eq_ignore_ascii_case(model) {
return false;
}
if !rest.starts_with(' ') {
return false;
}
let rest = &rest[1..];
if !rest.eq_ignore_ascii_case(serial) {
return false;
}
true
}
// Similar in spirit to Ord, but I don't want to derive Eq to avoid mistakes (you should use
// `Self::match`, not Eq).
pub fn compare(&self, other: &Self) -> std::cmp::Ordering {
let self_missing_mms = self.make.is_none() && self.model.is_none() && self.serial.is_none();
let other_missing_mms =
other.make.is_none() && other.model.is_none() && other.serial.is_none();
match (self_missing_mms, other_missing_mms) {
(true, true) => self.connector.cmp(&other.connector),
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => self
.make
.cmp(&other.make)
.then_with(|| self.model.cmp(&other.model))
.then_with(|| self.serial.cmp(&other.serial))
.then_with(|| self.connector.cmp(&other.connector)),
}
}
}
impl<S> knuffel::Decode<S> for DefaultPresetSize
where
S: knuffel::traits::ErrorSpan,
@@ -4601,32 +4399,6 @@ mod tests {
let _ = Config::default();
}
#[test]
fn parse_mode() {
assert_eq!(
"2560x1600@165.004".parse::<ConfiguredMode>().unwrap(),
ConfiguredMode {
width: 2560,
height: 1600,
refresh: Some(165.004),
},
);
assert_eq!(
"1920x1080".parse::<ConfiguredMode>().unwrap(),
ConfiguredMode {
width: 1920,
height: 1080,
refresh: None,
},
);
assert!("1920".parse::<ConfiguredMode>().is_err());
assert!("1920x".parse::<ConfiguredMode>().is_err());
assert!("1920x1080@".parse::<ConfiguredMode>().is_err());
assert!("1920x1080@60Hz".parse::<ConfiguredMode>().is_err());
}
#[test]
fn parse_size_change() {
assert_eq!(
@@ -4795,125 +4567,6 @@ mod tests {
assert_eq!(config.input.keyboard.repeat_rate, 25);
}
fn make_output_name(
connector: &str,
make: Option<&str>,
model: Option<&str>,
serial: Option<&str>,
) -> OutputName {
OutputName {
connector: connector.to_string(),
make: make.map(|x| x.to_string()),
model: model.map(|x| x.to_string()),
serial: serial.map(|x| x.to_string()),
}
}
#[test]
fn test_output_name_match() {
fn check(
target: &str,
connector: &str,
make: Option<&str>,
model: Option<&str>,
serial: Option<&str>,
) -> bool {
let name = make_output_name(connector, make, model, serial);
name.matches(target)
}
assert!(check("dp-2", "DP-2", None, None, None));
assert!(!check("dp-1", "DP-2", None, None, None));
assert!(check("dp-2", "DP-2", Some("a"), Some("b"), Some("c")));
assert!(check(
"some company some monitor 1234",
"DP-2",
Some("Some Company"),
Some("Some Monitor"),
Some("1234")
));
assert!(!check(
"some other company some monitor 1234",
"DP-2",
Some("Some Company"),
Some("Some Monitor"),
Some("1234")
));
assert!(!check(
"make model serial ",
"DP-2",
Some("make"),
Some("model"),
Some("serial")
));
assert!(check(
"make serial",
"DP-2",
Some("make"),
Some(""),
Some("serial")
));
assert!(check(
"make model unknown",
"DP-2",
Some("Make"),
Some("Model"),
None
));
assert!(check(
"unknown unknown serial",
"DP-2",
None,
None,
Some("Serial")
));
assert!(!check("unknown unknown unknown", "DP-2", None, None, None));
}
#[test]
fn test_output_name_sorting() {
let mut names = vec![
make_output_name("DP-2", None, None, None),
make_output_name("DP-1", None, None, None),
make_output_name("DP-3", Some("B"), Some("A"), Some("A")),
make_output_name("DP-3", Some("A"), Some("B"), Some("A")),
make_output_name("DP-3", Some("A"), Some("A"), Some("B")),
make_output_name("DP-3", None, Some("A"), Some("A")),
make_output_name("DP-3", Some("A"), None, Some("A")),
make_output_name("DP-3", Some("A"), Some("A"), None),
make_output_name("DP-5", Some("A"), Some("A"), Some("A")),
make_output_name("DP-4", Some("A"), Some("A"), Some("A")),
];
names.sort_by(|a, b| a.compare(b));
let names = names
.into_iter()
.map(|name| {
format!(
"{} | {}",
name.format_make_model_serial_or_connector(),
name.connector,
)
})
.collect::<Vec<_>>();
assert_debug_snapshot!(
names,
@r#"
[
"Unknown A A | DP-3",
"A Unknown A | DP-3",
"A A Unknown | DP-3",
"A A A | DP-4",
"A A A | DP-5",
"A A B | DP-3",
"A B A | DP-3",
"B A A | DP-3",
"DP-1 | DP-1",
"DP-2 | DP-2",
]
"#
);
}
#[test]
fn test_border_rule_on_off_merging() {
fn is_on(config: &str, rules: &[&str]) -> String {
+358
View File
@@ -0,0 +1,358 @@
use niri_ipc::{ConfiguredMode, Transform};
use crate::{Color, FloatOrInt};
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Outputs(pub Vec<Output>);
#[derive(knuffel::Decode, Debug, Clone, PartialEq)]
pub struct Output {
#[knuffel(child)]
pub off: bool,
#[knuffel(argument)]
pub name: String,
#[knuffel(child, unwrap(argument))]
pub scale: Option<FloatOrInt<0, 10>>,
#[knuffel(child, unwrap(argument, str), default = Transform::Normal)]
pub transform: Transform,
#[knuffel(child)]
pub position: Option<Position>,
#[knuffel(child, unwrap(argument, str))]
pub mode: Option<ConfiguredMode>,
#[knuffel(child)]
pub variable_refresh_rate: Option<Vrr>,
#[knuffel(child)]
pub focus_at_startup: bool,
#[knuffel(child)]
pub background_color: Option<Color>,
#[knuffel(child)]
pub backdrop_color: Option<Color>,
}
impl Output {
pub fn is_vrr_always_on(&self) -> bool {
self.variable_refresh_rate == Some(Vrr { on_demand: false })
}
pub fn is_vrr_on_demand(&self) -> bool {
self.variable_refresh_rate == Some(Vrr { on_demand: true })
}
pub fn is_vrr_always_off(&self) -> bool {
self.variable_refresh_rate.is_none()
}
}
impl Default for Output {
fn default() -> Self {
Self {
off: false,
focus_at_startup: false,
name: String::new(),
scale: None,
transform: Transform::Normal,
position: None,
mode: None,
variable_refresh_rate: None,
background_color: None,
backdrop_color: None,
}
}
}
#[derive(Debug, Clone)]
pub struct OutputName {
pub connector: String,
pub make: Option<String>,
pub model: Option<String>,
pub serial: Option<String>,
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
#[knuffel(property)]
pub x: i32,
#[knuffel(property)]
pub y: i32,
}
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Default)]
pub struct Vrr {
#[knuffel(property, default = false)]
pub on_demand: bool,
}
impl FromIterator<Output> for Outputs {
fn from_iter<T: IntoIterator<Item = Output>>(iter: T) -> Self {
Self(Vec::from_iter(iter))
}
}
impl Outputs {
pub fn find(&self, name: &OutputName) -> Option<&Output> {
self.0.iter().find(|o| name.matches(&o.name))
}
pub fn find_mut(&mut self, name: &OutputName) -> Option<&mut Output> {
self.0.iter_mut().find(|o| name.matches(&o.name))
}
}
impl OutputName {
pub fn from_ipc_output(output: &niri_ipc::Output) -> Self {
Self {
connector: output.name.clone(),
make: (output.make != "Unknown").then(|| output.make.clone()),
model: (output.model != "Unknown").then(|| output.model.clone()),
serial: output.serial.clone(),
}
}
/// Returns an output description matching what Smithay's `Output::new()` does.
pub fn format_description(&self) -> String {
format!(
"{} - {} - {}",
self.make.as_deref().unwrap_or("Unknown"),
self.model.as_deref().unwrap_or("Unknown"),
self.connector,
)
}
/// Returns an output name that will match by make/model/serial or, if they are missing, by
/// connector.
pub fn format_make_model_serial_or_connector(&self) -> String {
if self.make.is_none() && self.model.is_none() && self.serial.is_none() {
self.connector.to_string()
} else {
self.format_make_model_serial()
}
}
pub fn format_make_model_serial(&self) -> String {
let make = self.make.as_deref().unwrap_or("Unknown");
let model = self.model.as_deref().unwrap_or("Unknown");
let serial = self.serial.as_deref().unwrap_or("Unknown");
format!("{make} {model} {serial}")
}
pub fn matches(&self, target: &str) -> bool {
// Match by connector.
if target.eq_ignore_ascii_case(&self.connector) {
return true;
}
// If no other fields are available, don't try to match by them.
//
// This is used by niri msg output.
if self.make.is_none() && self.model.is_none() && self.serial.is_none() {
return false;
}
// Match by "make model serial" with Unknown if something is missing.
let make = self.make.as_deref().unwrap_or("Unknown");
let model = self.model.as_deref().unwrap_or("Unknown");
let serial = self.serial.as_deref().unwrap_or("Unknown");
let Some(target_make) = target.get(..make.len()) else {
return false;
};
let rest = &target[make.len()..];
if !target_make.eq_ignore_ascii_case(make) {
return false;
}
if !rest.starts_with(' ') {
return false;
}
let rest = &rest[1..];
let Some(target_model) = rest.get(..model.len()) else {
return false;
};
let rest = &rest[model.len()..];
if !target_model.eq_ignore_ascii_case(model) {
return false;
}
if !rest.starts_with(' ') {
return false;
}
let rest = &rest[1..];
if !rest.eq_ignore_ascii_case(serial) {
return false;
}
true
}
// Similar in spirit to Ord, but I don't want to derive Eq to avoid mistakes (you should use
// `Self::match`, not Eq).
pub fn compare(&self, other: &Self) -> std::cmp::Ordering {
let self_missing_mms = self.make.is_none() && self.model.is_none() && self.serial.is_none();
let other_missing_mms =
other.make.is_none() && other.model.is_none() && other.serial.is_none();
match (self_missing_mms, other_missing_mms) {
(true, true) => self.connector.cmp(&other.connector),
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => self
.make
.cmp(&other.make)
.then_with(|| self.model.cmp(&other.model))
.then_with(|| self.serial.cmp(&other.serial))
.then_with(|| self.connector.cmp(&other.connector)),
}
}
}
#[cfg(test)]
mod tests {
use insta::assert_debug_snapshot;
use super::*;
#[test]
fn parse_mode() {
assert_eq!(
"2560x1600@165.004".parse::<ConfiguredMode>().unwrap(),
ConfiguredMode {
width: 2560,
height: 1600,
refresh: Some(165.004),
},
);
assert_eq!(
"1920x1080".parse::<ConfiguredMode>().unwrap(),
ConfiguredMode {
width: 1920,
height: 1080,
refresh: None,
},
);
assert!("1920".parse::<ConfiguredMode>().is_err());
assert!("1920x".parse::<ConfiguredMode>().is_err());
assert!("1920x1080@".parse::<ConfiguredMode>().is_err());
assert!("1920x1080@60Hz".parse::<ConfiguredMode>().is_err());
}
fn make_output_name(
connector: &str,
make: Option<&str>,
model: Option<&str>,
serial: Option<&str>,
) -> OutputName {
OutputName {
connector: connector.to_string(),
make: make.map(|x| x.to_string()),
model: model.map(|x| x.to_string()),
serial: serial.map(|x| x.to_string()),
}
}
#[test]
fn test_output_name_match() {
fn check(
target: &str,
connector: &str,
make: Option<&str>,
model: Option<&str>,
serial: Option<&str>,
) -> bool {
let name = make_output_name(connector, make, model, serial);
name.matches(target)
}
assert!(check("dp-2", "DP-2", None, None, None));
assert!(!check("dp-1", "DP-2", None, None, None));
assert!(check("dp-2", "DP-2", Some("a"), Some("b"), Some("c")));
assert!(check(
"some company some monitor 1234",
"DP-2",
Some("Some Company"),
Some("Some Monitor"),
Some("1234")
));
assert!(!check(
"some other company some monitor 1234",
"DP-2",
Some("Some Company"),
Some("Some Monitor"),
Some("1234")
));
assert!(!check(
"make model serial ",
"DP-2",
Some("make"),
Some("model"),
Some("serial")
));
assert!(check(
"make serial",
"DP-2",
Some("make"),
Some(""),
Some("serial")
));
assert!(check(
"make model unknown",
"DP-2",
Some("Make"),
Some("Model"),
None
));
assert!(check(
"unknown unknown serial",
"DP-2",
None,
None,
Some("Serial")
));
assert!(!check("unknown unknown unknown", "DP-2", None, None, None));
}
#[test]
fn test_output_name_sorting() {
let mut names = vec![
make_output_name("DP-2", None, None, None),
make_output_name("DP-1", None, None, None),
make_output_name("DP-3", Some("B"), Some("A"), Some("A")),
make_output_name("DP-3", Some("A"), Some("B"), Some("A")),
make_output_name("DP-3", Some("A"), Some("A"), Some("B")),
make_output_name("DP-3", None, Some("A"), Some("A")),
make_output_name("DP-3", Some("A"), None, Some("A")),
make_output_name("DP-3", Some("A"), Some("A"), None),
make_output_name("DP-5", Some("A"), Some("A"), Some("A")),
make_output_name("DP-4", Some("A"), Some("A"), Some("A")),
];
names.sort_by(|a, b| a.compare(b));
let names = names
.into_iter()
.map(|name| {
format!(
"{} | {}",
name.format_make_model_serial_or_connector(),
name.connector,
)
})
.collect::<Vec<_>>();
assert_debug_snapshot!(
names,
@r#"
[
"Unknown A A | DP-3",
"A Unknown A | DP-3",
"A A Unknown | DP-3",
"A A A | DP-4",
"A A A | DP-5",
"A A B | DP-3",
"A B A | DP-3",
"B A A | DP-3",
"DP-1 | DP-1",
"DP-2 | DP-2",
]
"#
);
}
}