diff --git a/src/input/state/actions/action_board_pages.rs b/src/input/state/actions/action_board_pages.rs index 78c6ba72..b7b3dc34 100644 --- a/src/input/state/actions/action_board_pages.rs +++ b/src/input/state/actions/action_board_pages.rs @@ -6,7 +6,7 @@ use log::info; use super::super::{InputState, UiToastKind}; impl InputState { - pub(super) fn handle_board_pages_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_board_pages_action(&mut self, action: Action) -> bool { match action { Action::ToggleWhiteboard => { if self.boards.has_board(BOARD_ID_WHITEBOARD) { diff --git a/src/input/state/actions/action_capture_zoom.rs b/src/input/state/actions/action_capture_zoom.rs index 06aae06c..4860b84f 100644 --- a/src/input/state/actions/action_capture_zoom.rs +++ b/src/input/state/actions/action_capture_zoom.rs @@ -4,7 +4,7 @@ use crate::input::{OutputFocusAction, ZoomAction}; use super::super::InputState; impl InputState { - pub(super) fn handle_capture_zoom_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_capture_zoom_action(&mut self, action: Action) -> bool { match action { Action::CaptureFullScreen | Action::CaptureActiveWindow diff --git a/src/input/state/actions/action_colors.rs b/src/input/state/actions/action_colors.rs index 87e23b59..0340f3c5 100644 --- a/src/input/state/actions/action_colors.rs +++ b/src/input/state/actions/action_colors.rs @@ -6,7 +6,7 @@ use crate::util; use super::super::InputState; impl InputState { - pub(super) fn handle_color_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_color_action(&mut self, action: Action) -> bool { let Some(color) = action_color(action) else { return false; }; diff --git a/src/input/state/actions/action_core.rs b/src/input/state/actions/action_core.rs index cab7e3e0..50c58cda 100644 --- a/src/input/state/actions/action_core.rs +++ b/src/input/state/actions/action_core.rs @@ -3,7 +3,7 @@ use crate::config::Action; use log::info; impl InputState { - pub(super) fn handle_core_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_core_action(&mut self, action: Action) -> bool { match action { Action::Exit => { if self.try_cancel_active_interaction() { diff --git a/src/input/state/actions/action_dispatch.rs b/src/input/state/actions/action_dispatch.rs index 4d40f4ec..5ef285a7 100644 --- a/src/input/state/actions/action_dispatch.rs +++ b/src/input/state/actions/action_dispatch.rs @@ -1,51 +1,10 @@ use crate::config::Action; -use log::warn; -use super::super::{InputState, UiToastKind}; +use super::super::{InputState, interaction}; impl InputState { /// Handle an action triggered by a keybinding. pub(crate) fn handle_action(&mut self, action: Action) { - if !matches!( - action, - Action::OpenContextMenu | Action::ToggleSelectionProperties - ) { - self.close_properties_panel(); - } - - if matches!(action, Action::PickScreenColorDeprecated) { - warn!("Deprecated action pick_screen_color triggered; ignoring."); - self.set_ui_toast( - UiToastKind::Warning, - "Pick screen color was removed. Use the palette or paste a hex value.", - ); - return; - } - - if self.handle_core_action(action) { - return; - } - if self.handle_history_action(action) { - return; - } - if self.handle_selection_action(action) { - return; - } - if self.handle_tool_action(action) { - return; - } - if self.handle_board_pages_action(action) { - return; - } - if self.handle_ui_action(action) { - return; - } - if self.handle_color_action(action) { - return; - } - if self.handle_capture_zoom_action(action) { - return; - } - self.handle_preset_action(action); + let _ = interaction::route_action(self, action); } } diff --git a/src/input/state/actions/action_history.rs b/src/input/state/actions/action_history.rs index 0e752b98..da79c4d4 100644 --- a/src/input/state/actions/action_history.rs +++ b/src/input/state/actions/action_history.rs @@ -3,7 +3,7 @@ use crate::config::Action; use super::super::InputState; impl InputState { - pub(super) fn handle_history_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_history_action(&mut self, action: Action) -> bool { match action { Action::Undo => { if let Some(action) = self.boards.active_frame_mut().undo_last() { diff --git a/src/input/state/actions/action_presets.rs b/src/input/state/actions/action_presets.rs index 63fce2a9..94b2e97a 100644 --- a/src/input/state/actions/action_presets.rs +++ b/src/input/state/actions/action_presets.rs @@ -3,7 +3,7 @@ use crate::config::Action; use super::super::InputState; impl InputState { - pub(super) fn handle_preset_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_preset_action(&mut self, action: Action) -> bool { match action { Action::ApplyPreset1 => { let _ = self.apply_preset(1); diff --git a/src/input/state/actions/action_selection.rs b/src/input/state/actions/action_selection.rs index 7b54a55b..a8d5f22c 100644 --- a/src/input/state/actions/action_selection.rs +++ b/src/input/state/actions/action_selection.rs @@ -7,7 +7,7 @@ const KEYBOARD_NUDGE_SMALL: i32 = 8; const KEYBOARD_NUDGE_LARGE: i32 = 32; impl InputState { - pub(super) fn handle_selection_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_selection_action(&mut self, action: Action) -> bool { match action { Action::CopySelection => { let copied = self.copy_selection(); diff --git a/src/input/state/actions/action_tools.rs b/src/input/state/actions/action_tools.rs index 04b34b4b..a875f4aa 100644 --- a/src/input/state/actions/action_tools.rs +++ b/src/input/state/actions/action_tools.rs @@ -5,7 +5,7 @@ use log::info; use super::super::InputState; impl InputState { - pub(super) fn handle_tool_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_tool_action(&mut self, action: Action) -> bool { match action { Action::IncreaseThickness => { self.nudge_thickness_for_active_tool(1.0); diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs index 5c1f56f0..4407d00c 100644 --- a/src/input/state/actions/action_ui.rs +++ b/src/input/state/actions/action_ui.rs @@ -4,7 +4,7 @@ use log::info; use super::super::{DrawingState, InputState, UiToastKind}; impl InputState { - pub(super) fn handle_ui_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_ui_action(&mut self, action: Action) -> bool { match action { Action::ToggleHelp => { self.toggle_help_overlay(); diff --git a/src/input/state/actions/help_overlay.rs b/src/input/state/actions/help_overlay.rs index f66b4260..9bfa68d6 100644 --- a/src/input/state/actions/help_overlay.rs +++ b/src/input/state/actions/help_overlay.rs @@ -3,7 +3,7 @@ use crate::input::events::Key; use super::super::InputState; impl InputState { - pub(super) fn handle_help_overlay_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_help_overlay_key(&mut self, key: Key) -> bool { if !self.show_help { return false; } diff --git a/src/input/state/actions/key_press/bindings.rs b/src/input/state/actions/key_press/bindings.rs index 30d9e8a9..4cfca772 100644 --- a/src/input/state/actions/key_press/bindings.rs +++ b/src/input/state/actions/key_press/bindings.rs @@ -1,6 +1,6 @@ use crate::input::events::Key; -pub(super) fn key_to_action_label(key: Key) -> Option { +pub(in crate::input::state) fn key_to_action_label(key: Key) -> Option { match key { Key::Char(c) => Some(c.to_string()), Key::Escape => Some("Escape".to_string()), @@ -28,7 +28,7 @@ pub(super) fn key_to_action_label(key: Key) -> Option { } } -pub(super) fn fallback_unshifted_label(key: &str) -> Option<&'static str> { +pub(in crate::input::state) fn fallback_unshifted_label(key: &str) -> Option<&'static str> { match key { "!" => Some("1"), "@" => Some("2"), diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs index 5a819eae..deac17fd 100644 --- a/src/input/state/actions/key_press/mod.rs +++ b/src/input/state/actions/key_press/mod.rs @@ -1,15 +1,13 @@ -mod bindings; +pub(in crate::input::state) mod bindings; mod panels; mod text_input; -use crate::config::Action; use crate::input::events::Key; -use super::super::{DrawingState, InputState}; -use bindings::{fallback_unshifted_label, key_to_action_label}; +use super::super::{DrawingState, InputState, interaction}; impl InputState { - fn handle_modifier_key_press(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_modifier_key_press(&mut self, key: Key) -> bool { match key { Key::Shift => self.modifiers.shift = true, Key::Ctrl => self.modifiers.ctrl = true, @@ -34,130 +32,6 @@ impl InputState { /// - Help toggle (configurable) /// - Modifier key tracking pub fn on_key_press(&mut self, key: Key) { - // Tour takes highest priority when active - if self.tour_active && self.handle_tour_key(key) { - return; - } - - // Command palette takes priority - if self.command_palette_open && self.handle_command_palette_key(key) { - return; - } - - if self.show_help && self.handle_help_overlay_key(key) { - return; - } - - if self.is_radial_menu_open() { - if self.handle_modifier_key_press(key) { - return; - } - - if matches!(key, Key::Escape) { - self.close_radial_menu(); - return; - } - - if let Some(key_str) = key_to_action_label(key) { - let mapped_action = self.find_action(&key_str).or_else(|| { - if self.modifiers.shift { - fallback_unshifted_label(&key_str) - .and_then(|fallback| self.find_action(fallback)) - } else { - None - } - }); - if matches!(mapped_action, Some(Action::ToggleRadialMenu)) { - self.close_radial_menu(); - } - } - return; - } - - if self.is_color_picker_popup_open() && self.handle_color_picker_popup_key(key) { - return; - } - - if self.is_context_menu_open() && self.handle_context_menu_key(key) { - return; - } - - if self.is_board_picker_open() && self.handle_board_picker_key(key) { - return; - } - - // Handle modifier keys first - if self.handle_modifier_key_press(key) { - return; - } - - if self.is_properties_panel_open() { - let handled = self.handle_properties_panel_key(key); - if handled { - return; - } - return; - } - - // Escape cancels pending board or page deletion - if matches!(key, Key::Escape) && self.has_pending_board_delete() { - self.cancel_pending_board_delete(); - return; - } - if matches!(key, Key::Escape) && self.has_pending_page_delete() { - self.cancel_pending_page_delete(); - return; - } - - if matches!(key, Key::Escape) - && matches!(self.state, DrawingState::Idle) - && self.has_selection() - { - let bounds = self.selection_bounding_box(self.selected_shape_ids()); - self.clear_selection(); - self.mark_selection_dirty_region(bounds); - self.needs_redraw = true; - return; - } - - if matches!(&self.state, DrawingState::TextInput { .. }) { - self.handle_text_input_key(key); - return; - } - - // Handle Escape in Drawing state for canceling - if matches!(key, Key::Escape) - && let DrawingState::Drawing { .. } = &self.state - && let Some(Action::Exit) = self.find_action("Escape") - { - self.try_cancel_active_interaction(); - return; - } - - // Convert key to string for action lookup - let Some(key_str) = key_to_action_label(key) else { - return; - }; - - // Look up action based on keybinding - if let Some(action) = self.find_action(&key_str) { - self.handle_action(action); - return; - } - if self.modifiers.shift - && let Some(fallback) = fallback_unshifted_label(&key_str) - && let Some(action) = self.find_action(fallback) - { - self.handle_action(action); - return; - } - - if matches!(key, Key::Return) - && !self.modifiers.ctrl - && !self.modifiers.shift - && !self.modifiers.alt - && matches!(self.state, DrawingState::Idle) - && self.edit_selected_text() - {} + let _ = interaction::route_key_press(self, key); } } diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 1a796935..17cfbfed 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -5,7 +5,7 @@ use crate::input::state::InputState; const PROPERTIES_PANEL_COARSE_STEP: i32 = 5; impl InputState { - pub(super) fn handle_color_picker_popup_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_color_picker_popup_key(&mut self, key: Key) -> bool { if !self.is_color_picker_popup_open() { return false; } @@ -49,7 +49,7 @@ impl InputState { } } - pub(super) fn handle_board_picker_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_board_picker_key(&mut self, key: Key) -> bool { if !self.is_board_picker_open() { return false; } @@ -314,7 +314,7 @@ impl InputState { } } - pub(super) fn handle_properties_panel_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_properties_panel_key(&mut self, key: Key) -> bool { let adjust_step = if self.modifiers.shift { PROPERTIES_PANEL_COARSE_STEP } else { @@ -338,7 +338,7 @@ impl InputState { } } - pub(super) fn handle_context_menu_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_context_menu_key(&mut self, key: Key) -> bool { match key { Key::Escape => { self.close_context_menu(); diff --git a/src/input/state/actions/key_press/text_input.rs b/src/input/state/actions/key_press/text_input.rs index f182fa45..e664643d 100644 --- a/src/input/state/actions/key_press/text_input.rs +++ b/src/input/state/actions/key_press/text_input.rs @@ -9,7 +9,7 @@ use super::bindings::{fallback_unshifted_label, key_to_action_label}; const MAX_TEXT_LENGTH: usize = 10_000; impl InputState { - pub(super) fn handle_text_input_key(&mut self, key: Key) { + pub(in crate::input::state) fn handle_text_input_key(&mut self, key: Key) { let should_check_actions = match key { // Special keys always check for actions Key::Escape diff --git a/src/input/state/actions/mod.rs b/src/input/state/actions/mod.rs index b1ca7c5b..4094907f 100644 --- a/src/input/state/actions/mod.rs +++ b/src/input/state/actions/mod.rs @@ -9,5 +9,5 @@ mod action_selection; mod action_tools; mod action_ui; mod help_overlay; -mod key_press; +pub(in crate::input::state) mod key_press; mod key_release; diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs new file mode 100644 index 00000000..4e204fac --- /dev/null +++ b/src/input/state/interaction/actions.rs @@ -0,0 +1,154 @@ +use super::adapters; +use super::outcome::{ActionRoute, RoutingOutcome}; +use crate::config::Action; +use crate::input::state::InputState; + +pub(crate) fn classify_action(action: Action) -> ActionRoute { + match action { + Action::Exit + | Action::EnterTextMode + | Action::EnterStickyNoteMode + | Action::ClearCanvas => ActionRoute::Core, + Action::Undo + | Action::Redo + | Action::UndoAll + | Action::RedoAll + | Action::UndoAllDelayed + | Action::RedoAllDelayed => ActionRoute::History, + Action::DuplicateSelection + | Action::CopySelection + | Action::PasteSelection + | Action::SelectAll + | Action::MoveSelectionToFront + | Action::MoveSelectionToBack + | Action::NudgeSelectionUp + | Action::NudgeSelectionDown + | Action::NudgeSelectionLeft + | Action::NudgeSelectionRight + | Action::NudgeSelectionUpLarge + | Action::NudgeSelectionDownLarge + | Action::MoveSelectionToStart + | Action::MoveSelectionToEnd + | Action::MoveSelectionToTop + | Action::MoveSelectionToBottom + | Action::DeleteSelection => ActionRoute::Selection, + Action::IncreaseThickness + | Action::DecreaseThickness + | Action::IncreaseMarkerOpacity + | Action::DecreaseMarkerOpacity + | Action::SelectSelectionTool + | Action::SelectMarkerTool + | Action::SelectStepMarkerTool + | Action::SelectEraserTool + | Action::ToggleEraserMode + | Action::SelectPenTool + | Action::SelectLineTool + | Action::SelectRectTool + | Action::SelectEllipseTool + | Action::SelectArrowTool + | Action::SelectBlurTool + | Action::SelectHighlightTool + | Action::IncreaseFontSize + | Action::DecreaseFontSize + | Action::ResetArrowLabelCounter + | Action::ResetStepMarkerCounter + | Action::ToggleHighlightTool + | Action::ToggleFill => ActionRoute::Tool, + Action::ToggleWhiteboard + | Action::ToggleBlackboard + | Action::ReturnToTransparent + | Action::Board1 + | Action::Board2 + | Action::Board3 + | Action::Board4 + | Action::Board5 + | Action::Board6 + | Action::Board7 + | Action::Board8 + | Action::Board9 + | Action::BoardNext + | Action::BoardPrev + | Action::BoardNew + | Action::BoardDelete + | Action::BoardPicker + | Action::BoardRestoreDeleted + | Action::BoardDuplicate + | Action::BoardSwitchRecent + | Action::PagePrev + | Action::PageNext + | Action::PageNew + | Action::PageDuplicate + | Action::PageDelete + | Action::PageRestoreDeleted => ActionRoute::BoardPages, + Action::ToggleHelp + | Action::ToggleQuickHelp + | Action::ToggleStatusBar + | Action::ToggleClickHighlight + | Action::ToggleToolbar + | Action::TogglePresenterMode + | Action::ToggleLightMode + | Action::ToggleLightModeDrawing + | Action::ToggleRadialMenu + | Action::ToggleSelectionProperties + | Action::OpenContextMenu + | Action::OpenConfigurator + | Action::OpenCaptureFolder + | Action::ToggleCommandPalette + | Action::ReplayTour => ActionRoute::Ui, + Action::SetColorRed + | Action::SetColorGreen + | Action::SetColorBlue + | Action::SetColorYellow + | Action::SetColorOrange + | Action::SetColorPink + | Action::SetColorWhite + | Action::SetColorBlack => ActionRoute::Color, + Action::PickScreenColorDeprecated => ActionRoute::DeprecatedIgnored, + Action::CaptureFullScreen + | Action::CaptureActiveWindow + | Action::CaptureSelection + | Action::CaptureClipboardFull + | Action::CaptureFileFull + | Action::CaptureClipboardSelection + | Action::CaptureFileSelection + | Action::CaptureClipboardRegion + | Action::CaptureFileRegion + | Action::ToggleFrozenMode + | Action::ZoomIn + | Action::ZoomOut + | Action::ResetZoom + | Action::ToggleZoomLock + | Action::RefreshZoomCapture + | Action::FocusNextOutput + | Action::FocusPrevOutput + | Action::SavePendingToFile => ActionRoute::CaptureZoom, + Action::ApplyPreset1 + | Action::ApplyPreset2 + | Action::ApplyPreset3 + | Action::ApplyPreset4 + | Action::ApplyPreset5 + | Action::SavePreset1 + | Action::SavePreset2 + | Action::SavePreset3 + | Action::SavePreset4 + | Action::SavePreset5 + | Action::ClearPreset1 + | Action::ClearPreset2 + | Action::ClearPreset3 + | Action::ClearPreset4 + | Action::ClearPreset5 => ActionRoute::Preset, + } +} + +pub(crate) fn route_action(state: &mut InputState, action: Action) -> RoutingOutcome { + if !matches!( + action, + Action::OpenContextMenu | Action::ToggleSelectionProperties + ) { + adapters::close_properties_panel_before_action(state); + } + + let route = classify_action(action); + adapters::dispatch_action(state, action, route); + RoutingOutcome::DispatchedAction(route) +} diff --git a/src/input/state/interaction/active.rs b/src/input/state/interaction/active.rs new file mode 100644 index 00000000..40023647 --- /dev/null +++ b/src/input/state/interaction/active.rs @@ -0,0 +1,15 @@ +use super::outcome::ActiveInteractionKind; +use crate::input::state::{DrawingState, InputState}; + +pub(crate) fn active_interaction_kind(state: &InputState) -> Option { + match state.state { + DrawingState::Idle => None, + DrawingState::Drawing { .. } => Some(ActiveInteractionKind::Drawing), + DrawingState::TextInput { .. } => Some(ActiveInteractionKind::TextInput), + DrawingState::PendingTextClick { .. } => Some(ActiveInteractionKind::PendingTextClick), + DrawingState::MovingSelection { .. } => Some(ActiveInteractionKind::MovingSelection), + DrawingState::Selecting { .. } => Some(ActiveInteractionKind::BoxSelecting), + DrawingState::ResizingText { .. } => Some(ActiveInteractionKind::ResizingText), + DrawingState::ResizingSelection { .. } => Some(ActiveInteractionKind::ResizingSelection), + } +} diff --git a/src/input/state/interaction/adapters/actions.rs b/src/input/state/interaction/adapters/actions.rs new file mode 100644 index 00000000..43766ed3 --- /dev/null +++ b/src/input/state/interaction/adapters/actions.rs @@ -0,0 +1,47 @@ +use super::super::outcome::ActionRoute; +use crate::config::Action; +use crate::input::state::{InputState, UiToastKind}; +use log::warn; + +pub(crate) fn close_properties_panel_before_action(state: &mut InputState) { + state.close_properties_panel(); +} + +pub(crate) fn dispatch_action(state: &mut InputState, action: Action, route: ActionRoute) { + match route { + ActionRoute::Core => { + state.handle_core_action(action); + } + ActionRoute::History => { + state.handle_history_action(action); + } + ActionRoute::Selection => { + state.handle_selection_action(action); + } + ActionRoute::Tool => { + state.handle_tool_action(action); + } + ActionRoute::BoardPages => { + state.handle_board_pages_action(action); + } + ActionRoute::Ui => { + state.handle_ui_action(action); + } + ActionRoute::Color => { + state.handle_color_action(action); + } + ActionRoute::CaptureZoom => { + state.handle_capture_zoom_action(action); + } + ActionRoute::Preset => { + state.handle_preset_action(action); + } + ActionRoute::DeprecatedIgnored => { + warn!("Deprecated action pick_screen_color triggered; ignoring."); + state.set_ui_toast( + UiToastKind::Warning, + "Pick screen color was removed. Use the palette or paste a hex value.", + ); + } + } +} diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs new file mode 100644 index 00000000..d1ae1bbb --- /dev/null +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -0,0 +1,177 @@ +use super::super::event::PointerPoints; +use super::super::outcome::{ + ActiveInteractionKind, InteractionSideEffect, NoRouteReason, PointerSideEffect, RoutingOutcome, +}; +use crate::input::state::mouse::TEXT_CLICK_DRAG_THRESHOLD; +use crate::input::state::{DrawingState, InputState}; +use crate::input::{EraserMode, MouseButton, Tool}; +use std::sync::Arc; + +pub(crate) fn handle_active_motion( + state: &mut InputState, + points: PointerPoints, +) -> Option { + let canvas = points.canvas(); + if let DrawingState::ResizingText { + shape_id, + base_x, + size, + .. + } = &state.state + { + let new_width = state.clamp_text_wrap_width(*base_x, canvas.x(), *size); + let _ = state.update_text_wrap_width(*shape_id, new_width); + return Some(RoutingOutcome::Continued( + ActiveInteractionKind::ResizingText, + )); + } + + if let DrawingState::PendingTextClick { + x: start_x, + y: start_y, + tool, + .. + } = &state.state + { + let dx = canvas.x() - *start_x; + let dy = canvas.y() - *start_y; + if dx.abs() >= TEXT_CLICK_DRAG_THRESHOLD || dy.abs() >= TEXT_CLICK_DRAG_THRESHOLD { + let tool = *tool; + if tool != Tool::Highlight && tool != Tool::Select { + let drawing_thickness = state.thickness_for_tool(tool); + let mut points = vec![(*start_x, *start_y)]; + let mut point_thicknesses = vec![drawing_thickness as f32]; + if tool == Tool::Pen || tool == Tool::Marker || tool == Tool::Eraser { + points.push((canvas.x(), canvas.y())); + point_thicknesses.push(drawing_thickness as f32); + } + state.state = DrawingState::Drawing { + tool, + start_x: *start_x, + start_y: *start_y, + points, + point_thicknesses, + }; + state.last_text_click = None; + state.last_provisional_bounds = None; + state.update_provisional_dirty(canvas.x(), canvas.y()); + state.needs_redraw = true; + } + } + return Some(RoutingOutcome::Continued( + ActiveInteractionKind::PendingTextClick, + )); + } + + if let DrawingState::MovingSelection { last_x, last_y, .. } = &state.state { + let dx = canvas.x() - *last_x; + let dy = canvas.y() - *last_y; + if (dx != 0 || dy != 0) + && state.apply_translation_to_selection(dx, dy) + && let DrawingState::MovingSelection { + last_x, + last_y, + moved, + .. + } = &mut state.state + { + *last_x = canvas.x(); + *last_y = canvas.y(); + *moved = true; + } + return Some(RoutingOutcome::Continued( + ActiveInteractionKind::MovingSelection, + )); + } + + if let DrawingState::ResizingSelection { + handle, + original_bounds, + start_x, + start_y, + snapshots, + } = &state.state + { + let dx = canvas.x() - *start_x; + let dy = canvas.y() - *start_y; + let handle = *handle; + let original_bounds = *original_bounds; + let snapshots = Arc::clone(snapshots); + state.apply_selection_resize(handle, &original_bounds, dx, dy, snapshots.as_ref()); + state.needs_redraw = true; + return Some(RoutingOutcome::Continued( + ActiveInteractionKind::ResizingSelection, + )); + } + + if matches!(state.state, DrawingState::Selecting { .. }) { + state.update_provisional_dirty(canvas.x(), canvas.y()); + state.needs_redraw = true; + return Some(RoutingOutcome::Continued( + ActiveInteractionKind::BoxSelecting, + )); + } + + None +} + +pub(crate) fn handle_drawing_or_idle_motion( + state: &mut InputState, + points: PointerPoints, +) -> RoutingOutcome { + let canvas = points.canvas(); + let mut drawing = false; + if let DrawingState::Drawing { + tool, + points, + point_thicknesses, + .. + } = &mut state.state + { + if *tool == Tool::Pen || *tool == Tool::Marker || *tool == Tool::Eraser { + points.push((canvas.x(), canvas.y())); + let thickness = match *tool { + Tool::Eraser => state.eraser_size, + _ => state.tool_settings.get(*tool).thickness, + }; + point_thicknesses.push(thickness as f32); + } + drawing = true; + } + + if drawing { + state.update_provisional_dirty(canvas.x(), canvas.y()); + state.needs_redraw = true; + RoutingOutcome::Continued(ActiveInteractionKind::Drawing) + } else if state.eraser_mode == EraserMode::Stroke + && state.active_tool() == Tool::Eraser + && matches!(state.state, DrawingState::Idle) + { + state.needs_redraw = true; + RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( + PointerSideEffect::IdleEraserHover, + )) + } else { + RoutingOutcome::NoRoute(NoRouteReason::NoActiveInteraction) + } +} + +pub(crate) fn releasable_active_kind(state: &InputState) -> Option { + match state.state { + DrawingState::Drawing { .. } => Some(ActiveInteractionKind::Drawing), + DrawingState::MovingSelection { .. } => Some(ActiveInteractionKind::MovingSelection), + DrawingState::Selecting { .. } => Some(ActiveInteractionKind::BoxSelecting), + DrawingState::PendingTextClick { .. } => Some(ActiveInteractionKind::PendingTextClick), + DrawingState::ResizingText { .. } => Some(ActiveInteractionKind::ResizingText), + DrawingState::ResizingSelection { .. } => Some(ActiveInteractionKind::ResizingSelection), + DrawingState::Idle | DrawingState::TextInput { .. } => None, + } +} + +pub(crate) fn has_active_drag(state: &InputState) -> bool { + state.active_drag_button.is_some() +} + +pub(crate) fn release_button_matches_active_drag(state: &InputState, button: MouseButton) -> bool { + state.pointer_drag_button_matches(button) +} diff --git a/src/input/state/interaction/adapters/keyboard.rs b/src/input/state/interaction/adapters/keyboard.rs new file mode 100644 index 00000000..168495f7 --- /dev/null +++ b/src/input/state/interaction/adapters/keyboard.rs @@ -0,0 +1,207 @@ +use super::super::active::active_interaction_kind; +use super::super::outcome::{ + ActiveInteractionKind, CancelTarget, ConsumedBy, InteractionSideEffect, KeyboardSideEffect, + NoRouteReason, RoutingOutcome, +}; +use crate::config::Action; +use crate::input::events::Key; +use crate::input::state::actions::key_press::bindings::{ + fallback_unshifted_label, key_to_action_label, +}; +use crate::input::state::{DrawingState, InputState}; + +pub(crate) fn handle_tour_key(state: &mut InputState, key: Key) -> Option { + (state.tour_active && state.handle_tour_key(key)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::Tour)) +} + +pub(crate) fn handle_command_palette_key( + state: &mut InputState, + key: Key, +) -> Option { + (state.command_palette_open && state.handle_command_palette_key(key)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::CommandPalette)) +} + +pub(crate) fn handle_help_overlay_key(state: &mut InputState, key: Key) -> Option { + (state.show_help && state.handle_help_overlay_key(key)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::HelpOverlay)) +} + +pub(crate) fn handle_radial_menu_key(state: &mut InputState, key: Key) -> Option { + if !state.is_radial_menu_open() { + return None; + } + + if state.handle_modifier_key_press(key) { + return Some(modifier_key_side_effect()); + } + + if matches!(key, Key::Escape) { + state.close_radial_menu(); + return Some(RoutingOutcome::Consumed(ConsumedBy::RadialMenu)); + } + + if let Some(key_str) = key_to_action_label(key) { + let mapped_action = state.find_action(&key_str).or_else(|| { + if state.modifiers.shift { + fallback_unshifted_label(&key_str).and_then(|fallback| state.find_action(fallback)) + } else { + None + } + }); + if matches!(mapped_action, Some(Action::ToggleRadialMenu)) { + state.close_radial_menu(); + } + } + + Some(RoutingOutcome::Consumed(ConsumedBy::RadialMenu)) +} + +pub(crate) fn handle_color_picker_key(state: &mut InputState, key: Key) -> Option { + (state.is_color_picker_popup_open() && state.handle_color_picker_popup_key(key)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::ColorPickerPopup)) +} + +pub(crate) fn handle_context_menu_key(state: &mut InputState, key: Key) -> Option { + (state.is_context_menu_open() && state.handle_context_menu_key(key)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::ContextMenu)) +} + +pub(crate) fn handle_board_picker_key(state: &mut InputState, key: Key) -> Option { + (state.is_board_picker_open() && state.handle_board_picker_key(key)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::BoardPicker)) +} + +pub(crate) fn handle_global_modifier_key( + state: &mut InputState, + key: Key, +) -> Option { + state + .handle_modifier_key_press(key) + .then_some(modifier_key_side_effect()) +} + +pub(crate) fn handle_properties_panel_key( + state: &mut InputState, + key: Key, +) -> Option { + if !state.is_properties_panel_open() { + return None; + } + + let _ = state.handle_properties_panel_key(key); + Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) +} + +pub(crate) fn handle_pending_delete_cancel_key( + state: &mut InputState, + key: Key, +) -> Option { + if matches!(key, Key::Escape) && state.has_pending_board_delete() { + state.cancel_pending_board_delete(); + return Some(RoutingOutcome::Canceled(CancelTarget::PendingBoardDelete)); + } + if matches!(key, Key::Escape) && state.has_pending_page_delete() { + state.cancel_pending_page_delete(); + return Some(RoutingOutcome::Canceled(CancelTarget::PendingPageDelete)); + } + + None +} + +pub(crate) fn handle_idle_selection_cancel_key( + state: &mut InputState, + key: Key, +) -> Option { + if matches!(key, Key::Escape) + && matches!(state.state, DrawingState::Idle) + && state.has_selection() + { + let bounds = state.selection_bounding_box(state.selected_shape_ids()); + state.clear_selection(); + state.mark_selection_dirty_region(bounds); + state.needs_redraw = true; + return Some(RoutingOutcome::Canceled(CancelTarget::Selection)); + } + + None +} + +pub(crate) fn handle_text_input_key(state: &mut InputState, key: Key) -> Option { + if matches!(&state.state, DrawingState::TextInput { .. }) { + state.handle_text_input_key(key); + return Some(RoutingOutcome::Consumed(ConsumedBy::TextInput)); + } + + None +} + +pub(crate) fn handle_drawing_escape_cancel_key( + state: &mut InputState, + key: Key, +) -> Option { + if matches!(key, Key::Escape) + && let Some(ActiveInteractionKind::Drawing) = active_interaction_kind(state) + && let Some(Action::Exit) = state.find_action("Escape") + { + state.try_cancel_active_interaction(); + return Some(RoutingOutcome::Canceled(CancelTarget::ActiveInteraction( + ActiveInteractionKind::Drawing, + ))); + } + + None +} + +pub(crate) fn action_for_key_binding( + state: &InputState, + key: Key, +) -> Result, NoRouteReason> { + let Some(key_str) = key_to_action_label(key) else { + return Err(NoRouteReason::UnsupportedKey); + }; + + if let Some(action) = state.find_action(&key_str) { + return Ok(Some(action)); + } + if state.modifiers.shift + && let Some(fallback) = fallback_unshifted_label(&key_str) + && let Some(action) = state.find_action(fallback) + { + return Ok(Some(action)); + } + + Ok(None) +} + +pub(crate) fn handle_return_edit_selected_text_key( + state: &mut InputState, + key: Key, +) -> Option { + if matches!(key, Key::Return) + && !state.modifiers.ctrl + && !state.modifiers.shift + && !state.modifiers.alt + && matches!(state.state, DrawingState::Idle) + { + if state.edit_selected_text() { + return Some(RoutingOutcome::Started(ActiveInteractionKind::TextInput)); + } + return Some(return_edit_miss_side_effect()); + } + + None +} + +fn modifier_key_side_effect() -> RoutingOutcome { + RoutingOutcome::SideEffect(InteractionSideEffect::Keyboard( + KeyboardSideEffect::ModifierUpdated, + )) +} + +fn return_edit_miss_side_effect() -> RoutingOutcome { + RoutingOutcome::SideEffect(InteractionSideEffect::Keyboard( + KeyboardSideEffect::ReturnEditSelectedTextMiss, + )) +} diff --git a/src/input/state/interaction/adapters/mod.rs b/src/input/state/interaction/adapters/mod.rs new file mode 100644 index 00000000..76cba032 --- /dev/null +++ b/src/input/state/interaction/adapters/mod.rs @@ -0,0 +1,26 @@ +mod actions; +mod active_motion; +mod keyboard; +mod pointer; + +pub(crate) use actions::{close_properties_panel_before_action, dispatch_action}; +pub(crate) use active_motion::{ + handle_active_motion, handle_drawing_or_idle_motion, has_active_drag, releasable_active_kind, + release_button_matches_active_drag, +}; +pub(crate) use keyboard::{ + action_for_key_binding, handle_board_picker_key, handle_color_picker_key, + handle_command_palette_key, handle_context_menu_key, handle_drawing_escape_cancel_key, + handle_global_modifier_key, handle_help_overlay_key, handle_idle_selection_cancel_key, + handle_pending_delete_cancel_key, handle_properties_panel_key, handle_radial_menu_key, + handle_return_edit_selected_text_key, handle_text_input_key, handle_tour_key, +}; +pub(crate) use pointer::{ + close_properties_panel_before_tool_routing, finish_pointer_interaction, + handle_board_picker_motion, handle_board_picker_press, handle_color_picker_motion, + handle_color_picker_press, handle_context_menu_motion, handle_left_context_menu_press, + handle_middle_press, handle_properties_panel_motion, handle_properties_panel_press, + handle_radial_menu_motion, handle_radial_menu_press, handle_radial_menu_release, + handle_release_overlays, handle_right_press, handle_tool_button_press, + handle_unbound_left_press, update_pointer_positions, +}; diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs new file mode 100644 index 00000000..cd182264 --- /dev/null +++ b/src/input/state/interaction/adapters/pointer.rs @@ -0,0 +1,340 @@ +use super::super::active::active_interaction_kind; +use super::super::event::PointerPoints; +use super::super::outcome::{ + ActiveInteractionKind, CancelTarget, ConsumedBy, InteractionSideEffect, NoRouteReason, + PointerSideEffect, RoutingOutcome, +}; +use crate::draw::Shape; +use crate::input::MouseButton; +use crate::input::state::core::MenuCommand; +use crate::input::state::{ContextMenuKind, DrawingState, InputState}; + +pub(crate) fn update_pointer_positions(state: &mut InputState, points: PointerPoints) { + let screen = points.screen(); + let canvas = points.canvas(); + state.update_pointer_positions(screen.x(), screen.y(), canvas.x(), canvas.y()); +} + +pub(crate) fn handle_radial_menu_press( + state: &mut InputState, + button: MouseButton, + points: PointerPoints, +) -> Option { + let screen = points.screen(); + let canvas = points.canvas(); + state + .handle_radial_menu_press(button, screen.x(), screen.y(), canvas.x(), canvas.y()) + .then_some(RoutingOutcome::Consumed(ConsumedBy::RadialMenu)) +} + +pub(crate) fn handle_color_picker_press( + state: &mut InputState, + button: MouseButton, + points: PointerPoints, +) -> Option { + let screen = points.screen(); + state + .handle_color_picker_press(button, screen.x(), screen.y()) + .then_some(RoutingOutcome::Consumed(ConsumedBy::ColorPickerPopup)) +} + +pub(crate) fn handle_board_picker_press( + state: &mut InputState, + button: MouseButton, + points: PointerPoints, +) -> Option { + let screen = points.screen(); + state + .handle_board_picker_press(button, screen.x(), screen.y()) + .then_some(RoutingOutcome::Consumed(ConsumedBy::BoardPicker)) +} + +pub(crate) fn handle_properties_panel_press( + state: &mut InputState, + button: MouseButton, + points: PointerPoints, +) -> Option { + let screen = points.screen(); + state + .handle_properties_panel_press(button, screen.x(), screen.y()) + .then_some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) +} + +pub(crate) fn handle_left_context_menu_press( + state: &mut InputState, + points: PointerPoints, +) -> Option { + if !state.is_context_menu_open() { + return None; + } + let screen = points.screen(); + let canvas = points.canvas(); + state.update_pointer_positions(screen.x(), screen.y(), canvas.x(), canvas.y()); + state.trigger_click_highlight(canvas.x(), canvas.y()); + state.handle_context_menu_press(screen.x(), screen.y()); + Some(RoutingOutcome::Consumed(ConsumedBy::ContextMenu)) +} + +pub(crate) fn close_properties_panel_before_tool_routing(state: &mut InputState) { + state.close_properties_panel(); +} + +pub(crate) fn handle_tool_button_press( + state: &mut InputState, + button: MouseButton, + points: PointerPoints, +) -> Option { + let binding = state.drag_binding_for_button(button); + let tool = state.tool_for_button_press(button, binding.tool)?; + let before = active_interaction_kind(state); + let screen = points.screen(); + let canvas = points.canvas(); + state.handle_tool_button_press_at( + button, + tool, + binding.color, + (screen.x(), screen.y()), + (canvas.x(), canvas.y()), + ); + let after = active_interaction_kind(state); + match (before, after) { + (None, Some(kind)) => Some(RoutingOutcome::Started(kind)), + (Some(ActiveInteractionKind::TextInput), Some(ActiveInteractionKind::TextInput)) => { + Some(RoutingOutcome::Consumed(ConsumedBy::TextInput)) + } + _ => Some(RoutingOutcome::Consumed(ConsumedBy::ToolButton)), + } +} + +pub(crate) fn handle_unbound_left_press( + state: &mut InputState, + points: PointerPoints, +) -> RoutingOutcome { + let screen = points.screen(); + let canvas = points.canvas(); + state.update_pointer_positions(screen.x(), screen.y(), canvas.x(), canvas.y()); + state.trigger_click_highlight(canvas.x(), canvas.y()); + + if state.handle_context_menu_press(screen.x(), screen.y()) { + return RoutingOutcome::Consumed(ConsumedBy::ContextMenu); + } + + match &mut state.state { + DrawingState::Idle => RoutingOutcome::NoRoute(NoRouteReason::NoPointerBinding), + DrawingState::TextInput { x, y, .. } => { + *x = canvas.x(); + *y = canvas.y(); + state.update_text_preview_dirty(); + state.needs_redraw = true; + RoutingOutcome::Consumed(ConsumedBy::TextInput) + } + DrawingState::Drawing { .. } + | DrawingState::MovingSelection { .. } + | DrawingState::Selecting { .. } + | DrawingState::PendingTextClick { .. } + | DrawingState::ResizingText { .. } + | DrawingState::ResizingSelection { .. } => { + RoutingOutcome::NoRoute(NoRouteReason::NoPointerBinding) + } + } +} + +pub(crate) fn handle_right_press(state: &mut InputState, points: PointerPoints) -> RoutingOutcome { + let screen = points.screen(); + let canvas = points.canvas(); + if state.should_toggle_radial_menu_from_mouse(MouseButton::Right) { + state.toggle_radial_menu(screen.x() as f64, screen.y() as f64); + return RoutingOutcome::Consumed(ConsumedBy::RadialMenuToggle); + } + + state.update_pointer_positions(screen.x(), screen.y(), canvas.x(), canvas.y()); + state.last_text_click = None; + if let Some(kind) = active_interaction_kind(state) + && state.try_cancel_active_interaction() + { + return RoutingOutcome::Canceled(CancelTarget::ActiveInteraction(kind)); + } + if state.zoom_active() { + return RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( + PointerSideEffect::RightClickSuppressedByZoom, + )); + } + if !state.context_menu_enabled() { + return RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( + PointerSideEffect::RightClickContextMenuDisabled, + )); + } + + open_context_menu_from_right_click(state, screen.x(), screen.y(), canvas.x(), canvas.y()); + RoutingOutcome::Consumed(ConsumedBy::RightClickContextMenu) +} + +pub(crate) fn handle_middle_press(state: &mut InputState, points: PointerPoints) -> RoutingOutcome { + let screen = points.screen(); + if state.should_toggle_radial_menu_from_mouse(MouseButton::Middle) { + state.toggle_radial_menu(screen.x() as f64, screen.y() as f64); + RoutingOutcome::Consumed(ConsumedBy::RadialMenuToggle) + } else { + RoutingOutcome::NoRoute(NoRouteReason::NoPointerBinding) + } +} + +fn open_context_menu_from_right_click( + state: &mut InputState, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, +) { + let hit_shape = state.hit_test_at(canvas_x, canvas_y); + let mut focus_edit = false; + if let Some(id) = hit_shape { + if state.modifiers.shift { + state.extend_selection([id]); + } else if !state.selected_shape_ids().contains(&id) { + state.set_selection(vec![id]); + } + let selection = state.selected_shape_ids().to_vec(); + focus_edit = selection.len() == 1 + && state + .boards + .active_frame() + .shape(selection[0]) + .map(|shape| matches!(shape.shape, Shape::Text { .. } | Shape::StickyNote { .. })) + .unwrap_or(false); + state.open_context_menu( + (screen_x, screen_y), + selection, + ContextMenuKind::Shape, + hit_shape, + ); + } else { + state.clear_selection(); + state.open_context_menu( + (screen_x, screen_y), + Vec::new(), + ContextMenuKind::Canvas, + None, + ); + } + + state.update_context_menu_hover_from_pointer(screen_x, screen_y); + if focus_edit { + state.focus_context_menu_command(MenuCommand::EditText); + } + if state.is_context_menu_open() { + state.pending_onboarding_usage.used_context_menu_right_click = true; + } + state.needs_redraw = true; +} + +pub(crate) fn handle_radial_menu_motion( + state: &mut InputState, + points: PointerPoints, +) -> Option { + if !state.is_radial_menu_open() { + return None; + } + let screen = points.screen(); + state.update_radial_menu_hover(screen.x() as f64, screen.y() as f64); + Some(RoutingOutcome::Consumed(ConsumedBy::RadialMenu)) +} + +pub(crate) fn handle_color_picker_motion( + state: &mut InputState, + points: PointerPoints, +) -> Option { + if !state.is_color_picker_popup_open() { + return None; + } + if state.color_picker_popup_is_dragging() + && let Some(layout) = state.color_picker_popup_layout() + { + let screen = points.screen(); + let fx = screen.x() as f64; + let fy = screen.y() as f64; + let norm_x = ((fx - layout.gradient_x) / layout.gradient_w).clamp(0.0, 1.0); + let norm_y = ((fy - layout.gradient_y) / layout.gradient_h).clamp(0.0, 1.0); + state.color_picker_popup_set_from_gradient(norm_x, norm_y); + } + Some(RoutingOutcome::Consumed(ConsumedBy::ColorPickerPopup)) +} + +pub(crate) fn handle_board_picker_motion( + state: &mut InputState, + points: PointerPoints, +) -> Option { + if !state.is_board_picker_open() { + return None; + } + let screen = points.screen(); + if state.board_picker_is_page_dragging() { + state.board_picker_update_page_drag_from_pointer(screen.x(), screen.y()); + } else if state.board_picker_is_dragging() { + state.board_picker_update_drag_from_pointer(screen.x(), screen.y()); + } else { + state.update_board_picker_hover_from_pointer(screen.x(), screen.y()); + } + Some(RoutingOutcome::Consumed(ConsumedBy::BoardPicker)) +} + +pub(crate) fn handle_properties_panel_motion( + state: &mut InputState, + points: PointerPoints, +) -> Option { + if !state.is_properties_panel_open() { + return None; + } + if state.properties_panel_layout().is_some() { + let screen = points.screen(); + state.update_properties_panel_hover_from_pointer(screen.x(), screen.y()); + } + Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) +} + +pub(crate) fn handle_context_menu_motion( + state: &mut InputState, + points: PointerPoints, +) -> Option { + if !state.is_context_menu_open() { + return None; + } + let screen = points.screen(); + state.update_context_menu_hover_from_pointer(screen.x(), screen.y()); + Some(RoutingOutcome::Consumed(ConsumedBy::ContextMenu)) +} + +pub(crate) fn handle_release_overlays( + state: &mut InputState, + button: MouseButton, + points: PointerPoints, +) -> Option { + if button != MouseButton::Left { + return None; + } + let screen = points.screen(); + if state.handle_color_picker_popup_release_at(screen.x(), screen.y()) { + return Some(RoutingOutcome::Consumed(ConsumedBy::ColorPickerPopup)); + } + if state.handle_context_menu_release_at(screen.x(), screen.y()) { + return Some(RoutingOutcome::Consumed(ConsumedBy::ContextMenu)); + } + if state.handle_board_picker_release_at(screen.x(), screen.y()) { + return Some(RoutingOutcome::Consumed(ConsumedBy::BoardPicker)); + } + if state.handle_properties_panel_release_at(screen.x(), screen.y()) { + return Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)); + } + None +} + +pub(crate) fn handle_radial_menu_release(state: &InputState) -> Option { + state + .is_radial_menu_open() + .then_some(RoutingOutcome::Consumed(ConsumedBy::RadialMenu)) +} + +pub(crate) fn finish_pointer_interaction(state: &mut InputState, points: PointerPoints) { + let canvas = points.canvas(); + state.finish_pointer_interaction_at(canvas.x(), canvas.y()); +} diff --git a/src/input/state/interaction/event.rs b/src/input/state/interaction/event.rs new file mode 100644 index 00000000..c9715125 --- /dev/null +++ b/src/input/state/interaction/event.rs @@ -0,0 +1,116 @@ +use crate::input::MouseButton; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ScreenPoint { + x: i32, + y: i32, +} + +impl ScreenPoint { + pub(crate) fn new(x: i32, y: i32) -> Self { + Self { x, y } + } + + pub(crate) fn x(self) -> i32 { + self.x + } + + pub(crate) fn y(self) -> i32 { + self.y + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CanvasPoint { + x: i32, + y: i32, +} + +impl CanvasPoint { + pub(crate) fn new(x: i32, y: i32) -> Self { + Self { x, y } + } + + pub(crate) fn x(self) -> i32 { + self.x + } + + pub(crate) fn y(self) -> i32 { + self.y + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PointerPoints { + screen: ScreenPoint, + canvas: CanvasPoint, +} + +impl PointerPoints { + pub(crate) fn new(screen: ScreenPoint, canvas: CanvasPoint) -> Self { + Self { screen, canvas } + } + + pub(crate) fn screen(self) -> ScreenPoint { + self.screen + } + + pub(crate) fn canvas(self) -> CanvasPoint { + self.canvas + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PointerPress { + button: MouseButton, + points: PointerPoints, +} + +impl PointerPress { + pub(crate) fn new(button: MouseButton, points: PointerPoints) -> Self { + Self { button, points } + } + + pub(crate) fn button(self) -> MouseButton { + self.button + } + + pub(crate) fn points(self) -> PointerPoints { + self.points + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PointerMotion { + points: PointerPoints, +} + +impl PointerMotion { + pub(crate) fn new(points: PointerPoints) -> Self { + Self { points } + } + + pub(crate) fn points(self) -> PointerPoints { + self.points + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PointerRelease { + button: MouseButton, + points: PointerPoints, +} + +impl PointerRelease { + pub(crate) fn new(button: MouseButton, points: PointerPoints) -> Self { + Self { button, points } + } + + pub(crate) fn button(self) -> MouseButton { + self.button + } + + pub(crate) fn points(self) -> PointerPoints { + self.points + } +} diff --git a/src/input/state/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs new file mode 100644 index 00000000..b73dfeb2 --- /dev/null +++ b/src/input/state/interaction/keyboard.rs @@ -0,0 +1,62 @@ +use super::actions::route_action; +use super::adapters; +use super::outcome::{NoRouteReason, RoutingOutcome}; +use crate::input::events::Key; +use crate::input::state::InputState; + +pub(crate) fn route_key_press(state: &mut InputState, key: Key) -> RoutingOutcome { + if let Some(outcome) = adapters::handle_tour_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_command_palette_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_help_overlay_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_radial_menu_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_color_picker_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_context_menu_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_board_picker_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_global_modifier_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_properties_panel_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_pending_delete_cancel_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_idle_selection_cancel_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_text_input_key(state, key) { + return outcome; + } + if let Some(outcome) = adapters::handle_drawing_escape_cancel_key(state, key) { + return outcome; + } + + match adapters::action_for_key_binding(state, key) { + Ok(Some(action)) => return route_action(state, action), + Ok(None) => {} + Err(NoRouteReason::UnsupportedKey) => { + return RoutingOutcome::NoRoute(NoRouteReason::UnsupportedKey); + } + Err(reason) => return RoutingOutcome::NoRoute(reason), + } + + if let Some(outcome) = adapters::handle_return_edit_selected_text_key(state, key) { + return outcome; + } + + RoutingOutcome::NoRoute(NoRouteReason::NoKeyBinding) +} diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs new file mode 100644 index 00000000..ddc2e34b --- /dev/null +++ b/src/input/state/interaction/mod.rs @@ -0,0 +1,223 @@ +mod actions; +mod active; +mod adapters; +mod event; +mod keyboard; +mod outcome; +mod pointer; + +pub(crate) use actions::route_action; +pub(crate) use event::{ + CanvasPoint, PointerMotion, PointerPoints, PointerPress, PointerRelease, ScreenPoint, +}; +pub(crate) use keyboard::route_key_press; +pub(crate) use pointer::{route_pointer_motion, route_pointer_press, route_pointer_release}; + +#[cfg(test)] +mod tests { + use super::actions::classify_action; + use super::active::active_interaction_kind; + use super::outcome::{ + ActionRoute, ActiveInteractionKind, CancelTarget, ConsumedBy, InteractionSideEffect, + KeyboardSideEffect, PointerSideEffect, RoutingOutcome, + }; + use super::*; + use crate::config::Action; + use crate::draw::Shape; + use crate::input::state::test_support::make_test_input_state; + use crate::input::{BOARD_ID_BLACKBOARD, EraserMode, Key, MouseButton, Tool}; + + fn points() -> PointerPoints { + PointerPoints::new(ScreenPoint::new(10, 20), CanvasPoint::new(10, 20)) + } + + fn add_rect(state: &mut crate::input::state::InputState) -> crate::draw::ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 20, + w: 30, + h: 40, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }) + } + + #[test] + fn active_interaction_kind_is_only_non_idle() { + let mut state = make_test_input_state(); + + assert_eq!(active_interaction_kind(&state), None); + + state.state = crate::input::state::DrawingState::Drawing { + tool: Tool::Pen, + start_x: 1, + start_y: 2, + points: vec![(1, 2)], + point_thicknesses: vec![1.0], + }; + assert_eq!( + active_interaction_kind(&state), + Some(ActiveInteractionKind::Drawing) + ); + + state.state = crate::input::state::DrawingState::TextInput { + x: 1, + y: 2, + buffer: String::new(), + }; + assert_eq!( + active_interaction_kind(&state), + Some(ActiveInteractionKind::TextInput) + ); + } + + #[test] + fn modifier_key_press_returns_named_keyboard_side_effect() { + let mut state = make_test_input_state(); + + assert_eq!( + route_key_press(&mut state, Key::Shift), + RoutingOutcome::SideEffect(InteractionSideEffect::Keyboard( + KeyboardSideEffect::ModifierUpdated + )) + ); + assert!(state.modifiers.shift); + } + + #[test] + fn properties_panel_unhandled_key_is_consumed() { + let mut state = make_test_input_state(); + let id = add_rect(&mut state); + state.set_selection(vec![id]); + assert!(state.show_properties_panel()); + + assert_eq!( + route_key_press(&mut state, Key::Char('x')), + RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel) + ); + assert!(state.is_properties_panel_open()); + } + + #[test] + fn escape_cancels_pending_board_delete_with_named_outcome() { + let mut state = make_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + state.delete_active_board(); + assert!(state.has_pending_board_delete()); + + assert_eq!( + route_key_press(&mut state, Key::Escape), + RoutingOutcome::Canceled(CancelTarget::PendingBoardDelete) + ); + assert!(!state.has_pending_board_delete()); + } + + #[test] + fn return_without_editable_selection_returns_named_miss_side_effect() { + let mut state = make_test_input_state(); + + assert_eq!( + route_key_press(&mut state, Key::Return), + RoutingOutcome::SideEffect(InteractionSideEffect::Keyboard( + KeyboardSideEffect::ReturnEditSelectedTextMiss + )) + ); + } + + #[test] + fn right_click_cancels_active_interaction_before_context_menu_policy() { + let mut state = make_test_input_state(); + state.state = crate::input::state::DrawingState::Drawing { + tool: Tool::Pen, + start_x: 1, + start_y: 2, + points: vec![(1, 2)], + point_thicknesses: vec![1.0], + }; + state.begin_pointer_drag(MouseButton::Left, None); + + assert_eq!( + route_pointer_press(&mut state, PointerPress::new(MouseButton::Right, points())), + RoutingOutcome::Canceled(CancelTarget::ActiveInteraction( + ActiveInteractionKind::Drawing + )) + ); + assert!(matches!( + state.state, + crate::input::state::DrawingState::Idle + )); + assert!(state.active_drag_button.is_none()); + } + + #[test] + fn right_click_suppression_paths_return_named_side_effects() { + let mut zoomed = make_test_input_state(); + zoomed.set_zoom_status(true, false, 2.0, (0.0, 0.0)); + assert_eq!( + route_pointer_press(&mut zoomed, PointerPress::new(MouseButton::Right, points())), + RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( + PointerSideEffect::RightClickSuppressedByZoom + )) + ); + + let mut disabled = make_test_input_state(); + disabled.set_context_menu_enabled(false); + assert_eq!( + route_pointer_press( + &mut disabled, + PointerPress::new(MouseButton::Right, points()) + ), + RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( + PointerSideEffect::RightClickContextMenuDisabled + )) + ); + } + + #[test] + fn radial_menu_release_is_consumed() { + let mut state = make_test_input_state(); + state.toggle_radial_menu(10.0, 20.0); + + assert_eq!( + route_pointer_release(&mut state, PointerRelease::new(MouseButton::Left, points())), + RoutingOutcome::Consumed(ConsumedBy::RadialMenu) + ); + assert!(state.is_radial_menu_open()); + } + + #[test] + fn idle_eraser_hover_returns_named_pointer_side_effect() { + let mut state = make_test_input_state(); + state.eraser_mode = EraserMode::Stroke; + assert!(state.set_tool_override(Some(Tool::Eraser))); + + assert_eq!( + route_pointer_motion(&mut state, PointerMotion::new(points())), + RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( + PointerSideEffect::IdleEraserHover + )) + ); + assert!(state.needs_redraw); + } + + #[test] + fn action_classification_has_no_unknown_bucket() { + assert_eq!(classify_action(Action::Exit), ActionRoute::Core); + assert_eq!(classify_action(Action::Undo), ActionRoute::History); + assert_eq!( + classify_action(Action::DeleteSelection), + ActionRoute::Selection + ); + assert_eq!(classify_action(Action::SelectPenTool), ActionRoute::Tool); + assert_eq!(classify_action(Action::BoardNext), ActionRoute::BoardPages); + assert_eq!(classify_action(Action::ToggleHelp), ActionRoute::Ui); + assert_eq!(classify_action(Action::SetColorRed), ActionRoute::Color); + assert_eq!(classify_action(Action::ZoomIn), ActionRoute::CaptureZoom); + assert_eq!(classify_action(Action::ApplyPreset1), ActionRoute::Preset); + assert_eq!( + classify_action(Action::PickScreenColorDeprecated), + ActionRoute::DeprecatedIgnored + ); + } +} diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs new file mode 100644 index 00000000..c9765bd2 --- /dev/null +++ b/src/input/state/interaction/outcome.rs @@ -0,0 +1,89 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RoutingOutcome { + Consumed(ConsumedBy), + Started(ActiveInteractionKind), + Continued(ActiveInteractionKind), + Finished(ActiveInteractionKind), + Canceled(CancelTarget), + SideEffect(InteractionSideEffect), + DispatchedAction(ActionRoute), + NoRoute(NoRouteReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConsumedBy { + Tour, + CommandPalette, + HelpOverlay, + RadialMenu, + ColorPickerPopup, + ContextMenu, + BoardPicker, + PropertiesPanel, + TextInput, + ToolButton, + RightClickContextMenu, + RadialMenuToggle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ActiveInteractionKind { + Drawing, + TextInput, + PendingTextClick, + MovingSelection, + BoxSelecting, + ResizingText, + ResizingSelection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CancelTarget { + ActiveInteraction(ActiveInteractionKind), + PendingBoardDelete, + PendingPageDelete, + Selection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InteractionSideEffect { + Pointer(PointerSideEffect), + Keyboard(KeyboardSideEffect), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PointerSideEffect { + IdleEraserHover, + RightClickSuppressedByZoom, + RightClickContextMenuDisabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeyboardSideEffect { + ModifierUpdated, + ReturnEditSelectedTextMiss, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NoRouteReason { + NoPointerBinding, + NoActiveInteraction, + NonLeftReleaseWithoutActiveDrag, + ReleaseButtonMismatch, + UnsupportedKey, + NoKeyBinding, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ActionRoute { + Core, + History, + Selection, + Tool, + BoardPages, + Ui, + Color, + CaptureZoom, + Preset, + DeprecatedIgnored, +} diff --git a/src/input/state/interaction/pointer.rs b/src/input/state/interaction/pointer.rs new file mode 100644 index 00000000..69d08b67 --- /dev/null +++ b/src/input/state/interaction/pointer.rs @@ -0,0 +1,93 @@ +use super::adapters; +use super::event::{PointerMotion, PointerPress, PointerRelease}; +use super::outcome::{NoRouteReason, RoutingOutcome}; +use crate::input::MouseButton; +use crate::input::state::InputState; + +pub(crate) fn route_pointer_press(state: &mut InputState, event: PointerPress) -> RoutingOutcome { + let points = event.points(); + if let Some(outcome) = adapters::handle_radial_menu_press(state, event.button(), points) { + return outcome; + } + if let Some(outcome) = adapters::handle_color_picker_press(state, event.button(), points) { + return outcome; + } + if let Some(outcome) = adapters::handle_board_picker_press(state, event.button(), points) { + return outcome; + } + if let Some(outcome) = adapters::handle_properties_panel_press(state, event.button(), points) { + return outcome; + } + if event.button() == MouseButton::Left + && let Some(outcome) = adapters::handle_left_context_menu_press(state, points) + { + return outcome; + } + + adapters::close_properties_panel_before_tool_routing(state); + + if let Some(outcome) = adapters::handle_tool_button_press(state, event.button(), points) { + return outcome; + } + + match event.button() { + MouseButton::Right => adapters::handle_right_press(state, points), + MouseButton::Left => adapters::handle_unbound_left_press(state, points), + MouseButton::Middle => adapters::handle_middle_press(state, points), + } +} + +pub(crate) fn route_pointer_motion(state: &mut InputState, event: PointerMotion) -> RoutingOutcome { + let points = event.points(); + adapters::update_pointer_positions(state, points); + if let Some(outcome) = adapters::handle_radial_menu_motion(state, points) { + return outcome; + } + if let Some(outcome) = adapters::handle_color_picker_motion(state, points) { + return outcome; + } + if let Some(outcome) = adapters::handle_board_picker_motion(state, points) { + return outcome; + } + if let Some(outcome) = adapters::handle_properties_panel_motion(state, points) { + return outcome; + } + if let Some(outcome) = adapters::handle_active_motion(state, points) { + return outcome; + } + if let Some(outcome) = adapters::handle_context_menu_motion(state, points) { + return outcome; + } + adapters::handle_drawing_or_idle_motion(state, points) +} + +pub(crate) fn route_pointer_release( + state: &mut InputState, + event: PointerRelease, +) -> RoutingOutcome { + let points = event.points(); + adapters::update_pointer_positions(state, points); + + if let Some(outcome) = adapters::handle_radial_menu_release(state) { + return outcome; + } + + if let Some(outcome) = adapters::handle_release_overlays(state, event.button(), points) { + return outcome; + } + + let Some(kind) = adapters::releasable_active_kind(state) else { + return if event.button() != MouseButton::Left && !adapters::has_active_drag(state) { + RoutingOutcome::NoRoute(NoRouteReason::NonLeftReleaseWithoutActiveDrag) + } else { + RoutingOutcome::NoRoute(NoRouteReason::NoActiveInteraction) + }; + }; + + if !adapters::release_button_matches_active_drag(state, event.button()) { + return RoutingOutcome::NoRoute(NoRouteReason::ReleaseButtonMismatch); + } + + adapters::finish_pointer_interaction(state, points); + RoutingOutcome::Finished(kind) +} diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 61109e8b..cc004d42 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -1,6 +1,7 @@ mod actions; mod core; mod highlight; +pub(crate) mod interaction; mod mouse; mod render; #[cfg(test)] diff --git a/src/input/state/mouse/mod.rs b/src/input/state/mouse/mod.rs index f8acba3b..a9f5b90f 100644 --- a/src/input/state/mouse/mod.rs +++ b/src/input/state/mouse/mod.rs @@ -2,7 +2,7 @@ mod motion; mod press; mod release; -const TEXT_CLICK_DRAG_THRESHOLD: i32 = 4; +pub(in crate::input::state) const TEXT_CLICK_DRAG_THRESHOLD: i32 = 4; const TEXT_DOUBLE_CLICK_MS: u64 = 400; const TEXT_DOUBLE_CLICK_DISTANCE: i32 = 6; const BOARD_PICKER_DOUBLE_CLICK_MS: u64 = 400; diff --git a/src/input/state/mouse/motion.rs b/src/input/state/mouse/motion.rs index d0348dcd..f0955ffc 100644 --- a/src/input/state/mouse/motion.rs +++ b/src/input/state/mouse/motion.rs @@ -1,8 +1,7 @@ -use crate::input::{EraserMode, Tool}; - -use super::super::{DrawingState, InputState}; -use super::TEXT_CLICK_DRAG_THRESHOLD; -use std::sync::Arc; +use super::super::{ + InputState, + interaction::{CanvasPoint, PointerMotion, PointerPoints, ScreenPoint, route_pointer_motion}, +}; impl InputState { /// Processes mouse motion (dragging) events. @@ -27,168 +26,11 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); - - if self.is_radial_menu_open() { - self.update_radial_menu_hover(screen_x as f64, screen_y as f64); - return; - } - - if self.is_color_picker_popup_open() { - if self.color_picker_popup_is_dragging() - && let Some(layout) = self.color_picker_popup_layout() - { - let fx = screen_x as f64; - let fy = screen_y as f64; - let norm_x = ((fx - layout.gradient_x) / layout.gradient_w).clamp(0.0, 1.0); - let norm_y = ((fy - layout.gradient_y) / layout.gradient_h).clamp(0.0, 1.0); - self.color_picker_popup_set_from_gradient(norm_x, norm_y); - } - return; - } - - if self.is_board_picker_open() { - if self.board_picker_is_page_dragging() { - self.board_picker_update_page_drag_from_pointer(screen_x, screen_y); - } else if self.board_picker_is_dragging() { - self.board_picker_update_drag_from_pointer(screen_x, screen_y); - } else { - self.update_board_picker_hover_from_pointer(screen_x, screen_y); - } - return; - } - - if self.is_properties_panel_open() { - if self.properties_panel_layout().is_none() { - return; - } - self.update_properties_panel_hover_from_pointer(screen_x, screen_y); - return; - } - - if let DrawingState::ResizingText { - shape_id, - base_x, - size, - .. - } = &self.state - { - let new_width = self.clamp_text_wrap_width(*base_x, canvas_x, *size); - let _ = self.update_text_wrap_width(*shape_id, new_width); - return; - } - - if let DrawingState::PendingTextClick { - x: start_x, - y: start_y, - tool, - .. - } = &self.state - { - let dx = canvas_x - *start_x; - let dy = canvas_y - *start_y; - if dx.abs() >= TEXT_CLICK_DRAG_THRESHOLD || dy.abs() >= TEXT_CLICK_DRAG_THRESHOLD { - let tool = *tool; - if tool != Tool::Highlight && tool != Tool::Select { - let drawing_thickness = self.thickness_for_tool(tool); - let mut points = vec![(*start_x, *start_y)]; - let mut point_thicknesses = vec![drawing_thickness as f32]; - if tool == Tool::Pen || tool == Tool::Marker || tool == Tool::Eraser { - points.push((canvas_x, canvas_y)); - point_thicknesses.push(drawing_thickness as f32); - } - self.state = DrawingState::Drawing { - tool, - start_x: *start_x, - start_y: *start_y, - points, - point_thicknesses, - }; - self.last_text_click = None; - self.last_provisional_bounds = None; - self.update_provisional_dirty(canvas_x, canvas_y); - self.needs_redraw = true; - } - } - return; - } - - if let DrawingState::MovingSelection { last_x, last_y, .. } = &self.state { - let dx = canvas_x - *last_x; - let dy = canvas_y - *last_y; - if (dx != 0 || dy != 0) - && self.apply_translation_to_selection(dx, dy) - && let DrawingState::MovingSelection { - last_x, - last_y, - moved, - .. - } = &mut self.state - { - *last_x = canvas_x; - *last_y = canvas_y; - *moved = true; - } - return; - } - - if let DrawingState::ResizingSelection { - handle, - original_bounds, - start_x, - start_y, - snapshots, - } = &self.state - { - let dx = canvas_x - *start_x; - let dy = canvas_y - *start_y; - let handle = *handle; - let original_bounds = *original_bounds; - let snapshots = Arc::clone(snapshots); - self.apply_selection_resize(handle, &original_bounds, dx, dy, snapshots.as_ref()); - self.needs_redraw = true; - return; - } - - if matches!(self.state, DrawingState::Selecting { .. }) { - self.update_provisional_dirty(canvas_x, canvas_y); - self.needs_redraw = true; - return; - } - - if self.is_context_menu_open() { - self.update_context_menu_hover_from_pointer(screen_x, screen_y); - return; - } - - let mut drawing = false; - if let DrawingState::Drawing { - tool, - points, - point_thicknesses, - .. - } = &mut self.state - { - if *tool == Tool::Pen || *tool == Tool::Marker || *tool == Tool::Eraser { - points.push((canvas_x, canvas_y)); - let thickness = match *tool { - Tool::Eraser => self.eraser_size, - _ => self.tool_settings.get(*tool).thickness, - }; - point_thicknesses.push(thickness as f32); - } - drawing = true; - } - - if drawing { - self.update_provisional_dirty(canvas_x, canvas_y); - self.needs_redraw = true; - } else if self.eraser_mode == EraserMode::Stroke - && self.active_tool() == Tool::Eraser - && matches!(self.state, DrawingState::Idle) - { - self.needs_redraw = true; - } + let points = PointerPoints::new( + ScreenPoint::new(screen_x, screen_y), + CanvasPoint::new(canvas_x, canvas_y), + ); + let _ = route_pointer_motion(self, PointerMotion::new(points)); } } diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index c735006e..c9383af8 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -4,7 +4,10 @@ use crate::input::{DragTool, Tool, events::MouseButton}; use std::sync::Arc; use super::super::core::MenuCommand; -use super::super::{ContextMenuKind, DrawingState, InputState}; +use super::super::{ + ContextMenuKind, DrawingState, InputState, + interaction::{CanvasPoint, PointerPoints, PointerPress, ScreenPoint, route_pointer_press}, +}; #[derive(Clone, Copy)] struct PressCoords { @@ -15,7 +18,10 @@ struct PressCoords { } impl InputState { - fn is_radial_menu_toggle_button(&self, button: MouseButton) -> bool { + pub(in crate::input::state) fn is_radial_menu_toggle_button( + &self, + button: MouseButton, + ) -> bool { use crate::config::RadialMenuMouseBinding; match self.radial_menu_mouse_binding { RadialMenuMouseBinding::Middle => matches!(button, MouseButton::Middle), @@ -24,13 +30,22 @@ impl InputState { } } - fn should_toggle_radial_menu_from_mouse(&self, button: MouseButton) -> bool { + pub(in crate::input::state) fn should_toggle_radial_menu_from_mouse( + &self, + button: MouseButton, + ) -> bool { !self.zoom_active() && matches!(self.state, DrawingState::Idle) && self.is_radial_menu_toggle_button(button) } - fn handle_right_click(&mut self, screen_x: i32, screen_y: i32, canvas_x: i32, canvas_y: i32) { + pub(in crate::input::state) fn handle_right_click( + &mut self, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + ) { self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); self.last_text_click = None; if self.try_cancel_active_interaction() { @@ -125,84 +140,18 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - if self.handle_radial_menu_press(button, screen_x, screen_y, canvas_x, canvas_y) { - return; - } - - if self.handle_color_picker_press(button, screen_x, screen_y) { - return; - } - - if self.handle_board_picker_press(button, screen_x, screen_y) { - return; - } - - if self.handle_properties_panel_press(button, screen_x, screen_y) { - return; - } - - if button == MouseButton::Left && self.is_context_menu_open() { - self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); - self.trigger_click_highlight(canvas_x, canvas_y); - self.handle_context_menu_press(screen_x, screen_y); - return; - } - - self.close_properties_panel(); - - let binding = self.drag_binding_for_button(button); - if let Some(tool) = self.tool_for_button_press(button, binding.tool) { - let coords = PressCoords { - screen_x, - screen_y, - canvas_x, - canvas_y, - }; - self.handle_tool_button_press(button, tool, binding.color, coords); - return; - } - - match button { - MouseButton::Right => { - if self.should_toggle_radial_menu_from_mouse(MouseButton::Right) { - self.toggle_radial_menu(screen_x as f64, screen_y as f64); - } else { - self.handle_right_click(screen_x, screen_y, canvas_x, canvas_y); - } - } - MouseButton::Left => { - self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); - self.trigger_click_highlight(canvas_x, canvas_y); - - if self.handle_context_menu_press(screen_x, screen_y) { - return; - } - - match &mut self.state { - DrawingState::Idle => {} - DrawingState::TextInput { x: tx, y: ty, .. } => { - *tx = canvas_x; - *ty = canvas_y; - self.update_text_preview_dirty(); - self.needs_redraw = true; - } - DrawingState::Drawing { .. } - | DrawingState::MovingSelection { .. } - | DrawingState::Selecting { .. } - | DrawingState::PendingTextClick { .. } - | DrawingState::ResizingText { .. } - | DrawingState::ResizingSelection { .. } => {} - } - } - MouseButton::Middle => { - if self.should_toggle_radial_menu_from_mouse(MouseButton::Middle) { - self.toggle_radial_menu(screen_x as f64, screen_y as f64); - } - } - } + let points = PointerPoints::new( + ScreenPoint::new(screen_x, screen_y), + CanvasPoint::new(canvas_x, canvas_y), + ); + let _ = route_pointer_press(self, PointerPress::new(button, points)); } - fn tool_for_button_press(&self, button: MouseButton, binding_tool: DragTool) -> Option { + pub(in crate::input::state) fn tool_for_button_press( + &self, + button: MouseButton, + binding_tool: DragTool, + ) -> Option { let configured_tool = binding_tool.as_tool(); if configured_tool.is_some() && self.presenter_mode @@ -263,6 +212,27 @@ impl InputState { } } + pub(in crate::input::state) fn handle_tool_button_press_at( + &mut self, + button: MouseButton, + tool: Tool, + color: Option, + screen: (i32, i32), + canvas: (i32, i32), + ) { + self.handle_tool_button_press( + button, + tool, + color, + PressCoords { + screen_x: screen.0, + screen_y: screen.1, + canvas_x: canvas.0, + canvas_y: canvas.1, + }, + ); + } + fn handle_idle_tool_click( &mut self, button: MouseButton, @@ -396,7 +366,11 @@ impl InputState { } } - fn handle_context_menu_press(&mut self, screen_x: i32, screen_y: i32) -> bool { + pub(in crate::input::state) fn handle_context_menu_press( + &mut self, + screen_x: i32, + screen_y: i32, + ) -> bool { if !self.is_context_menu_open() { return false; } @@ -411,7 +385,7 @@ impl InputState { true } - fn handle_radial_menu_press( + pub(in crate::input::state) fn handle_radial_menu_press( &mut self, button: MouseButton, screen_x: i32, @@ -444,7 +418,12 @@ impl InputState { true } - fn handle_color_picker_press(&mut self, button: MouseButton, x: i32, y: i32) -> bool { + pub(in crate::input::state) fn handle_color_picker_press( + &mut self, + button: MouseButton, + x: i32, + y: i32, + ) -> bool { if !self.is_color_picker_popup_open() { return false; } @@ -472,7 +451,12 @@ impl InputState { true } - fn handle_board_picker_press(&mut self, button: MouseButton, x: i32, y: i32) -> bool { + pub(in crate::input::state) fn handle_board_picker_press( + &mut self, + button: MouseButton, + x: i32, + y: i32, + ) -> bool { if !self.is_board_picker_open() { return false; } @@ -511,7 +495,12 @@ impl InputState { true } - fn handle_properties_panel_press(&mut self, button: MouseButton, x: i32, y: i32) -> bool { + pub(in crate::input::state) fn handle_properties_panel_press( + &mut self, + button: MouseButton, + x: i32, + y: i32, + ) -> bool { if !self.is_properties_panel_open() { return false; } diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs index 08ffc998..c8f595d2 100644 --- a/src/input/state/mouse/release/mod.rs +++ b/src/input/state/mouse/release/mod.rs @@ -1,6 +1,9 @@ use crate::input::events::MouseButton; -use super::super::{DrawingState, InputState}; +use super::super::{ + DrawingState, InputState, + interaction::{CanvasPoint, PointerPoints, PointerRelease, ScreenPoint, route_pointer_release}, +}; mod drawing; mod panels; @@ -34,45 +37,50 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); + let points = PointerPoints::new( + ScreenPoint::new(screen_x, screen_y), + CanvasPoint::new(canvas_x, canvas_y), + ); + let _ = route_pointer_release(self, PointerRelease::new(button, points)); + } - // Radial menu uses press for selection, ignore release - if self.is_radial_menu_open() { - return; - } + pub(in crate::input::state) fn handle_color_picker_popup_release_at( + &mut self, + x: i32, + y: i32, + ) -> bool { + panels::handle_color_picker_popup_release(self, x, y) + } - if button == MouseButton::Left { - if panels::handle_color_picker_popup_release(self, screen_x, screen_y) { - return; - } - if panels::handle_context_menu_release(self, screen_x, screen_y) { - return; - } - if panels::handle_board_picker_release(self, screen_x, screen_y) { - return; - } - if panels::handle_properties_panel_release(self, screen_x, screen_y) { - return; - } - } + pub(in crate::input::state) fn handle_context_menu_release_at( + &mut self, + x: i32, + y: i32, + ) -> bool { + panels::handle_context_menu_release(self, x, y) + } - if matches!( - self.state, - DrawingState::Drawing { .. } - | DrawingState::MovingSelection { .. } - | DrawingState::Selecting { .. } - | DrawingState::PendingTextClick { .. } - | DrawingState::ResizingText { .. } - | DrawingState::ResizingSelection { .. } - ) && !self.pointer_drag_button_matches(button) - { - return; - } + pub(in crate::input::state) fn handle_board_picker_release_at( + &mut self, + x: i32, + y: i32, + ) -> bool { + panels::handle_board_picker_release(self, x, y) + } - if button != MouseButton::Left && self.active_drag_button.is_none() { - return; - } + pub(in crate::input::state) fn handle_properties_panel_release_at( + &mut self, + x: i32, + y: i32, + ) -> bool { + panels::handle_properties_panel_release(self, x, y) + } + pub(in crate::input::state) fn finish_pointer_interaction_at( + &mut self, + canvas_x: i32, + canvas_y: i32, + ) { let state = std::mem::replace(&mut self.state, DrawingState::Idle); match state { DrawingState::MovingSelection {