Title bar: inactive tabs without outlines, File menu, Help on the right (#1277)

* Title bar: inactive tabs without outlines, File menu, Help on the right

Inactive tabs go back to the plain bar colour (the Home tab's readable
hover stays). The separate Home button and theme toggle are gone: Home is
a tab and the theme is in Preferences. The folder icon becomes a File text
menu, Save moves into it (Ctrl+S unchanged), and Help sits on the right
next to Report a bug. Tabs start right after File, Undo and Redo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Title bar: File button hover uses the app bar hover token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-14 23:18:48 -04:00
1 parent bd8db8ea1c
commit 406293fd53
7 files changed
+117 -71

No files matched your search

+106 -48
View File
@@ -146,10 +146,19 @@ fn menu_commands(
out
}
/// What the right-hand group keeps clear between Report a bug and the window's caption buttons:
/// Help, Preferences and the version.
const RIGHT_GROUP: f32 = 120.0;
/// The centre of the Help (?) button: left of Preferences.
fn help_x(bar: Rect, captions: f32) -> f32 {
bar.right() - captions - 101.0
}
/// The Report a bug button: labelled when there is room for its text (`label_width`, measured in
/// the current language), otherwise just the icon.
fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32) -> (Rect, bool) {
let right = bar.right() - captions - 90.0;
let right = bar.right() - captions - RIGHT_GROUP;
// Icon, text and a margin; never narrower than the English button.
let full = (REPORT_TEXT_X + label_width + 10.0).max(124.0);
let labeled = right - tabs_start >= 216.0 + full;
@@ -184,12 +193,34 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolidCraft", FontId::proportional(14.0), t.app_bar_text);
}
let mut x = left + if compact_actions { 60.0 } else { 128.0 };
// While a file loads the design waits: no menus or commands from the bar.
let idle = !app.file_load.busy();
// The File menu: a text button (New, Open, Save, Save As, Export…). Its handle keeps the
// old `appbar:folder` name too, for scripts written against the folder button.
let file = {
let label = app.tr("ui.file").to_string();
let galley = ui.painter().layout_no_wrap(label.clone(), FontId::proportional(13.0), t.app_bar_text);
let br = Rect::from_min_size(pos2(x - 13.0, r.center().y - 13.0), vec2(galley.size().x + 16.0, 26.0));
x = br.right() + 17.0;
let resp = ui.interact(br, ui.id().with(("ab", "file")), if idle { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, idle, &label));
crate::scenario::publish_handle("appbar:file", br.center());
crate::scenario::publish_handle("appbar:folder", br.center());
let open = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::new("sc_file_menu")).unwrap_or(false));
if idle && (resp.hovered() || open) {
ui.painter().rect_filled(br, 4.0, t.app_bar_hover);
}
let ink = if idle { t.app_bar_text } else { t.app_bar_text.gamma_multiply(0.35) };
ui.painter().galley(pos2(br.left() + 8.0, br.center().y - galley.size().y / 2.0), galley, ink);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu_x"), br.left()));
idle && resp.clicked()
};
// A disabled button is dimmed and ignores clicks (its tooltip still shows).
let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str, enabled: bool| -> bool {
let click = |ui: &mut egui::Ui, at: f32, icon: &str, tip: &str, enabled: bool| -> bool {
let width = if compact_actions { 22.0 } else { 26.0 };
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(width, 26.0));
x += if compact_actions { 22.0 } else { 30.0 };
let br = Rect::from_center_size(pos2(at, r.center().y), vec2(width, 26.0));
let resp = ui.interact(br, ui.id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
if enabled && resp.hovered() {
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
@@ -199,61 +230,53 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let clicked = resp.on_hover_text(tip).clicked();
enabled && clicked
};
// While a file loads the design waits: no menus or commands from the bar.
let idle = !app.file_load.busy();
let file = click(ui, "folder", app.tr("ui.file"), idle);
// Save, Undo and Redo act on the design, which Home hides.
let step = if compact_actions { 22.0 } else { 30.0 };
// Undo and Redo act on the design, which Home hides. Save lives in the File menu (Ctrl+S).
let shown = design_shown(app) && idle;
let tip = |id: &str, key: &str| {
let label = app.tr(id);
if shown { format!("{label} ({key})") } else { crate::i18n::format("ui.needs_design", &[("command", label)]) }
};
let (save_tip, undo_tip, redo_tip) = (tip("ui.save", "Ctrl+S"), tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y"));
if click(ui, "save", &save_tip, shown) {
save(app);
}
if click(ui, "undo", &undo_tip, shown) {
let _ = app.run("edit.undo", json!({}));
}
if click(ui, "redo", &redo_tip, shown) {
let _ = app.run("edit.redo", json!({}));
}
if click(ui, "home", app.tr("ui.home"), idle) {
let rev = app.session.revision;
app.home.show(rev);
}
let theme_tip = if app.ui.dark { app.tr("ui.light") } else { app.tr("ui.dark") };
if !compact_actions && click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip, true) {
app.ui.dark = !app.ui.dark;
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
}
if click(ui, "help", app.tr("ui.help"), true) {
app.help.menu = !app.help.menu;
let at = ui.ctx().input(|i| i.pointer.latest_pos()).map_or(300.0, |p| p.x);
ui.ctx().data_mut(|d| {
d.insert_temp(egui::Id::new("sc_help_opened"), true);
d.insert_temp(egui::Id::new("sc_help_anchor"), at);
});
let (undo_tip, redo_tip) = (tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y"));
// A very narrow window keeps its room for the tabs (Home first); Ctrl+Z and Ctrl+Y still undo and redo.
if !compact_actions {
if click(ui, x, "undo", &undo_tip, shown) {
let _ = app.run("edit.undo", json!({}));
}
x += step;
if click(ui, x, "redo", &redo_tip, shown) {
let _ = app.run("edit.redo", json!({}));
}
x += step;
}
if file {
app.ui.palette_open = false;
let ctx = ui.ctx().clone();
let open = ctx.data(|d| d.get_temp::<bool>(egui::Id::new("sc_file_menu")).unwrap_or(false));
let pass = ctx.cumulative_pass_nr();
// The folder button toggles the menu. Remember the pass it opened on so the same
// The File button toggles the menu. Remember the pass it opened on so the same
// click isn't also treated as a click outside the menu (which would close it at once).
ctx.data_mut(|d| {
d.insert_temp(egui::Id::new("sc_file_menu"), !open);
d.insert_temp(egui::Id::new("sc_file_menu_opened"), pass);
});
}
// Document tabs.
// Document tabs (Home first), on the left after File, Undo and Redo.
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
let tabs_start = x + 16.0;
let tabs_start = x - step + 25.0;
let report_text = crate::i18n::tr("ui.report_a_bug");
let report_font = FontId::proportional(13.0);
let label_width = ui.painter().layout_no_wrap(report_text.to_string(), report_font.clone(), t.app_bar_text).size().x;
let (report, report_label) = report_rect(r, captions, tabs_start, label_width);
// Help, on the right between Report a bug and Preferences.
if click(ui, help_x(r, captions), "help", app.tr("ui.help"), true) {
app.help.menu = !app.help.menu;
let at = help_x(r, captions);
ui.ctx().data_mut(|d| {
d.insert_temp(egui::Id::new("sc_help_opened"), true);
d.insert_temp(egui::Id::new("sc_help_anchor"), at);
});
}
// A newer release: its Update button, left of Report a bug (`update`).
let update = crate::update::button(app, ui, report.left() - 6.0, report.top(), report_label);
let tabs_end = update.map_or(report.left(), |u| u.left()) - 8.0;
@@ -315,7 +338,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
let mut close = false;
let resp = crate::frame::area(egui::Id::new("sc_file_area"))
.fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0))
.fixed_pos(pos2(ctx.data(|d| d.get_temp::<f32>(egui::Id::new("sc_file_menu_x"))).unwrap_or(100.0), 34.0))
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
@@ -1288,10 +1311,11 @@ mod header_report_tests {
let (report, labeled) = report_rect(bar, captions, 354.0, label_width);
assert!(bar.contains_rect(report));
let prefs = Rect::from_center_size(pos2(width - captions - 70.0, 17.0), vec2(24.0, 24.0));
assert!(!report.intersects(prefs));
assert!(report.right() < width - captions - 82.0);
let help = Rect::from_center_size(pos2(help_x(bar, captions), 17.0), vec2(26.0, 26.0));
assert!(!report.intersects(prefs) && !report.intersects(help) && !help.intersects(prefs));
assert!(report.right() < help.left() && help.right() < prefs.left());
let full = (REPORT_TEXT_X + label_width + 10.0).max(124.0);
assert_eq!(labeled, width - captions - 90.0 - 354.0 >= 216.0 + full);
assert_eq!(labeled, width - captions - RIGHT_GROUP - 354.0 >= 216.0 + full);
if labeled {
assert!(REPORT_TEXT_X + label_width < report.width(), "{label_width}: the text overruns its button");
}
@@ -1301,6 +1325,39 @@ mod header_report_tests {
}
}
/// The title bar: the File menu, Undo, Redo and every tab on the left; Help, Report a bug,
/// Preferences and the window's buttons on the right. Home is a tab and the theme a
/// preference, so neither has a button of its own, and Save is in the File menu (Ctrl+S).
#[test]
fn title_bar_keeps_tabs_left_and_help_right() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.custom_titlebar = true;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.call("ui.resize", json!({"width":1400,"height":720}));
h.frames(3);
for gone in ["appbar:home", "appbar:sun", "appbar:moon", "appbar:save"] {
assert!(crate::scenario::handle_at(gone).is_none(), "{gone} is no longer in the title bar");
}
let at = |name: &str| crate::scenario::handle_at(name).unwrap_or_else(|| panic!("{name}")).x;
let [report, _] = h.widget("Report a bug").unwrap();
let file = h.widget("File").expect("the File menu has a label");
assert_eq!(file[0] as f32, at("appbar:file"));
assert!(at("appbar:file") < at("appbar:undo") && at("appbar:undo") < at("appbar:redo"));
assert!(at("appbar:redo") < at("tab:home") && at("tab:home") < at("tab:design:0"));
assert!((at("tab:design:0") as f64) < report && report < at("appbar:help") as f64);
assert!(at("appbar:help") < at("titlebar:preferences"));
h.step(&json!({"click":{"handle":"appbar:help"}})).unwrap();
assert!(h.app.help.menu, "? opens the Help menu");
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"click":{"handle":"tab:home"}})).unwrap();
assert!(h.app.home.open, "the Home tab shows the start page");
let dark = h.app.ui.dark;
h.call("ui.prefs", json!({"theme": if dark { "light" } else { "dark" }}));
h.frames(2);
assert_ne!(h.app.ui.dark, dark, "the theme is a preference");
}
#[test]
fn header_report_button_widens_for_a_longer_translation() {
let width_in = |language: &str| {
@@ -1312,7 +1369,7 @@ mod header_report_tests {
h.frames(3);
let center = crate::scenario::handle_at("titlebar:report").expect("the report button is drawn");
// The button's right edge is fixed; its width is twice the distance to its centre.
2.0 * (1400.0 - crate::titlebar::WIDTH - 90.0 - center.x)
2.0 * (1400.0 - crate::titlebar::WIDTH - RIGHT_GROUP - center.x)
};
let (en, ru) = (width_in("en"), width_in("ru"));
assert!((en - 124.0).abs() < 0.5, "{en}");
@@ -1345,8 +1402,8 @@ mod header_report_tests {
}
h.step(&json!({"widget":"Close draft"})).unwrap();
assert!(h.app.issue_report.draft.is_none(), "width {width}: review closes through its actual button");
h.step(&json!({"click":{"handle":"appbar:home"}})).unwrap();
assert!(h.app.home.open, "width {width}: Home remains reachable beside Report");
h.step(&json!({"click":{"handle":"tab:home"}})).unwrap();
assert!(h.app.home.open, "width {width}: the Home tab remains reachable beside Report");
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision));
}
}
@@ -1392,7 +1449,7 @@ mod header_report_tests {
mod home_tests {
use super::*;
/// Home hides the design: its Save, Undo and Redo buttons, the File menu's Insert, Save
/// Home hides the design: its Undo and Redo buttons, the File menu's Insert, Save
/// and Export, and the design's keyboard shortcuts leave it alone. They work again once
/// the design is back.
#[test]
@@ -1434,7 +1491,7 @@ mod home_tests {
h.call("ui.click", json!({"x": at.x, "y": at.y}));
};
let mut actions: Vec<(String, Box<dyn Fn(&mut crate::scenario::Harness)>)> = Vec::new();
for icon in ["save", "undo", "redo"] {
for icon in ["undo", "redo"] {
actions.push((format!("{icon} button"), Box::new(move |h| click(h, &format!("appbar:{icon}")))));
}
for (key, cmd) in [("S", true), ("Z", true), ("Y", true), ("Delete", false), ("V", false), ("E", false), ("S", false)] {
@@ -1473,8 +1530,9 @@ mod home_tests {
h.call("ui.key", json!({"key": "Z", "cmd": true}));
h.settle();
assert_eq!(h.app.session.redo.len(), 1, "Ctrl+Z works once Home is closed");
let at = crate::scenario::handle_at("appbar:save").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), false));
click(&mut h, "appbar:file");
h.step(&json!({"widget": "Save"})).unwrap();
h.settle();
assert!(h.app.session.path.is_some() && !h.app.session.is_dirty(), "the design saves once Home is closed");
}