diff --git a/packages/oneclient_app/src/components/context_menu.rs b/packages/oneclient_app/src/components/context_menu.rs index 562ad67b..7f0edbe8 100644 --- a/packages/oneclient_app/src/components/context_menu.rs +++ b/packages/oneclient_app/src/components/context_menu.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use crate::components::{Icon, IconType, OverlayPopup}; +use crate::components::{Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup}; use crate::theme::colors; const MENU_BG: Color = Color::from_rgb(25, 32, 38); @@ -8,6 +8,11 @@ const MENU_BORDER: Color = Color::from_argb(26, 255, 255, 255); const MENU_FG: Color = Color::from_rgb(155, 161, 166); const MENU_DANGER: Color = Color::from_rgb(242, 84, 90); +/// Panel padding and border on both axes, which the measured list sits inside of +const PANEL_INSET: f32 = 14.; +/// Gap the clamped menu keeps from the window edges +const EDGE_MARGIN: f32 = 8.; + enum Entry { Action { icon: IconType, @@ -33,10 +38,10 @@ pub struct ContextMenu { x: f32, y: f32, upwards: bool, - backdrop: bool, title: Option, entries: Vec, on_close: EventHandler<()>, + overlay_level: u8, } impl ContextMenu { @@ -45,10 +50,10 @@ impl ContextMenu { x, y, upwards: false, - backdrop: false, title: None, entries: Vec::new(), on_close: (|()| {}).into(), + overlay_level: OVERLAY_BASE_LEVEL, } } @@ -62,14 +67,13 @@ impl ContextMenu { self } - #[allow(dead_code)] - pub fn backdrop(mut self, backdrop: bool) -> Self { - self.backdrop = backdrop; + pub fn on_close(mut self, on_close: impl Into>) -> Self { + self.on_close = on_close.into(); self } - pub fn on_close(mut self, on_close: impl Into>) -> Self { - self.on_close = on_close.into(); + pub fn overlay_level(mut self, level: u8) -> Self { + self.overlay_level = level; self } @@ -111,6 +115,7 @@ impl ContextMenu { impl PartialEq for ContextMenu { fn eq(&self, _other: &Self) -> bool { + // Always unequal the menu is rebuilt from scratch whenever it is reopened false } } @@ -119,14 +124,10 @@ impl Component for ContextMenu { fn render(&self) -> impl IntoElement { let on_close = self.on_close.clone(); - let mut width = use_state(|| 0f32); - let mut height = use_state(|| 0f32); - let mut panel_width = use_state(|| 0f32); + let mut size = use_state(Size2D::zero); - let item_width = { - let w = *width.read(); - (w > 0.).then_some(w) - }; + let measured = *size.read(); + let item_width = (measured.width > 0.).then_some(measured.width); let mut list = rect().vertical().spacing(4.); @@ -169,26 +170,27 @@ impl Component for ContextMenu { } let list = list.on_sized(move |e: Event| { - let measured = e.data().area.width(); - if (measured - *width.peek()).abs() > 0.5 { - width.set(measured); + let area = e.data().area.size; + let prev = *size.peek(); + if (area.width - prev.width).abs() > 0.5 || (area.height - prev.height).abs() > 0.5 { + size.set(area); } }); - let platform = Platform::get(); - // `root_size` is physical while global positions and measured areas are logical - let scale = (*platform.scale_factor.read() as f32).max(f32::EPSILON); - let root = *platform.root_size.read(); - let window_width = root.width / scale; - let window_height = root.height / scale; + let placed = measured.width > 0.; + let menu_width = measured.width + PANEL_INSET; + let menu_height = measured.height + PANEL_INSET; - let measured = *height.read(); - let menu_width = *panel_width.read(); - let flips = self.upwards && measured > 0. && self.y - measured >= 0.; - let top = if flips { self.y - measured } else { self.y }; - let top = top.min(window_height - measured).max(0.); - let left = self.x.min(window_width - menu_width).max(0.); - let placed = measured > 0. && menu_width > 0.; + let (x, y) = if placed { + let top = if self.upwards && self.y - menu_height >= 0. { + self.y - menu_height + } else { + self.y + }; + clamp_to_window(self.x, top, menu_width, menu_height) + } else { + (self.x, self.y) + }; let panel = rect() .vertical() @@ -202,25 +204,37 @@ impl Component for ContextMenu { left: 1., })) .opacity(if placed { 1. } else { 0. }) - .on_sized(move |e: Event| { - let area = e.data().area; - if (area.height() - *height.peek()).abs() > 0.5 { - height.set(area.height()); - } - if (area.width() - *panel_width.peek()).abs() > 0.5 { - panel_width.set(area.width()); - } - }) .child(list); OverlayPopup::new() - .backdrop(self.backdrop) - .position(Position::new_global().top(top).left(left)) + .backdrop(false) + .overlay_level(self.overlay_level) + .position(Position::new_global().top(y).left(x)) .on_close(move |_| on_close.call(())) .child(panel.into_element()) } } +/// `root_size` is physical while the press position is logical, so it has to be +/// scaled down before the two are compared +fn clamp_to_window(x: f32, y: f32, menu_width: f32, menu_height: f32) -> (f32, f32) { + let platform = Platform::get(); + let scale = *platform.scale_factor.peek() as f32; + if scale <= 0. { + return (x, y); + } + + let window = *platform.root_size.peek(); + let clamp = |pos: f32, len: f32, limit: f32| { + pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN)) + }; + + ( + clamp(x, menu_width, window.width / scale), + clamp(y, menu_height, window.height / scale), + ) +} + #[derive(PartialEq)] struct ContextMenuRow { icon: IconType, diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 1c004678..bdb4a564 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -66,14 +66,14 @@ pub(crate) use microsoft_login::login_dialog; pub(crate) use navbar::window_controls; pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::NotificationCenter; -pub use overlay_popup::OverlayPopup; +pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost}; pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge}; pub use package_update_popup::PackageUpdatePopup; pub use pagination::Pagination; pub use player_model::PlayerModel; pub use progress_track::progress_track; pub use recents_row::RecentsRow; -pub use screenshot_viewer::ScreenshotViewer; +pub use screenshot_viewer::{ScreenshotViewer, screenshot_context_menu}; pub(crate) use scrollview::corrected_scroll; pub use scrollview::{ScrollArea, ScrollAreaCtx}; pub use segmented_control::{Segment, SegmentedControl}; diff --git a/packages/oneclient_app/src/components/overlay_popup.rs b/packages/oneclient_app/src/components/overlay_popup.rs index 33e50c4d..4f22759e 100644 --- a/packages/oneclient_app/src/components/overlay_popup.rs +++ b/packages/oneclient_app/src/components/overlay_popup.rs @@ -1,3 +1,5 @@ +use std::cell::RefCell; + use freya::{ animation::{AnimNum, Ease, OnCreation, use_animation}, prelude::*, @@ -7,12 +9,31 @@ use crate::hooks::use_overlay_claim; const SCRIM_ALPHA: f32 = 90.; +/// Overlay level of a top level popup. Its scrim sits two levels below it. +pub const OVERLAY_BASE_LEVEL: u8 = 12; + +/// Freya maps a level to `(level.max(1) as i16) * (i16::MAX / 16)`, which +/// saturates above 16 so every higher level collides with 16. +pub const OVERLAY_MAX_LEVEL: u8 = 16; + +thread_local! { + static OPEN_LEVELS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// True while no open overlay sits above `level`. Freya dispatches +/// `on_global_key_down` to every listener, so a handler that should only answer +/// for the topmost overlay has to gate itself on this. +pub fn overlay_is_topmost(level: u8) -> bool { + OPEN_LEVELS.with_borrow(|open| open.iter().all(|&other| other <= level)) +} + #[derive(PartialEq)] pub struct OverlayPopup { children: Vec, on_close: Option>, position: Position, backdrop: bool, + overlay_level: u8, key: DiffKey, } @@ -29,6 +50,7 @@ impl OverlayPopup { on_close: None, position: Position::new_global().top(0.).left(0.), backdrop: true, + overlay_level: OVERLAY_BASE_LEVEL, key: DiffKey::None, } } @@ -38,6 +60,15 @@ impl OverlayPopup { self } + pub fn overlay_level(mut self, level: u8) -> Self { + debug_assert!( + (1..=OVERLAY_MAX_LEVEL).contains(&level), + "overlay levels above {OVERLAY_MAX_LEVEL} saturate onto {OVERLAY_MAX_LEVEL}" + ); + self.overlay_level = level; + self + } + pub fn backdrop(mut self, backdrop: bool) -> Self { self.backdrop = backdrop; self @@ -68,6 +99,16 @@ impl Component for OverlayPopup { let a11y_id = use_a11y(); let scrim_close = self.on_close.clone(); let key_close = self.on_close.clone(); + let level = self.overlay_level; + + use_hook(move || OPEN_LEVELS.with_borrow_mut(|open| open.push(level))); + use_drop(move || { + OPEN_LEVELS.with_borrow_mut(|open| { + if let Some(pos) = open.iter().rposition(|&other| other == level) { + open.remove(pos); + } + }); + }); let fade = use_animation(|conf| { conf.on_creation(OnCreation::Run); @@ -87,7 +128,7 @@ impl Component for OverlayPopup { .position(Position::new_global().top(0.).left(0.)) .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) - .layer(Layer::OverlayLevel(10)) + .layer(Layer::OverlayLevel(self.overlay_level.saturating_sub(2))) .background(Color::from_argb(alpha, 0, 0, 0)) .on_press(move |_| { if let Some(on_close) = scrim_close.as_ref() { @@ -102,9 +143,10 @@ impl Component for OverlayPopup { .a11y_focusable(true) .a11y_auto_focus(true) .a11y_role(AccessibilityRole::Dialog) - .layer(Layer::OverlayLevel(12)) + .layer(Layer::OverlayLevel(self.overlay_level)) .on_global_key_down(move |e: Event| { if e.key == Key::Named(NamedKey::Escape) + && overlay_is_topmost(level) && let Some(on_close) = key_close.as_ref() { on_close.call(()); diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 2826d06d..0cd348a7 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -1,8 +1,15 @@ +use std::path::PathBuf; + use freya::prelude::*; use oneclient_core::ScreenshotInfo; -use crate::components::{Button, Icon, IconType, LocalImage, OverlayPopup}; -use crate::hooks::{ScreenshotAction, use_dispatch, use_screenshot_action}; +use crate::components::{ + Button, ContextMenu, Icon, IconType, LocalImage, OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, + OverlayPopup, overlay_is_topmost, +}; +use crate::hooks::{ + Actions, ScreenshotAction, UseScreenshotAction, use_dispatch, use_screenshot_action, +}; use crate::theme::colors; use crate::ui::fmt_date; use crate::utils::{format_res, format_size}; @@ -37,6 +44,7 @@ impl Component for ScreenshotViewer { let start = self.start.min(len.saturating_sub(1)); move || start }); + let mut menu = use_state(|| None::<(f32, f32)>); if len == 0 { return rect().into_element(); @@ -50,6 +58,7 @@ impl Component for ScreenshotViewer { let close = self.on_close.clone(); let scrim_close = self.on_close.clone(); let delete_close = self.on_close.clone(); + let menu_delete_close = self.on_close.clone(); let has_prev = idx > 0; let has_next = idx + 1 < len; @@ -61,6 +70,23 @@ impl Component for ScreenshotViewer { let copy_dispatch = dispatch.clone(); let delete_path = info.path.clone(); + let menu_overlay = (*menu.read()).map(|(x, y)| { + let menu_close = menu_delete_close.clone(); + + // Nested inside this viewer's popup, so it has to be raised above it + screenshot_context_menu( + x, + y, + info.path.clone(), + action, + dispatch.clone(), + move |()| menu_close.call(()), + ) + .overlay_level(OVERLAY_MAX_LEVEL) + .on_close(move |_| menu.set(None)) + .into_element() + }); + OverlayPopup::new() .on_close(move |_| scrim_close.call(())) .child( @@ -68,10 +94,15 @@ impl Component for ScreenshotViewer { .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) .center() - .on_global_key_down(move |e: Event| match &e.key { - Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), - Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), - _ => {} + .on_global_key_down(move |e: Event| { + if !overlay_is_topmost(OVERLAY_BASE_LEVEL) { + return; + } + match &e.key { + Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), + Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), + _ => {} + } }) .child( rect() @@ -100,6 +131,14 @@ impl Component for ScreenshotViewer { rect() .width(Size::flex(1.0)) .height(Size::fill()) + .on_secondary_down(move |e: Event| { + if let PressEventData::Mouse(m) = e.data() { + menu.set(Some(( + m.global_location.x as f32, + m.global_location.y as f32, + ))); + } + }) .child(preview), ) .child(chevron_btn( @@ -163,10 +202,50 @@ impl Component for ScreenshotViewer { ), ), ) + .maybe_child(menu_overlay) .into_element() } } +/// The screenshot menu shown both by the grid and by this viewer, so the two +/// cannot drift apart +pub fn screenshot_context_menu( + x: f32, + y: f32, + path: PathBuf, + action: UseScreenshotAction, + dispatch: Actions, + on_deleted: impl Into>, +) -> ContextMenu { + let open_path = path.clone(); + let copy_path = path.clone(); + let delete_path = path; + let on_deleted = on_deleted.into(); + + ContextMenu::new(x, y) + .action(IconType::Folder, "Open in folder", move |()| { + if let Some(dir) = open_path.parent() { + crate::platform::open_url(&dir.to_string_lossy()); + } + }) + .action(IconType::Copy01, "Copy", move |()| { + crate::platform::copy_image_to_clipboard(copy_path.clone()); + dispatch + .notify("Copied to clipboard") + .body("Screenshot copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + }) + .separator() + .danger_action(IconType::Trash01, "Delete", move |()| { + action.mutate(ScreenshotAction::Delete { + path: delete_path.clone(), + }); + on_deleted.call(()); + }) +} + fn chevron_btn( icon: IconType, enabled: bool, diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index b6e7521e..cb7e8ce2 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -6,9 +6,9 @@ use oneclient_core::ScreenshotInfo; use oneclient_core::settings::ViewLayout; use crate::components::{ - Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, - Segment, SegmentedControl, cell_width, grid_columns_picker, open_folder_button, - resolved_columns, + Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment, + SegmentedControl, cell_width, grid_columns_picker, open_folder_button, resolved_columns, + screenshot_context_menu, }; use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state}; use crate::layout::cluster_content; @@ -155,32 +155,8 @@ impl Component for ClusterScreenshots { }); let menu_overlay = menu.read().clone().map(|(x, y, path)| { - let copy_dispatch = menu_dispatch.clone(); - let open_path = path.clone(); - let copy_path = path.clone(); - let delete_path = path.clone(); - ContextMenu::new(x, y) + screenshot_context_menu(x, y, path, action, menu_dispatch.clone(), |()| {}) .on_close(move |_| menu.set(None)) - .action(IconType::Folder, "Open in folder", move |()| { - if let Some(dir) = open_path.parent() { - crate::platform::open_url(&dir.to_string_lossy()); - } - }) - .action(IconType::Copy01, "Copy", move |()| { - crate::platform::copy_image_to_clipboard(copy_path.clone()); - copy_dispatch - .notify("Copied to clipboard") - .body("Screenshot copied to your clipboard.") - .info() - .icon(IconType::ClipboardCheck) - .send(); - }) - .separator() - .danger_action(IconType::Trash01, "Delete", move |()| { - action.mutate(ScreenshotAction::Delete { - path: delete_path.clone(), - }); - }) .into_element() });