diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 062f8efc..2ba877fb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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, 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(); diff --git a/crates/ui-egui/tests/file_tab_titles.rs b/crates/ui-egui/tests/file_tab_titles.rs new file mode 100644 index 00000000..55a3a8fe --- /dev/null +++ b/crates/ui-egui/tests/file_tab_titles.rs @@ -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)); +}