diff --git a/src/app/view.rs b/src/app/view.rs index b300e0be5..abcb01ea1 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1626,7 +1626,7 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele for (idx, tab) in tabs.iter().enumerate() { let is_active = idx == active_tab; - let name = tab.tab_display_name(); + let name = crate::ui::text_util::elide(&tab.tab_display_name(), 24); let label = if tab.dirty { format!("● {}", name) } else { @@ -2977,7 +2977,7 @@ fn save_as_dialog_window<'a>( row![ text(icon).size(13), Space::new().width(6), - text(name.as_str()).size(13).color(color), + text(crate::ui::text_util::elide(name.as_str(), 48)).size(13).color(color), ] .align_y(iced::Alignment::Center), ) @@ -3395,8 +3395,8 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme let path_for_open = path.clone(); let open_btn = button( column![ - text(name).size(12).color(TEXT), - text(dir).size(10).color(MUTED), + text(crate::ui::text_util::elide(&name, 32)).size(12).color(TEXT), + text(crate::ui::text_util::elide(&dir, 42)).size(10).color(MUTED), ] .spacing(2), ) diff --git a/src/ui/layers.rs b/src/ui/layers.rs index e26d36f4b..81ef88a29 100644 --- a/src/ui/layers.rs +++ b/src/ui/layers.rs @@ -9,7 +9,7 @@ use acadrust::types::aci_table::aci_to_rgb; use acadrust::types::{Color as AcadColor, LineWeight}; use acadrust::Handle; use iced::widget::{ - button, column, combo_box, container, mouse_area, row, scrollable, text, text_input, + button, column, combo_box, container, mouse_area, row, scrollable, text, text_input, tooltip, }; use iced::Padding; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; @@ -373,6 +373,27 @@ fn toolbar_btn_cond(label: &str, msg: Message, enabled: bool) -> Element<'_, Mes // ── Layer row ───────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] +/// Hover popup showing a layer's full name when the cell truncates it. +fn name_tip<'a>(name: &'a str) -> Element<'a, Message> { + container(text(name).size(FONT_SZ).color(ROW_TEXT)) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 7.0, + right: 7.0, + }) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { + color: BORDER_COLOR, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .into() +} + fn layer_row<'a>( index: usize, layer: &'a Layer, @@ -499,29 +520,40 @@ fn layer_row<'a>( .width(Length::Fixed(COL_NAME)) .into() } else { - button(text(&layer.name).size(FONT_SZ).color(ROW_TEXT)) - .on_press(Message::LayerRenameStart(index)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => Color { - r: 0.30, - g: 0.30, - b: 0.30, - a: 1.0, - }, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) - .padding(Padding { - top: COMBO_PAD_V, - bottom: COMBO_PAD_V, - left: 4.0, - right: 4.0, - }) - .height(Length::Fixed(ROW_H)) - .width(Length::Fixed(COL_NAME)) - .into() + const NAME_BUDGET: usize = 17; + let name_btn = button( + text(crate::ui::text_util::elide(&layer.name, NAME_BUDGET)) + .size(FONT_SZ) + .color(ROW_TEXT), + ) + .on_press(Message::LayerRenameStart(index)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => Color { + r: 0.30, + g: 0.30, + b: 0.30, + a: 1.0, + }, + _ => Color::TRANSPARENT, + })), + ..Default::default() + }) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 4.0, + right: 4.0, + }) + .height(Length::Fixed(ROW_H)) + .width(Length::Fixed(COL_NAME)); + // When the name is truncated, reveal the full text on hover so the + // user can still read it without widening the column. + if layer.name.chars().count() > NAME_BUDGET { + tooltip(name_btn, name_tip(&layer.name), tooltip::Position::FollowCursor).into() + } else { + name_btn.into() + } }; // Color cell — looks like a combo_box input; click opens swatch dropdown below row. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b36a09fe7..c66a56a89 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -27,6 +27,7 @@ pub mod statusbar; pub mod statusbar_config; pub mod statusbar_menu; pub mod tablestyle; +pub mod text_util; pub mod textstyle; pub mod units_popup; diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 86de668eb..04efa1808 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -181,7 +181,7 @@ impl PropertiesPanel { // ── Title bar (entity type / "No Selection") ───────────────────── let title_content: Element<'_, Message> = if self.selection_groups.is_empty() { - text(&self.title).size(FONT_SZ).color(SECTION_LABEL).into() + text(crate::ui::text_util::elide(&self.title, 34)).size(FONT_SZ).color(SECTION_LABEL).into() } else { combo_box( &self.selection_group_combo, @@ -248,7 +248,7 @@ impl PropertiesPanel { if self.sections.is_empty() { return None; } - let title = container(text(&self.title).size(FONT_SZ).color(SECTION_LABEL)) + let title = container(text(crate::ui::text_util::elide(&self.title, 34)).size(FONT_SZ).color(SECTION_LABEL)) .style(|_: &Theme| container::Style { background: Some(Background::Color(SECTION_BG)), border: Border { @@ -847,7 +847,8 @@ fn render_bool_row<'a>(label: &'a str, field: &'static str, value: bool) -> Elem } fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { - let label_col = container(text(label).size(FONT_SZ).color(LABEL_COLOR)) + let value = crate::ui::text_util::elide(value, 22); + let label_col = container(text(crate::ui::text_util::elide(label, 18)).size(FONT_SZ).color(LABEL_COLOR)) .style(|_: &Theme| container::Style { background: Some(Background::Color(LABEL_BG)), ..Default::default() @@ -890,7 +891,7 @@ fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { - let label_col = container(text(label).size(FONT_SZ).color(LABEL_COLOR)) + let label_col = container(text(crate::ui::text_util::elide(label, 18)).size(FONT_SZ).color(LABEL_COLOR)) .style(|_: &Theme| container::Style { background: Some(Background::Color(LABEL_BG)), ..Default::default() diff --git a/src/ui/text_util.rs b/src/ui/text_util.rs new file mode 100644 index 000000000..38ff3124c --- /dev/null +++ b/src/ui/text_util.rs @@ -0,0 +1,45 @@ +//! Shared helpers for fitting text into width-constrained widgets. +//! +//! iced has no built-in ellipsis, so long user content (file names, layer +//! names, paths, property values) would otherwise wrap and break row +//! heights or spill past fixed-width panels. `elide` trims to a character +//! budget and marks the cut with a single ellipsis. + +/// Truncate `s` to at most `max` characters, replacing the overflow with a +/// trailing ellipsis. `max` includes the ellipsis character; `max == 0` +/// yields an empty string. Counts by `char` so multi-byte text is safe. +pub fn elide(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + if max == 0 { + return String::new(); + } + let mut out: String = s.chars().take(max - 1).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::elide; + + #[test] + fn short_unchanged() { + assert_eq!(elide("abc", 8), "abc"); + assert_eq!(elide("abc", 3), "abc"); + } + + #[test] + fn long_truncated() { + assert_eq!(elide("abcdef", 4), "abc…"); + assert_eq!(elide("abcdef", 1), "…"); + assert_eq!(elide("abcdef", 0), ""); + } + + #[test] + fn multibyte_safe() { + // No panic on a char boundary and the budget counts chars. + assert_eq!(elide("çağrışım", 4), "çağ…"); + } +}