diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index e969b250..4f2cf455 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -791,6 +791,7 @@ mod tests { bottom_right: true, }, ), + layout: None, }, ], ), diff --git a/niri-config/src/output.rs b/niri-config/src/output.rs index b0e1d26c..62c14705 100644 --- a/niri-config/src/output.rs +++ b/niri-config/src/output.rs @@ -1,7 +1,7 @@ use niri_ipc::{ConfiguredMode, Transform}; use crate::gestures::HotCorners; -use crate::{Color, FloatOrInt}; +use crate::{Color, FloatOrInt, LayoutPart}; #[derive(Debug, Default, Clone, PartialEq)] pub struct Outputs(pub Vec); @@ -24,12 +24,15 @@ pub struct Output { pub variable_refresh_rate: Option, #[knuffel(child)] pub focus_at_startup: bool, + // Deprecated; use layout.background_color. #[knuffel(child)] pub background_color: Option, #[knuffel(child)] pub backdrop_color: Option, #[knuffel(child)] pub hot_corners: Option, + #[knuffel(child)] + pub layout: Option, } impl Output { @@ -60,6 +63,7 @@ impl Default for Output { background_color: None, backdrop_color: None, hot_corners: None, + layout: None, } } } diff --git a/niri-visual-tests/src/cases/layout.rs b/niri-visual-tests/src/cases/layout.rs index b7f31dfb..5fa522d8 100644 --- a/niri-visual-tests/src/cases/layout.rs +++ b/niri-visual-tests/src/cases/layout.rs @@ -72,7 +72,7 @@ impl Layout { ..Default::default() }; let mut layout = niri::layout::Layout::with_options(clock.clone(), options); - layout.add_output(output.clone()); + layout.add_output(output.clone(), None); let start_time = clock.now_unadjusted(); diff --git a/src/layout/mod.rs b/src/layout/mod.rs index e6dacfde..09cc68c1 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -39,7 +39,7 @@ use std::time::Duration; use monitor::{InsertHint, InsertPosition, InsertWorkspace, MonitorAddWindowTarget}; use niri_config::utils::MergeWith as _; use niri_config::{ - Config, CornerRadius, PresetSize, Workspace as WorkspaceConfig, WorkspaceReference, + Config, CornerRadius, LayoutPart, PresetSize, Workspace as WorkspaceConfig, WorkspaceReference, }; use niri_ipc::{ColumnDisplay, PositionChange, SizeChange, WindowLayout}; use scrolling::{Column, ColumnWidth}; @@ -380,6 +380,10 @@ struct InteractiveMoveData { /// /// This helps the pointer remain inside the window as it resizes. pub(self) pointer_ratio_within_window: (f64, f64), + /// Config overrides for the output where the window is currently located. + /// + /// Cached here to be accessible while an output is removed. + pub(self) output_config: Option, /// Config overrides for the workspace where the window is currently located. /// /// To avoid sudden window changes when starting an interactive move, it will remember the @@ -658,7 +662,7 @@ impl Layout { } } - pub fn add_output(&mut self, output: Output) { + pub fn add_output(&mut self, output: Output, layout_config: Option) { self.monitor_set = match mem::take(&mut self.monitor_set) { MonitorSet::Normal { mut monitors, @@ -700,7 +704,7 @@ impl Layout { // workspaces set up across multiple monitors. Without this check, the // first monitor to connect can end up with the first empty workspace // focused instead of the first named workspace. - && !(self.options.layout.empty_workspace_above_first + && !(primary.options.layout.empty_workspace_above_first && primary.active_workspace_idx == 1) { primary.active_workspace_idx = @@ -731,6 +735,7 @@ impl Layout { ws_id_to_activate, self.clock.clone(), self.options.clone(), + layout_config, ); monitor.overview_open = self.overview_open; monitor.set_overview_progress(self.overview_progress.as_ref()); @@ -751,6 +756,7 @@ impl Layout { ws_id_to_activate, self.clock.clone(), self.options.clone(), + layout_config, ); monitor.overview_open = self.overview_open; monitor.set_overview_progress(self.overview_progress.as_ref()); @@ -782,10 +788,16 @@ impl Layout { monitor.workspaces[monitor.active_workspace_idx].id(), ); - let workspaces = monitor.into_workspaces(); + let mut workspaces = monitor.into_workspaces(); if monitors.is_empty() { // Removed the last monitor. + + for ws in &mut workspaces { + // Reset base options to layout ones. + ws.update_config(self.options.clone()); + } + MonitorSet::NoOutputs { workspaces } } else { if primary_idx >= idx { @@ -2310,6 +2322,7 @@ impl Layout { let scale = move_.output.current_scale().fractional_scale(); let options = Options::clone(&self.options) + .with_merged_layout(move_.output_config.as_ref()) .with_merged_layout(move_.workspace_config.as_ref().map(|(_, c)| c)) .adjusted_for_scale(scale); assert_eq!( @@ -2409,8 +2422,8 @@ impl Layout { for (idx, monitor) in monitors.iter().enumerate() { assert_eq!(self.clock, monitor.clock); assert_eq!( - monitor.options, self.options, - "monitor options must be synchronized with layout" + monitor.base_options, self.options, + "monitor base options must be synchronized with layout" ); assert_eq!(self.overview_open, monitor.overview_open); @@ -2863,6 +2876,7 @@ impl Layout { let view_size = output_size(&move_.output); let scale = move_.output.current_scale().fractional_scale(); let options = Options::clone(&options) + .with_merged_layout(move_.output_config.as_ref()) .with_merged_layout(move_.workspace_config.as_ref().map(|(_, c)| c)) .adjusted_for_scale(scale); move_.tile.update_config(view_size, scale, Rc::new(options)); @@ -3786,6 +3800,11 @@ impl Layout { return true; } + let output_config = self + .monitors() + .find(|mon| mon.output() == &output) + .and_then(|mon| mon.layout_config().cloned()); + // If the pointer is currently on the window's own output, then we can animate the // window movement from its current (rubberbanded and possibly moved away) position // to the pointer. Otherwise, we just teleport it as the layout code is not aware @@ -3832,6 +3851,7 @@ impl Layout { let view_size = output_size(&output); let scale = output.current_scale().fractional_scale(); let options = Options::clone(&self.options) + .with_merged_layout(output_config.as_ref()) .with_merged_layout(workspace_config.as_ref().map(|(_, c)| c)) .adjusted_for_scale(scale); tile.update_config(view_size, scale, Rc::new(options)); @@ -3887,6 +3907,7 @@ impl Layout { is_full_width, is_floating, pointer_ratio_within_window, + output_config, workspace_config, }; @@ -3930,6 +3951,11 @@ impl Layout { ); move_.output = output.clone(); self.focus_output(&output); + + move_.output_config = self + .monitor_for_output(&output) + .and_then(|mon| mon.layout_config().cloned()); + update_config = true; } @@ -3937,6 +3963,7 @@ impl Layout { let view_size = output_size(&output); let scale = output.current_scale().fractional_scale(); let options = Options::clone(&self.options) + .with_merged_layout(move_.output_config.as_ref()) .with_merged_layout(move_.workspace_config.as_ref().map(|(_, c)| c)) .adjusted_for_scale(scale); move_.tile.update_config(view_size, scale, Rc::new(options)); @@ -4125,7 +4152,7 @@ impl Layout { .position(|ws| ws.id() == ws_id) .unwrap(), InsertWorkspace::NewAt(ws_idx) => { - if self.options.layout.empty_workspace_above_first && ws_idx == 0 { + if mon.options.layout.empty_workspace_above_first && ws_idx == 0 { // Reuse the top empty workspace. 0 } else if mon.workspaces.len() - 1 <= ws_idx { @@ -4454,7 +4481,7 @@ impl Layout { } = &mut self.monitor_set { let monitor = &mut monitors[*active_monitor_idx]; - if self.options.layout.empty_workspace_above_first + if monitor.options.layout.empty_workspace_above_first && monitor .workspaces .first() diff --git a/src/layout/monitor.rs b/src/layout/monitor.rs index 54bcf9af..bb4b2ec8 100644 --- a/src/layout/monitor.rs +++ b/src/layout/monitor.rs @@ -3,7 +3,7 @@ use std::iter::zip; use std::rc::Rc; use std::time::Duration; -use niri_config::CornerRadius; +use niri_config::{CornerRadius, LayoutPart}; use smithay::backend::renderer::element::utils::{ CropRenderElement, Relocate, RelocateRenderElement, RescaleRenderElement, }; @@ -81,8 +81,12 @@ pub struct Monitor { overview_progress: Option, /// Clock for driving animations. pub(super) clock: Clock, + /// Configurable properties of the layout as received from the parent layout. + pub(super) base_options: Rc, /// Configurable properties of the layout. pub(super) options: Rc, + /// Layout config overrides for this monitor. + layout_config: Option, } #[derive(Debug)] @@ -280,8 +284,12 @@ impl Monitor { mut workspaces: Vec>, ws_id_to_activate: Option, clock: Clock, - options: Rc, + base_options: Rc, + layout_config: Option, ) -> Self { + let options = + Rc::new(Options::clone(&base_options).with_merged_layout(layout_config.as_ref())); + let scale = output.current_scale(); let view_size = output_size(&output); let working_area = compute_working_area(&output); @@ -293,6 +301,7 @@ impl Monitor { assert!(ws.has_windows_or_name()); ws.set_output(Some(output.clone())); + ws.update_config(options.clone()); if ws_id_to_activate.is_some_and(|id| ws.id() == id) { active_workspace_idx = idx; @@ -324,7 +333,9 @@ impl Monitor { overview_progress: None, workspace_switch: None, clock, + base_options, options, + layout_config, } } @@ -666,6 +677,7 @@ impl Monitor { pub fn insert_workspace(&mut self, mut ws: Workspace, mut idx: usize, activate: bool) { ws.set_output(Some(self.output.clone())); + ws.update_config(self.options.clone()); // Don't insert past the last empty workspace. if idx == self.workspaces.len() { @@ -699,6 +711,7 @@ impl Monitor { for ws in &mut workspaces { ws.set_output(Some(self.output.clone())); + ws.update_config(self.options.clone()); } let empty_was_focused = self.active_workspace_idx == self.workspaces.len() - 1; @@ -1151,7 +1164,10 @@ impl Monitor { } } - pub fn update_config(&mut self, options: Rc) { + pub fn update_config(&mut self, base_options: Rc) { + let options = + Rc::new(Options::clone(&base_options).with_merged_layout(self.layout_config.as_ref())); + if self.options.layout.empty_workspace_above_first != options.layout.empty_workspace_above_first && self.workspaces.len() > 1 @@ -1171,9 +1187,21 @@ impl Monitor { self.insert_hint_element .update_config(options.layout.insert_hint); + self.base_options = base_options; self.options = options; } + pub fn update_layout_config(&mut self, layout_config: Option) -> bool { + if self.layout_config == layout_config { + return false; + } + + self.layout_config = layout_config; + self.update_config(self.base_options.clone()); + + true + } + pub fn update_shaders(&mut self) { for ws in &mut self.workspaces { ws.update_shaders(); @@ -2017,10 +2045,18 @@ impl Monitor { self.working_area } + pub fn layout_config(&self) -> Option<&niri_config::LayoutPart> { + self.layout_config.as_ref() + } + #[cfg(test)] pub(super) fn verify_invariants(&self) { use approx::assert_abs_diff_eq; + let options = + Options::clone(&self.base_options).with_merged_layout(self.layout_config.as_ref()); + assert_eq!(&*self.options, &options); + assert!( !self.workspaces.is_empty(), "monitor must have at least one workspace" @@ -2107,7 +2143,7 @@ impl Monitor { assert_eq!( workspace.base_options, self.options, - "workspace options must be synchronized with layout" + "workspace options must be synchronized with monitor" ); } diff --git a/src/layout/tests.rs b/src/layout/tests.rs index 7d51b751..389408c2 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -406,9 +406,17 @@ enum Op { id: usize, #[proptest(strategy = "arbitrary_scale()")] scale: f64, + #[proptest(strategy = "prop::option::of(arbitrary_layout_part().prop_map(Box::new))")] + layout_config: Option>, }, RemoveOutput(#[proptest(strategy = "1..=5usize")] usize), FocusOutput(#[proptest(strategy = "1..=5usize")] usize), + UpdateOutputLayoutConfig { + #[proptest(strategy = "1..=5usize")] + id: usize, + #[proptest(strategy = "prop::option::of(arbitrary_layout_part().prop_map(Box::new))")] + layout_config: Option>, + }, AddNamedWorkspace { #[proptest(strategy = "1..=5usize")] ws_name: usize, @@ -771,9 +779,13 @@ impl Op { model: None, serial: None, }); - layout.add_output(output.clone()); + layout.add_output(output.clone(), None); } - Op::AddScaledOutput { id, scale } => { + Op::AddScaledOutput { + id, + scale, + layout_config, + } => { let name = format!("output{id}"); if layout.outputs().any(|o| o.name() == name) { return; @@ -804,7 +816,7 @@ impl Op { model: None, serial: None, }); - layout.add_output(output.clone()); + layout.add_output(output.clone(), layout_config.map(|x| *x)); } Op::RemoveOutput(id) => { let name = format!("output{id}"); @@ -822,6 +834,14 @@ impl Op { layout.focus_output(&output); } + Op::UpdateOutputLayoutConfig { id, layout_config } => { + let name = format!("output{id}"); + let Some(mon) = layout.monitors_mut().find(|m| m.output_name() == &name) else { + return; + }; + + mon.update_layout_config(layout_config.map(|x| *x)); + } Op::AddNamedWorkspace { ws_name, output_name, diff --git a/src/niri.rs b/src/niri.rs index 9351fb8e..939316f6 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -1667,6 +1667,26 @@ impl State { recolored_outputs.push(output.clone()); } } + + for mon in self.niri.layout.monitors_mut() { + if mon.output() != output { + continue; + } + + let mut layout_config = config.and_then(|c| c.layout.clone()); + // Support the deprecated non-layout background-color key. + if let Some(layout) = &mut layout_config { + if layout.background_color.is_none() { + layout.background_color = config.and_then(|c| c.background_color); + } + } + + if mon.update_layout_config(layout_config) { + // Also redraw these; if anything, the background color could've changed. + recolored_outputs.push(output.clone()); + } + break; + } } for output in resized_outputs { @@ -2903,6 +2923,14 @@ impl Niri { if name.connector == "winit" { transform = Transform::Flipped180; } + + let mut layout_config = c.and_then(|c| c.layout.clone()); + // Support the deprecated non-layout background-color key. + if let Some(layout) = &mut layout_config { + if layout.background_color.is_none() { + layout.background_color = c.and_then(|c| c.background_color); + } + } drop(config); // Set scale and transform before adding to the layout since that will read the output size. @@ -2913,7 +2941,7 @@ impl Niri { None, ); - self.layout.add_output(output.clone()); + self.layout.add_output(output.clone(), layout_config); let lock_render_state = if self.is_locked() { // We haven't rendered anything yet so it's as good as locked.