diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 39e6e54b..f31864fe 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -2035,6 +2035,42 @@ impl ConfigClient { radius } + pub fn seat_toggle_expand(&self, seat: Seat) { + self.send(&ClientMessage::SeatToggleExpand { seat }); + } + + pub fn seat_toggle_tab(&self, seat: Seat) { + self.send(&ClientMessage::SeatToggleTab { seat }); + } + + pub fn seat_make_group(&self, seat: Seat, axis: Axis, ephemeral: bool) { + self.send(&ClientMessage::SeatMakeGroup { + seat, + axis, + ephemeral, + }); + } + + pub fn seat_change_group_opposite(&self, seat: Seat) { + self.send(&ClientMessage::SeatChangeGroupOpposite { seat }); + } + + pub fn seat_equalize(&self, seat: Seat, recursive: bool) { + self.send(&ClientMessage::SeatEqualize { seat, recursive }); + } + + pub fn set_autotile(&self, enabled: bool) { + self.send(&ClientMessage::SetAutotile { enabled }); + } + + pub fn set_tab_title_align(&self, align: u32) { + self.send(&ClientMessage::SetTabTitleAlign { align }); + } + + pub fn seat_move_tab(&self, seat: Seat, right: bool) { + self.send(&ClientMessage::SeatMoveTab { seat, right }); + } + fn handle_msg(&self, msg: &[u8]) { self.handle_msg2(msg); self.dispatch_futures(); diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 05415f6b..acb5ad81 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -884,6 +884,34 @@ pub enum ClientMessage<'a> { radius: f32, }, GetCornerRadius, + SeatToggleExpand { + seat: Seat, + }, + SeatToggleTab { + seat: Seat, + }, + SeatMakeGroup { + seat: Seat, + axis: Axis, + ephemeral: bool, + }, + SeatChangeGroupOpposite { + seat: Seat, + }, + SeatEqualize { + seat: Seat, + recursive: bool, + }, + SetAutotile { + enabled: bool, + }, + SetTabTitleAlign { + align: u32, + }, + SeatMoveTab { + seat: Seat, + right: bool, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index 970299da..dbdef1ba 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -677,6 +677,33 @@ impl Seat { pub fn unstable_set_mouse_follows_focus(self, enabled: bool) { get!().seat_set_mouse_follows_focus(self, enabled) } + + /// Toggles tabbed mode on the focused window's parent container. + pub fn toggle_tab(self) { + get!().seat_toggle_tab(self) + } + + /// Wraps the focused child in a new sub-container with the given split axis. + pub fn make_group(self, axis: Axis, ephemeral: bool) { + get!().seat_make_group(self, axis, ephemeral) + } + + /// Toggles the parent container's split direction (H↔V). + pub fn change_group_opposite(self) { + get!().seat_change_group_opposite(self) + } + + /// Resets all siblings' size factors to equal. + pub fn equalize(self, recursive: bool) { + get!().seat_equalize(self, recursive) + } + + /// Move the active tab left or right within the current tab bar. + /// + /// Equivalent to hy3's `movewindow` within a tabbed group. + pub fn move_tab(self, right: bool) { + get!().seat_move_tab(self, right) + } } /// A focus-follows-mouse mode. diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index 0d674199..56c9167a 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -394,6 +394,30 @@ pub fn get_corner_radius() -> f32 { get!(0.0).get_corner_radius() } +/// 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). +/// +/// The default is `false`. +pub fn set_autotile(enabled: bool) { + get!().set_autotile(enabled) +} + +/// Sets the horizontal alignment of title text within tab buttons. +/// +/// - `"start"` — left-aligned (default) +/// - `"center"` — centered +/// - `"end"` — right-aligned +pub fn set_tab_title_align(align: &str) { + let val = match align { + "center" => 1, + "end" => 2, + _ => 0, // start + }; + get!().set_tab_title_align(val) +} + /// Sets a callback to run when this config is unloaded. /// /// Only one callback can be set at a time. If another callback is already set, it will be diff --git a/jay-config/src/theme.rs b/jay-config/src/theme.rs index e2218f9e..d1c9e9f5 100644 --- a/jay-config/src/theme.rs +++ b/jay-config/src/theme.rs @@ -302,6 +302,38 @@ pub mod colors { /// /// Default: `#9d28c67f`. const 15 => HIGHLIGHT_COLOR, + /// The background color of an active (focused) tab. + /// + /// Default: `#33ccff40`. + const 16 => TAB_ACTIVE_BACKGROUND_COLOR, + /// The border color of an active (focused) tab. + /// + /// Default: `#33ccffee`. + const 17 => TAB_ACTIVE_BORDER_COLOR, + /// The background color of an inactive tab. + /// + /// Default: `#222222`. + const 18 => TAB_INACTIVE_BACKGROUND_COLOR, + /// The border color of an inactive tab. + /// + /// Default: `#333333`. + const 19 => TAB_INACTIVE_BORDER_COLOR, + /// The text color of an active (focused) tab. + /// + /// Default: `#ffffff`. + const 20 => TAB_ACTIVE_TEXT_COLOR, + /// The text color of an inactive tab. + /// + /// Default: `#888888`. + const 21 => TAB_INACTIVE_TEXT_COLOR, + /// The background color of the tab bar strip. + /// + /// Default: `#111111`. + const 22 => TAB_BAR_BACKGROUND_COLOR, + /// The background color of a tab that has requested attention. + /// + /// Default: `#23092c`. + const 23 => TAB_ATTENTION_BACKGROUND_COLOR, } /// Sets the color of GUI element. @@ -374,5 +406,29 @@ pub mod sized { /// /// Default: 0 const 06 => TITLE_GAP, + /// The height of the tab bar in pixels. + /// + /// Default: 22 + const 07 => TAB_BAR_HEIGHT, + /// The padding between tabs in the tab bar in pixels. + /// + /// Default: 6 + const 08 => TAB_BAR_PADDING, + /// The corner radius of tabs in the tab bar in pixels. + /// + /// Default: 6 + const 09 => TAB_BAR_RADIUS, + /// The border width of tabs in the tab bar in pixels. + /// + /// Default: 2 + const 10 => TAB_BAR_BORDER_WIDTH, + /// The horizontal padding within each tab for text in pixels. + /// + /// Default: 4 + const 11 => TAB_BAR_TEXT_PADDING, + /// The gap between the tab bar and the window content below in pixels. + /// + /// Default: 4 + const 12 => TAB_BAR_GAP, } } diff --git a/src/compositor.rs b/src/compositor.rs index 09ca0c0d..38d25121 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -62,6 +62,7 @@ use { WorkspaceDisplayOrder, WorkspaceNode, container_layout, container_render_positions, float_layout, output_render_data, placeholder_render_textures, + container_tab_render_textures, }, user_session::import_environment, utils::{ @@ -265,6 +266,7 @@ fn start_compositor2( pending_toplevel_screencasts: Default::default(), pending_screencast_reallocs_or_reconfigures: Default::default(), pending_placeholder_render_textures: Default::default(), + pending_container_tab_render_textures: Default::default(), dbus: Dbus::new(&engine, &ring, &run_toplevel), fdcloser: FdCloser::new(), logger: logger.clone(), @@ -514,6 +516,11 @@ fn start_global_event_handlers(state: &Rc) -> Vec> { Phase::PostLayout, placeholder_render_textures(state.clone()), ), + eng.spawn2( + "container tab textures", + Phase::PostLayout, + container_tab_render_textures(state.clone()), + ), eng.spawn2( "output render", Phase::PostLayout, diff --git a/src/config/handler.rs b/src/config/handler.rs index 25694ac2..e30fefcf 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1757,6 +1757,41 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_seat_toggle_tab(&self, seat: Seat) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + seat.toggle_tab(); + Ok(()) + } + + fn handle_seat_make_group( + &self, + seat: Seat, + axis: Axis, + ephemeral: bool, + ) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + seat.make_group(axis.into(), ephemeral); + Ok(()) + } + + fn handle_seat_change_group_opposite(&self, seat: Seat) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + seat.change_group_opposite(); + Ok(()) + } + + fn handle_seat_equalize(&self, seat: Seat, recursive: bool) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + seat.equalize(recursive); + Ok(()) + } + + fn handle_seat_move_tab(&self, seat: Seat, right: bool) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + seat.move_tab(right); + Ok(()) + } + fn handle_get_window_split(&self, window: Window) -> Result<(), CphError> { let window = self.get_window(window)?; self.respond(Response::GetWindowSplit { @@ -2464,6 +2499,12 @@ impl ConfigProxyHandler { BAR_SEPARATOR_WIDTH => ThemeSized::bar_separator_width, GAP => ThemeSized::gap, TITLE_GAP => ThemeSized::title_gap, + TAB_BAR_HEIGHT => ThemeSized::tab_bar_height, + TAB_BAR_PADDING => ThemeSized::tab_bar_padding, + TAB_BAR_RADIUS => ThemeSized::tab_bar_radius, + TAB_BAR_BORDER_WIDTH => ThemeSized::tab_bar_border_width, + TAB_BAR_TEXT_PADDING => ThemeSized::tab_bar_text_padding, + TAB_BAR_GAP => ThemeSized::tab_bar_gap, _ => return Err(CphError::UnknownSized(sized.0)), }; Ok(sized) @@ -2541,6 +2582,14 @@ impl ConfigProxyHandler { BAR_STATUS_TEXT_COLOR => ThemeColor::bar_text, ATTENTION_REQUESTED_BACKGROUND_COLOR => ThemeColor::attention_requested_background, HIGHLIGHT_COLOR => ThemeColor::highlight, + TAB_ACTIVE_BACKGROUND_COLOR => ThemeColor::tab_active_background, + TAB_ACTIVE_BORDER_COLOR => ThemeColor::tab_active_border, + TAB_INACTIVE_BACKGROUND_COLOR => ThemeColor::tab_inactive_background, + TAB_INACTIVE_BORDER_COLOR => ThemeColor::tab_inactive_border, + TAB_ACTIVE_TEXT_COLOR => ThemeColor::tab_active_text, + TAB_INACTIVE_TEXT_COLOR => ThemeColor::tab_inactive_text, + TAB_BAR_BACKGROUND_COLOR => ThemeColor::tab_bar_background, + TAB_ATTENTION_BACKGROUND_COLOR => ThemeColor::tab_attention_background, _ => return Err(CphError::UnknownColor(colorable.0)), }; Ok(colorable) @@ -3448,6 +3497,40 @@ impl ConfigProxyHandler { } => self .handle_window_resize(window, dx1, dy1, dx2, dy2) .wrn("window_resize")?, + ClientMessage::SeatToggleTab { seat } => self + .handle_seat_toggle_tab(seat) + .wrn("seat_toggle_tab")?, + ClientMessage::SeatMakeGroup { + seat, + axis, + ephemeral, + } => self + .handle_seat_make_group(seat, axis, ephemeral) + .wrn("seat_make_group")?, + ClientMessage::SeatChangeGroupOpposite { seat } => self + .handle_seat_change_group_opposite(seat) + .wrn("seat_change_group_opposite")?, + ClientMessage::SeatEqualize { seat, recursive } => self + .handle_seat_equalize(seat, recursive) + .wrn("seat_equalize")?, + ClientMessage::SetAutotile { enabled } => { + self.state.theme.autotile_enabled.set(enabled); + } + ClientMessage::SeatToggleExpand { .. } => { + // Removed feature; kept for binary protocol compatibility. + } + ClientMessage::SetTabTitleAlign { align } => { + use crate::theme::TabTitleAlign; + let val = match align { + 1 => TabTitleAlign::Center, + 2 => TabTitleAlign::End, + _ => TabTitleAlign::Start, + }; + self.state.theme.tab_title_align.set(val); + } + ClientMessage::SeatMoveTab { seat, right } => self + .handle_seat_move_tab(seat, right) + .wrn("seat_move_tab")?, } Ok(()) } diff --git a/src/gfx_api.rs b/src/gfx_api.rs index 3e89c676..8e196ede 100644 --- a/src/gfx_api.rs +++ b/src/gfx_api.rs @@ -306,6 +306,8 @@ pub struct RoundedFillRect { pub border_width: f32, /// Output scale for antialiasing. pub scale: f32, + /// Sort order hint within the RoundedFill bucket (lower renders first). + pub z_order: u32, } impl RoundedFillRect { diff --git a/src/gfx_apis/vulkan/renderer.rs b/src/gfx_apis/vulkan/renderer.rs index 6e127ced..9caa0d78 100644 --- a/src/gfx_apis/vulkan/renderer.rs +++ b/src/gfx_apis/vulkan/renderer.rs @@ -254,6 +254,7 @@ struct VulkanRoundedFillOp { border_width: f32, scale: f32, range_address: DeviceAddress, + z_order: u32, } struct VulkanRoundedTexOp { @@ -923,7 +924,7 @@ impl VulkanRenderer { enum Key { Fill { color: [u32; 4] }, Tex(usize), - RoundedFill { color: [u32; 4] }, + RoundedFill { z_order: u32, color: [u32; 4] }, RoundedTex(usize), } match o { @@ -932,6 +933,7 @@ impl VulkanRenderer { }, VulkanOp::Tex(t) => Key::Tex(t.index), VulkanOp::RoundedFill(f) => Key::RoundedFill { + z_order: f.z_order, color: f.color.map(|c| c.to_bits()), }, VulkanOp::RoundedTex(t) => Key::RoundedTex(t.index), @@ -1152,6 +1154,7 @@ impl VulkanRenderer { border_width: rf.border_width, scale: rf.scale, range_address: 0, + z_order: rf.z_order, })); } } @@ -2385,12 +2388,13 @@ impl VulkanRenderer { }; (opaque, c.target) } - GfxApiOpt::RoundedFillRect(_) => { - // Rounded rects are never fully opaque due to AA at corners - continue; + GfxApiOpt::RoundedFillRect(rf) => { + // Rounded rects are never fully opaque due to AA at corners, + // but they do paint pixels and need paint regions. + (false, rf.rect) } - GfxApiOpt::RoundedCopyTexture(_) => { - continue; + GfxApiOpt::RoundedCopyTexture(ct) => { + (false, ct.target) } }; if opaque || bb.is_none() { diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs index c3014a38..ebfc66b4 100644 --- a/src/ifs/wl_seat.rs +++ b/src/ifs/wl_seat.rs @@ -83,7 +83,7 @@ use { rect::Rect, state::{DeviceHandlerData, State}, tree::{ - ContainerNode, ContainerSplit, Direction, FoundNode, Node, NodeId, NodeLayer, + ContainerNode, ContainerSplit, ChangeGroupAction, Direction, FoundNode, Node, NodeId, NodeLayer, NodeLayerLink, NodeLocation, OutputNode, StackedNode, ToplevelNode, WorkspaceNode, generic_node_visitor, toplevel_create_split, toplevel_parent_container, toplevel_set_floating, toplevel_set_workspace, @@ -745,6 +745,40 @@ impl WlSeatGlobal { toplevel_create_split(&self.state, tl, axis); } + pub fn toggle_tab(&self) { + if let Some(c) = self.kb_parent_container() { + c.change_group(ChangeGroupAction::ToggleTab); + } + } + + pub fn make_group(&self, axis: ContainerSplit, ephemeral: bool) { + if let Some(c) = self.kb_parent_container() { + c.make_group(axis, ephemeral); + } + } + + pub fn change_group_opposite(&self) { + if let Some(c) = self.kb_parent_container() { + c.change_group(ChangeGroupAction::Opposite); + } + } + + pub fn equalize(&self, recursive: bool) { + if let Some(c) = self.kb_parent_container() { + if recursive { + c.equalize_recursive(); + } else { + c.equalize(); + } + } + } + + pub fn move_tab(&self, right: bool) { + if let Some(c) = self.kb_parent_container() { + c.move_tab(right); + } + } + pub fn focus_parent(self: &Rc) { if let Some(tl) = self.keyboard_node.get().node_toplevel() && let Some(parent) = tl.tl_data().parent.get() diff --git a/src/renderer.rs b/src/renderer.rs index 0b508287..af64f1a0 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -15,7 +15,7 @@ use { theme::{Color, CornerRadius}, tree::{ ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, - ToplevelNodeBase, WorkspaceNode, + ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, }, }, std::{ops::Deref, rc::Rc, slice}, @@ -277,6 +277,105 @@ impl Renderer<'_> { self.render_tl_aux(placeholder.tl_data(), bounds, true); } + fn render_tab_bar(&mut self, tab_bar: &TabBar, x: i32, y: i32, _container_width: i32) { + let srgb_srgb = self.state.color_manager.srgb_gamma22(); + let srgb = &srgb_srgb.linear; + let perceptual = RenderIntent::Perceptual; + let scalef = self.base.scalef as f32; + + let radius = self.state.theme.sizes.tab_bar_radius.get(); + let border_width = self.state.theme.sizes.tab_bar_border_width.get(); + let text_padding = self.state.theme.sizes.tab_bar_text_padding.get(); + let bar_height = tab_bar.height; + let render_scale = tab_bar.render_scale; + + // Vulkan sorts ops: Fill < Tex < RoundedFill (by z_order, color) < RoundedTex. + // We use: + // FillRect – tiny strip for Vulkan paint regions (hidden) + // RoundedFillRect z0 – solid rounded bg + // RoundedFillRect z1 – rounded border ring (on top of bg) + // RoundedCopyTexture – title text (on top of everything) + for entry in &tab_bar.entries { + let (bg_color, border_color, _text_color) = + TabBar::entry_colors(self.state, entry); + let ex = entry.x.get(); + let ew = entry.width.get(); + let tab_rect = Rect::new_sized_saturating(ex, 0, ew, bar_height); + let tab_cr = CornerRadius::from(radius as f32); + + // Tiny FillRect strip to establish Vulkan paint regions (visually hidden + // behind the RoundedFillRect bg that renders later). + let strip = Rect::new_sized_saturating(ex + radius, bar_height / 2, (ew - 2 * radius).max(1), 1); + self.base + .fill_boxes2(slice::from_ref(&strip), &bg_color, srgb, perceptual, x, y); + + // Rounded solid bg fill (z_order=0, renders first among RoundedFill). + self.base.fill_rounded_rect_z( + tab_rect.move_(x, y), + &bg_color, + None, + srgb, + perceptual, + tab_cr.scaled_by(scalef), + 0.0, + 0, + ); + + // Rounded border ring on top (z_order=1, renders after bg). + if border_width > 0 { + self.base.fill_rounded_rect_z( + tab_rect.move_(x, y), + &border_color, + None, + srgb, + perceptual, + tab_cr.scaled_by(scalef), + border_width as f32 * scalef, + 1, + ); + } + + // Title text as RoundedCopyTexture (sorts after all RoundedFill). + let tex_ref = entry.title_texture.borrow(); + if let Some(tex) = tex_ref.as_ref() + && let Some(texture) = tex.texture() + { + use crate::theme::TabTitleAlign; + let (tw, _th) = texture.size(); + let tex_width = (tw as f64 / render_scale.to_f64()).round() as i32; + let tab_inner = ew - 2 * (text_padding + border_width); + let text_x = match self.state.theme.tab_title_align.get() { + TabTitleAlign::Start => x + ex + text_padding + border_width, + TabTitleAlign::Center => { + x + ex + border_width + (tab_inner.max(0) - tex_width).max(0) / 2 + text_padding.min(tab_inner.max(0) / 2) + } + TabTitleAlign::End => { + let end_x = x + ex + ew - tex_width - text_padding - border_width; + end_x.max(x + ex + border_width) + } + }; + let (tx, ty) = self.base.scale_point(text_x, y); + self.base.render_rounded_texture( + &texture, + None, + tx, + ty, + None, + None, + render_scale, + None, + None, + AcquireSync::None, + ReleaseSync::None, + self.state.color_manager.srgb_gamma22(), + perceptual, + AlphaMode::PremultipliedElectrical, + CornerRadius::from(0.0_f32), + ); + } + } + } + fn render_container_decorations(&mut self, container: &ContainerNode, x: i32, y: i32) { let srgb_srgb = self.state.color_manager.srgb_gamma22(); let srgb = &srgb_srgb.linear; @@ -291,6 +390,13 @@ impl Renderer<'_> { self.render_container_decorations(container, x, y); if let Some(child) = container.mono_child.get() { + // Render tab bar if present. + { + let tab_bar = container.tab_bar.borrow(); + if let Some(tb) = tab_bar.as_ref() { + self.render_tab_bar(tb, x, y, container.width.get()); + } + } let mb = container.mono_body.get(); if self.state.theme.sizes.gap.get() != 0 { let srgb_srgb = self.state.color_manager.srgb_gamma22(); @@ -308,21 +414,22 @@ impl Renderer<'_> { let perceptual = RenderIntent::Perceptual; if !child.node.node_is_container() { let cr = self.state.theme.corner_radius.get(); - let full_h = mb.y2(); + let frame_y = mb.y1(); + let frame_h = mb.height(); if cr.is_zero() { let frame_rects = [ - Rect::new_sized_saturating(mb.x1() - bw, 0, bw, full_h), - Rect::new_sized_saturating(mb.x2(), 0, bw, full_h), - Rect::new_sized_saturating(mb.x1() - bw, -bw, full_w + 2 * bw, bw), - Rect::new_sized_saturating(mb.x1() - bw, full_h, full_w + 2 * bw, bw), + Rect::new_sized_saturating(mb.x1() - bw, frame_y, bw, frame_h), + Rect::new_sized_saturating(mb.x2(), frame_y, bw, frame_h), + Rect::new_sized_saturating(mb.x1() - bw, frame_y - bw, full_w + 2 * bw, bw), + Rect::new_sized_saturating(mb.x1() - bw, frame_y + frame_h, full_w + 2 * bw, bw), ]; self.base.fill_boxes2(&frame_rects, c, srgb, perceptual, x, y); } else { let outer = Rect::new_sized_saturating( mb.x1() - bw, - -bw, + frame_y - bw, full_w + 2 * bw, - full_h + 2 * bw, + frame_h + 2 * bw, ); let scalef = self.base.scalef as f32; let scaled_cr = cr.scaled_by(scalef); diff --git a/src/renderer/renderer_base.rs b/src/renderer/renderer_base.rs index a549126f..dce5bfa8 100644 --- a/src/renderer/renderer_base.rs +++ b/src/renderer/renderer_base.rs @@ -259,6 +259,20 @@ impl RendererBase<'_> { render_intent: RenderIntent, corner_radius: CornerRadius, border_width: f32, + ) { + self.fill_rounded_rect_z(rect, color, alpha, cd, render_intent, corner_radius, border_width, 0) + } + + pub fn fill_rounded_rect_z( + &mut self, + rect: Rect, + color: &Color, + alpha: Option, + cd: &Rc, + render_intent: RenderIntent, + corner_radius: CornerRadius, + border_width: f32, + z_order: u32, ) { if *color == Color::TRANSPARENT { return; @@ -288,6 +302,7 @@ impl RendererBase<'_> { corner_radius: cr, border_width, scale, + z_order, })); } diff --git a/src/state.rs b/src/state.rs index abae8b04..ca60f01e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -195,6 +195,7 @@ pub struct State { pub pending_toplevel_screencasts: AsyncQueue>, pub pending_screencast_reallocs_or_reconfigures: AsyncQueue>, pub pending_placeholder_render_textures: AsyncQueue>, + pub pending_container_tab_render_textures: AsyncQueue>, pub dbus: Dbus, pub fdcloser: Arc, pub logger: Option>, @@ -1112,6 +1113,7 @@ impl State { self.pending_toplevel_screencasts.clear(); self.pending_screencast_reallocs_or_reconfigures.clear(); self.pending_placeholder_render_textures.clear(); + self.pending_container_tab_render_textures.clear(); self.render_ctx_watchers.clear(); self.workspace_watchers.clear(); self.toplevel_lists.clear(); diff --git a/src/theme.rs b/src/theme.rs index 04d23da4..d29138c8 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -454,6 +454,14 @@ colors! { bar_text = (0xff, 0xff, 0xff), attention_requested_background = (0x23, 0x09, 0x2c), highlight = (0x9d, 0x28, 0xc6, 0x7f), + tab_active_background = (0x4c, 0x78, 0x99), + tab_active_border = (0x28, 0x55, 0x77), + tab_inactive_background = (0x22, 0x22, 0x22), + tab_inactive_border = (0x33, 0x33, 0x33), + tab_active_text = (0xff, 0xff, 0xff), + tab_inactive_text = (0x88, 0x88, 0x88), + tab_bar_background = (0x00, 0x00, 0x00, 0x00), + tab_attention_background = (0x23, 0x09, 0x2c), } impl StaticText for ThemeColor { @@ -476,6 +484,14 @@ impl StaticText for ThemeColor { ThemeColor::bar_text => "Bar Text", ThemeColor::attention_requested_background => "Attention Requested", ThemeColor::highlight => "Highlight", + ThemeColor::tab_active_background => "Tab Background (active)", + ThemeColor::tab_active_border => "Tab Border (active)", + ThemeColor::tab_inactive_background => "Tab Background (inactive)", + ThemeColor::tab_inactive_border => "Tab Border (inactive)", + ThemeColor::tab_active_text => "Tab Text (active)", + ThemeColor::tab_inactive_text => "Tab Text (inactive)", + ThemeColor::tab_bar_background => "Tab Bar Background", + ThemeColor::tab_attention_background => "Tab Attention Background", } } } @@ -588,6 +604,12 @@ sizes! { bar_separator_width = (0, 1000, 1), gap = (0, 1000, 0), title_gap = (0, 1000, 5), + tab_bar_height = (0, 1000, 22), + tab_bar_padding = (0, 1000, 6), + tab_bar_radius = (0, 1000, 6), + tab_bar_border_width = (0, 1000, 2), + tab_bar_text_padding = (0, 1000, 4), + tab_bar_gap = (0, 1000, 4), } impl StaticText for ThemeSized { @@ -599,6 +621,12 @@ impl StaticText for ThemeSized { ThemeSized::bar_separator_width => "Bar Separator Width", ThemeSized::gap => "Gap", ThemeSized::title_gap => "Title Gap", + ThemeSized::tab_bar_height => "Tab Bar Height", + ThemeSized::tab_bar_padding => "Tab Bar Padding", + ThemeSized::tab_bar_radius => "Tab Bar Radius", + ThemeSized::tab_bar_border_width => "Tab Bar Border Width", + ThemeSized::tab_bar_text_padding => "Tab Bar Text Padding", + ThemeSized::tab_bar_gap => "Tab Bar Gap", } } } @@ -732,6 +760,15 @@ impl CornerRadius { } } +/// Horizontal alignment of title text inside tab buttons. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum TabTitleAlign { + #[default] + Start, + Center, + End, +} + pub struct Theme { pub colors: ThemeColors, pub sizes: ThemeSizes, @@ -745,6 +782,8 @@ pub struct Theme { pub floating_titles: Cell, pub bar_position: Cell, pub corner_radius: Cell, + pub autotile_enabled: Cell, + pub tab_title_align: Cell, } impl Default for Theme { @@ -761,6 +800,8 @@ impl Default for Theme { floating_titles: Cell::new(false), bar_position: Default::default(), corner_radius: Cell::new(CornerRadius::default()), + autotile_enabled: Cell::new(false), + tab_title_align: Cell::new(TabTitleAlign::default()), } } } @@ -770,6 +811,10 @@ impl Theme { self.bar_font.get().unwrap_or_else(|| self.font.get()) } + pub fn title_font(&self) -> Arc { + self.title_font.get().unwrap_or_else(|| self.font.get()) + } + pub fn title_height(&self) -> i32 { 0 } diff --git a/src/tree.rs b/src/tree.rs index b33abdc6..5aa601c5 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -50,6 +50,7 @@ mod float; mod output; mod placeholder; mod stacked; +pub mod tab_bar; mod toplevel; mod walker; mod workspace; diff --git a/src/tree/container.rs b/src/tree/container.rs index 9fde0706..2c701447 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -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, @@ -120,6 +146,9 @@ pub struct ContainerNode { pub child_added: Rc, pub child_removed: Rc, pub all_children_resized: Rc, + pub tab_bar: RefCell>, + pub update_tab_textures_scheduled: Cell, + pub ephemeral: Cell, } 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) { + 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, 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.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, + 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, + override_id: Option, + 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, 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> = { + 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, 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) { + 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.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, 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> { self.toplevel_data .parent @@ -955,6 +1257,42 @@ 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); @@ -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) { } } +pub async fn container_tab_render_textures(state: Rc) { + 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, + ) -> (Rc, Vec>>>) { + 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>>]) { + 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, _child: &dyn Node, _title: &str) { - // Titlebars removed; no title tracking needed + fn node_child_title_changed(self: Rc, 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, seat: &Rc, 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, _seat: &Rc, _event: &PendingScroll) { - // Scroll-to-switch-tabs was a title bar feature; no-op without titles + fn node_on_axis_event(self: Rc, _seat: &Rc, 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(); diff --git a/src/tree/tab_bar.rs b/src/tree/tab_bar.rs new file mode 100644 index 00000000..ef93f9f4 --- /dev/null +++ b/src/tree/tab_bar.rs @@ -0,0 +1,113 @@ +use { + crate::{ + scale::Scale, + state::State, + text::TextTexture, + theme::Color, + tree::NodeId, + }, + std::{cell::{Cell, RefCell}, rc::Rc}, +}; + +/// A single entry (tab) within a tab bar. +pub struct TabBarEntry { + /// The node ID of the child this tab represents. + pub child_id: NodeId, + /// The display title of the tab. + pub title: String, + /// Pre-rendered text texture for the tab title. + pub title_texture: Rc>>, + /// Whether this is the active (visible) tab. + pub active: bool, + /// Whether this tab's window has requested attention. + pub attention_requested: bool, + /// X offset of this tab within the tab bar (relative to tab bar start). + pub x: Cell, + /// Width of this tab in pixels. + pub width: Cell, +} + +/// A tab bar rendered above a container in mono (tabbed) mode. +pub struct TabBar { + /// The individual tab entries. + pub entries: Vec, + /// Height of the tab bar in pixels (from theme). + pub height: i32, + /// The output scale at which text textures were rendered. + pub render_scale: Scale, +} + +impl TabBar { + /// Create a new empty tab bar. + pub fn new(height: i32, render_scale: Scale) -> Self { + Self { + entries: Vec::new(), + height, + render_scale, + } + } + + /// Recompute the positions and widths of all tab entries. + /// + /// `total_width` is the available width for the entire tab bar. + pub fn layout_entries(&self, total_width: i32, padding: i32) { + let n = self.entries.len() as i32; + if n == 0 { + return; + } + let total_padding = padding * (n + 1); + let available = (total_width - total_padding).max(0); + let per_tab = available / n; + let mut remainder = available - per_tab * n; + let mut x = padding; + for entry in &self.entries { + let w = if remainder > 0 { + remainder -= 1; + per_tab + 1 + } else { + per_tab + }; + entry.x.set(x); + entry.width.set(w); + x += w + padding; + } + } + + /// Find the tab entry index at the given x coordinate (relative to tab bar). + /// + /// Returns `None` if the coordinate is in padding between tabs or out of bounds. + pub fn entry_at_x(&self, x: i32) -> Option { + for (i, entry) in self.entries.iter().enumerate() { + let ex = entry.x.get(); + let ew = entry.width.get(); + if x >= ex && x < ex + ew { + return Some(i); + } + } + None + } + + /// Get the colors for a tab entry based on its state. + pub fn entry_colors(state: &State, entry: &TabBarEntry) -> (Color, Color, Color) { + let theme = &state.theme; + if entry.attention_requested { + ( + theme.colors.tab_attention_background.get(), + theme.colors.tab_inactive_border.get(), + theme.colors.tab_active_text.get(), + ) + } else if entry.active { + ( + theme.colors.tab_active_background.get(), + theme.colors.tab_active_border.get(), + theme.colors.tab_active_text.get(), + ) + } else { + ( + theme.colors.tab_inactive_background.get(), + theme.colors.tab_inactive_border.get(), + theme.colors.tab_inactive_text.get(), + ) + } + } +} diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index c87ce12f..c82bc252 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -23,7 +23,7 @@ use { }, ahash::AHashMap, jay_config::{ - Axis, Direction, Workspace, + Direction, Workspace, client::ClientCapabilities, input::{ FallbackOutputMode, LayerDirection, SwitchEvent, Timeline, acceleration::AccelProfile, @@ -60,15 +60,10 @@ pub enum SimpleCommand { Quit, ReloadConfigSo, ReloadConfigToml, - Split(Axis), ToggleFloating, SetFloating(bool), ToggleFullscreen, SetFullscreen(bool), - ToggleMono, - SetMono(bool), - ToggleSplit, - SetSplit(Axis), Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), @@ -94,6 +89,17 @@ pub enum SimpleCommand { ReloadSimpleIm, EnableUnicodeInput, WarpMouseToFocus, + ToggleTab, + MakeGroupH, + MakeGroupV, + MakeGroupTab, + ChangeGroupOpposite, + Equalize, + EqualizeRecursive, + MoveTabLeft, + MoveTabRight, + SetAutotile(bool), + ToggleAutotile, } #[derive(Debug, Clone)] @@ -229,6 +235,21 @@ pub struct Theme { pub floating_titles: Option, pub title_gap: Option, pub corner_radius: Option, + pub tab_active_bg_color: Option, + pub tab_active_border_color: Option, + pub tab_inactive_bg_color: Option, + pub tab_inactive_border_color: Option, + pub tab_active_text_color: Option, + pub tab_inactive_text_color: Option, + pub tab_bar_bg_color: Option, + pub tab_attention_bg_color: Option, + pub tab_bar_height: Option, + pub tab_bar_padding: Option, + pub tab_bar_radius: Option, + pub tab_bar_border_width: Option, + pub tab_bar_text_padding: Option, + pub tab_bar_gap: Option, + pub tab_title_align: Option, } #[derive(Debug, Clone)] diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 9f8aef29..7581198d 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -33,7 +33,6 @@ use { }, indexmap::IndexMap, jay_config::{ - Axis::{Horizontal, Vertical}, Direction, get_workspace, input::{LayerDirection, Timeline}, }, @@ -115,14 +114,6 @@ impl ActionParser<'_> { "move-down" => Move(Down), "move-up" => Move(Up), "move-right" => Move(Right), - "split-horizontal" => Split(Horizontal), - "split-vertical" => Split(Vertical), - "toggle-split" => ToggleSplit, - "tile-horizontal" => SetSplit(Horizontal), - "tile-vertical" => SetSplit(Vertical), - "toggle-mono" => ToggleMono, - "show-single" => SetMono(true), - "show-all" => SetMono(false), "toggle-fullscreen" => ToggleFullscreen, "enter-fullscreen" => SetFullscreen(true), "exit-fullscreen" => SetFullscreen(false), @@ -172,6 +163,18 @@ impl ActionParser<'_> { "reload-simple-im" => ReloadSimpleIm, "enable-unicode-input" => EnableUnicodeInput, "warp-mouse-to-focus" => WarpMouseToFocus, + "toggle-tab" => ToggleTab, + "make-group-h" => MakeGroupH, + "make-group-v" => MakeGroupV, + "make-group-tab" => MakeGroupTab, + "change-group-opposite" => ChangeGroupOpposite, + "equalize" => Equalize, + "equalize-recursive" => EqualizeRecursive, + "move-tab-left" => MoveTabLeft, + "move-tab-right" => MoveTabRight, + "enable-autotile" => SetAutotile(true), + "disable-autotile" => SetAutotile(false), + "toggle-autotile" => ToggleAutotile, _ => { return Err( ActionParserError::UnknownSimpleAction(string.to_string()).spanned(span) diff --git a/toml-config/src/config/parsers/theme.rs b/toml-config/src/config/parsers/theme.rs index e2f728bd..fe8156f4 100644 --- a/toml-config/src/config/parsers/theme.rs +++ b/toml-config/src/config/parsers/theme.rs @@ -101,6 +101,41 @@ impl Parser for ThemeParser<'_> { recover(opt(s32("title-gap"))), recover(opt(fltorint("corner-radius"))), ))?; + let ( + ( + tab_active_bg_color, + tab_active_border_color, + tab_inactive_bg_color, + tab_inactive_border_color, + tab_active_text_color, + tab_inactive_text_color, + tab_bar_bg_color, + tab_attention_bg_color, + tab_bar_height, + tab_bar_padding, + ), + (tab_bar_radius, tab_bar_border_width, tab_bar_text_padding, tab_bar_gap, tab_title_align_val), + ) = ext.extract(( + ( + opt(val("tab-active-bg-color")), + opt(val("tab-active-border-color")), + opt(val("tab-inactive-bg-color")), + opt(val("tab-inactive-border-color")), + opt(val("tab-active-text-color")), + opt(val("tab-inactive-text-color")), + opt(val("tab-bar-bg-color")), + opt(val("tab-attention-bg-color")), + recover(opt(s32("tab-bar-height"))), + recover(opt(s32("tab-bar-padding"))), + ), + ( + recover(opt(s32("tab-bar-radius"))), + recover(opt(s32("tab-bar-border-width"))), + recover(opt(s32("tab-bar-text-padding"))), + recover(opt(s32("tab-bar-gap"))), + recover(opt(str("tab-title-align"))), + ), + ))?; macro_rules! color { ($e:expr) => { match $e { @@ -156,6 +191,21 @@ impl Parser for ThemeParser<'_> { floating_titles: floating_titles.despan(), title_gap: title_gap.despan(), corner_radius: corner_radius.map(|v| v.value as f32), + tab_active_bg_color: color!(tab_active_bg_color), + tab_active_border_color: color!(tab_active_border_color), + tab_inactive_bg_color: color!(tab_inactive_bg_color), + tab_inactive_border_color: color!(tab_inactive_border_color), + tab_active_text_color: color!(tab_active_text_color), + tab_inactive_text_color: color!(tab_inactive_text_color), + tab_bar_bg_color: color!(tab_bar_bg_color), + tab_attention_bg_color: color!(tab_attention_bg_color), + tab_bar_height: tab_bar_height.despan(), + tab_bar_padding: tab_bar_padding.despan(), + tab_bar_radius: tab_bar_radius.despan(), + tab_bar_border_width: tab_bar_border_width.despan(), + tab_bar_text_padding: tab_bar_text_padding.despan(), + tab_bar_gap: tab_bar_gap.despan(), + tab_title_align: tab_title_align_val.map(|v| v.value.to_string()), }) } } diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index d4164fe4..c15ace34 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -23,6 +23,7 @@ use { ahash::{AHashMap, AHashSet}, error_reporter::Report, jay_config::{ + Axis, client::Client, config, config_dir, exec::{Command, set_env, unset_env}, @@ -40,7 +41,7 @@ use { set_color_management_enabled, set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, set_idle, set_idle_grace_period, set_floating_titles, set_middle_click_paste_enabled, set_show_bar, set_show_float_pin_icon, - set_show_titles, set_corner_radius, + set_show_titles, set_corner_radius, set_autotile, 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, @@ -169,11 +170,6 @@ impl Action { Action::SimpleCommand { cmd } => match cmd { SimpleCommand::Focus(dir) => b.new(move || s.focus(dir)), SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)), - SimpleCommand::Split(axis) => window_or_seat!(s, s.create_split(axis)), - SimpleCommand::ToggleSplit => window_or_seat!(s, s.toggle_split()), - SimpleCommand::SetSplit(b) => window_or_seat!(s, s.set_split(b)), - SimpleCommand::ToggleMono => window_or_seat!(s, s.toggle_mono()), - SimpleCommand::SetMono(b) => window_or_seat!(s, s.set_mono(b)), SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()), SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), SimpleCommand::FocusParent => b.new(move || s.focus_parent()), @@ -259,6 +255,35 @@ impl Action { let persistent = state.persistent.clone(); b.new(move || persistent.seat.warp_mouse_to_focus()) } + SimpleCommand::ToggleTab => b.new(move || s.toggle_tab()), + SimpleCommand::MakeGroupH => { + b.new(move || s.make_group(Axis::Horizontal, true)) + } + SimpleCommand::MakeGroupV => { + b.new(move || s.make_group(Axis::Vertical, true)) + } + SimpleCommand::MakeGroupTab => { + b.new(move || { + s.make_group(Axis::Horizontal, true); + s.toggle_tab(); + }) + } + SimpleCommand::ChangeGroupOpposite => { + b.new(move || s.change_group_opposite()) + } + SimpleCommand::Equalize => b.new(move || s.equalize(false)), + SimpleCommand::EqualizeRecursive => b.new(move || s.equalize(true)), + 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) + }) + } }, Action::Multi { actions } => { let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); @@ -997,6 +1022,14 @@ impl State { color!(UNFOCUSED_TITLE_BACKGROUND_COLOR, unfocused_title_bg_color); color!(UNFOCUSED_TITLE_TEXT_COLOR, unfocused_title_text_color); color!(HIGHLIGHT_COLOR, highlight_color); + color!(TAB_ACTIVE_BACKGROUND_COLOR, tab_active_bg_color); + color!(TAB_ACTIVE_BORDER_COLOR, tab_active_border_color); + color!(TAB_INACTIVE_BACKGROUND_COLOR, tab_inactive_bg_color); + color!(TAB_INACTIVE_BORDER_COLOR, tab_inactive_border_color); + color!(TAB_ACTIVE_TEXT_COLOR, tab_active_text_color); + color!(TAB_INACTIVE_TEXT_COLOR, tab_inactive_text_color); + color!(TAB_BAR_BACKGROUND_COLOR, tab_bar_bg_color); + color!(TAB_ATTENTION_BACKGROUND_COLOR, tab_attention_bg_color); macro_rules! size { ($sized:ident, $field:ident) => { if let Some(size) = theme.$field { @@ -1010,6 +1043,12 @@ impl State { size!(BAR_SEPARATOR_WIDTH, bar_separator_width); size!(GAP, gap); size!(TITLE_GAP, title_gap); + size!(TAB_BAR_HEIGHT, tab_bar_height); + size!(TAB_BAR_PADDING, tab_bar_padding); + size!(TAB_BAR_RADIUS, tab_bar_radius); + size!(TAB_BAR_BORDER_WIDTH, tab_bar_border_width); + size!(TAB_BAR_TEXT_PADDING, tab_bar_text_padding); + size!(TAB_BAR_GAP, tab_bar_gap); macro_rules! font { ($fun:ident, $field:ident) => { if let Some(font) = &theme.$field { @@ -1023,6 +1062,9 @@ impl State { if let Some(radius) = theme.corner_radius { set_corner_radius(radius); } + if let Some(ref align) = theme.tab_title_align { + set_tab_title_align(align); + } } fn handle_switch_device(self: &Rc, dev: InputDevice, actions: &Rc) {