1010 lines
34 KiB
Rust
1010 lines
34 KiB
Rust
// Copyright (c) 2020-2024 Christopher Davis
|
|
// Copyright (c) 2022-2026 Sophie Herold
|
|
// Copyright (c) 2022 Maximiliano Sandoval R
|
|
// Copyright (c) 2023 Matteo Nardi
|
|
// Copyright (c) 2023 FineFindus
|
|
// Copyright (c) 2023 qwel
|
|
// Copyright (c) 2023 Huan Thieu Nguyen
|
|
// Copyright (c) 2024 Fina Wilke
|
|
// Copyright (c) 2024 DaPigGuy
|
|
// Copyright (c) 2024 James Frost
|
|
// Copyright (c) 2025 Abhinandan Thakur
|
|
// Copyright (c) 2025 pieterd
|
|
//
|
|
// 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
|
|
|
|
mod actions;
|
|
mod controls;
|
|
|
|
use std::cell::{Cell, OnceCell, RefCell};
|
|
use std::marker::PhantomData;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use actions::*;
|
|
use adw::prelude::*;
|
|
use adw::subclass::prelude::*;
|
|
use adw::{glib, gtk};
|
|
use gtk::Widget;
|
|
|
|
use crate::application::LpApplication;
|
|
use crate::config;
|
|
use crate::deps::*;
|
|
use crate::util::Direction;
|
|
use crate::util::gettext::*;
|
|
use crate::util::root::ParentWindow;
|
|
use crate::widgets::window::ActionPartGlobal;
|
|
use crate::widgets::{LpDragOverlay, LpImage, LpImageView, LpPropertiesView};
|
|
|
|
/// 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;
|
|
/// Time of inactivity after which controls will be hidden in milliseconds
|
|
const HIDE_CONTROLS_IDLE_TIMEOUT: u64 = 3000;
|
|
|
|
mod imp {
|
|
|
|
use super::*;
|
|
#[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
|
|
#[properties(wrapper_type = super::LpImageWindow)]
|
|
#[template(file = "image_window.ui")]
|
|
pub struct LpImageWindow {
|
|
#[template_child]
|
|
pub(super) shortcut_controller: TemplateChild<gtk::ShortcutController>,
|
|
|
|
#[template_child]
|
|
pub(super) headerbar: TemplateChild<adw::HeaderBar>,
|
|
#[template_child]
|
|
pub(super) headerbar_events: TemplateChild<gtk::EventControllerMotion>,
|
|
|
|
#[template_child]
|
|
pub(super) fullscreen_button: TemplateChild<gtk::Button>,
|
|
|
|
#[template_child]
|
|
pub(super) properties_button: TemplateChild<gtk::ToggleButton>,
|
|
#[template_child]
|
|
pub(super) primary_menu: TemplateChild<gtk::MenuButton>,
|
|
|
|
#[template_child]
|
|
pub(super) toolbar_view: TemplateChild<adw::ToolbarView>,
|
|
|
|
#[template_child]
|
|
pub(super) stack: TemplateChild<gtk::Stack>,
|
|
#[template_child]
|
|
pub(super) status_page: TemplateChild<adw::StatusPage>,
|
|
#[template_child]
|
|
pub(super) image_view: TemplateChild<LpImageView>,
|
|
#[template_child]
|
|
pub(super) properties_view: TemplateChild<LpPropertiesView>,
|
|
|
|
#[template_child]
|
|
pub(super) drag_overlay: TemplateChild<LpDragOverlay>,
|
|
#[template_child]
|
|
pub(super) drop_target: TemplateChild<gtk::DropTarget>,
|
|
|
|
#[template_child]
|
|
pub(super) forward_click_gesture: TemplateChild<gtk::GestureClick>,
|
|
#[template_child]
|
|
pub(super) backward_click_gesture: TemplateChild<gtk::GestureClick>,
|
|
#[template_child]
|
|
pub(super) general_click_gesture: TemplateChild<gtk::GestureClick>,
|
|
|
|
#[property(get = Self::is_showing_image)]
|
|
pub(super) is_showing_image: PhantomData<bool>,
|
|
|
|
/// Motion controller for complete window
|
|
pub(super) motion_controller: gtk::EventControllerMotion,
|
|
pub(super) pointer_position: Cell<(f64, f64)>,
|
|
|
|
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>>,
|
|
}
|
|
|
|
#[glib::object_subclass]
|
|
impl ObjectSubclass for LpImageWindow {
|
|
const NAME: &'static str = "LpImageWindow";
|
|
type Type = super::LpImageWindow;
|
|
type ParentType = adw::Bin;
|
|
|
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
|
obj.init_template();
|
|
}
|
|
|
|
fn class_init(klass: &mut Self::Class) {
|
|
Self::bind_template(klass);
|
|
|
|
// Set up actions
|
|
Action::init_actions(klass);
|
|
}
|
|
}
|
|
|
|
#[glib::derived_properties]
|
|
impl ObjectImpl for LpImageWindow {}
|
|
|
|
impl WidgetImpl for LpImageWindow {
|
|
fn root(&self) {
|
|
self.parent_root();
|
|
|
|
let obj = self.obj();
|
|
|
|
Action::add_bindings(&obj);
|
|
|
|
self.forward_click_gesture.connect_pressed(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
move |gesture, _, _, _| {
|
|
tracing::trace!("Mouse forward button clicked");
|
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
|
obj.image_view().navigate(Direction::Forward, false);
|
|
}
|
|
));
|
|
self.backward_click_gesture.connect_pressed(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
move |gesture, _, _, _| {
|
|
tracing::trace!("Mouse back button clicked");
|
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
|
obj.image_view().navigate(Direction::Back, false);
|
|
}
|
|
));
|
|
|
|
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 {
|
|
tracing::trace!("General 1 click event");
|
|
obj.on_click();
|
|
}
|
|
}
|
|
));
|
|
|
|
self.properties_button.connect_toggled(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
move |_| {
|
|
obj.on_properties_button_toggled();
|
|
}
|
|
));
|
|
|
|
obj.on_current_page_changed();
|
|
obj.on_fullscreen_changed();
|
|
|
|
self.image_view.connect_current_page_notify(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
move |_| obj.on_current_page_changed()
|
|
));
|
|
|
|
obj.window().connect_fullscreened_notify(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
move |_| obj.on_fullscreen_changed()
|
|
));
|
|
|
|
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))
|
|
));
|
|
obj.add_controller(self.motion_controller.clone());
|
|
|
|
let current_image_signals = self.image_view.current_image_signals();
|
|
|
|
current_image_signals.connect_local(
|
|
"metadata-changed",
|
|
true,
|
|
glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
#[upgrade_or_default]
|
|
move |_| {
|
|
obj.update_title();
|
|
None
|
|
}
|
|
),
|
|
);
|
|
|
|
current_image_signals.connect_closure(
|
|
"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.
|
|
glib::closure_local!(
|
|
#[watch]
|
|
obj,
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
obj.update_image_actions_status();
|
|
}
|
|
),
|
|
);
|
|
|
|
current_image_signals.connect_closure(
|
|
"notify::is-min-zoom",
|
|
false,
|
|
glib::closure_local!(
|
|
#[watch]
|
|
obj,
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
obj.update_image_actions_status();
|
|
}
|
|
),
|
|
);
|
|
|
|
current_image_signals.connect_closure(
|
|
"notify::is-max-zoom",
|
|
false,
|
|
glib::closure_local!(
|
|
#[watch]
|
|
obj,
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
obj.update_image_actions_status();
|
|
}
|
|
),
|
|
);
|
|
|
|
current_image_signals.connect_closure(
|
|
"notify::image-size-available",
|
|
false,
|
|
glib::closure_local!(
|
|
#[watch]
|
|
obj,
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
obj.image_size_available();
|
|
}
|
|
),
|
|
);
|
|
|
|
current_image_signals.connect_closure(
|
|
"notify::is-loaded",
|
|
false,
|
|
glib::closure_local!(
|
|
#[watch]
|
|
obj,
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
obj.update_image_actions_status();
|
|
}
|
|
),
|
|
);
|
|
|
|
current_image_signals.connect_closure(
|
|
"notify::error",
|
|
false,
|
|
glib::closure_local!(
|
|
#[watch]
|
|
obj,
|
|
move |_: &LpImage, _: &glib::ParamSpec| {
|
|
obj.image_error();
|
|
obj.update_image_actions_status();
|
|
}
|
|
),
|
|
);
|
|
|
|
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();
|
|
}
|
|
));
|
|
|
|
// action win.previous status
|
|
self.image_view
|
|
.connect_is_previous_available_notify(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
move |_| {
|
|
obj.action_set_enabled(
|
|
&Action::Previous,
|
|
obj.imp().image_view.is_previous_available(),
|
|
);
|
|
}
|
|
));
|
|
|
|
// action win.next status
|
|
self.image_view
|
|
.connect_is_next_available_notify(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
move |_| {
|
|
obj.action_set_enabled(
|
|
&Action::Next,
|
|
obj.imp().image_view.is_next_available(),
|
|
);
|
|
}
|
|
));
|
|
|
|
// Make widgets visible when the focus moves
|
|
obj.connect_move_focus(|obj, _| {
|
|
obj.show_controls();
|
|
obj.schedule_hide_controls();
|
|
});
|
|
|
|
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();
|
|
}
|
|
}
|
|
));
|
|
|
|
// Activate global shortcuts only if no dialog is open
|
|
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()
|
|
));
|
|
obj.connect_unmap(|win| win.update_accel_status());
|
|
obj.connect_map(|win| win.update_accel_status());
|
|
|
|
self.status_page
|
|
.set_icon_name(Some(&format!("{}-symbolic", config::APP_ID)));
|
|
|
|
self.drop_target.set_types(&[gdk::FileList::static_type()]);
|
|
|
|
self.drop_target.connect_accept(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
#[upgrade_or]
|
|
false,
|
|
move |_drop_target, drop| {
|
|
// Only accept drops from external sources or different windows
|
|
let different_source = drop.drag().is_none()
|
|
|| drop.drag() != obj.image_view().drag_source().drag();
|
|
// 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
|
|
}
|
|
));
|
|
|
|
// For callbacks, you will want to reference the GTK docs on
|
|
// the relevant signal to see which parameters you need.
|
|
// In this case, we need only need the GValue,
|
|
// so we name it `value` then use `_` for the other spots.
|
|
self.drop_target.connect_drop(glib::clone!(
|
|
#[weak]
|
|
obj,
|
|
#[upgrade_or]
|
|
false,
|
|
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.
|
|
//
|
|
// 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()`.
|
|
let files = match value.get::<gdk::FileList>() {
|
|
Ok(list) => list.files(),
|
|
Err(err) => {
|
|
tracing::error!("Issue with drop value: {err}");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
if !files.is_empty() {
|
|
obj.image_view().set_images_from_files(files);
|
|
} else {
|
|
tracing::error!("Dropped FileList was empty");
|
|
return false;
|
|
}
|
|
|
|
// Maybe one day this will actually work
|
|
tracing::debug!("Trying to focus window after drag and drop");
|
|
obj.window_inspect(|w| w.present());
|
|
|
|
true
|
|
}
|
|
));
|
|
}
|
|
}
|
|
|
|
impl BinImpl for LpImageWindow {}
|
|
|
|
impl LpImageWindow {
|
|
fn is_showing_image(&self) -> bool {
|
|
self.image_view.try_get().is_some_and(|image_view| {
|
|
image_view
|
|
.current_page()
|
|
.is_some_and(|page| !page.image().is_error())
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
glib::wrapper! {
|
|
pub struct LpImageWindow(ObjectSubclass<imp::LpImageWindow>)
|
|
@extends gtk::Widget, adw::Bin,
|
|
@implements gtk::Buildable, gtk::Accessible, gtk::ConstraintTarget;
|
|
}
|
|
|
|
impl LpImageWindow {
|
|
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();
|
|
}
|
|
}
|
|
|
|
fn zoom_in_center(&self) {
|
|
if let Some(image) = self.imp().image_view.current_image() {
|
|
image.zoom_in_center();
|
|
}
|
|
}
|
|
|
|
fn zoom_to_exact(&self, level: f64) {
|
|
if let Some(image) = self.image_view().current_image() {
|
|
image.zoom_to_exact(level);
|
|
}
|
|
}
|
|
|
|
fn zoom_best_fit(&self) {
|
|
if let Some(page) = self.imp().image_view.current_page() {
|
|
page.image().zoom_best_fit();
|
|
}
|
|
}
|
|
|
|
pub fn pan(&self, direction: >k::PanDirection) {
|
|
if let Some(image) = self.imp().image_view.current_image() {
|
|
image.pan(direction);
|
|
}
|
|
}
|
|
|
|
async fn pick_file(&self) {
|
|
let filter_list = gio::ListStore::new::<gtk::FileFilter>();
|
|
|
|
let filter_supported_formats = gtk::FileFilter::new();
|
|
filter_supported_formats.set_name(Some(&gettext("Supported image formats")));
|
|
for mime_type in glycin::Loader::supported_mime_types().await {
|
|
filter_supported_formats.add_mime_type(mime_type.as_str());
|
|
}
|
|
|
|
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);
|
|
|
|
let chooser = gtk::FileDialog::builder()
|
|
.title(gettext("Open Image"))
|
|
.filters(&filter_list)
|
|
.default_filter(&filter_supported_formats)
|
|
.modal(true)
|
|
.build();
|
|
|
|
chooser.set_initial_folder(
|
|
self.image_view()
|
|
.current_image()
|
|
.and_then(|x| x.file())
|
|
.and_then(|x| x.parent())
|
|
.as_ref(),
|
|
);
|
|
|
|
if let Ok(selected) = chooser
|
|
.open_multiple_future(self.try_window().as_ref())
|
|
.await
|
|
{
|
|
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);
|
|
} else {
|
|
tracing::debug!("File dialog canceled or file not readable");
|
|
}
|
|
}
|
|
|
|
async fn open_with(&self) {
|
|
let imp = self.imp();
|
|
|
|
if let Some(ref file) = imp.image_view.current_file() {
|
|
let launcher = gtk::FileLauncher::new(Some(file));
|
|
launcher.set_always_ask(true);
|
|
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);
|
|
}
|
|
} else {
|
|
tracing::error!("Could not load a path for the current image.")
|
|
}
|
|
}
|
|
|
|
fn rotate_image(&self, angle: f64) {
|
|
self.imp().image_view.rotate_image(angle)
|
|
}
|
|
|
|
async fn set_background(&self) {
|
|
let imp = self.imp();
|
|
|
|
if let Err(e) = imp.image_view.set_background().await {
|
|
tracing::error!("Failed to set background: {}", e);
|
|
}
|
|
}
|
|
|
|
fn print(&self) {
|
|
let imp = self.imp();
|
|
|
|
if let Err(e) = imp.image_view.print() {
|
|
tracing::error!("Failed to print file: {}", e);
|
|
}
|
|
}
|
|
|
|
/// Returns true if some text in metadata is currently selected
|
|
pub fn has_metadata_selected(&self) -> bool {
|
|
let Some(window) = self.try_window() else {
|
|
return false;
|
|
};
|
|
|
|
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();
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Copy image to clipboard or metadata text if selected
|
|
pub fn copy_image(&self) {
|
|
let imp = self.imp();
|
|
|
|
if let Err(e) = imp.image_view.copy() {
|
|
tracing::error!("Failed to copy to clipboard: {}", e);
|
|
} else {
|
|
self.window_show_toast(
|
|
&gettext("Image copied to clipboard"),
|
|
adw::ToastPriority::High,
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn trash(&self) {
|
|
let image_view = self.image_view();
|
|
let (Some(file), Some(path)) = (
|
|
image_view.current_file(),
|
|
image_view.current_file().and_then(|x| x.path()),
|
|
) else {
|
|
tracing::error!("No file to trash");
|
|
return;
|
|
};
|
|
|
|
let result = file.trash_future(glib::Priority::default()).await;
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
let toast = adw::Toast::builder()
|
|
.title(gettext("Image moved to trash"))
|
|
.button_label(gettext("Undo"))
|
|
.priority(adw::ToastPriority::High)
|
|
.build();
|
|
toast.connect_button_clicked(glib::clone!(
|
|
#[weak(rename_to = obj)]
|
|
self,
|
|
move |_| {
|
|
let path = path.clone();
|
|
glib::spawn_future_local(async move {
|
|
obj.image_view()
|
|
.set_trash_restore(Some(gio::File::for_path(&path)));
|
|
let result = crate::util::untrash(&path).await;
|
|
if let Err(err) = result {
|
|
tracing::error!("Failed to untrash {path:?}: {err}");
|
|
obj.window_show_toast(
|
|
&gettext("Failed to restore image from trash"),
|
|
adw::ToastPriority::High,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
));
|
|
self.window_add_toast(toast);
|
|
}
|
|
Err(err) => {
|
|
if Some(gio::IOErrorEnum::NotSupported) == err.kind::<gio::IOErrorEnum>() {
|
|
self.delete_future(&path).await;
|
|
} else {
|
|
tracing::error!("Failed to delete file {path:?}: {err}");
|
|
self.window_show_toast(
|
|
&gettext("Failed to move image to trash"),
|
|
adw::ToastPriority::Normal,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
tracing::error!("No file to delete");
|
|
return;
|
|
};
|
|
|
|
self.delete_future(&path).await;
|
|
}
|
|
|
|
/// Permanently delete image
|
|
///
|
|
/// Fallback for when trash not available or explicit call with shortcut
|
|
async fn delete_future(&self, path: &Path) {
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(gettext("Permanently Delete Image?"))
|
|
.body(gettext_f(
|
|
"After deleting the image “{}” it will be permanently lost.",
|
|
[PathBuf::from(&path.file_name().unwrap_or_default())
|
|
.display()
|
|
.to_string()],
|
|
))
|
|
.build();
|
|
|
|
dialog.add_responses(&[
|
|
("cancel", &gettext("Cancel")),
|
|
("delete", &gettext("Delete")),
|
|
]);
|
|
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
|
|
|
if "delete" == dialog.choose_future(Some(self)).await {
|
|
let file = gio::File::for_path(path);
|
|
let result = file.delete_future(glib::Priority::default()).await;
|
|
|
|
if let Err(err) = result {
|
|
tracing::error!("Failed to delete file {path:?}: {err}");
|
|
self.window_show_toast(
|
|
&gettext("Failed to delete image"),
|
|
adw::ToastPriority::Normal,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn image_view(&self) -> LpImageView {
|
|
self.imp().image_view.clone()
|
|
}
|
|
|
|
pub fn headerbar(&self) -> adw::HeaderBar {
|
|
self.imp().headerbar.clone()
|
|
}
|
|
|
|
/// 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,
|
|
Action::ZoomToExact100,
|
|
Action::ZoomToExact200,
|
|
Action::ZoomToExact300,
|
|
];
|
|
|
|
let enabled_shown = self
|
|
.imp()
|
|
.image_view
|
|
.current_page()
|
|
.is_some_and(|x| x.image().is_loaded() && !x.image().is_error());
|
|
|
|
for action in ACTIONS_SHOWN {
|
|
self.action_set_enabled(action, enabled_shown);
|
|
}
|
|
|
|
// Zoom specific actions
|
|
|
|
let can_zoom_out = self
|
|
.image_view()
|
|
.current_image()
|
|
.is_some_and(|image| !image.is_best_fit() && !image.is_min_zoom());
|
|
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);
|
|
|
|
self.image_view().set_zoom_toggle_state(
|
|
self.image_view()
|
|
.current_image()
|
|
.is_some_and(|image| !image.is_best_fit()),
|
|
);
|
|
|
|
self.image_view().zoom_toggle().set_sensitive(enabled_shown);
|
|
|
|
self.image_view()
|
|
.zoom_menu_button()
|
|
.set_sensitive(enabled_shown);
|
|
|
|
// Actions that are available if there is an current image, even if it's not
|
|
// shown
|
|
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);
|
|
}
|
|
|
|
// Editing related actions
|
|
let enable_editing = enabled_shown
|
|
&& self
|
|
.imp()
|
|
.image_view
|
|
.current_image()
|
|
.is_some_and(|x| x.editable());
|
|
self.action_set_enabled(&Action::Edit, enable_editing);
|
|
}
|
|
|
|
/// Handles change in image and availability of images
|
|
fn on_current_page_changed(&self) {
|
|
let imp = self.imp();
|
|
let was_showing_image =
|
|
imp.stack.visible_child().as_ref() == Some(imp.image_view.upcast_ref::<Widget>());
|
|
let current_page = imp.image_view.current_page();
|
|
|
|
// HeaderBar style
|
|
self.notify_is_showing_image();
|
|
|
|
// Window title
|
|
self.update_title();
|
|
|
|
// Properties view
|
|
let current_image: Option<LpImage> = current_page.as_ref().map(|x| x.image());
|
|
imp.properties_view.set_image(current_image.as_ref());
|
|
|
|
let has_image = current_page.is_some();
|
|
|
|
self.update_image_actions_status();
|
|
self.action_set_enabled(
|
|
&Action::Trash,
|
|
imp.image_view
|
|
.current_file()
|
|
.is_some_and(|file| file.path().is_some()),
|
|
);
|
|
|
|
if has_image {
|
|
// Properties buttons was not sensitive before
|
|
if !imp.properties_button.is_sensitive() {
|
|
let settings = LpApplication::default().settings();
|
|
// Pickup config for setting it's state
|
|
imp.properties_button
|
|
.set_active(settings.boolean("show-properties"));
|
|
}
|
|
if !was_showing_image {
|
|
imp.stack.set_visible_child(&*imp.image_view);
|
|
imp.image_view.grab_focus();
|
|
self.show_controls();
|
|
self.schedule_hide_controls();
|
|
}
|
|
} else {
|
|
imp.properties_button.set_active(false);
|
|
imp.stack.set_visible_child(&*imp.status_page);
|
|
imp.status_page.grab_focus();
|
|
// Leave fullscreen since status page has no controls to leave it
|
|
self.window_inspect(|w| w.set_fullscreened(false));
|
|
}
|
|
|
|
imp.properties_button.set_sensitive(has_image);
|
|
}
|
|
|
|
/// When the image-properties sidebar is displayed or hidden, we should
|
|
/// update the "show-properties" setting.
|
|
fn on_properties_button_toggled(&self) {
|
|
let imp = self.imp();
|
|
// When no image is shown, we skip this update as the sidebar should always be
|
|
// hidden. This can happen when deleting a picture.
|
|
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 {
|
|
tracing::warn!("Failed to save show-properties state, {}", err);
|
|
}
|
|
}
|
|
|
|
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"));
|
|
|
|
self.window_inspect(|x| x.set_title(Some(&title)));
|
|
}
|
|
|
|
pub fn image_size_available(&self) {
|
|
// if visible for whatever reason, don't do any resize
|
|
if self.is_visible() {
|
|
return;
|
|
}
|
|
|
|
let image = self
|
|
.imp()
|
|
.image_view
|
|
.current_page()
|
|
.map(|page| page.image());
|
|
|
|
if image.is_some_and(|img| img.image_size_available()) {
|
|
tracing::debug!("Showing window because image size is ready");
|
|
self.window().present();
|
|
}
|
|
}
|
|
|
|
pub fn image_error(&self) {
|
|
self.notify_is_showing_image();
|
|
|
|
if self.is_visible() {
|
|
return;
|
|
}
|
|
|
|
let current_page = self.imp().image_view.current_page();
|
|
|
|
if current_page.is_some_and(|page| page.image().is_error()) {
|
|
tracing::debug!("Showing window because loading image failed");
|
|
self.window_inspect(|w| w.present());
|
|
}
|
|
}
|
|
|
|
fn on_fullscreen_changed(&self) {
|
|
let is_fullscreen = self.try_window().is_some_and(|w| w.is_fullscreen());
|
|
|
|
let icon = if is_fullscreen {
|
|
"view-restore-symbolic"
|
|
} else {
|
|
"view-fullscreen-symbolic"
|
|
};
|
|
self.imp().fullscreen_button.set_icon_name(icon);
|
|
|
|
self.update_headerbar_style();
|
|
|
|
if !is_fullscreen {
|
|
self.set_cursor(None);
|
|
self.show_controls();
|
|
}
|
|
self.schedule_hide_controls();
|
|
}
|
|
|
|
fn is_headerbar_flat(&self) -> bool {
|
|
matches!(
|
|
self.imp().toolbar_view.top_bar_style(),
|
|
adw::ToolbarStyle::Flat
|
|
)
|
|
}
|
|
|
|
fn is_content_extended_to_top(&self) -> bool {
|
|
self.try_window().is_some_and(|w| w.is_fullscreen())
|
|
&& !self.imp().properties_button.is_active()
|
|
}
|
|
|
|
fn update_accel_status(&self) {
|
|
let Some(window) = self.try_window() else {
|
|
return;
|
|
};
|
|
|
|
let Some(application) = window.application() else {
|
|
tracing::error!("No application for window found");
|
|
return;
|
|
};
|
|
|
|
// Only change status if active window
|
|
if window.is_active() {
|
|
if window.visible_dialog().is_some() || !self.is_mapped() {
|
|
// If AdwDialog is visible, remove global accels that are for the main window
|
|
// Same for this not being visible, so potentially editing view is open
|
|
ActionPartGlobal::remove_accels(&application);
|
|
} else {
|
|
// Add accels if viewing main window
|
|
ActionPartGlobal::add_accels(&application);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn properties_button(&self) -> gtk::ToggleButton {
|
|
self.imp().properties_button.clone()
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|