Another project
0

Configure Feed

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

feat(app): tool dispatch, picking, hotkeys, undo, menu actions

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

author
Lewis
date (May 9, 2026, 11:28 AM +0300) commit 338ff00a parent c41e1b0f change-id oqrkwzns
+945 -41
+2
Cargo.lock
··· 211 211 dependencies = [ 212 212 "bone-document", 213 213 "bone-render", 214 + "bone-text", 214 215 "bone-types", 215 216 "bone-ui", 216 217 "pollster", 218 + "swash", 217 219 "thiserror 2.0.18", 218 220 "tracing", 219 221 "tracing-subscriber",
+943 -41
crates/bone-app/src/main.rs
··· 1 + use std::collections::BTreeMap; 2 + use std::num::NonZeroUsize; 1 3 use std::path::{Path, PathBuf}; 2 4 use std::sync::Arc; 3 5 4 - use bone_document::{Document, EditOutcome, Sketch, SketchEdit, SketchEntity}; 6 + use bone_document::{Document, EditOutcome, Sketch, SketchEdit, SketchEntity, UndoStack}; 5 7 use bone_render::{ 6 - Camera2, ChromeInstance, ChromePipeline, PixelsPerMm, SketchRenderer, SketchScene, Style, 7 - SurfaceContext, ViewportExtent, ViewportPx, 8 - }; 9 - use bone_types::{ 10 - DocumentId, Length, Point2, Point3, SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, 11 - UnitVec3, Vec2, 8 + Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, PickQuery, PickedItem, 9 + PixelsPerMm, SketchPreview, SketchRenderer, SketchScene, Style, SurfaceContext, ViewportExtent, 10 + ViewportPx, 12 11 }; 12 + use bone_types::{DocumentId, Length, Point2, SketchEntityId, SketchId, Vec2}; 13 13 use bone_ui::a11y::AccessTreeBuilder; 14 14 use bone_ui::focus::FocusManager; 15 15 use bone_ui::frame::FrameCtx; 16 16 use bone_ui::gallery::{GALLERY_CANVAS, GalleryState, render}; 17 17 use bone_ui::hit_test::{HitFrame, HitState, resolve}; 18 - use bone_ui::hotkey::HotkeyTable; 18 + use bone_ui::hotkey::{ActionId, HotkeyBinding, HotkeyScope, HotkeyScopes, HotkeyTable, KeyChord}; 19 19 use bone_ui::input::{ 20 20 FrameInstant, InputSnapshot, KeyChar, KeyCode as UiKeyCode, KeyEvent as UiKeyEvent, 21 21 ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample, ··· 24 24 use bone_ui::raster::{PngError, encode_png, rasterize}; 25 25 use bone_ui::strings::StringTable; 26 26 use bone_ui::theme::{Theme, ThemeMode}; 27 + use bone_ui::{SdfAtlas, SdfAtlasParams, Shaper}; 28 + use swash::FontRef; 27 29 use tracing_subscriber::EnvFilter; 28 30 use uom::si::length::millimeter; 29 31 use winit::{ ··· 40 42 mod sketch_mode; 41 43 mod strings; 42 44 43 - use sketch_mode::Mode; 45 + use sketch_mode::{Mode, Pending, Plane, SketchSession, SketchTool}; 44 46 45 47 #[derive(Debug, thiserror::Error)] 46 48 enum AppError { ··· 80 82 const PAN_STEP_PX: f64 = 40.0; 81 83 const PAN_FAST_MULTIPLIER: f64 = 5.0; 82 84 const ZOOM_FIT_MARGIN: f64 = 0.9; 85 + const UNDO_CAPACITY: usize = 256; 83 86 84 87 struct RenderState { 85 88 surface: SurfaceContext, 86 89 renderer: SketchRenderer, 87 90 chrome_pipeline: ChromePipeline, 91 + text_pipeline: ChromeTextPipeline, 92 + sdf_atlas: SdfAtlas, 93 + chrome_shaper: Shaper, 94 + sans_font: FontRef<'static>, 95 + mono_font: FontRef<'static>, 88 96 scene: SketchScene, 89 97 camera: Camera2, 90 98 style: Style, 91 99 theme: Arc<Theme>, 92 100 shell: shell::Shell, 93 101 document: Document, 94 - sketch_id: SketchId, 102 + plane_sketches: BTreeMap<Plane, SketchId>, 95 103 mode: Mode, 96 104 focus: FocusManager, 97 105 hit_state: HitState, 98 106 hotkeys: HotkeyTable, 99 107 strings: StringTable, 100 108 viewport_rect: LayoutRect, 109 + undo: UndoStack, 110 + selection: Option<SketchEntityId>, 111 + pending_exit: bool, 101 112 } 102 113 103 114 #[derive(Default)] ··· 187 198 input: InputState, 188 199 } 189 200 190 - fn plane_xy() -> SketchPlaneBasis { 191 - let Ok(basis) = SketchPlaneBasis::new( 192 - Point3::origin(), 193 - UnitVec3::x_axis(), 194 - UnitVec3::y_axis(), 195 - Tolerance::new(1e-9), 196 - ) else { 197 - unreachable!("canonical XY axes are orthonormal"); 198 - }; 199 - basis 200 - } 201 - 202 201 fn add_point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 203 202 let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 204 203 SketchEntity::point(Point2::from_mm(x, y)), ··· 241 240 } 242 241 243 242 fn default_sketch() -> Sketch { 244 - let sketch = Sketch::new(plane_xy()); 243 + let sketch = Sketch::new(Plane::Xy.basis()); 245 244 let (sketch, p0) = add_point(sketch, -20.0, -12.5); 246 245 let (sketch, p1) = add_point(sketch, 20.0, -12.5); 247 246 let (sketch, p2) = add_point(sketch, 20.0, 12.5); ··· 319 318 .with_pan(Vec2::from_mm(new_pan_x, new_pan_y)) 320 319 } 321 320 321 + const fn armed_sketch_tool(mode: &Mode) -> Option<SketchTool> { 322 + match mode { 323 + Mode::Sketch { 324 + session: SketchSession { tool, .. }, 325 + .. 326 + } => *tool, 327 + Mode::Idle => None, 328 + } 329 + } 330 + 331 + const fn dragging_in_sketch(mode: &Mode) -> bool { 332 + matches!( 333 + mode, 334 + Mode::Sketch { 335 + session: SketchSession { drag: Some(_), .. }, 336 + .. 337 + } 338 + ) 339 + } 340 + 341 + fn cursor_to_world(camera: Camera2, cursor: PhysicalPosition<f64>) -> Option<Point2> { 342 + let extent = camera.extent(); 343 + let w = f64::from(extent.width().value()); 344 + let h = f64::from(extent.height().value()); 345 + let zoom = camera.zoom().value(); 346 + if w <= 0.0 || h <= 0.0 || !zoom.is_finite() || zoom <= 0.0 { 347 + return None; 348 + } 349 + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 350 + Some(Point2::from_mm( 351 + pan_x + (cursor.x - w * 0.5) / zoom, 352 + pan_y + (h * 0.5 - cursor.y) / zoom, 353 + )) 354 + } 355 + 356 + fn place_in_sketch( 357 + sketch: Sketch, 358 + tool: SketchTool, 359 + world: Point2, 360 + pending: Option<Pending>, 361 + ) -> (Option<Sketch>, Option<Pending>) { 362 + let (wx, wy) = world.coords_mm(); 363 + match tool { 364 + SketchTool::Point => { 365 + let (next, _) = add_point(sketch, wx, wy); 366 + (Some(next), None) 367 + } 368 + SketchTool::Line => match pending { 369 + None => (None, Some(Pending::Position(world))), 370 + Some(Pending::Position(start)) => { 371 + let (sx, sy) = start.coords_mm(); 372 + let (s, p1) = add_point(sketch, sx, sy); 373 + let (s, p2) = add_point(s, wx, wy); 374 + (Some(add_line(s, p1, p2)), Some(Pending::Endpoint(p2))) 375 + } 376 + Some(Pending::Endpoint(prev)) => { 377 + let (s, p2) = add_point(sketch, wx, wy); 378 + (Some(add_line(s, prev, p2)), Some(Pending::Endpoint(p2))) 379 + } 380 + }, 381 + _ => (None, pending), 382 + } 383 + } 384 + 385 + fn try_place(state: &mut RenderState, world: Point2) { 386 + let Mode::Sketch { 387 + sketch_id, 388 + session: 389 + SketchSession { 390 + tool: Some(tool), 391 + pending: prev_pending, 392 + drag: _, 393 + }, 394 + } = state.mode 395 + else { 396 + return; 397 + }; 398 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 399 + return; 400 + }; 401 + let (next_sketch, new_pending) = place_in_sketch(sketch, tool, world, prev_pending); 402 + if let Some(next) = next_sketch { 403 + state.undo.record(state.document.clone()); 404 + state.document.replace_sketch(sketch_id, next); 405 + let Some(stored) = state.document.sketch(sketch_id) else { 406 + return; 407 + }; 408 + match SketchScene::extract(stored) { 409 + Ok(scene) => state.scene = scene, 410 + Err(e) => tracing::warn!(error = %e, "scene extract after place failed"), 411 + } 412 + } 413 + if let Mode::Sketch { 414 + ref mut session, .. 415 + } = state.mode 416 + { 417 + session.pending = new_pending; 418 + } 419 + } 420 + 421 + fn pick_at(state: &RenderState, cursor: PhysicalPosition<f64>) -> Option<PickedItem> { 422 + if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 { 423 + return None; 424 + } 425 + let extent = state.surface.extent(); 426 + #[allow( 427 + clippy::cast_possible_truncation, 428 + clippy::cast_sign_loss, 429 + reason = "cursor px is bounds-checked against surface extent before the cast" 430 + )] 431 + let qx = cursor.x.round() as u32; 432 + #[allow( 433 + clippy::cast_possible_truncation, 434 + clippy::cast_sign_loss, 435 + reason = "cursor px is bounds-checked against surface extent before the cast" 436 + )] 437 + let qy = cursor.y.round() as u32; 438 + if qx >= extent.width().value() || qy >= extent.height().value() { 439 + return None; 440 + } 441 + let index = match state.scene.pick_index() { 442 + Ok(i) => i, 443 + Err(e) => { 444 + tracing::warn!(error = %e, "build pick index"); 445 + return None; 446 + } 447 + }; 448 + let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)); 449 + match state.surface.picker(index).at(query) { 450 + Ok(item) => item, 451 + Err(e) => { 452 + tracing::warn!(error = %e, "pick failed"); 453 + None 454 + } 455 + } 456 + } 457 + 458 + fn handle_viewport_click(state: &mut RenderState, cursor: PhysicalPosition<f64>) { 459 + let picked = pick_at(state, cursor); 460 + state.selection = match picked { 461 + Some(PickedItem::Point(id)) => Some(id), 462 + Some(PickedItem::Line(id)) => Some(id), 463 + Some(PickedItem::Arc(id)) => Some(id), 464 + Some(PickedItem::Circle(id)) => Some(id), 465 + Some(_) | None => None, 466 + }; 467 + if let Some(PickedItem::Point(entity)) = picked 468 + && state.mode.is_sketch() 469 + { 470 + state.undo.record(state.document.clone()); 471 + state.mode = state.mode.start_drag(entity); 472 + } 473 + } 474 + 475 + fn try_drag_to(state: &mut RenderState, world: Point2) { 476 + let Mode::Sketch { 477 + sketch_id, 478 + session: 479 + SketchSession { 480 + drag: Some(entity), 481 + .. 482 + }, 483 + } = state.mode 484 + else { 485 + return; 486 + }; 487 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 488 + return; 489 + }; 490 + let edit = SketchEdit::MovePoint { 491 + id: entity, 492 + position: world, 493 + }; 494 + let Ok((next, _)) = sketch.apply(edit) else { 495 + return; 496 + }; 497 + state.document.replace_sketch(sketch_id, next); 498 + refresh_active_scene(state); 499 + } 500 + 501 + fn refresh_active_scene(state: &mut RenderState) { 502 + let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else { 503 + return; 504 + }; 505 + let Some(sketch) = state.document.sketch(active_id) else { 506 + return; 507 + }; 508 + match SketchScene::extract(sketch) { 509 + Ok(scene) => state.scene = scene, 510 + Err(e) => tracing::warn!(error = %e, "refresh active scene failed"), 511 + } 512 + } 513 + 514 + fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap<Plane, SketchId>) -> Option<SketchId> { 515 + match mode { 516 + Mode::Sketch { sketch_id, .. } => Some(*sketch_id), 517 + Mode::Idle => plane_sketches.get(&Plane::Xy).copied(), 518 + } 519 + } 520 + 521 + fn build_preview( 522 + mode: &Mode, 523 + document: &Document, 524 + cursor_world: Option<Point2>, 525 + ) -> SketchPreview { 526 + let Mode::Sketch { 527 + sketch_id, 528 + session: 529 + SketchSession { 530 + tool: Some(SketchTool::Line), 531 + pending, 532 + drag: None, 533 + }, 534 + } = mode 535 + else { 536 + return SketchPreview::empty(); 537 + }; 538 + let Some(start) = pending_anchor_world(*sketch_id, *pending, document) else { 539 + return SketchPreview::empty(); 540 + }; 541 + let Some(cursor) = cursor_world else { 542 + return SketchPreview { 543 + anchor: Some(start), 544 + rubber_band: None, 545 + }; 546 + }; 547 + SketchPreview { 548 + anchor: Some(start), 549 + rubber_band: Some((start, cursor)), 550 + } 551 + } 552 + 553 + fn pending_anchor_world( 554 + sketch_id: SketchId, 555 + pending: Option<Pending>, 556 + document: &Document, 557 + ) -> Option<Point2> { 558 + match pending? { 559 + Pending::Position(p) => Some(p), 560 + Pending::Endpoint(id) => match document.sketch(sketch_id)?.entities().get(id)? { 561 + SketchEntity::Point(p) => Some(p.at()), 562 + _ => None, 563 + }, 564 + } 565 + } 566 + 322 567 fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 { 323 568 let mm_per_px = camera.world_mm_per_pixel(); 324 569 let (pan_x, pan_y) = camera.pan_mm().coords_mm(); ··· 386 631 } 387 632 388 633 fn keyboard_camera(code: KeyCode, input: &InputState, state: &RenderState) -> Option<Camera2> { 634 + if input.modifiers.control_key() || input.modifiers.super_key() { 635 + return None; 636 + } 389 637 let camera = state.camera; 390 638 let step = input.pan_step_px(); 391 639 let shift = input.modifiers.shift_key(); ··· 410 658 } 411 659 } 412 660 661 + fn build_hotkey_table() -> HotkeyTable { 662 + let bindings = vec![ 663 + HotkeyBinding::new( 664 + KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE), 665 + HotkeyScope::Sketch, 666 + sketch_mode::EXIT_SKETCH_ACTION, 667 + ), 668 + HotkeyBinding::new( 669 + KeyChord::new( 670 + UiKeyCode::Char(KeyChar::from_char('z')), 671 + ModifierMask::CTRL, 672 + ), 673 + HotkeyScope::Global, 674 + sketch_mode::UNDO_ACTION, 675 + ), 676 + HotkeyBinding::new( 677 + KeyChord::new( 678 + UiKeyCode::Char(KeyChar::from_char('z')), 679 + ModifierMask::CTRL.union(ModifierMask::SHIFT), 680 + ), 681 + HotkeyScope::Global, 682 + sketch_mode::REDO_ACTION, 683 + ), 684 + HotkeyBinding::new( 685 + KeyChord::new( 686 + UiKeyCode::Char(KeyChar::from_char('y')), 687 + ModifierMask::CTRL, 688 + ), 689 + HotkeyScope::Global, 690 + sketch_mode::REDO_ACTION, 691 + ), 692 + ]; 693 + let Ok(table) = HotkeyTable::try_from_bindings(bindings) else { 694 + unreachable!("hotkey bindings are conflict-free"); 695 + }; 696 + table 697 + } 698 + 699 + fn scopes_for_mode(mode: &Mode) -> HotkeyScopes { 700 + let mut scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 701 + if mode.is_sketch() { 702 + scopes.push(HotkeyScope::Sketch); 703 + } 704 + scopes 705 + } 706 + 707 + fn next_mode( 708 + mode: Mode, 709 + frame: &shell::ShellFrame, 710 + actions: &[ActionId], 711 + plane_sketches: &BTreeMap<Plane, SketchId>, 712 + ) -> Mode { 713 + let after_pick = frame 714 + .plane_picked 715 + .filter(|_| !mode.is_sketch()) 716 + .and_then(|plane| plane_sketches.get(&plane).copied()) 717 + .map_or(mode, Mode::enter_sketch); 718 + let escape = actions.contains(&sketch_mode::EXIT_SKETCH_ACTION); 719 + let after_escape = if escape { 720 + cancel_pending_or_exit(after_pick) 721 + } else { 722 + after_pick 723 + }; 724 + let after_exit = if frame.exit_sketch { 725 + Mode::Idle 726 + } else { 727 + after_escape 728 + }; 729 + frame 730 + .activated_tool 731 + .map_or(after_exit, |t| toggle_or_arm(after_exit, t)) 732 + } 733 + 734 + fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode { 735 + match mode { 736 + Mode::Sketch { 737 + session: 738 + SketchSession { 739 + tool: Some(active), .. 740 + }, 741 + .. 742 + } if active == tool => mode.disarm_tool(), 743 + _ => mode.arm_tool(tool), 744 + } 745 + } 746 + 747 + fn cancel_pending_or_exit(mode: Mode) -> Mode { 748 + match mode { 749 + Mode::Sketch { 750 + session: 751 + SketchSession { 752 + pending: Some(_), .. 753 + }, 754 + .. 755 + } => mode.clear_pending(), 756 + Mode::Sketch { 757 + session: 758 + SketchSession { 759 + tool: Some(_), .. 760 + }, 761 + .. 762 + } => mode.disarm_tool(), 763 + Mode::Sketch { .. } | Mode::Idle => Mode::Idle, 764 + } 765 + } 766 + 413 767 impl ApplicationHandler for App { 414 768 fn resumed(&mut self, event_loop: &ActiveEventLoop) { 415 769 if self.window.is_some() { ··· 435 789 }; 436 790 let renderer = SketchRenderer::new(surface.gpu(), surface.color_format()); 437 791 let chrome_pipeline = ChromePipeline::new(surface.gpu(), surface.color_format()); 792 + let sdf_atlas = SdfAtlas::new(SdfAtlasParams::STANDARD); 793 + let text_pipeline = 794 + ChromeTextPipeline::new(surface.gpu(), surface.color_format(), sdf_atlas.extent()); 795 + let chrome_shaper = Shaper::new(); 796 + let sans_font = bone_text::load_font(bone_text::FontFace::Sans); 797 + let mono_font = bone_text::load_font(bone_text::FontFace::Mono); 438 798 let sketch = default_sketch(); 439 799 let scene = match SketchScene::extract(&sketch) { 440 800 Ok(s) => s, ··· 456 816 } 457 817 }; 458 818 let (document, sketch_id) = initial_document(sketch); 819 + let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); 459 820 let strings = strings::make_strings(bone_ui::strings::Locale::EnGb); 460 821 let viewport_rect = empty_rect(); 822 + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 823 + unreachable!("UNDO_CAPACITY constant is non-zero"); 824 + }; 461 825 window.request_redraw(); 462 826 self.window = Some(window); 463 827 self.render = Some(RenderState { 464 828 surface, 465 829 renderer, 466 830 chrome_pipeline, 831 + text_pipeline, 832 + sdf_atlas, 833 + chrome_shaper, 834 + sans_font, 835 + mono_font, 467 836 scene, 468 837 camera, 469 838 style, 470 839 theme, 471 840 shell, 472 841 document, 473 - sketch_id, 842 + plane_sketches, 474 843 mode: Mode::Idle, 475 844 focus: FocusManager::new(), 476 845 hit_state: HitState::new(), 477 - hotkeys: HotkeyTable::new(), 846 + hotkeys: build_hotkey_table(), 478 847 strings, 479 848 viewport_rect, 849 + undo: UndoStack::with_capacity(undo_capacity), 850 + selection: None, 851 + pending_exit: false, 480 852 }); 481 853 } 482 854 ··· 497 869 state.viewport_rect = empty_rect(); 498 870 window.request_redraw(); 499 871 } 500 - WindowEvent::RedrawRequested => render_frame(state, window, &mut self.input), 872 + WindowEvent::RedrawRequested => { 873 + render_frame(state, window, &mut self.input); 874 + if state.pending_exit { 875 + event_loop.exit(); 876 + } 877 + } 501 878 WindowEvent::Focused(false) => self.input.forget_pan_state(), 502 879 WindowEvent::ModifiersChanged(mods) => { 503 880 self.input.modifiers = mods.state(); ··· 509 886 && let Some(p) = prev 510 887 { 511 888 state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); 889 + } else if dragging_in_sketch(&state.mode) 890 + && let Some(world) = cursor_to_world(state.camera, position) 891 + { 892 + try_drag_to(state, world); 512 893 } 513 894 window.request_redraw(); 514 895 } ··· 521 902 .. 522 903 } => { 523 904 if btn_state == ElementState::Pressed { 524 - self.input.left_pan = self.input.cursor_in(state.viewport_rect); 905 + let in_viewport = self.input.cursor_in(state.viewport_rect); 906 + self.input.left_pan = in_viewport; 525 907 self.input.pending_pressed = 526 908 self.input.pending_pressed.with(PointerButton::Primary); 909 + if in_viewport 910 + && !self.input.modifiers.shift_key() 911 + && let Some(cursor) = self.input.cursor_px 912 + { 913 + if armed_sketch_tool(&state.mode).is_some() { 914 + if let Some(world) = cursor_to_world(state.camera, cursor) { 915 + try_place(state, world); 916 + } 917 + } else { 918 + handle_viewport_click(state, cursor); 919 + } 920 + } 527 921 } else { 528 922 self.input.left_pan = false; 923 + state.mode = state.mode.end_drag(); 529 924 self.input.pending_released = 530 925 self.input.pending_released.with(PointerButton::Primary); 531 926 } ··· 539 934 if btn_state == ElementState::Pressed { 540 935 self.input.pending_pressed = 541 936 self.input.pending_pressed.with(PointerButton::Secondary); 937 + if self.input.cursor_in(state.viewport_rect) { 938 + state.mode = state.mode.clear_pending(); 939 + } 542 940 } else { 543 941 self.input.pending_released = 544 942 self.input.pending_released.with(PointerButton::Secondary); ··· 583 981 UiKeyCode::Char(KeyChar::from_char(c)), 584 982 self.input.modifier_mask(), 585 983 )); 586 - } 587 - if code == KeyCode::Escape { 588 - state.mode = Mode::Idle; 589 984 } 590 985 match keyboard_action(code, &self.input, state) { 591 986 Some(KeyAction::Exit) => event_loop.exit(), ··· 612 1007 let mut input = input_state.drain_snapshot(); 613 1008 let mut hits = HitFrame::new(); 614 1009 let mut a11y = AccessTreeBuilder::new(); 615 - let frame = { 1010 + let scopes = scopes_for_mode(&state.mode); 1011 + let (frame, hotkey_actions) = { 616 1012 let mut ctx = FrameCtx::new( 617 1013 theme, 618 1014 &mut input, ··· 623 1019 &state.hit_state, 624 1020 &mut a11y, 625 1021 ); 626 - state 627 - .shell 628 - .render(&mut ctx, &state.document, &state.mode, layout_size) 1022 + let frame = state.shell.render( 1023 + &mut ctx, 1024 + &state.document, 1025 + &state.mode, 1026 + state.selection, 1027 + layout_size, 1028 + ); 1029 + let actions = ctx.dispatch_hotkeys(&scopes); 1030 + (frame, actions) 629 1031 }; 630 1032 state.viewport_rect = frame.viewport_rect; 631 1033 state.hit_state = resolve(&state.hit_state, &hits, &input, state.focus.focused()); 632 - if let Some(tool) = frame.activated_tool { 633 - let mode = core::mem::take(&mut state.mode); 634 - state.mode = match mode { 635 - Mode::Idle => Mode::enter_sketch(state.sketch_id).arm_tool(tool), 636 - sketching @ Mode::Sketch { .. } => sketching.arm_tool(tool), 637 - }; 1034 + if let Some(plane) = frame.plane_picked { 1035 + match ( 1036 + state.mode.is_sketch(), 1037 + state.plane_sketches.contains_key(&plane), 1038 + ) { 1039 + (true, _) => { 1040 + tracing::debug!(?plane, "plane pick ignored: already in sketch mode"); 1041 + } 1042 + (false, false) => { 1043 + tracing::debug!(?plane, "plane pick ignored: no sketch on this plane"); 1044 + } 1045 + (false, true) => {} 1046 + } 638 1047 } 1048 + state.mode = next_mode(state.mode, &frame, &hotkey_actions, &state.plane_sketches); 1049 + apply_undo_actions(state, &hotkey_actions); 1050 + apply_menu_action(state, frame.menu_action); 1051 + let cursor_world = input_state 1052 + .cursor_px 1053 + .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) 1054 + .and_then(|c| cursor_to_world(state.camera, c)); 1055 + let preview = build_preview(&state.mode, &state.document, cursor_world); 639 1056 let chrome_instances: Vec<ChromeInstance> = 640 1057 chrome::paint_to_instances(&state.theme, &frame.paints); 1058 + let text_spans = chrome::paint_to_text_spans(&frame.paints, &state.strings); 1059 + let glyph_instances = chrome::build_glyph_instances( 1060 + &text_spans, 1061 + &mut state.sdf_atlas, 1062 + &mut state.chrome_shaper, 1063 + &state.sans_font, 1064 + &state.mono_font, 1065 + ); 1066 + let atlas_pixels = state.sdf_atlas.pixels(); 1067 + let atlas_version = state.sdf_atlas.version(); 641 1068 let viewport_px = [ 642 1069 extent.width().value() as f32, 643 1070 extent.height().value() as f32, ··· 645 1072 let surface = &mut state.surface; 646 1073 let renderer = &mut state.renderer; 647 1074 let chrome_pipeline = &mut state.chrome_pipeline; 1075 + let text_pipeline = &mut state.text_pipeline; 648 1076 let scene = &state.scene; 649 1077 let camera = state.camera; 650 1078 let style = &state.style; 651 1079 renderer.prepare(scene, style); 652 1080 surface.render( 653 1081 |encoder, color, pick| { 654 - renderer.encode_passes(encoder, color, pick, scene, camera, style); 1082 + renderer.encode_passes(encoder, color, pick, scene, &preview, camera, style); 655 1083 chrome_pipeline.draw(encoder, color, viewport_px, &chrome_instances); 1084 + text_pipeline.draw( 1085 + encoder, 1086 + color, 1087 + viewport_px, 1088 + atlas_pixels, 1089 + atlas_version, 1090 + &glyph_instances, 1091 + ); 656 1092 }, 657 1093 || window.pre_present_notify(), 658 1094 ); 1095 + } 1096 + 1097 + fn apply_undo_actions(state: &mut RenderState, actions: &[ActionId]) { 1098 + if actions.contains(&sketch_mode::UNDO_ACTION) && state.undo.undo(&mut state.document) { 1099 + refresh_active_scene(state); 1100 + } 1101 + if actions.contains(&sketch_mode::REDO_ACTION) && state.undo.redo(&mut state.document) { 1102 + refresh_active_scene(state); 1103 + } 1104 + } 1105 + 1106 + fn apply_menu_action(state: &mut RenderState, action: Option<shell::MenuAction>) { 1107 + match action { 1108 + Some(shell::MenuAction::Quit) => { 1109 + state.pending_exit = true; 1110 + } 1111 + Some(shell::MenuAction::Undo) => { 1112 + if state.undo.undo(&mut state.document) { 1113 + refresh_active_scene(state); 1114 + } 1115 + } 1116 + Some(shell::MenuAction::Redo) => { 1117 + if state.undo.redo(&mut state.document) { 1118 + refresh_active_scene(state); 1119 + } 1120 + } 1121 + Some(shell::MenuAction::ZoomFit) => { 1122 + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 1123 + } 1124 + None => {} 1125 + } 659 1126 } 660 1127 661 1128 enum RunMode { ··· 793 1260 event_loop.run_app(&mut app)?; 794 1261 Ok(()) 795 1262 } 1263 + 1264 + #[cfg(test)] 1265 + mod tests { 1266 + use super::*; 1267 + use bone_document::SketchEntityKind; 1268 + 1269 + fn count_kind(sketch: &Sketch, kind: SketchEntityKind) -> usize { 1270 + sketch 1271 + .entities() 1272 + .iter() 1273 + .filter(|(_, e)| e.kind() == kind) 1274 + .count() 1275 + } 1276 + 1277 + #[test] 1278 + fn point_tool_commits_a_point_per_click() { 1279 + let sketch = Sketch::new(Plane::Xy.basis()); 1280 + let (next, pending) = 1281 + place_in_sketch(sketch, SketchTool::Point, Point2::from_mm(3.0, 4.0), None); 1282 + let Some(next) = next else { 1283 + panic!("point tool must commit"); 1284 + }; 1285 + assert_eq!(count_kind(&next, SketchEntityKind::Point), 1); 1286 + assert_eq!(pending, None); 1287 + } 1288 + 1289 + #[test] 1290 + fn line_tool_first_click_pends_anchor_only() { 1291 + let sketch = Sketch::new(Plane::Xy.basis()); 1292 + let world = Point2::from_mm(1.0, 2.0); 1293 + let (next, pending) = place_in_sketch(sketch, SketchTool::Line, world, None); 1294 + assert!(next.is_none()); 1295 + assert_eq!(pending, Some(Pending::Position(world))); 1296 + } 1297 + 1298 + #[test] 1299 + fn line_tool_second_click_emits_two_points_and_a_line() { 1300 + let sketch = Sketch::new(Plane::Xy.basis()); 1301 + let start = Point2::from_mm(0.0, 0.0); 1302 + let end = Point2::from_mm(5.0, 5.0); 1303 + let (next, pending) = 1304 + place_in_sketch(sketch, SketchTool::Line, end, Some(Pending::Position(start))); 1305 + let Some(next) = next else { 1306 + panic!("second click must commit"); 1307 + }; 1308 + assert_eq!(count_kind(&next, SketchEntityKind::Point), 2); 1309 + assert_eq!(count_kind(&next, SketchEntityKind::Line), 1); 1310 + let Some(Pending::Endpoint(_)) = pending else { 1311 + panic!("second click hands back endpoint to chain"); 1312 + }; 1313 + } 1314 + 1315 + #[test] 1316 + fn line_tool_chain_reuses_endpoint_no_duplicate_point() { 1317 + let sketch = Sketch::new(Plane::Xy.basis()); 1318 + let (sketch, anchor_id) = add_point(sketch, 0.0, 0.0); 1319 + let (next, pending) = place_in_sketch( 1320 + sketch, 1321 + SketchTool::Line, 1322 + Point2::from_mm(5.0, 0.0), 1323 + Some(Pending::Endpoint(anchor_id)), 1324 + ); 1325 + let Some(next) = next else { 1326 + panic!("chain click commits"); 1327 + }; 1328 + assert_eq!(count_kind(&next, SketchEntityKind::Point), 2); 1329 + assert_eq!(count_kind(&next, SketchEntityKind::Line), 1); 1330 + let Some(Pending::Endpoint(new_id)) = pending else { 1331 + panic!("chain click hands back new endpoint"); 1332 + }; 1333 + assert_ne!(new_id, anchor_id); 1334 + } 1335 + 1336 + #[test] 1337 + fn unsupported_tool_is_a_noop_and_preserves_pending() { 1338 + let sketch = Sketch::new(Plane::Xy.basis()); 1339 + let pending = Some(Pending::Position(Point2::from_mm(1.0, 1.0))); 1340 + let (next, kept) = place_in_sketch( 1341 + sketch, 1342 + SketchTool::Circle, 1343 + Point2::from_mm(2.0, 2.0), 1344 + pending, 1345 + ); 1346 + assert!(next.is_none()); 1347 + assert_eq!(kept, pending); 1348 + } 1349 + 1350 + #[test] 1351 + fn cursor_to_world_at_window_center_equals_camera_pan() { 1352 + let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); 1353 + let camera = Camera2::new(extent) 1354 + .with_pan(Vec2::from_mm(7.0, -3.0)) 1355 + .with_zoom(PixelsPerMm::new(5.0)); 1356 + let Some(world) = cursor_to_world(camera, PhysicalPosition::new(100.0, 50.0)) else { 1357 + panic!("center maps"); 1358 + }; 1359 + let (x, y) = world.coords_mm(); 1360 + assert!((x - 7.0).abs() < 1e-9); 1361 + assert!((y - -3.0).abs() < 1e-9); 1362 + } 1363 + 1364 + #[test] 1365 + fn cursor_to_world_inverts_y_so_up_in_window_is_up_in_world() { 1366 + let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); 1367 + let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0)); 1368 + let Some(above) = cursor_to_world(camera, PhysicalPosition::new(100.0, 0.0)) else { 1369 + panic!("top"); 1370 + }; 1371 + let Some(below) = cursor_to_world(camera, PhysicalPosition::new(100.0, 100.0)) else { 1372 + panic!("bottom"); 1373 + }; 1374 + let (_, ya) = above.coords_mm(); 1375 + let (_, yb) = below.coords_mm(); 1376 + assert!(ya > yb); 1377 + } 1378 + 1379 + #[test] 1380 + fn cursor_to_world_rejects_zero_extent() { 1381 + let extent = ViewportExtent::new(ViewportPx::new(0), ViewportPx::new(100)); 1382 + let camera = Camera2::new(extent); 1383 + assert!(cursor_to_world(camera, PhysicalPosition::new(0.0, 0.0)).is_none()); 1384 + } 1385 + 1386 + fn empty_frame() -> shell::ShellFrame { 1387 + shell::ShellFrame { 1388 + paints: Vec::new(), 1389 + viewport_rect: empty_rect(), 1390 + activated_tool: None, 1391 + plane_picked: None, 1392 + exit_sketch: false, 1393 + menu_action: None, 1394 + } 1395 + } 1396 + 1397 + fn xy_only() -> BTreeMap<Plane, SketchId> { 1398 + BTreeMap::from([(Plane::Xy, SketchId::default())]) 1399 + } 1400 + 1401 + #[test] 1402 + fn plane_pick_from_idle_enters_sketch_for_known_plane() { 1403 + let frame = shell::ShellFrame { 1404 + plane_picked: Some(Plane::Xy), 1405 + ..empty_frame() 1406 + }; 1407 + let next = next_mode(Mode::Idle, &frame, &[], &xy_only()); 1408 + assert_eq!(next, Mode::enter_sketch(SketchId::default())); 1409 + } 1410 + 1411 + #[test] 1412 + fn plane_pick_from_idle_with_no_sketch_for_plane_stays_idle() { 1413 + let frame = shell::ShellFrame { 1414 + plane_picked: Some(Plane::Yz), 1415 + ..empty_frame() 1416 + }; 1417 + let next = next_mode(Mode::Idle, &frame, &[], &xy_only()); 1418 + assert_eq!(next, Mode::Idle); 1419 + } 1420 + 1421 + #[test] 1422 + fn plane_pick_while_in_sketch_keeps_current_mode() { 1423 + let prev = Mode::enter_sketch(SketchId::default()); 1424 + let frame = shell::ShellFrame { 1425 + plane_picked: Some(Plane::Xy), 1426 + ..empty_frame() 1427 + }; 1428 + assert_eq!(next_mode(prev, &frame, &[], &xy_only()), prev); 1429 + } 1430 + 1431 + #[test] 1432 + fn ribbon_exit_returns_idle() { 1433 + let prev = Mode::enter_sketch(SketchId::default()); 1434 + let frame = shell::ShellFrame { 1435 + exit_sketch: true, 1436 + ..empty_frame() 1437 + }; 1438 + assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle); 1439 + } 1440 + 1441 + #[test] 1442 + fn exit_sketch_action_returns_idle() { 1443 + let prev = Mode::enter_sketch(SketchId::default()); 1444 + assert_eq!( 1445 + next_mode( 1446 + prev, 1447 + &empty_frame(), 1448 + &[sketch_mode::EXIT_SKETCH_ACTION], 1449 + &xy_only() 1450 + ), 1451 + Mode::Idle 1452 + ); 1453 + } 1454 + 1455 + #[test] 1456 + fn escape_with_pending_clears_pending_keeps_sketch_and_tool() { 1457 + let prev = Mode::Sketch { 1458 + sketch_id: SketchId::default(), 1459 + session: SketchSession { 1460 + tool: Some(SketchTool::Line), 1461 + pending: Some(Pending::Position(Point2::from_mm(1.0, 2.0))), 1462 + drag: None, 1463 + }, 1464 + }; 1465 + let next = next_mode( 1466 + prev, 1467 + &empty_frame(), 1468 + &[sketch_mode::EXIT_SKETCH_ACTION], 1469 + &xy_only(), 1470 + ); 1471 + let Mode::Sketch { session, .. } = next else { 1472 + panic!("escape with pending must keep sketch mode"); 1473 + }; 1474 + assert_eq!(session.tool, Some(SketchTool::Line)); 1475 + assert_eq!(session.pending, None); 1476 + } 1477 + 1478 + #[test] 1479 + fn build_preview_in_idle_is_empty() { 1480 + let document = Document::new(DocumentId::default(), "doc".to_owned()); 1481 + let preview = build_preview(&Mode::Idle, &document, Some(Point2::from_mm(1.0, 1.0))); 1482 + assert!(preview.is_empty()); 1483 + } 1484 + 1485 + #[test] 1486 + fn build_preview_without_armed_line_tool_is_empty() { 1487 + let document = Document::new(DocumentId::default(), "doc".to_owned()); 1488 + let mode = Mode::enter_sketch(SketchId::default()); 1489 + let preview = build_preview(&mode, &document, Some(Point2::from_mm(0.0, 0.0))); 1490 + assert!(preview.is_empty()); 1491 + } 1492 + 1493 + #[test] 1494 + fn build_preview_with_position_pending_emits_anchor_and_rubber_band() { 1495 + let document = Document::new(DocumentId::default(), "doc".to_owned()); 1496 + let anchor = Point2::from_mm(2.0, 3.0); 1497 + let mode = Mode::Sketch { 1498 + sketch_id: SketchId::default(), 1499 + session: SketchSession { 1500 + tool: Some(SketchTool::Line), 1501 + pending: Some(Pending::Position(anchor)), 1502 + drag: None, 1503 + }, 1504 + }; 1505 + let cursor = Point2::from_mm(5.0, 7.0); 1506 + let preview = build_preview(&mode, &document, Some(cursor)); 1507 + assert_eq!(preview.anchor, Some(anchor)); 1508 + assert_eq!(preview.rubber_band, Some((anchor, cursor))); 1509 + } 1510 + 1511 + #[test] 1512 + fn build_preview_with_endpoint_pending_resolves_via_document() { 1513 + let sketch = Sketch::new(Plane::Xy.basis()); 1514 + let target = Point2::from_mm(-4.0, 6.0); 1515 + let (sketch, endpoint) = add_point(sketch, -4.0, 6.0); 1516 + let (document, sketch_id) = initial_document(sketch); 1517 + let mode = Mode::Sketch { 1518 + sketch_id, 1519 + session: SketchSession { 1520 + tool: Some(SketchTool::Line), 1521 + pending: Some(Pending::Endpoint(endpoint)), 1522 + drag: None, 1523 + }, 1524 + }; 1525 + let cursor = Point2::from_mm(0.0, 0.0); 1526 + let preview = build_preview(&mode, &document, Some(cursor)); 1527 + assert_eq!(preview.anchor, Some(target)); 1528 + assert_eq!(preview.rubber_band, Some((target, cursor))); 1529 + } 1530 + 1531 + #[test] 1532 + fn build_preview_drops_rubber_band_when_cursor_outside_viewport() { 1533 + let document = Document::new(DocumentId::default(), "doc".to_owned()); 1534 + let anchor = Point2::from_mm(1.0, 1.0); 1535 + let mode = Mode::Sketch { 1536 + sketch_id: SketchId::default(), 1537 + session: SketchSession { 1538 + tool: Some(SketchTool::Line), 1539 + pending: Some(Pending::Position(anchor)), 1540 + drag: None, 1541 + }, 1542 + }; 1543 + let preview = build_preview(&mode, &document, None); 1544 + assert_eq!(preview.anchor, Some(anchor)); 1545 + assert_eq!(preview.rubber_band, None); 1546 + } 1547 + 1548 + #[test] 1549 + fn build_preview_during_drag_is_empty() { 1550 + let document = Document::new(DocumentId::default(), "doc".to_owned()); 1551 + let mode = Mode::Sketch { 1552 + sketch_id: SketchId::default(), 1553 + session: SketchSession { 1554 + tool: Some(SketchTool::Line), 1555 + pending: Some(Pending::Position(Point2::from_mm(0.0, 0.0))), 1556 + drag: Some(SketchEntityId::default()), 1557 + }, 1558 + }; 1559 + let preview = build_preview(&mode, &document, Some(Point2::from_mm(1.0, 1.0))); 1560 + assert!(preview.is_empty()); 1561 + } 1562 + 1563 + #[test] 1564 + fn escape_with_armed_tool_no_pending_disarms_tool() { 1565 + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 1566 + let next = next_mode( 1567 + prev, 1568 + &empty_frame(), 1569 + &[sketch_mode::EXIT_SKETCH_ACTION], 1570 + &xy_only(), 1571 + ); 1572 + let Mode::Sketch { session, .. } = next else { 1573 + panic!("escape with armed tool must keep sketch mode"); 1574 + }; 1575 + assert_eq!(session.tool, None); 1576 + assert_eq!(session.pending, None); 1577 + } 1578 + 1579 + #[test] 1580 + fn clicking_active_tool_disarms_it() { 1581 + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 1582 + let frame = shell::ShellFrame { 1583 + activated_tool: Some(SketchTool::Line), 1584 + ..empty_frame() 1585 + }; 1586 + let next = next_mode(prev, &frame, &[], &xy_only()); 1587 + let Mode::Sketch { session, .. } = next else { 1588 + panic!("expected sketch mode"); 1589 + }; 1590 + assert_eq!(session.tool, None); 1591 + } 1592 + 1593 + #[test] 1594 + fn clicking_different_tool_swaps() { 1595 + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 1596 + let frame = shell::ShellFrame { 1597 + activated_tool: Some(SketchTool::Point), 1598 + ..empty_frame() 1599 + }; 1600 + let next = next_mode(prev, &frame, &[], &xy_only()); 1601 + let Mode::Sketch { session, .. } = next else { 1602 + panic!("expected sketch mode"); 1603 + }; 1604 + assert_eq!(session.tool, Some(SketchTool::Point)); 1605 + } 1606 + 1607 + #[test] 1608 + fn ribbon_exit_overrides_pending_chain() { 1609 + let prev = Mode::Sketch { 1610 + sketch_id: SketchId::default(), 1611 + session: SketchSession { 1612 + tool: Some(SketchTool::Line), 1613 + pending: Some(Pending::Position(Point2::from_mm(0.0, 0.0))), 1614 + drag: None, 1615 + }, 1616 + }; 1617 + let frame = shell::ShellFrame { 1618 + exit_sketch: true, 1619 + ..empty_frame() 1620 + }; 1621 + assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle); 1622 + } 1623 + 1624 + #[test] 1625 + fn tool_in_idle_does_not_promote_to_sketch() { 1626 + let frame = shell::ShellFrame { 1627 + activated_tool: Some(SketchTool::Line), 1628 + ..empty_frame() 1629 + }; 1630 + assert_eq!(next_mode(Mode::Idle, &frame, &[], &xy_only()), Mode::Idle); 1631 + } 1632 + 1633 + #[test] 1634 + fn tool_in_sketch_arms_session() { 1635 + let prev = Mode::enter_sketch(SketchId::default()); 1636 + let frame = shell::ShellFrame { 1637 + activated_tool: Some(SketchTool::Line), 1638 + ..empty_frame() 1639 + }; 1640 + let Mode::Sketch { session, .. } = next_mode(prev, &frame, &[], &xy_only()) else { 1641 + panic!("expected sketch mode"); 1642 + }; 1643 + assert_eq!(session.tool, Some(SketchTool::Line)); 1644 + } 1645 + 1646 + #[test] 1647 + fn plane_pick_then_tool_enters_and_arms_in_one_frame() { 1648 + let frame = shell::ShellFrame { 1649 + plane_picked: Some(Plane::Xy), 1650 + activated_tool: Some(SketchTool::Line), 1651 + ..empty_frame() 1652 + }; 1653 + let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, &[], &xy_only()) else { 1654 + panic!("expected sketch mode"); 1655 + }; 1656 + assert_eq!(session.tool, Some(SketchTool::Line)); 1657 + } 1658 + 1659 + #[test] 1660 + fn exit_action_wins_over_pending_tool() { 1661 + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 1662 + let frame = shell::ShellFrame { 1663 + exit_sketch: true, 1664 + ..empty_frame() 1665 + }; 1666 + assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle); 1667 + } 1668 + 1669 + #[test] 1670 + fn idle_scopes_omit_sketch_scope() { 1671 + let scopes = scopes_for_mode(&Mode::Idle); 1672 + let collected: Vec<_> = scopes.innermost_first().copied().collect(); 1673 + assert!(!collected.contains(&HotkeyScope::Sketch)); 1674 + assert!(collected.contains(&HotkeyScope::Global)); 1675 + } 1676 + 1677 + #[test] 1678 + fn sketch_scopes_include_sketch_scope() { 1679 + let scopes = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); 1680 + let collected: Vec<_> = scopes.innermost_first().copied().collect(); 1681 + assert!(collected.contains(&HotkeyScope::Sketch)); 1682 + assert!(collected.contains(&HotkeyScope::Global)); 1683 + } 1684 + 1685 + #[test] 1686 + fn hotkey_table_binds_escape_to_exit_under_sketch_scope() { 1687 + let table = build_hotkey_table(); 1688 + let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 1689 + let in_sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); 1690 + assert_eq!( 1691 + table.dispatch(chord, &in_sketch), 1692 + Some(sketch_mode::EXIT_SKETCH_ACTION) 1693 + ); 1694 + let in_idle = scopes_for_mode(&Mode::Idle); 1695 + assert_eq!(table.dispatch(chord, &in_idle), None); 1696 + } 1697 + }