fix: better sidebar and tab title handling
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user