From a1f0c0d19e7b7816afbcb108161fa85e2b955a2a Mon Sep 17 00:00:00 2001 From: polycatenane <221788427+polycatenane@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:14:48 -0700 Subject: [PATCH] container: flatten tree --- book/src/configuration/misc.md | 26 ++++++++ book/src/control-center.md | 5 ++ jay-config/src/_private/client.rs | 5 ++ jay-config/src/_private/ipc.rs | 4 ++ jay-config/src/lib.rs | 21 +++++++ src/compositor.rs | 1 + src/config/handler.rs | 14 +++++ src/control_center/cc_look_and_feel.rs | 19 ++++++ src/state.rs | 7 +++ src/tree.rs | 43 +++++++++++++ src/tree/container.rs | 61 +++++++++++++++---- src/tree/toplevel.rs | 3 +- toml-config/src/config.rs | 2 + toml-config/src/config/parsers.rs | 1 + toml-config/src/config/parsers/config.rs | 13 ++++ .../src/config/parsers/flatten_tree.rs | 35 +++++++++++ toml-config/src/lib.rs | 4 ++ toml-spec/spec/spec.generated.json | 13 ++++ toml-spec/spec/spec.generated.md | 34 +++++++++++ toml-spec/spec/spec.yaml | 26 ++++++++ 20 files changed, 323 insertions(+), 14 deletions(-) create mode 100644 toml-config/src/config/parsers/flatten_tree.rs diff --git a/book/src/configuration/misc.md b/book/src/configuration/misc.md index f21e7491a..a0e6cbeba 100644 --- a/book/src/configuration/misc.md +++ b/book/src/configuration/misc.md @@ -227,6 +227,32 @@ instead. split-reuses-container = false # default ``` +## Flatten Tree + +When a window is removed from a container (e.g. moved to another workspace or +floated), or when pressing split-vertical/split-horizontal on a single window, +the container may end up with only one child. By default the container is kept, +resulting in a redundant nesting. + +The `flatten-tree` setting controls when such containers are automatically +collapsed: + +`"never"` +: Never collapse single-child containers (default). + +`"always"` +: Collapse a single-child container both when its second-to-last child is + removed and when pressing split-vertical or split-horizontal on a sole + child. + +`"on-remove"` +: Only collapse when the second-to-last child is removed (e.g. moved to + another workspace or floated). + +```toml +flatten-tree = "on-remove" +``` + ## Middle-Click Paste Controls whether middle-clicking pastes the primary selection. Changing this diff --git a/book/src/control-center.md b/book/src/control-center.md index 3e2583e55..cb0c2a76a 100644 --- a/book/src/control-center.md +++ b/book/src/control-center.md @@ -479,6 +479,11 @@ Split Reuses Container direction instead of wrapping the window in a new container (see [Split Reuses Container](configuration/misc.md#split-reuses-container)) +Flatten Tree +: Dropdown to select when single-child containers are flattened (`Never`, + `Always`, or `On Remove`; see + [Flatten Tree](configuration/misc.md#flatten-tree)) + Font : Text field for the main compositor font family diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 74ff92c24..fe8cbd997 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -23,6 +23,7 @@ use crate::_private::logging; use crate::Axis; use crate::ContainerTarget; use crate::Direction; +use crate::FlattenTree; use crate::ModifiedKeySym; use crate::PciId; use crate::RelativeAxis; @@ -1288,6 +1289,10 @@ impl ConfigClient { self.send(&ClientMessage::SetMiddleClickPasteEnabled { enabled }); } + pub fn set_flatten_tree(&self, mode: FlattenTree) { + self.send(&ClientMessage::SetFlattenTree { mode }); + } + pub fn open_control_center(&self) { self.send(&ClientMessage::OpenControlCenter); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index ce0338f99..ea8810e5a 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -8,6 +8,7 @@ use crate::_private::WorkspaceShowOpV2; use crate::Axis; use crate::ContainerTarget; use crate::Direction; +use crate::FlattenTree; use crate::PciId; use crate::RelativeAxis; use crate::Workspace; @@ -1084,6 +1085,9 @@ pub enum ClientMessage<'a> { target: ContainerTarget, axis: RelativeAxis, }, + SetFlattenTree { + mode: FlattenTree, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index 33391ca62..ce5678c8f 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -87,6 +87,20 @@ pub enum Direction { Right, } +/// Configures how single-child containers are flattened. +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)] +#[non_exhaustive] +pub enum FlattenTree { + /// Never flatten single-child containers. + Never, + /// Always flatten single-child containers, including when pressing + /// split-vertical or split-horizontal on a sole child. + Always, + /// Only flatten when the second-to-last child is removed from a + /// container. + OnRemove, +} + /// A planar axis. #[derive(Serialize, Deserialize, Copy, Clone, Debug, Hash, Eq, PartialEq)] pub enum Axis { @@ -597,6 +611,13 @@ pub fn set_middle_click_paste_enabled(enabled: bool) { get!().set_middle_click_paste_enabled(enabled); } +/// Sets the tree flattening mode. +/// +/// The default is [`FlattenTree::Never`]. +pub fn set_flatten_tree(mode: FlattenTree) { + get!().set_flatten_tree(mode); +} + /// Opens the control center. pub fn open_control_center() { get!().open_control_center(); diff --git a/src/compositor.rs b/src/compositor.rs index cff01975d..7a61bbfa6 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -440,6 +440,7 @@ fn start_compositor2( color_management_enabled: Cell::new(false), color_manager, float_above_fullscreen: Cell::new(false), + flatten_tree: Default::default(), icons: Default::default(), show_pin_icon: Cell::new(false), cl_matcher_manager: Rc::new(ClMatcherManager::new(&crit_ids)), diff --git a/src/config/handler.rs b/src/config/handler.rs index 8042008e3..78a82bf83 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -99,6 +99,7 @@ use jay_config::_private::serialize_server_message; use jay_config::Axis; use jay_config::ContainerTarget as ConfigContainerTarget; use jay_config::Direction; +use jay_config::FlattenTree as ConfigFlattenTree; use jay_config::RelativeAxis as ConfigRelativeAxis; use jay_config::Workspace; use jay_config::WorkspaceKind; @@ -2943,6 +2944,14 @@ impl ConfigProxyHandler { self.state.set_primary_selection_enabled(enabled); } + fn handle_set_flatten_tree(&self, mode: ConfigFlattenTree) -> Result<(), CphError> { + let Ok(mode) = mode.try_into() else { + return Err(CphError::UnknownFlattenTree(mode)); + }; + self.state.set_flatten_tree(mode); + Ok(()) + } + fn handle_seat_create_mark(&self, seat: Seat, kc: Option) -> Result<(), CphError> { let seat = self.get_seat(seat)?; if let Some(kc) = kc { @@ -4056,6 +4065,9 @@ impl ConfigProxyHandler { ClientMessage::SetMiddleClickPasteEnabled { enabled } => { self.handle_set_middle_click_paste_enabled(enabled) } + ClientMessage::SetFlattenTree { mode } => { + self.handle_set_flatten_tree(mode).wrn("set_flatten_tree")? + } ClientMessage::SetWorkspaceDisplayOrder { order } => { self.handle_set_workspace_display_order(order) } @@ -4485,6 +4497,8 @@ enum CphError { UnknownContainerTarget(ConfigContainerTarget), #[error("Tried to use an unknown relative axis: {0:?}")] UnknownRelativeAxis(ConfigRelativeAxis), + #[error("Unknown flatten tree mode {0:?}")] + UnknownFlattenTree(ConfigFlattenTree), } trait WithRequestName { diff --git a/src/control_center/cc_look_and_feel.rs b/src/control_center/cc_look_and_feel.rs index e14d8423b..489352bc9 100644 --- a/src/control_center/cc_look_and_feel.rs +++ b/src/control_center/cc_look_and_feel.rs @@ -3,6 +3,7 @@ use crate::control_center::ControlCenterInner; use crate::control_center::bool; use crate::control_center::bool_ui; use crate::control_center::combo_box; +use crate::control_center::combo_box_ui; use crate::control_center::drag_value; use crate::control_center::grid; use crate::control_center::grid_label_ui; @@ -134,6 +135,24 @@ impl LookAndFeelPane { self.state.split_reuses_container.get(), |v| self.state.set_split_reuses_container(v), ); + combo_box_ui( + ui, + "Flatten Tree", + |ui| { + tip(ui, |ui| { + ui.label(concat!( + "Containers that are left with only one child are replaced by ", + "that child.", + )); + ui.label(concat!( + "`Always` additionally applies this when splitting the only ", + "window in a container.", + )); + }); + }, + self.state.flatten_tree.get(), + |v| self.state.set_flatten_tree(v), + ); row(ui, "Font", |ui| { let mut v = self.state.theme.font.get().to_string(); if text_edit(ui, &mut v).changed() { diff --git a/src/state.rs b/src/state.rs index efbc4337a..ff444bae0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -179,6 +179,7 @@ use crate::tree::ContainerSplit; use crate::tree::Direction; use crate::tree::DisplayNode; use crate::tree::FindTreeUsecase; +use crate::tree::FlattenTree; use crate::tree::FloatNode; use crate::tree::FoundNode; use crate::tree::LatchListener; @@ -394,6 +395,7 @@ pub struct State { pub color_management_enabled: Cell, pub color_manager: Rc, pub float_above_fullscreen: Cell, + pub flatten_tree: Cell, pub icons: Icons, pub show_pin_icon: Cell, pub cl_matcher_manager: Rc, @@ -2314,6 +2316,11 @@ impl State { self.root.update_visible(self); } + pub fn set_flatten_tree(&self, v: FlattenTree) { + self.flatten_tree.set(v); + self.trigger_cci(CCI_LOOK_AND_FEEL); + } + pub fn reset_sizes(self: &Rc) { self.theme.sizes.reset(LiveTL); self.spaces_changed(); diff --git a/src/tree.rs b/src/tree.rs index 8d507e545..ef657a2d1 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -45,6 +45,7 @@ pub use containing::*; pub use display::*; pub use float::*; use jay_config::Direction as JayDirection; +use jay_config::FlattenTree as ConfigFlattenTree; use jay_config::video::Transform as ConfigTransform; use jay_config::window::TileState as ConfigTileState; use jay_config::workspace::WorkspaceDisplayOrder as ConfigWorkspaceDisplayOrder; @@ -275,6 +276,48 @@ impl From for Direction { } } +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq, Default, Linearize)] +pub enum FlattenTree { + #[default] + Never, + Always, + OnRemove, +} + +impl TryFrom for FlattenTree { + type Error = (); + + fn try_from(value: ConfigFlattenTree) -> Result { + let v = match value { + ConfigFlattenTree::Never => FlattenTree::Never, + ConfigFlattenTree::Always => FlattenTree::Always, + ConfigFlattenTree::OnRemove => FlattenTree::OnRemove, + _ => return Err(()), + }; + Ok(v) + } +} + +impl Into for FlattenTree { + fn into(self) -> ConfigFlattenTree { + match self { + FlattenTree::Never => ConfigFlattenTree::Never, + FlattenTree::Always => ConfigFlattenTree::Always, + FlattenTree::OnRemove => ConfigFlattenTree::OnRemove, + } + } +} + +impl StaticText for FlattenTree { + fn text(&self) -> &'static str { + match self { + FlattenTree::Never => "Never", + FlattenTree::Always => "Always", + FlattenTree::OnRemove => "On Remove", + } + } +} + pub struct NodeIds { next: NumCell, } diff --git a/src/tree/container.rs b/src/tree/container.rs index e868314c3..65ef935a6 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -30,6 +30,7 @@ use crate::tree::ContainingNode; use crate::tree::Direction; use crate::tree::FindTreeResult; use crate::tree::FindTreeUsecase; +use crate::tree::FlattenTree; use crate::tree::FloatNode; use crate::tree::FoundNode; use crate::tree::Node; @@ -176,6 +177,17 @@ struct MainAxisRange { active: bool, } +/// The outcome of an attempt to replace a container by its sole remaining child. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum ReplaceWithSoleChild { + /// The container was replaced by the child and destroyed. + Flattened, + /// The parent does not accept the child. + Rejected, + /// The container has no parent, or is fullscreen. + NotApplicable, +} + #[derive(Default)] pub struct ContainerNodeState { pub split: Cell, @@ -371,6 +383,28 @@ impl ContainerNode { ref_ } + fn try_replace_with_sole_child( + self: &Rc, + child: Rc, + ) -> ReplaceWithSoleChild { + let Some(parent) = self.toplevel_data.parent.get() else { + return ReplaceWithSoleChild::NotApplicable; + }; + if self.toplevel_data.is_fullscreen[LiveTL].get() { + return ReplaceWithSoleChild::NotApplicable; + } + if !parent.cnode_accepts_child(&*child) { + return ReplaceWithSoleChild::Rejected; + } + parent.cnode_replace_child(self.deref(), child); + self.toplevel_data.parent.take(); + for cn in self.child_nodes.borrow_mut().drain_values() { + self.schedule_unlink_child(cn); + } + self.tl_destroy(); + ReplaceWithSoleChild::Flattened + } + pub fn prepend_child(self: &Rc, new: Rc) { if let Some(child) = self.children.first() { self.add_child_before_(&child, new); @@ -1365,19 +1399,9 @@ impl ContainerNode { // CASE 1: This is the only child of the container. Replace the container by the child. if ns.num_children.get() == 1 { - if let Some(parent) = self.toplevel_data.parent.get() - && !self.toplevel_data.is_fullscreen[LiveTL].get() - { - if parent.cnode_accepts_child(&*child) { - parent.cnode_replace_child(self.deref(), child.clone()); - self.toplevel_data.parent.take(); - for cn in self.child_nodes.borrow_mut().drain_values() { - self.schedule_unlink_child(cn); - } - self.tl_destroy(); - } else { - move_to_neighboring_output(child); - } + match self.try_replace_with_sole_child(child.clone()) { + ReplaceWithSoleChild::Flattened | ReplaceWithSoleChild::NotApplicable => {} + ReplaceWithSoleChild::Rejected => move_to_neighboring_output(child), } return; } @@ -2400,6 +2424,17 @@ impl ContainingNode for ContainerNode { } let node = self.schedule_unlink_child(node); let num_children = self.adj_ns_num_children(|value| value - 1); + if num_children == 1 + && matches!( + self.state.flatten_tree.get(), + FlattenTree::Always | FlattenTree::OnRemove + ) + && let Some(remaining) = self.children.first_valid(LiveTL) + && self.try_replace_with_sole_child(remaining.node.clone()) + == ReplaceWithSoleChild::Flattened + { + return; + } if num_children == 0 { self.tl_destroy(); return; diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index ae7a02a97..dbb8c574d 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -38,6 +38,7 @@ use crate::tree::ContainerSplit; use crate::tree::ContainerTarget; use crate::tree::ContainingNode; use crate::tree::Direction; +use crate::tree::FlattenTree; use crate::tree::FloatNode; use crate::tree::Node; use crate::tree::NodeBase; @@ -1222,7 +1223,7 @@ pub fn toplevel_create_split(state: &Rc, tl: Rc, axis: Some(ws) => ws, _ => return, }; - if state.split_reuses_container.get() + if (state.split_reuses_container.get() || state.flatten_tree.get() == FlattenTree::Always) && let Some(pn) = toplevel_parent_container(&*tl) && pn.node_state[LiveTL].num_children.get() == 1 { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index cbfe48196..485dc3ac7 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -30,6 +30,7 @@ use counter::CounterSlot; use jay_config::Axis; use jay_config::ContainerTarget; use jay_config::Direction; +use jay_config::FlattenTree; use jay_config::RelativeAxis; use jay_config::Workspace; use jay_config::client::ClientCapabilities; @@ -634,6 +635,7 @@ pub struct Config { pub show_bar: Option, pub split_reuses_container: Option, pub show_titles: Option, + pub flatten_tree: Option, pub focus_history: Option, pub middle_click_paste: Option, pub input_modes: AHashMap, diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index f72a80e8e..3b78f3517 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -31,6 +31,7 @@ mod egui; mod env; pub mod exec; mod fallback_output_mode; +mod flatten_tree; pub mod float; pub mod focus_history; mod format; diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index bbdb62a0b..32f9d2e3e 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -30,6 +30,7 @@ use crate::config::parsers::drm_device_match::DrmDeviceMatchParser; use crate::config::parsers::egui::EguiParser; use crate::config::parsers::env::EnvParser; use crate::config::parsers::fallback_output_mode::FallbackOutputModeParser; +use crate::config::parsers::flatten_tree::FlattenTreeParser; use crate::config::parsers::float::FloatParser; use crate::config::parsers::focus_history::FocusHistoryParser; use crate::config::parsers::gfx_api::GfxApiParser; @@ -177,6 +178,7 @@ impl Parser for ConfigParser<'_, '_, '_> { split_reuses_container, triggers_val, max_trigger_depth_val, + flatten_tree_val, ), ) = ext.extract(( ( @@ -247,6 +249,7 @@ impl Parser for ConfigParser<'_, '_, '_> { recover(opt(bol("split-reuses-container"))), opt(val("triggers")), opt(int("max-trigger-depth")), + opt(val("flatten-tree")), ), ))?; let mut keymap = None; @@ -661,6 +664,15 @@ impl Parser for ConfigParser<'_, '_, '_> { } max_trigger_depth = value.value as _; } + let mut flatten_tree = None; + if let Some(value) = flatten_tree_val { + match value.parse(&mut FlattenTreeParser) { + Ok(v) => flatten_tree = Some(v), + Err(e) => { + log::warn!("Could not parse flatten-tree: {}", self.0.error(e)); + } + } + } Ok(Config { keymap, repeat_rate, @@ -706,6 +718,7 @@ impl Parser for ConfigParser<'_, '_, '_> { show_bar: show_bar.despan(), split_reuses_container: split_reuses_container.despan(), show_titles: show_titles.despan(), + flatten_tree, focus_history, middle_click_paste: middle_click_paste.despan(), input_modes, diff --git a/toml-config/src/config/parsers/flatten_tree.rs b/toml-config/src/config/parsers/flatten_tree.rs new file mode 100644 index 000000000..3a626cf8c --- /dev/null +++ b/toml-config/src/config/parsers/flatten_tree.rs @@ -0,0 +1,35 @@ +use crate::config::parser::DataType; +use crate::config::parser::ParseResult; +use crate::config::parser::Parser; +use crate::config::parser::UnexpectedDataType; +use crate::toml::toml_span::Span; +use crate::toml::toml_span::SpannedExt; +use jay_config::FlattenTree; +use thiserror::Error; + +pub struct FlattenTreeParser; + +#[derive(Debug, Error)] +pub enum FlattenTreeParserError { + #[error(transparent)] + DataType(#[from] UnexpectedDataType), + #[error("Unknown flatten-tree value {0}")] + Unknown(String), +} + +impl Parser for FlattenTreeParser { + type Value = FlattenTree; + type Error = FlattenTreeParserError; + const EXPECTED: &'static [DataType] = &[DataType::String]; + + fn parse_string(&mut self, span: Span, string: &str) -> ParseResult { + use FlattenTree::*; + let mode = match string.to_ascii_lowercase().as_str() { + "always" => Always, + "on-remove" => OnRemove, + "never" => Never, + _ => return Err(FlattenTreeParserError::Unknown(string.to_string()).spanned(span)), + }; + Ok(mode) + } +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index a3ee4b975..4c0a2d9c6 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -79,6 +79,7 @@ use jay_config::set_color_management_enabled; use jay_config::set_configure_timeout; use jay_config::set_default_workspace_capture; use jay_config::set_explicit_sync_enabled; +use jay_config::set_flatten_tree; use jay_config::set_float_above_fullscreen; use jay_config::set_idle; use jay_config::set_idle_grace_period; @@ -1898,6 +1899,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc +### `FlattenTree` + +Configures when single-child containers are automatically collapsed. + +Values of this type should be strings. + +The string should have one of the following values: + +- `never`: + + Never collapse single-child containers. + +- `always`: + + Collapse a single-child container both when its second-to-last + child is removed and when pressing split-vertical or + split-horizontal on a sole child. + +- `on-remove`: + + Only collapse when the second-to-last child is removed (e.g. + moved to another workspace or floated). + + + ### `Float` diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 134b9e893..b34f1b342 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -2365,6 +2365,25 @@ LogLevel: description: Error log level. +FlattenTree: + kind: string + description: | + Configures when single-child containers are automatically collapsed. + values: + - value: never + description: | + Never collapse single-child containers. + - value: always + description: | + Collapse a single-child container both when its second-to-last + child is removed and when pressing split-vertical or + split-horizontal on a sole child. + - value: on-remove + description: | + Only collapse when the second-to-last child is removed (e.g. + moved to another workspace or floated). + + Mode: kind: table description: | @@ -3573,6 +3592,13 @@ Config: Configures whether title bars on windows are shown. The default is `true`. + flatten-tree: + ref: FlattenTree + required: false + description: | + Configures when single-child containers are automatically collapsed. + + The default is `"never"`. focus-history: ref: FocusHistory required: false