From c185703eef79a48dead219f6b41a7874994c7273 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Tue, 12 May 2026 12:03:33 -0400 Subject: [PATCH] feat(messages): Open image attachments in a dialog Mirrors the official Signal desktop application by allowing the user to click on an image attachment and see it expanded in an Adw.Dialog overlay with built-in close button, Escape handling, and mobile swipe-down support. --- CHANGELOG.md | 4 ++ data/resources/meson.build | 1 + data/resources/resources.gresource.xml.in | 1 + .../ui/components/attachment_image.blp | 5 ++ data/resources/ui/image_viewer.blp | 15 +++++ src/backend/dummy.rs | 3 +- src/gui/components/attachment_image.rs | 28 ++++++++- src/gui/image_viewer.rs | 57 +++++++++++++++++++ src/gui/mod.rs | 1 + src/gui/window.rs | 7 +++ 10 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 data/resources/ui/image_viewer.blp create mode 100644 src/gui/image_viewer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 20dc578e..1e4649bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Click an image attachment to open it in a fullscreen-capable viewer with a close button, fullscreen toggle, and `Escape`-to-dismiss. + ## [0.20.4] - 2026-04-22 ### Fixed diff --git a/data/resources/meson.build b/data/resources/meson.build index 7fc00b7c..efd789b7 100644 --- a/data/resources/meson.build +++ b/data/resources/meson.build @@ -11,6 +11,7 @@ blueprints = custom_target('blueprints', 'ui/dialog_clear_messages.blp', 'ui/dialog_unlink.blp', 'ui/error_dialog.blp', + 'ui/image_viewer.blp', 'ui/setup_window.blp', 'ui/linked_devices_window.blp', 'ui/device_info_item.blp', diff --git a/data/resources/resources.gresource.xml.in b/data/resources/resources.gresource.xml.in index 8604fe24..7c958d02 100644 --- a/data/resources/resources.gresource.xml.in +++ b/data/resources/resources.gresource.xml.in @@ -13,6 +13,7 @@ ui/linked_devices_window.ui ui/device_info_item.ui ui/error_dialog.ui + ui/image_viewer.ui ui/attachment.ui ui/preferences_window.ui ui/shortcuts.ui diff --git a/data/resources/ui/components/attachment_image.blp b/data/resources/ui/components/attachment_image.blp index ec303a72..bcbdabe1 100644 --- a/data/resources/ui/components/attachment_image.blp +++ b/data/resources/ui/components/attachment_image.blp @@ -2,6 +2,11 @@ using Gtk 4.0; template $FlAttachmentImage: $FlAttachmentBase { Picture picture { + GestureClick { + button: 1; + pressed => $on_clicked() swapped; + } + styles [ "photo", ] diff --git a/data/resources/ui/image_viewer.blp b/data/resources/ui/image_viewer.blp new file mode 100644 index 00000000..19660f0b --- /dev/null +++ b/data/resources/ui/image_viewer.blp @@ -0,0 +1,15 @@ +using Gtk 4.0; +using Adw 1; + +template $FlImageViewer: Adw.Bin { + hexpand: true; + vexpand: true; + + Picture picture { + paintable: bind template.attachment as <$FlAttachmentObject>.image; + content-fit: contain; + can-shrink: true; + hexpand: true; + vexpand: true; + } +} diff --git a/src/backend/dummy.rs b/src/backend/dummy.rs index e6824e80..1ebff6f6 100644 --- a/src/backend/dummy.rs +++ b/src/backend/dummy.rs @@ -382,6 +382,7 @@ impl super::Manager { }, )); + let msg_screenshot = msg!(self, "", 2, GROUP_ID, 19 + base_minute); let screenshot_file = gtk::gio::File::for_uri("resource:///icon.svg"); let attachment = crate::backend::Attachment::from_file(screenshot_file, self); @@ -393,7 +394,7 @@ impl super::Manager { vec![ msg_replied, - // msg_screenshot, + msg_screenshot, msg_reply, msg!( self, diff --git a/src/gui/components/attachment_image.rs b/src/gui/components/attachment_image.rs index a91bf05b..9ca6e0c7 100644 --- a/src/gui/components/attachment_image.rs +++ b/src/gui/components/attachment_image.rs @@ -24,7 +24,10 @@ pub mod imp { use glib::subclass::InitializingObject; use gtk::{CompositeTemplate, Picture}; - use crate::gui::{attachment::Attachment, attachment::AttachmentImpl, utility::Utility}; + use crate::gui::{ + attachment::{Attachment, AttachmentImpl}, + utility::Utility, + }; #[derive(CompositeTemplate, Default)] #[template(resource = "/ui/components/attachment_image.ui")] @@ -41,6 +44,7 @@ pub mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); + Self::bind_template_callbacks(klass); Utility::bind_template_callbacks(klass); } @@ -49,6 +53,28 @@ pub mod imp { } } + #[gtk::template_callbacks] + impl AttachmentImage { + /// Open the image in a viewer presented inside the parent window. + /// Bails out silently if the attachment's texture hasn't loaded yet. + #[template_callback] + fn on_clicked(&self) { + let obj = self.obj(); + let base = obj.upcast_ref::(); + let attachment = base.attachment(); + if attachment.image().is_none() { + return; + } + let Some(window) = obj + .root() + .and_then(|r| r.dynamic_cast::().ok()) + else { + return; + }; + window.present_image_viewer(&attachment); + } + } + impl ObjectImpl for AttachmentImage { fn constructed(&self) { self.parent_constructed(); diff --git a/src/gui/image_viewer.rs b/src/gui/image_viewer.rs new file mode 100644 index 00000000..a667a5a5 --- /dev/null +++ b/src/gui/image_viewer.rs @@ -0,0 +1,57 @@ +use crate::prelude::*; + +use crate::backend::Attachment; + +glib::wrapper! { + pub struct ImageViewer(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ImageViewer { + pub fn new(attachment: &Attachment) -> Self { + Object::builder::() + .property("attachment", attachment) + .build() + } +} + +pub mod imp { + use crate::prelude::*; + + use gtk::{subclass::prelude::*, CompositeTemplate, Picture}; + + use crate::backend::Attachment; + + #[derive(CompositeTemplate, Default, glib::Properties)] + #[properties(wrapper_type = super::ImageViewer)] + #[template(resource = "/ui/image_viewer.ui")] + pub struct ImageViewer { + #[property(get, set, construct_only)] + attachment: RefCell>, + + #[template_child] + pub(super) picture: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ImageViewer { + const NAME: &'static str = "FlImageViewer"; + type Type = super::ImageViewer; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for ImageViewer {} + + impl WidgetImpl for ImageViewer {} + impl BinImpl for ImageViewer {} +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 6b5cfd19..d2e997ee 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -8,6 +8,7 @@ mod channel_messages; mod components; mod device_info_item; mod error_dialog; +mod image_viewer; mod linked_devices_window; mod message_item; mod new_channel_dialog; diff --git a/src/gui/window.rs b/src/gui/window.rs index 6335f3d6..4f75e360 100644 --- a/src/gui/window.rs +++ b/src/gui/window.rs @@ -103,6 +103,13 @@ impl Window { pub(crate) fn settings(&self) -> gio::Settings { self.imp().settings.clone() } + + pub fn present_image_viewer(&self, attachment: &crate::backend::Attachment) { + let viewer = crate::gui::image_viewer::ImageViewer::new(attachment); + let dialog = adw::Dialog::new(); + dialog.set_child(Some(&viewer)); + dialog.present(Some(self.upcast_ref::())); + } } pub mod imp { -- 2.53.0