1
0
Fork 0
forked from wry/wry

all: add support for hy3 like tiling

This commit is contained in:
kossLAN 2026-04-10 13:16:35 -04:00
parent a41dbae899
commit cea4187fc0
No known key found for this signature in database
21 changed files with 1237 additions and 48 deletions

View file

@ -12,20 +12,24 @@ use {
},
rect::Rect,
renderer::Renderer,
scale::Scale,
state::State,
text::TextTexture,
tree::{
ContainingNode, Direction, FindTreeResult, FindTreeUsecase, FloatNode, FoundNode, Node,
NodeId, NodeLayerLink, NodeLocation, OutputNode, TddType, TileDragDestination,
ToplevelData, ToplevelNode, ToplevelNodeBase, ToplevelType, WorkspaceNode,
default_tile_drag_bounds, toplevel_set_workspace,
default_tile_drag_bounds, tab_bar::{TabBar, TabBarEntry}, toplevel_set_workspace,
walker::NodeVisitor,
},
utils::{
clonecell::CloneCell,
errorfmt::ErrorFmt,
event_listener::LazyEventSource,
hash_map_ext::HashMapExt,
linkedlist::{LinkedList, LinkedNode, NodeRef},
numcell::NumCell,
on_drop_event::OnDropEvent,
rc_eq::rc_eq,
threshold_counter::ThresholdCounter,
},
@ -85,6 +89,28 @@ pub enum ContainerFocus {
tree_id!(ContainerNodeId);
/// Ephemeral group state (hy3-style auto-collapse).
///
/// An ephemeral container is a transient grouping that auto-collapses back to
/// its single child when all but one child is removed.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum Ephemeral {
/// Normal container — never auto-collapses.
#[default]
Off,
/// Ephemeral container — when child count drops to 1, collapse.
On,
}
/// Actions for the `changegroup` operation.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ChangeGroupAction {
/// Toggle between Horizontal and Vertical.
Opposite,
/// Toggle between tabbed (mono) and split mode.
ToggleTab,
}
#[derive(Default)]
pub struct ContainerRenderData {
pub border_rects: Vec<Rect>,
@ -120,6 +146,9 @@ pub struct ContainerNode {
pub child_added: Rc<LazyEventSource>,
pub child_removed: Rc<LazyEventSource>,
pub all_children_resized: Rc<LazyEventSource>,
pub tab_bar: RefCell<Option<TabBar>>,
pub update_tab_textures_scheduled: Cell<bool>,
pub ephemeral: Cell<Ephemeral>,
}
impl Debug for ContainerNode {
@ -231,6 +260,9 @@ impl ContainerNode {
child_added: state.lazy_event_sources.create_source(),
child_removed: state.lazy_event_sources.create_source(),
all_children_resized: state.post_layout_event_sources.create_source(),
tab_bar: RefCell::new(None),
update_tab_textures_scheduled: Cell::new(false),
ephemeral: Cell::new(Ephemeral::Off),
});
child.tl_set_parent(slf.clone());
slf.pull_child_properties(&child_node_ref);
@ -335,6 +367,8 @@ impl ContainerNode {
self.sum_factors.set(sum_factors);
if self.mono_child.is_some() {
self.activate_child(&new_ref);
self.rebuild_tab_bar();
self.damage();
}
// log::info!("add_child");
self.schedule_layout();
@ -549,11 +583,19 @@ impl ContainerNode {
self.content_width.set(self.width.get());
}
}
let tab_bar_height = if self.mono_child.is_some() {
// Tab bar sits above the window with a configurable gap.
let tbh = self.state.theme.sizes.tab_bar_height.get();
let gap = self.state.theme.sizes.tab_bar_gap.get();
tbh + gap
} else {
0
};
self.mono_body.set(Rect::new_sized_saturating(
0,
0,
tab_bar_height,
self.width.get(),
self.height.get(),
(self.height.get() - tab_bar_height).max(0),
));
}
@ -588,7 +630,9 @@ impl ContainerNode {
dist_left,
dist_right,
} => {
let prev = op.child.prev().unwrap();
let Some(prev) = op.child.prev() else {
return;
};
let prev_body = prev.body.get();
let child_body = op.child.body.get();
let (prev_factor, child_factor) = match self.split.get() {
@ -656,6 +700,14 @@ impl ContainerNode {
}
}
fn schedule_update_tab_textures(self: &Rc<Self>) {
if !self.update_tab_textures_scheduled.replace(true) {
self.state
.pending_container_tab_render_textures
.push(self.clone());
}
}
fn compute_render_positions(&self) {
self.compute_render_positions_scheduled.set(false);
let mut rd = self.render_data.borrow_mut();
@ -713,6 +765,13 @@ impl ContainerNode {
}
}
self.mono_child.set(Some(child.clone()));
// Keep focus_history in sync with mono_child so that
// toggle_tab and other operations that use focus_history.last()
// return the correct child.
child
.focus_history
.set(Some(self.focus_history.add_last(child.clone())));
self.rebuild_tab_bar();
if self.toplevel_data.visible.get() {
self.perform_layout();
child.node.tl_set_visible(true);
@ -764,9 +823,25 @@ impl ContainerNode {
}
}
}
self.mono_child.set(child);
// log::info!("set_mono");
self.mono_child.set(child.clone());
if child.is_some() {
self.rebuild_tab_bar();
} else {
*self.tab_bar.borrow_mut() = None;
}
self.update_content_size();
self.damage();
self.schedule_layout();
// Notify parent to rebuild its tab bar if we're a child of a mono container.
// Our tab title prefix changes when we enter/leave mono mode ([T] vs [H]/[V]).
if let Some(parent) = self.toplevel_data.parent.get() {
if let Some(pc) = parent.node_into_container() {
if pc.mono_child.is_some() {
pc.rebuild_tab_bar();
pc.damage();
}
}
}
}
pub fn set_split(self: &Rc<Self>, split: ContainerSplit) {
@ -777,6 +852,233 @@ impl ContainerNode {
}
}
/// Rebuild the tab bar entries from the current children.
///
/// Called when entering mono mode or when children change while in mono mode.
fn rebuild_tab_bar(self: &Rc<Self>) {
self.rebuild_tab_bar_with_override(None, None);
}
/// Generate a tab title for a child node. For windows this is the window
/// title or app_id. For container (group) children, we show a layout
/// prefix like hy3: `[H] child_title`, `[V] child_title`, `[T] child_title`.
fn get_child_tab_title(
&self,
child: &ContainerChild,
override_id: Option<NodeId>,
override_title: Option<&str>,
) -> String {
let child_id = child.node.node_id();
// If this child is a container (group), show layout prefix + focused child title.
if let Some(cn) = child.node.clone().node_into_container() {
let prefix = if cn.mono_child.is_some() {
"[T]"
} else {
match cn.split.get() {
ContainerSplit::Horizontal => "[H]",
ContainerSplit::Vertical => "[V]",
}
};
// Get the focused child's title recursively.
let inner = if let Some(focused) = cn.focus_history.last() {
let inner_child = ContainerChild {
node: focused.node.clone(),
active: Cell::new(false),
body: Default::default(),
content: Default::default(),
factor: Cell::new(0.0),
focus_history: Default::default(),
attention_requested: Cell::new(false),
border_color_is_focused: Default::default(),
};
cn.get_child_tab_title(&inner_child, override_id, override_title)
} else {
"Group".to_string()
};
return format!("{} {}", prefix, inner);
}
// Window node: use override, title, app_id, or "untitled".
let raw_title = if override_id == Some(child_id) && override_title.is_some() {
override_title.unwrap().to_string()
} else {
child.node.tl_data().title.borrow().clone()
};
if !raw_title.is_empty() {
return raw_title;
}
if override_id == Some(child_id) {
let app = child.node.tl_data().app_id.borrow().clone();
if !app.is_empty() {
return app;
}
} else {
let app = child.node.tl_data().app_id.borrow().clone();
if !app.is_empty() {
return app;
}
}
"untitled".to_string()
}
/// Rebuild the tab bar. If `override_id` and `override_title` are provided,
/// use the override title for that child instead of borrowing it (avoids
/// RefCell double-borrow when called from node_child_title_changed).
fn rebuild_tab_bar_with_override(
self: &Rc<Self>,
override_id: Option<NodeId>,
override_title: Option<&str>,
) {
let mono = self.mono_child.get();
if mono.is_none() {
*self.tab_bar.borrow_mut() = None;
return;
}
let mono_ref = mono.as_ref().unwrap();
let active_id = mono_ref.node.node_id();
let height = self.state.theme.sizes.tab_bar_height.get();
let render_scale = self.workspace.get().output.get().global.persistent.scale.get();
let mut bar = TabBar::new(height, render_scale);
for child in self.children.iter() {
let child_id = child.node.node_id();
let title = self.get_child_tab_title(&child, override_id, override_title);
bar.entries.push(TabBarEntry {
child_id,
title,
title_texture: Rc::new(RefCell::new(None)),
active: child_id == active_id,
attention_requested: child.attention_requested.get(),
x: Cell::new(0),
width: Cell::new(0),
});
}
let padding = self.state.theme.sizes.tab_bar_padding.get();
bar.layout_entries(self.width.get(), padding);
*self.tab_bar.borrow_mut() = Some(bar);
self.schedule_update_tab_textures();
}
/// Wrap the focused child in a new sub-container with the given split direction.
///
/// This is hy3's `makegroup` operation.
pub fn make_group(self: &Rc<Self>, split: ContainerSplit, ephemeral: bool) {
let Some(focused) = self.focus_history.last() else {
return;
};
if self.num_children.get() <= 1 {
return;
}
let focused_node = focused.node.clone();
// Record the sibling that comes AFTER the focused child so we can
// insert the new group at the same position.
let next_sibling: Option<Rc<dyn ToplevelNode>> = {
let nodes = self.child_nodes.borrow();
nodes
.get(&focused_node.node_id())
.and_then(|ln| ln.next())
.map(|n| n.node.clone())
};
// Temporarily disable ephemeral collapse during make_group so
// removing the focused child doesn't destroy this container.
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,
split,
);
if ephemeral {
sub.ephemeral.set(Ephemeral::On);
}
// Insert at the original position instead of appending to the end.
if let Some(ref next) = next_sibling {
self.add_child_before(&**next, sub);
} else {
// Was the last child — append.
self.append_child(sub);
}
}
/// Change this container's split direction (hy3's `changegroup`).
///
/// `opposite` toggles H↔V, `toggletab` toggles between mono and split.
pub fn change_group(self: &Rc<Self>, action: ChangeGroupAction) {
match action {
ChangeGroupAction::Opposite => {
let new_split = match self.split.get() {
ContainerSplit::Horizontal => ContainerSplit::Vertical,
ContainerSplit::Vertical => ContainerSplit::Horizontal,
};
self.set_split(new_split);
}
ChangeGroupAction::ToggleTab => {
if self.mono_child.is_some() {
// Exit mono/tabbed mode.
self.set_mono(None);
} else if let Some(focused) = self.focus_history.last() {
// Enter mono/tabbed mode with the focused child.
self.set_mono(Some(&*focused.node));
}
}
}
}
/// Reset all children's factors to equal (hy3's `equalize`).
pub fn equalize(self: &Rc<Self>) {
let n = self.num_children.get();
if n == 0 {
return;
}
let factor = 1.0 / n as f64;
let mut sum = 0.0;
for child in self.children.iter() {
child.factor.set(factor);
sum += factor;
}
self.sum_factors.set(sum);
self.schedule_layout();
}
/// Recursively equalize all descendant containers.
pub fn equalize_recursive(self: &Rc<Self>) {
self.equalize();
for child in self.children.iter() {
if let Some(cn) = child.node.clone().node_into_container() {
cn.equalize_recursive();
}
}
}
/// Move the currently active tab left or right within the tab bar.
///
/// This is the equivalent of hy3's `movewindow` within a tabbed group.
pub fn move_tab(self: &Rc<Self>, right: bool) {
let mc = match self.mono_child.get() {
Some(mc) => mc,
None => return,
};
let child_nodes = self.child_nodes.borrow();
let Some(link) = child_nodes.get(&mc.node.node_id()) else {
return;
};
let active_ref = link.to_ref();
if right {
if let Some(next) = active_ref.next() {
// Move active tab after the next sibling (moves right).
next.append_existing(&active_ref);
}
} else {
if let Some(prev) = active_ref.prev() {
// Move active tab before the previous sibling (moves left).
prev.prepend_existing(&active_ref);
}
}
drop(child_nodes);
self.rebuild_tab_bar();
self.damage();
}
fn parent_container(&self) -> Option<Rc<ContainerNode>> {
self.toplevel_data
.parent
@ -955,6 +1257,42 @@ impl ContainerNode {
}
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);
if split != self.split.get() || right {
self.append_child(node);
@ -1050,16 +1388,39 @@ impl ContainerNode {
if !pressed {
return;
}
// Handle tab bar clicks in mono mode.
if self.mono_child.is_some() {
let tab_bar = self.tab_bar.borrow();
if let Some(tb) = tab_bar.as_ref() {
if seat_data.y >= 0 && seat_data.y < tb.height {
if let Some(idx) = tb.entry_at_x(seat_data.x) {
let child_id = tb.entries[idx].child_id;
drop(tab_bar);
drop(seat_datas);
let children = self.child_nodes.borrow();
if let Some(child) = children.get(&child_id) {
let child_ref = child.to_ref();
drop(children);
self.activate_child(&child_ref);
}
return;
}
}
}
}
let (kind, child) = 'res: {
let mono = self.mono_child.is_some();
for child in self.children.iter() {
if !mono {
if self.split.get() == ContainerSplit::Horizontal {
if seat_data.x < child.body.get().x1() {
let Some(prev) = child.prev() else {
continue;
};
break 'res (
SeatOpKind::Resize {
dist_left: seat_data.x
- child.prev().unwrap().body.get().x2(),
- prev.body.get().x2(),
dist_right: child.body.get().x1() - seat_data.x,
},
child,
@ -1067,10 +1428,13 @@ impl ContainerNode {
}
} else {
if seat_data.y < child.body.get().y1() {
let Some(prev) = child.prev() else {
continue;
};
break 'res (
SeatOpKind::Resize {
dist_left: seat_data.y
- child.prev().unwrap().body.get().y2(),
- prev.body.get().y2(),
dist_right: child.body.get().y1() - seat_data.y,
},
child,
@ -1285,6 +1649,78 @@ pub async fn container_render_positions(state: Rc<State>) {
}
}
pub async fn container_tab_render_textures(state: Rc<State>) {
loop {
let container = state.pending_container_tab_render_textures.pop().await;
container.update_tab_textures_scheduled.set(false);
let (event, textures) = container.update_tab_textures_phase1();
event.triggered().await;
container.update_tab_textures_phase2(&textures);
}
}
impl ContainerNode {
fn update_tab_textures_phase1(
self: &Rc<Self>,
) -> (Rc<crate::utils::asyncevent::AsyncEvent>, Vec<Rc<RefCell<Option<TextTexture>>>>) {
let on_completed = Rc::new(OnDropEvent::default());
let (entries, bar_height, render_scale) = {
let tab_bar = self.tab_bar.borrow();
let Some(tb) = tab_bar.as_ref() else {
return (on_completed.event(), vec![]);
};
let entries: Vec<_> = tb.entries.iter().map(|e| {
(e.title.clone(), TabBar::entry_colors(&self.state, e), e.title_texture.clone())
}).collect();
(entries, tb.height, tb.render_scale)
};
let Some(ctx) = self.state.render_ctx.get() else {
log::warn!("tab text phase1: no render context");
return (on_completed.event(), vec![]);
};
let font = self.state.theme.title_font();
let scale = if render_scale != Scale::from_int(1) {
Some(render_scale.to_f64())
} else {
None
};
let mut texture_height = bar_height;
if let Some(s) = scale {
texture_height = (bar_height as f64 * s).round() as _;
}
let mut scheduled = 0;
let mut texture_refs = Vec::new();
for (title, (_, _, text_color), title_texture) in &entries {
let mut tex_ref = title_texture.borrow_mut();
let tex = tex_ref.get_or_insert_with(|| TextTexture::new(&self.state, &ctx));
tex.schedule_render_fitting(
on_completed.clone(),
Some(texture_height),
&font,
title,
*text_color,
false,
scale,
);
texture_refs.push(title_texture.clone());
scheduled += 1;
}
(on_completed.event(), texture_refs)
}
fn update_tab_textures_phase2(&self, textures: &[Rc<RefCell<Option<TextTexture>>>]) {
for title_texture in textures {
let tex_ref = title_texture.borrow();
if let Some(tex) = tex_ref.as_ref() {
if let Err(e) = tex.flip() {
log::warn!("Could not render tab text: {}", ErrorFmt(e));
}
}
}
self.damage();
}
}
impl Node for ContainerNode {
fn node_id(&self) -> NodeId {
self.id.into()
@ -1329,8 +1765,9 @@ impl Node for ContainerNode {
self.toplevel_data.node_layer()
}
fn node_child_title_changed(self: Rc<Self>, _child: &dyn Node, _title: &str) {
// Titlebars removed; no title tracking needed
fn node_child_title_changed(self: Rc<Self>, child: &dyn Node, title: &str) {
self.rebuild_tab_bar_with_override(Some(child.node_id()), Some(title));
self.damage();
}
fn node_do_focus(self: Rc<Self>, seat: &Rc<WlSeatGlobal>, direction: Direction) {
@ -1433,8 +1870,31 @@ impl Node for ContainerNode {
self.button(id, seat, time_usec, state == ButtonState::Pressed, button);
}
fn node_on_axis_event(self: Rc<Self>, _seat: &Rc<WlSeatGlobal>, _event: &PendingScroll) {
// Scroll-to-switch-tabs was a title bar feature; no-op without titles
fn node_on_axis_event(self: Rc<Self>, _seat: &Rc<WlSeatGlobal>, event: &PendingScroll) {
if self.mono_child.is_none() {
return;
}
// Use vertical scroll (index 1) to switch tabs.
let v = match event.v120[1].get() {
Some(v) if v != 0 => v,
_ => return,
};
let mono = match self.mono_child.get() {
Some(m) => m,
None => return,
};
let next = if v > 0 {
// Scroll down → next tab.
mono.next().or_else(|| self.children.first())
} else {
// Scroll up → previous tab.
mono.prev().or_else(|| self.children.last())
};
if let Some(next) = next {
if next.node.node_id() != mono.node.node_id() {
self.activate_child(&next);
}
}
}
fn node_on_leave(&self, seat: &WlSeatGlobal) {
@ -1644,7 +2104,27 @@ impl ContainingNode for ContainerNode {
}
}
self.sum_factors.set(sum);
// Ephemeral collapse: if this container is ephemeral and has exactly
// one child remaining, replace this container with that child in the parent.
if self.ephemeral.get() == Ephemeral::On
&& num_children == 1
&& !self.toplevel_data.is_fullscreen.get()
{
if let Some(parent) = self.toplevel_data.parent.get() {
if let Some(only_child) = self.children.first() {
let child_node = only_child.node.clone();
if parent.cnode_accepts_child(&*child_node) {
parent.cnode_replace_child(self.deref(), child_node);
self.toplevel_data.parent.take();
self.child_nodes.borrow_mut().clear();
self.tl_destroy();
return;
}
}
}
}
// log::info!("cnode_remove_child2");
self.rebuild_tab_bar();
self.schedule_layout();
self.cancel_seat_ops();
self.child_removed.trigger();
@ -1664,6 +2144,8 @@ impl ContainingNode for ContainerNode {
return;
}
self.mod_attention_requests(set);
drop(children);
self.rebuild_tab_bar();
self.schedule_compute_render_positions();
}
@ -1882,6 +2364,13 @@ impl ToplevelNodeBase for ContainerNode {
size_changed |= self.height.replace(rect.height()) != rect.height();
if size_changed {
self.update_content_size();
// Re-layout tab bar entries when container size changes in mono mode.
if self.mono_child.is_some() {
if let Some(bar) = self.tab_bar.borrow().as_ref() {
let padding = self.state.theme.sizes.tab_bar_padding.get();
bar.layout_entries(rect.width(), padding);
}
}
// log::info!("tl_change_extents");
self.perform_layout();
self.cancel_seat_ops();