-
Notifications
You must be signed in to change notification settings - Fork 37
Add image viewer widget #565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
91bf416 to
440bde3
Compare
|
Intention of adding an image magnifier and the ability to pan image. The image's image_scale, image_pan will always be set to default in draw_walk. Hence unable to dynamically scale and pan image. |
1b7875f to
ddd04b9
Compare
@alanpoon I just checked with Rik, and this line should not be present in the Image widget. It's some errant code left over from dealing with animations in a strange way. You can submit a PR to makepad that removes that line, and then continue with this issue here. |
|
Sure, the makepad PR is here: makepad/makepad#788 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks Alan, nice work here.
My main comment is that we should decouple the ImageViewer widget from the RoomScreen. It doesn't need to know anything about the RoomScreen except for being able to access the RoomScreen's MediaCache instance (technically even that is not required, see Aarav's PR for an example of just passing the image data directly from the media fetch background task to the ImageViewer via an action).
Also, Aarav and I had a lot of discussions about using actions to communicate with the ImageViewer widget in PR#443. I think you can combine your approach (of storing images in the RoomScreen's MediaCache) with the action-based design from #443 (in which you emit an action including the image data/status instead of directly accessing the widget and calling a function on it). Actions are more idiomatic and also allow easy communication from any context, both in a background task and in a RoomScreen TimelineUpdate handler.
|
Requires makepad/makepad#806 to be merged to use the rotated_image widget |
f2360cc to
1b82f74
Compare
I've asked Rik to review & merge this. |
@alanpoon this PR has been merged, and I've also updated the |
301a788 to
f0cecc1
Compare
231f433 to
130e1fe
Compare
2d14df8 to
91e324c
Compare
9919e8a to
68179c5
Compare
| content: { | ||
| width: Fill, height: Fill, | ||
| flow: Down | ||
| show_bg: true | ||
| draw_bg: { | ||
| color: #000 | ||
| } | ||
|
|
||
| <View> { | ||
| width: Fill, height: Fill, | ||
| flow: Overlay | ||
| image_viewer_inner = <ImageViewer> { | ||
| align: {x: 0.5, y: 0.5} | ||
| padding: {bottom: 0} | ||
| } | ||
| image_detail = <RoomImageViewerDetail> { | ||
| width: Fill, height: Fill, | ||
| } | ||
| } | ||
|
|
||
| footer = <RoomImageViewerFooter> {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
all of this should be moved into the ImageViewer itself. The top-level app itself should not have to be concerned with any of these inner details.
| let scope = &mut Scope::with_data(&mut self.app_state); | ||
| self.ui.handle_event(cx, event, scope); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what's this for? I don't think any sort of weird hack should be required to draw the modal, right? Draw events aren't "consumed" by just one modal, it's not like a visibility-requiring hit event.
| // If the image viewer modal is really opened, handles non-Draw events using the modal. | ||
| let image_viewer_modal = self.ui.modal(ids!(image_viewer)); | ||
| if image_viewer_modal.is_open() && image_viewer_modal.area().rect(cx).size.y > 0.0 { | ||
| let scope = &mut Scope::with_data(&mut self.app_state); | ||
| self.ui | ||
| .view(ids!(popup_list)) | ||
| .handle_event(cx, event, scope); | ||
| self.ui | ||
| .modal(ids!(image_viewer)) | ||
| .handle_event(cx, event, scope); | ||
| // Pass the Signal event to the underlying room screen, so as to populate the full image in the image viewer. | ||
| if let Event::Signal = event { | ||
| self.ui.handle_event(cx, event, scope); | ||
| } | ||
| if let Event::Actions(actions) = event { | ||
| for action in actions { | ||
| if self.handle_image_viewer_action(cx, action) { | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
| return; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why is this one modal so uniquely different than all the other modals? I would only expect you to have to handle opening and closing the ImageViewer modal here, just like all other modals.
I don't understand why you need to explicitly pass events to the modal; this is almost certainly incorrect and will result in the event being delivered to the modal twice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I forgot to comment on the rationale. Modal widget is not behaving as expected with the events being propagated to underneath layer. This results in other image being clicked while the image viewer modal is opened.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, that's concerning. Can you open an issue on the Makepad repo with a minimal example that reproduces this, and then link to it in a code comment here? Thanks.
I'll try to dig into modal issues this week to see if we can fix it there.
| /// Handles actions for the image viewer. | ||
| /// Returns a boolean, is true continues the actions for loop. | ||
| fn handle_image_viewer_action(&mut self, cx: &mut Cx, action: &Action) -> bool { | ||
|
|
||
| match action.downcast_ref() { | ||
| Some(ImageViewerAction::Show(load_state)) => { | ||
| match load_state { | ||
| LoadState::Loading(texture, image_size) => { | ||
| self.ui.modal(ids!(image_viewer)).open(cx); | ||
| self.ui.image_viewer(ids!(image_viewer_inner)).reset(cx); | ||
| self.ui.room_image_viewer_footer(ids!(footer)).show_loading(cx); | ||
| self.ui.view(ids!(footer)).apply_over(cx, live!{ | ||
| height: 50 | ||
| }); | ||
| self.ui.image_viewer(ids!(image_viewer_inner)).display_using_texture(cx, texture.as_ref().clone(), image_size); | ||
| } | ||
| LoadState::Loaded(image_bytes) => { | ||
| self.ui.modal(ids!(image_viewer)).open(cx); | ||
| self.ui.image_viewer(ids!(image_viewer_inner)).display_using_background_thread(cx, image_bytes); | ||
| } | ||
| LoadState::FinishedBackgroundDecoding => { | ||
| self.ui.room_image_viewer_footer(ids!(footer)).hide(cx); | ||
| // Collapse the footer | ||
| self.ui.view(ids!(footer)).apply_over(cx, live!{ | ||
| height: 0 | ||
| }); | ||
| } | ||
| LoadState::Error(error) => { | ||
| if self.ui.modal(ids!(image_viewer)).is_open() { | ||
| self.ui.room_image_viewer_footer(ids!(footer)).show_error(cx, image_viewer_error_to_string(error)); | ||
| self.ui.view(ids!(footer)).apply_over(cx, live!{ | ||
| height: 50 | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| true | ||
| } | ||
| Some(ImageViewerAction::Hide) => { | ||
| self.ui.modal(ids!(image_viewer)).close(cx); | ||
| self.ui.room_image_viewer_detail(ids!(image_detail)).reset_state(cx); | ||
| true | ||
| } | ||
| _ => false | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
again, why does this need to be here in the top-level app? The only thing that actually needs to be in the top-level app module is opening and closing the modal. There should be zero ImageViewer-specific logic here.
| /// Deletes a specific media format from the cache for the given MXC URI. | ||
| /// If `format` is None, deletes the entire cache entry for the URI. | ||
| /// Returns true if an entry was deleted, false if nothing was found. | ||
| pub fn delete_cache_entry(&mut self, mxc_uri: &OwnedMxcUri, format: Option<MediaFormat>) -> bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this function, but here are two ways you can improve it?
- Use "remove" instead of delete, which matches the rest of Rust.
- Return the actual removed cache entry instead of a boolean.
| /// Returns a human-readable string describing the given ImageViewerError. | ||
| /// | ||
| /// This can be used to update the label text of an error display. | ||
| /// | ||
| /// The error type is matched against a string which describes the error in a way that is visible to the user. | ||
| /// | ||
| /// The strings returned by this function will be appropriate for display in a label or similar widget. | ||
| pub fn image_viewer_error_to_string(error: &ImageViewerError) -> &str { | ||
| match error { | ||
| ImageViewerError::NotFound => "Full image is not found", | ||
| ImageViewerError::BadData => "Image appears to be empty or corrupted", | ||
| ImageViewerError::UnsupportedFormat => "This image format isn't supported", | ||
| ImageViewerError::ConnectionFailed => "Check your internet connection", | ||
| ImageViewerError::Unauthorized => "You don't have permission to view this image", | ||
| ImageViewerError::ServerError => "Server temporarily unavailable", | ||
| ImageViewerError::Unknown => "Unable to load image", | ||
| ImageViewerError::Timeout => "Timed out loading this image", | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should go into the imageviewer module
| let content_message = wr.text_or_image(ids!(content.message)); | ||
| if let TextOrImageAction::Clicked(mxc_uri) = actions.find_widget_action(content_message.widget_uid()).cast() { | ||
| let Some((texture, size)) = content_message.get_texture_and_size(cx) else { continue; }; | ||
| let texture = std::rc::Rc::new(texture); | ||
| let screen_width = wr.area().rect(cx).size.x; | ||
| let (capped_width, capped_height) = constrain_image_dimensions(size.x, size.y, screen_width); | ||
| if let Some(tl_state) = &mut self.tl_state { | ||
| if let Some(item) = tl_state.items.get(index) { | ||
| if let Some(event_tl_item) = item.as_event() { | ||
| let sender_profile = event_tl_item.sender_profile(); | ||
| let sender = event_tl_item.sender(); | ||
| let event_id = event_tl_item.event_id().map(|id| id.to_owned()); | ||
| let timestamp = event_tl_item.timestamp(); | ||
| // Extract image name and size from the message content | ||
| let (image_name, image_size) = if let Some(message) = event_tl_item.content().as_message() { | ||
| if let MessageType::Image(image_content) = message.msgtype() { | ||
| let name = message.body().to_string(); | ||
| let size = image_content.info.as_ref() | ||
| .and_then(|info| info.size) | ||
| .map(|s| i32::try_from(s).unwrap_or_default()) | ||
| .unwrap_or(0); | ||
| (name, size) | ||
| } else { | ||
| ("Unknown Image".to_string(), 0) | ||
| } | ||
| } else { | ||
| ("Unknown Image".to_string(), 0) | ||
| }; | ||
| cx.action(ImageViewerAction::Show(LoadState::Loading(texture, DVec2 { x: capped_width, y: capped_height }))); | ||
| let Some(mxc_uri_string) = mxc_uri else { continue; }; | ||
| let mxc_uri = OwnedMxcUri::from(mxc_uri_string); | ||
| populate_matrix_image_modal(cx, mxc_uri, &mut tl_state.media_cache); | ||
| cx.widget_action( | ||
| room_screen_widget_uid, | ||
| &scope.path, | ||
| RoomImageViewerDetailAction::SetImageDetail { | ||
| room_id: self.room_id.clone(), | ||
| sender: Some(sender.to_owned()), | ||
| sender_profile: Some(sender_profile.clone()), | ||
| event_id, | ||
| timestamp_millis: timestamp, | ||
| image_name, | ||
| image_size | ||
| } | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| continue | ||
| } | ||
| let link_preview_content = wr.link_preview(ids!(content.link_preview_view)).get_children(); | ||
| for text_or_image in link_preview_content.iter() { | ||
| let text_or_image = text_or_image.text_or_image(ids!(image_view.image)); | ||
| if let TextOrImageAction::Clicked(mxc_uri) = actions.find_widget_action(text_or_image.widget_uid()).cast() { | ||
| let Some((texture, size)) = text_or_image.get_texture_and_size(cx) else { | ||
| continue; | ||
| }; | ||
| let screen_width = wr.area().rect(cx).size.x; | ||
| let (capped_width, capped_height) = constrain_image_dimensions(size.x, size.y, screen_width); | ||
| let texture = std::rc::Rc::new(texture); | ||
| let Some(mxc_uri_string) = mxc_uri else { continue; }; | ||
| let mxc_uri = OwnedMxcUri::from(mxc_uri_string); | ||
| cx.action(ImageViewerAction::Show(LoadState::Loading(texture, DVec2 { x: capped_width, y: capped_height }))); | ||
| let Some(tl_state) = self.tl_state.as_mut() else { continue; }; | ||
| populate_matrix_image_modal(cx, mxc_uri, &mut tl_state.media_cache); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
phew, this is a lot of complex code.... can you clean it up (simplify all the nested indentation) and move it to a separate function?
Also, the latter half that handles link previews can be removed, along with the get_children() function. Although some clients do allow clicking a link preview image, it's better UX to open the link upon clicking any part of a link preview instead of zooming in on a preview image thumbnail.



Fixes #327

in replacement of #443
Waiting for this PR to be merged: makepad/makepad#788.