feat(ui): elide overflowing text, tooltip full layer name
iced has no built-in ellipsis, so long user content (file names, layer names, paths, property values) wrapped or spilled past fixed-width panels. Add an `elide` helper (char-budget truncation with a trailing ellipsis) and apply it to the layer manager, properties panel, document tabs, recent-files list and the save dialog. In the layer manager a truncated name reveals its full text in a cursor-following tooltip on hover, so the column need not be widened. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b3aadf569e
commit
ff10f8a89a
5 changed files with 111 additions and 32 deletions
|
|
@ -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() {
|
for (idx, tab) in tabs.iter().enumerate() {
|
||||||
let is_active = idx == active_tab;
|
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 {
|
let label = if tab.dirty {
|
||||||
format!("● {}", name)
|
format!("● {}", name)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2977,7 +2977,7 @@ fn save_as_dialog_window<'a>(
|
||||||
row![
|
row![
|
||||||
text(icon).size(13),
|
text(icon).size(13),
|
||||||
Space::new().width(6),
|
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),
|
.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 path_for_open = path.clone();
|
||||||
let open_btn = button(
|
let open_btn = button(
|
||||||
column![
|
column![
|
||||||
text(name).size(12).color(TEXT),
|
text(crate::ui::text_util::elide(&name, 32)).size(12).color(TEXT),
|
||||||
text(dir).size(10).color(MUTED),
|
text(crate::ui::text_util::elide(&dir, 42)).size(10).color(MUTED),
|
||||||
]
|
]
|
||||||
.spacing(2),
|
.spacing(2),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use acadrust::types::aci_table::aci_to_rgb;
|
||||||
use acadrust::types::{Color as AcadColor, LineWeight};
|
use acadrust::types::{Color as AcadColor, LineWeight};
|
||||||
use acadrust::Handle;
|
use acadrust::Handle;
|
||||||
use iced::widget::{
|
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::Padding;
|
||||||
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
|
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 ─────────────────────────────────────────────────────────────
|
// ── Layer row ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[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>(
|
fn layer_row<'a>(
|
||||||
index: usize,
|
index: usize,
|
||||||
layer: &'a Layer,
|
layer: &'a Layer,
|
||||||
|
|
@ -499,29 +520,40 @@ fn layer_row<'a>(
|
||||||
.width(Length::Fixed(COL_NAME))
|
.width(Length::Fixed(COL_NAME))
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
button(text(&layer.name).size(FONT_SZ).color(ROW_TEXT))
|
const NAME_BUDGET: usize = 17;
|
||||||
.on_press(Message::LayerRenameStart(index))
|
let name_btn = button(
|
||||||
.style(|_: &Theme, status| button::Style {
|
text(crate::ui::text_util::elide(&layer.name, NAME_BUDGET))
|
||||||
background: Some(Background::Color(match status {
|
.size(FONT_SZ)
|
||||||
button::Status::Hovered => Color {
|
.color(ROW_TEXT),
|
||||||
r: 0.30,
|
)
|
||||||
g: 0.30,
|
.on_press(Message::LayerRenameStart(index))
|
||||||
b: 0.30,
|
.style(|_: &Theme, status| button::Style {
|
||||||
a: 1.0,
|
background: Some(Background::Color(match status {
|
||||||
},
|
button::Status::Hovered => Color {
|
||||||
_ => Color::TRANSPARENT,
|
r: 0.30,
|
||||||
})),
|
g: 0.30,
|
||||||
..Default::default()
|
b: 0.30,
|
||||||
})
|
a: 1.0,
|
||||||
.padding(Padding {
|
},
|
||||||
top: COMBO_PAD_V,
|
_ => Color::TRANSPARENT,
|
||||||
bottom: COMBO_PAD_V,
|
})),
|
||||||
left: 4.0,
|
..Default::default()
|
||||||
right: 4.0,
|
})
|
||||||
})
|
.padding(Padding {
|
||||||
.height(Length::Fixed(ROW_H))
|
top: COMBO_PAD_V,
|
||||||
.width(Length::Fixed(COL_NAME))
|
bottom: COMBO_PAD_V,
|
||||||
.into()
|
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.
|
// Color cell — looks like a combo_box input; click opens swatch dropdown below row.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ pub mod statusbar;
|
||||||
pub mod statusbar_config;
|
pub mod statusbar_config;
|
||||||
pub mod statusbar_menu;
|
pub mod statusbar_menu;
|
||||||
pub mod tablestyle;
|
pub mod tablestyle;
|
||||||
|
pub mod text_util;
|
||||||
pub mod textstyle;
|
pub mod textstyle;
|
||||||
pub mod units_popup;
|
pub mod units_popup;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ impl PropertiesPanel {
|
||||||
|
|
||||||
// ── Title bar (entity type / "No Selection") ─────────────────────
|
// ── Title bar (entity type / "No Selection") ─────────────────────
|
||||||
let title_content: Element<'_, Message> = if self.selection_groups.is_empty() {
|
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 {
|
} else {
|
||||||
combo_box(
|
combo_box(
|
||||||
&self.selection_group_combo,
|
&self.selection_group_combo,
|
||||||
|
|
@ -248,7 +248,7 @@ impl PropertiesPanel {
|
||||||
if self.sections.is_empty() {
|
if self.sections.is_empty() {
|
||||||
return None;
|
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 {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(SECTION_BG)),
|
background: Some(Background::Color(SECTION_BG)),
|
||||||
border: Border {
|
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> {
|
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 {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(LABEL_BG)),
|
background: Some(Background::Color(LABEL_BG)),
|
||||||
..Default::default()
|
..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.
|
/// Build a label | widget property row.
|
||||||
fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> {
|
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 {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(LABEL_BG)),
|
background: Some(Background::Color(LABEL_BG)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
|
||||||
45
src/ui/text_util.rs
Normal file
45
src/ui/text_util.rs
Normal file
|
|
@ -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ğ…");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue