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:
Lazzexus 2026-09-18 09:38:31 +02:00
parent fcf211b7da
commit 2ce31a1a30
7 changed files with 842 additions and 0 deletions

View file

@ -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)
}
}

View 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();
}

View file

@ -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

View file

@ -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: &gtk::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);
}
}
}

View file

@ -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}");
}
}
}
}

View file

@ -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">

View file

@ -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])],
_ => &[],
}
}