diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs new file mode 100644 index 00000000..08d64f23 --- /dev/null +++ b/crates/ui-egui/build.rs @@ -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 { + 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"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8b45271d..1c208131 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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)) diff --git a/crates/ui-egui/src/help.rs b/crates/ui-egui/src/help.rs new file mode 100644 index 00000000..d209b1c4 --- /dev/null +++ b/crates/ui-egui/src/help.rs @@ -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, +} + +/// 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::(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}"); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c2efe59f..b24243cd 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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 { diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 80656bdf..e1cb5972 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -21,6 +21,8 @@ pub struct Keymap { pub custom: BTreeMap, /// 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, @@ -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() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec45cfd5..c7093c52 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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. diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 471abf9e..f1e1701a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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::(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", "") { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 12d95ad5..0e3cbb3d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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` | — | |