Implement niri msg output

This commit is contained in:
Ivan Molodetskikh 2024-05-05 10:19:47 +04:00
parent 2dff674470
commit 65b9c74f62
7 changed files with 311 additions and 101 deletions

View File

@ -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<Position>,
#[knuffel(child, unwrap(argument, str))]
pub mode: Option<Mode>,
pub mode: Option<ConfiguredMode>,
#[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<f64>,
}
#[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<Self, Self::Err> {
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::<Mode>().unwrap(),
Mode {
"2560x1600@165.004".parse::<ConfiguredMode>().unwrap(),
ConfiguredMode {
width: 2560,
height: 1600,
refresh: Some(165.004),
@ -2583,18 +2541,18 @@ mod tests {
);
assert_eq!(
"1920x1080".parse::<Mode>().unwrap(),
Mode {
"1920x1080".parse::<ConfiguredMode>().unwrap(),
ConfiguredMode {
width: 1920,
height: 1080,
refresh: None,
},
);
assert!("1920".parse::<Mode>().is_err());
assert!("1920x".parse::<Mode>().is_err());
assert!("1920x1080@".parse::<Mode>().is_err());
assert!("1920x1080@60Hz".parse::<Mode>().is_err());
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]

View File

@ -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<f64>,
}
/// 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<Self, Self::Err> {
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<Self, Self::Err> {
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,
})
}
}

View File

@ -2111,7 +2111,7 @@ fn queue_estimated_vblank_timer(
fn pick_mode(
connector: &connector::Info,
target: Option<niri_config::Mode>,
target: Option<niri_ipc::ConfiguredMode>,
) -> Option<(control::Mode, bool)> {
let mut mode = None;
let mut fallback = false;

View File

@ -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,
}

View File

@ -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(())

View File

@ -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)

View File

@ -138,6 +138,13 @@ const FRAME_CALLBACK_THROTTLE: Option<Duration> = Some(Duration::from_millis(995
pub struct Niri {
pub config: Rc<RefCell<Config>>,
/// 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<niri_config::Output>,
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,6 +964,50 @@ impl State {
}
if output_config_changed {
self.reload_output_config();
}
if debug_config_changed {
self.backend.on_debug_config_changed();
}
if window_rules_changed {
let _span = tracy_client::span!("recompute window rules");
let window_rules = &self.niri.config.borrow().window_rules;
for unmapped in self.niri.unmapped_windows.values_mut() {
let new_rules =
ResolvedWindowRules::compute(window_rules, WindowRef::Unmapped(unmapped));
if let InitialConfigureState::Configured { rules, .. } = &mut unmapped.state {
*rules = new_rules;
}
}
let mut windows = vec![];
self.niri.layout.with_windows_mut(|mapped, _| {
if mapped.recompute_window_rules(window_rules) {
windows.push(mapped.window.clone());
}
});
for win in windows {
self.niri.layout.update_window(&win);
}
}
if shaders_changed {
self.niri.layout.update_shaders();
}
// Can't really update xdg-decoration settings since we have to hide the globals for CSD
// due to the SDL2 bug... I don't imagine clients are prepared for the xdg-decoration
// global suddenly appearing? Either way, right now it's live-reloaded in a sense that new
// clients will use the new xdg-decoration setting.
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();
@ -992,44 +1055,48 @@ impl State {
}
}
if debug_config_changed {
self.backend.on_debug_config_changed();
}
if window_rules_changed {
let _span = tracy_client::span!("recompute window rules");
let window_rules = &self.niri.config.borrow().window_rules;
for unmapped in self.niri.unmapped_windows.values_mut() {
let new_rules =
ResolvedWindowRules::compute(window_rules, WindowRef::Unmapped(unmapped));
if let InitialConfigureState::Configured { rules, .. } = &mut unmapped.state {
*rules = new_rules;
}
}
let mut windows = vec![];
self.niri.layout.with_windows_mut(|mapped, _| {
if mapped.recompute_window_rules(window_rules) {
windows.push(mapped.window.clone());
}
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()
});
for win in windows {
self.niri.layout.update_window(&win);
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;
}
}
}
if shaders_changed {
self.niri.layout.update_shaders();
}
// Can't really update xdg-decoration settings since we have to hide the globals for CSD
// due to the SDL2 bug... I don't imagine clients are prepared for the xdg-decoration
// global suddenly appearing? Either way, right now it's live-reloaded in a sense that new
// clients will use the new xdg-decoration setting.
self.niri.queue_redraw_all();
self.reload_output_config();
}
pub fn refresh_ipc_outputs(&mut self) {
@ -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,