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
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue