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 539341712..01d6feac4 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 444ed5f75..96a555991 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -23,6 +23,7 @@ use crate::_private::ipc::WorkspaceSource; use crate::_private::logging; use crate::Axis; use crate::Direction; +use crate::FlattenTree; use crate::JcContainerTarget; use crate::JcRelativeAxis; use crate::JcWorkspaceKind; @@ -1294,6 +1295,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 c30710dc6..93878bfb5 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -8,6 +8,7 @@ use crate::_private::WorkspaceShowOpV1; use crate::_private::WorkspaceShowOpV2; use crate::Axis; use crate::Direction; +use crate::FlattenTree; use crate::JcContainerTarget; use crate::JcRelativeAxis; use crate::JcWorkspaceKind; @@ -1144,6 +1145,9 @@ pub enum ClientMessage<'a> { window: Window, kind: WindowThemeKind, }, + SetFlattenTree { + mode: FlattenTree, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index 241071980..9701d66e0 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -88,6 +88,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 { @@ -600,6 +614,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 8aa587ea1..e71eaa985 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 264e6a98b..f75a5ba7e 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -104,6 +104,7 @@ use jay_config::_private::ipc::WorkspaceSource; use jay_config::_private::serialize_server_message; use jay_config::Axis; use jay_config::Direction; +use jay_config::FlattenTree as ConfigFlattenTree; use jay_config::JcContainerTarget; use jay_config::JcRelativeAxis; use jay_config::JcWorkspaceKind; @@ -2926,6 +2927,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 { @@ -4236,6 +4245,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); } @@ -4717,6 +4729,8 @@ enum CphError { UnsupportedWindowThemeColor(u32), #[error("Sized element {0} is not supported in window themes")] UnsupportedWindowThemeSized(u32), + #[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 55638e6f5..0f76c3105 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; @@ -133,6 +134,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 87845432e..109a2b444 100644 --- a/src/state.rs +++ b/src/state.rs @@ -178,6 +178,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; @@ -399,6 +400,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, @@ -2237,6 +2239,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(); self.spaces_changed(); diff --git a/src/tree.rs b/src/tree.rs index 571b626ce..316e3c74e 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -49,6 +49,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::JcTileState; use jay_config::workspace::WorkspaceDisplayOrder as ConfigWorkspaceDisplayOrder; @@ -277,6 +278,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 fcfcd63e8..2da9cc9da 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -43,6 +43,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; @@ -211,6 +212,17 @@ struct MainAxisRange { color: Color, } +/// 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, @@ -533,6 +545,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 + } + fn prepend_child(self: &Rc, new: Rc) { if let Some(child) = self.children.first() { self.add_child_before_(&child, new); @@ -1549,19 +1583,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; } @@ -2822,6 +2846,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 120aa8d3c..63220d71d 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -40,6 +40,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; @@ -1291,7 +1292,7 @@ pub fn toplevel_create_split(state: &Rc, tl: Rc, axis: let Some(ws) = tl.tl_data().workspace[LiveTL].get() else { 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 0bf22f363..58dd6a454 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; @@ -666,6 +667,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 a5213994b..a5b54146f 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -81,6 +81,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; @@ -2002,6 +2003,12 @@ 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 4e6d7c7e7..aae685206 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -2451,6 +2451,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: | @@ -3877,6 +3896,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