feat: added context menu inside the screenshot viewer (#744)

This commit is contained in:
Maciek 2026-09-04 17:43:25 +02:00 • committed by LynithDev
parent 2a36bb8a88
commit 159e1c7e96
5 changed files with 191 additions and 80 deletions

View file

@ -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<String>,
entries: Vec<Entry>,
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<EventHandler<()>>) -> Self {
self.on_close = on_close.into();
self
}
pub fn on_close(mut self, on_close: impl Into<EventHandler<()>>) -> 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<SizedEventData>| {
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<SizedEventData>| {
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,

View file

@ -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};

View file

@ -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<Vec<u8>> = 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<Element>,
on_close: Option<EventHandler<()>>,
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<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape)
&& overlay_is_topmost(level)
&& let Some(on_close) = key_close.as_ref()
{
on_close.call(());

View file

@ -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<KeyboardEventData>| 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<KeyboardEventData>| {
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<PressEventData>| {
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<EventHandler<()>>,
) -> 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,

View file

@ -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()
});