diff --git a/src/widgets/image.rs b/src/widgets/image.rs index b454e81..94535f2 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -43,6 +43,7 @@ mod rendering; mod rotation; mod scrollable; mod zoom; +pub mod drawing; use std::cell::{Cell, OnceCell, RefCell}; use std::marker::PhantomData; @@ -269,6 +270,14 @@ mod imp { /// Editing queue pub(super) editing_queue: crate::editing::Queue, pub(super) operations: RefCell>>, + + // Drawing / annotation state + pub(super) drawing_mode: Cell, + pub(super) draw_tool: Cell, + pub(super) draw_color: RefCell, + pub(super) draw_line_width: Cell, + pub(super) strokes: RefCell>, + pub(super) current_stroke: RefCell>, } #[glib::object_subclass] @@ -314,6 +323,7 @@ mod imp { self.zoom_target.set(1.); self.best_fit.set(true); self.scaling.set(self.scaling()); + self.draw_line_width.set(3.0); self.connect_input_handling(); @@ -408,4 +418,92 @@ impl LpImage { imp.original_orientation .set(Some(original_imp.metadata.borrow().orientation())); } + + // ── Drawing / annotation API ────────────────────────────────────────── + + pub fn is_drawing_mode(&self) -> bool { + self.imp().drawing_mode.get() + } + + pub fn set_drawing_mode(&self, active: bool) { + let imp = self.imp(); + imp.drawing_mode.set(active); + if !active { + imp.current_stroke.replace(None); + } + self.queue_draw(); + } + + pub fn draw_tool(&self) -> drawing::DrawTool { + self.imp().draw_tool.get() + } + + pub fn set_draw_tool(&self, tool: drawing::DrawTool) { + self.imp().draw_tool.set(tool); + } + + pub fn draw_color(&self) -> drawing::DrawColor { + *self.imp().draw_color.borrow() + } + + pub fn set_draw_color(&self, color: drawing::DrawColor) { + self.imp().draw_color.replace(color); + } + + pub fn draw_line_width(&self) -> f64 { + self.imp().draw_line_width.get() + } + + pub fn set_draw_line_width(&self, width: f64) { + self.imp().draw_line_width.set(width.clamp(1., 50.)); + } + + pub fn undo_stroke(&self) { + self.imp().strokes.borrow_mut().pop(); + self.queue_draw(); + } + + pub fn clear_strokes(&self) { + self.imp().strokes.borrow_mut().clear(); + self.queue_draw(); + } + + pub fn has_strokes(&self) -> bool { + !self.imp().strokes.borrow().is_empty() + } + + /// Render the image with annotations to a texture suitable for saving as PNG. + pub fn render_annotated_texture(&self) -> Option { + let imp = self.imp(); + let (w, h) = imp.untransformed_dimensions(); + if w == 0 || h == 0 { + return None; + } + + let snapshot = gtk::Snapshot::new(); + let orientation = self.metadata().orientation(); + imp.snapshot_rotate_mirror( + &snapshot, + -(orientation.rotate().degrees() as f32), + orientation.mirror(), + 1.0, + ); + let render_options = crate::decoder::tiling::RenderOptions { + scaling_filter: gsk::ScalingFilter::Trilinear, + background_color: None, + scaling: 1., + }; + imp.active_frame_buffer() + .add_to_snapshot(&snapshot, 1.0, &render_options); + imp.render_drawing_overlay(&snapshot, 1.0); + + let node = snapshot.to_node()?; + let renderer = gsk::CairoRenderer::new(); + renderer + .realize_for_display(&gdk::Display::default()?) + .ok()?; + let texture = renderer.render_texture(&node, None); + renderer.unrealize(); + Some(texture) + } } diff --git a/src/widgets/image/drawing.rs b/src/widgets/image/drawing.rs new file mode 100644 index 0000000..98a6cbd --- /dev/null +++ b/src/widgets/image/drawing.rs @@ -0,0 +1,216 @@ +// Copyright (c) 2026 Loupe contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +use cairo::{Context, LineCap, LineJoin}; + +use crate::deps::*; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DrawTool { + #[default] + Freeform, + Rectangle, + Circle, + Triangle, + Star, +} + +#[derive(Debug, Clone, Copy)] +pub struct DrawColor { + pub r: f64, + pub g: f64, + pub b: f64, + pub a: f64, +} + +impl Default for DrawColor { + fn default() -> Self { + Self { + r: 0.96, + g: 0.22, + b: 0.22, + a: 1.0, + } + } +} + +impl From for DrawColor { + fn from(c: gdk::RGBA) -> Self { + Self { + r: c.red() as f64, + g: c.green() as f64, + b: c.blue() as f64, + a: c.alpha() as f64, + } + } +} + +impl From for gdk::RGBA { + fn from(c: DrawColor) -> gdk::RGBA { + gdk::RGBA::new(c.r as f32, c.g as f32, c.b as f32, c.a as f32) + } +} + +/// A completed drawing stroke stored in image pixel coordinates. +#[derive(Debug, Clone)] +pub struct Stroke { + pub tool: DrawTool, + /// Freeform: path points. Shapes: [start, end]. + pub points: Vec<(f64, f64)>, + pub color: DrawColor, + pub line_width: f64, +} + +/// A stroke currently being drawn. +#[derive(Debug, Clone)] +pub struct CurrentStroke { + pub tool: DrawTool, + pub start: (f64, f64), + pub end: (f64, f64), + pub points: Vec<(f64, f64)>, + pub color: DrawColor, + pub line_width: f64, +} + +impl Stroke { + pub fn render(&self, cr: &Context) { + cr.set_source_rgba(self.color.r, self.color.g, self.color.b, self.color.a); + cr.set_line_width(self.line_width); + cr.set_line_cap(LineCap::Round); + cr.set_line_join(LineJoin::Round); + + let se = || { + ( + self.points.first().copied().unwrap_or_default(), + self.points.last().copied().unwrap_or_default(), + ) + }; + match self.tool { + DrawTool::Freeform => render_freeform(cr, &self.points), + DrawTool::Rectangle => render_rectangle(cr, se()), + DrawTool::Circle => render_ellipse(cr, se()), + DrawTool::Triangle => render_triangle(cr, se()), + DrawTool::Star => render_star(cr, se()), + } + } +} + +impl CurrentStroke { + pub fn render(&self, cr: &Context) { + cr.set_source_rgba(self.color.r, self.color.g, self.color.b, self.color.a); + cr.set_line_width(self.line_width); + cr.set_line_cap(LineCap::Round); + cr.set_line_join(LineJoin::Round); + + match self.tool { + DrawTool::Freeform => render_freeform(cr, &self.points), + DrawTool::Rectangle => render_rectangle(cr, (self.start, self.end)), + DrawTool::Circle => render_ellipse(cr, (self.start, self.end)), + DrawTool::Triangle => render_triangle(cr, (self.start, self.end)), + DrawTool::Star => render_star(cr, (self.start, self.end)), + } + } + + pub fn into_stroke(self) -> Option { + let points = match self.tool { + DrawTool::Freeform => { + if self.points.is_empty() { + return None; + } + self.points + } + _ => { + if (self.end.0 - self.start.0).abs() < 1. + && (self.end.1 - self.start.1).abs() < 1. + { + return None; + } + vec![self.start, self.end] + } + }; + Some(Stroke { + tool: self.tool, + points, + color: self.color, + line_width: self.line_width, + }) + } +} + +fn render_freeform(cr: &Context, pts: &[(f64, f64)]) { + if pts.is_empty() { + return; + } + cr.move_to(pts[0].0, pts[0].1); + if pts.len() == 1 { + cr.line_to(pts[0].0 + 0.01, pts[0].1); + } else { + for &(x, y) in &pts[1..] { + cr.line_to(x, y); + } + } + let _ = cr.stroke(); +} + +fn render_rectangle(cr: &Context, ((x1, y1), (x2, y2)): ((f64, f64), (f64, f64))) { + let (x, y, w, h) = ( + f64::min(x1, x2), + f64::min(y1, y2), + (x2 - x1).abs(), + (y2 - y1).abs(), + ); + if w < 1. || h < 1. { + return; + } + cr.rectangle(x, y, w, h); + let _ = cr.stroke(); +} + +fn render_ellipse(cr: &Context, ((x1, y1), (x2, y2)): ((f64, f64), (f64, f64))) { + let (cx, cy) = ((x1 + x2) / 2., (y1 + y2) / 2.); + let (rx, ry) = ((x2 - x1).abs() / 2., (y2 - y1).abs() / 2.); + if rx < 1. || ry < 1. { + return; + } + let _ = cr.save(); + cr.translate(cx, cy); + cr.scale(rx, ry); + cr.arc(0., 0., 1., 0., 2. * std::f64::consts::PI); + let _ = cr.restore(); + let _ = cr.stroke(); +} + +fn render_triangle(cr: &Context, ((x1, y1), (x2, y2)): ((f64, f64), (f64, f64))) { + cr.move_to((x1 + x2) / 2., y1); + cr.line_to(x2, y2); + cr.line_to(x1, y2); + cr.close_path(); + let _ = cr.stroke(); +} + +fn render_star(cr: &Context, ((x1, y1), (x2, y2)): ((f64, f64), (f64, f64))) { + let (cx, cy) = ((x1 + x2) / 2., (y1 + y2) / 2.); + let outer = f64::min((x2 - x1).abs(), (y2 - y1).abs()) / 2.; + if outer < 1. { + return; + } + let inner = outer * 0.382; + for i in 0..10u32 { + let angle = i as f64 * std::f64::consts::PI / 5. - std::f64::consts::PI / 2.; + let r = if i % 2 == 0 { outer } else { inner }; + let (px, py) = (cx + r * angle.cos(), cy + r * angle.sin()); + if i == 0 { + cr.move_to(px, py); + } else { + cr.line_to(px, py); + } + } + cr.close_path(); + let _ = cr.stroke(); +} diff --git a/src/widgets/image/input_handling.rs b/src/widgets/image/input_handling.rs index f5737c4..d9f5e33 100644 --- a/src/widgets/image/input_handling.rs +++ b/src/widgets/image/input_handling.rs @@ -21,10 +21,88 @@ use super::*; impl imp::LpImage { /// To be called by constructor pub(super) fn connect_input_handling(&self) { + self.connect_drawing_gesture(); self.connect_controllers(); self.connect_gestures(); } + fn connect_drawing_gesture(&self) { + let obj = self.obj(); + + let gesture = gtk::GestureDrag::builder().button(1).build(); + obj.add_controller(gesture.clone()); + + gesture.connect_drag_begin(glib::clone!( + #[weak] + obj, + move |g, x, y| { + let imp = obj.imp(); + if !imp.drawing_mode.get() { + g.set_state(gtk::EventSequenceState::Denied); + return; + } + g.set_state(gtk::EventSequenceState::Claimed); + let pos = obj.widget_to_img_coord((x, y)); + imp.current_stroke.replace(Some(drawing::CurrentStroke { + tool: imp.draw_tool.get(), + start: pos, + end: pos, + points: vec![pos], + color: *imp.draw_color.borrow(), + line_width: imp.draw_line_width.get(), + })); + obj.queue_draw(); + } + )); + + gesture.connect_drag_update(glib::clone!( + #[weak] + obj, + move |g, dx, dy| { + let imp = obj.imp(); + if !imp.drawing_mode.get() { + return; + } + let Some((sx, sy)) = g.start_point() else { return }; + let pos = obj.widget_to_img_coord((sx + dx, sy + dy)); + let mut cur = imp.current_stroke.borrow_mut(); + if let Some(s) = cur.as_mut() { + s.end = pos; + if s.tool == drawing::DrawTool::Freeform { + s.points.push(pos); + } + } + drop(cur); + obj.queue_draw(); + } + )); + + gesture.connect_drag_end(glib::clone!( + #[weak] + obj, + move |g, dx, dy| { + let imp = obj.imp(); + if !imp.drawing_mode.get() { + return; + } + let mut cur = imp.current_stroke.replace(None); + if let Some(s) = cur.as_mut() { + if let Some((sx, sy)) = g.start_point() { + let pos = obj.widget_to_img_coord((sx + dx, sy + dy)); + s.end = pos; + if s.tool == drawing::DrawTool::Freeform { + s.points.push(pos); + } + } + } + if let Some(stroke) = cur.and_then(|s| s.into_stroke()) { + imp.strokes.borrow_mut().push(stroke); + } + obj.queue_draw(); + } + )); + } + fn connect_controllers(&self) { let obj = self.obj(); @@ -178,6 +256,12 @@ impl imp::LpImage { tracing::trace!("Drag gesture begin"); let imp = obj.imp(); + // Yield to the drawing gesture when in drawing mode + if imp.drawing_mode.get() { + gesture.set_state(gtk::EventSequenceState::Denied); + return; + } + // Allow only left and middle button if ![1, 2].contains(&gesture.current_button()) // Drag gesture for touchscreens is handled by ScrolledWindow diff --git a/src/widgets/image/rendering.rs b/src/widgets/image/rendering.rs index 8993d25..9bf9fa9 100644 --- a/src/widgets/image/rendering.rs +++ b/src/widgets/image/rendering.rs @@ -139,6 +139,9 @@ impl WidgetImpl for imp::LpImage { ); frame_buffer.add_to_snapshot(snapshot, applicable_zoom, &render_options); + + // Drawing / annotation overlay + self.render_drawing_overlay(snapshot, applicable_zoom); } snapshot.restore(); @@ -405,3 +408,36 @@ impl LpImage { (x, y) } } + +impl imp::LpImage { + /// Render completed and in-progress drawing strokes on top of the image. + /// + /// Must be called after the frame buffer has been added to the snapshot + /// (in the non-operations render path) while the image coordinate transforms + /// are still active. + pub(super) fn render_drawing_overlay(&self, snapshot: >k::Snapshot, zoom: f64) { + let strokes = self.strokes.borrow(); + let current = self.current_stroke.borrow(); + + if strokes.is_empty() && current.is_none() { + return; + } + + let (unw, unh) = self.untransformed_dimensions(); + if unw == 0 || unh == 0 { + return; + } + + let w = (unw as f64 * zoom) as f32; + let h = (unh as f64 * zoom) as f32; + let cr = snapshot.append_cairo(&graphene::Rect::new(0., 0., w, h)); + cr.scale(zoom, zoom); + + for stroke in strokes.iter() { + stroke.render(&cr); + } + if let Some(c) = current.as_ref() { + c.render(&cr); + } + } +} diff --git a/src/widgets/image_window.rs b/src/widgets/image_window.rs index 6e07913..b83d54b 100644 --- a/src/widgets/image_window.rs +++ b/src/widgets/image_window.rs @@ -111,6 +111,28 @@ mod imp { pub(super) show_controls_animation: OnceCell, pub(super) hide_controls_animation: OnceCell, pub(super) hide_controls_timeout: RefCell>, + + // Drawing toolbar template children + #[template_child] + pub(super) draw_mode_button: TemplateChild, + #[template_child] + pub(super) draw_toolbar_revealer: TemplateChild, + #[template_child] + pub(super) draw_freeform_btn: TemplateChild, + #[template_child] + pub(super) draw_rect_btn: TemplateChild, + #[template_child] + pub(super) draw_circle_btn: TemplateChild, + #[template_child] + pub(super) draw_triangle_btn: TemplateChild, + #[template_child] + pub(super) draw_star_btn: TemplateChild, + #[template_child] + pub(super) draw_color_btn: TemplateChild, + #[template_child] + pub(super) draw_line_width_btn: TemplateChild, + #[template_child] + pub(super) draw_clear_btn: TemplateChild, } #[glib::object_subclass] @@ -437,6 +459,131 @@ mod imp { true } )); + + // ── Drawing toolbar setup ────────────────────────────────────── + { + let imp = self; + + // Group tool toggle buttons (mutually exclusive) + imp.draw_rect_btn.set_group(Some(&*imp.draw_freeform_btn)); + imp.draw_circle_btn.set_group(Some(&*imp.draw_freeform_btn)); + imp.draw_triangle_btn.set_group(Some(&*imp.draw_freeform_btn)); + imp.draw_star_btn.set_group(Some(&*imp.draw_freeform_btn)); + imp.draw_freeform_btn.set_active(true); + + // Set initial color to match DrawColor::default() (red) + imp.draw_color_btn + .set_rgba(&gdk::RGBA::new(0.96, 0.22, 0.22, 1.0)); + + // Connect draw mode button (headerbar toggle) + imp.draw_mode_button.connect_toggled(glib::clone!( + #[weak] + obj, + move |btn| { + let active = btn.is_active(); + let imp = obj.imp(); + imp.draw_toolbar_revealer.set_reveal_child(active); + if let Some(img) = imp.image_view.current_image() { + img.set_drawing_mode(active); + if active { + let color = crate::widgets::image::drawing::DrawColor::from( + imp.draw_color_btn.rgba(), + ); + img.set_draw_color(color); + img.set_draw_line_width(imp.draw_line_width_btn.value()); + } + } + } + )); + + // Tool buttons + imp.draw_freeform_btn.connect_toggled(glib::clone!( + #[weak] + obj, + move |btn| { + if btn.is_active() { + obj.set_draw_tool( + crate::widgets::image::drawing::DrawTool::Freeform, + ); + } + } + )); + imp.draw_rect_btn.connect_toggled(glib::clone!( + #[weak] + obj, + move |btn| { + if btn.is_active() { + obj.set_draw_tool( + crate::widgets::image::drawing::DrawTool::Rectangle, + ); + } + } + )); + imp.draw_circle_btn.connect_toggled(glib::clone!( + #[weak] + obj, + move |btn| { + if btn.is_active() { + obj.set_draw_tool( + crate::widgets::image::drawing::DrawTool::Circle, + ); + } + } + )); + imp.draw_triangle_btn.connect_toggled(glib::clone!( + #[weak] + obj, + move |btn| { + if btn.is_active() { + obj.set_draw_tool( + crate::widgets::image::drawing::DrawTool::Triangle, + ); + } + } + )); + imp.draw_star_btn.connect_toggled(glib::clone!( + #[weak] + obj, + move |btn| { + if btn.is_active() { + obj.set_draw_tool( + crate::widgets::image::drawing::DrawTool::Star, + ); + } + } + )); + + // Color button + imp.draw_color_btn.connect_rgba_notify(glib::clone!( + #[weak] + obj, + move |btn| { + let color = + crate::widgets::image::drawing::DrawColor::from(btn.rgba()); + obj.set_draw_color(color); + } + )); + + // Line width spin button + imp.draw_line_width_btn.connect_value_changed(glib::clone!( + #[weak] + obj, + move |spin| { + obj.set_draw_line_width(spin.value()); + } + )); + + // Clear all strokes button + imp.draw_clear_btn.connect_clicked(glib::clone!( + #[weak] + obj, + move |_| { + if let Some(img) = obj.imp().image_view.current_image() { + img.clear_strokes(); + } + } + )); + } } } @@ -607,6 +754,25 @@ impl LpImageWindow { pub fn copy_image(&self) { let imp = self.imp(); + // When annotations exist, copy the flattened texture instead of the file + if let Some(img) = imp.image_view.current_image() { + if img.has_strokes() { + if let Some(texture) = img.render_annotated_texture() { + let content = + gdk::ContentProvider::for_value(&texture.to_value()); + if let Err(e) = self.clipboard().set_content(Some(&content)) { + tracing::error!("Failed to copy annotated image: {e}"); + } else { + self.window_show_toast( + &gettext("Image copied to clipboard"), + adw::ToastPriority::High, + ); + } + return; + } + } + } + if let Err(e) = imp.image_view.copy() { tracing::error!("Failed to copy to clipboard: {}", e); } else { @@ -1006,4 +1172,69 @@ impl LpImageWindow { fn headerbar_opacity(&self) -> f64 { self.imp().headerbar.opacity() } + + // ── Drawing helpers ─────────────────────────────────────────────────── + + pub fn toggle_draw_mode(&self) { + let imp = self.imp(); + let new_state = !imp.draw_mode_button.is_active(); + imp.draw_mode_button.set_active(new_state); + // The `connect_toggled` handler on `draw_mode_button` takes care of the rest. + } + + pub fn set_draw_tool(&self, tool: crate::widgets::image::drawing::DrawTool) { + if let Some(img) = self.imp().image_view.current_image() { + img.set_draw_tool(tool); + } + } + + pub fn set_draw_color(&self, color: crate::widgets::image::drawing::DrawColor) { + if let Some(img) = self.imp().image_view.current_image() { + img.set_draw_color(color); + } + } + + pub fn set_draw_line_width(&self, width: f64) { + if let Some(img) = self.imp().image_view.current_image() { + img.set_draw_line_width(width); + } + } + + pub async fn draw_save_as(&self) { + let Some(img) = self.imp().image_view.current_image() else { + return; + }; + let Some(current_file) = img.file() else { + return; + }; + + let file_dialog = gtk::FileDialog::new(); + if let Some(path) = current_file.path() { + if let Some(stem) = path.file_stem() { + let mut new_name = stem.to_os_string(); + new_name.push("-annotated.png"); + let mut suggested = path.clone(); + suggested.set_file_name(new_name); + file_dialog + .set_initial_file(Some(&gio::File::for_path(suggested))); + } + } + + let window = self.try_window(); + let Ok(save_file) = file_dialog.save_future(window.as_ref()).await else { + return; + }; + + let Some(texture) = img.render_annotated_texture() else { + tracing::error!("Failed to render annotated texture"); + return; + }; + + if let Some(path) = save_file.path() { + let bytes = texture.save_to_png_bytes(); + if let Err(err) = std::fs::write(&path, &*bytes) { + tracing::error!("Failed to save annotated image: {err}"); + } + } + } } diff --git a/src/widgets/image_window.ui b/src/widgets/image_window.ui index 50a1677..a9571a2 100644 --- a/src/widgets/image_window.ui +++ b/src/widgets/image_window.ui @@ -132,6 +132,15 @@ Edit Image + + + draw-freehand-symbolic + Annotate Image (D) + + + @@ -139,6 +148,144 @@ + + + False + slide-up + + + 6 + 6 + 6 + 12 + 12 + + + + + 0 + + + + draw-freehand-symbolic + Freeform + + + + + Rect + Rectangle + + + + + Circle + Circle / Ellipse + + + + + Tri + Triangle + + + + + Star + Star + + + + + + + + vertical + 6 + 6 + + + + + + Stroke Color + + + + + + + + + Line Width + + + 1 + 50 + 3 + 1 + 5 + + + 0 + True + 4 + + + + + + vertical + 6 + 6 + + + + + + edit-undo-symbolic + Undo Last Stroke (Ctrl+Z) + win.draw-undo + + + + + + + edit-clear-all-symbolic + Clear All Annotations + + + + + + + True + + + + + + Save Copy… + Save annotated copy as PNG + win.draw-save-as + + + + + + + diff --git a/src/widgets/image_window/actions.rs b/src/widgets/image_window/actions.rs index 8f742a2..96034e7 100644 --- a/src/widgets/image_window/actions.rs +++ b/src/widgets/image_window/actions.rs @@ -91,6 +91,12 @@ pub enum Action { Reload, #[strum(to_string = "win.edit")] Edit, + #[strum(to_string = "win.draw-toggle")] + DrawToggle, + #[strum(to_string = "win.draw-undo")] + DrawUndo, + #[strum(to_string = "win.draw-save-as")] + DrawSaveAs, } impl Action { @@ -293,6 +299,28 @@ impl Action { obj.window_inspect(|w| w.show_edit()); }); } + + Action::DrawToggle => { + klass.install_action(&action, None, move |win, _, _| { + win.toggle_draw_mode(); + }); + } + + Action::DrawUndo => { + klass.install_action(&action, None, move |win, _, _| { + if let Some(img) = win.imp().image_view.current_image() { + if img.is_drawing_mode() { + img.undo_stroke(); + } + } + }); + } + + Action::DrawSaveAs => { + klass.install_action_async(&action, None, |win, _, _| async move { + win.draw_save_as().await; + }); + } } } } @@ -371,6 +399,8 @@ impl Action { Action::ToggleProperties => &[(Key::F9, &[]), (Key::Return, &[ModifierType::ALT_MASK])], Action::Reload => &[(Key::F5, &[])], Action::Edit => &[(Key::e, &[])], + Action::DrawToggle => &[(Key::d, &[])], + Action::DrawUndo => &[(Key::z, &[ModifierType::CONTROL_MASK])], _ => &[], } }