diff --git a/src/app/mod.rs b/src/app/mod.rs index 1352f7c..fb41d2f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -401,6 +401,10 @@ pub enum AppMessage { /// the full `(author, id)` key so the correct sender's bytes are fetched and /// saved even if another peer reused the same attachment id (Tier C F-12). SaveAttachment(AttachmentKey), + /// Open the click-to-enlarge image lightbox for this attachment (author + id). + OpenImageLightbox(AttachmentKey), + /// Close the image lightbox overlay. + CloseImageLightbox, /// Result of the async save dialog: a status line to show, or None if cancelled. AttachmentSaved(Option), /// Fetch (if needed) and start an inline audio attachment. Carries the full @@ -645,6 +649,9 @@ pub struct AppState { /// `(author, id)` and bounded. Session-only (cleared on leave); never /// persisted. (Tier C F-02 bound + F-12 author keying.) attachments: AttachmentCache, + /// When `Some`, the image lightbox overlay is open showing this attachment + /// (click-to-enlarge from the chat panel). Closed = `None`. + image_lightbox: Option, /// Attachments the user asked to save before the bytes arrived; when the /// fetch completes a save dialog is opened for them. Keyed by `(author, id)` /// so a same-id attachment from a different sender can't trigger the save. @@ -804,6 +811,7 @@ impl AppState { self.chat_messages.clear(); self.chat_input.clear(); self.attachments.clear(); + self.image_lightbox = None; self.pending_saves.clear(); self.pending_plays.clear(); self.invalid_audio.clear(); @@ -949,6 +957,7 @@ impl Default for AppState { recording_started: None, chat_messages: Vec::new(), attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP), + image_lightbox: None, pending_saves: HashSet::new(), pending_plays: HashSet::new(), invalid_audio: HashSet::new(), @@ -1967,6 +1976,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::CloseRegenerateIdentityConfirm => { state.regenerate_identity_confirm_open = false; } + AppMessage::OpenImageLightbox(key) => { + state.image_lightbox = Some(key); + } + AppMessage::CloseImageLightbox => { + state.image_lightbox = None; + } AppMessage::ConfirmRegenerateIdentity => { state.regenerate_identity_confirm_open = false; // The core mints + persists the new key and replies with a fresh @@ -2673,7 +2688,13 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { .send(CoreCommand::SetMicMonitor { enabled, input_device }); } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { - if let Some(action) = state.hotkey_capture.take() { + if state.image_lightbox.is_some() + && key == keyboard::Key::Named(keyboard::key::Named::Escape) + { + // Esc closes the image lightbox (takes priority over hotkeys so a + // user-bound Escape can't fire while the overlay is up). + state.image_lightbox = None; + } else if let Some(action) = state.hotkey_capture.take() { if let Some(binding) = KeyBinding::from_key(&key) { state.config.hotkeys.set_binding(action, Some(binding)); state.config.save(); @@ -3778,30 +3799,35 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { /// through the gaps between panels. This is the registered top-level view. fn view_with_background(state: &AppState) -> Element<'_, AppMessage> { let content = view(state); - let Some(bytes) = state.background_image.clone() else { - return content; - }; - let pal = state.config.theme.palette(); - let dim = state.config.background_dim; - let image_layer = iced::widget::image(cached_image_handle(bytes)) - .content_fit(iced::ContentFit::Cover) - .width(iced::Length::Fill) - .height(iced::Length::Fill); - let scrim = container( - iced::widget::Space::new() + // Compose the base UI (optionally over a background image), then route the + // whole thing through the lightbox overlay so a click-to-enlarge image paints + // on top of everything (background + content). + let composed: Element<'_, AppMessage> = if let Some(bytes) = state.background_image.clone() { + let pal = state.config.theme.palette(); + let dim = state.config.background_dim; + let image_layer = iced::widget::image(cached_image_handle(bytes)) + .content_fit(iced::ContentFit::Cover) .width(iced::Length::Fill) - .height(iced::Length::Fill), - ) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))), - ..Default::default() - }); - iced::widget::stack![image_layer, scrim, content] + .height(iced::Length::Fill); + let scrim = container( + iced::widget::Space::new() + .width(iced::Length::Fill) + .height(iced::Length::Fill), + ) .width(iced::Length::Fill) .height(iced::Length::Fill) - .into() + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))), + ..Default::default() + }); + iced::widget::stack![image_layer, scrim, content] + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .into() + } else { + content + }; + with_image_lightbox(composed, state) } fn view(state: &AppState) -> Element<'_, AppMessage> { @@ -5772,9 +5798,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .into() } else if att.kind == crate::files::AttachmentKind::Image { match key.as_ref().and_then(|k| state.attachments.handle(k)) { - Some(handle) => iced::widget::image(handle.clone()) - .width(iced::Length::Fixed(260.0)) - .into(), + Some(handle) => { + // Click an inline image to open it enlarged in the + // lightbox overlay (pointer cursor signals it's + // interactive). Only wired when we have a valid + // (author, id) key to look the handle back up. + let img = iced::widget::image(handle.clone()) + .width(iced::Length::Fixed(260.0)); + match key { + Some(k) => mouse_area(img) + .interaction(mouse::Interaction::Pointer) + .on_press(AppMessage::OpenImageLightbox(k)) + .into(), + None => img.into(), + } + } None => text(format!("🖼 {} — loading…", att.name)) .size(12) .color(color_subtext) @@ -6922,6 +6960,85 @@ fn with_regenerate_confirm<'a>( .into() } +/// Overlay the click-to-enlarge image lightbox when `state.image_lightbox` is set +/// (W22). Clicking the dimmed backdrop, the enlarged image, or the top-right ✕ +/// button closes it (`CloseImageLightbox`). Modeled on `with_regenerate_confirm`. +fn with_image_lightbox<'a>( + base: Element<'a, AppMessage>, + state: &'a AppState, +) -> Element<'a, AppMessage> { + let Some(key) = state.image_lightbox.as_ref() else { + return base; + }; + // If the cached handle was evicted (Tier C F-02 bound), there's nothing to + // show — fall back to the base UI rather than an empty overlay. + let Some(handle) = state.attachments.handle(key) else { + return base; + }; + let pal = state.config.theme.palette(); + let crust = pal.crust; + let mantle = pal.mantle; + let surface = pal.surface; + let text_c = pal.text; + + // Full-window dim that closes on click (click-outside-to-close). + let backdrop = mouse_area( + container(horizontal_space()) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(Color { a: 0.80, ..crust })), + ..Default::default() + }), + ) + .on_press(AppMessage::CloseImageLightbox); + + // Contain-fit so the image scales down to the window without cropping or + // overflowing, bounded to most of the viewport by the surrounding padding. + let big = iced::widget::image(handle.clone()) + .content_fit(iced::ContentFit::Contain) + .width(iced::Length::Fill) + .height(iced::Length::Fill); + + // ✕ close button, pinned top-right (subtle styling matching the dialog's + // Cancel button). + let close_btn = button(text("✕").size(18)) + .on_press(AppMessage::CloseImageLightbox) + .style(move |_t: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => surface, + _ => mantle, + })), + text_color: text_c, + border: Border { color: surface, width: 1.0, radius: 6.0.into() }, + ..Default::default() + }) + .padding(8); + + // The image sits in its own layer above the backdrop, so clicking the picture + // wouldn't reach the backdrop — wrap it so the image itself also closes. + let image_area = mouse_area( + container(big) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill) + .padding(40), + ) + .on_press(AppMessage::CloseImageLightbox); + + stack![ + base, + backdrop, + image_area, + container(close_btn) + .align_x(iced::alignment::Horizontal::Right) + .align_y(iced::alignment::Vertical::Top) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .padding(16), + ] + .into() +} + /// A small two-pane glyph for the square layout-picker button in the top bar. struct LayoutIcon { fg: Color,