Another project
0

Configure Feed

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

fix(app): rename commits

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

author
Lewis
date (May 17, 2026, 7:50 PM +0300) commit 5ae36bc4 parent 42955ec7 change-id orvwmyop
+1046 -148
+1
Cargo.toml
··· 19 19 20 20 [workspace.lints.rust] 21 21 unsafe_code = "forbid" 22 + unused_must_use = "deny" 22 23 23 24 [workspace.lints.clippy] 24 25 all = { level = "deny", priority = -1 }
+85
crates/bone-app/src/event.rs
··· 1 + use winit::{ 2 + dpi::{PhysicalPosition, PhysicalSize}, 3 + event::{ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, WindowEvent}, 4 + keyboard::{Key, PhysicalKey, SmolStr}, 5 + }; 6 + 7 + pub enum AppEvent { 8 + Input(InputEvent), 9 + Redraw, 10 + Close, 11 + Ignored, 12 + } 13 + 14 + pub enum InputEvent { 15 + Resize(PhysicalSize<u32>), 16 + Focus(bool), 17 + Modifier(Modifiers), 18 + CursorMove(PhysicalPosition<f64>), 19 + CursorLeft, 20 + CursorEntered, 21 + Pointer { 22 + button: MouseButton, 23 + state: ElementState, 24 + }, 25 + Wheel(MouseScrollDelta), 26 + KeyDown { 27 + physical_key: PhysicalKey, 28 + logical_key: Key, 29 + text: Option<SmolStr>, 30 + }, 31 + } 32 + 33 + impl AppEvent { 34 + pub fn from_winit(event: WindowEvent) -> Self { 35 + match event { 36 + WindowEvent::CloseRequested => Self::Close, 37 + WindowEvent::RedrawRequested => Self::Redraw, 38 + WindowEvent::Resized(size) => Self::Input(InputEvent::Resize(size)), 39 + WindowEvent::Focused(focused) => Self::Input(InputEvent::Focus(focused)), 40 + WindowEvent::ModifiersChanged(mods) => Self::Input(InputEvent::Modifier(mods)), 41 + WindowEvent::CursorMoved { position, .. } => { 42 + Self::Input(InputEvent::CursorMove(position)) 43 + } 44 + WindowEvent::CursorLeft { .. } => Self::Input(InputEvent::CursorLeft), 45 + WindowEvent::CursorEntered { .. } => Self::Input(InputEvent::CursorEntered), 46 + WindowEvent::MouseInput { state, button, .. } => { 47 + Self::Input(InputEvent::Pointer { button, state }) 48 + } 49 + WindowEvent::MouseWheel { delta, .. } => Self::Input(InputEvent::Wheel(delta)), 50 + WindowEvent::KeyboardInput { 51 + event: 52 + KeyEvent { 53 + state: ElementState::Pressed, 54 + physical_key, 55 + logical_key, 56 + text, 57 + .. 58 + }, 59 + .. 60 + } => Self::Input(InputEvent::KeyDown { 61 + physical_key, 62 + logical_key, 63 + text, 64 + }), 65 + WindowEvent::KeyboardInput { .. } 66 + | WindowEvent::ActivationTokenDone { .. } 67 + | WindowEvent::Moved(_) 68 + | WindowEvent::Destroyed 69 + | WindowEvent::DroppedFile(_) 70 + | WindowEvent::HoveredFile(_) 71 + | WindowEvent::HoveredFileCancelled 72 + | WindowEvent::Ime(_) 73 + | WindowEvent::PinchGesture { .. } 74 + | WindowEvent::PanGesture { .. } 75 + | WindowEvent::DoubleTapGesture { .. } 76 + | WindowEvent::RotationGesture { .. } 77 + | WindowEvent::TouchpadPressure { .. } 78 + | WindowEvent::AxisMotion { .. } 79 + | WindowEvent::Touch(_) 80 + | WindowEvent::ScaleFactorChanged { .. } 81 + | WindowEvent::ThemeChanged(_) 82 + | WindowEvent::Occluded(_) => Self::Ignored, 83 + } 84 + } 85 + }
+234 -106
crates/bone-app/src/main.rs
··· 34 34 use winit::{ 35 35 application::ApplicationHandler, 36 36 dpi::{PhysicalPosition, PhysicalSize}, 37 - event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}, 37 + event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}, 38 38 event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, 39 39 keyboard::{Key, KeyCode, ModifiersState, NamedKey as WinitNamed, PhysicalKey}, 40 40 window::{Window, WindowId}, ··· 42 42 43 43 mod chrome; 44 44 mod dimension_editor; 45 + mod event; 46 + mod redraw; 45 47 mod relation_tools; 46 48 mod selection; 47 49 mod settings; ··· 101 103 const UNDO_CAPACITY: usize = 256; 102 104 const SNAP_TOLERANCE_PX: f64 = 8.0; 103 105 const SNAP_TOLERANCE_MAX_MM: f64 = 5.0; 104 - const REDRAW_KICKS_AFTER_INTERACTION: u8 = 2; 105 106 106 107 struct RenderState { 107 108 surface: SurfaceContext, ··· 131 132 dim_editor: DimensionEditorState, 132 133 dim_editor_bounds: Option<LayoutRect>, 133 134 pending_exit: bool, 134 - redraw_kicks: u8, 135 135 } 136 136 137 137 #[derive(Default)] ··· 216 216 } 217 217 218 218 struct App { 219 - window: Option<Arc<Window>>, 219 + redraw: Option<redraw::Scheduler>, 220 220 render: Option<RenderState>, 221 221 input: InputState, 222 222 } ··· 712 712 actions: &[ActionId], 713 713 plane_sketches: &BTreeMap<Plane, SketchId>, 714 714 ) -> Mode { 715 - let pick = frame 715 + let plane_pick = frame 716 716 .plane_picked 717 717 .filter(|_| !mode.is_sketch()) 718 718 .and_then(|plane| plane_sketches.get(&plane).copied()); 719 + let sketch_pick = frame.sketch_activated.filter(|_| !mode.is_sketch()); 720 + let pick = sketch_pick.or(plane_pick); 719 721 let after_pick = pick.map_or(mode, Mode::enter_sketch); 720 722 let escape = actions.contains(&sketch_mode::EXIT_SKETCH_ACTION); 721 723 let after_escape = if escape { ··· 756 758 757 759 impl ApplicationHandler for App { 758 760 fn resumed(&mut self, event_loop: &ActiveEventLoop) { 759 - if self.window.is_some() { 761 + if self.redraw.is_some() { 760 762 return; 761 763 } 762 764 let attrs = Window::default_attributes().with_title("bone"); ··· 806 808 unreachable!("UNDO_CAPACITY constant is non-zero"); 807 809 }; 808 810 window.request_redraw(); 809 - self.window = Some(window); 811 + self.redraw = Some(redraw::Scheduler::new(window)); 810 812 self.render = Some(RenderState { 811 813 surface, 812 814 renderer, ··· 835 837 dim_editor: DimensionEditorState::default(), 836 838 dim_editor_bounds: None, 837 839 pending_exit: false, 838 - redraw_kicks: 0, 839 840 }); 840 841 } 841 842 842 - #[allow( 843 - clippy::too_many_lines, 844 - reason = "winit event dispatch is a flat match by event variant; splitting obscures the dispatch table" 845 - )] 846 843 fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { 847 - let (Some(state), Some(window)) = (self.render.as_mut(), self.window.as_ref()) else { 848 - return; 844 + match event::AppEvent::from_winit(event) { 845 + event::AppEvent::Input(input) => { 846 + let ack = self.dispatch_input(event_loop, input); 847 + if let Some(scheduler) = self.redraw.as_mut() { 848 + scheduler.schedule(ack); 849 + } 850 + } 851 + event::AppEvent::Redraw => self.dispatch_redraw(event_loop), 852 + event::AppEvent::Close => event_loop.exit(), 853 + event::AppEvent::Ignored => {} 854 + } 855 + } 856 + } 857 + 858 + impl App { 859 + fn dispatch_input( 860 + &mut self, 861 + event_loop: &ActiveEventLoop, 862 + input: event::InputEvent, 863 + ) -> redraw::InputDispatched { 864 + let ack = redraw::InputDispatched::after_input(); 865 + let Some(state) = self.render.as_mut() else { 866 + return ack; 849 867 }; 850 - match event { 851 - WindowEvent::CloseRequested => event_loop.exit(), 852 - WindowEvent::Resized(size) => { 868 + match input { 869 + event::InputEvent::Resize(size) => { 853 870 let extent = viewport_extent(size); 854 871 state.surface.resize(extent); 855 872 state.camera = state.camera.with_extent(extent); 856 873 state.viewport_rect = empty_rect(); 857 - window.request_redraw(); 858 874 } 859 - WindowEvent::RedrawRequested => { 860 - render_frame(state, window, &mut self.input); 861 - if state.pending_exit { 862 - event_loop.exit(); 875 + event::InputEvent::Focus(focused) => { 876 + if !focused { 877 + self.input.forget_pan_state(); 863 878 } 864 879 } 865 - WindowEvent::Focused(false) => self.input.forget_pan_state(), 866 - WindowEvent::ModifiersChanged(mods) => { 880 + event::InputEvent::Modifier(mods) => { 867 881 self.input.modifiers = mods.state(); 868 882 } 869 - WindowEvent::CursorMoved { position, .. } => { 883 + event::InputEvent::CursorMove(position) => { 870 884 let prev = self.input.cursor_px; 871 885 self.input.cursor_px = Some(position); 872 886 if self.input.panning() ··· 878 892 { 879 893 try_drag_to(state, world); 880 894 } 881 - window.request_redraw(); 882 895 } 883 - WindowEvent::CursorLeft { .. } => { 896 + event::InputEvent::CursorLeft => { 884 897 self.input.cursor_px = None; 885 898 } 886 - WindowEvent::MouseInput { 887 - state: btn_state, 888 - button: MouseButton::Left, 889 - .. 899 + event::InputEvent::CursorEntered => {} 900 + event::InputEvent::Pointer { button, state: btn_state } => { 901 + self.dispatch_pointer_button(button, btn_state); 902 + } 903 + event::InputEvent::Wheel(delta) => { 904 + if self.input.cursor_in(state.viewport_rect) { 905 + state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 906 + } 907 + } 908 + event::InputEvent::KeyDown { 909 + physical_key, 910 + logical_key, 911 + text, 890 912 } => { 913 + self.dispatch_keydown(event_loop, physical_key, &logical_key, text.as_ref()); 914 + } 915 + } 916 + ack 917 + } 918 + 919 + fn dispatch_pointer_button(&mut self, button: MouseButton, btn_state: ElementState) { 920 + let Some(state) = self.render.as_mut() else { return }; 921 + match button { 922 + MouseButton::Left => { 891 923 if btn_state == ElementState::Pressed { 892 924 let in_viewport = self.input.cursor_in(state.viewport_rect); 893 925 let over_dim_editor = state ··· 919 951 self.input.pending_released = 920 952 self.input.pending_released.with(PointerButton::Primary); 921 953 } 922 - window.request_redraw(); 923 954 } 924 - WindowEvent::MouseInput { 925 - state: btn_state, 926 - button: MouseButton::Right, 927 - .. 928 - } => { 955 + MouseButton::Right => { 929 956 if btn_state == ElementState::Pressed { 930 957 self.input.pending_pressed = 931 958 self.input.pending_pressed.with(PointerButton::Secondary); ··· 936 963 self.input.pending_released = 937 964 self.input.pending_released.with(PointerButton::Secondary); 938 965 } 939 - window.request_redraw(); 940 966 } 941 - WindowEvent::MouseInput { 942 - state: btn_state, 943 - button: MouseButton::Middle, 944 - .. 945 - } => { 967 + MouseButton::Middle => { 946 968 if btn_state == ElementState::Pressed { 947 969 self.input.middle_pan = self.input.cursor_in(state.viewport_rect); 948 970 self.input.pending_pressed = ··· 953 975 self.input.pending_released.with(PointerButton::Middle); 954 976 } 955 977 } 956 - WindowEvent::MouseWheel { delta, .. } if self.input.cursor_in(state.viewport_rect) => { 957 - state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 958 - window.request_redraw(); 978 + MouseButton::Back | MouseButton::Forward | MouseButton::Other(_) => {} 979 + } 980 + } 981 + 982 + fn dispatch_keydown( 983 + &mut self, 984 + event_loop: &ActiveEventLoop, 985 + physical_key: PhysicalKey, 986 + logical_key: &Key, 987 + text: Option<&winit::keyboard::SmolStr>, 988 + ) { 989 + let Some(state) = self.render.as_mut() else { return }; 990 + let physical_code = match physical_key { 991 + PhysicalKey::Code(c) => Some(c), 992 + PhysicalKey::Unidentified(_) => None, 993 + }; 994 + let logical_named = match logical_key { 995 + Key::Named(nk) => winit_named_to_ui(*nk), 996 + _ => None, 997 + }; 998 + let physical_named = physical_code.and_then(keycode_to_named); 999 + let named = logical_named.or(physical_named); 1000 + let mods = self.input.modifier_mask(); 1001 + if let Some(named) = named { 1002 + self.input 1003 + .pending_keys 1004 + .push(UiKeyEvent::new(UiKeyCode::Named(named), mods)); 1005 + } else if let Some(c) = physical_code.and_then(keycode_to_char) { 1006 + self.input.pending_keys.push(UiKeyEvent::new( 1007 + UiKeyCode::Char(KeyChar::from_char(c)), 1008 + mods, 1009 + )); 1010 + } 1011 + if let Some(typed) = text { 1012 + let filtered: String = typed.chars().filter(|c| !c.is_control()).collect(); 1013 + if !filtered.is_empty() { 1014 + self.input.pending_text.push_str(&filtered); 959 1015 } 960 - WindowEvent::KeyboardInput { 961 - event: 962 - KeyEvent { 963 - physical_key, 964 - logical_key, 965 - state: ElementState::Pressed, 966 - text, 967 - .. 968 - }, 969 - .. 970 - } => { 971 - let physical_code = match physical_key { 972 - PhysicalKey::Code(c) => Some(c), 973 - PhysicalKey::Unidentified(_) => None, 974 - }; 975 - let logical_named = match &logical_key { 976 - Key::Named(nk) => winit_named_to_ui(*nk), 977 - _ => None, 978 - }; 979 - let physical_named = physical_code.and_then(keycode_to_named); 980 - let named = logical_named.or(physical_named); 981 - let mods = self.input.modifier_mask(); 982 - if let Some(named) = named { 983 - self.input 984 - .pending_keys 985 - .push(UiKeyEvent::new(UiKeyCode::Named(named), mods)); 986 - } else if let Some(c) = physical_code.and_then(keycode_to_char) { 987 - self.input.pending_keys.push(UiKeyEvent::new( 988 - UiKeyCode::Char(KeyChar::from_char(c)), 989 - mods, 990 - )); 991 - } 992 - if let Some(typed) = text.as_ref() { 993 - let filtered: String = typed.chars().filter(|c| !c.is_control()).collect(); 994 - if !filtered.is_empty() { 995 - self.input.pending_text.push_str(&filtered); 996 - } 997 - } 998 - let suppress_camera = 999 - dim_flow_active(&state.mode) || state.focus.focused().is_some(); 1000 - if let Some(code) = physical_code { 1001 - match keyboard_action(code, &self.input, state) { 1002 - Some(KeyAction::Exit) => event_loop.exit(), 1003 - Some(KeyAction::Camera(next)) if !suppress_camera => { 1004 - state.camera = next; 1005 - } 1006 - Some(KeyAction::Camera(_)) | None => {} 1007 - } 1016 + } 1017 + let suppress_camera = 1018 + dim_flow_active(&state.mode) || state.focus.focused().is_some(); 1019 + if let Some(code) = physical_code { 1020 + match keyboard_action(code, &self.input, state) { 1021 + Some(KeyAction::Exit) => event_loop.exit(), 1022 + Some(KeyAction::Camera(next)) if !suppress_camera => { 1023 + state.camera = next; 1008 1024 } 1009 - window.request_redraw(); 1025 + Some(KeyAction::Camera(_)) | None => {} 1010 1026 } 1011 - _ => {} 1027 + } 1028 + } 1029 + 1030 + fn dispatch_redraw(&mut self, event_loop: &ActiveEventLoop) { 1031 + let (Some(state), Some(scheduler)) = (self.render.as_mut(), self.redraw.as_mut()) else { 1032 + return; 1033 + }; 1034 + render_frame(state, scheduler, &mut self.input); 1035 + if state.pending_exit { 1036 + event_loop.exit(); 1012 1037 } 1013 1038 } 1014 1039 } ··· 1021 1046 clippy::too_many_lines, 1022 1047 reason = "splitting hides the per-outcome dispatch table" 1023 1048 )] 1024 - fn render_frame(state: &mut RenderState, window: &Window, input_state: &mut InputState) { 1049 + fn render_frame( 1050 + state: &mut RenderState, 1051 + scheduler: &mut redraw::Scheduler, 1052 + input_state: &mut InputState, 1053 + ) { 1025 1054 let extent = state.surface.extent(); 1026 1055 let layout_size = layout_size_from_extent(extent); 1027 1056 let theme = Arc::clone(&state.theme); ··· 1055 1084 frame 1056 1085 }; 1057 1086 state.viewport_rect = frame.viewport_rect; 1058 - apply_resolve_and_kick_redraws(state, &hits, &input); 1087 + apply_resolve_and_kick_redraws(state, scheduler, &hits, &input); 1059 1088 if let Some(plane) = frame.plane_picked { 1060 1089 match ( 1061 1090 state.mode.is_sketch(), ··· 1088 1117 apply_menu_action(state, frame.menu_action); 1089 1118 apply_settings_change(state, frame.settings_change); 1090 1119 apply_relation_action(state, frame.activated_relation); 1120 + apply_sketch_rename(state, frame.sketch_rename.clone()); 1091 1121 let cursor_world = input_state 1092 1122 .cursor_px 1093 1123 .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) ··· 1114 1144 let camera = state.camera; 1115 1145 let style = &state.style; 1116 1146 renderer.prepare(scene, style); 1147 + let pre_present = || scheduler.window().pre_present_notify(); 1117 1148 surface.render( 1118 1149 |encoder, color, pick| { 1119 1150 renderer.encode_passes( ··· 1126 1157 ); 1127 1158 chrome_stage.encode_layered(encoder, color, &main_layer, &overlay_layer); 1128 1159 }, 1129 - || window.pre_present_notify(), 1160 + pre_present, 1130 1161 ); 1131 - if state.redraw_kicks > 0 { 1132 - state.redraw_kicks -= 1; 1133 - window.request_redraw(); 1134 - } 1162 + scheduler.consume_kick(); 1135 1163 } 1136 1164 1137 1165 struct ChromeStage<'a> { ··· 1190 1218 } 1191 1219 } 1192 1220 1193 - fn apply_resolve_and_kick_redraws(state: &mut RenderState, hits: &HitFrame, input: &InputSnapshot) { 1221 + fn apply_resolve_and_kick_redraws( 1222 + state: &mut RenderState, 1223 + scheduler: &mut redraw::Scheduler, 1224 + hits: &HitFrame, 1225 + input: &InputSnapshot, 1226 + ) { 1194 1227 state.hit_state = resolve(&state.hit_state, hits, input, state.focus.focused()); 1195 1228 if any_actionable_interaction(&state.hit_state) { 1196 - state.redraw_kicks = state.redraw_kicks.max(REDRAW_KICKS_AFTER_INTERACTION); 1229 + scheduler.kick(); 1197 1230 } 1198 1231 } 1199 1232 ··· 1247 1280 activated_dimension: None, 1248 1281 dimension_edit: None, 1249 1282 plane_picked: None, 1283 + sketch_activated: None, 1284 + sketch_rename: None, 1250 1285 exit_sketch: false, 1251 1286 confirm_action: None, 1252 1287 menu_action: None, ··· 1652 1687 } 1653 1688 } 1654 1689 1690 + fn apply_sketch_rename(state: &mut RenderState, request: Option<shell::SketchRenameRequest>) { 1691 + let Some(req) = request else { return }; 1692 + apply_sketch_rename_into(&mut state.document, &mut state.undo, req); 1693 + } 1694 + 1695 + fn apply_sketch_rename_into( 1696 + document: &mut Document, 1697 + undo: &mut UndoStack, 1698 + request: shell::SketchRenameRequest, 1699 + ) { 1700 + let shell::SketchRenameRequest { id, label } = request; 1701 + if document.sketch_label(id).is_some_and(|l| l == label.trim()) { 1702 + return; 1703 + } 1704 + let snapshot = document.clone(); 1705 + match document.rename_sketch(id, &label) { 1706 + Ok(()) => undo.record(snapshot), 1707 + Err(e) => tracing::warn!(error = %e, ?id, "sketch rename rejected"), 1708 + } 1709 + } 1710 + 1655 1711 enum RunMode { 1656 1712 Window, 1657 1713 Gallery(PathBuf), ··· 1730 1786 KeyCode::End => Some(NamedKey::End), 1731 1787 KeyCode::PageUp => Some(NamedKey::PageUp), 1732 1788 KeyCode::PageDown => Some(NamedKey::PageDown), 1789 + KeyCode::F2 => Some(NamedKey::F2), 1733 1790 _ => None, 1734 1791 } 1735 1792 } ··· 1750 1807 WinitNamed::End => Some(NamedKey::End), 1751 1808 WinitNamed::PageUp => Some(NamedKey::PageUp), 1752 1809 WinitNamed::PageDown => Some(NamedKey::PageDown), 1810 + WinitNamed::F2 => Some(NamedKey::F2), 1753 1811 _ => None, 1754 1812 } 1755 1813 } ··· 1800 1858 let event_loop = EventLoop::new()?; 1801 1859 event_loop.set_control_flow(ControlFlow::Wait); 1802 1860 let mut app = App { 1803 - window: None, 1861 + redraw: None, 1804 1862 render: None, 1805 1863 input: InputState::default(), 1806 1864 }; ··· 1859 1917 activated_dimension: None, 1860 1918 dimension_edit: None, 1861 1919 plane_picked: None, 1920 + sketch_activated: None, 1921 + sketch_rename: None, 1862 1922 exit_sketch: false, 1863 1923 confirm_action: None, 1864 1924 menu_action: None, ··· 2250 2310 panic!("expected sketch mode"); 2251 2311 }; 2252 2312 assert_eq!(session.tool, Some(SketchTool::Line)); 2313 + } 2314 + 2315 + fn doc_with_default_sketch() -> (Document, SketchId) { 2316 + let sketch = bone_document::Sketch::new(Plane::Xy.basis()); 2317 + let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); 2318 + let id = SketchId::default(); 2319 + document.insert_sketch(id, "Sketch1".to_owned(), sketch); 2320 + (document, id) 2321 + } 2322 + 2323 + #[test] 2324 + fn apply_sketch_rename_into_writes_label_and_records_undo_on_change() { 2325 + let (mut document, id) = doc_with_default_sketch(); 2326 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 2327 + apply_sketch_rename_into( 2328 + &mut document, 2329 + &mut undo, 2330 + shell::SketchRenameRequest { id, label: "Profile".to_owned() }, 2331 + ); 2332 + assert_eq!(document.sketch_label(id), Some("Profile")); 2333 + assert_eq!(undo.past_len(), 1, "successful rename records one undo snapshot"); 2334 + } 2335 + 2336 + #[test] 2337 + fn apply_sketch_rename_into_drops_empty_label_without_undo() { 2338 + let (mut document, id) = doc_with_default_sketch(); 2339 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 2340 + apply_sketch_rename_into( 2341 + &mut document, 2342 + &mut undo, 2343 + shell::SketchRenameRequest { id, label: " ".to_owned() }, 2344 + ); 2345 + assert_eq!(document.sketch_label(id), Some("Sketch1")); 2346 + assert_eq!(undo.past_len(), 0); 2347 + } 2348 + 2349 + #[test] 2350 + fn apply_sketch_rename_into_skips_no_op_against_trimmed_match() { 2351 + let (mut document, id) = doc_with_default_sketch(); 2352 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 2353 + apply_sketch_rename_into( 2354 + &mut document, 2355 + &mut undo, 2356 + shell::SketchRenameRequest { id, label: " Sketch1 ".to_owned() }, 2357 + ); 2358 + assert_eq!(document.sketch_label(id), Some("Sketch1")); 2359 + assert_eq!(undo.past_len(), 0, "trimmed-equal rename must not record undo"); 2360 + } 2361 + 2362 + #[test] 2363 + fn sketch_activated_from_idle_enters_that_sketch_without_plane_map() { 2364 + let sketch_id = SketchId::default(); 2365 + let frame = shell::ShellFrame { 2366 + sketch_activated: Some(sketch_id), 2367 + ..empty_frame() 2368 + }; 2369 + let next = next_mode(Mode::Idle, &frame, &[], &BTreeMap::new()); 2370 + assert_eq!(next, Mode::enter_sketch(sketch_id)); 2371 + } 2372 + 2373 + #[test] 2374 + fn sketch_activated_while_in_sketch_is_ignored() { 2375 + let prev = Mode::enter_sketch(SketchId::default()); 2376 + let frame = shell::ShellFrame { 2377 + sketch_activated: Some(SketchId::default()), 2378 + ..empty_frame() 2379 + }; 2380 + assert_eq!(next_mode(prev.clone(), &frame, &[], &xy_only()), prev); 2253 2381 } 2254 2382 2255 2383 #[test]
+45
crates/bone-app/src/redraw.rs
··· 1 + use std::sync::Arc; 2 + use winit::window::Window; 3 + 4 + const KICKS_AFTER_INTERACTION: u8 = 2; 5 + 6 + pub struct Scheduler { 7 + window: Arc<Window>, 8 + kicks: u8, 9 + } 10 + 11 + #[must_use = "input dispatch must be acknowledged via Scheduler::schedule"] 12 + pub struct InputDispatched(()); 13 + 14 + impl InputDispatched { 15 + pub(crate) fn after_input() -> Self { 16 + Self(()) 17 + } 18 + } 19 + 20 + impl Scheduler { 21 + pub fn new(window: Arc<Window>) -> Self { 22 + Self { window, kicks: 0 } 23 + } 24 + 25 + pub fn schedule(&mut self, _ack: InputDispatched) { 26 + self.kicks = self.kicks.max(KICKS_AFTER_INTERACTION); 27 + self.window.request_redraw(); 28 + } 29 + 30 + pub fn kick(&mut self) { 31 + self.kicks = self.kicks.max(KICKS_AFTER_INTERACTION); 32 + } 33 + 34 + pub fn consume_kick(&mut self) { 35 + if self.kicks > 0 { 36 + self.kicks -= 1; 37 + self.window.request_redraw(); 38 + } 39 + } 40 + 41 + #[must_use] 42 + pub fn window(&self) -> &Window { 43 + &self.window 44 + } 45 + }
+378 -18
crates/bone-app/src/shell.rs
··· 20 20 use bone_ui::widgets::{ 21 21 AngleEditor, Clipboard, Dialog, DialogButton, LabelText, LengthEditor, MemoryClipboard, 22 22 MenuBar, MenuBarEntry, MenuBarState, MenuItem, PropertyCell, PropertyEditor, PropertyGrid, 23 - PropertyRow, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, Slider, SliderRange, 23 + PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, Slider, SliderRange, 24 24 SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Tabs, TabsOrientation, ToolbarItem, 25 25 TreeNode, TreeView, TreeViewState, WidgetPaint, show_dialog, show_menu_bar, show_property_grid, 26 26 show_ribbon, show_slider, show_status_bar, show_tabs, show_tree_view, ··· 251 251 pub activated_dimension: Option<PendingDimension>, 252 252 pub dimension_edit: Option<DimensionEdit>, 253 253 pub plane_picked: Option<Plane>, 254 + pub sketch_activated: Option<SketchId>, 255 + pub sketch_rename: Option<SketchRenameRequest>, 254 256 pub exit_sketch: bool, 255 257 pub confirm_action: Option<ConfirmAction>, 256 258 pub menu_action: Option<MenuAction>, ··· 274 276 activated_dimension: None, 275 277 dimension_edit: None, 276 278 plane_picked: None, 279 + sketch_activated: None, 280 + sketch_rename: None, 277 281 exit_sketch: false, 278 282 confirm_action: None, 279 283 menu_action: None, ··· 406 410 LeftPane::Tree => (content_rect, zero_rect()), 407 411 LeftPane::Properties => (zero_rect(), content_rect), 408 412 }; 409 - let double_activated = render_feature_tree( 413 + let feature_tree = render_feature_tree( 410 414 ctx, 411 415 tree_rect, 412 416 self.ids.feature_tree, ··· 461 465 active_sketch, 462 466 entity_ids, 463 467 ); 464 - let plane_picked = double_activated.and_then(|id| self.ids.plane_for(id)); 468 + let plane_picked = feature_tree 469 + .double_activated 470 + .and_then(|id| self.ids.plane_for(id)); 471 + let sketch_activated = feature_tree.sketch_activated; 472 + let sketch_rename = feature_tree.sketch_rename; 465 473 let mut dialog_paints: Vec<WidgetPaint> = Vec::new(); 466 474 let settings_change = render_settings_dialog( 467 475 ctx, ··· 483 491 activated_dimension, 484 492 dimension_edit, 485 493 plane_picked, 494 + sketch_activated, 495 + sketch_rename, 486 496 exit_sketch, 487 497 confirm_action, 488 498 menu_action, ··· 1010 1020 response.activated_tool 1011 1021 } 1012 1022 1023 + #[derive(Clone, Debug, PartialEq)] 1024 + pub struct SketchRenameRequest { 1025 + pub id: SketchId, 1026 + pub label: String, 1027 + } 1028 + 1029 + struct FeatureTreeOutcome { 1030 + double_activated: Option<WidgetId>, 1031 + sketch_activated: Option<SketchId>, 1032 + sketch_rename: Option<SketchRenameRequest>, 1033 + } 1034 + 1035 + fn sketch_widget_id(part_id: WidgetId, sketch_id: SketchId) -> WidgetId { 1036 + part_id.child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64()) 1037 + } 1038 + 1013 1039 fn render_feature_tree( 1014 1040 ctx: &mut FrameCtx<'_>, 1015 1041 rect: LayoutRect, ··· 1018 1044 state: &mut TreeViewState, 1019 1045 document: &Document, 1020 1046 paints: &mut Vec<WidgetPaint>, 1021 - ) -> Option<WidgetId> { 1047 + ) -> FeatureTreeOutcome { 1022 1048 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1023 - return None; 1049 + return FeatureTreeOutcome { 1050 + double_activated: None, 1051 + sketch_activated: None, 1052 + sketch_rename: None, 1053 + }; 1024 1054 } 1025 1055 let leaf = |key: &'static str, label: StringKey| { 1026 1056 TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) ··· 1034 1064 let plane_leaf = |key: &'static str, label: StringKey| { 1035 1065 leaf(key, label).with_glyph(GlyphMark::TreePlane) 1036 1066 }; 1037 - let sketches: Vec<TreeNode> = document 1067 + let sketch_rows: Vec<(SketchId, WidgetId, TreeNode)> = document 1038 1068 .sketches() 1039 - .enumerate() 1040 - .map(|(idx, _)| { 1041 - TreeNode::leaf( 1042 - part_id.child_indexed(WidgetKey::new("sketch"), idx as u64), 1043 - strings::FEATURE_SKETCH_DEFAULT, 1044 - ) 1045 - .with_glyph(GlyphMark::TreeSketch) 1069 + .map(|(sketch_id, _)| { 1070 + let widget_id = sketch_widget_id(part_id, sketch_id); 1071 + let label = document 1072 + .sketch_label(sketch_id) 1073 + .unwrap_or("") 1074 + .to_owned(); 1075 + let node = TreeNode::leaf_owned(widget_id, label) 1076 + .with_glyph(GlyphMark::TreeSketch); 1077 + (sketch_id, widget_id, node) 1046 1078 }) 1047 1079 .collect(); 1080 + let renamable: Vec<WidgetId> = sketch_rows.iter().map(|(_, w, _)| *w).collect(); 1081 + let widget_to_sketch: BTreeMap<WidgetId, SketchId> = sketch_rows 1082 + .iter() 1083 + .map(|(s, w, _)| (*w, *s)) 1084 + .collect(); 1048 1085 let children: Vec<TreeNode> = [ 1049 1086 placeholder("history", strings::FEATURE_HISTORY), 1050 1087 placeholder("sensors", strings::FEATURE_SENSORS), ··· 1057 1094 leaf("origin", strings::FEATURE_ORIGIN).with_glyph(GlyphMark::RadioDot), 1058 1095 ] 1059 1096 .into_iter() 1060 - .chain(sketches) 1097 + .chain(sketch_rows.into_iter().map(|(_, _, node)| node)) 1061 1098 .collect(); 1062 1099 let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children); 1063 1100 let roots = [part]; 1064 1101 let response = show_tree_view( 1065 1102 ctx, 1066 - TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state), 1103 + TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state) 1104 + .renamable(&renamable), 1067 1105 ); 1068 1106 paints.extend(response.paint); 1069 - response.double_activated 1107 + let sketch_activated = response 1108 + .double_activated 1109 + .and_then(|id| widget_to_sketch.get(&id).copied()); 1110 + let sketch_rename = response 1111 + .rename_committed 1112 + .and_then(|RenameCommit { id, text }| { 1113 + widget_to_sketch 1114 + .get(&id) 1115 + .copied() 1116 + .map(|sketch_id| SketchRenameRequest { id: sketch_id, label: text }) 1117 + }); 1118 + FeatureTreeOutcome { 1119 + double_activated: response.double_activated, 1120 + sketch_activated, 1121 + sketch_rename, 1122 + } 1070 1123 } 1071 1124 1072 1125 #[derive(Copy, Clone)] ··· 1652 1705 match mode { 1653 1706 Mode::Idle => LabelText::Key(strings::STATUS_READY), 1654 1707 Mode::Sketch { sketch_id, .. } => { 1655 - let Some(position) = document.sketches().position(|(id, _)| id == *sketch_id) else { 1708 + let Some(label) = document.sketch_label(*sketch_id) else { 1656 1709 tracing::warn!(?sketch_id, "active sketch missing from document"); 1657 1710 return LabelText::Key(strings::STATUS_READY); 1658 1711 }; 1659 1712 let prefix = strings_table.resolve(strings::STATUS_SKETCH_ACTIVE); 1660 - LabelText::Owned(format!("{prefix} {}", position + 1)) 1713 + LabelText::Owned(format!("{prefix} {label}")) 1661 1714 } 1662 1715 } 1663 1716 } ··· 3000 3053 }); 3001 3054 assert!(any_horizontal_label, "relation kind label should appear"); 3002 3055 assert!(shell.state.dim_property.is_none(), "relation does not own dim editor"); 3056 + } 3057 + 3058 + fn shell_drive( 3059 + shell: &mut Shell, 3060 + document: &Document, 3061 + mode: &Mode, 3062 + selection: &Selection, 3063 + focus: &mut FocusManager, 3064 + prev: &mut HitState, 3065 + snap: &mut InputSnapshot, 3066 + ) -> (ShellFrame, HitFrame) { 3067 + let theme = Arc::new(Theme::light()); 3068 + let table = HotkeyTable::new(); 3069 + let mut hits = HitFrame::new(); 3070 + let mut shaper = bone_text::Shaper::new(); 3071 + let mut a11y = AccessTreeBuilder::new(); 3072 + let frame = { 3073 + let mut ctx = FrameCtx::new( 3074 + theme, 3075 + snap, 3076 + focus, 3077 + &table, 3078 + StringTable::empty(), 3079 + &mut hits, 3080 + prev, 3081 + &mut a11y, 3082 + &mut shaper, 3083 + ); 3084 + shell.render( 3085 + &mut ctx, 3086 + document, 3087 + mode, 3088 + selection, 3089 + Settings::default(), 3090 + layout_size(1280.0, 800.0), 3091 + None, 3092 + ) 3093 + }; 3094 + *prev = bone_ui::hit_test::resolve(prev, &hits, snap, focus.focused()); 3095 + (frame, hits) 3096 + } 3097 + 3098 + fn sketch_widget(ids: &ShellIds, sketch_id: SketchId) -> WidgetId { 3099 + ids.feature_part 3100 + .child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64()) 3101 + } 3102 + 3103 + #[test] 3104 + fn f2_with_focused_sketch_row_starts_rename_in_full_shell() { 3105 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3106 + let (document, sketch_id) = document_with_sketch(sketch); 3107 + let mut shell = Shell::new(); 3108 + let widget = sketch_widget(&shell.ids, sketch_id); 3109 + let mut focus = FocusManager::new(); 3110 + let mut prev = HitState::new(); 3111 + 3112 + let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 3113 + let (_, _) = shell_drive( 3114 + &mut shell, 3115 + &document, 3116 + &Mode::Idle, 3117 + &Selection::default(), 3118 + &mut focus, 3119 + &mut prev, 3120 + &mut warm, 3121 + ); 3122 + focus.request_focus(widget); 3123 + let mut warm2 = InputSnapshot::idle(FrameInstant::ZERO); 3124 + let (_, _) = shell_drive( 3125 + &mut shell, 3126 + &document, 3127 + &Mode::Idle, 3128 + &Selection::default(), 3129 + &mut focus, 3130 + &mut prev, 3131 + &mut warm2, 3132 + ); 3133 + assert_eq!( 3134 + focus.focused(), 3135 + Some(widget), 3136 + "sketch row must be focusable+focused after second render", 3137 + ); 3138 + 3139 + let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 3140 + f2.keys_pressed.push(bone_ui::input::KeyEvent::new( 3141 + bone_ui::input::KeyCode::Named(bone_ui::input::NamedKey::F2), 3142 + bone_ui::input::ModifierMask::NONE, 3143 + )); 3144 + let (_, _) = shell_drive( 3145 + &mut shell, 3146 + &document, 3147 + &Mode::Idle, 3148 + &Selection::default(), 3149 + &mut focus, 3150 + &mut prev, 3151 + &mut f2, 3152 + ); 3153 + assert_eq!( 3154 + shell.state.feature_tree.renaming, 3155 + Some(widget), 3156 + "F2 with sketch row focused must enter rename", 3157 + ); 3158 + } 3159 + 3160 + fn drive_with_snap( 3161 + shell: &mut Shell, 3162 + document: &Document, 3163 + mode: &Mode, 3164 + selection: &Selection, 3165 + focus: &mut FocusManager, 3166 + prev: &mut HitState, 3167 + snap: InputSnapshot, 3168 + ) -> (ShellFrame, HitFrame) { 3169 + let mut snap = snap; 3170 + shell_drive(shell, document, mode, selection, focus, prev, &mut snap) 3171 + } 3172 + 3173 + #[test] 3174 + fn status_bar_uses_current_sketch_label_when_in_sketch_mode() { 3175 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3176 + let (mut document, sketch_id) = document_with_sketch(sketch); 3177 + let Ok(()) = document.rename_sketch(sketch_id, "Profile") else { 3178 + panic!("rename must accept non-empty label"); 3179 + }; 3180 + let label = super::mode_status_label( 3181 + StringTable::empty(), 3182 + &Mode::enter_sketch(sketch_id), 3183 + &document, 3184 + ); 3185 + let LabelText::Owned(text) = label else { 3186 + panic!("sketch-mode status label is owned text"); 3187 + }; 3188 + assert!( 3189 + text.contains("Profile"), 3190 + "status text must include current sketch label, got {text:?}", 3191 + ); 3192 + assert!( 3193 + !text.contains("Sketch1"), 3194 + "status text must not show the prior label after rename, got {text:?}", 3195 + ); 3196 + } 3197 + 3198 + #[test] 3199 + fn sketch_row_hit_rect_lies_within_left_pane_bounds() { 3200 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3201 + let (document, sketch_id) = document_with_sketch(sketch); 3202 + let mut shell = Shell::new(); 3203 + let widget = sketch_widget(&shell.ids, sketch_id); 3204 + let mut focus = FocusManager::new(); 3205 + let mut prev = HitState::new(); 3206 + let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 3207 + let (frame, hits) = shell_drive( 3208 + &mut shell, 3209 + &document, 3210 + &Mode::Idle, 3211 + &Selection::default(), 3212 + &mut focus, 3213 + &mut prev, 3214 + &mut warm, 3215 + ); 3216 + let Some(row_rect) = hits 3217 + .items() 3218 + .iter() 3219 + .find(|item| item.id == widget) 3220 + .map(|item| item.rect) 3221 + else { 3222 + panic!("sketch row must register a hit item"); 3223 + }; 3224 + let viewport = frame.viewport_rect; 3225 + let row_right = row_rect.origin.x.value() + row_rect.size.width.value(); 3226 + let row_bottom = row_rect.origin.y.value() + row_rect.size.height.value(); 3227 + assert!( 3228 + row_right <= viewport.origin.x.value(), 3229 + "sketch row must sit left of the viewport, row_right={row_right} viewport_x={}", 3230 + viewport.origin.x.value(), 3231 + ); 3232 + assert!( 3233 + row_rect.origin.y.value() >= 0.0, 3234 + "sketch row origin y >= 0, got {}", 3235 + row_rect.origin.y.value(), 3236 + ); 3237 + assert!( 3238 + row_bottom <= 800.0, 3239 + "sketch row must fit within 800px tall window, row_bottom={row_bottom}", 3240 + ); 3241 + } 3242 + 3243 + #[test] 3244 + fn click_on_sketch_row_then_f2_enters_rename_via_full_shell() { 3245 + use bone_ui::input::{KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample}; 3246 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3247 + let (document, sketch_id) = document_with_sketch(sketch); 3248 + let mut shell = Shell::new(); 3249 + let widget = sketch_widget(&shell.ids, sketch_id); 3250 + let mut focus = FocusManager::new(); 3251 + let mut prev = HitState::new(); 3252 + 3253 + let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 3254 + let (_, hits) = shell_drive( 3255 + &mut shell, 3256 + &document, 3257 + &Mode::Idle, 3258 + &Selection::default(), 3259 + &mut focus, 3260 + &mut prev, 3261 + &mut warm, 3262 + ); 3263 + let Some(row_rect) = hits 3264 + .items() 3265 + .iter() 3266 + .find(|item| item.id == widget) 3267 + .map(|item| item.rect) 3268 + else { 3269 + panic!("sketch row must register a hit item in the feature tree"); 3270 + }; 3271 + let center = LayoutPos::new( 3272 + LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0), 3273 + LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 3274 + ); 3275 + 3276 + let mut press = InputSnapshot::idle(FrameInstant::ZERO); 3277 + press.pointer = Some(PointerSample::new(center)); 3278 + press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 3279 + let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, press); 3280 + 3281 + let mut release = InputSnapshot::idle(FrameInstant::ZERO); 3282 + release.pointer = Some(PointerSample::new(center)); 3283 + release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 3284 + let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, release); 3285 + 3286 + let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 3287 + idle.pointer = Some(PointerSample::new(center)); 3288 + let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, idle); 3289 + 3290 + assert_eq!( 3291 + focus.focused(), 3292 + Some(widget), 3293 + "click on sketch row must focus it before F2 is pressed", 3294 + ); 3295 + 3296 + let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 3297 + f2.pointer = Some(PointerSample::new(center)); 3298 + f2.keys_pressed.push(KeyEvent::new(KeyCode::Named(NamedKey::F2), ModifierMask::NONE)); 3299 + let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, f2); 3300 + assert_eq!( 3301 + shell.state.feature_tree.renaming, 3302 + Some(widget), 3303 + "click-then-F2 must enter rename mode on sketch row", 3304 + ); 3305 + } 3306 + 3307 + #[test] 3308 + fn double_click_sketch_row_emits_sketch_activated() { 3309 + use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; 3310 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3311 + let (document, sketch_id) = document_with_sketch(sketch); 3312 + let mut shell = Shell::new(); 3313 + let widget = sketch_widget(&shell.ids, sketch_id); 3314 + let mut focus = FocusManager::new(); 3315 + let mut prev = HitState::new(); 3316 + 3317 + let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 3318 + let (_, hits) = shell_drive( 3319 + &mut shell, 3320 + &document, 3321 + &Mode::Idle, 3322 + &Selection::default(), 3323 + &mut focus, 3324 + &mut prev, 3325 + &mut warm, 3326 + ); 3327 + let Some(row_rect) = hits 3328 + .items() 3329 + .iter() 3330 + .find(|item| item.id == widget) 3331 + .map(|item| item.rect) 3332 + else { 3333 + panic!("sketch row must register a hit item"); 3334 + }; 3335 + let center = LayoutPos::new( 3336 + LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0), 3337 + LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 3338 + ); 3339 + 3340 + let click = |shell: &mut Shell, 3341 + focus: &mut FocusManager, 3342 + prev: &mut HitState| { 3343 + let mut press = InputSnapshot::idle(FrameInstant::ZERO); 3344 + press.pointer = Some(PointerSample::new(center)); 3345 + press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 3346 + let _ = drive_with_snap(shell, &document, &Mode::Idle, &Selection::default(), focus, prev, press); 3347 + let mut release = InputSnapshot::idle(FrameInstant::ZERO); 3348 + release.pointer = Some(PointerSample::new(center)); 3349 + release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 3350 + drive_with_snap(shell, &document, &Mode::Idle, &Selection::default(), focus, prev, release) 3351 + }; 3352 + 3353 + let _ = click(&mut shell, &mut focus, &mut prev); 3354 + let _ = click(&mut shell, &mut focus, &mut prev); 3355 + let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 3356 + idle.pointer = Some(PointerSample::new(center)); 3357 + let (frame, _) = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, idle); 3358 + assert_eq!( 3359 + frame.sketch_activated, 3360 + Some(sketch_id), 3361 + "double-click on sketch row must emit sketch_activated for that sketch", 3362 + ); 3003 3363 } 3004 3364 }
+2 -5
crates/bone-app/src/strings.rs
··· 70 70 pub const FEATURE_PLANE_XY: StringKey = StringKey::new("feature.plane.xy"); 71 71 pub const FEATURE_PLANE_YZ: StringKey = StringKey::new("feature.plane.yz"); 72 72 pub const FEATURE_PLANE_ZX: StringKey = StringKey::new("feature.plane.zx"); 73 - pub const FEATURE_SKETCH_DEFAULT: StringKey = StringKey::new("feature.sketch.default"); 74 73 75 74 pub const PROPERTY_PANE_LABEL: StringKey = StringKey::new("shell.property_pane"); 76 75 pub const LEFT_PANE_LABEL: StringKey = StringKey::new("shell.left_pane"); ··· 239 238 (FEATURE_PLANE_XY, "Front Plane"), 240 239 (FEATURE_PLANE_YZ, "Right Plane"), 241 240 (FEATURE_PLANE_ZX, "Top Plane"), 242 - (FEATURE_SKETCH_DEFAULT, "Sketch"), 243 241 (PROPERTY_PANE_LABEL, "Property Manager"), 244 242 (LEFT_PANE_LABEL, "Left Pane"), 245 243 (LEFT_PANE_TAB_CONFIGURATION, "Configuration Manager"), ··· 249 247 (DOC_TAB_MODEL, "Model"), 250 248 (STATUS_BAR_LABEL, "Status Bar"), 251 249 (STATUS_READY, "Ready"), 252 - (STATUS_SKETCH_ACTIVE, "Editing Sketch"), 250 + (STATUS_SKETCH_ACTIVE, "Editing"), 253 251 (STATUS_UNITS_MM, "MMGS"), 254 252 (MENU_BAR_LABEL, "Menu Bar"), 255 253 (MENU_FILE, "File"), ··· 397 395 (FEATURE_PLANE_XY, "[!! Front Plàne !!]"), 398 396 (FEATURE_PLANE_YZ, "[!! Rîght Plàne !!]"), 399 397 (FEATURE_PLANE_ZX, "[!! Tôp Plàne !!]"), 400 - (FEATURE_SKETCH_DEFAULT, "[!! Skêtch !!]"), 401 398 (PROPERTY_PANE_LABEL, "[!! Propérty Mânager !!]"), 402 399 (LEFT_PANE_LABEL, "[!! Léft Pâne !!]"), 403 400 (LEFT_PANE_TAB_CONFIGURATION, "[!! Cônfig Mânager !!]"), ··· 407 404 (DOC_TAB_MODEL, "[!! Môdel !!]"), 408 405 (STATUS_BAR_LABEL, "[!! Statûs Bar !!]"), 409 406 (STATUS_READY, "[!! Réady !!]"), 410 - (STATUS_SKETCH_ACTIVE, "[!! Edîting Skêtch !!]"), 407 + (STATUS_SKETCH_ACTIVE, "[!! Edîting !!]"), 411 408 (STATUS_UNITS_MM, "[!! MMGS !!]"), 412 409 (MENU_BAR_LABEL, "[!! Mênu Bâr !!]"), 413 410 (MENU_FILE, "[!! Fîle !!]"),
+94
crates/bone-document/src/document/mod.rs
··· 70 70 self.order.retain(|other| *other != id); 71 71 self.entries.remove(&id) 72 72 } 73 + 74 + pub(crate) fn label_mut(&mut self, id: SketchId) -> Option<&mut String> { 75 + self.entries.get_mut(&id).map(|entry| &mut entry.label) 76 + } 77 + } 78 + 79 + #[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)] 80 + pub enum RenameSketchError { 81 + #[error("sketch {0:?} not found")] 82 + UnknownSketch(SketchId), 83 + #[error("sketch label must contain non-whitespace characters")] 84 + EmptyLabel, 73 85 } 74 86 75 87 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] ··· 211 223 self.sketches.get(&id) 212 224 } 213 225 226 + #[must_use] 227 + pub fn sketch_label(&self, id: SketchId) -> Option<&str> { 228 + self.header.sketches.entry(id).map(|e| e.label.as_str()) 229 + } 230 + 231 + pub fn rename_sketch(&mut self, id: SketchId, label: &str) -> Result<(), RenameSketchError> { 232 + let trimmed = label.trim(); 233 + if trimmed.is_empty() { 234 + return Err(RenameSketchError::EmptyLabel); 235 + } 236 + let slot = self 237 + .header 238 + .sketches 239 + .label_mut(id) 240 + .ok_or(RenameSketchError::UnknownSketch(id))?; 241 + trimmed.clone_into(slot); 242 + Ok(()) 243 + } 244 + 214 245 pub fn sketches(&self) -> impl Iterator<Item = (SketchId, &Sketch)> + '_ { 215 246 self.header 216 247 .sketches ··· 269 300 use slotmap::Key; 270 301 format!("{:016x}.ron", id.data().as_ffi()) 271 302 } 303 + 304 + #[cfg(test)] 305 + mod tests { 306 + use super::{Document, RenameSketchError, Sketch, SketchId}; 307 + use bone_types::{DocumentId, Point3, SketchPlaneBasis, Tolerance, UnitVec3}; 308 + 309 + fn xy_basis() -> SketchPlaneBasis { 310 + let Ok(basis) = SketchPlaneBasis::new( 311 + Point3::origin(), 312 + UnitVec3::x_axis(), 313 + UnitVec3::y_axis(), 314 + Tolerance::new(1e-9), 315 + ) else { 316 + panic!("xy plane basis is orthogonal"); 317 + }; 318 + basis 319 + } 320 + 321 + fn doc_with_sketch() -> (Document, SketchId) { 322 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 323 + let id = SketchId::default(); 324 + document.insert_sketch(id, "Sketch1".to_owned(), Sketch::new(xy_basis())); 325 + (document, id) 326 + } 327 + 328 + #[test] 329 + fn rename_sketch_updates_label() { 330 + let (mut document, id) = doc_with_sketch(); 331 + let Ok(()) = document.rename_sketch(id, "Profile") else { 332 + panic!("rename should accept non-empty label"); 333 + }; 334 + assert_eq!(document.sketch_label(id), Some("Profile")); 335 + } 336 + 337 + #[test] 338 + fn rename_sketch_trims_whitespace() { 339 + let (mut document, id) = doc_with_sketch(); 340 + let Ok(()) = document.rename_sketch(id, " Base \n") else { 341 + panic!("rename should trim and accept"); 342 + }; 343 + assert_eq!(document.sketch_label(id), Some("Base")); 344 + } 345 + 346 + #[test] 347 + fn rename_sketch_rejects_empty_label() { 348 + let (mut document, id) = doc_with_sketch(); 349 + let Err(err) = document.rename_sketch(id, " ") else { 350 + panic!("whitespace-only label must be rejected"); 351 + }; 352 + assert_eq!(err, RenameSketchError::EmptyLabel); 353 + assert_eq!(document.sketch_label(id), Some("Sketch1")); 354 + } 355 + 356 + #[test] 357 + fn rename_sketch_rejects_unknown_id() { 358 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 359 + let stranger = SketchId::default(); 360 + let Err(err) = document.rename_sketch(stranger, "Profile") else { 361 + panic!("unknown sketch must be rejected"); 362 + }; 363 + assert_eq!(err, RenameSketchError::UnknownSketch(stranger)); 364 + } 365 + }
+2 -1
crates/bone-document/src/lib.rs
··· 6 6 7 7 pub use document::{ 8 8 Document, DocumentHeader, DocumentParameters, FeatureNode, FeatureTree, PrincipalPlane, 9 - SketchFile, SketchRegistry, SketchRegistryEntry, UnitsPreference, sketch_filename, 9 + RenameSketchError, SketchFile, SketchRegistry, SketchRegistryEntry, UnitsPreference, 10 + sketch_filename, 10 11 }; 11 12 pub use evaluator::{EvaluatedSketch, FeatureCache, evaluate_sketch}; 12 13 pub use io::{
+11
crates/bone-types/src/lib.rs
··· 45 45 pub struct SketchParameterId; 46 46 } 47 47 48 + impl SketchId { 49 + /// Encodes this id as an opaque `u64`, stable for the same slotmap slot+version 50 + /// within a single process. Use only for deterministic widget-key derivation; the 51 + /// representation is not portable across builds or persisted artifacts. 52 + #[must_use] 53 + pub fn as_u64(self) -> u64 { 54 + use slotmap::Key; 55 + self.data().as_ffi() 56 + } 57 + } 58 + 48 59 #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 49 60 pub struct Tolerance(f64); 50 61
+2
crates/bone-ui/src/input/key.rs
··· 66 66 End, 67 67 PageUp, 68 68 PageDown, 69 + F2, 69 70 } 70 71 71 72 impl NamedKey { ··· 86 87 Self::End => "End", 87 88 Self::PageUp => "PageUp", 88 89 Self::PageDown => "PageDown", 90 + Self::F2 => "F2", 89 91 } 90 92 } 91 93 }
+192 -18
crates/bone-ui/src/widgets/tree_view.rs
··· 114 114 pub mode: TreeSelectionMode, 115 115 pub row_height: LayoutPx, 116 116 pub indent_step: LayoutPx, 117 + pub renamable: &'a [WidgetId], 117 118 } 118 119 119 120 impl<'a, 'state> TreeView<'a, 'state> { ··· 134 135 mode: TreeSelectionMode::Single, 135 136 row_height: LayoutPx::new(22.0), 136 137 indent_step: LayoutPx::new(16.0), 138 + renamable: &[], 137 139 } 138 140 } 139 141 140 142 #[must_use] 141 143 pub const fn mode(self, mode: TreeSelectionMode) -> Self { 142 144 Self { mode, ..self } 145 + } 146 + 147 + #[must_use] 148 + pub const fn renamable(self, renamable: &'a [WidgetId]) -> Self { 149 + Self { renamable, ..self } 143 150 } 144 151 } 145 152 ··· 170 177 mode, 171 178 row_height, 172 179 indent_step, 180 + renamable, 173 181 } = view; 174 182 ctx.a11y 175 183 .push(id, rect, AccessNode::new(Role::Tree).with_label(label)); ··· 204 212 indent_step, 205 213 state, 206 214 mode, 215 + renamable, 207 216 }, 208 217 &mut activated, 209 218 &mut double_activated, ··· 235 244 None 236 245 }; 237 246 if state.renaming.is_none() { 238 - handle_keyboard(ctx, &visible, state, &mut activated); 247 + handle_keyboard(ctx, &visible, state, renamable, &mut activated); 239 248 } 240 249 TreeViewResponse { 241 250 activated, ··· 300 309 indent_step: LayoutPx, 301 310 state: &'a mut TreeViewState, 302 311 mode: TreeSelectionMode, 312 + renamable: &'a [WidgetId], 303 313 } 304 314 305 315 fn draw_row( ··· 315 325 indent_step, 316 326 state, 317 327 mode, 328 + renamable, 318 329 } = args; 319 330 #[allow(clippy::cast_precision_loss, reason = "tree depth fits f32 mantissa")] 320 331 let indent = LayoutPx::new(row.depth as f32 * indent_step.value()); ··· 341 352 let live_focused = ctx.is_focused(row.id); 342 353 apply_row_interaction( 343 354 ctx, 344 - row, 345 - row_rect, 346 - &interaction, 347 - state, 348 - mode, 349 - RowOutcomes { 350 - activated, 351 - double_activated, 352 - drop_committed, 355 + RowInteractionArgs { 356 + row, 357 + row_rect, 358 + interaction: &interaction, 359 + state, 360 + mode, 361 + renamable, 362 + outcomes: RowOutcomes { 363 + activated, 364 + double_activated, 365 + drop_committed, 366 + }, 353 367 }, 354 368 ); 355 369 let mut paint = vec![WidgetPaint::Surface { ··· 403 417 drop_committed: &'a mut Option<(WidgetId, DropTarget)>, 404 418 } 405 419 406 - fn apply_row_interaction( 407 - ctx: &mut FrameCtx<'_>, 408 - row: &VisibleRow, 420 + struct RowInteractionArgs<'a> { 421 + row: &'a VisibleRow, 409 422 row_rect: LayoutRect, 410 - interaction: &crate::hit_test::Interaction, 411 - state: &mut TreeViewState, 423 + interaction: &'a crate::hit_test::Interaction, 424 + state: &'a mut TreeViewState, 412 425 mode: TreeSelectionMode, 413 - outcomes: RowOutcomes<'_>, 414 - ) { 426 + renamable: &'a [WidgetId], 427 + outcomes: RowOutcomes<'a>, 428 + } 429 + 430 + fn apply_row_interaction(ctx: &mut FrameCtx<'_>, args: RowInteractionArgs<'_>) { 431 + let RowInteractionArgs { 432 + row, 433 + row_rect, 434 + interaction, 435 + state, 436 + mode, 437 + renamable, 438 + outcomes, 439 + } = args; 415 440 let RowOutcomes { 416 441 activated, 417 442 double_activated, ··· 421 446 return; 422 447 } 423 448 if interaction.click() { 449 + let was_selected = state.selection.contains(&row.id); 450 + let is_double = interaction.double_click(); 424 451 update_selection(state, row.id, ctx.input.modifiers, mode); 425 452 if activated.is_none() { 426 453 *activated = Some(row.id); 427 454 } 428 455 state.focused = Some(row.id); 429 456 ctx.focus.request_focus(row.id); 457 + if !is_double 458 + && was_selected 459 + && state.renaming.is_none() 460 + && renamable.contains(&row.id) 461 + { 462 + let text = match &row.label { 463 + LabelText::Key(k) => ctx.strings.resolve(*k).to_owned(), 464 + LabelText::Owned(s) => s.clone(), 465 + }; 466 + state.renaming = Some(row.id); 467 + state.rename_buffer = TextInputState::from_text(&text); 468 + } 430 469 } 431 470 if interaction.double_click() && double_activated.is_none() { 432 471 *double_activated = Some(row.id); ··· 703 742 ctx: &mut FrameCtx<'_>, 704 743 visible: &[VisibleRow], 705 744 state: &mut TreeViewState, 745 + renamable: &[WidgetId], 706 746 activated: &mut Option<WidgetId>, 707 747 ) { 708 748 let in_tree_focus = ctx ··· 724 764 TakeKey::named(NamedKey::End), 725 765 TakeKey::named(NamedKey::Enter), 726 766 TakeKey::named(NamedKey::Space), 767 + TakeKey::named(NamedKey::F2), 727 768 ], 728 769 ); 729 770 let Some(event) = event else { return }; ··· 781 822 } 782 823 } 783 824 } 825 + KeyCode::Named(NamedKey::F2) => { 826 + if let Some(idx) = current { 827 + let row = &visible[idx]; 828 + if !row.disabled && renamable.contains(&row.id) { 829 + let text = match &row.label { 830 + LabelText::Key(k) => ctx.strings.resolve(*k).to_owned(), 831 + LabelText::Owned(s) => s.clone(), 832 + }; 833 + state.renaming = Some(row.id); 834 + state.rename_buffer = TextInputState::from_text(&text); 835 + } 836 + } 837 + } 784 838 _ => {} 785 839 } 786 840 } ··· 829 883 snap: &mut InputSnapshot, 830 884 prev: &HitState, 831 885 ) -> (super::TreeViewResponse, HitState) { 886 + render_with(roots, state, focus, snap, prev, &[]) 887 + } 888 + 889 + fn render_with( 890 + roots: &[TreeNode], 891 + state: &mut TreeViewState, 892 + focus: &mut FocusManager, 893 + snap: &mut InputSnapshot, 894 + prev: &HitState, 895 + renamable: &[WidgetId], 896 + ) -> (super::TreeViewResponse, HitState) { 832 897 let theme = Arc::new(Theme::light()); 833 898 let table = HotkeyTable::new(); 834 899 let mut hits = HitFrame::new(); ··· 858 923 StringKey::new("test.tree"), 859 924 roots, 860 925 state, 861 - ), 926 + ) 927 + .renamable(renamable), 862 928 ) 863 929 }; 864 930 let next = resolve(prev, &hits, snap, focus.focused()); ··· 1135 1201 )); 1136 1202 let (response, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1137 1203 assert_eq!(response.activated, Some(roots[0].id)); 1204 + } 1205 + 1206 + #[test] 1207 + fn f2_on_renamable_focused_row_starts_rename_prefilled_with_label() { 1208 + let sketch_id = root_id("sketch"); 1209 + let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1210 + let mut state = TreeViewState::default(); 1211 + let mut focus = FocusManager::new(); 1212 + focus.register_focusable(sketch_id); 1213 + focus.request_focus(sketch_id); 1214 + focus.end_frame(); 1215 + let prev = HitState::new(); 1216 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1217 + snap.keys_pressed 1218 + .push(KeyEvent::new(KeyCode::Named(NamedKey::F2), ModifierMask::NONE)); 1219 + let (_, _) = render_with( 1220 + &roots, 1221 + &mut state, 1222 + &mut focus, 1223 + &mut snap, 1224 + &prev, 1225 + &[sketch_id], 1226 + ); 1227 + assert_eq!(state.renaming, Some(sketch_id)); 1228 + assert_eq!(state.rename_buffer.text, "Profile"); 1229 + } 1230 + 1231 + #[test] 1232 + fn slow_click_on_already_selected_renamable_row_starts_rename() { 1233 + let sketch_id = root_id("sketch"); 1234 + let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1235 + let mut state = TreeViewState { 1236 + selection: BTreeSet::from([sketch_id]), 1237 + ..TreeViewState::default() 1238 + }; 1239 + let mut focus = FocusManager::new(); 1240 + let mut prev = HitState::new(); 1241 + let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1242 + [press(click_pos), release(click_pos), idle(click_pos)] 1243 + .into_iter() 1244 + .for_each(|mut snap| { 1245 + let (_, next) = render_with( 1246 + &roots, 1247 + &mut state, 1248 + &mut focus, 1249 + &mut snap, 1250 + &prev, 1251 + &[sketch_id], 1252 + ); 1253 + prev = next; 1254 + }); 1255 + assert_eq!(state.renaming, Some(sketch_id)); 1256 + assert_eq!(state.rename_buffer.text, "Profile"); 1257 + } 1258 + 1259 + #[test] 1260 + fn double_click_on_renamable_row_does_not_start_rename() { 1261 + let sketch_id = root_id("sketch"); 1262 + let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1263 + let mut state = TreeViewState::default(); 1264 + let mut focus = FocusManager::new(); 1265 + let mut prev = HitState::new(); 1266 + let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1267 + let frames = [ 1268 + press(click_pos), 1269 + release(click_pos), 1270 + idle(click_pos), 1271 + press(click_pos), 1272 + release(click_pos), 1273 + idle(click_pos), 1274 + ]; 1275 + let mut last: Option<super::TreeViewResponse> = None; 1276 + frames.into_iter().for_each(|mut snap| { 1277 + let (response, next) = render_with( 1278 + &roots, 1279 + &mut state, 1280 + &mut focus, 1281 + &mut snap, 1282 + &prev, 1283 + &[sketch_id], 1284 + ); 1285 + last = Some(response); 1286 + prev = next; 1287 + }); 1288 + assert!( 1289 + state.renaming.is_none(), 1290 + "fast double-click must enter via double_activated, not rename", 1291 + ); 1292 + let Some(response) = last else { 1293 + panic!("response captured"); 1294 + }; 1295 + assert_eq!(response.double_activated, Some(sketch_id)); 1296 + } 1297 + 1298 + #[test] 1299 + fn f2_on_non_renamable_row_is_ignored() { 1300 + let roots = sample_tree(); 1301 + let mut state = TreeViewState::default(); 1302 + let mut focus = FocusManager::new(); 1303 + focus.register_focusable(roots[1].id); 1304 + focus.request_focus(roots[1].id); 1305 + focus.end_frame(); 1306 + let prev = HitState::new(); 1307 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1308 + snap.keys_pressed 1309 + .push(KeyEvent::new(KeyCode::Named(NamedKey::F2), ModifierMask::NONE)); 1310 + let (_, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1311 + assert!(state.renaming.is_none()); 1138 1312 } 1139 1313 }