diff --git a/Cargo.lock b/Cargo.lock index 517c4ec..69dda9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,6 +401,7 @@ dependencies = [ "accesskit", "ashpd", "bone-document", + "bone-interop", "bone-render", "bone-text", "bone-types", diff --git a/crates/bone-app/Cargo.toml b/crates/bone-app/Cargo.toml index d3c9dfb..8cbfe7c 100644 --- a/crates/bone-app/Cargo.toml +++ b/crates/bone-app/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] bone-types = { workspace = true } bone-document = { workspace = true } +bone-interop = { workspace = true } bone-render = { workspace = true } bone-text = { workspace = true } bone-ui = { workspace = true } diff --git a/crates/bone-app/src/file_menu.rs b/crates/bone-app/src/file_menu.rs index 8838e94..93be248 100644 --- a/crates/bone-app/src/file_menu.rs +++ b/crates/bone-app/src/file_menu.rs @@ -3,8 +3,8 @@ use std::path::{Component, Path, PathBuf}; use bone_ui::frame::FrameCtx; use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; use bone_ui::widgets::{ - FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerOutcome, FilePickerState, - LabelText, WidgetPaint, show_file_picker, + FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, FilePickerOutcome, + FilePickerState, LabelText, WidgetPaint, show_file_picker, }; use bone_ui::{WidgetId, WidgetKey}; @@ -12,6 +12,12 @@ use crate::strings; pub const DEFAULT_DOCUMENTS_SUBDIR: &str = "bone-documents"; +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FileKind { + Document, + Step, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PickerEntry { pub id: WidgetId, @@ -21,6 +27,7 @@ pub struct PickerEntry { pub struct FilePickerSession { pub mode: FilePickerMode, + pub kind: FileKind, pub root: PathBuf, pub entries: Vec, pub state: FilePickerState, @@ -31,6 +38,7 @@ impl FilePickerSession { pub fn open( root: PathBuf, mode: FilePickerMode, + kind: FileKind, seed_filename: Option, entries: Vec, ) -> Self { @@ -40,6 +48,7 @@ impl FilePickerSession { } Self { mode, + kind, root, entries, state, @@ -50,10 +59,50 @@ impl FilePickerSession { #[derive(Clone, Debug, PartialEq, Eq)] pub enum PickerCommand { Cancel, - OpenFolder(PathBuf), + Open(PathBuf), SaveAs(PathBuf), } +#[must_use] +pub fn title_key(kind: FileKind, mode: FilePickerMode) -> bone_ui::strings::StringKey { + match (kind, mode) { + (FileKind::Document, FilePickerMode::Open) => strings::FILE_PICKER_TITLE_OPEN, + (FileKind::Document, FilePickerMode::Save) => strings::FILE_PICKER_TITLE_SAVE_AS, + (FileKind::Step, FilePickerMode::Open) => strings::FILE_PICKER_TITLE_IMPORT, + (FileKind::Step, FilePickerMode::Save) => strings::FILE_PICKER_TITLE_EXPORT, + } +} + +#[must_use] +pub fn accept_key(kind: FileKind, mode: FilePickerMode) -> bone_ui::strings::StringKey { + match (kind, mode) { + (FileKind::Document, FilePickerMode::Open) => strings::FILE_PICKER_OPEN, + (FileKind::Document, FilePickerMode::Save) => strings::FILE_PICKER_SAVE, + (FileKind::Step, FilePickerMode::Open) => strings::FILE_PICKER_IMPORT, + (FileKind::Step, FilePickerMode::Save) => strings::FILE_PICKER_EXPORT, + } +} + +#[must_use] +pub fn picker_labels(kind: FileKind, mode: FilePickerMode) -> FilePickerLabels { + let (list, filename_placeholder) = match kind { + FileKind::Document => ( + strings::FILE_PICKER_LIST, + strings::FILE_PICKER_FILENAME_PLACEHOLDER, + ), + FileKind::Step => ( + strings::FILE_PICKER_LIST_STEP, + strings::FILE_PICKER_FILENAME_PLACEHOLDER_STEP, + ), + }; + FilePickerLabels { + title: title_key(kind, mode), + confirm: accept_key(kind, mode), + list, + filename_placeholder, + } +} + pub struct PickerModalOutcome { pub paints: Vec, pub command: Option, @@ -73,9 +122,9 @@ pub fn render( label: LabelText::Owned(e.label.clone()), }) .collect(); - let title = match session.mode { - FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN, - FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS, + let empty_key = match session.kind { + FileKind::Document => strings::FILE_PICKER_DIR_EMPTY, + FileKind::Step => strings::FILE_PICKER_NO_STEP_FILES, }; let current_path = session.root.display().to_string(); let entries_empty = entries.is_empty(); @@ -87,13 +136,13 @@ pub fn render( session.mode, current_path, &entries, - title, + picker_labels(session.kind, session.mode), &mut session.state, ), ); let mut paints = response.paint; if entries_empty { - paints.push(empty_state_label(ctx, viewport_rect)); + paints.push(empty_state_label(ctx, viewport_rect, empty_key)); } let command = response .outcome @@ -101,7 +150,11 @@ pub fn render( PickerModalOutcome { paints, command } } -fn empty_state_label(ctx: &FrameCtx<'_>, viewport: LayoutRect) -> WidgetPaint { +fn empty_state_label( + ctx: &FrameCtx<'_>, + viewport: LayoutRect, + key: bone_ui::strings::StringKey, +) -> WidgetPaint { let rect = LayoutRect::new( LayoutPos::new( LayoutPx::new(viewport.origin.x.value() + viewport.size.width.value() * 0.5 - 160.0), @@ -111,7 +164,7 @@ fn empty_state_label(ctx: &FrameCtx<'_>, viewport: LayoutRect) -> WidgetPaint { ); WidgetPaint::Label { rect, - text: LabelText::Key(strings::FILE_PICKER_DIR_EMPTY), + text: LabelText::Key(key), color: ctx.theme().colors.text_secondary(), role: ctx.theme().typography.body, } @@ -127,7 +180,7 @@ fn translate( FilePickerOutcome::Open { folder } => entries .iter() .find(|e| e.id == folder) - .map(|e| PickerCommand::OpenFolder(e.path.clone())), + .map(|e| PickerCommand::Open(e.path.clone())), FilePickerOutcome::Save { folder, filename } => { if filename.is_empty() { return folder @@ -195,7 +248,48 @@ pub fn picker_id() -> WidgetId { WidgetId::ROOT.child(WidgetKey::new("app.file_picker")) } +const STEP_EXTENSIONS: &[&str] = &["step", "stp"]; + +#[must_use] +pub fn is_step_file(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| { + STEP_EXTENSIONS + .iter() + .any(|known| e.eq_ignore_ascii_case(known)) + }) +} + +#[must_use] +pub fn with_step_extension(path: PathBuf) -> PathBuf { + if is_step_file(&path) { + return path; + } + [path.into_os_string(), std::ffi::OsString::from(".step")] + .into_iter() + .collect::() + .into() +} + pub fn scan_document_folders(root: &Path) -> Result, std::io::Error> { + scan_entries(root, "app.file_picker.entry", |path| { + path.is_dir() + && path + .join(bone_document::io::folder::DOCUMENT_FILE) + .is_file() + }) +} + +pub fn scan_step_files(root: &Path) -> Result, std::io::Error> { + scan_entries(root, "app.file_picker.step", |path| { + path.is_file() && is_step_file(path) + }) +} + +fn scan_entries( + root: &Path, + key: &'static str, + accept: impl Fn(&Path) -> bool, +) -> Result, std::io::Error> { let read = match std::fs::read_dir(root) { Ok(read) => read, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), @@ -205,9 +299,7 @@ pub fn scan_document_folders(root: &Path) -> Result, std::io::E .filter_map(Result::ok) .filter_map(|entry| { let path = entry.path(); - let document_ron = path.join(bone_document::io::folder::DOCUMENT_FILE); - let is_doc = path.is_dir() && document_ron.is_file(); - if !is_doc { + if !accept(&path) { return None; } let label = path @@ -215,7 +307,7 @@ pub fn scan_document_folders(root: &Path) -> Result, std::io::E .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); let id = WidgetId::ROOT - .child(WidgetKey::new("app.file_picker.entry")) + .child(WidgetKey::new(key)) .child_named(WidgetKey::new("name"), &label); Some(PickerEntry { id, label, path }) }) @@ -236,7 +328,8 @@ pub fn documents_root() -> PathBuf { #[cfg(test)] mod tests { use super::{ - FilePickerSession, PickerCommand, scan_document_folders, translate, validate_save_name, + FileKind, FilePickerSession, PickerCommand, is_step_file, scan_document_folders, + scan_step_files, translate, validate_save_name, with_step_extension, }; use bone_ui::widgets::{FilePickerMode, FilePickerOutcome}; use std::fs; @@ -313,6 +406,7 @@ mod tests { let session = FilePickerSession::open( root, FilePickerMode::Save, + FileKind::Document, Some("Untitled".to_owned()), Vec::new(), ); @@ -331,9 +425,46 @@ mod tests { &entries, &root, ); + assert_eq!(cmd, Some(PickerCommand::Open(entries[0].path.clone()))); + } + + #[test] + fn scan_step_files_picks_only_step_extensions() { + let root = tmp("scan-step"); + write_stub(&root.join("anemone.step"), "ISO-10303-21;"); + write_stub(&root.join("barnacle.STP"), "ISO-10303-21;"); + write_stub(&root.join("limpet.txt"), "x"); + write_stub(&root.join("whelk.step/document.ron"), RON_STUB); + let entries = match scan_step_files(&root) { + Ok(v) => v, + Err(e) => panic!("scan {}: {e}", root.display()), + }; + let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect(); + assert_eq!(labels, ["anemone.step", "barnacle.STP"]); + } + + #[test] + fn step_extension_recognized_case_insensitively() { + assert!(is_step_file(std::path::Path::new("conch.step"))); + assert!(is_step_file(std::path::Path::new("conch.STEP"))); + assert!(is_step_file(std::path::Path::new("conch.stp"))); + assert!(!is_step_file(std::path::Path::new("conch.ron"))); + assert!(!is_step_file(std::path::Path::new("conch"))); + } + + #[test] + fn step_extension_appended_without_clobbering_stem() { + assert_eq!( + with_step_extension(PathBuf::from("/tmp/mussel")), + PathBuf::from("/tmp/mussel.step"), + ); + assert_eq!( + with_step_extension(PathBuf::from("/tmp/mussel.v2")), + PathBuf::from("/tmp/mussel.v2.step"), + ); assert_eq!( - cmd, - Some(PickerCommand::OpenFolder(entries[0].path.clone())) + with_step_extension(PathBuf::from("/tmp/mussel.STP")), + PathBuf::from("/tmp/mussel.STP"), ); } diff --git a/crates/bone-app/src/hotkeys.rs b/crates/bone-app/src/hotkeys.rs index 1755d65..89a2cf4 100644 --- a/crates/bone-app/src/hotkeys.rs +++ b/crates/bone-app/src/hotkeys.rs @@ -32,6 +32,8 @@ pub const DELETE_SELECTION_ACTION: ActionId = action_id(14); pub const ZOOM_FIT_ACTION: ActionId = action_id(15); pub const OPEN_SHORTCUT_BAR_ACTION: ActionId = action_id(16); pub const QUIT_ACTION: ActionId = action_id(17); +pub const IMPORT_STEP_ACTION: ActionId = action_id(18); +pub const EXPORT_STEP_ACTION: ActionId = action_id(19); const fn ch(c: char) -> KeyCode { KeyCode::Char(KeyChar::from_ascii(c)) @@ -51,6 +53,8 @@ const CTRL_O: KeyChord = KeyChord::new(ch('o'), ModifierMask::CTRL); const CTRL_S: KeyChord = KeyChord::new(ch('s'), ModifierMask::CTRL); const CTRL_A: KeyChord = KeyChord::new(ch('a'), ModifierMask::CTRL); const CTRL_Q: KeyChord = KeyChord::new(ch('q'), ModifierMask::CTRL); +const CTRL_I: KeyChord = KeyChord::new(ch('i'), ModifierMask::CTRL); +const CTRL_E: KeyChord = KeyChord::new(ch('e'), ModifierMask::CTRL); const DELETE: KeyChord = KeyChord::new(named(NamedKey::Delete), ModifierMask::NONE); const F_KEY: KeyChord = KeyChord::new(ch('f'), ModifierMask::NONE); const S_KEY: KeyChord = KeyChord::new(ch('s'), ModifierMask::NONE); @@ -62,6 +66,8 @@ pub enum HotkeyCommand { NewDocument, OpenDocument, SaveDocument, + ImportStep, + ExportStep, SelectAll, DeleteSelection, ZoomFit, @@ -134,6 +140,20 @@ pub const COMMANDS: &[Command] = &[ label: s::HOTKEY_LABEL_SAVE, defaults: &[CTRL_S], }, + Command { + action: IMPORT_STEP_ACTION, + kind: Some(HotkeyCommand::ImportStep), + scope: HotkeyScope::Global, + label: s::HOTKEY_LABEL_IMPORT, + defaults: &[CTRL_I], + }, + Command { + action: EXPORT_STEP_ACTION, + kind: Some(HotkeyCommand::ExportStep), + scope: HotkeyScope::Global, + label: s::HOTKEY_LABEL_EXPORT, + defaults: &[CTRL_E], + }, Command { action: SELECT_ALL_ACTION, kind: Some(HotkeyCommand::SelectAll), @@ -336,11 +356,12 @@ fn reject_cross_scope_shadow(bindings: &[HotkeyBinding]) -> Result<(), HotkeyTab #[cfg(test)] mod tests { use super::{ - DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, ESCAPE_ACTION, EXTEND_ACTION, - HotkeyOverrides, MIRROR_ACTION, NEW_DOCUMENT_ACTION, OPEN_DOCUMENT_ACTION, - OPEN_SHORTCUT_BAR_ACTION, QUIT_ACTION, REDO_ACTION, SAVE_DOCUMENT_ACTION, - SELECT_ALL_ACTION, SMART_DIMENSION_ACTION, TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION, - UNDO_ACTION, ZOOM_FIT_ACTION, compose_table, default_bindings, remap_entries, + DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, ESCAPE_ACTION, EXPORT_STEP_ACTION, + EXTEND_ACTION, HotkeyOverrides, IMPORT_STEP_ACTION, MIRROR_ACTION, NEW_DOCUMENT_ACTION, + OPEN_DOCUMENT_ACTION, OPEN_SHORTCUT_BAR_ACTION, QUIT_ACTION, REDO_ACTION, + SAVE_DOCUMENT_ACTION, SELECT_ALL_ACTION, SMART_DIMENSION_ACTION, + TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION, UNDO_ACTION, ZOOM_FIT_ACTION, compose_table, + default_bindings, remap_entries, }; use bone_ui::hotkey::{HotkeyScope, HotkeyScopes, KeyChord}; use bone_ui::input::{KeyChar, KeyCode, ModifierMask, NamedKey}; @@ -426,6 +447,8 @@ mod tests { ZOOM_FIT_ACTION, OPEN_SHORTCUT_BAR_ACTION, QUIT_ACTION, + IMPORT_STEP_ACTION, + EXPORT_STEP_ACTION, ] .iter() .for_each(|a| assert!(actions.contains(a), "missing remappable: {a:?}")); diff --git a/crates/bone-app/src/main.rs b/crates/bone-app/src/main.rs index 410875e..e1213c3 100644 --- a/crates/bone-app/src/main.rs +++ b/crates/bone-app/src/main.rs @@ -66,6 +66,7 @@ mod sketch_mode; mod smart_dimension; mod snap; mod status_badge; +mod step_jobs; mod strings; mod tools; mod view_cube; @@ -165,7 +166,8 @@ struct RenderState { documents_root: PathBuf, file_picker: Option, native_picker: Option, - pending_overwrite: Option, + step_job: Option, + pending_overwrite: Option, last_saved: Option, pending_discard: Option, notification: Option, @@ -176,11 +178,32 @@ struct RenderState { enum PendingDiscard { New, Open(PathBuf), + ImportStep(PathBuf), + InstallImported { + document: Box, + file_name: String, + }, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum PickedVia { + NativePortal, + CustomPicker, +} + +#[derive(Clone, Debug, PartialEq)] +enum PendingOverwrite { + Document(DocumentFolder), + StepExport(PathBuf), } fn modal_active(state: &RenderState) -> bool { state.file_picker.is_some() || state.native_picker.is_some() + || state + .step_job + .as_ref() + .is_some_and(|job| job.meta().show_progress) || state.pending_overwrite.is_some() || state.pending_discard.is_some() || state.shortcut_bar.is_some() @@ -1418,6 +1441,7 @@ impl ApplicationHandler for App { documents_root: file_menu::documents_root(), file_picker: None, native_picker: None, + step_job: None, pending_overwrite: None, last_saved: Some(last_saved_baseline), pending_discard: None, @@ -1745,6 +1769,10 @@ fn next_wake_deadline(state: &RenderState, input: &InputState) -> Option Option apply_menu_action(state, Some(shell::MenuAction::NewDocument)), C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)), C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)), + C::ImportStep => apply_menu_action(state, Some(shell::MenuAction::ImportStep)), + C::ExportStep => apply_menu_action(state, Some(shell::MenuAction::ExportStep)), C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)), C::Quit => { state.pending_exit = true; @@ -2671,6 +2706,7 @@ fn apply_popup_overlays( picker_outcome: Option<&file_menu::PickerModalOutcome>, overwrite_outcome: Option<&OverwriteOutcome>, discard_outcome: Option<&DiscardOutcome>, + step_progress_outcome: Option<&StepProgressOutcome>, notification_outcome: Option<&NotificationOutcome>, shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>, ) -> Option { @@ -2716,6 +2752,9 @@ fn apply_popup_overlays( discard_outcome.map(|o| o.paints.as_slice()), discard_closing, ); + if let Some(progress) = step_progress_outcome { + overlay.extend(progress.paints.iter().cloned()); + } if let Some(notification) = notification_outcome { overlay.extend(notification.paints.iter().cloned()); } @@ -2752,6 +2791,7 @@ struct FrameOutcomes { picker: Option, overwrite: Option, discard: Option, + step_progress: Option, notification: Option, shortcut_bar: Option, } @@ -2825,12 +2865,18 @@ fn run_frame_ui( .map(|session| file_menu::render(&mut ctx, session, layout_size)); let overwrite_outcome = state .pending_overwrite - .is_some() - .then(|| render_overwrite_modal(&mut ctx, layout_size)); + .as_ref() + .map(|pending| render_overwrite_modal(&mut ctx, layout_size, pending)); let discard_outcome = state .pending_discard - .is_some() - .then(|| render_discard_modal(&mut ctx, layout_size)); + .as_ref() + .map(|pending| render_discard_modal(&mut ctx, layout_size, pending)); + let reduce_motion = state.settings.reduce_motion; + let step_progress_outcome = state + .step_job + .as_ref() + .filter(|job| job.meta().show_progress) + .map(|job| render_step_progress_dialog(&mut ctx, layout_size, job, reduce_motion)); let notification_outcome = state .notification .as_ref() @@ -2845,6 +2891,7 @@ fn run_frame_ui( || picker_outcome.is_some() || overwrite_outcome.is_some() || discard_outcome.is_some() + || step_progress_outcome.is_some() || shortcut_bar_outcome.is_some(); if !any_modal_open && ctx.focus.is_text_input_focused() { strip_plain_letter_chords(ctx.input); @@ -2865,6 +2912,7 @@ fn run_frame_ui( picker: picker_outcome, overwrite: overwrite_outcome, discard: discard_outcome, + step_progress: step_progress_outcome, notification: notification_outcome, shortcut_bar: shortcut_bar_outcome, } @@ -2936,20 +2984,34 @@ enum OverwriteAction { Cancel, } -fn render_overwrite_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> OverwriteOutcome { +fn render_overwrite_modal( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, + pending: &PendingOverwrite, +) -> OverwriteOutcome { use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; use bone_ui::{WidgetId, WidgetKey}; let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite")); + let (title, message) = match pending { + PendingOverwrite::Document(_) => ( + strings::FILE_OVERWRITE_TITLE, + strings::FILE_OVERWRITE_MESSAGE, + ), + PendingOverwrite::StepExport(_) => ( + strings::FILE_OVERWRITE_TITLE_STEP, + strings::FILE_OVERWRITE_MESSAGE_STEP, + ), + }; let response = show_confirmation( ctx, ConfirmationDialog { id, viewport, size: dialog_size, - title: strings::FILE_OVERWRITE_TITLE, - message: strings::FILE_OVERWRITE_MESSAGE, + title, + message, confirm_label: strings::FILE_OVERWRITE_REPLACE, cancel_label: strings::FILE_OVERWRITE_CANCEL, destructive: true, @@ -2979,22 +3041,40 @@ enum DiscardAction { Cancel, } -fn render_discard_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> DiscardOutcome { +fn render_discard_modal( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, + pending: &PendingDiscard, +) -> DiscardOutcome { use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; use bone_ui::{WidgetId, WidgetKey}; let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0)); let id = WidgetId::ROOT.child(WidgetKey::new("file.discard")); + let (title, message, confirm_label, cancel_label) = match pending { + PendingDiscard::New | PendingDiscard::Open(_) | PendingDiscard::ImportStep(_) => ( + strings::FILE_DISCARD_TITLE, + strings::FILE_DISCARD_MESSAGE, + strings::FILE_DISCARD_CONFIRM, + strings::FILE_DISCARD_CANCEL, + ), + PendingDiscard::InstallImported { .. } => ( + strings::FILE_IMPORT_REPLACE_TITLE, + strings::FILE_IMPORT_REPLACE_MESSAGE, + strings::FILE_IMPORT_REPLACE_CONFIRM, + strings::FILE_IMPORT_REPLACE_CANCEL, + ), + }; let response = show_confirmation( ctx, ConfirmationDialog { id, viewport, size: dialog_size, - title: strings::FILE_DISCARD_TITLE, - message: strings::FILE_DISCARD_MESSAGE, - confirm_label: strings::FILE_DISCARD_CONFIRM, - cancel_label: strings::FILE_DISCARD_CANCEL, + title, + message, + confirm_label, + cancel_label, destructive: true, }, ); @@ -3023,11 +3103,150 @@ fn apply_discard_outcome(state: &mut RenderState, outcome: Option apply_new_document(state), PendingDiscard::Open(path) => apply_open_folder(state, path), + PendingDiscard::ImportStep(path) => start_step_import(state, path), + PendingDiscard::InstallImported { + document, + file_name, + } => { + install_imported_document(state, *document); + notify_info(state, strings::NOTIFY_IMPORTED, Some(file_name)); + } } } } } +#[derive(Clone, Debug, PartialEq)] +struct StepProgressOutcome { + paints: Vec, + cancel_requested: bool, +} + +fn render_step_progress_dialog( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, + job: &step_jobs::StepJob, + reduce_motion: bool, +) -> StepProgressOutcome { + use bone_ui::widgets::{Dialog, DialogButton, LabelText, WidgetPaint, show_dialog}; + use bone_ui::{WidgetId, WidgetKey}; + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(170.0)); + let id = WidgetId::ROOT.child(WidgetKey::new("step.progress")); + let cancel_id = id.child(WidgetKey::new("cancel")); + let title = match job { + step_jobs::StepJob::Import { .. } => strings::STEP_PROGRESS_TITLE_IMPORT, + step_jobs::StepJob::Export { .. } => strings::STEP_PROGRESS_TITLE_EXPORT, + }; + let buttons = [DialogButton::secondary( + cancel_id, + strings::STEP_PROGRESS_CANCEL, + )]; + let sweep = sweep_phase(ctx.input.frame, reduce_motion); + let file_name = job.meta().file_name.clone(); + let (response, ()) = show_dialog( + ctx, + Dialog::new(id, viewport, dialog_size, title, &buttons), + |ctx, body_rect, paint| { + let label_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body_rect.origin.x.value() + 16.0), + LayoutPx::new(body_rect.origin.y.value() + 12.0), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body_rect.size.width.value() - 32.0), + LayoutPx::new(20.0), + ), + ); + paint.push(WidgetPaint::Label { + rect: label_rect, + text: LabelText::Owned(file_name), + color: ctx.theme().colors.text_secondary(), + role: ctx.theme().typography.body, + }); + push_progress_bar(ctx, body_rect, sweep, paint); + }, + ); + StepProgressOutcome { + paints: response.paint, + cancel_requested: response.dismissed || response.activated == Some(cancel_id), + } +} + +const SWEEP_PERIOD_SECS: f32 = 1.2; +const SWEEP_SPAN: f32 = 0.3; + +fn sweep_phase(now: bone_ui::input::FrameInstant, reduce_motion: bool) -> f32 { + if reduce_motion { + return 0.5; + } + (now.duration().as_secs_f32() / SWEEP_PERIOD_SECS).fract() +} + +fn push_progress_bar( + ctx: &FrameCtx<'_>, + body: LayoutRect, + sweep: f32, + paint: &mut Vec, +) { + use bone_ui::widgets::WidgetPaint; + let track = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body.origin.x.value() + 16.0), + LayoutPx::new(body.origin.y.value() + 48.0), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), + LayoutPx::new(8.0), + ), + ); + paint.push(WidgetPaint::Surface { + rect: track, + fill: ctx.theme().colors.surface(bone_ui::theme::SurfaceLevel::L0), + border: Some(bone_ui::theme::Border { + width: bone_ui::theme::StrokeWidth::HAIRLINE, + color: ctx + .theme() + .colors + .neutral + .step(bone_ui::theme::Step12::SUBTLE_BORDER), + }), + radius: ctx.theme().radius.sm, + elevation: None, + }); + let start = sweep * (1.0 + SWEEP_SPAN) - SWEEP_SPAN; + let left = start.max(0.0); + let right = (start + SWEEP_SPAN).min(1.0); + if right <= left { + return; + } + let width = track.size.width.value(); + let fill_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(track.origin.x.value() + width * left), + track.origin.y, + ), + LayoutSize::new(LayoutPx::new(width * (right - left)), track.size.height), + ); + paint.push(WidgetPaint::Surface { + rect: fill_rect, + fill: ctx.theme().colors.accent_solid(), + border: None, + radius: ctx.theme().radius.sm, + elevation: None, + }); +} + +fn apply_step_progress_outcome(state: &RenderState, outcome: Option) { + let cancel = outcome.is_some_and(|o| o.cancel_requested); + if !cancel { + return; + } + if let Some(job) = state.step_job.as_ref() { + job.meta().request_cancel(); + } +} + #[derive(Clone, Debug, PartialEq)] struct NotificationOutcome { paints: Vec, @@ -3141,11 +3360,11 @@ fn apply_overwrite_outcome(state: &mut RenderState, outcome: Option { state.pending_overwrite = None; } - OverwriteAction::Replace => { - if let Some(folder) = state.pending_overwrite.take() { - perform_save_to(state, folder); - } - } + OverwriteAction::Replace => match state.pending_overwrite.take() { + Some(PendingOverwrite::Document(folder)) => perform_save_to(state, folder), + Some(PendingOverwrite::StepExport(path)) => start_step_export(state, path), + None => {} + }, } } @@ -3391,14 +3610,45 @@ fn apply_menu_action(state: &mut RenderState, action: Option) request_new_document(state); } Some(shell::MenuAction::OpenDocument) => { - open_picker(state, bone_ui::widgets::FilePickerMode::Open, None); + open_picker( + state, + bone_ui::widgets::FilePickerMode::Open, + file_menu::FileKind::Document, + None, + ); } Some(shell::MenuAction::SaveDocument) => { apply_save_in_place(state); } Some(shell::MenuAction::SaveDocumentAs) => { let seed = state.document.name().to_owned(); - open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed)); + open_picker( + state, + bone_ui::widgets::FilePickerMode::Save, + file_menu::FileKind::Document, + Some(seed), + ); + } + Some(shell::MenuAction::ImportStep) => { + if state.step_job.is_none() { + open_picker( + state, + bone_ui::widgets::FilePickerMode::Open, + file_menu::FileKind::Step, + None, + ); + } + } + Some(shell::MenuAction::ExportStep) => { + if state.step_job.is_none() { + let seed = format!("{}.step", state.document.name()); + open_picker( + state, + bone_ui::widgets::FilePickerMode::Save, + file_menu::FileKind::Step, + Some(seed), + ); + } } Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) | None => {} @@ -3421,6 +3671,138 @@ fn request_open_folder(state: &mut RenderState, path: PathBuf) { } } +fn request_import_step(state: &mut RenderState, path: PathBuf) { + if is_dirty(state) { + state.pending_discard = Some(PendingDiscard::ImportStep(path)); + } else { + start_step_import(state, path); + } +} + +fn start_step_import(state: &mut RenderState, path: PathBuf) { + if state.step_job.is_some() { + return; + } + match step_jobs::spawn_import(path, state.document.clone()) { + Ok(job) => state.step_job = Some(job), + Err(e) => notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()), + } +} + +fn apply_export_step_as(state: &mut RenderState, path: PathBuf, via: PickedVia) { + let extension_appended = !file_menu::is_step_file(&path); + let path = file_menu::with_step_extension(path); + let unconfirmed = matches!(via, PickedVia::CustomPicker) || extension_appended; + if unconfirmed && path.is_file() { + state.pending_overwrite = Some(PendingOverwrite::StepExport(path)); + return; + } + start_step_export(state, path); +} + +fn start_step_export(state: &mut RenderState, path: PathBuf) { + if state.step_job.is_some() { + return; + } + match step_jobs::spawn_export(state.document.clone(), path) { + Ok(job) => state.step_job = Some(job), + Err(e) => notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()), + } +} + +fn poll_step_job(state: &mut RenderState) { + let Some(job) = state.step_job.take() else { + return; + }; + match job { + step_jobs::StepJob::Import { rx, baseline, meta } => match step_jobs::poll(&rx) { + std::task::Poll::Pending => { + state.step_job = Some(step_jobs::StepJob::Import { rx, baseline, meta }); + } + std::task::Poll::Ready(result) => finish_import(state, result, &baseline, &meta), + }, + step_jobs::StepJob::Export { rx, meta } => match step_jobs::poll(&rx) { + std::task::Poll::Pending => { + state.step_job = Some(step_jobs::StepJob::Export { rx, meta }); + } + std::task::Poll::Ready(result) => finish_export(state, result, &meta), + }, + } +} + +fn finish_import( + state: &mut RenderState, + result: step_jobs::JobResult>, + baseline: &Document, + meta: &step_jobs::JobMeta, +) { + match result { + step_jobs::JobResult::Finished(_) if meta.cancel_requested() => { + tracing::info!(file = %meta.file_name, "discarding import that finished after cancel"); + } + step_jobs::JobResult::Finished(document) if *baseline != state.document => { + state.pending_discard = Some(PendingDiscard::InstallImported { + document, + file_name: meta.file_name.clone(), + }); + } + step_jobs::JobResult::Finished(document) => { + install_imported_document(state, *document); + notify_info( + state, + strings::NOTIFY_IMPORTED, + Some(meta.file_name.clone()), + ); + } + step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { + tracing::info!(file = %meta.file_name, "step import canceled"); + } + step_jobs::JobResult::Failed(e) => { + tracing::warn!(error = %e, file = %meta.file_name, "step import failed"); + notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()); + } + step_jobs::JobResult::WorkerLost => { + tracing::error!(file = %meta.file_name, "step import worker stopped before reporting a result"); + notify_error( + state, + strings::NOTIFY_IMPORT_FAILED, + "worker stopped before reporting a result".to_owned(), + ); + } + } +} + +fn finish_export( + state: &mut RenderState, + result: step_jobs::JobResult<()>, + meta: &step_jobs::JobMeta, +) { + match result { + step_jobs::JobResult::Finished(()) => { + notify_info( + state, + strings::NOTIFY_EXPORTED, + Some(meta.file_name.clone()), + ); + } + step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { + tracing::info!(file = %meta.file_name, "step export canceled"); + } + step_jobs::JobResult::Failed(e) => { + tracing::warn!(error = %e, file = %meta.file_name, "step export failed"); + notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()); + } + step_jobs::JobResult::WorkerLost => { + tracing::error!(file = %meta.file_name, "step export worker stopped before reporting a result"); + notify_error( + state, + strings::NOTIFY_EXPORT_FAILED, + "worker stopped before reporting a result".to_owned(), + ); + } + } +} + fn is_dirty(state: &RenderState) -> bool { state.last_saved.as_ref() != Some(&state.document) } @@ -3455,6 +3837,7 @@ fn apply_new_document(state: &mut RenderState) { fn open_picker( state: &mut RenderState, mode: bone_ui::widgets::FilePickerMode, + kind: file_menu::FileKind, seed_filename: Option, ) { if state.file_picker.is_some() || state.native_picker.is_some() { @@ -3470,18 +3853,13 @@ fn open_picker( .is_dir() .then(|| state.documents_root.clone()) }); - let title_key = match mode { - bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN, - bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS, - }; - let accept_key = match mode { - bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_OPEN, - bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_SAVE, - }; + let title_key = file_menu::title_key(kind, mode); + let accept_key = file_menu::accept_key(kind, mode); let title = state.strings.resolve(title_key).to_owned(); let accept_label = state.strings.resolve(accept_key).to_owned(); let native_req = native_picker::Request { mode, + kind, title: title.as_str(), accept_label: accept_label.as_str(), seed_filename: seed_filename.as_deref(), @@ -3496,15 +3874,20 @@ fn open_picker( tracing::debug!("native picker unavailable, falling back to custom picker"); } } - open_custom_picker(state, mode, seed_filename); + open_custom_picker(state, mode, kind, seed_filename); } fn open_custom_picker( state: &mut RenderState, mode: bone_ui::widgets::FilePickerMode, + kind: file_menu::FileKind, seed_filename: Option, ) { - let entries = match file_menu::scan_document_folders(&state.documents_root) { + let scan = match kind { + file_menu::FileKind::Document => file_menu::scan_document_folders(&state.documents_root), + file_menu::FileKind::Step => file_menu::scan_step_files(&state.documents_root), + }; + let entries = match scan { Ok(v) => v, Err(e) => { tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed"); @@ -3515,6 +3898,7 @@ fn open_custom_picker( state.file_picker = Some(file_menu::FilePickerSession::open( state.documents_root.clone(), mode, + kind, seed_filename, entries, )); @@ -3525,22 +3909,47 @@ fn poll_native_picker(state: &mut RenderState) { return; }; let outcome = match handle.poll() { - native_picker::PollState::Pending => return, - native_picker::PollState::Ready(o) => o, + std::task::Poll::Pending => return, + std::task::Poll::Ready(o) => o, }; let mode = handle.mode; + let kind = handle.kind; state.native_picker = None; match outcome { - native_picker::NativeOutcome::Path(path) => match mode { - bone_ui::widgets::FilePickerMode::Open => request_open_folder(state, path), - bone_ui::widgets::FilePickerMode::Save => apply_save_as(state, path), - }, + native_picker::NativeOutcome::Path(path) => { + route_picked_path(state, kind, mode, path, PickedVia::NativePortal); + } native_picker::NativeOutcome::Cancelled => {} native_picker::NativeOutcome::Error(message) => { tracing::warn!(error = %message, "native picker errored, falling back to custom picker"); - let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save) - .then(|| state.document.name().to_owned()); - open_custom_picker(state, mode, seed); + let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save).then(|| match kind { + file_menu::FileKind::Document => state.document.name().to_owned(), + file_menu::FileKind::Step => format!("{}.step", state.document.name()), + }); + open_custom_picker(state, mode, kind, seed); + } + } +} + +fn route_picked_path( + state: &mut RenderState, + kind: file_menu::FileKind, + mode: bone_ui::widgets::FilePickerMode, + path: PathBuf, + via: PickedVia, +) { + match (kind, mode) { + (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Open) => { + request_open_folder(state, path); + } + (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Save) => { + apply_save_as(state, path); + } + (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Open) => { + request_import_step(state, path); + } + (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Save) => { + apply_export_step_as(state, path, via); } } } @@ -3548,7 +3957,12 @@ fn poll_native_picker(state: &mut RenderState) { fn apply_save_in_place(state: &mut RenderState) { let Some(folder) = state.current_folder.clone() else { let seed = state.document.name().to_owned(); - open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed)); + open_picker( + state, + bone_ui::widgets::FilePickerMode::Save, + file_menu::FileKind::Document, + Some(seed), + ); return; }; if let Err(e) = bone_document::save(&state.document, &folder) { @@ -3560,12 +3974,32 @@ fn apply_save_in_place(state: &mut RenderState) { notify_info(state, strings::NOTIFY_SAVED, None); } -fn apply_picker_command(state: &mut RenderState, command: file_menu::PickerCommand) { +fn apply_picker_command( + state: &mut RenderState, + kind: file_menu::FileKind, + command: file_menu::PickerCommand, +) { state.file_picker = None; match command { file_menu::PickerCommand::Cancel => {} - file_menu::PickerCommand::OpenFolder(path) => request_open_folder(state, path), - file_menu::PickerCommand::SaveAs(path) => apply_save_as(state, path), + file_menu::PickerCommand::Open(path) => { + route_picked_path( + state, + kind, + bone_ui::widgets::FilePickerMode::Open, + path, + PickedVia::CustomPicker, + ); + } + file_menu::PickerCommand::SaveAs(path) => { + route_picked_path( + state, + kind, + bone_ui::widgets::FilePickerMode::Save, + path, + PickedVia::CustomPicker, + ); + } } } @@ -3589,7 +4023,7 @@ fn apply_save_as(state: &mut RenderState, path: PathBuf) { .as_ref() .is_some_and(|current| same_folder(current.path(), folder.path())); if folder.document_file().is_file() && !in_place { - state.pending_overwrite = Some(folder); + state.pending_overwrite = Some(PendingOverwrite::Document(folder)); return; } perform_save_to(state, folder); @@ -3675,6 +4109,11 @@ fn apply_shortcut_bar_outcome( } } +fn install_imported_document(state: &mut RenderState, document: Document) { + install_loaded_document(state, document, None); + state.last_saved = None; +} + fn install_loaded_document( state: &mut RenderState, document: Document, diff --git a/crates/bone-app/src/native_picker.rs b/crates/bone-app/src/native_picker.rs index 45739ad..599faf6 100644 --- a/crates/bone-app/src/native_picker.rs +++ b/crates/bone-app/src/native_picker.rs @@ -3,6 +3,8 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use bone_ui::widgets::FilePickerMode; +use crate::file_menu::FileKind; + #[derive(Debug)] pub enum NativeOutcome { Path(PathBuf), @@ -18,30 +20,25 @@ pub enum SpawnError { pub struct PendingHandle { rx: Receiver, pub mode: FilePickerMode, + pub kind: FileKind, } impl PendingHandle { - #[must_use] - pub fn poll(&self) -> PollState { + pub fn poll(&self) -> std::task::Poll { match self.rx.try_recv() { - Ok(outcome) => PollState::Ready(outcome), - Err(TryRecvError::Empty) => PollState::Pending, - Err(TryRecvError::Disconnected) => PollState::Ready(NativeOutcome::Error( + Ok(outcome) => std::task::Poll::Ready(outcome), + Err(TryRecvError::Empty) => std::task::Poll::Pending, + Err(TryRecvError::Disconnected) => std::task::Poll::Ready(NativeOutcome::Error( "picker worker disconnected".to_owned(), )), } } } -#[derive(Debug)] -pub enum PollState { - Pending, - Ready(NativeOutcome), -} - #[derive(Copy, Clone)] pub struct Request<'a> { pub mode: FilePickerMode, + pub kind: FileKind, pub title: &'a str, pub accept_label: &'a str, pub seed_filename: Option<&'a str>, @@ -63,13 +60,14 @@ mod linux_impl { use std::path::PathBuf; use std::sync::mpsc; - use ashpd::desktop::file_chooser::{OpenFileRequest, SaveFileRequest}; + use ashpd::desktop::file_chooser::{FileFilter, OpenFileRequest, SaveFileRequest}; use bone_ui::widgets::FilePickerMode; - use super::{NativeOutcome, PendingHandle, Request, SpawnError}; + use super::{FileKind, NativeOutcome, PendingHandle, Request, SpawnError}; pub(super) fn spawn(req: Request<'_>) -> Result { let mode = req.mode; + let kind = req.kind; let title = req.title.to_owned(); let accept = req.accept_label.to_owned(); let seed = req.seed_filename.map(str::to_owned); @@ -78,35 +76,52 @@ mod linux_impl { let join = std::thread::Builder::new() .name("bone-native-picker".to_owned()) .spawn(move || { - let outcome = pollster::block_on(run(mode, &title, &accept, seed, folder)); + let outcome = pollster::block_on(run(mode, kind, &title, &accept, seed, folder)); let _ = tx.send(outcome); }); match join { - Ok(_handle) => Ok(PendingHandle { rx, mode }), + Ok(_handle) => Ok(PendingHandle { rx, mode, kind }), Err(_) => Err(SpawnError::Unsupported), } } async fn run( mode: FilePickerMode, + kind: FileKind, title: &str, accept: &str, seed: Option, folder: Option, ) -> NativeOutcome { match mode { - FilePickerMode::Open => run_open(title, accept, folder).await, - FilePickerMode::Save => run_save(title, accept, seed, folder).await, + FilePickerMode::Open => run_open(kind, title, accept, folder).await, + FilePickerMode::Save => run_save(kind, title, accept, seed, folder).await, } } - async fn run_open(title: &str, accept: &str, folder: Option) -> NativeOutcome { + fn step_filter() -> FileFilter { + FileFilter::new("STEP") + .glob("*.step") + .glob("*.stp") + .glob("*.STEP") + .glob("*.STP") + } + + async fn run_open( + kind: FileKind, + title: &str, + accept: &str, + folder: Option, + ) -> NativeOutcome { let mut req = OpenFileRequest::default() .title(title) .accept_label(accept) .modal(true) .multiple(false) - .directory(true); + .directory(matches!(kind, FileKind::Document)); + if matches!(kind, FileKind::Step) { + req = req.filter(step_filter()); + } if let Some(f) = folder { req = match req.current_folder::(Some(f)) { Ok(r) => r, @@ -132,6 +147,7 @@ mod linux_impl { } async fn run_save( + kind: FileKind, title: &str, accept: &str, seed: Option, @@ -141,6 +157,9 @@ mod linux_impl { .title(title) .accept_label(accept) .modal(true); + if matches!(kind, FileKind::Step) { + req = req.filter(step_filter()); + } if let Some(s) = seed.as_deref() { req = req.current_name(s); } diff --git a/crates/bone-app/src/shell.rs b/crates/bone-app/src/shell.rs index b32259a..ac62e29 100644 --- a/crates/bone-app/src/shell.rs +++ b/crates/bone-app/src/shell.rs @@ -120,6 +120,9 @@ struct ShellIds { menu_file_open: WidgetId, menu_file_save: WidgetId, menu_file_save_as: WidgetId, + menu_file_import: WidgetId, + menu_file_export: WidgetId, + menu_file_export_step: WidgetId, menu_file_quit: WidgetId, menu_edit_undo: WidgetId, menu_edit_redo: WidgetId, @@ -191,6 +194,11 @@ impl ShellIds { menu_file_open: menu_file.child(WidgetKey::new("open")), menu_file_save: menu_file.child(WidgetKey::new("save")), menu_file_save_as: menu_file.child(WidgetKey::new("save_as")), + menu_file_import: menu_file.child(WidgetKey::new("import")), + menu_file_export: menu_file.child(WidgetKey::new("export")), + menu_file_export_step: menu_file + .child(WidgetKey::new("export")) + .child(WidgetKey::new("step")), menu_file_quit: menu_file.child(WidgetKey::new("quit")), menu_edit_undo: menu_edit.child(WidgetKey::new("undo")), menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), @@ -226,6 +234,8 @@ impl ShellIds { (self.menu_file_open, MenuAction::OpenDocument), (self.menu_file_save, MenuAction::SaveDocument), (self.menu_file_save_as, MenuAction::SaveDocumentAs), + (self.menu_file_import, MenuAction::ImportStep), + (self.menu_file_export_step, MenuAction::ExportStep), (self.menu_file_quit, MenuAction::Quit), (self.menu_edit_undo, MenuAction::Undo), (self.menu_edit_redo, MenuAction::Redo), @@ -246,6 +256,8 @@ pub enum MenuAction { OpenDocument, SaveDocument, SaveDocumentAs, + ImportStep, + ExportStep, Quit, Undo, Redo, @@ -1446,6 +1458,21 @@ fn build_menu_entries( ), action_with_accel(ids.menu_file_save_as, strings::MENU_FILE_SAVE_AS, None), MenuItem::Separator, + action_with_accel( + ids.menu_file_import, + strings::MENU_FILE_IMPORT, + Some(crate::hotkeys::IMPORT_STEP_ACTION), + ), + MenuItem::Submenu { + id: ids.menu_file_export, + label: strings::MENU_FILE_EXPORT, + items: vec![action_with_accel( + ids.menu_file_export_step, + strings::MENU_FILE_EXPORT_STEP, + Some(crate::hotkeys::EXPORT_STEP_ACTION), + )], + }, + MenuItem::Separator, action_with_accel( ids.menu_file_quit, strings::MENU_FILE_QUIT, @@ -3459,6 +3486,15 @@ mod tests { shell.ids.menu_action_for(shell.ids.menu_file_save_as), Some(MenuAction::SaveDocumentAs), ); + assert_eq!( + shell.ids.menu_action_for(shell.ids.menu_file_import), + Some(MenuAction::ImportStep), + ); + assert_eq!( + shell.ids.menu_action_for(shell.ids.menu_file_export_step), + Some(MenuAction::ExportStep), + ); + assert_eq!(shell.ids.menu_action_for(shell.ids.menu_file_export), None); } #[test] @@ -3492,6 +3528,28 @@ mod tests { assert!(!entry_for(strings::MENU_FILE_OPEN).1); assert!(!entry_for(strings::MENU_FILE_SAVE).1); assert!(!entry_for(strings::MENU_FILE_SAVE_AS).1); + assert!(!entry_for(strings::MENU_FILE_IMPORT).1); + let export_formats: Vec<(StringKey, bool)> = file_menu + .items + .iter() + .filter_map(|i| match i { + MenuItem::Submenu { label, items, .. } if *label == strings::MENU_FILE_EXPORT => { + Some(items) + } + _ => None, + }) + .flatten() + .filter_map(|i| match i { + MenuItem::Action { + label, disabled, .. + } => Some((*label, *disabled)), + _ => None, + }) + .collect(); + assert_eq!( + export_formats, + vec![(strings::MENU_FILE_EXPORT_STEP, false)], + ); } #[test] diff --git a/crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap b/crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap index 5aaca83..6159903 100644 --- a/crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap +++ b/crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap @@ -12,6 +12,8 @@ action=14 chord=Delete scope=Global action=15 chord=F scope=Global action=16 chord=S scope=Global action=17 chord=Ctrl+Q scope=Global +action=18 chord=Ctrl+I scope=Global +action=19 chord=Ctrl+E scope=Global action=2 chord=Ctrl+Z scope=Global action=3 chord=Ctrl+Shift+Z scope=Global action=3 chord=Ctrl+Y scope=Global diff --git a/crates/bone-app/src/status_badge.rs b/crates/bone-app/src/status_badge.rs index f4b89d5..c9da327 100644 --- a/crates/bone-app/src/status_badge.rs +++ b/crates/bone-app/src/status_badge.rs @@ -112,7 +112,8 @@ fn kernel_panel_line(error: &BrepError, strings_table: &StringTable) -> LabelTex | BrepError::StepShellMalformed | BrepError::StepEmpty | BrepError::StepMultipleSolids { .. } - | BrepError::StepUnsupported { .. } => LabelText::Key(strings::EXTRUDE_PANEL_INTERNAL), + | BrepError::StepUnsupported { .. } + | BrepError::Canceled => LabelText::Key(strings::EXTRUDE_PANEL_INTERNAL), } } diff --git a/crates/bone-app/src/step_jobs.rs b/crates/bone-app/src/step_jobs.rs new file mode 100644 index 0000000..8a75716 --- /dev/null +++ b/crates/bone-app/src/step_jobs.rs @@ -0,0 +1,194 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use bone_document::Document; +use bone_interop::{CancelFlag, StepError}; +use bone_types::StepSchema; + +const PROGRESS_DIALOG_THRESHOLD_BYTES: u64 = 1_000_000; + +pub struct JobMeta { + cancel: Arc, + pub file_name: String, + pub show_progress: bool, +} + +impl JobMeta { + pub fn request_cancel(&self) { + self.cancel.store(true, Ordering::Relaxed); + } + + #[must_use] + pub fn cancel_requested(&self) -> bool { + self.cancel.load(Ordering::Relaxed) + } +} + +#[derive(Debug)] +pub enum JobResult { + Finished(T), + Failed(StepError), + WorkerLost, +} + +pub enum StepJob { + Import { + rx: Receiver, StepError>>, + baseline: Box, + meta: JobMeta, + }, + Export { + rx: Receiver>, + meta: JobMeta, + }, +} + +impl StepJob { + #[must_use] + pub fn meta(&self) -> &JobMeta { + match self { + Self::Import { meta, .. } | Self::Export { meta, .. } => meta, + } + } +} + +pub fn poll(rx: &Receiver>) -> std::task::Poll> { + match rx.try_recv() { + Ok(Ok(value)) => std::task::Poll::Ready(JobResult::Finished(value)), + Ok(Err(e)) => std::task::Poll::Ready(JobResult::Failed(e)), + Err(TryRecvError::Empty) => std::task::Poll::Pending, + Err(TryRecvError::Disconnected) => std::task::Poll::Ready(JobResult::WorkerLost), + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SpawnError { + #[error("step worker thread failed to start: {0}")] + Thread(std::io::Error), +} + +pub fn spawn_import(path: PathBuf, baseline: Document) -> Result { + let show_progress = + std::fs::metadata(&path).is_ok_and(|m| m.len() > PROGRESS_DIALOG_THRESHOLD_BYTES); + let (rx, meta) = spawn(display_name(&path), show_progress, move |cancel| { + bone_interop::read(&path, cancel).map(Box::new) + })?; + Ok(StepJob::Import { + rx, + baseline: Box::new(baseline), + meta, + }) +} + +pub fn spawn_export(document: Document, path: PathBuf) -> Result { + let (rx, meta) = spawn(display_name(&path), false, move |cancel| { + bone_interop::write(&document, &path, StepSchema::Ap214, cancel) + })?; + Ok(StepJob::Export { rx, meta }) +} + +fn display_name(path: &Path) -> String { + path.file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +fn spawn( + file_name: String, + show_progress: bool, + work: impl FnOnce(CancelFlag<'_>) -> Result + Send + 'static, +) -> Result<(Receiver>, JobMeta), SpawnError> { + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_for_worker = Arc::clone(&cancel); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("bone-step-job".to_owned()) + .spawn(move || { + let result = work(CancelFlag::new(cancel_for_worker.as_ref())); + let _ = tx.send(result); + }) + .map_err(SpawnError::Thread)?; + Ok(( + rx, + JobMeta { + cancel, + file_name, + show_progress, + }, + )) +} + +#[cfg(test)] +mod tests { + use super::{JobResult, StepJob, poll, spawn_export, spawn_import}; + use bone_interop::StepError; + use std::path::PathBuf; + use std::sync::mpsc::Receiver; + + fn wait(rx: &Receiver>) -> JobResult { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + match poll(rx) { + std::task::Poll::Ready(result) => return result, + std::task::Poll::Pending => { + assert!(std::time::Instant::now() < deadline, "step job timed out"); + std::thread::yield_now(); + } + } + } + } + + #[test] + fn import_of_missing_file_reports_failure() { + let baseline = + bone_document::Document::new(bone_types::DocumentId::default(), "scallop".to_owned()); + let job = match spawn_import(PathBuf::from("/nonexistent/scallop.step"), baseline) { + Ok(job) => job, + Err(e) => panic!("spawn import: {e}"), + }; + let StepJob::Import { rx, .. } = job else { + panic!("spawn_import yields an import job"); + }; + assert!(matches!(wait(&rx), JobResult::Failed(StepError::Io { .. }))); + } + + #[test] + fn export_failure_leaves_no_step_file() { + let dir = + std::env::temp_dir().join(format!("bone-step-job-export-fail-{}", std::process::id())); + if let Err(e) = std::fs::create_dir_all(&dir) { + panic!("temp dir: {e}"); + } + let target = dir.join("nautilus.step"); + let document = + bone_document::Document::new(bone_types::DocumentId::default(), "nautilus".to_owned()); + let job = match spawn_export(document, target.clone()) { + Ok(job) => job, + Err(e) => panic!("spawn export: {e}"), + }; + let StepJob::Export { rx, .. } = job else { + panic!("spawn_export yields an export job"); + }; + match wait(&rx) { + JobResult::Failed(_) => {} + other => panic!("empty document export must fail, got {other:?}"), + } + assert!(!target.exists()); + if let Err(e) = std::fs::remove_dir_all(&dir) { + panic!("temp dir cleanup: {e}"); + } + } + + #[test] + fn worker_panic_surfaces_as_worker_lost() { + let (rx, _meta) = match super::spawn("limpet.step".to_owned(), false, |_| { + panic!("worker dies before reporting") + }) { + Ok(spawned) => spawned, + Err(e) => panic!("spawn: {e}"), + }; + assert!(matches!(wait::<()>(&rx), JobResult::WorkerLost)); + } +} diff --git a/crates/bone-app/src/strings.rs b/crates/bone-app/src/strings.rs index 2cdaf1d..9d4e74d 100644 --- a/crates/bone-app/src/strings.rs +++ b/crates/bone-app/src/strings.rs @@ -146,28 +146,52 @@ pub const MENU_FILE_OPEN: StringKey = StringKey::new("menu.file.open"); pub const MENU_FILE_SAVE: StringKey = StringKey::new("menu.file.save"); pub const MENU_FILE_SAVE_AS: StringKey = StringKey::new("menu.file.save_as"); pub const MENU_FILE_QUIT: StringKey = StringKey::new("menu.file.quit"); +pub const MENU_FILE_IMPORT: StringKey = StringKey::new("menu.file.import"); +pub const MENU_FILE_EXPORT: StringKey = StringKey::new("menu.file.export"); +pub const MENU_FILE_EXPORT_STEP: StringKey = StringKey::new("menu.file.export.step"); pub const FILE_PICKER_TITLE_OPEN: StringKey = StringKey::new("file_picker.title.open"); pub const FILE_PICKER_TITLE_SAVE_AS: StringKey = StringKey::new("file_picker.title.save_as"); +pub const FILE_PICKER_TITLE_IMPORT: StringKey = StringKey::new("file_picker.title.import"); +pub const FILE_PICKER_TITLE_EXPORT: StringKey = StringKey::new("file_picker.title.export"); pub const FILE_PICKER_OPEN: StringKey = StringKey::new("file_picker.open"); pub const FILE_PICKER_SAVE: StringKey = StringKey::new("file_picker.save"); +pub const FILE_PICKER_IMPORT: StringKey = StringKey::new("file_picker.import"); +pub const FILE_PICKER_EXPORT: StringKey = StringKey::new("file_picker.export"); +pub const FILE_PICKER_NO_STEP_FILES: StringKey = StringKey::new("file_picker.step.empty"); pub const FILE_PICKER_CANCEL: StringKey = StringKey::new("file_picker.cancel"); pub const FILE_PICKER_LIST: StringKey = StringKey::new("file_picker.list"); +pub const FILE_PICKER_LIST_STEP: StringKey = StringKey::new("file_picker.list.step"); pub const FILE_PICKER_FILENAME_PLACEHOLDER: StringKey = StringKey::new("file_picker.filename_placeholder"); +pub const FILE_PICKER_FILENAME_PLACEHOLDER_STEP: StringKey = + StringKey::new("file_picker.filename_placeholder.step"); pub const FILE_PICKER_DIR_EMPTY: StringKey = StringKey::new("file_picker.dir.empty"); pub const FILE_OVERWRITE_TITLE: StringKey = StringKey::new("file.overwrite.title"); pub const FILE_OVERWRITE_MESSAGE: StringKey = StringKey::new("file.overwrite.message"); +pub const FILE_OVERWRITE_TITLE_STEP: StringKey = StringKey::new("file.overwrite.title.step"); +pub const FILE_OVERWRITE_MESSAGE_STEP: StringKey = StringKey::new("file.overwrite.message.step"); pub const FILE_OVERWRITE_REPLACE: StringKey = StringKey::new("file.overwrite.replace"); pub const FILE_OVERWRITE_CANCEL: StringKey = StringKey::new("file.overwrite.cancel"); pub const FILE_DISCARD_TITLE: StringKey = StringKey::new("file.discard.title"); pub const FILE_DISCARD_MESSAGE: StringKey = StringKey::new("file.discard.message"); pub const FILE_DISCARD_CONFIRM: StringKey = StringKey::new("file.discard.confirm"); pub const FILE_DISCARD_CANCEL: StringKey = StringKey::new("file.discard.cancel"); +pub const FILE_IMPORT_REPLACE_TITLE: StringKey = StringKey::new("file.import_replace.title"); +pub const FILE_IMPORT_REPLACE_MESSAGE: StringKey = StringKey::new("file.import_replace.message"); +pub const FILE_IMPORT_REPLACE_CONFIRM: StringKey = StringKey::new("file.import_replace.confirm"); +pub const FILE_IMPORT_REPLACE_CANCEL: StringKey = StringKey::new("file.import_replace.cancel"); pub const DEFAULT_DOCUMENT_NAME: StringKey = StringKey::new("file.default_document_name"); pub const NOTIFY_SAVE_FAILED: StringKey = StringKey::new("notify.save_failed"); pub const NOTIFY_LOAD_FAILED: StringKey = StringKey::new("notify.load_failed"); pub const NOTIFY_SCAN_FAILED: StringKey = StringKey::new("notify.scan_failed"); pub const NOTIFY_SAVED: StringKey = StringKey::new("notify.saved"); +pub const NOTIFY_IMPORTED: StringKey = StringKey::new("notify.imported"); +pub const NOTIFY_IMPORT_FAILED: StringKey = StringKey::new("notify.import_failed"); +pub const NOTIFY_EXPORTED: StringKey = StringKey::new("notify.exported"); +pub const NOTIFY_EXPORT_FAILED: StringKey = StringKey::new("notify.export_failed"); +pub const STEP_PROGRESS_TITLE_IMPORT: StringKey = StringKey::new("step.progress.title.import"); +pub const STEP_PROGRESS_TITLE_EXPORT: StringKey = StringKey::new("step.progress.title.export"); +pub const STEP_PROGRESS_CANCEL: StringKey = StringKey::new("step.progress.cancel"); pub const NOTIFY_DISMISS: StringKey = StringKey::new("notify.dismiss"); pub const MENU_EDIT_UNDO: StringKey = StringKey::new("menu.edit.undo"); pub const MENU_EDIT_REDO: StringKey = StringKey::new("menu.edit.redo"); @@ -199,6 +223,8 @@ pub const HOTKEY_LABEL_REDO: StringKey = StringKey::new("hotkey.label.redo"); pub const HOTKEY_LABEL_NEW: StringKey = StringKey::new("hotkey.label.new"); pub const HOTKEY_LABEL_OPEN: StringKey = StringKey::new("hotkey.label.open"); pub const HOTKEY_LABEL_SAVE: StringKey = StringKey::new("hotkey.label.save"); +pub const HOTKEY_LABEL_IMPORT: StringKey = StringKey::new("hotkey.label.import"); +pub const HOTKEY_LABEL_EXPORT: StringKey = StringKey::new("hotkey.label.export"); pub const HOTKEY_LABEL_SELECT_ALL: StringKey = StringKey::new("hotkey.label.select_all"); pub const HOTKEY_LABEL_DELETE_SELECTION: StringKey = StringKey::new("hotkey.label.delete_selection"); @@ -450,19 +476,34 @@ const EN_US: &[(StringKey, &str)] = &[ (MENU_FILE_SAVE, "Save"), (MENU_FILE_SAVE_AS, "Save As..."), (MENU_FILE_QUIT, "Quit"), + (MENU_FILE_IMPORT, "Import..."), + (MENU_FILE_EXPORT, "Export"), + (MENU_FILE_EXPORT_STEP, "STEP (.step)"), (FILE_PICKER_TITLE_OPEN, "Open Document"), (FILE_PICKER_TITLE_SAVE_AS, "Save Document As"), + (FILE_PICKER_TITLE_IMPORT, "Import STEP"), + (FILE_PICKER_TITLE_EXPORT, "Export STEP"), (FILE_PICKER_OPEN, "Open"), (FILE_PICKER_SAVE, "Save"), + (FILE_PICKER_IMPORT, "Import"), + (FILE_PICKER_EXPORT, "Export"), + (FILE_PICKER_NO_STEP_FILES, "No STEP files in this folder"), (FILE_PICKER_CANCEL, "Cancel"), (FILE_PICKER_LIST, "Document folders"), + (FILE_PICKER_LIST_STEP, "STEP files"), (FILE_PICKER_FILENAME_PLACEHOLDER, "Document name"), + (FILE_PICKER_FILENAME_PLACEHOLDER_STEP, "File name"), (FILE_PICKER_DIR_EMPTY, "No documents in this folder"), (FILE_OVERWRITE_TITLE, "Replace document?"), ( FILE_OVERWRITE_MESSAGE, "A document already exists at this location.", ), + (FILE_OVERWRITE_TITLE_STEP, "Replace file?"), + ( + FILE_OVERWRITE_MESSAGE_STEP, + "A file already exists at this location.", + ), (FILE_OVERWRITE_REPLACE, "Replace"), (FILE_OVERWRITE_CANCEL, "Cancel"), (FILE_DISCARD_TITLE, "Discard unsaved changes?"), @@ -472,11 +513,25 @@ const EN_US: &[(StringKey, &str)] = &[ ), (FILE_DISCARD_CONFIRM, "Discard"), (FILE_DISCARD_CANCEL, "Keep editing"), + (FILE_IMPORT_REPLACE_TITLE, "Replace current document?"), + ( + FILE_IMPORT_REPLACE_MESSAGE, + "The document changed while the import was running. Installing the import discards those changes.", + ), + (FILE_IMPORT_REPLACE_CONFIRM, "Install import"), + (FILE_IMPORT_REPLACE_CANCEL, "Keep current"), (DEFAULT_DOCUMENT_NAME, "Untitled"), (NOTIFY_SAVE_FAILED, "Save failed"), (NOTIFY_LOAD_FAILED, "Open failed"), (NOTIFY_SCAN_FAILED, "Could not read documents folder"), (NOTIFY_SAVED, "Saved"), + (NOTIFY_IMPORTED, "Imported"), + (NOTIFY_IMPORT_FAILED, "Import failed"), + (NOTIFY_EXPORTED, "Exported"), + (NOTIFY_EXPORT_FAILED, "Export failed"), + (STEP_PROGRESS_TITLE_IMPORT, "Importing STEP"), + (STEP_PROGRESS_TITLE_EXPORT, "Exporting STEP"), + (STEP_PROGRESS_CANCEL, "Cancel"), (NOTIFY_DISMISS, "Dismiss"), (MENU_EDIT_UNDO, "Undo"), (MENU_EDIT_REDO, "Redo"), @@ -510,6 +565,8 @@ const EN_US: &[(StringKey, &str)] = &[ (HOTKEY_LABEL_NEW, "New"), (HOTKEY_LABEL_OPEN, "Open"), (HOTKEY_LABEL_SAVE, "Save"), + (HOTKEY_LABEL_IMPORT, "Import STEP"), + (HOTKEY_LABEL_EXPORT, "Export STEP"), (HOTKEY_LABEL_SELECT_ALL, "Select All"), (HOTKEY_LABEL_DELETE_SELECTION, "Delete"), (HOTKEY_LABEL_ZOOM_FIT, "Zoom to Fit"), @@ -773,19 +830,37 @@ const AR_XB: &[(StringKey, &str)] = &[ (MENU_FILE_SAVE, "[!! Sâve !!]"), (MENU_FILE_SAVE_AS, "[!! Sâve Âs... !!]"), (MENU_FILE_QUIT, "[!! Quît !!]"), + (MENU_FILE_IMPORT, "[!! Impôrt... !!]"), + (MENU_FILE_EXPORT, "[!! Expôrt !!]"), + (MENU_FILE_EXPORT_STEP, "[!! STÊP (.step) !!]"), (FILE_PICKER_TITLE_OPEN, "[!! Ôpen Dôcument !!]"), (FILE_PICKER_TITLE_SAVE_AS, "[!! Sâve Dôcument Âs !!]"), + (FILE_PICKER_TITLE_IMPORT, "[!! Impôrt STÊP !!]"), + (FILE_PICKER_TITLE_EXPORT, "[!! Expôrt STÊP !!]"), (FILE_PICKER_OPEN, "[!! Ôpen !!]"), (FILE_PICKER_SAVE, "[!! Sâve !!]"), + (FILE_PICKER_IMPORT, "[!! Impôrt !!]"), + (FILE_PICKER_EXPORT, "[!! Expôrt !!]"), + ( + FILE_PICKER_NO_STEP_FILES, + "[!! Nô STÊP fîles în thîs fôlder !!]", + ), (FILE_PICKER_CANCEL, "[!! Cancêl !!]"), (FILE_PICKER_LIST, "[!! Dôcument fôlders !!]"), + (FILE_PICKER_LIST_STEP, "[!! STÊP fîles !!]"), (FILE_PICKER_FILENAME_PLACEHOLDER, "[!! Dôcument nâme !!]"), + (FILE_PICKER_FILENAME_PLACEHOLDER_STEP, "[!! Fîle nâme !!]"), (FILE_PICKER_DIR_EMPTY, "[!! Nô dôcuments în thîs fôlder !!]"), (FILE_OVERWRITE_TITLE, "[!! Replâce dôcument? !!]"), ( FILE_OVERWRITE_MESSAGE, "[!! Â dôcument alrêady êxists ât thîs locâtion. !!]", ), + (FILE_OVERWRITE_TITLE_STEP, "[!! Replâce fîle? !!]"), + ( + FILE_OVERWRITE_MESSAGE_STEP, + "[!! Â fîle alrêady êxists ât thîs locâtion. !!]", + ), (FILE_OVERWRITE_REPLACE, "[!! Replâce !!]"), (FILE_OVERWRITE_CANCEL, "[!! Cancêl !!]"), (FILE_DISCARD_TITLE, "[!! Discârd unsâved chânges? !!]"), @@ -795,6 +870,16 @@ const AR_XB: &[(StringKey, &str)] = &[ ), (FILE_DISCARD_CONFIRM, "[!! Discârd !!]"), (FILE_DISCARD_CANCEL, "[!! Kêep edîting !!]"), + ( + FILE_IMPORT_REPLACE_TITLE, + "[!! Replâce cûrrent dôcument? !!]", + ), + ( + FILE_IMPORT_REPLACE_MESSAGE, + "[!! Thê dôcument chânged whîle thê impôrt wâs rûnning. Instâlling thê impôrt discârds thôse chânges. !!]", + ), + (FILE_IMPORT_REPLACE_CONFIRM, "[!! Instâll impôrt !!]"), + (FILE_IMPORT_REPLACE_CANCEL, "[!! Kêep cûrrent !!]"), (DEFAULT_DOCUMENT_NAME, "[!! Untîtled !!]"), (NOTIFY_SAVE_FAILED, "[!! Sâve fâiled !!]"), (NOTIFY_LOAD_FAILED, "[!! Ôpen fâiled !!]"), @@ -803,6 +888,13 @@ const AR_XB: &[(StringKey, &str)] = &[ "[!! Côuld not rêad dôcuments fôlder !!]", ), (NOTIFY_SAVED, "[!! Sâved !!]"), + (NOTIFY_IMPORTED, "[!! Impôrted !!]"), + (NOTIFY_IMPORT_FAILED, "[!! Impôrt fâiled !!]"), + (NOTIFY_EXPORTED, "[!! Expôrted !!]"), + (NOTIFY_EXPORT_FAILED, "[!! Expôrt fâiled !!]"), + (STEP_PROGRESS_TITLE_IMPORT, "[!! Impôrting STÊP !!]"), + (STEP_PROGRESS_TITLE_EXPORT, "[!! Expôrting STÊP !!]"), + (STEP_PROGRESS_CANCEL, "[!! Cancêl !!]"), (NOTIFY_DISMISS, "[!! Dîsmiss !!]"), (MENU_EDIT_UNDO, "[!! Undô !!]"), (MENU_EDIT_REDO, "[!! Redô !!]"), @@ -842,6 +934,8 @@ const AR_XB: &[(StringKey, &str)] = &[ (HOTKEY_LABEL_NEW, "[!! Néw !!]"), (HOTKEY_LABEL_OPEN, "[!! Ôpen !!]"), (HOTKEY_LABEL_SAVE, "[!! Sâve !!]"), + (HOTKEY_LABEL_IMPORT, "[!! Impôrt STÊP !!]"), + (HOTKEY_LABEL_EXPORT, "[!! Expôrt STÊP !!]"), (HOTKEY_LABEL_SELECT_ALL, "[!! Sêlect Âll !!]"), (HOTKEY_LABEL_DELETE_SELECTION, "[!! Delête !!]"), (HOTKEY_LABEL_ZOOM_FIT, "[!! Zôom to Fît !!]"), diff --git a/crates/bone-interop/src/lib.rs b/crates/bone-interop/src/lib.rs index 4288a36..9d7499b 100644 --- a/crates/bone-interop/src/lib.rs +++ b/crates/bone-interop/src/lib.rs @@ -1,5 +1,4 @@ -pub mod cancel; pub mod step; -pub use cancel::{Cancel, CancelFlag}; +pub use bone_types::{Cancel, CancelFlag}; pub use step::{HeaderDefect, StepError, body_of, read, write}; diff --git a/crates/bone-interop/src/step.rs b/crates/bone-interop/src/step.rs index 6d2040f..94d85b6 100644 --- a/crates/bone-interop/src/step.rs +++ b/crates/bone-interop/src/step.rs @@ -7,12 +7,10 @@ use bone_document::{ }; use bone_kernel::{BrepError, BrepSolid}; use bone_types::{ - DocumentId, FeatureId, StepEntityKind, StepFileHeader, StepFileName, StepOrganization, - StepOriginatingSystem, StepSchema, + CancelFlag, DocumentId, FeatureId, StepEntityKind, StepFileHeader, StepFileName, + StepOrganization, StepOriginatingSystem, StepSchema, }; -use crate::cancel::CancelFlag; - const PINNED_TIMESTAMP: &str = "1970-01-01T00:00:00"; const ORIGINATING_SYSTEM: &str = concat!("Bone ", env!("CARGO_PKG_VERSION")); @@ -159,7 +157,7 @@ pub fn body_of(document: &Document) -> Result { pub fn read(path: &Path, cancel: CancelFlag) -> Result { guard(cancel)?; - let text = read_file(path)?; + let text = read_file(path, cancel)?; classify_schema(&text)?; let sidecar = read_sidecar(path)?; guard(cancel)?; @@ -169,6 +167,7 @@ pub fn read(path: &Path, cancel: CancelFlag) -> Result { &text, feature, sidecar.as_ref().map(|side| (side.solid(), side.reattach())), + cancel, ) .map_err(read_geometry_error) })?; @@ -178,6 +177,7 @@ pub fn read(path: &Path, cancel: CancelFlag) -> Result { fn read_geometry_error(error: BrepError) -> StepError { match error { + BrepError::Canceled => StepError::Canceled, BrepError::StepUnsupported { kind } => StepError::UnsupportedEntity { kind }, BrepError::StepSyntax | BrepError::StepNoData | BrepError::StepEmpty => { StepError::IncompleteFile @@ -369,11 +369,34 @@ fn commit_step(path: &Path, bytes: &[u8]) -> Result<(), StepError> { }) } -fn read_file(path: &Path) -> Result { - std::fs::read_to_string(path).map_err(|source| StepError::Io { +const READ_CHUNK_BYTES: usize = 64 * 1024; + +fn read_file(path: &Path, cancel: CancelFlag) -> Result { + use io::Read; + let as_io = |source: io::Error| StepError::Io { path: path.to_path_buf(), source, + }; + let file = std::fs::File::open(path).map_err(as_io)?; + let mut reader = io::BufReader::new(file); + let bytes = std::iter::from_fn(|| { + if cancel.is_canceled() { + return Some(Err(StepError::Canceled)); + } + let mut chunk = vec![0u8; READ_CHUNK_BYTES]; + match reader.read(&mut chunk) { + Ok(0) => None, + Ok(n) => { + chunk.truncate(n); + Some(Ok(chunk)) + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => Some(Ok(Vec::new())), + Err(e) => Some(Err(as_io(e))), + } }) + .collect::>, StepError>>()? + .concat(); + String::from_utf8(bytes).map_err(|e| as_io(io::Error::new(io::ErrorKind::InvalidData, e))) } fn read_sidecar(step: &Path) -> Result, StepError> { diff --git a/crates/bone-kernel/src/brep/mod.rs b/crates/bone-kernel/src/brep/mod.rs index 79bb580..7e08813 100644 --- a/crates/bone-kernel/src/brep/mod.rs +++ b/crates/bone-kernel/src/brep/mod.rs @@ -138,6 +138,8 @@ pub enum BrepError { StepMultipleSolids { count: usize }, #[error("STEP geometry uses {kind}, which the facade does not yet bridge")] StepUnsupported { kind: StepEntityKind }, + #[error("STEP parse canceled before it completed")] + Canceled, } #[derive(Clone, Debug)] diff --git a/crates/bone-kernel/src/brep/step.rs b/crates/bone-kernel/src/brep/step.rs index 024ad6b..134090d 100644 --- a/crates/bone-kernel/src/brep/step.rs +++ b/crates/bone-kernel/src/brep/step.rs @@ -1,8 +1,8 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use bone_types::{ - EdgeLabel, EdgeRole, FaceLabel, FaceRole, FeatureId, ImportOrdinal, SolidKey, StepEntityKind, - VertexLabel, VertexRole, + CancelFlag, EdgeLabel, EdgeRole, FaceLabel, FaceRole, FeatureId, ImportOrdinal, SolidKey, + StepEntityKind, VertexLabel, VertexRole, }; use truck_modeling::{ BSplineCurve, Curve, Cut, Matrix4, ParametricCurve, Point3, RevolutedCurve, Shell, Solid, @@ -33,8 +33,9 @@ impl BrepSolid { text: &str, feature: FeatureId, expected: Option<(SolidKey, &BrepReattach)>, + cancel: CancelFlag, ) -> Result { - let solid = parse_step_solid(text)?; + let solid = parse_step_solid(text, cancel)?; let key = content_key(&solid); let labeling = match expected { Some((expected_key, reattach)) if expected_key == key => { @@ -42,6 +43,7 @@ impl BrepSolid { } _ => imported_labeling(&solid, feature), }; + guard(cancel)?; assemble(solid, &labeling) } } @@ -58,24 +60,36 @@ struct StepTopology { references: HashMap>, } -fn parse_step_solid(text: &str) -> Result { +fn guard(cancel: CancelFlag) -> Result<(), BrepError> { + if cancel.is_canceled() { + return Err(BrepError::Canceled); + } + Ok(()) +} + +fn parse_step_solid(text: &str, cancel: CancelFlag) -> Result { + guard(cancel)?; let data = first_data_section(text)?; + guard(cancel)?; let topology = classify_topology(&data); if topology.solid_roots.len() >= 2 { return Err(BrepError::StepMultipleSolids { count: topology.solid_roots.len(), }); } + guard(cancel)?; let table = Table::from_data_section(&data); let shells = solid_shell_keys(&table, &topology)? .iter() .map(|key| { + guard(cancel)?; let holder = &table.shell[&key.0]; let compressed = table .to_compressed_shell(holder) .map_err(|_| BrepError::StepShellMalformed)?; - let bridged = bridge_shell(compressed)?; + let bridged = bridge_shell(compressed, cancel)?; let normalized = split_closed_edges(bridged); + guard(cancel)?; Shell::extract(normalized).map_err(|_| BrepError::StepShellMalformed) }) .collect::, BrepError>>()?; @@ -202,11 +216,12 @@ fn first_data_section(text: &str) -> Result { type StepShell = CompressedShell; type ModelShell = CompressedShell; -fn bridge_shell(shell: StepShell) -> Result { +fn bridge_shell(shell: StepShell, cancel: CancelFlag) -> Result { let edges = shell .edges .into_iter() .map(|edge| { + guard(cancel)?; Ok(CompressedEdge { vertices: edge.vertices, curve: bridge_curve(edge.curve)?, @@ -217,6 +232,7 @@ fn bridge_shell(shell: StepShell) -> Result { .faces .into_iter() .map(|face| { + guard(cancel)?; Ok(CompressedFace { boundaries: face.boundaries, orientation: face.orientation, diff --git a/crates/bone-kernel/tests/extrude.rs b/crates/bone-kernel/tests/extrude.rs index 57b782e..c891a0f 100644 --- a/crates/bone-kernel/tests/extrude.rs +++ b/crates/bone-kernel/tests/extrude.rs @@ -4,8 +4,9 @@ use bone_kernel::{ MergeResult, ProfileDefect, ProfileEdge, ProfileLoop, TruckGap, evaluate_extrude, }; use bone_types::{ - Angle, EdgeLabel, FaceLabel, FaceRole, FeatureId, Length, Plane3, Point2, PositiveLength, - SketchEntityId, SketchId, Tolerance, UnitVec3, VertexLabel, VertexRole, degree, millimeter, + Angle, CancelFlag, EdgeLabel, FaceLabel, FaceRole, FeatureId, Length, Plane3, Point2, + PositiveLength, SketchEntityId, SketchId, Tolerance, UnitVec3, VertexLabel, VertexRole, degree, + millimeter, }; use slotmap::{Key, SlotMap}; @@ -601,6 +602,7 @@ fn step_round_trip_reattaches_cube_labels() { &step, feature, Some((solid.content_key(), solid.reattach_data())), + CancelFlag::never(), ) else { panic!("step body reattaches under a matching sidecar"); }; @@ -620,7 +622,7 @@ fn step_without_sidecar_imports_as_dumb_body() { }; let step = wrap_step(&body); let feature = ids.feature(); - let Ok(imported) = BrepSolid::from_step(&step, feature, None) else { + let Ok(imported) = BrepSolid::from_step(&step, feature, None, CancelFlag::never()) else { panic!("step body imports as a dumb body"); }; assert!( @@ -654,6 +656,7 @@ fn step_reattach_falls_back_when_count_mismatches() { &step, feature, Some((solid.content_key(), cylinder.reattach_data())), + CancelFlag::never(), ) else { panic!("a matching key with a wrong-count reattach degrades, it does not error"); }; @@ -676,9 +679,12 @@ fn step_reattach_falls_back_when_key_mismatches() { let step = wrap_step(&body); let feature = ids.feature(); let wrong_key = bone_types::SolidKey::from_bytes([0u8; 16]); - let Ok(imported) = - BrepSolid::from_step(&step, feature, Some((wrong_key, solid.reattach_data()))) - else { + let Ok(imported) = BrepSolid::from_step( + &step, + feature, + Some((wrong_key, solid.reattach_data())), + CancelFlag::never(), + ) else { panic!("a mismatched key still imports"); }; assert!( diff --git a/crates/bone-kernel/tests/step_geometry.rs b/crates/bone-kernel/tests/step_geometry.rs index 304dbdd..88df003 100644 --- a/crates/bone-kernel/tests/step_geometry.rs +++ b/crates/bone-kernel/tests/step_geometry.rs @@ -1,5 +1,5 @@ use bone_kernel::{BrepError, BrepSolid}; -use bone_types::{FeatureId, StepEntityKind}; +use bone_types::{CancelFlag, FeatureId, StepEntityKind}; use slotmap::SlotMap; use truck_modeling::{Point3, Rad, Solid, Vector3, builder}; use truck_stepio::out::StepModels; @@ -29,7 +29,7 @@ fn analytic_revolved_surface_is_typed_unsupported() { ); let doc = envelope(&StepModels::from_iter([&revolved.compress()]).to_string()); assert!(matches!( - BrepSolid::from_step(&doc, feature(), None), + BrepSolid::from_step(&doc, feature(), None, CancelFlag::never()), Err(BrepError::StepUnsupported { kind: StepEntityKind::SweptSurface }) @@ -40,16 +40,26 @@ fn analytic_revolved_surface_is_typed_unsupported() { fn text_without_a_data_section_reports_no_data() { let doc = "ISO-10303-21;\nHEADER;\nENDSEC;\nEND-ISO-10303-21;\n"; assert!(matches!( - BrepSolid::from_step(doc, feature(), None), + BrepSolid::from_step(doc, feature(), None, CancelFlag::never()), Err(BrepError::StepNoData) )); } +#[test] +fn preset_cancel_flag_aborts_the_parse() { + let doc = "ISO-10303-21;\nHEADER;\nENDSEC;\nEND-ISO-10303-21;\n"; + let flag = std::sync::atomic::AtomicBool::new(true); + assert!(matches!( + BrepSolid::from_step(doc, feature(), None, CancelFlag::new(&flag)), + Err(BrepError::Canceled) + )); +} + #[test] fn unparseable_data_section_reports_syntax() { let doc = "ISO-10303-21;\nDATA;\nthis is not a record\nENDSEC;\n"; assert!(matches!( - BrepSolid::from_step(doc, feature(), None), + BrepSolid::from_step(doc, feature(), None, CancelFlag::never()), Err(BrepError::StepSyntax) )); } @@ -67,7 +77,7 @@ fn multiple_bodies_are_rejected_not_merged() { let second = truck_cube(5.0); let doc = envelope(&StepModels::from_iter([&first, &second]).to_string()); assert!(matches!( - BrepSolid::from_step(&doc, feature(), None), + BrepSolid::from_step(&doc, feature(), None, CancelFlag::never()), Err(BrepError::StepMultipleSolids { count: 2 }) )); } diff --git a/crates/bone-interop/src/cancel.rs b/crates/bone-types/src/cancel.rs similarity index 100% rename from crates/bone-interop/src/cancel.rs rename to crates/bone-types/src/cancel.rs diff --git a/crates/bone-types/src/lib.rs b/crates/bone-types/src/lib.rs index 91ce844..65fa2e1 100644 --- a/crates/bone-types/src/lib.rs +++ b/crates/bone-types/src/lib.rs @@ -4,6 +4,7 @@ use uom::si::length::meter; pub use uom::si::length::millimeter; pub mod camera; +pub mod cancel; pub mod content; pub mod dimensioned_serde; pub mod display; @@ -16,6 +17,7 @@ pub mod step; pub use camera::{ Camera3, CubicEasing, OrbitState, Projection, ProjectionKind, StandardView, ZoomFactor, }; +pub use cancel::{Cancel, CancelFlag}; pub use content::SolidKey; pub use display::{DisplayMode, ShadingModel}; pub use label::{ diff --git a/crates/bone-ui/src/gallery.rs b/crates/bone-ui/src/gallery.rs index 0ae22ea..974e311 100644 --- a/crates/bone-ui/src/gallery.rs +++ b/crates/bone-ui/src/gallery.rs @@ -19,23 +19,32 @@ use crate::widget_id::{WidgetId, WidgetKey}; use crate::widgets::{ AlwaysValid, AngleEditor, BoolEditor, Button, ButtonState, ButtonVariant, Checkbox, CheckboxState, ConfirmationDialog, ContextMenu, Dialog, DialogButton, Dropdown, DropdownItem, - DropdownState, FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerState, - HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, ListItem, ListView, ListViewState, - MemoryClipboard, Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, Modal, - NumericInput, Panel, PanelState, PanelTitlebar, PropertyGrid, PropertyOption, PropertyRow, - RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, SelectionEditor, - Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Table, TableColumn, - TableRow, TableState, Tabs, TabsOrientation, TextEditor, TextInput, TextInputState, Toast, - ToastKind, ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, TooltipPlacement, - TooltipState, TreeNode, TreeView, TreeViewState, WidgetPaint, show_button, show_checkbox, - show_confirmation, show_context_menu, show_dialog, show_dropdown, show_file_picker, - show_hotkey_capture, show_list_view, show_menu, show_menu_bar, show_modal, show_panel, - show_parsed_input, show_property_grid, show_radio_group, show_ribbon, show_slider, + DropdownState, FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, + FilePickerState, HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, ListItem, + ListView, ListViewState, MemoryClipboard, Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, + MenuState, Modal, NumericInput, Panel, PanelState, PanelTitlebar, PropertyGrid, PropertyOption, + PropertyRow, RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, + SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, + Table, TableColumn, TableRow, TableState, Tabs, TabsOrientation, TextEditor, TextInput, + TextInputState, Toast, ToastKind, ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, + TooltipPlacement, TooltipState, TreeNode, TreeView, TreeViewState, WidgetPaint, show_button, + show_checkbox, show_confirmation, show_context_menu, show_dialog, show_dropdown, + show_file_picker, show_hotkey_capture, show_list_view, show_menu, show_menu_bar, show_modal, + show_panel, show_parsed_input, show_property_grid, show_radio_group, show_ribbon, show_slider, show_status_bar, show_table, show_tabs, show_text_input, show_toast, show_toggle_button, show_toolbar, show_tooltip, show_tree_view, }; pub const GALLERY_LABEL: StringKey = StringKey::new("gallery.label"); + +const fn gallery_picker_labels() -> FilePickerLabels { + FilePickerLabels { + title: GALLERY_LABEL, + confirm: GALLERY_LABEL, + list: GALLERY_LABEL, + filename_placeholder: GALLERY_LABEL, + } +} pub const GALLERY_FRAME_NOW: FrameInstant = FrameInstant::from_duration(Duration::from_secs(1)); pub const GALLERY_CANVAS: CanvasSize = CanvasSize::new(CanvasPx::new(1400), CanvasPx::new(2400)); const TOOLTIP_ANCHOR_KEY: &str = "button"; @@ -1022,7 +1031,7 @@ fn render_overlays(ctx: &mut FrameCtx<'_>, state: &mut GalleryState, paint: &mut FilePickerMode::Save, GALLERY_LABEL, &picker_entries, - GALLERY_LABEL, + gallery_picker_labels(), &mut state.file_picker, ), ); @@ -1039,7 +1048,7 @@ fn render_overlays(ctx: &mut FrameCtx<'_>, state: &mut GalleryState, paint: &mut FilePickerMode::Open, GALLERY_LABEL, &open_entries, - GALLERY_LABEL, + gallery_picker_labels(), &mut state.file_picker_open, ), ); diff --git a/crates/bone-ui/src/widgets/file_picker.rs b/crates/bone-ui/src/widgets/file_picker.rs index 25005d9..7ede934 100644 --- a/crates/bone-ui/src/widgets/file_picker.rs +++ b/crates/bone-ui/src/widgets/file_picker.rs @@ -28,6 +28,14 @@ pub struct FilePickerState { pub clipboard: MemoryClipboard, } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct FilePickerLabels { + pub title: StringKey, + pub confirm: StringKey, + pub list: StringKey, + pub filename_placeholder: StringKey, +} + #[derive(Debug, PartialEq)] pub struct FilePickerDialog<'a, 'state> { pub id: WidgetId, @@ -37,7 +45,7 @@ pub struct FilePickerDialog<'a, 'state> { pub current_path: LabelText, pub entries: &'a [FilePickerEntry], pub state: &'state mut FilePickerState, - pub title: StringKey, + pub labels: FilePickerLabels, } impl<'a, 'state> FilePickerDialog<'a, 'state> { @@ -48,7 +56,7 @@ impl<'a, 'state> FilePickerDialog<'a, 'state> { mode: FilePickerMode, current_path: impl Into, entries: &'a [FilePickerEntry], - title: StringKey, + labels: FilePickerLabels, state: &'state mut FilePickerState, ) -> Self { Self { @@ -59,7 +67,7 @@ impl<'a, 'state> FilePickerDialog<'a, 'state> { current_path: current_path.into(), entries, state, - title, + labels, } } } @@ -96,14 +104,10 @@ pub fn show_file_picker( current_path, entries, state, - title, + labels, } = picker; let confirm_id = id.child(WidgetKey::new("confirm")); let cancel_id = id.child(WidgetKey::new("cancel")); - let confirm_label = match mode { - FilePickerMode::Open => StringKey::new("file_picker.open"), - FilePickerMode::Save => StringKey::new("file_picker.save"), - }; let trimmed_empty = state.filename.text.trim().is_empty(); let any_text = !state.filename.text.is_empty(); let confirm_disabled = match mode { @@ -114,7 +118,7 @@ pub fn show_file_picker( DialogButton::secondary(cancel_id, StringKey::new("file_picker.cancel")), DialogButton { disabled: confirm_disabled, - ..DialogButton::primary(confirm_id, confirm_label) + ..DialogButton::primary(confirm_id, labels.confirm) }, ]; let list_items: Vec = entries @@ -126,7 +130,7 @@ pub fn show_file_picker( .collect(); let (response, list_opened) = show_dialog( ctx, - Dialog::new(id, viewport, size, title, &buttons), + Dialog::new(id, viewport, size, labels.title, &buttons), |ctx, body_rect, paint| { paint.push(WidgetPaint::Label { rect: path_label_rect(body_rect), @@ -150,7 +154,7 @@ pub fn show_file_picker( ListView::new( id.child(WidgetKey::new("list")), list_rect, - StringKey::new("file_picker.list"), + labels.list, &list_items, &mut state.list, ), @@ -160,7 +164,7 @@ pub fn show_file_picker( let widget = TextInput { id: id.child(WidgetKey::new("filename")), rect: filename_rect(body_rect), - placeholder: StringKey::new("file_picker.filename_placeholder"), + placeholder: labels.filename_placeholder, state: &mut state.filename, disabled: false, validator: AlwaysValid, @@ -314,6 +318,15 @@ mod tests { ) } + const fn test_labels() -> super::FilePickerLabels { + super::FilePickerLabels { + title: StringKey::new("file_picker.title"), + confirm: StringKey::new("file_picker.confirm"), + list: StringKey::new("file_picker.list"), + filename_placeholder: StringKey::new("file_picker.filename_placeholder"), + } + } + fn run_picker( focus: &mut FocusManager, hits: &mut HitFrame, @@ -346,7 +359,7 @@ mod tests { mode, StringKey::new("path.home"), entries, - StringKey::new("file_picker.title"), + test_labels(), state, ), ) @@ -454,7 +467,7 @@ mod tests { FilePickerMode::Open, StringKey::new("path.home"), &entries, - StringKey::new("file_picker.title"), + test_labels(), &mut state, ), ) @@ -494,7 +507,7 @@ mod tests { FilePickerMode::Save, StringKey::new("path.home"), &entries, - StringKey::new("file_picker.title"), + test_labels(), &mut state, ), ) diff --git a/crates/bone-ui/src/widgets/mod.rs b/crates/bone-ui/src/widgets/mod.rs index 7a9a9f4..b6db596 100644 --- a/crates/bone-ui/src/widgets/mod.rs +++ b/crates/bone-ui/src/widgets/mod.rs @@ -38,8 +38,8 @@ pub use dialog::{ pub use dimensioned_input::{DimensionedInput, DimensionedInputResponse, DimensionedParseError}; pub use dropdown::{Dropdown, DropdownItem, DropdownResponse, DropdownState, show_dropdown}; pub use file_picker::{ - FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerOutcome, FilePickerResponse, - FilePickerState, show_file_picker, + FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, FilePickerOutcome, + FilePickerResponse, FilePickerState, show_file_picker, }; pub use hotkey_capture::{ HotkeyCapture, HotkeyCaptureResponse, HotkeyCaptureState, show_hotkey_capture,