From e13d8cbf15a2eea323397accb2183e2362dae106 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 6 May 2026 18:41:15 -0400 Subject: [PATCH 7/7] feat(messages): Open image attachments in a fullscreen-capable viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking an image bubble currently does nothing — the existing "pressed" gesture is right-click-only and routes to the context menu. To actually look at an image at full resolution the user has to right-click and choose "Open", which dispatches the file through the xdg-open portal to the system image viewer. That is the wrong affordance for a chat client; every other Signal frontend opens the image inline on tap. Add an `ImageViewer` (Adw.Window subclass) that hosts the attachment's texture in a `gtk::Picture` filling the window over a near-black backdrop, with two `osd circular` buttons floating in the top-right corner: a fullscreen toggle and a close button. Closing happens on `Escape` or via the close button — clicks on the picture and on the letterboxed area do nothing, leaving the gesture stack open for a future pan/zoom implementation. Wire `AttachmentImage` to open the viewer on left-click. Bails out silently if the texture has not loaded yet or if the row is not currently parented in a `gtk::Window`. --- CHANGELOG.md | 1 + data/resources/meson.build | 1 + data/resources/resources.gresource.xml.in | 5 + data/resources/style.css | 8 +- .../ui/components/attachment_image.blp | 5 + data/resources/ui/image_viewer.blp | 64 +++++ data/resources/ui/window.blp | 224 +++++++++--------- src/backend/dummy.rs | 36 ++- src/gui/components/attachment_image.rs | 33 ++- src/gui/image_viewer.rs | 120 ++++++++++ src/gui/mod.rs | 1 + src/gui/window.rs | 30 +++ 13 files changed, 411 insertions(+), 117 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 e221f555..8ebca71c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Display incoming edited messages with an `edited` indicator and edit your own sent messages from their context menu. - Multi-select messages from their context menu and delete the selection locally with a single action. - In-channel message search (Ctrl+Shift+F) over the loaded timeline with prev/next navigation and a match counter. +- 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 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..863564b3 100644 --- a/data/resources/resources.gresource.xml.in +++ b/data/resources/resources.gresource.xml.in @@ -2,6 +2,10 @@ ../icons/de.schmidhuberj.Flare.svg + + + + ../screenshots/dummy_apple.jpg ui/window.ui ui/channel_list.ui @@ -13,6 +17,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/style.css b/data/resources/style.css index b3a517f9..0c95a55a 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -309,4 +309,10 @@ unread-indicator { background: alpha(@accent_bg_color, 0.35); outline: 2px solid alpha(@accent_color, 0.7); outline-offset: -2px; -} \ No newline at end of file +} + +/* Image attachment viewer: a near-black backdrop so the picture appears to + float and the floating OSD close button has enough contrast. */ +.image-viewer-backdrop { + background-color: rgb(15, 15, 15); +} 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..1b27cc09 --- /dev/null +++ b/data/resources/ui/image_viewer.blp @@ -0,0 +1,64 @@ +using Gtk 4.0; +using Adw 1; + +template $FlImageViewer: Adw.Bin { + hexpand: true; + vexpand: true; + focusable: true; + + EventControllerKey { + key-pressed => $on_key_pressed() swapped; + } + + // Box paints the dark backdrop. Overlay does not draw a background, so + // the CSS class lives on the Box. + Box { + hexpand: true; + vexpand: true; + + styles [ + "image-viewer-backdrop", + ] + + Overlay { + hexpand: true; + vexpand: true; + + // Any left-click in the viewer dismisses — on the image, on the + // letterboxed dark area, anywhere. The close button absorbs its own + // click before it reaches us via standard GTK click-bubbling so the + // button still works. + GestureClick { + button: 1; + pressed => $on_backdrop_pressed() swapped; + } + + Picture picture { + paintable: bind template.attachment as <$FlAttachmentObject>.image; + content-fit: contain; + can-shrink: true; + hexpand: true; + vexpand: true; + } + + [overlay] + Box { + halign: end; + valign: start; + margin-top: 12; + margin-end: 12; + + Button close_button { + icon-name: "window-close-symbolic"; + tooltip-text: _("Close"); + clicked => $on_close_clicked() swapped; + + styles [ + "osd", + "circular", + ] + } + } + } + } +} diff --git a/data/resources/ui/window.blp b/data/resources/ui/window.blp index 82de6af3..3706121a 100644 --- a/data/resources/ui/window.blp +++ b/data/resources/ui/window.blp @@ -52,130 +52,136 @@ template $FlWindow: Adw.ApplicationWindow { Gtk.StackPage { name: "main"; - child: Adw.NavigationSplitView split_view { - min-sidebar-width: 300; - max-sidebar-width: 400; - sidebar-width-fraction: 0.25; - notify::show-content => $handle_show_content() swapped; - - sidebar: Adw.NavigationPage { - title: _("Channel list"); - tag: "channel-list"; - - Adw.ToolbarView { - [top] - Adw.HeaderBar { - title-widget: Adw.WindowTitle { - title: "Flare"; - }; + child: Gtk.Overlay image_viewer_overlay { + // Wraps the navigation split view so an image viewer pushed in via + // `Window::present_image_viewer` covers the entire window content + // (sidebar + chat) rather than being constrained to the split + // view's content pane like an Adw.Dialog would be. + Adw.NavigationSplitView split_view { + min-sidebar-width: 300; + max-sidebar-width: 400; + sidebar-width-fraction: 0.25; + notify::show-content => $handle_show_content() swapped; + + sidebar: Adw.NavigationPage { + title: _("Channel list"); + tag: "channel-list"; + + Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.WindowTitle { + title: "Flare"; + }; + + [start] + Gtk.Button { + accessibility { + label: C_("accessibility", "Add Conversation"); + } - [start] - Gtk.Button { - accessibility { - label: C_("accessibility", "Add Conversation"); + tooltip-text: "Add Conversation"; + icon-name: "chat-message-new-symbolic"; + clicked => $handle_add_conversation_clicked() swapped; } - tooltip-text: "Add Conversation"; - icon-name: "chat-message-new-symbolic"; - clicked => $handle_add_conversation_clicked() swapped; - } + [end] + MenuButton { + accessibility { + label: C_("accessibility", "Menu"); + } - [end] - MenuButton { - accessibility { - label: C_("accessibility", "Menu"); + tooltip-text: "Menu"; + menu-model: menubar; + primary: true; + icon-name: "open-menu-symbolic"; } - tooltip-text: "Menu"; - menu-model: menubar; - primary: true; - icon-name: "open-menu-symbolic"; - } + [end] + ToggleButton { + accessibility { + label: C_("accessibility", "Search"); + } - [end] - ToggleButton { - accessibility { - label: C_("accessibility", "Search"); + tooltip-text: "Search"; + icon-name: "system-search-symbolic"; + active: bind channel_list.search-enabled no-sync-create; + clicked => $handle_search_clicked() swapped; } - - tooltip-text: "Search"; - icon-name: "system-search-symbolic"; - active: bind channel_list.search-enabled no-sync-create; - clicked => $handle_search_clicked() swapped; } - } - content: $FlChannelList channel_list { - notify::active-channel => $handle_go_forward() swapped; - manager: bind template.manager; - }; - } - }; - - content: Adw.NavigationPage { - title: _("Chat"); - tag: "chat"; - - Adw.ToolbarView { - [top] - Adw.HeaderBar { - title-widget: Button { - styles [ - "flat", - ] - - sensitive: bind $is_some(channel_list.active-channel) as ; - visible: bind $is_some(channel_list.active-channel) as ; - action-name: "win.channel-information"; - - child: Box { - name: "room_title"; - orientation: vertical; - halign: center; - valign: center; - - Label title_label { - focusable: true; - ellipsize: end; - halign: center; - wrap: false; - single-line-mode: true; - use-markup: false; - width-chars: 5; - label: bind channel_list.active-channel as <$FlChannel>.title; - - styles [ - "title", - ] - } + content: $FlChannelList channel_list { + notify::active-channel => $handle_go_forward() swapped; + manager: bind template.manager; + }; + } + }; - Label subtitle_label { - focusable: true; - ellipsize: end; + content: Adw.NavigationPage { + title: _("Chat"); + tag: "chat"; + + Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Button { + styles [ + "flat", + ] + + sensitive: bind $is_some(channel_list.active-channel) as ; + visible: bind $is_some(channel_list.active-channel) as ; + action-name: "win.channel-information"; + + child: Box { + name: "room_title"; + orientation: vertical; halign: center; - wrap: false; - single-line-mode: true; - use-markup: true; - visible: bind channel_list.active-channel as <$FlChannel>.is-typing; - label: bind channel_list.active-channel as <$FlChannel>.typing-label; - tooltip-markup: bind channel_list.active-channel as <$FlChannel>.description; - - styles [ - "subtitle-room", - "accent", - ] - } + valign: center; + + Label title_label { + focusable: true; + ellipsize: end; + halign: center; + wrap: false; + single-line-mode: true; + use-markup: false; + width-chars: 5; + label: bind channel_list.active-channel as <$FlChannel>.title; + + styles [ + "title", + ] + } + + Label subtitle_label { + focusable: true; + ellipsize: end; + halign: center; + wrap: false; + single-line-mode: true; + use-markup: true; + visible: bind channel_list.active-channel as <$FlChannel>.is-typing; + label: bind channel_list.active-channel as <$FlChannel>.typing-label; + tooltip-markup: bind channel_list.active-channel as <$FlChannel>.description; + + styles [ + "subtitle-room", + "accent", + ] + } + }; }; + } + + content: $FlChannelMessages channel_messages { + manager: bind template.manager; + active-channel: bind channel_list.active-channel; + has-channels: bind channel_list.has-channels; }; } - - content: $FlChannelMessages channel_messages { - manager: bind template.manager; - active-channel: bind channel_list.active-channel; - has-channels: bind channel_list.has-channels; - }; - } - }; + }; + } }; } } diff --git a/src/backend/dummy.rs b/src/backend/dummy.rs index e6824e80..c1aae764 100644 --- a/src/backend/dummy.rs +++ b/src/backend/dummy.rs @@ -382,18 +382,40 @@ 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); - msg_screenshot + // A short question-and-answer in the 1-on-1 chat with contact 2 + // ("Developer") demonstrating the click-to-open image viewer + // (`gui::image_viewer`). The contact sends a photo of an apple + // (bundled into the screenshot-mode build via + // `data/resources/resources.gresource.xml.in` as `dummy_apple.jpg`, + // public domain "Big red apple.jpg" by Paolo Neo via Wikimedia + // Commons) along with a + // text question, and the local user answers with the obvious + // one-word reply. + let msg_fruit_question = msg!( + self, + "what is the name of this fruit?", + 2, + 2, + 30 + base_minute + ); + // `Attachment::from_file` only loads images via filesystem paths; for + // `resource://` URIs `GFile::path()` returns None, so the image + // would never reach the Picture. Load the bytes directly out of the + // gresource bundle into a Texture instead, and build the attachment + // from that. + let fruit_pixbuf = gtk::gdk_pixbuf::Pixbuf::from_resource("/dummy_apple.jpg") + .expect("apple photo resource to load"); + let fruit_texture = gtk::gdk::Texture::for_pixbuf(&fruit_pixbuf); + let fruit_photo = crate::backend::Attachment::from_texture(fruit_texture, self); + msg_fruit_question .clone() .downcast::() .unwrap() - .add_attachment(attachment); + .add_attachment(fruit_photo); + let msg_fruit_answer = msg!(self, "apple", 0, 2, 32 + base_minute); vec![ msg_replied, - // msg_screenshot, msg_reply, msg!( self, @@ -405,6 +427,8 @@ impl super::Manager { msg!(self, "Glad you like it.", 2, GROUP_ID, 25 + base_minute), msg!(self, "YAY!", 0, GROUP_ID, 27 + base_minute), msg!(self, "Greetings", 2, 2, base_minute - 100), + msg_fruit_question, + msg_fruit_answer, msg!(self, "It's a trap!", 3, 3, 1 + base_minute), msg!(self, "Hello, Mr. Anderson", 4, 4, 2 + base_minute), ] diff --git a/src/gui/components/attachment_image.rs b/src/gui/components/attachment_image.rs index a91bf05b..4ddd29b6 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,33 @@ 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; + } + // Walk up to the top-level `crate::gui::Window` and ask it to + // host the viewer in its window-level overlay so the viewer + // covers the entire window content (sidebar + chat) rather + // than being constrained to the navigation split view's + // content pane like an Adw.Dialog would be. + 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..b3e9d720 --- /dev/null +++ b/src/gui/image_viewer.rs @@ -0,0 +1,120 @@ +use crate::prelude::*; + +use crate::backend::Attachment; + +glib::wrapper! { + /// A widget that displays an image attachment over a dark backdrop. It is + /// designed to be inserted into a top-level [`gtk::Overlay`] in + /// [`crate::gui::Window`] (see [`crate::gui::Window::present_image_viewer`]) + /// so that it covers every other widget in the window — sidebar, chat + /// pane, header bar — rather than being constrained to the content area + /// of the surrounding navigation split view (which is what `Adw.Dialog` + /// would force). + /// + /// Emits `closed` when the user dismisses the viewer. Consumers should + /// remove the widget from the overlay in response. + pub struct ImageViewer(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ImageViewer { + pub fn new(attachment: &Attachment) -> Self { + log::trace!("Initializing `ImageViewer`"); + Object::builder::() + .property("attachment", attachment) + .build() + } +} + +pub mod imp { + use crate::prelude::*; + + use glib::subclass::{InitializingObject, Signal}; + use gtk::{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, + } + + #[gtk::template_callbacks] + impl ImageViewer { + /// Click anywhere in the viewer (image or backdrop) closes. The + /// close button stops its own click before it reaches us via the + /// usual GTK click-bubbling, so the button still works. + #[template_callback] + fn on_backdrop_pressed(&self) { + self.obj().emit_by_name::<()>("closed", &[]); + } + + #[template_callback] + fn on_close_clicked(&self) { + self.obj().emit_by_name::<()>("closed", &[]); + } + + /// Close on `Escape`. The viewer grabs focus on construction so the + /// key controller actually sees keystrokes; without that, focus + /// would still be on whatever widget the user clicked on (typically + /// the message row) and `Escape` would route there instead. + #[template_callback] + fn on_key_pressed( + &self, + keyval: gdk::Key, + _keycode: u32, + _state: gdk::ModifierType, + ) -> glib::Propagation { + if keyval == gdk::Key::Escape { + self.obj().emit_by_name::<()>("closed", &[]); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + } + } + + #[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); + Self::bind_template_callbacks(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for ImageViewer { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = + Lazy::new(|| vec![Signal::builder("closed").build()]); + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + // Make the widget focusable so the key controller below is + // actually allowed to receive keystrokes; Adw.Bin defaults to + // not-focusable. + self.obj().set_focusable(true); + self.obj().set_can_focus(true); + } + } + + 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 ce097ce1..bd9474e2 100644 --- a/src/gui/window.rs +++ b/src/gui/window.rs @@ -104,6 +104,33 @@ impl Window { pub(crate) fn settings(&self) -> gio::Settings { self.imp().settings.clone() } + + /// Show an image viewer overlay covering the whole window content area + /// for `attachment`. Removes itself from the overlay when the viewer + /// emits `closed` (on `Escape`, click anywhere inside the viewer, or + /// the close button). + pub fn present_image_viewer(&self, attachment: &crate::backend::Attachment) { + use crate::gui::image_viewer::ImageViewer; + let viewer = ImageViewer::new(attachment); + let overlay = self.imp().image_viewer_overlay.clone(); + overlay.add_overlay(&viewer); + viewer.grab_focus(); + viewer.connect_local( + "closed", + false, + clone!( + #[weak] + overlay, + #[weak] + viewer, + #[upgrade_or_default] + move |_| { + overlay.remove_overlay(&viewer); + None + } + ), + ); + } } pub mod imp { @@ -142,6 +169,8 @@ pub mod imp { #[template_child] split_view: TemplateChild, #[template_child] + pub(super) image_viewer_overlay: TemplateChild, + #[template_child] pub(super) channel_list: TemplateChild, #[template_child] subtitle_label: TemplateChild, @@ -165,6 +194,7 @@ pub mod imp { channel_list: Default::default(), subtitle_label: Default::default(), channel_messages: Default::default(), + image_viewer_overlay: Default::default(), new_channel_dialog: Default::default(), manager: Default::default(), settings: Settings::new(BASE_ID), -- 2.53.0