From dfdff7d7bce31bb8714fe668e583e20b6b8e4418 Mon Sep 17 00:00:00 2001 From: "MVB.Mir" Date: Fri, 17 Apr 2026 18:04:04 +0300 Subject: [PATCH 1/2] Redesign top bar and sidebar (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top bar: - Custom top bar at the top of the window with the sidebar toggle, settings cog, "+" new-workspace button, a row of workspace indicator pills, and the window controls (minimize/maximize/close). - macOS-style indicator pills: subtle hover/active backgrounds, pill shows a small dot + bold when the workspace has unread activity. - Empty header space drags the window via gtk::WindowHandle. - Custom buttons replace gtk::WindowControls so hover shape, padding, and border-radius match the pane action icons exactly. - Pane action icons (new terminal, split, close pane) reworked to the same compact visual language; tab close X the same. - Settings cog moved out of the per-pane action row into the top bar, freeing horizontal tab space. Pane header: - Empty space between the last tab and the action icons is a WindowHandle filler, so it drags the window without conflicting with the tabs' DragSource. - Middle-click on a tab closes it. Sidebar: - Cleaner workspace rows with a close X on the top-right (replaces the star's old position). The favorite star moved below next to the folder path; both are hover-to-reveal 20×20 buttons. - Double-click a row to inline-rename. - Row selection paints the inner row box with an accent-tinted background; Adwaita's default ListBox row styling is suppressed. - First-row top margin bumped so the gap above the first workspace matches the between-workspace gap. - Drops the "WORKSPACES" title and the big "+" button at the bottom. New-workspace behavior: - "+" clones the active workspace's folder instead of opening the folder picker. The picker only appears on first run / when no workspace exists. Settings (General tab): - Top bar (switch) — when off, removes the top bar entirely and relocates its buttons: dock toggle, settings cog, "+", and window controls move into a new sidebar header (with an hexpand spacer between the left group and the right group). When the sidebar is collapsed, the dock toggle parks on the active workspace's leading pane via a new `leading_box` slot on every pane; the rest of the controls stay hidden. - Window controls side (Left/Right) — moves minimize/maximize/close between the two ends of whichever header is active. Applies live. - Workspace indicators on the top bar (switch) — hides the per- workspace pills without hiding the top bar. Implementation hides each pill individually so `indicator_box` keeps its hexpand role between the left group and the window controls. - All three persist under `interface.*` in settings.json. Split ratio fix: - shrink_start_child / shrink_end_child on every Paned so the saved ratio wins over larger child minimums (e.g. wide tab strips). - position-notify tracks `last_size`; width-changed notifies are auto-adjusts (skip ratio update), same-width notifies are real user drags (update ratio). Without this, sidebar toggles and window resizes silently drifted the ratio because GtkPaned's position is absolute pixels. - Per-frame tick callback observes the paned's actual width and re- applies `position = ratio × new_width` on size changes. GtkWidget's width/height properties don't reliably emit notify across GTK 4.x versions, so polling is intentional; the check is O(1). - Startup uses a one-shot tick callback to apply the ratio once the paned first has a non-zero allocation. Structural: - Extracted handle_config_change() to dedupe appearance + top-bar side-effect + save/revert logic that ran from two config-change sites. - apply_top_bar_mode() is the single source of truth for how the dock toggle, settings cog, +, indicator pills, and window controls are laid out. Respects both the persistent `show_top_bar` setting and the transient keyboard toggle. - Dropped the unused on_config_changed field from PaneCallbacks; the pane no longer opens the Settings dialog. - Widget tree walkers (is_pane_widget, find_leaf_focused_pane) unwrap gtk::WindowHandle so the dock-parked-on-pane case still resolves panes correctly. Defaults: `show_top_bar = true`, `show_workspace_indicators = true`, `window_controls_side = Right`. Existing users without these fields in settings.json get the familiar layout. Closes #5 (notifications explicitly out of scope per the issue). --- rust/limux-host-linux/src/app_config.rs | 74 ++ rust/limux-host-linux/src/pane.rs | 103 +- rust/limux-host-linux/src/settings_editor.rs | 78 +- rust/limux-host-linux/src/split_tree.rs | 63 +- rust/limux-host-linux/src/window.rs | 1086 +++++++++++++++--- 5 files changed, 1238 insertions(+), 166 deletions(-) diff --git a/rust/limux-host-linux/src/app_config.rs b/rust/limux-host-linux/src/app_config.rs index 26894102..255a9624 100644 --- a/rust/limux-host-linux/src/app_config.rs +++ b/rust/limux-host-linux/src/app_config.rs @@ -36,6 +36,30 @@ impl ColorScheme { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WindowControlsSide { + Left, + #[default] + Right, +} + +impl WindowControlsSide { + pub fn as_str(self) -> &'static str { + match self { + Self::Left => "left", + Self::Right => "right", + } + } + + fn from_str(s: &str) -> Option { + match s { + "left" => Some(Self::Left), + "right" => Some(Self::Right), + _ => None, + } + } +} + #[derive(Clone, Debug, Default, PartialEq, Deserialize)] pub struct AppConfig { #[serde(default)] @@ -45,6 +69,8 @@ pub struct AppConfig { #[serde(skip)] pub notifications: NotificationConfig, #[serde(skip)] + pub interface: InterfaceConfig, + #[serde(skip)] pub font_size: Option, } @@ -54,6 +80,23 @@ pub struct AppearanceConfig { pub ghostty_color_scheme: ColorScheme, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InterfaceConfig { + pub window_controls_side: WindowControlsSide, + pub show_top_bar: bool, + pub show_workspace_indicators: bool, +} + +impl Default for InterfaceConfig { + fn default() -> Self { + Self { + window_controls_side: WindowControlsSide::default(), + show_top_bar: true, + show_workspace_indicators: true, + } + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] pub struct FocusConfig { #[serde(default)] @@ -256,6 +299,24 @@ fn parse_app_config_value(root: &Value) -> AppConfig { .map(|v| v as f32) .filter(|v| (1.0..=255.0).contains(v)); + let interface_obj = root.get("interface").and_then(Value::as_object); + + let window_controls_side = interface_obj + .and_then(|interface| interface.get("window_controls_side")) + .and_then(Value::as_str) + .and_then(WindowControlsSide::from_str) + .unwrap_or_default(); + + let show_top_bar = interface_obj + .and_then(|interface| interface.get("show_top_bar")) + .and_then(Value::as_bool) + .unwrap_or(true); + + let show_workspace_indicators = interface_obj + .and_then(|interface| interface.get("show_workspace_indicators")) + .and_then(Value::as_bool) + .unwrap_or(true); + AppConfig { focus: FocusConfig { hover_terminal_focus, @@ -268,6 +329,11 @@ fn parse_app_config_value(root: &Value) -> AppConfig { enabled: notifications_enabled, sound: notification_sound, }, + interface: InterfaceConfig { + window_controls_side, + show_top_bar, + show_workspace_indicators, + }, font_size, } } @@ -302,6 +368,14 @@ fn save_to_path(path: &Path, config: &AppConfig) -> Result<(), String> { "sound": config.notifications.sound.as_str(), }), ); + root.insert( + "interface".to_string(), + json!({ + "window_controls_side": config.interface.window_controls_side.as_str(), + "show_top_bar": config.interface.show_top_bar, + "show_workspace_indicators": config.interface.show_workspace_indicators, + }), + ); if let Some(size) = config.font_size { root.insert("font_size".to_string(), json!(size)); diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index 00f0753f..5cc4350a 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -20,7 +20,6 @@ use crate::keybind_editor; use crate::layout_state::{ PaneState, RestorableAgentState, TabContentState, TabState as SavedTabState, }; -use crate::settings_editor; use crate::shortcut_config::{NormalizedShortcut, ResolvedShortcutConfig, ShortcutId}; use crate::terminal::{self, TerminalCallbacks}; @@ -201,7 +200,6 @@ type PaneShortcutCaptureCallback = dyn Fn(ShortcutId, Option) -> Result; type PaneSplitWithTabCallback = dyn Fn(>k::Widget, >k::Widget, gtk::Orientation, String, bool); type PaneConfigCallback = dyn Fn() -> Rc>; -type PaneConfigChangedCallback = dyn Fn(&AppConfig, &AppConfig); /// Returns the workspace id that owns a given pane widget, or `None` if the /// pane is not yet attached to a workspace. Used to stamp `LIMUX_WORKSPACE_ID` /// onto every terminal spawned inside the pane. @@ -221,7 +219,6 @@ pub struct PaneCallbacks { pub on_state_changed: Box, pub on_split_with_tab: Box, pub current_config: Box, - pub on_config_changed: Rc, /// Resolve the workspace id for a given pane widget. May be `None` while /// the pane is still being constructed; callers treat that as "unknown". pub workspace_for_pane: Box, @@ -327,24 +324,25 @@ pub const PANE_CSS: &str = r#" .limux-tab-close { background: none; border: none; - border-radius: 3px; - padding: 1px; + border-radius: 6px; + padding: 2px; min-height: 0; min-width: 0; + margin: 0 0 0 4px; color: alpha(@window_fg_color, 0.28); - margin-left: 4px; } .limux-tab-close:hover { color: alpha(@window_fg_color, 0.8); - background: alpha(@window_fg_color, 0.1); + background: alpha(@window_fg_color, 0.08); } .limux-pane-action { background: none; border: none; - border-radius: 4px; - padding: 4px 5px; + border-radius: 6px; + padding: 4px; min-height: 0; min-width: 0; + margin: 0 1px; color: alpha(@window_fg_color, 0.4); } .limux-pane-action:hover { @@ -438,23 +436,44 @@ pub fn create_pane( .build(); outer.set_size_request(MIN_PANE_WIDTH, MIN_PANE_HEIGHT); - // The single header line: tabs (left) + action icons (right) + // The single header line: [leading slot] tabs (left) + action icons (right) let header = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(0) .build(); header.add_css_class("limux-pane-header"); + // Empty leading slot at the very start of the header — window.rs can + // stash the dock toggle here when the top bar is hidden and the sidebar + // is collapsed. Hidden by default (no children = no width). + let leading_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + leading_box.add_css_class("limux-pane-leading"); + header.append(&leading_box); + let tab_overlay = gtk::Overlay::new(); tab_overlay.add_css_class("limux-tab-overlay"); tab_overlay.set_hexpand(true); + // tab_strip holds the actual tab buttons (natural width). A WindowHandle + // sibling to its right soaks up the remaining space and drags the window + // when clicked, so the empty area after the last tab is also draggable. let tab_strip = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + let tab_drag_filler = gtk::WindowHandle::new(); + tab_drag_filler.set_hexpand(true); + let tab_strip_wrapper = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(0) .hexpand(true) .build(); - tab_overlay.set_child(Some(&tab_strip)); + tab_strip_wrapper.append(&tab_strip); + tab_strip_wrapper.append(&tab_drag_filler); + tab_overlay.set_child(Some(&tab_strip_wrapper)); let drop_indicator = gtk::Box::new(gtk::Orientation::Vertical, 0); drop_indicator.add_css_class("limux-tab-drop-indicator"); @@ -507,7 +526,6 @@ pub fn create_pane( "limux-split-vertical-symbolic", &pane_action_tooltip(&shortcuts, "Split down", Some(ShortcutId::SplitDown)), ); - let settings_btn = icon_button("emblem-system-symbolic", "Settings"); let close_btn = icon_button( "window-close-symbolic", &pane_action_tooltip(&shortcuts, "Close pane", Some(ShortcutId::CloseFocusedPane)), @@ -517,7 +535,6 @@ pub fn create_pane( actions.append(&new_browser_btn); actions.append(&split_h_btn); actions.append(&split_v_btn); - actions.append(&settings_btn); actions.append(&close_btn); header.append(&tab_overlay); @@ -543,6 +560,7 @@ pub fn create_pane( drop_indicator: drop_indicator.clone(), content_drop_overlay: content_drop_overlay.clone(), pane_outer: outer.clone(), + leading_box: leading_box.clone(), callbacks: callbacks.clone(), working_directory: ws_wd.clone(), workspace_dragging: workspace_dragging.clone(), @@ -593,21 +611,6 @@ pub fn create_pane( (cb.on_close_pane)(&pw.clone().upcast()); }); } - { - let internals = internals.clone(); - settings_btn.connect_clicked(move |_| { - settings_editor::present_settings_dialog( - &internals.pane_outer, - settings_editor::SettingsEditorInput { - config: (internals.callbacks.current_config)(), - shortcuts: (internals.callbacks.current_shortcuts)(), - on_capture: internals.callbacks.on_capture_shortcut.clone(), - on_config_changed: internals.callbacks.on_config_changed.clone(), - }, - ); - }); - } - install_tab_strip_drop_target(&tab_overlay, &internals); install_content_drop_target(&internals); @@ -863,6 +866,7 @@ pub struct PaneInternals { drop_indicator: gtk::Box, content_drop_overlay: gtk::Box, pane_outer: gtk::Box, + leading_box: gtk::Box, callbacks: Rc, working_directory: Rc>>, workspace_dragging: Rc>, @@ -891,6 +895,7 @@ fn icon_button(icon_name: &str, tooltip: &str) -> gtk::Button { .icon_name(icon_name) .tooltip_text(tooltip) .has_frame(false) + .valign(gtk::Align::Center) .build(); btn.add_css_class("limux-pane-action"); btn @@ -1606,6 +1611,13 @@ fn find_pane_internals(pane_widget: >k::Widget) -> Option> { } } +/// Returns the leading slot (at the very start of the pane header) so the +/// outer app can place widgets there (e.g. a dock toggle). The box stays +/// empty by default. +pub fn pane_leading_box(pane_widget: >k::Widget) -> Option { + find_pane_internals(pane_widget).map(|internals| internals.leading_box.clone()) +} + pub fn is_pane_widget(widget: >k::Widget) -> bool { let Some(container) = widget.downcast_ref::() else { return false; @@ -1616,6 +1628,15 @@ pub fn is_pane_widget(widget: >k::Widget) -> bool { if current.has_css_class("limux-pane-header") { return true; } + // The header can be wrapped in a WindowHandle (used so empty space in + // the header drags the window); look through it for the real header. + if let Some(handle) = current.downcast_ref::() { + if let Some(inner) = handle.child() { + if inner.has_css_class("limux-pane-header") { + return true; + } + } + } child = current.next_sibling(); } @@ -1920,6 +1941,7 @@ fn build_tab_button_from_label( let close_btn = gtk::Button::builder() .icon_name("window-close-symbolic") .has_frame(false) + .valign(gtk::Align::Center) .build(); close_btn.add_css_class("limux-tab-close"); @@ -1968,6 +1990,31 @@ fn build_tab_button_from_label( } tab_btn.add_controller(right_click); + // Middle-click to close the tab. + let middle_click = gtk::GestureClick::new(); + middle_click.set_button(2); + { + let tab_id = tab_id.to_string(); + let tab_strip = internals.tab_strip.clone(); + let content_stack = internals.content_stack.clone(); + let tab_state = internals.tab_state.clone(); + let callbacks = internals.callbacks.clone(); + let pane_outer = internals.pane_outer.clone(); + middle_click.connect_pressed(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + remove_tab( + &tab_strip, + &content_stack, + &tab_state, + &tab_id, + &callbacks, + &pane_outer, + PaneEmptyReason::ClosedLastTab, + ); + }); + } + tab_btn.add_controller(middle_click); + let drag_source = gtk::DragSource::new(); drag_source.set_actions(gtk::gdk::DragAction::MOVE); { diff --git a/rust/limux-host-linux/src/settings_editor.rs b/rust/limux-host-linux/src/settings_editor.rs index 225fe1ca..fab3d4b1 100644 --- a/rust/limux-host-linux/src/settings_editor.rs +++ b/rust/limux-host-linux/src/settings_editor.rs @@ -5,7 +5,7 @@ use adw::prelude::*; use gtk4 as gtk; use libadwaita as adw; -use crate::app_config::{AppConfig, ColorScheme, NotificationSound}; +use crate::app_config::{AppConfig, ColorScheme, NotificationSound, WindowControlsSide}; use crate::keybind_editor; use crate::shortcut_config::{NormalizedShortcut, ResolvedShortcutConfig, ShortcutId}; @@ -172,6 +172,49 @@ fn build_general_page(input: &SettingsEditorInput) -> gtk::Widget { hover_row.set_activatable_widget(Some(&hover_switch)); group.add(&hover_row); + let top_bar_row = adw::ActionRow::builder() + .title("Top bar") + .subtitle("Show a top bar with workspace indicators. When off, the dock toggle, settings, new workspace, and window controls move into the sidebar header (or the leading pane when the sidebar is collapsed).") + .build(); + top_bar_row.set_title_lines(1); + top_bar_row.set_subtitle_lines(4); + let top_bar_switch = gtk::Switch::new(); + top_bar_switch.set_active(input.config.borrow().interface.show_top_bar); + top_bar_switch.set_valign(gtk::Align::Center); + top_bar_row.add_suffix(&top_bar_switch); + top_bar_row.set_activatable_widget(Some(&top_bar_switch)); + group.add(&top_bar_row); + + let indicators_row = adw::ActionRow::builder() + .title("Workspace indicators on the top bar") + .subtitle("Show a clickable pill for each workspace in the top bar") + .build(); + indicators_row.set_title_lines(1); + indicators_row.set_subtitle_lines(2); + let indicators_switch = gtk::Switch::new(); + indicators_switch.set_active(input.config.borrow().interface.show_workspace_indicators); + indicators_switch.set_valign(gtk::Align::Center); + indicators_row.add_suffix(&indicators_switch); + indicators_row.set_activatable_widget(Some(&indicators_switch)); + group.add(&indicators_row); + + let controls_row = adw::ActionRow::builder() + .title("Window controls side") + .subtitle("Place close, minimize, and maximize on the left or right of the top bar (or of the sidebar header when the top bar is off)") + .build(); + controls_row.set_title_lines(1); + controls_row.set_subtitle_lines(3); + let controls_dropdown = gtk::DropDown::from_strings(&["Left", "Right"]); + let initial_side = input.config.borrow().interface.window_controls_side; + controls_dropdown.set_selected(match initial_side { + WindowControlsSide::Left => 0, + WindowControlsSide::Right => 1, + }); + controls_dropdown.set_valign(gtk::Align::Center); + controls_row.add_suffix(&controls_dropdown); + controls_row.set_activatable_widget(Some(&controls_dropdown)); + group.add(&controls_row); + page.add(&group); { @@ -212,6 +255,39 @@ fn build_general_page(input: &SettingsEditorInput) -> gtk::Widget { }); }); } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + top_bar_switch.connect_active_notify(move |switch| { + let show_top_bar = switch.is_active(); + apply_config_change(&config, &*on_changed, move |c| { + c.interface.show_top_bar = show_top_bar; + }); + }); + } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + indicators_switch.connect_active_notify(move |switch| { + let show_workspace_indicators = switch.is_active(); + apply_config_change(&config, &*on_changed, move |c| { + c.interface.show_workspace_indicators = show_workspace_indicators; + }); + }); + } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + controls_dropdown.connect_selected_notify(move |dropdown| { + let side = match dropdown.selected() { + 0 => WindowControlsSide::Left, + _ => WindowControlsSide::Right, + }; + apply_config_change(&config, &*on_changed, move |c| { + c.interface.window_controls_side = side; + }); + }); + } let scroller = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) diff --git a/rust/limux-host-linux/src/split_tree.rs b/rust/limux-host-linux/src/split_tree.rs index 56147764..78752519 100644 --- a/rust/limux-host-linux/src/split_tree.rs +++ b/rust/limux-host-linux/src/split_tree.rs @@ -452,6 +452,13 @@ fn build_widget_tree(node: &SplitNode, state: &State) -> gtk::Widget { .orientation(*orientation) .hexpand(true) .vexpand(true) + // Allow either child to be shrunk below its minimum size so + // the saved split ratio (e.g. 50/50) is honored even when one + // pane has wider tabs than the other. Without this, gtk::Paned + // clamps the position to respect the larger pane's minimum + // width, producing visibly uneven splits. + .shrink_start_child(true) + .shrink_end_child(true) .build(); paned.set_shrink_start_child(false); paned.set_shrink_end_child(false); @@ -468,19 +475,34 @@ fn build_widget_tree(node: &SplitNode, state: &State) -> gtk::Widget { // position, corrupting the stored ratio. let applying = Rc::new(Cell::new(false)); - // Wire resize drags back to the shared ratio cell in the data model. + // Track the width we last saw, so position_notify can distinguish + // user drags (width unchanged → recompute ratio) from width-driven + // auto-adjust (width changed → preserve ratio by re-applying + // position = ratio * new_width). Without this, opening the + // sidebar (which shrinks the inner paned's width) silently skews + // the saved ratio because GtkPaned's position is absolute pixels. + let last_size = Rc::new(Cell::new(0i32)); let shared_ratio = ratio.clone(); let applying_for_notify = applying.clone(); + let last_size_for_notify = last_size.clone(); paned.connect_position_notify(move |paned| { if applying_for_notify.get() { return; } - let allocation = paned.allocation(); let size = if paned.orientation() == gtk::Orientation::Horizontal { - allocation.width() + paned.width() } else { - allocation.height() + paned.height() }; + if size <= 0 { + return; + } + if last_size_for_notify.get() != size { + // Width changed — this position-notify is an auto-adjust, + // not a user drag. Don't update the ratio. + last_size_for_notify.set(size); + return; + } let new_ratio = layout_state::snapshot_split_ratio( paned.position(), size, @@ -489,6 +511,39 @@ fn build_widget_tree(node: &SplitNode, state: &State) -> gtk::Widget { *shared_ratio.borrow_mut() = layout_state::clamp_split_ratio(new_ratio); }); + // Re-apply position = ratio * size whenever the paned's actual + // size changes (sidebar toggles, window resizes). GtkWidget's + // `width`/`height` properties don't reliably emit notify across + // GTK 4.x versions, so we poll via a per-frame tick callback + // (intentional: O(1) integer comparison per frame; always returns + // Continue so the paned stays reactive for its entire lifetime). + let paned_for_resize = paned.clone(); + let shared_ratio_for_resize = ratio.clone(); + let applying_for_resize = applying.clone(); + let last_size_for_resize = last_size.clone(); + let resize_orientation = *orientation; + paned.add_tick_callback(move |paned, _| { + let size = if resize_orientation == gtk::Orientation::Horizontal { + paned.width() + } else { + paned.height() + }; + if size <= 0 { + return glib::ControlFlow::Continue; + } + if last_size_for_resize.get() != size { + last_size_for_resize.set(size); + let ratio = *shared_ratio_for_resize.borrow(); + crate::window::apply_ratio_value( + &paned_for_resize, + resize_orientation, + ratio, + &applying_for_resize, + ); + } + glib::ControlFlow::Continue + }); + let left_widget = build_widget_tree(left, state); let right_widget = build_widget_tree(right, state); paned.set_start_child(Some(&left_widget)); diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index d089b3b5..9e81aac5 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -21,6 +21,7 @@ use crate::layout_state::{ self, AppSessionState, LayoutNodeState, LoadedSession, PaneState, WorkspaceState, }; use crate::pane::{self, PaneCallbacks}; +use crate::settings_editor; use crate::shortcut_config::{ self, EditableCapturePolicy, ResolvedShortcutConfig, ShortcutCommand, ShortcutId, }; @@ -61,12 +62,27 @@ struct Workspace { /// Path label shown below workspace name in sidebar. #[allow(dead_code)] path_label: gtk::Label, + /// The workspace indicator pill in the top bar. + indicator_button: gtk::Button, + /// The unread dot inside the indicator pill. + indicator_unread_dot: gtk::Label, } pub(crate) struct AppState { app: adw::Application, window: adw::ApplicationWindow, - top_bar: Option, + top_bar: Option, + top_bar_content: Option, + top_bar_minimize_btn: Option, + top_bar_maximize_btn: Option, + top_bar_close_btn: Option, + top_bar_sidebar_toggle: Option, + top_bar_new_ws_btn_ref: Option, + top_bar_settings_btn: Option, + sidebar_box: gtk::Box, + sidebar_header: gtk::Box, + sidebar_header_handle: gtk::WindowHandle, + sidebar_drag_area: gtk::Box, top_bar_visible: bool, config: Rc>, system_prefers_dark: Rc>>, @@ -78,6 +94,7 @@ pub(crate) struct AppState { sidebar_shell: gtk::Box, sidebar_handle: gtk::Box, new_ws_btn: gtk::Button, + indicator_box: gtk::Box, sidebar_animation: Option, sidebar_animation_epoch: u64, sidebar_expanded_width: i32, @@ -900,6 +917,16 @@ fn apply_loaded_session(state: &State, mut loaded: LoadedSession) { if restored_any || matches!(loaded.source, layout_state::SessionLoadSource::Legacy) { save_session_now(state); } + + // Defer one more apply until after the window is mapped, so the leading + // pane's widget tree is fully realized when we go to park the dock + // toggle on it. + { + let state = state.clone(); + glib::idle_add_local_once(move || { + apply_top_bar_mode(&state); + }); + } } fn restore_active_workspace(state: &State, index: usize) { @@ -942,6 +969,9 @@ fn apply_sidebar_state_immediately(state: &State, sidebar_state: &layout_state:: if sidebar_state.visible { width } else { 0 }, sidebar_state.visible, ); + // Re-run the top-bar mode now that sidebar visibility has been restored, + // so the dock toggle / controls land in the right place on startup. + apply_top_bar_mode(state); } fn apply_top_bar_state_immediately(state: &State, visible: bool) { @@ -1062,7 +1092,7 @@ fn build_workspace_root( (root, container) } -fn apply_ratio_value( +pub(crate) fn apply_ratio_value( paned: >k::Paned, orientation: gtk::Orientation, ratio: f64, @@ -1091,19 +1121,35 @@ pub(crate) fn apply_split_ratio_after_layout( ratio_cell: Rc>, applying: Rc>, ) { - // Capture the ratio by value for the initial idle callback so that early + // Capture the ratio by value for the initial retry loop so that early // position_notify events (which may corrupt the cell) don't affect it. let initial_ratio = *ratio_cell.borrow(); - let paned_for_idle = paned.clone(); - let applying_for_idle = applying.clone(); - glib::idle_add_local_once(move || { - apply_ratio_value( - &paned_for_idle, - orientation, - initial_ratio, - &applying_for_idle, - ); + // GTK doesn't expose a reliable "allocation done" signal on GtkWidget. + // Poll via add_tick_callback until the paned actually has a non-zero + // width, then apply the ratio once and stop. + let paned_tick = paned.clone(); + let applying_tick = applying.clone(); + let applied = Rc::new(Cell::new(false)); + paned.add_tick_callback(move |paned, _clock| { + if applied.get() { + return glib::ControlFlow::Break; + } + let size = if orientation == gtk::Orientation::Horizontal { + paned.width() + } else { + paned.height() + }; + if size <= 0 { + return glib::ControlFlow::Continue; + } + let ok = apply_ratio_value(&paned_tick, orientation, initial_ratio, &applying_tick); + if ok { + applied.set(true); + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } }); let paned_for_map = paned.clone(); @@ -1113,6 +1159,9 @@ pub(crate) fn apply_split_ratio_after_layout( let ratio = *ratio_cell.borrow(); apply_ratio_value(&paned_for_map, orientation, ratio, &applying); }); + // Note: width/height change handling (for sidebar toggles and window + // resizes) lives on the paned in split_tree.rs, where it has direct + // access to the shared ratio cell and the position-notify guard state. } pub(crate) fn attach_split_position_persistence(state: &State, paned: >k::Paned) { @@ -1184,39 +1233,175 @@ const BASE_CSS: &str = r#" .limux-host-entry image { color: var(--limux-host-entry-placeholder); } + +/* ---------- Top bar (matches pane header height/typography) ---------- */ +.limux-top-bar { + background-color: @window_bg_color; + border-bottom: 1px solid alpha(@window_fg_color, 0.08); + min-height: 30px; + padding: 0 4px; +} +.limux-top-bar-btn { + background: none; + border: none; + border-radius: 6px; + padding: 4px; + min-height: 0; + min-width: 0; + margin: 0 1px; + color: alpha(@window_fg_color, 0.4); +} +.limux-top-bar-btn:hover { + background: alpha(@window_fg_color, 0.08); + color: alpha(@window_fg_color, 0.8); +} +.limux-top-bar-close { + border-radius: 8px; + margin: 0 2px 0 1px; +} +.limux-top-bar-close:hover { + background: alpha(#e81123, 0.85); + color: #ffffff; +} +.limux-indicator-box { + margin: 0 4px; +} +.limux-indicator-pill { + background: transparent; + color: alpha(@window_fg_color, 0.5); + border: none; + border-radius: 4px; + padding: 2px 10px; + min-height: 0; + min-width: 0; + font-size: 12px; + font-weight: 500; + transition: all 120ms ease; +} +.limux-indicator-pill:hover { + background: alpha(@window_fg_color, 0.06); + color: alpha(@window_fg_color, 0.75); +} +.limux-indicator-pill-active { + background: alpha(@window_fg_color, 0.1); + color: @window_fg_color; + font-weight: 600; +} +.limux-indicator-pill-active:hover { + background: alpha(@window_fg_color, 0.14); +} +.limux-indicator-pill-unread { + color: @window_fg_color; + font-weight: 600; +} +.limux-indicator-unread-dot { + color: @accent_bg_color; + font-size: 7px; + margin-right: 4px; +} +.limux-indicator-unread-dot-hidden { + font-size: 7px; + margin-right: 0; + min-width: 0; +} + +/* ---------- Sidebar ---------- */ .limux-sidebar { background-color: @window_bg_color; color: @window_fg_color; - border-right: 1px solid alpha(@window_fg_color, 0.08); + border-right: 1px solid alpha(@window_fg_color, 0.06); +} +.limux-sidebar-header { + padding: 0 4px; + min-height: 30px; +} +.limux-sidebar-list { + background: transparent; + /* Make the gap above the first row match the visible gap between rows. + Adwaita's row adds its own vertical padding; give the first row the + same leading-space by adding an extra margin-top on it. */ +} +.limux-sidebar-list row:first-child .limux-sidebar-row-box { + margin-top: 4px; +} +/* Strip default ListBox row selection styling; we paint the inner row box instead. */ +.limux-sidebar-list row, +.limux-sidebar-list row:selected, +.limux-sidebar-list row:selected:hover, +.limux-sidebar-list row:focus, +.limux-sidebar-list row:focus:focus-visible { + background: transparent; + box-shadow: none; + outline: none; } .limux-sidebar-row-box { - padding: 8px 6px 8px 3px; - border-radius: 6px; - margin: 2px 3px 2px 1px; + padding: 8px 10px 8px 10px; + border-radius: 8px; + margin: 1px 6px; +} +.limux-sidebar-list row:hover .limux-sidebar-row-box { + background: alpha(@window_fg_color, 0.05); +} +.limux-sidebar-list row:selected .limux-sidebar-row-box { + background: alpha(@accent_bg_color, 0.14); } .limux-ws-name { - color: alpha(@window_fg_color, 0.72); - font-size: 15px; + color: alpha(@window_fg_color, 0.65); + font-size: 13px; + font-weight: 500; } -row:selected .limux-ws-name { +.limux-sidebar-list row:selected .limux-ws-name { color: @window_fg_color; + font-weight: 600; } .limux-ws-star-btn { - color: alpha(@window_fg_color, 0.45); + background: transparent; + color: alpha(@window_fg_color, 0.3); border: none; - min-height: 0; - min-width: 0; - padding: 0 4px; - font-size: 22px; + border-radius: 4px; + min-height: 20px; + min-width: 20px; + padding: 0; + font-size: 12px; + opacity: 0; + transition: opacity 150ms ease; +} +.limux-sidebar-list row:hover .limux-ws-star-btn, +.limux-sidebar-list row:selected .limux-ws-star-btn { + opacity: 1; } .limux-ws-star-btn:hover { color: alpha(@window_fg_color, 0.9); } -row:selected .limux-ws-star-btn { - color: alpha(@window_fg_color, 0.85); +.limux-sidebar-list row:selected .limux-ws-star-btn { + color: alpha(@window_fg_color, 0.6); } .limux-ws-star-btn-active { color: @accent_bg_color; + opacity: 1; +} + +/* Workspace row close X — visible on hover/selected */ +.limux-ws-close-btn { + background: transparent; + color: alpha(@window_fg_color, 0.35); + border: none; + border-radius: 4px; + min-height: 20px; + min-width: 20px; + padding: 0; + margin: 0; + opacity: 0; + -gtk-icon-size: 12px; + transition: opacity 150ms ease; +} +.limux-sidebar-list row:hover .limux-ws-close-btn, +.limux-sidebar-list row:selected .limux-ws-close-btn { + opacity: 1; +} +.limux-ws-close-btn:hover { + background: alpha(@window_fg_color, 0.1); + color: @window_fg_color; } .limux-ws-rename-entry { min-height: 0; @@ -1225,32 +1410,31 @@ row:selected .limux-ws-star-btn { } .limux-notify-dot { color: @accent_bg_color; - font-size: 10px; + font-size: 8px; margin-right: 6px; } .limux-notify-dot-hidden { color: transparent; - font-size: 10px; + font-size: 8px; margin-right: 6px; } .limux-notify-msg { - color: alpha(@window_fg_color, 0.35); + color: alpha(@window_fg_color, 0.3); font-size: 11px; } .limux-notify-msg-unread { - color: alpha(@accent_bg_color, 0.9); + color: alpha(@accent_bg_color, 0.85); font-size: 11px; } .limux-sidebar-row-unread { - background-color: alpha(@accent_bg_color, 0.16); + background-color: alpha(@accent_bg_color, 0.1); border-left: 3px solid @accent_bg_color; - border-radius: 6px; - margin-left: 0; - margin-right: 0; + border-radius: 8px; + margin-left: 3px; } .limux-sidebar-row-unread .limux-ws-name { color: @window_fg_color; - font-weight: 700; + font-weight: 600; } .limux-drop-above .limux-sidebar-row-box { border-radius: 0; @@ -1267,24 +1451,19 @@ row:selected .limux-ws-star-btn { .limux-sidebar row:drop(active) { box-shadow: none; } -.limux-sidebar-title { - color: alpha(@window_fg_color, 0.55); - font-size: 11px; - font-weight: 600; - letter-spacing: 1px; -} .limux-sidebar-btn { - background: alpha(@window_fg_color, 0.08); - color: alpha(@window_fg_color, 0.7); + background: alpha(@window_fg_color, 0.06); + color: alpha(@window_fg_color, 0.5); border: 1px solid transparent; - border-radius: 6px; + border-radius: 8px; padding: 6px 12px; min-height: 0; + font-size: 18px; transition: all 200ms ease; } .limux-sidebar-btn:hover { - background: alpha(@window_fg_color, 0.14); - color: @window_fg_color; + background: alpha(@window_fg_color, 0.1); + color: alpha(@window_fg_color, 0.8); } .limux-sidebar-btn-trash { background: alpha(@error_color, 0.16); @@ -1309,10 +1488,10 @@ row:selected .limux-ws-star-btn { } .limux-ws-path { color: alpha(@window_fg_color, 0.3); - font-size: 12px; + font-size: 11px; } -row:selected .limux-ws-path { - color: alpha(@window_fg_color, 0.5); +.limux-sidebar-list row:selected .limux-ws-path { + color: alpha(@window_fg_color, 0.45); } .limux-content { background-color: @window_bg_color; @@ -1409,23 +1588,110 @@ pub fn build_window(app: &adw::Application) { .build(); apply_window_background_class(&window, background_opacity); - // On Wayland compositors with xdg-decoration support, the compositor - // already provides the window chrome, so keep Limux from rendering a - // duplicate header bar. X11 continues to use the in-app header. - let provides_decorations = display - .clone() - .downcast::() - .ok() - .map(|display| display.query_registry("zxdg_decoration_manager_v1")) - .unwrap_or(false); + // Workspace indicator pill container (shared between header and state) + let indicator_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(2) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .hexpand(true) + .build(); + indicator_box.add_css_class("limux-indicator-box"); - let header = if provides_decorations { - None - } else { - let bar = adw::HeaderBar::new(); - bar.set_title_widget(Some(>k::Label::builder().label(&title).build())); - Some(bar) - }; + let top_bar_sidebar_toggle: gtk::Button; + let top_bar_new_ws_btn: gtk::Button; + let top_bar_settings_btn: gtk::Button; + + // The top bar itself is a WindowHandle so empty space drags the window, + // while child buttons (sidebar toggle, workspace pills, +) stay clickable. + let top_bar_content = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + top_bar_content.add_css_class("limux-top-bar"); + + // Sidebar toggle button (leftmost) — Adwaita sidebar icon + let sidebar_toggle = gtk::Button::from_icon_name("sidebar-show-symbolic"); + sidebar_toggle.add_css_class("flat"); + sidebar_toggle.add_css_class("limux-top-bar-btn"); + sidebar_toggle.set_focus_on_click(false); + sidebar_toggle.set_valign(gtk::Align::Center); + sidebar_toggle.set_tooltip_text(Some("Toggle sidebar")); + top_bar_content.append(&sidebar_toggle); + top_bar_sidebar_toggle = sidebar_toggle; + + // Settings cog — between the dock toggle and the + button. + let settings_button = gtk::Button::from_icon_name("emblem-system-symbolic"); + settings_button.add_css_class("flat"); + settings_button.add_css_class("limux-top-bar-btn"); + settings_button.set_focus_on_click(false); + settings_button.set_valign(gtk::Align::Center); + settings_button.set_tooltip_text(Some("Settings")); + top_bar_content.append(&settings_button); + top_bar_settings_btn = settings_button; + + // New workspace button + let new_ws = gtk::Button::from_icon_name("list-add-symbolic"); + new_ws.add_css_class("flat"); + new_ws.add_css_class("limux-top-bar-btn"); + new_ws.set_focus_on_click(false); + new_ws.set_valign(gtk::Align::Center); + new_ws.set_tooltip_text(Some("New workspace")); + top_bar_content.append(&new_ws); + top_bar_new_ws_btn = new_ws; + + // Workspace indicator pills (takes the rest of the space) + top_bar_content.append(&indicator_box); + + // Window controls on the right — plain buttons styled the same as top-bar + // action buttons so hover shape matches the pane bar exactly. We skip the + // stock gtk::WindowControls widget because Adwaita forces circular 24px + // bubbles that are hard to override cleanly. + let minimize_btn = gtk::Button::from_icon_name("window-minimize-symbolic"); + minimize_btn.add_css_class("flat"); + minimize_btn.add_css_class("limux-top-bar-btn"); + minimize_btn.set_focus_on_click(false); + minimize_btn.set_valign(gtk::Align::Center); + minimize_btn.set_tooltip_text(Some("Minimize")); + top_bar_content.append(&minimize_btn); + + let maximize_btn = gtk::Button::from_icon_name("window-maximize-symbolic"); + maximize_btn.add_css_class("flat"); + maximize_btn.add_css_class("limux-top-bar-btn"); + maximize_btn.set_focus_on_click(false); + maximize_btn.set_valign(gtk::Align::Center); + maximize_btn.set_tooltip_text(Some("Maximize")); + top_bar_content.append(&maximize_btn); + + let close_btn = gtk::Button::from_icon_name("window-close-symbolic"); + close_btn.add_css_class("flat"); + close_btn.add_css_class("limux-top-bar-btn"); + close_btn.add_css_class("limux-top-bar-close"); + close_btn.set_focus_on_click(false); + close_btn.set_valign(gtk::Align::Center); + close_btn.set_tooltip_text(Some("Close")); + top_bar_content.append(&close_btn); + + { + let w = window.clone(); + minimize_btn.connect_clicked(move |_| w.minimize()); + } + { + let w = window.clone(); + maximize_btn.connect_clicked(move |_| { + if gtk::prelude::GtkWindowExt::is_maximized(&w) { + w.unmaximize(); + } else { + w.maximize(); + } + }); + } + { + let w = window.clone(); + close_btn.connect_clicked(move |_| w.close()); + } + + let header = gtk::WindowHandle::builder().child(&top_bar_content).build(); let stack = gtk::Stack::new(); stack.set_transition_type(gtk::StackTransitionType::None); @@ -1435,7 +1701,7 @@ pub fn build_window(app: &adw::Application) { let sidebar_list = gtk::ListBox::new(); sidebar_list.set_selection_mode(gtk::SelectionMode::Single); - sidebar_list.add_css_class("navigation-sidebar"); + sidebar_list.add_css_class("limux-sidebar-list"); let sidebar_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) @@ -1444,25 +1710,14 @@ pub fn build_window(app: &adw::Application) { .child(&sidebar_list) .build(); - let sidebar_title_label = gtk::Label::builder() - .label("WORKSPACES") - .xalign(0.0) - .hexpand(true) - .margin_start(12) - .build(); - sidebar_title_label.add_css_class("limux-sidebar-title"); - - let sidebar_title = gtk::Box::builder() + // Draggable spacer at the top of the sidebar (for window move) + let sidebar_drag_area = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) - .margin_top(8) - .margin_bottom(4) - .margin_end(6) + .height_request(8) .build(); - sidebar_title.append(&sidebar_title_label); - { let window = window.clone(); - let drag_title = sidebar_title.clone(); + let drag_area = sidebar_drag_area.clone(); let drag = gtk::GestureClick::new(); drag.set_button(1); drag.connect_pressed(move |gesture, _, x, y| { @@ -1471,14 +1726,14 @@ pub fn build_window(app: &adw::Application) { }; let button = gesture.current_button() as i32; let timestamp = gesture.current_event_time(); - begin_window_move_from_widget(&drag_title, &window, &device, button, x, y, timestamp); + begin_window_move_from_widget(&drag_area, &window, &device, button, x, y, timestamp); gesture.set_state(gtk::EventSequenceState::Claimed); }); - sidebar_title.add_controller(drag); + sidebar_drag_area.add_controller(drag); } let new_ws_btn = gtk::Button::builder() - .label("New Workspace") + .label("+") .hexpand(true) .margin_start(6) .margin_end(6) @@ -1509,28 +1764,57 @@ pub fn build_window(app: &adw::Application) { } new_ws_btn.add_controller(btn_drop.clone()); + // new_ws_btn is kept in state as the drop target for workspace/tab DnD, + // but we hide it from the sidebar — the "+" in the top bar creates + // workspaces, and closing/creating via drag lands on sidebar rows / the + // top bar add button. + new_ws_btn.set_visible(false); + + // Alternate header for the sidebar, used when the top bar is hidden. + // Populated by apply_top_bar_mode() — stays empty + invisible otherwise. + // Wrapped in a WindowHandle so empty space in the header drags the window + // (same pattern as the regular top bar). + let sidebar_header = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + sidebar_header.add_css_class("limux-sidebar-header"); + let sidebar_header_handle = gtk::WindowHandle::builder() + .child(&sidebar_header) + .visible(false) + .build(); + let sidebar = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(4) .build(); sidebar.add_css_class("limux-sidebar"); - sidebar.append(&sidebar_title); + sidebar.append(&sidebar_drag_area); + sidebar.append(&sidebar_header_handle); sidebar.append(&sidebar_scroll); - sidebar.append(&new_ws_btn); let (main_split, sidebar_shell, sidebar_handle) = build_sidebar_split(&sidebar, &stack); let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0); - if let Some(ref header) = header { - vbox.append(header); - } + vbox.append(&header); vbox.append(&main_split); window.set_content(Some(&vbox)); let state: State = Rc::new(RefCell::new(AppState { app: app.clone(), window: window.clone(), - top_bar: header.clone(), + top_bar: Some(header.clone()), + top_bar_content: Some(top_bar_content.clone()), + top_bar_minimize_btn: Some(minimize_btn.clone()), + top_bar_maximize_btn: Some(maximize_btn.clone()), + top_bar_close_btn: Some(close_btn.clone()), + top_bar_sidebar_toggle: Some(top_bar_sidebar_toggle.clone()), + top_bar_new_ws_btn_ref: Some(top_bar_new_ws_btn.clone()), + top_bar_settings_btn: Some(top_bar_settings_btn.clone()), + sidebar_box: sidebar.clone(), + sidebar_header: sidebar_header.clone(), + sidebar_header_handle: sidebar_header_handle.clone(), + sidebar_drag_area: sidebar_drag_area.clone(), top_bar_visible: true, config, system_prefers_dark: system_prefers_dark.clone(), @@ -1538,6 +1822,7 @@ pub fn build_window(app: &adw::Application) { active_idx: 0, shortcuts, stack: stack.clone(), + indicator_box: indicator_box.clone(), sidebar_list: sidebar_list.clone(), sidebar_shell: sidebar_shell.clone(), sidebar_handle: sidebar_handle.clone(), @@ -1606,6 +1891,10 @@ pub fn build_window(app: &adw::Application) { }); } + // Apply the initial top-bar layout (controls side, sidebar-header mode, + // pane leading slot) based on the loaded config. + apply_top_bar_mode(&state); + register_app_actions(app, &state); register_window_actions(&window, &state); install_key_capture(&window, &state); @@ -1653,6 +1942,31 @@ pub fn build_window(app: &adw::Application) { }); } + // Wire top bar sidebar toggle button + { + let state = state.clone(); + top_bar_sidebar_toggle.connect_clicked(move |_| { + toggle_sidebar(&state); + }); + } + + // Wire top bar new workspace button + { + let state = state.clone(); + top_bar_new_ws_btn.connect_clicked(move |_| { + add_workspace(&state, None); + }); + } + + // Wire top bar settings button — opens the same settings dialog the + // pane cog used to, parented on whatever widget makes sense. + { + let state = state.clone(); + top_bar_settings_btn.connect_clicked(move |_| { + open_settings_dialog(&state); + }); + } + { let btn = new_ws_btn.clone(); pane::on_tab_drag_change(move |dragging| { @@ -1669,7 +1983,7 @@ pub fn build_window(app: &adw::Application) { let state = state.clone(); let btn = new_ws_btn.clone(); btn_drop.connect_drop(move |_, value, _, _| { - btn.set_label("New Workspace"); + btn.set_label("+"); btn.remove_css_class("limux-sidebar-btn-trash"); btn.remove_css_class("limux-sidebar-btn-trash-hover"); btn.remove_css_class("limux-tab-drop-target"); @@ -2234,6 +2548,75 @@ fn refresh_shortcut_tooltips_in_layout(widget: >k::Widget, shortcuts: &Resolve pane::refresh_shortcut_tooltips(widget, shortcuts); } +/// Open the Settings dialog from the top bar (the cog used to live on the +/// pane action row). +fn open_settings_dialog(state: &State) { + let (parent, config, shortcuts) = { + let s = state.borrow(); + ( + s.window.clone().upcast::(), + s.config.clone(), + s.shortcuts.clone(), + ) + }; + + let on_capture: Rc< + dyn Fn( + ShortcutId, + Option, + ) -> Result, + > = { + let state = state.clone(); + Rc::new(move |id, binding| persist_shortcut_binding(&state, id, binding)) + }; + + #[allow(clippy::type_complexity)] + let on_config_changed: Rc = { + let state = state.clone(); + Rc::new(move |previous, updated| { + handle_config_change(&state, previous, updated); + }) + }; + + settings_editor::present_settings_dialog( + &parent, + settings_editor::SettingsEditorInput { + config, + shortcuts, + on_capture, + on_config_changed, + }, + ); +} + +/// Apply a config change (appearance + interface side effects) and persist. +/// On save error, revert the in-memory config and re-apply the previous state. +fn handle_config_change( + state: &State, + previous: &app_config::AppConfig, + updated: &app_config::AppConfig, +) { + let style_manager = adw::StyleManager::default(); + let system_prefers_dark = state.borrow().system_prefers_dark.get(); + apply_appearance(&style_manager, system_prefers_dark, &updated.appearance); + if previous.interface.window_controls_side != updated.interface.window_controls_side + || previous.interface.show_top_bar != updated.interface.show_top_bar + || previous.interface.show_workspace_indicators + != updated.interface.show_workspace_indicators + { + apply_top_bar_mode(state); + } + if let Err(err) = app_config::save(updated) { + state.borrow().config.borrow_mut().clone_from(previous); + apply_appearance(&style_manager, system_prefers_dark, &previous.appearance); + apply_top_bar_mode(state); + + let detail = format!("Failed to save Limux settings: {err}"); + eprintln!("limux: {detail}"); + show_runtime_error(state, "Failed to save settings", &detail); + } +} + fn persist_shortcut_binding( state: &State, id: ShortcutId, @@ -2777,6 +3160,209 @@ fn apply_appearance( sync_ghostty_color_scheme_for_config(style_manager, system_prefers_dark, appearance); } +/// Detach a widget from its current parent, if it has one. Safe to call +/// regardless of whether the widget is currently parented or not. +fn detach(widget: &impl IsA) { + let w = widget.as_ref(); + if let Some(parent) = w.parent() { + if let Some(bx) = parent.downcast_ref::() { + bx.remove(w); + } else { + w.unparent(); + } + } +} + +/// Locate the leading pane of the currently active workspace, so we can park +/// the dock toggle there when the top bar is hidden and the sidebar is closed. +fn active_workspace_leading_pane(state: &State) -> Option { + let root = { + let s = state.borrow(); + s.active_workspace().map(|ws| ws.root.clone()) + }?; + Some(first_leaf_pane(&root)) +} + +/// Reparent the dock toggle, + button, and window-controls into the top bar +/// or the sidebar header (or, in the top-bar-off + sidebar-closed case, park +/// the dock toggle on the active workspace's leading pane). +fn apply_top_bar_mode(state: &State) { + let ( + top_bar_handle, + top_bar_content_box, + dock_toggle, + settings_btn, + new_ws_btn, + minimize, + maximize, + close, + indicator_box, + sidebar_header, + sidebar_header_handle, + sidebar_drag_area, + show_top_bar, + controls_side, + show_workspace_indicators, + sidebar_visible_now, + ) = { + let s = state.borrow(); + let config = s.config.borrow(); + ( + s.top_bar.clone(), + s.top_bar_content.clone(), + s.top_bar_sidebar_toggle.clone(), + s.top_bar_settings_btn.clone(), + s.top_bar_new_ws_btn_ref.clone(), + s.top_bar_minimize_btn.clone(), + s.top_bar_maximize_btn.clone(), + s.top_bar_close_btn.clone(), + s.indicator_box.clone(), + s.sidebar_header.clone(), + s.sidebar_header_handle.clone(), + s.sidebar_drag_area.clone(), + // The persisted setting AND the transient keyboard toggle must + // both be on for the top bar layout to apply. + config.interface.show_top_bar && s.top_bar_visible, + config.interface.window_controls_side, + config.interface.show_workspace_indicators, + // Just the widget's visible property — the paned position can be + // stale during animations or startup; we don't want to misclassify + // a set_visible(true) sidebar as closed. + s.sidebar_box.is_visible(), + ) + }; + + let ( + Some(handle), + Some(content), + Some(dock), + Some(settings), + Some(new_ws), + Some(mi), + Some(ma), + Some(cl), + ) = ( + top_bar_handle, + top_bar_content_box, + dock_toggle, + settings_btn, + new_ws_btn, + minimize, + maximize, + close, + ) + else { + return; + }; + + // Detach the mobile widgets from wherever they're parented now — this + // covers the case where a widget lives in the top bar, the sidebar + // header, or a pane's leading_box from a previous arrangement. + detach(&dock); + detach(&settings); + detach(&new_ws); + detach(&mi); + detach(&ma); + detach(&cl); + detach(&indicator_box); + + // Clear the alt sidebar header from previous arrangements (removes the + // leftover hexpand spacer child). + while let Some(child) = sidebar_header.first_child() { + sidebar_header.remove(&child); + } + + // Workspace indicator pills are only shown when the user opts in. + // Hide the individual pills (children) rather than the box itself so the + // box keeps its hexpand spacer role between the top bar's left group and + // the window controls on the right. + { + let s = state.borrow(); + for ws in &s.workspaces { + ws.indicator_button.set_visible(show_workspace_indicators); + } + } + + if show_top_bar { + // Classic layout: put everything back into the top bar, in order + // dock | settings | new_ws | indicator_box | [controls at side] + content.append(&dock); + content.append(&settings); + content.append(&new_ws); + content.append(&indicator_box); + + match controls_side { + app_config::WindowControlsSide::Left => { + cl.insert_before(&content, content.first_child().as_ref()); + mi.insert_after(&content, Some(&cl)); + ma.insert_after(&content, Some(&mi)); + } + app_config::WindowControlsSide::Right => { + content.append(&mi); + content.append(&ma); + content.append(&cl); + } + } + + handle.set_visible(true); + sidebar_header_handle.set_visible(false); + // Top bar already handles window drag — hide the 8px drag strip above + // the workspace list so the first row sits flush with the sidebar top, + // matching the sidebar-header mode's spacing. + sidebar_drag_area.set_visible(false); + return; + } + + // Top bar hidden. Hide the whole top-bar widget. + handle.set_visible(false); + + if sidebar_visible_now { + // Sidebar open: left group + expanding spacer + right group, so the + // window controls sit at one end and the app buttons at the other. + let spacer = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .hexpand(true) + .build(); + + match controls_side { + app_config::WindowControlsSide::Left => { + // close | min | max || spacer || dock | settings | + (new_ws) + sidebar_header.append(&cl); + sidebar_header.append(&mi); + sidebar_header.append(&ma); + sidebar_header.append(&spacer); + sidebar_header.append(&dock); + sidebar_header.append(&settings); + sidebar_header.append(&new_ws); + } + app_config::WindowControlsSide::Right => { + // dock | settings | + || spacer || min | max | close + sidebar_header.append(&dock); + sidebar_header.append(&settings); + sidebar_header.append(&new_ws); + sidebar_header.append(&spacer); + sidebar_header.append(&mi); + sidebar_header.append(&ma); + sidebar_header.append(&cl); + } + } + sidebar_header_handle.set_visible(true); + // Sidebar header replaces the drag strip above it visually, so hide + // the 8px drag spacer to match the pane header height exactly. + sidebar_drag_area.set_visible(false); + } else { + // Sidebar collapsed: dock toggle goes on the leading pane, all other + // controls stay detached (not visible anywhere). + sidebar_header_handle.set_visible(false); + sidebar_drag_area.set_visible(true); + if let Some(pane) = active_workspace_leading_pane(state) { + if let Some(leading) = pane::pane_leading_box(&pane) { + leading.append(&dock); + } + } + } +} + fn open_keybind_editor_tab(state: &State, pane_widget: >k::Widget) { let shortcuts = { let s = state.borrow(); @@ -2819,6 +3405,81 @@ fn activate_last_workspace_shortcut(state: &State) { activate_workspace_shortcut(state, last_idx); } +// --------------------------------------------------------------------------- +// Workspace indicator pill (top bar) +// --------------------------------------------------------------------------- + +fn build_workspace_indicator(name: &str) -> (gtk::Button, gtk::Label) { + let unread_dot = gtk::Label::builder() + .label("\u{25CF}") + .visible(false) + .build(); + unread_dot.add_css_class("limux-indicator-unread-dot-hidden"); + + let label = gtk::Label::builder() + .label(name) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(20) + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + content.append(&unread_dot); + content.append(&label); + + let button = gtk::Button::builder() + .child(&content) + .focus_on_click(false) + .build(); + button.add_css_class("flat"); + button.add_css_class("limux-indicator-pill"); + + (button, unread_dot) +} + +fn sync_indicator_active_state(state: &AppState) { + for (idx, ws) in state.workspaces.iter().enumerate() { + if idx == state.active_idx { + ws.indicator_button + .add_css_class("limux-indicator-pill-active"); + } else { + ws.indicator_button + .remove_css_class("limux-indicator-pill-active"); + } + } +} + +fn update_indicator_label(button: >k::Button, name: &str) { + if let Some(content) = button.child() { + if let Some(content_box) = content.downcast_ref::() { + let mut child = content_box.first_child(); + while let Some(widget) = child { + if let Some(label) = widget.downcast_ref::() { + // Skip the unread dot label (it has the dot character) + if label.label() != "\u{25CF}" { + label.set_label(name); + break; + } + } + child = widget.next_sibling(); + } + } + } +} + +fn sync_indicator_order(state: &mut AppState) { + while let Some(child) = state.indicator_box.first_child() { + state.indicator_box.remove(&child); + } + for ws in &state.workspaces { + state.indicator_box.append(&ws.indicator_button); + } +} + // --------------------------------------------------------------------------- // Sidebar row // --------------------------------------------------------------------------- @@ -2833,6 +3494,7 @@ fn build_sidebar_row( gtk::Label, gtk::Label, gtk::Label, + gtk::Button, ) { let notify_dot = gtk::Label::builder().label("\u{25CF}").build(); notify_dot.add_css_class("limux-notify-dot-hidden"); @@ -2845,21 +3507,24 @@ fn build_sidebar_row( .build(); name_label.add_css_class("limux-ws-name"); - let favorite_button = gtk::Button::with_label("\u{2606}"); - favorite_button.add_css_class("flat"); - favorite_button.add_css_class("limux-ws-star-btn"); - favorite_button.set_focus_on_click(false); - favorite_button.set_valign(gtk::Align::Center); - favorite_button.set_halign(gtk::Align::End); - favorite_button.set_tooltip_text(Some("Favorite workspace")); + // Close X in the top-right of the row, replaces where the star used to be. + let close_button = gtk::Button::from_icon_name("window-close-symbolic"); + close_button.add_css_class("flat"); + close_button.add_css_class("limux-ws-close-btn"); + close_button.set_focus_on_click(false); + close_button.set_valign(gtk::Align::Center); + close_button.set_halign(gtk::Align::End); + close_button.set_tooltip_text(Some("Close workspace")); let top_row = gtk::Box::new(gtk::Orientation::Horizontal, 0); top_row.append(¬ify_dot); top_row.append(&name_label); - top_row.append(&favorite_button); + top_row.append(&close_button); + // Second row: path label on the left, favorite star right-aligned below the X. let path_label = gtk::Label::builder() .xalign(0.0) + .hexpand(true) .ellipsize(gtk::pango::EllipsizeMode::End) .margin_start(8) .build(); @@ -2867,11 +3532,22 @@ fn build_sidebar_row( if let Some(p) = folder_path { path_label.set_label(&abbreviate_path(p)); path_label.set_tooltip_text(Some(p)); - path_label.set_visible(true); } else { - path_label.set_visible(false); + path_label.set_label(""); } + let favorite_button = gtk::Button::with_label("\u{2606}"); + favorite_button.add_css_class("flat"); + favorite_button.add_css_class("limux-ws-star-btn"); + favorite_button.set_focus_on_click(false); + favorite_button.set_valign(gtk::Align::Center); + favorite_button.set_halign(gtk::Align::End); + favorite_button.set_tooltip_text(Some("Favorite workspace")); + + let path_row = gtk::Box::new(gtk::Orientation::Horizontal, 0); + path_row.append(&path_label); + path_row.append(&favorite_button); + let notify_label = gtk::Label::builder() .xalign(0.0) .ellipsize(gtk::pango::EllipsizeMode::End) @@ -2886,7 +3562,7 @@ fn build_sidebar_row( .build(); vbox.add_css_class("limux-sidebar-row-box"); vbox.append(&top_row); - vbox.append(&path_label); + vbox.append(&path_row); vbox.append(¬ify_label); let row = gtk::ListBoxRow::new(); @@ -2899,6 +3575,7 @@ fn build_sidebar_row( notify_dot, notify_label, path_label, + close_button, ) } @@ -3050,6 +3727,7 @@ fn sync_sidebar_row_order(state: &mut AppState) { for workspace in &state.workspaces { state.sidebar_list.append(&workspace.sidebar_row); } + sync_indicator_order(state); } fn set_workspace_favorite_visual(workspace: &Workspace) { @@ -3183,6 +3861,8 @@ fn begin_workspace_inline_rename(state: &State, workspace_id: &str) { .find(|workspace| workspace.id == workspace_id) { workspace.name = next_name; + // Update the indicator pill label + update_indicator_label(&workspace.indicator_button, &workspace.name); } drop(s); request_session_save(&state_for_commit); @@ -3422,13 +4102,43 @@ fn create_workspace_for_tab(state: &State, payload: &str) -> bool { let split_container = SplitTreeContainer::new(state, pane.clone().upcast()); let root = split_container.widget().clone(); - let (row, name_label, favorite_button, notify_dot, notify_label, path_label) = + let (row, name_label, favorite_button, notify_dot, notify_label, path_label, close_button) = build_sidebar_row(&seed.name, seed.folder_path.as_deref()); + // Wire close button + { + let state = state.clone(); + let ws_id = new_workspace_id.clone(); + close_button.connect_clicked(move |_| { + close_workspace_by_id(&state, &ws_id); + }); + } + let (indicator_button, indicator_unread_dot) = build_workspace_indicator(&seed.name); + // Wire indicator pill click + { + let state = state.clone(); + let ws_id = new_workspace_id.clone(); + indicator_button.connect_clicked(move |_| { + let (idx, row, sidebar_list) = { + let s = state.borrow(); + let Some(idx) = s.workspaces.iter().position(|w| w.id == ws_id) else { + return; + }; + ( + idx, + s.workspaces[idx].sidebar_row.clone(), + s.sidebar_list.clone(), + ) + }; + switch_workspace(&state, idx); + sidebar_list.select_row(Some(&row)); + }); + } let row_clone = row.clone(); { let mut app_state = state.borrow_mut(); app_state.stack.add_named(&root, Some(&stack_name)); app_state.sidebar_list.append(&row); + app_state.indicator_box.append(&indicator_button); install_workspace_row_interactions(state, &new_workspace_id, &row, &favorite_button); app_state.workspaces.push(Workspace { @@ -3446,8 +4156,11 @@ fn create_workspace_for_tab(state: &State, payload: &str) -> bool { cwd: Rc::new(RefCell::new(seed.cwd.clone())), folder_path: seed.folder_path.clone(), path_label, + indicator_button, + indicator_unread_dot, }); app_state.active_idx = app_state.workspaces.len() - 1; + sync_indicator_active_state(&app_state); app_state.stack.set_visible_child_name(&stack_name); } @@ -3457,6 +4170,7 @@ fn create_workspace_for_tab(state: &State, payload: &str) -> bool { } if pane::move_tab_to_pane(&source_pane, tab_id, &pane.clone().upcast()) { + apply_top_bar_mode(state); request_session_save(state); return true; } @@ -3487,6 +4201,21 @@ fn install_workspace_row_interactions( } row.add_controller(right_click); + // Double-left-click anywhere on the row starts inline rename. + let double_click = gtk::GestureClick::new(); + double_click.set_button(1); + { + let state = state.clone(); + let workspace_id = workspace_id.to_string(); + double_click.connect_pressed(move |gesture, n_press, _, _| { + if n_press == 2 { + gesture.set_state(gtk::EventSequenceState::Claimed); + begin_workspace_inline_rename(&state, &workspace_id); + } + }); + } + row.add_controller(double_click); + let drag_source = gtk::DragSource::new(); drag_source.set_actions(gtk::gdk::DragAction::MOVE); { @@ -3652,6 +4381,24 @@ fn install_workspace_row_interactions( } fn add_workspace(state: &State, _working_directory: Option<&str>) { + // If there's already an active workspace, clone its folder instead of + // asking — matches cmux UX where the "+" creates a workspace in context. + let active_folder = { + let s = state.borrow(); + s.active_workspace() + .and_then(|ws| ws.folder_path.clone().or_else(|| ws.cwd.borrow().clone())) + }; + + if let Some(folder_path) = active_folder { + let folder_name = std::path::Path::new(&folder_path) + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_else(|| folder_path.clone()); + create_workspace_with_folder(state, &folder_name, &folder_path); + return; + } + + // No active workspace (first-run): ask for a folder. show_workspace_path_dialog(state); } @@ -4234,6 +4981,7 @@ fn handle_control_command(state: &State, command: ControlCommand) { let workspace = &mut app_state.workspaces[index]; workspace.name = title.clone(); workspace.name_label.set_label(&title); + update_indicator_label(&workspace.indicator_button, &title); } request_session_save(state); @@ -4498,9 +5246,13 @@ fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { let s = state.borrow(); s.shortcuts.clone() }; - let (stack, sidebar_list) = { + let (stack, sidebar_list, indicator_box) = { let s = state.borrow(); - (s.stack.clone(), s.sidebar_list.clone()) + ( + s.stack.clone(), + s.sidebar_list.clone(), + s.indicator_box.clone(), + ) }; let id = workspace .id @@ -4517,10 +5269,42 @@ fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { build_workspace_root(state, &shortcuts, &id, working_dir, &workspace.layout); stack.add_named(&root, Some(&stack_name)); - let (row, name_label, favorite_button, notify_dot, notify_label, path_label) = + let (row, name_label, favorite_button, notify_dot, notify_label, path_label, close_button) = build_sidebar_row(&workspace.name, workspace.folder_path.as_deref()); sidebar_list.append(&row); install_workspace_row_interactions(state, &id, &row, &favorite_button); + // Wire close button + { + let state = state.clone(); + let ws_id = id.clone(); + close_button.connect_clicked(move |_| { + close_workspace_by_id(&state, &ws_id); + }); + } + + let (indicator_button, indicator_unread_dot) = build_workspace_indicator(&workspace.name); + indicator_box.append(&indicator_button); + + // Wire indicator pill click to switch workspace + { + let state = state.clone(); + let ws_id = id.clone(); + indicator_button.connect_clicked(move |_| { + let (idx, row, sidebar_list) = { + let s = state.borrow(); + let Some(idx) = s.workspaces.iter().position(|w| w.id == ws_id) else { + return; + }; + ( + idx, + s.workspaces[idx].sidebar_row.clone(), + s.sidebar_list.clone(), + ) + }; + switch_workspace(&state, idx); + sidebar_list.select_row(Some(&row)); + }); + } let cwd: Rc>> = Rc::new(RefCell::new(workspace.cwd.clone())); let ws = Workspace { @@ -4538,6 +5322,8 @@ fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { cwd, folder_path: workspace.folder_path.clone(), path_label, + indicator_button, + indicator_unread_dot, }; if workspace.favorite { @@ -4548,10 +5334,14 @@ fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { let mut s = state.borrow_mut(); s.workspaces.push(ws); s.active_idx = s.workspaces.len() - 1; + sync_indicator_active_state(&s); } stack.set_visible_child_name(&stack_name); sidebar_list.select_row(Some(&row)); + // Ensure the new pill's visibility honors the show_workspace_indicators + // preference, and that pane/sidebar placement is up to date. + apply_top_bar_mode(state); } /// Create a PaneWidget wired up with callbacks for a specific workspace. @@ -4578,7 +5368,6 @@ pub(crate) fn create_pane_for_workspace( let ws_id_empty = ws_id.to_string(); let state_for_split_with_tab = state.clone(); let state_for_config = state.clone(); - let state_for_config_changed = state.clone(); let ws_id_split_with_tab = ws_id.to_string(); let ws_id_for_env = ws_id.to_string(); @@ -4694,30 +5483,6 @@ pub(crate) fn create_pane_for_workspace( let s = state_for_config.borrow(); s.config.clone() }), - on_config_changed: Rc::new( - move |previous: &app_config::AppConfig, updated: &app_config::AppConfig| { - let style_manager = adw::StyleManager::default(); - let system_prefers_dark = - state_for_config_changed.borrow().system_prefers_dark.get(); - apply_appearance(&style_manager, system_prefers_dark, &updated.appearance); - if let Err(err) = app_config::save(updated) { - state_for_config_changed - .borrow() - .config - .borrow_mut() - .clone_from(previous); - apply_appearance(&style_manager, system_prefers_dark, &previous.appearance); - - let detail = format!("Failed to save Limux settings: {err}"); - eprintln!("limux: {detail}"); - show_runtime_error( - &state_for_config_changed, - "Failed to save settings", - &detail, - ); - } - }, - ), workspace_for_pane: Box::new(move |_pane_widget| Some(ws_id_for_env.clone())), }); @@ -4761,6 +5526,7 @@ fn close_workspace_by_id_internal( let ws = s.workspaces.remove(idx); s.stack.remove(&ws.root); s.sidebar_list.remove(&ws.sidebar_row); + s.indicator_box.remove(&ws.indicator_button); if s.workspaces.is_empty() { s.active_idx = 0; @@ -4782,6 +5548,7 @@ fn close_workspace_by_id_internal( idx, ); s.active_idx = new_idx; + sync_indicator_active_state(&s); let stack_name = format!("ws-{}", s.workspaces[new_idx].id); s.stack.set_visible_child_name(&stack_name); @@ -4803,6 +5570,7 @@ fn switch_workspace(state: &State, idx: usize) { return; } s.active_idx = idx; + sync_indicator_active_state(&s); let stack = s.stack.clone(); let stack_name = format!("ws-{}", s.workspaces[idx].id); let focus_root = s.workspaces[idx].root.clone(); @@ -4814,6 +5582,8 @@ fn switch_workspace(state: &State, idx: usize) { ws.notify_dot.clone(), ws.notify_label.clone(), ws.sidebar_row.clone(), + ws.indicator_button.clone(), + ws.indicator_unread_dot.clone(), )) } else { None @@ -4827,7 +5597,9 @@ fn switch_workspace(state: &State, idx: usize) { focus_workspace_entrypoint(&focus_root); }); - if let Some((notify_dot, notify_label, sidebar_row)) = unread_handles { + if let Some((notify_dot, notify_label, sidebar_row, indicator_btn, indicator_dot)) = + unread_handles + { notify_dot.remove_css_class("limux-notify-dot"); notify_dot.add_css_class("limux-notify-dot-hidden"); notify_label.remove_css_class("limux-notify-msg-unread"); @@ -4836,8 +5608,16 @@ fn switch_workspace(state: &State, idx: usize) { if let Some(row_box) = sidebar_row.child() { row_box.remove_css_class("limux-sidebar-row-unread"); } + // Clear unread state on indicator pill + indicator_btn.remove_css_class("limux-indicator-pill-unread"); + indicator_dot.remove_css_class("limux-indicator-unread-dot"); + indicator_dot.add_css_class("limux-indicator-unread-dot-hidden"); + indicator_dot.set_visible(false); } + // If the dock toggle is parked on a pane (top-bar off, sidebar closed), + // move it to the new active workspace's leading pane. + apply_top_bar_mode(state); request_session_save(state); } @@ -4925,6 +5705,9 @@ fn toggle_top_bar(state: &State) { s.top_bar_visible = !s.top_bar_visible; } sync_top_bar_visibility(state); + // Also reparent the dock/settings/+/window controls so they don't get + // stranded when the user hides the top bar via the keyboard shortcut. + apply_top_bar_mode(state); request_session_save(state); } @@ -4992,6 +5775,7 @@ fn toggle_sidebar(state: &State) { }; if is_current { set_sidebar_state_widgets(&sidebar_shell, &sidebar_handle, 0, false); + apply_top_bar_mode(&state_for_done); request_session_save(&state_for_done); } }); @@ -5000,6 +5784,7 @@ fn toggle_sidebar(state: &State) { } else { // Expand: make sidebar visible, then animate position from 0 to remembered width. set_sidebar_state_widgets(&sidebar_shell, &sidebar_handle, 0, true); + apply_top_bar_mode(state); let target = adw::CallbackAnimationTarget::new({ let sidebar_shell = sidebar_shell.clone(); move |value| { @@ -5092,6 +5877,15 @@ fn split_pane( ) { return None; } + + // Split may have changed which pane is the workspace's leading one. + { + let state = state.clone(); + glib::idle_add_local_once(move || { + apply_top_bar_mode(&state); + }); + } + if options.persist { request_session_save(state); } @@ -5122,6 +5916,17 @@ fn remove_pane_internal(state: &State, ws_id: &str, pane_widget: >k::Widget, p // Mutate the data model and trigger async widget tree rebuild container.remove(pane_widget); + // After the pane is removed, the workspace's leading pane may be a + // different widget — reapply so the dock toggle (when top bar is off and + // sidebar closed) lands on the new leading pane. Run on idle so the + // split-tree rebuild has finished allocating the new widgets. + { + let state = state.clone(); + glib::idle_add_local_once(move || { + apply_top_bar_mode(&state); + }); + } + if persist { request_session_save(state); } @@ -5178,6 +5983,14 @@ fn find_leaf_focused_pane(state: &State) -> Option<(String, gtk::Widget)> { if c.has_css_class("limux-pane-header") { return Some((ws_id, w)); } + // Header may be wrapped in a WindowHandle for window dragging. + if let Some(handle) = c.downcast_ref::() { + if let Some(inner) = handle.child() { + if inner.has_css_class("limux-pane-header") { + return Some((ws_id, w)); + } + } + } child = c.next_sibling(); } } @@ -5768,10 +6581,17 @@ fn mark_workspace_unread_with_message( ws.notify_label.remove_css_class("limux-notify-msg"); ws.notify_label.add_css_class("limux-notify-msg-unread"); ws.notify_label.set_visible(true); - // Add glow pulse to the sidebar row box if let Some(row_box) = ws.sidebar_row.child() { row_box.add_css_class("limux-sidebar-row-unread"); } + // Show unread state on indicator pill + ws.indicator_button + .add_css_class("limux-indicator-pill-unread"); + ws.indicator_unread_dot + .remove_css_class("limux-indicator-unread-dot-hidden"); + ws.indicator_unread_dot + .add_css_class("limux-indicator-unread-dot"); + ws.indicator_unread_dot.set_visible(true); } return desktop_request; From 48365a8f02d143fbd496ed30d22326d02c746b6e Mon Sep 17 00:00:00 2001 From: "MVB.Mir" Date: Fri, 5 Jun 2026 16:16:54 +0300 Subject: [PATCH 2/2] Address review: extract top-bar layout helpers, leading-pane fallback, jitter probe - apply_top_bar_mode: group resolved widgets into TopBarWidgets and split the three layout branches into layout_top_bar_visible / layout_sidebar_header / layout_collapsed_dock helpers (behavior unchanged). - Collapsed-sidebar layout retries once on idle when the active workspace leading pane is momentarily unavailable during a rebuild, so the dock toggle no longer briefly disappears. - split_tree resize tick logs a debug-only line per ratio re-apply to surface any size oscillation on deep split trees; compiled out in release. --- rust/limux-host-linux/src/split_tree.rs | 11 + rust/limux-host-linux/src/window.rs | 278 +++++++++++++----------- 2 files changed, 168 insertions(+), 121 deletions(-) diff --git a/rust/limux-host-linux/src/split_tree.rs b/rust/limux-host-linux/src/split_tree.rs index 78752519..e029f8bb 100644 --- a/rust/limux-host-linux/src/split_tree.rs +++ b/rust/limux-host-linux/src/split_tree.rs @@ -532,6 +532,17 @@ fn build_widget_tree(node: &SplitNode, state: &State) -> gtk::Widget { return glib::ControlFlow::Continue; } if last_size_for_resize.get() != size { + // Debug-only jitter probe: each line is one ratio re-apply. + // Rapid repeated lines on a stable layout signal the size is + // oscillating (worth watching as split trees get deep). Costs + // nothing in release builds. + #[cfg(debug_assertions)] + { + let previous = last_size_for_resize.get(); + eprintln!( + "limux: split-ratio tick reapply ({resize_orientation:?}) size {previous} -> {size}" + ); + } last_size_for_resize.set(size); let ratio = *shared_ratio_for_resize.borrow(); crate::window::apply_ratio_value( diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index 9e81aac5..15f725c1 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -3186,28 +3186,47 @@ fn active_workspace_leading_pane(state: &State) -> Option { /// Reparent the dock toggle, + button, and window-controls into the top bar /// or the sidebar header (or, in the top-bar-off + sidebar-closed case, park /// the dock toggle on the active workspace's leading pane). +/// The top-bar widgets resolved from `AppState`, after confirming the optional +/// ones exist. Grouped so the per-layout helpers take a single ref instead of a +/// dozen individual widget arguments. +struct TopBarWidgets { + handle: gtk::WindowHandle, + content: gtk::Box, + dock: gtk::Button, + settings: gtk::Button, + new_ws: gtk::Button, + minimize: gtk::Button, + maximize: gtk::Button, + close: gtk::Button, + indicator_box: gtk::Box, + sidebar_header: gtk::Box, + sidebar_header_handle: gtk::WindowHandle, + sidebar_drag_area: gtk::Box, +} + fn apply_top_bar_mode(state: &State) { - let ( - top_bar_handle, - top_bar_content_box, - dock_toggle, - settings_btn, - new_ws_btn, - minimize, - maximize, - close, - indicator_box, - sidebar_header, - sidebar_header_handle, - sidebar_drag_area, - show_top_bar, - controls_side, - show_workspace_indicators, - sidebar_visible_now, - ) = { + apply_top_bar_mode_impl(state, true); +} + +/// Lays out the dock toggle / settings / new-workspace / window controls into +/// the top bar, the sidebar header, or the leading pane depending on config and +/// sidebar visibility. `allow_retry` guards a single idle re-run used by the +/// collapsed-sidebar layout when the leading pane is momentarily missing during +/// a workspace rebuild; the retry runs with `false` so it can never loop. +fn apply_top_bar_mode_impl(state: &State, allow_retry: bool) { + let (show_top_bar, controls_side, show_workspace_indicators, sidebar_visible_now, widgets) = { let s = state.borrow(); let config = s.config.borrow(); - ( + let ( + Some(handle), + Some(content), + Some(dock), + Some(settings), + Some(new_ws), + Some(minimize), + Some(maximize), + Some(close), + ) = ( s.top_bar.clone(), s.top_bar_content.clone(), s.top_bar_sidebar_toggle.clone(), @@ -3216,10 +3235,25 @@ fn apply_top_bar_mode(state: &State) { s.top_bar_minimize_btn.clone(), s.top_bar_maximize_btn.clone(), s.top_bar_close_btn.clone(), - s.indicator_box.clone(), - s.sidebar_header.clone(), - s.sidebar_header_handle.clone(), - s.sidebar_drag_area.clone(), + ) + else { + return; + }; + let widgets = TopBarWidgets { + handle, + content, + dock, + settings, + new_ws, + minimize, + maximize, + close, + indicator_box: s.indicator_box.clone(), + sidebar_header: s.sidebar_header.clone(), + sidebar_header_handle: s.sidebar_header_handle.clone(), + sidebar_drag_area: s.sidebar_drag_area.clone(), + }; + ( // The persisted setting AND the transient keyboard toggle must // both be on for the top bar layout to apply. config.interface.show_top_bar && s.top_bar_visible, @@ -3229,47 +3263,25 @@ fn apply_top_bar_mode(state: &State) { // stale during animations or startup; we don't want to misclassify // a set_visible(true) sidebar as closed. s.sidebar_box.is_visible(), + widgets, ) }; - let ( - Some(handle), - Some(content), - Some(dock), - Some(settings), - Some(new_ws), - Some(mi), - Some(ma), - Some(cl), - ) = ( - top_bar_handle, - top_bar_content_box, - dock_toggle, - settings_btn, - new_ws_btn, - minimize, - maximize, - close, - ) - else { - return; - }; - // Detach the mobile widgets from wherever they're parented now — this // covers the case where a widget lives in the top bar, the sidebar // header, or a pane's leading_box from a previous arrangement. - detach(&dock); - detach(&settings); - detach(&new_ws); - detach(&mi); - detach(&ma); - detach(&cl); - detach(&indicator_box); + detach(&widgets.dock); + detach(&widgets.settings); + detach(&widgets.new_ws); + detach(&widgets.minimize); + detach(&widgets.maximize); + detach(&widgets.close); + detach(&widgets.indicator_box); // Clear the alt sidebar header from previous arrangements (removes the // leftover hexpand spacer child). - while let Some(child) = sidebar_header.first_child() { - sidebar_header.remove(&child); + while let Some(child) = widgets.sidebar_header.first_child() { + widgets.sidebar_header.remove(&child); } // Workspace indicator pills are only shown when the user opts in. @@ -3284,80 +3296,104 @@ fn apply_top_bar_mode(state: &State) { } if show_top_bar { - // Classic layout: put everything back into the top bar, in order - // dock | settings | new_ws | indicator_box | [controls at side] - content.append(&dock); - content.append(&settings); - content.append(&new_ws); - content.append(&indicator_box); - - match controls_side { - app_config::WindowControlsSide::Left => { - cl.insert_before(&content, content.first_child().as_ref()); - mi.insert_after(&content, Some(&cl)); - ma.insert_after(&content, Some(&mi)); - } - app_config::WindowControlsSide::Right => { - content.append(&mi); - content.append(&ma); - content.append(&cl); - } - } - - handle.set_visible(true); - sidebar_header_handle.set_visible(false); - // Top bar already handles window drag — hide the 8px drag strip above - // the workspace list so the first row sits flush with the sidebar top, - // matching the sidebar-header mode's spacing. - sidebar_drag_area.set_visible(false); + layout_top_bar_visible(&widgets, controls_side); return; } // Top bar hidden. Hide the whole top-bar widget. - handle.set_visible(false); + widgets.handle.set_visible(false); if sidebar_visible_now { - // Sidebar open: left group + expanding spacer + right group, so the - // window controls sit at one end and the app buttons at the other. - let spacer = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .hexpand(true) - .build(); + layout_sidebar_header(&widgets, controls_side); + } else { + layout_collapsed_dock(state, &widgets, allow_retry); + } +} - match controls_side { - app_config::WindowControlsSide::Left => { - // close | min | max || spacer || dock | settings | + (new_ws) - sidebar_header.append(&cl); - sidebar_header.append(&mi); - sidebar_header.append(&ma); - sidebar_header.append(&spacer); - sidebar_header.append(&dock); - sidebar_header.append(&settings); - sidebar_header.append(&new_ws); - } - app_config::WindowControlsSide::Right => { - // dock | settings | + || spacer || min | max | close - sidebar_header.append(&dock); - sidebar_header.append(&settings); - sidebar_header.append(&new_ws); - sidebar_header.append(&spacer); - sidebar_header.append(&mi); - sidebar_header.append(&ma); - sidebar_header.append(&cl); - } +/// Classic layout: everything back in the top bar, controls at the chosen side. +fn layout_top_bar_visible(w: &TopBarWidgets, controls_side: app_config::WindowControlsSide) { + // dock | settings | new_ws | indicator_box | [controls at side] + w.content.append(&w.dock); + w.content.append(&w.settings); + w.content.append(&w.new_ws); + w.content.append(&w.indicator_box); + + match controls_side { + app_config::WindowControlsSide::Left => { + w.close + .insert_before(&w.content, w.content.first_child().as_ref()); + w.minimize.insert_after(&w.content, Some(&w.close)); + w.maximize.insert_after(&w.content, Some(&w.minimize)); } - sidebar_header_handle.set_visible(true); - // Sidebar header replaces the drag strip above it visually, so hide - // the 8px drag spacer to match the pane header height exactly. - sidebar_drag_area.set_visible(false); - } else { - // Sidebar collapsed: dock toggle goes on the leading pane, all other - // controls stay detached (not visible anywhere). - sidebar_header_handle.set_visible(false); - sidebar_drag_area.set_visible(true); - if let Some(pane) = active_workspace_leading_pane(state) { - if let Some(leading) = pane::pane_leading_box(&pane) { - leading.append(&dock); + app_config::WindowControlsSide::Right => { + w.content.append(&w.minimize); + w.content.append(&w.maximize); + w.content.append(&w.close); + } + } + + w.handle.set_visible(true); + w.sidebar_header_handle.set_visible(false); + // Top bar already handles window drag — hide the 8px drag strip above + // the workspace list so the first row sits flush with the sidebar top, + // matching the sidebar-header mode's spacing. + w.sidebar_drag_area.set_visible(false); +} + +/// Top bar hidden, sidebar open: left group + expanding spacer + right group, +/// so the window controls sit at one end and the app buttons at the other. +fn layout_sidebar_header(w: &TopBarWidgets, controls_side: app_config::WindowControlsSide) { + let spacer = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .hexpand(true) + .build(); + + match controls_side { + app_config::WindowControlsSide::Left => { + // close | min | max || spacer || dock | settings | + (new_ws) + w.sidebar_header.append(&w.close); + w.sidebar_header.append(&w.minimize); + w.sidebar_header.append(&w.maximize); + w.sidebar_header.append(&spacer); + w.sidebar_header.append(&w.dock); + w.sidebar_header.append(&w.settings); + w.sidebar_header.append(&w.new_ws); + } + app_config::WindowControlsSide::Right => { + // dock | settings | + || spacer || min | max | close + w.sidebar_header.append(&w.dock); + w.sidebar_header.append(&w.settings); + w.sidebar_header.append(&w.new_ws); + w.sidebar_header.append(&spacer); + w.sidebar_header.append(&w.minimize); + w.sidebar_header.append(&w.maximize); + w.sidebar_header.append(&w.close); + } + } + w.sidebar_header_handle.set_visible(true); + // Sidebar header replaces the drag strip above it visually, so hide + // the 8px drag spacer to match the pane header height exactly. + w.sidebar_drag_area.set_visible(false); +} + +/// Top bar hidden, sidebar collapsed: the dock toggle parks on the leading +/// pane, all other controls stay detached. The leading pane can be momentarily +/// absent while the workspace widget tree rebuilds; in that case retry once on +/// idle so the dock toggle doesn't briefly vanish during transient cycles. +fn layout_collapsed_dock(state: &State, w: &TopBarWidgets, allow_retry: bool) { + w.sidebar_header_handle.set_visible(false); + w.sidebar_drag_area.set_visible(true); + + let leading_box = + active_workspace_leading_pane(state).and_then(|pane| pane::pane_leading_box(&pane)); + match leading_box { + Some(leading) => leading.append(&w.dock), + None => { + if allow_retry { + let state = state.clone(); + glib::idle_add_local_once(move || { + apply_top_bar_mode_impl(&state, false); + }); } } }