diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2ba877f..085f46a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -273,6 +273,27 @@ fn elided(p: &egui::Painter, text: &str, font: egui::FontId, color: egui::Color3 (galley, cut) } +/// Widest layout tab name before it is cut with "…". +const LAYOUT_TAB_TEXT: f32 = 150.0; +/// Room the layout tabs leave for the coordinates and messages. +const STATUS_READOUT_MIN: f32 = 150.0; + +/// Which of the tabs with these `widths` to show in `room`: all of them when they fit, else a run +/// that holds the `active` one (shown even when it alone is too wide), starting as early as it can. +fn visible_tabs(widths: &[f32], active: Option, room: f32) -> std::ops::Range { + let fits = |range: std::ops::Range| widths.get(range).is_some_and(|w| w.iter().sum::() <= room); + let mut end = 0; + while end < widths.len() && fits(0..end + 1) { + end += 1; + } + let Some(active) = active.filter(|a| *a >= end && *a < widths.len()) else { return 0..end }; + let mut start = active; + while start > 0 && fits(start - 1..active + 1) { + start -= 1; + } + start..active + 1 +} + /// 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(); @@ -287,64 +308,23 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let left = std::time::Duration::try_from_secs_f64((5000.0 - (crate::now_ms() - at)) / 1000.0).unwrap_or_default(); ui.ctx().request_repaint_after(left); } + let mut list_button = None; for (icon, tip) in [(Icon::Plus, "New layout"), (Icon::Menu, "Layout list")] { let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click()); icons::paint(&p, br.shrink(2.0), icon, false); icons::describe_control(ui, &resp, tip, None, false); - if resp.on_hover_text(crate::i18n::t(tip)).clicked() && icon == Icon::Plus { - let _ = app.run("layout.new", json!({})); + let resp = resp.on_hover_text(crate::i18n::t(tip)); + if icon == Icon::Plus { + if resp.clicked() { + let _ = app.run("layout.new", json!({})); + } + } else { + list_button = Some(resp); } x += 22.0; } x = x.max(r.left() + if app.ui.show_toolsets { 232.0 } else { 60.0 }); - // Layout tabs. - let mut tabs: Vec = vec!["Model".into()]; - let cur = app.session.state().map(|s| match &s.space { - cadcraft_doc::Space::Model => "Model".to_string(), - cadcraft_doc::Space::Paper(n) => n.clone(), - }); - if let Ok(st) = app.session.state() { - let mut ls: Vec<_> = st.doc.layouts.iter().collect(); - ls.sort_by_key(|l| l.tab_order); - tabs.extend(ls.iter().map(|l| l.name.clone())); - } - let mut switch = None; - for (i, name) in tabs.iter().enumerate() { - let g = p.layout_no_wrap(if i == 0 { crate::tl!("Model").to_string() } else { name.clone() }, crate::theme::body(), t.text); - let w = g.size().x + 26.0; - let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)); - let active = cur.as_deref().is_ok_and(|c| c == name); - let resp = ui.interact(tr, ui.id().with(("lt", i)), Sense::click()); - if active { - p.rect_filled(tr, 2.0, t.tab_active); - } else if resp.hovered() { - p.rect_filled(tr, 2.0, t.control.gamma_multiply(0.5)); - } - p.galley(pos2(tr.left() + 13.0, tr.center().y - g.size().y / 2.0), g, if active { t.text } else { t.text_dim }); - icons::describe_control(ui, &resp, name, Some(active), true); - if resp.clicked() { - switch = Some(name.clone()); - } - crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::layout_tab_entries(app, name)); - x += w + 2.0; - if i == 0 { - let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); - let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click()); - icons::paint(&p, pr.shrink(2.0), Icon::Plus, false); - icons::describe_control(ui, &presp, "New layout", None, false); - if presp.on_hover_text(crate::tl!("New layout")).clicked() - && let Ok(v) = app.run("layout.new", json!({})) - && let Some(n) = v.get("name").and_then(serde_json::Value::as_str) - { - switch = Some(n.to_string()); - } - x += 22.0; - } - } - if let Some(n) = switch { - let _ = app.run("layout.set", json!({ "name": n })); - } // Right side: toggles, right-aligned. let s = app.session.settings.clone(); let anno_all = app.session.doc().is_ok_and(|d| d.anno_all_visible()); @@ -397,6 +377,98 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { // Scale and lock of the active or selected viewport. coord_right = crate::vpscale::status(app, ui, coord_right + 4.0, r.center().y) - 10.0; } + // Layout tabs: Model, "+", then the layouts that fit before the right-hand controls (the + // current one always); the layout list reaches the rest. + let mut tabs: Vec = vec!["Model".into()]; + let cur = app.session.state().map(|s| match &s.space { + cadcraft_doc::Space::Model => "Model".to_string(), + cadcraft_doc::Space::Paper(n) => n.clone(), + }); + if let Ok(st) = app.session.state() { + let mut ls: Vec<_> = st.doc.layouts.iter().collect(); + ls.sort_by_key(|l| l.tab_order); + tabs.extend(ls.iter().map(|l| l.name.clone())); + } + let shown_name = |i: usize, name: &str| if i == 0 { crate::tl!("Model").to_string() } else { name.to_string() }; + let limit = (coord_right - STATUS_READOUT_MIN).max(x); + let labels: Vec<_> = + tabs.iter().enumerate().map(|(i, n)| elided(&p, &shown_name(i, n), crate::theme::body(), t.text, LAYOUT_TAB_TEXT)).collect(); + let widths: Vec = labels.iter().map(|(g, _)| g.size().x + 26.0 + 2.0).collect(); + let active_tab = tabs.iter().position(|n| cur.as_deref().is_ok_and(|c| c == n)); + // Model and "+" stay; the layouts share what is left. + let layouts = widths.get(1..).unwrap_or_default(); + let room = limit - x - widths.first().copied().unwrap_or(0.0) - 22.0; + let active_layout = active_tab.and_then(|a| a.checked_sub(1)); + let mut shown = visible_tabs(layouts, active_layout, room); + let hidden = shown.len() < layouts.len(); + if hidden { + shown = visible_tabs(layouts, active_layout, room - 22.0); + } + let clip = Rect::from_min_max(r.min, pos2(limit.max(r.left()), r.bottom())); + let tp = p.with_clip_rect(clip); + let mut switch = None; + for (i, (name, (g, cut))) in tabs.iter().zip(labels).enumerate() { + if i > 0 && !shown.contains(&(i - 1)) { + continue; + } + let w = g.size().x + 26.0; + let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)).intersect(clip); + let active = active_tab == Some(i); + let resp = ui.interact(tr, ui.id().with(("lt", i)), Sense::click()); + if active { + tp.rect_filled(tr, 2.0, t.tab_active); + } else if resp.hovered() { + tp.rect_filled(tr, 2.0, t.control.gamma_multiply(0.5)); + } + tp.galley(pos2(tr.left() + 13.0, tr.center().y - g.size().y / 2.0), g, if active { t.text } else { t.text_dim }); + icons::describe_control(ui, &resp, name, Some(active), true); + let resp = if cut { resp.on_hover_text(shown_name(i, name)) } else { resp }; + if resp.clicked() { + switch = Some(name.clone()); + } + crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::layout_tab_entries(app, name)); + x += w + 2.0; + if i == 0 { + let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); + let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click()); + icons::paint(&p, pr.shrink(2.0), Icon::Plus, false); + icons::describe_control(ui, &presp, "New layout", None, false); + if presp.on_hover_text(crate::tl!("New layout")).clicked() + && let Ok(v) = app.run("layout.new", json!({})) + && let Some(n) = v.get("name").and_then(serde_json::Value::as_str) + { + switch = Some(n.to_string()); + } + x += 22.0; + } + } + // The layouts that didn't fit, behind a button after the last tab shown. + let more = hidden.then(|| { + let mr = Rect::from_min_size(pos2(x + 2.0, r.top() + 5.0), vec2(16.0, 16.0)); + let resp = ui.interact(mr, ui.id().with("layout_more"), Sense::click()); + if resp.hovered() { + p.rect_filled(mr, 3.0, t.control_hover.gamma_multiply(0.5)); + } + icons::paint(&p, mr.shrink(2.0), Icon::ChevronDown, false); + icons::describe_control(ui, &resp, "Layout list", None, false); + x += 22.0; + resp.on_hover_text(crate::tl!("Layout list")) + }); + for resp in list_button.iter().chain(&more) { + egui::Popup::from_toggle_button_response(resp).width(200.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (i, name) in tabs.iter().enumerate() { + if ui.add(egui::Button::selectable(active_tab == Some(i), shown_name(i, name)).truncate()).clicked() { + switch = Some(name.clone()); + ui.close(); + } + } + }); + }); + } + if let Some(n) = switch { + let _ = app.run("layout.set", json!({ "name": n })); + } // A transient message occupies the same bounded readout slot as coordinates. // Ellipsis and a tooltip keep either value readable without covering layout tabs. let message = app.status.as_ref().filter(|(_, at)| crate::now_ms() - at < 5000.0).map(|(message, _)| message); @@ -518,4 +590,21 @@ mod tests { assert_eq!(title_left(100.0, 1000.0, 200.0), 500.0); assert_eq!(title_left(100.0, 900.0, 800.0), 100.0 + MAC_CONTENT_LEFT); } + + #[test] + fn layout_tabs_that_do_not_fit_keep_the_current_one() { + let w = [50.0; 6]; + // All fit, with or without a current tab. + assert_eq!(visible_tabs(&w, None, 300.0), 0..6); + assert_eq!(visible_tabs(&w, Some(5), 300.0), 0..6); + // Too many: the first ones, unless the current one is further on. + assert_eq!(visible_tabs(&w, None, 170.0), 0..3); + assert_eq!(visible_tabs(&w, Some(1), 170.0), 0..3); + assert_eq!(visible_tabs(&w, Some(4), 170.0), 2..5); + // No room at all: nothing but the current tab; hostile numbers don't panic. + assert_eq!(visible_tabs(&w, None, -10.0), 0..0); + assert_eq!(visible_tabs(&w, Some(3), 0.0), 3..4); + assert_eq!(visible_tabs(&w, Some(9), f32::NAN), 0..0); + assert_eq!(visible_tabs(&[], Some(0), 100.0), 0..0); + } } diff --git a/crates/ui-egui/tests/chrome_geometry.rs b/crates/ui-egui/tests/chrome_geometry.rs index 4aee494..280fc5a 100644 --- a/crates/ui-egui/tests/chrome_geometry.rs +++ b/crates/ui-egui/tests/chrome_geometry.rs @@ -61,7 +61,11 @@ fn status_labels_never_overlap_at_standard_and_compact_sizes() { visible_text(&shape.shape, shape.clip_rect, &mut texts); } texts.retain(|(_, rect)| rect.center().y > height - 26.0); - assert!(texts.iter().any(|(text, _)| text == "Layout2")); + // The current tab is always drawn; the others move to the layout list when the bar is narrow. + assert!(texts.iter().any(|(text, _)| text == "Model")); + if width >= 1200.0 { + assert!(texts.iter().any(|(text, _)| text == "Layout2")); + } for (i, (first, first_rect)) in texts.iter().enumerate() { for (second, second_rect) in texts.iter().skip(i + 1) { if first_rect.intersects(*second_rect) {