Files
cadcraft/apps/cadcraft-web/src/main.rs
T
74e77f8a92 feat(web): persist validated browser panel layouts (#2258)
* xtask: the test suite and clippy pass on Windows

Two Windows-only failures stopped `cargo xtask ci` before it got going on
a Windows checkout (CI runs the gate on Linux only, so neither showed):

- parity::tests::civil_dates built a SystemTime 115,740,740 days after
  the epoch (year 318857). Windows' SystemTime ends in the year 30828, so
  the addition panicked ("overflow when adding duration to `SystemTime`").
  That case now checks `civil_from_days` on the day count, which is what
  it was about, on every platform.
- stats.rs imported `walk` for a test that only exists on Unix, so the
  import was unused on Windows and `clippy -D warnings` refused it. The
  Unix test calls `super::walk`, as its neighbour calls `super::walk_inner`.

Checked on Windows 11 x64: `cargo test --workspace` and
`cargo clippy --workspace --all-targets -- -D warnings` both pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web): persist validated browser panel layouts

CC-053: keep embedded floating, docked and hidden panels across reloads
through the existing host save cycle. Reject malformed/versioned records
atomically; native workspaces and drawings remain unchanged.

Validation: all seven cargo xtask ci gates passed (2558 pass, 0 fail,
1 ignored) on a2dd814 plus this exact change. Actual release Trunk bundle
and Edge floating/close/reload/reset/reload passed. Forced WebGL startup,
render and recovered layout passed; full edit cycle used default mode.

Dependency: original #2198 Windows test prerequisite. No #2226 dependency.
Revert this commit independently to stop using the optional storage key.
Upstream c3b8e7b adds nextest minimum configuration only; no runtime overlap.

---------

Co-authored-by: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:21:42 -04:00

341 lines
17 KiB
Rust

//! CADCraft in the browser.
//!
//! Runs the same [`cadcraft_ui_egui::CadApp`] as the desktop app through eframe's web runner
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?cpu` draws the drawing
//! with egui shapes instead of the GPU canvas, `?sample` opens the sample drawing. Saving downloads
//! the drawing through the browser (`download.rs`). With `?host=parent` (inside an `<iframe>`), a
//! same-origin parent page sends the drawing to open and stores what CADCraft saves instead
//! (`host.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
pub mod download;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod panel_layout;
#[cfg(target_arch = "wasm32")]
mod web {
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services};
use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "cadcraft_canvas";
const LOADING_ID: &str = "cadcraft_loading";
/// The app, the files File > Open… and drag and drop read for it in the background, the
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
/// and the document title last written to the page.
struct Shell(
CadApp,
crate::upload::Inbox,
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
Option<web_sys::MediaQueryList>,
Option<String>,
);
impl eframe::App for Shell {
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
/// uploads, follows the browser's reduced-motion setting, runs the app, writes the browser
/// document title when it changed (so the tab and an embedding page name the open
/// drawing), and reports unsaved changes to that page.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Background reads are consume-based and run on hidden ticks too. Dropped files are
// input and are read from `ui` only.
self.1.drain(&mut self.0);
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
}
}
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
/// Preferences and validated panel placement survive reloads; egui memory is not kept.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json());
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
match crate::panel_layout::save(&self.0.ui) {
Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout),
Err(error) => log::warn!("panel layout was not saved: {error}"),
}
}
fn persist_egui_memory(&self) -> bool {
false
}
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let first_pass = ui.ctx().current_pass_index() == 0;
if first_pass {
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
// re-read the same file.
self.1.take_dropped(ui.ctx());
}
self.0.ui(ui);
}
}
/// Writes the browser's document title, which names the tab and is what an embedding page
/// (`?host=parent`) reads. eframe's web runner ignores [`egui::ViewportCommand::Title`], so
/// the title has to reach the DOM directly.
fn set_page_title(title: &str) {
match web_sys::window().and_then(|window| window.document()) {
Some(document) => document.set_title(title),
None => log::warn!("cannot set the page title to {title:?}: there is no document"),
}
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is
/// no file system either, so a dropped file is opened from its contents and APPLOAD says to
/// drop a `.lsp` file rather than asking for a name it cannot read.
fn services() -> Services {
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
// files reach the user as browser downloads.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
match &bridge {
Some(bridge) => {
bridge.install();
cadcraft_engine::cmd::file::set_deliver(crate::host::deliver);
}
None => cadcraft_engine::cmd::file::set_deliver(crate::download::download),
}
let app_bridge = bridge.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
.start(
canvas,
options,
Box::new(move |cc| {
// Embedded, the parent sends the drawing: start on the Start tab, not Drawing1.
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
let mut app = CadApp::new(session, services());
app.system_languages =
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
app.load_profile(&profile);
}
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
app.load_prefs(&prefs);
}
if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY))
&& let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout)
{
log::warn!("panel layout was not restored: {error}");
}
// `?cpu`: no render state for the app, so the canvas draws with egui shapes.
if !crate::wants_cpu(&query())
&& let Some(rs) = &cc.wgpu_render_state
{
// eframe's web runner has no stencil buffer: clipped layout
// viewports stay in the layout's display list.
app.set_wgpu(rs, None);
}
if query().contains("sample") {
let _ = app.run("ui.sample", serde_json::json!({}));
}
// No paths on the web: OPEN asks the browser's file picker, which reads the
// chosen file in the background (`upload.rs`), so the picker returns none.
let inbox = crate::upload::Inbox::new(&cc.egui_ctx);
let picker = inbox.clone();
app.services.pick_open = Some(Box::new(move || {
picker.pick();
None
}));
let host = app_bridge.map(|bridge| {
bridge.listen(inbox.clone());
bridge.post_ready();
(bridge, crate::host::DirtyReport::default())
});
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
}),
)
.await;
if let (Err(e), Some(bridge)) = (&result, &bridge) {
bridge.post_failed(&format!("{e:?}"));
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => {
el.set_inner_html(&format!("<p>CADCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
}
}
}
});
}
}
/// True when the URL query (`?cpu&sample`) has a `cpu` flag: the CPU canvas path is requested.
#[cfg(any(target_arch = "wasm32", test))]
fn wants_cpu(query: &str) -> bool {
query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu="))
}
/// The browser tab title: the open drawing's file name, a bullet once it has unsaved changes,
/// and the app name (`drawing.dwg • — CADCraft`), or just the app name with no drawing. An
/// embedding page (`?host=parent`) reads it to tell which drawing opened and whether it is dirty.
#[cfg(any(target_arch = "wasm32", test))]
fn document_title(name: Option<&str>, dirty: bool) -> String {
match name.filter(|n| !n.trim().is_empty()) {
Some(name) => format!("{name}{} — CADCraft", if dirty { " •" } else { "" }),
None => "CADCraft".to_string(),
}
}
/// The title to write this frame, or `None` while the page already shows it: the browser needs
/// the document title only when it changes, not on every frame.
#[cfg(any(target_arch = "wasm32", test))]
fn title_to_send(sent: &mut Option<String>, name: Option<&str>, dirty: bool) -> Option<String> {
let title = document_title(name, dirty);
if sent.as_deref() == Some(title.as_str()) {
return None;
}
*sent = Some(title.clone());
Some(title)
}
/// Writes a changed title through `write` (on the web, the browser's document title) and reports
/// whether it wrote one. Split from [`title_to_send`] so the change decision and the write are
/// both testable: eframe's web backend ignores `egui::ViewportCommand::Title`, so a title that is
/// computed but not written leaves the tab on `CADCraft`.
#[cfg(any(target_arch = "wasm32", test))]
fn push_title(sent: &mut Option<String>, name: Option<&str>, dirty: bool, write: impl FnOnce(&str)) -> bool {
match title_to_send(sent, name, dirty) {
Some(title) => {
write(&title);
true
}
None => false,
}
}
#[cfg(target_arch = "wasm32")]
mod upload;
#[cfg(target_arch = "wasm32")]
fn main() {
web::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("cadcraft-web only runs in the browser: build it with `trunk build --release` in apps/cadcraft-web");
}
#[cfg(test)]
mod tests {
use super::{document_title, push_title, title_to_send, wants_cpu};
/// Guards the `?cpu` URL flag: only a whole `cpu` query parameter (bare or with a value)
/// selects the CPU canvas, so `?sample` and `?gpucpu` stay on the GPU path.
#[test]
fn cpu_flag_is_detected() {
assert!(wants_cpu("?cpu"));
assert!(wants_cpu("?cpu&sample"));
assert!(wants_cpu("?sample&cpu"));
assert!(!wants_cpu(""));
assert!(!wants_cpu("?sample"));
assert!(!wants_cpu("?webgl&sample"));
assert!(!wants_cpu("?gpucpu"));
}
/// Guards the tab title text: a drawing contributes its name and, when dirty, a trailing
/// bullet; a blank or missing name leaves the app name alone and never shows a bullet.
#[test]
fn viewport_title_names_the_document_and_marks_unsaved_changes() {
assert_eq!(document_title(Some("drawing.dwg"), false), "drawing.dwg — CADCraft");
assert_eq!(document_title(Some("drawing.dwg"), true), "drawing.dwg • — CADCraft");
assert_eq!(document_title(Some("Drawing1"), false), "Drawing1 — CADCraft");
assert_eq!(document_title(None, false), "CADCraft");
assert_eq!(document_title(None, true), "CADCraft");
assert_eq!(document_title(Some(" "), true), "CADCraft");
}
/// Guards the change signal: a title is sent on open, rename, dirty change and close, but
/// never twice in a row, so an idle tab is not repainted by a repeated title command.
#[test]
fn viewport_title_follows_open_rename_and_dirty_changes_only() {
let mut sent = None;
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false).as_deref(), Some("drawing.dwg — CADCraft"));
// The page already has it: send nothing.
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false), None);
// Unsaved changes add the bullet.
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), true).as_deref(), Some("drawing.dwg • — CADCraft"));
// A rename shows the new name.
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), true).as_deref(), Some("plan.dwg • — CADCraft"));
// Saving clears it again.
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), false).as_deref(), Some("plan.dwg — CADCraft"));
// Closing the last drawing leaves the app name only.
assert_eq!(title_to_send(&mut sent, None, false).as_deref(), Some("CADCraft"));
assert_eq!(title_to_send(&mut sent, None, false), None);
}
/// Guards the title write: every changed title reaches the writer (on the web, the browser
/// document title) exactly once, and an unchanged one is not written again. Without the write
/// the tab and an embedding page stay on `CADCraft` however the drawing changes.
#[test]
fn changed_title_reaches_the_document_title_writer_once() {
let mut sent = None;
let mut written: Vec<String> = Vec::new();
assert!(push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
// Unchanged: nothing is written again.
assert!(!push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
// Unsaved changes are written.
assert!(push_title(&mut sent, Some("drawing.dwg"), true, |title| written.push(title.to_owned())));
// Closing the drawing writes the app name.
assert!(push_title(&mut sent, None, false, |title| written.push(title.to_owned())));
assert_eq!(written, ["drawing.dwg — CADCraft", "drawing.dwg • — CADCraft", "CADCraft"]);
}
}