Add per-workspace layout config

Per-workspace background-color doesn't work yet.
This commit is contained in:
Ivan Molodetskikh
2025-09-20 09:37:52 +03:00
parent d015c7e55b
commit d5f4e79e4c
5 changed files with 233 additions and 29 deletions

View File

@@ -38,7 +38,7 @@ pub use crate::output::{Output, OutputName, Outputs, Position, Vrr};
pub use crate::utils::FloatOrInt;
use crate::utils::MergeWith as _;
pub use crate::window_rule::{FloatingPosition, RelativeTo, WindowRule};
pub use crate::workspace::Workspace;
pub use crate::workspace::{Workspace, WorkspaceLayoutPart};
#[derive(knuffel::Decode, Debug, PartialEq)]
pub struct Config {
@@ -1795,18 +1795,21 @@ mod tests {
open_on_output: Some(
"eDP-1",
),
layout: None,
},
Workspace {
name: WorkspaceName(
"workspace-2",
),
open_on_output: None,
layout: None,
},
Workspace {
name: WorkspaceName(
"workspace-3",
),
open_on_output: None,
layout: None,
},
],
}

View File

@@ -1,16 +1,50 @@
use knuffel::errors::DecodeError;
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)]
use crate::LayoutPart;
#[derive(knuffel::Decode, Debug, Clone, PartialEq)]
pub struct Workspace {
#[knuffel(argument)]
pub name: WorkspaceName,
#[knuffel(child, unwrap(argument))]
pub open_on_output: Option<String>,
#[knuffel(child)]
pub layout: Option<WorkspaceLayoutPart>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceName(pub String);
#[derive(Debug, Clone, PartialEq)]
pub struct WorkspaceLayoutPart(pub LayoutPart);
impl<S: knuffel::traits::ErrorSpan> knuffel::Decode<S> for WorkspaceLayoutPart {
fn decode_node(
node: &knuffel::ast::SpannedNode<S>,
ctx: &mut knuffel::decode::Context<S>,
) -> Result<Self, DecodeError<S>> {
for child in node.children() {
let name = &**child.node_name;
// Check for disallowed properties.
//
// - empty-workspace-above-first is a monitor-level concept.
// - insert-hint customization could make sense for workspaces, however currently it is
// also handled at the monitor level (since insert hints in-between workspaces are a
// monitor-level concept), so for now this config option would do nothing.
if matches!(name, "empty-workspace-above-first" | "insert-hint") {
ctx.emit_error(DecodeError::unexpected(
child,
"node",
format!("node `{name}` is not allowed inside `workspace.layout`"),
));
}
}
LayoutPart::decode_node(node, ctx).map(Self)
}
}
impl<S: knuffel::traits::ErrorSpan> knuffel::DecodeScalar<S> for WorkspaceName {
fn type_check(
type_name: &Option<knuffel::span::Spanned<knuffel::ast::TypeName, S>>,

View File

@@ -380,6 +380,12 @@ struct InteractiveMoveData<W: LayoutElement> {
///
/// This helps the pointer remain inside the window as it resizes.
pub(self) pointer_ratio_within_window: (f64, f64),
/// 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
/// config overrides for the workspace where the move originated from. As soon as the window
/// moves over some different workspace though, this override will reset.
pub(self) workspace_config: Option<(WorkspaceId, niri_config::LayoutPart)>,
}
#[derive(Debug)]
@@ -576,6 +582,13 @@ impl Options {
}
}
fn with_merged_layout(mut self, part: Option<&niri_config::LayoutPart>) -> Self {
if let Some(part) = part {
self.layout.merge_with(part);
}
self
}
fn adjusted_for_scale(mut self, scale: f64) -> Self {
let round = |logical: f64| round_logical_in_physical_max1(scale, logical);
@@ -2296,7 +2309,9 @@ impl<W: LayoutElement> Layout<W> {
move_.tile.verify_invariants();
let scale = move_.output.current_scale().fractional_scale();
let options = Options::clone(&self.options).adjusted_for_scale(scale);
let options = Options::clone(&self.options)
.with_merged_layout(move_.workspace_config.as_ref().map(|(_, c)| c))
.adjusted_for_scale(scale);
assert_eq!(
&*move_.tile.options, &options,
"interactive moved tile options must be \
@@ -2830,6 +2845,14 @@ impl<W: LayoutElement> Layout<W> {
}
pub fn update_config(&mut self, config: &Config) {
// Update workspace-specific config for all named workspaces.
for ws in self.workspaces_mut() {
let Some(name) = ws.name() else { continue };
if let Some(config) = config.workspaces.iter().find(|w| &w.name.0 == name) {
ws.update_layout_config(config.layout.clone().map(|x| x.0));
}
}
self.update_options(Options::from_config(config));
}
@@ -2839,11 +2862,10 @@ impl<W: LayoutElement> Layout<W> {
if let Some(InteractiveMoveState::Moving(move_)) = &mut self.interactive_move {
let view_size = output_size(&move_.output);
let scale = move_.output.current_scale().fractional_scale();
move_.tile.update_config(
view_size,
scale,
Rc::new(Options::clone(&options).adjusted_for_scale(scale)),
);
let options = Options::clone(&options)
.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));
}
match &mut self.monitor_set {
@@ -3737,15 +3759,17 @@ impl<W: LayoutElement> Layout<W> {
}
.band(sq_dist / INTERACTIVE_MOVE_START_THRESHOLD);
let (is_floating, tile) = self
let (is_floating, tile, workspace_config) = self
.workspaces_mut()
.find(|ws| ws.has_window(&window_id))
.map(|ws| {
let workspace_config = ws.layout_config().cloned().map(|c| (ws.id(), c));
(
ws.is_floating(&window_id),
ws.tiles_mut()
.find(|tile| *tile.window().id() == window_id)
.unwrap(),
workspace_config,
)
})
.unwrap();
@@ -3807,11 +3831,10 @@ impl<W: LayoutElement> Layout<W> {
let view_size = output_size(&output);
let scale = output.current_scale().fractional_scale();
tile.update_config(
view_size,
scale,
Rc::new(Options::clone(&self.options).adjusted_for_scale(scale)),
);
let options = Options::clone(&self.options)
.with_merged_layout(workspace_config.as_ref().map(|(_, c)| c))
.adjusted_for_scale(scale);
tile.update_config(view_size, scale, Rc::new(options));
// Unfullscreen.
let floating_size = tile.floating_window_size;
@@ -3864,6 +3887,7 @@ impl<W: LayoutElement> Layout<W> {
is_full_width,
is_floating,
pointer_ratio_within_window,
workspace_config,
};
if let Some((tile_pos, zoom)) = tile_pos {
@@ -3880,6 +3904,23 @@ impl<W: LayoutElement> Layout<W> {
return false;
}
let mut ws_id = None;
if let Some(mon) = self.monitor_for_output(&output) {
let (insert_ws, _) = mon.insert_position(move_.pointer_pos_within_output);
if let InsertWorkspace::Existing(id) = insert_ws {
ws_id = Some(id);
}
}
// If moved over a different workspace, reset the config override.
let mut update_config = false;
if let Some((id, _)) = &move_.workspace_config {
if Some(*id) != ws_id {
move_.workspace_config = None;
update_config = true;
}
}
if output != move_.output {
move_.tile.window().output_leave(&move_.output);
move_.tile.window().output_enter(&output);
@@ -3887,15 +3928,18 @@ impl<W: LayoutElement> Layout<W> {
output.current_scale(),
output.current_transform(),
);
let view_size = output_size(&output);
let scale = output.current_scale().fractional_scale();
move_.tile.update_config(
view_size,
scale,
Rc::new(Options::clone(&self.options).adjusted_for_scale(scale)),
);
move_.output = output.clone();
self.focus_output(&output);
update_config = true;
}
if update_config {
let view_size = output_size(&output);
let scale = output.current_scale().fractional_scale();
let options = Options::clone(&self.options)
.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));
}
move_.pointer_pos_within_output = pointer_pos_within_output;

View File

@@ -414,11 +414,19 @@ enum Op {
ws_name: usize,
#[proptest(strategy = "prop::option::of(1..=5usize)")]
output_name: Option<usize>,
#[proptest(strategy = "prop::option::of(arbitrary_layout_part().prop_map(Box::new))")]
layout_config: Option<Box<niri_config::LayoutPart>>,
},
UnnameWorkspace {
#[proptest(strategy = "1..=5usize")]
ws_name: usize,
},
UpdateWorkspaceLayoutConfig {
#[proptest(strategy = "1..=5usize")]
ws_name: usize,
#[proptest(strategy = "prop::option::of(arbitrary_layout_part().prop_map(Box::new))")]
layout_config: Option<Box<niri_config::LayoutPart>>,
},
AddWindow {
params: TestWindowParams,
},
@@ -817,15 +825,31 @@ impl Op {
Op::AddNamedWorkspace {
ws_name,
output_name,
layout_config,
} => {
layout.ensure_named_workspace(&WorkspaceConfig {
name: WorkspaceName(format!("ws{ws_name}")),
open_on_output: output_name.map(|name| format!("output{name}")),
layout: layout_config.map(|x| niri_config::WorkspaceLayoutPart(*x)),
});
}
Op::UnnameWorkspace { ws_name } => {
layout.unname_workspace(&format!("ws{ws_name}"));
}
Op::UpdateWorkspaceLayoutConfig {
ws_name,
layout_config,
} => {
let ws_name = format!("ws{ws_name}");
let Some(ws) = layout
.workspaces_mut()
.find(|ws| ws.name() == Some(&ws_name))
else {
return;
};
ws.update_layout_config(layout_config.map(|x| *x));
}
Op::SetWorkspaceName {
new_ws_name,
ws_name,
@@ -1607,6 +1631,7 @@ fn operations_dont_panic() {
Op::AddNamedWorkspace {
ws_name: 1,
output_name: Some(1),
layout_config: None,
},
Op::UnnameWorkspace { ws_name: 1 },
Op::AddWindow {
@@ -1754,6 +1779,7 @@ fn operations_from_starting_state_dont_panic() {
Op::AddNamedWorkspace {
ws_name: 1,
output_name: Some(1),
layout_config: None,
},
Op::UnnameWorkspace { ws_name: 1 },
Op::AddWindow {
@@ -2289,10 +2315,12 @@ fn removing_all_outputs_preserves_empty_named_workspaces() {
Op::AddNamedWorkspace {
ws_name: 1,
output_name: None,
layout_config: None,
},
Op::AddNamedWorkspace {
ws_name: 2,
output_name: None,
layout_config: None,
},
Op::RemoveOutput(1),
];
@@ -2655,6 +2683,7 @@ fn named_workspace_to_output() {
Op::AddNamedWorkspace {
ws_name: 1,
output_name: None,
layout_config: None,
},
Op::AddOutput(1),
Op::MoveWorkspaceToOutput(1),
@@ -2670,6 +2699,7 @@ fn named_workspace_to_output_ewaf() {
Op::AddNamedWorkspace {
ws_name: 1,
output_name: Some(2),
layout_config: None,
},
Op::AddOutput(1),
Op::AddOutput(2),
@@ -2897,6 +2927,65 @@ fn interactive_move_toggle_floating_ends_dnd_gesture() {
check_ops(ops);
}
#[test]
fn interactive_move_from_workspace_with_layout_config() {
let ops = [
Op::AddNamedWorkspace {
ws_name: 1,
output_name: Some(2),
layout_config: Some(Box::new(niri_config::LayoutPart {
border: Some(niri_config::BorderRule {
on: true,
..Default::default()
}),
..Default::default()
})),
},
Op::AddOutput(1),
Op::AddWindow {
params: TestWindowParams::new(2),
},
Op::InteractiveMoveBegin {
window: 2,
output_idx: 1,
px: 0.0,
py: 0.0,
},
Op::InteractiveMoveUpdate {
window: 2,
dx: 0.0,
dy: 3586.692842955048,
output_idx: 1,
px: 0.0,
py: 0.0,
},
// Now remove and add the output. It will have the same workspace.
Op::RemoveOutput(1),
Op::AddOutput(1),
Op::InteractiveMoveUpdate {
window: 2,
dx: 0.0,
dy: 0.0,
output_idx: 1,
px: 0.0,
py: 0.0,
},
// Now move onto a different workspace.
Op::FocusWorkspaceDown,
Op::CompleteAnimations,
Op::InteractiveMoveUpdate {
window: 2,
dx: 0.0,
dy: 0.0,
output_idx: 1,
px: 0.0,
py: 0.0,
},
];
check_ops(ops);
}
#[test]
fn set_width_fixed_negative() {
let ops = [

View File

@@ -99,6 +99,9 @@ pub struct Workspace<W: LayoutElement> {
/// Optional name of this workspace.
pub(super) name: Option<String>,
/// Layout config overrides for this workspace.
layout_config: Option<niri_config::LayoutPart>,
/// Unique ID of this workspace.
id: WorkspaceId,
}
@@ -202,7 +205,7 @@ impl<W: LayoutElement> Workspace<W> {
pub fn new_with_config(
output: Output,
config: Option<WorkspaceConfig>,
mut config: Option<WorkspaceConfig>,
clock: Clock,
base_options: Rc<Options>,
) -> Self {
@@ -212,9 +215,14 @@ impl<W: LayoutElement> Workspace<W> {
.map(OutputId)
.unwrap_or(OutputId::new(&output));
let layout_config = config.as_mut().and_then(|c| c.layout.take().map(|x| x.0));
let scale = output.current_scale();
let options =
Rc::new(Options::clone(&base_options).adjusted_for_scale(scale.fractional_scale()));
let options = Rc::new(
Options::clone(&base_options)
.with_merged_layout(layout_config.as_ref())
.adjusted_for_scale(scale.fractional_scale()),
);
let view_size = output_size(&output);
let working_area = compute_working_area(&output);
@@ -253,12 +261,13 @@ impl<W: LayoutElement> Workspace<W> {
base_options,
options,
name: config.map(|c| c.name.0),
layout_config,
id: WorkspaceId::next(),
}
}
pub fn new_with_config_no_outputs(
config: Option<WorkspaceConfig>,
mut config: Option<WorkspaceConfig>,
clock: Clock,
base_options: Rc<Options>,
) -> Self {
@@ -269,9 +278,14 @@ impl<W: LayoutElement> Workspace<W> {
.unwrap_or_default(),
);
let layout_config = config.as_mut().and_then(|c| c.layout.take().map(|x| x.0));
let scale = smithay::output::Scale::Integer(1);
let options =
Rc::new(Options::clone(&base_options).adjusted_for_scale(scale.fractional_scale()));
let options = Rc::new(
Options::clone(&base_options)
.with_merged_layout(layout_config.as_ref())
.adjusted_for_scale(scale.fractional_scale()),
);
let view_size = Size::from((1280., 720.));
let working_area = Rectangle::from_size(Size::from((1280., 720.)));
@@ -310,6 +324,7 @@ impl<W: LayoutElement> Workspace<W> {
base_options,
options,
name: config.map(|c| c.name.0),
layout_config,
id: WorkspaceId::next(),
}
}
@@ -370,7 +385,11 @@ impl<W: LayoutElement> Workspace<W> {
pub fn update_config(&mut self, base_options: Rc<Options>) {
let scale = self.scale.fractional_scale();
let options = Rc::new(Options::clone(&base_options).adjusted_for_scale(scale));
let options = Rc::new(
Options::clone(&base_options)
.with_merged_layout(self.layout_config.as_ref())
.adjusted_for_scale(scale),
);
self.scrolling.update_config(
self.view_size,
@@ -394,6 +413,15 @@ impl<W: LayoutElement> Workspace<W> {
self.options = options;
}
pub fn update_layout_config(&mut self, layout_config: Option<niri_config::LayoutPart>) {
if self.layout_config == layout_config {
return;
}
self.layout_config = layout_config;
self.update_config(self.base_options.clone());
}
pub fn update_shaders(&mut self) {
self.scrolling.update_shaders();
self.floating.update_shaders();
@@ -1770,6 +1798,10 @@ impl<W: LayoutElement> Workspace<W> {
self.working_area
}
pub fn layout_config(&self) -> Option<&niri_config::LayoutPart> {
self.layout_config.as_ref()
}
#[cfg(test)]
pub fn scrolling(&self) -> &ScrollingSpace<W> {
&self.scrolling
@@ -1788,7 +1820,9 @@ impl<W: LayoutElement> Workspace<W> {
assert!(scale > 0.);
assert!(scale.is_finite());
let options = Options::clone(&self.base_options).adjusted_for_scale(scale);
let options = Options::clone(&self.base_options)
.with_merged_layout(self.layout_config.as_ref())
.adjusted_for_scale(scale);
assert_eq!(
&*self.options, &options,
"options must be base options adjusted for scale"