Start page and document tabs
Start page (shown on launch without a file, and from the house button in the application bar): New Design, Open, recent designs (kept in the preferences, newest first) and built-in samples, each card with a picture rendered from the design in the background. Samples are scripts of engine commands: the sample plate plus a sheet metal bracket, a hinge assembly with a revolute joint, a plastic enclosure (lip, bosses, rest, snap fit), a bolted flange with a parametric circular pattern and a vent plate with a parametric rectangular pattern (test: all build without a failing feature). Several open designs as tabs in the application bar: each keeps its own session (undo, selection), camera and hidden/shown items; + and Ctrl+N open a new design, opening a file or a sample uses a new tab unless the current design is blank, closing a tab with unsaved changes asks Save / Don't Save / Cancel. Control channel: ui.documents and ui.home (docs/control-protocol.md). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
c8da3b78f3
commit
bdc15d2204
9 files changed
+1003
-12
No files matched your search
@@ -128,6 +128,12 @@ fn main() -> eframe::Result {
|
||||
for f in &files {
|
||||
app.open_path(f);
|
||||
}
|
||||
// No design given: the start page (not when driven over the control channel, whose
|
||||
// clients expect the design view; `ui.home` shows it there).
|
||||
if files.is_empty() && !sample && control_port.is_none() {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -31,3 +31,125 @@ pub fn script() -> Value {
|
||||
{"command": "Extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}},
|
||||
]})
|
||||
}
|
||||
|
||||
/// A built-in sample design: its name, a line about what it shows, and the script that builds it.
|
||||
pub struct Sample {
|
||||
pub name: &'static str,
|
||||
pub about: &'static str,
|
||||
pub script: fn() -> Value,
|
||||
}
|
||||
|
||||
/// The built-in samples (the start page lists them), each built through commands.
|
||||
pub fn samples() -> Vec<Sample> {
|
||||
vec![
|
||||
Sample { name: "Sample Plate", about: "Parametric plate with fillets, holes and a bored boss", script },
|
||||
Sample { name: "Sheet Metal Bracket", about: "Base flange with edge flanges and a hem on a 2 mm rule", script: bracket },
|
||||
Sample { name: "Hinge Assembly", about: "Two components and a revolute joint, driven to 60°", script: hinge },
|
||||
Sample { name: "Plastic Enclosure", about: "Shelled box with a lip, screw bosses, a rest and a snap fit", script: enclosure },
|
||||
Sample { name: "Bolted Flange", about: "Hub and flange with a circular pattern driven by a parameter", script: flange },
|
||||
Sample { name: "Vent Plate", about: "Slots in a rectangular pattern: rows, columns and pitch are parameters", script: vents },
|
||||
]
|
||||
}
|
||||
|
||||
fn bracket() -> Value {
|
||||
json!({"commands": [
|
||||
{"command": "FusionSheetMetalRulesCommand", "params": {"name": "Steel 2mm", "thickness": "2 mm", "bend_radius": "Thickness", "active": true}},
|
||||
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Base"}},
|
||||
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [120, 60]}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "FusionSheetMetalFlangeCommand", "params": {"sketch": "Base"}},
|
||||
{"command": "FusionSheetMetalFlangeCommand", "params": {"edges": [[60, 0, 2], [60, 60, 2]], "height": 30}},
|
||||
{"command": "FusionSheetMetalFlangeCommand", "params": {"edges": [[120, 30, 2]], "height": 20, "angle": "60 deg"}},
|
||||
{"command": "FusionSheetMetalHemFlangeCommand", "params": {"edges": [[0, 30, 2]], "length": 8}},
|
||||
]})
|
||||
}
|
||||
|
||||
fn hinge() -> Value {
|
||||
let mut cmds = Vec::new();
|
||||
for name in ["Leaf A", "Leaf B"] {
|
||||
cmds.extend([
|
||||
json!({"command": "component.activate", "params": {"component": "root"}}),
|
||||
json!({"command": "FusionCreateNewComponentCommand", "params": {"name": name}}),
|
||||
json!({"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}}),
|
||||
json!({"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}}),
|
||||
]);
|
||||
}
|
||||
cmds.extend([
|
||||
json!({"command": "component.activate", "params": {"component": "root"}}),
|
||||
json!({"command": "occurrence.ground", "params": {"occurrence": "Leaf A:1", "grounded": true}}),
|
||||
json!({"command": "JointAssembleCmdNew", "params": {"type": "revolute", "name": "Hinge",
|
||||
"a": {"occurrence": "Leaf A:1", "circle": [1, 37, 4]}, "b": {"occurrence": "Leaf B:1", "circle": [1, 37, 4]},
|
||||
"limits": [["0 deg", "180 deg"]]}}),
|
||||
json!({"command": "FusionMoveJointsCommand", "params": {"joint": "Hinge", "value": 60}}),
|
||||
]);
|
||||
json!({ "commands": cmds })
|
||||
}
|
||||
|
||||
fn enclosure() -> Value {
|
||||
json!({"commands": [
|
||||
{"command": "PrimitiveBox", "params": {"length": 80, "width": 50, "height": 30, "body_name": "Enclosure"}},
|
||||
{"command": "FusionShellBodyCommand", "params": {"faces": [[40, 25, 30]], "thickness": 2}},
|
||||
{"command": "FusionLipCommand", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
|
||||
{"command": "FusionSnapFitCommand", "params": {"position": [40, 44, 2], "hook": [0, -1, 0], "length": 14, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
|
||||
{"command": "FusionBossCommand", "params": {"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}},
|
||||
{"command": "FusionBossCommand", "params": {"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}},
|
||||
{"command": "FusionRestCommand", "params": {"position": [40, 25, 2], "width": 16, "length": 24, "height": 3}},
|
||||
]})
|
||||
}
|
||||
|
||||
fn flange() -> Value {
|
||||
json!({"commands": [
|
||||
{"command": "ChangeParameterCommand", "params": {"name": "bolts", "expression": "6", "unit": "", "comment": "number of bolt holes"}},
|
||||
{"command": "ChangeParameterCommand", "params": {"name": "pcd", "expression": "70 mm", "comment": "bolt circle diameter"}},
|
||||
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Disc"}},
|
||||
{"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 100}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": 10, "body_name": "Flange", "name": "Disc"}},
|
||||
{"command": "SketchCreate", "params": {"plane": {"face": [0, 0, 10]}, "name": "Hub"}},
|
||||
{"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 44}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": 16, "operation": "join", "name": "Hub"}},
|
||||
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Bore"}},
|
||||
{"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 24}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": 26, "operation": "cut", "name": "Bore"}},
|
||||
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Bolt"}},
|
||||
{"command": "CircleCenterRadius", "params": {"center": [35, 0], "diameter": 9}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": 10, "operation": "cut", "name": "BoltHole"}},
|
||||
{"command": "PatternCircular", "params": {"features": ["BoltHole"], "axis": "Z", "count": "bolts"}},
|
||||
]})
|
||||
}
|
||||
|
||||
fn vents() -> Value {
|
||||
json!({"commands": [
|
||||
{"command": "ChangeParameterCommand", "params": {"name": "cols", "expression": "6", "unit": ""}},
|
||||
{"command": "ChangeParameterCommand", "params": {"name": "rows", "expression": "3", "unit": ""}},
|
||||
{"command": "ChangeParameterCommand", "params": {"name": "pitch", "expression": "16 mm", "comment": "slot spacing"}},
|
||||
{"command": "PrimitiveBox", "params": {"length": 120, "width": 80, "height": 4, "body_name": "Plate"}},
|
||||
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 2], [120, 0, 2], [120, 80, 2], [0, 80, 2]], "radius": "8 mm"}},
|
||||
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Slot"}},
|
||||
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [18, 12], "p1": [24, 26]}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": 4, "operation": "cut", "name": "Vent"}},
|
||||
{"command": "PatternRectangular", "params": {"features": ["Vent"], "dir1": [1, 0, 0], "count1": "cols", "spacing1": "pitch",
|
||||
"dir2": [0, 1, 0], "count2": "rows", "spacing2": "22 mm"}},
|
||||
]})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Every built-in sample builds without a failing feature or joint.
|
||||
#[test]
|
||||
fn samples_build_cleanly() {
|
||||
for s in super::samples() {
|
||||
let mut session = crate::Session::default();
|
||||
if let Err(e) = session.run_script(&(s.script)()) {
|
||||
panic!("{}: {e}", s.name);
|
||||
}
|
||||
let failed: Vec<String> = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!("{}: {e}", r.name))).collect();
|
||||
assert!(failed.is_empty(), "{}: {failed:?}", s.name);
|
||||
assert!(!session.model.state().bodies.is_empty(), "{}", s.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,24 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"frame_ms": app.frame_ms,
|
||||
"build_ms": app.viewport.build_ms,
|
||||
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
|
||||
"home": app.home.open,
|
||||
"documents": app.docs.count(),
|
||||
})),
|
||||
"ui.documents" => ok(crate::documents::control(app, p)),
|
||||
"ui.home" => {
|
||||
match p.get("open").and_then(Value::as_bool) {
|
||||
Some(true) => {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
Some(false) => app.home.open = false,
|
||||
None => {}
|
||||
}
|
||||
if let Some(i) = p.get("sample").and_then(Value::as_u64) {
|
||||
crate::home::open_sample(app, i as usize);
|
||||
}
|
||||
ok(json!({"open": app.home.open, "recent": app.home.recent}))
|
||||
}
|
||||
"ui.set" => {
|
||||
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
|
||||
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Several open designs, as tabs in the application bar. The active design lives in the app as
|
||||
//! always (`app.session`, `app.cam`…); the others wait here with their own session (undo,
|
||||
//! selection), camera and per-design view state, and are swapped in when their tab is picked.
|
||||
//! Closing a design with unsaved changes asks first.
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::Camera;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Most designs open at once.
|
||||
pub const MAX_OPEN: usize = 32;
|
||||
|
||||
/// A design that is not the active one: everything that belongs to it alone.
|
||||
pub struct DocSlot {
|
||||
session: Session,
|
||||
cam: Camera,
|
||||
pre_sketch_cam: Option<Camera>,
|
||||
pre_sketch_marker: Option<Option<usize>>,
|
||||
view: DocView,
|
||||
}
|
||||
|
||||
/// The per-design part of the UI state (what is hidden, shown or locked).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct DocView {
|
||||
hidden_bodies: Vec<String>,
|
||||
hidden_origin: Vec<String>,
|
||||
hidden_sketches: Vec<u64>,
|
||||
shown_sketches: Vec<u64>,
|
||||
hidden_profiles: Vec<u64>,
|
||||
shown_dims: Vec<u64>,
|
||||
locked_bodies: Vec<String>,
|
||||
}
|
||||
|
||||
impl DocSlot {
|
||||
fn new(session: Session) -> DocSlot {
|
||||
DocSlot { session, cam: Camera::default(), pre_sketch_cam: None, pre_sketch_marker: None, view: DocView::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The open designs: one slot per tab; the active tab's slot is empty (its design is in the app).
|
||||
#[derive(Default)]
|
||||
pub struct Documents {
|
||||
slots: Vec<Option<DocSlot>>,
|
||||
pub active: usize,
|
||||
/// A tab waiting for the answer to "save changes?".
|
||||
pub closing: Option<usize>,
|
||||
}
|
||||
|
||||
impl Documents {
|
||||
/// Number of open designs (at least the active one).
|
||||
pub fn count(&self) -> usize {
|
||||
self.slots.len().max(1)
|
||||
}
|
||||
}
|
||||
|
||||
fn take_view(app: &mut SolveApp) -> DocView {
|
||||
let u = &mut app.ui;
|
||||
DocView {
|
||||
hidden_bodies: std::mem::take(&mut u.hidden_bodies),
|
||||
hidden_origin: std::mem::take(&mut u.hidden_origin),
|
||||
hidden_sketches: std::mem::take(&mut u.hidden_sketches),
|
||||
shown_sketches: std::mem::take(&mut u.shown_sketches),
|
||||
hidden_profiles: std::mem::take(&mut u.hidden_profiles),
|
||||
shown_dims: std::mem::take(&mut u.shown_dims),
|
||||
locked_bodies: std::mem::take(&mut u.locked_bodies),
|
||||
}
|
||||
}
|
||||
|
||||
fn put_view(app: &mut SolveApp, v: DocView) {
|
||||
let u = &mut app.ui;
|
||||
u.hidden_bodies = v.hidden_bodies;
|
||||
u.hidden_origin = v.hidden_origin;
|
||||
u.hidden_sketches = v.hidden_sketches;
|
||||
u.shown_sketches = v.shown_sketches;
|
||||
u.hidden_profiles = v.hidden_profiles;
|
||||
u.shown_dims = v.shown_dims;
|
||||
u.locked_bodies = v.locked_bodies;
|
||||
}
|
||||
|
||||
/// Put `slot` into the app and return what was there.
|
||||
fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
// Whatever was going on in the design leaves with it.
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.params = None;
|
||||
app.cam_anim = None;
|
||||
app.preview = crate::preview::PreviewState::default();
|
||||
app.viewport.hover = None;
|
||||
let out_rev = app.session.revision;
|
||||
let out = DocSlot {
|
||||
session: std::mem::replace(&mut app.session, slot.session),
|
||||
cam: std::mem::replace(&mut app.cam, slot.cam),
|
||||
pre_sketch_cam: std::mem::replace(&mut app.pre_sketch_cam, slot.pre_sketch_cam),
|
||||
pre_sketch_marker: std::mem::replace(&mut app.pre_sketch_marker, slot.pre_sketch_marker),
|
||||
view: take_view(app),
|
||||
};
|
||||
put_view(app, slot.view);
|
||||
// Views cache by revision: the incoming design must not look unchanged.
|
||||
app.session.revision = app.session.revision.max(out_rev) + 1;
|
||||
app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH".into() } else { "SOLID".into() };
|
||||
out
|
||||
}
|
||||
|
||||
/// Make tab `i` the active design.
|
||||
pub fn switch(app: &mut SolveApp, i: usize) {
|
||||
if i == app.docs.active || i >= app.docs.slots.len() {
|
||||
return;
|
||||
}
|
||||
let Some(incoming) = app.docs.slots.get_mut(i).and_then(Option::take) else { return };
|
||||
let out = swap_in(app, incoming);
|
||||
let a = app.docs.active;
|
||||
if let Some(s) = app.docs.slots.get_mut(a) {
|
||||
*s = Some(out);
|
||||
}
|
||||
app.docs.active = i;
|
||||
}
|
||||
|
||||
/// Open `session` in a new tab after the active one and switch to it (`fit`: frame its model).
|
||||
pub fn open_tab(app: &mut SolveApp, session: Session) -> Result<(), String> {
|
||||
if app.docs.slots.is_empty() {
|
||||
app.docs.slots.push(None);
|
||||
app.docs.active = 0;
|
||||
}
|
||||
if app.docs.slots.len() >= MAX_OPEN {
|
||||
return Err(format!("at most {MAX_OPEN} designs can be open"));
|
||||
}
|
||||
let at = app.docs.active + 1;
|
||||
app.docs.slots.insert(at, Some(DocSlot::new(session)));
|
||||
switch(app, at);
|
||||
app.fit_view();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is the active design an untouched new one (it can be replaced instead of opening a tab)?
|
||||
pub fn active_is_blank(app: &SolveApp) -> bool {
|
||||
let s = &app.session;
|
||||
s.path.is_none() && s.doc.features.is_empty() && s.doc.params.is_empty() && s.doc.components.is_empty() && !s.is_dirty()
|
||||
}
|
||||
|
||||
/// Show `session` (a design built or read elsewhere): in place of a blank active design,
|
||||
/// otherwise in a new tab.
|
||||
pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
if active_is_blank(app) {
|
||||
let rev = app.session.revision;
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.session = session;
|
||||
app.session.revision = app.session.revision.max(rev) + 1;
|
||||
app.cam = Camera::default();
|
||||
app.fit_view();
|
||||
} else if let Err(e) = open_tab(app, session) {
|
||||
app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new, empty design in its own tab.
|
||||
pub fn new_design(app: &mut SolveApp) {
|
||||
app.home.open = false;
|
||||
if active_is_blank(app) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = open_tab(app, Session::default()) {
|
||||
app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Before opening a file: a fresh tab unless the active design is blank.
|
||||
pub fn prepare_open(app: &mut SolveApp) {
|
||||
if !active_is_blank(app)
|
||||
&& let Err(e) = open_tab(app, Session::default())
|
||||
{
|
||||
app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close tab `i`: asks first when it has unsaved changes (`force`: close anyway).
|
||||
pub fn close(app: &mut SolveApp, i: usize, force: bool) {
|
||||
let n = app.docs.count();
|
||||
if i >= n {
|
||||
return;
|
||||
}
|
||||
if !force && dirty(app, i) {
|
||||
app.docs.closing = Some(i);
|
||||
return;
|
||||
}
|
||||
app.docs.closing = None;
|
||||
if n <= 1 {
|
||||
// The last design: a blank one takes its place.
|
||||
let rev = app.session.revision;
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.session = Session::default();
|
||||
app.session.revision = rev + 1;
|
||||
app.cam = Camera::default();
|
||||
app.fit_view();
|
||||
put_view(app, DocView::default());
|
||||
app.docs = Documents::default();
|
||||
app.home.open = true;
|
||||
return;
|
||||
}
|
||||
if i == app.docs.active {
|
||||
// Show a neighbour first, then drop this one.
|
||||
let next = if i + 1 < n { i + 1 } else { i - 1 };
|
||||
switch(app, next);
|
||||
}
|
||||
app.docs.slots.remove(i);
|
||||
if app.docs.active > i {
|
||||
app.docs.active -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn dirty(app: &SolveApp, i: usize) -> bool {
|
||||
if i == app.docs.active || app.docs.slots.is_empty() {
|
||||
return app.session.is_dirty();
|
||||
}
|
||||
app.docs.slots.get(i).and_then(Option::as_ref).is_some_and(|s| s.session.is_dirty())
|
||||
}
|
||||
|
||||
/// Tab names (with an unsaved-changes flag), in order.
|
||||
pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
|
||||
if app.docs.slots.is_empty() {
|
||||
return vec![(app.session.doc.name.clone(), app.session.is_dirty())];
|
||||
}
|
||||
app.docs
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
Some(d) => (d.session.doc.name.clone(), d.session.is_dirty()),
|
||||
None => (app.session.doc.name.clone(), app.session.is_dirty()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force`
|
||||
/// closes without asking).
|
||||
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
|
||||
match p.get("action").and_then(Value::as_str) {
|
||||
Some("new") => new_design(app),
|
||||
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
|
||||
Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)),
|
||||
_ => {}
|
||||
}
|
||||
let tabs: Vec<Value> = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect();
|
||||
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing})
|
||||
}
|
||||
|
||||
/// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close,
|
||||
/// + for a new design.
|
||||
pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
let t = Tokens::get();
|
||||
let info = tabs_info(app);
|
||||
let n = info.len();
|
||||
let avail = (x1 - x0 - 34.0).max(80.0);
|
||||
let w = (avail / n as f32).clamp(90.0, 220.0);
|
||||
let mut x = x0;
|
||||
let mut pick: Option<usize> = None;
|
||||
let mut shut: Option<usize> = None;
|
||||
for (i, (name, dirty)) in info.iter().enumerate() {
|
||||
let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0));
|
||||
let active = i == app.docs.active || app.docs.slots.is_empty();
|
||||
let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag());
|
||||
// The tabs are part of the title bar: dragging one moves the window, a double click
|
||||
// maximizes it.
|
||||
if app.custom_titlebar || app.integrated_titlebar {
|
||||
if resp.drag_started_by(egui::PointerButton::Primary) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
} else if resp.double_clicked() {
|
||||
crate::titlebar::toggle_maximize(ui.ctx());
|
||||
}
|
||||
}
|
||||
let fill = if active {
|
||||
t.toolbar
|
||||
} else if resp.hovered() {
|
||||
Color32::from_white_alpha(28)
|
||||
} else {
|
||||
Color32::from_white_alpha(10)
|
||||
};
|
||||
ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill);
|
||||
let ink = if active { t.text } else { t.app_bar_text };
|
||||
let label = format!("{name}{}", if *dirty { " •" } else { "" });
|
||||
let g = ui.painter().layout_no_wrap(label, FontId::proportional(13.0), ink);
|
||||
let text_w = (tr.width() - 30.0).max(10.0);
|
||||
let clip = Rect::from_min_size(tr.min, vec2(text_w + 10.0, tr.height()));
|
||||
ui.painter().with_clip_rect(clip).galley(pos2(tr.left() + 10.0, tr.center().y - g.size().y / 2.0), g, ink);
|
||||
// ×
|
||||
let xr = Rect::from_center_size(pos2(tr.right() - 13.0, tr.center().y), vec2(16.0, 16.0));
|
||||
let xresp = ui.interact(xr, ui.id().with(("doc_close", i)), Sense::click());
|
||||
if xresp.hovered() {
|
||||
ui.painter().rect_filled(xr, 3.0, Color32::from_white_alpha(40));
|
||||
}
|
||||
let c = xr.center();
|
||||
let s = Stroke::new(1.3, if active { t.text_dim } else { t.app_bar_text });
|
||||
ui.painter().line_segment([c - vec2(3.5, 3.5), c + vec2(3.5, 3.5)], s);
|
||||
ui.painter().line_segment([c - vec2(3.5, -3.5), c + vec2(3.5, -3.5)], s);
|
||||
if xresp.on_hover_text("Close").clicked() {
|
||||
shut = Some(i);
|
||||
} else if resp.on_hover_text(name.as_str()).clicked() {
|
||||
pick = Some(i);
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
// New design.
|
||||
let pr = Rect::from_center_size(pos2(x + 14.0, r.center().y + 2.0), vec2(22.0, 22.0));
|
||||
let presp = ui.interact(pr, ui.id().with("doc_new"), Sense::click());
|
||||
if presp.hovered() {
|
||||
ui.painter().rect_filled(pr, 4.0, Color32::from_white_alpha(30));
|
||||
}
|
||||
let c = pr.center();
|
||||
let s = Stroke::new(1.6, t.app_bar_text);
|
||||
ui.painter().line_segment([c - vec2(6.0, 0.0), c + vec2(6.0, 0.0)], s);
|
||||
ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s);
|
||||
if presp.on_hover_text("New Design (Ctrl+N)").clicked() {
|
||||
new_design(app);
|
||||
}
|
||||
if let Some(i) = shut {
|
||||
close(app, i, false);
|
||||
} else if let Some(i) = pick {
|
||||
switch(app, i);
|
||||
}
|
||||
}
|
||||
|
||||
/// The "save changes?" prompt for a tab being closed.
|
||||
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(i) = app.docs.closing else { return };
|
||||
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
||||
let mut answer: Option<u8> = None;
|
||||
egui::Window::new("Save changes?")
|
||||
.id(egui::Id::new("sc_close_prompt"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(format!("{name} has unsaved changes."));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
answer = Some(0);
|
||||
}
|
||||
if ui.button("Don't Save").clicked() {
|
||||
answer = Some(1);
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
answer = Some(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
|
||||
answer = Some(2);
|
||||
app.esc_handled = true;
|
||||
}
|
||||
match answer {
|
||||
Some(0) => {
|
||||
switch(app, i);
|
||||
crate::toolbar::save(app);
|
||||
// Saved (not cancelled in the file picker): close it.
|
||||
if !app.session.is_dirty() {
|
||||
close(app, app.docs.active, true);
|
||||
} else {
|
||||
app.docs.closing = None;
|
||||
}
|
||||
}
|
||||
Some(1) => close(app, i, true),
|
||||
Some(2) => app.docs.closing = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
fn app() -> SolveApp {
|
||||
SolveApp::new(Session::default(), Services::default())
|
||||
}
|
||||
|
||||
/// Each tab keeps its own design, undo, selection and camera; closing asks when there are
|
||||
/// unsaved changes; the last tab closing leaves a blank design.
|
||||
#[test]
|
||||
fn tabs_keep_their_own_design() {
|
||||
let mut a = app();
|
||||
a.run("PrimitiveBox", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
a.session.selection = vec![solvecraft_engine::Sel::Body { name: "Body1".into() }];
|
||||
a.cam.distance = 123.0;
|
||||
new_design(&mut a);
|
||||
assert_eq!(a.docs.count(), 2);
|
||||
assert_eq!(a.docs.active, 1);
|
||||
assert!(a.session.doc.features.is_empty() && a.session.selection.is_empty());
|
||||
a.run("PrimitiveSphere", json!({"diameter": 8})).unwrap();
|
||||
a.run("PrimitiveSphere", json!({"diameter": 4})).unwrap();
|
||||
switch(&mut a, 0);
|
||||
assert_eq!(a.session.doc.features.len(), 1);
|
||||
assert_eq!(a.session.selection.len(), 1);
|
||||
assert!((a.cam.distance - 123.0).abs() < 1e-9);
|
||||
a.run("UndoCommand", json!({})).unwrap();
|
||||
assert!(a.session.doc.features.is_empty(), "undo is this design's");
|
||||
switch(&mut a, 1);
|
||||
assert_eq!(a.session.doc.features.len(), 2, "the other design kept its features");
|
||||
// Unsaved: closing asks, Don't Save closes.
|
||||
close(&mut a, 1, false);
|
||||
assert_eq!(a.docs.closing, Some(1));
|
||||
assert_eq!(a.docs.count(), 2);
|
||||
close(&mut a, 1, true);
|
||||
assert_eq!(a.docs.count(), 1);
|
||||
assert_eq!(a.docs.active, 0);
|
||||
close(&mut a, 0, true);
|
||||
assert!(active_is_blank(&a) && a.home.open);
|
||||
}
|
||||
|
||||
/// A design built elsewhere replaces a blank one and gets a tab otherwise.
|
||||
#[test]
|
||||
fn adopt_reuses_a_blank_design() {
|
||||
let mut a = app();
|
||||
let mut s = Session::default();
|
||||
s.run_script(&solvecraft_engine::sample::script()).unwrap();
|
||||
adopt(&mut a, s);
|
||||
assert_eq!(a.docs.count(), 1);
|
||||
assert!(!a.session.doc.features.is_empty());
|
||||
adopt(&mut a, Session::default());
|
||||
assert_eq!(a.docs.count(), 2);
|
||||
let v = control(&mut a, &json!({"action": "switch", "index": 0}));
|
||||
assert_eq!(v["active"], 0);
|
||||
assert_eq!(v["documents"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! The start page: New Design, Open, the recent designs and the built-in samples, each with a
|
||||
//! picture of the design rendered from it. Shown on launch without a file and from the house
|
||||
//! button; it closes when a design is opened or started, or the design changes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2};
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::{Camera, StandardView};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Recent designs kept.
|
||||
pub const MAX_RECENT: usize = 12;
|
||||
const THUMB: [usize; 2] = [240, 150];
|
||||
|
||||
/// What a background job makes: a key ("sample:N" or a path), the picture, and for samples the
|
||||
/// built design (opened at once when picked).
|
||||
type Made = (String, Option<egui::ColorImage>, Option<Session>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HomeState {
|
||||
pub open: bool,
|
||||
/// Recent design paths, newest first (kept in the preferences).
|
||||
pub recent: Vec<String>,
|
||||
/// The session revision when the page opened (a change closes it).
|
||||
rev: u64,
|
||||
thumbs: HashMap<String, Option<egui::TextureHandle>>,
|
||||
samples: HashMap<usize, Session>,
|
||||
/// Keys asked for and not made yet.
|
||||
queued: Vec<String>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
rx: Option<std::sync::mpsc::Receiver<Made>>,
|
||||
/// A sample picked before its design was ready.
|
||||
wanted: Option<usize>,
|
||||
}
|
||||
|
||||
impl HomeState {
|
||||
pub fn show(&mut self, rev: u64) {
|
||||
self.open = true;
|
||||
self.rev = rev;
|
||||
}
|
||||
|
||||
/// Put a design at the top of the recent list.
|
||||
pub fn add_recent(&mut self, path: &str) {
|
||||
self.recent.retain(|p| p != path);
|
||||
self.recent.insert(0, path.to_string());
|
||||
self.recent.truncate(MAX_RECENT);
|
||||
self.thumbs.remove(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture of a design: iso view, fitted.
|
||||
pub fn thumbnail(s: &Session) -> Option<egui::ColorImage> {
|
||||
let mut cam = Camera::default();
|
||||
cam.set_view(StandardView::Iso);
|
||||
let b = solvecraft_engine::view::bounds(s);
|
||||
if b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cam.fit(&b);
|
||||
// Fill the card: the fit leaves room for a whole window's margins.
|
||||
cam.distance *= 0.7;
|
||||
let scene = solvecraft_engine::view::scene(s, &cam);
|
||||
let c = solvecraft_engine::render::render(&scene, &cam, THUMB[0], THUMB[1]);
|
||||
(c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba))
|
||||
}
|
||||
|
||||
/// Build what a key stands for (runs off the UI thread where threads exist).
|
||||
fn make(key: &str) -> Made {
|
||||
let mut s = Session::default();
|
||||
let built = match key.strip_prefix("sample:").and_then(|i| i.parse::<usize>().ok()) {
|
||||
Some(i) => solvecraft_engine::sample::samples().get(i).is_some_and(|smp| {
|
||||
let ok = s.run_script(&(smp.script)()).is_ok();
|
||||
s.doc_mut().name = smp.name.to_string();
|
||||
s.undo.clear();
|
||||
s.mark_saved();
|
||||
ok
|
||||
}),
|
||||
None => s.execute("doc.open", &serde_json::json!({ "path": key })).is_ok(),
|
||||
};
|
||||
if !built {
|
||||
return (key.to_string(), None, None);
|
||||
}
|
||||
let img = thumbnail(&s);
|
||||
(key.to_string(), img, key.starts_with("sample:").then_some(s))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_jobs(h: &mut HomeState) {
|
||||
if h.rx.is_some() || h.queued.is_empty() {
|
||||
return;
|
||||
}
|
||||
let keys = std::mem::take(&mut h.queued);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || {
|
||||
for k in keys {
|
||||
let made = std::panic::catch_unwind(|| make(&k)).unwrap_or((k, None, None));
|
||||
if tx.send(made).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
h.rx = Some(rx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take finished pictures and designs (one job per frame on the web, where there are no
|
||||
/// threads).
|
||||
fn poll(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut done: Vec<Made> = Vec::new();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
start_jobs(&mut app.home);
|
||||
if let Some(rx) = &app.home.rx {
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(m) => done.push(m),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
app.home.rx = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if app.home.rx.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if !app.home.queued.is_empty() {
|
||||
let k = app.home.queued.remove(0);
|
||||
done.push(make(&k));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
for (key, img, session) in done {
|
||||
if session.is_none() && key.starts_with("sample:") && app.home.wanted.is_some_and(|w| key == format!("sample:{w}")) {
|
||||
app.home.wanted = None;
|
||||
app.set_status(format!("the sample could not be built ({key})"), true);
|
||||
}
|
||||
let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR));
|
||||
app.home.thumbs.insert(key.clone(), tex);
|
||||
if let (Some(s), Some(i)) = (session, key.strip_prefix("sample:").and_then(|i| i.parse::<usize>().ok())) {
|
||||
app.home.samples.insert(i, s);
|
||||
}
|
||||
}
|
||||
if let Some(i) = app.home.wanted
|
||||
&& app.home.samples.contains_key(&i)
|
||||
{
|
||||
app.home.wanted = None;
|
||||
open_sample(app, i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for a key's picture (once: an empty entry marks it as asked for).
|
||||
fn want(h: &mut HomeState, key: &str) {
|
||||
if !h.thumbs.contains_key(key) {
|
||||
h.queued.push(key.to_string());
|
||||
h.thumbs.insert(key.to_string(), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a built-in sample (a copy of the design made for its picture).
|
||||
pub fn open_sample(app: &mut SolveApp, i: usize) {
|
||||
match app.home.samples.get(&i) {
|
||||
Some(s) => {
|
||||
let mut copy = s.scratch();
|
||||
copy.mark_saved();
|
||||
crate::documents::adopt(app, copy);
|
||||
app.home.open = false;
|
||||
}
|
||||
None => {
|
||||
app.home.wanted = Some(i);
|
||||
want(&mut app.home, &format!("sample:{i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_recent(app: &mut SolveApp, path: &str) {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
app.home.recent.retain(|p| p != path);
|
||||
app.set_status(format!("{path} is no longer there"), true);
|
||||
return;
|
||||
}
|
||||
app.home.open = false;
|
||||
app.open_path(path);
|
||||
}
|
||||
|
||||
/// The page itself (in place of the toolbar, browser and viewport).
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.session.revision != app.home.rev {
|
||||
app.home.open = false;
|
||||
return;
|
||||
}
|
||||
poll(app, ui.ctx());
|
||||
let t = Tokens::get();
|
||||
let samples = solvecraft_engine::sample::samples();
|
||||
for i in 0..samples.len() {
|
||||
want(&mut app.home, &format!("sample:{i}"));
|
||||
}
|
||||
let recent = app.home.recent.clone();
|
||||
for p in recent.iter().filter(|p| std::path::Path::new(p.as_str()).exists()) {
|
||||
want(&mut app.home, p);
|
||||
}
|
||||
let mut action: Option<Box<dyn FnOnce(&mut SolveApp)>> = None;
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(36, 24))).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("SolveCraft").size(26.0).strong().color(t.text));
|
||||
ui.add_space(12.0);
|
||||
ui.label(RichText::new("Start").size(16.0).color(t.text_dim));
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
let big = |ui: &mut egui::Ui, icon: &str, label: &str, primary: bool| -> bool {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::click());
|
||||
let fill = if primary {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field
|
||||
};
|
||||
ui.painter().rect(r, 6.0, fill, Stroke::new(1.0, if primary { t.accent } else { t.border }), egui::StrokeKind::Inside);
|
||||
let ink = if primary { Color32::WHITE } else { t.icon };
|
||||
crate::icons::paint(
|
||||
ui.painter(),
|
||||
egui::Rect::from_center_size(r.left_center() + vec2(30.0, 0.0), vec2(30.0, 30.0)),
|
||||
icon,
|
||||
ink,
|
||||
t.icon_fill,
|
||||
ink,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(56.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
label,
|
||||
egui::FontId::proportional(15.0),
|
||||
if primary { Color32::WHITE } else { t.text },
|
||||
);
|
||||
resp.clicked()
|
||||
};
|
||||
if big(ui, "new", "New Design", true) {
|
||||
action = Some(Box::new(|app: &mut SolveApp| crate::documents::new_design(app)));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if big(ui, "open", "Open…", false) {
|
||||
action = Some(Box::new(|app: &mut SolveApp| {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.home.open = false;
|
||||
app.open_path(&p);
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
ui.add_space(24.0);
|
||||
section(ui, "Recent");
|
||||
if recent.is_empty() {
|
||||
ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim));
|
||||
} else {
|
||||
grid(ui, recent.len(), |ui, i| {
|
||||
let Some(p) = recent.get(i) else { return };
|
||||
let path = std::path::Path::new(p);
|
||||
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
|
||||
let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let missing = !path.exists();
|
||||
let sub = if missing { "missing".to_string() } else { folder };
|
||||
if card(ui, app.home.thumbs.get(p.as_str()).and_then(Option::as_ref), &name, &sub, missing).on_hover_text(p.as_str()).clicked() {
|
||||
let p = p.clone();
|
||||
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(24.0);
|
||||
section(ui, "Samples");
|
||||
grid(ui, samples.len(), |ui, i| {
|
||||
let Some(s) = samples.get(i) else { return };
|
||||
let key = format!("sample:{i}");
|
||||
let busy = app.home.wanted == Some(i);
|
||||
let sub = if busy { "opening…" } else { s.about };
|
||||
if card(ui, app.home.thumbs.get(&key).and_then(Option::as_ref), s.name, sub, false).on_hover_text(s.about).clicked() {
|
||||
action = Some(Box::new(move |app: &mut SolveApp| open_sample(app, i)));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(f) = action {
|
||||
f(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn section(ui: &mut egui::Ui, title: &str) {
|
||||
let t = Tokens::get();
|
||||
ui.label(RichText::new(title).size(15.0).strong().color(t.text));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
/// Cards in rows that fill the width.
|
||||
fn grid(ui: &mut egui::Ui, n: usize, mut each: impl FnMut(&mut egui::Ui, usize)) {
|
||||
let w = THUMB[0] as f32 + 16.0;
|
||||
let per_row = ((ui.available_width() + 14.0) / (w + 14.0)).floor().max(1.0) as usize;
|
||||
for row in 0..n.div_ceil(per_row) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 14.0;
|
||||
for i in row * per_row..((row + 1) * per_row).min(n) {
|
||||
each(ui, i);
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A design card: its picture (a spinner while it renders), name and a line under it.
|
||||
fn card(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, name: &str, sub: &str, dim: bool) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0);
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let hover = resp.hovered();
|
||||
ui.painter().rect(
|
||||
r,
|
||||
6.0,
|
||||
if hover { t.hover } else { t.field },
|
||||
Stroke::new(1.0, if hover { t.accent } else { t.border }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let img = egui::Rect::from_min_size(r.min + vec2(8.0, 8.0), vec2(THUMB[0] as f32, THUMB[1] as f32));
|
||||
match tex {
|
||||
Some(tx) => {
|
||||
let tint = if dim { Color32::from_white_alpha(90) } else { Color32::WHITE };
|
||||
ui.painter().image(tx.id(), img, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint);
|
||||
}
|
||||
None => {
|
||||
ui.painter().rect_filled(img, CornerRadius::same(4), t.panel_header);
|
||||
if !dim {
|
||||
egui::Spinner::new().size(18.0).paint_at(ui, egui::Rect::from_center_size(img.center(), vec2(18.0, 18.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ty = img.bottom() + 8.0;
|
||||
let clip = egui::Rect::from_min_max(egui::pos2(r.left() + 8.0, ty), egui::pos2(r.right() - 8.0, r.bottom()));
|
||||
let p = ui.painter().with_clip_rect(clip);
|
||||
p.text(egui::pos2(r.left() + 10.0, ty), egui::Align2::LEFT_TOP, name, egui::FontId::proportional(14.0), if dim { t.text_dim } else { t.text });
|
||||
p.text(egui::pos2(r.left() + 10.0, ty + 20.0), egui::Align2::LEFT_TOP, sub, egui::FontId::proportional(11.5), t.text_dim);
|
||||
if hover {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recent_list_is_newest_first_and_bounded() {
|
||||
let mut h = HomeState::default();
|
||||
for i in 0..20 {
|
||||
h.add_recent(&format!("/d/{i}.solvecraft"));
|
||||
}
|
||||
h.add_recent("/d/5.solvecraft");
|
||||
assert_eq!(h.recent.len(), MAX_RECENT);
|
||||
assert_eq!(h.recent.first().map(String::as_str), Some("/d/5.solvecraft"));
|
||||
assert_eq!(h.recent.iter().filter(|p| p.as_str() == "/d/5.solvecraft").count(), 1);
|
||||
}
|
||||
|
||||
/// Samples build in the background and make a picture; picking one opens its design.
|
||||
#[test]
|
||||
fn sample_card_makes_a_picture_and_opens() {
|
||||
let (key, img, session) = make("sample:0");
|
||||
assert_eq!(key, "sample:0");
|
||||
let img = img.unwrap();
|
||||
assert_eq!(img.size, THUMB);
|
||||
let s = session.unwrap();
|
||||
assert_eq!(s.doc.name, "Sample Plate");
|
||||
assert!(!s.is_dirty() && s.undo.is_empty());
|
||||
let mut app = SolveApp::new(Session::default(), crate::Services::default());
|
||||
app.home.samples.insert(0, s);
|
||||
app.home.show(app.session.revision);
|
||||
open_sample(&mut app, 0);
|
||||
assert!(!app.home.open);
|
||||
assert_eq!(app.session.doc.name, "Sample Plate");
|
||||
assert!(!app.session.is_dirty());
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ pub mod dialogs_assembly;
|
||||
pub mod dialogs_plastic;
|
||||
pub mod dialogs_sheet;
|
||||
pub mod dim_view;
|
||||
pub mod documents;
|
||||
pub mod gpu;
|
||||
pub mod home;
|
||||
pub mod icons;
|
||||
pub mod inference;
|
||||
#[cfg(test)]
|
||||
@@ -164,6 +166,10 @@ pub struct SolveApp {
|
||||
shot_token: u64,
|
||||
styled: bool,
|
||||
fitted: bool,
|
||||
/// The start page.
|
||||
pub home: home::HomeState,
|
||||
/// The other open designs (tabs).
|
||||
pub docs: documents::Documents,
|
||||
}
|
||||
|
||||
impl SolveApp {
|
||||
@@ -199,6 +205,8 @@ impl SolveApp {
|
||||
shot_token: 0,
|
||||
styled: false,
|
||||
fitted: false,
|
||||
home: home::HomeState::default(),
|
||||
docs: documents::Documents::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +227,7 @@ impl SolveApp {
|
||||
"visual_style": self.ui.visual_style,
|
||||
"browser_collapsed": self.tree.collapsed,
|
||||
"browser_expanded": self.tree.expanded,
|
||||
"recent": self.home.recent,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
@@ -254,6 +263,11 @@ impl SolveApp {
|
||||
};
|
||||
self.tree.collapsed = keys("browser_collapsed");
|
||||
self.tree.expanded = keys("browser_expanded");
|
||||
self.home.recent = v
|
||||
.get("recent")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).take(home::MAX_RECENT).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
@@ -271,6 +285,11 @@ impl SolveApp {
|
||||
self.cam = Camera::default();
|
||||
self.fit_view();
|
||||
}
|
||||
if matches!(id, "doc.open" | "SaveDocumentCommand" | "SaveDocumentAsCommand")
|
||||
&& let Some(p) = self.session.path.clone()
|
||||
{
|
||||
self.home.add_recent(&p);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.session.echo(format!("{id}: {e}"));
|
||||
@@ -290,6 +309,7 @@ impl SolveApp {
|
||||
/// that have them, otherwise run it with defaults.
|
||||
pub fn start(&mut self, id: &str) {
|
||||
self.tool = None;
|
||||
self.home.open = false;
|
||||
if matches!(id, "FusionImportCommandFromToolbar" | "ParaMeshInsertAlignCommand") {
|
||||
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
self.insert_path(&p);
|
||||
@@ -455,6 +475,8 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
self.home.open = false;
|
||||
documents::prepare_open(self);
|
||||
if self.run("doc.open", json!({ "path": path })).is_ok() {
|
||||
self.fit_view();
|
||||
}
|
||||
@@ -599,6 +621,14 @@ impl SolveApp {
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let t0 = now_ms();
|
||||
toolbar::app_bar(self, ui);
|
||||
documents::prompt(self, ui.ctx());
|
||||
if self.home.open {
|
||||
home::show(self, ui);
|
||||
if self.home.open {
|
||||
self.frame_ms = now_ms() - t0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
toolbar::toolbar(self, ui);
|
||||
if self.ui.show_timeline {
|
||||
timeline::timeline(self, ui);
|
||||
|
||||
@@ -44,7 +44,7 @@ fn maximized(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
|
||||
}
|
||||
|
||||
fn toggle_maximize(ctx: &egui::Context) {
|
||||
pub fn toggle_maximize(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if click(ui, "redo", "Redo (Ctrl+Y)") {
|
||||
let _ = app.run("RedoCommand", json!({}));
|
||||
}
|
||||
if click(ui, "home", "Start page") {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" };
|
||||
if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
|
||||
app.ui.dark = !app.ui.dark;
|
||||
@@ -94,12 +98,9 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
d.insert_temp(egui::Id::new("sc_file_menu_opened"), pass);
|
||||
});
|
||||
}
|
||||
// Document tab.
|
||||
let name = format!("{}{}", app.session.doc.name, if app.session.is_dirty() { " •" } else { "" });
|
||||
let tab = Rect::from_min_size(pos2(r.center().x - 110.0, r.top() + 5.0), vec2(220.0, r.height() - 5.0));
|
||||
ui.painter().rect_filled(tab, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, t.toolbar);
|
||||
ui.painter().text(tab.center(), Align2::CENTER_CENTER, name, FontId::proportional(13.0), t.text);
|
||||
// Document tabs.
|
||||
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
|
||||
crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 60.0);
|
||||
ui.painter().text(
|
||||
pos2(r.right() - 12.0 - captions, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
@@ -130,8 +131,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
|
||||
};
|
||||
if item(ui, "New Design", "Ctrl+N") {
|
||||
let _ = app.run("NewDocumentCommand", json!({}));
|
||||
app.fit_view();
|
||||
crate::documents::new_design(app);
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Open…", "Ctrl+O") {
|
||||
@@ -406,10 +406,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Key::Z => drop(app.run("UndoCommand", json!({}))),
|
||||
Key::Y => drop(app.run("RedoCommand", json!({}))),
|
||||
Key::S => save(app),
|
||||
Key::N => {
|
||||
let _ = app.run("NewDocumentCommand", json!({}));
|
||||
app.fit_view();
|
||||
}
|
||||
Key::N => crate::documents::new_design(app),
|
||||
Key::O => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
|
||||
@@ -42,6 +42,8 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
|
||||
| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) |
|
||||
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) |
|
||||
| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists |
|
||||
| `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.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` | — | |
|
||||
|
||||
Reference in new issue
Block a user