File tabs: cut long drawing names short with an ellipsis (#657)

Squash of PR #657 by @aldozar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aldozar
2026-10-12 03:11:59 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bc6dc997ef
commit 507c5e5b34
2 changed files with 105 additions and 44 deletions
+58 -44
View File
@@ -192,55 +192,59 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
app.ui.start_tab = false;
}
x += 24.0;
let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool, egui::Response) {
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height()));
let resp = ui.interact(tr, id, Sense::click());
ui.painter().rect_filled(
tr,
0.0,
if active {
t.tab_active
} else if resp.hovered() {
t.chrome
} else {
t.chrome_dark
},
);
ui.painter().text(
pos2(tr.left() + 8.0, tr.center().y),
egui::Align2::LEFT_CENTER,
label,
crate::theme::body(),
if active { t.text } else { t.text_dim },
);
let mut close = false;
if closable && (active || resp.hovered()) {
let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0));
let cresp = ui.interact(cr, id.with("x"), Sense::click());
icons::paint(ui.painter(), cr, Icon::Close, false);
icons::describe_control(ui, &cresp, &format!("Close {label}"), None, false);
close = cresp.clicked();
}
icons::describe_control(ui, &resp, label, Some(active), true);
ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border));
x += w;
(resp.clicked(), close, resp)
};
let (c, _, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
// `label` is the drawing's name; `dirty` adds the unsaved-changes mark, kept visible when a
// long name is cut short.
let mut tab =
|ui: &mut egui::Ui, label: &str, dirty: bool, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool, egui::Response) {
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height()));
let resp = ui.interact(tr, id, Sense::click());
ui.painter().rect_filled(
tr,
0.0,
if active {
t.tab_active
} else if resp.hovered() {
t.chrome
} else {
t.chrome_dark
},
);
// The name ends in "…" when it doesn't fit before the close button (#513).
let color = if active { t.text } else { t.text_dim };
let mark = if dirty { "*" } else { "" };
let text_w = (w - 8.0 - if closable { 24.0 } else { 8.0 }).max(0.0);
let mark_w = ui.painter().layout_no_wrap(mark.to_string(), crate::theme::body(), color).size().x;
let (galley, cut) = elided(ui.painter(), label, crate::theme::body(), color, text_w - mark_w);
let pos = pos2(tr.left() + 8.0, tr.center().y - galley.size().y / 2.0);
let name_w = galley.size().x;
ui.painter().galley(pos, galley, color);
if dirty {
ui.painter().text(pos2(pos.x + name_w, tr.center().y), egui::Align2::LEFT_CENTER, mark, crate::theme::body(), color);
}
let resp = if cut { resp.on_hover_text(format!("{label}{mark}")) } else { resp };
let mut close = false;
if closable && (active || resp.hovered()) {
let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0));
let cresp = ui.interact(cr, id.with("x"), Sense::click());
icons::paint(ui.painter(), cr, Icon::Close, false);
icons::describe_control(ui, &cresp, &format!("Close {label}"), None, false);
close = cresp.clicked();
}
icons::describe_control(ui, &resp, label, Some(active), true);
ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border));
x += w;
(resp.clicked(), close, resp)
};
let (c, _, _) = tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
if c {
app.ui.start_tab = true;
}
let mut switch_to = None;
let mut close = None;
let titles: Vec<(usize, String, bool)> = app
.session
.docs
.iter()
.enumerate()
.map(|(i, d)| (i, format!("{}{}", d.title, if d.is_dirty() { "*" } else { "" }), i == app.session.active))
.collect();
for (i, title, active) in titles {
let (c, x, resp) = tab(ui, &title, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
let titles: Vec<(usize, String, bool, bool)> =
app.session.docs.iter().enumerate().map(|(i, d)| (i, d.title.clone(), d.is_dirty(), i == app.session.active)).collect();
for (i, title, dirty, active) in titles {
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i));
if c {
switch_to = Some(i);
@@ -259,6 +263,16 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
});
}
/// `text` laid out on one line no wider than `max_width`, ending in "…" when it had to be cut;
/// and whether it was.
fn elided(p: &egui::Painter, text: &str, font: egui::FontId, color: egui::Color32, max_width: f32) -> (std::sync::Arc<egui::Galley>, bool) {
let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);
job.wrap = egui::text::TextWrapping { max_width: max_width.max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
let galley = p.layout_job(job);
let cut = galley.elided;
(galley, cut)
}
/// Status bar: layout tabs on the left, coordinates and drafting toggles on the right.
pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let t = Tokens::get();
+47
View File
@@ -0,0 +1,47 @@
//! A long drawing name is cut short with "…" inside its file tab instead of running under the
//! next tab and the close button (#513); the unsaved-changes mark stays visible.
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services};
use egui::{Rect, Shape, vec2};
use egui_kittest::Harness;
/// Painted texts: the full source text, the painted bounds, and whether it was cut short.
fn texts(shape: &Shape, out: &mut Vec<(String, Rect, bool)>) {
match shape {
Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect(), t.galley.elided)),
Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
#[test]
fn long_names_are_cut_short_inside_the_tab() {
let mut app = CadApp::new(Session::default(), Services::default());
app.run("new", serde_json::json!({})).unwrap();
app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap();
app.session.state_mut().unwrap().title = "LICENCIA COFRE DE PEROTE REPOSICION FINAL.dwg".into();
app.ui.start_tab = false;
let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(5);
let mut found = Vec::new();
for s in &h.output().shapes {
texts(&s.shape, &mut found);
}
// The window title shows the whole name too; the tab's is the lower one.
let (_, rect, cut) =
found.iter().filter(|(t, ..)| t.starts_with("LICENCIA")).max_by(|a, b| a.1.top().total_cmp(&b.1.top())).cloned().expect("the tab's name");
assert!(cut, "the name ends in an ellipsis");
// Tabs are 170 points wide: 8 of padding, then the name, the mark and the close button (24).
assert!(rect.width() <= 170.0 - 8.0 - 24.0, "{} points wide", rect.width());
let mark = found.iter().find(|(t, r, _)| t == "*" && (r.left() - rect.right()).abs() < 4.0);
assert!(mark.is_some(), "the unsaved mark follows the shortened name");
// A short name is not cut.
assert!(found.iter().any(|(t, _, cut)| t == "Start" && !cut));
}