From 39aecebb60e610a3ea4778f0b6b9f1cee5855057 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 8 Apr 2026 19:54:39 +0800 Subject: [PATCH] {"schema":"maestro/commit/1","summary":"add text annotations with toolbar editing and font fallback","authority":"manual","breaking":false} --- Cargo.lock | 86 +- Cargo.toml | 4 + packages/rsnap-overlay/Cargo.toml | 4 + packages/rsnap-overlay/src/lib.rs | 2 + packages/rsnap-overlay/src/overlay.rs | 808 ++++++++++++++++-- .../src/overlay/aux_window_runtime.rs | 24 +- .../rsnap-overlay/src/overlay/hud_runtime.rs | 3 + .../rsnap-overlay/src/overlay/rendering.rs | 129 +-- .../src/overlay/rendering/affordances.rs | 559 +++++++++++- .../rendering/scroll_preview_window.rs | 2 +- .../src/overlay/session_state.rs | 160 +++- packages/rsnap-overlay/src/overlay/tests.rs | 405 ++++++++- .../src/overlay/tests/rendering_behaviors.rs | 104 ++- .../src/overlay/toolbar_runtime.rs | 9 + packages/rsnap-overlay/src/system_fonts.rs | 408 +++++++++ packages/rsnap-overlay/src/text_rendering.rs | 385 +++++++++ 16 files changed, 2884 insertions(+), 208 deletions(-) create mode 100644 packages/rsnap-overlay/src/system_fonts.rs create mode 100644 packages/rsnap-overlay/src/text_rendering.rs 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..9abb5deb 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::{ @@ -306,6 +308,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 +918,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 +934,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 +1135,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 +1147,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 +1609,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 +1658,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 +1680,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 +1997,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 +2288,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 +2332,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 { @@ -3963,14 +4014,31 @@ 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 push_frozen_edit_to_undo_history(&mut self, edit_kind: FrozenEditKind) { + self.frozen_edit_undo_stack.push(edit_kind); - if self.frozen_mosaic_undo_stack.len() > FROZEN_EDIT_HISTORY_LIMIT { - self.frozen_mosaic_undo_stack.remove(0); + if self.frozen_edit_undo_stack.len() > FROZEN_EDIT_HISTORY_LIMIT { + let evicted = self.frozen_edit_undo_stack.remove(0); + + if matches!(evicted, FrozenEditKind::MosaicEdit) + && !self.frozen_mosaic_undo_stack.is_empty() + { + self.frozen_mosaic_undo_stack.remove(0); + } } - 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 +4081,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 +4155,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,6 +4278,374 @@ impl OverlaySession { monitor_image } + fn frozen_text_tool_active(&self) -> bool { + self.toolbar_state.selected_tool == FrozenToolbarTool::Text + } + + fn sync_text_input_ime_state(&self) { + let ime_allowed = self.frozen_text_tool_active() && self.frozen_text_edit.is_some(); + + for overlay_window in self.windows.values() { + overlay_window.window.set_ime_allowed(ime_allowed); + } + + if let Some(toolbar_window) = self.toolbar_window.as_ref() { + toolbar_window.window.set_ime_allowed(ime_allowed); + } + } + + 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()), + ); + + 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)), + ), + ); + } + + 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(); + + return false; + }; + let had_visible_text = !edit_state.visible_text().trim().is_empty(); + + if commit && edit_state.has_committed_text() { + self.frozen_text_annotations.push(FrozenTextAnnotation { + anchor: edit_state.anchor, + text: edit_state.text, + style: self.toolbar_state.text_style, + }); + self.push_frozen_edit_to_undo_history(FrozenEditKind::TextAnnotation); + self.sync_frozen_toolbar_state(); + } + + self.frozen_text_recent_input = None; + + self.sync_text_input_ime_state(); + + had_visible_text + } + + 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.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; + } + + 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; + }; + + edit_state.ime_preedit = None; + edit_state.ime_preedit_cursor_char_range = None; + + if edit_state.text.pop().is_none() { + return false; + } + + true + } + + 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 { + 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(), + 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_annotations_into_image(&self, image: &mut RgbaImage) { + if self.frozen_text_annotations.is_empty() || self.scroll_capture.active { + return; + } + + let Some(monitor) = self.state.monitor else { + return; + }; + let capture_rect = self + .state + .frozen_capture_rect + .unwrap_or_else(|| RectPoints::new(0, 0, monitor.width, monitor.height)); + let scale_factor = monitor.scale_factor(); + let annotations = self + .frozen_text_annotations + .iter() + .map(|annotation| RasterTextAnnotation { + anchor_px: Pos2::new( + (annotation.anchor.x - capture_rect.x as f32) * scale_factor, + (annotation.anchor.y - capture_rect.y as f32) * scale_factor, + ), + font_size_px: annotation.style.font_size_points * scale_factor, + fill_rgba: annotation.style.color.export_rgba(), + text: annotation.text.as_str(), + }) + .collect::>(); + + text_rendering::render_text_annotations(image, &annotations); + } + fn handle_captured_freeze_response( &mut self, monitor: MonitorRect, @@ -4432,6 +4881,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) @@ -4518,6 +4968,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 +5524,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 +5868,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 +5927,40 @@ 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 = 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::Enabled | Ime::Disabled => false, + }; + + if changed { + self.sync_frozen_text_ime_cursor_area(monitor); + self.request_redraw_for_monitor(monitor); + } + + OverlayControl::Continue + } + fn handle_scroll_mouse_wheel( &mut self, window_id: WindowId, @@ -6018,7 +6541,71 @@ 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 monitor = self.state.monitor?; + let generation = self.note_frozen_text_input_event(); + let control = match &event.logical_key { + Key::Named(NamedKey::Escape) => { + let _ = self.finish_frozen_text_editing(false); + + Some(OverlayControl::Continue) + }, + Key::Named(NamedKey::Enter) => { + if self.keyboard_modifiers.shift_key() { + self.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + generation, + "\n", + ); + } else { + let _ = self.finish_frozen_text_editing(true); + } + + Some(OverlayControl::Continue) + }, + Key::Named(NamedKey::Backspace) => { + self.backspace_frozen_text_edit(); + + Some(OverlayControl::Continue) + }, + _ if !self.keyboard_modifiers.control_key() + && !self.keyboard_modifiers.super_key() + && !self.keyboard_modifiers.alt_key() => + { + if let Some(text) = event.text.as_deref() { + self.append_text_to_frozen_edit_for_input_event( + FrozenTextInputSource::Key, + generation, + text, + ); + } + + Some(OverlayControl::Continue) + }, + _ => Some(OverlayControl::Continue), + }; + + if control.is_some() { + self.sync_text_input_ime_state(); + self.sync_frozen_text_ime_cursor_area(monitor); + self.request_redraw_for_monitor(monitor); + } + + control + } + 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 +6749,12 @@ 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_text_annotations_into_image(&mut export_image); + + Some(self.annotated_frozen_export_image(export_image)) } #[cfg(target_os = "macos")] @@ -6867,10 +7457,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 +7677,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 +7709,7 @@ impl OverlaySession { let Some(gpu) = self.gpu.as_ref() else { return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); }; + let frozen_text_style = self.toolbar_state.text_style; let toolbar_state = if draw_toolbar { Some(&mut self.toolbar_state) } else { None }; { @@ -7119,6 +7743,9 @@ impl OverlaySession { self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, frozen_toolbar_reserved_rect, (!self.scroll_capture.active).then_some(&self.frozen_brush), + &self.frozen_text_annotations, + self.frozen_text_edit.as_ref(), + frozen_text_style, toolbar_state, toolbar_input, ) { @@ -7132,6 +7759,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); @@ -7259,6 +7914,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 +8101,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 +8177,26 @@ 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(Debug)] struct OverlayExitMetadata<'a> { exit_kind: &'static str, diff --git a/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs b/packages/rsnap-overlay/src/overlay/aux_window_runtime.rs index 1c1b886b..5444ca7d 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,19 @@ 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; + } + + let Some(monitor) = self.state.monitor else { + return; + }; + + self.request_redraw_for_monitor(monitor); + 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..faf4c18f 100644 --- a/packages/rsnap-overlay/src/overlay/hud_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/hud_runtime.rs @@ -126,6 +126,9 @@ impl OverlaySession { 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..ccfa27e8 100644 --- a/packages/rsnap-overlay/src/overlay/rendering.rs +++ b/packages/rsnap-overlay/src/overlay/rendering.rs @@ -18,18 +18,19 @@ 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, - FrozenToolbarState, FullOutput, HudAnchor, HudTheme, Id, Instant, LayerId, LoadOp, MemoryHints, - MipmapFilterMode, MonitorRect, MultisampleState, Mutex, Order, OverlayMode, OverlaySession, - OverlayState, PhysicalSize, PipelineCompilationOptions, PointerButton, PolygonMode, Pos2, - PowerPreference, PresentMode, PrimitiveTopology, Queue, Rect, RectPoints, RenderPipeline, - Renderer, Result, SLOW_OP_WARN_RENDER, Sampler, SamplerBindingType, ScreenDescriptor, - ShaderSource, ShaderStages, SlowOperationLogger, StoreOp, Surface, SurfaceCapabilities, - SurfaceFrameSkipReason, SurfaceTexture, Texture, TextureAspect, TextureSampleType, - TextureUsages, TextureView, TextureViewDescriptor, TextureViewDimension, ThemeMode, - ToolbarPlacement, Trace, Variant, Vec2, ViewportId, Visuals, WindowId, WindowRendererPath, - WrapErr, eyre, hud_helpers, mem, + FrozenBrushState, FrozenCaptureSource, 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, PowerPreference, + PresentMode, PrimitiveTopology, Queue, Rect, RectPoints, RenderPipeline, Renderer, Result, + SLOW_OP_WARN_RENDER, Sampler, SamplerBindingType, ScreenDescriptor, ShaderSource, ShaderStages, + SlowOperationLogger, StoreOp, Surface, SurfaceCapabilities, SurfaceFrameSkipReason, + SurfaceTexture, Texture, TextureAspect, TextureSampleType, TextureUsages, TextureView, + TextureViewDescriptor, TextureViewDimension, ThemeMode, 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 +44,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, @@ -693,6 +718,9 @@ impl WindowRenderer { frozen_capture_is_fullscreen_fallback: bool, frozen_toolbar_reserved_rect: Option, 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>, @@ -790,6 +818,9 @@ impl WindowRenderer { frozen_capture_source, frozen_toolbar_reserved_rect, 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 +1058,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); @@ -1323,6 +1335,9 @@ impl WindowRenderer { frozen_capture_is_fullscreen_fallback: bool, frozen_toolbar_reserved_rect: Option, 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<()> { @@ -1386,6 +1401,9 @@ impl WindowRenderer { frozen_capture_is_fullscreen_fallback, frozen_toolbar_reserved_rect, 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 +1459,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 +1580,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..f4ac32f8 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -1,4 +1,5 @@ use egui::Context; +use egui::text::CCursor; #[allow(unused_imports)] use crate::overlay::rendering::{ @@ -18,27 +19,43 @@ 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, - HUD_PILL_CORNER_RADIUS_POINTS, HUD_PILL_INNER_MARGIN_X_POINTS, HUD_PILL_INNER_MARGIN_Y_POINTS, - HUD_PILL_STROKE_WIDTH_POINTS, HudPillGeometry, HudTheme, Id, - LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, - LIVE_DRAG_START_THRESHOLD_PX, LayerId, Layout, Mesh, MonitorRect, Order, OverlayMode, - OverlaySession, OverlayState, Painter, Pos2, Rect, RectPoints, SELECTION_DASHED_BORDER_ALPHA, - SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, - SELECTION_DASHED_BORDER_WIDTH_PX, SELECTION_FLOW_CORE_FLOW_WIDTH, - SELECTION_FLOW_CORNER_RADIUS_PX, SELECTION_FLOW_FLOW_BOOST, SELECTION_FLOW_LIGHT_PALETTE, - SELECTION_FLOW_MAX_SEGMENTS, SELECTION_FLOW_MIN_SEGMENTS, SELECTION_FLOW_PALETTE, - SELECTION_FLOW_SAMPLE_STEP_PX, SELECTION_FLOW_SPEED, SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX, - SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, SELECTION_SIZE_BADGE_GAP_PX, - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX, - SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX, SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, - SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS, SelectionFlowStyle, Sense, Shape, Stroke, StrokeKind, - TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_EXPANDED_HEIGHT_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, - Ui, UiBuilder, Vec2, regular, + FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, FROZEN_TEXT_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, FrozenSelectionCorner, FrozenTextAnnotation, + FrozenTextColor, FrozenTextEditState, FrozenTextStyle, FrozenToolbarPointerState, + FrozenToolbarState, FrozenToolbarTool, HUD_PILL_CORNER_RADIUS_POINTS, + HUD_PILL_INNER_MARGIN_X_POINTS, HUD_PILL_INNER_MARGIN_Y_POINTS, HUD_PILL_STROKE_WIDTH_POINTS, + HudPillGeometry, HudTheme, Id, LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, + LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, LIVE_DRAG_START_THRESHOLD_PX, LayerId, Layout, Mesh, + MonitorRect, Order, OverlayMode, OverlaySession, OverlayState, Painter, Pos2, Rect, RectPoints, + SELECTION_DASHED_BORDER_ALPHA, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, SELECTION_DASHED_BORDER_WIDTH_PX, + SELECTION_FLOW_CORE_FLOW_WIDTH, SELECTION_FLOW_CORNER_RADIUS_PX, SELECTION_FLOW_FLOW_BOOST, + SELECTION_FLOW_LIGHT_PALETTE, SELECTION_FLOW_MAX_SEGMENTS, SELECTION_FLOW_MIN_SEGMENTS, + SELECTION_FLOW_PALETTE, SELECTION_FLOW_SAMPLE_STEP_PX, SELECTION_FLOW_SPEED, + SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX, SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, + SELECTION_SIZE_BADGE_GAP_PX, SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, + SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX, SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX, + SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS, + SelectionFlowStyle, Sense, Shape, Stroke, StrokeKind, TOOLBAR_CAPTURE_GAP_PX, + TOOLBAR_EXPANDED_HEIGHT_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Ui, UiBuilder, Vec2, + regular, }; +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; +const FROZEN_TEXT_INTERACTION_LINE_HEIGHT_FACTOR: f32 = 1.25; +const FROZEN_TEXT_INTERACTION_CHAR_WIDTH_FACTOR: f32 = 0.58; + #[derive(Clone, Copy)] pub(in crate::overlay) struct SelectionScrimStyle { pub(in crate::overlay) scrim_fill: Color32, @@ -139,6 +156,9 @@ impl WindowRenderer { frozen_capture_source: FrozenCaptureSource, frozen_toolbar_reserved_rect: Option, 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, @@ -162,6 +182,14 @@ impl WindowRenderer { selection_dashed_border_cache, ); + has_affordance |= Self::render_frozen_text_annotations( + &painter, + theme, + frozen_text_annotations, + frozen_text_edit, + frozen_text_style, + ); + if let Some(target) = Self::frozen_capture_size_badge_target(state, screen_rect) { Self::render_selection_size_badge( ctx, @@ -294,6 +322,208 @@ 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 = painter.layout_no_wrap(text.to_owned(), font_id.clone(), Color32::WHITE); + 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_size_points: f32, + ) -> Rect { + let text = if text.is_empty() { FROZEN_TEXT_PREVIEW_PLACEHOLDER } else { text }; + let line_count = text.lines().count().max(1) as f32; + let widest_line_chars = + text.lines().map(|line| line.chars().count()).max().unwrap_or(0).max(1) as f32; + let text_width = + (widest_line_chars * font_size_points * FROZEN_TEXT_INTERACTION_CHAR_WIDTH_FACTOR) + .max(font_size_points); + let text_height = + (line_count * font_size_points * FROZEN_TEXT_INTERACTION_LINE_HEIGHT_FACTOR) + .max(font_size_points); + + 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_width + FROZEN_TEXT_INTERACTION_PADDING_X_POINTS, + anchor.y + text_height + 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 +2279,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 +2480,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 +2522,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 +2532,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 +2722,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..b731c34a 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 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() + } + + pub(super) fn has_committed_text(&self) -> bool { + !self.text.trim().is_empty() + } +} + #[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..062c3495 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -26,9 +26,7 @@ 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; @@ -48,19 +46,21 @@ 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, + FrozenBrushModelState, FrozenEditKind, FrozenImagePatch, FrozenMosaicEdit, + FrozenSelectionDragState, FrozenTextAnnotation, FrozenTextColor, 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 +299,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 +404,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"); @@ -1009,6 +991,363 @@ 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 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 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 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 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 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, + }); + + 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 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()); +} + #[cfg(target_os = "macos")] #[test] fn duplicate_live_frames_schedule_forced_refresh_when_downward_backlog_is_fresh() { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index dfe7ca6f..8b8d9b1d 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -21,7 +21,10 @@ 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, + FrozenSelectionCorner, FrozenSelectionInteractionKind, FrozenTextColor, +}; use crate::worker::{WorkerErrorSource, WorkerResponse}; fn test_mosaic_source_image() -> RgbaImage { @@ -243,8 +246,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 +300,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 +701,94 @@ 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 rect = + WindowRenderer::frozen_text_edit_interaction_rect(anchor, "", FROZEN_TEXT_FONT_SIZE_POINTS); + + assert!(rect.contains(anchor)); + assert!(rect.width() > FROZEN_TEXT_FONT_SIZE_POINTS); + assert!(rect.height() >= FROZEN_TEXT_FONT_SIZE_POINTS); +} + +#[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(); @@ -1899,6 +1990,9 @@ fn render_frozen_capture_affordance_keeps_tiny_frozen_badge_path() { FrozenCaptureSource::None, None, None, + &[], + None, + FrozenToolbarState::default().text_style, false, true, 1.0, diff --git a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs index 1cffe63b..9d422210 100644 --- a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs @@ -278,6 +278,9 @@ impl OverlaySession { 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 +341,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); diff --git a/packages/rsnap-overlay/src/system_fonts.rs b/packages/rsnap-overlay/src/system_fonts.rs new file mode 100644 index 00000000..3bfeddda --- /dev/null +++ b/packages/rsnap-overlay/src/system_fonts.rs @@ -0,0 +1,408 @@ +use std::{ + collections::HashSet, + sync::{Arc, OnceLock}, +}; + +use egui::{FontData, FontDefinitions, FontFamily}; +use fontdb::{Database, Family, ID, Query, Stretch, Style, Weight}; +use ttf_parser::Face; + +const NORMAL_WEIGHT_MIN: u16 = 300; +const NORMAL_WEIGHT_MAX: u16 = 700; +const MAX_SYSTEM_TEXT_FALLBACKS: usize = 16; + +type UnicodeCoverage = Vec; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct UnicodeCoverageRange { + start: u32, + end: u32, +} + +#[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, +} + +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: &fontdb::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(); + + if let Some(face_id) = generic_sans_face_id + && let Some(candidate) = candidates.iter().find(|candidate| candidate.face_id == face_id) + { + try_select_system_text_font( + database, + candidate, + &mut selected, + &mut selected_face_ids, + &mut selected_families, + &mut covered_codepoints, + ); + } + + for candidate in candidates { + if selected.len() >= MAX_SYSTEM_TEXT_FALLBACKS { + break; + } + + try_select_system_text_font( + database, + candidate, + &mut selected, + &mut selected_face_ids, + &mut selected_families, + &mut covered_codepoints, + ); + } + + selected +} + +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, +) { + if selected_face_ids.contains(&candidate.face_id) + || selected_families.contains(candidate.family_name.as_str()) + || selected.len() >= MAX_SYSTEM_TEXT_FALLBACKS + { + return; + } + + let Some(coverage_codepoints) = load_system_text_coverage(database, candidate.face_id) else { + return; + }; + + if !selected.is_empty() + && !coverage_adds_new_codepoints(covered_codepoints, &coverage_codepoints) + { + return; + } + + let Some(font) = build_system_text_font(database, candidate.face_id) else { + return; + }; + + selected.push(font); + selected_face_ids.insert(candidate.face_id); + selected_families.insert(candidate.family_name.clone()); + merge_coverage_codepoints(covered_codepoints, &coverage_codepoints); +} + +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 mut code_points = Vec::new(); + let cmap = face.tables().cmap?; + + 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: &fontdb::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 super::{ + UnicodeCoverage, UnicodeCoverageRange, coverage_adds_new_codepoints, + merge_coverage_codepoints, push_coverage_range, + }; + + #[test] + fn coverage_tracks_arbitrary_script_codepoints() { + let mut coverage = UnicodeCoverage::new(); + + push_coverage_range( + &mut coverage, + UnicodeCoverageRange { start: u32::from('ᚠ'), end: u32::from('ᚠ') }, + ); + 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 mut covered_codepoints = + vec![UnicodeCoverageRange { start: u32::from('A'), end: u32::from('A') }]; + let candidate_codepoints = + vec![UnicodeCoverageRange { start: u32::from('z'), end: u32::from('z') }]; + + assert!(coverage_adds_new_codepoints(&covered_codepoints, &candidate_codepoints)); + + 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!(!coverage_adds_new_codepoints(&covered_codepoints, &candidate_codepoints)); + } +} diff --git a/packages/rsnap-overlay/src/text_rendering.rs b/packages/rsnap-overlay/src/text_rendering.rs new file mode 100644 index 00000000..a04b3042 --- /dev/null +++ b/packages/rsnap-overlay/src/text_rendering.rs @@ -0,0 +1,385 @@ +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); + + fonts + .families + .get(&FontFamily::Proportional) + .into_iter() + .flat_map(|family| family.iter()) + .filter_map(|font_name| fonts.font_data.get(font_name).cloned()) + .map(ExportTextFont::new) + .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)) +} + +fn render_with_font_stack( + image: &mut RgbaImage, + annotation: RasterTextAnnotation<'_>, + fonts: &[ExportTextFont], + runs: &[TextFontRun<'_>], +) -> bool { + let Some(parsed_fonts) = fonts.iter().map(ExportTextFont::font).collect::>>() + else { + return false; + }; + let fill_rgba = Rgba(annotation.fill_rgba); + let mut layout = Layout::new(CoordinateSystem::PositiveYDown); + + layout.reset(&LayoutSettings { + x: annotation.anchor_px.x.max(0.0), + y: annotation.anchor_px.y.max(0.0), + ..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((7_usize.saturating_sub(column_index)) as u32 * 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 alpha = (f32::from(color[3]) / 255.0) * (f32::from(coverage) / 255.0); + + if alpha <= 0.0 { + return; + } + + let pixel = image.get_pixel_mut(x, y); + let inv_alpha = 1.0 - alpha; + + for channel in 0..3 { + let dst = f32::from(pixel[channel]); + let src = f32::from(color[channel]); + + pixel[channel] = (src * alpha + dst * inv_alpha).round().clamp(0.0, 255.0) as u8; + } + + pixel[3] = 255; +} + +#[cfg(test)] +mod tests { + use egui::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})"); + } + } + } +}