Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

app: step import/export in file menu

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 13, 2026, 11:16 PM +0300) commit 9d5cf11a parent 0792e960 change-id qqwrnkro
+1190 -147
+1
Cargo.lock
··· 401 401 "accesskit", 402 402 "ashpd", 403 403 "bone-document", 404 + "bone-interop", 404 405 "bone-render", 405 406 "bone-text", 406 407 "bone-types",
+1
crates/bone-app/Cargo.toml
··· 8 8 [dependencies] 9 9 bone-types = { workspace = true } 10 10 bone-document = { workspace = true } 11 + bone-interop = { workspace = true } 11 12 bone-render = { workspace = true } 12 13 bone-text = { workspace = true } 13 14 bone-ui = { workspace = true }
+149 -18
crates/bone-app/src/file_menu.rs
··· 3 3 use bone_ui::frame::FrameCtx; 4 4 use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 5 use bone_ui::widgets::{ 6 - FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerOutcome, FilePickerState, 7 - LabelText, WidgetPaint, show_file_picker, 6 + FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, FilePickerOutcome, 7 + FilePickerState, LabelText, WidgetPaint, show_file_picker, 8 8 }; 9 9 use bone_ui::{WidgetId, WidgetKey}; 10 10 ··· 12 12 13 13 pub const DEFAULT_DOCUMENTS_SUBDIR: &str = "bone-documents"; 14 14 15 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 16 + pub enum FileKind { 17 + Document, 18 + Step, 19 + } 20 + 15 21 #[derive(Clone, Debug, PartialEq, Eq)] 16 22 pub struct PickerEntry { 17 23 pub id: WidgetId, ··· 21 27 22 28 pub struct FilePickerSession { 23 29 pub mode: FilePickerMode, 30 + pub kind: FileKind, 24 31 pub root: PathBuf, 25 32 pub entries: Vec<PickerEntry>, 26 33 pub state: FilePickerState, ··· 31 38 pub fn open( 32 39 root: PathBuf, 33 40 mode: FilePickerMode, 41 + kind: FileKind, 34 42 seed_filename: Option<String>, 35 43 entries: Vec<PickerEntry>, 36 44 ) -> Self { ··· 40 48 } 41 49 Self { 42 50 mode, 51 + kind, 43 52 root, 44 53 entries, 45 54 state, ··· 50 59 #[derive(Clone, Debug, PartialEq, Eq)] 51 60 pub enum PickerCommand { 52 61 Cancel, 53 - OpenFolder(PathBuf), 62 + Open(PathBuf), 54 63 SaveAs(PathBuf), 55 64 } 56 65 66 + #[must_use] 67 + pub fn title_key(kind: FileKind, mode: FilePickerMode) -> bone_ui::strings::StringKey { 68 + match (kind, mode) { 69 + (FileKind::Document, FilePickerMode::Open) => strings::FILE_PICKER_TITLE_OPEN, 70 + (FileKind::Document, FilePickerMode::Save) => strings::FILE_PICKER_TITLE_SAVE_AS, 71 + (FileKind::Step, FilePickerMode::Open) => strings::FILE_PICKER_TITLE_IMPORT, 72 + (FileKind::Step, FilePickerMode::Save) => strings::FILE_PICKER_TITLE_EXPORT, 73 + } 74 + } 75 + 76 + #[must_use] 77 + pub fn accept_key(kind: FileKind, mode: FilePickerMode) -> bone_ui::strings::StringKey { 78 + match (kind, mode) { 79 + (FileKind::Document, FilePickerMode::Open) => strings::FILE_PICKER_OPEN, 80 + (FileKind::Document, FilePickerMode::Save) => strings::FILE_PICKER_SAVE, 81 + (FileKind::Step, FilePickerMode::Open) => strings::FILE_PICKER_IMPORT, 82 + (FileKind::Step, FilePickerMode::Save) => strings::FILE_PICKER_EXPORT, 83 + } 84 + } 85 + 86 + #[must_use] 87 + pub fn picker_labels(kind: FileKind, mode: FilePickerMode) -> FilePickerLabels { 88 + let (list, filename_placeholder) = match kind { 89 + FileKind::Document => ( 90 + strings::FILE_PICKER_LIST, 91 + strings::FILE_PICKER_FILENAME_PLACEHOLDER, 92 + ), 93 + FileKind::Step => ( 94 + strings::FILE_PICKER_LIST_STEP, 95 + strings::FILE_PICKER_FILENAME_PLACEHOLDER_STEP, 96 + ), 97 + }; 98 + FilePickerLabels { 99 + title: title_key(kind, mode), 100 + confirm: accept_key(kind, mode), 101 + list, 102 + filename_placeholder, 103 + } 104 + } 105 + 57 106 pub struct PickerModalOutcome { 58 107 pub paints: Vec<WidgetPaint>, 59 108 pub command: Option<PickerCommand>, ··· 73 122 label: LabelText::Owned(e.label.clone()), 74 123 }) 75 124 .collect(); 76 - let title = match session.mode { 77 - FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN, 78 - FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS, 125 + let empty_key = match session.kind { 126 + FileKind::Document => strings::FILE_PICKER_DIR_EMPTY, 127 + FileKind::Step => strings::FILE_PICKER_NO_STEP_FILES, 79 128 }; 80 129 let current_path = session.root.display().to_string(); 81 130 let entries_empty = entries.is_empty(); ··· 87 136 session.mode, 88 137 current_path, 89 138 &entries, 90 - title, 139 + picker_labels(session.kind, session.mode), 91 140 &mut session.state, 92 141 ), 93 142 ); 94 143 let mut paints = response.paint; 95 144 if entries_empty { 96 - paints.push(empty_state_label(ctx, viewport_rect)); 145 + paints.push(empty_state_label(ctx, viewport_rect, empty_key)); 97 146 } 98 147 let command = response 99 148 .outcome ··· 101 150 PickerModalOutcome { paints, command } 102 151 } 103 152 104 - fn empty_state_label(ctx: &FrameCtx<'_>, viewport: LayoutRect) -> WidgetPaint { 153 + fn empty_state_label( 154 + ctx: &FrameCtx<'_>, 155 + viewport: LayoutRect, 156 + key: bone_ui::strings::StringKey, 157 + ) -> WidgetPaint { 105 158 let rect = LayoutRect::new( 106 159 LayoutPos::new( 107 160 LayoutPx::new(viewport.origin.x.value() + viewport.size.width.value() * 0.5 - 160.0), ··· 111 164 ); 112 165 WidgetPaint::Label { 113 166 rect, 114 - text: LabelText::Key(strings::FILE_PICKER_DIR_EMPTY), 167 + text: LabelText::Key(key), 115 168 color: ctx.theme().colors.text_secondary(), 116 169 role: ctx.theme().typography.body, 117 170 } ··· 127 180 FilePickerOutcome::Open { folder } => entries 128 181 .iter() 129 182 .find(|e| e.id == folder) 130 - .map(|e| PickerCommand::OpenFolder(e.path.clone())), 183 + .map(|e| PickerCommand::Open(e.path.clone())), 131 184 FilePickerOutcome::Save { folder, filename } => { 132 185 if filename.is_empty() { 133 186 return folder ··· 195 248 WidgetId::ROOT.child(WidgetKey::new("app.file_picker")) 196 249 } 197 250 251 + const STEP_EXTENSIONS: &[&str] = &["step", "stp"]; 252 + 253 + #[must_use] 254 + pub fn is_step_file(path: &Path) -> bool { 255 + path.extension().and_then(|e| e.to_str()).is_some_and(|e| { 256 + STEP_EXTENSIONS 257 + .iter() 258 + .any(|known| e.eq_ignore_ascii_case(known)) 259 + }) 260 + } 261 + 262 + #[must_use] 263 + pub fn with_step_extension(path: PathBuf) -> PathBuf { 264 + if is_step_file(&path) { 265 + return path; 266 + } 267 + [path.into_os_string(), std::ffi::OsString::from(".step")] 268 + .into_iter() 269 + .collect::<std::ffi::OsString>() 270 + .into() 271 + } 272 + 198 273 pub fn scan_document_folders(root: &Path) -> Result<Vec<PickerEntry>, std::io::Error> { 274 + scan_entries(root, "app.file_picker.entry", |path| { 275 + path.is_dir() 276 + && path 277 + .join(bone_document::io::folder::DOCUMENT_FILE) 278 + .is_file() 279 + }) 280 + } 281 + 282 + pub fn scan_step_files(root: &Path) -> Result<Vec<PickerEntry>, std::io::Error> { 283 + scan_entries(root, "app.file_picker.step", |path| { 284 + path.is_file() && is_step_file(path) 285 + }) 286 + } 287 + 288 + fn scan_entries( 289 + root: &Path, 290 + key: &'static str, 291 + accept: impl Fn(&Path) -> bool, 292 + ) -> Result<Vec<PickerEntry>, std::io::Error> { 199 293 let read = match std::fs::read_dir(root) { 200 294 Ok(read) => read, 201 295 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), ··· 205 299 .filter_map(Result::ok) 206 300 .filter_map(|entry| { 207 301 let path = entry.path(); 208 - let document_ron = path.join(bone_document::io::folder::DOCUMENT_FILE); 209 - let is_doc = path.is_dir() && document_ron.is_file(); 210 - if !is_doc { 302 + if !accept(&path) { 211 303 return None; 212 304 } 213 305 let label = path ··· 215 307 .map(|s| s.to_string_lossy().into_owned()) 216 308 .unwrap_or_default(); 217 309 let id = WidgetId::ROOT 218 - .child(WidgetKey::new("app.file_picker.entry")) 310 + .child(WidgetKey::new(key)) 219 311 .child_named(WidgetKey::new("name"), &label); 220 312 Some(PickerEntry { id, label, path }) 221 313 }) ··· 236 328 #[cfg(test)] 237 329 mod tests { 238 330 use super::{ 239 - FilePickerSession, PickerCommand, scan_document_folders, translate, validate_save_name, 331 + FileKind, FilePickerSession, PickerCommand, is_step_file, scan_document_folders, 332 + scan_step_files, translate, validate_save_name, with_step_extension, 240 333 }; 241 334 use bone_ui::widgets::{FilePickerMode, FilePickerOutcome}; 242 335 use std::fs; ··· 313 406 let session = FilePickerSession::open( 314 407 root, 315 408 FilePickerMode::Save, 409 + FileKind::Document, 316 410 Some("Untitled".to_owned()), 317 411 Vec::new(), 318 412 ); ··· 331 425 &entries, 332 426 &root, 333 427 ); 428 + assert_eq!(cmd, Some(PickerCommand::Open(entries[0].path.clone()))); 429 + } 430 + 431 + #[test] 432 + fn scan_step_files_picks_only_step_extensions() { 433 + let root = tmp("scan-step"); 434 + write_stub(&root.join("anemone.step"), "ISO-10303-21;"); 435 + write_stub(&root.join("barnacle.STP"), "ISO-10303-21;"); 436 + write_stub(&root.join("limpet.txt"), "x"); 437 + write_stub(&root.join("whelk.step/document.ron"), RON_STUB); 438 + let entries = match scan_step_files(&root) { 439 + Ok(v) => v, 440 + Err(e) => panic!("scan {}: {e}", root.display()), 441 + }; 442 + let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect(); 443 + assert_eq!(labels, ["anemone.step", "barnacle.STP"]); 444 + } 445 + 446 + #[test] 447 + fn step_extension_recognized_case_insensitively() { 448 + assert!(is_step_file(std::path::Path::new("conch.step"))); 449 + assert!(is_step_file(std::path::Path::new("conch.STEP"))); 450 + assert!(is_step_file(std::path::Path::new("conch.stp"))); 451 + assert!(!is_step_file(std::path::Path::new("conch.ron"))); 452 + assert!(!is_step_file(std::path::Path::new("conch"))); 453 + } 454 + 455 + #[test] 456 + fn step_extension_appended_without_clobbering_stem() { 334 457 assert_eq!( 335 - cmd, 336 - Some(PickerCommand::OpenFolder(entries[0].path.clone())) 458 + with_step_extension(PathBuf::from("/tmp/mussel")), 459 + PathBuf::from("/tmp/mussel.step"), 460 + ); 461 + assert_eq!( 462 + with_step_extension(PathBuf::from("/tmp/mussel.v2")), 463 + PathBuf::from("/tmp/mussel.v2.step"), 464 + ); 465 + assert_eq!( 466 + with_step_extension(PathBuf::from("/tmp/mussel.STP")), 467 + PathBuf::from("/tmp/mussel.STP"), 337 468 ); 338 469 } 339 470
+28 -5
crates/bone-app/src/hotkeys.rs
··· 32 32 pub const ZOOM_FIT_ACTION: ActionId = action_id(15); 33 33 pub const OPEN_SHORTCUT_BAR_ACTION: ActionId = action_id(16); 34 34 pub const QUIT_ACTION: ActionId = action_id(17); 35 + pub const IMPORT_STEP_ACTION: ActionId = action_id(18); 36 + pub const EXPORT_STEP_ACTION: ActionId = action_id(19); 35 37 36 38 const fn ch(c: char) -> KeyCode { 37 39 KeyCode::Char(KeyChar::from_ascii(c)) ··· 51 53 const CTRL_S: KeyChord = KeyChord::new(ch('s'), ModifierMask::CTRL); 52 54 const CTRL_A: KeyChord = KeyChord::new(ch('a'), ModifierMask::CTRL); 53 55 const CTRL_Q: KeyChord = KeyChord::new(ch('q'), ModifierMask::CTRL); 56 + const CTRL_I: KeyChord = KeyChord::new(ch('i'), ModifierMask::CTRL); 57 + const CTRL_E: KeyChord = KeyChord::new(ch('e'), ModifierMask::CTRL); 54 58 const DELETE: KeyChord = KeyChord::new(named(NamedKey::Delete), ModifierMask::NONE); 55 59 const F_KEY: KeyChord = KeyChord::new(ch('f'), ModifierMask::NONE); 56 60 const S_KEY: KeyChord = KeyChord::new(ch('s'), ModifierMask::NONE); ··· 62 66 NewDocument, 63 67 OpenDocument, 64 68 SaveDocument, 69 + ImportStep, 70 + ExportStep, 65 71 SelectAll, 66 72 DeleteSelection, 67 73 ZoomFit, ··· 135 141 defaults: &[CTRL_S], 136 142 }, 137 143 Command { 144 + action: IMPORT_STEP_ACTION, 145 + kind: Some(HotkeyCommand::ImportStep), 146 + scope: HotkeyScope::Global, 147 + label: s::HOTKEY_LABEL_IMPORT, 148 + defaults: &[CTRL_I], 149 + }, 150 + Command { 151 + action: EXPORT_STEP_ACTION, 152 + kind: Some(HotkeyCommand::ExportStep), 153 + scope: HotkeyScope::Global, 154 + label: s::HOTKEY_LABEL_EXPORT, 155 + defaults: &[CTRL_E], 156 + }, 157 + Command { 138 158 action: SELECT_ALL_ACTION, 139 159 kind: Some(HotkeyCommand::SelectAll), 140 160 scope: HotkeyScope::Global, ··· 336 356 #[cfg(test)] 337 357 mod tests { 338 358 use super::{ 339 - DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, ESCAPE_ACTION, EXTEND_ACTION, 340 - HotkeyOverrides, MIRROR_ACTION, NEW_DOCUMENT_ACTION, OPEN_DOCUMENT_ACTION, 341 - OPEN_SHORTCUT_BAR_ACTION, QUIT_ACTION, REDO_ACTION, SAVE_DOCUMENT_ACTION, 342 - SELECT_ALL_ACTION, SMART_DIMENSION_ACTION, TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION, 343 - UNDO_ACTION, ZOOM_FIT_ACTION, compose_table, default_bindings, remap_entries, 359 + DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, ESCAPE_ACTION, EXPORT_STEP_ACTION, 360 + EXTEND_ACTION, HotkeyOverrides, IMPORT_STEP_ACTION, MIRROR_ACTION, NEW_DOCUMENT_ACTION, 361 + OPEN_DOCUMENT_ACTION, OPEN_SHORTCUT_BAR_ACTION, QUIT_ACTION, REDO_ACTION, 362 + SAVE_DOCUMENT_ACTION, SELECT_ALL_ACTION, SMART_DIMENSION_ACTION, 363 + TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION, UNDO_ACTION, ZOOM_FIT_ACTION, compose_table, 364 + default_bindings, remap_entries, 344 365 }; 345 366 use bone_ui::hotkey::{HotkeyScope, HotkeyScopes, KeyChord}; 346 367 use bone_ui::input::{KeyChar, KeyCode, ModifierMask, NamedKey}; ··· 426 447 ZOOM_FIT_ACTION, 427 448 OPEN_SHORTCUT_BAR_ACTION, 428 449 QUIT_ACTION, 450 + IMPORT_STEP_ACTION, 451 + EXPORT_STEP_ACTION, 429 452 ] 430 453 .iter() 431 454 .for_each(|a| assert!(actions.contains(a), "missing remappable: {a:?}"));
+486 -47
crates/bone-app/src/main.rs
··· 66 66 mod smart_dimension; 67 67 mod snap; 68 68 mod status_badge; 69 + mod step_jobs; 69 70 mod strings; 70 71 mod tools; 71 72 mod view_cube; ··· 165 166 documents_root: PathBuf, 166 167 file_picker: Option<file_menu::FilePickerSession>, 167 168 native_picker: Option<native_picker::PendingHandle>, 168 - pending_overwrite: Option<DocumentFolder>, 169 + step_job: Option<step_jobs::StepJob>, 170 + pending_overwrite: Option<PendingOverwrite>, 169 171 last_saved: Option<Document>, 170 172 pending_discard: Option<PendingDiscard>, 171 173 notification: Option<Notification>, ··· 176 178 enum PendingDiscard { 177 179 New, 178 180 Open(PathBuf), 181 + ImportStep(PathBuf), 182 + InstallImported { 183 + document: Box<Document>, 184 + file_name: String, 185 + }, 186 + } 187 + 188 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 189 + enum PickedVia { 190 + NativePortal, 191 + CustomPicker, 192 + } 193 + 194 + #[derive(Clone, Debug, PartialEq)] 195 + enum PendingOverwrite { 196 + Document(DocumentFolder), 197 + StepExport(PathBuf), 179 198 } 180 199 181 200 fn modal_active(state: &RenderState) -> bool { 182 201 state.file_picker.is_some() 183 202 || state.native_picker.is_some() 203 + || state 204 + .step_job 205 + .as_ref() 206 + .is_some_and(|job| job.meta().show_progress) 184 207 || state.pending_overwrite.is_some() 185 208 || state.pending_discard.is_some() 186 209 || state.shortcut_bar.is_some() ··· 1418 1441 documents_root: file_menu::documents_root(), 1419 1442 file_picker: None, 1420 1443 native_picker: None, 1444 + step_job: None, 1421 1445 pending_overwrite: None, 1422 1446 last_saved: Some(last_saved_baseline), 1423 1447 pending_discard: None, ··· 1745 1769 .native_picker 1746 1770 .is_some() 1747 1771 .then(|| now + std::time::Duration::from_millis(40)); 1772 + let step_poll = state 1773 + .step_job 1774 + .is_some() 1775 + .then(|| now + std::time::Duration::from_millis(40)); 1748 1776 let rename_deadline = state 1749 1777 .shell 1750 1778 .state ··· 1760 1788 .tween 1761 1789 .is_some() 1762 1790 .then(|| now + std::time::Duration::from_millis(8)); 1763 - [native_poll, rename_deadline, tween_tick] 1791 + [native_poll, step_poll, rename_deadline, tween_tick] 1764 1792 .into_iter() 1765 1793 .flatten() 1766 1794 .min() ··· 1780 1808 input_state: &mut InputState, 1781 1809 ) { 1782 1810 poll_native_picker(state); 1811 + poll_step_job(state); 1783 1812 let extent = state.surface.extent(); 1784 1813 let layout_size = layout_size_from_extent(extent); 1785 1814 let theme = Arc::clone(&state.theme); ··· 1800 1829 picker: picker_outcome, 1801 1830 overwrite: overwrite_outcome, 1802 1831 discard: discard_outcome, 1832 + step_progress: step_progress_outcome, 1803 1833 notification: notification_outcome, 1804 1834 shortcut_bar: shortcut_bar_outcome, 1805 1835 } = run_frame_ui( ··· 1819 1849 picker_outcome.as_ref(), 1820 1850 overwrite_outcome.as_ref(), 1821 1851 discard_outcome.as_ref(), 1852 + step_progress_outcome.as_ref(), 1822 1853 notification_outcome.as_ref(), 1823 1854 shortcut_bar_outcome.as_ref(), 1824 1855 ); 1825 1856 apply_shortcut_bar_outcome(state, shortcut_bar_outcome.as_ref()); 1826 - if let Some(cmd) = picker_outcome.and_then(|o| o.command) { 1827 - apply_picker_command(state, cmd); 1857 + let picker_kind = state.file_picker.as_ref().map(|s| s.kind); 1858 + if let (Some(cmd), Some(kind)) = (picker_outcome.and_then(|o| o.command), picker_kind) { 1859 + apply_picker_command(state, kind, cmd); 1828 1860 } 1829 1861 apply_overwrite_outcome(state, overwrite_outcome); 1830 1862 apply_discard_outcome(state, discard_outcome); 1863 + apply_step_progress_outcome(state, step_progress_outcome); 1831 1864 apply_notification_outcome(state, notification_outcome); 1832 1865 let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer); 1833 1866 let frame = if claimed_pointer { ··· 2119 2152 C::NewDocument => apply_menu_action(state, Some(shell::MenuAction::NewDocument)), 2120 2153 C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)), 2121 2154 C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)), 2155 + C::ImportStep => apply_menu_action(state, Some(shell::MenuAction::ImportStep)), 2156 + C::ExportStep => apply_menu_action(state, Some(shell::MenuAction::ExportStep)), 2122 2157 C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)), 2123 2158 C::Quit => { 2124 2159 state.pending_exit = true; ··· 2671 2706 picker_outcome: Option<&file_menu::PickerModalOutcome>, 2672 2707 overwrite_outcome: Option<&OverwriteOutcome>, 2673 2708 discard_outcome: Option<&DiscardOutcome>, 2709 + step_progress_outcome: Option<&StepProgressOutcome>, 2674 2710 notification_outcome: Option<&NotificationOutcome>, 2675 2711 shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>, 2676 2712 ) -> Option<LayoutRect> { ··· 2716 2752 discard_outcome.map(|o| o.paints.as_slice()), 2717 2753 discard_closing, 2718 2754 ); 2755 + if let Some(progress) = step_progress_outcome { 2756 + overlay.extend(progress.paints.iter().cloned()); 2757 + } 2719 2758 if let Some(notification) = notification_outcome { 2720 2759 overlay.extend(notification.paints.iter().cloned()); 2721 2760 } ··· 2752 2791 picker: Option<file_menu::PickerModalOutcome>, 2753 2792 overwrite: Option<OverwriteOutcome>, 2754 2793 discard: Option<DiscardOutcome>, 2794 + step_progress: Option<StepProgressOutcome>, 2755 2795 notification: Option<NotificationOutcome>, 2756 2796 shortcut_bar: Option<shortcut_bar::ShortcutBarOutcome>, 2757 2797 } ··· 2825 2865 .map(|session| file_menu::render(&mut ctx, session, layout_size)); 2826 2866 let overwrite_outcome = state 2827 2867 .pending_overwrite 2828 - .is_some() 2829 - .then(|| render_overwrite_modal(&mut ctx, layout_size)); 2868 + .as_ref() 2869 + .map(|pending| render_overwrite_modal(&mut ctx, layout_size, pending)); 2830 2870 let discard_outcome = state 2831 2871 .pending_discard 2832 - .is_some() 2833 - .then(|| render_discard_modal(&mut ctx, layout_size)); 2872 + .as_ref() 2873 + .map(|pending| render_discard_modal(&mut ctx, layout_size, pending)); 2874 + let reduce_motion = state.settings.reduce_motion; 2875 + let step_progress_outcome = state 2876 + .step_job 2877 + .as_ref() 2878 + .filter(|job| job.meta().show_progress) 2879 + .map(|job| render_step_progress_dialog(&mut ctx, layout_size, job, reduce_motion)); 2834 2880 let notification_outcome = state 2835 2881 .notification 2836 2882 .as_ref() ··· 2845 2891 || picker_outcome.is_some() 2846 2892 || overwrite_outcome.is_some() 2847 2893 || discard_outcome.is_some() 2894 + || step_progress_outcome.is_some() 2848 2895 || shortcut_bar_outcome.is_some(); 2849 2896 if !any_modal_open && ctx.focus.is_text_input_focused() { 2850 2897 strip_plain_letter_chords(ctx.input); ··· 2865 2912 picker: picker_outcome, 2866 2913 overwrite: overwrite_outcome, 2867 2914 discard: discard_outcome, 2915 + step_progress: step_progress_outcome, 2868 2916 notification: notification_outcome, 2869 2917 shortcut_bar: shortcut_bar_outcome, 2870 2918 } ··· 2936 2984 Cancel, 2937 2985 } 2938 2986 2939 - fn render_overwrite_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> OverwriteOutcome { 2987 + fn render_overwrite_modal( 2988 + ctx: &mut FrameCtx<'_>, 2989 + layout_size: LayoutSize, 2990 + pending: &PendingOverwrite, 2991 + ) -> OverwriteOutcome { 2940 2992 use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 2941 2993 use bone_ui::{WidgetId, WidgetKey}; 2942 2994 let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 2943 2995 let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); 2944 2996 let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite")); 2997 + let (title, message) = match pending { 2998 + PendingOverwrite::Document(_) => ( 2999 + strings::FILE_OVERWRITE_TITLE, 3000 + strings::FILE_OVERWRITE_MESSAGE, 3001 + ), 3002 + PendingOverwrite::StepExport(_) => ( 3003 + strings::FILE_OVERWRITE_TITLE_STEP, 3004 + strings::FILE_OVERWRITE_MESSAGE_STEP, 3005 + ), 3006 + }; 2945 3007 let response = show_confirmation( 2946 3008 ctx, 2947 3009 ConfirmationDialog { 2948 3010 id, 2949 3011 viewport, 2950 3012 size: dialog_size, 2951 - title: strings::FILE_OVERWRITE_TITLE, 2952 - message: strings::FILE_OVERWRITE_MESSAGE, 3013 + title, 3014 + message, 2953 3015 confirm_label: strings::FILE_OVERWRITE_REPLACE, 2954 3016 cancel_label: strings::FILE_OVERWRITE_CANCEL, 2955 3017 destructive: true, ··· 2979 3041 Cancel, 2980 3042 } 2981 3043 2982 - fn render_discard_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> DiscardOutcome { 3044 + fn render_discard_modal( 3045 + ctx: &mut FrameCtx<'_>, 3046 + layout_size: LayoutSize, 3047 + pending: &PendingDiscard, 3048 + ) -> DiscardOutcome { 2983 3049 use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 2984 3050 use bone_ui::{WidgetId, WidgetKey}; 2985 3051 let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 2986 3052 let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0)); 2987 3053 let id = WidgetId::ROOT.child(WidgetKey::new("file.discard")); 3054 + let (title, message, confirm_label, cancel_label) = match pending { 3055 + PendingDiscard::New | PendingDiscard::Open(_) | PendingDiscard::ImportStep(_) => ( 3056 + strings::FILE_DISCARD_TITLE, 3057 + strings::FILE_DISCARD_MESSAGE, 3058 + strings::FILE_DISCARD_CONFIRM, 3059 + strings::FILE_DISCARD_CANCEL, 3060 + ), 3061 + PendingDiscard::InstallImported { .. } => ( 3062 + strings::FILE_IMPORT_REPLACE_TITLE, 3063 + strings::FILE_IMPORT_REPLACE_MESSAGE, 3064 + strings::FILE_IMPORT_REPLACE_CONFIRM, 3065 + strings::FILE_IMPORT_REPLACE_CANCEL, 3066 + ), 3067 + }; 2988 3068 let response = show_confirmation( 2989 3069 ctx, 2990 3070 ConfirmationDialog { 2991 3071 id, 2992 3072 viewport, 2993 3073 size: dialog_size, 2994 - title: strings::FILE_DISCARD_TITLE, 2995 - message: strings::FILE_DISCARD_MESSAGE, 2996 - confirm_label: strings::FILE_DISCARD_CONFIRM, 2997 - cancel_label: strings::FILE_DISCARD_CANCEL, 3074 + title, 3075 + message, 3076 + confirm_label, 3077 + cancel_label, 2998 3078 destructive: true, 2999 3079 }, 3000 3080 ); ··· 3023 3103 match pending { 3024 3104 PendingDiscard::New => apply_new_document(state), 3025 3105 PendingDiscard::Open(path) => apply_open_folder(state, path), 3106 + PendingDiscard::ImportStep(path) => start_step_import(state, path), 3107 + PendingDiscard::InstallImported { 3108 + document, 3109 + file_name, 3110 + } => { 3111 + install_imported_document(state, *document); 3112 + notify_info(state, strings::NOTIFY_IMPORTED, Some(file_name)); 3113 + } 3026 3114 } 3027 3115 } 3028 3116 } 3029 3117 } 3030 3118 3031 3119 #[derive(Clone, Debug, PartialEq)] 3120 + struct StepProgressOutcome { 3121 + paints: Vec<bone_ui::widgets::WidgetPaint>, 3122 + cancel_requested: bool, 3123 + } 3124 + 3125 + fn render_step_progress_dialog( 3126 + ctx: &mut FrameCtx<'_>, 3127 + layout_size: LayoutSize, 3128 + job: &step_jobs::StepJob, 3129 + reduce_motion: bool, 3130 + ) -> StepProgressOutcome { 3131 + use bone_ui::widgets::{Dialog, DialogButton, LabelText, WidgetPaint, show_dialog}; 3132 + use bone_ui::{WidgetId, WidgetKey}; 3133 + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 3134 + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(170.0)); 3135 + let id = WidgetId::ROOT.child(WidgetKey::new("step.progress")); 3136 + let cancel_id = id.child(WidgetKey::new("cancel")); 3137 + let title = match job { 3138 + step_jobs::StepJob::Import { .. } => strings::STEP_PROGRESS_TITLE_IMPORT, 3139 + step_jobs::StepJob::Export { .. } => strings::STEP_PROGRESS_TITLE_EXPORT, 3140 + }; 3141 + let buttons = [DialogButton::secondary( 3142 + cancel_id, 3143 + strings::STEP_PROGRESS_CANCEL, 3144 + )]; 3145 + let sweep = sweep_phase(ctx.input.frame, reduce_motion); 3146 + let file_name = job.meta().file_name.clone(); 3147 + let (response, ()) = show_dialog( 3148 + ctx, 3149 + Dialog::new(id, viewport, dialog_size, title, &buttons), 3150 + |ctx, body_rect, paint| { 3151 + let label_rect = LayoutRect::new( 3152 + LayoutPos::new( 3153 + LayoutPx::new(body_rect.origin.x.value() + 16.0), 3154 + LayoutPx::new(body_rect.origin.y.value() + 12.0), 3155 + ), 3156 + LayoutSize::new( 3157 + LayoutPx::saturating_nonneg(body_rect.size.width.value() - 32.0), 3158 + LayoutPx::new(20.0), 3159 + ), 3160 + ); 3161 + paint.push(WidgetPaint::Label { 3162 + rect: label_rect, 3163 + text: LabelText::Owned(file_name), 3164 + color: ctx.theme().colors.text_secondary(), 3165 + role: ctx.theme().typography.body, 3166 + }); 3167 + push_progress_bar(ctx, body_rect, sweep, paint); 3168 + }, 3169 + ); 3170 + StepProgressOutcome { 3171 + paints: response.paint, 3172 + cancel_requested: response.dismissed || response.activated == Some(cancel_id), 3173 + } 3174 + } 3175 + 3176 + const SWEEP_PERIOD_SECS: f32 = 1.2; 3177 + const SWEEP_SPAN: f32 = 0.3; 3178 + 3179 + fn sweep_phase(now: bone_ui::input::FrameInstant, reduce_motion: bool) -> f32 { 3180 + if reduce_motion { 3181 + return 0.5; 3182 + } 3183 + (now.duration().as_secs_f32() / SWEEP_PERIOD_SECS).fract() 3184 + } 3185 + 3186 + fn push_progress_bar( 3187 + ctx: &FrameCtx<'_>, 3188 + body: LayoutRect, 3189 + sweep: f32, 3190 + paint: &mut Vec<bone_ui::widgets::WidgetPaint>, 3191 + ) { 3192 + use bone_ui::widgets::WidgetPaint; 3193 + let track = LayoutRect::new( 3194 + LayoutPos::new( 3195 + LayoutPx::new(body.origin.x.value() + 16.0), 3196 + LayoutPx::new(body.origin.y.value() + 48.0), 3197 + ), 3198 + LayoutSize::new( 3199 + LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), 3200 + LayoutPx::new(8.0), 3201 + ), 3202 + ); 3203 + paint.push(WidgetPaint::Surface { 3204 + rect: track, 3205 + fill: ctx.theme().colors.surface(bone_ui::theme::SurfaceLevel::L0), 3206 + border: Some(bone_ui::theme::Border { 3207 + width: bone_ui::theme::StrokeWidth::HAIRLINE, 3208 + color: ctx 3209 + .theme() 3210 + .colors 3211 + .neutral 3212 + .step(bone_ui::theme::Step12::SUBTLE_BORDER), 3213 + }), 3214 + radius: ctx.theme().radius.sm, 3215 + elevation: None, 3216 + }); 3217 + let start = sweep * (1.0 + SWEEP_SPAN) - SWEEP_SPAN; 3218 + let left = start.max(0.0); 3219 + let right = (start + SWEEP_SPAN).min(1.0); 3220 + if right <= left { 3221 + return; 3222 + } 3223 + let width = track.size.width.value(); 3224 + let fill_rect = LayoutRect::new( 3225 + LayoutPos::new( 3226 + LayoutPx::new(track.origin.x.value() + width * left), 3227 + track.origin.y, 3228 + ), 3229 + LayoutSize::new(LayoutPx::new(width * (right - left)), track.size.height), 3230 + ); 3231 + paint.push(WidgetPaint::Surface { 3232 + rect: fill_rect, 3233 + fill: ctx.theme().colors.accent_solid(), 3234 + border: None, 3235 + radius: ctx.theme().radius.sm, 3236 + elevation: None, 3237 + }); 3238 + } 3239 + 3240 + fn apply_step_progress_outcome(state: &RenderState, outcome: Option<StepProgressOutcome>) { 3241 + let cancel = outcome.is_some_and(|o| o.cancel_requested); 3242 + if !cancel { 3243 + return; 3244 + } 3245 + if let Some(job) = state.step_job.as_ref() { 3246 + job.meta().request_cancel(); 3247 + } 3248 + } 3249 + 3250 + #[derive(Clone, Debug, PartialEq)] 3032 3251 struct NotificationOutcome { 3033 3252 paints: Vec<bone_ui::widgets::WidgetPaint>, 3034 3253 dismissed: bool, ··· 3141 3360 OverwriteAction::Cancel => { 3142 3361 state.pending_overwrite = None; 3143 3362 } 3144 - OverwriteAction::Replace => { 3145 - if let Some(folder) = state.pending_overwrite.take() { 3146 - perform_save_to(state, folder); 3147 - } 3148 - } 3363 + OverwriteAction::Replace => match state.pending_overwrite.take() { 3364 + Some(PendingOverwrite::Document(folder)) => perform_save_to(state, folder), 3365 + Some(PendingOverwrite::StepExport(path)) => start_step_export(state, path), 3366 + None => {} 3367 + }, 3149 3368 } 3150 3369 } 3151 3370 ··· 3391 3610 request_new_document(state); 3392 3611 } 3393 3612 Some(shell::MenuAction::OpenDocument) => { 3394 - open_picker(state, bone_ui::widgets::FilePickerMode::Open, None); 3613 + open_picker( 3614 + state, 3615 + bone_ui::widgets::FilePickerMode::Open, 3616 + file_menu::FileKind::Document, 3617 + None, 3618 + ); 3395 3619 } 3396 3620 Some(shell::MenuAction::SaveDocument) => { 3397 3621 apply_save_in_place(state); 3398 3622 } 3399 3623 Some(shell::MenuAction::SaveDocumentAs) => { 3400 3624 let seed = state.document.name().to_owned(); 3401 - open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed)); 3625 + open_picker( 3626 + state, 3627 + bone_ui::widgets::FilePickerMode::Save, 3628 + file_menu::FileKind::Document, 3629 + Some(seed), 3630 + ); 3631 + } 3632 + Some(shell::MenuAction::ImportStep) => { 3633 + if state.step_job.is_none() { 3634 + open_picker( 3635 + state, 3636 + bone_ui::widgets::FilePickerMode::Open, 3637 + file_menu::FileKind::Step, 3638 + None, 3639 + ); 3640 + } 3641 + } 3642 + Some(shell::MenuAction::ExportStep) => { 3643 + if state.step_job.is_none() { 3644 + let seed = format!("{}.step", state.document.name()); 3645 + open_picker( 3646 + state, 3647 + bone_ui::widgets::FilePickerMode::Save, 3648 + file_menu::FileKind::Step, 3649 + Some(seed), 3650 + ); 3651 + } 3402 3652 } 3403 3653 Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) 3404 3654 | None => {} ··· 3421 3671 } 3422 3672 } 3423 3673 3674 + fn request_import_step(state: &mut RenderState, path: PathBuf) { 3675 + if is_dirty(state) { 3676 + state.pending_discard = Some(PendingDiscard::ImportStep(path)); 3677 + } else { 3678 + start_step_import(state, path); 3679 + } 3680 + } 3681 + 3682 + fn start_step_import(state: &mut RenderState, path: PathBuf) { 3683 + if state.step_job.is_some() { 3684 + return; 3685 + } 3686 + match step_jobs::spawn_import(path, state.document.clone()) { 3687 + Ok(job) => state.step_job = Some(job), 3688 + Err(e) => notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()), 3689 + } 3690 + } 3691 + 3692 + fn apply_export_step_as(state: &mut RenderState, path: PathBuf, via: PickedVia) { 3693 + let extension_appended = !file_menu::is_step_file(&path); 3694 + let path = file_menu::with_step_extension(path); 3695 + let unconfirmed = matches!(via, PickedVia::CustomPicker) || extension_appended; 3696 + if unconfirmed && path.is_file() { 3697 + state.pending_overwrite = Some(PendingOverwrite::StepExport(path)); 3698 + return; 3699 + } 3700 + start_step_export(state, path); 3701 + } 3702 + 3703 + fn start_step_export(state: &mut RenderState, path: PathBuf) { 3704 + if state.step_job.is_some() { 3705 + return; 3706 + } 3707 + match step_jobs::spawn_export(state.document.clone(), path) { 3708 + Ok(job) => state.step_job = Some(job), 3709 + Err(e) => notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()), 3710 + } 3711 + } 3712 + 3713 + fn poll_step_job(state: &mut RenderState) { 3714 + let Some(job) = state.step_job.take() else { 3715 + return; 3716 + }; 3717 + match job { 3718 + step_jobs::StepJob::Import { rx, baseline, meta } => match step_jobs::poll(&rx) { 3719 + std::task::Poll::Pending => { 3720 + state.step_job = Some(step_jobs::StepJob::Import { rx, baseline, meta }); 3721 + } 3722 + std::task::Poll::Ready(result) => finish_import(state, result, &baseline, &meta), 3723 + }, 3724 + step_jobs::StepJob::Export { rx, meta } => match step_jobs::poll(&rx) { 3725 + std::task::Poll::Pending => { 3726 + state.step_job = Some(step_jobs::StepJob::Export { rx, meta }); 3727 + } 3728 + std::task::Poll::Ready(result) => finish_export(state, result, &meta), 3729 + }, 3730 + } 3731 + } 3732 + 3733 + fn finish_import( 3734 + state: &mut RenderState, 3735 + result: step_jobs::JobResult<Box<Document>>, 3736 + baseline: &Document, 3737 + meta: &step_jobs::JobMeta, 3738 + ) { 3739 + match result { 3740 + step_jobs::JobResult::Finished(_) if meta.cancel_requested() => { 3741 + tracing::info!(file = %meta.file_name, "discarding import that finished after cancel"); 3742 + } 3743 + step_jobs::JobResult::Finished(document) if *baseline != state.document => { 3744 + state.pending_discard = Some(PendingDiscard::InstallImported { 3745 + document, 3746 + file_name: meta.file_name.clone(), 3747 + }); 3748 + } 3749 + step_jobs::JobResult::Finished(document) => { 3750 + install_imported_document(state, *document); 3751 + notify_info( 3752 + state, 3753 + strings::NOTIFY_IMPORTED, 3754 + Some(meta.file_name.clone()), 3755 + ); 3756 + } 3757 + step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { 3758 + tracing::info!(file = %meta.file_name, "step import canceled"); 3759 + } 3760 + step_jobs::JobResult::Failed(e) => { 3761 + tracing::warn!(error = %e, file = %meta.file_name, "step import failed"); 3762 + notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()); 3763 + } 3764 + step_jobs::JobResult::WorkerLost => { 3765 + tracing::error!(file = %meta.file_name, "step import worker stopped before reporting a result"); 3766 + notify_error( 3767 + state, 3768 + strings::NOTIFY_IMPORT_FAILED, 3769 + "worker stopped before reporting a result".to_owned(), 3770 + ); 3771 + } 3772 + } 3773 + } 3774 + 3775 + fn finish_export( 3776 + state: &mut RenderState, 3777 + result: step_jobs::JobResult<()>, 3778 + meta: &step_jobs::JobMeta, 3779 + ) { 3780 + match result { 3781 + step_jobs::JobResult::Finished(()) => { 3782 + notify_info( 3783 + state, 3784 + strings::NOTIFY_EXPORTED, 3785 + Some(meta.file_name.clone()), 3786 + ); 3787 + } 3788 + step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { 3789 + tracing::info!(file = %meta.file_name, "step export canceled"); 3790 + } 3791 + step_jobs::JobResult::Failed(e) => { 3792 + tracing::warn!(error = %e, file = %meta.file_name, "step export failed"); 3793 + notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()); 3794 + } 3795 + step_jobs::JobResult::WorkerLost => { 3796 + tracing::error!(file = %meta.file_name, "step export worker stopped before reporting a result"); 3797 + notify_error( 3798 + state, 3799 + strings::NOTIFY_EXPORT_FAILED, 3800 + "worker stopped before reporting a result".to_owned(), 3801 + ); 3802 + } 3803 + } 3804 + } 3805 + 3424 3806 fn is_dirty(state: &RenderState) -> bool { 3425 3807 state.last_saved.as_ref() != Some(&state.document) 3426 3808 } ··· 3455 3837 fn open_picker( 3456 3838 state: &mut RenderState, 3457 3839 mode: bone_ui::widgets::FilePickerMode, 3840 + kind: file_menu::FileKind, 3458 3841 seed_filename: Option<String>, 3459 3842 ) { 3460 3843 if state.file_picker.is_some() || state.native_picker.is_some() { ··· 3470 3853 .is_dir() 3471 3854 .then(|| state.documents_root.clone()) 3472 3855 }); 3473 - let title_key = match mode { 3474 - bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN, 3475 - bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS, 3476 - }; 3477 - let accept_key = match mode { 3478 - bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_OPEN, 3479 - bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_SAVE, 3480 - }; 3856 + let title_key = file_menu::title_key(kind, mode); 3857 + let accept_key = file_menu::accept_key(kind, mode); 3481 3858 let title = state.strings.resolve(title_key).to_owned(); 3482 3859 let accept_label = state.strings.resolve(accept_key).to_owned(); 3483 3860 let native_req = native_picker::Request { 3484 3861 mode, 3862 + kind, 3485 3863 title: title.as_str(), 3486 3864 accept_label: accept_label.as_str(), 3487 3865 seed_filename: seed_filename.as_deref(), ··· 3496 3874 tracing::debug!("native picker unavailable, falling back to custom picker"); 3497 3875 } 3498 3876 } 3499 - open_custom_picker(state, mode, seed_filename); 3877 + open_custom_picker(state, mode, kind, seed_filename); 3500 3878 } 3501 3879 3502 3880 fn open_custom_picker( 3503 3881 state: &mut RenderState, 3504 3882 mode: bone_ui::widgets::FilePickerMode, 3883 + kind: file_menu::FileKind, 3505 3884 seed_filename: Option<String>, 3506 3885 ) { 3507 - let entries = match file_menu::scan_document_folders(&state.documents_root) { 3886 + let scan = match kind { 3887 + file_menu::FileKind::Document => file_menu::scan_document_folders(&state.documents_root), 3888 + file_menu::FileKind::Step => file_menu::scan_step_files(&state.documents_root), 3889 + }; 3890 + let entries = match scan { 3508 3891 Ok(v) => v, 3509 3892 Err(e) => { 3510 3893 tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed"); ··· 3515 3898 state.file_picker = Some(file_menu::FilePickerSession::open( 3516 3899 state.documents_root.clone(), 3517 3900 mode, 3901 + kind, 3518 3902 seed_filename, 3519 3903 entries, 3520 3904 )); ··· 3525 3909 return; 3526 3910 }; 3527 3911 let outcome = match handle.poll() { 3528 - native_picker::PollState::Pending => return, 3529 - native_picker::PollState::Ready(o) => o, 3912 + std::task::Poll::Pending => return, 3913 + std::task::Poll::Ready(o) => o, 3530 3914 }; 3531 3915 let mode = handle.mode; 3916 + let kind = handle.kind; 3532 3917 state.native_picker = None; 3533 3918 match outcome { 3534 - native_picker::NativeOutcome::Path(path) => match mode { 3535 - bone_ui::widgets::FilePickerMode::Open => request_open_folder(state, path), 3536 - bone_ui::widgets::FilePickerMode::Save => apply_save_as(state, path), 3537 - }, 3919 + native_picker::NativeOutcome::Path(path) => { 3920 + route_picked_path(state, kind, mode, path, PickedVia::NativePortal); 3921 + } 3538 3922 native_picker::NativeOutcome::Cancelled => {} 3539 3923 native_picker::NativeOutcome::Error(message) => { 3540 3924 tracing::warn!(error = %message, "native picker errored, falling back to custom picker"); 3541 - let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save) 3542 - .then(|| state.document.name().to_owned()); 3543 - open_custom_picker(state, mode, seed); 3925 + let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save).then(|| match kind { 3926 + file_menu::FileKind::Document => state.document.name().to_owned(), 3927 + file_menu::FileKind::Step => format!("{}.step", state.document.name()), 3928 + }); 3929 + open_custom_picker(state, mode, kind, seed); 3930 + } 3931 + } 3932 + } 3933 + 3934 + fn route_picked_path( 3935 + state: &mut RenderState, 3936 + kind: file_menu::FileKind, 3937 + mode: bone_ui::widgets::FilePickerMode, 3938 + path: PathBuf, 3939 + via: PickedVia, 3940 + ) { 3941 + match (kind, mode) { 3942 + (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Open) => { 3943 + request_open_folder(state, path); 3944 + } 3945 + (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Save) => { 3946 + apply_save_as(state, path); 3947 + } 3948 + (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Open) => { 3949 + request_import_step(state, path); 3950 + } 3951 + (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Save) => { 3952 + apply_export_step_as(state, path, via); 3544 3953 } 3545 3954 } 3546 3955 } ··· 3548 3957 fn apply_save_in_place(state: &mut RenderState) { 3549 3958 let Some(folder) = state.current_folder.clone() else { 3550 3959 let seed = state.document.name().to_owned(); 3551 - open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed)); 3960 + open_picker( 3961 + state, 3962 + bone_ui::widgets::FilePickerMode::Save, 3963 + file_menu::FileKind::Document, 3964 + Some(seed), 3965 + ); 3552 3966 return; 3553 3967 }; 3554 3968 if let Err(e) = bone_document::save(&state.document, &folder) { ··· 3560 3974 notify_info(state, strings::NOTIFY_SAVED, None); 3561 3975 } 3562 3976 3563 - fn apply_picker_command(state: &mut RenderState, command: file_menu::PickerCommand) { 3977 + fn apply_picker_command( 3978 + state: &mut RenderState, 3979 + kind: file_menu::FileKind, 3980 + command: file_menu::PickerCommand, 3981 + ) { 3564 3982 state.file_picker = None; 3565 3983 match command { 3566 3984 file_menu::PickerCommand::Cancel => {} 3567 - file_menu::PickerCommand::OpenFolder(path) => request_open_folder(state, path), 3568 - file_menu::PickerCommand::SaveAs(path) => apply_save_as(state, path), 3985 + file_menu::PickerCommand::Open(path) => { 3986 + route_picked_path( 3987 + state, 3988 + kind, 3989 + bone_ui::widgets::FilePickerMode::Open, 3990 + path, 3991 + PickedVia::CustomPicker, 3992 + ); 3993 + } 3994 + file_menu::PickerCommand::SaveAs(path) => { 3995 + route_picked_path( 3996 + state, 3997 + kind, 3998 + bone_ui::widgets::FilePickerMode::Save, 3999 + path, 4000 + PickedVia::CustomPicker, 4001 + ); 4002 + } 3569 4003 } 3570 4004 } 3571 4005 ··· 3589 4023 .as_ref() 3590 4024 .is_some_and(|current| same_folder(current.path(), folder.path())); 3591 4025 if folder.document_file().is_file() && !in_place { 3592 - state.pending_overwrite = Some(folder); 4026 + state.pending_overwrite = Some(PendingOverwrite::Document(folder)); 3593 4027 return; 3594 4028 } 3595 4029 perform_save_to(state, folder); ··· 3673 4107 if outcome.dismissed || outcome.activated.is_some() { 3674 4108 state.shortcut_bar = None; 3675 4109 } 4110 + } 4111 + 4112 + fn install_imported_document(state: &mut RenderState, document: Document) { 4113 + install_loaded_document(state, document, None); 4114 + state.last_saved = None; 3676 4115 } 3677 4116 3678 4117 fn install_loaded_document(
+38 -19
crates/bone-app/src/native_picker.rs
··· 3 3 4 4 use bone_ui::widgets::FilePickerMode; 5 5 6 + use crate::file_menu::FileKind; 7 + 6 8 #[derive(Debug)] 7 9 pub enum NativeOutcome { 8 10 Path(PathBuf), ··· 18 20 pub struct PendingHandle { 19 21 rx: Receiver<NativeOutcome>, 20 22 pub mode: FilePickerMode, 23 + pub kind: FileKind, 21 24 } 22 25 23 26 impl PendingHandle { 24 - #[must_use] 25 - pub fn poll(&self) -> PollState { 27 + pub fn poll(&self) -> std::task::Poll<NativeOutcome> { 26 28 match self.rx.try_recv() { 27 - Ok(outcome) => PollState::Ready(outcome), 28 - Err(TryRecvError::Empty) => PollState::Pending, 29 - Err(TryRecvError::Disconnected) => PollState::Ready(NativeOutcome::Error( 29 + Ok(outcome) => std::task::Poll::Ready(outcome), 30 + Err(TryRecvError::Empty) => std::task::Poll::Pending, 31 + Err(TryRecvError::Disconnected) => std::task::Poll::Ready(NativeOutcome::Error( 30 32 "picker worker disconnected".to_owned(), 31 33 )), 32 34 } 33 35 } 34 36 } 35 37 36 - #[derive(Debug)] 37 - pub enum PollState { 38 - Pending, 39 - Ready(NativeOutcome), 40 - } 41 - 42 38 #[derive(Copy, Clone)] 43 39 pub struct Request<'a> { 44 40 pub mode: FilePickerMode, 41 + pub kind: FileKind, 45 42 pub title: &'a str, 46 43 pub accept_label: &'a str, 47 44 pub seed_filename: Option<&'a str>, ··· 63 60 use std::path::PathBuf; 64 61 use std::sync::mpsc; 65 62 66 - use ashpd::desktop::file_chooser::{OpenFileRequest, SaveFileRequest}; 63 + use ashpd::desktop::file_chooser::{FileFilter, OpenFileRequest, SaveFileRequest}; 67 64 use bone_ui::widgets::FilePickerMode; 68 65 69 - use super::{NativeOutcome, PendingHandle, Request, SpawnError}; 66 + use super::{FileKind, NativeOutcome, PendingHandle, Request, SpawnError}; 70 67 71 68 pub(super) fn spawn(req: Request<'_>) -> Result<PendingHandle, SpawnError> { 72 69 let mode = req.mode; 70 + let kind = req.kind; 73 71 let title = req.title.to_owned(); 74 72 let accept = req.accept_label.to_owned(); 75 73 let seed = req.seed_filename.map(str::to_owned); ··· 78 76 let join = std::thread::Builder::new() 79 77 .name("bone-native-picker".to_owned()) 80 78 .spawn(move || { 81 - let outcome = pollster::block_on(run(mode, &title, &accept, seed, folder)); 79 + let outcome = pollster::block_on(run(mode, kind, &title, &accept, seed, folder)); 82 80 let _ = tx.send(outcome); 83 81 }); 84 82 match join { 85 - Ok(_handle) => Ok(PendingHandle { rx, mode }), 83 + Ok(_handle) => Ok(PendingHandle { rx, mode, kind }), 86 84 Err(_) => Err(SpawnError::Unsupported), 87 85 } 88 86 } 89 87 90 88 async fn run( 91 89 mode: FilePickerMode, 90 + kind: FileKind, 92 91 title: &str, 93 92 accept: &str, 94 93 seed: Option<String>, 95 94 folder: Option<PathBuf>, 96 95 ) -> NativeOutcome { 97 96 match mode { 98 - FilePickerMode::Open => run_open(title, accept, folder).await, 99 - FilePickerMode::Save => run_save(title, accept, seed, folder).await, 97 + FilePickerMode::Open => run_open(kind, title, accept, folder).await, 98 + FilePickerMode::Save => run_save(kind, title, accept, seed, folder).await, 100 99 } 101 100 } 102 101 103 - async fn run_open(title: &str, accept: &str, folder: Option<PathBuf>) -> NativeOutcome { 102 + fn step_filter() -> FileFilter { 103 + FileFilter::new("STEP") 104 + .glob("*.step") 105 + .glob("*.stp") 106 + .glob("*.STEP") 107 + .glob("*.STP") 108 + } 109 + 110 + async fn run_open( 111 + kind: FileKind, 112 + title: &str, 113 + accept: &str, 114 + folder: Option<PathBuf>, 115 + ) -> NativeOutcome { 104 116 let mut req = OpenFileRequest::default() 105 117 .title(title) 106 118 .accept_label(accept) 107 119 .modal(true) 108 120 .multiple(false) 109 - .directory(true); 121 + .directory(matches!(kind, FileKind::Document)); 122 + if matches!(kind, FileKind::Step) { 123 + req = req.filter(step_filter()); 124 + } 110 125 if let Some(f) = folder { 111 126 req = match req.current_folder::<PathBuf>(Some(f)) { 112 127 Ok(r) => r, ··· 132 147 } 133 148 134 149 async fn run_save( 150 + kind: FileKind, 135 151 title: &str, 136 152 accept: &str, 137 153 seed: Option<String>, ··· 141 157 .title(title) 142 158 .accept_label(accept) 143 159 .modal(true); 160 + if matches!(kind, FileKind::Step) { 161 + req = req.filter(step_filter()); 162 + } 144 163 if let Some(s) = seed.as_deref() { 145 164 req = req.current_name(s); 146 165 }
+58
crates/bone-app/src/shell.rs
··· 120 120 menu_file_open: WidgetId, 121 121 menu_file_save: WidgetId, 122 122 menu_file_save_as: WidgetId, 123 + menu_file_import: WidgetId, 124 + menu_file_export: WidgetId, 125 + menu_file_export_step: WidgetId, 123 126 menu_file_quit: WidgetId, 124 127 menu_edit_undo: WidgetId, 125 128 menu_edit_redo: WidgetId, ··· 191 194 menu_file_open: menu_file.child(WidgetKey::new("open")), 192 195 menu_file_save: menu_file.child(WidgetKey::new("save")), 193 196 menu_file_save_as: menu_file.child(WidgetKey::new("save_as")), 197 + menu_file_import: menu_file.child(WidgetKey::new("import")), 198 + menu_file_export: menu_file.child(WidgetKey::new("export")), 199 + menu_file_export_step: menu_file 200 + .child(WidgetKey::new("export")) 201 + .child(WidgetKey::new("step")), 194 202 menu_file_quit: menu_file.child(WidgetKey::new("quit")), 195 203 menu_edit_undo: menu_edit.child(WidgetKey::new("undo")), 196 204 menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), ··· 226 234 (self.menu_file_open, MenuAction::OpenDocument), 227 235 (self.menu_file_save, MenuAction::SaveDocument), 228 236 (self.menu_file_save_as, MenuAction::SaveDocumentAs), 237 + (self.menu_file_import, MenuAction::ImportStep), 238 + (self.menu_file_export_step, MenuAction::ExportStep), 229 239 (self.menu_file_quit, MenuAction::Quit), 230 240 (self.menu_edit_undo, MenuAction::Undo), 231 241 (self.menu_edit_redo, MenuAction::Redo), ··· 246 256 OpenDocument, 247 257 SaveDocument, 248 258 SaveDocumentAs, 259 + ImportStep, 260 + ExportStep, 249 261 Quit, 250 262 Undo, 251 263 Redo, ··· 1445 1457 Some(crate::hotkeys::SAVE_DOCUMENT_ACTION), 1446 1458 ), 1447 1459 action_with_accel(ids.menu_file_save_as, strings::MENU_FILE_SAVE_AS, None), 1460 + MenuItem::Separator, 1461 + action_with_accel( 1462 + ids.menu_file_import, 1463 + strings::MENU_FILE_IMPORT, 1464 + Some(crate::hotkeys::IMPORT_STEP_ACTION), 1465 + ), 1466 + MenuItem::Submenu { 1467 + id: ids.menu_file_export, 1468 + label: strings::MENU_FILE_EXPORT, 1469 + items: vec![action_with_accel( 1470 + ids.menu_file_export_step, 1471 + strings::MENU_FILE_EXPORT_STEP, 1472 + Some(crate::hotkeys::EXPORT_STEP_ACTION), 1473 + )], 1474 + }, 1448 1475 MenuItem::Separator, 1449 1476 action_with_accel( 1450 1477 ids.menu_file_quit, ··· 3459 3486 shell.ids.menu_action_for(shell.ids.menu_file_save_as), 3460 3487 Some(MenuAction::SaveDocumentAs), 3461 3488 ); 3489 + assert_eq!( 3490 + shell.ids.menu_action_for(shell.ids.menu_file_import), 3491 + Some(MenuAction::ImportStep), 3492 + ); 3493 + assert_eq!( 3494 + shell.ids.menu_action_for(shell.ids.menu_file_export_step), 3495 + Some(MenuAction::ExportStep), 3496 + ); 3497 + assert_eq!(shell.ids.menu_action_for(shell.ids.menu_file_export), None); 3462 3498 } 3463 3499 3464 3500 #[test] ··· 3492 3528 assert!(!entry_for(strings::MENU_FILE_OPEN).1); 3493 3529 assert!(!entry_for(strings::MENU_FILE_SAVE).1); 3494 3530 assert!(!entry_for(strings::MENU_FILE_SAVE_AS).1); 3531 + assert!(!entry_for(strings::MENU_FILE_IMPORT).1); 3532 + let export_formats: Vec<(StringKey, bool)> = file_menu 3533 + .items 3534 + .iter() 3535 + .filter_map(|i| match i { 3536 + MenuItem::Submenu { label, items, .. } if *label == strings::MENU_FILE_EXPORT => { 3537 + Some(items) 3538 + } 3539 + _ => None, 3540 + }) 3541 + .flatten() 3542 + .filter_map(|i| match i { 3543 + MenuItem::Action { 3544 + label, disabled, .. 3545 + } => Some((*label, *disabled)), 3546 + _ => None, 3547 + }) 3548 + .collect(); 3549 + assert_eq!( 3550 + export_formats, 3551 + vec![(strings::MENU_FILE_EXPORT_STEP, false)], 3552 + ); 3495 3553 } 3496 3554 3497 3555 #[test]
+2
crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap
··· 12 12 action=15 chord=F scope=Global 13 13 action=16 chord=S scope=Global 14 14 action=17 chord=Ctrl+Q scope=Global 15 + action=18 chord=Ctrl+I scope=Global 16 + action=19 chord=Ctrl+E scope=Global 15 17 action=2 chord=Ctrl+Z scope=Global 16 18 action=3 chord=Ctrl+Shift+Z scope=Global 17 19 action=3 chord=Ctrl+Y scope=Global
+2 -1
crates/bone-app/src/status_badge.rs
··· 112 112 | BrepError::StepShellMalformed 113 113 | BrepError::StepEmpty 114 114 | BrepError::StepMultipleSolids { .. } 115 - | BrepError::StepUnsupported { .. } => LabelText::Key(strings::EXTRUDE_PANEL_INTERNAL), 115 + | BrepError::StepUnsupported { .. } 116 + | BrepError::Canceled => LabelText::Key(strings::EXTRUDE_PANEL_INTERNAL), 116 117 } 117 118 } 118 119
+194
crates/bone-app/src/step_jobs.rs
··· 1 + use std::path::{Path, PathBuf}; 2 + use std::sync::Arc; 3 + use std::sync::atomic::{AtomicBool, Ordering}; 4 + use std::sync::mpsc::{Receiver, TryRecvError}; 5 + 6 + use bone_document::Document; 7 + use bone_interop::{CancelFlag, StepError}; 8 + use bone_types::StepSchema; 9 + 10 + const PROGRESS_DIALOG_THRESHOLD_BYTES: u64 = 1_000_000; 11 + 12 + pub struct JobMeta { 13 + cancel: Arc<AtomicBool>, 14 + pub file_name: String, 15 + pub show_progress: bool, 16 + } 17 + 18 + impl JobMeta { 19 + pub fn request_cancel(&self) { 20 + self.cancel.store(true, Ordering::Relaxed); 21 + } 22 + 23 + #[must_use] 24 + pub fn cancel_requested(&self) -> bool { 25 + self.cancel.load(Ordering::Relaxed) 26 + } 27 + } 28 + 29 + #[derive(Debug)] 30 + pub enum JobResult<T> { 31 + Finished(T), 32 + Failed(StepError), 33 + WorkerLost, 34 + } 35 + 36 + pub enum StepJob { 37 + Import { 38 + rx: Receiver<Result<Box<Document>, StepError>>, 39 + baseline: Box<Document>, 40 + meta: JobMeta, 41 + }, 42 + Export { 43 + rx: Receiver<Result<(), StepError>>, 44 + meta: JobMeta, 45 + }, 46 + } 47 + 48 + impl StepJob { 49 + #[must_use] 50 + pub fn meta(&self) -> &JobMeta { 51 + match self { 52 + Self::Import { meta, .. } | Self::Export { meta, .. } => meta, 53 + } 54 + } 55 + } 56 + 57 + pub fn poll<T>(rx: &Receiver<Result<T, StepError>>) -> std::task::Poll<JobResult<T>> { 58 + match rx.try_recv() { 59 + Ok(Ok(value)) => std::task::Poll::Ready(JobResult::Finished(value)), 60 + Ok(Err(e)) => std::task::Poll::Ready(JobResult::Failed(e)), 61 + Err(TryRecvError::Empty) => std::task::Poll::Pending, 62 + Err(TryRecvError::Disconnected) => std::task::Poll::Ready(JobResult::WorkerLost), 63 + } 64 + } 65 + 66 + #[derive(Debug, thiserror::Error)] 67 + pub enum SpawnError { 68 + #[error("step worker thread failed to start: {0}")] 69 + Thread(std::io::Error), 70 + } 71 + 72 + pub fn spawn_import(path: PathBuf, baseline: Document) -> Result<StepJob, SpawnError> { 73 + let show_progress = 74 + std::fs::metadata(&path).is_ok_and(|m| m.len() > PROGRESS_DIALOG_THRESHOLD_BYTES); 75 + let (rx, meta) = spawn(display_name(&path), show_progress, move |cancel| { 76 + bone_interop::read(&path, cancel).map(Box::new) 77 + })?; 78 + Ok(StepJob::Import { 79 + rx, 80 + baseline: Box::new(baseline), 81 + meta, 82 + }) 83 + } 84 + 85 + pub fn spawn_export(document: Document, path: PathBuf) -> Result<StepJob, SpawnError> { 86 + let (rx, meta) = spawn(display_name(&path), false, move |cancel| { 87 + bone_interop::write(&document, &path, StepSchema::Ap214, cancel) 88 + })?; 89 + Ok(StepJob::Export { rx, meta }) 90 + } 91 + 92 + fn display_name(path: &Path) -> String { 93 + path.file_name() 94 + .map(|s| s.to_string_lossy().into_owned()) 95 + .unwrap_or_default() 96 + } 97 + 98 + fn spawn<T: Send + 'static>( 99 + file_name: String, 100 + show_progress: bool, 101 + work: impl FnOnce(CancelFlag<'_>) -> Result<T, StepError> + Send + 'static, 102 + ) -> Result<(Receiver<Result<T, StepError>>, JobMeta), SpawnError> { 103 + let cancel = Arc::new(AtomicBool::new(false)); 104 + let cancel_for_worker = Arc::clone(&cancel); 105 + let (tx, rx) = std::sync::mpsc::channel(); 106 + std::thread::Builder::new() 107 + .name("bone-step-job".to_owned()) 108 + .spawn(move || { 109 + let result = work(CancelFlag::new(cancel_for_worker.as_ref())); 110 + let _ = tx.send(result); 111 + }) 112 + .map_err(SpawnError::Thread)?; 113 + Ok(( 114 + rx, 115 + JobMeta { 116 + cancel, 117 + file_name, 118 + show_progress, 119 + }, 120 + )) 121 + } 122 + 123 + #[cfg(test)] 124 + mod tests { 125 + use super::{JobResult, StepJob, poll, spawn_export, spawn_import}; 126 + use bone_interop::StepError; 127 + use std::path::PathBuf; 128 + use std::sync::mpsc::Receiver; 129 + 130 + fn wait<T>(rx: &Receiver<Result<T, StepError>>) -> JobResult<T> { 131 + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); 132 + loop { 133 + match poll(rx) { 134 + std::task::Poll::Ready(result) => return result, 135 + std::task::Poll::Pending => { 136 + assert!(std::time::Instant::now() < deadline, "step job timed out"); 137 + std::thread::yield_now(); 138 + } 139 + } 140 + } 141 + } 142 + 143 + #[test] 144 + fn import_of_missing_file_reports_failure() { 145 + let baseline = 146 + bone_document::Document::new(bone_types::DocumentId::default(), "scallop".to_owned()); 147 + let job = match spawn_import(PathBuf::from("/nonexistent/scallop.step"), baseline) { 148 + Ok(job) => job, 149 + Err(e) => panic!("spawn import: {e}"), 150 + }; 151 + let StepJob::Import { rx, .. } = job else { 152 + panic!("spawn_import yields an import job"); 153 + }; 154 + assert!(matches!(wait(&rx), JobResult::Failed(StepError::Io { .. }))); 155 + } 156 + 157 + #[test] 158 + fn export_failure_leaves_no_step_file() { 159 + let dir = 160 + std::env::temp_dir().join(format!("bone-step-job-export-fail-{}", std::process::id())); 161 + if let Err(e) = std::fs::create_dir_all(&dir) { 162 + panic!("temp dir: {e}"); 163 + } 164 + let target = dir.join("nautilus.step"); 165 + let document = 166 + bone_document::Document::new(bone_types::DocumentId::default(), "nautilus".to_owned()); 167 + let job = match spawn_export(document, target.clone()) { 168 + Ok(job) => job, 169 + Err(e) => panic!("spawn export: {e}"), 170 + }; 171 + let StepJob::Export { rx, .. } = job else { 172 + panic!("spawn_export yields an export job"); 173 + }; 174 + match wait(&rx) { 175 + JobResult::Failed(_) => {} 176 + other => panic!("empty document export must fail, got {other:?}"), 177 + } 178 + assert!(!target.exists()); 179 + if let Err(e) = std::fs::remove_dir_all(&dir) { 180 + panic!("temp dir cleanup: {e}"); 181 + } 182 + } 183 + 184 + #[test] 185 + fn worker_panic_surfaces_as_worker_lost() { 186 + let (rx, _meta) = match super::spawn("limpet.step".to_owned(), false, |_| { 187 + panic!("worker dies before reporting") 188 + }) { 189 + Ok(spawned) => spawned, 190 + Err(e) => panic!("spawn: {e}"), 191 + }; 192 + assert!(matches!(wait::<()>(&rx), JobResult::WorkerLost)); 193 + } 194 + }
+94
crates/bone-app/src/strings.rs
··· 146 146 pub const MENU_FILE_SAVE: StringKey = StringKey::new("menu.file.save"); 147 147 pub const MENU_FILE_SAVE_AS: StringKey = StringKey::new("menu.file.save_as"); 148 148 pub const MENU_FILE_QUIT: StringKey = StringKey::new("menu.file.quit"); 149 + pub const MENU_FILE_IMPORT: StringKey = StringKey::new("menu.file.import"); 150 + pub const MENU_FILE_EXPORT: StringKey = StringKey::new("menu.file.export"); 151 + pub const MENU_FILE_EXPORT_STEP: StringKey = StringKey::new("menu.file.export.step"); 149 152 pub const FILE_PICKER_TITLE_OPEN: StringKey = StringKey::new("file_picker.title.open"); 150 153 pub const FILE_PICKER_TITLE_SAVE_AS: StringKey = StringKey::new("file_picker.title.save_as"); 154 + pub const FILE_PICKER_TITLE_IMPORT: StringKey = StringKey::new("file_picker.title.import"); 155 + pub const FILE_PICKER_TITLE_EXPORT: StringKey = StringKey::new("file_picker.title.export"); 151 156 pub const FILE_PICKER_OPEN: StringKey = StringKey::new("file_picker.open"); 152 157 pub const FILE_PICKER_SAVE: StringKey = StringKey::new("file_picker.save"); 158 + pub const FILE_PICKER_IMPORT: StringKey = StringKey::new("file_picker.import"); 159 + pub const FILE_PICKER_EXPORT: StringKey = StringKey::new("file_picker.export"); 160 + pub const FILE_PICKER_NO_STEP_FILES: StringKey = StringKey::new("file_picker.step.empty"); 153 161 pub const FILE_PICKER_CANCEL: StringKey = StringKey::new("file_picker.cancel"); 154 162 pub const FILE_PICKER_LIST: StringKey = StringKey::new("file_picker.list"); 163 + pub const FILE_PICKER_LIST_STEP: StringKey = StringKey::new("file_picker.list.step"); 155 164 pub const FILE_PICKER_FILENAME_PLACEHOLDER: StringKey = 156 165 StringKey::new("file_picker.filename_placeholder"); 166 + pub const FILE_PICKER_FILENAME_PLACEHOLDER_STEP: StringKey = 167 + StringKey::new("file_picker.filename_placeholder.step"); 157 168 pub const FILE_PICKER_DIR_EMPTY: StringKey = StringKey::new("file_picker.dir.empty"); 158 169 pub const FILE_OVERWRITE_TITLE: StringKey = StringKey::new("file.overwrite.title"); 159 170 pub const FILE_OVERWRITE_MESSAGE: StringKey = StringKey::new("file.overwrite.message"); 171 + pub const FILE_OVERWRITE_TITLE_STEP: StringKey = StringKey::new("file.overwrite.title.step"); 172 + pub const FILE_OVERWRITE_MESSAGE_STEP: StringKey = StringKey::new("file.overwrite.message.step"); 160 173 pub const FILE_OVERWRITE_REPLACE: StringKey = StringKey::new("file.overwrite.replace"); 161 174 pub const FILE_OVERWRITE_CANCEL: StringKey = StringKey::new("file.overwrite.cancel"); 162 175 pub const FILE_DISCARD_TITLE: StringKey = StringKey::new("file.discard.title"); 163 176 pub const FILE_DISCARD_MESSAGE: StringKey = StringKey::new("file.discard.message"); 164 177 pub const FILE_DISCARD_CONFIRM: StringKey = StringKey::new("file.discard.confirm"); 165 178 pub const FILE_DISCARD_CANCEL: StringKey = StringKey::new("file.discard.cancel"); 179 + pub const FILE_IMPORT_REPLACE_TITLE: StringKey = StringKey::new("file.import_replace.title"); 180 + pub const FILE_IMPORT_REPLACE_MESSAGE: StringKey = StringKey::new("file.import_replace.message"); 181 + pub const FILE_IMPORT_REPLACE_CONFIRM: StringKey = StringKey::new("file.import_replace.confirm"); 182 + pub const FILE_IMPORT_REPLACE_CANCEL: StringKey = StringKey::new("file.import_replace.cancel"); 166 183 pub const DEFAULT_DOCUMENT_NAME: StringKey = StringKey::new("file.default_document_name"); 167 184 pub const NOTIFY_SAVE_FAILED: StringKey = StringKey::new("notify.save_failed"); 168 185 pub const NOTIFY_LOAD_FAILED: StringKey = StringKey::new("notify.load_failed"); 169 186 pub const NOTIFY_SCAN_FAILED: StringKey = StringKey::new("notify.scan_failed"); 170 187 pub const NOTIFY_SAVED: StringKey = StringKey::new("notify.saved"); 188 + pub const NOTIFY_IMPORTED: StringKey = StringKey::new("notify.imported"); 189 + pub const NOTIFY_IMPORT_FAILED: StringKey = StringKey::new("notify.import_failed"); 190 + pub const NOTIFY_EXPORTED: StringKey = StringKey::new("notify.exported"); 191 + pub const NOTIFY_EXPORT_FAILED: StringKey = StringKey::new("notify.export_failed"); 192 + pub const STEP_PROGRESS_TITLE_IMPORT: StringKey = StringKey::new("step.progress.title.import"); 193 + pub const STEP_PROGRESS_TITLE_EXPORT: StringKey = StringKey::new("step.progress.title.export"); 194 + pub const STEP_PROGRESS_CANCEL: StringKey = StringKey::new("step.progress.cancel"); 171 195 pub const NOTIFY_DISMISS: StringKey = StringKey::new("notify.dismiss"); 172 196 pub const MENU_EDIT_UNDO: StringKey = StringKey::new("menu.edit.undo"); 173 197 pub const MENU_EDIT_REDO: StringKey = StringKey::new("menu.edit.redo"); ··· 199 223 pub const HOTKEY_LABEL_NEW: StringKey = StringKey::new("hotkey.label.new"); 200 224 pub const HOTKEY_LABEL_OPEN: StringKey = StringKey::new("hotkey.label.open"); 201 225 pub const HOTKEY_LABEL_SAVE: StringKey = StringKey::new("hotkey.label.save"); 226 + pub const HOTKEY_LABEL_IMPORT: StringKey = StringKey::new("hotkey.label.import"); 227 + pub const HOTKEY_LABEL_EXPORT: StringKey = StringKey::new("hotkey.label.export"); 202 228 pub const HOTKEY_LABEL_SELECT_ALL: StringKey = StringKey::new("hotkey.label.select_all"); 203 229 pub const HOTKEY_LABEL_DELETE_SELECTION: StringKey = 204 230 StringKey::new("hotkey.label.delete_selection"); ··· 450 476 (MENU_FILE_SAVE, "Save"), 451 477 (MENU_FILE_SAVE_AS, "Save As..."), 452 478 (MENU_FILE_QUIT, "Quit"), 479 + (MENU_FILE_IMPORT, "Import..."), 480 + (MENU_FILE_EXPORT, "Export"), 481 + (MENU_FILE_EXPORT_STEP, "STEP (.step)"), 453 482 (FILE_PICKER_TITLE_OPEN, "Open Document"), 454 483 (FILE_PICKER_TITLE_SAVE_AS, "Save Document As"), 484 + (FILE_PICKER_TITLE_IMPORT, "Import STEP"), 485 + (FILE_PICKER_TITLE_EXPORT, "Export STEP"), 455 486 (FILE_PICKER_OPEN, "Open"), 456 487 (FILE_PICKER_SAVE, "Save"), 488 + (FILE_PICKER_IMPORT, "Import"), 489 + (FILE_PICKER_EXPORT, "Export"), 490 + (FILE_PICKER_NO_STEP_FILES, "No STEP files in this folder"), 457 491 (FILE_PICKER_CANCEL, "Cancel"), 458 492 (FILE_PICKER_LIST, "Document folders"), 493 + (FILE_PICKER_LIST_STEP, "STEP files"), 459 494 (FILE_PICKER_FILENAME_PLACEHOLDER, "Document name"), 495 + (FILE_PICKER_FILENAME_PLACEHOLDER_STEP, "File name"), 460 496 (FILE_PICKER_DIR_EMPTY, "No documents in this folder"), 461 497 (FILE_OVERWRITE_TITLE, "Replace document?"), 462 498 ( 463 499 FILE_OVERWRITE_MESSAGE, 464 500 "A document already exists at this location.", 465 501 ), 502 + (FILE_OVERWRITE_TITLE_STEP, "Replace file?"), 503 + ( 504 + FILE_OVERWRITE_MESSAGE_STEP, 505 + "A file already exists at this location.", 506 + ), 466 507 (FILE_OVERWRITE_REPLACE, "Replace"), 467 508 (FILE_OVERWRITE_CANCEL, "Cancel"), 468 509 (FILE_DISCARD_TITLE, "Discard unsaved changes?"), ··· 472 513 ), 473 514 (FILE_DISCARD_CONFIRM, "Discard"), 474 515 (FILE_DISCARD_CANCEL, "Keep editing"), 516 + (FILE_IMPORT_REPLACE_TITLE, "Replace current document?"), 517 + ( 518 + FILE_IMPORT_REPLACE_MESSAGE, 519 + "The document changed while the import was running. Installing the import discards those changes.", 520 + ), 521 + (FILE_IMPORT_REPLACE_CONFIRM, "Install import"), 522 + (FILE_IMPORT_REPLACE_CANCEL, "Keep current"), 475 523 (DEFAULT_DOCUMENT_NAME, "Untitled"), 476 524 (NOTIFY_SAVE_FAILED, "Save failed"), 477 525 (NOTIFY_LOAD_FAILED, "Open failed"), 478 526 (NOTIFY_SCAN_FAILED, "Could not read documents folder"), 479 527 (NOTIFY_SAVED, "Saved"), 528 + (NOTIFY_IMPORTED, "Imported"), 529 + (NOTIFY_IMPORT_FAILED, "Import failed"), 530 + (NOTIFY_EXPORTED, "Exported"), 531 + (NOTIFY_EXPORT_FAILED, "Export failed"), 532 + (STEP_PROGRESS_TITLE_IMPORT, "Importing STEP"), 533 + (STEP_PROGRESS_TITLE_EXPORT, "Exporting STEP"), 534 + (STEP_PROGRESS_CANCEL, "Cancel"), 480 535 (NOTIFY_DISMISS, "Dismiss"), 481 536 (MENU_EDIT_UNDO, "Undo"), 482 537 (MENU_EDIT_REDO, "Redo"), ··· 510 565 (HOTKEY_LABEL_NEW, "New"), 511 566 (HOTKEY_LABEL_OPEN, "Open"), 512 567 (HOTKEY_LABEL_SAVE, "Save"), 568 + (HOTKEY_LABEL_IMPORT, "Import STEP"), 569 + (HOTKEY_LABEL_EXPORT, "Export STEP"), 513 570 (HOTKEY_LABEL_SELECT_ALL, "Select All"), 514 571 (HOTKEY_LABEL_DELETE_SELECTION, "Delete"), 515 572 (HOTKEY_LABEL_ZOOM_FIT, "Zoom to Fit"), ··· 773 830 (MENU_FILE_SAVE, "[!! Sâve !!]"), 774 831 (MENU_FILE_SAVE_AS, "[!! Sâve Âs... !!]"), 775 832 (MENU_FILE_QUIT, "[!! Quît !!]"), 833 + (MENU_FILE_IMPORT, "[!! Impôrt... !!]"), 834 + (MENU_FILE_EXPORT, "[!! Expôrt !!]"), 835 + (MENU_FILE_EXPORT_STEP, "[!! STÊP (.step) !!]"), 776 836 (FILE_PICKER_TITLE_OPEN, "[!! Ôpen Dôcument !!]"), 777 837 (FILE_PICKER_TITLE_SAVE_AS, "[!! Sâve Dôcument Âs !!]"), 838 + (FILE_PICKER_TITLE_IMPORT, "[!! Impôrt STÊP !!]"), 839 + (FILE_PICKER_TITLE_EXPORT, "[!! Expôrt STÊP !!]"), 778 840 (FILE_PICKER_OPEN, "[!! Ôpen !!]"), 779 841 (FILE_PICKER_SAVE, "[!! Sâve !!]"), 842 + (FILE_PICKER_IMPORT, "[!! Impôrt !!]"), 843 + (FILE_PICKER_EXPORT, "[!! Expôrt !!]"), 844 + ( 845 + FILE_PICKER_NO_STEP_FILES, 846 + "[!! Nô STÊP fîles în thîs fôlder !!]", 847 + ), 780 848 (FILE_PICKER_CANCEL, "[!! Cancêl !!]"), 781 849 (FILE_PICKER_LIST, "[!! Dôcument fôlders !!]"), 850 + (FILE_PICKER_LIST_STEP, "[!! STÊP fîles !!]"), 782 851 (FILE_PICKER_FILENAME_PLACEHOLDER, "[!! Dôcument nâme !!]"), 852 + (FILE_PICKER_FILENAME_PLACEHOLDER_STEP, "[!! Fîle nâme !!]"), 783 853 (FILE_PICKER_DIR_EMPTY, "[!! Nô dôcuments în thîs fôlder !!]"), 784 854 (FILE_OVERWRITE_TITLE, "[!! Replâce dôcument? !!]"), 785 855 ( 786 856 FILE_OVERWRITE_MESSAGE, 787 857 "[!! Â dôcument alrêady êxists ât thîs locâtion. !!]", 788 858 ), 859 + (FILE_OVERWRITE_TITLE_STEP, "[!! Replâce fîle? !!]"), 860 + ( 861 + FILE_OVERWRITE_MESSAGE_STEP, 862 + "[!! Â fîle alrêady êxists ât thîs locâtion. !!]", 863 + ), 789 864 (FILE_OVERWRITE_REPLACE, "[!! Replâce !!]"), 790 865 (FILE_OVERWRITE_CANCEL, "[!! Cancêl !!]"), 791 866 (FILE_DISCARD_TITLE, "[!! Discârd unsâved chânges? !!]"), ··· 795 870 ), 796 871 (FILE_DISCARD_CONFIRM, "[!! Discârd !!]"), 797 872 (FILE_DISCARD_CANCEL, "[!! Kêep edîting !!]"), 873 + ( 874 + FILE_IMPORT_REPLACE_TITLE, 875 + "[!! Replâce cûrrent dôcument? !!]", 876 + ), 877 + ( 878 + FILE_IMPORT_REPLACE_MESSAGE, 879 + "[!! 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. !!]", 880 + ), 881 + (FILE_IMPORT_REPLACE_CONFIRM, "[!! Instâll impôrt !!]"), 882 + (FILE_IMPORT_REPLACE_CANCEL, "[!! Kêep cûrrent !!]"), 798 883 (DEFAULT_DOCUMENT_NAME, "[!! Untîtled !!]"), 799 884 (NOTIFY_SAVE_FAILED, "[!! Sâve fâiled !!]"), 800 885 (NOTIFY_LOAD_FAILED, "[!! Ôpen fâiled !!]"), ··· 803 888 "[!! Côuld not rêad dôcuments fôlder !!]", 804 889 ), 805 890 (NOTIFY_SAVED, "[!! Sâved !!]"), 891 + (NOTIFY_IMPORTED, "[!! Impôrted !!]"), 892 + (NOTIFY_IMPORT_FAILED, "[!! Impôrt fâiled !!]"), 893 + (NOTIFY_EXPORTED, "[!! Expôrted !!]"), 894 + (NOTIFY_EXPORT_FAILED, "[!! Expôrt fâiled !!]"), 895 + (STEP_PROGRESS_TITLE_IMPORT, "[!! Impôrting STÊP !!]"), 896 + (STEP_PROGRESS_TITLE_EXPORT, "[!! Expôrting STÊP !!]"), 897 + (STEP_PROGRESS_CANCEL, "[!! Cancêl !!]"), 806 898 (NOTIFY_DISMISS, "[!! Dîsmiss !!]"), 807 899 (MENU_EDIT_UNDO, "[!! Undô !!]"), 808 900 (MENU_EDIT_REDO, "[!! Redô !!]"), ··· 842 934 (HOTKEY_LABEL_NEW, "[!! Néw !!]"), 843 935 (HOTKEY_LABEL_OPEN, "[!! Ôpen !!]"), 844 936 (HOTKEY_LABEL_SAVE, "[!! Sâve !!]"), 937 + (HOTKEY_LABEL_IMPORT, "[!! Impôrt STÊP !!]"), 938 + (HOTKEY_LABEL_EXPORT, "[!! Expôrt STÊP !!]"), 845 939 (HOTKEY_LABEL_SELECT_ALL, "[!! Sêlect Âll !!]"), 846 940 (HOTKEY_LABEL_DELETE_SELECTION, "[!! Delête !!]"), 847 941 (HOTKEY_LABEL_ZOOM_FIT, "[!! Zôom to Fît !!]"),
crates/bone-interop/src/cancel.rs crates/bone-types/src/cancel.rs
+1 -2
crates/bone-interop/src/lib.rs
··· 1 - pub mod cancel; 2 1 pub mod step; 3 2 4 - pub use cancel::{Cancel, CancelFlag}; 3 + pub use bone_types::{Cancel, CancelFlag}; 5 4 pub use step::{HeaderDefect, StepError, body_of, read, write};
+30 -7
crates/bone-interop/src/step.rs
··· 7 7 }; 8 8 use bone_kernel::{BrepError, BrepSolid}; 9 9 use bone_types::{ 10 - DocumentId, FeatureId, StepEntityKind, StepFileHeader, StepFileName, StepOrganization, 11 - StepOriginatingSystem, StepSchema, 10 + CancelFlag, DocumentId, FeatureId, StepEntityKind, StepFileHeader, StepFileName, 11 + StepOrganization, StepOriginatingSystem, StepSchema, 12 12 }; 13 - 14 - use crate::cancel::CancelFlag; 15 13 16 14 const PINNED_TIMESTAMP: &str = "1970-01-01T00:00:00"; 17 15 const ORIGINATING_SYSTEM: &str = concat!("Bone ", env!("CARGO_PKG_VERSION")); ··· 159 157 160 158 pub fn read(path: &Path, cancel: CancelFlag) -> Result<Document, StepError> { 161 159 guard(cancel)?; 162 - let text = read_file(path)?; 160 + let text = read_file(path, cancel)?; 163 161 classify_schema(&text)?; 164 162 let sidecar = read_sidecar(path)?; 165 163 guard(cancel)?; ··· 169 167 &text, 170 168 feature, 171 169 sidecar.as_ref().map(|side| (side.solid(), side.reattach())), 170 + cancel, 172 171 ) 173 172 .map_err(read_geometry_error) 174 173 })?; ··· 178 177 179 178 fn read_geometry_error(error: BrepError) -> StepError { 180 179 match error { 180 + BrepError::Canceled => StepError::Canceled, 181 181 BrepError::StepUnsupported { kind } => StepError::UnsupportedEntity { kind }, 182 182 BrepError::StepSyntax | BrepError::StepNoData | BrepError::StepEmpty => { 183 183 StepError::IncompleteFile ··· 369 369 }) 370 370 } 371 371 372 - fn read_file(path: &Path) -> Result<String, StepError> { 373 - std::fs::read_to_string(path).map_err(|source| StepError::Io { 372 + const READ_CHUNK_BYTES: usize = 64 * 1024; 373 + 374 + fn read_file(path: &Path, cancel: CancelFlag) -> Result<String, StepError> { 375 + use io::Read; 376 + let as_io = |source: io::Error| StepError::Io { 374 377 path: path.to_path_buf(), 375 378 source, 379 + }; 380 + let file = std::fs::File::open(path).map_err(as_io)?; 381 + let mut reader = io::BufReader::new(file); 382 + let bytes = std::iter::from_fn(|| { 383 + if cancel.is_canceled() { 384 + return Some(Err(StepError::Canceled)); 385 + } 386 + let mut chunk = vec![0u8; READ_CHUNK_BYTES]; 387 + match reader.read(&mut chunk) { 388 + Ok(0) => None, 389 + Ok(n) => { 390 + chunk.truncate(n); 391 + Some(Ok(chunk)) 392 + } 393 + Err(e) if e.kind() == io::ErrorKind::Interrupted => Some(Ok(Vec::new())), 394 + Err(e) => Some(Err(as_io(e))), 395 + } 376 396 }) 397 + .collect::<Result<Vec<Vec<u8>>, StepError>>()? 398 + .concat(); 399 + String::from_utf8(bytes).map_err(|e| as_io(io::Error::new(io::ErrorKind::InvalidData, e))) 377 400 } 378 401 379 402 fn read_sidecar(step: &Path) -> Result<Option<LabelSidecar>, StepError> {
+2
crates/bone-kernel/src/brep/mod.rs
··· 138 138 StepMultipleSolids { count: usize }, 139 139 #[error("STEP geometry uses {kind}, which the facade does not yet bridge")] 140 140 StepUnsupported { kind: StepEntityKind }, 141 + #[error("STEP parse canceled before it completed")] 142 + Canceled, 141 143 } 142 144 143 145 #[derive(Clone, Debug)]
+22 -6
crates/bone-kernel/src/brep/step.rs
··· 1 1 use std::collections::{BTreeSet, HashMap, HashSet}; 2 2 3 3 use bone_types::{ 4 - EdgeLabel, EdgeRole, FaceLabel, FaceRole, FeatureId, ImportOrdinal, SolidKey, StepEntityKind, 5 - VertexLabel, VertexRole, 4 + CancelFlag, EdgeLabel, EdgeRole, FaceLabel, FaceRole, FeatureId, ImportOrdinal, SolidKey, 5 + StepEntityKind, VertexLabel, VertexRole, 6 6 }; 7 7 use truck_modeling::{ 8 8 BSplineCurve, Curve, Cut, Matrix4, ParametricCurve, Point3, RevolutedCurve, Shell, Solid, ··· 33 33 text: &str, 34 34 feature: FeatureId, 35 35 expected: Option<(SolidKey, &BrepReattach)>, 36 + cancel: CancelFlag, 36 37 ) -> Result<BrepSolid, BrepError> { 37 - let solid = parse_step_solid(text)?; 38 + let solid = parse_step_solid(text, cancel)?; 38 39 let key = content_key(&solid); 39 40 let labeling = match expected { 40 41 Some((expected_key, reattach)) if expected_key == key => { ··· 42 43 } 43 44 _ => imported_labeling(&solid, feature), 44 45 }; 46 + guard(cancel)?; 45 47 assemble(solid, &labeling) 46 48 } 47 49 } ··· 58 60 references: HashMap<StepEntityId, BTreeSet<StepEntityId>>, 59 61 } 60 62 61 - fn parse_step_solid(text: &str) -> Result<Solid, BrepError> { 63 + fn guard(cancel: CancelFlag) -> Result<(), BrepError> { 64 + if cancel.is_canceled() { 65 + return Err(BrepError::Canceled); 66 + } 67 + Ok(()) 68 + } 69 + 70 + fn parse_step_solid(text: &str, cancel: CancelFlag) -> Result<Solid, BrepError> { 71 + guard(cancel)?; 62 72 let data = first_data_section(text)?; 73 + guard(cancel)?; 63 74 let topology = classify_topology(&data); 64 75 if topology.solid_roots.len() >= 2 { 65 76 return Err(BrepError::StepMultipleSolids { 66 77 count: topology.solid_roots.len(), 67 78 }); 68 79 } 80 + guard(cancel)?; 69 81 let table = Table::from_data_section(&data); 70 82 let shells = solid_shell_keys(&table, &topology)? 71 83 .iter() 72 84 .map(|key| { 85 + guard(cancel)?; 73 86 let holder = &table.shell[&key.0]; 74 87 let compressed = table 75 88 .to_compressed_shell(holder) 76 89 .map_err(|_| BrepError::StepShellMalformed)?; 77 - let bridged = bridge_shell(compressed)?; 90 + let bridged = bridge_shell(compressed, cancel)?; 78 91 let normalized = split_closed_edges(bridged); 92 + guard(cancel)?; 79 93 Shell::extract(normalized).map_err(|_| BrepError::StepShellMalformed) 80 94 }) 81 95 .collect::<Result<Vec<Shell>, BrepError>>()?; ··· 202 216 type StepShell = CompressedShell<truck_modeling::Point3, step_in::Curve3D, step_in::Surface>; 203 217 type ModelShell = CompressedShell<truck_modeling::Point3, Curve, Surface>; 204 218 205 - fn bridge_shell(shell: StepShell) -> Result<ModelShell, BrepError> { 219 + fn bridge_shell(shell: StepShell, cancel: CancelFlag) -> Result<ModelShell, BrepError> { 206 220 let edges = shell 207 221 .edges 208 222 .into_iter() 209 223 .map(|edge| { 224 + guard(cancel)?; 210 225 Ok(CompressedEdge { 211 226 vertices: edge.vertices, 212 227 curve: bridge_curve(edge.curve)?, ··· 217 232 .faces 218 233 .into_iter() 219 234 .map(|face| { 235 + guard(cancel)?; 220 236 Ok(CompressedFace { 221 237 boundaries: face.boundaries, 222 238 orientation: face.orientation,
+12 -6
crates/bone-kernel/tests/extrude.rs
··· 4 4 MergeResult, ProfileDefect, ProfileEdge, ProfileLoop, TruckGap, evaluate_extrude, 5 5 }; 6 6 use bone_types::{ 7 - Angle, EdgeLabel, FaceLabel, FaceRole, FeatureId, Length, Plane3, Point2, PositiveLength, 8 - SketchEntityId, SketchId, Tolerance, UnitVec3, VertexLabel, VertexRole, degree, millimeter, 7 + Angle, CancelFlag, EdgeLabel, FaceLabel, FaceRole, FeatureId, Length, Plane3, Point2, 8 + PositiveLength, SketchEntityId, SketchId, Tolerance, UnitVec3, VertexLabel, VertexRole, degree, 9 + millimeter, 9 10 }; 10 11 use slotmap::{Key, SlotMap}; 11 12 ··· 601 602 &step, 602 603 feature, 603 604 Some((solid.content_key(), solid.reattach_data())), 605 + CancelFlag::never(), 604 606 ) else { 605 607 panic!("step body reattaches under a matching sidecar"); 606 608 }; ··· 620 622 }; 621 623 let step = wrap_step(&body); 622 624 let feature = ids.feature(); 623 - let Ok(imported) = BrepSolid::from_step(&step, feature, None) else { 625 + let Ok(imported) = BrepSolid::from_step(&step, feature, None, CancelFlag::never()) else { 624 626 panic!("step body imports as a dumb body"); 625 627 }; 626 628 assert!( ··· 654 656 &step, 655 657 feature, 656 658 Some((solid.content_key(), cylinder.reattach_data())), 659 + CancelFlag::never(), 657 660 ) else { 658 661 panic!("a matching key with a wrong-count reattach degrades, it does not error"); 659 662 }; ··· 676 679 let step = wrap_step(&body); 677 680 let feature = ids.feature(); 678 681 let wrong_key = bone_types::SolidKey::from_bytes([0u8; 16]); 679 - let Ok(imported) = 680 - BrepSolid::from_step(&step, feature, Some((wrong_key, solid.reattach_data()))) 681 - else { 682 + let Ok(imported) = BrepSolid::from_step( 683 + &step, 684 + feature, 685 + Some((wrong_key, solid.reattach_data())), 686 + CancelFlag::never(), 687 + ) else { 682 688 panic!("a mismatched key still imports"); 683 689 }; 684 690 assert!(
+15 -5
crates/bone-kernel/tests/step_geometry.rs
··· 1 1 use bone_kernel::{BrepError, BrepSolid}; 2 - use bone_types::{FeatureId, StepEntityKind}; 2 + use bone_types::{CancelFlag, FeatureId, StepEntityKind}; 3 3 use slotmap::SlotMap; 4 4 use truck_modeling::{Point3, Rad, Solid, Vector3, builder}; 5 5 use truck_stepio::out::StepModels; ··· 29 29 ); 30 30 let doc = envelope(&StepModels::from_iter([&revolved.compress()]).to_string()); 31 31 assert!(matches!( 32 - BrepSolid::from_step(&doc, feature(), None), 32 + BrepSolid::from_step(&doc, feature(), None, CancelFlag::never()), 33 33 Err(BrepError::StepUnsupported { 34 34 kind: StepEntityKind::SweptSurface 35 35 }) ··· 40 40 fn text_without_a_data_section_reports_no_data() { 41 41 let doc = "ISO-10303-21;\nHEADER;\nENDSEC;\nEND-ISO-10303-21;\n"; 42 42 assert!(matches!( 43 - BrepSolid::from_step(doc, feature(), None), 43 + BrepSolid::from_step(doc, feature(), None, CancelFlag::never()), 44 44 Err(BrepError::StepNoData) 45 45 )); 46 46 } 47 47 48 48 #[test] 49 + fn preset_cancel_flag_aborts_the_parse() { 50 + let doc = "ISO-10303-21;\nHEADER;\nENDSEC;\nEND-ISO-10303-21;\n"; 51 + let flag = std::sync::atomic::AtomicBool::new(true); 52 + assert!(matches!( 53 + BrepSolid::from_step(doc, feature(), None, CancelFlag::new(&flag)), 54 + Err(BrepError::Canceled) 55 + )); 56 + } 57 + 58 + #[test] 49 59 fn unparseable_data_section_reports_syntax() { 50 60 let doc = "ISO-10303-21;\nDATA;\nthis is not a record\nENDSEC;\n"; 51 61 assert!(matches!( 52 - BrepSolid::from_step(doc, feature(), None), 62 + BrepSolid::from_step(doc, feature(), None, CancelFlag::never()), 53 63 Err(BrepError::StepSyntax) 54 64 )); 55 65 } ··· 67 77 let second = truck_cube(5.0); 68 78 let doc = envelope(&StepModels::from_iter([&first, &second]).to_string()); 69 79 assert!(matches!( 70 - BrepSolid::from_step(&doc, feature(), None), 80 + BrepSolid::from_step(&doc, feature(), None, CancelFlag::never()), 71 81 Err(BrepError::StepMultipleSolids { count: 2 }) 72 82 )); 73 83 }
+2
crates/bone-types/src/lib.rs
··· 4 4 pub use uom::si::length::millimeter; 5 5 6 6 pub mod camera; 7 + pub mod cancel; 7 8 pub mod content; 8 9 pub mod dimensioned_serde; 9 10 pub mod display; ··· 16 17 pub use camera::{ 17 18 Camera3, CubicEasing, OrbitState, Projection, ProjectionKind, StandardView, ZoomFactor, 18 19 }; 20 + pub use cancel::{Cancel, CancelFlag}; 19 21 pub use content::SolidKey; 20 22 pub use display::{DisplayMode, ShadingModel}; 21 23 pub use label::{
+23 -14
crates/bone-ui/src/gallery.rs
··· 19 19 use crate::widgets::{ 20 20 AlwaysValid, AngleEditor, BoolEditor, Button, ButtonState, ButtonVariant, Checkbox, 21 21 CheckboxState, ConfirmationDialog, ContextMenu, Dialog, DialogButton, Dropdown, DropdownItem, 22 - DropdownState, FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerState, 23 - HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, ListItem, ListView, ListViewState, 24 - MemoryClipboard, Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, Modal, 25 - NumericInput, Panel, PanelState, PanelTitlebar, PropertyGrid, PropertyOption, PropertyRow, 26 - RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, SelectionEditor, 27 - Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Table, TableColumn, 28 - TableRow, TableState, Tabs, TabsOrientation, TextEditor, TextInput, TextInputState, Toast, 29 - ToastKind, ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, TooltipPlacement, 30 - TooltipState, TreeNode, TreeView, TreeViewState, WidgetPaint, show_button, show_checkbox, 31 - show_confirmation, show_context_menu, show_dialog, show_dropdown, show_file_picker, 32 - show_hotkey_capture, show_list_view, show_menu, show_menu_bar, show_modal, show_panel, 33 - show_parsed_input, show_property_grid, show_radio_group, show_ribbon, show_slider, 22 + DropdownState, FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, 23 + FilePickerState, HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, ListItem, 24 + ListView, ListViewState, MemoryClipboard, Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, 25 + MenuState, Modal, NumericInput, Panel, PanelState, PanelTitlebar, PropertyGrid, PropertyOption, 26 + PropertyRow, RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, 27 + SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, 28 + Table, TableColumn, TableRow, TableState, Tabs, TabsOrientation, TextEditor, TextInput, 29 + TextInputState, Toast, ToastKind, ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, 30 + TooltipPlacement, TooltipState, TreeNode, TreeView, TreeViewState, WidgetPaint, show_button, 31 + show_checkbox, show_confirmation, show_context_menu, show_dialog, show_dropdown, 32 + show_file_picker, show_hotkey_capture, show_list_view, show_menu, show_menu_bar, show_modal, 33 + show_panel, show_parsed_input, show_property_grid, show_radio_group, show_ribbon, show_slider, 34 34 show_status_bar, show_table, show_tabs, show_text_input, show_toast, show_toggle_button, 35 35 show_toolbar, show_tooltip, show_tree_view, 36 36 }; 37 37 38 38 pub const GALLERY_LABEL: StringKey = StringKey::new("gallery.label"); 39 + 40 + const fn gallery_picker_labels() -> FilePickerLabels { 41 + FilePickerLabels { 42 + title: GALLERY_LABEL, 43 + confirm: GALLERY_LABEL, 44 + list: GALLERY_LABEL, 45 + filename_placeholder: GALLERY_LABEL, 46 + } 47 + } 39 48 pub const GALLERY_FRAME_NOW: FrameInstant = FrameInstant::from_duration(Duration::from_secs(1)); 40 49 pub const GALLERY_CANVAS: CanvasSize = CanvasSize::new(CanvasPx::new(1400), CanvasPx::new(2400)); 41 50 const TOOLTIP_ANCHOR_KEY: &str = "button"; ··· 1022 1031 FilePickerMode::Save, 1023 1032 GALLERY_LABEL, 1024 1033 &picker_entries, 1025 - GALLERY_LABEL, 1034 + gallery_picker_labels(), 1026 1035 &mut state.file_picker, 1027 1036 ), 1028 1037 ); ··· 1039 1048 FilePickerMode::Open, 1040 1049 GALLERY_LABEL, 1041 1050 &open_entries, 1042 - GALLERY_LABEL, 1051 + gallery_picker_labels(), 1043 1052 &mut state.file_picker_open, 1044 1053 ), 1045 1054 );
+28 -15
crates/bone-ui/src/widgets/file_picker.rs
··· 28 28 pub clipboard: MemoryClipboard, 29 29 } 30 30 31 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 32 + pub struct FilePickerLabels { 33 + pub title: StringKey, 34 + pub confirm: StringKey, 35 + pub list: StringKey, 36 + pub filename_placeholder: StringKey, 37 + } 38 + 31 39 #[derive(Debug, PartialEq)] 32 40 pub struct FilePickerDialog<'a, 'state> { 33 41 pub id: WidgetId, ··· 37 45 pub current_path: LabelText, 38 46 pub entries: &'a [FilePickerEntry], 39 47 pub state: &'state mut FilePickerState, 40 - pub title: StringKey, 48 + pub labels: FilePickerLabels, 41 49 } 42 50 43 51 impl<'a, 'state> FilePickerDialog<'a, 'state> { ··· 48 56 mode: FilePickerMode, 49 57 current_path: impl Into<LabelText>, 50 58 entries: &'a [FilePickerEntry], 51 - title: StringKey, 59 + labels: FilePickerLabels, 52 60 state: &'state mut FilePickerState, 53 61 ) -> Self { 54 62 Self { ··· 59 67 current_path: current_path.into(), 60 68 entries, 61 69 state, 62 - title, 70 + labels, 63 71 } 64 72 } 65 73 } ··· 96 104 current_path, 97 105 entries, 98 106 state, 99 - title, 107 + labels, 100 108 } = picker; 101 109 let confirm_id = id.child(WidgetKey::new("confirm")); 102 110 let cancel_id = id.child(WidgetKey::new("cancel")); 103 - let confirm_label = match mode { 104 - FilePickerMode::Open => StringKey::new("file_picker.open"), 105 - FilePickerMode::Save => StringKey::new("file_picker.save"), 106 - }; 107 111 let trimmed_empty = state.filename.text.trim().is_empty(); 108 112 let any_text = !state.filename.text.is_empty(); 109 113 let confirm_disabled = match mode { ··· 114 118 DialogButton::secondary(cancel_id, StringKey::new("file_picker.cancel")), 115 119 DialogButton { 116 120 disabled: confirm_disabled, 117 - ..DialogButton::primary(confirm_id, confirm_label) 121 + ..DialogButton::primary(confirm_id, labels.confirm) 118 122 }, 119 123 ]; 120 124 let list_items: Vec<ListItem> = entries ··· 126 130 .collect(); 127 131 let (response, list_opened) = show_dialog( 128 132 ctx, 129 - Dialog::new(id, viewport, size, title, &buttons), 133 + Dialog::new(id, viewport, size, labels.title, &buttons), 130 134 |ctx, body_rect, paint| { 131 135 paint.push(WidgetPaint::Label { 132 136 rect: path_label_rect(body_rect), ··· 150 154 ListView::new( 151 155 id.child(WidgetKey::new("list")), 152 156 list_rect, 153 - StringKey::new("file_picker.list"), 157 + labels.list, 154 158 &list_items, 155 159 &mut state.list, 156 160 ), ··· 160 164 let widget = TextInput { 161 165 id: id.child(WidgetKey::new("filename")), 162 166 rect: filename_rect(body_rect), 163 - placeholder: StringKey::new("file_picker.filename_placeholder"), 167 + placeholder: labels.filename_placeholder, 164 168 state: &mut state.filename, 165 169 disabled: false, 166 170 validator: AlwaysValid, ··· 314 318 ) 315 319 } 316 320 321 + const fn test_labels() -> super::FilePickerLabels { 322 + super::FilePickerLabels { 323 + title: StringKey::new("file_picker.title"), 324 + confirm: StringKey::new("file_picker.confirm"), 325 + list: StringKey::new("file_picker.list"), 326 + filename_placeholder: StringKey::new("file_picker.filename_placeholder"), 327 + } 328 + } 329 + 317 330 fn run_picker( 318 331 focus: &mut FocusManager, 319 332 hits: &mut HitFrame, ··· 346 359 mode, 347 360 StringKey::new("path.home"), 348 361 entries, 349 - StringKey::new("file_picker.title"), 362 + test_labels(), 350 363 state, 351 364 ), 352 365 ) ··· 454 467 FilePickerMode::Open, 455 468 StringKey::new("path.home"), 456 469 &entries, 457 - StringKey::new("file_picker.title"), 470 + test_labels(), 458 471 &mut state, 459 472 ), 460 473 ) ··· 494 507 FilePickerMode::Save, 495 508 StringKey::new("path.home"), 496 509 &entries, 497 - StringKey::new("file_picker.title"), 510 + test_labels(), 498 511 &mut state, 499 512 ), 500 513 )
+2 -2
crates/bone-ui/src/widgets/mod.rs
··· 38 38 pub use dimensioned_input::{DimensionedInput, DimensionedInputResponse, DimensionedParseError}; 39 39 pub use dropdown::{Dropdown, DropdownItem, DropdownResponse, DropdownState, show_dropdown}; 40 40 pub use file_picker::{ 41 - FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerOutcome, FilePickerResponse, 42 - FilePickerState, show_file_picker, 41 + FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, FilePickerOutcome, 42 + FilePickerResponse, FilePickerState, show_file_picker, 43 43 }; 44 44 pub use hotkey_capture::{ 45 45 HotkeyCapture, HotkeyCaptureResponse, HotkeyCaptureState, show_hotkey_capture,