Skip to content

Commit

Permalink
Add TextOrImage widget that can be used to display an image or text (
Browse files Browse the repository at this point in the history
…#47)

* Use this widget to display "fetching image..." while an image is being fetched,
  and also for displaying an error if the image fails to load.

* This allows us to use the same DSL widget view for image messages
  regardless of whether they can be successfully displayed or not.
  * This simplifies caching of widgets both internal and external to a timeline's portallist,
    which will be implemented in an upcoming PR.

* Change `MediaCache` functions to always return a `MediaCacheEntry`,
  which allows callers to distinguish between all three states of an image
  having been requested, having been fully fetched, and having failed to fetch.
  • Loading branch information
kevinaboos authored Feb 17, 2024
1 parent daa5063 commit 38cdef9
Show file tree
Hide file tree
Showing 7 changed files with 188 additions and 76 deletions.
1 change: 1 addition & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ impl LiveRegister for App {
crate::shared::dropdown_menu::live_design(cx);
crate::shared::clickable_view::live_design(cx);
crate::shared::avatar::live_design(cx);
crate::shared::text_or_image::live_design(cx);

// home - chats
crate::home::home_screen::live_design(cx);
Expand Down
113 changes: 58 additions & 55 deletions src/home/room_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ use matrix_sdk_ui::timeline::{

use unicode_segmentation::UnicodeSegmentation;
use crate::{
media_cache::{MediaCache, AVATAR_CACHE},
shared::avatar::{AvatarWidgetRefExt, AvatarRef},
sliding_sync::{submit_async_request, MatrixRequest, take_timeline_update_receiver},
utils::{unix_time_millis_to_datetime, self, MediaFormatConst},
media_cache::{MediaCache, MediaCacheEntry, AVATAR_CACHE},
shared::{avatar::{AvatarRef, AvatarWidgetRefExt}, text_or_image::TextOrImageWidgetRefExt},
sliding_sync::{submit_async_request, take_timeline_update_receiver, MatrixRequest},
utils::{self, unix_time_millis_to_datetime, MediaFormatConst},
};

live_design! {
Expand All @@ -48,6 +48,7 @@ live_design! {
import crate::shared::helpers::*;
import crate::shared::search_bar::SearchBar;
import crate::shared::avatar::Avatar;
import crate::shared::text_or_image::TextOrImage;

IMG_DEFAULT_AVATAR = dep("crate://self/resources/img/default_avatar.png")
IMG_LOADING = dep("crate://self/resources/img/loading.png")
Expand Down Expand Up @@ -291,11 +292,9 @@ live_design! {
ImageMessage = <Message> {
body = {
content = {
message = <Image> {
width: Fill, height: 200,
min_width: 10., min_height: 10.,
fit: Horizontal,
source: (IMG_LOADING),
message = <TextOrImage> {
width: Fill, height: 300,
// img_view = { img = { fit: Horizontal } }
}
}
}
Expand All @@ -307,11 +306,9 @@ live_design! {
CondensedImageMessage = <CondensedMessage> {
body = {
content = {
message = <Image> {
width: Fill, height: 200,
min_width: 10., min_height: 10.,
fit: Horizontal,
source: (IMG_LOADING),
message = <TextOrImage> {
width: Fill, height: 300,
// img_view = { img = { fit: Horizontal } }
}
}
}
Expand Down Expand Up @@ -990,6 +987,13 @@ fn populate_message_view(
item
}
MessageType::Image(image) => {
let template = if use_compact_view {
live_id!(CondensedImageMessage)
} else {
live_id!(ImageMessage)
};
let (item, _existed) = list.item_with_existed(cx, item_id, template).unwrap();

// We don't use thumbnails, as their resolution is too low to be visually useful.
let (mimetype, _width, _height) = if let Some(info) = image.info.as_ref() {
(
Expand All @@ -1000,46 +1004,41 @@ fn populate_message_view(
} else {
(None, None, None)
};
let uri = match &image.source {
MediaSource::Plain(mxc_uri) => Some(mxc_uri.clone()),
MediaSource::Encrypted(_) => None,
};
// now that we've obtained the image URI and its mimetype, try to fetch the image.
let item_result = if let Some(mxc_uri) = uri {
let template = if use_compact_view {
live_id!(CondensedImageMessage)
} else {
live_id!(ImageMessage)
};
let (item, _existed) = list.item_with_existed(cx, item_id, template).unwrap();

let img_ref = item.image(id!(body.content.message));
if let Some(data) = media_cache.try_get_media_or_fetch(mxc_uri, None) {
match mimetype {
Some(utils::ImageFormat::Png) => img_ref.load_png_from_data(cx, &data),
Some(utils::ImageFormat::Jpeg) => img_ref.load_jpg_from_data(cx, &data),
_unknown => utils::load_png_or_jpg(&img_ref, cx, &data),
}.map(|_| item)
} else {
// waiting for the image to be fetched
Ok(item)
}
} else {
Err(ImageError::EmptyData)
};

match item_result {
Ok(item) => item,
Err(e) => {
let item = list.item(cx, item_id, live_id!(Message)).unwrap();
if let MediaSource::Encrypted(encrypted) = &image.source {
item.label(id!(content.message)).set_text(&format!("[TODO] Display encrypted image at {:?}", encrypted.url));
} else {
item.label(id!(content.message)).set_text(&format!("Failed to get image: {e:?}:\n {:#?}", image));
let text_or_image_ref = item.text_or_image(id!(content.message));
match &image.source {
MediaSource::Plain(mxc_uri) => {
// now that we've obtained the image URI and its mimetype, try to fetch the image.
match media_cache.try_get_media_or_fetch(mxc_uri.clone(), None) {
MediaCacheEntry::Loaded(data) => {
let set_image_result = text_or_image_ref.set_image(|img|
match mimetype {
Some(utils::ImageFormat::Png) => img.load_png_from_data(cx, &data),
Some(utils::ImageFormat::Jpeg) => img.load_jpg_from_data(cx, &data),
_unknown => utils::load_png_or_jpg(&img, cx, &data),
}
);
if let Err(e) = set_image_result {
let err_str = format!("Failed to display image: {e:?}");
error!("{err_str}");
text_or_image_ref.set_text(&err_str);
}

// The image content is completely drawn here, ready to be marked as cached/drawn.
}
MediaCacheEntry::Requested => {
text_or_image_ref.set_text(&format!("Fetching image from {:?}", mxc_uri));
}
MediaCacheEntry::Failed => {
text_or_image_ref.set_text(&format!("Failed to fetch image from {:?}", mxc_uri));
// The image content is complete here, ready to be marked as cached/drawn.
}
}
item
}
}
MediaSource::Encrypted(encrypted) => {
text_or_image_ref.set_text(&format!("[TODO] fetch encrypted image at {:?}", encrypted.url));
}
};
item
}
other => {
let item = list.item(cx, item_id, live_id!(Message)).unwrap();
Expand Down Expand Up @@ -1385,7 +1384,7 @@ fn set_timestamp(
/// will be the user ID and the first character of that user ID, respectively.
fn set_avatar_and_get_username(
cx: &mut Cx,
mut avatar: AvatarRef,
avatar: AvatarRef,
event_tl_item: &EventTimelineItem,
) -> String {
let mut username = String::new();
Expand All @@ -1405,8 +1404,12 @@ fn set_avatar_and_get_username(
match event_tl_item.sender_profile() {
TimelineDetails::Ready(profile) => {
// Set the sender's avatar image, or use a text character if no image is available.
let avatar_img = profile.avatar_url.as_ref()
.and_then(|uri| AVATAR_CACHE.lock().unwrap().try_get_media_or_fetch(uri.clone(), None));
let avatar_img = profile.avatar_url.as_ref().and_then(|uri|
match AVATAR_CACHE.lock().unwrap().try_get_media_or_fetch(uri.clone(), None) {
MediaCacheEntry::Loaded(data) => Some(data),
_ => None,
}
);
match (avatar_img, &profile.display_name) {
// Both the avatar image and display name are available.
(Some(avatar_img), Some(name)) => {
Expand Down
25 changes: 9 additions & 16 deletions src/media_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ pub static AVATAR_CACHE: Mutex<MediaCache> = Mutex::new(MediaCache::new(MEDIA_TH

pub type MediaCacheEntryRef = Arc<Mutex<MediaCacheEntry>>;

/// An entry in the media cache.
/// An entry in the media cache.
#[derive(Debug, Clone)]
pub enum MediaCacheEntry {
/// A request has been issued and we're waiting for it to complete.
Requested,
Expand All @@ -16,14 +17,6 @@ pub enum MediaCacheEntry {
/// The media failed to load from the server.
Failed,
}
impl MediaCacheEntry {
fn to_option(&self) -> Option<Arc<[u8]>> {
match self {
MediaCacheEntry::Loaded(data) => Some(data.clone()),
_ => None,
}
}
}

/// A cache of fetched media. Keys are Matrix URIs, values are references to byte arrays.
pub struct MediaCache {
Expand Down Expand Up @@ -57,25 +50,25 @@ impl MediaCache {
/// Gets media from the cache without sending a fetch request if the media is absent.
///
/// This is suitable for use in a latency-sensitive context, such as a UI draw routine.
pub fn try_get_media(&self, mxc_uri: &OwnedMxcUri) -> Option<Arc<[u8]>> {
self.get(mxc_uri).and_then(|v| v.lock().unwrap().to_option())
pub fn try_get_media(&self, mxc_uri: &OwnedMxcUri) -> Option<MediaCacheEntry> {
self.get(mxc_uri).map(|v| v.lock().unwrap().deref().clone())
}

/// 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,
/// and will return `None` while the async request is in flight.
/// If a request is already in flight, this will return `None` and not issue a new redundant request.
/// and will return `MediaCache::Requested` while the async request is in flight.
/// If a request is already in flight, this will not issue a new redundant request.
pub fn try_get_media_or_fetch(
&mut self,
mxc_uri: OwnedMxcUri,
media_format: Option<MediaFormat>,
) -> Option<Arc<[u8]>> {
) -> MediaCacheEntry {
let value_ref = match self.entry(mxc_uri.clone()) {
Entry::Vacant(vacant) => vacant.insert(
Arc::new(Mutex::new(MediaCacheEntry::Requested))
),
Entry::Occupied(occupied) => return occupied.get().lock().unwrap().to_option(),
Entry::Occupied(occupied) => return occupied.get().lock().unwrap().deref().clone(),
};

let destination = Arc::clone(value_ref);
Expand All @@ -92,7 +85,7 @@ impl MediaCache {
destination,
}
);
None
MediaCacheEntry::Requested
}

}
Expand Down
4 changes: 2 additions & 2 deletions src/shared/avatar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ impl AvatarRef {
/// * `text`: the text that will be displayed in this avatar.
/// This should be a single character, but we accept anything that can be
/// treated as a `&str` in order to support multi-character Unicode.
pub fn set_text<T: AsRef<str>>(&mut self, text: T) {
pub fn set_text<T: AsRef<str>>(&self, text: T) {
if let Some(mut inner) = self.borrow_mut() {
inner.label(id!(text_view.text)).set_text(text.as_ref());
inner.view(id!(img_view)).set_visible(false);
Expand All @@ -117,7 +117,7 @@ impl AvatarRef {
/// to the image that will be displayed in this avatar.
/// This allows the caller to set the image contents in any way they want.
/// If `image_set_function` returns an error, no change is made to the avatar.
pub fn set_image<F, E>(&mut self, image_set_function: F) -> Result<(), E>
pub fn set_image<F, E>(&self, image_set_function: F) -> Result<(), E>
where F: FnOnce(ImageRef) -> Result<(), E>
{
if let Some(mut inner) = self.borrow_mut() {
Expand Down
1 change: 1 addition & 0 deletions src/shared/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ pub mod popup_menu;
pub mod search_bar;
pub mod styles;
pub mod avatar;
pub mod text_or_image;
108 changes: 108 additions & 0 deletions src/shared/text_or_image.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! A `TextOrImage` view displays a loading message while waiting for an image to be fetched.
//!
//! Once the image is fetched and loaded, it displays the image as normal.
//! If the image fails to load, it displays an error message permanently.

use makepad_widgets::*;

live_design! {
import makepad_draw::shader::std::*;
import makepad_widgets::view::*;
import makepad_widgets::base::*;
import makepad_widgets::theme_desktop_dark::*;
import crate::shared::styles::*;

TextOrImage = {{TextOrImage}} {
width: Fit, height: Fit,
flow: Overlay

text_view = <View> {
visible: true,
text = <Label> {
width: Fit, height: Fit,
draw_text: {
text_style: <REGULAR_TEXT>{ font_size: 12. }
}
text: "Loading..."
}
}

img_view = <View> {
visible: false,
img = <Image> {
fit: Smallest,
width: Fill, height: Fill,
}
}
}
}


#[derive(LiveHook, Live, Widget)]
pub struct TextOrImage {
#[deref] view: View,
}

impl Widget for TextOrImage {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope)
}

fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
self.view.draw_walk(cx, scope, walk)
}
}

impl TextOrImageRef {
/// Sets the text content, makin the text visible and the image invisible.
///
/// ## Arguments
/// * `text`: the text that will be displayed in this `TextOrImage`, e.g.,
/// a message like "Loading..." or an error message.
pub fn set_text<T: AsRef<str>>(&self, text: T) {
if let Some(mut inner) = self.borrow_mut() {
inner.label(id!(text_view.text)).set_text(text.as_ref());
inner.view(id!(img_view)).set_visible(false);
inner.view(id!(text_view)).set_visible(true);
}
}

/// Sets the image content, making the image visible and the text invisible.
///
/// ## Arguments
/// * `image_set_function`: this function will be called with an [ImageRef] argument,
/// which refers to the image that will be displayed within this `TextOrImage`.
/// This allows the caller to set the image contents in any way they want.
/// If `image_set_function` returns an error, no change is made to this `TextOrImage`.
pub fn set_image<F, E>(&self, image_set_function: F) -> Result<(), E>
where F: FnOnce(ImageRef) -> Result<(), E>
{
if let Some(mut inner) = self.borrow_mut() {
let img_ref = inner.image(id!(img_view.img));
let res = image_set_function(img_ref);
if res.is_ok() {
inner.view(id!(img_view)).set_visible(true);
inner.view(id!(text_view)).set_visible(false);
}
res
} else {
Ok(())
}
}

/// Returns whether this `TextOrImage` is currently displaying an image or text.
pub fn status(&self) -> DisplayStatus {
if let Some(mut inner) = self.borrow_mut() {
if inner.view(id!(img_view)).is_visible() {
return DisplayStatus::Image;
}
}
DisplayStatus::Text
}
}

/// Whether a `TextOrImage` instance is currently displaying text or an image.
pub enum DisplayStatus {
Text,
Image,
}
Loading

0 comments on commit 38cdef9

Please sign in to comment.