From 83132b7043c0e24d9be753862a272146a85c86d6 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 5 Apr 2026 12:58:56 +0200 Subject: [PATCH] fix: better sidebar and tab title handling --- crates/bds-ui/src/app.rs | 22 ++++++++++- crates/bds-ui/src/views/sidebar.rs | 55 +++++++++++++++++++++++++++- crates/bds-ui/src/views/tab_bar.rs | 37 ++++++++++++++++++- crates/bds-ui/src/views/workspace.rs | 5 ++- 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index fd09e2f..52caaae 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -690,7 +690,25 @@ impl BdsApp { Subscription::none() }; - Subscription::batch([menu_sub, task_tick, toast_tick]) + // Global mouse tracking for sidebar resize dragging. + // The 4px drag handle mouse_area only fires on_press; move/release + // are captured here so dragging works even when the cursor leaves + // the narrow handle strip. + let drag_sub = if self.sidebar_dragging { + iced::event::listen_with(|event, _status, _id| match event { + iced::Event::Mouse(iced::mouse::Event::CursorMoved { position }) => { + Some(Message::SidebarResizeMove(position.x)) + } + iced::Event::Mouse(iced::mouse::Event::ButtonReleased( + iced::mouse::Button::Left, + )) => Some(Message::SidebarResizeEnd), + _ => None, + }) + } else { + Subscription::none() + }; + + Subscription::batch([menu_sub, task_tick, toast_tick, drag_sub]) } // ── Private helpers ── @@ -845,7 +863,7 @@ impl BdsApp { if let Some(t) = self.tabs.get(idx) { self.active_tab = Some(t.id.clone()); } - self.enforce_panel_tab_availability(); + self.enforce_panel_tab_fallback(); } fn refresh_task_snapshots(&mut self) { diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index f2ed8f6..b3f21a1 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -65,6 +65,28 @@ fn placeholder_key(view: SidebarView) -> &'static str { /// sidebar_views.allium media_title_max_length = 60 const MEDIA_TITLE_MAX_LEN: usize = 60; +/// Approximate average character width at font size 12 for proportional fonts. +/// Used to estimate how many characters fit in a given pixel width. +const AVG_CHAR_WIDTH_PX: f32 = 6.8; + +/// Sidebar padding on each side (12px) plus item padding (6px each side). +const SIDEBAR_TEXT_OVERHEAD_PX: f32 = 36.0; + +/// Extra space used by the post status indicator prefix ("○ " etc.). +const STATUS_INDICATOR_CHARS: usize = 2; + +/// Truncate a string to fit approximately within `available_px` pixels, +/// appending "…" if truncation occurs. +fn truncate_to_fit(s: &str, available_px: f32) -> String { + let max_chars = ((available_px / AVG_CHAR_WIDTH_PX) as usize).max(6); + if s.chars().count() <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect(); + format!("{truncated}\u{2026}") + } +} + /// Truncate a media title to the max length, appending "..." if over limit. /// Per sidebar_views.allium: JS hard limit of 60 chars on title (substring + "..."). fn truncate_media_title(title: &str) -> String { @@ -116,7 +138,12 @@ pub fn view( bds_core::model::PostStatus::Published => "\u{25CF} ", bds_core::model::PostStatus::Archived => "\u{25A1} ", }; - let label = format!("{status_indicator}{}", p.title); + // Truncate title to fit sidebar width, accounting for + // padding and the status indicator prefix. + let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX + - (STATUS_INDICATOR_CHARS as f32 * AVG_CHAR_WIDTH_PX); + let display_title = truncate_to_fit(&p.title, text_px); + let label = format!("{status_indicator}{display_title}"); let label_text = text(label) .size(12) .shaping(Shaping::Advanced) @@ -190,10 +217,14 @@ pub fn view( let is_active = active_tab == Some(m.id.as_str()); // Per sidebar_views.allium MediaGridItem: title truncated to 60 chars + "..." // if over limit; fallback originalName (no truncation). + // Additionally truncate to fit sidebar width. let display_name = match m.title.as_deref() { Some(title) => truncate_media_title(title), None => m.original_name.clone(), }; + // Emoji prefix "🖼 " is ~2 chars wide + let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 16.0; + let display_name = truncate_to_fit(&display_name, text_px); let label = format!("\u{1F5BC} {display_name}"); let label_text = text(label) .size(12) @@ -277,4 +308,26 @@ mod tests { let expected = format!("{}...", "\u{00FC}".repeat(60)); assert_eq!(truncate_media_title(&title), expected); } + + #[test] + fn truncate_to_fit_short() { + // 100px at ~6.8px/char ≈ 14 chars; "Hello" fits. + assert_eq!(truncate_to_fit("Hello", 100.0), "Hello"); + } + + #[test] + fn truncate_to_fit_long() { + // 50px at ~6.8px/char ≈ 7 chars; 20-char string truncated. + let result = truncate_to_fit(&"a".repeat(20), 50.0); + assert!(result.ends_with('\u{2026}')); + assert!(result.chars().count() <= 8); + } + + #[test] + fn truncate_to_fit_narrow() { + // Very narrow (10px): minimum 6 chars enforced. + let result = truncate_to_fit(&"a".repeat(20), 10.0); + assert!(result.ends_with('\u{2026}')); + assert!(result.chars().count() >= 2); + } } diff --git a/crates/bds-ui/src/views/tab_bar.rs b/crates/bds-ui/src/views/tab_bar.rs index 8adda16..aae342c 100644 --- a/crates/bds-ui/src/views/tab_bar.rs +++ b/crates/bds-ui/src/views/tab_bar.rs @@ -15,6 +15,11 @@ use crate::state::tabs::Tab; const TAB_WIDTH: f32 = 160.0; const CHAT_TITLE_MAX_LEN: usize = 18; +/// Maximum characters for tab titles. +/// TAB_WIDTH (160) minus padding (16) minus close button + spacing (~28) +/// leaves ~116px. At ~6.8px per char (12px proportional font) ≈ 17 chars. +const TAB_TITLE_MAX_LEN: usize = 17; + /// Tab bar background. fn bar_style(_theme: &Theme) -> container::Style { container::Style { @@ -97,6 +102,17 @@ fn truncate_chat_title(title: &str) -> String { } } +/// Truncate a tab title to fit within the tab's available text area. +/// Uses "…" (Unicode ellipsis) as the truncation indicator. +fn truncate_tab_title(title: &str) -> String { + if title.chars().count() > TAB_TITLE_MAX_LEN { + let truncated: String = title.chars().take(TAB_TITLE_MAX_LEN.saturating_sub(1)).collect(); + format!("{truncated}\u{2026}") + } else { + title.to_string() + } +} + /// Build tooltip text per tabs.allium: /// Base: tab title. If transient: append " (Preview)". If dirty: append " * Modified". fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String { @@ -131,10 +147,11 @@ pub fn view( let close_id = tab.id.clone(); // Per tabs.allium: chat titles are JS-truncated to 18 chars + "..." + // All other titles are truncated to fit the tab's text area. let display_title = if tab.tab_type == crate::state::tabs::TabType::Chat { truncate_chat_title(&tab.title) } else { - tab.title.clone() + truncate_tab_title(&tab.title) }; // Per tabs.allium: transient tabs show italic title @@ -240,4 +257,22 @@ mod tests { let expected = format!("{}...", "a".repeat(18)); assert_eq!(truncate_chat_title(&title), expected); } + + #[test] + fn truncate_tab_title_short() { + assert_eq!(truncate_tab_title("Settings"), "Settings"); + } + + #[test] + fn truncate_tab_title_exact_limit() { + let title: String = "a".repeat(TAB_TITLE_MAX_LEN); + assert_eq!(truncate_tab_title(&title), title); + } + + #[test] + fn truncate_tab_title_over_limit() { + let title: String = "a".repeat(30); + let expected = format!("{}\u{2026}", "a".repeat(TAB_TITLE_MAX_LEN - 1)); + assert_eq!(truncate_tab_title(&title), expected); + } } diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 94b9ab3..074b8d2 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -114,10 +114,11 @@ pub fn view( .height(Length::Fill) .style(drag_handle_style); + // Only on_press here; move/release are captured by a global + // subscription so dragging works even when the cursor leaves + // the narrow 4px handle strip. let handle_hover = mouse_area(handle) .on_press(Message::SidebarResizeStart) - .on_release(Message::SidebarResizeEnd) - .on_move(|point| Message::SidebarResizeMove(point.x)) .interaction(iced::mouse::Interaction::ResizingHorizontally); main_row = main_row.push(handle_hover);