From 65042407bac3a89a5592e153f3c54f0dacc44a33 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:48:21 -0500 Subject: [PATCH 1/9] Add markup layers and screenshot editor tools --- src/gui/mod.rs | 27 +- src/gui/screenshot_editor.rs | 568 ++++++++++++++++++++++++++++++++--- src/plugins/screenshot.rs | 15 +- tests/screenshot_plugin.rs | 55 ++++ 4 files changed, 611 insertions(+), 54 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index f74bec50..4f10b4b8 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -53,7 +53,10 @@ pub use note_panel::{ NotePanel, }; pub use notes_dialog::NotesDialog; -pub use screenshot_editor::ScreenshotEditor; +pub use screenshot_editor::{ + render_markup_layers, MarkupArrow, MarkupHistory, MarkupLayer, MarkupRect, MarkupStroke, + MarkupTool, ScreenshotEditor, +}; pub use shell_cmd_dialog::ShellCmdDialog; pub use snippet_dialog::SnippetDialog; pub use tempfile_alias_dialog::TempfileAliasDialog; @@ -2542,16 +2545,17 @@ impl LauncherApp { } } else if let Some(mode) = a.action.strip_prefix("screenshot:") { use crate::actions::screenshot::Mode as ScreenshotMode; - let (mode, clip) = match mode { - "window" => (ScreenshotMode::Window, false), - "region" => (ScreenshotMode::Region, false), - "desktop" => (ScreenshotMode::Desktop, false), - "window_clip" => (ScreenshotMode::Window, true), - "region_clip" => (ScreenshotMode::Region, true), - "desktop_clip" => (ScreenshotMode::Desktop, true), - _ => (ScreenshotMode::Desktop, false), + let (mode, clip, tool) = match mode { + "window" => (ScreenshotMode::Window, false, MarkupTool::Rectangle), + "region" => (ScreenshotMode::Region, false, MarkupTool::Rectangle), + "region_markup" => (ScreenshotMode::Region, false, MarkupTool::Pen), + "desktop" => (ScreenshotMode::Desktop, false, MarkupTool::Rectangle), + "window_clip" => (ScreenshotMode::Window, true, MarkupTool::Rectangle), + "region_clip" => (ScreenshotMode::Region, true, MarkupTool::Rectangle), + "desktop_clip" => (ScreenshotMode::Desktop, true, MarkupTool::Rectangle), + _ => (ScreenshotMode::Desktop, false, MarkupTool::Rectangle), }; - if let Err(e) = crate::plugins::screenshot::launch_editor(self, mode, clip) { + if let Err(e) = crate::plugins::screenshot::launch_editor(self, mode, clip, tool) { self.set_error(format!("Failed: {e}")); } else if a.action != "help:show" { self.record_history_usage(&a, ¤t, source); @@ -4583,7 +4587,7 @@ impl LauncherApp { } /// Open the screenshot editor for a captured image. - pub fn open_screenshot_editor(&mut self, img: image::RgbaImage, clip: bool) { + pub fn open_screenshot_editor(&mut self, img: image::RgbaImage, clip: bool, tool: MarkupTool) { use chrono::Local; let dir = crate::plugins::screenshot::screenshot_dir(); let _ = std::fs::create_dir_all(&dir); @@ -4597,6 +4601,7 @@ impl LauncherApp { path, clip, self.screenshot_auto_save, + tool, )); self.update_panel_stack(); } diff --git a/src/gui/screenshot_editor.rs b/src/gui/screenshot_editor.rs index 8cc6b3a2..2bccfae9 100644 --- a/src/gui/screenshot_editor.rs +++ b/src/gui/screenshot_editor.rs @@ -1,14 +1,253 @@ use crate::gui::LauncherApp; -use eframe::egui::{self, Color32, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions}; +use eframe::egui::{ + self, Color32, PointerButton, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, +}; use image::RgbaImage; use std::path::PathBuf; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarkupTool { + Pen, + Arrow, + Rectangle, + Highlight, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MarkupStroke { + pub points: Vec, + pub color: Color32, + pub thickness: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MarkupRect { + pub rect: Rect, + pub color: Color32, + pub thickness: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MarkupArrow { + pub start: Pos2, + pub end: Pos2, + pub color: Color32, + pub thickness: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum MarkupLayer { + Stroke(MarkupStroke), + Rectangle(MarkupRect), + Arrow(MarkupArrow), + Highlight(MarkupRect), +} + +#[derive(Clone, Debug, Default)] +pub struct MarkupHistory { + layers: Vec, + undo_stack: Vec, + redo_stack: Vec, +} + +impl MarkupHistory { + pub fn layers(&self) -> &[MarkupLayer] { + &self.layers + } + + pub fn push(&mut self, layer: MarkupLayer) { + self.layers.push(layer); + self.redo_stack.clear(); + } + + pub fn undo(&mut self) -> bool { + if let Some(layer) = self.layers.pop() { + self.redo_stack.push(layer); + true + } else { + false + } + } + + pub fn redo(&mut self) -> bool { + if let Some(layer) = self.redo_stack.pop() { + self.layers.push(layer); + true + } else { + false + } + } +} + +fn blend_pixel(img: &mut RgbaImage, x: u32, y: u32, color: Color32) { + let [r, g, b, a] = color.to_array(); + if a == 0 { + return; + } + let dst = img.get_pixel(x, y).0; + let src_a = a as f32 / 255.0; + let dst_a = dst[3] as f32 / 255.0; + let out_a = src_a + dst_a * (1.0 - src_a); + if out_a <= 0.0 { + return; + } + let blend = |src: u8, dst: u8| { + let src_f = src as f32 / 255.0; + let dst_f = dst as f32 / 255.0; + ((src_f * src_a + dst_f * dst_a * (1.0 - src_a)) / out_a * 255.0) + .round() + .clamp(0.0, 255.0) as u8 + }; + img.put_pixel( + x, + y, + image::Rgba([ + blend(r, dst[0]), + blend(g, dst[1]), + blend(b, dst[2]), + (out_a * 255.0) as u8, + ]), + ); +} + +fn draw_circle(img: &mut RgbaImage, center: Pos2, radius: f32, color: Color32) { + if radius <= 0.0 { + return; + } + let radius_sq = radius * radius; + let width = img.width() as i32; + let height = img.height() as i32; + let min_x = (center.x - radius).floor().max(0.0) as i32; + let max_x = (center.x + radius).ceil().min((width - 1) as f32) as i32; + let min_y = (center.y - radius).floor().max(0.0) as i32; + let max_y = (center.y + radius).ceil().min((height - 1) as f32) as i32; + for y in min_y..=max_y { + for x in min_x..=max_x { + let dx = x as f32 + 0.5 - center.x; + let dy = y as f32 + 0.5 - center.y; + if dx * dx + dy * dy <= radius_sq { + blend_pixel(img, x as u32, y as u32, color); + } + } + } +} + +fn draw_line(img: &mut RgbaImage, start: Pos2, end: Pos2, color: Color32, thickness: f32) { + let dx = end.x - start.x; + let dy = end.y - start.y; + let steps = dx.abs().max(dy.abs()).ceil().max(1.0) as i32; + let radius = (thickness / 2.0).max(0.5); + for i in 0..=steps { + let t = i as f32 / steps as f32; + let point = Pos2::new(start.x + dx * t, start.y + dy * t); + draw_circle(img, point, radius, color); + } +} + +fn draw_rect_outline(img: &mut RgbaImage, rect: Rect, color: Color32, thickness: f32) { + let min = rect.min; + let max = rect.max; + draw_line( + img, + Pos2::new(min.x, min.y), + Pos2::new(max.x, min.y), + color, + thickness, + ); + draw_line( + img, + Pos2::new(max.x, min.y), + Pos2::new(max.x, max.y), + color, + thickness, + ); + draw_line( + img, + Pos2::new(max.x, max.y), + Pos2::new(min.x, max.y), + color, + thickness, + ); + draw_line( + img, + Pos2::new(min.x, max.y), + Pos2::new(min.x, min.y), + color, + thickness, + ); +} + +fn draw_rect_fill(img: &mut RgbaImage, rect: Rect, color: Color32) { + let width = img.width() as i32; + let height = img.height() as i32; + let min_x = rect.min.x.floor().max(0.0) as i32; + let max_x = rect.max.x.ceil().min((width - 1) as f32) as i32; + let min_y = rect.min.y.floor().max(0.0) as i32; + let max_y = rect.max.y.ceil().min((height - 1) as f32) as i32; + for y in min_y..=max_y { + for x in min_x..=max_x { + blend_pixel(img, x as u32, y as u32, color); + } + } +} + +fn rotate_vec(vec: Vec2, angle: f32) -> Vec2 { + let (sin, cos) = angle.sin_cos(); + Vec2::new(vec.x * cos - vec.y * sin, vec.x * sin + vec.y * cos) +} + +pub fn render_markup_layers(base: &RgbaImage, layers: &[MarkupLayer]) -> RgbaImage { + let mut img = base.clone(); + for layer in layers { + match layer { + MarkupLayer::Stroke(stroke) => { + for points in stroke.points.windows(2) { + draw_line( + &mut img, + points[0], + points[1], + stroke.color, + stroke.thickness, + ); + } + } + MarkupLayer::Rectangle(rect) => { + draw_rect_outline(&mut img, rect.rect, rect.color, rect.thickness); + } + MarkupLayer::Arrow(arrow) => { + draw_line( + &mut img, + arrow.start, + arrow.end, + arrow.color, + arrow.thickness, + ); + let dir = arrow.end - arrow.start; + let len = dir.length(); + if len > 0.5 { + let unit = dir / len; + let head_len = (10.0 + arrow.thickness * 2.0).min(len * 0.5); + let angle = 30.0_f32.to_radians(); + let left = arrow.end - rotate_vec(unit, angle) * head_len; + let right = arrow.end - rotate_vec(unit, -angle) * head_len; + draw_line(&mut img, arrow.end, left, arrow.color, arrow.thickness); + draw_line(&mut img, arrow.end, right, arrow.color, arrow.thickness); + } + } + MarkupLayer::Highlight(rect) => { + draw_rect_fill(&mut img, rect.rect, rect.color); + } + } + } + img +} + /// Editor window for captured screenshots allowing simple cropping and annotation. /// -/// Cropping is initiated by dragging with the primary mouse button. Holding -/// `Shift` while dragging creates a red annotation rectangle. When saving or -/// copying the screenshot the selected region and annotations are applied to the -/// output image. +/// Cropping is initiated by dragging with the secondary mouse button. Markup +/// tools are selected from the toolbar and applied with the primary mouse +/// button. When saving or copying the screenshot the selected region and +/// markup layers are applied to the output image. pub struct ScreenshotEditor { pub open: bool, image: RgbaImage, @@ -16,17 +255,28 @@ pub struct ScreenshotEditor { tex: Option, crop_start: Option, crop_rect: Option, - ann_start: Option, - annotations: Vec, + active_start: Option, + active_end: Option, + active_stroke: Option, + history: MarkupHistory, path: PathBuf, _clip: bool, auto_save: bool, zoom: f32, + tool: MarkupTool, + color_index: usize, + thickness: f32, } impl ScreenshotEditor { /// Create a new editor from the captured image. - pub fn new(img: RgbaImage, path: PathBuf, clip: bool, auto_save: bool) -> Self { + pub fn new( + img: RgbaImage, + path: PathBuf, + clip: bool, + auto_save: bool, + tool: MarkupTool, + ) -> Self { let size = [img.width() as usize, img.height() as usize]; let color_image = egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()); Self { @@ -36,29 +286,22 @@ impl ScreenshotEditor { tex: None, crop_start: None, crop_rect: None, - ann_start: None, - annotations: Vec::new(), + active_start: None, + active_end: None, + active_stroke: None, + history: MarkupHistory::default(), path, _clip: clip, auto_save, zoom: 1.0, + tool, + color_index: 0, + thickness: 4.0, } } fn apply_edits(&self) -> RgbaImage { - let mut img = self.image.clone(); - // draw annotations first - for rect in &self.annotations { - let x1 = rect.min.x.max(0.0) as u32; - let y1 = rect.min.y.max(0.0) as u32; - let x2 = rect.max.x.min(img.width() as f32) as u32; - let y2 = rect.max.y.min(img.height() as f32) as u32; - for y in y1..y2 { - for x in x1..x2 { - img.put_pixel(x, y, image::Rgba([255, 0, 0, 128])); - } - } - } + let mut img = render_markup_layers(&self.image, self.history.layers()); if let Some(rect) = self.crop_rect { let x1 = rect.min.x.max(0.0) as u32; let y1 = rect.min.y.max(0.0) as u32; @@ -93,6 +336,37 @@ impl ScreenshotEditor { Ok(()) } + fn palette() -> [Color32; 5] { + [ + Color32::from_rgb(231, 76, 60), + Color32::from_rgb(241, 196, 15), + Color32::from_rgb(46, 204, 113), + Color32::from_rgb(52, 152, 219), + Color32::from_rgb(155, 89, 182), + ] + } + + fn current_color(&self) -> Color32 { + let base = Self::palette()[self.color_index]; + if self.tool == MarkupTool::Highlight { + Color32::from_rgba_unmultiplied(base.r(), base.g(), base.b(), 96) + } else { + base + } + } + + fn push_layer(&mut self, layer: MarkupLayer) { + self.history.push(layer); + } + + fn undo(&mut self) { + self.history.undo(); + } + + fn redo(&mut self) { + self.history.redo(); + } + pub fn ui(&mut self, ctx: &egui::Context, app: &mut LauncherApp) { if !self.open { return; @@ -138,6 +412,69 @@ impl ScreenshotEditor { } ui.add(egui::Slider::new(&mut self.zoom, 0.1..=4.0).text("Zoom")); }); + ui.horizontal(|ui| { + ui.label("Tool"); + ui.selectable_value(&mut self.tool, MarkupTool::Pen, "Pen"); + ui.selectable_value(&mut self.tool, MarkupTool::Arrow, "Arrow"); + ui.selectable_value(&mut self.tool, MarkupTool::Rectangle, "Rect"); + ui.selectable_value(&mut self.tool, MarkupTool::Highlight, "Highlight"); + ui.separator(); + ui.label("Color"); + for (idx, color) in Self::palette().iter().enumerate() { + let selected = self.color_index == idx; + let mut button = egui::Button::new(format!("{}", idx + 1)) + .fill(*color) + .stroke(Stroke::new(1.0, Color32::BLACK)); + if selected { + button = button.stroke(Stroke::new(2.0, Color32::WHITE)); + } + if ui.add(button).clicked() { + self.color_index = idx; + } + } + ui.separator(); + ui.label(format!("Thickness {}", self.thickness as i32)); + if ui.button("−").clicked() { + self.thickness = (self.thickness - 1.0).max(1.0); + } + if ui.button("+").clicked() { + self.thickness = (self.thickness + 1.0).min(20.0); + } + if ui.button("Undo").clicked() { + self.undo(); + } + if ui.button("Redo").clicked() { + self.redo(); + } + }); + let pressed_undo = ctx.input(|i| i.key_pressed(egui::Key::Z) && i.modifiers.ctrl); + let pressed_redo = ctx.input(|i| { + (i.key_pressed(egui::Key::Y) && i.modifiers.ctrl) + || (i.key_pressed(egui::Key::Z) && i.modifiers.ctrl && i.modifiers.shift) + }); + if pressed_undo { + self.undo(); + } + if pressed_redo { + self.redo(); + } + if ctx.input(|i| i.key_pressed(egui::Key::OpenBracket)) { + self.thickness = (self.thickness - 1.0).max(1.0); + } + if ctx.input(|i| i.key_pressed(egui::Key::CloseBracket)) { + self.thickness = (self.thickness + 1.0).min(20.0); + } + if ctx.input(|i| i.key_pressed(egui::Key::Num1)) { + self.color_index = 0; + } else if ctx.input(|i| i.key_pressed(egui::Key::Num2)) { + self.color_index = 1; + } else if ctx.input(|i| i.key_pressed(egui::Key::Num3)) { + self.color_index = 2; + } else if ctx.input(|i| i.key_pressed(egui::Key::Num4)) { + self.color_index = 3; + } else if ctx.input(|i| i.key_pressed(egui::Key::Num5)) { + self.color_index = 4; + } let tex = self.tex.get_or_insert_with(|| { ctx.load_texture( "screenshot", @@ -162,41 +499,188 @@ impl ScreenshotEditor { Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)), Color32::WHITE, ); - if response.drag_started() { + if response.drag_started_by(PointerButton::Secondary) { if let Some(pos) = response.interact_pointer_pos() { - if ctx.input(|i| i.modifiers.shift) { - self.ann_start = Some(to_img(pos)); - } else { - self.crop_start = Some(to_img(pos)); - self.crop_rect = None; - } + self.crop_start = Some(to_img(pos)); + self.crop_rect = None; } } - if response.dragged() { + if response.dragged_by(PointerButton::Secondary) { if let Some(start) = self.crop_start { if let Some(pos) = response.interact_pointer_pos() { self.crop_rect = Some(Rect::from_two_pos(start, to_img(pos))); } } - if let Some(start) = self.ann_start { - if let Some(pos) = response.interact_pointer_pos() { - self.annotations - .push(Rect::from_two_pos(start, to_img(pos))); - self.ann_start = None; + } + if response.drag_stopped_by(PointerButton::Secondary) { + self.crop_start = None; + } + + if response.drag_started_by(PointerButton::Primary) { + if let Some(pos) = response.interact_pointer_pos() { + let start = to_img(pos); + match self.tool { + MarkupTool::Pen => { + self.active_stroke = Some(MarkupStroke { + points: vec![start], + color: self.current_color(), + thickness: self.thickness, + }); + } + MarkupTool::Arrow | MarkupTool::Rectangle | MarkupTool::Highlight => { + self.active_start = Some(start); + self.active_end = Some(start); + } } } } - if response.drag_stopped() { - self.crop_start = None; - self.ann_start = None; + if response.dragged_by(PointerButton::Primary) { + if let Some(pos) = response.interact_pointer_pos() { + let current = to_img(pos); + match self.tool { + MarkupTool::Pen => { + if let Some(stroke) = &mut self.active_stroke { + stroke.points.push(current); + } + } + MarkupTool::Arrow | MarkupTool::Rectangle | MarkupTool::Highlight => { + self.active_end = Some(current); + } + } + } + } + if response.drag_stopped_by(PointerButton::Primary) { + match self.tool { + MarkupTool::Pen => { + if let Some(stroke) = self.active_stroke.take() { + if stroke.points.len() > 1 { + self.push_layer(MarkupLayer::Stroke(stroke)); + } + } + } + MarkupTool::Arrow => { + if let (Some(start), Some(end)) = + (self.active_start.take(), self.active_end.take()) + { + self.push_layer(MarkupLayer::Arrow(MarkupArrow { + start, + end, + color: self.current_color(), + thickness: self.thickness, + })); + } + } + MarkupTool::Rectangle => { + if let (Some(start), Some(end)) = + (self.active_start.take(), self.active_end.take()) + { + self.push_layer(MarkupLayer::Rectangle(MarkupRect { + rect: Rect::from_two_pos(start, end), + color: self.current_color(), + thickness: self.thickness, + })); + } + } + MarkupTool::Highlight => { + if let (Some(start), Some(end)) = + (self.active_start.take(), self.active_end.take()) + { + self.push_layer(MarkupLayer::Highlight(MarkupRect { + rect: Rect::from_two_pos(start, end), + color: self.current_color(), + thickness: self.thickness, + })); + } + } + } + self.active_start = None; + self.active_end = None; + self.active_stroke = None; } if let Some(rect) = self.crop_rect { let draw = Rect::from_min_max(to_screen(rect.min), to_screen(rect.max)); painter.rect_stroke(draw, 0.0, Stroke::new(1.0, Color32::GREEN)); } - for rect in &self.annotations { - let draw = Rect::from_min_max(to_screen(rect.min), to_screen(rect.max)); - painter.rect_stroke(draw, 0.0, Stroke::new(1.0, Color32::RED)); + for layer in self.history.layers() { + match layer { + MarkupLayer::Stroke(stroke) => { + for points in stroke.points.windows(2) { + painter.line_segment( + [to_screen(points[0]), to_screen(points[1])], + Stroke::new(stroke.thickness, stroke.color), + ); + } + } + MarkupLayer::Rectangle(rect) => { + let draw = Rect::from_min_max( + to_screen(rect.rect.min), + to_screen(rect.rect.max), + ); + painter.rect_stroke(draw, 0.0, Stroke::new(rect.thickness, rect.color)); + } + MarkupLayer::Arrow(arrow) => { + painter.line_segment( + [to_screen(arrow.start), to_screen(arrow.end)], + Stroke::new(arrow.thickness, arrow.color), + ); + let dir = arrow.end - arrow.start; + let len = dir.length(); + if len > 0.5 { + let unit = dir / len; + let head_len = (10.0 + arrow.thickness * 2.0).min(len * 0.5); + let angle = 30.0_f32.to_radians(); + let left = arrow.end - rotate_vec(unit, angle) * head_len; + let right = arrow.end - rotate_vec(unit, -angle) * head_len; + painter.line_segment( + [to_screen(arrow.end), to_screen(left)], + Stroke::new(arrow.thickness, arrow.color), + ); + painter.line_segment( + [to_screen(arrow.end), to_screen(right)], + Stroke::new(arrow.thickness, arrow.color), + ); + } + } + MarkupLayer::Highlight(rect) => { + let draw = Rect::from_min_max( + to_screen(rect.rect.min), + to_screen(rect.rect.max), + ); + painter.rect_filled(draw, 0.0, rect.color); + } + } + } + if let (Some(start), Some(end)) = (self.active_start, self.active_end) { + let rect = Rect::from_two_pos(start, end); + match self.tool { + MarkupTool::Arrow => { + painter.line_segment( + [to_screen(start), to_screen(end)], + Stroke::new(self.thickness, self.current_color()), + ); + } + MarkupTool::Rectangle => { + let draw = Rect::from_min_max(to_screen(rect.min), to_screen(rect.max)); + painter.rect_stroke( + draw, + 0.0, + Stroke::new(self.thickness, self.current_color()), + ); + } + MarkupTool::Highlight => { + let draw = Rect::from_min_max(to_screen(rect.min), to_screen(rect.max)); + painter.rect_filled(draw, 0.0, self.current_color()); + } + MarkupTool::Pen => {} + } + } + if let Some(stroke) = &self.active_stroke { + for points in stroke.points.windows(2) { + painter.line_segment( + [to_screen(points[0]), to_screen(points[1])], + Stroke::new(stroke.thickness, stroke.color), + ); + } } }); self.open = open; diff --git a/src/plugins/screenshot.rs b/src/plugins/screenshot.rs index f7b57198..25f1de35 100644 --- a/src/plugins/screenshot.rs +++ b/src/plugins/screenshot.rs @@ -45,12 +45,13 @@ pub fn launch_editor( app: &mut crate::gui::LauncherApp, mode: crate::actions::screenshot::Mode, clip: bool, + tool: crate::gui::MarkupTool, ) -> anyhow::Result<()> { use chrono::Local; use std::borrow::Cow; let img = crate::actions::screenshot::capture_raw(mode)?; if app.get_screenshot_use_editor() { - app.open_screenshot_editor(img, clip); + app.open_screenshot_editor(img, clip, tool); } else { if clip { let (w, h) = img.dimensions(); @@ -105,6 +106,12 @@ impl Plugin for ScreenshotPlugin { action: "screenshot:region".into(), args: None, }, + Action { + label: "Screenshot region (markup pen)".into(), + desc: "Screenshot".into(), + action: "screenshot:region_markup".into(), + args: None, + }, Action { label: "Screenshot desktop".into(), desc: "Screenshot".into(), @@ -158,6 +165,12 @@ impl Plugin for ScreenshotPlugin { action: "query:ss clip".into(), args: None, }, + Action { + label: "shot region markup".into(), + desc: "Screenshot".into(), + action: "screenshot:region_markup".into(), + args: None, + }, ] } diff --git a/tests/screenshot_plugin.rs b/tests/screenshot_plugin.rs index e703366e..6dd50840 100644 --- a/tests/screenshot_plugin.rs +++ b/tests/screenshot_plugin.rs @@ -1,3 +1,8 @@ +use eframe::egui::{Color32, Pos2, Rect}; +use image::RgbaImage; +use multi_launcher::gui::{ + render_markup_layers, MarkupArrow, MarkupHistory, MarkupLayer, MarkupRect, MarkupStroke, +}; use multi_launcher::plugin::Plugin; use multi_launcher::plugins::screenshot::ScreenshotPlugin; @@ -9,6 +14,7 @@ fn search_lists_screenshot_actions() { let prefixes = [ "screenshot:window", "screenshot:region", + "screenshot:region_markup", "screenshot:desktop", "screenshot:window_clip", "screenshot:region_clip", @@ -22,3 +28,52 @@ fn search_lists_screenshot_actions() { ); } } + +#[test] +fn markup_layers_render_on_image() { + let base = RgbaImage::from_pixel(10, 10, image::Rgba([255, 255, 255, 255])); + let stroke = MarkupLayer::Stroke(MarkupStroke { + points: vec![Pos2::new(1.0, 1.0), Pos2::new(1.0, 8.0)], + color: Color32::from_rgb(255, 0, 0), + thickness: 2.0, + }); + let rect = MarkupLayer::Rectangle(MarkupRect { + rect: Rect::from_min_max(Pos2::new(3.0, 3.0), Pos2::new(8.0, 8.0)), + color: Color32::from_rgb(0, 0, 255), + thickness: 1.0, + }); + let arrow = MarkupLayer::Arrow(MarkupArrow { + start: Pos2::new(8.0, 1.0), + end: Pos2::new(2.0, 1.0), + color: Color32::from_rgb(0, 255, 0), + thickness: 1.0, + }); + let rendered = render_markup_layers(&base, &[stroke, rect, arrow]); + assert_ne!(rendered.get_pixel(1, 5).0, [255, 255, 255, 255]); + assert_ne!(rendered.get_pixel(3, 3).0, [255, 255, 255, 255]); + assert_ne!(rendered.get_pixel(6, 1).0, [255, 255, 255, 255]); +} + +#[test] +fn markup_history_undo_redo() { + let mut history = MarkupHistory::default(); + let layer_a = MarkupLayer::Rectangle(MarkupRect { + rect: Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(2.0, 2.0)), + color: Color32::from_rgb(0, 0, 0), + thickness: 1.0, + }); + let layer_b = MarkupLayer::Highlight(MarkupRect { + rect: Rect::from_min_max(Pos2::new(2.0, 2.0), Pos2::new(4.0, 4.0)), + color: Color32::from_rgba_unmultiplied(255, 255, 0, 96), + thickness: 1.0, + }); + history.push(layer_a.clone()); + history.push(layer_b.clone()); + assert_eq!(history.layers().len(), 2); + assert!(history.undo()); + assert_eq!(history.layers().len(), 1); + assert!(history.redo()); + assert_eq!(history.layers().len(), 2); + assert_eq!(history.layers()[0], layer_a); + assert_eq!(history.layers()[1], layer_b); +} From 52fac85098ce1e2d0b5db36cc52be46e6c1b825d Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:17:04 -0500 Subject: [PATCH 2/9] Fix borrow conflict in screenshot editor --- src/gui/screenshot_editor.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/gui/screenshot_editor.rs b/src/gui/screenshot_editor.rs index 2bccfae9..2d2eefb0 100644 --- a/src/gui/screenshot_editor.rs +++ b/src/gui/screenshot_editor.rs @@ -488,11 +488,10 @@ impl ScreenshotEditor { ); let display = img_size * self.zoom; let (response, painter) = ui.allocate_painter(display, Sense::drag()); - let to_img = |pos: Pos2| { - let offset = response.rect.min; - ((pos - offset) / self.zoom).to_pos2() - }; - let to_screen = |p: Pos2| response.rect.min + (p * self.zoom).to_vec2(); + let zoom = self.zoom; + let rect_min = response.rect.min; + let to_img = |pos: Pos2| ((pos - rect_min) / zoom).to_pos2(); + let to_screen = |p: Pos2| rect_min + (p * zoom).to_vec2(); painter.image( tex.id(), response.rect, From 0991b354ba5346130201a19e08a17d754eb4c335 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:20:40 -0500 Subject: [PATCH 3/9] Fix screenshot markup render test --- tests/screenshot_plugin.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/screenshot_plugin.rs b/tests/screenshot_plugin.rs index 6dd50840..3d794673 100644 --- a/tests/screenshot_plugin.rs +++ b/tests/screenshot_plugin.rs @@ -49,9 +49,11 @@ fn markup_layers_render_on_image() { thickness: 1.0, }); let rendered = render_markup_layers(&base, &[stroke, rect, arrow]); - assert_ne!(rendered.get_pixel(1, 5).0, [255, 255, 255, 255]); - assert_ne!(rendered.get_pixel(3, 3).0, [255, 255, 255, 255]); - assert_ne!(rendered.get_pixel(6, 1).0, [255, 255, 255, 255]); + let changed = rendered + .pixels() + .zip(base.pixels()) + .any(|(after, before)| after.0 != before.0); + assert!(changed, "expected at least one pixel to change"); } #[test] From 1d0261a5e7e82cca381df5ff2f367096a41514e8 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:33:53 -0500 Subject: [PATCH 4/9] Add text tool to screenshot editor --- Cargo.toml | 1 + src/gui/mod.rs | 2 +- src/gui/screenshot_editor.rs | 147 ++++++++++++++++++++++++++++++++++- 3 files changed, 148 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index af98709f..c362edca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ rfd = { version = "0.15.3", features = ["common-controls-v6"] } slab = "0.4.11" rdev = { git = "https://github.com/Narsil/rdev", rev = "c14f2dc5c8100a96c5d7e3013de59d6aa0b9eae2", features = ["x11"] } rodio = { version = "0.17", default-features = false, features = ["wav"] } +ab_glyph = "0.2" [features] unstable_grab = ["rdev/unstable_grab"] diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 4f10b4b8..ed40e890 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -55,7 +55,7 @@ pub use note_panel::{ pub use notes_dialog::NotesDialog; pub use screenshot_editor::{ render_markup_layers, MarkupArrow, MarkupHistory, MarkupLayer, MarkupRect, MarkupStroke, - MarkupTool, ScreenshotEditor, + MarkupText, MarkupTool, ScreenshotEditor, }; pub use shell_cmd_dialog::ShellCmdDialog; pub use snippet_dialog::SnippetDialog; diff --git a/src/gui/screenshot_editor.rs b/src/gui/screenshot_editor.rs index 2d2eefb0..5033c9f3 100644 --- a/src/gui/screenshot_editor.rs +++ b/src/gui/screenshot_editor.rs @@ -11,6 +11,7 @@ pub enum MarkupTool { Arrow, Rectangle, Highlight, + Text, } #[derive(Clone, Debug, PartialEq)] @@ -35,12 +36,21 @@ pub struct MarkupArrow { pub thickness: f32, } +#[derive(Clone, Debug, PartialEq)] +pub struct MarkupText { + pub position: Pos2, + pub text: String, + pub color: Color32, + pub size: f32, +} + #[derive(Clone, Debug, PartialEq)] pub enum MarkupLayer { Stroke(MarkupStroke), Rectangle(MarkupRect), Arrow(MarkupArrow), Highlight(MarkupRect), + Text(MarkupText), } #[derive(Clone, Debug, Default)] @@ -196,8 +206,68 @@ fn rotate_vec(vec: Vec2, angle: f32) -> Vec2 { Vec2::new(vec.x * cos - vec.y * sin, vec.x * sin + vec.y * cos) } +fn default_font_data() -> Option<(egui::FontData, egui::FontTweak)> { + let definitions = egui::FontDefinitions::default(); + let family = definitions.families.get(&egui::FontFamily::Proportional)?; + let font_name = family.first()?; + let data = definitions.font_data.get(font_name)?.clone(); + Some((data.clone(), data.tweak)) +} + +fn default_font_arc() -> Option<(ab_glyph::FontArc, egui::FontTweak)> { + let (data, tweak) = default_font_data()?; + let font = match data.font { + std::borrow::Cow::Borrowed(bytes) => { + ab_glyph::FontRef::try_from_slice_and_index(bytes, data.index) + .map(ab_glyph::FontArc::from) + .ok() + } + std::borrow::Cow::Owned(bytes) => { + ab_glyph::FontVec::try_from_vec_and_index(bytes, data.index) + .map(ab_glyph::FontArc::from) + .ok() + } + }?; + Some((font, tweak)) +} + +fn draw_text( + img: &mut RgbaImage, + font: &ab_glyph::FontArc, + tweak: egui::FontTweak, + pos: Pos2, + text: &str, + color: Color32, + size: f32, +) { + use ab_glyph::{point, Font, ScaleFont}; + if text.is_empty() { + return; + } + let scaled = font.as_scaled(size * tweak.scale); + let mut caret = point(pos.x, pos.y + scaled.ascent() + tweak.y_offset * size); + for ch in text.chars() { + let glyph = scaled.scaled_glyph(ch).positioned(caret); + caret.x += scaled.h_advance(glyph.id); + if let Some(outlined) = scaled.outline_glyph(glyph) { + let bounds = outlined.px_bounds(); + outlined.draw(|x, y, coverage| { + let px = x as i32 + bounds.min.x as i32; + let py = y as i32 + bounds.min.y as i32; + if px >= 0 && py >= 0 && px < img.width() as i32 && py < img.height() as i32 { + let alpha = (color.a() as f32 * coverage).round().clamp(0.0, 255.0) as u8; + let blended = + Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha); + blend_pixel(img, px as u32, py as u32, blended); + } + }); + } + } +} + pub fn render_markup_layers(base: &RgbaImage, layers: &[MarkupLayer]) -> RgbaImage { let mut img = base.clone(); + let font = default_font_arc(); for layer in layers { match layer { MarkupLayer::Stroke(stroke) => { @@ -237,6 +307,19 @@ pub fn render_markup_layers(base: &RgbaImage, layers: &[MarkupLayer]) -> RgbaIma MarkupLayer::Highlight(rect) => { draw_rect_fill(&mut img, rect.rect, rect.color); } + MarkupLayer::Text(text) => { + if let Some((font, tweak)) = &font { + draw_text( + &mut img, + font, + *tweak, + text.position, + &text.text, + text.color, + text.size, + ); + } + } } } img @@ -258,6 +341,9 @@ pub struct ScreenshotEditor { active_start: Option, active_end: Option, active_stroke: Option, + text_anchor: Option, + text_input: String, + text_focus: bool, history: MarkupHistory, path: PathBuf, _clip: bool, @@ -266,6 +352,7 @@ pub struct ScreenshotEditor { tool: MarkupTool, color_index: usize, thickness: f32, + text_size: f32, } impl ScreenshotEditor { @@ -289,6 +376,9 @@ impl ScreenshotEditor { active_start: None, active_end: None, active_stroke: None, + text_anchor: None, + text_input: String::new(), + text_focus: false, history: MarkupHistory::default(), path, _clip: clip, @@ -297,6 +387,7 @@ impl ScreenshotEditor { tool, color_index: 0, thickness: 4.0, + text_size: 18.0, } } @@ -411,6 +502,7 @@ impl ScreenshotEditor { } } ui.add(egui::Slider::new(&mut self.zoom, 0.1..=4.0).text("Zoom")); + ui.add(egui::Slider::new(&mut self.text_size, 6.0..=48.0).text("Text Size")); }); ui.horizontal(|ui| { ui.label("Tool"); @@ -418,6 +510,7 @@ impl ScreenshotEditor { ui.selectable_value(&mut self.tool, MarkupTool::Arrow, "Arrow"); ui.selectable_value(&mut self.tool, MarkupTool::Rectangle, "Rect"); ui.selectable_value(&mut self.tool, MarkupTool::Highlight, "Highlight"); + ui.selectable_value(&mut self.tool, MarkupTool::Text, "Text"); ui.separator(); ui.label("Color"); for (idx, color) in Self::palette().iter().enumerate() { @@ -446,6 +539,13 @@ impl ScreenshotEditor { if ui.button("Redo").clicked() { self.redo(); } + ui.separator(); + ui.label("Text"); + let text_edit = ui.text_edit_singleline(&mut self.text_input); + if self.text_focus { + ui.memory_mut(|mem| mem.request_focus(text_edit.id)); + self.text_focus = false; + } }); let pressed_undo = ctx.input(|i| i.key_pressed(egui::Key::Z) && i.modifiers.ctrl); let pressed_redo = ctx.input(|i| { @@ -464,6 +564,25 @@ impl ScreenshotEditor { if ctx.input(|i| i.key_pressed(egui::Key::CloseBracket)) { self.thickness = (self.thickness + 1.0).min(20.0); } + if self.tool == MarkupTool::Text && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + if let Some(anchor) = self.text_anchor.take() { + if !self.text_input.is_empty() { + let text = MarkupText { + position: anchor, + text: self.text_input.clone(), + color: self.current_color(), + size: self.text_size, + }; + self.push_layer(MarkupLayer::Text(text)); + self.text_input.clear(); + } + } + } + if self.tool == MarkupTool::Text && ctx.input(|i| i.key_pressed(egui::Key::Escape)) + { + self.text_anchor = None; + self.text_input.clear(); + } if ctx.input(|i| i.key_pressed(egui::Key::Num1)) { self.color_index = 0; } else if ctx.input(|i| i.key_pressed(egui::Key::Num2)) { @@ -530,6 +649,10 @@ impl ScreenshotEditor { self.active_start = Some(start); self.active_end = Some(start); } + MarkupTool::Text => { + self.text_anchor = Some(start); + self.text_focus = true; + } } } } @@ -545,6 +668,7 @@ impl ScreenshotEditor { MarkupTool::Arrow | MarkupTool::Rectangle | MarkupTool::Highlight => { self.active_end = Some(current); } + MarkupTool::Text => {} } } } @@ -591,6 +715,7 @@ impl ScreenshotEditor { })); } } + MarkupTool::Text => {} } self.active_start = None; self.active_end = None; @@ -647,6 +772,15 @@ impl ScreenshotEditor { ); painter.rect_filled(draw, 0.0, rect.color); } + MarkupLayer::Text(text) => { + painter.text( + to_screen(text.position), + egui::Align2::LEFT_TOP, + &text.text, + egui::FontId::proportional(text.size), + text.color, + ); + } } } if let (Some(start), Some(end)) = (self.active_start, self.active_end) { @@ -670,7 +804,7 @@ impl ScreenshotEditor { let draw = Rect::from_min_max(to_screen(rect.min), to_screen(rect.max)); painter.rect_filled(draw, 0.0, self.current_color()); } - MarkupTool::Pen => {} + MarkupTool::Pen | MarkupTool::Text => {} } } if let Some(stroke) = &self.active_stroke { @@ -681,6 +815,17 @@ impl ScreenshotEditor { ); } } + if let Some(anchor) = self.text_anchor { + if !self.text_input.is_empty() { + painter.text( + to_screen(anchor), + egui::Align2::LEFT_TOP, + &self.text_input, + egui::FontId::proportional(self.text_size), + self.current_color(), + ); + } + } }); self.open = open; } From 7026ad5ee1de9392003fc2d36031739776fc5c20 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:42:28 -0500 Subject: [PATCH 5/9] Fix ab_glyph positioned call --- src/gui/screenshot_editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/screenshot_editor.rs b/src/gui/screenshot_editor.rs index 5033c9f3..71cd75d6 100644 --- a/src/gui/screenshot_editor.rs +++ b/src/gui/screenshot_editor.rs @@ -247,7 +247,7 @@ fn draw_text( let scaled = font.as_scaled(size * tweak.scale); let mut caret = point(pos.x, pos.y + scaled.ascent() + tweak.y_offset * size); for ch in text.chars() { - let glyph = scaled.scaled_glyph(ch).positioned(caret); + let glyph = scaled.scaled_glyph(ch).with_position(caret); caret.x += scaled.h_advance(glyph.id); if let Some(outlined) = scaled.outline_glyph(glyph) { let bounds = outlined.px_bounds(); From 6e29ecb92b8b609956fbf79b5c56e96e4e743cc4 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:47:07 -0500 Subject: [PATCH 6/9] Fix ab_glyph glyph positioning --- src/gui/screenshot_editor.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gui/screenshot_editor.rs b/src/gui/screenshot_editor.rs index 71cd75d6..f7d7ce49 100644 --- a/src/gui/screenshot_editor.rs +++ b/src/gui/screenshot_editor.rs @@ -247,7 +247,8 @@ fn draw_text( let scaled = font.as_scaled(size * tweak.scale); let mut caret = point(pos.x, pos.y + scaled.ascent() + tweak.y_offset * size); for ch in text.chars() { - let glyph = scaled.scaled_glyph(ch).with_position(caret); + let mut glyph = scaled.scaled_glyph(ch); + glyph.position = caret; caret.x += scaled.h_advance(glyph.id); if let Some(outlined) = scaled.outline_glyph(glyph) { let bounds = outlined.px_bounds(); From 3c7e5e158b2641640b943dee4348e802399662cb Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:50:58 -0500 Subject: [PATCH 7/9] Fix gesture hint selection updates --- src/mouse_gestures/service.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/mouse_gestures/service.rs b/src/mouse_gestures/service.rs index f286af58..3491bf22 100644 --- a/src/mouse_gestures/service.rs +++ b/src/mouse_gestures/service.rs @@ -533,9 +533,13 @@ fn worker_loop( HookEvent::SelectBinding(idx) => { if active { pending_selection_idx = Some(idx); - if !cached_actions.is_empty() { - let len = cached_actions.len(); - selected_binding_idx = idx.min(len.saturating_sub(1)); + let binding_len = if exact_binding_count > 0 { + exact_binding_count + } else { + cached_actions.len() + }; + if binding_len > 0 { + selected_binding_idx = idx.min(binding_len.saturating_sub(1)); pending_selection_idx = None; if let Some(key) = exact_selection_key.as_ref() { @@ -740,10 +744,7 @@ fn worker_loop( != stored_idx { selection_state.selections.insert(key.clone(), stored_idx); - save_selection_state( - GESTURES_STATE_FILE, - &selection_state, - ); + save_selection_state(GESTURES_STATE_FILE, &selection_state); } } } @@ -908,10 +909,7 @@ fn match_type_label(match_type: GestureMatchType) -> &'static str { } } -fn format_cheatsheet_text( - db: &Option, - limit: usize, -) -> Option { +fn format_cheatsheet_text(db: &Option, limit: usize) -> Option { let db = db.as_ref()?; let guard = db.lock().ok()?; let mut lines = Vec::new(); From fa94542371b84e8f83ae1905307c32d297417b50 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:59:31 -0500 Subject: [PATCH 8/9] Add gesture token fallback for hints --- src/mouse_gestures/engine.rs | 4 ++++ src/mouse_gestures/service.rs | 24 +++++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/mouse_gestures/engine.rs b/src/mouse_gestures/engine.rs index 7a3e8908..3738dc4d 100644 --- a/src/mouse_gestures/engine.rs +++ b/src/mouse_gestures/engine.rs @@ -185,6 +185,10 @@ impl GestureTracker { } } +pub fn token_from_delta(dx: f32, dy: f32, mode: DirMode) -> Option { + direction_from_delta(dx, dy, mode).map(|dir| dir.token(mode)) +} + fn direction_from_delta(dx: f32, dy: f32, mode: DirMode) -> Option { let abs_x = dx.abs(); let abs_y = dy.abs(); diff --git a/src/mouse_gestures/service.rs b/src/mouse_gestures/service.rs index 3491bf22..8e766a6d 100644 --- a/src/mouse_gestures/service.rs +++ b/src/mouse_gestures/service.rs @@ -2,7 +2,7 @@ use crate::mouse_gestures::db::{ format_gesture_label, load_gestures, GestureCandidate, GestureMatchType, SharedGestureDb, GESTURES_FILE, }; -use crate::mouse_gestures::engine::{DirMode, GestureTracker}; +use crate::mouse_gestures::engine::{token_from_delta, DirMode, GestureTracker}; use crate::mouse_gestures::overlay::{ DefaultOverlayBackend, HintOverlay, OverlayBackend, TrailOverlay, }; @@ -435,7 +435,16 @@ fn worker_loop( let ms = start_time.elapsed().as_millis() as u64; let _ = tracker.feed_point(cursor_pos, ms); - let tokens = tracker.tokens_string(); + let mut tokens = tracker.tokens_string(); + if tokens.is_empty() { + let dx = cursor_pos.0 - start_pos.0; + let dy = cursor_pos.1 - start_pos.1; + if dx * dx + dy * dy >= config.threshold_px * config.threshold_px { + if let Some(token) = token_from_delta(dx, dy, config.dir_mode) { + tokens = token.to_string(); + } + } + } if config.debug_logging { tracing::debug!(tokens = %tokens, "mouse gesture tokens"); } @@ -681,7 +690,16 @@ fn worker_loop( if last_recognition.elapsed() >= recognition_interval { let ms = start_time.elapsed().as_millis() as u64; let _ = tracker.feed_point(pos, ms); - let tokens = tracker.tokens_string(); + let mut tokens = tracker.tokens_string(); + if tokens.is_empty() { + let dx = pos.0 - start_pos.0; + let dy = pos.1 - start_pos.1; + if dx * dx + dy * dy >= config.threshold_px * config.threshold_px { + if let Some(token) = token_from_delta(dx, dy, config.dir_mode) { + tokens = token.to_string(); + } + } + } if tokens != cached_tokens { cached_tokens = tokens.to_string(); selected_binding_idx = 0; From dbe50f7d074b18f468ce0ee9dff37de39d1cb929 Mon Sep 17 00:00:00 2001 From: multiplex55 Date: Sat, 31 Jan 2026 19:38:02 -0500 Subject: [PATCH 9/9] Refactor text tool to support direct canvas typing Replaces the previous text input UI with an in-canvas text editing experience. Introduces an ActiveText struct to manage ephemeral text state, allowing users to click to place text and type directly onto the screenshot. Commits or cancels text on Enter or Escape, and ensures each text instance captures its color and size at creation. --- src/gui/screenshot_editor.rs | 119 ++++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 37 deletions(-) diff --git a/src/gui/screenshot_editor.rs b/src/gui/screenshot_editor.rs index f7d7ce49..da110e13 100644 --- a/src/gui/screenshot_editor.rs +++ b/src/gui/screenshot_editor.rs @@ -44,6 +44,17 @@ pub struct MarkupText { pub size: f32, } +/// Ephemeral state for the Text tool while the user is typing directly onto the canvas. +/// +/// This intentionally captures color/size at creation time so each text instance is independent. +#[derive(Clone, Debug, PartialEq)] +struct ActiveText { + position: Pos2, + text: String, + color: Color32, + size: f32, +} + #[derive(Clone, Debug, PartialEq)] pub enum MarkupLayer { Stroke(MarkupStroke), @@ -342,9 +353,7 @@ pub struct ScreenshotEditor { active_start: Option, active_end: Option, active_stroke: Option, - text_anchor: Option, - text_input: String, - text_focus: bool, + active_text: Option, history: MarkupHistory, path: PathBuf, _clip: bool, @@ -377,9 +386,7 @@ impl ScreenshotEditor { active_start: None, active_end: None, active_stroke: None, - text_anchor: None, - text_input: String::new(), - text_focus: false, + active_text: None, history: MarkupHistory::default(), path, _clip: clip, @@ -459,6 +466,19 @@ impl ScreenshotEditor { self.history.redo(); } + fn commit_active_text(&mut self) { + if let Some(active) = self.active_text.take() { + if !active.text.is_empty() { + self.push_layer(MarkupLayer::Text(MarkupText { + position: active.position, + text: active.text, + color: active.color, + size: active.size, + })); + } + } + } + pub fn ui(&mut self, ctx: &egui::Context, app: &mut LauncherApp) { if !self.open { return; @@ -505,6 +525,7 @@ impl ScreenshotEditor { ui.add(egui::Slider::new(&mut self.zoom, 0.1..=4.0).text("Zoom")); ui.add(egui::Slider::new(&mut self.text_size, 6.0..=48.0).text("Text Size")); }); + let prev_tool = self.tool; ui.horizontal(|ui| { ui.label("Tool"); ui.selectable_value(&mut self.tool, MarkupTool::Pen, "Pen"); @@ -540,14 +561,13 @@ impl ScreenshotEditor { if ui.button("Redo").clicked() { self.redo(); } - ui.separator(); - ui.label("Text"); - let text_edit = ui.text_edit_singleline(&mut self.text_input); - if self.text_focus { - ui.memory_mut(|mem| mem.request_focus(text_edit.id)); - self.text_focus = false; - } }); + + // If we switched away from the Text tool, commit any active text. + if prev_tool == MarkupTool::Text && self.tool != MarkupTool::Text { + self.commit_active_text(); + } + let pressed_undo = ctx.input(|i| i.key_pressed(egui::Key::Z) && i.modifiers.ctrl); let pressed_redo = ctx.input(|i| { (i.key_pressed(egui::Key::Y) && i.modifiers.ctrl) @@ -565,24 +585,41 @@ impl ScreenshotEditor { if ctx.input(|i| i.key_pressed(egui::Key::CloseBracket)) { self.thickness = (self.thickness + 1.0).min(20.0); } - if self.tool == MarkupTool::Text && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { - if let Some(anchor) = self.text_anchor.take() { - if !self.text_input.is_empty() { - let text = MarkupText { - position: anchor, - text: self.text_input.clone(), - color: self.current_color(), - size: self.text_size, - }; - self.push_layer(MarkupLayer::Text(text)); - self.text_input.clear(); + // Text tool (Paint-like): click to place an insertion point, then type directly onto the canvas. + // Each text instance captures color/size at creation time. + if self.tool == MarkupTool::Text { + if let Some(active) = &mut self.active_text { + // Collect typed characters from this frame. + let events = ctx.input(|i| i.events.clone()); + for ev in events { + if let egui::Event::Text(s) = ev { + for ch in s.chars() { + // Conservative filter: alphanumeric + whitespace. + if ch.is_alphanumeric() || ch.is_whitespace() { + active.text.push(ch); + } + } + } + } + + // Basic editing. + if ctx.input(|i| i.key_pressed(egui::Key::Backspace)) { + active.text.pop(); + } + + // Enter commits and returns to "waiting for click". + if ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + self.commit_active_text(); + } + + // Escape cancels the active text instance. + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + self.active_text = None; } } - } - if self.tool == MarkupTool::Text && ctx.input(|i| i.key_pressed(egui::Key::Escape)) - { - self.text_anchor = None; - self.text_input.clear(); + } else if self.active_text.is_some() { + // Switching away from the Text tool commits the active text (Paint-like behavior). + self.commit_active_text(); } if ctx.input(|i| i.key_pressed(egui::Key::Num1)) { self.color_index = 0; @@ -651,8 +688,16 @@ impl ScreenshotEditor { self.active_end = Some(start); } MarkupTool::Text => { - self.text_anchor = Some(start); - self.text_focus = true; + // If a text instance is currently active, commit it and start a new one. + if self.active_text.is_some() { + self.commit_active_text(); + } + self.active_text = Some(ActiveText { + position: start, + text: String::new(), + color: self.current_color(), + size: self.text_size, + }); } } } @@ -816,14 +861,14 @@ impl ScreenshotEditor { ); } } - if let Some(anchor) = self.text_anchor { - if !self.text_input.is_empty() { + if let Some(active) = &self.active_text { + if !active.text.is_empty() { painter.text( - to_screen(anchor), + to_screen(active.position), egui::Align2::LEFT_TOP, - &self.text_input, - egui::FontId::proportional(self.text_size), - self.current_color(), + &active.text, + egui::FontId::proportional(active.size), + active.color, ); } }