mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Keep the layout tabs inside the status bar
With many layouts or long layout names the tabs ran under the coordinates and the drafting toggles and off the window. The tabs now stop before the right-hand controls, leaving room for the coordinates: Model and "+" stay, then the run of layouts that fits, always holding the current one. A name wider than 150 px is cut with "…" and shows in full on hover. When some layouts don't fit, a button after the last tab lists them all; the "Layout list" button at the left of the bar opens the same list (it did nothing before). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
00c7613f1a
commit
d2fe40b17b
1 file changed
+138
-49
+138
-49
@@ -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<usize>, room: f32) -> std::ops::Range<usize> {
|
||||
let fits = |range: std::ops::Range<usize>| widths.get(range).is_some_and(|w| w.iter().sum::<f32>() <= 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<String> = 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<String> = 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<f32> = 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user