Files
designcraft/crates/ui-egui/src/lib.rs
T

2325 lines
102 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! DesignCraft's egui frontend: an InDesign-style UI over `designcraft-engine`.
//!
//! The UI is thin: every action goes through [`DesignApp::run`], which dispatches UI commands
//! (view/window) here and everything else to the engine. Menus, shortcuts, the ⌘K palette and
//! the control channel ([`control`]) share that entry point.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod about;
pub mod canvas;
pub mod chrome;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod dock;
pub mod i18n;
pub mod icons;
pub mod menus;
mod panel_docking;
pub mod panels;
mod prefs;
pub mod render_worker;
mod rtl;
pub mod story_editor;
pub mod taskbar;
pub mod theme;
pub mod toolbar;
pub mod widgets;
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender};
use designcraft_engine::{Session, SnapView, UiRequest, ViewInfo};
use designcraft_geom::{Point, Unit};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
pub use control::{ControlRequest, ControlResponse};
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
/// Context captured before any host file-picker or read await.
#[derive(Clone, Debug)]
pub struct ImportRequest {
pub purpose: String,
pub target: Option<u64>,
}
pub struct ImportedFile {
pub request: ImportRequest,
pub name: String,
pub bytes: Vec<u8>,
}
pub type OpenAsyncFn = Box<dyn FnMut(ImportRequest)>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// Files delivered asynchronously with their original operation and document identity.
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>;
/// Platform services injected by the host (desktop or web).
#[derive(Default)]
pub struct Services {
/// Open-file dialog for a purpose (`open`, `place`) → path.
pub pick_open: Option<PickFn>,
/// Save dialog with a suggested name → path.
pub pick_save: Option<PickFn>,
pub read: Option<ReadFn>,
pub write: Option<WriteFn>,
/// Asynchronous open dialog for a purpose (`open`, `place`); the chosen file arrives later
/// through [`Services::inbox`]. Used when `pick_open` is unset (web).
pub open_async: Option<OpenAsyncFn>,
/// Hand bytes to the user as a named file (browser download). When set, Save uses it
/// instead of writing to a path.
pub download: Option<DownloadFn>,
/// Files delivered asynchronously, drained every frame with their captured request context.
pub inbox: Option<Inbox>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ScreenMode {
#[default]
Normal,
Preview,
Bleed,
Slug,
Presentation,
}
/// A saved workspace: which bars show and where the panels are.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SavedWorkspace {
pub name: String,
pub control_bar: bool,
pub task_bar: bool,
pub task_bar_pin: Option<[f32; 2]>,
pub tools_double_column: bool,
pub dock_tab: String,
pub dock_expanded: bool,
pub open_panel: Option<String>,
pub floating: Vec<(String, [f32; 2])>,
/// Custom panel membership and placement; absent in older workspace files.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
}
/// A missing on-by-default bool stays on. `bool::default` is false.
fn default_true() -> bool {
true
}
/// A missing snap zone stays at the factory width, in screen pixels.
fn default_zone() -> f64 {
4.0
}
/// File › Export PDF… options: the values persisted in `UiState.pdf_export` and used as the
/// dialog's defaults. Serialised as `pdfExport` with camelCase field names.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PdfExportSettings {
/// Preset (one of the five presets, or "Custom" after a manual edit).
pub preset: String,
/// PDF standard (`none`, `x4`, `a2b`).
pub standard: String,
/// Compression › Compress images (JPEG).
pub compress_images: bool,
/// Transparency flattener preset (`""` = none, `high`, `medium`, `low`).
pub flatten: String,
pub spreads: bool,
pub bleed: bool,
pub marks_crop: bool,
pub marks_bleed: bool,
pub marks_page_info: bool,
pub marks_weight: String,
pub marks_offset: String,
/// File › Export PDF › Advanced › Tagged PDF.
pub tagged: bool,
}
impl Default for PdfExportSettings {
fn default() -> Self {
PdfExportSettings {
preset: "Desktop Printing".into(),
standard: "none".into(),
compress_images: false,
flatten: "".into(),
spreads: false,
bleed: true,
marks_crop: false,
marks_bleed: false,
marks_page_info: false,
marks_weight: "0.25".into(),
marks_offset: "6".into(),
tagged: true,
}
}
}
/// Persisted UI state.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
pub brightness: theme::Brightness,
pub screen_mode: ScreenMode,
pub frame_edges: bool,
pub rulers: bool,
pub guides: bool,
pub baseline_grid: bool,
pub document_grid: bool,
pub text_threads: bool,
pub hidden_characters: bool,
/// View › Structure › Show Tagged Frames.
pub tagged_frames: bool,
/// Edit › Menus: hidden menu items (`menu/label`), and Show Full Menus.
pub hidden_menu_items: Vec<String>,
pub show_full_menus: bool,
/// Scripts panel: saved scripts (name, text).
pub scripts: Vec<(String, String)>,
/// View › Structure › Show Tag Markers.
pub tag_markers: bool,
/// View › Flattener Preview: objects that involve transparency highlighted in red.
#[serde(skip)]
pub flattener_preview: bool,
/// Tab / Shift+Tab: 1 = every panel hidden, 2 = all but the Tools panel (0 = shown).
pub hidden_panels: u8,
/// Edit › Color Settings (re-applied at start).
pub color_settings: Option<designcraft_color::cms::ColorSettings>,
/// View › Proof Colors and View › Proof Setup.
pub proof_colors: bool,
pub proof_setup: designcraft_color::cms::ProofSetup,
/// Edit › Spelling › Dynamic Spelling: misspelled words underlined on the canvas.
pub dynamic_spelling: bool,
/// Preferences › Story Editor Display: text size (points).
pub story_editor_size: f32,
/// Edit › Interface Language: supported codes are listed in `i18n::LANGUAGES`.
pub language: String,
/// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export.
pub flattener: String,
/// File › Export PDF… options (persisted; see `PdfExportSettings`).
pub pdf_export: PdfExportSettings,
/// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4).
#[serde(skip)]
pub separation: Option<u8>,
#[serde(skip)]
pub ink_limit: Option<f32>,
/// View › Display Performance.
pub display_quality: designcraft_render::DisplayQuality,
/// Preferences › Interface › UI scaling (1 = 100%).
pub ui_scale: f32,
/// Preferences › Appearance of Black: 100% K on screen as rich black.
pub rich_black: bool,
/// View › Overprint Preview.
pub overprint_preview: bool,
/// Edit › Keyboard Shortcuts: command id → shortcut ("" = none), over the defaults.
pub shortcuts: std::collections::BTreeMap<String, String>,
pub control_bar: bool,
pub tools_double_column: bool,
/// Expanded right-dock panel group tab.
pub dock_tab: String,
/// Panel opened from the collapsed icon column (flyout).
pub open_panel: Option<String>,
/// Panels torn off the dock into their own floating windows: (panel id, top-left position).
pub floating: Vec<(String, [f32; 2])>,
/// Shared panel layout after the user customizes the legacy dock.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
/// Invalidates in-flight gestures when a workspace is replaced.
#[serde(skip)]
pub docking_generation: u64,
/// One-frame reveal request; z-order is never persisted.
#[serde(skip)]
pub docking_raise: Option<String>,
/// Last explicitly revealed customized panel, for the legacy closePanel control.
#[serde(skip)]
pub docking_opened: Option<String>,
pub dock_expanded: bool,
pub units: Unit,
pub workspace: String,
/// Window › Workspace › New Workspace: saved panel arrangements.
pub custom_workspaces: Vec<SavedWorkspace>,
/// Transform reference point (0..8, row-major; 0 = top-left).
pub ref_point: u8,
/// Align To target (`selection`, `keyObject`, `margins`, `page`, `spread`).
pub align_to: String,
/// Properties > Text Style tab: 0 = Paragraph Styles, 1 = Character Styles.
pub text_style_tab: u8,
pub guides_locked: bool,
pub smart_guides: bool,
#[serde(default = "default_true")]
pub snap_to_guides: bool,
pub snap_to_document_grid: bool,
#[serde(default = "default_true")]
pub align_edges: bool,
#[serde(default = "default_true")]
pub align_centers: bool,
#[serde(default = "default_true")]
pub smart_dimensions: bool,
#[serde(default = "default_true")]
pub smart_spacing: bool,
#[serde(default = "default_zone")]
pub snap_zone: f64,
/// Window > Contextual Task Bar.
pub task_bar: bool,
/// Where the Contextual Task Bar is pinned: its top-left, in points from the canvas's top-left
/// (`None`: it follows the selection).
pub task_bar_pin: Option<[f32; 2]>,
/// Where the Contextual Task Bar was last shown, like [`Self::task_bar_pin`].
#[serde(skip)]
pub task_bar_at: Option<[f32; 2]>,
/// Help › About DesignCraft is open.
pub about: bool,
/// The About window's tab: 0 About, 1 Contributors, 2 Models (`about::ABOUT_TABS`).
#[serde(skip)]
pub about_tab: u8,
/// URLs to open in the browser on the next frame (Help links, About, start screen).
pub pending_urls: Vec<String>,
#[serde(skip)]
pub status: String,
#[serde(skip)]
pub dialog: Option<dialogs::Dialog>,
#[serde(skip)]
pub palette: Option<String>,
#[serde(skip)]
pub flyout: Option<usize>,
/// Fill/Stroke proxy (Tools and Color panels): the stroke is in front, so Apply None and the
/// Color panel act on it (X toggles).
#[serde(skip)]
pub stroke_front: bool,
}
impl Default for UiState {
fn default() -> Self {
UiState {
brightness: theme::Brightness::MediumDark,
screen_mode: ScreenMode::Normal,
frame_edges: true,
rulers: true,
guides: true,
baseline_grid: false,
document_grid: false,
text_threads: false,
hidden_characters: false,
tagged_frames: false,
hidden_menu_items: Vec::new(),
show_full_menus: false,
story_editor_size: 14.0,
scripts: vec![
("Number the pages".into(), "# A page-number frame at the bottom of the current page.\nframe.create {\"rect\": [288, 740, 324, 760], \"content\": \"text\", \"text\": \"\"}\ntext.select {\"story\": \"$0.story\", \"anchor\": 0, \"focus\": 0}\ntext.insert {\"text\": \"\\ue000\", \"raw\": true}".into()),
("Two-column grid".into(), "# Four text frames in a 2 × 2 grid.\nframe.grid {\"rect\": [36, 36, 576, 756], \"cols\": 2, \"rows\": 2, \"gutter\": 12}".into()),
],
tag_markers: false,
flattener_preview: false,
hidden_panels: 0,
color_settings: None,
proof_colors: false,
proof_setup: Default::default(),
dynamic_spelling: false,
language: String::new(),
flattener: String::new(),
pdf_export: PdfExportSettings::default(),
separation: None,
ink_limit: None,
display_quality: designcraft_render::DisplayQuality::High,
ui_scale: 1.0,
rich_black: false,
overprint_preview: false,
shortcuts: Default::default(),
control_bar: false,
tools_double_column: false,
dock_tab: "properties".into(),
open_panel: None,
floating: Vec::new(),
docking: None,
docking_hidden: Default::default(),
docking_generation: 0,
docking_raise: None,
docking_opened: None,
dock_expanded: true,
units: Unit::Picas,
workspace: "Essentials".into(),
custom_workspaces: Vec::new(),
ref_point: 0,
align_to: "selection".into(),
text_style_tab: 0,
guides_locked: false,
smart_guides: true,
snap_to_guides: true,
snap_to_document_grid: false,
align_edges: true,
align_centers: true,
smart_dimensions: true,
smart_spacing: true,
snap_zone: 4.0,
task_bar: true,
task_bar_pin: None,
task_bar_at: None,
about: false,
about_tab: 0,
pending_urls: Vec::new(),
status: String::new(),
dialog: None,
palette: None,
flyout: None,
stroke_front: false,
}
}
}
impl UiState {
/// Copies the live view switches into the snap engine's view.
pub fn snap_view(&self) -> SnapView {
SnapView {
snap_to_guides: self.snap_to_guides,
snap_to_document_grid: self.snap_to_document_grid,
show_guides: self.guides,
smart_guides: self.smart_guides,
align_edges: self.align_edges,
align_centers: self.align_centers,
smart_dimensions: self.smart_dimensions,
smart_spacing: self.smart_spacing,
zone_px: self.snap_zone,
}
}
}
/// Per-document view: zoom (screen points per document point) and the canvas point at the
/// top-left of the canvas area.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct View {
pub zoom: f64,
pub origin: Point,
pub fitted: bool,
/// View › Rotate Spread: quarter turns clockwise (0–3).
pub rotation: u8,
}
impl Default for View {
fn default() -> Self {
View { zoom: 0.5, origin: Point::new(-100.0, -100.0), fitted: false, rotation: 0 }
}
}
/// What the canvas texture currently shows.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Shown {
/// (doc uid, doc pointer, revision, preview).
pub doc: (u64, u64, u64, bool),
/// Parent and document spreads have different canvas placement and content.
pub editing_parents: bool,
/// Canvas point at the texture's top-left, zoom, and texture size in screen points.
pub origin: Point,
pub zoom: f64,
pub size: (f32, f32),
}
impl CanvasCache {
pub fn new() -> Self {
let mut renderer = designcraft_render::Renderer::new();
renderer.threads = designcraft_render::default_threads();
CanvasCache {
renderer,
texture: None,
shown: None,
pending: None,
token: 0,
#[cfg(not(target_arch = "wasm32"))]
worker: None,
worker_started: false,
last_ms: 0.0,
shown_doc: None,
pending_doc: None,
patcher: designcraft_render::Renderer::new(),
patches: 0,
}
}
}
impl Default for CanvasCache {
fn default() -> Self {
Self::new()
}
}
pub struct CanvasCache {
pub renderer: designcraft_render::Renderer,
pub texture: Option<egui::TextureHandle>,
pub shown: Option<Shown>,
/// The request in flight on the worker.
pub pending: Option<(u64, Shown)>,
pub token: u64,
#[cfg(not(target_arch = "wasm32"))]
pub worker: Option<render_worker::Worker>,
pub worker_started: bool,
pub last_ms: f64,
/// The document the texture shows (to repaint only what an edit changed).
pub shown_doc: Option<std::sync::Arc<designcraft_doc::Document>>,
/// Document of the request in flight.
pub pending_doc: Option<std::sync::Arc<designcraft_doc::Document>>,
/// Renders damaged regions (its own context: region sizes vary).
pub patcher: designcraft_render::Renderer,
/// Partial repaints / full renders since start (perf readout).
pub patches: u64,
}
#[derive(Default, Clone, Copy, Debug, Serialize)]
pub struct Perf {
pub frame_ms: f64,
pub render_ms: f64,
pub fps: f64,
}
pub struct DesignApp {
pending_pdf: Option<ImportedFile>,
pub session: Session,
pub ui: UiState,
pub services: Services,
pub views: HashMap<u64, View>,
/// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`).
pub canvas: CanvasCache,
pub canvas_rect: Option<egui::Rect>,
/// Window › Arrange › Split Window: two views of the document side by side.
pub split: bool,
/// Window › Arrange › New Window: the second view in its own window (pane 1).
pub second_window: bool,
/// The current pane (0 or 1): views, cache and rect refer to it.
pub pane: u8,
/// The pane last clicked (menu zoom and scroll go there).
pub focus_pane: u8,
pub other_pane: Option<(CanvasCache, Option<egui::Rect>)>,
/// Power Zoom in progress: the zoom to return to and the canvas point it will centre on.
pub power_zoom: Option<(f64, designcraft_geom::Point)>,
/// The persisted colour settings have been applied this session.
pub color_applied: bool,
pub perf: Perf,
pub synthetic: Vec<egui::Event>,
/// Story open in the Story Editor.
pub story_editor: Option<designcraft_doc::StoryId>,
control_rx: Option<Receiver<ControlRequest>>,
pending_shots: Vec<(u64, Option<String>, Sender<ControlResponse>, f64)>,
queued_shots: Vec<(u64, f64, u32)>,
shot_token: u64,
styled: bool,
/// The interface language the UI fonts were installed for (it orders the CJK fallbacks).
fonts_lang: String,
pub restyle: bool,
fonts_ready: bool,
pub integrated_titlebar: bool,
/// The host installed a native menu bar (macOS): don't draw menus in the window.
pub native_menu: bool,
/// Shortcuts the native menu handles (skip them in the egui shortcut handler).
pub native_shortcuts: std::collections::HashSet<String>,
last_time: f64,
/// When recovery data was last written (seconds, egui time).
pub last_recovery: f64,
/// Quit was chosen: close the window on the next frame, as its close button does.
quit_requested: bool,
/// Close the window without asking about unsaved changes (the control channel's `app.quit`).
pub(crate) quit_without_asking: bool,
/// No document is discarded until every unsaved document has an up-to-date approval.
quitting: Option<QuitState>,
}
struct QuitState {
original_active: Option<u64>,
accepted: HashMap<u64, u64>,
pending: Option<u64>,
}
impl DesignApp {
pub fn new(session: Session, services: Services) -> Self {
// The font menus and the first file opened need the installed fonts: catalog them now.
designcraft_fonts::FontDb::global().scan_in_background();
DesignApp {
pending_pdf: None,
session,
ui: UiState::default(),
services,
views: HashMap::new(),
canvas: CanvasCache::new(),
canvas_rect: None,
split: false,
second_window: false,
pane: 0,
focus_pane: 0,
other_pane: None,
power_zoom: None,
color_applied: false,
perf: Perf::default(),
synthetic: vec![],
story_editor: None,
control_rx: None,
pending_shots: vec![],
queued_shots: vec![],
shot_token: 0,
styled: false,
fonts_lang: String::new(),
restyle: false,
fonts_ready: false,
integrated_titlebar: false,
native_menu: false,
native_shortcuts: Default::default(),
last_time: 0.0,
last_recovery: 0.0,
quit_requested: false,
quit_without_asking: false,
quitting: None,
}
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
/// The view key of the active document in the current pane.
fn view_key(&self) -> Option<u64> {
let uid = self.session.active()?.uid;
Some(if self.pane == 1 { uid ^ (1 << 63) } else { uid })
}
pub fn view(&self) -> Option<&View> {
self.views.get(&self.view_key()?)
}
pub fn view_mut(&mut self) -> Option<&mut View> {
let k = self.view_key()?;
Some(self.views.entry(k).or_default())
}
/// Make `pane` current: its canvas cache and rect move in.
pub fn switch_pane(&mut self, pane: u8) {
if pane == self.pane {
return;
}
let (c, r) = self.other_pane.take().unwrap_or_default();
let old = (std::mem::replace(&mut self.canvas, c), std::mem::replace(&mut self.canvas_rect, r));
self.other_pane = Some(old);
self.pane = pane;
}
pub fn view_info(&self) -> ViewInfo {
ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: self.ui.snap_view(), unit: self.ui.units }
}
pub fn status(&mut self, s: impl Into<String>) {
self.ui.status = s.into();
}
/// THE entry point for every action (menus, shortcuts, palette, panels, control channel).
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if let Some(r) = menus::run_ui(self, id, &params) {
return r;
}
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
self.after_engine();
if let Err(e) = &r {
self.status(e.clone());
}
r
}
/// Handle requests produced by tools/commands (dialogs, view changes, file pickers).
pub fn after_engine(&mut self) {
let reqs = std::mem::take(&mut self.session.ui_requests);
for r in reqs {
match r {
UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)),
UiRequest::View { params } => canvas::apply_view_request(self, &params),
UiRequest::Pick { purpose, params } => {
let _ = params;
let _ = self.pick_and_open(&purpose);
}
}
}
// New documents get a fitted view.
if let Some(st) = self.session.active() {
let uid = st.uid;
self.views.entry(uid).or_default();
}
}
/// Ask the host for a file to open (`open`) or place (`place`). Synchronous pickers run the
/// command right away; asynchronous ones (web) deliver the file through the inbox.
pub fn pick_and_open(&mut self, purpose: &str) -> Result<Value, String> {
let target = self.session.active().map(|d| d.uid);
let cmd = if purpose == "place" { "file.place" } else { "file.open" };
if let Some(pick) = self.services.pick_open.as_mut() {
return match pick(purpose) {
// Image Import Options: a multi-page PDF asks which page.
Some(path) if purpose == "place" && path.to_ascii_lowercase().ends_with(".pdf") => {
let pages =
self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1);
if pages > 1 {
self.ui.dialog =
Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target})));
Ok(Value::Null)
} else {
self.open_file(cmd, json!({"path": path}))
}
}
Some(path) => self.open_file(cmd, json!({"path": path})),
None => Ok(Value::Null),
};
}
let request = self.import_request(purpose);
if let Some(open) = self.services.open_async.as_mut() {
open(request);
}
Ok(Value::Null)
}
pub fn import_request(&self, purpose: &str) -> ImportRequest {
ImportRequest { purpose: purpose.into(), target: self.session.active().map(|d| d.uid) }
}
pub(crate) fn activate_import_target(&mut self, request: &ImportRequest) -> Result<(), String> {
let Some(index) = request.target.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) else {
let message = "Import cancelled: the originating document has closed";
self.status(message);
return Err(message.into());
};
self.run("file.activate", json!({"index": index}))?;
Ok(())
}
pub(crate) fn cancel_pdf_import(&mut self) {
self.pending_pdf = None;
}
pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result<Value, String> {
let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?;
self.activate_import_target(&file.request)?;
self.open_file(
"file.place",
json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}),
)
}
/// A single unresolved PDF owns its bytes. Later deliveries cancel explicitly rather than
/// replacing its options. Cancellation, replacement and target closure release its payload.
fn drain_inbox(&mut self) {
if let Some(file) = &self.pending_pdf {
let target_exists = self.session.documents().iter().any(|d| Some(d.uid) == file.request.target);
let owns_dialog =
self.ui.dialog.as_ref().is_some_and(|d| d.id == "pdfImport" && d.fields.get("async").and_then(Value::as_bool) == Some(true));
if !target_exists || !owns_dialog {
self.pending_pdf = None;
if !target_exists {
if owns_dialog {
self.ui.dialog = None;
}
self.status("PDF import cancelled: the originating document has closed");
}
}
}
let Some(inbox) = self.services.inbox.clone() else { return };
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
for file in files {
if self.ui.dialog.is_some() {
self.status(format!("{}: import cancelled while a dialog is open; choose the file again", file.name));
continue;
}
let name = file.name.clone();
if let Err(e) = self.import_file(file) {
self.status(format!("{name}: {e}"));
}
}
}
fn import_file(&mut self, file: ImportedFile) -> Result<Value, String> {
let lower = file.name.to_ascii_lowercase();
let layout = opens_as_document(&file.name);
let place =
file.request.purpose == "place" || (matches!(file.request.purpose.as_str(), "drop" | "open") && !layout && !lower.ends_with(".ase"));
if place || lower.ends_with(".ase") || file.request.purpose == "swatches" {
self.activate_import_target(&file.request)?;
}
if place && lower.ends_with(".pdf") {
let pages = designcraft_render::pdf_page_count(&file.bytes).unwrap_or(1);
if pages > 1 {
self.ui.dialog =
Some(dialogs::Dialog::new("pdfImport", json!({"async": true, "name": file.name.clone(), "page": "1", "pages": pages})));
self.pending_pdf = Some(file);
return Ok(Value::Null);
}
}
let b64 = designcraft_engine::cmd::base64_encode(&file.bytes);
if lower.ends_with(".ase") || file.request.purpose == "swatches" {
self.run("swatch.load", json!({"base64": b64}))
} else if place {
self.open_file("file.place", json!({"name": file.name, "base64": b64}))
} else {
let title = file.name.rsplit_once('.').map_or(file.name.as_str(), |(stem, _)| stem);
self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
}
}
pub fn select_tool(&mut self, id: &str) {
let _ = self.run("tool.select", json!({"tool": id}));
self.ui.flyout = None;
}
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
if !self.color_applied {
self.color_applied = true;
if let Some(cs) = self.ui.color_settings.clone() {
let _ = designcraft_color::cms::set_active(&cs);
}
}
let scale = self.ui.ui_scale.clamp(0.5, 3.0);
if (ctx.zoom_factor() - scale).abs() > 1e-3 {
ctx.set_zoom_factor(scale);
}
if !self.styled {
theme::install_fonts(ctx, &self.ui.language);
self.fonts_lang = self.ui.language.clone();
self.styled = true;
self.restyle = true;
} else {
if self.fonts_lang != self.ui.language {
theme::install_fonts(ctx, &self.ui.language);
self.fonts_lang = self.ui.language.clone();
}
self.fonts_ready = true;
}
if self.restyle {
theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness));
self.restyle = false;
}
let now = ctx.input(|i| i.time);
// Crash recovery: unsaved documents are written to the recovery folder every 30 s.
#[cfg(not(target_arch = "wasm32"))]
if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(5.0) {
self.last_recovery = now;
if self.session.documents().iter().any(|d| d.is_dirty())
&& let Err(e) = self.session.execute("file.recovery.save", &json!({}))
{
self.status(format!("Couldn't write recovery data: {e}"));
}
}
let dt = now - self.last_time;
if dt > 0.0 {
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
}
self.last_time = now;
self.drain_control(ctx);
if self.fonts_ready {
self.drain_inbox();
}
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
if self.fonts_ready {
menus::shortcuts(self, ctx);
}
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let p = f.path().to_string_lossy().to_string();
if !p.is_empty() {
let _ = self.open_dropped(&p);
}
}
self.handle_quit(ctx);
}
/// Quit: close the window the way its close button does, so unsaved documents ask first.
pub fn request_quit(&mut self) {
self.quit_requested = true;
}
/// Ask about unsaved documents without closing any of them yet. A later Cancel must also
/// keep documents whose earlier answer was Don't Save, including their history and recovery.
fn handle_quit(&mut self, ctx: &egui::Context) {
if std::mem::take(&mut self.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if ctx.input(|i| i.viewport().close_requested()) {
if self.quit_without_asking {
self.quitting = None;
return;
}
if self.quitting.is_none() && !self.session.documents().iter().any(|d| d.is_dirty()) {
return;
}
let approved =
self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none();
if approved {
// Commit only when the backend receives the final close, after checking for
// edits that arrived since the last answer. Closing also discards recovery data.
if let Err(e) = self.finish_confirmed_quit() {
self.status(e);
self.cancel_quit();
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
return;
}
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
// Dock Quit can arrive while the app is hidden or minimized. Make its question
// visible through the normal safe window commands before asking about documents.
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
if self.quitting.is_none() {
self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None });
}
}
let Some(q) = self.quitting.as_ref() else { return };
if let Some(uid) = q.pending {
let asking = self.ui.dialog.as_ref().is_some_and(|d| {
d.id == "closeDocument"
&& d.fields.get("quitting").and_then(Value::as_bool) == Some(true)
&& d.fields.get("uid").and_then(Value::as_u64) == Some(uid)
});
if !asking {
self.cancel_quit();
}
return;
}
if self.ui.dialog.is_some() {
self.cancel_quit();
return;
}
self.ask_next_unsaved(ctx);
}
/// Finalize a complete, still-current quit plan at an orderly host exit. The host can
/// terminate before egui receives its queued Close; an unfinished/stale plan leaves every
/// document live so the host can preserve its recovery data instead.
pub fn finish_confirmed_quit(&mut self) -> Result<(), String> {
let approved =
self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none();
if !approved {
return Ok(());
}
while !self.session.documents().is_empty() {
self.run("file.close", json!({"index": 0}))?;
}
self.quitting = None;
Ok(())
}
fn next_unapproved_document(&self) -> Option<(usize, u64)> {
let q = self.quitting.as_ref()?;
self.session
.documents()
.iter()
.enumerate()
.find(|(_, d)| d.is_dirty() && q.accepted.get(&d.uid) != Some(&d.revision))
.map(|(i, d)| (i, d.uid))
}
fn cancel_quit(&mut self) {
let Some(q) = self.quitting.take() else { return };
if let Some(i) = q.original_active.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) {
let _ = self.run("file.activate", json!({"index": i}));
}
}
/// Save/Don't Save during Quit approves this UID's current revision, without destroying it.
pub(crate) fn confirm_quit_document(&mut self, uid: u64, discard: bool) -> Result<Value, String> {
if self.quitting.as_ref().and_then(|q| q.pending) != Some(uid) {
return Err("Quit confirmation is no longer active".into());
}
let Some(i) = self.session.documents().iter().position(|d| d.uid == uid) else {
if let Some(q) = self.quitting.as_mut() {
q.pending = None;
}
return Ok(Value::Null);
};
if !discard {
let saved = self.run("file.activate", json!({"index": i})).and_then(|_| self.run("app.save", json!({})));
if let Err(e) = saved {
self.cancel_quit();
return Err(e);
}
if self.session.documents().iter().find(|d| d.uid == uid).is_none_or(|d| d.is_dirty()) {
// No file chosen (or an asynchronous host save has not completed): stop quitting.
self.cancel_quit();
return Ok(Value::Null);
}
}
if let Some(q) = self.quitting.as_mut() {
if let Some(d) = self.session.documents().iter().find(|d| d.uid == uid) {
q.accepted.insert(uid, d.revision);
}
q.pending = None;
}
Ok(Value::Null)
}
/// Show the next unapproved document, or request a final close that revalidates all approvals.
fn ask_next_unsaved(&mut self, ctx: &egui::Context) {
let Some((i, uid)) = self.next_unapproved_document() else {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
return;
};
if self.run("file.activate", json!({"index": i})).is_err() {
self.cancel_quit();
return;
}
menus::close_document(self, Some(i));
if let Some(d) = self.ui.dialog.as_mut() {
d.fields.insert("quitting".into(), json!(true));
}
if let Some(q) = self.quitting.as_mut() {
q.pending = Some(uid);
}
}
/// A file dropped on the window: documents open, anything else is placed.
pub fn open_dropped(&mut self, path: &str) -> Result<Value, String> {
let cmd = if opens_as_document(path) { "file.open" } else { "file.place" };
self.open_file(cmd, json!({"path": path}))
}
/// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file
/// that can't be opened or placed gets an alert with the reason, as well as the status line.
/// A successful import with warnings keeps the document and shows the warnings in an alert.
pub fn open_file(&mut self, cmd: &str, params: Value) -> Result<Value, String> {
let file = match params.get("path").and_then(Value::as_str) {
Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()),
None => params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
};
let r = self.run(cmd, params);
if let Err(e) = &r {
let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" };
self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e})));
} else if let Ok(result) = &r {
let warnings: Vec<&str> = result
.get("warnings")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.filter(|warning| !warning.is_empty())
.collect();
if !warnings.is_empty() {
self.status(format!("{file}: {}", warnings.join("; ")));
self.ui.dialog =
Some(dialogs::Dialog::new("alert", json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n")})));
}
}
r
}
/// Inject synthetic events (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
return;
}
let n = match self.synthetic[0] {
egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
raw.events.push(egui::Event::PointerMoved(*p));
}
raw.events.extend(self.synthetic.drain(..n));
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();
return;
}
let t0 = now_ms();
let t = theme::Tokens::get(&ctx);
let presenting = self.ui.screen_mode == ScreenMode::Presentation;
if presenting || self.ui.hidden_panels != 0 {
panel_docking::cancel_gesture(self, &ctx);
}
let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some();
if dbg {
eprintln!("root start {:?}", ui.available_rect_before_wrap());
}
if !presenting {
chrome::app_bar(self, ui);
if dbg {
eprintln!("after app bar {:?}", ui.available_rect_before_wrap());
}
if self.ui.control_bar && self.session.active().is_some() && self.ui.hidden_panels == 0 {
chrome::control_bar(self, ui);
}
chrome::status_bar(self, ui);
if dbg {
eprintln!("after status {:?}", ui.available_rect_before_wrap());
}
if self.ui.hidden_panels != 1 {
toolbar::show(self, ui);
}
if self.ui.hidden_panels == 0 {
dock::show(self, ui);
}
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {
if self.session.active().is_none() {
chrome::start_screen(self, ui);
return;
}
if !presenting {
chrome::doc_tabs(self, ui);
}
let split_layout = (self.split && !presenting)
.then(|| {
craft_ui::layout::split_rect(
ui.available_rect_before_wrap(),
craft_ui::layout::SplitAxis::Horizontal,
craft_ui::layout::SplitSize::Ratio(0.5),
2.0,
0.0,
)
})
.flatten();
if let Some(layout) = split_layout {
// Two panes with a divider; each has its own view (zoom, scroll) and render cache.
ui.painter().rect_filled(layout.divider, 0.0, t.border);
// Resolve the clicked pane before either canvas handles this frame's keys.
if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) {
if layout.first.contains(p) {
self.focus_pane = 0;
} else if layout.second.contains(p) {
self.focus_pane = 1;
}
}
for (k, half) in [layout.first, layout.second].into_iter().enumerate() {
self.switch_pane(k as u8);
// A new pane starts at the other pane's view.
if k == 1 && self.view().is_none() {
self.switch_pane(0);
let v = self.view().copied();
self.switch_pane(1);
if let (Some(mut v), Some(m)) = (v, self.view_mut()) {
v.fitted = false;
*m = v;
}
}
ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui));
}
self.switch_pane(self.focus_pane);
} else {
self.switch_pane(0);
// With a second window, the main one takes the focus back when clicked.
if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) {
self.focus_pane = 0;
}
canvas::show(self, ui);
}
});
// New Window: pane 1 in a window of its own.
if self.second_window
&& !presenting
&& let Some(title) = self.session.active().map(|d| format!("{} — 2", d.doc.title))
{
let fill = t.pasteboard;
let ctx = ui.ctx().clone();
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("designcraft_second_window"),
egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 720.0]),
|ui, _class| {
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| {
self.switch_pane(1);
if self.view().is_none() {
self.switch_pane(0);
let v = self.view().copied();
self.switch_pane(1);
if let (Some(mut v), Some(m)) = (v, self.view_mut()) {
v.fitted = false;
*m = v;
}
}
if ui.input(|i| i.pointer.any_pressed()) {
self.focus_pane = 1;
}
canvas::show(self, ui);
});
if ui.input(|i| i.viewport().close_requested()) {
self.second_window = false;
self.focus_pane = 0;
}
},
);
self.switch_pane(self.focus_pane);
}
dock::flyout(self, &ctx);
dock::floating(self, &ctx);
story_editor::show(self, &ctx);
dialogs::show(self, &ctx);
about::show(self, &ctx);
menus::palette(self, &ctx);
for url in std::mem::take(&mut self.ui.pending_urls) {
ctx.open_url(egui::OpenUrl::new_tab(url));
}
self.perf.frame_ms = now_ms() - t0;
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path } => {
self.shot_token += 1;
let token = self.shot_token;
self.queued_shots.push((token, now_ms() + 120.0, 0));
self.pending_shots.push((token, path, reply, now_ms() + 8000.0));
}
}
}
self.control_rx = Some(rx);
}
fn issue_screenshots(&mut self, ctx: &egui::Context) {
let now = now_ms();
self.queued_shots.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
false
} else {
true
}
});
if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
fn collect_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_shots.is_empty() {
return;
}
let events: Vec<_> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
Some((token, image.clone()))
}
_ => None,
})
.collect()
});
for (token, image) in events {
if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) {
let (_, path, reply, _) = self.pending_shots.remove(i);
let _ = reply.send(control::save_screenshot(self, &image, path.as_deref()));
}
}
let now = now_ms();
self.pending_shots.retain(|(_, _, reply, deadline)| {
if now < *deadline {
return true;
}
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"}));
false
});
}
}
/// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft
/// and IDML.
pub fn opens_as_document(name: &str) -> bool {
let n = name.to_ascii_lowercase();
[".designcraft", ".idml"].iter().any(|ext| n.ends_with(ext))
}
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(target_arch = "wasm32")]
{
0.0
}
}
/// Is `text` the name of a key ("AltGraph", "Dead", "AudioVolumeUp") instead of typed text?
/// Browsers report a key press as a string: the character it types, or a name made of ASCII
/// letters and digits that starts with a capital. One key press never types such a word.
pub fn is_key_name(text: &str) -> bool {
text.len() > 1 && text.starts_with(|c: char| c.is_ascii_uppercase()) && text.chars().all(|c| c.is_ascii_alphanumeric())
}
/// Drop text events that are key names. eframe's web backend sends the name of every key it
/// doesn't know as text, so AltGr typed "AltGraph" into the story. `text_field_focused`: an
/// egui text field has the keyboard; its text comes from the browser's input events, which can
/// hold whole words, and is left alone.
pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) {
if !text_field_focused {
raw.events.retain(|e| !matches!(e, egui::Event::Text(t) if is_key_name(t)));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// An app with one document that has an unsaved edit.
fn app_with_unsaved_document() -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
app.run("file.new", json!({})).unwrap();
assert!(!app.session.documents()[0].is_dirty(), "a new document has nothing to save");
app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap();
assert!(app.session.documents()[0].is_dirty());
app
}
/// File ▸ Close (and its shortcut) closed a document with unsaved changes without asking, and
/// closing discards the document's recovery data too.
#[test]
fn closing_an_unsaved_document_asks_first() {
let mut app = app_with_unsaved_document();
menus::activate(&mut app, "file.close", &Value::Null);
assert_eq!(app.session.documents().len(), 1, "the document stays open until the user answers");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("closeDocument"));
// Cancel: nothing happens.
app.ui.dialog = None;
assert!(app.session.documents()[0].is_dirty());
// The tab's × asks too.
menus::close_document(&mut app, Some(0));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("closeDocument"));
assert_eq!(app.session.documents().len(), 1);
}
#[test]
fn closing_without_saving_discards_the_document() {
let mut app = app_with_unsaved_document();
menus::close_document(&mut app, None);
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
dialogs::confirm(&mut app).unwrap();
assert!(app.session.documents().is_empty());
assert!(app.ui.dialog.is_none());
}
#[test]
fn closing_with_save_writes_the_file_then_closes() {
let mut app = app_with_unsaved_document();
let path = std::env::temp_dir().join(format!("designcraft-close-test-{}.designcraft", std::process::id()));
app.run("file.saveAs", json!({"path": path})).unwrap();
let saved = std::fs::metadata(&path).unwrap().len();
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
menus::close_document(&mut app, None);
dialogs::confirm(&mut app).unwrap();
assert!(app.session.documents().is_empty());
let written = std::fs::metadata(&path).unwrap().len();
std::fs::remove_file(&path).unwrap();
assert!(written > saved, "the second frame was saved ({saved} → {written} bytes)");
}
/// Save on a document that has no file opens the file picker; cancelling it must not close the
/// document. (No picker service here: the same as cancelling.)
#[test]
fn a_cancelled_save_keeps_the_document_open() {
let mut app = app_with_unsaved_document();
menus::close_document(&mut app, None);
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.documents().len(), 1);
assert!(app.session.documents()[0].is_dirty());
}
#[test]
fn closing_a_saved_document_does_not_ask() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
app.run("file.new", json!({})).unwrap();
menus::activate(&mut app, "file.close", &Value::Null);
assert!(app.session.documents().is_empty());
assert!(app.ui.dialog.is_none());
}
/// One frame of the quit handling, with the window asked to close when `close`; returns the
/// commands sent to the window.
fn quit_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) -> Vec<egui::ViewportCommand> {
let mut raw = egui::RawInput::default();
if close {
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
}
let mut out = ctx.run_ui(raw, |ui| app.handle_quit(ui.ctx()));
out.textures_delta.clear();
out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
}
fn dialog_uid(app: &DesignApp) -> Option<u64> {
app.ui.dialog.as_ref().filter(|d| d.id == "closeDocument").and_then(|d| d.fields.get("uid")?.as_u64())
}
/// Quitting closed the window at once, losing unsaved changes (#544).
#[test]
fn quitting_with_unsaved_changes_asks_and_cancel_keeps_everything() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
let cmds = quit_frame(&mut app, &ctx, true);
assert!(cmds.contains(&egui::ViewportCommand::CancelClose), "{cmds:?}");
assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid));
// Cancel: the window and the document stay as they were.
app.ui.dialog = None;
for _ in 0..2 {
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
}
assert_eq!(app.session.documents().len(), 1);
assert!(app.session.documents()[0].is_dirty());
// A Save whose file picker is cancelled stops quitting too.
quit_frame(&mut app, &ctx, true);
dialogs::confirm(&mut app).unwrap();
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert!(app.session.documents()[0].is_dirty());
}
#[test]
fn quitting_asks_about_each_unsaved_document_then_closes() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap();
app.run("file.new", json!({})).unwrap();
let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
for (n, uid) in uids.iter().take(2).enumerate() {
assert_eq!(dialog_uid(&app), Some(*uid), "question {n}");
assert_eq!(app.session.active().map(|d| d.uid), Some(*uid), "the document asked about is shown");
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
dialogs::confirm(&mut app).unwrap();
let cmds = quit_frame(&mut app, &ctx, false);
assert_eq!(cmds.contains(&egui::ViewportCommand::Close), n == 1, "{cmds:?}");
}
// All documents stay open until the backend's final close commits the decision.
assert_eq!(app.session.documents().len(), 3);
assert!(quit_frame(&mut app, &ctx, true).is_empty());
assert!(app.session.documents().is_empty());
}
/// A later Cancel must not destroy documents already approved for discard.
#[test]
fn quitting_later_cancel_retains_every_document_and_active_tab() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
let active = app.session.active().unwrap().uid;
let revisions: Vec<u64> = app.session.documents().iter().map(|d| d.revision).collect();
let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect();
quit_frame(&mut app, &ctx, true);
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
dialogs::confirm(&mut app).unwrap();
quit_frame(&mut app, &ctx, false);
assert_eq!(dialog_uid(&app), Some(uids[1]));
app.ui.dialog = None;
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::<Vec<_>>(), uids);
assert_eq!(app.session.active().unwrap().uid, active);
assert_eq!(app.session.documents().iter().map(|d| d.revision).collect::<Vec<_>>(), revisions);
for (d, original) in app.session.documents().iter().zip(docs) {
assert!(std::sync::Arc::ptr_eq(&d.doc, &original));
assert!(d.is_dirty());
assert!(!d.history.undo.is_empty());
}
}
fn second_unsaved_document(app: &mut DesignApp) {
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
}
fn discard_quit_dialog(app: &mut DesignApp) {
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
dialogs::confirm(app).unwrap();
}
#[test]
fn native_quit_prompts_restore_window_visibility_without_focusing_completed_quits() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
let commands = quit_frame(&mut app, &ctx, true);
assert!(commands.contains(&egui::ViewportCommand::CancelClose));
for command in [egui::ViewportCommand::Visible(true), egui::ViewportCommand::Minimized(false), egui::ViewportCommand::Focus] {
assert!(commands.contains(&command), "unsaved prompt must restore the window: {commands:?}");
}
discard_quit_dialog(&mut app);
let approved = quit_frame(&mut app, &ctx, false);
assert!(approved.contains(&egui::ViewportCommand::Close));
let completed = quit_frame(&mut app, &ctx, true);
assert!(app.session.documents().is_empty());
let mut clean = DesignApp::new(designcraft_engine::Session::new(), Services::default());
clean.run("file.new", json!({})).unwrap();
let clean_close = quit_frame(&mut clean, &ctx, true);
let mut control = app_with_unsaved_document();
control.quit_without_asking = true;
let control_close = quit_frame(&mut control, &ctx, true);
for commands in [&approved, &completed, &clean_close, &control_close] {
assert!(
!commands.iter().any(|command| matches!(
command,
egui::ViewportCommand::Visible(true) | egui::ViewportCommand::Minimized(false) | egui::ViewportCommand::Focus
)),
"completed and immediate quits must not activate the window: {commands:?}"
);
}
}
/// Dock Quit may arrive again while the first request's dialogs are still open.
#[test]
fn repeated_native_quit_requests_preserve_partial_approvals_and_cancel() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let original_active = app.session.active().unwrap().uid;
let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect();
let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect();
let directory = tempfile::tempdir().unwrap();
app.session.recovery_dir = Some(directory.path().to_path_buf());
app.run("file.recovery.save", json!({})).unwrap();
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(uids[0]));
discard_quit_dialog(&mut app);
quit_frame(&mut app, &ctx, false);
assert_eq!(dialog_uid(&app), Some(uids[1]));
for _ in 0..3 {
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(uids[1]));
}
app.ui.dialog = None; // Cancel the second document, retaining the first Don't Save answer.
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.active().unwrap().uid, original_active);
assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::<Vec<_>>(), uids);
for (document, original) in app.session.documents().iter().zip(docs) {
assert!(std::sync::Arc::ptr_eq(&document.doc, &original));
assert!(document.is_dirty());
assert!(!document.history.undo.is_empty());
assert!(directory.path().join(format!("{}.designcraft", document.uid)).exists());
}
// A fresh Dock Quit must ask about the first document again after cancellation.
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(uids[0]));
}
#[test]
fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect();
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("saved.designcraft");
let chosen = path.to_string_lossy().to_string();
app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone())));
app.session.recovery_dir = Some(directory.path().join("recovery"));
app.run("file.recovery.save", json!({})).unwrap();
quit_frame(&mut app, &ctx, true);
assert_eq!(dialog_uid(&app), Some(uids[0]));
dialogs::confirm(&mut app).unwrap(); // Save the first document.
assert!(!app.session.documents()[0].is_dirty());
quit_frame(&mut app, &ctx, false);
assert_eq!(dialog_uid(&app), Some(uids[1]));
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(uids[1]));
discard_quit_dialog(&mut app);
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.documents().len(), 2);
assert!(app.session.recovery_dir.as_ref().unwrap().join(format!("{}.designcraft", uids[1])).exists());
assert!(quit_frame(&mut app, &ctx, true).is_empty());
assert!(app.session.documents().is_empty());
assert!(path.exists());
assert_eq!(designcraft_engine::Session::new().execute("file.open", &json!({"path": path})).unwrap()["index"], 0);
assert_eq!(std::fs::read_dir(app.session.recovery_dir.as_ref().unwrap()).unwrap().count(), 0);
}
#[test]
fn quitting_first_cancel_restores_original_clean_active_document() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
app.run("file.new", json!({})).unwrap();
let original_active = app.session.active().unwrap().uid;
quit_frame(&mut app, &ctx, true);
assert_ne!(app.session.active().unwrap().uid, original_active);
app.ui.dialog = None;
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.active().unwrap().uid, original_active);
assert_eq!(app.session.documents().len(), 2);
}
#[test]
fn quitting_save_as_cancel_after_discard_keeps_all_documents() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let active = app.session.active().unwrap().uid;
quit_frame(&mut app, &ctx, true);
discard_quit_dialog(&mut app);
quit_frame(&mut app, &ctx, false);
dialogs::confirm(&mut app).unwrap(); // No picker: Save As was cancelled.
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.documents().len(), 2);
assert!(app.session.documents().iter().all(|d| d.is_dirty()));
assert_eq!(app.session.active().unwrap().uid, active);
}
#[test]
fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let path = std::env::temp_dir()
.join(format!("designcraft-quit-missing-{}-{}", std::process::id(), app.session.active().unwrap().uid))
.join("missing")
.join("document.designcraft");
assert!(!path.parent().unwrap().exists());
let picked = path.to_string_lossy().to_string();
app.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
quit_frame(&mut app, &ctx, true);
discard_quit_dialog(&mut app);
quit_frame(&mut app, &ctx, false);
assert!(dialogs::confirm(&mut app).is_err());
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.documents().len(), 2);
assert!(app.session.documents().iter().all(|d| d.is_dirty()));
// Starting over must ask about the previously discarded first document again.
quit_frame(&mut app, &ctx, true);
assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid));
}
#[test]
fn quitting_save_then_later_cancel_keeps_saved_document_open() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let first = app.session.documents()[0].uid;
let path = std::env::temp_dir().join(format!("designcraft-quit-save-{}-{first}.designcraft", std::process::id()));
let picked = path.to_string_lossy().to_string();
app.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
quit_frame(&mut app, &ctx, true);
dialogs::confirm(&mut app).unwrap();
quit_frame(&mut app, &ctx, false);
assert_eq!(app.session.documents().len(), 2);
assert!(!app.session.documents()[0].is_dirty());
assert!(path.exists());
app.ui.dialog = None;
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.documents()[0].uid, first);
assert!(app.session.documents()[1].is_dirty());
std::fs::remove_file(path).unwrap();
}
#[test]
fn quitting_rechecks_discard_approval_after_a_later_edit() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let first = app.session.documents()[0].uid;
quit_frame(&mut app, &ctx, true);
discard_quit_dialog(&mut app);
quit_frame(&mut app, &ctx, false);
app.run("file.activate", json!({"index": 0})).unwrap();
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
discard_quit_dialog(&mut app); // Approval is bound to the second UID, despite the switch.
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(dialog_uid(&app), Some(first), "new edit needs a fresh decision");
assert_eq!(app.session.documents().len(), 2);
}
#[test]
fn quitting_final_close_rechecks_new_unsaved_document() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
quit_frame(&mut app, &ctx, true);
discard_quit_dialog(&mut app);
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
second_unsaved_document(&mut app); // Arrives after Close was queued, before its event.
let new_uid = app.session.active().unwrap().uid;
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(new_uid));
assert_eq!(app.session.documents().len(), 2);
}
#[test]
fn quitting_pending_save_uses_dialog_uid_after_tab_switch() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let first = app.session.documents()[0].uid;
let path = std::env::temp_dir().join(format!("designcraft-quit-uid-{}-{first}.designcraft", std::process::id()));
let picked = path.to_string_lossy().to_string();
app.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
quit_frame(&mut app, &ctx, true);
app.run("file.activate", json!({"index": 1})).unwrap();
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.documents()[0].path.as_deref(), path.to_str());
assert!(!app.session.documents()[0].is_dirty());
assert!(app.session.documents()[1].is_dirty());
quit_frame(&mut app, &ctx, false);
assert_eq!(dialog_uid(&app), Some(app.session.documents()[1].uid));
std::fs::remove_file(path).unwrap();
}
#[test]
fn quitting_discard_preserves_recovery_until_final_commit() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
let uid = app.session.active().unwrap().uid;
let dir = std::env::temp_dir().join(format!("designcraft-quit-recovery-{}-{uid}", std::process::id()));
app.session.recovery_dir = Some(dir.clone());
app.run("file.recovery.save", json!({})).unwrap();
let entry = dir.join(format!("{uid}.designcraft"));
quit_frame(&mut app, &ctx, true);
discard_quit_dialog(&mut app);
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert!(entry.exists(), "a queued close is not a committed close");
assert!(quit_frame(&mut app, &ctx, true).is_empty());
assert!(!entry.exists(), "accepted Don't Save must not be recovered on restart");
assert!(app.session.documents().is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn quitting_native_exit_commits_completed_approvals_before_close_event() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
quit_frame(&mut app, &ctx, true);
discard_quit_dialog(&mut app);
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.documents().len(), 1);
app.finish_confirmed_quit().unwrap();
assert!(app.session.documents().is_empty());
}
#[test]
fn quitting_native_exit_retains_incomplete_stale_and_new_document_plans() {
for case in ["incomplete", "stale", "new document"] {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
if case == "incomplete" {
second_unsaved_document(&mut app);
}
quit_frame(&mut app, &ctx, true);
discard_quit_dialog(&mut app);
quit_frame(&mut app, &ctx, false);
if case == "stale" {
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
} else if case == "new document" {
second_unsaved_document(&mut app);
}
let active = app.session.active().unwrap().uid;
let before: Vec<_> = app
.session
.documents()
.iter()
.map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()))
.collect();
app.finish_confirmed_quit().unwrap();
assert_eq!(app.session.documents().len(), before.len(), "{case}");
assert_eq!(app.session.active().unwrap().uid, active, "{case}");
for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) {
assert_eq!(
(d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()),
(uid, revision, &selection, undo, redo),
"{case}"
);
assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}");
}
}
}
#[test]
fn quitting_without_unsaved_changes_closes_at_once() {
let ctx = egui::Context::default();
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
app.run("file.new", json!({})).unwrap();
// Quit (⌘Q) closes the window like its close button.
app.run("app.quit", json!({})).unwrap();
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert!(quit_frame(&mut app, &ctx, true).is_empty());
assert!(app.ui.dialog.is_none());
}
/// Scripts must not hang on a question: the control channel's quit never asks.
#[test]
fn control_channel_quit_does_not_ask() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
let (req, _rx) = control::ControlRequest::new("app.quit", json!({}));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
control::handle(&mut app, ui.ctx(), &req);
});
out.textures_delta.clear();
assert!(quit_frame(&mut app, &ctx, true).is_empty());
assert!(app.ui.dialog.is_none());
}
/// The Control panel's two-row groups were centred as if they were one row tall, so the second
/// row hung below the panel and was cut off.
#[test]
fn control_bar_shows_its_second_row() {
use egui_kittest::kittest::Queryable as _;
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
app.run("file.newSample", json!({})).unwrap();
app.ui.control_bar = true;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("control_bar")).unwrap().outer_rect;
assert!(bar.height() > 40.0, "{bar:?}");
let rows: Vec<egui::Rect> = h.query_all_by_label("Y:").map(|n| n.rect()).filter(|r| bar.contains(r.left_top())).collect();
assert_eq!(rows.len(), 1, "the Y: caption of the Control panel");
assert!(rows[0].bottom() <= bar.bottom(), "Y: caption {:?} hangs below the Control panel {bar:?}", rows[0]);
}
#[test]
fn key_names_sent_as_text_are_dropped() {
let text = |t: &str| egui::Event::Text(t.to_string());
let events = || {
vec![text("AltGraph"), text("é"), text("Dead"), text("A"), text("Unidentified"), text("ß"), text("AudioVolumeUp"), text("ab"), text("Æ")]
};
let mut raw = egui::RawInput { events: events(), ..Default::default() };
drop_key_name_text(&mut raw, false);
assert_eq!(raw.events, vec![text("é"), text("A"), text("ß"), text("ab"), text("Æ")]);
// A focused text field gets its text from input events, which can be whole words.
let mut raw = egui::RawInput { events: events(), ..Default::default() };
drop_key_name_text(&mut raw, true);
assert_eq!(raw.events, events());
}
#[test]
fn snap_view_uses_the_saved_switches() {
let mut ui = UiState::default();
assert!(ui.snap_view().snap_to_guides);
assert!(!ui.snap_view().snap_to_document_grid);
assert_eq!(ui.snap_view().zone_px, 4.0);
ui.snap_to_guides = false;
ui.snap_zone = 0.0;
assert!(!ui.snap_view().snap_to_guides);
assert_eq!(ui.snap_view().zone_px, 0.0);
}
#[test]
fn pdf_export_settings_round_trip() {
let ui = UiState::default();
let json = serde_json::to_string(&ui).unwrap();
let parsed: UiState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.pdf_export, PdfExportSettings::default());
}
#[test]
fn pdf_export_settings_defaults() {
let settings = PdfExportSettings::default();
assert_eq!(settings.preset, "Desktop Printing");
assert_eq!(settings.standard, "none");
assert!(!settings.compress_images);
assert_eq!(settings.flatten, "");
assert!(!settings.spreads);
assert!(settings.bleed);
assert!(!settings.marks_crop);
assert!(!settings.marks_bleed);
assert!(!settings.marks_page_info);
assert_eq!(settings.marks_weight, "0.25");
assert_eq!(settings.marks_offset, "6");
assert!(settings.tagged);
}
#[test]
fn pdf_export_settings_survive_missing_key() {
let json = r#"{ "language": "en" }"#;
let ui: UiState = serde_json::from_str(json).unwrap();
assert_eq!(ui.pdf_export, PdfExportSettings::default());
}
/// A temporary file named `name` whose bytes aren't a document or a graphic.
fn unreadable_file(name: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}", std::process::id()));
std::fs::write(&path, b"neither a document nor a graphic").unwrap();
path
}
/// The open alert dialog: (title, message).
fn alert(app: &DesignApp) -> (String, String) {
let d = app.ui.dialog.as_ref().expect("an alert is open");
assert_eq!(d.id, "alert");
(d.fields["title"].as_str().unwrap().to_string(), d.fields["message"].as_str().unwrap().to_string())
}
#[test]
fn dropped_documents_open_and_the_rest_is_placed() {
for name in ["a.designcraft", "B.IDML"] {
assert!(opens_as_document(name), "{name}");
}
for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip"] {
assert!(!opens_as_document(name), "{name}");
}
}
#[test]
fn dropping_an_unreadable_file_alerts() {
// A document opens, so it says it can't be opened.
let path = unreadable_file("broken.designcraft");
let mut app = DesignApp::new(Session::new(), Services::default());
let r = app.open_dropped(&path.to_string_lossy());
let _ = std::fs::remove_file(&path);
let e = r.unwrap_err();
let (title, message) = alert(&app);
assert_eq!(title, "Can't Open the File", "a document opens, it isn't placed");
assert_eq!(message, e);
assert!(app.ui.status.contains(&e), "the status line says it too");
assert!(app.session.documents().is_empty());
dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "OK closes the alert");
// Anything else is placed, so it says it can't be placed.
app.run("file.new", json!({})).unwrap();
let path = unreadable_file("notes.xyz");
let r = app.open_dropped(&path.to_string_lossy());
let _ = std::fs::remove_file(&path);
let e = r.unwrap_err();
assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
}
#[test]
fn file_open_failures_show_an_alert() {
let path = unreadable_file("open.designcraft");
let picked = path.to_string_lossy().to_string();
let services = Services { pick_open: Some(Box::new(move |_| Some(picked.clone()))), ..Default::default() };
let mut app = DesignApp::new(Session::new(), services);
let e = app.run("app.openDialog", json!({})).unwrap_err();
assert_eq!(alert(&app), ("Can't Open the File".to_string(), e));
// Placing it says so too.
app.ui.dialog = None;
app.run("file.new", json!({})).unwrap();
let e = app.run("app.placeDialog", json!({})).unwrap_err();
let _ = std::fs::remove_file(&path);
assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
}
#[test]
fn other_command_errors_stay_in_the_status_line() {
let mut app = DesignApp::new(Session::new(), Services::default());
assert!(app.run("file.open", json!({"path": "/nonexistent/x.designcraft"})).is_err());
assert!(app.ui.dialog.is_none(), "a scripted file.open doesn't raise an alert");
assert!(!app.ui.status.is_empty());
// The control channel's app.open answers with the error; no alert waits for a click.
let (req, _rx) = control::ControlRequest::new("app.open", json!({"path": "/nonexistent/x.designcraft"}));
let control::Outcome::Done(r) = control::handle(&mut app, &egui::Context::default(), &req) else { panic!("app.open answers") };
assert_eq!(r["ok"], json!(false), "{r}");
assert!(app.ui.dialog.is_none(), "a control-channel app.open doesn't raise an alert");
}
}
#[cfg(test)]
mod import_warning_tests {
use super::*;
use std::io::Write;
fn idml(missing_parts: bool) -> Vec<u8> {
let missing = if missing_parts { r#"<idPkg:Spread src="Spreads/missing.xml"/><idPkg:Story src="Stories/missing.xml"/>"# } else { "" };
let map = format!(
r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d"><idPkg:Spread src="Spreads/one.xml"/>{missing}</Document>"#
);
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Spread Self="s"><Page Self="p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/><Rectangle Self="r" ItemTransform="1 0 0 1 0 0"/></Spread></idPkg:Spread>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, text) in [("mimetype", designcraft_idml::MIMETYPE), ("designmap.xml", &map), ("Spreads/one.xml", spread)] {
zip.start_file(name, options).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn app() -> DesignApp {
let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() });
app.run("file.new", json!({"title": "Existing work"})).unwrap();
app.run("frame.create", json!({"rect": [10, 10, 50, 50]})).unwrap();
app
}
fn assert_partial_import_warning(app: &mut DesignApp, file: &str) {
let dialog = app.ui.dialog.as_ref().expect("partial import must show an alert");
assert_eq!(dialog.id, "alert");
assert_eq!(dialog.fields["title"], "Import Warnings");
assert_eq!(dialog.fields["file"], file);
for part in ["Spreads/missing.xml", "Stories/missing.xml"] {
assert!(dialog.fields["message"].as_str().unwrap().contains(part), "every missing part is named");
assert!(app.ui.status.contains(part), "the status line retains the warning");
}
assert_eq!(app.session.documents().len(), 2);
assert_eq!(app.session.documents()[0].doc.title, "Existing work");
assert!(app.session.documents()[0].is_dirty(), "existing unsaved work stays intact");
assert_eq!(app.session.documents()[0].doc.spreads[0].items.len(), 1);
assert_eq!(app.session.doc().unwrap().doc.spreads.len(), 1);
assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), 1, "available document content is kept");
dialogs::confirm(app).unwrap();
assert!(app.ui.dialog.is_none(), "OK dismisses the warning");
assert_eq!(app.session.documents().len(), 2, "dismissing the warning keeps the imported document");
}
#[test]
fn byte_open_reports_partial_import_warnings_without_losing_documents() {
let mut app = app();
let result =
app.open_file("file.openBytes", json!({"name": "partial.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(true))})).unwrap();
assert_eq!(result["warnings"].as_array().unwrap().len(), 2);
assert_partial_import_warning(&mut app, "partial.idml");
}
#[test]
fn native_open_reports_partial_import_warnings_without_losing_documents() {
let path = std::env::temp_dir().join(format!("designcraft-partial-import-{}", std::process::id())).join("partial.idml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, idml(true)).unwrap();
let picked = path.to_string_lossy().to_string();
let mut app = app();
app.services.pick_open = Some(Box::new(move |_| Some(picked.clone())));
let result = app.run("app.openDialog", json!({}));
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(path.parent().unwrap()).unwrap();
assert_eq!(result.unwrap()["warnings"].as_array().unwrap().len(), 2);
assert_partial_import_warning(&mut app, "partial.idml");
}
#[test]
fn inbox_open_reports_partial_import_warnings_without_losing_documents() {
let mut app = app();
let request = app.import_request("open");
app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: "partial.idml".into(), bytes: idml(true) });
app.drain_inbox();
assert_partial_import_warning(&mut app, "partial");
}
#[test]
fn complete_import_does_not_show_a_warning() {
let mut app = app();
let result = app
.open_file("file.openBytes", json!({"name": "complete.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(false))}))
.unwrap();
assert_eq!(result["warnings"], json!([]));
assert!(app.ui.dialog.is_none());
assert!(app.ui.status.is_empty());
assert_eq!(app.session.documents().len(), 2);
}
}
/// The whole window in a headless test harness, at a chosen window size.
#[cfg(test)]
pub(crate) mod test_window {
use egui_kittest::Harness;
pub struct Window {
pub app: crate::DesignApp,
ready: bool,
}
/// The window at `size` with a new document open.
pub fn open(mut app: crate::DesignApp, size: egui::Vec2) -> Harness<'static, Window> {
if app.session.active().is_none() {
app.run("file.new", serde_json::json!({})).unwrap();
}
let mut h = Harness::builder().with_size(size).with_max_steps(1000).build_ui_state(
|ui, w: &mut Window| {
// The builder runs a frame before the texture size can be raised: skip it.
if !w.ready {
return;
}
let ctx = ui.ctx().clone();
w.app.logic(&ctx);
w.app.ui(ui);
},
Window { app, ready: false },
);
h.input_mut().max_texture_side = Some(8192);
h.state_mut().ready = true;
h.run_steps(6);
h
}
/// The mouse wheel turned over `pos`: positive `delta` moves the content right and down.
pub fn wheel(h: &mut Harness<'static, Window>, pos: egui::Pos2, delta: egui::Vec2) {
h.hover_at(pos);
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta,
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(10);
}
/// The outer rect of a side or top panel, from egui's memory.
pub fn panel_rect(h: &Harness<'static, Window>, id: &str) -> egui::Rect {
egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(id)).map(|s| s.outer_rect).unwrap()
}
/// A primary click at `pos`, then a few frames.
pub fn click_at(h: &mut Harness<'static, Window>, pos: egui::Pos2) {
h.hover_at(pos);
h.drag_at(pos);
h.drop_at(pos);
h.run_steps(4);
}
}
#[cfg(test)]
mod browser_import_tests {
use super::*;
fn app() -> DesignApp {
let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() });
app.run("file.new", json!({"title": "A"})).unwrap();
app
}
fn deliver(app: &mut DesignApp, request: ImportRequest, name: &str, bytes: Vec<u8>) {
app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: name.into(), bytes });
app.drain_inbox();
}
fn image() -> Vec<u8> {
br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#ff0000"/></svg>"##.to_vec()
}
fn pdf_pages(count: usize) -> Vec<u8> {
let kids = (0..count).map(|i| format!("{} 0 R", 3 + i * 2)).collect::<Vec<_>>().join(" ");
let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{kids}] /Count {count} >>")];
for (i, color) in ["1 0 0", "0 1 0", "0 0 1"].iter().take(count).enumerate() {
let content = format!("{color} rg 0 0 200 200 re f\n");
objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents {} 0 R >>", 4 + i * 2));
objects.push(format!("<< /Length {} >>\nstream\n{content}endstream", content.len()));
}
let mut result = "%PDF-1.4\n".to_string();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(result.len());
result.push_str(&format!("{} 0 obj\n{object}\nendobj\n", i + 1));
}
let xref = result.len();
result.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1));
for offset in offsets {
result.push_str(&format!("{offset:010} 00000 n \n"));
}
result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1));
result.into_bytes()
}
fn pdf() -> Vec<u8> {
pdf_pages(3)
}
#[test]
fn picker_receives_context_before_returning_and_cancel_does_not_edit() {
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
let copy = captured.clone();
let mut app = app();
app.services.open_async = Some(Box::new(move |request| *copy.lock().unwrap() = Some(request)));
let uid = app.session.doc().unwrap().uid;
app.pick_and_open("place").unwrap();
app.run("file.new", json!({"title": "B"})).unwrap();
let request = captured.lock().unwrap().take().unwrap();
assert_eq!(request.purpose, "place");
assert_eq!(request.target, Some(uid));
assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty()));
deliver(&mut app, request, "broken.pdf", b"not a PDF".to_vec());
assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty()));
assert!(app.pending_pdf.is_none());
}
#[test]
fn delayed_place_activates_original_document_and_undo_belongs_to_it() {
let mut app = app();
let request = app.import_request("place");
app.run("file.new", json!({"title": "B"})).unwrap();
deliver(&mut app, request, "red.svg", image());
assert_eq!(app.session.active_index(), Some(0));
assert_eq!(app.session.documents()[0].doc.assets.len(), 1);
assert!(app.session.documents()[1].doc.assets.is_empty());
app.run("edit.undo", json!({})).unwrap();
assert!(app.session.doc().unwrap().doc.assets.is_empty());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1);
}
#[test]
fn closed_target_is_not_replaced_by_a_reused_tab_index() {
let mut app = app();
let request = app.import_request("place");
app.run("file.close", json!({})).unwrap();
app.run("file.new", json!({"title": "replacement"})).unwrap();
deliver(&mut app, request, "red.svg", image());
assert!(app.session.doc().unwrap().doc.assets.is_empty());
}
#[test]
fn closing_earlier_tab_does_not_shift_import_target() {
let mut app = app();
app.run("file.new", json!({"title": "target"})).unwrap();
let request = app.import_request("place");
app.run("file.new", json!({"title": "other"})).unwrap();
app.run("file.close", json!({"index": 0})).unwrap();
deliver(&mut app, request, "red.svg", image());
assert_eq!(app.session.doc().unwrap().doc.title, "target");
assert_eq!(app.session.documents()[0].doc.assets.len(), 1);
assert!(app.session.documents()[1].doc.assets.is_empty());
}
#[test]
fn layout_place_and_open_preserve_explicit_intent() {
let mut source = Session::new();
source.execute("file.new", &json!({"title": "source"})).unwrap();
source.execute("frame.create", &json!({"rect": [10, 10, 50, 50], "content": "text", "text": "placed"})).unwrap();
let native = designcraft_engine::cmd::to_bytes(&source.doc().unwrap().doc);
let idml = designcraft_idml::export_idml(&source.doc().unwrap().doc);
for (name, bytes) in [("source.designcraft", native), ("source.idml", idml)] {
let mut app = app();
let request = app.import_request("place");
deliver(&mut app, request, name, bytes.clone());
assert_eq!(app.session.documents().len(), 1);
assert_eq!(app.session.doc().unwrap().doc.title, "A");
assert!(app.session.doc().unwrap().doc.stories.values().any(|s| s.text == "placed"));
app.run("edit.undo", json!({})).unwrap();
assert!(app.session.doc().unwrap().doc.stories.is_empty());
app.run("edit.redo", json!({})).unwrap();
assert!(!app.session.doc().unwrap().doc.stories.is_empty());
let request = app.import_request("open");
deliver(&mut app, request, name, bytes);
assert_eq!(app.session.documents().len(), 2);
}
}
#[test]
fn pdf_options_keep_bytes_target_and_selected_page() {
let mut app = app();
let bytes = pdf();
let request = app.import_request("place");
deliver(&mut app, request, "three.pdf", bytes.clone());
assert!(app.session.doc().unwrap().doc.assets.is_empty());
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport");
app.run("file.new", json!({"title": "B"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.active_index(), Some(0));
let asset = app.session.doc().unwrap().doc.assets.values().next().unwrap();
assert_eq!(asset.page, 1);
assert_eq!(*asset.data, bytes);
assert!(app.pending_pdf.is_none());
assert!(app.session.documents()[1].doc.assets.is_empty());
let exported = app.run("file.exportPdf", json!({})).unwrap();
let exported = designcraft_engine::cmd::base64_decode(exported["base64"].as_str().unwrap());
let pixels = designcraft_render::render_pdf_page(&exported, 0, 400).unwrap();
assert!(pixels.data().iter().filter(|p| p.g > 240 && p.r < 10 && p.b < 10).count() > 1000);
assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0);
}
#[test]
fn pdf_options_reject_oversized_page_without_editing() {
let mut app = app();
for page in [0_u64, 4, 4_294_967_296, 4_294_967_297, u64::MAX] {
let request = app.import_request("place");
deliver(&mut app, request, "three.pdf", pdf());
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(page.to_string()));
// The native dialog clamps zero to one; positive invalid pages must fail.
if page == 0 {
assert!(app.confirm_pdf_import(page, "crop".into()).is_err());
} else {
assert!(dialogs::confirm(&mut app).is_err());
}
app.ui.dialog = None;
assert!(app.session.doc().unwrap().doc.assets.is_empty());
assert!(app.pending_pdf.is_none());
}
let request = app.import_request("place");
deliver(&mut app, request, "three.pdf", pdf());
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1);
}
#[test]
fn drop_routing_and_nonlayout_open_remain_compatible() {
let mut app = app();
let native = designcraft_engine::cmd::to_bytes(&app.session.doc().unwrap().doc);
let request = app.import_request("drop");
deliver(&mut app, request, "document.designcraft", native);
assert_eq!(app.session.documents().len(), 2);
let request = app.import_request("drop");
app.run("file.activate", json!({"index": 0})).unwrap();
deliver(&mut app, request, "red.svg", image());
assert_eq!(app.session.active_index(), Some(1));
assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1);
let request = app.import_request("open");
deliver(&mut app, request, "single.pdf", pdf_pages(1));
assert_eq!(app.session.documents().len(), 2);
assert!(app.pending_pdf.is_none());
assert!(app.ui.dialog.is_none());
}
#[test]
fn ase_swatches_keep_purpose_and_target() {
let mut source = Session::new();
source.execute("file.new", &json!({})).unwrap();
source.execute("swatch.create", &json!({"name": "Import Teal", "color": "#108080"})).unwrap();
let data = source.execute("swatch.save", &json!({"names": ["Import Teal"]})).unwrap();
let bytes = designcraft_engine::cmd::base64_decode(data["base64"].as_str().unwrap());
for (purpose, name) in [("swatches", "picker-file"), ("drop", "palette.ase")] {
let mut app = app();
let request = app.import_request(purpose);
app.run("file.new", json!({"title": "B"})).unwrap();
deliver(&mut app, request, name, bytes.clone());
assert_eq!(app.session.active_index(), Some(0));
assert!(app.session.documents()[0].doc.swatches.iter().any(|s| s.name == "Import Teal"));
assert!(!app.session.documents()[1].doc.swatches.iter().any(|s| s.name == "Import Teal"));
}
}
#[test]
fn native_pdf_dialog_keeps_path_and_uid_at_confirmation() {
let mut app = app();
let path = std::env::temp_dir().join(format!("designcraft-native-import-{}-{}.pdf", std::process::id(), app.session.doc().unwrap().uid));
std::fs::write(&path, pdf()).unwrap();
let pick_path = path.to_string_lossy().to_string();
app.services.pick_open = Some(Box::new(move |_| Some(pick_path.clone())));
app.services.read = Some(Box::new(|path| std::fs::read(path).map_err(|e| e.to_string())));
app.pick_and_open("place").unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport");
app.run("file.new", json!({"title": "B"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.active_index(), Some(0));
assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1);
assert!(app.session.documents()[1].doc.assets.is_empty());
std::fs::remove_file(path).unwrap();
}
#[test]
fn pending_pdf_cancel_close_supersession_and_second_delivery_release_bytes() {
let mut app = app();
let request = app.import_request("place");
deliver(&mut app, request.clone(), "first.pdf", pdf());
deliver(&mut app, request.clone(), "second.svg", image());
assert_eq!(app.pending_pdf.as_ref().unwrap().name, "first.pdf");
assert!(app.session.doc().unwrap().doc.assets.is_empty());
app.ui.dialog = None;
app.drain_inbox();
assert!(app.pending_pdf.is_none());
deliver(&mut app, request.clone(), "first.pdf", pdf());
app.ui.dialog = Some(dialogs::Dialog::new("newDocument", json!({})));
app.drain_inbox();
assert!(app.pending_pdf.is_none());
app.ui.dialog = None;
deliver(&mut app, request, "first.pdf", pdf());
app.run("file.close", json!({})).unwrap();
app.run("file.new", json!({})).unwrap();
assert!(dialogs::confirm(&mut app).is_err());
assert!(app.pending_pdf.is_none());
assert!(app.session.doc().unwrap().doc.assets.is_empty());
}
}