From e51b874efed05f64d3d07fa80a16474d21b83e74 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 10 Jul 2025 11:45:27 +0800 Subject: [PATCH 01/60] move MEDIA_CACHE to global --- src/home/room_screen.rs | 18 ++++++------------ src/media_cache.rs | 42 ++++++++++++++++++++++++++--------------- src/sliding_sync.rs | 4 ++-- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index c3a91239..126742b2 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -19,7 +19,7 @@ use matrix_sdk_ui::timeline::{ }; use crate::{ - app::RoomsPanelRestoreAction, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, editing_pane::EditingPaneState, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef}, location::init_location_subscriber, media_cache::{MediaCache, MediaCacheEntry}, profile::{ + app::RoomsPanelRestoreAction, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, editing_pane::EditingPaneState, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef}, location::init_location_subscriber, media_cache::{MediaCacheEntry, get_media_cache}, profile::{ user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, shared::{ @@ -1339,7 +1339,6 @@ impl Widget for RoomScreen { event_tl_item, msg_like_content, prev_event, - &mut tl_state.media_cache, &tl_state.user_power, item_drawn_status, room_screen_widget_uid, @@ -2323,7 +2322,6 @@ impl RoomScreen { profile_drawn_since_last_update: RangeSet::new(), update_receiver, request_sender, - media_cache: MediaCache::new(Some(update_sender)), replying_to: None, saved_state: SavedState::default(), message_highlight_animation_state: MessageHighlightAnimationState::default(), @@ -2332,6 +2330,10 @@ impl RoomScreen { scrolled_past_read_marker: false, latest_own_user_receipt: None, }; + + // Add this timeline's update sender to the global MediaCache + get_media_cache().lock().unwrap().add_timeline_update_sender(update_sender); + (tl_state, true) }; @@ -2834,10 +2836,6 @@ struct TimelineUiState { /// to the background async task that handles this room's timeline updates. request_sender: TimelineRequestSender, - /// The cache of media items (images, videos, etc.) that appear in this timeline. - /// - /// Currently this excludes avatars, as those are shared across multiple rooms. - media_cache: MediaCache, /// Info about the event currently being replied to, if any. replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, @@ -3001,7 +2999,6 @@ fn populate_message_view( event_tl_item: &EventTimelineItem, msg_like_content: &MsgLikeContent, prev_event: Option<&Arc>, - media_cache: &mut MediaCache, user_power_levels: &UserPowerLevels, item_drawn_status: ItemDrawnStatus, room_screen_widget_uid: WidgetUid, @@ -3200,7 +3197,6 @@ fn populate_message_view( image_info, image.source.clone(), msg.body(), - media_cache, ); new_drawn_status.content_drawn = is_image_fully_drawn; (item, false) @@ -3353,7 +3349,6 @@ fn populate_message_view( Some(Box::new(image_info.clone())), MediaSource::Plain(owned_mxc_url.clone()), body, - media_cache, ); new_drawn_status.content_drawn = is_image_fully_drawn; (item, false) @@ -3521,7 +3516,6 @@ fn populate_image_message_content( image_info_source: Option>, original_source: MediaSource, body: &str, - media_cache: &mut MediaCache, ) -> bool { // We don't use thumbnails, as their resolution is too low to be visually useful. // We also don't trust the provided mimetype, as it can be incorrect. @@ -3546,7 +3540,7 @@ fn populate_image_message_content( // A closure that fetches and shows the image from the given `mxc_uri`, // marking it as fully drawn if the image was available. let mut fetch_and_show_image_uri = |cx: &mut Cx2d, mxc_uri: OwnedMxcUri, image_info: Box| { - match media_cache.try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) { + match get_media_cache().lock().unwrap().try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) { (MediaCacheEntry::Loaded(data), _media_format) => { let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { utils::load_png_or_jpg(&img, cx, &data) diff --git a/src/media_cache.rs b/src/media_cache.rs index ae071011..8ff42def 100644 --- a/src/media_cache.rs +++ b/src/media_cache.rs @@ -1,4 +1,4 @@ -use std::{collections::{btree_map::Entry, BTreeMap}, ops::{Deref, DerefMut}, sync::{Arc, Mutex}, time::SystemTime}; +use std::{collections::{btree_map::Entry, BTreeMap}, ops::{Deref, DerefMut}, sync::{Arc, Mutex, OnceLock}, time::SystemTime}; use makepad_widgets::{error, log, SignalToUI}; use matrix_sdk::{media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings}, ruma::{events::room::MediaSource, OwnedMxcUri}}; use crate::{home::room_screen::TimelineUpdate, sliding_sync::{self, MatrixRequest}}; @@ -32,8 +32,8 @@ pub type MediaCacheEntryRef = Arc>; pub struct MediaCache { /// The actual cached data. cache: BTreeMap, - /// A channel to send updates to a particular timeline when a media request has completed. - timeline_update_sender: Option>, + /// A list of channels to send updates to various timelines when a media request has completed. + timeline_update_senders: Vec>, } impl Deref for MediaCache { type Target = BTreeMap; @@ -48,20 +48,24 @@ impl DerefMut for MediaCache { } impl MediaCache { - /// Creates a new media cache that will use the given media format - /// when fetching media from the server. - /// - /// It will also optionally send updates to the given timeline update sender - /// when a media request has completed. - pub const fn new( - timeline_update_sender: Option>, - ) -> Self { + /// Creates a new media cache. + pub const fn new() -> Self { Self { cache: BTreeMap::new(), - timeline_update_sender, + timeline_update_senders: Vec::new(), } } + /// Add a timeline update sender to receive notifications when media requests complete. + pub fn add_timeline_update_sender(&mut self, sender: crossbeam_channel::Sender) { + self.timeline_update_senders.push(sender); + } + + /// Remove a timeline update sender. + pub fn remove_timeline_update_sender(&mut self, sender: &crossbeam_channel::Sender) { + self.timeline_update_senders.retain(|s| !std::ptr::eq(s, sender)); + } + /// Tries to get the media from the cache, or submits an async request to fetch it. /// /// This method *does not* block or wait for the media to be fetched, @@ -158,7 +162,7 @@ impl MediaCache { }, on_fetched: insert_into_cache, destination: entry_ref, - update_sender: self.timeline_update_sender.clone(), + update_sender: self.timeline_update_senders.clone(), } ); post_request_retval @@ -170,7 +174,7 @@ fn insert_into_cache>>( value_ref: &Mutex, _request: MediaRequestParameters, data: matrix_sdk::Result, - update_sender: Option>, + update_senders: Vec>, ) { let new_value = match data { Ok(data) => { @@ -202,8 +206,16 @@ fn insert_into_cache>>( *value_ref.lock().unwrap() = new_value; - if let Some(sender) = update_sender { + for sender in update_senders { let _ = sender.send(TimelineUpdate::MediaFetched); } SignalToUI::set_ui_signal(); } + +/// Global media cache instance shared across all rooms. +static MEDIA_CACHE: OnceLock>> = OnceLock::new(); + +/// Get a reference to the global media cache. +pub fn get_media_cache() -> &'static Arc> { + MEDIA_CACHE.get_or_init(|| Arc::new(Mutex::new(MediaCache::new()))) +} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 191b86f6..ea3c8125 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -220,7 +220,7 @@ pub type OnMediaFetchedFn = fn( &Mutex, MediaRequestParameters, matrix_sdk::Result>, - Option>, + Vec>, ); @@ -312,7 +312,7 @@ pub enum MatrixRequest { media_request: MediaRequestParameters, on_fetched: OnMediaFetchedFn, destination: MediaCacheEntryRef, - update_sender: Option>, + update_sender: Vec>, }, /// Request to send a message to the given room. SendMessage { From e1d2aaf7049950dd3a1735dfdac741b9ef2654f9 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Wed, 16 Jul 2025 14:46:13 +0800 Subject: [PATCH 02/60] some testing --- src/app.rs | 21 ++- src/home/room_screen.rs | 48 +++++- src/login/login_screen.rs | 3 + src/shared/image_viewer_modal.rs | 242 +++++++++++++++++++++++++++++++ src/shared/mod.rs | 2 + src/shared/text_or_image.rs | 59 +++++++- 6 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 src/shared/image_viewer_modal.rs diff --git a/src/app.rs b/src/app.rs index ab824816..11208832 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use makepad_widgets::{makepad_micro_serde::*, *}; -use matrix_sdk::ruma::{OwnedRoomId, RoomId}; +use matrix_sdk::ruma::{OwnedMxcUri, OwnedRoomId, RoomId}; use crate::{ - home::{main_desktop_ui::MainDesktopUiAction, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_screen::MessageAction, rooms_list::RoomsListAction}, join_leave_room_modal::{JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt}, login::login_screen::LoginAction, persistent_state::{load_window_state, save_room_panel, save_window_state}, shared::callout_tooltip::{CalloutTooltipOptions, CalloutTooltipWidgetRefExt, TooltipAction}, sliding_sync::current_user_id, utils::{room_name_or_id, OwnedRoomIdRon}, verification::VerificationAction, verification_modal::{VerificationModalAction, VerificationModalWidgetRefExt} + home::{main_desktop_ui::MainDesktopUiAction, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_screen::MessageAction, rooms_list::RoomsListAction}, join_leave_room_modal::{JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt}, login::login_screen::LoginAction, persistent_state::{load_window_state, save_room_panel, save_window_state}, shared::{callout_tooltip::{CalloutTooltipOptions, CalloutTooltipWidgetRefExt, TooltipAction}, image_viewer_modal::{set_global_image_viewer_modal, ImageViewerModalWidgetRefExt}}, sliding_sync::current_user_id, utils::{room_name_or_id, OwnedRoomIdRon}, verification::VerificationAction, verification_modal::{VerificationModalAction, VerificationModalWidgetRefExt} }; use serde::{self, Deserialize, Serialize}; @@ -26,7 +26,7 @@ live_design! { use crate::shared::popup_list::PopupList; use crate::home::new_message_context_menu::*; use crate::shared::callout_tooltip::CalloutTooltip; - + use crate::shared::image_viewer_modal::ImageViewerModal; APP_TAB_COLOR = #344054 APP_TAB_COLOR_HOVER = #636e82 @@ -145,7 +145,7 @@ live_design! { width: Fill, height: Fill, flow: Overlay, home_screen_view = { - visible: false + visible: true home_screen = {} } join_leave_modal = { @@ -154,7 +154,7 @@ live_design! { } } login_screen_view = { - visible: true + visible: false login_screen = {} } app_tooltip = {} @@ -171,6 +171,8 @@ live_design! { verification_modal_inner = {} } } + + image_viewer_modal = {} } } // end of body } @@ -220,7 +222,8 @@ impl MatchEvent for App { log!("App::handle_startup(): app_data_dir: {:?}", _app_data_dir); self.update_login_visibility(cx); - + set_global_image_viewer_modal(cx, self.ui.image_viewer_modal(id!(image_viewer_modal))); + //self.ui.image_viewer_modal(id!(image_viewer_modal)).open(cx, None); log!("App::handle_startup(): starting matrix sdk loop"); crate::sliding_sync::start_matrix_tokio().unwrap(); if let Err(e) = load_window_state(self.ui.window(id!(main_window)), cx) { @@ -362,6 +365,12 @@ impl MatchEvent for App { continue; } + match action.as_widget_action().cast() { + crate::shared::image_viewer_modal::ImageViewerAction::Clicked(mxc_uri) => { + self.ui.image_viewer_modal(id!(image_viewer_modal)).open(cx, None); + } + _ => {} + } // // message source modal handling. // match action.as_widget_action().cast() { // MessageAction::MessageSourceModalOpen { room_id: _, event_id: _, original_json: _ } => { diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 5855d560..6c7098ed 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -19,11 +19,11 @@ use matrix_sdk_ui::timeline::{ }; use crate::{ - app::RoomsPanelRestoreAction, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, editing_pane::EditingPaneState, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef}, location::init_location_subscriber, media_cache::{MediaCacheEntry, get_media_cache}, profile::{ + app::RoomsPanelRestoreAction, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, editing_pane::EditingPaneState, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef}, location::init_location_subscriber, media_cache::{get_media_cache, MediaCacheEntry}, profile::{ user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, shared::{ - avatar::AvatarWidgetRefExt, callout_tooltip::TooltipAction, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{enqueue_popup_notification, PopupItem}, styles::COLOR_DANGER_RED, text_or_image::{TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt, typing_animation::TypingAnimationWidgetExt + avatar::AvatarWidgetRefExt, callout_tooltip::TooltipAction, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer_modal::{set_global_image_viewer_modal, ImageViewerAction, ImageViewerModalRef, ImageViewerModalWidgetExt}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{enqueue_popup_notification, PopupItem}, styles::COLOR_DANGER_RED, text_or_image::{TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt, typing_animation::TypingAnimationWidgetExt }, sliding_sync::{get_client, submit_async_request, take_timeline_endpoints, BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineRequestSender, UserPowerLevels}, utils::{self, room_name_or_id, unix_time_millis_to_datetime, ImageFormat, MEDIA_THUMBNAIL_FORMAT} }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; @@ -70,7 +70,8 @@ live_design! { use crate::room::room_input_bar::*; use crate::home::room_read_receipt::*; use crate::rooms_list::*; - + use crate::shared::image_viewer_modal::ImageViewerModal; + IMG_DEFAULT_AVATAR = dep("crate://self/resources/img/default_avatar.png") ICO_LOCATION_PERSON = dep("crate://self/resources/icons/location-person.svg") @@ -759,10 +760,10 @@ live_design! { // The user profile sliding pane should be displayed on top of other "static" subviews // (on top of all other views that are always visible). user_profile_sliding_pane = { } - + image_viewer_modal = {} // The loading pane appears while the user is waiting for something in the room screen // to finish loading, e.g., when loading an older replied-to message. - loading_pane = { } + //loading_pane = { } /* @@ -876,6 +877,7 @@ impl Widget for RoomScreen { // and wrap it in a `if let Event::Signal` conditional. user_profile_cache::process_user_profile_updates(cx); avatar_cache::process_avatar_updates(cx); + } if let Event::Actions(actions) = event { @@ -985,6 +987,12 @@ impl Widget for RoomScreen { ); } } + if let Some(crate::shared::image_viewer_modal::ImageViewerAction::Test) = action.downcast_ref() { + println!("test"); + self.view.image_viewer_modal(id!(image_viewer_modal)).open(cx, None); + } + + } /* @@ -1196,6 +1204,8 @@ impl Widget for RoomScreen { // Forward the event to the inner timeline view, but capture any actions it produces // such that we can handle the ones relevant to only THIS RoomScreen widget right here and now, // ensuring they are not mistakenly handled by other RoomScreen widget instances. + self.view.handle_event(cx, event, &mut room_scope); + return; let mut actions_generated_within_this_room_screen = cx.capture_actions(|cx| self.view.handle_event(cx, event, &mut room_scope) ); @@ -3546,7 +3556,7 @@ fn populate_image_message_content( let mut fetch_and_show_image_uri = |cx: &mut Cx2d, mxc_uri: OwnedMxcUri, image_info: Box| { match get_media_cache().lock().unwrap().try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) { (MediaCacheEntry::Loaded(data), _media_format) => { - let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { + let show_image_result = text_or_image_ref.show_image(cx, mxc_uri.clone(),|cx, img| { utils::load_png_or_jpg(&img, cx, &data) .map(|()| img.size_in_pixels(cx).unwrap_or_default()) }); @@ -3554,6 +3564,13 @@ fn populate_image_message_content( let err_str = format!("{body}\n\nFailed to display image: {e:?}"); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); + } else { + // Add click handler for the image + let mxc_uri_clone = mxc_uri.clone(); + // text_or_image_ref.set_image_click_handler(cx, move |cx| { + // let image_viewer_modal = cx.get_global::(); + // image_viewer_modal.open(cx, mxc_uri_clone.clone()); + // }); } // We're done drawing the image, so mark it as fully drawn. @@ -3561,7 +3578,7 @@ fn populate_image_message_content( } (MediaCacheEntry::Requested, _media_format) => { if let (Some(ref blurhash), Some(width), Some(height)) = (image_info.blurhash.clone(), image_info.width, image_info.height) { - let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { + let show_image_result = text_or_image_ref.show_image(cx, mxc_uri.clone(), |cx, img| { let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) else { return Err(image_cache::ImageError::EmptyData)}; if let Ok(data) = blurhash::decode(blurhash, width, height, 1.0) { ImageBuffer::new(&data, width as usize, height as usize).map(|img_buff| { @@ -3577,6 +3594,13 @@ fn populate_image_message_content( let err_str = format!("{body}\n\nFailed to display image: {e:?}"); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); + } else { + // Add click handler for the blurhash image + let mxc_uri_clone = mxc_uri.clone(); + // text_or_image_ref.set_image_click_handler(cx, move |cx| { + // let image_viewer_modal = cx.get_global::(); + // image_viewer_modal.open(cx, mxc_uri_clone.clone()); + // }); } } fully_drawn = false; @@ -4245,8 +4269,12 @@ impl Widget for Message { // clickable or otherwise interactive. match event.hits(cx, self.view(id!(replied_to_message)).area()) { Hit::FingerDown(fe) => { - cx.set_key_focus(self.view(id!(replied_to_message)).area()); + // cx.set_key_focus(self.view(id!(replied_to_message)).area()); + // println!("Opening context menu for replied-to message preview"); + //self.view.image_viewer_modal(id!(image_viewer_modal)).open(cx, None); if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { + println!("Opening context menu for replied-to message preview"); + self.view.image_viewer_modal(id!(image_viewer_modal)).open(cx, None); cx.widget_action( details.room_screen_widget_uid, &scope.path, @@ -4255,6 +4283,7 @@ impl Widget for Message { abs_pos: fe.abs, } ); + } } Hit::FingerLongPress(lp) => { @@ -4293,8 +4322,11 @@ impl Widget for Message { match event.hits(cx, message_view_area) { Hit::FingerDown(fe) => { cx.set_key_focus(message_view_area); + // A right click means we should display the context menu. if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { + println!("Opening context menu for replied-to message preview"); + cx.action(ImageViewerAction::Test); cx.widget_action( details.room_screen_widget_uid, &scope.path, diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 9b5ebb5d..3c0c7b90 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -329,6 +329,9 @@ impl MatchEvent for LoginScreen { || password_input.returned(actions).is_some() || homeserver_input.returned(actions).is_some() { + // let image_viewer_modal = crate::shared::image_viewer_modal::get_global_image_viewer_modal(cx); + // image_viewer_modal.open(cx, None); + // return; let user_id = user_id_input.text(); let password = password_input.text(); let homeserver = homeserver_input.text(); diff --git a/src/shared/image_viewer_modal.rs b/src/shared/image_viewer_modal.rs new file mode 100644 index 00000000..03ae6cd3 --- /dev/null +++ b/src/shared/image_viewer_modal.rs @@ -0,0 +1,242 @@ +use makepad_widgets::*; +use matrix_sdk::{media::MediaFormat, ruma::OwnedMxcUri}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::{ + media_cache::{get_media_cache, MediaCacheEntry}, + utils::{ImageFormat, load_png_or_jpg}, +}; + +live_design! { + use link::theme::*; + use link::widgets::*; + + use crate::shared::styles::*; + use crate::shared::icon_button::RobrixIconButton; + + pub ImageViewerModal = {{ImageViewerModal}} { + + image_modal = { + content: { + flow: Down + width: 100 + height: 100 + padding: {top: 20, right: 20, bottom: 20, left: 20} + spacing: 10 + + show_bg: true + draw_bg: { + color: #000 + border_radius: 8.0 + } + + header = { + width: Fill, + height: Fit, + flow: Right + padding: {bottom: 10} + align: {x: 1.0, y: 0.0} + + // close_button = { + // width: 30, + // height: 30, + // padding: 5, + // draw_icon: { + // svg_file: dep("crate://self/resources/icons/close.svg"), + // color: #fff + // } + // icon_walk: {width: 16, height: 16} + // draw_bg: { + // color: #444, + // border_radius: 4.0 + // } + // } + } + + image_container = { + width: Fill, + height: Fill, + align: {x: 0.5, y: 0.5} + + image = { + width: Fit, + height: Fit, + fit: Best, + source: EmptyTexture, + } + + loading_label =