Add image annotation / drawing mode
Adds a draw mode toggle (pencil icon, shortcut D) that reveals a bottom toolbar with freeform pen and shape tools (rectangle, ellipse, triangle, star), a colour picker, line-width spinner, undo (Ctrl+Z), clear-all, and a "Save Copy…" button that exports a flattened PNG. Copying to clipboard (Ctrl+C) also flattens the annotations when any strokes are present. Strokes are stored in image-pixel coordinates and rendered via a Cairo overlay in the snapshot pipeline, so they remain correctly positioned during zoom and pan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fcf211b7da
commit
2ce31a1a30
7 changed files with 842 additions and 0 deletions
|
|
@ -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<Option<Arc<Operations>>>,
|
||||
|
||||
// Drawing / annotation state
|
||||
pub(super) drawing_mode: Cell<bool>,
|
||||
pub(super) draw_tool: Cell<drawing::DrawTool>,
|
||||
pub(super) draw_color: RefCell<drawing::DrawColor>,
|
||||
pub(super) draw_line_width: Cell<f64>,
|
||||
pub(super) strokes: RefCell<Vec<drawing::Stroke>>,
|
||||
pub(super) current_stroke: RefCell<Option<drawing::CurrentStroke>>,
|
||||
}
|
||||
|
||||
#[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<gdk::Texture> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
216
src/widgets/image/drawing.rs
Normal file
216
src/widgets/image/drawing.rs
Normal file
|
|
@ -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<gdk::RGBA> 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<DrawColor> 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<Stroke> {
|
||||
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();
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,28 @@ mod imp {
|
|||
pub(super) show_controls_animation: OnceCell<adw::TimedAnimation>,
|
||||
pub(super) hide_controls_animation: OnceCell<adw::TimedAnimation>,
|
||||
pub(super) hide_controls_timeout: RefCell<Option<glib::SourceId>>,
|
||||
|
||||
// Drawing toolbar template children
|
||||
#[template_child]
|
||||
pub(super) draw_mode_button: TemplateChild<gtk::ToggleButton>,
|
||||
#[template_child]
|
||||
pub(super) draw_toolbar_revealer: TemplateChild<gtk::Revealer>,
|
||||
#[template_child]
|
||||
pub(super) draw_freeform_btn: TemplateChild<gtk::ToggleButton>,
|
||||
#[template_child]
|
||||
pub(super) draw_rect_btn: TemplateChild<gtk::ToggleButton>,
|
||||
#[template_child]
|
||||
pub(super) draw_circle_btn: TemplateChild<gtk::ToggleButton>,
|
||||
#[template_child]
|
||||
pub(super) draw_triangle_btn: TemplateChild<gtk::ToggleButton>,
|
||||
#[template_child]
|
||||
pub(super) draw_star_btn: TemplateChild<gtk::ToggleButton>,
|
||||
#[template_child]
|
||||
pub(super) draw_color_btn: TemplateChild<gtk::ColorDialogButton>,
|
||||
#[template_child]
|
||||
pub(super) draw_line_width_btn: TemplateChild<gtk::SpinButton>,
|
||||
#[template_child]
|
||||
pub(super) draw_clear_btn: TemplateChild<gtk::Button>,
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,15 @@
|
|||
<property name="tooltip-text" translatable="yes">Edit Image</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkToggleButton" id="draw_mode_button">
|
||||
<property name="icon-name">draw-freehand-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Annotate Image (D)</property>
|
||||
<style>
|
||||
<class name="flat" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkEventControllerMotion" id="headerbar_events" />
|
||||
</child>
|
||||
|
|
@ -139,6 +148,144 @@
|
|||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="bottom">
|
||||
<object class="GtkRevealer" id="draw_toolbar_revealer">
|
||||
<property name="reveal-child">False</property>
|
||||
<property name="transition-type">slide-up</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">6</property>
|
||||
<property name="margin-top">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="margin-start">12</property>
|
||||
<property name="margin-end">12</property>
|
||||
<style>
|
||||
<class name="toolbar" />
|
||||
</style>
|
||||
<!-- Tool selector (linked toggle buttons) -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">0</property>
|
||||
<style>
|
||||
<class name="linked" />
|
||||
</style>
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="draw_freeform_btn">
|
||||
<property name="icon-name">draw-freehand-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Freeform</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="draw_rect_btn">
|
||||
<property name="label">Rect</property>
|
||||
<property name="tooltip-text" translatable="yes">Rectangle</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="draw_circle_btn">
|
||||
<property name="label">Circle</property>
|
||||
<property name="tooltip-text" translatable="yes">Circle / Ellipse</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="draw_triangle_btn">
|
||||
<property name="label">Tri</property>
|
||||
<property name="tooltip-text" translatable="yes">Triangle</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="draw_star_btn">
|
||||
<property name="label">Star</property>
|
||||
<property name="tooltip-text" translatable="yes">Star</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<!-- Separator -->
|
||||
<child>
|
||||
<object class="GtkSeparator">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
</object>
|
||||
</child>
|
||||
<!-- Stroke color -->
|
||||
<child>
|
||||
<object class="GtkColorDialogButton" id="draw_color_btn">
|
||||
<property name="tooltip-text" translatable="yes">Stroke Color</property>
|
||||
<property name="dialog">
|
||||
<object class="GtkColorDialog" />
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<!-- Line width -->
|
||||
<child>
|
||||
<object class="GtkSpinButton" id="draw_line_width_btn">
|
||||
<property name="tooltip-text" translatable="yes">Line Width</property>
|
||||
<property name="adjustment">
|
||||
<object class="GtkAdjustment">
|
||||
<property name="lower">1</property>
|
||||
<property name="upper">50</property>
|
||||
<property name="value">3</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="page-increment">5</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="digits">0</property>
|
||||
<property name="numeric">True</property>
|
||||
<property name="width-chars">4</property>
|
||||
</object>
|
||||
</child>
|
||||
<!-- Separator -->
|
||||
<child>
|
||||
<object class="GtkSeparator">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
</object>
|
||||
</child>
|
||||
<!-- Undo last stroke -->
|
||||
<child>
|
||||
<object class="GtkButton">
|
||||
<property name="icon-name">edit-undo-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Undo Last Stroke (Ctrl+Z)</property>
|
||||
<property name="action-name">win.draw-undo</property>
|
||||
<style>
|
||||
<class name="flat" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<!-- Clear all -->
|
||||
<child>
|
||||
<object class="GtkButton" id="draw_clear_btn">
|
||||
<property name="icon-name">edit-clear-all-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Clear All Annotations</property>
|
||||
<style>
|
||||
<class name="flat" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<!-- Spacer -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="hexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<!-- Save annotated copy -->
|
||||
<child>
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">Save Copy…</property>
|
||||
<property name="tooltip-text" translatable="yes">Save annotated copy as PNG</property>
|
||||
<property name="action-name">win.draw-save-as</property>
|
||||
<style>
|
||||
<class name="suggested-action" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<property name="content">
|
||||
<object class="AdwMultiLayoutView">
|
||||
<binding name="layout-name">
|
||||
|
|
|
|||
|
|
@ -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])],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue