diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 49485fa8..393c9548 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -162,11 +162,12 @@ use self::rendering::{ #[cfg(all(target_os = "macos", test))] use self::session_state::InflightScrollCaptureObservation; use self::session_state::{ - ActiveFrozenBrushStroke, CursorMoveTrace, FrozenBrushModelState, FrozenBrushState, - FrozenBrushStroke, FrozenMosaicDragState, FrozenSelectionDragCursorMoveTiming, - FrozenSelectionDragState, FrozenTextAnnotation, FrozenTextColor, FrozenTextEditState, - FrozenTextStyle, FrozenToolbarPointerState, FrozenToolbarState, HudDrawConfig, - LiveSampleApplyResult, ScrollCaptureState, SlowOperationLogger, WindowFreezeCaptureTarget, + ActiveFrozenBrushStroke, CursorMoveTrace, FrozenAnnotationColor, FrozenBrushModelState, + FrozenBrushState, FrozenBrushStroke, FrozenMosaicDragState, + FrozenSelectionDragCursorMoveTiming, FrozenSelectionDragState, FrozenTextAnnotation, + FrozenTextEditState, FrozenTextStyle, FrozenToolbarPointerState, FrozenToolbarState, + HudDrawConfig, LiveSampleApplyResult, ScrollCaptureState, SlowOperationLogger, + WindowFreezeCaptureTarget, }; #[cfg(target_os = "macos")] use self::session_state::{ @@ -258,6 +259,7 @@ const MACOS_HUD_WINDOW_LEVEL: isize = 26; const MACOS_OVERLAY_WINDOW_LEVEL: isize = 25; const FROZEN_TOOLBAR_BUTTON_SIZE_POINTS: f32 = 24.0; const FROZEN_TOOLBAR_ITEM_SPACING_POINTS: f32 = 4.0; +const TOOLBAR_PILL_INNER_MARGIN_Y_POINTS: f32 = 6.0; const LIVE_EVENT_CURSOR_CACHE_TTL: Duration = Duration::from_millis(120); const CURSOR_EVENT_TICK_TTL: Duration = Duration::from_millis(24); const LIVE_HOVER_HIT_TEST_INTERVAL: Duration = Duration::from_millis(60); @@ -297,17 +299,17 @@ const SCROLL_CAPTURE_DUPLICATE_STREAM_STALL_THRESHOLD: u8 = 3; #[cfg(target_os = "macos")] const SCROLL_CAPTURE_DUPLICATE_STREAM_REFRESH_INTERVAL: Duration = Duration::from_millis(80); const HUD_PILL_INNER_MARGIN_X_POINTS: f32 = 12.0; -const HUD_PILL_INNER_MARGIN_Y_POINTS: f32 = 8.0; const HUD_PILL_STROKE_WIDTH_POINTS: f32 = 1.0; const TOOLBAR_EXPANDED_HEIGHT_PX: f32 = FROZEN_TOOLBAR_BUTTON_SIZE_POINTS - + 2.0 * HUD_PILL_INNER_MARGIN_Y_POINTS + + 2.0 * TOOLBAR_PILL_INNER_MARGIN_Y_POINTS + 2.0 * HUD_PILL_STROKE_WIDTH_POINTS; const TOOLBAR_CAPTURE_GAP_PX: f32 = 10.0; const TOOLBAR_SCREEN_MARGIN_PX: f32 = 10.0; const TOOLBAR_DEFAULT_SLOT_POSITION_EPSILON_POINTS: f32 = 1.0; const HUD_PILL_CORNER_RADIUS_POINTS: u8 = 18; const FROZEN_TEXT_FONT_SIZE_POINTS: f32 = 16.0; -const FROZEN_TEXT_FONT_SIZE_PRESETS: [f32; 4] = [16.0, 22.0, 30.0, 40.0]; +const FROZEN_TEXT_FONT_SIZE_MIN_POINTS: f32 = 12.0; +const FROZEN_TEXT_FONT_SIZE_MAX_POINTS: f32 = 72.0; const FROZEN_TEXT_PREVIEW_PLACEHOLDER: &str = "Type"; const FROZEN_TEXT_CARET_BLINK_PERIOD_SECS: f64 = 1.0; const FROZEN_TEXT_CARET_REPAINT_INTERVAL: Duration = Duration::from_millis(250); @@ -356,6 +358,8 @@ const FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS: f32 = 1.3; const FROZEN_MOSAIC_BLOCK_SIZE_PX: u32 = 12; const FROZEN_EDIT_HISTORY_LIMIT: usize = 24; const FROZEN_BRUSH_STROKE_WIDTH_POINTS: f32 = 3.5; +const FROZEN_BRUSH_STROKE_WIDTH_MIN_POINTS: f32 = 1.0; +const FROZEN_BRUSH_STROKE_WIDTH_MAX_POINTS: f32 = 24.0; const FROZEN_BRUSH_POINT_SPACING_MIN_POINTS: f32 = 0.25; const FROZEN_BRUSH_PREVIEW_POINT_SPACING_MIN_POINTS: f32 = 0.1; const FROZEN_BRUSH_MODELED_POINT_SPACING_MIN_POINTS: f32 = 0.25; @@ -381,7 +385,6 @@ const FROZEN_BRUSH_STREAMLINE_DISTANCE_CEILING_POINTS: f32 = 6.0; const FROZEN_BRUSH_PREVIEW_ROUNDING_PASSES: usize = 1; const FROZEN_BRUSH_COMMIT_ROUNDING_PASSES: usize = 2; const FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS: f32 = 0.25; -const FROZEN_BRUSH_COLOR_RGBA: [u8; 4] = [255, 69, 58, 255]; const WINDOW_CAPTURE_MATTE_LIGHT_RGBA: image::Rgba = image::Rgba([246, 246, 246, 255]); const WINDOW_CAPTURE_MATTE_DARK_RGBA: image::Rgba = image::Rgba([24, 24, 24, 255]); const SCROLL_PREVIEW_WINDOW_WIDTH_POINTS: f64 = 260.0; @@ -1600,6 +1603,14 @@ impl OverlaySession { } } + fn inline_toolbar_size_wheel_active(&self, toolbar_window_id: bool) -> bool { + !toolbar_window_id + && !cfg!(target_os = "macos") + && matches!(self.state.mode, OverlayMode::Frozen) + && self.toolbar_state.visible + && self.toolbar_state.annotation_size_control_hovered + } + /// Handles a winit window event for one of the overlay-owned windows. pub fn handle_window_event( &mut self, @@ -1635,6 +1646,8 @@ impl OverlaySession { .toolbar_window .as_ref() .is_some_and(|toolbar_window| toolbar_window.window.id() == window_id); + let inline_toolbar_size_wheel_active = + self.inline_toolbar_size_wheel_active(toolbar_window_id); let control = match event { WindowEvent::CloseRequested => self.cancel_overlay("window_close_requested"), WindowEvent::MouseInput { @@ -1645,6 +1658,9 @@ impl OverlaySession { WindowEvent::Resized(size) if toolbar_window_id => { self.handle_toolbar_window_resized(*size) }, + WindowEvent::Moved(position) if toolbar_window_id => { + self.handle_toolbar_window_moved(window_id, *position) + }, WindowEvent::Resized(size) => self.handle_resized(window_id, *size), WindowEvent::ScaleFactorChanged { .. } if toolbar_window_id => { self.handle_toolbar_window_scale_factor_changed(window_id) @@ -1652,20 +1668,7 @@ impl OverlaySession { WindowEvent::ScaleFactorChanged { .. } => self.handle_scale_factor_changed(window_id), WindowEvent::CursorEntered { .. } if toolbar_window_id => OverlayControl::Continue, WindowEvent::CursorLeft { .. } if toolbar_window_id => { - self.toolbar_pointer_local = None; - self.toolbar_left_button_down = false; - self.toolbar_left_button_went_down = false; - self.toolbar_left_button_went_up = false; - self.toolbar_state.dragging = false; - self.toolbar_state.drag_offset = Vec2::ZERO; - self.toolbar_state.drag_anchor = None; - - #[cfg(target_os = "macos")] - { - self.request_redraw_toolbar_window(); - } - - OverlayControl::Continue + self.handle_toolbar_cursor_left() }, WindowEvent::CursorMoved { position, .. } => { if toolbar_window_id { @@ -1675,7 +1678,12 @@ impl OverlaySession { } }, WindowEvent::Ime(event) => self.handle_ime_event(window_id, event), - WindowEvent::MouseWheel { delta, .. } if toolbar_window_id => OverlayControl::Continue, + WindowEvent::MouseWheel { delta, .. } if toolbar_window_id => { + self.handle_toolbar_mouse_wheel(delta) + }, + WindowEvent::MouseWheel { delta, .. } if inline_toolbar_size_wheel_active => { + self.handle_toolbar_mouse_wheel(delta) + }, WindowEvent::MouseWheel { delta, .. } => { self.handle_scroll_mouse_wheel(window_id, delta) }, @@ -1843,16 +1851,18 @@ impl OverlaySession { let left_button_went_down = self.toolbar_left_button_went_down; let left_button_went_up = self.toolbar_left_button_went_up; + #[cfg(not(target_os = "macos"))] + let left_button_down = self.toolbar_left_button_down; self.toolbar_left_button_went_down = false; self.toolbar_left_button_went_up = false; let cursor_local = toolbar_cursor_local_override .or_else(|| self.state.cursor.and_then(|cursor| global_to_local(cursor, monitor)))?; - let left_button_down = self.toolbar_left_button_down; Some(FrozenToolbarPointerState { cursor_local, + #[cfg(not(target_os = "macos"))] left_button_down, left_button_went_down, left_button_went_up, @@ -3087,9 +3097,25 @@ enum FrozenCommittedOverlay<'a> { Text(&'a FrozenTextAnnotation), } +pub(super) fn frozen_toolbar_corner_radius_u8(toolbar_height_points: f32) -> u8 { + if toolbar_height_points <= TOOLBAR_EXPANDED_HEIGHT_PX + 0.5 { + (toolbar_height_points * 0.5).round().clamp(1.0, f32::from(u8::MAX)) as u8 + } else { + HUD_PILL_CORNER_RADIUS_POINTS + } +} + +pub(super) fn frozen_toolbar_corner_radius_points(toolbar_height_points: f32) -> f64 { + f64::from(frozen_toolbar_corner_radius_u8(toolbar_height_points)) +} + fn frozen_toolbar_window_startup_size_points() -> Vec2 { [ FrozenToolbarState::default(), + FrozenToolbarState { + selected_tool: FrozenToolbarTool::Pen, + ..FrozenToolbarState::default() + }, FrozenToolbarState { selected_tool: FrozenToolbarTool::Text, ..FrozenToolbarState::default() @@ -3101,6 +3127,12 @@ fn frozen_toolbar_window_startup_size_points() -> Vec2 { scroll_capture_available: true, ..FrozenToolbarState::default() }, + FrozenToolbarState { + selected_tool: FrozenToolbarTool::Pen, + auto_center_available: true, + scroll_capture_available: true, + ..FrozenToolbarState::default() + }, FrozenToolbarState { selected_tool: FrozenToolbarTool::Text, auto_center_available: true, diff --git a/packages/rsnap-overlay/src/overlay/config_runtime.rs b/packages/rsnap-overlay/src/overlay/config_runtime.rs index a93e35c8..453765a7 100644 --- a/packages/rsnap-overlay/src/overlay/config_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/config_runtime.rs @@ -4,11 +4,12 @@ use winit::window::WindowId; #[cfg(target_os = "macos")] use crate::backend; +use crate::overlay; #[cfg(target_os = "macos")] -use crate::overlay::{self, OverlayWorker}; +use crate::overlay::OverlayWorker; use crate::overlay::{ - Arc, HUD_PILL_CORNER_RADIUS_POINTS, Instant, LOUPE_TILE_CORNER_RADIUS_POINTS, OverlayConfig, - OverlayMode, OverlaySession, + Arc, Instant, LOUPE_TILE_CORNER_RADIUS_POINTS, OverlayConfig, OverlayMode, OverlaySession, + WindowRenderer, }; #[cfg(target_os = "macos")] use crate::overlay::{MacLiveFrameStream, MacOSHudWindowConfigState, SLOW_OP_WARN_HUD_CONFIG}; @@ -178,7 +179,9 @@ impl OverlaySession { self.configure_hud_window_common( window.as_ref(), - Some(f64::from(HUD_PILL_CORNER_RADIUS_POINTS)), + Some(overlay::frozen_toolbar_corner_radius_points( + WindowRenderer::frozen_toolbar_size(&self.toolbar_state).y, + )), ); } } diff --git a/packages/rsnap-overlay/src/overlay/frozen_brush_runtime.rs b/packages/rsnap-overlay/src/overlay/frozen_brush_runtime.rs index a2107a85..8b5c5036 100644 --- a/packages/rsnap-overlay/src/overlay/frozen_brush_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/frozen_brush_runtime.rs @@ -4,6 +4,7 @@ use std::time::Instant; #[cfg(test)] use crate::overlay::FROZEN_BRUSH_MODEL_SYNTHETIC_SAMPLE_INTERVAL_SECONDS; +use crate::overlay::session_state::FrozenBrushStyle; use crate::overlay::{ ActiveFrozenBrushStroke, FROZEN_BRUSH_COMMIT_ROUNDING_PASSES, FROZEN_BRUSH_MODEL_CURVE_AMPLITUDE_POINTS, FROZEN_BRUSH_MODEL_CURVE_RESPONSE_BOOST, @@ -64,8 +65,11 @@ impl OverlaySession { let point = Pos2::new(cursor_x as f32, cursor_y as f32); let sampled_at = Instant::now(); - self.frozen_brush.active_stroke = - Some(Self::new_active_frozen_brush_stroke(point, sampled_at)); + self.frozen_brush.active_stroke = Some(Self::new_active_frozen_brush_stroke( + point, + sampled_at, + self.toolbar_state.brush_style, + )); self.request_redraw_for_monitor(monitor); @@ -105,9 +109,10 @@ impl OverlaySession { return false; } - self.frozen_brush - .committed_strokes - .push(FrozenBrushStroke { points: Self::finished_frozen_brush_points(&stroke) }); + self.frozen_brush.committed_strokes.push(FrozenBrushStroke { + points: Self::finished_frozen_brush_points(&stroke), + style: stroke.style, + }); self.push_frozen_edit_to_undo_history(FrozenEditKind::BrushStroke); self.sync_frozen_toolbar_state(); @@ -173,10 +178,12 @@ impl OverlaySession { pub(super) fn new_active_frozen_brush_stroke( point: Pos2, sampled_at: Instant, + style: FrozenBrushStyle, ) -> ActiveFrozenBrushStroke { ActiveFrozenBrushStroke { raw_points: vec![point], points: vec![point], + style, model_state: FrozenBrushModelState { filtered_input_point: point, modeled_point: point, @@ -753,7 +760,11 @@ impl OverlaySession { } let started_at = Instant::now(); - let mut stroke = Self::new_active_frozen_brush_stroke(points[0], started_at); + let mut stroke = Self::new_active_frozen_brush_stroke( + points[0], + started_at, + FrozenBrushStyle::default(), + ); for (index, point) in points.iter().copied().enumerate().skip(1) { let sampled_at = started_at diff --git a/packages/rsnap-overlay/src/overlay/frozen_export_runtime.rs b/packages/rsnap-overlay/src/overlay/frozen_export_runtime.rs index 826e4e53..26379350 100644 --- a/packages/rsnap-overlay/src/overlay/frozen_export_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/frozen_export_runtime.rs @@ -3,9 +3,9 @@ use image::{ imageops::{self, FilterType}, }; +use crate::overlay::session_state::FrozenBrushStyle; use crate::overlay::{ - FROZEN_BRUSH_COLOR_RGBA, FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, - FROZEN_BRUSH_STROKE_WIDTH_POINTS, FrozenBrushStroke, FrozenCaptureSource, + FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, FrozenBrushStroke, FrozenCaptureSource, FrozenCommittedOverlay, FrozenEditKind, FrozenExportTransform, FrozenTextAnnotation, MonitorRect, OverlaySession, Pos2, RectPoints, WINDOW_CAPTURE_MATTE_DARK_RGBA, WINDOW_CAPTURE_MATTE_LIGHT_RGBA, WindowCaptureAlphaMode, @@ -72,6 +72,7 @@ impl OverlaySession { coverage_mask, export_transform, &stroke.points, + stroke.style, ); }, FrozenCommittedOverlay::Text(annotation) => { @@ -95,6 +96,7 @@ impl OverlaySession { coverage_mask, export_transform, &display_points, + active_stroke.style, ); } } @@ -104,6 +106,7 @@ impl OverlaySession { coverage_mask: &mut [u8], export_transform: FrozenExportTransform, points: &[Pos2], + style: FrozenBrushStyle, ) { if export_image.width() == 0 || export_image.height() == 0 { return; @@ -112,9 +115,8 @@ impl OverlaySession { return; } - let radius = - (FROZEN_BRUSH_STROKE_WIDTH_POINTS * export_transform.scalar_scale() * 0.5).max(1.0); - let color = Rgba(FROZEN_BRUSH_COLOR_RGBA); + let radius = (style.stroke_width_points * export_transform.scalar_scale() * 0.5).max(1.0); + let color = Rgba(style.color.export_rgba()); coverage_mask.fill(0); diff --git a/packages/rsnap-overlay/src/overlay/input_runtime.rs b/packages/rsnap-overlay/src/overlay/input_runtime.rs index f31dfb5e..97e5b22a 100644 --- a/packages/rsnap-overlay/src/overlay/input_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/input_runtime.rs @@ -7,8 +7,8 @@ use crate::overlay::{ AltActivationMode, CURSOR_EVENT_TICK_TTL, CursorMoveTrace, DeviceCursorPointSource, ElementState, FrozenSelectionDragCursorMoveTiming, FrozenTextEditState, FrozenTextInputSource, FrozenToolbarTool, GlobalPoint, Ime, Key, KeyEvent, LIVE_DRAG_START_THRESHOLD_PX, Modifiers, - MonitorRect, NamedKey, OverlayControl, OverlayMode, OverlaySession, PhysicalPosition, - PhysicalSize, PngAction, Vec2, WindowId, + MonitorRect, MouseScrollDelta, NamedKey, OverlayControl, OverlayMode, OverlaySession, + PhysicalPosition, PhysicalSize, PngAction, Vec2, WindowId, }; impl OverlaySession { @@ -43,6 +43,16 @@ impl OverlaySession { self.toolbar_left_button_down = toolbar_left_button_down; if !toolbar_left_button_down { + if self.toolbar_state.dragging + && let Some(monitor) = self.state.monitor.or_else(|| self.active_cursor_monitor()) + && let Some(toolbar_window) = self.toolbar_window.as_ref() + && let Some(outer_position) = Self::toolbar_window_outer_position(toolbar_window) + { + let _ = self.sync_toolbar_outer_position_from_window(monitor, outer_position); + + self.force_apply_pending_toolbar_window_move(); + } + self.stop_frozen_selection_drag(); self.stop_frozen_mosaic_drag(); @@ -69,11 +79,38 @@ impl OverlaySession { OverlayControl::Continue } + pub(super) fn handle_toolbar_mouse_wheel( + &mut self, + delta: &MouseScrollDelta, + ) -> OverlayControl { + if !matches!(self.state.mode, OverlayMode::Frozen) || !self.toolbar_state.visible { + return OverlayControl::Continue; + } + if !self.toolbar_state.apply_annotation_size_wheel_delta(delta) { + return OverlayControl::Continue; + } + + self.toolbar_state.needs_redraw = true; + + if let Some(monitor) = self.state.monitor { + self.request_redraw_for_monitor(monitor); + } + + #[cfg(target_os = "macos")] + { + self.request_redraw_toolbar_window(); + } + + OverlayControl::Continue + } + pub(super) fn reset_toolbar_pointer_state(&mut self) { self.toolbar_left_button_down = false; self.toolbar_left_button_went_down = false; self.toolbar_left_button_went_up = false; self.toolbar_pointer_local = None; + self.toolbar_state.annotation_size_control_hovered = false; + self.toolbar_state.annotation_size_wheel_accumulator = 0.0; self.toolbar_state.drag_anchor = None; } diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index f194b6c6..7e5ed507 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -5,6 +5,7 @@ use egui::Context; use egui::FontDefinitions; use egui::Galley; use egui::RawInput; +use egui::Response; use egui::text::CCursor; use crate::overlay::rendering::{ @@ -14,8 +15,7 @@ use crate::overlay::rendering::{ SelectionSizeBadgeTarget, WindowRenderer, }; use crate::overlay::{ - self, Align, Align2, Area, Color32, CornerRadius, FROZEN_BRUSH_COLOR_RGBA, - FROZEN_BRUSH_STROKE_WIDTH_POINTS, FROZEN_SELECTION_DASHED_BORDER_WIDTH_PX, + self, Align, Align2, Area, Color32, CornerRadius, FROZEN_SELECTION_DASHED_BORDER_WIDTH_PX, FROZEN_SELECTION_RESIZE_HANDLE_CENTER_DOT_RADIUS_POINTS, FROZEN_SELECTION_RESIZE_HANDLE_CORNER_KEEPOUT_POINTS, FROZEN_SELECTION_RESIZE_HANDLE_HIT_OFFSET_POINTS, @@ -23,38 +23,40 @@ use crate::overlay::{ FROZEN_SELECTION_RESIZE_HANDLE_INTERIOR_REACH_MAX_POINTS, FROZEN_SELECTION_RESIZE_HANDLE_OUTER_RADIUS_POINTS, FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS, FROZEN_SELECTION_SCRIM_ALPHA_DARK, - FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, FROZEN_TEXT_FONT_SIZE_PRESETS, - FROZEN_TEXT_PREVIEW_PLACEHOLDER, FROZEN_TOOLBAR_BUTTON_SIZE_POINTS, - FROZEN_TOOLBAR_ITEM_SPACING_POINTS, FontFamily, FontId, FrozenBrushState, FrozenCaptureSource, - FrozenCommittedOverlay, FrozenEditKind, FrozenSelectionCorner, FrozenTextAnnotation, - FrozenTextColor, FrozenTextEditState, FrozenTextStyle, FrozenToolbarPointerState, - FrozenToolbarState, FrozenToolbarTool, HUD_PILL_CORNER_RADIUS_POINTS, - HUD_PILL_INNER_MARGIN_X_POINTS, HUD_PILL_INNER_MARGIN_Y_POINTS, HUD_PILL_STROKE_WIDTH_POINTS, - HudPillGeometry, HudTheme, Id, LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, - LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, LIVE_DRAG_START_THRESHOLD_PX, LayerId, Layout, Mesh, - MonitorRect, Order, OverlayMode, OverlaySession, OverlayState, Painter, Pos2, Rect, RectPoints, - SELECTION_DASHED_BORDER_ALPHA, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, - SELECTION_DASHED_BORDER_GAP_LENGTH_PX, SELECTION_DASHED_BORDER_WIDTH_PX, - SELECTION_FLOW_CORE_FLOW_WIDTH, SELECTION_FLOW_CORNER_RADIUS_PX, SELECTION_FLOW_FLOW_BOOST, - SELECTION_FLOW_LIGHT_PALETTE, SELECTION_FLOW_MAX_SEGMENTS, SELECTION_FLOW_MIN_SEGMENTS, - SELECTION_FLOW_PALETTE, SELECTION_FLOW_SAMPLE_STEP_PX, SELECTION_FLOW_SPEED, - SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX, SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, - SELECTION_SIZE_BADGE_GAP_PX, SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, - SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX, SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX, - SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS, - SelectionFlowStyle, Sense, Shape, Stroke, StrokeKind, TOOLBAR_CAPTURE_GAP_PX, - TOOLBAR_EXPANDED_HEIGHT_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Ui, UiBuilder, Vec2, - regular, + FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, FROZEN_TEXT_PREVIEW_PLACEHOLDER, + FROZEN_TOOLBAR_BUTTON_SIZE_POINTS, FROZEN_TOOLBAR_ITEM_SPACING_POINTS, FontFamily, FontId, + FrozenAnnotationColor, FrozenBrushState, FrozenCaptureSource, FrozenCommittedOverlay, + FrozenEditKind, FrozenSelectionCorner, FrozenTextAnnotation, FrozenTextEditState, + FrozenTextStyle, FrozenToolbarPointerState, FrozenToolbarState, FrozenToolbarTool, + HUD_PILL_INNER_MARGIN_X_POINTS, HUD_PILL_STROKE_WIDTH_POINTS, HudPillGeometry, HudTheme, Id, + LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, + LIVE_DRAG_START_THRESHOLD_PX, LayerId, Layout, Mesh, MonitorRect, Order, OverlayMode, + OverlaySession, OverlayState, Painter, Pos2, Rect, RectPoints, SELECTION_DASHED_BORDER_ALPHA, + SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + SELECTION_DASHED_BORDER_WIDTH_PX, SELECTION_FLOW_CORE_FLOW_WIDTH, + SELECTION_FLOW_CORNER_RADIUS_PX, SELECTION_FLOW_FLOW_BOOST, SELECTION_FLOW_LIGHT_PALETTE, + SELECTION_FLOW_MAX_SEGMENTS, SELECTION_FLOW_MIN_SEGMENTS, SELECTION_FLOW_PALETTE, + SELECTION_FLOW_SAMPLE_STEP_PX, SELECTION_FLOW_SPEED, SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX, + SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, SELECTION_SIZE_BADGE_GAP_PX, + SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX, + SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX, SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, + SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS, SelectionFlowStyle, Sense, Shape, Stroke, StrokeKind, + TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_EXPANDED_HEIGHT_PX, TOOLBAR_PILL_INNER_MARGIN_Y_POINTS, + TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Ui, UiBuilder, Vec2, regular, }; -const FROZEN_TEXT_TOOLBAR_SECTION_GAP_POINTS: f32 = 8.0; -const FROZEN_TEXT_TOOLBAR_SECTION_HEIGHT_POINTS: f32 = 30.0; -const FROZEN_TEXT_TOOLBAR_SECTION_DIVIDER_ALPHA_DARK: u8 = 60; -const FROZEN_TEXT_TOOLBAR_SECTION_DIVIDER_ALPHA_LIGHT: u8 = 72; -const FROZEN_TEXT_TOOLBAR_SWATCH_SIZE_POINTS: f32 = 18.0; -const FROZEN_TEXT_TOOLBAR_SWATCH_GAP_POINTS: f32 = 8.0; -const FROZEN_TEXT_TOOLBAR_SIZE_BUTTON_WIDTH_POINTS: f32 = 24.0; -const FROZEN_TEXT_TOOLBAR_SIZE_LABEL_WIDTH_POINTS: f32 = 54.0; +const FROZEN_ANNOTATION_TOOLBAR_SECTION_GAP_POINTS: f32 = 4.0; +const FROZEN_ANNOTATION_TOOLBAR_SECTION_HEIGHT_POINTS: f32 = 24.0; +const FROZEN_ANNOTATION_TOOLBAR_SECTION_DIVIDER_ALPHA_DARK: u8 = 60; +const FROZEN_ANNOTATION_TOOLBAR_SECTION_DIVIDER_ALPHA_LIGHT: u8 = 72; +const FROZEN_ANNOTATION_TOOLBAR_SWATCH_SIZE_POINTS: f32 = 16.0; +const FROZEN_ANNOTATION_TOOLBAR_SWATCH_GAP_POINTS: f32 = 6.0; +const FROZEN_ANNOTATION_TOOLBAR_SIZE_BUTTON_WIDTH_POINTS: f32 = 20.0; +const FROZEN_ANNOTATION_TOOLBAR_SIZE_DISPLAY_WIDTH_POINTS: f32 = 58.0; +const FROZEN_ANNOTATION_TOOLBAR_PEN_SIZE_DISPLAY_WIDTH_POINTS: f32 = 84.0; +const FROZEN_ANNOTATION_TOOLBAR_SIZE_CAPSULE_CORNER_RADIUS_POINTS: u8 = 8; +const FROZEN_ANNOTATION_TOOLBAR_SIZE_PREVIEW_GAP_POINTS: f32 = 8.0; +const FROZEN_ANNOTATION_TOOLBAR_PEN_PREVIEW_LENGTH_POINTS: f32 = 18.0; const FROZEN_TEXT_INTERACTION_PADDING_X_POINTS: f32 = 8.0; const FROZEN_TEXT_INTERACTION_PADDING_Y_POINTS: f32 = 6.0; @@ -65,6 +67,128 @@ pub(in crate::overlay) struct SelectionScrimStyle { pub(in crate::overlay) exclude_resize_handle_corners: bool, } +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum FrozenAnnotationStyleToolbarKind { + Pen, + Text, +} +impl FrozenAnnotationStyleToolbarKind { + fn from_toolbar_state(toolbar_state: &FrozenToolbarState) -> Option { + match toolbar_state.selected_tool { + FrozenToolbarTool::Pen => Some(Self::Pen), + FrozenToolbarTool::Text => Some(Self::Text), + _ => None, + } + } + + const fn size_hover_text(self) -> &'static str { + match self { + Self::Pen => "Scroll or use +/- to adjust stroke size", + Self::Text => "Scroll or use +/- to adjust text size", + } + } + + const fn size_display_width(self) -> f32 { + match self { + Self::Pen => FROZEN_ANNOTATION_TOOLBAR_PEN_SIZE_DISPLAY_WIDTH_POINTS, + Self::Text => FROZEN_ANNOTATION_TOOLBAR_SIZE_DISPLAY_WIDTH_POINTS, + } + } + + const fn size_control_width(self) -> f32 { + self.size_display_width() + FROZEN_ANNOTATION_TOOLBAR_SIZE_BUTTON_WIDTH_POINTS * 2.0 + } + + const fn decrease_hover_text(self) -> &'static str { + match self { + Self::Pen => "Smaller stroke", + Self::Text => "Smaller text", + } + } + + const fn increase_hover_text(self) -> &'static str { + match self { + Self::Pen => "Larger stroke", + Self::Text => "Larger text", + } + } + + fn size_value(self, toolbar_state: &FrozenToolbarState) -> f64 { + match self { + Self::Pen => toolbar_state.brush_style.stroke_width_points, + Self::Text => toolbar_state.text_style.font_size_points, + } + .into() + } + + fn formatted_size_text(self, toolbar_state: &FrozenToolbarState) -> String { + match self { + Self::Pen => { + let size_points = self.size_value(toolbar_state); + let mut text = format!("{size_points:.2}"); + + while text.contains('.') && text.ends_with('0') { + let _ = text.pop(); + } + + if text.ends_with('.') { + let _ = text.pop(); + } + + text + }, + Self::Text => { + let font_size = toolbar_state.text_style.font_size_points; + + if (font_size - font_size.round()).abs() <= f32::EPSILON { + format!("{}", font_size.round() as i32) + } else { + format!("{font_size:.1}") + } + }, + } + } + + fn selected_color(self, toolbar_state: &FrozenToolbarState) -> FrozenAnnotationColor { + match self { + Self::Pen => toolbar_state.brush_style.color, + Self::Text => toolbar_state.text_style.color, + } + } + + fn set_color( + self, + toolbar_state: &mut FrozenToolbarState, + color: FrozenAnnotationColor, + ) -> bool { + let selected_color = match self { + Self::Pen => &mut toolbar_state.brush_style.color, + Self::Text => &mut toolbar_state.text_style.color, + }; + + if *selected_color == color { + return false; + } + + *selected_color = color; + + true + } + + fn apply_size_steps(self, toolbar_state: &mut FrozenToolbarState, steps: i32) -> bool { + toolbar_state.apply_annotation_size_steps(steps) + } +} + +#[derive(Clone, Copy)] +struct FrozenAnnotationSizeControlAppearance { + capsule_fill: Color32, + capsule_stroke: Color32, + divider_color: Color32, + button_hover_fill: Color32, + text_color: Color32, +} + impl WindowRenderer { fn frozen_text_measurement_ctx() -> &'static Context { static CTX: OnceLock = OnceLock::new(); @@ -292,13 +416,6 @@ impl WindowRenderer { }; let brush_strokes = frozen_brush_state.map_or_else(|| &[][..], |state| state.committed_strokes.as_slice()); - let radius = FROZEN_BRUSH_STROKE_WIDTH_POINTS * 0.5; - let color = Color32::from_rgba_unmultiplied( - FROZEN_BRUSH_COLOR_RGBA[0], - FROZEN_BRUSH_COLOR_RGBA[1], - FROZEN_BRUSH_COLOR_RGBA[2], - FROZEN_BRUSH_COLOR_RGBA[3], - ); let mut drew = false; OverlaySession::for_each_frozen_committed_overlay( @@ -310,8 +427,8 @@ impl WindowRenderer { drew |= Self::paint_frozen_brush_stroke( brush_painter, &stroke.points, - radius, - color, + stroke.style.stroke_width_points * 0.5, + stroke.style.color.swatch_fill(), ); }, FrozenCommittedOverlay::Text(annotation) => { @@ -340,16 +457,14 @@ impl WindowRenderer { let Some(active_stroke) = &frozen_brush_state.active_stroke else { return false; }; - let color = Color32::from_rgba_unmultiplied( - FROZEN_BRUSH_COLOR_RGBA[0], - FROZEN_BRUSH_COLOR_RGBA[1], - FROZEN_BRUSH_COLOR_RGBA[2], - FROZEN_BRUSH_COLOR_RGBA[3], - ); - let radius = FROZEN_BRUSH_STROKE_WIDTH_POINTS * 0.5; let preview_points = OverlaySession::preview_frozen_brush_points(active_stroke); - Self::paint_frozen_brush_stroke(painter, &preview_points, radius, color) + Self::paint_frozen_brush_stroke( + painter, + &preview_points, + active_stroke.style.stroke_width_points * 0.5, + active_stroke.style.color.swatch_fill(), + ) } fn paint_frozen_brush_stroke( @@ -501,7 +616,7 @@ impl WindowRenderer { } pub(in crate::overlay) fn frozen_text_placeholder_fill( - color: FrozenTextColor, + color: FrozenAnnotationColor, theme: HudTheme, ) -> Color32 { let [r, g, b, _] = color.swatch_fill().to_array(); @@ -2173,6 +2288,8 @@ impl WindowRenderer { let Some(toolbar_state) = toolbar_state else { return; }; + #[cfg(target_os = "macos")] + let _ = pointer_state; if !matches!(state.mode, OverlayMode::Frozen) || !toolbar_state.visible { return; @@ -2181,11 +2298,10 @@ impl WindowRenderer { return; } + #[cfg(not(target_os = "macos"))] let (cursor, left_button_down) = if let Some(pointer_state) = pointer_state { (pointer_state.cursor_local, pointer_state.left_button_down) } else { - toolbar_state.dragging = false; - (Pos2::new(-1.0, -1.0), false) }; let toolbar_size = Self::frozen_toolbar_size(toolbar_state); @@ -2226,7 +2342,9 @@ impl WindowRenderer { hud_opacity, hud_milk_amount, hud_tint_hue, + #[cfg(not(target_os = "macos"))] cursor, + #[cfg(not(target_os = "macos"))] left_button_down, hud_pill_out, ); @@ -2364,9 +2482,9 @@ impl WindowRenderer { + 2.0 * HUD_PILL_STROKE_WIDTH_POINTS; let mut height = toolbar_state.pill_height_points.unwrap_or(TOOLBAR_EXPANDED_HEIGHT_PX); - if Self::frozen_text_style_toolbar_visible(toolbar_state) { - height += - FROZEN_TEXT_TOOLBAR_SECTION_GAP_POINTS + FROZEN_TEXT_TOOLBAR_SECTION_HEIGHT_POINTS; + if Self::frozen_annotation_style_toolbar_visible(toolbar_state) { + height += FROZEN_ANNOTATION_TOOLBAR_SECTION_GAP_POINTS + + FROZEN_ANNOTATION_TOOLBAR_SECTION_HEIGHT_POINTS; } Vec2::new(width, height) @@ -2542,16 +2660,28 @@ impl WindowRenderer { hud_opacity: f32, hud_milk_amount: f32, hud_tint_hue: f32, - cursor: Pos2, - left_button_down: bool, + #[cfg(not(target_os = "macos"))] cursor: Pos2, + #[cfg(not(target_os = "macos"))] left_button_down: bool, hud_pill_out: &mut Option, ) { + #[cfg(target_os = "macos")] + let _ = screen_rect; + Area::new(Id::new(format!("frozen-toolbar-{}", monitor.id))) .order(Order::Foreground) .fixed_pos(toolbar_pos) .show(ctx, |ui| { - let (rect, response) = - ui.allocate_exact_size(toolbar_size, Sense::click_and_drag()); + let (rect, response) = ui.allocate_exact_size( + toolbar_size, + if cfg!(target_os = "macos") { + Sense::hover() + } else { + Sense::click_and_drag() + }, + ); + #[cfg(target_os = "macos")] + let _ = &response; + let corner_radius = overlay::frozen_toolbar_corner_radius_u8(rect.height()); let body_fill = Self::tinted_hud_body_fill( theme, hud_blur_active, @@ -2563,6 +2693,9 @@ impl WindowRenderer { let toolbar_frame = Self::hud_pill_frame(theme, hud_opaque, hud_opacity, body_fill, false); + toolbar_state.annotation_size_control_hovered = false; + + #[cfg(not(target_os = "macos"))] Self::update_frozen_toolbar_drag_state( toolbar_state, response.drag_started(), @@ -2575,14 +2708,10 @@ impl WindowRenderer { // Draw the capsule ourselves at the exact allocated rect. This keeps the visible pill // and the blur rect perfectly aligned (no shrink-to-content surprises on first frame). - ui.painter().rect_filled( - rect, - f32::from(HUD_PILL_CORNER_RADIUS_POINTS), - toolbar_frame.fill, - ); + ui.painter().rect_filled(rect, f32::from(corner_radius), toolbar_frame.fill); ui.painter().rect_stroke( rect.shrink(0.5), - CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS), + CornerRadius::same(corner_radius), toolbar_frame.stroke, StrokeKind::Inside, ); @@ -2596,25 +2725,24 @@ impl WindowRenderer { ui.painter().rect_stroke( inner_rect, - CornerRadius::same(HUD_PILL_CORNER_RADIUS_POINTS.saturating_sub(1)), + CornerRadius::same(corner_radius.saturating_sub(1)), inner_stroke, StrokeKind::Inside, ); let inner_rect = rect.shrink2(egui::vec2( HUD_PILL_INNER_MARGIN_X_POINTS, - HUD_PILL_INNER_MARGIN_Y_POINTS, + TOOLBAR_PILL_INNER_MARGIN_Y_POINTS, )); Self::render_frozen_toolbar_body(ui, inner_rect, toolbar_state, theme); - *hud_pill_out = Some(HudPillGeometry { - rect, - radius_points: f32::from(HUD_PILL_CORNER_RADIUS_POINTS), - }); + *hud_pill_out = + Some(HudPillGeometry { rect, radius_points: f32::from(corner_radius) }); }); } + #[cfg(not(target_os = "macos"))] fn update_frozen_toolbar_drag_state( toolbar_state: &mut FrozenToolbarState, drag_started: bool, @@ -2639,8 +2767,6 @@ impl WindowRenderer { TOOLBAR_SCREEN_MARGIN_PX, TOOLBAR_SCREEN_MARGIN_PX, )); - } else if toolbar_state.dragging { - toolbar_state.dragging = false; } } @@ -2659,8 +2785,13 @@ impl WindowRenderer { theme, ); - if Self::frozen_text_style_toolbar_visible(toolbar_state) { - Self::render_frozen_text_toolbar_section(ui, inner_rect, toolbar_state, theme); + if Self::frozen_annotation_style_toolbar_visible(toolbar_state) { + Self::render_frozen_annotation_toolbar_section( + ui, + inner_rect, + toolbar_state, + theme, + ); } }); }); @@ -2683,36 +2814,36 @@ impl WindowRenderer { ); } - fn paint_frozen_text_toolbar_spacing(ui: &mut Ui, inner_rect: Rect, theme: HudTheme) { - ui.add_space(FROZEN_TEXT_TOOLBAR_SECTION_GAP_POINTS * 0.5); + fn paint_frozen_annotation_toolbar_spacing(ui: &mut Ui, inner_rect: Rect, theme: HudTheme) { + ui.add_space(FROZEN_ANNOTATION_TOOLBAR_SECTION_GAP_POINTS * 0.5); - Self::paint_frozen_text_toolbar_divider(ui, inner_rect, theme); + Self::paint_frozen_annotation_toolbar_divider(ui, inner_rect, theme); - ui.add_space(FROZEN_TEXT_TOOLBAR_SECTION_GAP_POINTS * 0.5); + ui.add_space(FROZEN_ANNOTATION_TOOLBAR_SECTION_GAP_POINTS * 0.5); } - fn render_frozen_text_toolbar_section( + fn render_frozen_annotation_toolbar_section( ui: &mut Ui, inner_rect: Rect, toolbar_state: &mut FrozenToolbarState, theme: HudTheme, ) { - Self::paint_frozen_text_toolbar_spacing(ui, inner_rect, theme); + Self::paint_frozen_annotation_toolbar_spacing(ui, inner_rect, theme); let _ = ui.allocate_ui_with_layout( - Vec2::new(inner_rect.width(), FROZEN_TEXT_TOOLBAR_SECTION_HEIGHT_POINTS), + Vec2::new(inner_rect.width(), FROZEN_ANNOTATION_TOOLBAR_SECTION_HEIGHT_POINTS), Layout::left_to_right(Align::Center), - |ui| Self::render_frozen_text_toolbar_controls(ui, toolbar_state, theme), + |ui| Self::render_frozen_annotation_toolbar_controls(ui, toolbar_state, theme), ); } - fn paint_frozen_text_toolbar_divider(ui: &Ui, inner_rect: Rect, theme: HudTheme) { + fn paint_frozen_annotation_toolbar_divider(ui: &Ui, inner_rect: Rect, theme: HudTheme) { let divider_color = match theme { HudTheme::Dark => { - Color32::from_white_alpha(FROZEN_TEXT_TOOLBAR_SECTION_DIVIDER_ALPHA_DARK) + Color32::from_white_alpha(FROZEN_ANNOTATION_TOOLBAR_SECTION_DIVIDER_ALPHA_DARK) }, HudTheme::Light => { - Color32::from_black_alpha(FROZEN_TEXT_TOOLBAR_SECTION_DIVIDER_ALPHA_LIGHT) + Color32::from_black_alpha(FROZEN_ANNOTATION_TOOLBAR_SECTION_DIVIDER_ALPHA_LIGHT) }, }; let divider_y = ui.cursor().min.y; @@ -2724,7 +2855,7 @@ impl WindowRenderer { } #[allow(clippy::too_many_arguments)] - pub(in crate::overlay) fn render_frozen_toolbar_controls( + fn render_frozen_toolbar_controls( ui: &mut Ui, toolbar_state: &mut FrozenToolbarState, theme: HudTheme, @@ -2805,121 +2936,325 @@ impl WindowRenderer { }); } - fn frozen_text_style_toolbar_visible(toolbar_state: &FrozenToolbarState) -> bool { - toolbar_state.selected_tool == FrozenToolbarTool::Text + fn frozen_annotation_style_toolbar_visible(toolbar_state: &FrozenToolbarState) -> bool { + FrozenAnnotationStyleToolbarKind::from_toolbar_state(toolbar_state).is_some() } - fn render_frozen_text_toolbar_controls( + fn render_frozen_annotation_toolbar_controls( ui: &mut Ui, toolbar_state: &mut FrozenToolbarState, theme: HudTheme, ) { - let can_decrease = toolbar_state.text_style.font_size_points - > FROZEN_TEXT_FONT_SIZE_PRESETS[0] + f32::EPSILON; - let can_increase = toolbar_state.text_style.font_size_points - < FROZEN_TEXT_FONT_SIZE_PRESETS[FROZEN_TEXT_FONT_SIZE_PRESETS.len() - 1] - f32::EPSILON; + let Some(style_kind) = FrozenAnnotationStyleToolbarKind::from_toolbar_state(toolbar_state) + else { + toolbar_state.annotation_size_control_hovered = false; + + return; + }; + let size_label = match style_kind { + FrozenAnnotationStyleToolbarKind::Text => { + format!("{} pt", style_kind.formatted_size_text(toolbar_state)) + }, + FrozenAnnotationStyleToolbarKind::Pen => style_kind.formatted_size_text(toolbar_state), + }; ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing.x = FROZEN_TEXT_TOOLBAR_SWATCH_GAP_POINTS; + ui.spacing_mut().item_spacing.x = FROZEN_ANNOTATION_TOOLBAR_SWATCH_GAP_POINTS; - if Self::render_frozen_text_toolbar_icon_button( + Self::render_frozen_annotation_size_control( ui, - regular::MINUS, - "Smaller text", - can_decrease, + toolbar_state, theme, - ) && toolbar_state.text_style.step_font_size(-1) - { - toolbar_state.needs_redraw = true; - } - - let label_response = ui.allocate_response( - Vec2::new( - FROZEN_TEXT_TOOLBAR_SIZE_LABEL_WIDTH_POINTS, - FROZEN_TEXT_TOOLBAR_SECTION_HEIGHT_POINTS, - ), - Sense::hover(), - ); - let label_color = Self::hud_text_colors(theme).0; - - ui.painter().text( - label_response.rect.center(), - Align2::CENTER_CENTER, - format!("{} pt", toolbar_state.text_style.font_size_points.round() as i32), - FontId::new(13.0, FontFamily::Proportional), - label_color, + style_kind, + &size_label, ); - if Self::render_frozen_text_toolbar_icon_button( - ui, - regular::PLUS, - "Larger text", - can_increase, - theme, - ) && toolbar_state.text_style.step_font_size(1) - { - toolbar_state.needs_redraw = true; - } - ui.add_space(4.0); - for color in FrozenTextColor::ALL { - if Self::render_frozen_text_color_swatch( + for color in FrozenAnnotationColor::ALL { + if Self::render_frozen_annotation_color_swatch( ui, color, - toolbar_state.text_style.color == color, + style_kind.selected_color(toolbar_state) == color, theme, - ) { - toolbar_state.text_style.color = color; + ) && style_kind.set_color(toolbar_state, color) + { toolbar_state.needs_redraw = true; } } }); + + if !toolbar_state.annotation_size_control_hovered { + toolbar_state.annotation_size_wheel_accumulator = 0.0; + } } - fn render_frozen_text_toolbar_icon_button( + fn render_frozen_annotation_size_control( ui: &mut Ui, - icon: &str, - hover_text: &str, - enabled: bool, + toolbar_state: &mut FrozenToolbarState, theme: HudTheme, - ) -> bool { - let response = ui.allocate_response( + style_kind: FrozenAnnotationStyleToolbarKind, + size_label: &str, + ) { + let (size_rect, size_response) = ui.allocate_exact_size( Vec2::new( - FROZEN_TEXT_TOOLBAR_SIZE_BUTTON_WIDTH_POINTS, - FROZEN_TEXT_TOOLBAR_SECTION_HEIGHT_POINTS, + style_kind.size_control_width(), + FROZEN_ANNOTATION_TOOLBAR_SECTION_HEIGHT_POINTS, ), - Sense::click(), + Sense::hover(), + ); + let size_response = size_response.on_hover_text(style_kind.size_hover_text()); + let minus_rect = Rect::from_min_max( + size_rect.min, + Pos2::new( + size_rect.min.x + FROZEN_ANNOTATION_TOOLBAR_SIZE_BUTTON_WIDTH_POINTS, + size_rect.max.y, + ), + ); + let plus_rect = Rect::from_min_max( + Pos2::new( + size_rect.max.x - FROZEN_ANNOTATION_TOOLBAR_SIZE_BUTTON_WIDTH_POINTS, + size_rect.min.y, + ), + size_rect.max, + ); + let display_rect = Rect::from_min_max( + Pos2::new(minus_rect.max.x, size_rect.min.y), + Pos2::new(plus_rect.min.x, size_rect.max.y), + ); + let minus_response = ui + .interact( + minus_rect, + ui.id().with(("annotation-size-decrease", style_kind)), + Sense::click(), + ) + .on_hover_text(style_kind.decrease_hover_text()); + let plus_response = ui + .interact( + plus_rect, + ui.id().with(("annotation-size-increase", style_kind)), + Sense::click(), + ) + .on_hover_text(style_kind.increase_hover_text()); + let hovered = + size_response.hovered() || minus_response.hovered() || plus_response.hovered(); + let capsule_rect = size_rect.shrink2(egui::vec2(1.0, 3.0)); + let appearance = Self::frozen_annotation_size_control_appearance(theme, hovered); + + toolbar_state.annotation_size_control_hovered = hovered; + + Self::paint_frozen_annotation_size_control_frame( + ui, + capsule_rect, + display_rect, + &minus_response, + &plus_response, + appearance, ); - let hovered = enabled && response.hovered(); - let response = response.on_hover_text(hover_text); - let style = Self::frozen_toolbar_button_style(theme, enabled, hovered, false); - let bg_rect = response.rect.shrink2(egui::vec2(2.0, 3.0)); + Self::paint_frozen_annotation_size_step_button(ui, theme, &minus_response, regular::MINUS); + Self::paint_frozen_annotation_size_step_button(ui, theme, &plus_response, regular::PLUS); + Self::apply_frozen_annotation_size_control_clicks( + toolbar_state, + style_kind, + &minus_response, + &plus_response, + ); + Self::paint_frozen_annotation_size_display( + ui, + toolbar_state, + style_kind, + display_rect, + size_label, + appearance.text_color, + ); + } - if hovered { - ui.painter().rect_filled(bg_rect, 8.0, style.bg_color); + fn frozen_annotation_size_control_appearance( + theme: HudTheme, + hovered: bool, + ) -> FrozenAnnotationSizeControlAppearance { + match theme { + HudTheme::Dark => FrozenAnnotationSizeControlAppearance { + capsule_fill: Color32::from_rgba_unmultiplied( + 255, + 255, + 255, + if hovered { 22 } else { 12 }, + ), + capsule_stroke: Color32::from_rgba_unmultiplied( + 255, + 255, + 255, + if hovered { 34 } else { 22 }, + ), + divider_color: Color32::from_white_alpha(if hovered { 34 } else { 22 }), + button_hover_fill: Color32::from_rgba_unmultiplied(255, 255, 255, 16), + text_color: Self::frozen_toolbar_button_style(theme, true, hovered, false) + .icon_color, + }, + HudTheme::Light => FrozenAnnotationSizeControlAppearance { + capsule_fill: Color32::from_rgba_unmultiplied( + 0, + 0, + 0, + if hovered { 18 } else { 10 }, + ), + capsule_stroke: Color32::from_rgba_unmultiplied( + 0, + 0, + 0, + if hovered { 28 } else { 18 }, + ), + divider_color: Color32::from_black_alpha(if hovered { 30 } else { 18 }), + button_hover_fill: Color32::from_rgba_unmultiplied(0, 0, 0, 14), + text_color: Self::frozen_toolbar_button_style(theme, true, hovered, false) + .icon_color, + }, } + } + + fn paint_frozen_annotation_size_control_frame( + ui: &Ui, + capsule_rect: Rect, + display_rect: Rect, + minus_response: &Response, + plus_response: &Response, + appearance: FrozenAnnotationSizeControlAppearance, + ) { + ui.painter().rect_filled( + capsule_rect, + CornerRadius::same(FROZEN_ANNOTATION_TOOLBAR_SIZE_CAPSULE_CORNER_RADIUS_POINTS), + appearance.capsule_fill, + ); + ui.painter().rect_stroke( + capsule_rect, + CornerRadius::same(FROZEN_ANNOTATION_TOOLBAR_SIZE_CAPSULE_CORNER_RADIUS_POINTS), + Stroke::new(1.0, appearance.capsule_stroke), + StrokeKind::Inside, + ); + + for response in [minus_response, plus_response] { + if response.hovered() { + ui.painter().rect_filled( + response.rect.shrink2(egui::vec2(2.0, 4.0)), + CornerRadius::same(6), + appearance.button_hover_fill, + ); + } + } + for divider_x in [display_rect.left(), display_rect.right()] { + ui.painter().line_segment( + [ + Pos2::new(divider_x, capsule_rect.top() + 5.0), + Pos2::new(divider_x, capsule_rect.bottom() - 5.0), + ], + Stroke::new(1.0, appearance.divider_color), + ); + } + } + + fn paint_frozen_annotation_size_step_button( + ui: &Ui, + theme: HudTheme, + response: &Response, + icon: &str, + ) { + let button_style = + Self::frozen_toolbar_button_style(theme, true, response.hovered(), false); ui.painter().text( response.rect.center(), Align2::CENTER_CENTER, icon, - FontId::new(16.0, FontFamily::Proportional), - style.icon_color, + FontId::new(13.0, FontFamily::Proportional), + button_style.icon_color, ); + } - enabled && response.clicked() + fn apply_frozen_annotation_size_control_clicks( + toolbar_state: &mut FrozenToolbarState, + style_kind: FrozenAnnotationStyleToolbarKind, + minus_response: &Response, + plus_response: &Response, + ) { + let mut size_changed = false; + + if minus_response.clicked() { + toolbar_state.annotation_size_wheel_accumulator = 0.0; + size_changed |= style_kind.apply_size_steps(toolbar_state, -1); + } + if plus_response.clicked() { + toolbar_state.annotation_size_wheel_accumulator = 0.0; + size_changed |= style_kind.apply_size_steps(toolbar_state, 1); + } + if size_changed { + toolbar_state.needs_redraw = true; + } + } + + fn paint_frozen_annotation_size_display( + ui: &Ui, + toolbar_state: &FrozenToolbarState, + style_kind: FrozenAnnotationStyleToolbarKind, + display_rect: Rect, + size_label: &str, + text_color: Color32, + ) { + match style_kind { + FrozenAnnotationStyleToolbarKind::Text => { + ui.painter().text( + display_rect.center(), + Align2::CENTER_CENTER, + size_label, + FontId::new(13.0, FontFamily::Proportional), + text_color, + ); + }, + FrozenAnnotationStyleToolbarKind::Pen => { + let preview_width = toolbar_state.brush_style.stroke_width_points.clamp(1.0, 10.0); + let preview_center = Pos2::new( + display_rect.left() + + 10.0 + FROZEN_ANNOTATION_TOOLBAR_PEN_PREVIEW_LENGTH_POINTS * 0.5, + display_rect.center().y, + ); + let preview_half_length = FROZEN_ANNOTATION_TOOLBAR_PEN_PREVIEW_LENGTH_POINTS * 0.5; + let preview_start = + Pos2::new(preview_center.x - preview_half_length, preview_center.y); + let preview_end = + Pos2::new(preview_center.x + preview_half_length, preview_center.y); + let preview_color = toolbar_state.brush_style.color.swatch_fill(); + + ui.painter().line_segment( + [preview_start, preview_end], + Stroke::new(preview_width, preview_color), + ); + ui.painter().circle_filled(preview_start, preview_width * 0.5, preview_color); + ui.painter().circle_filled(preview_end, preview_width * 0.5, preview_color); + ui.painter().text( + Pos2::new( + preview_end.x + FROZEN_ANNOTATION_TOOLBAR_SIZE_PREVIEW_GAP_POINTS, + display_rect.center().y, + ), + Align2::LEFT_CENTER, + size_label, + FontId::new(13.0, FontFamily::Proportional), + text_color, + ); + }, + } } - fn render_frozen_text_color_swatch( + fn render_frozen_annotation_color_swatch( ui: &mut Ui, - color: FrozenTextColor, + color: FrozenAnnotationColor, selected: bool, theme: HudTheme, ) -> bool { - let response = ui - .allocate_response(Vec2::splat(FROZEN_TEXT_TOOLBAR_SWATCH_SIZE_POINTS), Sense::click()); - let radius = FROZEN_TEXT_TOOLBAR_SWATCH_SIZE_POINTS * 0.5 - 1.0; + let response = ui.allocate_response( + Vec2::splat(FROZEN_ANNOTATION_TOOLBAR_SWATCH_SIZE_POINTS), + Sense::click(), + ); + let radius = FROZEN_ANNOTATION_TOOLBAR_SWATCH_SIZE_POINTS * 0.5 - 1.0; let stroke_color = match theme { HudTheme::Dark => { if selected { @@ -2944,7 +3279,7 @@ impl WindowRenderer { Stroke::new(if selected { 2.0 } else { 1.0 }, stroke_color), ); - response.on_hover_text("Text color").clicked() + response.on_hover_text("Annotation color").clicked() } pub(in crate::overlay) fn frozen_toolbar_button_style( diff --git a/packages/rsnap-overlay/src/overlay/session_state.rs b/packages/rsnap-overlay/src/overlay/session_state.rs index 721b7a08..7c29abd0 100644 --- a/packages/rsnap-overlay/src/overlay/session_state.rs +++ b/packages/rsnap-overlay/src/overlay/session_state.rs @@ -8,11 +8,13 @@ use std::{ use image::RgbaImage; use crate::overlay::{ - Color32, DeviceCursorPointSource, FROZEN_TEXT_FONT_SIZE_POINTS, FROZEN_TEXT_FONT_SIZE_PRESETS, - FrozenSelectionInteractionKind, FrozenToolbarTool, GlobalPoint, LIVE_PRESENT_INTERVAL_MIN, - MonitorRect, PhysicalPosition, Pos2, REDRAW_SUBSTEP_CONTRIBUTION_FLOOR, RectPoints, - SLOW_OP_WARN_INTERVAL, ScrollCaptureTraceRecorder, ScrollDirection, ScrollSession, Vec2, - WindowId, + Color32, DeviceCursorPointSource, FROZEN_BRUSH_STROKE_WIDTH_MAX_POINTS, + FROZEN_BRUSH_STROKE_WIDTH_MIN_POINTS, FROZEN_BRUSH_STROKE_WIDTH_POINTS, + FROZEN_TEXT_FONT_SIZE_MAX_POINTS, FROZEN_TEXT_FONT_SIZE_MIN_POINTS, + FROZEN_TEXT_FONT_SIZE_POINTS, FrozenSelectionInteractionKind, FrozenToolbarTool, GlobalPoint, + LIVE_PRESENT_INTERVAL_MIN, MonitorRect, MouseScrollDelta, PhysicalPosition, Pos2, + REDRAW_SUBSTEP_CONTRIBUTION_FLOOR, RectPoints, SLOW_OP_WARN_INTERVAL, + ScrollCaptureTraceRecorder, ScrollDirection, ScrollSession, Vec2, WindowId, }; #[cfg(target_os = "macos")] use crate::overlay::{ExternalScrollInputDrainReader, MacLiveFrameStream}; @@ -142,7 +144,10 @@ pub(super) struct HudDrawConfig { pub(super) struct FrozenToolbarState { pub(super) visible: bool, pub(super) dragging: bool, + pub(super) annotation_size_control_hovered: bool, + pub(super) annotation_size_wheel_accumulator: f32, pub(super) selected_tool: FrozenToolbarTool, + pub(super) brush_style: FrozenBrushStyle, pub(super) text_style: FrozenTextStyle, pub(super) auto_center_available: bool, pub(super) undo_available: bool, @@ -160,12 +165,116 @@ pub(super) struct FrozenToolbarState { pub(super) drag_offset: Vec2, pub(super) drag_anchor: Option, } +impl FrozenToolbarState { + fn consume_annotation_size_wheel_steps(&mut self, delta: &MouseScrollDelta) -> i32 { + match delta { + MouseScrollDelta::LineDelta(_, y) => { + self.annotation_size_wheel_accumulator = 0.0; + + discrete_toolbar_wheel_steps(*y) + }, + MouseScrollDelta::PixelDelta(position) => { + self.annotation_size_wheel_accumulator += + (position.y as f32 / 24.0).clamp(-2.0, 2.0); + + let mut steps = 0_i32; + + while self.annotation_size_wheel_accumulator >= 1.0 { + steps += 1; + self.annotation_size_wheel_accumulator -= 1.0; + } + while self.annotation_size_wheel_accumulator <= -1.0 { + steps -= 1; + self.annotation_size_wheel_accumulator += 1.0; + } + + steps + }, + } + } + + fn apply_text_size_wheel_steps(&mut self, steps: i32) -> bool { + if steps == 0 { + return false; + } + + let mut next_size = self.text_style.font_size_points; + + for _ in 0..steps.abs() { + next_size = if steps > 0 { + if (next_size - next_size.round()).abs() <= f32::EPSILON { + next_size + 1.0 + } else { + next_size.ceil() + } + } else if (next_size - next_size.round()).abs() <= f32::EPSILON { + next_size - 1.0 + } else { + next_size.floor() + }; + } + + self.text_style.set_font_size(next_size) + } + + fn brush_size_wheel_step(&self) -> f32 { + match self.brush_style.stroke_width_points { + width if width < 4.0 => 0.25, + width if width < 12.0 => 0.5, + _ => 1.0, + } + } + + fn apply_brush_size_wheel_steps(&mut self, steps: i32) -> bool { + if steps == 0 { + return false; + } + + let direction = steps.signum() as f32; + let mut changed = false; + + for _ in 0..steps.abs() { + changed |= + self.brush_style.offset_stroke_width(direction * self.brush_size_wheel_step()); + } + + changed + } + + pub(super) fn apply_annotation_size_steps(&mut self, steps: i32) -> bool { + if steps == 0 { + return false; + } + + match self.selected_tool { + FrozenToolbarTool::Pen => self.apply_brush_size_wheel_steps(steps), + FrozenToolbarTool::Text => self.apply_text_size_wheel_steps(steps), + _ => false, + } + } + + pub(super) fn apply_annotation_size_wheel_delta(&mut self, delta: &MouseScrollDelta) -> bool { + if !self.annotation_size_control_hovered { + self.annotation_size_wheel_accumulator = 0.0; + + return false; + } + + let steps = self.consume_annotation_size_wheel_steps(delta); + + self.apply_annotation_size_steps(steps) + } +} + impl Default for FrozenToolbarState { fn default() -> Self { Self { visible: true, dragging: false, + annotation_size_control_hovered: false, + annotation_size_wheel_accumulator: 0.0, selected_tool: FrozenToolbarTool::Pointer, + brush_style: FrozenBrushStyle::default(), text_style: FrozenTextStyle::default(), auto_center_available: false, undo_available: false, @@ -189,6 +298,7 @@ impl Default for FrozenToolbarState { #[derive(Clone, Debug, Default, PartialEq)] pub(super) struct FrozenBrushStroke { pub(super) points: Vec, + pub(super) style: FrozenBrushStyle, } #[derive(Clone, Copy, Debug)] @@ -203,6 +313,7 @@ pub(super) struct FrozenBrushModelState { pub(super) struct ActiveFrozenBrushStroke { pub(super) raw_points: Vec, pub(super) points: Vec, + pub(super) style: FrozenBrushStyle, pub(super) model_state: FrozenBrushModelState, pub(super) started_at: Instant, pub(super) last_sample_at: Instant, @@ -215,74 +326,54 @@ pub(super) struct FrozenBrushState { pub(super) active_stroke: Option, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum FrozenTextColor { - White, - Yellow, - Green, - Blue, - Red, - Black, -} -impl FrozenTextColor { - pub(super) const ALL: [Self; 6] = - [Self::White, Self::Yellow, Self::Green, Self::Blue, Self::Red, Self::Black]; - - pub(super) const fn swatch_fill(self) -> Color32 { - match self { - Self::White => Color32::from_rgb(255, 255, 255), - Self::Yellow => Color32::from_rgb(255, 219, 77), - Self::Green => Color32::from_rgb(92, 214, 149), - Self::Blue => Color32::from_rgb(102, 178, 255), - Self::Red => Color32::from_rgb(255, 107, 107), - Self::Black => Color32::from_rgb(24, 24, 24), - } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct FrozenBrushStyle { + pub(super) stroke_width_points: f32, + pub(super) color: FrozenAnnotationColor, +} +impl FrozenBrushStyle { + pub(super) fn set_stroke_width(&mut self, stroke_width_points: f32) -> bool { + set_clamped_points( + &mut self.stroke_width_points, + stroke_width_points, + FROZEN_BRUSH_STROKE_WIDTH_MIN_POINTS, + FROZEN_BRUSH_STROKE_WIDTH_MAX_POINTS, + ) } - pub(super) const fn export_rgba(self) -> [u8; 4] { - let [r, g, b, a] = self.swatch_fill().to_array(); + pub(super) fn offset_stroke_width(&mut self, delta_points: f32) -> bool { + self.set_stroke_width(self.stroke_width_points + delta_points) + } +} - [r, g, b, a] +impl Default for FrozenBrushStyle { + fn default() -> Self { + Self { + stroke_width_points: FROZEN_BRUSH_STROKE_WIDTH_POINTS, + color: FrozenAnnotationColor::Red, + } } } #[derive(Clone, Copy, Debug, PartialEq)] pub(super) struct FrozenTextStyle { pub(super) font_size_points: f32, - pub(super) color: FrozenTextColor, + pub(super) color: FrozenAnnotationColor, } impl FrozenTextStyle { - pub(super) fn step_font_size(&mut self, direction: i8) -> bool { - let current_index = FROZEN_TEXT_FONT_SIZE_PRESETS - .iter() - .position(|size| (*size - self.font_size_points).abs() <= f32::EPSILON) - .unwrap_or_else(|| { - FROZEN_TEXT_FONT_SIZE_PRESETS - .iter() - .position(|size| *size >= self.font_size_points) - .unwrap_or(FROZEN_TEXT_FONT_SIZE_PRESETS.len().saturating_sub(1)) - }); - let next_index = if direction < 0 { - current_index.saturating_sub(1) - } else if direction > 0 { - (current_index + 1).min(FROZEN_TEXT_FONT_SIZE_PRESETS.len().saturating_sub(1)) - } else { - current_index - }; - let next_size = FROZEN_TEXT_FONT_SIZE_PRESETS[next_index]; - - if (next_size - self.font_size_points).abs() <= f32::EPSILON { - return false; - } - - self.font_size_points = next_size; - - true + pub(super) fn set_font_size(&mut self, font_size_points: f32) -> bool { + set_clamped_points( + &mut self.font_size_points, + font_size_points, + FROZEN_TEXT_FONT_SIZE_MIN_POINTS, + FROZEN_TEXT_FONT_SIZE_MAX_POINTS, + ) } } + impl Default for FrozenTextStyle { fn default() -> Self { - Self { font_size_points: FROZEN_TEXT_FONT_SIZE_POINTS, color: FrozenTextColor::Blue } + Self { font_size_points: FROZEN_TEXT_FONT_SIZE_POINTS, color: FrozenAnnotationColor::Blue } } } @@ -529,6 +620,7 @@ pub(super) struct MacOSScrollWheelEvent { #[derive(Clone, Copy, Debug)] pub(super) struct FrozenToolbarPointerState { pub(super) cursor_local: Pos2, + #[cfg(not(target_os = "macos"))] pub(super) left_button_down: bool, pub(super) left_button_went_down: bool, pub(super) left_button_went_up: bool, @@ -545,3 +637,56 @@ impl LiveSampleApplyResult { self.overlay_changed || self.hud_changed || self.loupe_changed } } + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum FrozenAnnotationColor { + White, + Yellow, + Green, + Blue, + Red, + Black, +} +impl FrozenAnnotationColor { + pub(super) const ALL: [Self; 6] = + [Self::White, Self::Yellow, Self::Green, Self::Blue, Self::Red, Self::Black]; + + pub(super) const fn swatch_fill(self) -> Color32 { + match self { + Self::White => Color32::from_rgb(255, 255, 255), + Self::Yellow => Color32::from_rgb(255, 219, 77), + Self::Green => Color32::from_rgb(92, 214, 149), + Self::Blue => Color32::from_rgb(102, 178, 255), + Self::Red => Color32::from_rgb(255, 107, 107), + Self::Black => Color32::from_rgb(24, 24, 24), + } + } + + pub(super) const fn export_rgba(self) -> [u8; 4] { + let [r, g, b, a] = self.swatch_fill().to_array(); + + [r, g, b, a] + } +} + +fn set_clamped_points(current_value: &mut f32, next_value: f32, min: f32, max: f32) -> bool { + let next_size = next_value.clamp(min, max); + + if (next_size - *current_value).abs() <= f32::EPSILON { + return false; + } + + *current_value = next_size; + + true +} + +fn discrete_toolbar_wheel_steps(units: f32) -> i32 { + if units.abs() <= f32::EPSILON { + return 0; + } + + let magnitude = if units.abs() < 1.0 { 1.0 } else { units.abs().round() }; + + units.signum() as i32 * magnitude as i32 +} diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs index c8199dc1..66c284c8 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -30,13 +30,13 @@ use egui::RawInput; use image::Rgba; #[cfg(target_os = "macos")] use image::imageops; -#[cfg(target_os = "macos")] use winit::dpi::PhysicalPosition; +#[cfg(not(target_os = "macos"))] +use winit::event::{DeviceId, TouchPhase, WindowEvent}; use winit::event::{ElementState, Ime, MouseButton, MouseScrollDelta}; #[cfg(target_os = "macos")] use winit::keyboard::ModifiersState; use winit::keyboard::{Key, NamedKey}; -#[cfg(target_os = "macos")] use winit::window::WindowId; #[cfg(target_os = "macos")] @@ -48,20 +48,20 @@ use crate::live_frame_stream_macos::MacLiveFrameStream; use crate::overlay::FrozenCaptureSource; use crate::overlay::PngAction; use crate::overlay::rendering; +use crate::overlay::session_state::FrozenBrushStyle; #[cfg(target_os = "macos")] use crate::overlay::session_state::ScrollCaptureLiveFrame; use crate::overlay::{ - self, ActiveFrozenBrushStroke, FROZEN_BRUSH_COLOR_RGBA, FROZEN_EDIT_HISTORY_LIMIT, - FROZEN_TEXT_CARET_REPAINT_INTERVAL, FrozenBrushModelState, FrozenBrushStroke, - FrozenCommittedOverlay, FrozenEditKind, FrozenExportTransform, FrozenImagePatch, - FrozenMosaicEdit, FrozenSelectionDragState, FrozenTextAnnotation, FrozenTextColor, - FrozenTextEditState, FrozenTextInputSource, FrozenToolbarState, FrozenToolbarTool, - HUD_LOUPE_STRIP_GAP_POINTS, HudRedrawSummary, HudTheme, OCCLUDED_FRAME_REDRAW_RETRY_WINDOW, - OverlaySession, Pos2, Rect, SCROLL_CAPTURE_SAMPLE_INTERVAL, SELECTION_SIZE_BADGE_GAP_PX, - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, - SelectionDashedBorderCache, SelectionFlowGeometryCache, SelectionSizeBadgeTarget, - SurfaceFrameSkipReason, TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, - Vec2, WindowRenderer, hud_helpers, + self, ActiveFrozenBrushStroke, FROZEN_EDIT_HISTORY_LIMIT, FROZEN_TEXT_CARET_REPAINT_INTERVAL, + FrozenAnnotationColor, FrozenBrushModelState, FrozenBrushStroke, FrozenCommittedOverlay, + FrozenEditKind, FrozenExportTransform, FrozenImagePatch, FrozenMosaicEdit, + FrozenSelectionDragState, FrozenTextAnnotation, FrozenTextEditState, FrozenTextInputSource, + FrozenToolbarState, FrozenToolbarTool, HUD_LOUPE_STRIP_GAP_POINTS, HudRedrawSummary, HudTheme, + OCCLUDED_FRAME_REDRAW_RETRY_WINDOW, OverlaySession, Pos2, Rect, SCROLL_CAPTURE_SAMPLE_INTERVAL, + SELECTION_SIZE_BADGE_GAP_PX, SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, + SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, SelectionDashedBorderCache, SelectionFlowGeometryCache, + SelectionSizeBadgeTarget, SurfaceFrameSkipReason, TOOLBAR_CAPTURE_GAP_PX, + TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, hud_helpers, }; #[cfg(target_os = "macos")] use crate::overlay::{ @@ -386,7 +386,33 @@ fn current_export_image_includes_frozen_brush_strokes() { let export_image = session.current_export_image().expect("annotated export image"); assert_eq!(export_image.get_pixel(7, 7), &Rgba([12, 34, 56, 255])); - assert_eq!(export_image.get_pixel(2, 2), &Rgba(FROZEN_BRUSH_COLOR_RGBA)); + assert_eq!(export_image.get_pixel(2, 2), &Rgba(FrozenAnnotationColor::Red.export_rgba())); +} + +#[test] +fn current_export_image_uses_selected_brush_color() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session + .state + .finish_freeze(monitor, image::RgbaImage::from_pixel(8, 8, Rgba([12, 34, 56, 255]))); + + session.state.frozen_capture_rect = Some(RectPoints::new(0, 0, 8, 8)); + session.authoritative_frozen_capture_ready = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Pen; + session.toolbar_state.brush_style.color = FrozenAnnotationColor::Green; + + assert!(session.begin_frozen_brush_stroke(GlobalPoint::new(2, 2))); + assert!(session.finish_frozen_brush_stroke()); + + let export_image = session.current_export_image().expect("annotated export image"); + + assert_eq!( + export_image.get_pixel(2, 2), + &Rgba(session.toolbar_state.brush_style.color.export_rgba()) + ); } #[test] @@ -414,7 +440,7 @@ fn frozen_brush_undo_and_redo_update_export_image() { let redone = session.current_export_image().expect("redo export image"); - assert_eq!(redone.get_pixel(3, 3), &Rgba(FROZEN_BRUSH_COLOR_RGBA)); + assert_eq!(redone.get_pixel(3, 3), &Rgba(FrozenAnnotationColor::Red.export_rgba())); } #[test] @@ -435,9 +461,9 @@ fn current_export_image_antialiases_frozen_brush_edges() { assert!(session.finish_frozen_brush_stroke()); let export_image = session.current_export_image().expect("annotated export image"); - let has_antialiased_edge = export_image - .pixels() - .any(|pixel| pixel != &background && pixel != &Rgba(FROZEN_BRUSH_COLOR_RGBA)); + let has_antialiased_edge = export_image.pixels().any(|pixel| { + pixel != &background && pixel != &Rgba(FrozenAnnotationColor::Red.export_rgba()) + }); assert!(has_antialiased_edge, "expected blended edge pixels around the exported brush"); } @@ -454,10 +480,11 @@ fn rasterizing_frozen_brush_clears_reused_coverage_mask() { &mut coverage_mask, export_transform, &[Pos2::new(2.0, 2.0)], + FrozenBrushStyle::default(), ); assert_eq!(export_image.get_pixel(7, 7), &Rgba([12, 34, 56, 255])); - assert_eq!(export_image.get_pixel(2, 2), &Rgba(FROZEN_BRUSH_COLOR_RGBA)); + assert_eq!(export_image.get_pixel(2, 2), &Rgba(FrozenAnnotationColor::Red.export_rgba())); } fn significant_y_direction_reversals(points: &[Pos2], min_delta: f32) -> usize { @@ -606,6 +633,7 @@ fn preview_frozen_brush_points_keep_live_modeled_path_before_commit() { Pos2::new(12.0, 4.0), ], points: vec![Pos2::new(0.0, 0.0), Pos2::new(6.0, 2.0), Pos2::new(12.0, 4.0)], + style: FrozenBrushStyle::default(), model_state: FrozenBrushModelState { filtered_input_point: Pos2::new(12.0, 4.0), modeled_point: Pos2::new(12.0, 4.0), @@ -639,6 +667,7 @@ fn preview_frozen_brush_points_follow_modeled_centerline_instead_of_raw_wobble() Pos2::new(8.0, 0.0), ], points: vec![Pos2::new(0.0, 0.0), Pos2::new(4.0, 0.04), Pos2::new(8.0, 0.0)], + style: FrozenBrushStyle::default(), model_state: FrozenBrushModelState { filtered_input_point: Pos2::new(8.0, 0.0), modeled_point: Pos2::new(8.0, 0.0), @@ -673,7 +702,11 @@ fn rendered_live_frozen_brush_wave_preview_avoids_hard_inflection_kinks() { Pos2::new(24.0, 0.0), ]; let started_at = Instant::now(); - let mut stroke = OverlaySession::new_active_frozen_brush_stroke(raw_points[0], started_at); + let mut stroke = OverlaySession::new_active_frozen_brush_stroke( + raw_points[0], + started_at, + FrozenBrushStyle::default(), + ); for (index, point) in raw_points.iter().copied().enumerate().skip(1) { let sampled_at = started_at @@ -716,7 +749,11 @@ fn rendered_live_frozen_brush_arc_preview_avoids_corner_snap() { Pos2::new(4.9, 12.0), ]; let started_at = Instant::now(); - let mut stroke = OverlaySession::new_active_frozen_brush_stroke(raw_points[0], started_at); + let mut stroke = OverlaySession::new_active_frozen_brush_stroke( + raw_points[0], + started_at, + FrozenBrushStyle::default(), + ); for (index, point) in raw_points.iter().copied().enumerate().skip(1) { let sampled_at = started_at @@ -766,7 +803,11 @@ fn rendered_live_frozen_brush_suppresses_slow_straight_wobble() { Pos2::new(8.0, 0.0), ]; let started_at = Instant::now(); - let mut stroke = OverlaySession::new_active_frozen_brush_stroke(raw_points[0], started_at); + let mut stroke = OverlaySession::new_active_frozen_brush_stroke( + raw_points[0], + started_at, + FrozenBrushStyle::default(), + ); for (index, point) in raw_points.iter().copied().enumerate().skip(1) { let sampled_at = started_at @@ -1060,7 +1101,220 @@ fn default_frozen_text_style_uses_16_point_font() { overlay::FROZEN_TEXT_FONT_SIZE_POINTS ); assert_eq!(session.toolbar_state.text_style.font_size_points, 16.0); - assert_eq!(session.toolbar_state.text_style.color, FrozenTextColor::Blue); + assert_eq!(session.toolbar_state.text_style.color, FrozenAnnotationColor::Blue); +} + +#[test] +fn default_frozen_brush_style_uses_existing_width_and_color() { + let session = OverlaySession::new(); + + assert_eq!( + session.toolbar_state.brush_style.stroke_width_points, + overlay::FROZEN_BRUSH_STROKE_WIDTH_POINTS + ); + assert_eq!(session.toolbar_state.brush_style.color, FrozenAnnotationColor::Red); +} + +#[test] +fn frozen_text_style_accepts_arbitrary_sizes_and_clamps_to_bounds() { + let mut session = OverlaySession::new(); + + assert!(session.toolbar_state.text_style.set_font_size(27.5)); + assert_eq!(session.toolbar_state.text_style.font_size_points, 27.5); + assert!(session.toolbar_state.text_style.set_font_size(2.0)); + assert_eq!( + session.toolbar_state.text_style.font_size_points, + overlay::FROZEN_TEXT_FONT_SIZE_MIN_POINTS + ); + assert!(session.toolbar_state.text_style.set_font_size(200.0)); + assert_eq!( + session.toolbar_state.text_style.font_size_points, + overlay::FROZEN_TEXT_FONT_SIZE_MAX_POINTS + ); +} + +#[test] +fn frozen_brush_style_accepts_arbitrary_sizes_and_clamps_to_bounds() { + let mut session = OverlaySession::new(); + + assert!(session.toolbar_state.brush_style.set_stroke_width(4.25)); + assert_eq!(session.toolbar_state.brush_style.stroke_width_points, 4.25); + assert!(session.toolbar_state.brush_style.set_stroke_width(0.1)); + assert_eq!( + session.toolbar_state.brush_style.stroke_width_points, + overlay::FROZEN_BRUSH_STROKE_WIDTH_MIN_POINTS + ); + assert!(session.toolbar_state.brush_style.set_stroke_width(100.0)); + assert_eq!( + session.toolbar_state.brush_style.stroke_width_points, + overlay::FROZEN_BRUSH_STROKE_WIDTH_MAX_POINTS + ); +} + +#[test] +fn toolbar_annotation_size_wheel_requires_hovered_size_control() { + let mut session = OverlaySession::new(); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!( + !session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, 1.0)) + ); + assert_eq!(session.toolbar_state.text_style.font_size_points, 16.0); +} + +#[test] +fn toolbar_annotation_size_wheel_adjusts_pen_and_text_sizes() { + let mut session = OverlaySession::new(); + + session.toolbar_state.annotation_size_control_hovered = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Pen; + + assert!( + session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, 2.0)) + ); + assert_eq!(session.toolbar_state.brush_style.stroke_width_points, 4.0); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!( + session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, -2.0)) + ); + assert_eq!(session.toolbar_state.text_style.font_size_points, 14.0); +} + +#[test] +fn toolbar_annotation_size_line_wheel_treats_nonzero_delta_as_immediate_step() { + let mut session = OverlaySession::new(); + + session.toolbar_state.annotation_size_control_hovered = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!( + session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, 0.25)) + ); + assert_eq!(session.toolbar_state.text_style.font_size_points, 17.0); + assert_eq!(session.toolbar_state.annotation_size_wheel_accumulator, 0.0); +} + +#[test] +fn toolbar_annotation_size_wheel_accumulates_trackpad_pixel_deltas() { + let mut session = OverlaySession::new(); + + session.toolbar_state.annotation_size_control_hovered = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(!session.toolbar_state.apply_annotation_size_wheel_delta( + &MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.0, 15.0)), + )); + assert_eq!(session.toolbar_state.text_style.font_size_points, 16.0); + assert!(session.toolbar_state.apply_annotation_size_wheel_delta( + &MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.0, 17.0)), + )); + assert_eq!(session.toolbar_state.text_style.font_size_points, 17.0); +} + +#[test] +fn toolbar_annotation_size_wheel_snaps_fractional_text_sizes_toward_integers() { + let mut session = OverlaySession::new(); + + session.toolbar_state.annotation_size_control_hovered = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.toolbar_state.text_style.set_font_size(27.5)); + assert!( + session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, 1.0)) + ); + assert_eq!(session.toolbar_state.text_style.font_size_points, 28.0); + assert!(session.toolbar_state.text_style.set_font_size(27.5)); + + session.toolbar_state.annotation_size_wheel_accumulator = 0.0; + + assert!( + session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, -1.0)) + ); + assert_eq!(session.toolbar_state.text_style.font_size_points, 27.0); +} + +#[test] +fn toolbar_annotation_size_wheel_uses_adaptive_pen_step_sizes() { + let mut session = OverlaySession::new(); + + session.toolbar_state.annotation_size_control_hovered = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Pen; + + assert_eq!(session.toolbar_state.brush_style.stroke_width_points, 3.5); + assert!( + session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, 1.0)) + ); + assert_eq!(session.toolbar_state.brush_style.stroke_width_points, 3.75); + assert!(session.toolbar_state.brush_style.set_stroke_width(6.0)); + assert!( + session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, 1.0)) + ); + assert_eq!(session.toolbar_state.brush_style.stroke_width_points, 6.5); + assert!(session.toolbar_state.brush_style.set_stroke_width(12.0)); + assert!( + session + .toolbar_state + .apply_annotation_size_wheel_delta(&MouseScrollDelta::LineDelta(0.0, 1.0)) + ); + assert_eq!(session.toolbar_state.brush_style.stroke_width_points, 13.0); +} + +#[test] +fn toolbar_annotation_size_steps_share_the_same_pen_and_text_logic() { + let mut session = OverlaySession::new(); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Pen; + + assert!(session.toolbar_state.apply_annotation_size_steps(1)); + assert_eq!(session.toolbar_state.brush_style.stroke_width_points, 3.75); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.toolbar_state.text_style.set_font_size(27.5)); + assert!(session.toolbar_state.apply_annotation_size_steps(-1)); + assert_eq!(session.toolbar_state.text_style.font_size_points, 27.0); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn overlay_window_mouse_wheel_routes_inline_toolbar_size_adjustments() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + session.toolbar_state.visible = true; + session.toolbar_state.annotation_size_control_hovered = true; + + let event = WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(0.0, 1.0), + phase: TouchPhase::Moved, + }; + let _ = session.handle_window_event(WindowId::dummy(), &event); + + assert_eq!(session.toolbar_state.text_style.font_size_points, 17.0); } fn test_frozen_mosaic_edit() -> FrozenMosaicEdit { @@ -1117,6 +1371,54 @@ fn toolbar_mouse_release_stops_active_frozen_text_edit_drag() { assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.dragging), Some(false)); } +#[test] +fn toolbar_cursor_left_during_drag_keeps_drag_session_alive() { + let mut session = OverlaySession::new(); + + session.toolbar_left_button_down = true; + session.toolbar_left_button_went_down = true; + session.toolbar_pointer_local = Some(Pos2::new(48.0, 18.0)); + session.toolbar_state.dragging = true; + session.toolbar_state.drag_offset = Vec2::new(12.0, 6.0); + session.toolbar_state.drag_anchor = Some(Pos2::new(40.0, 14.0)); + session.toolbar_state.annotation_size_control_hovered = true; + session.toolbar_state.annotation_size_wheel_accumulator = 24.0; + + let _ = session.handle_toolbar_cursor_left(); + + assert!(session.toolbar_left_button_down); + assert!(session.toolbar_left_button_went_down); + assert_eq!(session.toolbar_pointer_local, Some(Pos2::new(48.0, 18.0))); + assert!(session.toolbar_state.dragging); + assert_eq!(session.toolbar_state.drag_offset, Vec2::new(12.0, 6.0)); + assert_eq!(session.toolbar_state.drag_anchor, Some(Pos2::new(40.0, 14.0))); + assert!(!session.toolbar_state.annotation_size_control_hovered); + assert_eq!(session.toolbar_state.annotation_size_wheel_accumulator, 0.0); +} + +#[test] +fn toolbar_cursor_left_while_idle_clears_pointer_state() { + let mut session = OverlaySession::new(); + + session.toolbar_left_button_went_down = true; + session.toolbar_left_button_went_up = true; + session.toolbar_pointer_local = Some(Pos2::new(22.0, 12.0)); + session.toolbar_state.drag_offset = Vec2::new(5.0, 3.0); + session.toolbar_state.drag_anchor = Some(Pos2::new(18.0, 11.0)); + session.toolbar_state.annotation_size_control_hovered = true; + session.toolbar_state.annotation_size_wheel_accumulator = 16.0; + + let _ = session.handle_toolbar_cursor_left(); + + assert_eq!(session.toolbar_pointer_local, None); + assert!(!session.toolbar_left_button_went_down); + assert!(!session.toolbar_left_button_went_up); + assert_eq!(session.toolbar_state.drag_offset, Vec2::ZERO); + assert_eq!(session.toolbar_state.drag_anchor, None); + assert!(!session.toolbar_state.annotation_size_control_hovered); + assert_eq!(session.toolbar_state.annotation_size_wheel_accumulator, 0.0); +} + #[test] fn adjacent_text_events_from_key_and_ime_are_deduplicated() { let monitor = test_monitor(); @@ -1454,8 +1756,8 @@ fn finish_frozen_text_editing_commits_current_toolbar_text_style() { session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); session.toolbar_state.selected_tool = FrozenToolbarTool::Text; - session.toolbar_state.text_style.font_size_points = 30.0; - session.toolbar_state.text_style.color = FrozenTextColor::Yellow; + session.toolbar_state.text_style.font_size_points = 27.5; + session.toolbar_state.text_style.color = FrozenAnnotationColor::Yellow; assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); assert!(session.append_text_to_frozen_edit("Styled")); @@ -1463,8 +1765,31 @@ fn finish_frozen_text_editing_commits_current_toolbar_text_style() { let annotation = session.frozen_text_annotations.first().expect("annotation"); - assert_eq!(annotation.style.font_size_points, 30.0); - assert_eq!(annotation.style.color, FrozenTextColor::Yellow); + assert_eq!(annotation.style.font_size_points, 27.5); + assert_eq!(annotation.style.color, FrozenAnnotationColor::Yellow); +} + +#[test] +fn finish_frozen_brush_stroke_commits_current_toolbar_brush_style() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.state.frozen_capture_rect = Some(RectPoints::new(100, 120, 220, 180)); + session.authoritative_frozen_capture_ready = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Pen; + session.toolbar_state.brush_style.stroke_width_points = 4.25; + session.toolbar_state.brush_style.color = FrozenAnnotationColor::Green; + + assert!(session.begin_frozen_brush_stroke(GlobalPoint::new(140, 160))); + assert!(session.finish_frozen_brush_stroke()); + + let stroke = session.frozen_brush.committed_strokes.first().expect("brush stroke"); + + assert_eq!(stroke.style.stroke_width_points, 4.25); + assert_eq!(stroke.style.color, FrozenAnnotationColor::Green); } #[test] @@ -1791,10 +2116,10 @@ fn evicting_old_brush_and_text_history_discards_matching_payloads() { let x = index as f32; if index % 2 == 0 { - session - .frozen_brush - .committed_strokes - .push(FrozenBrushStroke { points: vec![Pos2::new(x, 0.0)] }); + session.frozen_brush.committed_strokes.push(FrozenBrushStroke { + points: vec![Pos2::new(x, 0.0)], + style: FrozenBrushStyle::default(), + }); session.push_frozen_edit_to_undo_history(FrozenEditKind::BrushStroke); } else { session.frozen_text_annotations.push(FrozenTextAnnotation { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index df4db57b..ace2ba01 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -21,9 +21,9 @@ use crate::overlay::tests::{ overlay, }; use crate::overlay::{ - FROZEN_TEXT_CARET_BLINK_PERIOD_SECS, FROZEN_TEXT_FONT_SIZE_POINTS, FontId, FrozenEditKind, - FrozenSelectionCorner, FrozenSelectionInteractionKind, FrozenTextAnnotation, FrozenTextColor, - FrozenTextEditState, + FROZEN_TEXT_CARET_BLINK_PERIOD_SECS, FROZEN_TEXT_FONT_SIZE_POINTS, FontId, + FrozenAnnotationColor, FrozenEditKind, FrozenSelectionCorner, FrozenSelectionInteractionKind, + FrozenTextAnnotation, FrozenTextEditState, }; use crate::worker::{WorkerErrorSource, WorkerResponse}; @@ -481,6 +481,19 @@ fn toolbar_position_update_queues_pending_move_without_window() { assert_eq!(session.pending_toolbar_outer_pos, Some(GlobalPoint::new(120, 160))); } +#[test] +fn toolbar_window_position_sync_updates_runtime_state_without_requeueing_in_bounds_move() { + let monitor = tests::test_monitor(); + let mut session = OverlaySession::new(); + + session.toolbar_inner_size_points = Some((460, 54)); + + assert!(session.sync_toolbar_outer_position_from_window(monitor, GlobalPoint::new(120, 160))); + assert_eq!(session.toolbar_state.floating_position, Some(Pos2::new(120.0, 160.0))); + assert_eq!(session.toolbar_outer_pos, Some(GlobalPoint::new(120, 160))); + assert_eq!(session.pending_toolbar_outer_pos, None); +} + #[test] fn toolbar_cursor_global_position_from_outer_uses_cached_toolbar_origin() { let outer_position = GlobalPoint::new(220, 260); @@ -819,8 +832,10 @@ fn frozen_text_edit_caret_rect_tracks_explicit_preedit_cursor_position() { #[test] fn frozen_text_placeholder_fill_tracks_selected_text_color() { - let blue = WindowRenderer::frozen_text_placeholder_fill(FrozenTextColor::Blue, HudTheme::Dark); - let red = WindowRenderer::frozen_text_placeholder_fill(FrozenTextColor::Red, HudTheme::Dark); + let blue = + WindowRenderer::frozen_text_placeholder_fill(FrozenAnnotationColor::Blue, HudTheme::Dark); + let red = + WindowRenderer::frozen_text_placeholder_fill(FrozenAnnotationColor::Red, HudTheme::Dark); assert!(blue.b() > blue.r()); assert!(red.r() > red.b()); @@ -1045,16 +1060,159 @@ fn frozen_text_caret_visible_blinks_on_half_periods() { } #[test] -fn frozen_toolbar_size_expands_for_text_style_toolbar() { +fn frozen_toolbar_size_expands_for_annotation_style_toolbar() { let mut toolbar_state = FrozenToolbarState::default(); let base_size = WindowRenderer::frozen_toolbar_size(&toolbar_state); + toolbar_state.selected_tool = FrozenToolbarTool::Pen; + + let pen_size = WindowRenderer::frozen_toolbar_size(&toolbar_state); + + assert!(pen_size.y > base_size.y); + assert_eq!(pen_size.x, base_size.x); + toolbar_state.selected_tool = FrozenToolbarTool::Text; let expanded_size = WindowRenderer::frozen_toolbar_size(&toolbar_state); assert!(expanded_size.y > base_size.y); - assert_eq!(expanded_size.x, base_size.x); + assert_eq!(expanded_size, pen_size); +} + +#[test] +fn render_frozen_toolbar_ui_keeps_runtime_drag_when_pointer_snapshot_is_missing() { + let ctx = tests::test_egui_context(); + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(200, 180, 200, 300); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); + assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); + + session.toolbar_state.dragging = true; + + let toolbar_placement = session.config.toolbar_placement; + let state = &session.state; + let toolbar_state = &mut session.toolbar_state; + let mut hud_pill = None; + let _ = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |ui: &mut Ui| { + WindowRenderer::render_frozen_toolbar_ui( + ui.ctx(), + state, + monitor, + HudTheme::Dark, + toolbar_placement, + false, + false, + 1.0, + 0.0, + 0.0, + Some(toolbar_state), + None, + &mut hud_pill, + ); + }, + ); + + assert!(hud_pill.is_some(), "toolbar should still render once readiness stabilizes"); + assert!( + session.toolbar_state.dragging, + "rendering without a pointer snapshot must not clear runtime-managed drag state" + ); +} + +#[test] +fn frozen_base_toolbar_hud_pill_uses_half_height_corner_radius() { + let ctx = tests::test_egui_context(); + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(200, 180, 200, 300); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); + assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); + + let toolbar_placement = session.config.toolbar_placement; + let state = &session.state; + let toolbar_state = &mut session.toolbar_state; + let mut hud_pill = None; + let _ = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |ui: &mut Ui| { + WindowRenderer::render_frozen_toolbar_ui( + ui.ctx(), + state, + monitor, + HudTheme::Dark, + toolbar_placement, + false, + false, + 1.0, + 0.0, + 0.0, + Some(toolbar_state), + None, + &mut hud_pill, + ); + }, + ); + let hud_pill = hud_pill.expect("base toolbar should render after readiness stabilizes"); + + assert_eq!(hud_pill.radius_points, (hud_pill.rect.height() * 0.5).round()); +} + +#[test] +fn frozen_annotation_toolbar_hud_pill_keeps_standard_corner_radius() { + let ctx = tests::test_egui_context(); + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(200, 180, 200, 300); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); + assert!(!session.advance_frozen_toolbar_readiness_sample(screen_rect)); + + let toolbar_placement = session.config.toolbar_placement; + let state = &session.state; + let toolbar_state = &mut session.toolbar_state; + let mut hud_pill = None; + let _ = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |ui: &mut Ui| { + WindowRenderer::render_frozen_toolbar_ui( + ui.ctx(), + state, + monitor, + HudTheme::Dark, + toolbar_placement, + false, + false, + 1.0, + 0.0, + 0.0, + Some(toolbar_state), + None, + &mut hud_pill, + ); + }, + ); + let hud_pill = hud_pill.expect("annotation toolbar should render after readiness stabilizes"); + + assert_eq!(hud_pill.radius_points, 18.0); } #[test] @@ -2583,6 +2741,10 @@ fn toolbar_window_startup_size_covers_every_tool_permutation() { let startup_size = overlay::frozen_toolbar_window_startup_size_points(); let toolbar_states = [ FrozenToolbarState::default(), + FrozenToolbarState { + selected_tool: FrozenToolbarTool::Pen, + ..FrozenToolbarState::default() + }, FrozenToolbarState { selected_tool: FrozenToolbarTool::Text, ..FrozenToolbarState::default() @@ -2594,6 +2756,12 @@ fn toolbar_window_startup_size_covers_every_tool_permutation() { scroll_capture_available: true, ..FrozenToolbarState::default() }, + FrozenToolbarState { + selected_tool: FrozenToolbarTool::Pen, + auto_center_available: true, + scroll_capture_available: true, + ..FrozenToolbarState::default() + }, FrozenToolbarState { selected_tool: FrozenToolbarTool::Text, auto_center_available: true, diff --git a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs index 7b791dc7..e62c27c4 100644 --- a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs @@ -1,13 +1,74 @@ use crate::overlay::{ - Arc, FrozenToolbarPointerState, GlobalPoint, HUD_PILL_CORNER_RADIUS_POINTS, HudOverlayWindow, - Instant, MonitorRect, OverlayControl, OverlayEventLoopPhase, OverlayExit, OverlayMode, - OverlaySession, PhysicalPosition, PhysicalSize, Pos2, Result, TOOLBAR_DRAG_START_THRESHOLD_PX, - Vec2, WindowId, + self, Arc, FrozenToolbarPointerState, GlobalPoint, HudOverlayWindow, Instant, MonitorRect, + OverlayControl, OverlayEventLoopPhase, OverlayExit, OverlayMode, OverlaySession, + PhysicalPosition, PhysicalSize, Pos2, Result, TOOLBAR_DRAG_START_THRESHOLD_PX, Vec2, WindowId, + WindowRenderer, }; #[cfg(target_os = "macos")] use crate::overlay::{FrozenCaptureSource, HudAnchor, LogicalSize, TOOLBAR_WINDOW_WARMUP_REDRAWS}; impl OverlaySession { + pub(super) fn handle_toolbar_window_moved( + &mut self, + window_id: WindowId, + position: PhysicalPosition, + ) -> OverlayControl { + let Some((toolbar_window_id, toolbar_scale)) = + self.toolbar_window.as_ref().map(|toolbar_window| { + (toolbar_window.window.id(), toolbar_window.window.scale_factor().max(1.0)) + }) + else { + return OverlayControl::Continue; + }; + + if toolbar_window_id != window_id + || !matches!(self.state.mode, OverlayMode::Frozen) + || !self.toolbar_state.visible + { + return OverlayControl::Continue; + } + + let Some(monitor) = self.state.monitor.or_else(|| self.active_cursor_monitor()) else { + return OverlayControl::Continue; + }; + let outer_position = GlobalPoint::new( + (f64::from(position.x) / toolbar_scale).round() as i32, + (f64::from(position.y) / toolbar_scale).round() as i32, + ); + let changed = self.sync_toolbar_outer_position_from_window(monitor, outer_position); + + if self.pending_toolbar_outer_pos.is_some() { + self.force_apply_pending_toolbar_window_move(); + } else { + self.last_toolbar_window_move_at = Instant::now(); + } + if changed { + self.request_redraw_toolbar_window(); + } + + OverlayControl::Continue + } + + pub(super) fn handle_toolbar_cursor_left(&mut self) -> OverlayControl { + self.toolbar_state.annotation_size_control_hovered = false; + self.toolbar_state.annotation_size_wheel_accumulator = 0.0; + + if !self.toolbar_left_button_down && !self.toolbar_state.dragging { + self.toolbar_pointer_local = None; + self.toolbar_left_button_went_down = false; + self.toolbar_left_button_went_up = false; + self.toolbar_state.drag_offset = Vec2::ZERO; + self.toolbar_state.drag_anchor = None; + } + + #[cfg(target_os = "macos")] + { + self.request_redraw_toolbar_window(); + } + + OverlayControl::Continue + } + pub(super) fn note_frozen_toolbar_cursor_event( &mut self, monitor: MonitorRect, @@ -37,24 +98,31 @@ impl OverlaySession { window_id: WindowId, position: PhysicalPosition, ) -> OverlayControl { - let Some(toolbar_window) = self.toolbar_window.as_ref() else { + let Some((toolbar_window_id, toolbar_scale, window_toolbar_outer_pos)) = + self.toolbar_window.as_ref().map(|toolbar_window| { + ( + toolbar_window.window.id(), + toolbar_window.window.scale_factor().max(1.0), + Self::toolbar_window_outer_position(toolbar_window), + ) + }) + else { return OverlayControl::Continue; }; - if toolbar_window.window.id() != window_id + if toolbar_window_id != window_id || !matches!(self.state.mode, OverlayMode::Frozen) || !self.toolbar_state.visible { return OverlayControl::Continue; } - let scale = toolbar_window.window.scale_factor().max(1.0); - let cursor_local = Pos2::new((position.x / scale) as f32, (position.y / scale) as f32); + let cursor_local = + Pos2::new((position.x / toolbar_scale) as f32, (position.y / toolbar_scale) as f32); self.toolbar_pointer_local = Some(cursor_local); let cached_toolbar_outer_pos = self.toolbar_outer_pos; - let window_toolbar_outer_pos = Self::toolbar_window_outer_position(toolbar_window); let monitor = match self.state.monitor.or_else(|| self.active_cursor_monitor()) { Some(monitor) => monitor, None => return OverlayControl::Continue, @@ -68,49 +136,40 @@ impl OverlaySession { let global_cursor = toolbar_outer_pos .map(|outer| Self::toolbar_cursor_global_position_from_outer(outer, cursor_local)); - if self.frozen_selection_drag.active { - if let Some(global_cursor) = global_cursor { - self.update_frozen_selection_drag_rect(global_cursor); - self.update_frozen_mosaic_drag_rect(global_cursor); - } - + if self.handle_toolbar_cursor_move_for_active_selection(global_cursor) { return OverlayControl::Continue; } - if self.frozen_mosaic_drag.active { - if let Some(global_cursor) = global_cursor { - self.update_frozen_mosaic_drag_rect(global_cursor); - } - return OverlayControl::Continue; - } - - if let Some(global_cursor) = global_cursor { - self.maybe_log_suspicious_toolbar_cursor_translation( - monitor, - self.state.cursor, - cursor_local, - global_cursor, - cached_toolbar_outer_pos, - window_toolbar_outer_pos, - ); - self.note_frozen_toolbar_cursor_event(monitor, global_cursor); - } + self.update_toolbar_cursor_event_from_global( + monitor, + cursor_local, + global_cursor, + cached_toolbar_outer_pos, + window_toolbar_outer_pos, + ); - let drag_monitor = - global_cursor.and_then(|cursor| self.monitor_at(cursor)).unwrap_or(monitor); + #[cfg(not(target_os = "macos"))] + let drag_monitor = global_cursor.and_then(|cursor| self.monitor_at(cursor)).unwrap_or(monitor); + #[cfg(target_os = "macos")] + let mouse_drag = self.toolbar_left_button_down && self.toolbar_state.dragging; + #[cfg(not(target_os = "macos"))] let mut mouse_drag = self.toolbar_left_button_down && self.toolbar_state.dragging; if self.toolbar_left_button_down && self.toolbar_state.drag_anchor.is_none() { self.toolbar_state.drag_anchor = Some(cursor_local); } - if !mouse_drag && let Some(drag_anchor) = self.toolbar_state.drag_anchor { - let dx = cursor_local.x - drag_anchor.x; - let dy = cursor_local.y - drag_anchor.y; - let threshold_sq = TOOLBAR_DRAG_START_THRESHOLD_PX * TOOLBAR_DRAG_START_THRESHOLD_PX; + if !mouse_drag + && let Some(drag_anchor) = self.toolbar_state.drag_anchor + && Self::toolbar_drag_threshold_reached(cursor_local, drag_anchor) + { + #[cfg(target_os = "macos")] + { + return self.begin_native_toolbar_drag(); + } - if dx * dx + dy * dy >= threshold_sq - && let (Some(global_cursor), Some(toolbar_outer_pos)) = - (global_cursor, toolbar_outer_pos) + #[cfg(not(target_os = "macos"))] + if let (Some(global_cursor), Some(toolbar_outer_pos)) = + (global_cursor, toolbar_outer_pos) { self.toolbar_state.drag_offset = Vec2::new( global_cursor.x as f32 - toolbar_outer_pos.x as f32, @@ -121,9 +180,11 @@ impl OverlaySession { mouse_drag = true; } } + #[cfg(not(target_os = "macos"))] if mouse_drag && global_cursor.is_none() { mouse_drag = false; } + #[cfg(not(target_os = "macos"))] if mouse_drag && let Some(global_cursor) = global_cursor { let desired_global = Pos2::new( global_cursor.x as f32 - self.toolbar_state.drag_offset.x, @@ -135,12 +196,83 @@ impl OverlaySession { ); let _ = self.update_toolbar_outer_position(drag_monitor, desired_local); } + #[cfg(target_os = "macos")] + if self.toolbar_state.dragging { + return OverlayControl::Continue; + } self.request_redraw_toolbar_window(); OverlayControl::Continue } + fn handle_toolbar_cursor_move_for_active_selection( + &mut self, + global_cursor: Option, + ) -> bool { + if self.frozen_selection_drag.active { + if let Some(global_cursor) = global_cursor { + self.update_frozen_selection_drag_rect(global_cursor); + self.update_frozen_mosaic_drag_rect(global_cursor); + } + + return true; + } + if self.frozen_mosaic_drag.active { + if let Some(global_cursor) = global_cursor { + self.update_frozen_mosaic_drag_rect(global_cursor); + } + + return true; + } + + false + } + + #[cfg(target_os = "macos")] + fn begin_native_toolbar_drag(&mut self) -> OverlayControl { + self.toolbar_state.dragging = true; + self.toolbar_state.drag_anchor = None; + + let Some(toolbar_window_handle) = + self.toolbar_window.as_ref().map(|toolbar_window| Arc::clone(&toolbar_window.window)) + else { + return OverlayControl::Continue; + }; + let _ = toolbar_window_handle.drag_window(); + + OverlayControl::Continue + } + + fn update_toolbar_cursor_event_from_global( + &mut self, + monitor: MonitorRect, + cursor_local: Pos2, + global_cursor: Option, + cached_toolbar_outer_pos: Option, + window_toolbar_outer_pos: Option, + ) { + if let Some(global_cursor) = global_cursor { + self.maybe_log_suspicious_toolbar_cursor_translation( + monitor, + self.state.cursor, + cursor_local, + global_cursor, + cached_toolbar_outer_pos, + window_toolbar_outer_pos, + ); + self.note_frozen_toolbar_cursor_event(monitor, global_cursor); + } + } + + fn toolbar_drag_threshold_reached(cursor_local: Pos2, drag_anchor: Pos2) -> bool { + let dx = cursor_local.x - drag_anchor.x; + let dy = cursor_local.y - drag_anchor.y; + let threshold_sq = TOOLBAR_DRAG_START_THRESHOLD_PX * TOOLBAR_DRAG_START_THRESHOLD_PX; + + dx * dx + dy * dy >= threshold_sq + } + pub(super) fn toolbar_event_outer_position_from_sources( monitor: MonitorRect, window_toolbar_outer_pos: Option, @@ -267,7 +399,9 @@ impl OverlaySession { self.configure_hud_window_common( window.as_ref(), - Some(f64::from(HUD_PILL_CORNER_RADIUS_POINTS)), + Some(overlay::frozen_toolbar_corner_radius_points( + WindowRenderer::frozen_toolbar_size(&self.toolbar_state).y, + )), ); OverlayControl::Continue @@ -410,6 +544,11 @@ impl OverlaySession { { self.toolbar_inner_size_points = Some(desired); + self.configure_hud_window_common( + toolbar_window.as_ref(), + Some(overlay::frozen_toolbar_corner_radius_points(desired.1 as f32)), + ); + let _ = toolbar_window.request_inner_size(LogicalSize::new( f64::from(desired.0), f64::from(desired.1), diff --git a/packages/rsnap-overlay/src/overlay/window_position_runtime.rs b/packages/rsnap-overlay/src/overlay/window_position_runtime.rs index b50c58b7..67652c19 100644 --- a/packages/rsnap-overlay/src/overlay/window_position_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_position_runtime.rs @@ -230,4 +230,48 @@ impl OverlaySession { true } + + pub(super) fn sync_toolbar_outer_position_from_window( + &mut self, + monitor: MonitorRect, + outer_position: GlobalPoint, + ) -> bool { + let toolbar_size = if let Some((width, height)) = self.toolbar_inner_size_points { + Vec2::new(width as f32, height as f32) + } else if let Some(toolbar_window) = self.toolbar_window.as_ref() { + let toolbar_scale = toolbar_window.window.scale_factor().max(1.0); + let size = toolbar_window.window.inner_size(); + let toolbar_w = ((size.width as f64) / toolbar_scale).ceil().max(1.0) as f32; + let toolbar_h = ((size.height as f64) / toolbar_scale).ceil().max(1.0) as f32; + + Vec2::new(toolbar_w, toolbar_h) + } else { + WindowRenderer::frozen_toolbar_size(&self.toolbar_state) + }; + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let local_pos = Pos2::new( + outer_position.x as f32 - monitor.origin.x as f32, + outer_position.y as f32 - monitor.origin.y as f32, + ); + let clamped_local_pos = WindowRenderer::clamp_toolbar_position( + screen_rect, + toolbar_size, + local_pos, + TOOLBAR_SCREEN_MARGIN_PX, + TOOLBAR_SCREEN_MARGIN_PX, + ); + let desired = GlobalPoint::new( + monitor.origin.x.saturating_add(clamped_local_pos.x.round() as i32), + monitor.origin.y.saturating_add(clamped_local_pos.y.round() as i32), + ); + let changed = self.toolbar_outer_pos != Some(desired) + || self.toolbar_state.floating_position != Some(clamped_local_pos); + + self.toolbar_outer_pos = Some(desired); + self.toolbar_state.floating_position = Some(clamped_local_pos); + self.pending_toolbar_outer_pos = (desired != outer_position).then_some(desired); + + changed + } } diff --git a/packages/rsnap-overlay/src/overlay/window_runtime.rs b/packages/rsnap-overlay/src/overlay/window_runtime.rs index 0964505a..41e3e651 100644 --- a/packages/rsnap-overlay/src/overlay/window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_runtime.rs @@ -10,16 +10,17 @@ use objc2_foundation::NSArray; use winit::window::{Window, WindowId}; use crate::backend; +use crate::overlay; #[cfg(target_os = "macos")] use crate::overlay::MacOSHudWindowConfigState; -#[cfg(target_os = "macos")] -use crate::overlay::{self, MacLiveFrameStream, MainThreadMarker, NSScreen}; use crate::overlay::{ - ActiveEventLoop, GlobalPoint, GpuContext, HUD_PILL_CORNER_RADIUS_POINTS, HudOverlayWindow, - LOUPE_TILE_CORNER_RADIUS_POINTS, LiveSampleApplyResult, LogicalPosition, LogicalSize, - MonitorRect, OverlayMode, OverlaySession, OverlayWindow, OverlayWorker, Result, - ScrollPreviewWindow, TOOLBAR_EXPANDED_HEIGHT_PX, WindowLevel, WindowRenderer, hud_helpers, + ActiveEventLoop, GlobalPoint, GpuContext, HudOverlayWindow, LOUPE_TILE_CORNER_RADIUS_POINTS, + LiveSampleApplyResult, LogicalPosition, LogicalSize, MonitorRect, OverlayMode, OverlaySession, + OverlayWindow, OverlayWorker, Result, ScrollPreviewWindow, TOOLBAR_EXPANDED_HEIGHT_PX, + WindowLevel, WindowRenderer, hud_helpers, }; +#[cfg(target_os = "macos")] +use crate::overlay::{MacLiveFrameStream, MainThreadMarker, NSScreen}; impl OverlaySession { /// Starts the overlay session and creates the required capture windows. @@ -855,7 +856,9 @@ impl OverlaySession { window.set_transparent(true); self.configure_hud_window_common( window.as_ref(), - Some(f64::from(HUD_PILL_CORNER_RADIUS_POINTS)), + Some(overlay::frozen_toolbar_corner_radius_points( + startup_size.y.max(TOOLBAR_EXPANDED_HEIGHT_PX), + )), ); window.request_redraw();