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..e029f8bb 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,50 @@ 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 { + // 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( + &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..15f725c1 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,245 @@ 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). +/// 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) { + 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(), + 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(), + ) + 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, + 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(), + widgets, + ) + }; + + // 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(&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) = widgets.sidebar_header.first_child() { + widgets.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 { + layout_top_bar_visible(&widgets, controls_side); + return; + } + + // Top bar hidden. Hide the whole top-bar widget. + widgets.handle.set_visible(false); + + if sidebar_visible_now { + layout_sidebar_header(&widgets, controls_side); + } else { + layout_collapsed_dock(state, &widgets, allow_retry); + } +} + +/// 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)); + } + 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); + }); + } + } + } +} + fn open_keybind_editor_tab(state: &State, pane_widget: >k::Widget) { let shortcuts = { let s = state.borrow(); @@ -2819,6 +3441,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 +3530,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 +3543,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 +3568,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 +3598,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 +3611,7 @@ fn build_sidebar_row( notify_dot, notify_label, path_label, + close_button, ) } @@ -3050,6 +3763,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 +3897,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 +4138,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 +4192,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 +4206,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 +4237,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 +4417,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 +5017,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 +5282,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 +5305,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 +5358,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 +5370,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 +5404,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 +5519,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 +5562,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 +5584,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 +5606,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 +5618,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 +5633,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 +5644,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 +5741,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 +5811,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 +5820,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 +5913,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 +5952,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 +6019,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 +6617,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;