1
0
Fork 0
forked from wry/wry

feat: add alternating autotiling

This commit is contained in:
atagen 2026-05-31 17:16:44 +10:00
parent ce14169d6b
commit 5c2f631fdb
17 changed files with 244 additions and 59 deletions

View file

@ -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 In mono mode, scroll over the title bar to cycle between windows in the
container. 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 ## Fullscreen
Press `alt-u` (`toggle-fullscreen`) to make the focused window fill the entire Press `alt-u` (`toggle-fullscreen`) to make the focused window fill the entire

View file

@ -2079,6 +2079,12 @@ impl ConfigClient {
self.send(&ClientMessage::SetAutotile { enabled }); 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) { pub fn set_tab_title_align(&self, align: u32) {
self.send(&ClientMessage::SetTabTitleAlign { align }); self.send(&ClientMessage::SetTabTitleAlign { align });
} }

View file

@ -923,6 +923,7 @@ pub enum ClientMessage<'a> {
SetAutotile { SetAutotile {
enabled: bool, enabled: bool,
}, },
GetAutotile,
SetTabTitleAlign { SetTabTitleAlign {
align: u32, align: u32,
}, },
@ -1189,6 +1190,9 @@ pub enum Response {
GetCornerRadius { GetCornerRadius {
radius: f32, radius: f32,
}, },
GetAutotile {
enabled: bool,
},
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]

View file

@ -453,14 +453,21 @@ pub fn get_corner_radius() -> f32 {
/// Enables or disables autotiling. /// Enables or disables autotiling.
/// ///
/// When enabled, new windows are automatically placed in a perpendicular /// When enabled, newly tiled windows alternate split orientation from the
/// sub-container if the predicted body would be narrower than tall (or vice versa). /// 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`. /// The default is `false`.
pub fn set_autotile(enabled: bool) { pub fn set_autotile(enabled: bool) {
get!().set_autotile(enabled) 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. /// Sets the horizontal alignment of title text within tab buttons.
/// ///
/// - `"start"` — left-aligned (default) /// - `"start"` — left-aligned (default)

View file

@ -3587,6 +3587,11 @@ impl ConfigProxyHandler {
ClientMessage::SetAutotile { enabled } => { ClientMessage::SetAutotile { enabled } => {
self.state.theme.autotile_enabled.set(enabled); self.state.theme.autotile_enabled.set(enabled);
} }
ClientMessage::GetAutotile => {
self.respond(Response::GetAutotile {
enabled: self.state.theme.autotile_enabled.get(),
});
}
ClientMessage::SeatToggleExpand { .. } => { ClientMessage::SeatToggleExpand { .. } => {
// Removed feature; kept for binary protocol compatibility. // Removed feature; kept for binary protocol compatibility.
} }

View file

@ -331,6 +331,10 @@ impl TestConfig {
pub fn set_show_titles(&self, show: bool) -> TestResult { pub fn set_show_titles(&self, show: bool) -> TestResult {
self.send(ClientMessage::SetShowTitles { show }) self.send(ClientMessage::SetShowTitles { show })
} }
pub fn set_autotile(&self, enabled: bool) -> TestResult {
self.send(ClientMessage::SetAutotile { enabled })
}
} }
impl Drop for TestConfig { impl Drop for TestConfig {

View file

@ -85,6 +85,7 @@ mod t0051_pointer_warp;
mod t0052_bar; mod t0052_bar;
mod t0053_theme; mod t0053_theme;
mod t0054_subsurface_already_attached; mod t0054_subsurface_already_attached;
mod t0055_autotiling;
pub trait TestCase: Sync { pub trait TestCase: Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
@ -158,5 +159,6 @@ pub fn tests() -> Vec<&'static dyn TestCase> {
t0052_bar, t0052_bar,
t0053_theme, t0053_theme,
t0054_subsurface_already_attached, t0054_subsurface_already_attached,
t0055_autotiling,
} }
} }

View file

@ -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<TestRun>) -> 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(())
}

View file

@ -925,19 +925,39 @@ impl State {
&& node.tl_data().kind.is_app_window() && node.tl_data().kind.is_app_window()
&& !node.tl_data().visible.get(); && !node.tl_data().visible.get();
if animate_new_app_map { 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 { } 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()); self.focus_after_map(node, seat.as_deref());
} }
fn do_map_tiled(self: &Rc<Self>, seat: Option<&Rc<WlSeatGlobal>>, node: Rc<dyn ToplevelNode>) { pub fn map_tiled_without_autotile(self: &Rc<Self>, node: Rc<dyn ToplevelNode>) {
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<Self>,
seat: Option<&Rc<WlSeatGlobal>>,
node: Rc<dyn ToplevelNode>,
autotile: bool,
) {
let ws = self.ensure_map_workspace(seat); 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<Self>, node: Rc<dyn ToplevelNode>, ws: &Rc<WorkspaceNode>) { pub fn map_tiled_on(self: &Rc<Self>, node: Rc<dyn ToplevelNode>, ws: &Rc<WorkspaceNode>) {
self.map_tiled_on_(node, ws, false);
}
fn map_tiled_on_(
self: &Rc<Self>,
node: Rc<dyn ToplevelNode>,
ws: &Rc<WorkspaceNode>,
autotile: bool,
) {
if let Some(c) = ws.container.get() { if let Some(c) = ws.container.get() {
let la = c.clone().tl_last_active_child(); let la = c.clone().tl_last_active_child();
let lap = la let lap = la
@ -946,7 +966,11 @@ impl State {
.get() .get()
.and_then(|n| n.node_into_container()); .and_then(|n| n.node_into_container());
if let Some(lap) = lap { 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 { } else {
c.append_child(node); c.append_child(node);
} }

View file

@ -290,6 +290,47 @@ impl ContainerNode {
self.add_child_x(prev, new, |prev, new| self.add_child_after_(prev, new)); self.add_child_x(prev, new, |prev, new| self.add_child_after_(prev, new));
} }
pub fn add_tiled_child_after(self: &Rc<Self>, prev: &dyn Node, new: Rc<dyn ToplevelNode>) {
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<Self>, prev: &dyn Node, new: Rc<dyn ToplevelNode>) { pub fn add_child_before(self: &Rc<Self>, prev: &dyn Node, new: Rc<dyn ToplevelNode>) {
self.add_child_x(prev, new, |prev, new| self.add_child_before_(prev, new)); 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<Self>, node: Rc<dyn ToplevelNode>, direction: Direction) { pub fn insert_child(self: &Rc<Self>, node: Rc<dyn ToplevelNode>, 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); let (split, right) = direction_to_split(direction);
if split != self.split.get() || right { if split != self.split.get() || right {
self.append_child(node); self.append_child(node);

View file

@ -979,7 +979,7 @@ impl ToplevelData {
} }
fd.workspace.remove_fullscreen_node(); fd.workspace.remove_fullscreen_node();
if fd.placeholder.is_destroyed() { if fd.placeholder.is_destroyed() {
state.map_tiled(node); state.map_tiled_without_autotile(node);
return; return;
} }
let parent = fd.placeholder.tl_data().parent.take().unwrap(); let parent = fd.placeholder.tl_data().parent.take().unwrap();
@ -1262,7 +1262,7 @@ pub fn toplevel_set_floating(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, floati
}; };
if !floating { if !floating {
parent.cnode_remove_child2(&*tl, true); parent.cnode_remove_child2(&*tl, true);
state.map_tiled(tl); state.map_tiled_without_autotile(tl);
} else if let Some(ws) = data.workspace.get() { } else if let Some(ws) = data.workspace.get() {
let node_id = data.node_id; let node_id = data.node_id;
let old_body = let old_body =

View file

@ -600,6 +600,14 @@ pub struct Config {
pub simple_im: Option<SimpleIm>, pub simple_im: Option<SimpleIm>,
pub fallback_output_mode: Option<FallbackOutputMode>, pub fallback_output_mode: Option<FallbackOutputMode>,
pub mouse_follows_focus: Option<bool>, pub mouse_follows_focus: Option<bool>,
pub scratchpads: Vec<Scratchpad>,
pub autotile: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct Scratchpad {
pub name: String,
pub exec: Option<Exec>,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]

View file

@ -156,6 +156,7 @@ impl Parser for ConfigParser<'_> {
mouse_follows_focus, mouse_follows_focus,
animations_val, animations_val,
), ),
(scratchpads_val, autotile),
) = ext.extract(( ) = ext.extract((
( (
opt(val("keymap")), opt(val("keymap")),
@ -217,6 +218,7 @@ impl Parser for ConfigParser<'_> {
recover(opt(bol("unstable-mouse-follows-focus"))), recover(opt(bol("unstable-mouse-follows-focus"))),
opt(val("animations")), opt(val("animations")),
), ),
(opt(val("scratchpads")), recover(opt(bol("autotile")))),
))?; ))?;
let mut keymap = None; let mut keymap = None;
if let Some(value) = keymap_val { if let Some(value) = keymap_val {
@ -618,6 +620,8 @@ impl Parser for ConfigParser<'_> {
simple_im, simple_im,
fallback_output_mode, fallback_output_mode,
mouse_follows_focus: mouse_follows_focus.despan(), mouse_follows_focus: mouse_follows_focus.despan(),
scratchpads,
autotile: autotile.despan(),
}) })
} }
} }

View file

@ -27,7 +27,7 @@ use {
client::Client, client::Client,
config, config_dir, config, config_dir,
exec::{Command, set_env, unset_env}, exec::{Command, set_env, unset_env},
get_workspace, get_autotile, get_workspace,
input::{ input::{
FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, capability::CAP_SWITCH, FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, capability::CAP_SWITCH,
get_seat, input_devices, on_input_device_removed, on_new_input_device, 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, on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier,
set_animation_curve, set_animation_duration_ms, set_animation_style, set_animation_curve, set_animation_duration_ms, set_animation_style,
set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius,
set_default_workspace_capture, set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen,
set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled,
set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, set_show_bar, set_show_float_pin_icon, set_show_titles, set_tab_title_align,
set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, set_ui_drag_enabled, set_ui_drag_threshold,
set_ui_drag_threshold,
status::{set_i3bar_separator, set_status, set_status_command, unset_status_command}, status::{set_i3bar_separator, set_status, set_status_command, unset_status_command},
switch_to_vt, switch_to_vt,
tasks::{self, JoinHandle}, tasks::{self, JoinHandle},
@ -270,12 +269,7 @@ impl Action {
SimpleCommand::MoveTabLeft => b.new(move || s.move_tab(false)), SimpleCommand::MoveTabLeft => b.new(move || s.move_tab(false)),
SimpleCommand::MoveTabRight => b.new(move || s.move_tab(true)), SimpleCommand::MoveTabRight => b.new(move || s.move_tab(true)),
SimpleCommand::SetAutotile(enabled) => b.new(move || set_autotile(enabled)), SimpleCommand::SetAutotile(enabled) => b.new(move || set_autotile(enabled)),
SimpleCommand::ToggleAutotile => { SimpleCommand::ToggleAutotile => b.new(move || set_autotile(!get_autotile())),
b.new(move || {
// Toggle not directly supported; set to true
set_autotile(true)
})
}
}, },
Action::Multi { actions } => { Action::Multi { actions } => {
let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); 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<Persistent
.seat .seat
.unstable_set_mouse_follows_focus(mouse_follows_focus); .unstable_set_mouse_follows_focus(mouse_follows_focus);
} }
if let Some(v) = config.autotile {
set_autotile(v);
}
} }
fn create_command(exec: &Exec) -> Command { fn create_command(exec: &Exec) -> Command {

View file

@ -1209,6 +1209,10 @@
"type": "boolean", "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" "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": { "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", "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", "type": "object",
@ -2068,6 +2072,9 @@
"make-group-tab", "make-group-tab",
"change-group-opposite", "change-group-opposite",
"toggle-tab", "toggle-tab",
"enable-autotile",
"disable-autotile",
"toggle-autotile",
"toggle-fullscreen", "toggle-fullscreen",
"enter-fullscreen", "enter-fullscreen",
"exit-fullscreen", "exit-fullscreen",

View file

@ -2489,6 +2489,18 @@ The table has the following fields:
The value of this field should be a boolean. 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): - `modes` (optional):
Configures the input modes. 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. 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-fullscreen`:
Toggle the currently focused window between fullscreen and windowed. 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 scaling mode of X windows.
The value of this field should be a [XScalingMode](#types-XScalingMode). The value of this field should be a [XScalingMode](#types-XScalingMode).

View file

@ -1064,6 +1064,12 @@ SimpleActionName:
description: Toggles the current group's direction. description: Toggles the current group's direction.
- value: toggle-tab - value: toggle-tab
description: Toggles the current group between tabbed and split mode. 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 - value: toggle-fullscreen
description: Toggle the currently focused window between fullscreen and windowed. description: Toggle the currently focused window between fullscreen and windowed.
- value: enter-fullscreen - value: enter-fullscreen
@ -3129,10 +3135,21 @@ Config:
required: false required: false
description: | description: |
Configures whether middle-click pasting is enabled. Configures whether middle-click pasting is enabled.
Changing this has no effect on running applications. Changing this has no effect on running applications.
The default is `true`. 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: modes:
kind: map kind: map
values: values: