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

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