From 65b9c74f624b4a78c2247135bf1a96dcac0bd009 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sun, 5 May 2024 10:19:47 +0400 Subject: Implement niri msg output --- niri-config/src/lib.rs | 64 ++++---------------- niri-ipc/src/lib.rs | 153 ++++++++++++++++++++++++++++++++++++++++++++++ src/backend/tty.rs | 2 +- src/cli.rs | 17 +++++- src/ipc/client.rs | 9 +++ src/ipc/server.rs | 6 ++ src/niri.rs | 161 +++++++++++++++++++++++++++++++++++-------------- 7 files changed, 311 insertions(+), 101 deletions(-) diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 39784c4e..4aa1edad 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -11,7 +11,7 @@ use bitflags::bitflags; use knuffel::errors::DecodeError; use knuffel::Decode as _; use miette::{miette, Context, IntoDiagnostic, NarratableReportHandler}; -use niri_ipc::{LayoutSwitchTarget, SizeChange, Transform}; +use niri_ipc::{ConfiguredMode, LayoutSwitchTarget, SizeChange, Transform}; use regex::Regex; use smithay::input::keyboard::keysyms::KEY_NoSymbol; use smithay::input::keyboard::xkb::{keysym_from_name, KEYSYM_CASE_INSENSITIVE}; @@ -250,7 +250,7 @@ pub struct Output { #[knuffel(child)] pub position: Option, #[knuffel(child, unwrap(argument, str))] - pub mode: Option, + pub mode: Option, #[knuffel(child)] pub variable_refresh_rate: bool, } @@ -277,13 +277,6 @@ pub struct Position { pub y: i32, } -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Mode { - pub width: u16, - pub height: u16, - pub refresh: Option, -} - #[derive(knuffel::Decode, Debug, Clone, PartialEq)] pub struct Layout { #[knuffel(child, default)] @@ -1971,41 +1964,6 @@ where } } -impl FromStr for Mode { - type Err = miette::Error; - - fn from_str(s: &str) -> Result { - let Some((width, rest)) = s.split_once('x') else { - return Err(miette!("no 'x' separator found")); - }; - - let (height, refresh) = match rest.split_once('@') { - Some((height, refresh)) => (height, Some(refresh)), - None => (rest, None), - }; - - let width = width - .parse() - .into_diagnostic() - .context("error parsing width")?; - let height = height - .parse() - .into_diagnostic() - .context("error parsing height")?; - let refresh = refresh - .map(str::parse) - .transpose() - .into_diagnostic() - .context("error parsing refresh rate")?; - - Ok(Self { - width, - height, - refresh, - }) - } -} - impl FromStr for Key { type Err = miette::Error; @@ -2336,7 +2294,7 @@ mod tests { scale: 2., transform: Transform::Flipped90, position: Some(Position { x: 10, y: 20 }), - mode: Some(Mode { + mode: Some(ConfiguredMode { width: 1920, height: 1080, refresh: Some(144.), @@ -2574,8 +2532,8 @@ mod tests { #[test] fn parse_mode() { assert_eq!( - "2560x1600@165.004".parse::().unwrap(), - Mode { + "2560x1600@165.004".parse::().unwrap(), + ConfiguredMode { width: 2560, height: 1600, refresh: Some(165.004), @@ -2583,18 +2541,18 @@ mod tests { ); assert_eq!( - "1920x1080".parse::().unwrap(), - Mode { + "1920x1080".parse::().unwrap(), + ConfiguredMode { width: 1920, height: 1080, refresh: None, }, ); - assert!("1920".parse::().is_err()); - assert!("1920x".parse::().is_err()); - assert!("1920x1080@".parse::().is_err()); - assert!("1920x1080@60Hz".parse::().is_err()); + assert!("1920".parse::().is_err()); + assert!("1920x".parse::().is_err()); + assert!("1920x1080@".parse::().is_err()); + assert!("1920x1080@60Hz".parse::().is_err()); } #[test] diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 74d04aa0..c721ac9b 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -20,6 +20,17 @@ pub enum Request { FocusedWindow, /// Perform an action. Action(Action), + /// Change output configuration temporarily. + /// + /// The configuration is changed temporarily and not saved into the config file. If the output + /// configuration subsequently changes in the config file, these temporary changes will be + /// forgotten. + Output { + /// Output name. + output: String, + /// Configuration to apply. + action: OutputAction, + }, /// Respond with an error (for testing error handling). ReturnError, } @@ -245,6 +256,103 @@ pub enum LayoutSwitchTarget { Prev, } +/// Output actions that niri can perform. +// Variants in this enum should match the spelling of the ones in niri-config. Most thigs from +// niri-config should be present here. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "clap", derive(clap::Parser))] +#[cfg_attr(feature = "clap", command(subcommand_value_name = "ACTION"))] +#[cfg_attr(feature = "clap", command(subcommand_help_heading = "Actions"))] +pub enum OutputAction { + /// Turn off the output. + Off, + /// Turn on the output. + On, + /// Set the output mode. + Mode { + /// Mode to set, or "auto" for automatic selection. + /// + /// Run `niri msg outputs` to see the avaliable modes. + #[cfg_attr(feature = "clap", arg())] + mode: ModeToSet, + }, + /// Set the output scale. + Scale { + /// Scale factor to set. + #[cfg_attr(feature = "clap", arg())] + scale: f64, + }, + /// Set the output transform. + Transform { + /// Transform to set, counter-clockwise. + #[cfg_attr(feature = "clap", arg())] + transform: Transform, + }, + /// Set the output position. + Position { + /// Position to set, or "auto" for automatic selection. + #[cfg_attr(feature = "clap", command(subcommand))] + position: PositionToSet, + }, + /// Toggle variable refresh rate. + Vrr { + /// Whether to enable variable refresh rate. + #[cfg_attr( + feature = "clap", + arg( + value_name = "ON|OFF", + action = clap::ArgAction::Set, + value_parser = clap::builder::BoolishValueParser::new(), + ), + )] + enable: bool, + }, +} + +/// Output mode to set. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] +pub enum ModeToSet { + /// Niri will pick the mode automatically. + Automatic, + /// Specific mode. + Specific(ConfiguredMode), +} + +/// Output mode as set in the config file. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] +pub struct ConfiguredMode { + /// Width in physical pixels. + pub width: u16, + /// Height in physical pixels. + pub height: u16, + /// Refresh rate. + pub refresh: Option, +} + +/// Output position to set. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "clap", derive(clap::Subcommand))] +#[cfg_attr(feature = "clap", command(subcommand_value_name = "POSITION"))] +#[cfg_attr(feature = "clap", command(subcommand_help_heading = "Position Values"))] +pub enum PositionToSet { + /// Position the output automatically. + #[cfg_attr(feature = "clap", command(name = "auto"))] + Automatic, + /// Set a specific position. + #[cfg_attr(feature = "clap", command(name = "set"))] + Specific(ConfiguredPosition), +} + +/// Output position as set in the config file. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "clap", derive(clap::Args))] +pub struct ConfiguredPosition { + /// Logical X position. + pub x: i32, + /// Logical Y position. + pub y: i32, +} + /// Connected output. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Output { @@ -304,6 +412,7 @@ pub struct LogicalOutput { /// Output transform, which goes counter-clockwise. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] pub enum Transform { /// Untransformed. Normal, @@ -319,10 +428,13 @@ pub enum Transform { /// Flipped horizontally. Flipped, /// Rotated by 90° and flipped horizontally. + #[cfg_attr(feature = "clap", value(name("flipped-90")))] Flipped90, /// Flipped vertically. + #[cfg_attr(feature = "clap", value(name("flipped-180")))] Flipped180, /// Rotated by 270° and flipped horizontally. + #[cfg_attr(feature = "clap", value(name("flipped-270")))] Flipped270, } @@ -407,3 +519,44 @@ impl FromStr for Transform { } } } + +impl FromStr for ModeToSet { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("auto") { + return Ok(Self::Automatic); + } + + let mode = s.parse()?; + Ok(Self::Specific(mode)) + } +} + +impl FromStr for ConfiguredMode { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let Some((width, rest)) = s.split_once('x') else { + return Err("no 'x' separator found"); + }; + + let (height, refresh) = match rest.split_once('@') { + Some((height, refresh)) => (height, Some(refresh)), + None => (rest, None), + }; + + let width = width.parse().map_err(|_| "error parsing width")?; + let height = height.parse().map_err(|_| "error parsing height")?; + let refresh = refresh + .map(str::parse) + .transpose() + .map_err(|_| "error parsing refresh rate")?; + + Ok(Self { + width, + height, + refresh, + }) + } +} diff --git a/src/backend/tty.rs b/src/backend/tty.rs index 327a5c97..4e7055ee 100644 --- a/src/backend/tty.rs +++ b/src/backend/tty.rs @@ -2111,7 +2111,7 @@ fn queue_estimated_vblank_timer( fn pick_mode( connector: &connector::Info, - target: Option, + target: Option, ) -> Option<(control::Mode, bool)> { let mut mode = None; let mut fallback = false; diff --git a/src/cli.rs b/src/cli.rs index 78f9fc0e..65e2fd14 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use std::path::PathBuf; use clap::{Parser, Subcommand}; -use niri_ipc::Action; +use niri_ipc::{Action, OutputAction}; use crate::utils::version; @@ -63,6 +63,21 @@ pub enum Msg { #[command(subcommand)] action: Action, }, + /// Change output configuration temporarily. + /// + /// The configuration is changed temporarily and not saved into the config file. If the output + /// configuration subsequently changes in the config file, these temporary changes will be + /// forgotten. + Output { + /// Output name. + /// + /// Run `niri msg outputs` to see the output names. + #[arg()] + output: String, + /// Configuration to apply. + #[command(subcommand)] + action: OutputAction, + }, /// Request an error from the running niri instance. RequestError, } diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 1704adfb..3aa5eb22 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -11,6 +11,10 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { Msg::Outputs => Request::Outputs, Msg::FocusedWindow => Request::FocusedWindow, Msg::Action { action } => Request::Action(action.clone()), + Msg::Output { output, action } => Request::Output { + output: output.clone(), + action: action.clone(), + }, Msg::RequestError => Request::ReturnError, }; @@ -237,6 +241,11 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { bail!("unexpected response: expected Handled, got {response:?}"); }; } + Msg::Output { .. } => { + let Response::Handled = response else { + bail!("unexpected response: expected Handled, got {response:?}"); + }; + } } Ok(()) diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 5e18c16a..59f929d8 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -169,6 +169,12 @@ fn process(ctx: &ClientCtx, request: Request) -> Reply { }); Response::Handled } + Request::Output { output, action } => { + ctx.event_loop.insert_idle(move |state| { + state.apply_transient_output_config(&output, action); + }); + Response::Handled + } }; Ok(response) diff --git a/src/niri.rs b/src/niri.rs index b1e2d917..b6bfb84d 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -138,6 +138,13 @@ const FRAME_CALLBACK_THROTTLE: Option = Some(Duration::from_millis(995 pub struct Niri { pub config: Rc>, + /// Output config from the config file. + /// + /// This does not include transient output config changes done via IPC. It is only used when + /// reloading the config from disk to determine if the output configuration should be reloaded + /// (and transient changes dropped). + pub config_file_output_config: Vec, + pub event_loop: LoopHandle<'static, State>, pub scheduler: Scheduler<()>, pub stop_signal: LoopSignal, @@ -858,6 +865,7 @@ impl State { let mut reload_xkb = None; let mut libinput_config_changed = false; let mut output_config_changed = false; + let mut preserved_output_config = None; let mut window_rules_changed = false; let mut debug_config_changed = false; let mut shaders_changed = false; @@ -894,8 +902,15 @@ impl State { libinput_config_changed = true; } - if config.outputs != old_config.outputs { + if config.outputs != self.niri.config_file_output_config { output_config_changed = true; + self.niri + .config_file_output_config + .clone_from(&config.outputs); + } else { + // Output config did not change from the last disk load, so we need to preserve the + // transient changes. + preserved_output_config = Some(mem::take(&mut old_config.outputs)); } if config.binds != old_config.binds { @@ -926,6 +941,10 @@ impl State { *old_config = config; + if let Some(outputs) = preserved_output_config { + old_config.outputs = outputs; + } + // Release the borrow. drop(old_config); @@ -945,51 +964,7 @@ impl State { } if output_config_changed { - let mut resized_outputs = vec![]; - for output in self.niri.global_space.outputs() { - let name = output.name(); - let config = self.niri.config.borrow_mut(); - let config = config.outputs.iter().find(|o| o.name == name); - - let scale = config.map(|c| c.scale).unwrap_or_else(|| { - let size_mm = output.physical_properties().size; - let resolution = output.current_mode().unwrap().size; - guess_monitor_scale(size_mm, resolution) - }); - let scale = scale.clamp(1., 10.).ceil() as i32; - - let mut transform = config - .map(|c| ipc_transform_to_smithay(c.transform)) - .unwrap_or(Transform::Normal); - // FIXME: fix winit damage on other transforms. - if name == "winit" { - transform = Transform::Flipped180; - } - - if output.current_scale().integer_scale() != scale - || output.current_transform() != transform - { - output.change_current_state( - None, - Some(transform), - Some(output::Scale::Integer(scale)), - None, - ); - self.niri.ipc_outputs_changed = true; - resized_outputs.push(output.clone()); - } - } - for output in resized_outputs { - self.niri.output_resized(&output); - } - - self.backend.on_output_config_changed(&mut self.niri); - - self.niri.reposition_outputs(None); - - if let Some(touch) = self.niri.seat.get_touch() { - touch.cancel(self); - } + self.reload_output_config(); } if debug_config_changed { @@ -1032,6 +1007,98 @@ impl State { self.niri.queue_redraw_all(); } + fn reload_output_config(&mut self) { + let mut resized_outputs = vec![]; + for output in self.niri.global_space.outputs() { + let name = output.name(); + let config = self.niri.config.borrow_mut(); + let config = config.outputs.iter().find(|o| o.name == name); + + let scale = config.map(|c| c.scale).unwrap_or_else(|| { + let size_mm = output.physical_properties().size; + let resolution = output.current_mode().unwrap().size; + guess_monitor_scale(size_mm, resolution) + }); + let scale = scale.clamp(1., 10.).ceil() as i32; + + let mut transform = config + .map(|c| ipc_transform_to_smithay(c.transform)) + .unwrap_or(Transform::Normal); + // FIXME: fix winit damage on other transforms. + if name == "winit" { + transform = Transform::Flipped180; + } + + if output.current_scale().integer_scale() != scale + || output.current_transform() != transform + { + output.change_current_state( + None, + Some(transform), + Some(output::Scale::Integer(scale)), + None, + ); + self.niri.ipc_outputs_changed = true; + resized_outputs.push(output.clone()); + } + } + for output in resized_outputs { + self.niri.output_resized(&output); + } + + self.backend.on_output_config_changed(&mut self.niri); + + self.niri.reposition_outputs(None); + + if let Some(touch) = self.niri.seat.get_touch() { + touch.cancel(self); + } + } + + pub fn apply_transient_output_config(&mut self, name: &str, action: niri_ipc::OutputAction) { + { + let mut config = self.niri.config.borrow_mut(); + let config = if let Some(config) = config.outputs.iter_mut().find(|o| o.name == name) { + config + } else { + config.outputs.push(niri_config::Output { + name: String::from(name), + ..Default::default() + }); + config.outputs.last_mut().unwrap() + }; + + match action { + niri_ipc::OutputAction::Off => config.off = true, + niri_ipc::OutputAction::On => config.off = false, + niri_ipc::OutputAction::Mode { mode } => { + config.mode = match mode { + niri_ipc::ModeToSet::Automatic => None, + niri_ipc::ModeToSet::Specific(mode) => Some(mode), + } + } + niri_ipc::OutputAction::Scale { scale } => config.scale = scale, + niri_ipc::OutputAction::Transform { transform } => config.transform = transform, + niri_ipc::OutputAction::Position { position } => { + config.position = match position { + niri_ipc::PositionToSet::Automatic => None, + niri_ipc::PositionToSet::Specific(position) => { + Some(niri_config::Position { + x: position.x, + y: position.y, + }) + } + } + } + niri_ipc::OutputAction::Vrr { enable } => { + config.variable_refresh_rate = enable; + } + } + } + + self.reload_output_config(); + } + pub fn refresh_ipc_outputs(&mut self) { if !self.niri.ipc_outputs_changed { return; @@ -1175,6 +1242,7 @@ impl Niri { let display_handle = display.handle(); let config_ = config.borrow(); + let config_file_output_config = config_.outputs.clone(); let layout = Layout::new(&config_); @@ -1353,6 +1421,7 @@ impl Niri { drop(config_); Self { config, + config_file_output_config, event_loop, scheduler, -- cgit