diff --git a/Cargo.lock b/Cargo.lock index f51c78ab..24042c16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,6 +825,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1366,6 +1375,45 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font8x8" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.25.1", +] + +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser 0.21.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1870,6 +1918,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -3231,7 +3281,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -3816,6 +3866,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsnap" version = "0.1.0" @@ -3859,6 +3915,9 @@ dependencies = [ "egui-phosphor", "egui-wgpu", "egui-winit", + "font8x8", + "fontdb", + "fontdue", "image", "objc", "objc2 0.6.4", @@ -3877,6 +3936,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "tracing-subscriber", + "ttf-parser 0.21.1", "wgpu", "winit", "xcap", @@ -4467,6 +4527,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml" version = "0.8.2" @@ -4668,11 +4743,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "type-map" diff --git a/Cargo.toml b/Cargo.toml index 1438c682..b9b4528b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ egui = { version = "0.34" } egui-phosphor = { version = "0.12", features = ["fill"] } egui-wgpu = { version = "0.34" } egui-winit = { version = "0.34" } +font8x8 = { version = "0.3" } +fontdb = { version = "0.23" } +fontdue = { version = "0.9" } global-hotkey = { version = "0.7", features = ["tracing"] } image = { version = "0.25", default-features = false, features = ["png"] } objc = { version = "0.2" } @@ -42,6 +45,7 @@ tracing = { version = "0.1" } tracing-appender = { version = "0.2" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tray-icon = { version = "0.22" } +ttf-parser = { version = "0.21" } wgpu = { version = "29.0" } winit = { version = "0.30", features = ["rwh_06"] } xcap = { version = "0.9" } diff --git a/packages/rsnap-overlay/Cargo.toml b/packages/rsnap-overlay/Cargo.toml index e0dcb202..203e2e58 100644 --- a/packages/rsnap-overlay/Cargo.toml +++ b/packages/rsnap-overlay/Cargo.toml @@ -23,6 +23,9 @@ egui = { workspace = true } egui-phosphor = { workspace = true } egui-wgpu = { workspace = true } egui-winit = { workspace = true } +font8x8 = { workspace = true } +fontdb = { workspace = true } +fontdue = { workspace = true } image = { workspace = true } pollster = { workspace = true } serde = { workspace = true } @@ -30,6 +33,7 @@ serde_json = { version = "1.0" } thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +ttf-parser = { workspace = true } wgpu = { workspace = true } winit = { workspace = true } diff --git a/packages/rsnap-overlay/src/lib.rs b/packages/rsnap-overlay/src/lib.rs index 90ed40a4..ecb27777 100644 --- a/packages/rsnap-overlay/src/lib.rs +++ b/packages/rsnap-overlay/src/lib.rs @@ -34,6 +34,8 @@ mod overlay; mod png; mod scroll_capture; mod state; +mod system_fonts; +mod text_rendering; mod worker; #[cfg(target_os = "macos")] diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index c15e0ba1..a9a570a2 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -137,7 +137,7 @@ use winit::event::Modifiers; use winit::window::Window; use winit::{ dpi::PhysicalSize, - event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}, + event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, event_loop::ActiveEventLoop, keyboard::{Key, ModifiersState, NamedKey}, window::{CursorIcon, WindowId, WindowLevel}, @@ -159,7 +159,8 @@ use self::session_state::InflightScrollCaptureObservation; use self::session_state::{ ActiveFrozenBrushStroke, CursorMoveTrace, FrozenBrushModelState, FrozenBrushState, FrozenBrushStroke, FrozenMosaicDragState, FrozenSelectionDragCursorMoveTiming, - FrozenSelectionDragState, FrozenToolbarPointerState, FrozenToolbarState, HudDrawConfig, + FrozenSelectionDragState, FrozenTextAnnotation, FrozenTextColor, FrozenTextEditState, + FrozenTextStyle, FrozenToolbarPointerState, FrozenToolbarState, HudDrawConfig, LiveSampleApplyResult, ScrollCaptureState, SlowOperationLogger, WindowFreezeCaptureTarget, }; #[cfg(target_os = "macos")] @@ -178,6 +179,7 @@ use crate::deferred_text_recognition::DeferredTextRecognitionRequest; use crate::live_frame_stream_macos::{CursorSampleRequest, MacLiveFrameStream}; use crate::scroll_capture::{self, ScrollDirection, ScrollObserveOutcome, ScrollSession}; use crate::state::LiveCursorSample; +use crate::text_rendering::{self, RasterTextAnnotation}; use crate::worker::CapturedMonitorRegionResult; use crate::{ state::{ @@ -252,7 +254,6 @@ 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_MAX_TOOL_COUNT: usize = 10; 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); @@ -294,11 +295,6 @@ const SCROLL_CAPTURE_DUPLICATE_STREAM_REFRESH_INTERVAL: Duration = Duration::fro 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_WIDTH_PX: f32 = (TOOLBAR_MAX_TOOL_COUNT as f32) - * FROZEN_TOOLBAR_BUTTON_SIZE_POINTS - + ((TOOLBAR_MAX_TOOL_COUNT as f32) - 1.0) * FROZEN_TOOLBAR_ITEM_SPACING_POINTS - + 2.0 * HUD_PILL_INNER_MARGIN_X_POINTS - + 2.0 * HUD_PILL_STROKE_WIDTH_POINTS; const TOOLBAR_EXPANDED_HEIGHT_PX: f32 = FROZEN_TOOLBAR_BUTTON_SIZE_POINTS + 2.0 * HUD_PILL_INNER_MARGIN_Y_POINTS + 2.0 * HUD_PILL_STROKE_WIDTH_POINTS; @@ -306,6 +302,11 @@ 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_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); const SELECTION_SIZE_BADGE_FONT_SIZE_POINTS: f32 = 13.0; const SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS: f32 = 2.0; const SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX: f32 = 1.0; @@ -911,6 +912,11 @@ pub struct OverlaySession { png_encode_inflight: bool, #[cfg(target_os = "macos")] pending_self_capture_exception_window_ids_worker_refresh: bool, + frozen_text_annotations: Vec, + frozen_text_redo_annotations: Vec, + frozen_text_edit: Option, + frozen_text_input_generation: u64, + frozen_text_recent_input: Option, toolbar_state: FrozenToolbarState, toolbar_left_button_down: bool, toolbar_left_button_went_down: bool, @@ -922,6 +928,8 @@ pub struct OverlaySession { frozen_brush: FrozenBrushState, frozen_selection_drag: FrozenSelectionDragState, frozen_mosaic_drag: FrozenMosaicDragState, + frozen_edit_undo_stack: Vec, + frozen_edit_redo_stack: Vec, frozen_mosaic_undo_stack: Vec, frozen_mosaic_redo_stack: Vec, hud_window_visible: bool, @@ -1121,6 +1129,11 @@ impl OverlaySession { png_encode_inflight: false, #[cfg(target_os = "macos")] pending_self_capture_exception_window_ids_worker_refresh: false, + frozen_text_annotations: Vec::new(), + frozen_text_redo_annotations: Vec::new(), + frozen_text_edit: None, + frozen_text_input_generation: 0, + frozen_text_recent_input: None, toolbar_state: FrozenToolbarState::default(), toolbar_left_button_down: false, toolbar_left_button_went_down: false, toolbar_left_button_went_up: false, toolbar_pointer_local: None, @@ -1128,6 +1141,8 @@ impl OverlaySession { frozen_brush: FrozenBrushState::default(), frozen_selection_drag: FrozenSelectionDragState::default(), frozen_mosaic_drag: FrozenMosaicDragState::default(), + frozen_edit_undo_stack: Vec::new(), + frozen_edit_redo_stack: Vec::new(), frozen_mosaic_undo_stack: Vec::new(), frozen_mosaic_redo_stack: Vec::new(), hud_window_visible: false, toolbar_window_visible: false, @@ -1588,6 +1603,26 @@ impl OverlaySession { } } + fn reset_frozen_text_state(&mut self) { + self.frozen_text_annotations.clear(); + self.frozen_text_redo_annotations.clear(); + + self.frozen_text_edit = None; + + self.sync_text_input_ime_state(); + } + + fn reset_frozen_annotation_state(&mut self) { + self.frozen_brush = FrozenBrushState::default(); + self.frozen_selection_drag = FrozenSelectionDragState::default(); + self.frozen_mosaic_drag = FrozenMosaicDragState::default(); + + self.frozen_edit_undo_stack.clear(); + self.frozen_edit_redo_stack.clear(); + self.frozen_mosaic_undo_stack.clear(); + self.frozen_mosaic_redo_stack.clear(); + } + fn begin_frozen_capture_with_rect( &mut self, monitor: MonitorRect, @@ -1617,12 +1652,8 @@ impl OverlaySession { self.state.frozen_mosaic_preview_rect = None; self.state.drag_rect = None; self.state.hovered_window_rect = None; - self.frozen_brush = FrozenBrushState::default(); - self.frozen_selection_drag = FrozenSelectionDragState::default(); - self.frozen_mosaic_drag = FrozenMosaicDragState::default(); - self.frozen_mosaic_undo_stack.clear(); - self.frozen_mosaic_redo_stack.clear(); + self.reset_frozen_annotation_state(); tracing::debug!( monitor_id = monitor.id, @@ -1643,6 +1674,7 @@ impl OverlaySession { self.toolbar_state.layout_last_screen_size_points = None; self.toolbar_state.layout_stable_frames = 0; + self.reset_frozen_text_state(); self.sync_frozen_toolbar_state(); // Spawn the toolbar immediately at the default position (capture aware). This avoids any // dependency on egui viewport stabilization or additional input events (mouse move) to @@ -1959,6 +1991,21 @@ impl OverlaySession { let Some((cursor_x, cursor_y)) = monitor.local_u32(cursor) else { return CursorIcon::Default; }; + let cursor_local = Pos2::new(cursor_x as f32, cursor_y as f32); + + if let Some(hit_rect) = self.frozen_text_edit_hit_rect_for_monitor(monitor) + && hit_rect.contains(cursor_local) + { + return if self.frozen_text_edit.as_ref().is_some_and(|edit| edit.dragging) { + CursorIcon::Grabbing + } else { + CursorIcon::Grab + }; + } + + if self.frozen_text_tool_active() && capture_rect.contains((cursor_x, cursor_y)) { + return CursorIcon::Text; + } match Self::frozen_selection_interaction_kind(capture_rect, cursor_x, cursor_y) { Some(FrozenSelectionInteractionKind::Resize(corner)) => { @@ -2235,9 +2282,6 @@ impl OverlaySession { let point = Pos2::new(cursor_x as f32, cursor_y as f32); let sampled_at = Instant::now(); - self.frozen_brush.redo_strokes.clear(); - self.sync_frozen_toolbar_state(); - self.frozen_brush.active_stroke = Some(Self::new_active_frozen_brush_stroke(point, sampled_at)); @@ -2282,6 +2326,7 @@ impl OverlaySession { self.frozen_brush .committed_strokes .push(FrozenBrushStroke { points: Self::finished_frozen_brush_points(&stroke) }); + self.push_frozen_edit_to_undo_history(FrozenEditKind::BrushStroke); self.sync_frozen_toolbar_state(); if let Some(monitor) = self.state.monitor { @@ -3502,92 +3547,120 @@ impl OverlaySession { } } - fn annotated_frozen_export_image(&self, mut export_image: RgbaImage) -> RgbaImage { - if self.frozen_brush.committed_strokes.is_empty() - && self.frozen_brush.active_stroke.is_none() - { - return export_image; + fn render_frozen_committed_overlays_into_image(&self, image: &mut RgbaImage) { + if self.scroll_capture.active { + return; } - let Some(monitor) = self.state.monitor else { - return export_image; - }; - let Some(capture_rect) = self.frozen_capture_rect_for_monitor(monitor) else { - return export_image; + let Some(export_transform) = self.frozen_export_transform_for_image(image) else { + return; }; - - Self::rasterize_frozen_brush_strokes( - &mut export_image, - monitor, - capture_rect, - &self.frozen_brush, + let mut brush_coverage_mask = None; + + Self::for_each_frozen_committed_overlay( + &self.frozen_edit_undo_stack, + &self.frozen_brush.committed_strokes, + &self.frozen_text_annotations, + |overlay| match overlay { + FrozenCommittedOverlay::Brush(stroke) => { + let coverage_mask = brush_coverage_mask.get_or_insert_with(|| { + vec![0_u8; image.width() as usize * image.height() as usize] + }); + + Self::rasterize_frozen_brush_points_into_image( + image, + coverage_mask, + export_transform, + &stroke.points, + ); + }, + FrozenCommittedOverlay::Text(annotation) => { + Self::render_frozen_text_annotation_into_image( + image, + export_transform, + annotation, + ); + }, + }, ); - export_image + if let Some(active_stroke) = &self.frozen_brush.active_stroke { + let display_points = Self::active_frozen_brush_display_points(active_stroke); + let coverage_mask = brush_coverage_mask.get_or_insert_with(|| { + vec![0_u8; image.width() as usize * image.height() as usize] + }); + + Self::rasterize_frozen_brush_points_into_image( + image, + coverage_mask, + export_transform, + &display_points, + ); + } } - fn rasterize_frozen_brush_strokes( + fn rasterize_frozen_brush_points_into_image( export_image: &mut RgbaImage, - monitor: MonitorRect, - capture_rect: RectPoints, - frozen_brush: &FrozenBrushState, + coverage_mask: &mut [u8], + export_transform: FrozenExportTransform, + points: &[Pos2], ) { if export_image.width() == 0 || export_image.height() == 0 { return; } - - let radius = (FROZEN_BRUSH_STROKE_WIDTH_POINTS * monitor.scale_factor() * 0.5).max(1.0); - let color = image::Rgba(FROZEN_BRUSH_COLOR_RGBA); - let mut coverage_mask = - vec![0_u8; export_image.width() as usize * export_image.height() as usize]; - - for stroke in &frozen_brush.committed_strokes { - Self::rasterize_frozen_brush_stroke( - &mut coverage_mask, - export_image.width(), - export_image.height(), - stroke, - capture_rect, - monitor, - radius, - ); + if coverage_mask.len() != export_image.width() as usize * export_image.height() as usize { + return; } - if let Some(active_stroke) = &frozen_brush.active_stroke { - let display_points = Self::active_frozen_brush_display_points(active_stroke); - - Self::rasterize_frozen_brush_points( - &mut coverage_mask, - export_image.width(), - export_image.height(), - &display_points, - capture_rect, - monitor, - radius, - ); - } + let radius = + (FROZEN_BRUSH_STROKE_WIDTH_POINTS * export_transform.scalar_scale() * 0.5).max(1.0); + let color = image::Rgba(FROZEN_BRUSH_COLOR_RGBA); - Self::blend_frozen_brush_coverage_mask(export_image, &coverage_mask, color); - } + coverage_mask.fill(0); - fn rasterize_frozen_brush_stroke( - coverage_mask: &mut [u8], - export_width: u32, - export_height: u32, - stroke: &FrozenBrushStroke, - capture_rect: RectPoints, - monitor: MonitorRect, - radius: f32, - ) { Self::rasterize_frozen_brush_points( coverage_mask, - export_width, - export_height, - &stroke.points, - capture_rect, - monitor, + export_image.width(), + export_image.height(), + points, + export_transform, radius, ); + Self::blend_frozen_brush_coverage_mask(export_image, coverage_mask, color); + } + + fn for_each_frozen_committed_overlay( + edit_history: &[FrozenEditKind], + brush_strokes: &[FrozenBrushStroke], + text_annotations: &[FrozenTextAnnotation], + mut f: impl FnMut(FrozenCommittedOverlay<'_>), + ) { + let mut brush_index = 0; + let mut text_index = 0; + + for edit_kind in edit_history { + match edit_kind { + FrozenEditKind::BrushStroke => { + let Some(stroke) = brush_strokes.get(brush_index) else { + continue; + }; + + brush_index += 1; + + f(FrozenCommittedOverlay::Brush(stroke)); + }, + FrozenEditKind::TextAnnotation => { + let Some(annotation) = text_annotations.get(text_index) else { + continue; + }; + + text_index += 1; + + f(FrozenCommittedOverlay::Text(annotation)); + }, + FrozenEditKind::MosaicEdit => {}, + } + } } fn rasterize_frozen_brush_points( @@ -3595,8 +3668,7 @@ impl OverlaySession { export_width: u32, export_height: u32, points: &[Pos2], - capture_rect: RectPoints, - monitor: MonitorRect, + export_transform: FrozenExportTransform, radius: f32, ) { let rendered_points = @@ -3604,9 +3676,7 @@ impl OverlaySession { let Some(first) = rendered_points.first().copied() else { return; }; - let scale_factor = monitor.scale_factor(); - let mut previous = - Self::frozen_brush_point_to_export_pixels(first, capture_rect, scale_factor); + let mut previous = export_transform.point_to_pixels(first); Self::rasterize_frozen_brush_circle( coverage_mask, @@ -3617,8 +3687,7 @@ impl OverlaySession { ); for point in rendered_points.into_iter().skip(1) { - let current = - Self::frozen_brush_point_to_export_pixels(point, capture_rect, scale_factor); + let current = export_transform.point_to_pixels(point); Self::rasterize_frozen_brush_segment( coverage_mask, @@ -3633,14 +3702,20 @@ impl OverlaySession { } } - fn frozen_brush_point_to_export_pixels( - point: Pos2, - capture_rect: RectPoints, - scale_factor: f32, - ) -> Pos2 { - Pos2::new( - (point.x - capture_rect.x as f32) * scale_factor, - (point.y - capture_rect.y as f32) * scale_factor, + fn frozen_export_capture_rect(&self) -> Option { + self.state.frozen_capture_rect.or_else(|| { + self.state.monitor.map(|monitor| RectPoints::new(0, 0, monitor.width, monitor.height)) + }) + } + + fn frozen_export_transform_for_image( + &self, + image: &RgbaImage, + ) -> Option { + FrozenExportTransform::new( + self.frozen_export_capture_rect()?, + image.width(), + image.height(), ) } @@ -3963,14 +4038,47 @@ impl OverlaySession { } } - fn push_frozen_mosaic_edit(&mut self, edit: FrozenMosaicEdit) { - self.frozen_mosaic_undo_stack.push(edit); + fn clear_frozen_redo_history(&mut self) { + self.frozen_edit_redo_stack.clear(); + self.frozen_brush.redo_strokes.clear(); + self.frozen_mosaic_redo_stack.clear(); + self.frozen_text_redo_annotations.clear(); + } + + fn discard_evicted_frozen_edit_payload(&mut self, edit_kind: FrozenEditKind) { + match edit_kind { + FrozenEditKind::BrushStroke => { + if !self.frozen_brush.committed_strokes.is_empty() { + self.frozen_brush.committed_strokes.remove(0); + } + }, + FrozenEditKind::MosaicEdit => { + if !self.frozen_mosaic_undo_stack.is_empty() { + self.frozen_mosaic_undo_stack.remove(0); + } + }, + FrozenEditKind::TextAnnotation => { + if !self.frozen_text_annotations.is_empty() { + self.frozen_text_annotations.remove(0); + } + }, + } + } - if self.frozen_mosaic_undo_stack.len() > FROZEN_EDIT_HISTORY_LIMIT { - self.frozen_mosaic_undo_stack.remove(0); + fn push_frozen_edit_to_undo_history(&mut self, edit_kind: FrozenEditKind) { + self.frozen_edit_undo_stack.push(edit_kind); + + if self.frozen_edit_undo_stack.len() > FROZEN_EDIT_HISTORY_LIMIT { + let evicted = self.frozen_edit_undo_stack.remove(0); + + self.discard_evicted_frozen_edit_payload(evicted); } - self.frozen_mosaic_redo_stack.clear(); + self.clear_frozen_redo_history(); + } + + fn push_frozen_mosaic_edit(&mut self, edit: FrozenMosaicEdit) { + self.frozen_mosaic_undo_stack.push(edit); } fn apply_frozen_mosaic_edit(&mut self, rect_points: RectPoints) -> bool { @@ -4013,6 +4121,7 @@ impl OverlaySession { } self.push_frozen_mosaic_edit(FrozenMosaicEdit { preview_patch, window_patch }); + self.push_frozen_edit_to_undo_history(FrozenEditKind::MosaicEdit); self.note_frozen_image_mutated(monitor); true @@ -4086,41 +4195,53 @@ impl OverlaySession { } fn frozen_undo_available(&self) -> bool { - match self.toolbar_state.selected_tool { - FrozenToolbarTool::Pen => !self.frozen_brush.committed_strokes.is_empty(), - FrozenToolbarTool::Mosaic => !self.frozen_mosaic_undo_stack.is_empty(), - _ => { - !self.frozen_brush.committed_strokes.is_empty() - || !self.frozen_mosaic_undo_stack.is_empty() - }, - } + !self.frozen_edit_undo_stack.is_empty() } fn frozen_redo_available(&self) -> bool { - match self.toolbar_state.selected_tool { - FrozenToolbarTool::Pen => !self.frozen_brush.redo_strokes.is_empty(), - FrozenToolbarTool::Mosaic => !self.frozen_mosaic_redo_stack.is_empty(), - _ => { - !self.frozen_brush.redo_strokes.is_empty() - || !self.frozen_mosaic_redo_stack.is_empty() - }, - } + !self.frozen_edit_redo_stack.is_empty() } fn perform_frozen_undo(&mut self) -> bool { - match self.toolbar_state.selected_tool { - FrozenToolbarTool::Pen => self.undo_frozen_brush_stroke(), - FrozenToolbarTool::Mosaic => self.undo_frozen_mosaic_edit(), - _ => self.undo_frozen_brush_stroke() || self.undo_frozen_mosaic_edit(), + let Some(edit_kind) = self.frozen_edit_undo_stack.pop() else { + return false; + }; + let undone = match edit_kind { + FrozenEditKind::BrushStroke => self.undo_frozen_brush_stroke(), + FrozenEditKind::MosaicEdit => self.undo_frozen_mosaic_edit(), + FrozenEditKind::TextAnnotation => self.undo_frozen_text_annotation(), + }; + + if undone { + self.frozen_edit_redo_stack.push(edit_kind); + } else { + self.frozen_edit_undo_stack.push(edit_kind); } + + self.sync_frozen_toolbar_state(); + + undone } fn perform_frozen_redo(&mut self) -> bool { - match self.toolbar_state.selected_tool { - FrozenToolbarTool::Pen => self.redo_frozen_brush_stroke(), - FrozenToolbarTool::Mosaic => self.redo_frozen_mosaic_edit(), - _ => self.redo_frozen_brush_stroke() || self.redo_frozen_mosaic_edit(), + let Some(edit_kind) = self.frozen_edit_redo_stack.pop() else { + return false; + }; + let redone = match edit_kind { + FrozenEditKind::BrushStroke => self.redo_frozen_brush_stroke(), + FrozenEditKind::MosaicEdit => self.redo_frozen_mosaic_edit(), + FrozenEditKind::TextAnnotation => self.redo_frozen_text_annotation(), + }; + + if redone { + self.frozen_edit_undo_stack.push(edit_kind); + } else { + self.frozen_edit_redo_stack.push(edit_kind); } + + self.sync_frozen_toolbar_state(); + + redone } fn flatten_window_image_with_matte(image: &RgbaImage, matte: image::Rgba) -> RgbaImage { @@ -4197,93 +4318,469 @@ impl OverlaySession { monitor_image } - fn handle_captured_freeze_response( - &mut self, - monitor: MonitorRect, - image: RgbaImage, - window_image: Option, - captured_window_id: Option, - ) { - if matches!(self.state.mode, OverlayMode::Frozen) && self.state.monitor == Some(monitor) { - self.inflight_freeze_capture = None; - self.authoritative_frozen_capture_ready = true; + fn frozen_text_tool_active(&self) -> bool { + !self.scroll_capture.active && self.toolbar_state.selected_tool == FrozenToolbarTool::Text + } - let window_capture_target = self.inflight_window_freeze_capture.take(); - let mut frozen_preview_image = image; + fn sync_text_input_ime_state(&self) { + let ime_allowed = self.frozen_text_tool_active() && self.frozen_text_edit.is_some(); - self.pending_window_freeze_capture = None; - self.frozen_window_image = None; + for overlay_window in self.windows.values() { + overlay_window.window.set_ime_allowed(ime_allowed); + } - if let (Some(target), Some(window_capture_image), Some(window_id)) = - (window_capture_target, window_image, captured_window_id) - && target.monitor == monitor - && target.window_id == window_id - { - match self.config.window_capture_alpha_mode { - WindowCaptureAlphaMode::Background => {}, - WindowCaptureAlphaMode::MatteLight | WindowCaptureAlphaMode::MatteDark => { - let window_capture_image = Self::compose_window_preview_layer( - &window_capture_image, - self.config.window_capture_alpha_mode, - ); + if let Some(toolbar_window) = self.toolbar_window.as_ref() { + toolbar_window.window.set_ime_allowed(ime_allowed); + } + } - frozen_preview_image = Self::composite_window_capture_preview( - frozen_preview_image, - &window_capture_image, - monitor, - target.rect, - WindowCaptureAlphaMode::Background, - ); - self.frozen_window_image = Some(window_capture_image); - }, - } - } + fn sync_frozen_text_ime_cursor_area(&self, monitor: MonitorRect) { + let Some(edit_state) = self.frozen_text_edit.as_ref() else { + return; + }; + let Some(overlay_window) = self.windows.values().find(|window| window.monitor == monitor) + else { + return; + }; + let (visible_text, caret_char_index) = edit_state.visible_text_and_caret_char_index(); + let caret_rect = overlay_window.renderer.frozen_text_edit_caret_rect_for_window( + edit_state.anchor, + visible_text.as_str(), + &FontId::proportional(self.toolbar_state.text_style.font_size_points), + caret_char_index.unwrap_or_else(|| visible_text.chars().count()), + ); - self.state.finish_freeze(monitor, frozen_preview_image); - self.restore_capture_windows_visibility(); + overlay_window.window.set_ime_cursor_area( + LogicalPosition::new( + f64::from(caret_rect.min.x.max(0.0)), + f64::from(caret_rect.min.y.max(0.0)), + ), + LogicalSize::new( + f64::from(caret_rect.width().max(1.0)), + f64::from(caret_rect.height().max(self.toolbar_state.text_style.font_size_points)), + ), + ); + } - self.toolbar_state.needs_redraw = true; + fn should_refresh_frozen_text_ime_cursor_area_for_text_style_change( + &self, + monitor: MonitorRect, + ) -> bool { + self.state.monitor == Some(monitor) + && self.frozen_text_tool_active() + && self.frozen_text_edit.as_ref().is_some_and(FrozenTextEditState::has_ime_preedit) + } - #[cfg(target_os = "macos")] - if self.toolbar_state.visible { - self.toolbar_window_warmup_redraws_remaining = - self.toolbar_window_warmup_redraws_remaining.max(TOOLBAR_WINDOW_WARMUP_REDRAWS); - } + fn refresh_frozen_text_ime_cursor_area_for_text_style_change(&self, monitor: MonitorRect) { + if self.should_refresh_frozen_text_ime_cursor_area_for_text_style_change(monitor) { + self.sync_frozen_text_ime_cursor_area(monitor); + } + } - if let Some(cursor) = self.state.cursor { - self.state.rgb = - image_helpers::frozen_rgb(&self.state.frozen_image, Some(monitor), cursor); - self.state.loupe = image_helpers::frozen_loupe_patch( - &self.state.frozen_image, - Some(monitor), - cursor, - self.loupe_patch_width_px, - self.loupe_patch_height_px, - ) - .map(|patch| crate::state::LoupeSample { center: cursor, patch }); + fn finish_frozen_text_editing(&mut self, commit: bool) -> bool { + let Some(edit_state) = self.frozen_text_edit.take() else { + self.sync_text_input_ime_state(); - self.update_hud_window_position(monitor, cursor); - } + return false; + }; + let committed_text = edit_state.visible_text(); + let had_visible_text = !committed_text.trim().is_empty(); + + if commit && had_visible_text { + self.frozen_text_annotations.push(FrozenTextAnnotation { + anchor: edit_state.anchor, + text: committed_text, + style: self.toolbar_state.text_style, + }); + self.push_frozen_edit_to_undo_history(FrozenEditKind::TextAnnotation); + self.sync_frozen_toolbar_state(); + } - self.maybe_start_loupe_window_warmup_redraw(); - self.request_redraw_hud_window(); + self.frozen_text_recent_input = None; - if self.state.alt_held || self.loupe_window_visible { - self.request_redraw_loupe_window(); - } + self.sync_text_input_ime_state(); - self.request_redraw_toolbar_window(); - self.request_redraw_for_monitor(monitor); - #[cfg(not(target_os = "macos"))] - self.raise_hud_windows(); + had_visible_text + } - return; + fn note_frozen_text_input_event(&mut self) -> u64 { + self.frozen_text_input_generation = self.frozen_text_input_generation.wrapping_add(1); + + self.frozen_text_input_generation + } + + fn append_text_to_frozen_edit_for_input_event( + &mut self, + source: FrozenTextInputSource, + generation: u64, + text: &str, + ) -> bool { + let text = text.replace('\r', ""); + + if text.is_empty() { + return false; } - if self.inflight_freeze_capture == Some(monitor) { - self.inflight_freeze_capture = None; + if self.frozen_text_recent_input.as_ref().is_some_and(|recent| { + recent.source != source + && recent.text == text + && generation == recent.generation.saturating_add(1) + }) { + self.frozen_text_recent_input = None; + + return false; } - if self.inflight_window_freeze_capture.is_some_and(|inflight| inflight.monitor == monitor) { - self.inflight_window_freeze_capture = None; + + let changed = self.append_text_to_frozen_edit(text.as_str()); + + if changed { + self.frozen_text_recent_input = + Some(FrozenTextRecentInput { source, text, generation }); + } + + changed + } + + fn append_text_to_frozen_edit(&mut self, text: &str) -> bool { + let Some(edit_state) = self.frozen_text_edit.as_mut() else { + return false; + }; + let text = text.replace('\r', ""); + + if text.is_empty() { + return false; + } + + edit_state.text.push_str(&text); + + edit_state.ime_preedit = None; + edit_state.ime_preedit_cursor_char_range = None; + self.frozen_text_recent_input = None; + + true + } + + fn backspace_frozen_text_edit(&mut self) -> bool { + let Some(edit_state) = self.frozen_text_edit.as_mut() else { + return false; + }; + let had_preedit = edit_state.has_ime_preedit(); + + edit_state.ime_preedit = None; + edit_state.ime_preedit_cursor_char_range = None; + + let changed = had_preedit || edit_state.text.pop().is_some(); + + if changed { + self.frozen_text_recent_input = None; + } + + changed + } + + fn undo_frozen_text_annotation(&mut self) -> bool { + let Some(annotation) = self.frozen_text_annotations.pop() else { + return false; + }; + + self.frozen_text_redo_annotations.push(annotation); + + self.toolbar_state.needs_redraw = true; + + self.request_redraw_toolbar_window(); + + if let Some(monitor) = self.state.monitor { + self.request_redraw_for_monitor(monitor); + } + + true + } + + fn redo_frozen_text_annotation(&mut self) -> bool { + let Some(annotation) = self.frozen_text_redo_annotations.pop() else { + return false; + }; + + self.frozen_text_annotations.push(annotation); + + self.toolbar_state.needs_redraw = true; + + self.request_redraw_toolbar_window(); + + if let Some(monitor) = self.state.monitor { + self.request_redraw_for_monitor(monitor); + } + + true + } + + fn set_frozen_text_ime_preedit( + &mut self, + preedit: Option, + cursor_range: Option<(usize, usize)>, + ) -> bool { + let Some(edit_state) = self.frozen_text_edit.as_mut() else { + return false; + }; + let normalized = preedit.filter(|text| !text.is_empty()); + let normalized_cursor_range = normalized.as_deref().and_then(|text| { + FrozenTextEditState::normalize_ime_preedit_cursor_char_range(text, cursor_range) + }); + + if edit_state.ime_preedit == normalized + && edit_state.ime_preedit_cursor_char_range == normalized_cursor_range + { + return false; + } + + edit_state.ime_preedit = normalized; + edit_state.ime_preedit_cursor_char_range = normalized_cursor_range; + + true + } + + fn begin_frozen_text_edit_at(&mut self, monitor: MonitorRect, cursor: GlobalPoint) -> bool { + if self.state.monitor != Some(monitor) { + return false; + } + + let Some((local_x, local_y)) = monitor.local_u32(cursor) else { + return false; + }; + let Some(capture_rect) = self.state.frozen_capture_rect else { + return false; + }; + + if !capture_rect.contains((local_x, local_y)) { + return false; + } + + let _ = self.finish_frozen_text_editing(true); + + self.frozen_text_edit = + Some(FrozenTextEditState::new(Pos2::new(local_x as f32, local_y as f32))); + self.frozen_text_recent_input = None; + + self.sync_text_input_ime_state(); + self.sync_frozen_text_ime_cursor_area(monitor); + #[cfg(target_os = "macos")] + self.focus_frozen_text_input_window(Some(monitor)); + + true + } + + fn frozen_text_edit_hit_rect_for_monitor(&self, monitor: MonitorRect) -> Option { + if self.state.monitor != Some(monitor) { + return None; + } + + let edit_state = self.frozen_text_edit.as_ref()?; + let visible_text = edit_state.visible_text(); + + Some(WindowRenderer::frozen_text_edit_interaction_rect( + edit_state.anchor, + visible_text.as_str(), + &FontId::proportional(self.toolbar_state.text_style.font_size_points), + )) + } + + fn begin_frozen_text_edit_drag_at( + &mut self, + monitor: MonitorRect, + cursor: GlobalPoint, + ) -> bool { + let Some((local_x, local_y)) = monitor.local_u32(cursor) else { + return false; + }; + let Some(hit_rect) = self.frozen_text_edit_hit_rect_for_monitor(monitor) else { + return false; + }; + let pointer = Pos2::new(local_x as f32, local_y as f32); + + if !hit_rect.contains(pointer) { + return false; + } + + let Some(edit_state) = self.frozen_text_edit.as_mut() else { + return false; + }; + + edit_state.dragging = true; + edit_state.drag_offset = pointer - edit_state.anchor; + + true + } + + fn stop_frozen_text_edit_drag(&mut self) -> bool { + let Some(edit_state) = self.frozen_text_edit.as_mut() else { + return false; + }; + let was_dragging = edit_state.dragging; + + edit_state.dragging = false; + edit_state.drag_offset = Vec2::ZERO; + + was_dragging + } + + fn update_frozen_text_edit_drag_anchor(&mut self, global: GlobalPoint) -> bool { + let Some(monitor) = self.state.monitor else { + let _ = self.stop_frozen_text_edit_drag(); + + return false; + }; + let Some(capture_rect) = self.state.frozen_capture_rect else { + let _ = self.stop_frozen_text_edit_drag(); + + return false; + }; + let Some(edit_state) = self.frozen_text_edit.as_mut() else { + return false; + }; + + if !edit_state.dragging { + return false; + } + + let (cursor_x, cursor_y) = Self::clamped_local_point_in_monitor(monitor, global); + let max_x = capture_rect.x.saturating_add(capture_rect.width.saturating_sub(1)) as f32; + let max_y = capture_rect.y.saturating_add(capture_rect.height.saturating_sub(1)) as f32; + let changed = { + let next_anchor = Pos2::new( + (cursor_x as f32 - edit_state.drag_offset.x).clamp(capture_rect.x as f32, max_x), + (cursor_y as f32 - edit_state.drag_offset.y).clamp(capture_rect.y as f32, max_y), + ); + + if next_anchor == edit_state.anchor { + false + } else { + edit_state.anchor = next_anchor; + + true + } + }; + + if !changed { + return false; + } + + self.sync_frozen_text_ime_cursor_area(monitor); + self.request_redraw_for_monitor(monitor); + + true + } + + fn sync_frozen_text_edit_for_selected_tool(&mut self) -> bool { + if self.frozen_text_tool_active() { + self.sync_text_input_ime_state(); + + return false; + } + + self.finish_frozen_text_editing(true) + } + + fn render_frozen_text_annotation_into_image( + image: &mut RgbaImage, + export_transform: FrozenExportTransform, + annotation: &FrozenTextAnnotation, + ) { + let raster_annotation = RasterTextAnnotation { + anchor_px: export_transform.point_to_pixels(annotation.anchor), + font_size_px: annotation.style.font_size_points * export_transform.scalar_scale(), + fill_rgba: annotation.style.color.export_rgba(), + text: annotation.text.as_str(), + }; + + text_rendering::render_text_annotations(image, &[raster_annotation]); + } + + fn handle_captured_freeze_response( + &mut self, + monitor: MonitorRect, + image: RgbaImage, + window_image: Option, + captured_window_id: Option, + ) { + if matches!(self.state.mode, OverlayMode::Frozen) && self.state.monitor == Some(monitor) { + self.inflight_freeze_capture = None; + self.authoritative_frozen_capture_ready = true; + + let window_capture_target = self.inflight_window_freeze_capture.take(); + let mut frozen_preview_image = image; + + self.pending_window_freeze_capture = None; + self.frozen_window_image = None; + + if let (Some(target), Some(window_capture_image), Some(window_id)) = + (window_capture_target, window_image, captured_window_id) + && target.monitor == monitor + && target.window_id == window_id + { + match self.config.window_capture_alpha_mode { + WindowCaptureAlphaMode::Background => {}, + WindowCaptureAlphaMode::MatteLight | WindowCaptureAlphaMode::MatteDark => { + let window_capture_image = Self::compose_window_preview_layer( + &window_capture_image, + self.config.window_capture_alpha_mode, + ); + + frozen_preview_image = Self::composite_window_capture_preview( + frozen_preview_image, + &window_capture_image, + monitor, + target.rect, + WindowCaptureAlphaMode::Background, + ); + self.frozen_window_image = Some(window_capture_image); + }, + } + } + + self.state.finish_freeze(monitor, frozen_preview_image); + self.restore_capture_windows_visibility(); + + self.toolbar_state.needs_redraw = true; + + #[cfg(target_os = "macos")] + if self.toolbar_state.visible { + self.toolbar_window_warmup_redraws_remaining = + self.toolbar_window_warmup_redraws_remaining.max(TOOLBAR_WINDOW_WARMUP_REDRAWS); + } + + if let Some(cursor) = self.state.cursor { + self.state.rgb = + image_helpers::frozen_rgb(&self.state.frozen_image, Some(monitor), cursor); + self.state.loupe = image_helpers::frozen_loupe_patch( + &self.state.frozen_image, + Some(monitor), + cursor, + self.loupe_patch_width_px, + self.loupe_patch_height_px, + ) + .map(|patch| crate::state::LoupeSample { center: cursor, patch }); + + self.update_hud_window_position(monitor, cursor); + } + + self.maybe_start_loupe_window_warmup_redraw(); + self.request_redraw_hud_window(); + + if self.state.alt_held || self.loupe_window_visible { + self.request_redraw_loupe_window(); + } + + self.request_redraw_toolbar_window(); + self.request_redraw_for_monitor(monitor); + #[cfg(not(target_os = "macos"))] + self.raise_hud_windows(); + + return; + } + if self.inflight_freeze_capture == Some(monitor) { + self.inflight_freeze_capture = None; + } + if self.inflight_window_freeze_capture.is_some_and(|inflight| inflight.monitor == monitor) { + self.inflight_window_freeze_capture = None; self.pending_window_freeze_capture = None; } if matches!(self.state.mode, OverlayMode::Live) @@ -4432,6 +4929,7 @@ impl OverlaySession { self.handle_cursor_moved(window_id, *position) } }, + WindowEvent::Ime(event) => self.handle_ime_event(window_id, event), WindowEvent::MouseWheel { delta, .. } if toolbar_window_id => OverlayControl::Continue, WindowEvent::MouseWheel { delta, .. } => { self.handle_scroll_mouse_wheel(window_id, delta) @@ -4506,6 +5004,8 @@ impl OverlaySession { self.stop_frozen_selection_drag(); self.stop_frozen_mosaic_drag(); + let _ = self.stop_frozen_text_edit_drag(); + self.toolbar_state.dragging = false; self.toolbar_state.drag_offset = Vec2::ZERO; self.toolbar_state.drag_anchor = None; @@ -4518,6 +5018,10 @@ impl OverlaySession { #[cfg(target_os = "macos")] { self.request_redraw_toolbar_window(); + + if !toolbar_left_button_down && self.frozen_text_edit.is_some() { + self.focus_frozen_text_input_window(self.state.monitor); + } } OverlayControl::Continue @@ -5070,12 +5574,14 @@ impl OverlaySession { let frozen_rect_changed = self.update_frozen_selection_drag_rect(global); self.update_frozen_mosaic_drag_rect(global); + self.update_frozen_text_edit_drag_anchor(global); (frozen_rect_changed, Some(frozen_drag_update_started_at.elapsed())) } else { let frozen_rect_changed = self.update_frozen_selection_drag_rect(global); self.update_frozen_mosaic_drag_rect(global); + self.update_frozen_text_edit_drag_anchor(global); (frozen_rect_changed, None) }; @@ -5412,6 +5918,39 @@ impl OverlaySession { ) -> OverlayControl { self.reset_toolbar_pointer_state(); + if self.frozen_text_tool_active() { + match state { + ElementState::Pressed => { + let cursor = self.current_device_cursor(); + let started_drag = self.begin_frozen_text_edit_drag_at(monitor, cursor); + + if !started_drag { + let started = self.begin_frozen_text_edit_at(monitor, cursor); + + if !started { + let _ = self.finish_frozen_text_editing(true); + } + } + + self.sync_overlay_cursor_icons(); + }, + ElementState::Released => { + let stopped_drag = self.stop_frozen_text_edit_drag(); + + if stopped_drag { + self.sync_overlay_cursor_icons(); + } + }, + } + + self.request_redraw_for_monitor(monitor); + + return OverlayControl::Continue; + } + if self.frozen_text_edit.is_some() { + let _ = self.finish_frozen_text_editing(true); + } + match state { ElementState::Pressed => { let cursor = self.current_frozen_interaction_cursor(); @@ -5438,6 +5977,45 @@ impl OverlaySession { OverlayControl::Continue } + fn handle_ime_event(&mut self, window_id: WindowId, event: &Ime) -> OverlayControl { + if !matches!(self.state.mode, OverlayMode::Frozen) || self.frozen_text_edit.is_none() { + return OverlayControl::Continue; + } + + let Some(monitor) = + self.windows.get(&window_id).map(|window| window.monitor).or(self.state.monitor) + else { + return OverlayControl::Continue; + }; + let changed = self.apply_frozen_text_ime_event(event); + + if changed { + self.sync_frozen_text_ime_cursor_area(monitor); + self.request_redraw_for_monitor(monitor); + } + + OverlayControl::Continue + } + + fn apply_frozen_text_ime_event(&mut self, event: &Ime) -> bool { + match event { + Ime::Commit(text) => { + let generation = self.note_frozen_text_input_event(); + + self.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Ime, + generation, + text, + ) + }, + Ime::Preedit(text, cursor_range) => { + self.set_frozen_text_ime_preedit(Some(text.clone()), *cursor_range) + }, + Ime::Disabled => self.set_frozen_text_ime_preedit(None, None), + Ime::Enabled => false, + } + } + fn handle_scroll_mouse_wheel( &mut self, window_id: WindowId, @@ -6018,7 +6596,80 @@ impl OverlaySession { }) } + fn handle_frozen_text_key_event(&mut self, event: &KeyEvent) -> Option { + self.frozen_text_edit.as_ref()?; + + if event.state != ElementState::Pressed { + return Some(OverlayControl::Continue); + } + + let changed = + self.handle_frozen_text_pressed_key(&event.logical_key, event.text.as_deref()); + + if changed { + self.sync_text_input_ime_state(); + + if let Some(monitor) = self.state.monitor { + self.sync_frozen_text_ime_cursor_area(monitor); + self.request_redraw_for_monitor(monitor); + } + } + + Some(OverlayControl::Continue) + } + + fn handle_frozen_text_pressed_key(&mut self, logical_key: &Key, text: Option<&str>) -> bool { + match logical_key { + Key::Named(NamedKey::Escape) => { + let _ = self.finish_frozen_text_editing(false); + + true + }, + Key::Named(NamedKey::Enter) => { + if self.frozen_text_edit.as_ref().is_some_and(FrozenTextEditState::has_ime_preedit) + { + return false; + } + if self.keyboard_modifiers.shift_key() { + let generation = self.note_frozen_text_input_event(); + + self.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + generation, + "\n", + ) + } else { + let _ = self.finish_frozen_text_editing(true); + + true + } + }, + Key::Named(NamedKey::Backspace) => self.backspace_frozen_text_edit(), + _ if !self.keyboard_modifiers.control_key() + && !self.keyboard_modifiers.super_key() + && !self.keyboard_modifiers.alt_key() => + { + let Some(text) = text else { + return false; + }; + let generation = self.note_frozen_text_input_event(); + + self.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + generation, + text, + ) + }, + _ => false, + } + } + fn handle_key_event(&mut self, event: &KeyEvent) -> OverlayControl { + if matches!(self.state.mode, OverlayMode::Frozen) + && let Some(control) = self.handle_frozen_text_key_event(event) + { + return control; + } if matches!(event.logical_key, Key::Named(NamedKey::Tab)) { let pressed = event.state == ElementState::Pressed; @@ -6162,9 +6813,22 @@ impl OverlaySession { .map(|session| session.export_image().clone()); } - self.cropped_frozen_capture_image() - .or_else(|| self.state.frozen_image.clone()) - .map(|export_image| self.annotated_frozen_export_image(export_image)) + let mut export_image = + self.cropped_frozen_capture_image().or_else(|| self.state.frozen_image.clone())?; + + self.render_frozen_committed_overlays_into_image(&mut export_image); + + Some(export_image) + } + + #[cfg(test)] + fn visible_frozen_text_annotations(&self) -> &[FrozenTextAnnotation] { + if self.scroll_capture.active { &[] } else { &self.frozen_text_annotations } + } + + #[cfg(test)] + fn visible_frozen_text_edit(&self) -> Option<&FrozenTextEditState> { + if self.scroll_capture.active { None } else { self.frozen_text_edit.as_ref() } } #[cfg(target_os = "macos")] @@ -6867,10 +7531,60 @@ impl OverlaySession { } } + #[cfg(target_os = "macos")] + fn frozen_text_input_overlay_window(&self, monitor: Option) -> Option<&Window> { + monitor + .and_then(|target| self.windows.values().find(|window| window.monitor == target)) + .or_else(|| self.windows.values().next()) + .map(|overlay_window| overlay_window.window.as_ref()) + } + + #[cfg(target_os = "macos")] + fn focus_frozen_text_input_window(&self, monitor: Option) { + macos_activate_app(); + + let Some(target_window) = self.frozen_text_input_overlay_window(monitor) else { + tracing::info!( + op = "overlay.frozen_text_focus_requested", + target = "missing_window", + monitor_id = ?monitor.map(|target| target.id), + "Requested frozen text input focus, but no overlay window was available." + ); + + return; + }; + + tracing::info!( + op = "overlay.frozen_text_focus_requested", + target = "overlay_window", + monitor_id = ?monitor.map(|target| target.id), + "Requested frozen text input focus." + ); + + macos_make_window_key(target_window); + } + #[cfg(target_os = "macos")] fn focus_frozen_keyboard_window(&self) { macos_activate_app(); + if self.frozen_text_edit.is_some() + && let Some(target_window) = self.frozen_text_input_overlay_window(self.state.monitor) + { + tracing::info!( + op = "scroll_capture.frozen_focus_requested", + target = "overlay_window", + state_mode = ?self.state.mode, + toolbar_window_visible = self.toolbar_window_visible, + monitor_id = ?self.state.monitor.map(|monitor| monitor.id), + "Requested frozen keyboard focus for text editing." + ); + + macos_make_window_key(target_window); + + return; + } + let target_window = if let Some(toolbar_window) = self.toolbar_window.as_ref() { Some(toolbar_window.window.as_ref()) } else { @@ -7037,24 +7751,7 @@ impl OverlaySession { let toolbar_input = if draw_toolbar { self.toolbar_pointer_state(overlay_monitor, None) } else { None }; - if matches!(self.state.mode, OverlayMode::Frozen) - && self.state.monitor == Some(overlay_monitor) - { - tracing::trace!( - window_id = ?window_id, - monitor_id = overlay_monitor.id, - frozen_generation = self.state.frozen_generation, - final_capture_ready = self.authoritative_frozen_capture_ready, - frozen_image_ready = self.state.frozen_image.is_some(), - pending_freeze_capture = self.pending_freeze_capture.map(|m| m.id), - draw_toolbar, - toolbar_visible = self.toolbar_state.visible, - toolbar_floating_position = ?self.toolbar_state.floating_position, - toolbar_stable_frames = self.toolbar_state.layout_stable_frames, - toolbar_last_screen_size_points = ?self.toolbar_state.layout_last_screen_size_points, - "Overlay redraw (Frozen)." - ); - } + self.log_frozen_overlay_redraw_trace(window_id, overlay_monitor, draw_toolbar); let overlay_screen_rect = self.overlay_window_screen_rect(window_id, overlay_monitor); let toolbar_visible_for_badge = if cfg!(target_os = "macos") { @@ -7086,6 +7783,12 @@ impl OverlaySession { let Some(gpu) = self.gpu.as_ref() else { return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); }; + let scroll_capture_active = self.scroll_capture.active; + let frozen_text_style = self.toolbar_state.text_style; + let visible_frozen_text_annotations: &[FrozenTextAnnotation] = + if scroll_capture_active { &[] } else { &self.frozen_text_annotations }; + let visible_frozen_text_edit = + if scroll_capture_active { None } else { self.frozen_text_edit.as_ref() }; let toolbar_state = if draw_toolbar { Some(&mut self.toolbar_state) } else { None }; { @@ -7112,13 +7815,17 @@ impl OverlaySession { self.config.theme_mode, self.config.selection_flow_enabled, self.config.selection_flow_stroke_width_px, - !self.scroll_capture.active, - self.scroll_capture.active, + !scroll_capture_active, + scroll_capture_active, frozen_selection_resize_handles_enabled, self.frozen_capture_source, self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, frozen_toolbar_reserved_rect, - (!self.scroll_capture.active).then_some(&self.frozen_brush), + &self.frozen_edit_undo_stack, + (!scroll_capture_active).then_some(&self.frozen_brush), + visible_frozen_text_annotations, + visible_frozen_text_edit, + frozen_text_style, toolbar_state, toolbar_input, ) { @@ -7132,6 +7839,34 @@ impl OverlaySession { self.handle_capture_and_toolbar_redraw_post(overlay_monitor, draw_toolbar) } + fn log_frozen_overlay_redraw_trace( + &self, + window_id: WindowId, + overlay_monitor: MonitorRect, + draw_toolbar: bool, + ) { + if !matches!(self.state.mode, OverlayMode::Frozen) + || self.state.monitor != Some(overlay_monitor) + { + return; + } + + tracing::trace!( + window_id = ?window_id, + monitor_id = overlay_monitor.id, + frozen_generation = self.state.frozen_generation, + final_capture_ready = self.authoritative_frozen_capture_ready, + frozen_image_ready = self.state.frozen_image.is_some(), + pending_freeze_capture = self.pending_freeze_capture.map(|m| m.id), + draw_toolbar, + toolbar_visible = self.toolbar_state.visible, + toolbar_floating_position = ?self.toolbar_state.floating_position, + toolbar_stable_frames = self.toolbar_state.layout_stable_frames, + toolbar_last_screen_size_points = ?self.toolbar_state.layout_last_screen_size_points, + "Overlay redraw (Frozen)." + ); + } + fn overlay_window_screen_rect(&self, window_id: WindowId, monitor: MonitorRect) -> Rect { let fallback_size = Vec2::new(monitor.width as f32, monitor.height as f32); @@ -7242,6 +7977,9 @@ impl OverlaySession { } } } + if draw_toolbar && self.sync_frozen_text_edit_for_selected_tool() { + self.request_redraw_for_monitor(overlay_monitor); + } if draw_toolbar && let Some(action) = self.toolbar_state.pending_action.take() { let control = self.handle_toolbar_action(action); @@ -7252,6 +7990,7 @@ impl OverlaySession { if draw_toolbar && self.toolbar_state.needs_redraw { self.toolbar_state.needs_redraw = false; + self.refresh_frozen_text_ime_cursor_area_for_text_style_change(overlay_monitor); self.request_redraw_for_monitor(overlay_monitor); } @@ -7259,6 +7998,10 @@ impl OverlaySession { } fn handle_toolbar_action(&mut self, action: FrozenToolbarTool) -> OverlayControl { + if self.frozen_text_edit.is_some() { + let _ = self.finish_frozen_text_editing(true); + } + match action { FrozenToolbarTool::Undo => { let _ = self.perform_frozen_undo(); @@ -7442,8 +8185,17 @@ impl OverlaySession { self.toolbar_left_button_went_up = false; self.toolbar_pointer_local = None; + self.frozen_text_annotations.clear(); + self.frozen_text_redo_annotations.clear(); + + self.frozen_text_edit = None; + self.frozen_text_recent_input = None; + + self.sync_text_input_ime_state(); self.stop_frozen_selection_drag(); self.stop_frozen_mosaic_drag(); + self.frozen_edit_undo_stack.clear(); + self.frozen_edit_redo_stack.clear(); self.frozen_mosaic_undo_stack.clear(); self.frozen_mosaic_redo_stack.clear(); self.clear_pending_output_actions(); @@ -7509,6 +8261,67 @@ struct FrozenMosaicEdit { window_patch: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FrozenTextInputSource { + Key, + Ime, +} + +#[derive(Clone, Debug, PartialEq)] +struct FrozenTextRecentInput { + source: FrozenTextInputSource, + text: String, + generation: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FrozenEditKind { + BrushStroke, + MosaicEdit, + TextAnnotation, +} + +#[derive(Clone, Copy, Debug)] +enum FrozenCommittedOverlay<'a> { + Brush(&'a FrozenBrushStroke), + Text(&'a FrozenTextAnnotation), +} + +#[derive(Clone, Copy, Debug)] +struct FrozenExportTransform { + capture_rect: RectPoints, + scale_x: f32, + scale_y: f32, +} +impl FrozenExportTransform { + fn new(capture_rect: RectPoints, export_width: u32, export_height: u32) -> Option { + if capture_rect.width == 0 + || capture_rect.height == 0 + || export_width == 0 + || export_height == 0 + { + return None; + } + + Some(Self { + capture_rect, + scale_x: export_width as f32 / capture_rect.width as f32, + scale_y: export_height as f32 / capture_rect.height as f32, + }) + } + + fn point_to_pixels(self, point: Pos2) -> Pos2 { + Pos2::new( + (point.x - self.capture_rect.x as f32) * self.scale_x, + (point.y - self.capture_rect.y as f32) * self.scale_y, + ) + } + + fn scalar_scale(self) -> f32 { + (self.scale_x + self.scale_y) * 0.5 + } +} + #[derive(Debug)] struct OverlayExitMetadata<'a> { exit_kind: &'static str, @@ -7583,6 +8396,39 @@ unsafe impl Encode for MacOSOverlayPoint { } } +fn frozen_toolbar_window_startup_size_points() -> Vec2 { + [ + FrozenToolbarState::default(), + FrozenToolbarState { + selected_tool: FrozenToolbarTool::Text, + ..FrozenToolbarState::default() + }, + FrozenToolbarState { auto_center_available: true, ..FrozenToolbarState::default() }, + FrozenToolbarState { scroll_capture_available: true, ..FrozenToolbarState::default() }, + FrozenToolbarState { + auto_center_available: true, + scroll_capture_available: true, + ..FrozenToolbarState::default() + }, + FrozenToolbarState { + selected_tool: FrozenToolbarTool::Text, + auto_center_available: true, + scroll_capture_available: true, + ..FrozenToolbarState::default() + }, + FrozenToolbarState { + scroll_capture_active: true, + scroll_capture_available: true, + ..FrozenToolbarState::default() + }, + ] + .into_iter() + .map(|toolbar_state| WindowRenderer::frozen_toolbar_size(&toolbar_state)) + .fold(Vec2::new(0.0, TOOLBAR_EXPANDED_HEIGHT_PX), |max_size, size| { + Vec2::new(max_size.x.max(size.x), max_size.y.max(size.y)) + }) +} + #[cfg(target_os = "macos")] fn overlay_cursor_rect_icon_at_point( rects: &[OverlayCursorRect], diff --git a/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs index 1c1b886b..b4f74b42 100644 --- a/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs @@ -1,10 +1,10 @@ #[allow(unused_imports)] use crate::overlay::{ - Duration, Event, GlobalPoint, HUD_LOUPE_MOVE_INTERVAL_MIN, INTERACTIVE_REPAINT_FPS_CAP, - Instant, LOUPE_WINDOW_WARMUP_REDRAWS, LogicalPosition, MonitorRect, MonitorRectPoints, - OVERLAY_EVENT_LOOP_STALL_THRESHOLD, Ordering, OverlayControl, OverlayEventLoopPhase, - OverlayMode, OverlaySession, SLOW_OP_WARN_INTERVAL, SLOW_OP_WARN_OUTER_POSITION, WindowEvent, - scroll_capture, + Duration, Event, FROZEN_TEXT_CARET_REPAINT_INTERVAL, GlobalPoint, HUD_LOUPE_MOVE_INTERVAL_MIN, + INTERACTIVE_REPAINT_FPS_CAP, Instant, LOUPE_WINDOW_WARMUP_REDRAWS, LogicalPosition, + MonitorRect, MonitorRectPoints, OVERLAY_EVENT_LOOP_STALL_THRESHOLD, Ordering, OverlayControl, + OverlayEventLoopPhase, OverlayMode, OverlaySession, SLOW_OP_WARN_INTERVAL, + SLOW_OP_WARN_OUTER_POSITION, WindowEvent, scroll_capture, }; impl OverlaySession { @@ -66,6 +66,7 @@ impl OverlaySession { self.maybe_clear_loupe_activation_after_focus_loss(); self.maybe_request_keepalive_redraw(); self.maybe_keep_selection_flow_repaint(); + self.maybe_keep_frozen_text_caret_repaint(); self.maybe_keep_frozen_capture_redraw(); self.maybe_tick_toolbar_window_warmup_redraw(); self.maybe_tick_loupe_window_warmup_redraw(); @@ -191,6 +192,17 @@ impl OverlaySession { self.schedule_egui_repaint_after(repaint_interval); } + pub(super) fn maybe_keep_frozen_text_caret_repaint(&self) { + if !matches!(self.state.mode, OverlayMode::Frozen) || self.frozen_text_edit.is_none() { + return; + } + if self.state.monitor.is_none() { + return; + } + + self.schedule_egui_repaint_after(FROZEN_TEXT_CARET_REPAINT_INTERVAL); + } + pub(super) fn live_overlay_selection_flow_repaint_active(&self) -> bool { if !self.config.selection_flow_enabled { return false; diff --git a/packages/rsnap-overlay/src/overlay/hud_runtime.rs b/packages/rsnap-overlay/src/overlay/hud_runtime.rs index a892b8ca..cdd0d1a6 100644 --- a/packages/rsnap-overlay/src/overlay/hud_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/hud_runtime.rs @@ -125,7 +125,11 @@ impl OverlaySession { self.frozen_capture_source, self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, None, + &[], None, + &self.frozen_text_annotations, + self.frozen_text_edit.as_ref(), + self.toolbar_state.text_style, None, None, )?; diff --git a/packages/rsnap-overlay/src/overlay/rendering.rs b/packages/rsnap-overlay/src/overlay/rendering.rs index 76275457..8de81b3f 100644 --- a/packages/rsnap-overlay/src/overlay/rendering.rs +++ b/packages/rsnap-overlay/src/overlay/rendering.rs @@ -18,7 +18,8 @@ use crate::overlay::{ BindingType, BlendState, Buffer, BufferBindingType, BufferSize, BufferUsages, ClippedPrimitive, Color32, ColorWrites, CompositeAlphaMode, Cow, CurrentSurfaceTexture, Device, Duration, Event, ExperimentalFeatures, Features, FilterMode, FontDefinitions, FontFamily, FrontFace, - FrozenBrushState, FrozenCaptureSource, FrozenSelectionCorner, FrozenToolbarPointerState, + FrozenBrushState, FrozenCaptureSource, FrozenEditKind, FrozenSelectionCorner, + FrozenTextAnnotation, FrozenTextEditState, FrozenTextStyle, FrozenToolbarPointerState, FrozenToolbarState, FullOutput, HudAnchor, HudTheme, Id, Instant, LayerId, LoadOp, MemoryHints, MipmapFilterMode, MonitorRect, MultisampleState, Mutex, Order, OverlayMode, OverlaySession, OverlayState, PhysicalSize, PipelineCompilationOptions, PointerButton, PolygonMode, Pos2, @@ -30,6 +31,7 @@ use crate::overlay::{ ToolbarPlacement, Trace, Variant, Vec2, ViewportId, Visuals, WindowId, WindowRendererPath, WrapErr, eyre, hud_helpers, mem, }; +use crate::system_fonts; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct FrozenToolbarButtonStyle { @@ -43,6 +45,30 @@ pub(super) struct ScrollPreviewView { pub(super) theme: HudTheme, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct SelectionFlowGeometryCacheKey { + rect_min_x_bits: u32, + rect_min_y_bits: u32, + rect_max_x_bits: u32, + rect_max_y_bits: u32, + corner_radius_bits: u32, + seam_offset_bits: u32, + sample_count: usize, +} +impl SelectionFlowGeometryCacheKey { + const fn new(rect: Rect, corner_radius: f32, seam_offset: f32, sample_count: usize) -> Self { + Self { + rect_min_x_bits: rect.min.x.to_bits(), + rect_min_y_bits: rect.min.y.to_bits(), + rect_max_x_bits: rect.max.x.to_bits(), + rect_max_y_bits: rect.max.y.to_bits(), + corner_radius_bits: corner_radius.to_bits(), + seam_offset_bits: seam_offset.to_bits(), + sample_count, + } + } +} + #[derive(Debug, Default)] pub(super) struct SelectionFlowGeometryCache { key: Option, @@ -692,7 +718,11 @@ impl WindowRenderer { frozen_capture_source: FrozenCaptureSource, frozen_capture_is_fullscreen_fallback: bool, frozen_toolbar_reserved_rect: Option, + frozen_edit_history: &[FrozenEditKind], frozen_brush_state: Option<&FrozenBrushState>, + frozen_text_annotations: &[FrozenTextAnnotation], + frozen_text_edit: Option<&FrozenTextEditState>, + frozen_text_style: FrozenTextStyle, selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, selection_dashed_border_cache: &mut SelectionDashedBorderCache, mut toolbar_state: Option<&mut FrozenToolbarState>, @@ -789,7 +819,11 @@ impl WindowRenderer { frozen_selection_resize_handles_enabled, frozen_capture_source, frozen_toolbar_reserved_rect, + frozen_edit_history, frozen_brush_state, + frozen_text_annotations, + frozen_text_edit, + frozen_text_style, frozen_capture_is_fullscreen_fallback, selection_flow_enabled, selection_flow_stroke_width_px, @@ -1027,26 +1061,7 @@ impl WindowRenderer { let egui_ctx = egui::Context::default(); let mut fonts = FontDefinitions::default(); - egui_phosphor::add_to_fonts(&mut fonts, Variant::Regular); - - let phosphor_fill = String::from("phosphor-fill"); - let proportional_fallback = - fonts.families.get(&FontFamily::Proportional).and_then(|names| names.first()).cloned(); - - fonts.font_data.insert(phosphor_fill.clone(), Variant::Fill.font_data().into()); - - { - let family = - fonts.families.entry(FontFamily::Name(phosphor_fill.clone().into())).or_default(); - - family.insert(0, phosphor_fill.clone()); - - if let Some(fallback) = proportional_fallback - && !family.contains(&fallback) - { - family.push(fallback); - } - } + configure_egui_fonts(&mut fonts); egui_ctx.set_fonts(fonts); @@ -1322,7 +1337,11 @@ impl WindowRenderer { frozen_capture_source: FrozenCaptureSource, frozen_capture_is_fullscreen_fallback: bool, frozen_toolbar_reserved_rect: Option, + frozen_edit_history: &[FrozenEditKind], frozen_brush_state: Option<&FrozenBrushState>, + frozen_text_annotations: &[FrozenTextAnnotation], + frozen_text_edit: Option<&FrozenTextEditState>, + frozen_text_style: FrozenTextStyle, toolbar_state: Option<&mut FrozenToolbarState>, toolbar_pointer: Option, ) -> Result<()> { @@ -1385,7 +1404,11 @@ impl WindowRenderer { frozen_capture_source, frozen_capture_is_fullscreen_fallback, frozen_toolbar_reserved_rect, + frozen_edit_history, frozen_brush_state, + frozen_text_annotations, + frozen_text_edit, + frozen_text_style, &mut selection_flow_cache, &mut selection_dashed_border_cache, toolbar_state, @@ -1441,30 +1464,6 @@ impl WindowRenderer { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct SelectionFlowGeometryCacheKey { - rect_min_x_bits: u32, - rect_min_y_bits: u32, - rect_max_x_bits: u32, - rect_max_y_bits: u32, - corner_radius_bits: u32, - seam_offset_bits: u32, - sample_count: usize, -} -impl SelectionFlowGeometryCacheKey { - const fn new(rect: Rect, corner_radius: f32, seam_offset: f32, sample_count: usize) -> Self { - Self { - rect_min_x_bits: rect.min.x.to_bits(), - rect_min_y_bits: rect.min.y.to_bits(), - rect_max_x_bits: rect.max.x.to_bits(), - rect_max_y_bits: rect.max.y.to_bits(), - corner_radius_bits: corner_radius.to_bits(), - seam_offset_bits: seam_offset.to_bits(), - sample_count, - } - } -} - #[derive(Clone, Copy, Debug, PartialEq)] struct SelectionSizeBadgePadding { left: f32, @@ -1586,3 +1585,28 @@ impl WindowRendererPhaseTimings { slow_op_logger.warn_if_redraw_substep_slow(op, elapsed, self.total, describe); } } + +pub(super) fn configure_egui_fonts(fonts: &mut FontDefinitions) { + egui_phosphor::add_to_fonts(fonts, Variant::Regular); + + let phosphor_fill = String::from("phosphor-fill"); + let proportional_fallback = + fonts.families.get(&FontFamily::Proportional).and_then(|names| names.first()).cloned(); + + fonts.font_data.insert(phosphor_fill.clone(), Variant::Fill.font_data().into()); + + { + let family = + fonts.families.entry(FontFamily::Name(phosphor_fill.clone().into())).or_default(); + + family.insert(0, phosphor_fill.clone()); + + if let Some(fallback) = proportional_fallback + && !family.contains(&fallback) + { + family.push(fallback); + } + } + + system_fonts::configure_text_font_fallbacks(fonts); +} diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index 45383acf..f270ab73 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -1,4 +1,10 @@ +use std::sync::{Arc, OnceLock}; + use egui::Context; +use egui::FontDefinitions; +use egui::Galley; +use egui::RawInput; +use egui::text::CCursor; #[allow(unused_imports)] use crate::overlay::rendering::{ @@ -18,9 +24,12 @@ 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_TOOLBAR_BUTTON_SIZE_POINTS, - FROZEN_TOOLBAR_ITEM_SPACING_POINTS, FontFamily, FontId, FrozenBrushState, FrozenCaptureSource, - FrozenSelectionCorner, FrozenToolbarPointerState, FrozenToolbarState, FrozenToolbarTool, + FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, FROZEN_TEXT_FONT_SIZE_POINTS, + 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, @@ -39,6 +48,17 @@ use crate::overlay::{ 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_TEXT_INTERACTION_PADDING_X_POINTS: f32 = 8.0; +const FROZEN_TEXT_INTERACTION_PADDING_Y_POINTS: f32 = 6.0; + #[derive(Clone, Copy)] pub(in crate::overlay) struct SelectionScrimStyle { pub(in crate::overlay) scrim_fill: Color32, @@ -47,6 +67,35 @@ pub(in crate::overlay) struct SelectionScrimStyle { } impl WindowRenderer { + fn frozen_text_measurement_ctx() -> &'static Context { + static CTX: OnceLock = OnceLock::new(); + + CTX.get_or_init(|| { + let ctx = Context::default(); + let mut fonts = FontDefinitions::default(); + + ctx.set_fonts({ + super::configure_egui_fonts(&mut fonts); + + fonts + }); + + let _ = ctx.run_ui(RawInput::default(), |_ui| {}); + + ctx + }) + } + + fn frozen_text_edit_layout(painter: &Painter, text: &str, font_id: &FontId) -> Arc { + painter.layout_no_wrap(text.to_owned(), font_id.clone(), Color32::WHITE) + } + + fn frozen_text_edit_measurement_layout(text: &str, font_id: &FontId) -> Arc { + Self::frozen_text_measurement_ctx().fonts_mut(|fonts| { + fonts.layout_no_wrap(text.to_owned(), font_id.clone(), Color32::WHITE) + }) + } + #[allow(clippy::too_many_arguments)] pub(in crate::overlay) fn render_live_capture_affordances( ctx: &Context, @@ -138,7 +187,11 @@ impl WindowRenderer { frozen_selection_resize_handles_enabled: bool, frozen_capture_source: FrozenCaptureSource, frozen_toolbar_reserved_rect: Option, + frozen_edit_history: &[FrozenEditKind], frozen_brush_state: Option<&FrozenBrushState>, + frozen_text_annotations: &[FrozenTextAnnotation], + frozen_text_edit: Option<&FrozenTextEditState>, + frozen_text_style: FrozenTextStyle, _frozen_capture_is_fullscreen_fallback: bool, _selection_flow_enabled: bool, _selection_flow_stroke_width_px: f32, @@ -161,6 +214,14 @@ impl WindowRenderer { show_resize_handles, selection_dashed_border_cache, ); + let brush_painter = painter.with_clip_rect(rect); + + has_affordance |= Self::render_frozen_committed_overlay_annotations( + &brush_painter, + frozen_edit_history, + frozen_brush_state, + frozen_text_annotations, + ); if let Some(target) = Self::frozen_capture_size_badge_target(state, screen_rect) { Self::render_selection_size_badge( @@ -201,41 +262,97 @@ impl WindowRenderer { selection_dashed_border_cache, ); } - if let Some(frozen_brush_state) = frozen_brush_state { - let brush_painter = painter.with_clip_rect(rect); - has_affordance |= Self::render_frozen_brush_strokes(&brush_painter, frozen_brush_state); + has_affordance |= Self::render_frozen_text_annotations( + &brush_painter, + theme, + &[], + frozen_text_edit, + frozen_text_style, + ); + + if let Some(frozen_brush_state) = frozen_brush_state { + has_affordance |= + Self::render_active_frozen_brush_stroke(&brush_painter, frozen_brush_state); } has_affordance } - pub(in crate::overlay) fn render_frozen_brush_strokes( - painter: &Painter, - frozen_brush_state: &FrozenBrushState, + fn render_frozen_committed_overlay_annotations( + brush_painter: &Painter, + frozen_edit_history: &[FrozenEditKind], + frozen_brush_state: Option<&FrozenBrushState>, + frozen_text_annotations: &[FrozenTextAnnotation], ) -> bool { + let font_fill = |annotation: &FrozenTextAnnotation| { + ( + FontId::proportional(annotation.style.font_size_points), + annotation.style.color.swatch_fill(), + ) + }; + 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 radius = FROZEN_BRUSH_STROKE_WIDTH_POINTS * 0.5; let mut drew = false; - for brush in &frozen_brush_state.committed_strokes { - drew |= Self::paint_frozen_brush_stroke(painter, &brush.points, radius, color); - } - - if let Some(active_stroke) = &frozen_brush_state.active_stroke { - let preview_points = OverlaySession::preview_frozen_brush_points(active_stroke); + OverlaySession::for_each_frozen_committed_overlay( + frozen_edit_history, + brush_strokes, + frozen_text_annotations, + |overlay| match overlay { + FrozenCommittedOverlay::Brush(stroke) => { + drew |= Self::paint_frozen_brush_stroke( + brush_painter, + &stroke.points, + radius, + color, + ); + }, + FrozenCommittedOverlay::Text(annotation) => { + let (font_id, fill) = font_fill(annotation); + + Self::paint_frozen_text_label( + brush_painter, + annotation.anchor, + annotation.text.as_str(), + &font_id, + fill, + ); - drew |= Self::paint_frozen_brush_stroke(painter, &preview_points, radius, color); - } + drew = true; + }, + }, + ); drew } + fn render_active_frozen_brush_stroke( + painter: &Painter, + frozen_brush_state: &FrozenBrushState, + ) -> bool { + 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) + } + fn paint_frozen_brush_stroke( painter: &Painter, points: &[Pos2], @@ -294,6 +411,202 @@ impl WindowRenderer { } } + fn render_frozen_text_annotations( + painter: &Painter, + theme: HudTheme, + annotations: &[FrozenTextAnnotation], + text_edit: Option<&FrozenTextEditState>, + text_style: FrozenTextStyle, + ) -> bool { + let mut rendered = false; + + for annotation in annotations { + let font_id = FontId::proportional(annotation.style.font_size_points); + + Self::paint_frozen_text_label( + painter, + annotation.anchor, + annotation.text.as_str(), + &font_id, + annotation.style.color.swatch_fill(), + ); + + rendered = true; + } + + if let Some(text_edit) = text_edit { + let (visible_text, caret_char_index) = text_edit.visible_text_and_caret_char_index(); + let font_id = FontId::proportional(text_style.font_size_points); + let (text, color) = if visible_text.is_empty() { + ( + FROZEN_TEXT_PREVIEW_PLACEHOLDER, + Self::frozen_text_placeholder_fill(text_style.color, theme), + ) + } else { + (visible_text.as_str(), text_style.color.swatch_fill()) + }; + + Self::paint_frozen_text_label(painter, text_edit.anchor, text, &font_id, color); + + if let Some(caret_char_index) = caret_char_index + && Self::frozen_text_caret_visible(painter.ctx().input(|i| i.time)) + { + Self::paint_frozen_text_caret( + painter, + text_edit.anchor, + visible_text.as_str(), + &font_id, + caret_char_index, + text_style.color.swatch_fill(), + ); + } + + rendered = true; + } + + rendered + } + + fn paint_frozen_text_label( + painter: &Painter, + anchor: Pos2, + text: &str, + font_id: &FontId, + fill: Color32, + ) { + let galley = painter.layout_no_wrap(text.to_owned(), font_id.clone(), fill); + + painter.galley(anchor, galley, fill); + } + + fn paint_frozen_text_caret( + painter: &Painter, + anchor: Pos2, + text: &str, + font_id: &FontId, + caret_char_index: usize, + fill: Color32, + ) { + let caret_rect = Self::frozen_text_edit_caret_rect_at_char_index( + painter, + anchor, + text, + font_id, + caret_char_index, + ); + let caret_top = caret_rect.min; + let caret_bottom = Pos2::new(caret_rect.min.x, caret_rect.max.y); + + painter.line_segment([caret_top, caret_bottom], Stroke::new(1.5, fill)); + } + + pub(in crate::overlay) fn frozen_text_placeholder_fill( + color: FrozenTextColor, + theme: HudTheme, + ) -> Color32 { + let [r, g, b, _] = color.swatch_fill().to_array(); + let soften_ratio = match theme { + HudTheme::Dark => 0.46, + HudTheme::Light => 0.56, + }; + let alpha = match theme { + HudTheme::Dark => 196, + HudTheme::Light => 172, + }; + + Color32::from_rgba_unmultiplied( + Self::blend_color_channel(r, 255, soften_ratio), + Self::blend_color_channel(g, 255, soften_ratio), + Self::blend_color_channel(b, 255, soften_ratio), + alpha, + ) + } + + fn blend_color_channel(from: u8, to: u8, ratio: f32) -> u8 { + (from as f32 + (to as f32 - from as f32) * ratio).clamp(0.0, 255.0).round() as u8 + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(in crate::overlay) fn frozen_text_edit_caret_rect( + painter: &Painter, + anchor: Pos2, + text: &str, + font_id: &FontId, + ) -> Rect { + Self::frozen_text_edit_caret_rect_at_char_index( + painter, + anchor, + text, + font_id, + text.chars().count(), + ) + } + + pub(in crate::overlay) fn frozen_text_edit_caret_rect_at_char_index( + painter: &Painter, + anchor: Pos2, + text: &str, + font_id: &FontId, + caret_char_index: usize, + ) -> Rect { + let galley = Self::frozen_text_edit_layout(painter, text, font_id); + let caret = + galley.pos_from_cursor(CCursor::new(caret_char_index.min(text.chars().count()))); + let caret_height = caret.height().max(font_id.size); + + Rect::from_min_max( + Pos2::new(anchor.x + caret.min.x, anchor.y + caret.min.y), + Pos2::new(anchor.x + caret.max.x, anchor.y + caret.min.y + caret_height), + ) + } + + pub(in crate::overlay) fn frozen_text_edit_caret_rect_for_window( + &self, + anchor: Pos2, + text: &str, + font_id: &FontId, + caret_char_index: usize, + ) -> Rect { + let painter = self + .egui_ctx + .layer_painter(LayerId::new(Order::Foreground, Id::new("frozen-text-ime-caret"))); + + Self::frozen_text_edit_caret_rect_at_char_index( + &painter, + anchor, + text, + font_id, + caret_char_index, + ) + } + + pub(in crate::overlay) fn frozen_text_edit_interaction_rect( + anchor: Pos2, + text: &str, + font_id: &FontId, + ) -> Rect { + let text = if text.is_empty() { FROZEN_TEXT_PREVIEW_PLACEHOLDER } else { text }; + let galley = Self::frozen_text_edit_measurement_layout(text, font_id); + let text_size = + Vec2::new(galley.size().x.max(font_id.size), galley.size().y.max(font_id.size)); + + Rect::from_min_max( + Pos2::new( + anchor.x - FROZEN_TEXT_INTERACTION_PADDING_X_POINTS, + anchor.y - FROZEN_TEXT_INTERACTION_PADDING_Y_POINTS, + ), + Pos2::new( + anchor.x + text_size.x + FROZEN_TEXT_INTERACTION_PADDING_X_POINTS, + anchor.y + text_size.y + FROZEN_TEXT_INTERACTION_PADDING_Y_POINTS, + ), + ) + } + + pub(in crate::overlay) fn frozen_text_caret_visible(time_secs: f64) -> bool { + (time_secs.rem_euclid(crate::overlay::FROZEN_TEXT_CARET_BLINK_PERIOD_SECS)) + < crate::overlay::FROZEN_TEXT_CARET_BLINK_PERIOD_SECS * 0.5 + } + pub(in crate::overlay) fn frozen_capture_focus_rect( state: &OverlayState, screen_rect: Rect, @@ -2049,7 +2362,12 @@ impl WindowRenderer { + spacing_count * FROZEN_TOOLBAR_ITEM_SPACING_POINTS + 2.0 * HUD_PILL_INNER_MARGIN_X_POINTS + 2.0 * HUD_PILL_STROKE_WIDTH_POINTS; - let height = toolbar_state.pill_height_points.unwrap_or(TOOLBAR_EXPANDED_HEIGHT_PX); + 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; + } Vec2::new(width, height) } @@ -2245,24 +2563,15 @@ impl WindowRenderer { let toolbar_frame = Self::hud_pill_frame(theme, hud_opaque, hud_opacity, body_fill, false); - if response.drag_started() { - toolbar_state.dragging = true; - toolbar_state.floating_position = Some(toolbar_pos); - toolbar_state.drag_offset = cursor - toolbar_pos; - } - if toolbar_state.dragging && left_button_down { - let desired_pos = cursor - toolbar_state.drag_offset; - - toolbar_state.floating_position = Some(Self::clamp_toolbar_position( - screen_rect, - toolbar_size, - desired_pos, - TOOLBAR_SCREEN_MARGIN_PX, - TOOLBAR_SCREEN_MARGIN_PX, - )); - } else if toolbar_state.dragging { - toolbar_state.dragging = false; - } + Self::update_frozen_toolbar_drag_state( + toolbar_state, + response.drag_started(), + toolbar_pos, + screen_rect, + toolbar_size, + cursor, + left_button_down, + ); // 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). @@ -2296,13 +2605,8 @@ impl WindowRenderer { HUD_PILL_INNER_MARGIN_X_POINTS, HUD_PILL_INNER_MARGIN_Y_POINTS, )); - let _ = ui.scope_builder(UiBuilder::new().max_rect(inner_rect), |ui| { - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - ui.spacing_mut().item_spacing = egui::vec2(4.0, 0.0); - Self::render_frozen_toolbar_controls(ui, toolbar_state, theme); - }); - }); + Self::render_frozen_toolbar_body(ui, inner_rect, toolbar_state, theme); *hud_pill_out = Some(HudPillGeometry { rect, @@ -2311,6 +2615,114 @@ impl WindowRenderer { }); } + fn update_frozen_toolbar_drag_state( + toolbar_state: &mut FrozenToolbarState, + drag_started: bool, + toolbar_pos: Pos2, + screen_rect: Rect, + toolbar_size: Vec2, + cursor: Pos2, + left_button_down: bool, + ) { + if drag_started { + toolbar_state.dragging = true; + toolbar_state.floating_position = Some(toolbar_pos); + toolbar_state.drag_offset = cursor - toolbar_pos; + } + if toolbar_state.dragging && left_button_down { + let desired_pos = cursor - toolbar_state.drag_offset; + + toolbar_state.floating_position = Some(Self::clamp_toolbar_position( + screen_rect, + toolbar_size, + desired_pos, + TOOLBAR_SCREEN_MARGIN_PX, + TOOLBAR_SCREEN_MARGIN_PX, + )); + } else if toolbar_state.dragging { + toolbar_state.dragging = false; + } + } + + fn render_frozen_toolbar_body( + ui: &mut Ui, + inner_rect: Rect, + toolbar_state: &mut FrozenToolbarState, + theme: HudTheme, + ) { + let _ = ui.scope_builder(UiBuilder::new().max_rect(inner_rect), |ui| { + ui.with_layout(Layout::top_down(Align::Center), |ui| { + Self::render_frozen_toolbar_primary_row( + ui, + inner_rect.width(), + toolbar_state, + theme, + ); + + if Self::frozen_text_style_toolbar_visible(toolbar_state) { + Self::render_frozen_text_toolbar_section(ui, inner_rect, toolbar_state, theme); + } + }); + }); + } + + fn render_frozen_toolbar_primary_row( + ui: &mut Ui, + width: f32, + toolbar_state: &mut FrozenToolbarState, + theme: HudTheme, + ) { + let _ = ui.allocate_ui_with_layout( + Vec2::new(width, FROZEN_TOOLBAR_BUTTON_SIZE_POINTS), + Layout::left_to_right(Align::Center), + |ui| { + ui.spacing_mut().item_spacing = egui::vec2(4.0, 0.0); + + Self::render_frozen_toolbar_controls(ui, toolbar_state, theme); + }, + ); + } + + 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); + + Self::paint_frozen_text_toolbar_divider(ui, inner_rect, theme); + + ui.add_space(FROZEN_TEXT_TOOLBAR_SECTION_GAP_POINTS * 0.5); + } + + fn render_frozen_text_toolbar_section( + ui: &mut Ui, + inner_rect: Rect, + toolbar_state: &mut FrozenToolbarState, + theme: HudTheme, + ) { + Self::paint_frozen_text_toolbar_spacing(ui, inner_rect, theme); + + let _ = ui.allocate_ui_with_layout( + Vec2::new(inner_rect.width(), FROZEN_TEXT_TOOLBAR_SECTION_HEIGHT_POINTS), + Layout::left_to_right(Align::Center), + |ui| Self::render_frozen_text_toolbar_controls(ui, toolbar_state, theme), + ); + } + + fn paint_frozen_text_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) + }, + HudTheme::Light => { + Color32::from_black_alpha(FROZEN_TEXT_TOOLBAR_SECTION_DIVIDER_ALPHA_LIGHT) + }, + }; + let divider_y = ui.cursor().min.y; + + ui.painter().line_segment( + [Pos2::new(inner_rect.left(), divider_y), Pos2::new(inner_rect.right(), divider_y)], + Stroke::new(1.0, divider_color), + ); + } + #[allow(clippy::too_many_arguments)] pub(in crate::overlay) fn render_frozen_toolbar_controls( ui: &mut Ui, @@ -2393,6 +2805,148 @@ impl WindowRenderer { }); } + fn frozen_text_style_toolbar_visible(toolbar_state: &FrozenToolbarState) -> bool { + toolbar_state.selected_tool == FrozenToolbarTool::Text + } + + fn render_frozen_text_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; + + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = FROZEN_TEXT_TOOLBAR_SWATCH_GAP_POINTS; + + if Self::render_frozen_text_toolbar_icon_button( + ui, + regular::MINUS, + "Smaller text", + can_decrease, + 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, + ); + + 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( + ui, + color, + toolbar_state.text_style.color == color, + theme, + ) { + toolbar_state.text_style.color = color; + toolbar_state.needs_redraw = true; + } + } + }); + } + + fn render_frozen_text_toolbar_icon_button( + ui: &mut Ui, + icon: &str, + hover_text: &str, + enabled: bool, + theme: HudTheme, + ) -> bool { + let response = ui.allocate_response( + Vec2::new( + FROZEN_TEXT_TOOLBAR_SIZE_BUTTON_WIDTH_POINTS, + FROZEN_TEXT_TOOLBAR_SECTION_HEIGHT_POINTS, + ), + Sense::click(), + ); + 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)); + + if hovered { + ui.painter().rect_filled(bg_rect, 8.0, style.bg_color); + } + + ui.painter().text( + response.rect.center(), + Align2::CENTER_CENTER, + icon, + FontId::new(16.0, FontFamily::Proportional), + style.icon_color, + ); + + enabled && response.clicked() + } + + fn render_frozen_text_color_swatch( + ui: &mut Ui, + color: FrozenTextColor, + 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 stroke_color = match theme { + HudTheme::Dark => { + if selected { + Color32::WHITE + } else { + Color32::from_white_alpha(96) + } + }, + HudTheme::Light => { + if selected { + Color32::BLACK + } else { + Color32::from_black_alpha(96) + } + }, + }; + + ui.painter().circle_filled(response.rect.center(), radius, color.swatch_fill()); + ui.painter().circle_stroke( + response.rect.center(), + radius, + Stroke::new(if selected { 2.0 } else { 1.0 }, stroke_color), + ); + + response.on_hover_text("Text color").clicked() + } + pub(in crate::overlay) fn frozen_toolbar_button_style( theme: HudTheme, action_ready: bool, diff --git a/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs b/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs index 040c74f2..92b19c95 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs @@ -55,7 +55,7 @@ impl ScrollPreviewWindow { let egui_ctx = egui::Context::default(); let mut fonts = FontDefinitions::default(); - egui_phosphor::add_to_fonts(&mut fonts, Variant::Regular); + super::configure_egui_fonts(&mut fonts); egui_ctx.set_fonts(fonts); diff --git a/packages/rsnap-overlay/src/overlay/session_state.rs b/packages/rsnap-overlay/src/overlay/session_state.rs index 1ee09e24..647ea849 100644 --- a/packages/rsnap-overlay/src/overlay/session_state.rs +++ b/packages/rsnap-overlay/src/overlay/session_state.rs @@ -8,10 +8,11 @@ use std::{ use image::RgbaImage; use crate::overlay::{ - DeviceCursorPointSource, 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_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, }; #[cfg(target_os = "macos")] use crate::overlay::{ExternalScrollInputDrainReader, MacLiveFrameStream}; @@ -142,6 +143,7 @@ pub(super) struct FrozenToolbarState { pub(super) visible: bool, pub(super) dragging: bool, pub(super) selected_tool: FrozenToolbarTool, + pub(super) text_style: FrozenTextStyle, pub(super) auto_center_available: bool, pub(super) undo_available: bool, pub(super) redo_available: bool, @@ -164,6 +166,7 @@ impl Default for FrozenToolbarState { visible: true, dragging: false, selected_tool: FrozenToolbarTool::Pointer, + text_style: FrozenTextStyle::default(), auto_center_available: false, undo_available: false, redo_available: false, @@ -212,6 +215,155 @@ 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), + } + } + + pub(super) const fn export_rgba(self) -> [u8; 4] { + let [r, g, b, a] = self.swatch_fill().to_array(); + + [r, g, b, a] + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct FrozenTextStyle { + pub(super) font_size_points: f32, + pub(super) color: FrozenTextColor, +} +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 + } +} +impl Default for FrozenTextStyle { + fn default() -> Self { + Self { font_size_points: FROZEN_TEXT_FONT_SIZE_POINTS, color: FrozenTextColor::Blue } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct FrozenTextAnnotation { + pub(super) anchor: Pos2, + pub(super) text: String, + pub(super) style: FrozenTextStyle, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct FrozenTextEditState { + pub(super) anchor: Pos2, + pub(super) text: String, + pub(super) ime_preedit: Option, + pub(super) ime_preedit_cursor_char_range: Option<(usize, usize)>, + pub(super) dragging: bool, + pub(super) drag_offset: Vec2, +} +impl FrozenTextEditState { + pub(super) fn new(anchor: Pos2) -> Self { + Self { + anchor, + text: String::new(), + ime_preedit: None, + ime_preedit_cursor_char_range: None, + dragging: false, + drag_offset: Vec2::ZERO, + } + } + + pub(super) fn visible_text(&self) -> String { + self.visible_text_and_caret_char_index().0 + } + + pub(super) fn has_ime_preedit(&self) -> bool { + self.ime_preedit.is_some() + } + + pub(super) fn visible_text_and_caret_char_index(&self) -> (String, Option) { + let committed_char_count = self.text.chars().count(); + + match self.ime_preedit.as_deref() { + Some(preedit) if !preedit.is_empty() => { + let mut visible = self.text.clone(); + + visible.push_str(preedit); + + ( + visible, + self.ime_preedit_cursor_char_range + .map(|(_, end)| committed_char_count.saturating_add(end)), + ) + }, + _ => (self.text.clone(), Some(committed_char_count)), + } + } + + pub(super) fn normalize_ime_preedit_cursor_char_range( + preedit: &str, + cursor_range: Option<(usize, usize)>, + ) -> Option<(usize, usize)> { + let (start, end) = cursor_range?; + + Some(( + Self::char_index_from_byte_offset(preedit, start), + Self::char_index_from_byte_offset(preedit, end), + )) + } + + fn char_index_from_byte_offset(text: &str, byte_offset: usize) -> usize { + let clamped = byte_offset.min(text.len()); + + if clamped == text.len() { + return text.chars().count(); + } + + text.char_indices().take_while(|(index, _)| *index < clamped).count() + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct FrozenSelectionDragState { pub(super) active: bool, diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs index 754df1e1..e6d851de 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -26,17 +26,16 @@ use color_eyre::eyre; #[cfg(target_os = "macos")] use color_eyre::eyre::Result; use egui::FontDefinitions; -use egui::FontFamily; use egui::RawInput; -use egui_phosphor::Variant; use image::Rgba; #[cfg(target_os = "macos")] use image::imageops; #[cfg(target_os = "macos")] use winit::dpi::PhysicalPosition; -use winit::event::{ElementState, MouseButton, MouseScrollDelta}; +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; @@ -48,19 +47,23 @@ use crate::backend::CaptureBackend; use crate::live_frame_stream_macos::MacLiveFrameStream; use crate::overlay::FrozenCaptureSource; use crate::overlay::PngAction; +use crate::overlay::rendering; #[cfg(target_os = "macos")] use crate::overlay::session_state::ScrollCaptureLiveFrame; use crate::overlay::{ - self, ActiveFrozenBrushStroke, FROZEN_BRUSH_COLOR_RGBA, FrozenBrushModelState, - FrozenSelectionDragState, FrozenToolbarState, FrozenToolbarTool, HUD_LOUPE_STRIP_GAP_POINTS, - HudRedrawSummary, HudTheme, OCCLUDED_FRAME_REDRAW_RETRY_WINDOW, OverlaySession, Pos2, Rect, - SCROLL_CAPTURE_SAMPLE_INTERVAL, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, - SELECTION_DASHED_BORDER_GAP_LENGTH_PX, SELECTION_DASHED_BORDER_WIDTH_PX, - SELECTION_SIZE_BADGE_GAP_PX, SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, - SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, SelectionDashedBorderCache, - SelectionDashedBorderMetrics, SelectionFlowGeometryCache, SelectionSizeBadgeTarget, - SurfaceFrameSkipReason, TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, - Vec2, WindowRenderer, hud_helpers, + 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_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + SELECTION_DASHED_BORDER_WIDTH_PX, SELECTION_SIZE_BADGE_GAP_PX, + SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, + SelectionDashedBorderCache, SelectionDashedBorderMetrics, SelectionFlowGeometryCache, + SelectionSizeBadgeTarget, SurfaceFrameSkipReason, TOOLBAR_CAPTURE_GAP_PX, + TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, hud_helpers, }; #[cfg(target_os = "macos")] use crate::overlay::{ @@ -299,26 +302,8 @@ fn test_frozen_image() -> image::RgbaImage { fn test_egui_context() -> egui::Context { let ctx = egui::Context::default(); let mut fonts = FontDefinitions::default(); - let phosphor_fill = String::from("phosphor-fill"); - let proportional_fallback = - fonts.families.get(&FontFamily::Proportional).and_then(|names| names.first()).cloned(); - egui_phosphor::add_to_fonts(&mut fonts, Variant::Regular); - - fonts.font_data.insert(phosphor_fill.clone(), Variant::Fill.font_data().into()); - fonts - .families - .entry(FontFamily::Name(phosphor_fill.clone().into())) - .or_default() - .extend([phosphor_fill]); - - if let Some(fallback) = proportional_fallback { - let family = fonts.families.entry(FontFamily::Name("phosphor-fill".into())).or_default(); - - if !family.contains(&fallback) { - family.push(fallback); - } - } + rendering::configure_egui_fonts(&mut fonts); ctx.set_fonts(fonts); @@ -422,12 +407,12 @@ fn frozen_brush_undo_and_redo_update_export_image() { assert!(session.begin_frozen_brush_stroke(GlobalPoint::new(3, 3))); assert!(session.finish_frozen_brush_stroke()); - assert!(session.undo_frozen_brush_stroke()); + assert!(session.perform_frozen_undo()); let undone = session.current_export_image().expect("undo export image"); assert_eq!(undone.get_pixel(3, 3), &Rgba([12, 34, 56, 255])); - assert!(session.redo_frozen_brush_stroke()); + assert!(session.perform_frozen_redo()); let redone = session.current_export_image().expect("redo export image"); @@ -459,6 +444,24 @@ fn current_export_image_antialiases_frozen_brush_edges() { assert!(has_antialiased_edge, "expected blended edge pixels around the exported brush"); } +#[test] +fn rasterizing_frozen_brush_clears_reused_coverage_mask() { + let export_transform = + FrozenExportTransform::new(RectPoints::new(0, 0, 8, 8), 8, 8).expect("export transform"); + let mut export_image = image::RgbaImage::from_pixel(8, 8, Rgba([12, 34, 56, 255])); + let mut coverage_mask = vec![255_u8; 8 * 8]; + + OverlaySession::rasterize_frozen_brush_points_into_image( + &mut export_image, + &mut coverage_mask, + export_transform, + &[Pos2::new(2.0, 2.0)], + ); + + 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)); +} + fn significant_y_direction_reversals(points: &[Pos2], min_delta: f32) -> usize { let mut last_direction = 0_i8; let mut reversals = 0; @@ -1009,6 +1012,766 @@ fn begin_ocr_action_uses_scroll_capture_export_image_in_deferred_request() { assert!(session.state.error_message.is_none()); } +#[test] +fn begin_frozen_text_edit_at_starts_text_input_inside_capture_rect() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert_eq!( + session.frozen_text_edit.as_ref().map(|edit| edit.anchor), + Some(Pos2::new(140.0, 160.0)) + ); + assert!(!session.frozen_selection_drag.active); +} + +#[test] +fn begin_frozen_text_edit_at_ignores_non_authoritative_monitor() { + let monitor = test_monitor(); + let other_monitor = MonitorRect { + id: 2, + origin: GlobalPoint::new(1_000, 0), + width: monitor.width, + height: monitor.height, + scale_factor_x1000: monitor.scale_factor_x1000, + }; + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(!session.begin_frozen_text_edit_at(other_monitor, GlobalPoint::new(1_140, 160))); + assert!(session.frozen_text_edit.is_none()); +} + +#[test] +fn default_frozen_text_style_uses_16_point_font() { + let session = OverlaySession::new(); + + assert_eq!( + session.toolbar_state.text_style.font_size_points, + 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); +} + +fn test_frozen_mosaic_edit() -> FrozenMosaicEdit { + let patch = FrozenImagePatch { + rect: RectPoints::new(0, 0, 1, 1), + before: image::RgbaImage::from_pixel(1, 1, Rgba([0, 0, 0, 255])), + after: image::RgbaImage::from_pixel(1, 1, Rgba([255, 255, 255, 255])), + }; + + FrozenMosaicEdit { preview_patch: patch.clone(), window_patch: Some(patch) } +} + +#[test] +fn frozen_text_edit_drag_repositions_anchor_within_capture_rect() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.begin_frozen_text_edit_drag_at(monitor, GlobalPoint::new(141, 161))); + assert!(session.update_frozen_text_edit_drag_anchor(GlobalPoint::new(200, 210))); + assert_eq!( + session.frozen_text_edit.as_ref().map(|edit| edit.anchor), + Some(Pos2::new(199.0, 209.0)) + ); + assert!(session.stop_frozen_text_edit_drag()); + assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.dragging), Some(false)); +} + +#[test] +fn toolbar_mouse_release_stops_active_frozen_text_edit_drag() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.begin_frozen_text_edit_drag_at(monitor, GlobalPoint::new(141, 161))); + + session.toolbar_left_button_down = true; + + let _ = session.handle_toolbar_mouse_input(ElementState::Released); + + assert!(!session.toolbar_left_button_down); + assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.dragging), Some(false)); +} + +#[test] +fn adjacent_text_events_from_key_and_ime_are_deduplicated() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + + let key_generation = session.note_frozen_text_input_event(); + + assert!(session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + key_generation, + "A", + )); + + let ime_generation = session.note_frozen_text_input_event(); + + assert!(!session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Ime, + ime_generation, + "A", + )); + assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.text.as_str()), Some("A")); + + let _ = session.finish_frozen_text_editing(false); + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(150, 165))); + + let ime_generation = session.note_frozen_text_input_event(); + + assert!(session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Ime, + ime_generation, + "B", + )); + + let key_generation = session.note_frozen_text_input_event(); + + assert!(!session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + key_generation, + "B", + )); + assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.text.as_str()), Some("B")); +} + +#[test] +fn non_adjacent_identical_text_events_from_different_sources_are_not_deduplicated() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + + let key_generation = session.note_frozen_text_input_event(); + + assert!(session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + key_generation, + "A", + )); + + let _ = session.note_frozen_text_input_event(); + let ime_generation = session.note_frozen_text_input_event(); + + assert!(session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Ime, + ime_generation, + "A", + )); + assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.text.as_str()), Some("AA")); +} + +#[test] +fn backspace_clears_recent_input_dedupe_marker_before_cross_source_retype() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + + let key_generation = session.note_frozen_text_input_event(); + + assert!(session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + key_generation, + "A", + )); + assert!(session.backspace_frozen_text_edit()); + assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.text.as_str()), Some("")); + + let ime_generation = session.note_frozen_text_input_event(); + + assert!(session.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Ime, + ime_generation, + "A", + )); + assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.text.as_str()), Some("A")); +} + +#[test] +fn ime_disabled_clears_frozen_text_preedit_state() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉")), Some((0, 0)))); + assert!(session.frozen_text_edit.as_ref().is_some_and(FrozenTextEditState::has_ime_preedit)); + assert!(session.apply_frozen_text_ime_event(&Ime::Disabled)); + assert_eq!( + session.frozen_text_edit.as_ref().and_then(|edit| edit.ime_preedit.as_deref()), + None + ); + assert!(!session.frozen_text_edit.as_ref().is_some_and(FrozenTextEditState::has_ime_preedit)); +} + +#[test] +fn frozen_text_preedit_cursor_range_updates_caret_position() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.append_text_to_frozen_edit("A")); + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉字")), Some((3, 3)))); + + let edit_state = session.frozen_text_edit.as_ref().expect("text edit"); + + assert_eq!(edit_state.visible_text(), "A汉字"); + assert_eq!(edit_state.visible_text_and_caret_char_index().1, Some(2)); + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉字")), Some((0, 0)))); + assert_eq!( + session + .frozen_text_edit + .as_ref() + .and_then(|edit| edit.visible_text_and_caret_char_index().1), + Some(1) + ); +} + +#[test] +fn frozen_text_style_change_refresh_check_requires_active_ime_preedit() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(!session.should_refresh_frozen_text_ime_cursor_area_for_text_style_change(monitor)); + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉")), Some((0, 0)))); + assert!(session.should_refresh_frozen_text_ime_cursor_area_for_text_style_change(monitor)); +} + +#[test] +fn frozen_text_style_change_refresh_check_ignores_other_monitor() { + let monitor = test_monitor(); + let other_monitor = MonitorRect { + id: 2, + origin: GlobalPoint::new(1_000, 0), + width: monitor.width, + height: monitor.height, + scale_factor_x1000: monitor.scale_factor_x1000, + }; + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉")), Some((0, 0)))); + assert!( + !session.should_refresh_frozen_text_ime_cursor_area_for_text_style_change(other_monitor) + ); +} + +#[test] +fn frozen_text_enter_does_not_finish_while_ime_preedit_is_active() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.append_text_to_frozen_edit("A")); + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉")), Some((3, 3)))); + assert!(!session.handle_frozen_text_pressed_key(&Key::Named(NamedKey::Enter), None)); + assert_eq!(session.frozen_text_edit.as_ref().map(|edit| edit.text.as_str()), Some("A")); + assert_eq!( + session.frozen_text_edit.as_ref().and_then(|edit| edit.ime_preedit.as_deref()), + Some("汉") + ); + assert!(session.frozen_text_annotations.is_empty()); +} + +#[test] +fn frozen_text_caret_repaint_schedules_delayed_repaint_while_editing() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_frozen_image()); + + session.frozen_text_edit = Some(FrozenTextEditState::new(Pos2::new(120.0, 140.0))); + *session.egui_repaint_deadline.lock().unwrap_or_else(|err| err.into_inner()) = None; + + let started_at = Instant::now(); + + session.maybe_keep_frozen_text_caret_repaint(); + + let deadline = session + .egui_repaint_deadline + .lock() + .unwrap_or_else(|err| err.into_inner()) + .expect("caret repaint should be scheduled"); + + assert!(deadline >= started_at); + assert!( + deadline <= started_at + FROZEN_TEXT_CARET_REPAINT_INTERVAL + Duration::from_millis(20) + ); +} + +#[test] +fn finish_frozen_text_editing_commits_current_toolbar_text_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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + session.toolbar_state.text_style.font_size_points = 30.0; + session.toolbar_state.text_style.color = FrozenTextColor::Yellow; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.append_text_to_frozen_edit("Styled")); + assert!(session.finish_frozen_text_editing(true)); + + 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); +} + +#[test] +fn finish_frozen_text_editing_commits_active_ime_preedit_text() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.append_text_to_frozen_edit("A")); + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉")), Some((3, 3)))); + assert!(session.finish_frozen_text_editing(true)); + assert_eq!(session.frozen_text_annotations.len(), 1); + assert_eq!(session.frozen_text_annotations[0].text, "A汉"); +} + +#[test] +fn inline_toolbar_mode_switch_finishes_active_frozen_text_edit() { + 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::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.append_text_to_frozen_edit("Switched")); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Pointer; + + let _ = session.handle_capture_and_toolbar_redraw_post(monitor, true); + + assert!(session.frozen_text_edit.is_none()); + assert_eq!(session.frozen_text_annotations.len(), 1); + assert_eq!(session.frozen_text_annotations[0].text, "Switched"); + assert_eq!(session.toolbar_state.selected_tool, FrozenToolbarTool::Pointer); +} + +#[test] +fn inline_toolbar_mode_switch_commits_active_ime_preedit_text() { + 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::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.append_text_to_frozen_edit("A")); + assert!(session.set_frozen_text_ime_preedit(Some(String::from("汉")), Some((3, 3)))); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Pointer; + + let _ = session.handle_capture_and_toolbar_redraw_post(monitor, true); + + assert!(session.frozen_text_edit.is_none()); + assert_eq!(session.frozen_text_annotations.len(), 1); + assert_eq!(session.frozen_text_annotations[0].text, "A汉"); +} + +#[test] +fn frozen_text_undo_and_redo_round_trip_annotations() { + 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.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(140, 160))); + assert!(session.append_text_to_frozen_edit("Undoable")); + assert!(session.finish_frozen_text_editing(true)); + assert_eq!(session.frozen_text_annotations.len(), 1); + assert!(session.toolbar_state.undo_available); + assert!(!session.toolbar_state.redo_available); + assert!(session.perform_frozen_undo()); + assert!(session.frozen_text_annotations.is_empty()); + assert!(!session.toolbar_state.undo_available); + assert!(session.toolbar_state.redo_available); + assert!(session.perform_frozen_redo()); + assert_eq!(session.frozen_text_annotations.len(), 1); + assert_eq!(session.frozen_text_annotations[0].text, "Undoable"); + assert!(session.toolbar_state.undo_available); + assert!(!session.toolbar_state.redo_available); +} + +#[test] +fn current_export_image_renders_frozen_text_annotations() { + let monitor = test_monitor_with_scale(160, 120, 1_000); + let base = image::RgbaImage::from_pixel(160, 120, Rgba([0, 0, 0, 255])); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, base); + + session.state.frozen_capture_rect = Some(RectPoints::new(10, 12, 120, 80)); + + session.frozen_text_annotations.push(FrozenTextAnnotation { + anchor: Pos2::new(24.0, 24.0), + text: String::from("Text"), + style: session.toolbar_state.text_style, + }); + session.push_frozen_edit_to_undo_history(FrozenEditKind::TextAnnotation); + + let export = session.current_export_image().expect("export image"); + + assert_eq!(export.dimensions(), (120, 80)); + assert!(export.pixels().any(|pixel| *pixel != Rgba([0, 0, 0, 255]))); +} + +#[test] +fn scroll_capture_hides_frozen_text_annotations_in_preview() { + let mut session = OverlaySession::new(); + + session.frozen_text_annotations.push(FrozenTextAnnotation { + anchor: Pos2::new(12.0, 18.0), + text: String::from("visible"), + style: session.toolbar_state.text_style, + }); + + assert_eq!(session.visible_frozen_text_annotations().len(), 1); + + session.scroll_capture.active = true; + + assert!(session.visible_frozen_text_annotations().is_empty()); +} + +#[test] +fn scroll_capture_hides_active_frozen_text_edit_in_preview() { + let mut session = OverlaySession::new(); + + session.frozen_text_edit = Some(FrozenTextEditState::new(Pos2::new(12.0, 18.0))); + + assert!(session.visible_frozen_text_edit().is_some()); + + session.scroll_capture.active = true; + + assert!(session.visible_frozen_text_edit().is_none()); +} + +#[test] +fn frozen_export_transform_uses_actual_export_image_dimensions() { + let capture_rect = RectPoints::new(10, 12, 20, 10); + let transform = FrozenExportTransform::new(capture_rect, 60, 30).expect("transform"); + + assert_eq!(transform.point_to_pixels(Pos2::new(10.0, 12.0)), Pos2::new(0.0, 0.0)); + assert_eq!(transform.point_to_pixels(Pos2::new(20.0, 17.0)), Pos2::new(30.0, 15.0)); + assert_eq!(transform.point_to_pixels(Pos2::new(30.0, 22.0)), Pos2::new(60.0, 30.0)); + assert_eq!(transform.scalar_scale(), 3.0); +} + +#[test] +fn frozen_committed_overlay_iteration_preserves_cross_tool_order() { + let monitor = test_monitor(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session + .state + .finish_freeze(monitor, image::RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 255]))); + + session.state.frozen_capture_rect = Some(RectPoints::new(0, 0, 16, 16)); + session.authoritative_frozen_capture_ready = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Pen; + + assert!(session.begin_frozen_brush_stroke(GlobalPoint::new(2, 2))); + assert!(session.finish_frozen_brush_stroke()); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(6, 6))); + assert!(session.append_text_to_frozen_edit("middle")); + assert!(session.finish_frozen_text_editing(true)); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Pen; + + assert!(session.begin_frozen_brush_stroke(GlobalPoint::new(10, 10))); + assert!(session.finish_frozen_brush_stroke()); + + let mut observed = Vec::new(); + + OverlaySession::for_each_frozen_committed_overlay( + &session.frozen_edit_undo_stack, + &session.frozen_brush.committed_strokes, + &session.frozen_text_annotations, + |overlay| match overlay { + FrozenCommittedOverlay::Brush(stroke) => { + observed.push(format!("brush:{:.0}", stroke.points[0].x)); + }, + FrozenCommittedOverlay::Text(annotation) => { + observed.push(format!("text:{}", annotation.text)); + }, + }, + ); + + assert_eq!(observed, ["brush:2", "text:middle", "brush:10"]); +} + +#[test] +fn frozen_annotation_history_undoes_across_tools_in_reverse_commit_order() { + let monitor = test_monitor_with_scale(8, 8, 1_000); + let original = image::RgbaImage::from_fn(8, 8, |x, y| { + Rgba([(x * 17) as u8, (y * 23) as u8, ((x + y) * 11) as u8, 255]) + }); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, original.clone()); + + 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; + + assert!(session.begin_frozen_brush_stroke(GlobalPoint::new(2, 2))); + assert!(session.finish_frozen_brush_stroke()); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(3, 4))); + assert!(session.append_text_to_frozen_edit("Layered")); + assert!(session.finish_frozen_text_editing(true)); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Mosaic; + + assert!(session.begin_frozen_mosaic_drag(GlobalPoint::new(1, 1))); + assert!(session.update_frozen_mosaic_drag_rect(GlobalPoint::new(4, 4))); + assert!(session.commit_frozen_mosaic_drag()); + + let mosaiced = + session.state.frozen_image.clone().expect("mosaic commit should retain the frozen image"); + + assert!(session.perform_frozen_undo()); + assert_eq!(session.state.frozen_image.as_ref(), Some(&original)); + assert_eq!(session.frozen_text_annotations.len(), 1); + assert_eq!(session.frozen_brush.committed_strokes.len(), 1); + assert!(session.perform_frozen_undo()); + assert!(session.frozen_text_annotations.is_empty()); + assert_eq!(session.frozen_brush.committed_strokes.len(), 1); + assert!(session.perform_frozen_undo()); + assert!(session.frozen_brush.committed_strokes.is_empty()); + assert!(!session.toolbar_state.undo_available); + assert!(session.toolbar_state.redo_available); + assert!(session.perform_frozen_redo()); + assert_eq!(session.frozen_brush.committed_strokes.len(), 1); + assert!(session.frozen_text_annotations.is_empty()); + assert_eq!(session.state.frozen_image.as_ref(), Some(&original)); + assert!(session.perform_frozen_redo()); + assert_eq!(session.frozen_text_annotations.len(), 1); + assert_eq!(session.state.frozen_image.as_ref(), Some(&original)); + assert!(session.perform_frozen_redo()); + assert_eq!(session.state.frozen_image.as_ref(), Some(&mosaiced)); + assert!(session.toolbar_state.undo_available); + assert!(!session.toolbar_state.redo_available); +} + +#[test] +fn committing_new_frozen_edit_clears_redo_across_tools() { + let monitor = test_monitor_with_scale(8, 8, 1_000); + let base = image::RgbaImage::from_fn(8, 8, |x, y| { + Rgba([(x * 11) as u8, (y * 13) as u8, ((x + y) * 7) as u8, 255]) + }); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, base); + + session.state.frozen_capture_rect = Some(RectPoints::new(0, 0, 8, 8)); + session.authoritative_frozen_capture_ready = true; + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + assert!(session.begin_frozen_text_edit_at(monitor, GlobalPoint::new(2, 2))); + assert!(session.append_text_to_frozen_edit("redo")); + assert!(session.finish_frozen_text_editing(true)); + assert!(session.perform_frozen_undo()); + assert!(session.toolbar_state.redo_available); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Mosaic; + + assert!(session.begin_frozen_mosaic_drag(GlobalPoint::new(1, 1))); + assert!(session.update_frozen_mosaic_drag_rect(GlobalPoint::new(4, 4))); + assert!(session.commit_frozen_mosaic_drag()); + assert!(!session.toolbar_state.redo_available); + assert!(!session.perform_frozen_redo()); + assert!(session.frozen_text_annotations.is_empty()); +} + +#[test] +fn evicting_old_mosaic_history_also_discards_patch_payloads() { + let mut session = OverlaySession::new(); + + for _ in 0..FROZEN_EDIT_HISTORY_LIMIT { + session.push_frozen_mosaic_edit(test_frozen_mosaic_edit()); + session.push_frozen_edit_to_undo_history(FrozenEditKind::MosaicEdit); + } + + assert_eq!(session.frozen_mosaic_undo_stack.len(), FROZEN_EDIT_HISTORY_LIMIT); + + for _ in 0..FROZEN_EDIT_HISTORY_LIMIT { + session.push_frozen_edit_to_undo_history(FrozenEditKind::BrushStroke); + } + + assert_eq!(session.frozen_edit_undo_stack.len(), FROZEN_EDIT_HISTORY_LIMIT); + assert!(session.frozen_mosaic_undo_stack.is_empty()); +} + +#[test] +fn evicting_old_brush_and_text_history_discards_matching_payloads() { + let mut session = OverlaySession::new(); + + for index in 0..(FROZEN_EDIT_HISTORY_LIMIT + 2) { + let x = index as f32; + + if index % 2 == 0 { + session + .frozen_brush + .committed_strokes + .push(FrozenBrushStroke { points: vec![Pos2::new(x, 0.0)] }); + session.push_frozen_edit_to_undo_history(FrozenEditKind::BrushStroke); + } else { + session.frozen_text_annotations.push(FrozenTextAnnotation { + anchor: Pos2::new(x, 0.0), + text: format!("text-{index}"), + style: session.toolbar_state.text_style, + }); + session.push_frozen_edit_to_undo_history(FrozenEditKind::TextAnnotation); + } + } + + assert_eq!(session.frozen_edit_undo_stack.len(), FROZEN_EDIT_HISTORY_LIMIT); + assert_eq!(session.frozen_brush.committed_strokes.len(), FROZEN_EDIT_HISTORY_LIMIT / 2); + assert_eq!(session.frozen_text_annotations.len(), FROZEN_EDIT_HISTORY_LIMIT / 2); + assert_eq!(session.frozen_brush.committed_strokes[0].points[0], Pos2::new(2.0, 0.0)); + assert_eq!(session.frozen_text_annotations[0].text, "text-3"); + + let mut observed = Vec::new(); + + OverlaySession::for_each_frozen_committed_overlay( + &session.frozen_edit_undo_stack, + &session.frozen_brush.committed_strokes, + &session.frozen_text_annotations, + |overlay| match overlay { + FrozenCommittedOverlay::Brush(stroke) => { + observed.push(format!("brush:{:.0}", stroke.points[0].x)); + }, + FrozenCommittedOverlay::Text(annotation) => observed.push(annotation.text.clone()), + }, + ); + + let expected = (2..(FROZEN_EDIT_HISTORY_LIMIT + 2)) + .map( + |index| { + if index % 2 == 0 { format!("brush:{index}") } else { format!("text-{index}") } + }, + ) + .collect::>(); + + assert_eq!(observed, expected); +} + #[cfg(target_os = "macos")] #[test] fn duplicate_live_frames_schedule_forced_refresh_when_downward_backlog_is_fresh() { @@ -1249,6 +2012,22 @@ fn scroll_capture_start_skips_scroll_live_stream_when_worker_sampling_is_forced( assert!(session.scroll_capture.live_stream_backlog.is_empty()); } +#[cfg(target_os = "macos")] +#[test] +fn scroll_capture_start_disables_text_mode_while_active() { + let mut session = OverlaySession::new(); + + seed_ready_scroll_capture_selection(&mut session); + + session.toolbar_state.selected_tool = FrozenToolbarTool::Text; + + let control = session.start_scroll_capture(); + + assert!(matches!(control, OverlayControl::Continue)); + assert!(session.scroll_capture.active); + assert!(!session.frozen_text_tool_active()); +} + #[cfg(target_os = "macos")] #[test] fn reset_for_start_preserves_external_scroll_input_drain_reader() { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index dfe7ca6f..5b9d0519 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -1,3 +1,5 @@ +use std::slice; + use egui::Id; use egui::LayerId; use egui::Order; @@ -21,7 +23,11 @@ use crate::overlay::tests::{ TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, overlay, }; -use crate::overlay::{FrozenSelectionCorner, FrozenSelectionInteractionKind}; +use crate::overlay::{ + FROZEN_TEXT_CARET_BLINK_PERIOD_SECS, FROZEN_TEXT_FONT_SIZE_POINTS, FontId, FrozenEditKind, + FrozenSelectionCorner, FrozenSelectionInteractionKind, FrozenTextAnnotation, FrozenTextColor, + FrozenTextEditState, +}; use crate::worker::{WorkerErrorSource, WorkerResponse}; fn test_mosaic_source_image() -> RgbaImage { @@ -243,8 +249,8 @@ fn frozen_mosaic_drag_waits_for_final_capture_ready() { assert!(!session.frozen_final_capture_ready()); assert!(!session.begin_frozen_mosaic_drag(GlobalPoint::new(1, 1))); assert!(!session.commit_frozen_mosaic_drag()); - assert!(!session.undo_frozen_mosaic_edit()); - assert!(!session.redo_frozen_mosaic_edit()); + assert!(!session.perform_frozen_undo()); + assert!(!session.perform_frozen_redo()); assert_eq!(session.state.frozen_mosaic_preview_rect, None); assert_eq!(session.state.frozen_image.as_ref(), Some(&original)); @@ -297,11 +303,11 @@ fn frozen_mosaic_commit_round_trips_through_undo_and_redo() { assert_eq!(session.state.frozen_mosaic_preview_rect, None); assert!(session.toolbar_state.undo_available); assert!(!session.toolbar_state.redo_available); - assert!(session.undo_frozen_mosaic_edit()); + assert!(session.perform_frozen_undo()); assert_eq!(session.state.frozen_image.as_ref(), Some(&original)); assert!(!session.toolbar_state.undo_available); assert!(session.toolbar_state.redo_available); - assert!(session.redo_frozen_mosaic_edit()); + assert!(session.perform_frozen_redo()); assert_eq!(session.state.frozen_image.as_ref(), Some(&edited)); assert!(session.toolbar_state.undo_available); assert!(!session.toolbar_state.redo_available); @@ -698,6 +704,290 @@ fn frozen_selection_cursor_icon_uses_corner_resize_hover() { assert_eq!(session.frozen_selection_cursor_icon_for_monitor(monitor), CursorIcon::Grab); } +#[test] +fn frozen_text_edit_caret_rect_starts_at_anchor_when_text_is_empty() { + let ctx = tests::test_egui_context(); + let painter = ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("text-caret-empty"))); + let anchor = Pos2::new(140.0, 160.0); + let font_id = FontId::proportional(FROZEN_TEXT_FONT_SIZE_POINTS); + let caret_rect = WindowRenderer::frozen_text_edit_caret_rect(&painter, anchor, "", &font_id); + + assert!((caret_rect.min.x - anchor.x).abs() <= f32::EPSILON); + assert!((caret_rect.min.y - anchor.y).abs() <= f32::EPSILON); + assert!(caret_rect.height() >= FROZEN_TEXT_FONT_SIZE_POINTS); +} + +#[test] +fn frozen_text_edit_caret_rect_tracks_multiline_text_end() { + let ctx = tests::test_egui_context(); + let painter = + ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("text-caret-multiline"))); + let anchor = Pos2::new(140.0, 160.0); + let font_id = FontId::proportional(FROZEN_TEXT_FONT_SIZE_POINTS); + let caret_rect = + WindowRenderer::frozen_text_edit_caret_rect(&painter, anchor, "A\nB", &font_id); + + assert!(caret_rect.min.y > anchor.y); + assert!(caret_rect.min.x > anchor.x); +} + +#[test] +fn frozen_text_edit_caret_rect_tracks_explicit_preedit_cursor_position() { + let ctx = tests::test_egui_context(); + let painter = + ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("text-caret-preedit-cursor"))); + let anchor = Pos2::new(140.0, 160.0); + let font_id = FontId::proportional(FROZEN_TEXT_FONT_SIZE_POINTS); + let caret_rect = WindowRenderer::frozen_text_edit_caret_rect_at_char_index( + &painter, anchor, "ABCD", &font_id, 2, + ); + let end_rect = WindowRenderer::frozen_text_edit_caret_rect(&painter, anchor, "ABCD", &font_id); + + assert!(caret_rect.min.x > anchor.x); + assert!(caret_rect.min.x < end_rect.min.x); + assert!((caret_rect.min.y - anchor.y).abs() <= f32::EPSILON); +} + +#[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); + + assert!(blue.b() > blue.r()); + assert!(red.r() > red.b()); + assert!(blue.a() < 255); + assert!(red.a() < 255); +} + +#[test] +fn frozen_text_edit_interaction_rect_uses_placeholder_bounds_when_empty() { + let anchor = Pos2::new(140.0, 160.0); + let font_id = FontId::proportional(FROZEN_TEXT_FONT_SIZE_POINTS); + let rect = WindowRenderer::frozen_text_edit_interaction_rect(anchor, "", &font_id); + + assert!(rect.contains(anchor)); + assert!(rect.width() > FROZEN_TEXT_FONT_SIZE_POINTS); + assert!(rect.height() >= FROZEN_TEXT_FONT_SIZE_POINTS); +} + +#[test] +fn frozen_text_edit_interaction_rect_covers_full_width_text_layout() { + let ctx = tests::test_egui_context(); + let painter = ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("text-hitbox-cjk"))); + let anchor = Pos2::new(140.0, 160.0); + let font_id = FontId::proportional(FROZEN_TEXT_FONT_SIZE_POINTS); + let rect = WindowRenderer::frozen_text_edit_interaction_rect(anchor, "你好世界", &font_id); + let caret_rect = + WindowRenderer::frozen_text_edit_caret_rect(&painter, anchor, "你好世界", &font_id); + + assert!(rect.contains(caret_rect.min)); + assert!(rect.contains(Pos2::new(caret_rect.max.x, caret_rect.min.y))); +} + +#[test] +fn frozen_committed_text_annotations_are_clipped_to_capture_rect() { + let ctx = tests::test_egui_context(); + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(200.0, 120.0)); + let capture_rect_points = RectPoints::new(40, 20, 80, 40); + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), + Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), + ); + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: screen_rect.width() as u32, + height: screen_rect.height() as u32, + scale_factor_x1000: 1_000, + }; + let style = OverlaySession::new().toolbar_state.text_style; + let annotation = FrozenTextAnnotation { + anchor: Pos2::new(capture_rect.max.x - 2.0, capture_rect.min.y + 4.0), + text: String::from("edge"), + style, + }; + let mut state = OverlayState::new(); + let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); + let mut selection_dashed_border_cache = SelectionDashedBorderCache::default(); + + state.mode = OverlayMode::Frozen; + state.monitor = Some(monitor); + state.frozen_capture_rect = Some(capture_rect_points); + + let empty_output = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |_ui: &mut Ui| { + assert!(WindowRenderer::render_frozen_capture_affordance( + &ctx, + &state, + monitor, + screen_rect, + HudTheme::Dark, + false, + FrozenCaptureSource::None, + None, + &[], + None, + &[], + None, + style, + false, + true, + 1.0, + &mut selection_flow_geometry_cache, + &mut selection_dashed_border_cache, + )); + }, + ); + let clipped_shape_count_without_text = + empty_output.shapes.iter().filter(|shape| shape.clip_rect == capture_rect).count(); + let full_output = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |_ui: &mut Ui| { + assert!(WindowRenderer::render_frozen_capture_affordance( + &ctx, + &state, + monitor, + screen_rect, + HudTheme::Dark, + false, + FrozenCaptureSource::None, + None, + &[FrozenEditKind::TextAnnotation], + None, + slice::from_ref(&annotation), + None, + style, + false, + true, + 1.0, + &mut selection_flow_geometry_cache, + &mut selection_dashed_border_cache, + )); + }, + ); + let clipped_shape_count_with_text = + full_output.shapes.iter().filter(|shape| shape.clip_rect == capture_rect).count(); + + assert!( + clipped_shape_count_with_text > clipped_shape_count_without_text, + "committed text should add shapes clipped to the frozen capture rect", + ); +} + +#[test] +fn frozen_active_text_preview_is_clipped_to_capture_rect() { + let ctx = tests::test_egui_context(); + let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(200.0, 120.0)); + let capture_rect_points = RectPoints::new(40, 20, 80, 40); + let capture_rect = Rect::from_min_size( + Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), + Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), + ); + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: screen_rect.width() as u32, + height: screen_rect.height() as u32, + scale_factor_x1000: 1_000, + }; + let style = OverlaySession::new().toolbar_state.text_style; + let mut text_edit = + FrozenTextEditState::new(Pos2::new(capture_rect.max.x - 2.0, capture_rect.min.y + 4.0)); + + text_edit.text = String::from("editing"); + + let mut state = OverlayState::new(); + let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); + let mut selection_dashed_border_cache = SelectionDashedBorderCache::default(); + + state.mode = OverlayMode::Frozen; + state.monitor = Some(monitor); + state.frozen_capture_rect = Some(capture_rect_points); + + let empty_output = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |_ui: &mut Ui| { + assert!(WindowRenderer::render_frozen_capture_affordance( + &ctx, + &state, + monitor, + screen_rect, + HudTheme::Dark, + false, + FrozenCaptureSource::None, + None, + &[], + None, + &[], + None, + style, + false, + true, + 1.0, + &mut selection_flow_geometry_cache, + &mut selection_dashed_border_cache, + )); + }, + ); + let clipped_shape_count_without_preview = + empty_output.shapes.iter().filter(|shape| shape.clip_rect == capture_rect).count(); + let preview_output = ctx.run_ui( + egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }, + |_ui: &mut Ui| { + assert!(WindowRenderer::render_frozen_capture_affordance( + &ctx, + &state, + monitor, + screen_rect, + HudTheme::Dark, + false, + FrozenCaptureSource::None, + None, + &[], + None, + &[], + Some(&text_edit), + style, + false, + true, + 1.0, + &mut selection_flow_geometry_cache, + &mut selection_dashed_border_cache, + )); + }, + ); + let clipped_shape_count_with_preview = + preview_output.shapes.iter().filter(|shape| shape.clip_rect == capture_rect).count(); + + assert!( + clipped_shape_count_with_preview > clipped_shape_count_without_preview, + "active text preview should add shapes clipped to the frozen capture rect", + ); +} + +#[test] +fn frozen_text_caret_visible_blinks_on_half_periods() { + assert!(WindowRenderer::frozen_text_caret_visible(0.0)); + assert!(WindowRenderer::frozen_text_caret_visible(FROZEN_TEXT_CARET_BLINK_PERIOD_SECS * 0.49,)); + assert!( + !WindowRenderer::frozen_text_caret_visible(FROZEN_TEXT_CARET_BLINK_PERIOD_SECS * 0.51,) + ); +} + +#[test] +fn frozen_toolbar_size_expands_for_text_style_toolbar() { + let mut toolbar_state = FrozenToolbarState::default(); + let base_size = WindowRenderer::frozen_toolbar_size(&toolbar_state); + + 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); +} + #[test] fn frozen_selection_cursor_icon_tracks_active_resize_drag() { let monitor = tests::test_monitor(); @@ -1898,7 +2188,11 @@ fn render_frozen_capture_affordance_keeps_tiny_frozen_badge_path() { false, FrozenCaptureSource::None, None, + &[], + None, + &[], None, + FrozenToolbarState::default().text_style, false, true, 1.0, @@ -2199,6 +2493,53 @@ fn auto_center_toolbar_tool_only_appears_when_available() { } } +#[test] +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::Text, + ..FrozenToolbarState::default() + }, + FrozenToolbarState { auto_center_available: true, ..FrozenToolbarState::default() }, + FrozenToolbarState { scroll_capture_available: true, ..FrozenToolbarState::default() }, + FrozenToolbarState { + auto_center_available: true, + scroll_capture_available: true, + ..FrozenToolbarState::default() + }, + FrozenToolbarState { + selected_tool: FrozenToolbarTool::Text, + auto_center_available: true, + scroll_capture_available: true, + ..FrozenToolbarState::default() + }, + FrozenToolbarState { + scroll_capture_active: true, + scroll_capture_available: true, + ..FrozenToolbarState::default() + }, + ]; + + for toolbar_state in toolbar_states { + let toolbar_size = WindowRenderer::frozen_toolbar_size(&toolbar_state); + + assert!( + startup_size.x >= toolbar_size.x, + "startup width {} should cover toolbar width {} for {toolbar_state:?}", + startup_size.x, + toolbar_size.x + ); + assert!( + startup_size.y >= toolbar_size.y, + "startup height {} should cover toolbar height {} for {toolbar_state:?}", + startup_size.y, + toolbar_size.y + ); + } +} + #[test] fn scroll_preview_prefers_right_side_when_space_exists() { let monitor = MonitorRect { diff --git a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs index 1cffe63b..818b7fcf 100644 --- a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs @@ -277,7 +277,11 @@ impl OverlaySession { self.frozen_capture_source, self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, None, + &[], None, + &self.frozen_text_annotations, + self.frozen_text_edit.as_ref(), + self.toolbar_state.text_style, Some(&mut self.toolbar_state), toolbar_input, ); @@ -338,6 +342,12 @@ impl OverlaySession { return self.exit(OverlayExit::Error(format!("{err:#}"))); } + if self.sync_frozen_text_edit_for_selected_tool() { + self.request_redraw_for_monitor(monitor); + } + + self.sync_overlay_cursor_icons(); + let draw_frame_elapsed = draw_frame_started_at.elapsed(); self.update_scroll_toolbar_default_position(monitor); @@ -363,6 +373,7 @@ impl OverlaySession { if self.toolbar_state.needs_redraw { self.toolbar_state.needs_redraw = false; + self.refresh_frozen_text_ime_cursor_area_for_text_style_change(monitor); self.request_redraw_for_monitor(monitor); self.request_redraw_toolbar_window(); } diff --git a/packages/rsnap-overlay/src/overlay/window_runtime.rs b/packages/rsnap-overlay/src/overlay/window_runtime.rs index 3cbc0c53..c3c71f3e 100644 --- a/packages/rsnap-overlay/src/overlay/window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_runtime.rs @@ -18,8 +18,7 @@ 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, TOOLBAR_EXPANDED_WIDTH_PX, WindowLevel, - WindowRenderer, hud_helpers, + ScrollPreviewWindow, TOOLBAR_EXPANDED_HEIGHT_PX, WindowLevel, WindowRenderer, hud_helpers, }; impl OverlaySession { @@ -760,13 +759,14 @@ impl OverlaySession { } fn create_toolbar_window(&mut self, event_loop: &ActiveEventLoop) -> Result<(), String> { + let startup_size = super::frozen_toolbar_window_startup_size_points(); let attrs = Window::default_attributes() .with_title("rsnap-toolbar") .with_decorations(false) .with_resizable(false) .with_inner_size(LogicalSize::new( - TOOLBAR_EXPANDED_WIDTH_PX as f64, - TOOLBAR_EXPANDED_HEIGHT_PX as f64, + startup_size.x as f64, + f64::from(startup_size.y.max(TOOLBAR_EXPANDED_HEIGHT_PX)), )) .with_transparent(true) .with_visible(false) diff --git a/packages/rsnap-overlay/src/system_fonts.rs b/packages/rsnap-overlay/src/system_fonts.rs new file mode 100644 index 00000000..2f4bba46 --- /dev/null +++ b/packages/rsnap-overlay/src/system_fonts.rs @@ -0,0 +1,480 @@ +use std::{ + collections::HashSet, + sync::{Arc, OnceLock}, +}; + +use egui::{FontData, FontDefinitions, FontFamily}; +use fontdb::{Database, FaceInfo, Family, ID, Query, Stretch, Style, Weight}; +use ttf_parser::Face; + +type UnicodeCoverage = Vec; + +const NORMAL_WEIGHT_MIN: u16 = 300; +const NORMAL_WEIGHT_MAX: u16 = 700; +const MAX_SYSTEM_TEXT_FALLBACKS: usize = 16; +const MAX_SYSTEM_TEXT_COVERAGE_PROBES: usize = 64; + +#[derive(Debug)] +pub(crate) struct SystemTextFont { + name: String, + font_data: Arc, +} +impl SystemTextFont { + pub(crate) fn egui_name(&self) -> &str { + self.name.as_str() + } + + pub(crate) fn egui_font_data(&self) -> Arc { + Arc::clone(&self.font_data) + } +} + +#[derive(Clone, Debug)] +struct CandidateSystemTextFont { + face_id: ID, + family_name: String, + order: usize, + weight_delta: u16, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct UnicodeCoverageRange { + start: u32, + end: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SystemTextFontProbeResult { + Selected, + SkippedNoCoverage, + SkippedAfterProbe, + SkippedUnprobed, +} + +pub(crate) fn system_text_fonts() -> &'static [SystemTextFont] { + static FONTS: OnceLock> = OnceLock::new(); + + FONTS.get_or_init(load_system_text_fonts).as_slice() +} + +pub(crate) fn configure_text_font_fallbacks(fonts: &mut FontDefinitions) { + for font in system_text_fonts() { + fonts.font_data.insert(font.egui_name().to_owned(), font.egui_font_data()); + } + + let proportional_family = fonts.families.entry(FontFamily::Proportional).or_default(); + + for font in system_text_fonts() { + if !proportional_family.iter().any(|name| name == font.egui_name()) { + proportional_family.push(font.egui_name().to_owned()); + } + } +} + +fn load_system_text_fonts() -> Vec { + let mut database = Database::new(); + + database.load_system_fonts(); + + let generic_sans_face_id = database.query(&Query { + families: &[Family::SansSerif], + weight: Weight::NORMAL, + stretch: Stretch::Normal, + style: Style::Normal, + }); + let mut candidates = database + .faces() + .enumerate() + .filter_map(|(order, face)| build_candidate_system_text_font(&database, face.id, order)) + .collect::>(); + + if let Some(face_id) = generic_sans_face_id + && !candidates.iter().any(|candidate| candidate.face_id == face_id) + && let Some(candidate) = + build_candidate_system_text_font(&database, face_id, candidates.len()) + { + candidates.push(candidate); + } + + candidates.sort_by(|left, right| { + left.weight_delta.cmp(&right.weight_delta).then_with(|| left.order.cmp(&right.order)) + }); + + select_system_text_fonts(&database, &candidates, generic_sans_face_id) +} + +fn build_candidate_system_text_font( + database: &Database, + face_id: ID, + order: usize, +) -> Option { + let face = database.face(face_id)?; + + if !is_system_text_candidate_face(face) { + return None; + } + + Some(CandidateSystemTextFont { + face_id, + family_name: primary_family_name(face)?, + order, + weight_delta: face.weight.0.abs_diff(Weight::NORMAL.0), + }) +} + +fn is_system_text_candidate_face(face: &FaceInfo) -> bool { + !face.monospaced + && face.style == Style::Normal + && face.stretch == Stretch::Normal + && (NORMAL_WEIGHT_MIN..=NORMAL_WEIGHT_MAX).contains(&face.weight.0) +} + +fn select_system_text_fonts( + database: &Database, + candidates: &[CandidateSystemTextFont], + generic_sans_face_id: Option, +) -> Vec { + let mut selected = Vec::new(); + let mut selected_face_ids = HashSet::new(); + let mut selected_families = HashSet::new(); + let mut covered_codepoints = UnicodeCoverage::new(); + let mut coverage_probes = 0_usize; + + if let Some(face_id) = generic_sans_face_id + && let Some(candidate) = candidates.iter().find(|candidate| candidate.face_id == face_id) + { + let probe_result = try_select_system_text_font( + database, + candidate, + &mut selected, + &mut selected_face_ids, + &mut selected_families, + &mut covered_codepoints, + ); + + coverage_probes = next_system_text_probe_count(coverage_probes, probe_result); + } + + for candidate in candidates { + if selected.len() >= MAX_SYSTEM_TEXT_FALLBACKS + || system_text_probe_budget_exhausted(coverage_probes) + { + break; + } + + let probe_result = try_select_system_text_font( + database, + candidate, + &mut selected, + &mut selected_face_ids, + &mut selected_families, + &mut covered_codepoints, + ); + + coverage_probes = next_system_text_probe_count(coverage_probes, probe_result); + } + + selected +} + +fn next_system_text_probe_count( + coverage_probes: usize, + probe_result: SystemTextFontProbeResult, +) -> usize { + match probe_result { + SystemTextFontProbeResult::Selected + | SystemTextFontProbeResult::SkippedNoCoverage + | SystemTextFontProbeResult::SkippedAfterProbe => coverage_probes.saturating_add(1), + SystemTextFontProbeResult::SkippedUnprobed => coverage_probes, + } +} + +fn system_text_probe_budget_exhausted(coverage_probes: usize) -> bool { + coverage_probes >= MAX_SYSTEM_TEXT_COVERAGE_PROBES +} + +fn try_select_system_text_font( + database: &Database, + candidate: &CandidateSystemTextFont, + selected: &mut Vec, + selected_face_ids: &mut HashSet, + selected_families: &mut HashSet, + covered_codepoints: &mut UnicodeCoverage, +) -> SystemTextFontProbeResult { + if selected_face_ids.contains(&candidate.face_id) + || selected_families.contains(candidate.family_name.as_str()) + || selected.len() >= MAX_SYSTEM_TEXT_FALLBACKS + { + return SystemTextFontProbeResult::SkippedUnprobed; + } + + let Some(coverage_codepoints) = load_system_text_coverage(database, candidate.face_id) else { + return SystemTextFontProbeResult::SkippedAfterProbe; + }; + + if !selected.is_empty() + && !coverage_adds_new_codepoints(covered_codepoints, &coverage_codepoints) + { + return SystemTextFontProbeResult::SkippedNoCoverage; + } + + let Some(font) = build_system_text_font(database, candidate.face_id) else { + return SystemTextFontProbeResult::SkippedAfterProbe; + }; + + selected.push(font); + selected_face_ids.insert(candidate.face_id); + selected_families.insert(candidate.family_name.clone()); + + merge_coverage_codepoints(covered_codepoints, &coverage_codepoints); + + SystemTextFontProbeResult::Selected +} + +fn load_system_text_coverage(database: &Database, face_id: ID) -> Option { + database + .with_face_data(face_id, |font_bytes, face_index| { + let face = Face::parse(font_bytes, face_index).ok()?; + let cmap = face.tables().cmap?; + let mut code_points = Vec::new(); + + for subtable in cmap.subtables { + if !subtable.is_unicode() { + continue; + } + + subtable.codepoints(|code_point| code_points.push(code_point)); + } + + compress_codepoints_into_coverage(code_points) + }) + .flatten() +} + +fn primary_family_name(face: &FaceInfo) -> Option { + Some(face.families.first()?.0.clone()) +} + +fn build_system_text_font(database: &Database, face_id: ID) -> Option { + database + .with_face_data(face_id, |font_bytes, face_index| { + let mut font_data = FontData::from_owned(font_bytes.to_vec()); + + font_data.index = face_index; + + Some(SystemTextFont { + name: format!("system-fallback-{face_id}"), + font_data: Arc::new(font_data), + }) + }) + .flatten() +} + +fn compress_codepoints_into_coverage(mut code_points: Vec) -> Option { + if code_points.is_empty() { + return None; + } + + code_points.sort_unstable(); + code_points.dedup(); + + let mut coverage = Vec::new(); + let mut start = code_points[0]; + let mut end = code_points[0]; + + for code_point in code_points.into_iter().skip(1) { + if code_point <= end.saturating_add(1) { + end = code_point; + + continue; + } + + push_coverage_range(&mut coverage, UnicodeCoverageRange { start, end }); + + start = code_point; + end = code_point; + } + + push_coverage_range(&mut coverage, UnicodeCoverageRange { start, end }); + + Some(coverage) +} + +fn push_coverage_range(coverage: &mut UnicodeCoverage, range: UnicodeCoverageRange) { + if let Some(last_range) = coverage.last_mut() + && range.start <= last_range.end.saturating_add(1) + { + last_range.end = last_range.end.max(range.end); + + return; + } + + coverage.push(range); +} + +fn coverage_adds_new_codepoints( + covered_codepoints: &UnicodeCoverage, + candidate_codepoints: &UnicodeCoverage, +) -> bool { + let mut covered_index = 0; + + for candidate_range in candidate_codepoints { + while covered_index < covered_codepoints.len() + && covered_codepoints[covered_index].end < candidate_range.start + { + covered_index += 1; + } + + let mut uncovered_start = candidate_range.start; + let mut scan_index = covered_index; + + while scan_index < covered_codepoints.len() + && covered_codepoints[scan_index].start <= candidate_range.end + { + let covered_range = covered_codepoints[scan_index]; + + if covered_range.start > uncovered_start { + return true; + } + + uncovered_start = covered_range.end.saturating_add(1); + + if uncovered_start > candidate_range.end { + break; + } + + scan_index += 1; + } + + if uncovered_start <= candidate_range.end { + return true; + } + } + + false +} + +fn merge_coverage_codepoints( + covered_codepoints: &mut UnicodeCoverage, + candidate_codepoints: &UnicodeCoverage, +) { + let mut merged = Vec::with_capacity(covered_codepoints.len() + candidate_codepoints.len()); + let mut covered_index = 0; + let mut candidate_index = 0; + + while covered_index < covered_codepoints.len() || candidate_index < candidate_codepoints.len() { + let next_range = match ( + covered_codepoints.get(covered_index).copied(), + candidate_codepoints.get(candidate_index).copied(), + ) { + (Some(covered_range), Some(candidate_range)) + if covered_range.start <= candidate_range.start => + { + covered_index += 1; + + covered_range + }, + (Some(_), Some(candidate_range)) => { + candidate_index += 1; + + candidate_range + }, + (Some(covered_range), None) => { + covered_index += 1; + + covered_range + }, + (None, Some(candidate_range)) => { + candidate_index += 1; + + candidate_range + }, + (None, None) => break, + }; + + push_coverage_range(&mut merged, next_range); + } + + *covered_codepoints = merged; +} + +#[cfg(test)] +mod tests { + use crate::system_fonts::{SystemTextFontProbeResult, UnicodeCoverage, UnicodeCoverageRange}; + + #[test] + fn coverage_tracks_arbitrary_script_codepoints() { + let mut coverage = UnicodeCoverage::new(); + + super::push_coverage_range( + &mut coverage, + UnicodeCoverageRange { start: u32::from('ᚠ'), end: u32::from('ᚠ') }, + ); + super::push_coverage_range( + &mut coverage, + UnicodeCoverageRange { start: u32::from('𐓐'), end: u32::from('𐓐') }, + ); + + assert_eq!( + coverage, + vec![ + UnicodeCoverageRange { start: u32::from('ᚠ'), end: u32::from('ᚠ') }, + UnicodeCoverageRange { start: u32::from('𐓐'), end: u32::from('𐓐') }, + ] + ); + } + + #[test] + fn coverage_adds_new_codepoints_within_same_unicode_page() { + let candidate_codepoints = + vec![UnicodeCoverageRange { start: u32::from('z'), end: u32::from('z') }]; + let mut covered_codepoints = + vec![UnicodeCoverageRange { start: u32::from('A'), end: u32::from('A') }]; + + assert!(super::coverage_adds_new_codepoints(&covered_codepoints, &candidate_codepoints,)); + + super::merge_coverage_codepoints(&mut covered_codepoints, &candidate_codepoints); + + assert_eq!( + covered_codepoints, + vec![ + UnicodeCoverageRange { start: u32::from('A'), end: u32::from('A') }, + UnicodeCoverageRange { start: u32::from('z'), end: u32::from('z') } + ] + ); + assert!(!super::coverage_adds_new_codepoints(&covered_codepoints, &candidate_codepoints,)); + } + + #[test] + fn system_text_probe_count_advances_when_coverage_was_loaded() { + let probe_count = + super::next_system_text_probe_count(3, SystemTextFontProbeResult::Selected); + + assert_eq!(probe_count, 4); + assert!(super::system_text_probe_budget_exhausted(super::MAX_SYSTEM_TEXT_COVERAGE_PROBES)); + } + + #[test] + fn system_text_probe_count_ignores_unprobed_candidates() { + let probe_count = + super::next_system_text_probe_count(3, SystemTextFontProbeResult::SkippedUnprobed); + + assert_eq!(probe_count, 3); + } + + #[test] + fn system_text_probe_count_advances_on_no_coverage_fonts() { + let probe_count = + super::next_system_text_probe_count(3, SystemTextFontProbeResult::SkippedNoCoverage); + + assert_eq!(probe_count, 4); + } + + #[test] + fn system_text_probe_count_advances_after_failed_probe_attempts() { + let probe_count = + super::next_system_text_probe_count(3, SystemTextFontProbeResult::SkippedAfterProbe); + + assert_eq!(probe_count, 4); + } +} diff --git a/packages/rsnap-overlay/src/text_rendering.rs b/packages/rsnap-overlay/src/text_rendering.rs new file mode 100644 index 00000000..8a1480c9 --- /dev/null +++ b/packages/rsnap-overlay/src/text_rendering.rs @@ -0,0 +1,520 @@ +use std::sync::{Arc, OnceLock}; + +use egui::{FontData, FontDefinitions, FontFamily, Pos2}; +use font8x8::{BASIC_FONTS, UnicodeFonts}; +use fontdue::{ + Font, FontSettings, + layout::{CoordinateSystem, Layout, LayoutSettings, TextStyle}, +}; +use image::{Rgba, RgbaImage}; + +use crate::system_fonts; + +const BITMAP_GLYPH_SIDE_PX: u32 = 8; +const BITMAP_GLYPH_ADVANCE_PX: u32 = 8; +const BITMAP_LINE_GAP_PX: u32 = 2; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RasterTextAnnotation<'a> { + pub(crate) anchor_px: Pos2, + pub(crate) font_size_px: f32, + pub(crate) fill_rgba: [u8; 4], + pub(crate) text: &'a str, +} + +#[derive(Debug)] +struct ExportTextFont { + font_data: Arc, + fontdue_font: OnceLock>, +} +impl ExportTextFont { + fn new(font_data: Arc) -> Self { + Self { font_data, fontdue_font: OnceLock::new() } + } + + fn font(&self) -> Option<&Font> { + self.fontdue_font + .get_or_init(|| { + Font::from_bytes( + self.font_data.as_ref().as_ref(), + FontSettings { + collection_index: self.font_data.index, + ..FontSettings::default() + }, + ) + .ok() + }) + .as_ref() + } + + fn supports_char(&self, ch: char) -> bool { + self.font().is_some_and(|font| font.has_glyph(ch)) + } +} + +#[derive(Clone, Copy, Debug)] +struct TextFontRun<'a> { + font_index: usize, + text: &'a str, +} + +pub(crate) fn render_text_annotations( + image: &mut RgbaImage, + annotations: &[RasterTextAnnotation<'_>], +) { + let fonts = export_text_fonts(); + + for annotation in annotations { + if annotation.text.trim().is_empty() { + continue; + } + + if let Some(runs) = build_text_font_runs(fonts, annotation.text) + && render_with_font_stack(image, *annotation, fonts, &runs) + { + continue; + } + + render_with_bitmap_fallback(image, *annotation); + } +} + +fn export_text_fonts() -> &'static [ExportTextFont] { + static FONTS: OnceLock> = OnceLock::new(); + + FONTS.get_or_init(load_export_text_fonts).as_slice() +} + +fn load_export_text_fonts() -> Vec { + let mut fonts = FontDefinitions::default(); + + system_fonts::configure_text_font_fallbacks(&mut fonts); + + collect_export_text_fonts( + fonts + .families + .get(&FontFamily::Proportional) + .into_iter() + .flat_map(|family| family.iter()) + .filter_map(|font_name| fonts.font_data.get(font_name).cloned()), + ) +} + +fn collect_export_text_fonts( + font_data: impl IntoIterator>, +) -> Vec { + font_data + .into_iter() + .filter_map(|font_data| { + let export_font = ExportTextFont::new(font_data); + + export_font.font().is_some().then_some(export_font) + }) + .collect() +} + +fn build_text_font_runs<'a>( + fonts: &[ExportTextFont], + text: &'a str, +) -> Option>> { + let mut runs = Vec::new(); + let mut active_font_index = None; + let mut run_start = 0; + let mut previous_visible_font_index = None; + + for (byte_index, ch) in text.char_indices() { + let font_index = + font_index_for_char(fonts, ch, active_font_index, previous_visible_font_index)?; + + if active_font_index.is_some_and(|current| current != font_index) { + runs.push(TextFontRun { + font_index: active_font_index?, + text: &text[run_start..byte_index], + }); + + run_start = byte_index; + } + + active_font_index = Some(font_index); + + if !ch.is_whitespace() && ch != '\n' { + previous_visible_font_index = Some(font_index); + } + } + + if let Some(font_index) = active_font_index { + runs.push(TextFontRun { font_index, text: &text[run_start..] }); + } + + Some(runs) +} + +fn font_index_for_char( + fonts: &[ExportTextFont], + ch: char, + active_font_index: Option, + previous_visible_font_index: Option, +) -> Option { + if ch.is_whitespace() || ch == '\n' { + return active_font_index + .or(previous_visible_font_index) + .or_else(|| (!fonts.is_empty()).then_some(0)); + } + + fonts + .iter() + .position(|font| font.supports_char(ch)) + .or(active_font_index) + .or(previous_visible_font_index) + .or_else(|| (!fonts.is_empty()).then_some(0)) +} + +fn render_with_font_stack( + image: &mut RgbaImage, + annotation: RasterTextAnnotation<'_>, + fonts: &[ExportTextFont], + runs: &[TextFontRun<'_>], +) -> bool { + let parsed_fonts: Vec<_> = fonts.iter().filter_map(ExportTextFont::font).collect(); + + if parsed_fonts.is_empty() || parsed_fonts.len() != fonts.len() { + return false; + } + + let fill_rgba = Rgba(annotation.fill_rgba); + let mut layout = Layout::new(CoordinateSystem::PositiveYDown); + + layout.reset(&LayoutSettings { + x: annotation.anchor_px.x, + y: annotation.anchor_px.y, + ..LayoutSettings::default() + }); + + for run in runs { + layout.append( + &parsed_fonts, + &TextStyle::new(run.text, annotation.font_size_px.max(8.0), run.font_index), + ); + } + for glyph in layout.glyphs() { + if glyph.width == 0 || glyph.height == 0 { + continue; + } + + let font = parsed_fonts[glyph.font_index]; + let (metrics, bitmap) = font.rasterize_config(glyph.key); + + if metrics.width == 0 || metrics.height == 0 || bitmap.is_empty() { + continue; + } + + let x = glyph.x.round() as i32; + let y = glyph.y.round() as i32; + + blend_coverage_bitmap(image, x, y, metrics.width, metrics.height, &bitmap, fill_rgba); + } + + true +} + +fn render_with_bitmap_fallback(image: &mut RgbaImage, annotation: RasterTextAnnotation<'_>) { + let fill_rgba = Rgba(annotation.fill_rgba); + let scale = + (annotation.font_size_px.max(8.0) / BITMAP_GLYPH_SIDE_PX as f32).round().max(1.0) as u32; + let glyph_advance = BITMAP_GLYPH_ADVANCE_PX.saturating_mul(scale); + let line_height = BITMAP_GLYPH_SIDE_PX + .saturating_mul(scale) + .saturating_add(BITMAP_LINE_GAP_PX.saturating_mul(scale)); + let origin_x = annotation.anchor_px.x.round() as i32; + let mut cursor_x = origin_x; + let mut cursor_y = annotation.anchor_px.y.round() as i32; + + for ch in annotation.text.chars() { + match ch { + '\n' => { + cursor_x = origin_x; + cursor_y += i32::try_from(line_height).unwrap_or(i32::MAX); + }, + _ if ch.is_whitespace() => { + cursor_x += i32::try_from(glyph_advance).unwrap_or(i32::MAX); + }, + _ => { + let Some(glyph) = BASIC_FONTS.get(ch) else { + cursor_x += i32::try_from(glyph_advance).unwrap_or(i32::MAX); + + continue; + }; + + draw_bitmap_glyph(image, cursor_x, cursor_y, &glyph, scale, fill_rgba); + + cursor_x += i32::try_from(glyph_advance).unwrap_or(i32::MAX); + }, + } + } +} + +fn draw_bitmap_glyph( + image: &mut RgbaImage, + origin_x: i32, + origin_y: i32, + glyph: &[u8; 8], + scale: u32, + color: Rgba, +) { + for (row_index, row_bits) in glyph.iter().copied().enumerate() { + for column_index in 0..8_usize { + if row_bits & (1 << column_index) == 0 { + continue; + } + + let x = origin_x + + i32::try_from(u32::try_from(column_index).unwrap_or(0).saturating_mul(scale)) + .unwrap_or(i32::MAX); + let y = origin_y + + i32::try_from(u32::try_from(row_index).unwrap_or(0).saturating_mul(scale)) + .unwrap_or(i32::MAX); + + fill_rect(image, x, y, scale, scale, color); + } + } +} + +fn fill_rect(image: &mut RgbaImage, x: i32, y: i32, width: u32, height: u32, color: Rgba) { + for row in 0..height { + for column in 0..width { + blend_pixel( + image, + x.saturating_add(i32::try_from(column).unwrap_or(i32::MAX)), + y.saturating_add(i32::try_from(row).unwrap_or(i32::MAX)), + color, + 255, + ); + } + } +} + +fn blend_coverage_bitmap( + image: &mut RgbaImage, + origin_x: i32, + origin_y: i32, + width: usize, + height: usize, + bitmap: &[u8], + color: Rgba, +) { + for row in 0..height { + for column in 0..width { + let coverage = bitmap[row.saturating_mul(width).saturating_add(column)]; + + if coverage == 0 { + continue; + } + + blend_pixel( + image, + origin_x.saturating_add(i32::try_from(column).unwrap_or(i32::MAX)), + origin_y.saturating_add(i32::try_from(row).unwrap_or(i32::MAX)), + color, + coverage, + ); + } + } +} + +fn blend_pixel(image: &mut RgbaImage, x: i32, y: i32, color: Rgba, coverage: u8) { + if x < 0 || y < 0 { + return; + } + + let Ok(x) = u32::try_from(x) else { + return; + }; + let Ok(y) = u32::try_from(y) else { + return; + }; + + if x >= image.width() || y >= image.height() { + return; + } + + let src_alpha = (f32::from(color[3]) / 255.0) * (f32::from(coverage) / 255.0); + + if src_alpha <= 0.0 { + return; + } + + let pixel = image.get_pixel_mut(x, y); + let dst_alpha = f32::from(pixel[3]) / 255.0; + let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha); + + if out_alpha <= 0.0 { + return; + } + + for channel in 0..3 { + let dst = f32::from(pixel[channel]); + let src = f32::from(color[channel]); + + pixel[channel] = ((src * src_alpha + dst * dst_alpha * (1.0 - src_alpha)) / out_alpha) + .round() + .clamp(0.0, 255.0) as u8; + } + + pixel[3] = (out_alpha * 255.0).round().clamp(0.0, 255.0) as u8; +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use egui::{FontData, FontDefinitions, Pos2}; + use image::Rgba; + + use crate::text_rendering::RasterTextAnnotation; + + #[test] + fn bitmap_fallback_draws_visible_pixels_for_ascii_text() { + let mut image = image::RgbaImage::from_pixel(96, 48, Rgba([0, 0, 0, 0])); + + super::render_with_bitmap_fallback( + &mut image, + RasterTextAnnotation { + anchor_px: Pos2::new(8.0, 8.0), + font_size_px: 16.0, + fill_rgba: [255, 255, 255, 255], + text: "Text", + }, + ); + + assert!(image.pixels().any(|pixel| pixel[3] != 0)); + } + + #[test] + fn bitmap_fallback_does_not_draw_shadow_offset_pixels() { + let mut image = image::RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 0])); + + super::render_with_bitmap_fallback( + &mut image, + RasterTextAnnotation { + anchor_px: Pos2::new(8.0, 8.0), + font_size_px: 16.0, + fill_rgba: [255, 255, 255, 255], + text: "A", + }, + ); + + for (x, y, pixel) in image.enumerate_pixels() { + let inside_expected_bounds = (8..24).contains(&x) && (8..24).contains(&y); + + if !inside_expected_bounds { + assert_eq!(pixel[3], 0, "unexpected pixel outside glyph bounds at ({x}, {y})"); + } + } + } + + #[test] + fn bitmap_glyph_draw_uses_lsb_first_bit_order() { + let mut image = image::RgbaImage::from_pixel(8, 8, Rgba([0, 0, 0, 0])); + + super::draw_bitmap_glyph( + &mut image, + 0, + 0, + &[0b0000_0001, 0, 0, 0, 0, 0, 0, 0], + 1, + Rgba([255, 255, 255, 255]), + ); + + assert_eq!(*image.get_pixel(0, 0), Rgba([255, 255, 255, 255])); + assert_eq!(*image.get_pixel(7, 0), Rgba([0, 0, 0, 0])); + } + + #[test] + fn blend_pixel_preserves_partial_alpha_for_antialiased_text_edges() { + let mut image = image::RgbaImage::from_pixel(1, 1, Rgba([0, 0, 0, 0])); + + super::blend_pixel(&mut image, 0, 0, Rgba([255, 255, 255, 255]), 128); + + assert_eq!(*image.get_pixel(0, 0), Rgba([255, 255, 255, 128])); + } + + #[test] + fn font_stack_rendering_preserves_negative_anchor_offsets_for_clipping() { + let fonts = super::export_text_fonts(); + let text = "Wide"; + let runs = + super::build_text_font_runs(fonts, text).expect("ASCII text should map to a font"); + let annotation_at_origin = RasterTextAnnotation { + anchor_px: Pos2::new(0.0, 0.0), + font_size_px: 28.0, + fill_rgba: [255, 255, 255, 255], + text, + }; + let annotation_with_negative_anchor = + RasterTextAnnotation { anchor_px: Pos2::new(-8.0, -8.0), ..annotation_at_origin }; + let mut image_at_origin = image::RgbaImage::from_pixel(96, 64, Rgba([0, 0, 0, 0])); + let mut image_with_negative_anchor = + image::RgbaImage::from_pixel(96, 64, Rgba([0, 0, 0, 0])); + + assert!(super::render_with_font_stack( + &mut image_at_origin, + annotation_at_origin, + fonts, + &runs, + )); + assert!(super::render_with_font_stack( + &mut image_with_negative_anchor, + annotation_with_negative_anchor, + fonts, + &runs, + )); + + let visible_pixels_at_origin = + image_at_origin.pixels().filter(|pixel| pixel[3] != 0).count(); + let visible_pixels_with_negative_anchor = + image_with_negative_anchor.pixels().filter(|pixel| pixel[3] != 0).count(); + + assert!(visible_pixels_at_origin > 0); + assert!(visible_pixels_with_negative_anchor > 0); + assert!(visible_pixels_with_negative_anchor < visible_pixels_at_origin); + } + + #[test] + fn collect_export_text_fonts_filters_unparsable_font_data() { + let valid_font_data = FontDefinitions::default() + .font_data + .values() + .next() + .cloned() + .expect("default egui fonts should include at least one font"); + let fonts = super::collect_export_text_fonts([ + Arc::new(FontData::from_static(b"not-a-font")), + valid_font_data, + ]); + + assert_eq!(fonts.len(), 1); + assert!(fonts[0].font().is_some()); + } + + #[test] + fn font_stack_rendering_keeps_supported_chars_when_text_contains_missing_glyph() { + let fonts = super::export_text_fonts(); + let text = "A\u{10ffff}B"; + let runs = super::build_text_font_runs(fonts, text) + .expect("missing glyphs should not force the whole annotation onto bitmap fallback"); + let mut image = image::RgbaImage::from_pixel(128, 64, Rgba([0, 0, 0, 0])); + + assert!(super::render_with_font_stack( + &mut image, + RasterTextAnnotation { + anchor_px: Pos2::new(8.0, 8.0), + font_size_px: 24.0, + fill_rgba: [255, 255, 255, 255], + text, + }, + fonts, + &runs, + )); + assert!(image.pixels().any(|pixel| pixel[3] != 0)); + } +}