fix: better sidebar and tab title handling

This commit is contained in:
2026-04-05 12:58:56 +02:00
parent c7a939736c
commit 83132b7043
4 changed files with 113 additions and 6 deletions

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);