use std::sync::Arc; use galaxy_core::ui::Icon; use galaxy_core::ui::appearance::Appearance; use galaxyui_core::assets::asset_cache::AssetSource; use galaxyui_core::elements::{ CacheOption, Dismiss, DispatchEventResult, EventHandler, Image, Shrinkable, }; use galaxyui_core::keymap::Keystroke; use galaxyui_core::prelude::stack::*; use galaxyui_core::prelude::*; use pathfinder_geometry::vector::{Vector2F, vec2f}; use crate::{Component, Options as _, button}; /// Padding between the scrim edge and the image. const SCRIM_PADDING: f32 = 48.; /// Spacing between the image/loading area and the description text. const DESCRIPTION_SPACING: f32 = 12.; const LIGHTBOX_TEXT_SIZE_DELTA: f32 = 4.; /// Semi-transparent black background color for the scrim. fn scrim_color() -> ColorU { ColorU::new(0, 0, 0, 230) } /// The loading state of a lightbox image. #[derive(Clone, Debug)] pub enum LightboxImageSource { /// The image metadata is still being fetched. Loading, /// The image source has been resolved. /// Note: the actual image bytes may still be loading via the `AssetCache`. Resolved { asset_source: AssetSource }, } /// A single image entry in the lightbox. #[derive(Clone, Debug)] pub struct LightboxImage { /// The loading/loaded state of this image. pub source: LightboxImageSource, /// Optional description displayed below the image. pub description: Option, } /// Direction for navigating between images. #[derive(Clone, Copy, Debug)] pub enum NavigationDirection { Previous, Next, } /// A handler invoked when the user navigates between images. pub type NavigateHandler = Arc; /// A lightbox component for displaying images in a full-window overlay. /// /// The lightbox displays one or more images centered on screen with a semi-transparent scrim /// background. It supports navigating between images via arrow buttons and can be dismissed by /// clicking outside the image, clicking the close button, or pressing Escape. #[derive(Default)] pub struct Lightbox { close_button: button::Button, prev_button: button::Button, next_button: button::Button, } pub struct Params<'a> { /// The list of images to display. pub images: &'a [LightboxImage], /// The index of the currently displayed image. pub current_index: usize, /// Handler to invoke when the lightbox is dismissed. pub on_dismiss: DismissHandler, /// The native pixel dimensions of the currently displayed image, if known. /// When `Some`, the image is fully loaded and the lightbox renders it with a /// `ConstrainedBox` plus description. When `None`, the lightbox shows a loading /// indicator instead. pub current_image_native_size: Option, /// Optional configuration for the lightbox. pub options: Options, } impl crate::Params for Params<'_> { type Options<'a> = Options; } /// A function that handles dismiss events. pub type DismissHandler = Arc; pub struct Options { /// Optional keystroke associated with the dismiss action. This will be rendered alongside /// the dismiss button in the dialog, but the caller is responsible for adding a keybinding. pub dismiss_keystroke: Option, /// Handler to invoke when the user navigates between images. /// If `None`, navigation buttons are not shown. pub on_navigate: Option, } impl crate::Options for Options { fn default(_appearance: &Appearance) -> Self { Self { dismiss_keystroke: None, on_navigate: None, } } } impl Component for Lightbox { type Params<'a> = Params<'a>; fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box { let on_dismiss_for_button = params.on_dismiss.clone(); let on_dismiss = params.on_dismiss; let image_count = params.images.len(); let current_index = params.current_index; // Extract current image data via direct indexing. let current_image = params.images.get(current_index); let current_source = current_image.map(|img| &img.source); let current_description = current_image.and_then(|img| img.description.clone()); let text_size = lightbox_text_size(appearance); // Close button in the top-right corner. let close_button = self.close_button.render( appearance, button::Params { content: button::Content::Icon(Icon::X), theme: &ButtonTheme, options: button::Options { size: button::Size::Small, on_click: Some(Box::new(move |ctx, app, _| { on_dismiss_for_button(ctx, app); })), keystroke: params.options.dismiss_keystroke, ..button::Options::default(appearance) }, }, ); // Build the central content based on the image source and whether the // native size is known (i.e. the image data has been loaded). let central_content: Box = match (current_source, params.current_image_native_size) { // Image source resolved AND native size known → render the image. (Some(LightboxImageSource::Resolved { asset_source }), Some(native_size)) => { let image = ConstrainedBox::new( Image::new(asset_source.clone(), CacheOption::Original) .contain() .layout_using_paint_bounds() .before_load(Align::new(loading_element(appearance)).finish()) .finish(), ) .with_max_width(native_size.x()) .with_max_height(native_size.y()) .finish(); EventHandler::new(image) .on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation) .finish() } // No images provided at all. _ if image_count == 0 => { Text::new("No images", appearance.ui_font_family(), text_size) .with_color(ColorU::white()) .finish() } // Still loading (either metadata or image bytes). _ => loading_element(appearance), }; // Show the description only when the image is fully loaded (native size known). let content_with_description = if let (Some(description), Some(_)) = (current_description, params.current_image_native_size) { let description_text = Text::new(description, appearance.ui_font_family(), text_size) .with_color(ColorU::white()) .finish(); Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(DESCRIPTION_SPACING) .with_child(Shrinkable::new(1.0, central_content).finish()) .with_child(description_text) .finish() } else { central_content }; let centered_content = Align::new(content_with_description).finish(); let scrim = Container::new( Dismiss::new(centered_content) .prevent_interaction_with_other_elements() .on_dismiss(move |ctx, app| on_dismiss(ctx, app)) .finish(), ) .with_background_color(scrim_color()) .with_uniform_padding(SCRIM_PADDING) .finish(); // Stack the scrim, close button, and optional navigation arrows. let mut content = Stack::new().with_child(scrim); content.add_positioned_child( close_button, OffsetPositioning::offset_from_parent( vec2f(-12., 12.), ParentOffsetBounds::Unbounded, ParentAnchor::TopRight, ChildAnchor::TopRight, ), ); // Navigation arrows (only shown when there are multiple images). if image_count > 1 && let Some(on_navigate) = params.options.on_navigate { // Previous button (hidden on first image). if current_index > 0 { let on_nav = on_navigate.clone(); let prev_button = self.prev_button.render( appearance, button::Params { content: button::Content::Icon(Icon::ChevronLeft), theme: &ButtonTheme, options: button::Options { size: button::Size::Small, on_click: Some(Box::new(move |ctx, app, _| { on_nav(NavigationDirection::Previous, ctx, app); })), ..button::Options::default(appearance) }, }, ); content.add_positioned_child( prev_button, OffsetPositioning::offset_from_parent( vec2f(12., 0.), ParentOffsetBounds::Unbounded, ParentAnchor::MiddleLeft, ChildAnchor::MiddleLeft, ), ); } // Next button (hidden on last image). if current_index < image_count - 1 { let on_nav = on_navigate; let next_button = self.next_button.render( appearance, button::Params { content: button::Content::Icon(Icon::ChevronRight), theme: &ButtonTheme, options: button::Options { size: button::Size::Small, on_click: Some(Box::new(move |ctx, app, _| { on_nav(NavigationDirection::Next, ctx, app); })), ..button::Options::default(appearance) }, }, ); content.add_positioned_child( next_button, OffsetPositioning::offset_from_parent( vec2f(-12., 0.), ParentOffsetBounds::Unbounded, ParentAnchor::MiddleRight, ChildAnchor::MiddleRight, ), ); } } content.finish() } } /// Builds the shared "Loading..." text element used in both the `Loading` state /// and as the `before_load` fallback while the `AssetCache` fetches image bytes. fn loading_element(appearance: &Appearance) -> Box { Text::new( "Loading...", appearance.ui_font_family(), lightbox_text_size(appearance), ) .with_color(ColorU::white()) .finish() } fn lightbox_text_size(appearance: &Appearance) -> f32 { appearance.ui_font_size() + LIGHTBOX_TEXT_SIZE_DELTA } /// A custom button theme for lightbox buttons to force colors to match /// a Dark theme button, as these buttons always appear on top of a near-black /// scrim, independent of application theme. struct ButtonTheme; impl button::Theme for ButtonTheme { fn background( &self, button_state: button::State, _appearance: &Appearance, ) -> Option { match button_state { button::State::Default => None, button::State::Hovered => Some(galaxy_core::ui::theme::Fill::white().with_opacity(10)), button::State::Pressed => Some(galaxy_core::ui::theme::Fill::white().with_opacity(15)), } } fn text_color( &self, _background: Option, _appearance: &Appearance, ) -> ColorU { ColorU::new(255, 255, 255, 255) } fn border(&self, _appearance: &Appearance) -> Option { Some(ColorU::new(51, 51, 51, 255)) } fn keyboard_shortcut_background(&self, _appearance: &Appearance) -> Option { Some(ColorU::new(38, 38, 38, 255)) } }