From 786121c21aa0e9d536a7de4ba771761cd9de1348 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:43:10 -0400 Subject: [PATCH] UI: a setup checklist at the top of the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a model has geometry or a mesh, the browser shows the analysis stages from the engine's checklist, each ticked, next, dashed (optional) or open; a click starts the stage's command and a blocked stage's hover says what it waits for. The × hides it and View › Setup checklist shows it again; the choice is kept in the per-user prefs.json. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- apps/fieldcraft-web/src/web.rs | 1 + apps/fieldcraft/src/main.rs | 1 + crates/ui-egui/src/browser.rs | 1 + crates/ui-egui/src/control.rs | 24 ++++++ crates/ui-egui/src/files.rs | 59 +++++++++++++++ crates/ui-egui/src/lib.rs | 32 ++++++++ crates/ui-egui/src/setup.rs | 132 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/viewport.rs | 1 + 8 files changed, 251 insertions(+) create mode 100644 crates/ui-egui/src/setup.rs diff --git a/apps/fieldcraft-web/src/web.rs b/apps/fieldcraft-web/src/web.rs index 4e816a2..8a061fd 100644 --- a/apps/fieldcraft-web/src/web.rs +++ b/apps/fieldcraft-web/src/web.rs @@ -188,6 +188,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))), // The recent list holds the page's files, which go with the page. recent_path: None, + prefs_path: None, } } diff --git a/apps/fieldcraft/src/main.rs b/apps/fieldcraft/src/main.rs index 1a9981a..c07bc7e 100644 --- a/apps/fieldcraft/src/main.rs +++ b/apps/fieldcraft/src/main.rs @@ -95,6 +95,7 @@ fn services() -> Services { })), // Beside the recovery folder in the per-user data folder. recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))), + prefs_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("prefs.json"))), } } diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index d5c8ad0..0b1ff25 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -334,6 +334,7 @@ pub fn browser(app: &mut ModelApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6))) .show(ui, |ui| { app.place("browser", ui.max_rect()); + crate::setup::card(app, ui); ui.label(RichText::new("BROWSER").font(crate::theme::label_font(ui.ctx(), 10.5)).color(t.text_dim)); ui.add_space(4.0); egui::ScrollArea::vertical().id_salt("fc_browser_scroll").auto_shrink([false, false]).show(ui, |ui| { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index be14c18..091e441 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -585,6 +585,30 @@ mod tests { } } + /// The setup checklist shows once there is something to set up, and a click on its next + /// stage opens that stage's form (#74). + #[test] + fn a_setup_stage_click_starts_its_command() { + let mut app = ModelApp::new(Session::default()); + let ctx = egui::Context::default(); + frame(&mut app, &ctx); + assert!(!app.layout.contains_key("setup"), "an empty model shows the Start card instead"); + let cube = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]; + app.run("mesh.block", json!({"body": "B", "kind": "hex8", "corners": cube, "divisions": [1, 1, 1]})).unwrap(); + frame(&mut app, &ctx); + let at = app.layout[crate::setup::region(fieldcraft_engine::progress::Stage::Material)].center(); + call_in(&mut app, &ctx, "ui.click", json!({"x": at.x, "y": at.y})); + for _ in 0..4 { + frame(&mut app, &ctx); + } + assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create")); + // Hidden from the View menu (or `ui.set`), it stays hidden. + assert_eq!(call_in(&mut app, &ctx, "ui.set", json!({"show_setup": false}))["ok"], true); + app.layout.clear(); + frame(&mut app, &ctx); + assert!(!app.layout.contains_key("setup")); + } + /// The cantilever's free end (x = 100), looked at from +x: picked by a simulated click and /// by `ui.select`, made into a set through the set form's Target row, and resolved. #[test] diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 24463c0..1852192 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -42,6 +42,8 @@ pub struct Services { pub pick_save: Option, /// The recent-files list's file (JSON); `None` keeps the list for this run only. pub recent_path: Option, + /// The user's preferences file (JSON, [`Prefs`]); `None` keeps them for this run only. + pub prefs_path: Option, } const MODEL: Filter = Filter { name: "FieldCraft model", extensions: &["fieldcraft"] }; @@ -164,6 +166,47 @@ impl Recent { } } +/// Largest preferences file read. +const MAX_PREFS_BYTES: u64 = 64 * 1024; + +/// What the window remembers for the user between runs. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct Prefs { + /// The setup checklist at the top of the browser ([`crate::setup`]). + pub show_setup: bool, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { show_setup: true } + } +} + +impl Prefs { + /// The preferences kept in `file` (an unreadable or malformed file gives the defaults). + pub fn load(file: Option<&Path>) -> Prefs { + file.filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_PREFS_BYTES)) + .and_then(|f| std::fs::read(f).ok()) + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() + } + + /// Write them to `file`; a failure is logged (the preferences stay for this run). + pub fn save(&self, file: Option<&Path>) { + let Some(file) = file else { return }; + let write = || -> std::io::Result<()> { + if let Some(dir) = file.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(file, serde_json::to_vec_pretty(self).unwrap_or_default()) + }; + if let Err(e) = write() { + log::warn!("preferences {}: {e}", file.display()); + } + } +} + /// A command running in the background on a copy of the session: a file opening, a solve, a /// mesher started from its form. pub struct Loading { @@ -962,6 +1005,22 @@ mod tests { dir } + /// Hiding the setup checklist is remembered in the preferences file; a missing or + /// malformed file gives the defaults. + #[test] + fn hiding_the_setup_checklist_is_remembered() { + let file = temp("prefs").join("prefs.json"); + assert!(Prefs::load(Some(&file)).show_setup); + let services = || Services { prefs_path: Some(file.clone()), ..Default::default() }; + let mut app = ModelApp::new(Session::default()).with_services(services()); + app.ui.show_setup = false; + app.save_prefs(); + let again = ModelApp::new(Session::default()).with_services(services()); + assert!(!again.ui.show_setup); + std::fs::write(&file, b"{nope").unwrap(); + assert_eq!(Prefs::load(Some(&file)), Prefs::default()); + } + /// A picker that answers `path` and records the filters it was offered. fn answering(path: Option, offered: Rc>>>) -> PickOpen { Box::new(move |f: &[Filter]| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 61df60e..5f5a3c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,7 @@ pub mod results; pub mod results_panel; pub mod scene; pub mod selection; +pub mod setup; pub mod theme; pub mod timeline; pub mod titlebar; @@ -86,6 +87,8 @@ pub struct UiState { pub show_tree: bool, /// The journal timeline (bottom). pub show_timeline: bool, + /// The setup checklist at the top of the browser (remembered per user). + pub show_setup: bool, /// The workspace shown, and its toolbar tab. pub workspace: Workspace, pub tab: Tab, @@ -109,6 +112,7 @@ impl Default for UiState { dark: true, show_tree: true, show_timeline: true, + show_setup: true, workspace: Workspace::default(), tab: Tab::default(), palette: false, @@ -342,6 +346,10 @@ pub struct ModelApp { pub services: files::Services, /// Recently opened and saved files. pub recent: files::Recent, + /// The user's preferences as last saved. + prefs: files::Prefs, + /// The setup checklist and what it was worked out for. + setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc>), /// The file opening in the background, and files waiting to open after it. pub loading: Option, pub queued: std::collections::VecDeque, @@ -417,6 +425,8 @@ impl ModelApp { quit_requested: false, services: files::Services::default(), recent: files::Recent::default(), + prefs: files::Prefs::default(), + setup: (None, std::sync::Arc::default()), loading: None, queued: std::collections::VecDeque::new(), prompt: None, @@ -450,6 +460,8 @@ impl ModelApp { /// Use the host's services (file pickers, the recent-files file) and load the recent list. pub fn with_services(mut self, services: files::Services) -> Self { self.recent = files::Recent::load(services.recent_path.clone()); + self.prefs = files::Prefs::load(services.prefs_path.as_deref()); + self.ui.show_setup = self.prefs.show_setup; self.services = services; self } @@ -1243,6 +1255,7 @@ impl ModelApp { browser::browser(self, ui); } context::panel(self, ui); + self.save_prefs(); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| viewport::show(self, ui)); palette::popup(self, ui.ctx()); files::show(self, ui.ctx()); @@ -1251,6 +1264,25 @@ impl ModelApp { } } + /// The setup checklist ([`fieldcraft_engine::progress`]), worked out again only when the model + /// or its results change. + pub fn setup_stages(&mut self) -> std::sync::Arc> { + use fieldcraft_engine::requirement::Requirement; + let key = (self.revision, self.session.revision(), Requirement::Result.met(&self.session), Requirement::ResultsShown.met(&self.session)); + if self.setup.0 != Some(key) { + self.setup = (Some(key), std::sync::Arc::new(self.session.progress())); + } + self.setup.1.clone() + } + + /// Save the preferences the window changed (a View menu toggle, `ui.set`). + pub(crate) fn save_prefs(&mut self) { + if self.ui.show_setup != self.prefs.show_setup { + self.prefs.show_setup = self.ui.show_setup; + self.prefs.save(self.services.prefs_path.as_deref()); + } + } + /// Remember where a region of the window was drawn (`ui.inspect`'s `layout`). pub fn place(&mut self, region: &'static str, rect: egui::Rect) { self.layout.insert(region, rect); diff --git a/crates/ui-egui/src/setup.rs b/crates/ui-egui/src/setup.rs new file mode 100644 index 0000000..a596eba --- /dev/null +++ b/crates/ui-egui/src/setup.rs @@ -0,0 +1,132 @@ +//! The setup checklist at the top of the browser: the analysis stages from geometry to results +//! ([`fieldcraft_engine::progress`]), each ticked when done, the next one highlighted, and +//! optional ones dashed. A click on a stage starts its command (as its toolbar button would); +//! a blocked stage's hover says what it waits for. It shows once the model has geometry or a +//! mesh (before that the viewport's Start card is the entry), and the × hides it until the +//! View menu shows it again; the choice is remembered per user. + +use egui::{FontId, Pos2, Rect, Sense, Stroke, pos2, vec2}; +use fieldcraft_engine::progress::{Stage, StageState, Status}; + +use crate::ModelApp; +use crate::theme::Tokens; + +const ROW_H: f32 = 20.0; + +/// Where a stage's row was drawn (`ui.inspect`'s `layout`). +pub fn region(stage: Stage) -> &'static str { + match stage { + Stage::Geometry => "setup/geometry", + Stage::Prepare => "setup/prepare", + Stage::Mesh => "setup/mesh", + Stage::Material => "setup/material", + Stage::Section => "setup/section", + Stage::Sets => "setup/sets", + Stage::BoundaryConditions => "setup/boundary_conditions", + Stage::Loads => "setup/loads", + Stage::Step => "setup/step", + Stage::Solve => "setup/solve", + Stage::Results => "setup/results", + } +} + +pub fn card(app: &mut ModelApp, ui: &mut egui::Ui) { + if !app.ui.show_setup { + return; + } + let stages = app.setup_stages(); + if stages.first().is_none_or(|g| g.status != Status::Done) { + return; + } + let t = Tokens::get(app.ui.dark); + let mut start = None; + let frame = egui::Frame::NONE.fill(t.dialog_bg).stroke(Stroke::new(1.0, t.border)).corner_radius(4.0).inner_margin(egui::Margin::same(6)); + let r = frame.show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.horizontal(|ui| { + ui.label(egui::RichText::new("SETUP").font(crate::theme::label_font(ui.ctx(), 10.5)).color(t.text_dim)); + let done = stages.iter().filter(|s| matches!(s.status, Status::Done)).count(); + ui.label(egui::RichText::new(format!("{done} of {}", stages.len())).size(10.5).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let close = ui.add(egui::Button::new(egui::RichText::new("×").size(13.0).color(t.text_dim)).frame(false)); + if close.on_hover_text("Hide the setup checklist (View › Setup checklist shows it again)").clicked() { + app.ui.show_setup = false; + } + }); + }); + ui.add_space(2.0); + for s in stages.iter() { + if row(app, ui, &t, s) { + start = Some(s.command); + } + } + }); + app.place("setup", r.response.rect); + ui.add_space(6.0); + if let Some(command) = start { + // A command that cannot run yet says why in the status bar. + let _ = app.start(command); + } +} + +/// One stage; true when clicked. +fn row(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, s: &StageState) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + app.place(region(s.stage), r); + let next = s.status == Status::Next; + if next { + ui.painter().rect_filled(r, 3.0, t.accent_soft); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let mark = Rect::from_center_size(pos2(r.left() + 10.0, r.center().y), vec2(10.0, 10.0)); + paint_mark(ui.painter(), mark, s.status, t); + let colour = match s.status { + Status::Done | Status::Next => t.text, + Status::Todo | Status::Blocked | Status::Optional => t.text_dim, + }; + let x = r.left() + 22.0; + ui.painter().with_clip_rect(r).text(pos2(x, r.center().y), egui::Align2::LEFT_CENTER, s.label, FontId::proportional(12.5), colour); + let note = match s.status { + Status::Next => Some("next"), + Status::Optional => Some("optional"), + _ => None, + }; + if let Some(n) = note { + let c = if next { t.accent } else { t.text_dim }; + ui.painter().text(pos2(r.right() - 4.0, r.center().y), egui::Align2::RIGHT_CENTER, n, FontId::proportional(11.0), c); + } + let tip = match &s.detail { + Some(why) => format!("{}\n\n{why}", s.place), + None => s.place.clone(), + }; + let resp = resp.on_hover_text(tip); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.label)); + resp.clicked() +} + +/// ✓ done, → next, – optional, an open circle for the rest (struck through when blocked). +fn paint_mark(p: &egui::Painter, r: Rect, status: Status, t: &Tokens) { + let at = |x: f32, y: f32| Pos2::new(r.left() + x * r.width(), r.top() + y * r.height()); + match status { + Status::Done => { + p.line_segment([at(0.05, 0.55), at(0.4, 0.88)], Stroke::new(1.8, t.ok)); + p.line_segment([at(0.4, 0.88), at(0.95, 0.15)], Stroke::new(1.8, t.ok)); + } + Status::Next => { + p.line_segment([at(0.0, 0.5), at(0.95, 0.5)], Stroke::new(1.6, t.accent)); + p.line_segment([at(0.55, 0.15), at(0.95, 0.5)], Stroke::new(1.6, t.accent)); + p.line_segment([at(0.55, 0.85), at(0.95, 0.5)], Stroke::new(1.6, t.accent)); + } + Status::Optional => { + p.line_segment([at(0.15, 0.5), at(0.85, 0.5)], Stroke::new(1.4, t.text_dim)); + } + Status::Todo => { + p.circle_stroke(r.center(), r.width() * 0.4, Stroke::new(1.2, t.text_dim)); + } + Status::Blocked => { + p.circle_stroke(r.center(), r.width() * 0.4, Stroke::new(1.2, t.text_dim)); + p.line_segment([at(0.2, 0.8), at(0.8, 0.2)], Stroke::new(1.2, t.text_dim)); + } + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index b825ed3..d2bcd1e 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -535,6 +535,7 @@ fn nav_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { ui.checkbox(&mut app.ui.show_interactions, "Interactions"); ui.separator(); ui.checkbox(&mut app.ui.show_tree, "Browser"); + ui.checkbox(&mut app.ui.show_setup, "Setup checklist"); ui.checkbox(&mut app.ui.show_timeline, "Timeline"); }); }