Merge W22: click-to-enlarge image lightbox for chat images
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+142
-25
@@ -401,6 +401,10 @@ pub enum AppMessage {
|
|||||||
/// the full `(author, id)` key so the correct sender's bytes are fetched and
|
/// 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).
|
/// saved even if another peer reused the same attachment id (Tier C F-12).
|
||||||
SaveAttachment(AttachmentKey),
|
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.
|
/// Result of the async save dialog: a status line to show, or None if cancelled.
|
||||||
AttachmentSaved(Option<String>),
|
AttachmentSaved(Option<String>),
|
||||||
/// Fetch (if needed) and start an inline audio attachment. Carries the full
|
/// 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
|
/// `(author, id)` and bounded. Session-only (cleared on leave); never
|
||||||
/// persisted. (Tier C F-02 bound + F-12 author keying.)
|
/// persisted. (Tier C F-02 bound + F-12 author keying.)
|
||||||
attachments: AttachmentCache,
|
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<AttachmentKey>,
|
||||||
/// Attachments the user asked to save before the bytes arrived; when the
|
/// 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)`
|
/// 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.
|
/// 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_messages.clear();
|
||||||
self.chat_input.clear();
|
self.chat_input.clear();
|
||||||
self.attachments.clear();
|
self.attachments.clear();
|
||||||
|
self.image_lightbox = None;
|
||||||
self.pending_saves.clear();
|
self.pending_saves.clear();
|
||||||
self.pending_plays.clear();
|
self.pending_plays.clear();
|
||||||
self.invalid_audio.clear();
|
self.invalid_audio.clear();
|
||||||
@@ -949,6 +957,7 @@ impl Default for AppState {
|
|||||||
recording_started: None,
|
recording_started: None,
|
||||||
chat_messages: Vec::new(),
|
chat_messages: Vec::new(),
|
||||||
attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP),
|
attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP),
|
||||||
|
image_lightbox: None,
|
||||||
pending_saves: HashSet::new(),
|
pending_saves: HashSet::new(),
|
||||||
pending_plays: HashSet::new(),
|
pending_plays: HashSet::new(),
|
||||||
invalid_audio: HashSet::new(),
|
invalid_audio: HashSet::new(),
|
||||||
@@ -1967,6 +1976,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
AppMessage::CloseRegenerateIdentityConfirm => {
|
AppMessage::CloseRegenerateIdentityConfirm => {
|
||||||
state.regenerate_identity_confirm_open = false;
|
state.regenerate_identity_confirm_open = false;
|
||||||
}
|
}
|
||||||
|
AppMessage::OpenImageLightbox(key) => {
|
||||||
|
state.image_lightbox = Some(key);
|
||||||
|
}
|
||||||
|
AppMessage::CloseImageLightbox => {
|
||||||
|
state.image_lightbox = None;
|
||||||
|
}
|
||||||
AppMessage::ConfirmRegenerateIdentity => {
|
AppMessage::ConfirmRegenerateIdentity => {
|
||||||
state.regenerate_identity_confirm_open = false;
|
state.regenerate_identity_confirm_open = false;
|
||||||
// The core mints + persists the new key and replies with a fresh
|
// The core mints + persists the new key and replies with a fresh
|
||||||
@@ -2673,7 +2688,13 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
.send(CoreCommand::SetMicMonitor { enabled, input_device });
|
.send(CoreCommand::SetMicMonitor { enabled, input_device });
|
||||||
}
|
}
|
||||||
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
|
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) {
|
if let Some(binding) = KeyBinding::from_key(&key) {
|
||||||
state.config.hotkeys.set_binding(action, Some(binding));
|
state.config.hotkeys.set_binding(action, Some(binding));
|
||||||
state.config.save();
|
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.
|
/// through the gaps between panels. This is the registered top-level view.
|
||||||
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
|
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
|
||||||
let content = view(state);
|
let content = view(state);
|
||||||
let Some(bytes) = state.background_image.clone() else {
|
// Compose the base UI (optionally over a background image), then route the
|
||||||
return content;
|
// whole thing through the lightbox overlay so a click-to-enlarge image paints
|
||||||
};
|
// on top of everything (background + content).
|
||||||
let pal = state.config.theme.palette();
|
let composed: Element<'_, AppMessage> = if let Some(bytes) = state.background_image.clone() {
|
||||||
let dim = state.config.background_dim;
|
let pal = state.config.theme.palette();
|
||||||
let image_layer = iced::widget::image(cached_image_handle(bytes))
|
let dim = state.config.background_dim;
|
||||||
.content_fit(iced::ContentFit::Cover)
|
let image_layer = iced::widget::image(cached_image_handle(bytes))
|
||||||
.width(iced::Length::Fill)
|
.content_fit(iced::ContentFit::Cover)
|
||||||
.height(iced::Length::Fill);
|
|
||||||
let scrim = container(
|
|
||||||
iced::widget::Space::new()
|
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.height(iced::Length::Fill),
|
.height(iced::Length::Fill);
|
||||||
)
|
let scrim = container(
|
||||||
.width(iced::Length::Fill)
|
iced::widget::Space::new()
|
||||||
.height(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.style(move |_: &Theme| container::Style {
|
.height(iced::Length::Fill),
|
||||||
background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))),
|
)
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
iced::widget::stack![image_layer, scrim, content]
|
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.height(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> {
|
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||||
@@ -5772,9 +5798,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.into()
|
.into()
|
||||||
} else if att.kind == crate::files::AttachmentKind::Image {
|
} else if att.kind == crate::files::AttachmentKind::Image {
|
||||||
match key.as_ref().and_then(|k| state.attachments.handle(k)) {
|
match key.as_ref().and_then(|k| state.attachments.handle(k)) {
|
||||||
Some(handle) => iced::widget::image(handle.clone())
|
Some(handle) => {
|
||||||
.width(iced::Length::Fixed(260.0))
|
// Click an inline image to open it enlarged in the
|
||||||
.into(),
|
// 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))
|
None => text(format!("🖼 {} — loading…", att.name))
|
||||||
.size(12)
|
.size(12)
|
||||||
.color(color_subtext)
|
.color(color_subtext)
|
||||||
@@ -6922,6 +6960,85 @@ fn with_regenerate_confirm<'a>(
|
|||||||
.into()
|
.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.
|
/// A small two-pane glyph for the square layout-picker button in the top bar.
|
||||||
struct LayoutIcon {
|
struct LayoutIcon {
|
||||||
fg: Color,
|
fg: Color,
|
||||||
|
|||||||
Reference in New Issue
Block a user