forked from 1b-agent/fieldcraft-upstream
UI: status messages elide in the middle, the command line's placeholder fits its width
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
8b12cdd5c7
commit
2443746b35
3 files changed
+118
-5
No files matched your search
@@ -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<char> = 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), "αβ…ζ");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user