Add per-output layout config

This commit is contained in:
Ivan Molodetskikh
2025-09-25 18:15:46 +03:00
parent a5e285865b
commit 09cf8402c3
7 changed files with 134 additions and 18 deletions

View File

@@ -791,6 +791,7 @@ mod tests {
bottom_right: true, bottom_right: true,
}, },
), ),
layout: None,
}, },
], ],
), ),

View File

@@ -1,7 +1,7 @@
use niri_ipc::{ConfiguredMode, Transform}; use niri_ipc::{ConfiguredMode, Transform};
use crate::gestures::HotCorners; use crate::gestures::HotCorners;
use crate::{Color, FloatOrInt}; use crate::{Color, FloatOrInt, LayoutPart};
#[derive(Debug, Default, Clone, PartialEq)] #[derive(Debug, Default, Clone, PartialEq)]
pub struct Outputs(pub Vec<Output>); pub struct Outputs(pub Vec<Output>);
@@ -24,12 +24,15 @@ pub struct Output {
pub variable_refresh_rate: Option<Vrr>, pub variable_refresh_rate: Option<Vrr>,
#[knuffel(child)] #[knuffel(child)]
pub focus_at_startup: bool, pub focus_at_startup: bool,
// Deprecated; use layout.background_color.
#[knuffel(child)] #[knuffel(child)]
pub background_color: Option<Color>, pub background_color: Option<Color>,
#[knuffel(child)] #[knuffel(child)]
pub backdrop_color: Option<Color>, pub backdrop_color: Option<Color>,
#[knuffel(child)] #[knuffel(child)]
pub hot_corners: Option<HotCorners>, pub hot_corners: Option<HotCorners>,
#[knuffel(child)]
pub layout: Option<LayoutPart>,
} }
impl Output { impl Output {
@@ -60,6 +63,7 @@ impl Default for Output {
background_color: None, background_color: None,
backdrop_color: None, backdrop_color: None,
hot_corners: None, hot_corners: None,
layout: None,
} }
} }
} }

View File

@@ -72,7 +72,7 @@ impl Layout {
..Default::default() ..Default::default()
}; };
let mut layout = niri::layout::Layout::with_options(clock.clone(), options); 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(); let start_time = clock.now_unadjusted();

View File

@@ -39,7 +39,7 @@ use std::time::Duration;
use monitor::{InsertHint, InsertPosition, InsertWorkspace, MonitorAddWindowTarget}; use monitor::{InsertHint, InsertPosition, InsertWorkspace, MonitorAddWindowTarget};
use niri_config::utils::MergeWith as _; use niri_config::utils::MergeWith as _;
use niri_config::{ 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 niri_ipc::{ColumnDisplay, PositionChange, SizeChange, WindowLayout};
use scrolling::{Column, ColumnWidth}; use scrolling::{Column, ColumnWidth};
@@ -380,6 +380,10 @@ struct InteractiveMoveData<W: LayoutElement> {
/// ///
/// This helps the pointer remain inside the window as it resizes. /// This helps the pointer remain inside the window as it resizes.
pub(self) pointer_ratio_within_window: (f64, f64), 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<niri_config::LayoutPart>,
/// Config overrides for the workspace where the window is currently located. /// 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 /// To avoid sudden window changes when starting an interactive move, it will remember the
@@ -658,7 +662,7 @@ impl<W: LayoutElement> Layout<W> {
} }
} }
pub fn add_output(&mut self, output: Output) { pub fn add_output(&mut self, output: Output, layout_config: Option<LayoutPart>) {
self.monitor_set = match mem::take(&mut self.monitor_set) { self.monitor_set = match mem::take(&mut self.monitor_set) {
MonitorSet::Normal { MonitorSet::Normal {
mut monitors, mut monitors,
@@ -700,7 +704,7 @@ impl<W: LayoutElement> Layout<W> {
// workspaces set up across multiple monitors. Without this check, the // workspaces set up across multiple monitors. Without this check, the
// first monitor to connect can end up with the first empty workspace // first monitor to connect can end up with the first empty workspace
// focused instead of the first named 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 == 1)
{ {
primary.active_workspace_idx = primary.active_workspace_idx =
@@ -731,6 +735,7 @@ impl<W: LayoutElement> Layout<W> {
ws_id_to_activate, ws_id_to_activate,
self.clock.clone(), self.clock.clone(),
self.options.clone(), self.options.clone(),
layout_config,
); );
monitor.overview_open = self.overview_open; monitor.overview_open = self.overview_open;
monitor.set_overview_progress(self.overview_progress.as_ref()); monitor.set_overview_progress(self.overview_progress.as_ref());
@@ -751,6 +756,7 @@ impl<W: LayoutElement> Layout<W> {
ws_id_to_activate, ws_id_to_activate,
self.clock.clone(), self.clock.clone(),
self.options.clone(), self.options.clone(),
layout_config,
); );
monitor.overview_open = self.overview_open; monitor.overview_open = self.overview_open;
monitor.set_overview_progress(self.overview_progress.as_ref()); monitor.set_overview_progress(self.overview_progress.as_ref());
@@ -782,10 +788,16 @@ impl<W: LayoutElement> Layout<W> {
monitor.workspaces[monitor.active_workspace_idx].id(), monitor.workspaces[monitor.active_workspace_idx].id(),
); );
let workspaces = monitor.into_workspaces(); let mut workspaces = monitor.into_workspaces();
if monitors.is_empty() { if monitors.is_empty() {
// Removed the last monitor. // Removed the last monitor.
for ws in &mut workspaces {
// Reset base options to layout ones.
ws.update_config(self.options.clone());
}
MonitorSet::NoOutputs { workspaces } MonitorSet::NoOutputs { workspaces }
} else { } else {
if primary_idx >= idx { if primary_idx >= idx {
@@ -2310,6 +2322,7 @@ impl<W: LayoutElement> Layout<W> {
let scale = move_.output.current_scale().fractional_scale(); let scale = move_.output.current_scale().fractional_scale();
let options = Options::clone(&self.options) 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)) .with_merged_layout(move_.workspace_config.as_ref().map(|(_, c)| c))
.adjusted_for_scale(scale); .adjusted_for_scale(scale);
assert_eq!( assert_eq!(
@@ -2409,8 +2422,8 @@ impl<W: LayoutElement> Layout<W> {
for (idx, monitor) in monitors.iter().enumerate() { for (idx, monitor) in monitors.iter().enumerate() {
assert_eq!(self.clock, monitor.clock); assert_eq!(self.clock, monitor.clock);
assert_eq!( assert_eq!(
monitor.options, self.options, monitor.base_options, self.options,
"monitor options must be synchronized with layout" "monitor base options must be synchronized with layout"
); );
assert_eq!(self.overview_open, monitor.overview_open); assert_eq!(self.overview_open, monitor.overview_open);
@@ -2863,6 +2876,7 @@ impl<W: LayoutElement> Layout<W> {
let view_size = output_size(&move_.output); let view_size = output_size(&move_.output);
let scale = move_.output.current_scale().fractional_scale(); let scale = move_.output.current_scale().fractional_scale();
let options = Options::clone(&options) let options = Options::clone(&options)
.with_merged_layout(move_.output_config.as_ref())
.with_merged_layout(move_.workspace_config.as_ref().map(|(_, c)| c)) .with_merged_layout(move_.workspace_config.as_ref().map(|(_, c)| c))
.adjusted_for_scale(scale); .adjusted_for_scale(scale);
move_.tile.update_config(view_size, scale, Rc::new(options)); move_.tile.update_config(view_size, scale, Rc::new(options));
@@ -3786,6 +3800,11 @@ impl<W: LayoutElement> Layout<W> {
return true; 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 // 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 // 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 // to the pointer. Otherwise, we just teleport it as the layout code is not aware
@@ -3832,6 +3851,7 @@ impl<W: LayoutElement> Layout<W> {
let view_size = output_size(&output); let view_size = output_size(&output);
let scale = output.current_scale().fractional_scale(); let scale = output.current_scale().fractional_scale();
let options = Options::clone(&self.options) let options = Options::clone(&self.options)
.with_merged_layout(output_config.as_ref())
.with_merged_layout(workspace_config.as_ref().map(|(_, c)| c)) .with_merged_layout(workspace_config.as_ref().map(|(_, c)| c))
.adjusted_for_scale(scale); .adjusted_for_scale(scale);
tile.update_config(view_size, scale, Rc::new(options)); tile.update_config(view_size, scale, Rc::new(options));
@@ -3887,6 +3907,7 @@ impl<W: LayoutElement> Layout<W> {
is_full_width, is_full_width,
is_floating, is_floating,
pointer_ratio_within_window, pointer_ratio_within_window,
output_config,
workspace_config, workspace_config,
}; };
@@ -3930,6 +3951,11 @@ impl<W: LayoutElement> Layout<W> {
); );
move_.output = output.clone(); move_.output = output.clone();
self.focus_output(&output); self.focus_output(&output);
move_.output_config = self
.monitor_for_output(&output)
.and_then(|mon| mon.layout_config().cloned());
update_config = true; update_config = true;
} }
@@ -3937,6 +3963,7 @@ impl<W: LayoutElement> Layout<W> {
let view_size = output_size(&output); let view_size = output_size(&output);
let scale = output.current_scale().fractional_scale(); let scale = output.current_scale().fractional_scale();
let options = Options::clone(&self.options) 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)) .with_merged_layout(move_.workspace_config.as_ref().map(|(_, c)| c))
.adjusted_for_scale(scale); .adjusted_for_scale(scale);
move_.tile.update_config(view_size, scale, Rc::new(options)); move_.tile.update_config(view_size, scale, Rc::new(options));
@@ -4125,7 +4152,7 @@ impl<W: LayoutElement> Layout<W> {
.position(|ws| ws.id() == ws_id) .position(|ws| ws.id() == ws_id)
.unwrap(), .unwrap(),
InsertWorkspace::NewAt(ws_idx) => { 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. // Reuse the top empty workspace.
0 0
} else if mon.workspaces.len() - 1 <= ws_idx { } else if mon.workspaces.len() - 1 <= ws_idx {
@@ -4454,7 +4481,7 @@ impl<W: LayoutElement> Layout<W> {
} = &mut self.monitor_set } = &mut self.monitor_set
{ {
let monitor = &mut monitors[*active_monitor_idx]; let monitor = &mut monitors[*active_monitor_idx];
if self.options.layout.empty_workspace_above_first if monitor.options.layout.empty_workspace_above_first
&& monitor && monitor
.workspaces .workspaces
.first() .first()

View File

@@ -3,7 +3,7 @@ use std::iter::zip;
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
use niri_config::CornerRadius; use niri_config::{CornerRadius, LayoutPart};
use smithay::backend::renderer::element::utils::{ use smithay::backend::renderer::element::utils::{
CropRenderElement, Relocate, RelocateRenderElement, RescaleRenderElement, CropRenderElement, Relocate, RelocateRenderElement, RescaleRenderElement,
}; };
@@ -81,8 +81,12 @@ pub struct Monitor<W: LayoutElement> {
overview_progress: Option<OverviewProgress>, overview_progress: Option<OverviewProgress>,
/// Clock for driving animations. /// Clock for driving animations.
pub(super) clock: Clock, pub(super) clock: Clock,
/// Configurable properties of the layout as received from the parent layout.
pub(super) base_options: Rc<Options>,
/// Configurable properties of the layout. /// Configurable properties of the layout.
pub(super) options: Rc<Options>, pub(super) options: Rc<Options>,
/// Layout config overrides for this monitor.
layout_config: Option<niri_config::LayoutPart>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -280,8 +284,12 @@ impl<W: LayoutElement> Monitor<W> {
mut workspaces: Vec<Workspace<W>>, mut workspaces: Vec<Workspace<W>>,
ws_id_to_activate: Option<WorkspaceId>, ws_id_to_activate: Option<WorkspaceId>,
clock: Clock, clock: Clock,
options: Rc<Options>, base_options: Rc<Options>,
layout_config: Option<LayoutPart>,
) -> Self { ) -> Self {
let options =
Rc::new(Options::clone(&base_options).with_merged_layout(layout_config.as_ref()));
let scale = output.current_scale(); let scale = output.current_scale();
let view_size = output_size(&output); let view_size = output_size(&output);
let working_area = compute_working_area(&output); let working_area = compute_working_area(&output);
@@ -293,6 +301,7 @@ impl<W: LayoutElement> Monitor<W> {
assert!(ws.has_windows_or_name()); assert!(ws.has_windows_or_name());
ws.set_output(Some(output.clone())); ws.set_output(Some(output.clone()));
ws.update_config(options.clone());
if ws_id_to_activate.is_some_and(|id| ws.id() == id) { if ws_id_to_activate.is_some_and(|id| ws.id() == id) {
active_workspace_idx = idx; active_workspace_idx = idx;
@@ -324,7 +333,9 @@ impl<W: LayoutElement> Monitor<W> {
overview_progress: None, overview_progress: None,
workspace_switch: None, workspace_switch: None,
clock, clock,
base_options,
options, options,
layout_config,
} }
} }
@@ -666,6 +677,7 @@ impl<W: LayoutElement> Monitor<W> {
pub fn insert_workspace(&mut self, mut ws: Workspace<W>, mut idx: usize, activate: bool) { pub fn insert_workspace(&mut self, mut ws: Workspace<W>, mut idx: usize, activate: bool) {
ws.set_output(Some(self.output.clone())); ws.set_output(Some(self.output.clone()));
ws.update_config(self.options.clone());
// Don't insert past the last empty workspace. // Don't insert past the last empty workspace.
if idx == self.workspaces.len() { if idx == self.workspaces.len() {
@@ -699,6 +711,7 @@ impl<W: LayoutElement> Monitor<W> {
for ws in &mut workspaces { for ws in &mut workspaces {
ws.set_output(Some(self.output.clone())); 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; let empty_was_focused = self.active_workspace_idx == self.workspaces.len() - 1;
@@ -1151,7 +1164,10 @@ impl<W: LayoutElement> Monitor<W> {
} }
} }
pub fn update_config(&mut self, options: Rc<Options>) { pub fn update_config(&mut self, base_options: Rc<Options>) {
let options =
Rc::new(Options::clone(&base_options).with_merged_layout(self.layout_config.as_ref()));
if self.options.layout.empty_workspace_above_first if self.options.layout.empty_workspace_above_first
!= options.layout.empty_workspace_above_first != options.layout.empty_workspace_above_first
&& self.workspaces.len() > 1 && self.workspaces.len() > 1
@@ -1171,9 +1187,21 @@ impl<W: LayoutElement> Monitor<W> {
self.insert_hint_element self.insert_hint_element
.update_config(options.layout.insert_hint); .update_config(options.layout.insert_hint);
self.base_options = base_options;
self.options = options; self.options = options;
} }
pub fn update_layout_config(&mut self, layout_config: Option<niri_config::LayoutPart>) -> 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) { pub fn update_shaders(&mut self) {
for ws in &mut self.workspaces { for ws in &mut self.workspaces {
ws.update_shaders(); ws.update_shaders();
@@ -2017,10 +2045,18 @@ impl<W: LayoutElement> Monitor<W> {
self.working_area self.working_area
} }
pub fn layout_config(&self) -> Option<&niri_config::LayoutPart> {
self.layout_config.as_ref()
}
#[cfg(test)] #[cfg(test)]
pub(super) fn verify_invariants(&self) { pub(super) fn verify_invariants(&self) {
use approx::assert_abs_diff_eq; 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!( assert!(
!self.workspaces.is_empty(), !self.workspaces.is_empty(),
"monitor must have at least one workspace" "monitor must have at least one workspace"
@@ -2107,7 +2143,7 @@ impl<W: LayoutElement> Monitor<W> {
assert_eq!( assert_eq!(
workspace.base_options, self.options, workspace.base_options, self.options,
"workspace options must be synchronized with layout" "workspace options must be synchronized with monitor"
); );
} }

View File

@@ -406,9 +406,17 @@ enum Op {
id: usize, id: usize,
#[proptest(strategy = "arbitrary_scale()")] #[proptest(strategy = "arbitrary_scale()")]
scale: f64, scale: f64,
#[proptest(strategy = "prop::option::of(arbitrary_layout_part().prop_map(Box::new))")]
layout_config: Option<Box<niri_config::LayoutPart>>,
}, },
RemoveOutput(#[proptest(strategy = "1..=5usize")] usize), RemoveOutput(#[proptest(strategy = "1..=5usize")] usize),
FocusOutput(#[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<Box<niri_config::LayoutPart>>,
},
AddNamedWorkspace { AddNamedWorkspace {
#[proptest(strategy = "1..=5usize")] #[proptest(strategy = "1..=5usize")]
ws_name: usize, ws_name: usize,
@@ -771,9 +779,13 @@ impl Op {
model: None, model: None,
serial: 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}"); let name = format!("output{id}");
if layout.outputs().any(|o| o.name() == name) { if layout.outputs().any(|o| o.name() == name) {
return; return;
@@ -804,7 +816,7 @@ impl Op {
model: None, model: None,
serial: None, serial: None,
}); });
layout.add_output(output.clone()); layout.add_output(output.clone(), layout_config.map(|x| *x));
} }
Op::RemoveOutput(id) => { Op::RemoveOutput(id) => {
let name = format!("output{id}"); let name = format!("output{id}");
@@ -822,6 +834,14 @@ impl Op {
layout.focus_output(&output); 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 { Op::AddNamedWorkspace {
ws_name, ws_name,
output_name, output_name,

View File

@@ -1667,6 +1667,26 @@ impl State {
recolored_outputs.push(output.clone()); 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 { for output in resized_outputs {
@@ -2903,6 +2923,14 @@ impl Niri {
if name.connector == "winit" { if name.connector == "winit" {
transform = Transform::Flipped180; 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); drop(config);
// Set scale and transform before adding to the layout since that will read the output size. // Set scale and transform before adding to the layout since that will read the output size.
@@ -2913,7 +2941,7 @@ impl Niri {
None, None,
); );
self.layout.add_output(output.clone()); self.layout.add_output(output.clone(), layout_config);
let lock_render_state = if self.is_locked() { let lock_render_state = if self.is_locked() {
// We haven't rendered anything yet so it's as good as locked. // We haven't rendered anything yet so it's as good as locked.