From 2a36bb8a88d0f364731c964aa7247d732f5686e2 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:32:29 +0200 Subject: [PATCH] feat: added context menu on homepage clusters (#743) --- .../src/components/context_menu.rs | 93 ++++++++++++++++--- .../src/components/recents_row.rs | 78 +++++++++++++++- 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/packages/oneclient_app/src/components/context_menu.rs b/packages/oneclient_app/src/components/context_menu.rs index a2d1a3eb..562ad67b 100644 --- a/packages/oneclient_app/src/components/context_menu.rs +++ b/packages/oneclient_app/src/components/context_menu.rs @@ -18,9 +18,23 @@ enum Entry { Separator, } +fn separator(item_width: Option) -> Rect { + let mut sep = rect() + .height(Size::px(1.)) + .margin(Gaps::new_symmetric(4., 0.)) + .background(MENU_BORDER); + if let Some(w) = item_width { + sep = sep.width(Size::px(w)); + } + sep +} + pub struct ContextMenu { x: f32, y: f32, + upwards: bool, + backdrop: bool, + title: Option, entries: Vec, on_close: EventHandler<()>, } @@ -30,11 +44,30 @@ impl ContextMenu { Self { x, y, + upwards: false, + backdrop: false, + title: None, entries: Vec::new(), on_close: (|()| {}).into(), } } + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn open_upwards(mut self) -> Self { + self.upwards = true; + self + } + + #[allow(dead_code)] + pub fn backdrop(mut self, backdrop: bool) -> Self { + self.backdrop = backdrop; + self + } + pub fn on_close(mut self, on_close: impl Into>) -> Self { self.on_close = on_close.into(); self @@ -78,7 +111,6 @@ 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 } } @@ -88,6 +120,8 @@ impl Component for ContextMenu { 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 item_width = { let w = *width.read(); @@ -95,18 +129,26 @@ impl Component for ContextMenu { }; let mut list = rect().vertical().spacing(4.); + + if let Some(title) = &self.title { + list = list + .child( + // Asymmetric on purpose + rect().padding(Gaps::new(6., 8., 4., 8.)).child( + label() + .text(title.clone()) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child(separator(item_width)); + } + for entry in &self.entries { list = match entry { - Entry::Separator => { - let mut sep = rect() - .height(Size::px(1.)) - .margin(Gaps::new_symmetric(4., 0.)) - .background(MENU_BORDER); - if let Some(w) = item_width { - sep = sep.width(Size::px(w)); - } - list.child(sep) - } + Entry::Separator => list.child(separator(item_width)), Entry::Action { icon, label, @@ -133,6 +175,21 @@ impl Component for ContextMenu { } }); + 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 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 panel = rect() .vertical() .padding(Gaps::new_all(6.)) @@ -144,11 +201,21 @@ impl Component for ContextMenu { bottom: 1., 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(false) - .position(Position::new_global().top(self.y).left(self.x)) + .backdrop(self.backdrop) + .position(Position::new_global().top(top).left(left)) .on_close(move |_| on_close.call(())) .child(panel.into_element()) } diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index a7d613a4..191b1e72 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -3,7 +3,7 @@ use freya::prelude::*; use freya::router::RouterContext; use oneclient_core::clusters::Cluster; -use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType}; +use crate::components::{ART_PREVIEW_EDGE, ContextMenu, DynamicArt, Icon, IconType}; use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; use crate::routes::Route; use crate::theme; @@ -67,7 +67,9 @@ impl Component for RecentsRow { index, items, progress, + key: DiffKey::None, } + .key(cluster.id) .into_element() })) .child(OtherVersionsTile { @@ -94,11 +96,58 @@ fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 { 1.0 - (1.0 - local).powi(3) } +fn cluster_menu_entries(cluster_id: i64) -> [(IconType, &'static str, Route); 7] { + [ + ( + IconType::InfoCircle, + "Overview", + Route::ClusterOverview { cluster_id }, + ), + ( + IconType::Terminal, + "Logs", + Route::ClusterLogs { cluster_id }, + ), + ( + IconType::Eye, + "Screenshots", + Route::ClusterScreenshots { cluster_id }, + ), + ( + IconType::CodeSnippet02, + "Mods", + Route::ClusterMods { cluster_id }, + ), + ( + IconType::PaintPour, + "Shaders", + Route::ClusterShaders { cluster_id }, + ), + ( + IconType::Colors, + "Textures", + Route::ClusterTextures { cluster_id }, + ), + ( + IconType::Settings01, + "Settings", + Route::ClusterSettings { cluster_id }, + ), + ] +} + struct ClusterCard { cluster: Cluster, index: usize, items: usize, progress: f32, + key: DiffKey, +} + +impl KeyExt for ClusterCard { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } } impl PartialEq for ClusterCard { @@ -115,6 +164,7 @@ impl Component for ClusterCard { let mut active_id = use_active_cluster_id(); let active = *active_id.read() == Some(self.cluster.id); let mut hovering = use_state(|| false); + let mut menu = use_state(|| None::<(f32, f32)>); let a11y_id = use_a11y(); let focus = use_focus(a11y_id); @@ -130,6 +180,19 @@ impl Component for ClusterCard { *active_id.write() = Some(cluster_id); }; + let menu_overlay = (*menu.read()).map(|(x, y)| { + let mut context = ContextMenu::new(x, y) + .open_upwards() + .title(title.clone()) + .on_close(move |_| menu.set(None)); + for (icon, label, route) in cluster_menu_entries(cluster_id) { + context = context.action(icon, label, move |()| { + let _ = RouterContext::get().push(route.clone()); + }); + } + context.into_element() + }); + rect() .key(self.cluster.id) .width(Size::flex(1.0)) @@ -174,6 +237,14 @@ impl Component for ClusterCard { .color(Color::from_af32rgb(0.3, 0, 0, 0)), ) .on_press(on_press) + .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( rect() .width(Size::fill()) @@ -223,6 +294,11 @@ impl Component for ClusterCard { ), ), ) + .maybe_child(menu_overlay) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) } }