* WIP deep rename (not for push) * WIP2 * WIP3 * WIP4 wxs fix * rustfmt * Rename the remaining identifiers in merged crash-report code Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
667 lines
26 KiB
Rust
667 lines
26 KiB
Rust
//! 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, and so does quitting: one "save changes?"
|
||
//! per design with unsaved changes ([`request_quit`]).
|
||
|
||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||
use serde_json::{Value, json};
|
||
use solidcraft_engine::Session;
|
||
use solidcraft_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_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>,
|
||
/// Quitting: the tabs answered so far with Don't Save (`None`: not quitting).
|
||
pub quitting: Option<Vec<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_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_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);
|
||
crate::dim_view::cancel();
|
||
app.tool = None;
|
||
app.params = None;
|
||
app.cancel_view_animation();
|
||
app.viewport.orbit_snap = None;
|
||
app.viewport.orbit_free = None;
|
||
app.preview = crate::preview::PreviewState::default();
|
||
app.viewport.hover = None;
|
||
app.menu.props = None;
|
||
crate::sketch_attachment_ui::clear(app);
|
||
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.invalidate_view(out_rev);
|
||
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) {
|
||
app.home.open = false;
|
||
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.menu.props = None;
|
||
crate::sketch_attachment_ui::clear(app);
|
||
app.session = session;
|
||
app.session.invalidate_view(rev);
|
||
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);
|
||
}
|
||
crate::prefs::apply_new_design(app);
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
}
|
||
|
||
/// Quit (the title bar's ×, File ▸ Quit, the window being closed): asks "save changes?" for each
|
||
/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window.
|
||
/// Cancel, or a Save that didn't save, stays open.
|
||
pub fn request_quit(app: &mut SolveApp) {
|
||
if app.docs.quitting.is_none() {
|
||
app.docs.quitting = Some(Vec::new());
|
||
}
|
||
quit_step(app);
|
||
}
|
||
|
||
/// Ask about the next design with unsaved changes, or quit when none is left.
|
||
fn quit_step(app: &mut SolveApp) {
|
||
let Some(skip) = app.docs.quitting.clone() else { return };
|
||
if app.docs.closing.is_some() {
|
||
return;
|
||
}
|
||
match (0..app.docs.count()).find(|i| !skip.contains(i) && dirty(app, *i)) {
|
||
Some(i) => {
|
||
switch(app, i);
|
||
app.docs.closing = Some(i);
|
||
}
|
||
None => {
|
||
// Don't Save means discard: no recovery copy is kept for the next launch either.
|
||
for i in skip {
|
||
switch(app, i);
|
||
app.session.mark_saved();
|
||
}
|
||
app.docs.quitting = None;
|
||
app.quit_requested = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Is any open design unsaved (the web page asks before it is left)?
|
||
pub fn any_dirty(app: &SolveApp) -> bool {
|
||
(0..app.docs.count()).any(|i| dirty(app, i))
|
||
}
|
||
|
||
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`, `close` (by index; `force`
|
||
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
|
||
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)),
|
||
Some("quit") => request_quit(app),
|
||
_ => {}
|
||
}
|
||
let answer = match p.get("answer").and_then(Value::as_str) {
|
||
Some("save") => Some(Answer::Save),
|
||
Some("dont_save") => Some(Answer::DontSave),
|
||
Some("cancel") => Some(Answer::Cancel),
|
||
_ => None,
|
||
};
|
||
if let Some(a) = answer {
|
||
answer_prompt(app, a);
|
||
}
|
||
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, "quitting": app.docs.quitting.is_some()})
|
||
}
|
||
|
||
/// 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 home = Rect::from_min_size(pos2(x0, r.top() + 5.0), vec2(66.0, r.height() - 5.0));
|
||
let response = ui.interact(home, ui.id().with("home_tab"), Sense::click());
|
||
ui.painter().rect_filled(
|
||
home,
|
||
egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 },
|
||
if app.home.open {
|
||
t.toolbar
|
||
} else if response.hovered() {
|
||
t.hover
|
||
} else {
|
||
t.app_bar
|
||
},
|
||
);
|
||
let home_text = app.tr("ui.home");
|
||
ui.painter().text(
|
||
home.center(),
|
||
Align2::CENTER_CENTER,
|
||
home_text,
|
||
FontId::proportional(13.0),
|
||
if app.home.open { t.text } else { t.app_bar_text },
|
||
);
|
||
crate::scenario::publish_handle("tab:home", home.center());
|
||
if response.clicked() {
|
||
app.home.show(app.session.revision);
|
||
}
|
||
let x0 = x0 + 70.0;
|
||
let info = tabs_info(app);
|
||
let n = info.len();
|
||
let (first, shown, w) = layout(n, app.docs.active, x1 - x0);
|
||
let mut x = x0;
|
||
let mut pick: Option<usize> = None;
|
||
let mut shut: Option<usize> = None;
|
||
for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) {
|
||
let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0));
|
||
let active = !app.home.open && (i == app.docs.active || app.docs.slots.is_empty());
|
||
let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag());
|
||
crate::scenario::publish_handle(&format!("tab:design:{i}"), tr.center());
|
||
// 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 {
|
||
t.app_bar
|
||
};
|
||
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 name_display = if name == "Untitled" { app.tr("ui.untitled") } else { name };
|
||
let label = format!("{name_display}{}", 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;
|
||
}
|
||
// More tabs than fit: a chevron lists them all.
|
||
if shown < n {
|
||
let mr = Rect::from_center_size(pos2(x + 12.0, r.center().y + 2.0), vec2(20.0, 22.0));
|
||
let mresp = ui.interact(mr, ui.id().with("doc_more"), Sense::click());
|
||
if mresp.hovered() {
|
||
ui.painter().rect_filled(mr, 4.0, Color32::from_white_alpha(30));
|
||
}
|
||
ui.painter().text(mr.center(), egui::Align2::CENTER_CENTER, format!("»{}", n - shown), FontId::proportional(12.0), t.app_bar_text);
|
||
let id = egui::Id::new("sc_doc_more");
|
||
if mresp.clone().on_hover_text("All open designs").clicked() {
|
||
ui.ctx().data_mut(|d| d.insert_temp(id, !d.get_temp::<bool>(id).unwrap_or(false)));
|
||
}
|
||
if ui.ctx().data(|d| d.get_temp::<bool>(id).unwrap_or(false)) {
|
||
let resp =
|
||
crate::frame::area(id.with("area")).fixed_pos(pos2(mr.left(), r.bottom())).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||
crate::context_menu::menu_frame(ui, |ui| {
|
||
for (i, (name, dirty)) in info.iter().enumerate() {
|
||
let item = crate::context_menu::Item::action(
|
||
"doc",
|
||
&format!("{name}{}", if *dirty { " •" } else { "" }),
|
||
if i == app.docs.active { "box" } else { "" },
|
||
);
|
||
if crate::context_menu::list_row(ui, &item).is_some() {
|
||
pick = Some(i);
|
||
}
|
||
}
|
||
})
|
||
});
|
||
let pressed_outside = ui.ctx().input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer() && !mresp.contains_pointer();
|
||
if pick.is_some() || pressed_outside {
|
||
ui.ctx().data_mut(|d| d.insert_temp(id, false));
|
||
}
|
||
}
|
||
x += 26.0;
|
||
}
|
||
// New design: a new tab showing the start page.
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// Which tabs the strip shows in `room` points: (first, how many, tab width). Tabs shrink to
|
||
/// `MIN_TAB`; past that a window of tabs around the active one is shown and the rest go in the »
|
||
/// list.
|
||
pub fn layout(n: usize, active: usize, room: f32) -> (usize, usize, f32) {
|
||
const MIN_TAB: f32 = 90.0;
|
||
let n = n.max(1);
|
||
let avail = (room - 34.0).max(MIN_TAB);
|
||
if avail / n as f32 >= MIN_TAB {
|
||
return (0, n, (avail / n as f32).min(220.0));
|
||
}
|
||
// Leave room for the » button.
|
||
let fit = (((avail - 26.0) / MIN_TAB).floor() as usize).max(1);
|
||
let first = active.saturating_sub(fit - 1).min(n - fit);
|
||
(first, fit, MIN_TAB)
|
||
}
|
||
|
||
/// What "save changes?" was answered.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Answer {
|
||
Save,
|
||
DontSave,
|
||
Cancel,
|
||
}
|
||
|
||
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
|
||
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
|
||
let _language = crate::i18n::scope(&app.preferences.language);
|
||
quit_step(app);
|
||
let Some(i) = app.docs.closing else { return };
|
||
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
||
let name_display = if name == "Untitled" { crate::i18n::tr("ui.untitled") } else { &name };
|
||
let mut answer: Option<Answer> = None;
|
||
let title = crate::i18n::format("document.save_changes", &[("name", name_display)]);
|
||
crate::frame::window(ctx, title, crate::frame::Width::Normal)
|
||
.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(crate::i18n::format("document.unsaved_changes", &[("name", name_display)]));
|
||
ui.add_space(8.0);
|
||
ui.horizontal(|ui| {
|
||
if ui.button(crate::i18n::tr("ui.save")).clicked() {
|
||
answer = Some(Answer::Save);
|
||
}
|
||
if ui.button(crate::i18n::tr("ui.don_t_save")).clicked() {
|
||
answer = Some(Answer::DontSave);
|
||
}
|
||
if ui.button(crate::i18n::tr("ui.cancel")).clicked() {
|
||
answer = Some(Answer::Cancel);
|
||
}
|
||
});
|
||
});
|
||
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
|
||
answer = Some(Answer::Cancel);
|
||
app.esc_handled = true;
|
||
}
|
||
if let Some(a) = answer {
|
||
answer_prompt(app, a);
|
||
}
|
||
}
|
||
|
||
/// Act on the "save changes?" answer (also the control channel's `ui.documents {answer}`).
|
||
pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
|
||
let Some(i) = app.docs.closing else { return };
|
||
match answer {
|
||
Answer::Save => {
|
||
switch(app, i);
|
||
crate::toolbar::save(app);
|
||
let saved = !app.session.is_dirty();
|
||
app.docs.closing = None;
|
||
if app.docs.quitting.is_some() {
|
||
// Quit only once it is saved; a cancelled picker or a failed save stays open.
|
||
if !saved {
|
||
app.docs.quitting = None;
|
||
}
|
||
} else if saved {
|
||
close(app, app.docs.active, true);
|
||
}
|
||
}
|
||
Answer::DontSave => {
|
||
if let Some(skip) = app.docs.quitting.as_mut() {
|
||
skip.push(i);
|
||
app.docs.closing = None;
|
||
} else {
|
||
close(app, i, true);
|
||
}
|
||
}
|
||
Answer::Cancel => {
|
||
app.docs.closing = None;
|
||
app.docs.quitting = None;
|
||
}
|
||
}
|
||
quit_step(app);
|
||
}
|
||
|
||
#[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 the_tab_strip_overflows_into_a_list_keeping_the_active_tab() {
|
||
assert_eq!(layout(2, 0, 800.0), (0, 2, 220.0));
|
||
let (first, shown, w) = layout(12, 11, 600.0);
|
||
assert!(shown < 12 && w == 90.0);
|
||
assert!((first..first + shown).contains(&11), "the active tab stays visible");
|
||
assert!(shown as f32 * w + 26.0 + 34.0 <= 600.0 + 0.5);
|
||
assert_eq!(layout(12, 0, 600.0).0, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn tabs_keep_their_own_design() {
|
||
let mut a = app();
|
||
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||
a.session.selection = vec![solidcraft_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("solid.sphere", json!({"diameter": 8})).unwrap();
|
||
a.run("solid.sphere", 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("edit.undo", 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);
|
||
}
|
||
|
||
/// Quitting asks once per design with unsaved changes: Don't Save moves on (and keeps no
|
||
/// recovery copy), Save saves and moves on, and the app quits after the last answer.
|
||
#[test]
|
||
fn quit_asks_about_each_unsaved_design_then_quits() {
|
||
let mut a = app();
|
||
request_quit(&mut a);
|
||
assert!(a.quit_requested, "nothing unsaved: quits at once");
|
||
|
||
let mut a = app();
|
||
let file = std::env::temp_dir().join(format!("solidcraft-quit-save-{}.solidcraft", std::process::id()));
|
||
let _ = std::fs::remove_file(&file);
|
||
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||
a.run("file.save_as", json!({"path": file.to_string_lossy()})).unwrap();
|
||
a.run("solid.sphere", json!({"diameter": 4})).unwrap();
|
||
new_design(&mut a);
|
||
a.run("solid.sphere", json!({"diameter": 8})).unwrap();
|
||
assert!(dirty(&a, 0) && dirty(&a, 1));
|
||
|
||
request_quit(&mut a);
|
||
assert!(!a.quit_requested);
|
||
assert_eq!(a.docs.closing, Some(0), "the first unsaved design is asked about first");
|
||
assert_eq!(a.docs.active, 0, "and shown");
|
||
answer_prompt(&mut a, Answer::Save);
|
||
assert!(!dirty(&a, 0), "saved");
|
||
assert_eq!(
|
||
std::fs::read(&file).ok().and_then(|b| solidcraft_engine::io::read_design(&b).ok()).map(|d| d.features.len()),
|
||
Some(2),
|
||
"the file holds the change"
|
||
);
|
||
assert_eq!(a.docs.closing, Some(1), "then the next one");
|
||
assert!(!a.quit_requested);
|
||
answer_prompt(&mut a, Answer::DontSave);
|
||
assert_eq!(a.docs.closing, None);
|
||
assert!(a.quit_requested, "every design answered: quit");
|
||
assert!(!any_dirty(&a), "Don't Save discards: nothing is kept for recovery");
|
||
let _ = std::fs::remove_file(&file);
|
||
}
|
||
|
||
/// Cancel, Esc's answer, or a Save that saves nothing (a never-saved design whose Save As
|
||
/// picker is cancelled) stay open with the changes.
|
||
#[test]
|
||
fn quit_cancelled_or_unsaved_stays_open() {
|
||
let mut a = app();
|
||
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||
request_quit(&mut a);
|
||
assert_eq!(a.docs.closing, Some(0));
|
||
answer_prompt(&mut a, Answer::Cancel);
|
||
assert!(!a.quit_requested && a.docs.closing.is_none() && a.docs.quitting.is_none());
|
||
assert!(a.session.is_dirty(), "the changes are still there");
|
||
|
||
// No Save As picker answer (the default services have none): not saved, so no quit.
|
||
request_quit(&mut a);
|
||
answer_prompt(&mut a, Answer::Save);
|
||
assert!(!a.quit_requested && a.docs.quitting.is_none() && a.session.is_dirty());
|
||
|
||
// A picked file: saved, then quit.
|
||
let file = std::env::temp_dir().join(format!("solidcraft-quit-saveas-{}.solidcraft", std::process::id()));
|
||
let _ = std::fs::remove_file(&file);
|
||
let to = file.to_string_lossy().to_string();
|
||
a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Some(to.clone())));
|
||
request_quit(&mut a);
|
||
answer_prompt(&mut a, Answer::Save);
|
||
assert!(a.quit_requested && file.exists());
|
||
let _ = std::fs::remove_file(&file);
|
||
}
|
||
|
||
/// 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(&solidcraft_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));
|
||
}
|
||
}
|