From 2443746b350e15e5100e268ae581210827e56f39 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:37:54 -0400 Subject: [PATCH] UI: status messages elide in the middle, the command line's placeholder fits its width Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/elide.rs | 84 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/palette.rs | 38 +++++++++++++--- 3 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/elide.rs diff --git a/crates/ui-egui/src/elide.rs b/crates/ui-egui/src/elide.rs new file mode 100644 index 0000000..e706841 --- /dev/null +++ b/crates/ui-egui/src/elide.rs @@ -0,0 +1,84 @@ +//! Text that does not fit: elided in the middle, so both ends of a name stay readable +//! (`F2:side:a5#[F2…]|F2:side:a6`), with the full text left to a hover. + +use std::borrow::Cow; + +use egui::{FontId, Ui}; + +/// The ellipsis put in place of the middle. +pub const ELLIPSIS: char = '…'; + +/// `text`, or as much of its start and end as fits in `max_width` around an [`ELLIPSIS`]. +/// `width_of` measures a string as drawn. Ties keep one more character at the start. With no room +/// even for the ellipsis alone, the ellipsis. +pub fn middle<'a>(text: &'a str, max_width: f32, width_of: impl Fn(&str) -> f32) -> Cow<'a, str> { + if width_of(text) <= max_width { + return Cow::Borrowed(text); + } + let chars: Vec = text.chars().collect(); + let joined = |kept: usize| -> String { + let head = kept.div_ceil(2); + let tail = kept - head; + let mut s: String = chars.iter().take(head).collect(); + s.push(ELLIPSIS); + s.extend(chars.iter().skip(chars.len() - tail)); + s + }; + // The widest that fits: the width grows with the characters kept. + let (mut lo, mut hi) = (0, chars.len().saturating_sub(1)); + while lo < hi { + let mid = (lo + hi).div_ceil(2); + if width_of(&joined(mid)) <= max_width { + lo = mid; + } else { + hi = mid - 1; + } + } + Cow::Owned(joined(lo)) +} + +/// The width of `text` on one line in `font`. +pub fn width(ui: &Ui, text: &str, font: &FontId) -> f32 { + ui.painter().layout_no_wrap(text.to_string(), font.clone(), egui::Color32::PLACEHOLDER).size().x +} + +/// [`middle`] measured with `font` as `ui` draws it. +pub fn middle_in<'a>(ui: &Ui, text: &'a str, font: &FontId, max_width: f32) -> Cow<'a, str> { + middle(text, max_width, |s| width(ui, s, font)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One unit per character. + fn chars(s: &str) -> f32 { + s.chars().count() as f32 + } + + #[test] + fn fitting_text_is_kept_whole() { + assert!(matches!(middle("Seeds", 5.0, chars), Cow::Borrowed("Seeds"))); + } + + #[test] + fn long_text_keeps_both_ends() { + let name = "F2:side:a5#[F2:side:a6]|F2:side:a6"; + let short = middle(name, 15.0, chars); + assert_eq!(short, "F2:side…side:a6"); + assert!(chars(&short) <= 15.0); + assert_eq!(middle(name, 16.0, chars), "F2:side:…side:a6"); + } + + #[test] + fn no_room_leaves_the_ellipsis() { + assert_eq!(middle("abcdef", 1.0, chars), "…"); + assert_eq!(middle("abcdef", 0.0, chars), "…"); + assert_eq!(middle("", -1.0, chars), "…"); + } + + #[test] + fn multibyte_text_splits_on_characters() { + assert_eq!(middle("αβγδεζ", 4.0, chars), "αβ…ζ"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ba54df1..61df60e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ pub mod browser; pub mod capture; pub mod context; pub mod control; +pub mod elide; pub mod fields; pub mod files; pub mod form; diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 27303a4..75fe123 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -102,6 +102,28 @@ pub fn submit(app: &mut ModelApp, line: &str) { } } +/// The command line's width: 40 % of the bar, within these. +const MIN_LINE_W: f32 = 150.0; +const MAX_LINE_W: f32 = 440.0; + +/// The command line's placeholders, longest first: the longest that fits whole is shown. +const PLACEHOLDERS: [&str; 5] = [ + r#"Type a command, e.g. mesh.node.add {"label": 1, "xyz": [0, 0, 0]} — or press S to search"#, + "Type a command, e.g. mesh.tet.generate — or press S to search", + "Type a command — or press S to search", + "Command, or S to search", + "Command", +]; + +/// The longest [`PLACEHOLDERS`] that fits a command line `width` wide, so it is never cut. +fn placeholder(ui: &egui::Ui, width: f32) -> &'static str { + let font = egui::TextStyle::Body.resolve(ui.style()); + // A text field's default margin is 4 points a side. + let room = width - 8.0; + let shortest = PLACEHOLDERS[PLACEHOLDERS.len() - 1]; + PLACEHOLDERS.iter().copied().find(|p| crate::elide::width(ui, p, &font) <= room).unwrap_or(shortest) +} + /// The command line, the status and the model's size, above the timeline. pub fn command_line(app: &mut ModelApp, ui: &mut egui::Ui) { let t = Tokens::get(app.ui.dark); @@ -112,11 +134,11 @@ pub fn command_line(app: &mut ModelApp, ui: &mut egui::Ui) { app.place("command_line", ui.max_rect()); ui.horizontal_centered(|ui| { ui.label(RichText::new("›").strong().color(t.accent)); + // The rest of the bar keeps room for the status and the model's size. + let width = (ui.available_width() * 0.4).clamp(MIN_LINE_W, MAX_LINE_W); + let hint = if app.command_line.is_empty() { placeholder(ui, width) } else { "" }; let r = ui.add( - egui::TextEdit::singleline(&mut app.command_line) - .id(egui::Id::new("fc_command_line_text")) - .hint_text(r#"Type a command, e.g. mesh.node.add {"label": 1, "xyz": [0, 0, 0]} — or press S to search"#) - .desired_width(440.0), + egui::TextEdit::singleline(&mut app.command_line).id(egui::Id::new("fc_command_line_text")).hint_text(hint).desired_width(width), ); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { let line = std::mem::take(&mut app.command_line); @@ -134,7 +156,13 @@ pub fn command_line(app: &mut ModelApp, ui: &mut egui::Ui) { Some(s) => (s.text.as_str(), if s.error { t.error } else { t.text }), None => ("Ready", t.text_dim), }; - ui.add(egui::Label::new(RichText::new(text).size(11.5).color(color)).truncate()); + // Elided in the middle (both the subject and the outcome stay), whole on hover. + let font = FontId::proportional(11.5); + let shown = crate::elide::middle_in(ui, text, &font, ui.available_width()); + let r = ui.add(egui::Label::new(RichText::new(shown.as_ref()).font(font).color(color))); + if shown != text { + r.on_hover_text(text); + } }); }); });