2024-07-19 14:30:06 +02:00
|
|
|
// Copyright (c) 2020-2024 Christopher Davis
|
2026-01-21 13:27:48 +01:00
|
|
|
// Copyright (c) 2022-2026 Sophie Herold
|
2023-04-19 20:02:01 +02:00
|
|
|
// Copyright (c) 2022 Maximiliano Sandoval R
|
2023-12-06 23:45:02 +01:00
|
|
|
// Copyright (c) 2023 Matteo Nardi
|
2023-07-16 22:47:10 +02:00
|
|
|
// Copyright (c) 2023 FineFindus
|
|
|
|
|
// Copyright (c) 2023 qwel
|
|
|
|
|
// Copyright (c) 2023 Huan Thieu Nguyen
|
2024-07-17 19:06:34 +02:00
|
|
|
// Copyright (c) 2024 Fina Wilke
|
2024-06-28 23:00:14 +02:00
|
|
|
// Copyright (c) 2024 DaPigGuy
|
2024-04-03 01:41:21 +02:00
|
|
|
// Copyright (c) 2024 James Frost
|
2026-01-02 16:25:56 +01:00
|
|
|
// Copyright (c) 2025 pieterd
|
2020-12-26 02:05:34 -08:00
|
|
|
//
|
|
|
|
|
// 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.
|
|
|
|
|
//
|
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
// GNU General Public License for more details.
|
|
|
|
|
//
|
|
|
|
|
// You should have received a copy of the GNU General Public License
|
|
|
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
//
|
|
|
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
|
|
2024-05-09 22:51:05 +02:00
|
|
|
mod actions;
|
2023-12-22 22:51:17 +01:00
|
|
|
mod controls;
|
|
|
|
|
|
2023-12-06 23:45:02 +01:00
|
|
|
use std::cell::{Cell, OnceCell, RefCell};
|
2024-07-19 15:02:29 +02:00
|
|
|
use std::marker::PhantomData;
|
2023-12-06 23:45:02 +01:00
|
|
|
use std::path::{Path, PathBuf};
|
2021-12-21 16:09:48 -08:00
|
|
|
|
2024-05-09 22:51:05 +02:00
|
|
|
use actions::*;
|
2023-01-31 21:56:44 +01:00
|
|
|
use adw::prelude::*;
|
2021-12-21 12:15:46 -08:00
|
|
|
use adw::subclass::prelude::*;
|
2024-09-14 00:48:46 +02:00
|
|
|
use adw::{glib, gtk};
|
|
|
|
|
use gtk::Widget;
|
2020-12-26 02:05:34 -08:00
|
|
|
|
2023-12-05 17:24:53 +01:00
|
|
|
use crate::application::LpApplication;
|
2020-12-26 02:05:34 -08:00
|
|
|
use crate::config;
|
2023-12-06 23:45:02 +01:00
|
|
|
use crate::deps::*;
|
2025-12-04 05:38:59 +01:00
|
|
|
use crate::util::Direction;
|
2023-12-06 23:45:02 +01:00
|
|
|
use crate::util::gettext::*;
|
2024-12-18 17:12:17 +01:00
|
|
|
use crate::util::root::ParentWindow;
|
2024-12-16 21:27:20 +01:00
|
|
|
use crate::widgets::window::ActionPartGlobal;
|
2024-12-19 23:25:50 +01:00
|
|
|
use crate::widgets::{LpDragOverlay, LpImage, LpImageView, LpPropertiesView};
|
2023-04-27 21:12:47 +02:00
|
|
|
|
2023-04-20 02:32:41 +02:00
|
|
|
/// Animation duration for showing overlay buttons in milliseconds
|
|
|
|
|
const SHOW_CONTROLS_ANIMATION_DURATION: u32 = 200;
|
|
|
|
|
/// Animation duration for hiding overlay buttons in milliseconds
|
|
|
|
|
const HIDE_CONTROLS_ANIMATION_DURATION: u32 = 1000;
|
2023-04-27 21:12:47 +02:00
|
|
|
/// Time of inactivity after which controls will be hidden in milliseconds
|
|
|
|
|
const HIDE_CONTROLS_IDLE_TIMEOUT: u64 = 3000;
|
2023-04-20 02:32:41 +02:00
|
|
|
|
2020-12-26 02:05:34 -08:00
|
|
|
mod imp {
|
2024-07-19 15:02:29 +02:00
|
|
|
|
2020-12-26 02:05:34 -08:00
|
|
|
use super::*;
|
2024-09-14 00:48:46 +02:00
|
|
|
#[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
|
2024-09-14 21:39:53 +02:00
|
|
|
#[properties(wrapper_type = super::LpImageWindow)]
|
|
|
|
|
#[template(file = "image_window.ui")]
|
|
|
|
|
pub struct LpImageWindow {
|
2024-12-21 03:09:15 +01:00
|
|
|
#[template_child]
|
|
|
|
|
pub(super) shortcut_controller: TemplateChild<gtk::ShortcutController>,
|
|
|
|
|
|
2023-04-26 21:17:03 +02:00
|
|
|
#[template_child]
|
|
|
|
|
pub(super) headerbar: TemplateChild<adw::HeaderBar>,
|
|
|
|
|
#[template_child]
|
|
|
|
|
pub(super) headerbar_events: TemplateChild<gtk::EventControllerMotion>,
|
2024-09-14 00:48:46 +02:00
|
|
|
|
|
|
|
|
#[template_child]
|
|
|
|
|
pub(super) fullscreen_button: TemplateChild<gtk::Button>,
|
|
|
|
|
|
2020-12-26 02:05:34 -08:00
|
|
|
#[template_child]
|
2023-03-14 18:56:35 +01:00
|
|
|
pub(super) properties_button: TemplateChild<gtk::ToggleButton>,
|
2022-11-23 19:12:19 +01:00
|
|
|
#[template_child]
|
2023-12-02 22:07:33 +01:00
|
|
|
pub(super) primary_menu: TemplateChild<gtk::MenuButton>,
|
2024-09-09 22:55:39 +02:00
|
|
|
|
2024-12-19 23:25:50 +01:00
|
|
|
#[template_child]
|
|
|
|
|
pub(super) toolbar_view: TemplateChild<adw::ToolbarView>,
|
|
|
|
|
|
2021-12-28 16:38:36 -08:00
|
|
|
#[template_child]
|
2023-03-14 18:56:35 +01:00
|
|
|
pub(super) stack: TemplateChild<gtk::Stack>,
|
2020-12-26 02:05:34 -08:00
|
|
|
#[template_child]
|
2023-03-14 18:56:35 +01:00
|
|
|
pub(super) status_page: TemplateChild<adw::StatusPage>,
|
2020-12-26 02:05:34 -08:00
|
|
|
#[template_child]
|
2023-03-14 18:56:35 +01:00
|
|
|
pub(super) image_view: TemplateChild<LpImageView>,
|
2021-12-27 15:03:31 -08:00
|
|
|
#[template_child]
|
2023-03-14 18:56:35 +01:00
|
|
|
pub(super) properties_view: TemplateChild<LpPropertiesView>,
|
2023-06-10 23:43:09 +02:00
|
|
|
|
|
|
|
|
#[template_child]
|
2023-06-25 21:13:56 +02:00
|
|
|
pub(super) drag_overlay: TemplateChild<LpDragOverlay>,
|
2022-07-26 11:31:11 -04:00
|
|
|
#[template_child]
|
2023-03-14 18:56:35 +01:00
|
|
|
pub(super) drop_target: TemplateChild<gtk::DropTarget>,
|
2023-05-21 16:17:48 +00:00
|
|
|
|
|
|
|
|
#[template_child]
|
|
|
|
|
pub(super) forward_click_gesture: TemplateChild<gtk::GestureClick>,
|
|
|
|
|
#[template_child]
|
|
|
|
|
pub(super) backward_click_gesture: TemplateChild<gtk::GestureClick>,
|
2025-12-08 16:15:13 +01:00
|
|
|
#[template_child]
|
|
|
|
|
pub(super) general_click_gesture: TemplateChild<gtk::GestureClick>,
|
2023-05-21 16:17:48 +00:00
|
|
|
|
2024-07-19 15:02:29 +02:00
|
|
|
#[property(get = Self::is_showing_image)]
|
|
|
|
|
pub(super) is_showing_image: PhantomData<bool>,
|
2024-06-13 00:24:45 -04:00
|
|
|
|
2023-04-26 21:17:03 +02:00
|
|
|
/// Motion controller for complete window
|
2023-05-01 18:06:38 +02:00
|
|
|
pub(super) motion_controller: gtk::EventControllerMotion,
|
|
|
|
|
pub(super) pointer_position: Cell<(f64, f64)>,
|
2023-04-20 02:32:41 +02:00
|
|
|
|
|
|
|
|
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>>,
|
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>
2026-09-18 09:38:31 +02:00
|
|
|
|
|
|
|
|
// 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>,
|
2026-09-19 09:52:32 +01:00
|
|
|
#[template_child]
|
|
|
|
|
pub(super) draw_auto_copy_btn: TemplateChild<gtk::ToggleButton>,
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
|
2021-06-27 15:28:35 -07:00
|
|
|
#[glib::object_subclass]
|
2024-09-14 21:39:53 +02:00
|
|
|
impl ObjectSubclass for LpImageWindow {
|
|
|
|
|
const NAME: &'static str = "LpImageWindow";
|
|
|
|
|
type Type = super::LpImageWindow;
|
2024-09-14 00:48:46 +02:00
|
|
|
type ParentType = adw::Bin;
|
|
|
|
|
|
|
|
|
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
|
|
|
|
obj.init_template();
|
|
|
|
|
}
|
2020-12-26 02:05:34 -08:00
|
|
|
|
|
|
|
|
fn class_init(klass: &mut Self::Class) {
|
2021-01-30 15:52:32 -08:00
|
|
|
Self::bind_template(klass);
|
2021-09-22 21:33:47 -07:00
|
|
|
|
2024-05-09 22:51:05 +02:00
|
|
|
// Set up actions
|
2024-12-21 03:09:15 +01:00
|
|
|
Action::init_actions(klass);
|
2021-01-13 23:05:45 -08:00
|
|
|
}
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
|
2024-06-13 00:24:45 -04:00
|
|
|
#[glib::derived_properties]
|
2024-12-18 17:12:17 +01:00
|
|
|
impl ObjectImpl for LpImageWindow {}
|
|
|
|
|
|
|
|
|
|
impl WidgetImpl for LpImageWindow {
|
|
|
|
|
fn root(&self) {
|
|
|
|
|
self.parent_root();
|
2020-12-26 02:05:34 -08:00
|
|
|
|
2023-12-02 22:07:33 +01:00
|
|
|
let obj = self.obj();
|
|
|
|
|
|
2024-12-21 03:09:15 +01:00
|
|
|
Action::add_bindings(&obj);
|
|
|
|
|
|
2024-09-14 00:48:46 +02:00
|
|
|
self.forward_click_gesture.connect_pressed(glib::clone!(
|
2024-06-18 00:37:38 +02:00
|
|
|
#[weak]
|
|
|
|
|
obj,
|
2025-12-08 16:15:13 +01:00
|
|
|
move |gesture, _, _, _| {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::trace!("Mouse forward button clicked");
|
2025-12-08 16:15:13 +01:00
|
|
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
2024-03-28 21:21:22 +00:00
|
|
|
obj.image_view().navigate(Direction::Forward, false);
|
2024-06-18 00:37:38 +02:00
|
|
|
}
|
|
|
|
|
));
|
2024-09-14 00:48:46 +02:00
|
|
|
self.backward_click_gesture.connect_pressed(glib::clone!(
|
2024-06-18 00:37:38 +02:00
|
|
|
#[weak]
|
|
|
|
|
obj,
|
2025-12-08 16:15:13 +01:00
|
|
|
move |gesture, _, _, _| {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::trace!("Mouse back button clicked");
|
2025-12-08 16:15:13 +01:00
|
|
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
2024-03-28 21:21:22 +00:00
|
|
|
obj.image_view().navigate(Direction::Back, false);
|
2024-06-18 00:37:38 +02:00
|
|
|
}
|
|
|
|
|
));
|
2025-12-08 16:15:13 +01:00
|
|
|
|
|
|
|
|
self.general_click_gesture.connect_pressed(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_, n_press, _, _| {
|
|
|
|
|
// TODO: We can't claim the gesture here, right? Otherwise we suppress the
|
|
|
|
|
// double-click
|
|
|
|
|
if n_press == 1 {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::trace!("General 1 click event");
|
2025-12-08 16:15:13 +01:00
|
|
|
obj.on_click();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
|
2024-09-14 00:48:46 +02:00
|
|
|
self.properties_button.connect_toggled(glib::clone!(
|
2024-06-18 00:37:38 +02:00
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_| {
|
2023-12-05 17:24:53 +01:00
|
|
|
obj.on_properties_button_toggled();
|
2024-06-18 00:37:38 +02:00
|
|
|
}
|
|
|
|
|
));
|
2023-05-21 16:17:48 +00:00
|
|
|
|
2023-11-30 22:20:57 +01:00
|
|
|
obj.on_current_page_changed();
|
2023-12-04 18:22:47 +01:00
|
|
|
obj.on_fullscreen_changed();
|
2023-11-30 22:20:57 +01:00
|
|
|
|
2024-06-18 00:37:38 +02:00
|
|
|
self.image_view.connect_current_page_notify(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_| obj.on_current_page_changed()
|
|
|
|
|
));
|
2023-03-02 23:14:14 -05:00
|
|
|
|
2024-09-14 00:48:46 +02:00
|
|
|
obj.window().connect_fullscreened_notify(glib::clone!(
|
2024-06-18 00:37:38 +02:00
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_| obj.on_fullscreen_changed()
|
|
|
|
|
));
|
2023-12-02 22:07:33 +01:00
|
|
|
|
2024-06-18 00:37:38 +02:00
|
|
|
self.motion_controller.connect_motion(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_, x, y| obj.on_motion((x, y))
|
|
|
|
|
));
|
|
|
|
|
self.motion_controller.connect_enter(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_, x, y| obj.on_motion((x, y))
|
|
|
|
|
));
|
2023-05-01 18:06:38 +02:00
|
|
|
obj.add_controller(self.motion_controller.clone());
|
|
|
|
|
|
2023-03-04 11:47:07 -05:00
|
|
|
let current_image_signals = self.image_view.current_image_signals();
|
2023-03-02 23:14:14 -05:00
|
|
|
|
2023-12-04 12:52:40 +01:00
|
|
|
current_image_signals.connect_local(
|
|
|
|
|
"metadata-changed",
|
|
|
|
|
true,
|
2024-06-18 00:37:38 +02:00
|
|
|
glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
2024-06-23 22:28:16 +02:00
|
|
|
#[upgrade_or_default]
|
2024-06-18 00:37:38 +02:00
|
|
|
move |_| {
|
|
|
|
|
obj.update_title();
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
),
|
2023-12-04 12:52:40 +01:00
|
|
|
);
|
|
|
|
|
|
2023-03-04 11:47:07 -05:00
|
|
|
current_image_signals.connect_closure(
|
2023-03-02 23:14:14 -05:00
|
|
|
"notify::best-fit",
|
|
|
|
|
false,
|
|
|
|
|
// `closure_local!` is similar to `clone`, but you use `@watch` instead of clone.
|
|
|
|
|
// `@watch` means that this signal will be disconnected when the watched object
|
|
|
|
|
// is dropped.
|
2024-06-18 00:37:38 +02:00
|
|
|
glib::closure_local!(
|
|
|
|
|
#[watch]
|
|
|
|
|
obj,
|
|
|
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
2024-07-30 18:24:39 +02:00
|
|
|
obj.update_image_actions_status();
|
2024-06-18 00:37:38 +02:00
|
|
|
}
|
|
|
|
|
),
|
2023-03-02 23:14:14 -05:00
|
|
|
);
|
|
|
|
|
|
2025-12-23 13:41:44 +01:00
|
|
|
current_image_signals.connect_closure(
|
|
|
|
|
"notify::is-min-zoom",
|
|
|
|
|
false,
|
|
|
|
|
glib::closure_local!(
|
|
|
|
|
#[watch]
|
|
|
|
|
obj,
|
|
|
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
|
|
|
obj.update_image_actions_status();
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2023-03-04 11:47:07 -05:00
|
|
|
current_image_signals.connect_closure(
|
2023-03-02 23:14:14 -05:00
|
|
|
"notify::is-max-zoom",
|
|
|
|
|
false,
|
2024-06-18 00:37:38 +02:00
|
|
|
glib::closure_local!(
|
|
|
|
|
#[watch]
|
|
|
|
|
obj,
|
|
|
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
2024-07-30 18:24:39 +02:00
|
|
|
obj.update_image_actions_status();
|
2024-06-18 00:37:38 +02:00
|
|
|
}
|
|
|
|
|
),
|
2023-03-02 23:14:14 -05:00
|
|
|
);
|
2022-11-28 03:23:06 +01:00
|
|
|
|
2023-03-04 11:47:07 -05:00
|
|
|
current_image_signals.connect_closure(
|
2023-11-29 01:49:14 +01:00
|
|
|
"notify::image-size-available",
|
2023-03-04 11:47:07 -05:00
|
|
|
false,
|
2024-06-18 00:37:38 +02:00
|
|
|
glib::closure_local!(
|
|
|
|
|
#[watch]
|
|
|
|
|
obj,
|
|
|
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
|
|
|
obj.image_size_available();
|
|
|
|
|
}
|
|
|
|
|
),
|
2023-03-04 11:47:07 -05:00
|
|
|
);
|
|
|
|
|
|
2024-07-30 18:24:39 +02:00
|
|
|
current_image_signals.connect_closure(
|
|
|
|
|
"notify::is-loaded",
|
|
|
|
|
false,
|
|
|
|
|
glib::closure_local!(
|
|
|
|
|
#[watch]
|
|
|
|
|
obj,
|
|
|
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
|
|
|
obj.update_image_actions_status();
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
decoder: Use image-rs, libheif-rs, librsvg
This adds proper support for a lot of image formats including SVGs.
It will be the basis for many features to come. The technical core
is an asynchronous renderer that can arrange textures in smaller tiles
which might be generated on demand.
Closes #34, #40, #41, #49
Basis for fixing the following issues #7, #27, #36, #43, #68, #71
2023-03-04 03:14:52 +01:00
|
|
|
current_image_signals.connect_closure(
|
2023-03-04 11:47:07 -05:00
|
|
|
"notify::error",
|
|
|
|
|
false,
|
2024-06-18 00:37:38 +02:00
|
|
|
glib::closure_local!(
|
|
|
|
|
#[watch]
|
|
|
|
|
obj,
|
|
|
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
|
|
|
obj.image_error();
|
2024-07-30 18:24:39 +02:00
|
|
|
obj.update_image_actions_status();
|
2024-06-18 00:37:38 +02:00
|
|
|
}
|
|
|
|
|
),
|
2023-01-23 01:10:15 +01:00
|
|
|
);
|
|
|
|
|
|
2024-12-19 23:25:50 +01:00
|
|
|
obj.connect_is_showing_image_notify(|obj| {
|
|
|
|
|
obj.update_headerbar_style();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
self.properties_button.connect_active_notify(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_| {
|
|
|
|
|
obj.update_headerbar_style();
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
|
2022-12-06 18:24:32 +01:00
|
|
|
// action win.previous status
|
2024-06-18 00:37:38 +02:00
|
|
|
self.image_view
|
|
|
|
|
.connect_is_previous_available_notify(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_| {
|
|
|
|
|
obj.action_set_enabled(
|
2024-07-30 18:24:39 +02:00
|
|
|
&Action::Previous,
|
2024-06-18 00:37:38 +02:00
|
|
|
obj.imp().image_view.is_previous_available(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
));
|
2022-12-06 18:24:32 +01:00
|
|
|
|
|
|
|
|
// action win.next status
|
2023-11-30 22:23:05 +01:00
|
|
|
self.image_view
|
2024-06-18 00:37:38 +02:00
|
|
|
.connect_is_next_available_notify(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_| {
|
|
|
|
|
obj.action_set_enabled(
|
2024-07-30 18:24:39 +02:00
|
|
|
&Action::Next,
|
2024-06-18 00:37:38 +02:00
|
|
|
obj.imp().image_view.is_next_available(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
));
|
2022-12-06 18:24:32 +01:00
|
|
|
|
2023-04-20 02:32:41 +02:00
|
|
|
// Make widgets visible when the focus moves
|
|
|
|
|
obj.connect_move_focus(|obj, _| {
|
|
|
|
|
obj.show_controls();
|
2023-11-30 22:27:21 +01:00
|
|
|
obj.schedule_hide_controls();
|
2023-04-20 02:32:41 +02:00
|
|
|
});
|
|
|
|
|
|
2024-09-09 22:55:39 +02:00
|
|
|
obj.image_view()
|
|
|
|
|
.zoom_menu_button()
|
|
|
|
|
.connect_active_notify(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |button| {
|
|
|
|
|
if button.is_active() {
|
|
|
|
|
// Show overlay controls when zoom popover in them gets opened
|
|
|
|
|
obj.show_controls();
|
|
|
|
|
} else {
|
|
|
|
|
// Schedule hide overlay controls when zoom popover gets closed
|
|
|
|
|
obj.schedule_hide_controls();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
|
2024-05-09 22:51:05 +02:00
|
|
|
// Activate global shortcuts only if no dialog is open
|
2024-09-14 00:48:46 +02:00
|
|
|
obj.window().connect_visible_dialog_notify(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_| obj.update_accel_status()
|
|
|
|
|
));
|
|
|
|
|
obj.window().connect_is_active_notify(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |_| obj.update_accel_status()
|
|
|
|
|
));
|
2024-12-16 21:27:20 +01:00
|
|
|
obj.connect_unmap(|win| win.update_accel_status());
|
|
|
|
|
obj.connect_map(|win| win.update_accel_status());
|
2024-05-09 22:51:05 +02:00
|
|
|
|
2020-12-26 02:05:34 -08:00
|
|
|
self.status_page
|
|
|
|
|
.set_icon_name(Some(&format!("{}-symbolic", config::APP_ID)));
|
|
|
|
|
|
2022-07-26 12:07:08 -04:00
|
|
|
self.drop_target.set_types(&[gdk::FileList::static_type()]);
|
|
|
|
|
|
2024-09-14 00:48:46 +02:00
|
|
|
self.drop_target.connect_accept(glib::clone!(
|
2024-06-18 00:37:38 +02:00
|
|
|
#[weak]
|
|
|
|
|
obj,
|
2024-06-23 22:28:16 +02:00
|
|
|
#[upgrade_or]
|
|
|
|
|
false,
|
2024-06-18 00:37:38 +02:00
|
|
|
move |_drop_target, drop| {
|
2023-09-05 23:07:18 +02:00
|
|
|
// Only accept drops from external sources or different windows
|
2024-06-18 00:37:38 +02:00
|
|
|
let different_source = drop.drag().is_none()
|
|
|
|
|
|| drop.drag() != obj.image_view().drag_source().drag();
|
2023-09-05 23:07:18 +02:00
|
|
|
// We have to do this manually since we are overwriting the default handler
|
|
|
|
|
let correct_format = drop.formats().contains_type(gdk::FileList::static_type());
|
|
|
|
|
|
|
|
|
|
different_source && correct_format
|
2024-06-18 00:37:38 +02:00
|
|
|
}
|
|
|
|
|
));
|
2023-06-10 23:43:09 +02:00
|
|
|
|
2020-12-26 02:05:34 -08:00
|
|
|
// For callbacks, you will want to reference the GTK docs on
|
|
|
|
|
// the relevant signal to see which parameters you need.
|
2022-07-26 12:07:08 -04:00
|
|
|
// In this case, we need only need the GValue,
|
|
|
|
|
// so we name it `value` then use `_` for the other spots.
|
2024-09-14 00:48:46 +02:00
|
|
|
self.drop_target.connect_drop(glib::clone!(
|
2024-06-18 00:37:38 +02:00
|
|
|
#[weak]
|
|
|
|
|
obj,
|
2024-06-23 22:28:16 +02:00
|
|
|
#[upgrade_or]
|
|
|
|
|
false,
|
2024-06-18 00:37:38 +02:00
|
|
|
move |_, value, _, _| {
|
|
|
|
|
// Here we use a GValue, which is a dynamic object that can hold different
|
|
|
|
|
// types, e.g. strings, numbers, or in this case objects. In
|
|
|
|
|
// order to get the GdkFileList from the GValue, we need to
|
|
|
|
|
// use the `get()` method.
|
2021-12-27 15:03:31 -08:00
|
|
|
//
|
2024-06-18 00:37:38 +02:00
|
|
|
// We've added type annotations here, and written it as `let list: gdk::FileList
|
|
|
|
|
// = ...`, but you might also see places where type
|
|
|
|
|
// arguments are used. This line could have been written as
|
|
|
|
|
// `let list = value.get::<gdk::FileList>().unwrap()`.
|
2023-05-30 12:52:59 +00:00
|
|
|
let files = match value.get::<gdk::FileList>() {
|
2023-04-18 01:52:06 +02:00
|
|
|
Ok(list) => list.files(),
|
2022-11-04 19:45:28 +01:00
|
|
|
Err(err) => {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Issue with drop value: {err}");
|
2022-11-04 19:45:28 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
2021-12-27 15:03:31 -08:00
|
|
|
|
2023-05-30 12:52:59 +00:00
|
|
|
if !files.is_empty() {
|
2023-04-18 01:52:06 +02:00
|
|
|
obj.image_view().set_images_from_files(files);
|
2021-12-27 15:03:31 -08:00
|
|
|
} else {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Dropped FileList was empty");
|
2023-04-18 01:52:06 +02:00
|
|
|
return false;
|
2021-12-27 15:03:31 -08:00
|
|
|
}
|
|
|
|
|
|
2023-04-18 01:52:06 +02:00
|
|
|
// Maybe one day this will actually work
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::debug!("Trying to focus window after drag and drop");
|
2025-12-09 21:55:07 +01:00
|
|
|
obj.window_inspect(|w| w.present());
|
2023-04-18 01:52:06 +02:00
|
|
|
|
2021-12-27 15:03:31 -08:00
|
|
|
true
|
2024-06-18 00:37:38 +02:00
|
|
|
}
|
|
|
|
|
));
|
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>
2026-09-18 09:38:31 +02:00
|
|
|
|
|
|
|
|
// ── 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));
|
|
|
|
|
|
2026-09-19 09:52:32 +01:00
|
|
|
// Persist automatic clipboard updates as an app preference.
|
|
|
|
|
let settings = LpApplication::default().settings();
|
|
|
|
|
imp.draw_auto_copy_btn
|
|
|
|
|
.set_active(settings.boolean("auto-copy-annotations"));
|
|
|
|
|
imp.draw_auto_copy_btn.connect_toggled(move |btn| {
|
|
|
|
|
if let Err(err) =
|
|
|
|
|
settings.set_boolean("auto-copy-annotations", btn.is_active())
|
|
|
|
|
{
|
|
|
|
|
tracing::warn!("Failed to save auto-copy setting: {err}");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 13:23:07 +01:00
|
|
|
// Remember annotation mode and apply it to each newly opened image.
|
|
|
|
|
let draw_mode_settings = LpApplication::default().settings();
|
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>
2026-09-18 09:38:31 +02:00
|
|
|
imp.draw_mode_button.connect_toggled(glib::clone!(
|
|
|
|
|
#[weak]
|
|
|
|
|
obj,
|
|
|
|
|
move |btn| {
|
|
|
|
|
let active = btn.is_active();
|
2026-09-20 13:23:07 +01:00
|
|
|
if let Err(err) = draw_mode_settings.set_boolean("annotation-mode", active)
|
|
|
|
|
{
|
|
|
|
|
tracing::warn!("Failed to save annotation mode setting: {err}");
|
|
|
|
|
}
|
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>
2026-09-18 09:38:31 +02:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
));
|
2026-09-20 13:23:07 +01:00
|
|
|
imp.draw_mode_button.set_active(
|
|
|
|
|
LpApplication::default()
|
|
|
|
|
.settings()
|
|
|
|
|
.boolean("annotation-mode"),
|
|
|
|
|
);
|
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>
2026-09-18 09:38:31 +02:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
}
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-14 21:39:53 +02:00
|
|
|
impl BinImpl for LpImageWindow {}
|
2024-07-19 15:02:29 +02:00
|
|
|
|
2024-09-14 21:39:53 +02:00
|
|
|
impl LpImageWindow {
|
2024-07-19 15:02:29 +02:00
|
|
|
fn is_showing_image(&self) -> bool {
|
2025-01-13 15:02:01 +01:00
|
|
|
self.image_view.try_get().is_some_and(|image_view| {
|
2024-07-19 15:02:29 +02:00
|
|
|
image_view
|
|
|
|
|
.current_page()
|
2025-12-21 15:16:11 +01:00
|
|
|
.is_some_and(|page| !page.image().is_error())
|
2024-07-19 15:02:29 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
glib::wrapper! {
|
2024-09-14 21:39:53 +02:00
|
|
|
pub struct LpImageWindow(ObjectSubclass<imp::LpImageWindow>)
|
2025-08-22 15:03:55 +02:00
|
|
|
@extends gtk::Widget, adw::Bin,
|
|
|
|
|
@implements gtk::Buildable, gtk::Accessible, gtk::ConstraintTarget;
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
|
2024-09-14 21:39:53 +02:00
|
|
|
impl LpImageWindow {
|
2024-01-04 14:33:23 +01:00
|
|
|
fn zoom_out_cursor(&self) {
|
|
|
|
|
if let Some(image) = self.imp().image_view.current_image() {
|
|
|
|
|
image.zoom_out_cursor();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn zoom_out_center(&self) {
|
|
|
|
|
if let Some(image) = self.imp().image_view.current_image() {
|
|
|
|
|
image.zoom_out_center();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn zoom_in_cursor(&self) {
|
|
|
|
|
if let Some(image) = self.imp().image_view.current_image() {
|
|
|
|
|
image.zoom_in_cursor();
|
|
|
|
|
}
|
2022-11-10 19:39:23 +01:00
|
|
|
}
|
|
|
|
|
|
2024-01-04 14:33:23 +01:00
|
|
|
fn zoom_in_center(&self) {
|
|
|
|
|
if let Some(image) = self.imp().image_view.current_image() {
|
|
|
|
|
image.zoom_in_center();
|
|
|
|
|
}
|
2022-11-10 19:39:23 +01:00
|
|
|
}
|
|
|
|
|
|
2023-04-13 04:52:14 +02:00
|
|
|
fn zoom_to_exact(&self, level: f64) {
|
2024-09-09 22:55:39 +02:00
|
|
|
if let Some(image) = self.image_view().current_image() {
|
2023-04-13 04:52:14 +02:00
|
|
|
image.zoom_to_exact(level);
|
|
|
|
|
}
|
2022-11-10 19:39:23 +01:00
|
|
|
}
|
|
|
|
|
|
2022-11-23 19:12:19 +01:00
|
|
|
fn zoom_best_fit(&self) {
|
|
|
|
|
if let Some(page) = self.imp().image_view.current_page() {
|
|
|
|
|
page.image().zoom_best_fit();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-16 21:27:20 +01:00
|
|
|
pub fn pan(&self, direction: >k::PanDirection) {
|
2023-01-30 19:07:17 +01:00
|
|
|
if let Some(image) = self.imp().image_view.current_image() {
|
|
|
|
|
image.pan(direction);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-12 11:25:12 -05:00
|
|
|
async fn pick_file(&self) {
|
2023-08-02 14:27:23 +02:00
|
|
|
let filter_list = gio::ListStore::new::<gtk::FileFilter>();
|
2020-12-26 02:05:34 -08:00
|
|
|
|
2023-04-21 16:53:12 +02:00
|
|
|
let filter_supported_formats = gtk::FileFilter::new();
|
|
|
|
|
filter_supported_formats.set_name(Some(&gettext("Supported image formats")));
|
2024-07-18 01:51:51 +02:00
|
|
|
for mime_type in glycin::Loader::supported_mime_types().await {
|
2024-06-14 17:46:33 +02:00
|
|
|
filter_supported_formats.add_mime_type(mime_type.as_str());
|
2023-06-20 20:58:01 +02:00
|
|
|
}
|
2023-04-21 16:53:12 +02:00
|
|
|
|
|
|
|
|
let filter_all_files = gtk::FileFilter::new();
|
|
|
|
|
filter_all_files.set_name(Some(&gettext("All files")));
|
|
|
|
|
filter_all_files.add_pattern("*");
|
|
|
|
|
|
|
|
|
|
filter_list.append(&filter_supported_formats);
|
|
|
|
|
filter_list.append(&filter_all_files);
|
2020-12-26 02:05:34 -08:00
|
|
|
|
2022-12-12 11:25:12 -05:00
|
|
|
let chooser = gtk::FileDialog::builder()
|
2023-04-11 00:02:28 +02:00
|
|
|
.title(gettext("Open Image"))
|
2023-04-21 16:53:12 +02:00
|
|
|
.filters(&filter_list)
|
|
|
|
|
.default_filter(&filter_supported_formats)
|
2022-12-12 11:25:12 -05:00
|
|
|
.modal(true)
|
|
|
|
|
.build();
|
2020-12-26 02:05:34 -08:00
|
|
|
|
2023-08-27 22:20:13 +02:00
|
|
|
chooser.set_initial_folder(
|
|
|
|
|
self.image_view()
|
|
|
|
|
.current_image()
|
|
|
|
|
.and_then(|x| x.file())
|
|
|
|
|
.and_then(|x| x.parent())
|
|
|
|
|
.as_ref(),
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-09 21:55:07 +01:00
|
|
|
if let Ok(selected) = chooser
|
|
|
|
|
.open_multiple_future(self.try_window().as_ref())
|
|
|
|
|
.await
|
|
|
|
|
{
|
2023-05-21 23:41:22 +02:00
|
|
|
let images: Vec<_> = selected
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|files| {
|
|
|
|
|
files
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|object| object.downcast::<gio::File>().ok())
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
self.image_view().set_images_from_files(images);
|
2023-01-23 01:10:15 +01:00
|
|
|
} else {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::debug!("File dialog canceled or file not readable");
|
2022-12-12 11:25:12 -05:00
|
|
|
}
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
|
2023-01-15 19:18:16 -05:00
|
|
|
async fn open_with(&self) {
|
2021-12-29 14:02:48 -08:00
|
|
|
let imp = self.imp();
|
2021-12-29 17:43:58 -08:00
|
|
|
|
2023-04-12 01:11:59 +02:00
|
|
|
if let Some(ref file) = imp.image_view.current_file() {
|
2023-01-15 19:18:16 -05:00
|
|
|
let launcher = gtk::FileLauncher::new(Some(file));
|
2023-08-15 17:18:57 +02:00
|
|
|
launcher.set_always_ask(true);
|
2026-01-29 19:04:26 +01:00
|
|
|
if let Err(e) = launcher.launch_future(self.try_window().as_ref()).await
|
|
|
|
|
&& !e.matches(gtk::DialogError::Dismissed)
|
|
|
|
|
{
|
|
|
|
|
tracing::error!("Could not open image in external program: {}", e);
|
2023-01-15 19:18:16 -05:00
|
|
|
}
|
2020-12-26 02:05:34 -08:00
|
|
|
} else {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Could not load a path for the current image.")
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-05 00:57:18 +01:00
|
|
|
fn rotate_image(&self, angle: f64) {
|
|
|
|
|
self.imp().image_view.rotate_image(angle)
|
2022-11-03 17:40:06 +01:00
|
|
|
}
|
|
|
|
|
|
2023-01-22 12:18:43 -05:00
|
|
|
async fn set_background(&self) {
|
2021-12-29 14:02:48 -08:00
|
|
|
let imp = self.imp();
|
2020-12-26 02:05:34 -08:00
|
|
|
|
2023-01-22 12:18:43 -05:00
|
|
|
if let Err(e) = imp.image_view.set_background().await {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Failed to set background: {}", e);
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print(&self) {
|
2021-12-29 14:02:48 -08:00
|
|
|
let imp = self.imp();
|
2020-12-26 02:05:34 -08:00
|
|
|
|
2021-01-13 23:16:02 -08:00
|
|
|
if let Err(e) = imp.image_view.print() {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Failed to print file: {}", e);
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-30 01:20:13 +01:00
|
|
|
/// Returns true if some text in metadata is currently selected
|
2026-04-26 23:19:20 +02:00
|
|
|
pub fn has_metadata_selected(&self) -> bool {
|
2025-12-09 21:55:07 +01:00
|
|
|
let Some(window) = self.try_window() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-29 19:04:26 +01:00
|
|
|
if let Some(focus_widget) = GtkWindowExt::focus(&window)
|
|
|
|
|
&& focus_widget.is_ancestor(&*self.imp().properties_view)
|
|
|
|
|
&& let Ok(label) = focus_widget.downcast::<gtk::Label>()
|
|
|
|
|
{
|
|
|
|
|
return label.selection_bounds().is_some();
|
2023-11-30 01:20:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Copy image to clipboard or metadata text if selected
|
2026-04-26 23:19:20 +02:00
|
|
|
pub fn copy_image(&self) {
|
2022-01-08 14:02:16 +01:00
|
|
|
let imp = self.imp();
|
|
|
|
|
|
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>
2026-09-18 09:38:31 +02:00
|
|
|
// 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}");
|
2026-09-19 09:59:02 +01:00
|
|
|
} else if !LpApplication::default()
|
|
|
|
|
.settings()
|
|
|
|
|
.boolean("auto-copy-annotations")
|
|
|
|
|
{
|
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>
2026-09-18 09:38:31 +02:00
|
|
|
self.window_show_toast(
|
|
|
|
|
&gettext("Image copied to clipboard"),
|
|
|
|
|
adw::ToastPriority::High,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-08 14:02:16 +01:00
|
|
|
if let Err(e) = imp.image_view.copy() {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Failed to copy to clipboard: {}", e);
|
2026-09-19 09:59:02 +01:00
|
|
|
} else if !LpApplication::default()
|
|
|
|
|
.settings()
|
|
|
|
|
.boolean("auto-copy-annotations")
|
|
|
|
|
{
|
2025-12-09 15:55:30 +01:00
|
|
|
self.window_show_toast(
|
2024-12-18 01:29:14 +01:00
|
|
|
&gettext("Image copied to clipboard"),
|
2023-04-11 00:02:28 +02:00
|
|
|
adw::ToastPriority::High,
|
|
|
|
|
);
|
2022-01-08 14:02:16 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-31 21:56:44 +01:00
|
|
|
async fn trash(&self) {
|
|
|
|
|
let image_view = self.image_view();
|
2023-07-06 23:48:56 +02:00
|
|
|
let (Some(file), Some(path)) = (
|
|
|
|
|
image_view.current_file(),
|
|
|
|
|
image_view.current_file().and_then(|x| x.path()),
|
|
|
|
|
) else {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("No file to trash");
|
2023-07-06 23:48:56 +02:00
|
|
|
return;
|
|
|
|
|
};
|
2023-01-31 21:56:44 +01:00
|
|
|
|
|
|
|
|
let result = file.trash_future(glib::Priority::default()).await;
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
let toast = adw::Toast::builder()
|
2023-04-11 00:02:28 +02:00
|
|
|
.title(gettext("Image moved to trash"))
|
|
|
|
|
.button_label(gettext("Undo"))
|
2023-06-20 23:37:30 +02:00
|
|
|
.priority(adw::ToastPriority::High)
|
2023-01-31 21:56:44 +01:00
|
|
|
.build();
|
2024-06-18 00:37:38 +02:00
|
|
|
toast.connect_button_clicked(glib::clone!(
|
2024-09-14 00:48:46 +02:00
|
|
|
#[weak(rename_to = obj)]
|
2024-06-18 00:37:38 +02:00
|
|
|
self,
|
|
|
|
|
move |_| {
|
|
|
|
|
let path = path.clone();
|
|
|
|
|
glib::spawn_future_local(async move {
|
2024-09-14 00:48:46 +02:00
|
|
|
obj.image_view()
|
2024-06-18 00:37:38 +02:00
|
|
|
.set_trash_restore(Some(gio::File::for_path(&path)));
|
|
|
|
|
let result = crate::util::untrash(&path).await;
|
|
|
|
|
if let Err(err) = result {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Failed to untrash {path:?}: {err}");
|
2025-12-09 15:55:30 +01:00
|
|
|
obj.window_show_toast(
|
2024-12-18 01:29:14 +01:00
|
|
|
&gettext("Failed to restore image from trash"),
|
2024-06-18 00:37:38 +02:00
|
|
|
adw::ToastPriority::High,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
));
|
2025-12-09 15:55:30 +01:00
|
|
|
self.window_add_toast(toast);
|
2023-01-31 21:56:44 +01:00
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
if Some(gio::IOErrorEnum::NotSupported) == err.kind::<gio::IOErrorEnum>() {
|
2024-02-09 21:19:35 +01:00
|
|
|
self.delete_future(&path).await;
|
2023-01-31 21:56:44 +01:00
|
|
|
} else {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Failed to delete file {path:?}: {err}");
|
2025-12-09 15:55:30 +01:00
|
|
|
self.window_show_toast(
|
2024-12-18 01:29:14 +01:00
|
|
|
&gettext("Failed to move image to trash"),
|
2023-01-31 21:56:44 +01:00
|
|
|
adw::ToastPriority::Normal,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-09 21:19:35 +01:00
|
|
|
/// Delete action called
|
|
|
|
|
async fn delete(&self) {
|
|
|
|
|
let image_view = self.image_view();
|
|
|
|
|
let Some(path) = image_view.current_file().and_then(|x| x.path()) else {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("No file to delete");
|
2024-02-09 21:19:35 +01:00
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
self.delete_future(&path).await;
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-31 21:56:44 +01:00
|
|
|
/// Permanently delete image
|
|
|
|
|
///
|
2024-02-09 21:19:35 +01:00
|
|
|
/// Fallback for when trash not available or explicit call with shortcut
|
|
|
|
|
async fn delete_future(&self, path: &Path) {
|
2024-06-11 23:32:45 -07:00
|
|
|
let dialog = adw::AlertDialog::builder()
|
2023-04-11 00:02:28 +02:00
|
|
|
.heading(gettext("Permanently Delete Image?"))
|
|
|
|
|
.body(gettext_f(
|
2024-02-09 21:19:35 +01:00
|
|
|
"After deleting the image “{}” it will be permanently lost.",
|
2024-01-01 22:41:56 +01:00
|
|
|
[PathBuf::from(&path.file_name().unwrap_or_default())
|
2023-01-31 21:56:44 +01:00
|
|
|
.display()
|
|
|
|
|
.to_string()],
|
|
|
|
|
))
|
|
|
|
|
.build();
|
|
|
|
|
|
2023-04-11 00:02:28 +02:00
|
|
|
dialog.add_responses(&[
|
|
|
|
|
("cancel", &gettext("Cancel")),
|
|
|
|
|
("delete", &gettext("Delete")),
|
|
|
|
|
]);
|
2023-01-31 21:56:44 +01:00
|
|
|
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
|
|
|
|
|
2025-12-04 05:38:59 +01:00
|
|
|
if "delete" == dialog.choose_future(Some(self)).await {
|
2023-01-31 21:56:44 +01:00
|
|
|
let file = gio::File::for_path(path);
|
|
|
|
|
let result = file.delete_future(glib::Priority::default()).await;
|
|
|
|
|
|
|
|
|
|
if let Err(err) = result {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("Failed to delete file {path:?}: {err}");
|
2025-12-09 15:55:30 +01:00
|
|
|
self.window_show_toast(
|
2024-12-18 01:29:14 +01:00
|
|
|
&gettext("Failed to delete image"),
|
2023-04-11 00:02:28 +02:00
|
|
|
adw::ToastPriority::Normal,
|
|
|
|
|
);
|
2023-01-31 21:56:44 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-18 01:52:06 +02:00
|
|
|
pub fn image_view(&self) -> LpImageView {
|
2023-01-31 21:56:44 +01:00
|
|
|
self.imp().image_view.clone()
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-26 21:17:03 +02:00
|
|
|
pub fn headerbar(&self) -> adw::HeaderBar {
|
|
|
|
|
self.imp().headerbar.clone()
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-30 18:24:39 +02:00
|
|
|
/// Set status for actions that are related to the active image
|
|
|
|
|
fn update_image_actions_status(&self) {
|
|
|
|
|
// Actions that are enabled if the image is shown
|
|
|
|
|
const ACTIONS_SHOWN: &[Action] = &[
|
|
|
|
|
Action::SetBackground,
|
|
|
|
|
Action::Print,
|
|
|
|
|
Action::RotateCw,
|
|
|
|
|
Action::RotateCcw,
|
|
|
|
|
Action::ZoomBestFit,
|
2024-09-09 22:55:39 +02:00
|
|
|
Action::ZoomToExact100,
|
|
|
|
|
Action::ZoomToExact200,
|
|
|
|
|
Action::ZoomToExact300,
|
2023-11-30 22:32:29 +01:00
|
|
|
];
|
|
|
|
|
|
2024-07-30 18:24:39 +02:00
|
|
|
let enabled_shown = self
|
|
|
|
|
.imp()
|
|
|
|
|
.image_view
|
|
|
|
|
.current_page()
|
2025-12-21 15:16:11 +01:00
|
|
|
.is_some_and(|x| x.image().is_loaded() && !x.image().is_error());
|
2024-07-30 18:24:39 +02:00
|
|
|
|
|
|
|
|
for action in ACTIONS_SHOWN {
|
|
|
|
|
self.action_set_enabled(action, enabled_shown);
|
2023-11-30 22:32:29 +01:00
|
|
|
}
|
2024-07-29 04:59:10 +02:00
|
|
|
|
2024-07-30 18:24:39 +02:00
|
|
|
// Zoom specific actions
|
|
|
|
|
|
|
|
|
|
let can_zoom_out = self
|
|
|
|
|
.image_view()
|
|
|
|
|
.current_image()
|
2025-12-23 13:41:44 +01:00
|
|
|
.is_some_and(|image| !image.is_best_fit() && !image.is_min_zoom());
|
2024-07-30 18:24:39 +02:00
|
|
|
let can_zoom_in = self
|
|
|
|
|
.image_view()
|
|
|
|
|
.current_image()
|
|
|
|
|
.is_some_and(|image| !image.is_max_zoom());
|
|
|
|
|
|
|
|
|
|
self.action_set_enabled(&Action::ZoomOutCursor, can_zoom_out && enabled_shown);
|
|
|
|
|
self.action_set_enabled(&Action::ZoomOutCenter, can_zoom_out && enabled_shown);
|
|
|
|
|
self.action_set_enabled(&Action::ZoomInCursor, can_zoom_in && enabled_shown);
|
|
|
|
|
self.action_set_enabled(&Action::ZoomInCenter, can_zoom_in && enabled_shown);
|
|
|
|
|
|
2024-09-09 22:55:39 +02:00
|
|
|
self.image_view().set_zoom_toggle_state(
|
|
|
|
|
self.image_view()
|
|
|
|
|
.current_image()
|
|
|
|
|
.is_some_and(|image| !image.is_best_fit()),
|
|
|
|
|
);
|
|
|
|
|
|
2025-01-29 18:04:07 +01:00
|
|
|
self.image_view().zoom_toggle().set_sensitive(enabled_shown);
|
|
|
|
|
|
|
|
|
|
self.image_view()
|
|
|
|
|
.zoom_menu_button()
|
|
|
|
|
.set_sensitive(enabled_shown);
|
|
|
|
|
|
2024-08-05 23:28:01 +02:00
|
|
|
// Actions that are available if there is an current image, even if it's not
|
|
|
|
|
// shown
|
2024-07-30 18:24:39 +02:00
|
|
|
const ACTIONS_CURRENT: &[Action] = &[
|
|
|
|
|
Action::OpenWith,
|
|
|
|
|
Action::CopyImage,
|
|
|
|
|
Action::ToggleProperties,
|
|
|
|
|
Action::Reload,
|
|
|
|
|
Action::ToggleFullscreen,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let enabled_current = self.imp().image_view.current_page().is_some();
|
|
|
|
|
|
|
|
|
|
for action in ACTIONS_CURRENT {
|
|
|
|
|
self.action_set_enabled(action, enabled_current);
|
|
|
|
|
}
|
2024-12-21 01:16:24 +01:00
|
|
|
|
|
|
|
|
// Editing related actions
|
2025-01-29 18:04:07 +01:00
|
|
|
let enable_editing = enabled_shown
|
|
|
|
|
&& self
|
|
|
|
|
.imp()
|
|
|
|
|
.image_view
|
|
|
|
|
.current_image()
|
|
|
|
|
.is_some_and(|x| x.editable());
|
2024-12-21 01:16:24 +01:00
|
|
|
self.action_set_enabled(&Action::Edit, enable_editing);
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|
2021-03-02 16:39:03 -08:00
|
|
|
|
2023-12-19 15:25:15 +01:00
|
|
|
/// Handles change in image and availability of images
|
2023-11-30 22:20:57 +01:00
|
|
|
fn on_current_page_changed(&self) {
|
2023-01-23 01:10:15 +01:00
|
|
|
let imp = self.imp();
|
2023-12-19 15:25:15 +01:00
|
|
|
let was_showing_image =
|
|
|
|
|
imp.stack.visible_child().as_ref() == Some(imp.image_view.upcast_ref::<Widget>());
|
2023-12-02 22:07:33 +01:00
|
|
|
let current_page = imp.image_view.current_page();
|
|
|
|
|
|
|
|
|
|
// HeaderBar style
|
2024-07-19 14:30:06 +02:00
|
|
|
self.notify_is_showing_image();
|
2023-01-23 01:10:15 +01:00
|
|
|
|
2023-11-30 22:20:57 +01:00
|
|
|
// Window title
|
2023-12-04 12:52:40 +01:00
|
|
|
self.update_title();
|
2023-11-30 22:20:57 +01:00
|
|
|
|
2023-12-02 22:07:33 +01:00
|
|
|
// Properties view
|
2024-12-21 01:16:24 +01:00
|
|
|
let current_image: Option<LpImage> = current_page.as_ref().map(|x| x.image());
|
2023-12-02 22:07:33 +01:00
|
|
|
imp.properties_view.set_image(current_image.as_ref());
|
2026-09-20 13:23:07 +01:00
|
|
|
if let Some(image) = current_image.as_ref() {
|
|
|
|
|
image.set_drawing_mode(imp.draw_mode_button.is_active());
|
|
|
|
|
}
|
2023-12-02 22:07:33 +01:00
|
|
|
|
|
|
|
|
let has_image = current_page.is_some();
|
2023-11-30 22:20:57 +01:00
|
|
|
|
2024-07-30 18:24:39 +02:00
|
|
|
self.update_image_actions_status();
|
2023-06-23 19:42:30 +00:00
|
|
|
self.action_set_enabled(
|
2024-07-30 18:24:39 +02:00
|
|
|
&Action::Trash,
|
2023-06-23 19:42:30 +00:00
|
|
|
imp.image_view
|
|
|
|
|
.current_file()
|
2023-08-27 22:05:46 +02:00
|
|
|
.is_some_and(|file| file.path().is_some()),
|
2023-06-23 19:42:30 +00:00
|
|
|
);
|
2023-04-20 21:40:55 +02:00
|
|
|
|
2023-11-30 22:20:57 +01:00
|
|
|
if has_image {
|
2023-12-19 15:25:15 +01:00
|
|
|
// Properties buttons was not sensitive before
|
2023-12-07 15:20:13 +01:00
|
|
|
if !imp.properties_button.is_sensitive() {
|
|
|
|
|
let settings = LpApplication::default().settings();
|
2023-12-19 15:25:15 +01:00
|
|
|
// Pickup config for setting it's state
|
2023-12-07 15:20:13 +01:00
|
|
|
imp.properties_button
|
|
|
|
|
.set_active(settings.boolean("show-properties"));
|
|
|
|
|
}
|
2023-12-19 15:25:15 +01:00
|
|
|
if !was_showing_image {
|
|
|
|
|
imp.stack.set_visible_child(&*imp.image_view);
|
|
|
|
|
imp.image_view.grab_focus();
|
|
|
|
|
self.show_controls();
|
|
|
|
|
self.schedule_hide_controls();
|
|
|
|
|
}
|
2023-01-23 01:10:15 +01:00
|
|
|
} else {
|
2023-12-05 17:24:53 +01:00
|
|
|
imp.properties_button.set_active(false);
|
2023-01-23 01:10:15 +01:00
|
|
|
imp.stack.set_visible_child(&*imp.status_page);
|
|
|
|
|
imp.status_page.grab_focus();
|
2023-04-27 20:48:59 +02:00
|
|
|
// Leave fullscreen since status page has no controls to leave it
|
2025-12-09 21:55:07 +01:00
|
|
|
self.window_inspect(|w| w.set_fullscreened(false));
|
2023-01-23 01:10:15 +01:00
|
|
|
}
|
2023-12-07 15:20:13 +01:00
|
|
|
|
|
|
|
|
imp.properties_button.set_sensitive(has_image);
|
2023-01-23 01:10:15 +01:00
|
|
|
}
|
|
|
|
|
|
2023-12-06 23:45:02 +01:00
|
|
|
/// When the image-properties sidebar is displayed or hidden, we should
|
|
|
|
|
/// update the "show-properties" setting.
|
2023-12-05 17:24:53 +01:00
|
|
|
fn on_properties_button_toggled(&self) {
|
|
|
|
|
let imp = self.imp();
|
2023-12-06 23:45:02 +01:00
|
|
|
// When no image is shown, we skip this update as the sidebar should always be
|
|
|
|
|
// hidden. This can happen when deleting a picture.
|
2023-12-05 17:24:53 +01:00
|
|
|
if imp.image_view.current_page().is_none() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let settings = LpApplication::default().settings();
|
|
|
|
|
let result = settings.set_boolean("show-properties", imp.properties_button.is_active());
|
|
|
|
|
if let Err(err) = result {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::warn!("Failed to save show-properties state, {}", err);
|
2023-12-05 17:24:53 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-04 12:52:40 +01:00
|
|
|
pub fn update_title(&self) {
|
|
|
|
|
let title = self
|
|
|
|
|
.imp()
|
|
|
|
|
.image_view
|
|
|
|
|
.current_image()
|
|
|
|
|
.and_then(|x| x.metadata().file_name())
|
|
|
|
|
.unwrap_or_else(|| gettext("Image Viewer"));
|
|
|
|
|
|
2025-12-09 21:55:07 +01:00
|
|
|
self.window_inspect(|x| x.set_title(Some(&title)));
|
2023-12-04 12:52:40 +01:00
|
|
|
}
|
|
|
|
|
|
2024-01-19 16:03:24 +01:00
|
|
|
pub fn image_size_available(&self) {
|
2022-11-16 20:14:39 +01:00
|
|
|
// if visible for whatever reason, don't do any resize
|
|
|
|
|
if self.is_visible() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let image = self
|
|
|
|
|
.imp()
|
|
|
|
|
.image_view
|
2023-01-23 01:10:15 +01:00
|
|
|
.current_page()
|
2022-11-16 20:14:39 +01:00
|
|
|
.map(|page| page.image());
|
|
|
|
|
|
2024-01-19 16:03:24 +01:00
|
|
|
if image.is_some_and(|img| img.image_size_available()) {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::debug!("Showing window because image size is ready");
|
2024-09-14 00:48:46 +02:00
|
|
|
self.window().present();
|
2022-11-16 20:14:39 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn image_error(&self) {
|
2024-07-19 15:02:29 +02:00
|
|
|
self.notify_is_showing_image();
|
|
|
|
|
|
2022-11-16 20:14:39 +01:00
|
|
|
if self.is_visible() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-23 01:10:15 +01:00
|
|
|
let current_page = self.imp().image_view.current_page();
|
2022-11-16 20:14:39 +01:00
|
|
|
|
2025-12-21 15:16:11 +01:00
|
|
|
if current_page.is_some_and(|page| page.image().is_error()) {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::debug!("Showing window because loading image failed");
|
2025-12-09 21:55:07 +01:00
|
|
|
self.window_inspect(|w| w.present());
|
2022-11-16 20:14:39 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-02 22:07:33 +01:00
|
|
|
fn on_fullscreen_changed(&self) {
|
2025-12-19 00:04:27 +01:00
|
|
|
let is_fullscreen = self.try_window().is_some_and(|w| w.is_fullscreen());
|
2025-12-09 21:55:07 +01:00
|
|
|
|
|
|
|
|
let icon = if is_fullscreen {
|
2024-09-09 22:55:39 +02:00
|
|
|
"view-restore-symbolic"
|
|
|
|
|
} else {
|
|
|
|
|
"view-fullscreen-symbolic"
|
|
|
|
|
};
|
|
|
|
|
self.imp().fullscreen_button.set_icon_name(icon);
|
2023-12-04 18:22:47 +01:00
|
|
|
|
2024-12-19 23:25:50 +01:00
|
|
|
self.update_headerbar_style();
|
|
|
|
|
|
2025-12-09 21:55:07 +01:00
|
|
|
if !is_fullscreen {
|
2023-04-22 01:35:38 +02:00
|
|
|
self.set_cursor(None);
|
|
|
|
|
self.show_controls();
|
2023-04-20 02:32:41 +02:00
|
|
|
}
|
2023-11-30 22:27:21 +01:00
|
|
|
self.schedule_hide_controls();
|
2023-04-20 02:32:41 +02:00
|
|
|
}
|
2023-04-26 21:17:03 +02:00
|
|
|
|
|
|
|
|
fn is_headerbar_flat(&self) -> bool {
|
2024-12-19 23:25:50 +01:00
|
|
|
matches!(
|
|
|
|
|
self.imp().toolbar_view.top_bar_style(),
|
|
|
|
|
adw::ToolbarStyle::Flat
|
|
|
|
|
)
|
2023-04-26 21:17:03 +02:00
|
|
|
}
|
|
|
|
|
|
2023-12-02 22:07:33 +01:00
|
|
|
fn is_content_extended_to_top(&self) -> bool {
|
2025-12-19 00:04:27 +01:00
|
|
|
self.try_window().is_some_and(|w| w.is_fullscreen())
|
2025-12-09 21:55:07 +01:00
|
|
|
&& !self.imp().properties_button.is_active()
|
2023-04-26 21:17:03 +02:00
|
|
|
}
|
2024-05-09 22:51:05 +02:00
|
|
|
|
|
|
|
|
fn update_accel_status(&self) {
|
2025-12-09 21:55:07 +01:00
|
|
|
let Some(window) = self.try_window() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let Some(application) = window.application() else {
|
2025-12-18 23:15:46 +01:00
|
|
|
tracing::error!("No application for window found");
|
2024-05-09 22:51:05 +02:00
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Only change status if active window
|
2025-12-09 21:55:07 +01:00
|
|
|
if window.is_active() {
|
|
|
|
|
if window.visible_dialog().is_some() || !self.is_mapped() {
|
2024-05-09 22:51:05 +02:00
|
|
|
// If AdwDialog is visible, remove global accels that are for the main window
|
2024-12-16 21:27:20 +01:00
|
|
|
// Same for this not being visible, so potentially editing view is open
|
2024-05-09 22:51:05 +02:00
|
|
|
ActionPartGlobal::remove_accels(&application);
|
|
|
|
|
} else {
|
|
|
|
|
// Add accels if viewing main window
|
|
|
|
|
ActionPartGlobal::add_accels(&application);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-14 00:48:46 +02:00
|
|
|
pub fn properties_button(&self) -> gtk::ToggleButton {
|
|
|
|
|
self.imp().properties_button.clone()
|
2024-05-09 22:51:05 +02:00
|
|
|
}
|
2024-12-19 23:25:50 +01:00
|
|
|
|
|
|
|
|
fn update_header_opacity(&self) {
|
|
|
|
|
let imp = self.imp();
|
|
|
|
|
|
|
|
|
|
if self.is_headerbar_flat() && self.is_showing_image() {
|
|
|
|
|
// Bring headerbar opacity in sync with controls
|
|
|
|
|
imp.headerbar.set_opacity(self.headerbar_opacity());
|
|
|
|
|
} else {
|
|
|
|
|
imp.headerbar.set_opacity(1.);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn update_headerbar_style(&self) {
|
|
|
|
|
let imp = self.imp();
|
|
|
|
|
|
|
|
|
|
imp.toolbar_view
|
|
|
|
|
.set_extend_content_to_top_edge(self.is_content_extended_to_top());
|
|
|
|
|
|
|
|
|
|
let style = if !self.is_showing_image() || self.is_content_extended_to_top() {
|
|
|
|
|
// Flat headerbar for empty state and fullscreen without properties enabled
|
|
|
|
|
adw::ToolbarStyle::Flat
|
|
|
|
|
} else {
|
|
|
|
|
// Use the border variant of raised to avoid shadows over images
|
|
|
|
|
adw::ToolbarStyle::RaisedBorder
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if style != imp.toolbar_view.top_bar_style() {
|
|
|
|
|
imp.toolbar_view.set_top_bar_style(style);
|
|
|
|
|
self.update_header_opacity();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn headerbar_opacity(&self) -> f64 {
|
|
|
|
|
self.imp().headerbar.opacity()
|
|
|
|
|
}
|
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>
2026-09-18 09:38:31 +02:00
|
|
|
|
|
|
|
|
// ── 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}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-12-26 02:05:34 -08:00
|
|
|
}
|