Help menu (? on the application bar): About SolveCraft (version, commit, build date, ATTRIBUTION.md, fonts, licences), Keyboard Shortcuts read only, Report an Issue copies diagnostics (version, OS, GPU); ui.help control method

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 12:09:31 +00:00
1 parent 5096bf2442
commit bfa70f005b
8 files changed
+225 -1

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
//! Build information for Help › About: the git commit and the build date (UTC). Both fall back to
//! "unknown" when git is missing (a source tarball).
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()).filter(|s| !s.is_empty())
}
/// Days since 1970-01-01 to a civil date (proleptic Gregorian).
fn civil(days: i64) -> (i64, i64, i64) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(yoe + era * 400 + i64::from(m <= 2), m, d)
}
fn main() {
let commit = git(&["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".into());
let dirty = git(&["status", "--porcelain", "--untracked-files=no"]).is_some();
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
let (y, m, d) = civil(secs.div_euclid(86_400));
println!("cargo:rustc-env=SOLVECRAFT_COMMIT={commit}{}", if dirty { "+" } else { "" });
println!("cargo:rustc-env=SOLVECRAFT_BUILD_DATE={y:04}-{m:02}-{d:02}");
// A new commit in this checkout (or worktree) restamps the build.
if let Some(dir) = git(&["rev-parse", "--git-dir"]) {
println!("cargo:rerun-if-changed={dir}/logs/HEAD");
}
println!("cargo:rerun-if-changed=build.rs");
}
+12
View File
@@ -23,6 +23,7 @@
//! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do
//! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists)
//! - `ui.shortcut {command, key?, replace?}`: a command's key, or bind one ("" clears)
//! - `ui.help {item?: about|shortcuts|report}`: the Help menu; returns the version and diagnostics
//! - `ui.resize {width, height}`, `app.quit`
use std::sync::mpsc::Sender;
@@ -375,6 +376,17 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})),
}
}
"ui.help" => match s("item") {
Some(i @ ("about" | "shortcuts" | "report")) => {
crate::help::run(app, ctx, i);
ok(
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
)
}
_ => ok(
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
),
},
"ui.menuPick" => {
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
wrap(crate::context_menu::pick(app, key))
+143
View File
@@ -0,0 +1,143 @@
//! The Help menu (the ? button on the application bar): About SolveCraft (version, commit, build
//! date, licences, attributions, fonts), the keyboard shortcuts (read only), and Report an Issue,
//! which copies diagnostic information to the clipboard.
use egui::{RichText, pos2, vec2};
use crate::SolveApp;
use crate::theme::Tokens;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const COMMIT: &str = env!("SOLVECRAFT_COMMIT");
pub const BUILD_DATE: &str = env!("SOLVECRAFT_BUILD_DATE");
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
/// Fonts: what this build carries.
pub const FONTS: &str = "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and \
the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans \
(SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans \
CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolveCraft builds.";
#[derive(Default)]
pub struct HelpState {
pub menu: bool,
pub about: bool,
/// The GPU the window renders with (set by the host).
pub gpu: Option<String>,
}
/// One line per fact, for bug reports.
pub fn diagnostics(app: &SolveApp) -> String {
let renderer = if app.viewport.gpu.is_some() { "GPU" } else { "CPU" };
format!(
"SolveCraft {VERSION} ({COMMIT}, built {BUILD_DATE})\nOS: {} {}\nRenderer: {renderer}{}\nWindow: {}\nDesign: {} ({} features, {} bodies)",
std::env::consts::OS,
std::env::consts::ARCH,
app.help.gpu.as_ref().map(|g| format!(", {g}")).unwrap_or_default(),
if app.custom_titlebar { "custom title bar" } else { "system title bar" },
app.session.doc.name,
app.session.doc.features.len(),
app.session.model.state().bodies.len(),
)
}
/// Run a Help menu item: `about`, `shortcuts`, `report`.
pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
app.help.menu = false;
match item {
"about" => app.help.about = true,
"shortcuts" => {
app.keymap.open = true;
app.keymap.read_only = true;
}
"report" => {
ctx.copy_text(diagnostics(app));
app.set_status("Diagnostic information copied: paste it into your issue report", false);
}
_ => {}
}
}
/// The Help menu under the ? button, and the About window.
pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
if app.help.menu {
let mut pick: Option<&str> = None;
let resp = egui::Area::new(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
crate::context_menu::menu_frame(ui, |ui| {
for (id, label) in [("about", "About SolveCraft"), ("shortcuts", "Keyboard Shortcuts"), ("report", "Report an Issue…")] {
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
pick = Some(id);
}
}
})
});
if let Some(p) = pick {
run(app, ctx, p);
} else if ctx.input(|i| i.pointer.any_pressed() || i.key_pressed(egui::Key::Escape)) && !resp.response.contains_pointer() {
// Pressed elsewhere (the ? button toggles it itself).
if !ctx.data(|d| d.get_temp::<bool>(egui::Id::new("sc_help_opened")).unwrap_or(false)) {
app.help.menu = false;
}
}
ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_help_opened"), false));
}
about(app, ctx);
}
fn about(app: &mut SolveApp, ctx: &egui::Context) {
if !app.help.about {
return;
}
let t = Tokens::get();
let mut open = true;
let screen = ctx.content_rect();
egui::Window::new("About SolveCraft")
.id(egui::Id::new("sc_about"))
.open(&mut open)
.default_pos(pos2(screen.center().x - 260.0, screen.top() + 90.0))
.default_size(vec2(540.0, 560.0))
.show(ctx, |ui| {
ui.label(RichText::new("SolveCraft").size(22.0).strong());
ui.label(format!("Version {VERSION} · commit {COMMIT} · built {BUILD_DATE}"));
ui.label(RichText::new("Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.").color(t.text_dim));
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("Copy diagnostic information").clicked() {
ctx.copy_text(diagnostics(app));
}
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").default_open(true).show(ui, |ui| {
ui.label(RichText::new(ATTRIBUTION).monospace().size(11.0));
});
egui::CollapsingHeader::new("Fonts").show(ui, |ui| {
ui.label(FONTS);
});
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
});
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
});
});
});
if !open {
app.help.about = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn about_knows_its_build_and_diagnostics_say_where_it_runs() {
assert!(!COMMIT.is_empty() && BUILD_DATE.len() == 10, "{COMMIT} {BUILD_DATE}");
assert!(ATTRIBUTION.contains("ATTRIBUTION") || ATTRIBUTION.contains("Attribution"));
let app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
let d = diagnostics(&app);
assert!(d.contains(VERSION) && d.contains(std::env::consts::OS) && d.contains("Renderer: CPU"), "{d}");
}
}
+7
View File
@@ -192,6 +192,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.poly(&[(4.0, 7.5), (12.0, 12.0), (12.0, 21.0), (4.0, 16.5)], a, ink);
pen.poly(&[(12.0, 12.0), (20.0, 7.5), (20.0, 16.5), (12.0, 21.0)], Color32::TRANSPARENT, ink);
}
// Help: a question mark in a ring.
"help" => {
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
pen.arc(12.0, 9.5, 3.2, -0.6, 3.4, ink);
pen.line(&[(13.9, 12.0), (12.0, 13.6), (12.0, 15.0)], ink);
pen.dot(12.0, 17.8, ink);
}
"sun" => {
pen.circle(12.0, 12.0, 4.5, a, ink);
for i in 0..8 {
+8 -1
View File
@@ -21,6 +21,8 @@ pub struct Keymap {
pub custom: BTreeMap<String, String>,
/// The Keyboard Shortcuts window.
pub open: bool,
/// Opened from Help: a list to read, not to edit.
pub read_only: bool,
filter: String,
/// The command waiting for a key press.
capturing: Option<String>,
@@ -145,7 +147,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut app.keymap.filter).hint_text("Filter commands or keys").desired_width(300.0));
if ui.button("Reset All").clicked() {
if !app.keymap.read_only && ui.button("Reset All").clicked() {
action = Some((String::new(), "reset_all"));
}
});
@@ -182,6 +184,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
} else {
key.clone()
};
if app.keymap.read_only {
ui.label(RichText::new(text).strong());
ui.end_row();
continue;
}
let b =
egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(110.0, 0.0));
if ui.add(b).on_hover_text("Click, then press the new key").clicked() {
+6
View File
@@ -23,6 +23,7 @@ pub mod dialogs_sheet;
pub mod dim_view;
pub mod documents;
pub mod gpu;
pub mod help;
pub mod home;
pub mod icons;
pub mod inference;
@@ -158,6 +159,8 @@ pub struct SolveApp {
pub sbox: shortcut_box::ShortcutBox,
/// Keyboard shortcuts the user changed.
pub keymap: keymap::Keymap,
/// The Help menu and the About window.
pub help: help::HelpState,
pub preview: preview::PreviewState,
/// The last command started interactively (id, label), for Repeat.
pub last_command: Option<(String, String)>,
@@ -211,6 +214,7 @@ impl SolveApp {
tree: browser::TreeState::default(),
sbox: shortcut_box::ShortcutBox::default(),
keymap: keymap::Keymap::default(),
help: help::HelpState::default(),
preview: preview::PreviewState::default(),
last_command: None,
esc_handled: false,
@@ -239,6 +243,8 @@ impl SolveApp {
/// bits and MSAA sample count). Without it the viewport renders on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples));
let info = rs.adapter.get_info();
self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type));
}
/// Preferences kept between runs (JSON), for the host to store.
+11
View File
@@ -86,6 +86,14 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
app.ui.dark = !app.ui.dark;
}
if click(ui, "help", "Help") {
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);
});
}
if file {
app.ui.palette_open = false;
let ctx = ui.ctx().clone();
@@ -113,6 +121,8 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
});
file_menu(app, ui.ctx());
let help_x = ui.ctx().data(|d| d.get_temp::<f32>(egui::Id::new("sc_help_anchor")).unwrap_or(300.0));
crate::help::show(app, &ui.ctx().clone(), pos2(help_x - 14.0, 34.0));
}
fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
@@ -180,6 +190,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
if item(ui, "Keyboard Shortcuts…", "") {
app.keymap.open = true;
app.keymap.read_only = false;
close = true;
}
if item(ui, "Quit", "") {
+1
View File
@@ -47,6 +47,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
| `ui.documents` | `action?`: `new`, `switch`, `close`; `index?`; `force?` (close without asking) | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt |
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) |
| `ui.resize` | `width`, `height` | |
| `app.quit` | — | |