feat: added context menu inside the screenshot viewer (#744)
This commit is contained in:
parent
2a36bb8a88
commit
159e1c7e96
5 changed files with 191 additions and 80 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue