diff --git a/book/src/tiling.md b/book/src/tiling.md index 650cd73a..2ff61d5e 100644 --- a/book/src/tiling.md +++ b/book/src/tiling.md @@ -77,6 +77,20 @@ You can also right-click any title in a container to toggle mono mode. In mono mode, scroll over the title bar to cycle between windows in the container. +## Autotiling + +Autotiling makes newly tiled windows alternate split direction from the focused +tiled window. The first split uses the containing group direction, then later +windows wrap the focused tile in the opposite direction, producing a horizontal, +vertical, horizontal pattern as the layout grows. + +```toml +[shortcuts] +alt-a = "toggle-autotile" +``` + +Manual grouping and split commands still use the direction you request. + ## Fullscreen Press `alt-u` (`toggle-fullscreen`) to make the focused window fill the entire diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 71927bbc..7c78abac 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -2079,6 +2079,12 @@ impl ConfigClient { self.send(&ClientMessage::SetAutotile { enabled }); } + pub fn get_autotile(&self) -> bool { + let res = self.send_with_response(&ClientMessage::GetAutotile); + get_response!(res, false, GetAutotile { enabled }); + enabled + } + pub fn set_tab_title_align(&self, align: u32) { self.send(&ClientMessage::SetTabTitleAlign { align }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index e86e79ca..c61c1af6 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -923,6 +923,7 @@ pub enum ClientMessage<'a> { SetAutotile { enabled: bool, }, + GetAutotile, SetTabTitleAlign { align: u32, }, @@ -1189,6 +1190,9 @@ pub enum Response { GetCornerRadius { radius: f32, }, + GetAutotile { + enabled: bool, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index c95c6620..fff94506 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -453,14 +453,21 @@ pub fn get_corner_radius() -> f32 { /// Enables or disables autotiling. /// -/// When enabled, new windows are automatically placed in a perpendicular -/// sub-container if the predicted body would be narrower than tall (or vice versa). +/// When enabled, newly tiled windows alternate split orientation from the +/// focused tiled window: the first split uses the containing group's direction, +/// then subsequent splits wrap the focused window in the perpendicular +/// direction. /// /// The default is `false`. pub fn set_autotile(enabled: bool) { get!().set_autotile(enabled) } +/// Returns whether autotiling is enabled. +pub fn get_autotile() -> bool { + get!(false).get_autotile() +} + /// Sets the horizontal alignment of title text within tab buttons. /// /// - `"start"` — left-aligned (default) diff --git a/src/config/handler.rs b/src/config/handler.rs index 336da9ff..9a11acab 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -3587,6 +3587,11 @@ impl ConfigProxyHandler { ClientMessage::SetAutotile { enabled } => { self.state.theme.autotile_enabled.set(enabled); } + ClientMessage::GetAutotile => { + self.respond(Response::GetAutotile { + enabled: self.state.theme.autotile_enabled.get(), + }); + } ClientMessage::SeatToggleExpand { .. } => { // Removed feature; kept for binary protocol compatibility. } diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 56ee5272..7691bbcd 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -331,6 +331,10 @@ impl TestConfig { pub fn set_show_titles(&self, show: bool) -> TestResult { self.send(ClientMessage::SetShowTitles { show }) } + + pub fn set_autotile(&self, enabled: bool) -> TestResult { + self.send(ClientMessage::SetAutotile { enabled }) + } } impl Drop for TestConfig { diff --git a/src/it/tests.rs b/src/it/tests.rs index dc28888c..3e1e502c 100644 --- a/src/it/tests.rs +++ b/src/it/tests.rs @@ -85,6 +85,7 @@ mod t0051_pointer_warp; mod t0052_bar; mod t0053_theme; mod t0054_subsurface_already_attached; +mod t0055_autotiling; pub trait TestCase: Sync { fn name(&self) -> &'static str; @@ -158,5 +159,6 @@ pub fn tests() -> Vec<&'static dyn TestCase> { t0052_bar, t0053_theme, t0054_subsurface_already_attached, + t0055_autotiling, } } diff --git a/src/it/tests/t0055_autotiling.rs b/src/it/tests/t0055_autotiling.rs new file mode 100644 index 00000000..4b3611c4 --- /dev/null +++ b/src/it/tests/t0055_autotiling.rs @@ -0,0 +1,58 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::{ContainerSplit, Node, ToplevelNodeBase}, + }, + std::rc::Rc, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + run.backend.install_default()?; + run.cfg.set_autotile(true)?; + + let client = run.create_client().await?; + + let win1 = client.create_window().await?; + win1.map().await?; + let root = win1.tl.container_parent()?; + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + + let win2 = client.create_window().await?; + win2.map().await?; + client.sync().await; + + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + tassert_eq!(win1.tl.container_parent()?.node_id(), root.node_id()); + tassert_eq!(win2.tl.container_parent()?.node_id(), root.node_id()); + + let win3 = client.create_window().await?; + win3.map().await?; + client.sync().await; + + let v_group = win3.tl.container_parent()?; + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + tassert_eq!(v_group.split.get(), ContainerSplit::Vertical); + tassert_eq!(win2.tl.container_parent()?.node_id(), v_group.node_id()); + + let win4 = client.create_window().await?; + win4.map().await?; + client.sync().await; + + let h_group = win4.tl.container_parent()?; + tassert_eq!(h_group.split.get(), ContainerSplit::Horizontal); + tassert_eq!(win3.tl.container_parent()?.node_id(), h_group.node_id()); + let h_parent = match h_group + .tl_data() + .parent + .get() + .and_then(|p| p.node_into_container()) + { + Some(parent) => parent, + None => bail!("autotile group does not have a container parent"), + }; + tassert_eq!(h_parent.node_id(), v_group.node_id()); + + Ok(()) +} diff --git a/src/state.rs b/src/state.rs index 42dd909d..a7dad1d5 100644 --- a/src/state.rs +++ b/src/state.rs @@ -925,19 +925,39 @@ impl State { && node.tl_data().kind.is_app_window() && !node.tl_data().visible.get(); if animate_new_app_map { - self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone())); + self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone(), true)); } else { - self.do_map_tiled(seat.as_deref(), node.clone()); + self.do_map_tiled(seat.as_deref(), node.clone(), true); } self.focus_after_map(node, seat.as_deref()); } - fn do_map_tiled(self: &Rc, seat: Option<&Rc>, node: Rc) { + pub fn map_tiled_without_autotile(self: &Rc, node: Rc) { + let seat = self.seat_queue.last(); + self.do_map_tiled(seat.as_deref(), node.clone(), false); + self.focus_after_map(node, seat.as_deref()); + } + + fn do_map_tiled( + self: &Rc, + seat: Option<&Rc>, + node: Rc, + autotile: bool, + ) { let ws = self.ensure_map_workspace(seat); - self.map_tiled_on(node, &ws); + self.map_tiled_on_(node, &ws, autotile); } pub fn map_tiled_on(self: &Rc, node: Rc, ws: &Rc) { + self.map_tiled_on_(node, ws, false); + } + + fn map_tiled_on_( + self: &Rc, + node: Rc, + ws: &Rc, + autotile: bool, + ) { if let Some(c) = ws.container.get() { let la = c.clone().tl_last_active_child(); let lap = la @@ -946,7 +966,11 @@ impl State { .get() .and_then(|n| n.node_into_container()); if let Some(lap) = lap { - lap.add_child_after(&*la, node); + if autotile { + lap.add_tiled_child_after(&*la, node); + } else { + lap.add_child_after(&*la, node); + } } else { c.append_child(node); } diff --git a/src/tree/container.rs b/src/tree/container.rs index b8de7b25..b81f2e85 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -290,6 +290,47 @@ impl ContainerNode { self.add_child_x(prev, new, |prev, new| self.add_child_after_(prev, new)); } + pub fn add_tiled_child_after(self: &Rc, prev: &dyn Node, new: Rc) { + if !self.state.theme.autotile_enabled.get() + || self.mono_child.is_some() + || self.num_children.get() <= 1 + { + self.add_child_after(prev, new); + return; + } + let focused = self + .child_nodes + .borrow() + .get(&prev.node_id()) + .map(|n| n.to_ref()); + let Some(focused) = focused else { + log::error!( + "Tried to autotile a child into a container but the preceding node is not in the container" + ); + return; + }; + let focused_node = focused.node.clone(); + let focused_active = focused_node.tl_data().active(); + let sub = ContainerNode::new( + &self.state, + &self.workspace.get(), + focused_node.clone(), + self.split.get().other(), + ); + // Autotile-created groups are structural and collapse once only one + // child remains. Explicit make-group commands control their own + // grouping through the regular manual paths. + sub.ephemeral.set(Ephemeral::On); + sub.append_child(new); + let sub_id = sub.node_id(); + self.clone().cnode_replace_child(&*focused_node, sub); + if focused_active + && let Some(group) = self.child_nodes.borrow().get(&sub_id).map(|n| n.to_ref()) + { + self.update_child_active(&group, true, 1); + } + } + pub fn add_child_before(self: &Rc, prev: &dyn Node, new: Rc) { self.add_child_x(prev, new, |prev, new| self.add_child_before_(prev, new)); } @@ -1369,42 +1410,6 @@ impl ContainerNode { } pub fn insert_child(self: &Rc, node: Rc, direction: Direction) { - // Autotile: if the container would become too narrow/tall, wrap the - // focused child and new node in a perpendicular sub-container. - if self.state.theme.autotile_enabled.get() && self.mono_child.is_none() { - let (pw, ph) = self.predict_child_body_size(); - let opposite = match self.split.get() { - ContainerSplit::Horizontal if pw > 0 && ph > 0 && pw < ph => { - Some(ContainerSplit::Vertical) - } - ContainerSplit::Vertical if pw > 0 && ph > 0 && ph < pw => { - Some(ContainerSplit::Horizontal) - } - _ => None, - }; - if let Some(opp_split) = opposite { - if let Some(focused) = self.focus_history.last() { - if self.num_children.get() <= 1 { - // Single child, autotile not applicable. - } else { - let focused_node = focused.node.clone(); - let was_ephemeral = self.ephemeral.replace(Ephemeral::Off); - self.clone().cnode_remove_child2(&*focused_node, true); - self.ephemeral.set(was_ephemeral); - let sub = ContainerNode::new( - &self.state, - &self.workspace.get(), - focused_node, - opp_split, - ); - sub.ephemeral.set(Ephemeral::On); - sub.append_child(node); - self.append_child(sub); - return; - } - } - } - } let (split, right) = direction_to_split(direction); if split != self.split.get() || right { self.append_child(node); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 312b4ac6..7fff564b 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -979,7 +979,7 @@ impl ToplevelData { } fd.workspace.remove_fullscreen_node(); if fd.placeholder.is_destroyed() { - state.map_tiled(node); + state.map_tiled_without_autotile(node); return; } let parent = fd.placeholder.tl_data().parent.take().unwrap(); @@ -1262,7 +1262,7 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati }; if !floating { parent.cnode_remove_child2(&*tl, true); - state.map_tiled(tl); + state.map_tiled_without_autotile(tl); } else if let Some(ws) = data.workspace.get() { let node_id = data.node_id; let old_body = diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 35aca02c..0eed4a21 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -600,6 +600,14 @@ pub struct Config { pub simple_im: Option, pub fallback_output_mode: Option, pub mouse_follows_focus: Option, + pub scratchpads: Vec, + pub autotile: Option, +} + +#[derive(Debug, Clone)] +pub struct Scratchpad { + pub name: String, + pub exec: Option, } #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index d82be95b..112f7471 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -156,6 +156,7 @@ impl Parser for ConfigParser<'_> { mouse_follows_focus, animations_val, ), + (scratchpads_val, autotile), ) = ext.extract(( ( opt(val("keymap")), @@ -217,6 +218,7 @@ impl Parser for ConfigParser<'_> { recover(opt(bol("unstable-mouse-follows-focus"))), opt(val("animations")), ), + (opt(val("scratchpads")), recover(opt(bol("autotile")))), ))?; let mut keymap = None; if let Some(value) = keymap_val { @@ -618,6 +620,8 @@ impl Parser for ConfigParser<'_> { simple_im, fallback_output_mode, mouse_follows_focus: mouse_follows_focus.despan(), + scratchpads, + autotile: autotile.despan(), }) } } diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 4dbf8e74..d8bfea89 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -27,7 +27,7 @@ use { client::Client, config, config_dir, exec::{Command, set_env, unset_env}, - get_workspace, + get_autotile, get_workspace, input::{ FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, capability::CAP_SWITCH, get_seat, input_devices, on_input_device_removed, on_new_input_device, @@ -40,11 +40,10 @@ use { on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier, set_animation_curve, set_animation_duration_ms, set_animation_style, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, - set_default_workspace_capture, - set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, - set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, - set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, - set_ui_drag_threshold, + set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, + set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, + set_show_bar, set_show_float_pin_icon, set_show_titles, set_tab_title_align, + set_ui_drag_enabled, set_ui_drag_threshold, status::{set_i3bar_separator, set_status, set_status_command, unset_status_command}, switch_to_vt, tasks::{self, JoinHandle}, @@ -270,12 +269,7 @@ impl Action { SimpleCommand::MoveTabLeft => b.new(move || s.move_tab(false)), SimpleCommand::MoveTabRight => b.new(move || s.move_tab(true)), SimpleCommand::SetAutotile(enabled) => b.new(move || set_autotile(enabled)), - SimpleCommand::ToggleAutotile => { - b.new(move || { - // Toggle not directly supported; set to true - set_autotile(true) - }) - } + SimpleCommand::ToggleAutotile => b.new(move || set_autotile(!get_autotile())), }, Action::Multi { actions } => { let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); @@ -1747,6 +1741,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Command { diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 50cc8887..4d6cb2bf 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -1209,6 +1209,10 @@ "type": "boolean", "description": "Configures whether middle-click pasting is enabled.\n\nChanging this has no effect on running applications.\n\nThe default is `true`.\n" }, + "autotile": { + "type": "boolean", + "description": "Configures whether autotiling is enabled by default.\n\nWhen enabled, newly mapped tiled windows alternate their split\norientation automatically. This can also be toggled at runtime via the\n`enable-autotile`, `disable-autotile`, and `toggle-autotile` actions.\n\nThe default is `false`.\n" + }, "modes": { "description": "Configures the input modes.\n\nModes can be used to define shortcuts that are only active when the mode is\nactive.\n\n- Example\n\n ```toml\n [modes.\"navigation\".shortcuts]\n w = \"focus-up\"\n a = \"focus-left\"\n s = \"focus-down\"\n d = \"focus-right\"\n r = \"focus-above\"\n f = \"focus-below\"\n q = \"focus-prev\"\n e = \"focus-next\"\n ```\n\nModes can be activated with the `push-mode` and `latch-mode` actions.\n", "type": "object", @@ -2068,6 +2072,9 @@ "make-group-tab", "change-group-opposite", "toggle-tab", + "enable-autotile", + "disable-autotile", + "toggle-autotile", "toggle-fullscreen", "enter-fullscreen", "exit-fullscreen", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index a31a3767..1a9d82a8 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -2489,6 +2489,18 @@ The table has the following fields: The value of this field should be a boolean. +- `autotile` (optional): + + Configures whether autotiling is enabled by default. + + When enabled, newly mapped tiled windows alternate their split + orientation automatically. This can also be toggled at runtime via the + `enable-autotile`, `disable-autotile`, and `toggle-autotile` actions. + + The default is `false`. + + The value of this field should be a boolean. + - `modes` (optional): Configures the input modes. @@ -4613,6 +4625,18 @@ The string should have one of the following values: Toggles the current group between tabbed and split mode. +- `enable-autotile`: + + Enables alternating split orientation for newly tiled windows. + +- `disable-autotile`: + + Disables alternating split orientation for newly tiled windows. + +- `toggle-autotile`: + + Toggles alternating split orientation for newly tiled windows. + - `toggle-fullscreen`: Toggle the currently focused window between fullscreen and windowed. @@ -5806,4 +5830,3 @@ The table has the following fields: The scaling mode of X windows. The value of this field should be a [XScalingMode](#types-XScalingMode). - diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 706c016a..7bc2b970 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -1064,6 +1064,12 @@ SimpleActionName: description: Toggles the current group's direction. - value: toggle-tab description: Toggles the current group between tabbed and split mode. + - value: enable-autotile + description: Enables alternating split orientation for newly tiled windows. + - value: disable-autotile + description: Disables alternating split orientation for newly tiled windows. + - value: toggle-autotile + description: Toggles alternating split orientation for newly tiled windows. - value: toggle-fullscreen description: Toggle the currently focused window between fullscreen and windowed. - value: enter-fullscreen @@ -3129,10 +3135,21 @@ Config: required: false description: | Configures whether middle-click pasting is enabled. - + Changing this has no effect on running applications. The default is `true`. + autotile: + kind: boolean + required: false + description: | + Configures whether autotiling is enabled by default. + + When enabled, newly mapped tiled windows alternate their split + orientation automatically. This can also be toggled at runtime via the + `enable-autotile`, `disable-autotile`, and `toggle-autotile` actions. + + The default is `false`. modes: kind: map values: