From 65a9c670cc165916c69b0f4623f1e2d0df153e5d Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:45:46 +0200 Subject: [PATCH] feat: package selection (#897) --- .../oneclient_app/src/components/icons.rs | 8 +- packages/oneclient_app/src/components/mod.rs | 5 +- .../src/components/package_row.rs | 133 +++++++++---- .../src/components/screenshot_viewer.rs | 10 +- packages/oneclient_app/src/hooks/mod.rs | 2 + .../src/hooks/queries/mutations.rs | 23 ++- packages/oneclient_app/src/hooks/selection.rs | 128 +++++++++++++ .../view/app/cluster/package_manager/mod.rs | 133 ++++++++++++- .../view/app/cluster/package_manager/views.rs | 176 +++++++++++++++++- .../src/view/app/cluster/screenshots.rs | 128 ++++++++----- 10 files changed, 640 insertions(+), 106 deletions(-) create mode 100644 packages/oneclient_app/src/hooks/selection.rs diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs index 0f65f866..e2967477 100644 --- a/packages/oneclient_app/src/components/icons.rs +++ b/packages/oneclient_app/src/components/icons.rs @@ -51,7 +51,11 @@ impl Icon { impl Component for Icon { fn render(&self) -> impl IntoElement { let path = self.icon.path(); - let bytes = use_memo(move || AppAssets::get_bytes(path).unwrap_or_default()); + let load = move || (path, AppAssets::get_bytes(path).unwrap_or_default()); + let mut bytes = use_state(load); + if bytes.peek().0 != path { + bytes.set(load()); + } let tint = use_hook(try_consume_context::); let color = self @@ -59,7 +63,7 @@ impl Component for Icon { .or_else(|| tint.map(|tint| *tint.0.read())) .unwrap_or_else(colors::fg_primary); - SvgViewer::new((path, bytes.read().cloned())) + SvgViewer::new((path, bytes.peek().1.clone())) .show_loader(false) .width(Size::px(self.size_px)) .height(Size::px(self.size_px)) diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index c50812a5..e055899e 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -96,8 +96,9 @@ pub use optional_mods_popup::OptionalModsPopup; pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost}; pub use package_delete_dialog::use_shared_delete; pub(crate) use package_row::{ - CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, filled_pill, grid_card, - icon_box, kebab_button, meta_size, meta_text, on_secondary, package_icon, pill, remote_icon, + CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, disable_warning_body, + filled_pill, grid_card, icon_box, kebab_button, meta_size, meta_text, on_secondary, + package_icon, pill, remote_icon, toggle_action, }; pub use package_row::{ CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge, diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 3c5f228b..e50770ac 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -99,6 +99,9 @@ pub struct PackageRow { package_type: &'static str, layout: CardLayout, on_context: EventHandler<(f32, f32)>, + selected: bool, + selecting: bool, + on_select: EventHandler<()>, key: DiffKey, } @@ -110,6 +113,9 @@ impl PackageRow { package_type, layout: CardLayout::List, on_context: (|_| {}).into(), + selected: false, + selecting: false, + on_select: (|()| {}).into(), key: DiffKey::None, } } @@ -123,6 +129,18 @@ impl PackageRow { self.on_context = on_context.into(); self } + + pub fn selection( + mut self, + selected: bool, + selecting: bool, + on_select: impl Into>, + ) -> Self { + self.selected = selected; + self.selecting = selecting; + self.on_select = on_select.into(); + self + } } impl KeyExt for PackageRow { @@ -152,29 +170,12 @@ impl Component for PackageRow { let icon = package_icon(&item, &icon_query, icon_size); let on_toggle: EventHandler<()> = { - let hash = item.hash.clone(); - let bundle_name = item.bundle_name.clone(); - let package_id = item.package_id.clone(); + let action = toggle_action(&item, cluster_id, !item.enabled); let enabled_now = item.enabled; - let manifest_default = item.manifest_default; let name = item.name.clone(); let mut guard = guard; (move |()| { - let action = if let Some(h) = &hash { - ClusterAction::SetArtifactEnabled { - cluster_id, - hash: h.clone(), - enabled: !enabled_now, - } - } else if let Some(bundle) = &bundle_name { - ClusterAction::SetBundlePackageEnabled { - cluster_id, - bundle_name: bundle.clone(), - package_id: package_id.clone(), - enabled: !enabled_now, - manifest_default, - } - } else { + let Some(action) = action.clone() else { return; }; @@ -191,25 +192,80 @@ impl Component for PackageRow { .into() }; - let on_context = has_menu(&item).then(|| self.on_context.clone()); + let on_context = Some(self.on_context.clone()); - match layout { - CardLayout::List => list_card(&item, package_type, cluster_id, icon, on_toggle, on_context), - CardLayout::Grid => grid_card( - &item, - package_type, - cluster_id, - icon, - on_toggle, - true, - on_context, - hovered, + let (card, radius) = match layout { + CardLayout::List => ( + list_card(&item, package_type, cluster_id, icon, on_toggle, on_context.clone()), + 8., ), - } + CardLayout::Grid => ( + grid_card( + &item, + package_type, + cluster_id, + icon, + on_toggle, + true, + on_context.clone(), + hovered, + ), + 6., + ), + }; + + let on_select = self.on_select.clone(); + let overlay = self.selecting.then(|| { + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::percent(100.)) + .height(Size::percent(100.)) + .layer(Layer::Relative(16)) + .corner_radius(CornerRadius::new_all(radius)) + .maybe(self.selected, |el| { + el.background(colors::brand().with_a(28)).border( + border_all_color(2., colors::brand()).alignment(BorderAlignment::Inner), + ) + }) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_select.call(())) + .on_secondary_down(on_secondary(on_context)) + .into_element() + }); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(card) + .maybe_child(overlay) } } -fn disable_warning_body( +pub(crate) fn toggle_action( + item: &PackageEntry, + cluster_id: i64, + enabled: bool, +) -> Option { + if let Some(hash) = &item.hash { + Some(ClusterAction::SetArtifactEnabled { + cluster_id, + hash: hash.clone(), + enabled, + }) + } else { + item.bundle_name + .clone() + .map(|bundle_name| ClusterAction::SetBundlePackageEnabled { + cluster_id, + bundle_name, + package_id: item.package_id.clone(), + enabled, + manifest_default: item.manifest_default, + }) + } +} + +pub(crate) fn disable_warning_body( item: &PackageEntry, warnings: Option, ) -> Option { @@ -221,10 +277,6 @@ fn disable_warning_body( } } -pub fn has_menu(item: &PackageEntry) -> bool { - item.is_remote() || item.hash.is_some() -} - pub fn package_context_menu( x: f32, y: f32, @@ -232,8 +284,13 @@ pub fn package_context_menu( cluster_id: i64, package_type: &'static str, on_delete: EventHandler<(String, String)>, + on_select: EventHandler<()>, ) -> ContextMenu { - let mut menu = ContextMenu::new(x, y).title(item.name.clone()); + let mut menu = ContextMenu::new(x, y).title(item.name.clone()).action( + IconType::Check, + "Select", + on_select, + ); if item.is_remote() { let provider = item.provider; diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 98ad22ee..6f1b83dc 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -81,6 +81,7 @@ impl Component for ScreenshotViewer { action, dispatch.clone(), move |()| menu_close.call(()), + None, ) .overlay_level(OVERLAY_MAX_LEVEL) .on_close(move |_| menu.set(None)) @@ -217,13 +218,20 @@ pub fn screenshot_context_menu( action: UseScreenshotAction, dispatch: Actions, on_deleted: impl Into>, + on_select: Option>, ) -> 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) + let context_menu = match on_select { + Some(on_select) => ContextMenu::new(x, y) + .action(IconType::Check, "Select", on_select) + .separator(), + None => ContextMenu::new(x, y), + }; + context_menu .action(IconType::Folder, "Open in folder", move |()| { if let Some(dir) = open_path.parent() { crate::platform::open_path(&dir.to_string_lossy()); diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 3cd3bd7f..83dc5315 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -3,10 +3,12 @@ mod active_cluster; mod debounce; mod overlay_claims; mod queries; +mod selection; mod shortcut_actions; mod view_state; pub use debounce::use_debounced; +pub use selection::{Selection, use_selection}; pub use overlay_claims::{ OverlayClaims, use_overlay_claim, use_overlay_claim_when, use_overlay_claims, use_provide_overlay_claims, diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 57d58992..d9c4275b 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -191,6 +191,7 @@ pub enum ClusterAction { DeleteInstance { cluster_id: ClusterId, }, + Batch(Vec), } impl MutationCapability for ClusterMutation { @@ -199,6 +200,19 @@ impl MutationCapability for ClusterMutation { type Keys = ClusterAction; async fn run(&self, keys: &ClusterAction) -> Result<(), String> { + if let ClusterAction::Batch(actions) = keys { + let mut errors = Vec::new(); + for action in actions { + if let Err(err) = Box::pin(self.run(action)).await { + errors.push(err); + } + } + return if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("\n")) + }; + } let started = std::time::Instant::now(); let state = crate::launcher::state().map_err(|e| e.to_string())?; let services = &state.services; @@ -438,6 +452,7 @@ impl MutationCapability for ClusterMutation { outcome } } + ClusterAction::Batch(_) => unreachable!(), }; tracing::debug!( target: "oneclient_app::perf", @@ -460,7 +475,13 @@ impl MutationCapability for ClusterMutation { .error() .send(); } - if matches!(keys, ClusterAction::SetArtifactEnabled { .. }) { + let enabled_only = match keys { + ClusterAction::Batch(actions) => actions + .iter() + .all(|a| matches!(a, ClusterAction::SetArtifactEnabled { .. })), + keys => matches!(keys, ClusterAction::SetArtifactEnabled { .. }), + }; + if enabled_only { invalidate_enabled_flag_queries().await; } else { invalidate_cluster_queries().await; diff --git a/packages/oneclient_app/src/hooks/selection.rs b/packages/oneclient_app/src/hooks/selection.rs new file mode 100644 index 00000000..9a0f3248 --- /dev/null +++ b/packages/oneclient_app/src/hooks/selection.rs @@ -0,0 +1,128 @@ +use std::collections::HashSet; +use std::hash::Hash; + +use freya::prelude::*; + +#[derive(PartialEq)] +pub struct Selection { + keys: State>, + anchor: State>, + modifiers: State, + active: State, +} + +impl Clone for Selection { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Selection {} + +pub fn use_selection() -> Selection { + Selection { + keys: use_state(HashSet::new), + anchor: use_state(|| None), + modifiers: use_state(Modifiers::empty), + active: use_state(|| false), + } +} + +fn held_after(e: &KeyboardEventData, down: bool) -> Modifiers { + let key = match e.code { + Code::ShiftLeft | Code::ShiftRight => Modifiers::SHIFT, + Code::ControlLeft | Code::ControlRight => Modifiers::CONTROL, + Code::MetaLeft | Code::MetaRight => Modifiers::META, + _ => Modifiers::empty(), + }; + let mut held = e.modifiers; + held.set(key, down); + held +} + +impl Selection { + pub fn track_modifiers(self, el: E) -> E { + let mut down = self.modifiers; + let mut up = self.modifiers; + el.on_global_key_down(move |e: Event| { + down.set_if_modified(held_after(&e, true)); + }) + .on_global_key_up(move |e: Event| { + up.set_if_modified(held_after(&e, false)); + }) + } + + pub fn modifier_held(&self) -> bool { + self.modifiers + .read() + .intersects(Modifiers::SHIFT | Modifiers::CONTROL | Modifiers::META) + } + + pub fn is_active(&self) -> bool { + *self.active.read() + } + + pub fn enter(mut self) { + self.active.set_if_modified(true); + } + + pub fn is_selected(&self, key: &K) -> bool { + self.keys.read().contains(key) + } + + pub fn selected_in(&self, order: &[K]) -> Vec { + let keys = self.keys.read(); + order.iter().filter(|k| keys.contains(k)).cloned().collect() + } + + pub fn toggle(mut self, key: K) { + if !self.keys.write().remove(&key) { + self.keys.write().insert(key.clone()); + } + self.anchor.set(Some(key)); + self.enter(); + } + + pub fn click(mut self, key: K, order: &[K]) { + let anchor = self.anchor.peek().clone(); + let range = anchor + .filter(|_| self.modifiers.peek().contains(Modifiers::SHIFT)) + .and_then(|anchor| order.iter().position(|k| *k == anchor)) + .zip(order.iter().position(|k| *k == key)); + + let Some((from, to)) = range else { + return self.toggle(key); + }; + + let select = !self.keys.peek().contains(&key); + let mut keys = self.keys.write(); + for k in &order[from.min(to)..=from.max(to)] { + if select { + keys.insert(k.clone()); + } else { + keys.remove(k); + } + } + drop(keys); + self.enter(); + } + + pub fn toggle_all(mut self, order: &[K]) { + let all = order.iter().all(|k| self.keys.peek().contains(k)); + if all { + self.clear(); + } else { + self.keys.write().extend(order.iter().cloned()); + } + } + + pub fn clear(mut self) { + self.keys.write().clear(); + self.anchor.set(None); + } + + pub fn exit(mut self) { + self.clear(); + self.active.set_if_modified(false); + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index e4a2fd28..53df41f8 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -8,12 +8,22 @@ use oneclient_core::{ }; use oneclient_db::models::OverrideType; -use crate::components::{CARD_GRID_H, CardLayout, GRID_GAP, GRID_MIN_W, PackageEntry}; -use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state}; +use crate::components::{ + CARD_GRID_H, CardLayout, GRID_GAP, GRID_MIN_W, PackageEntry, disable_warning_body, + toggle_action, +}; +use crate::hooks::{ + ClusterAction, EssentialGuardKind, PendingEssential, disable_warnings, package_meta_batch, + use_cluster_mutation, use_disable_warnings, use_essential_guard, use_game_snapshot, + use_package_meta_batch, use_selection, use_view_state, +}; + +use super::folder_list::confirm_dialog; mod views; use views::{ - AdvancedSection, ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, toolbar_bar, + AdvancedSection, Bulk, ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, + toolbar_bar, }; pub(super) use views::{empty_hint, empty_shell, empty_title, notice_bar}; @@ -466,6 +476,11 @@ impl Component for PackageManager { let hidden_filter = use_state(|| HiddenFilter::Hide); let advanced_open = use_state(|| false); let toolbar_width = use_state(|| 0f32); + let selection = use_selection::(); + let mutation = use_cluster_mutation(); + let mut guard = use_essential_guard(); + let warnings_query = use_disable_warnings(); + let mut confirm_delete = use_state(|| false); let view = use_view_state("cluster.packages"); let sort = view.sort; let layout = view.layout; @@ -532,7 +547,109 @@ impl Component for PackageManager { let (advanced, filtered): (Vec<_>, Vec<_>) = filtered.into_iter().partition(|p| p.advanced); - rect() + let section = AdvancedSection { + open: advanced_open, + forced: !query.is_empty(), + }; + let shown: Vec<&PackageEntry> = filtered + .iter() + .chain(advanced.iter().filter(|_| section.expanded())) + .collect(); + let order: Vec = shown.iter().map(|p| p.package_id.clone()).collect(); + let chosen: Vec = shown + .into_iter() + .filter(|p| selection.is_selected(&p.package_id)) + .cloned() + .collect(); + let deletable: Vec = chosen + .iter() + .filter(|p| p.installed && !p.in_bundle()) + .filter_map(|p| p.hash.clone()) + .collect(); + + let set_enabled: EventHandler = { + let chosen = chosen.clone(); + let warnings = disable_warnings(&warnings_query); + (move |enabled: bool| { + let targets: Vec<&PackageEntry> = + chosen.iter().filter(|p| p.enabled != enabled).collect(); + let actions: Vec = targets + .iter() + .filter_map(|p| toggle_action(p, cluster_id, enabled)) + .collect(); + if actions.is_empty() { + return; + } + let action = ClusterAction::Batch(actions); + + let warned: Vec = targets + .iter() + .filter(|_| !enabled) + .filter_map(|p| { + disable_warning_body(p, warnings.clone()) + .map(|body| format!("**{}**\n\n{body}", p.name)) + }) + .collect(); + if warned.is_empty() { + mutation.mutate(action); + return; + } + guard.set(Some(PendingEssential { + name: match targets.as_slice() { + [only] => only.name.clone(), + _ => format!("{} {noun_plural}", targets.len()), + }, + body: warned.join("\n\n"), + kind: EssentialGuardKind::Disable, + action, + })); + }) + .into() + }; + + let bulk = Bulk { + selection, + order, + count: chosen.len(), + deletable: deletable.len(), + set_enabled, + delete: (move |()| confirm_delete.set(true)).into(), + }; + + let delete_dialog = confirm_delete.read().then(|| { + let count = deletable.len(); + let noun = if count == 1 { + package_type + } else { + noun_plural + }; + let shared = if shares_content == Some(true) { + format!(" Shared {noun_plural} are deleted from every cluster that uses them.") + } else { + String::new() + }; + confirm_dialog( + format!("Delete {count} {noun}?"), + format!("This can't be undone.{shared}"), + move || confirm_delete.set(false), + move || { + mutation.mutate(ClusterAction::Batch( + deletable + .iter() + .map(|hash| ClusterAction::RemoveArtifact { + cluster_id, + hash: hash.clone(), + }) + .collect(), + )); + selection.exit(); + confirm_delete.set(false); + }, + ) + }); + + selection + .track_modifiers(rect()) .vertical() .width(Size::fill()) .height(Size::fill()) @@ -550,23 +667,23 @@ impl Component for PackageManager { cluster_id, package_type, toolbar_width, + &bulk, )) .child( ContentBox::new( filtered, advanced, - AdvancedSection { - open: advanced_open, - forced: !query.is_empty(), - }, + section, noun_plural, package_type, content_type, cluster_id, content_kind, card_layout, + bulk, ) .notices(notices), ) + .maybe_child(delete_dialog) } } diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 139d5f9a..51548420 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -5,11 +5,11 @@ use oneclient_content::packages::ContentType; use oneclient_core::settings::ViewLayout; use crate::components::{ - Button, CardLayout, ChevronToggle, FilterMenu, FilterOption, Icon, IconType, LazySection, - PackageEntry, PackageRow, ScrollArea, Segment, SegmentedControl, TextInput, + Button, CardLayout, ChevronToggle, ContextMenu, FilterMenu, FilterOption, Icon, IconType, + LazySection, PackageEntry, PackageRow, ScrollArea, Segment, SegmentedControl, TextInput, package_context_menu, use_shared_delete, }; -use crate::hooks::{ClusterAction, use_cluster_mutation, use_dispatch}; +use crate::hooks::{ClusterAction, Selection, use_cluster_mutation, use_dispatch}; use crate::routes::Route; use crate::theme::colors; use crate::{Actions, utils}; @@ -120,6 +120,131 @@ impl HiddenFilter { } } +#[derive(Clone, PartialEq)] +pub(super) struct Bulk { + pub(super) selection: Selection, + pub(super) order: Vec, + pub(super) count: usize, + pub(super) deletable: usize, + pub(super) set_enabled: EventHandler, + pub(super) delete: EventHandler<()>, +} + +impl Bulk { + pub(super) fn active(&self) -> bool { + self.selection.is_active() + } + + fn all_selected(&self) -> bool { + self.count > 0 && self.count == self.order.len() + } + + fn toggle_all_label(&self) -> (IconType, &'static str) { + if self.all_selected() { + (IconType::XClose, "Unselect all") + } else { + (IconType::Check, "Select all") + } + } + + fn controls(&self) -> Vec { + let selection = self.selection; + let order = self.order.clone(); + let enable = self.set_enabled.clone(); + let disable = self.set_enabled.clone(); + let delete = self.delete.clone(); + let (toggle_icon, toggle_label) = self.toggle_all_label(); + let button = |icon: IconType, text: String| { + Button::new() + .secondary() + .height(Size::px(34.)) + .font_size(12.) + .child(Icon::new(icon).size(15.)) + .text(text) + }; + + vec![ + label() + .text(format!("{} selected", self.count)) + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + button(toggle_icon, toggle_label.to_string()) + .on_press(move |_| selection.toggle_all(&order)) + .into_element(), + button(IconType::CheckCircle, "Enable".to_string()) + .enabled(self.count > 0) + .on_press(move |_| enable.call(true)) + .into_element(), + button(IconType::Minus, "Disable".to_string()) + .enabled(self.count > 0) + .on_press(move |_| disable.call(false)) + .into_element(), + button(IconType::Trash01, format!("Delete ({})", self.deletable)) + .danger() + .enabled(self.deletable > 0) + .on_press(move |_| delete.call(())) + .into_element(), + Button::new() + .ghost() + .icon() + .height(Size::px(34.)) + .tooltip("Exit select mode") + .on_press(move |_| selection.exit()) + .child(Icon::new(IconType::XClose).size(15.)) + .into_element(), + ] + } + + fn menu(&self, x: f32, y: f32, key: String) -> ContextMenu { + let selection = self.selection; + let order = self.order.clone(); + let enable = self.set_enabled.clone(); + let disable = self.set_enabled.clone(); + let delete = self.delete.clone(); + let (toggle_icon, toggle_label) = self.toggle_all_label(); + + let (icon, text) = if selection.is_selected(&key) { + (IconType::XClose, "Unselect") + } else { + (IconType::Check, "Select") + }; + + let mut menu = ContextMenu::new(x, y) + .title(format!("{} selected", self.count)) + .action(icon, text, move |()| selection.toggle(key.clone())) + .separator(); + if self.count > 0 { + menu = menu + .action(IconType::CheckCircle, "Enable Selected", move |()| { + enable.call(true) + }) + .action(IconType::Minus, "Disable Selected", move |()| { + disable.call(false) + }) + .separator(); + } + menu = menu.action(toggle_icon, toggle_label, move |()| { + selection.toggle_all(&order) + }); + if self.count > 0 && !self.all_selected() { + menu = menu.action(IconType::XClose, "Clear selection", move |()| { + selection.clear() + }); + } + + if self.deletable == 0 { + return menu; + } + menu.separator().danger_action( + IconType::Trash01, + format!("Delete {}", self.deletable), + move |()| delete.call(()), + ) + } +} + const TOOLBAR_STACK_W: f32 = 640.; const TABS_ROW_H: f32 = 34.; @@ -147,6 +272,7 @@ pub(super) fn toolbar_bar( cluster_id: i64, package_type: &'static str, mut toolbar_width: State, + bulk: &Bulk, ) -> impl IntoElement { let chips = tabs.iter().enumerate().map(|(i, tab)| { let mut active = active; @@ -226,6 +352,11 @@ pub(super) fn toolbar_bar( .text("Add Content") .into_element(), ); + let controls = if bulk.active() { + bulk.controls() + } else { + controls + }; let inner = if stacked { rect() @@ -514,7 +645,7 @@ pub(super) struct AdvancedSection { } impl AdvancedSection { - fn expanded(self) -> bool { + pub(super) fn expanded(self) -> bool { self.forced || *self.open.read() } } @@ -581,6 +712,7 @@ pub(super) struct ContentBox { cluster_id: i64, kind: ContentKind, layout: CardLayout, + bulk: Bulk, notices: Vec, } @@ -596,6 +728,7 @@ impl ContentBox { cluster_id: i64, kind: ContentKind, layout: CardLayout, + bulk: Bulk, ) -> Self { Self { items, @@ -607,6 +740,7 @@ impl ContentBox { cluster_id, kind, layout, + bulk, notices: Vec::new(), } } @@ -641,14 +775,30 @@ impl Component for ContentBox { cluster.mutate(ClusterAction::RemoveArtifact { cluster_id, hash }); }); + let bulk = self.bulk.clone(); + let selection = bulk.selection; + let active = bulk.active(); + let selecting = active || selection.modifier_held(); + let selected: Vec = items + .iter() + .map(|p| selection.is_selected(&p.package_id)) + .collect(); + let row = { let items = items.clone(); + let order = bulk.order.clone(); move |i: usize| { let item: PackageEntry = items[i].clone(); let key = item.package_id.clone(); let for_menu = item.clone(); + let is_selected = selected[i]; + let order = order.clone(); + let click_key = key.clone(); PackageRow::new(item, cluster_id, package_type) .layout(layout) + .selection(is_selected, selecting, move |()| { + selection.click(click_key.clone(), &order) + }) .on_context(move |(x, y)| menu.set(Some((x, y, for_menu.clone())))) .key(key) .into_element() @@ -696,9 +846,21 @@ impl Component for ContentBox { }); let menu_overlay = menu.read().clone().map(|(x, y, item)| { - package_context_menu(x, y, &item, cluster_id, package_type, on_delete) - .on_close(move |_| menu.set(None)) - .into_element() + let menu_for = if bulk.active() { + bulk.menu(x, y, item.package_id.clone()) + } else { + let key = item.package_id.clone(); + package_context_menu( + x, + y, + &item, + cluster_id, + package_type, + on_delete, + (move |()| selection.toggle(key.clone())).into(), + ) + }; + menu_for.on_close(move |_| menu.set(None)).into_element() }); let empty = (count == 0).then(|| match kind { diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index 3edb0f4e..4d049594 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::path::PathBuf; use freya::prelude::*; @@ -6,12 +5,13 @@ use oneclient_core::ScreenshotInfo; use oneclient_core::settings::ViewLayout; use crate::components::{ - Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment, - SegmentedControl, open_folder_button, screenshot_context_menu, + Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, + Segment, SegmentedControl, open_folder_button, 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, + ScreenshotAction, Selection, query_is_loading, try_cluster_screenshots, + use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, + use_selection, use_view_state, }; use crate::layout::cluster_content; use crate::theme::colors; @@ -51,19 +51,23 @@ impl Component for ClusterScreenshots { let view_mode = use_view_state("cluster.screenshots").layout; let menu_dispatch = use_dispatch(); - let mut edit_mode = use_state(|| false); - let mut selected = use_state(HashSet::::new); + let selection = use_selection::(); let mut viewing = use_state(|| None::); - let confirm_delete = use_state(|| false); + let mut confirm_delete = use_state(|| false); let grid_width = use_state(|| 0f32); let mut menu = use_state(|| None::<(f32, f32, PathBuf)>); + let order: Vec = shots.iter().map(|s| s.path.clone()).collect(); + let selected = selection.selected_in(&order); + let editing = selection.is_active(); + let toolbar = toolbar_row( &shots, folder, view_mode, - edit_mode, - selected, + editing, + selection, + selected.len(), confirm_delete, ); @@ -73,17 +77,13 @@ impl Component for ClusterScreenshots { let mut items: Vec = Vec::new(); for (idx, info) in shots.iter().enumerate() { let info = info.clone(); - let is_selected = selected.read().contains(&info.path); - let editing = *edit_mode.read(); + let is_selected = selected.contains(&info.path); let activate_path = info.path.clone(); + let activate_order = order.clone(); let on_activate = move |_| { - if editing { - let p = activate_path.clone(); - let mut set = selected.write(); - if !set.remove(&p) { - set.insert(p); - } + if editing || selection.modifier_held() { + selection.click(activate_path.clone(), &activate_order); } else { viewing.set(Some(idx)); } @@ -137,27 +137,67 @@ impl Component for ClusterScreenshots { .children(vec![content]); let confirm_overlay = confirm_delete.read().then(|| { - let count = selected.read().len(); - confirm_panel(count, confirm_delete, move || { - let paths: Vec = selected.read().iter().cloned().collect(); - for path in paths { + let paths = selected.clone(); + confirm_panel(paths.len(), confirm_delete, move || { + for path in paths.iter().cloned() { action.mutate(ScreenshotAction::Delete { path }); } - selected.write().clear(); - edit_mode.set(false); + selection.exit(); confirm_delete.clone().set(false); }) }); let menu_overlay = menu.read().clone().map(|(x, y, path)| { - screenshot_context_menu(x, y, path, action, menu_dispatch.clone(), |()| {}) - .on_close(move |_| menu.set(None)) - .into_element() + let menu_for = if !editing { + let select_path = path.clone(); + screenshot_context_menu( + x, + y, + path, + action, + menu_dispatch.clone(), + |()| {}, + Some((move |()| selection.toggle(select_path.clone())).into()), + ) + } else { + let all = order.clone(); + let every = !selected.is_empty() && selected.len() == order.len(); + let (icon, text) = if every { + (IconType::XClose, "Unselect all") + } else { + (IconType::Check, "Select all") + }; + let own = if selected.contains(&path) { + (IconType::XClose, "Unselect") + } else { + (IconType::Check, "Select") + }; + let mut multi = ContextMenu::new(x, y) + .title(format!("{} selected", selected.len())) + .action(own.0, own.1, move |()| selection.toggle(path.clone())) + .separator() + .action(icon, text, move |()| selection.toggle_all(&all)); + if !selected.is_empty() && !every { + multi = multi.action(IconType::XClose, "Clear selection", move |()| { + selection.clear() + }); + } + if !selected.is_empty() { + multi = multi.separator().danger_action( + IconType::Trash01, + format!("Delete {}", selected.len()), + move |()| confirm_delete.set(true), + ); + } + multi + }; + menu_for.on_close(move |_| menu.set(None)).into_element() }); cluster_content() .child( - rect() + selection + .track_modifiers(rect()) .vertical() .width(Size::fill()) .height(Size::fill()) @@ -179,12 +219,11 @@ fn toolbar_row( shots: &[ScreenshotInfo], folder: Option, view_mode: State, - mut edit_mode: State, - mut selected: State>, + editing: bool, + selection: Selection, + count: usize, mut confirm_delete: State, ) -> impl IntoElement { - let editing = *edit_mode.read(); - let count = selected.read().len(); let all_paths: Vec = shots.iter().map(|s| s.path.clone()).collect(); let mut right = rect() @@ -199,7 +238,7 @@ fn toolbar_row( ); if editing { - let select_all_paths = all_paths.clone(); + let every = count > 0 && count == all_paths.len(); right = right .child( label() @@ -211,20 +250,18 @@ fn toolbar_row( Button::new() .secondary() .small() - .on_press(move |_| { - let mut set = selected.write(); - *set = select_all_paths.iter().cloned().collect(); - }) - .text("Select all"), + .on_press(move |_| selection.toggle_all(&all_paths)) + .text(if every { "Unselect all" } else { "Select all" }), ) - .child( + .maybe_child((count > 0 && !every).then(|| { Button::new() .ghost() .small() .enabled(count > 0) - .on_press(move |_| selected.write().clear()) - .text("Deselect all"), - ) + .on_press(move |_| selection.clear()) + .text("Deselect all") + .into_element() + })) .child( Button::new() .danger() @@ -238,10 +275,7 @@ fn toolbar_row( Button::new() .ghost() .small() - .on_press(move |_| { - selected.write().clear(); - edit_mode.set(false); - }) + .on_press(move |_| selection.exit()) .text("Cancel"), ); } else { @@ -257,7 +291,7 @@ fn toolbar_row( .secondary() .small() .enabled(!shots.is_empty()) - .on_press(move |_| edit_mode.set(true)) + .on_press(move |_| selection.enter()) .child(Icon::new(IconType::Pencil01).size(14.)) .text("Select"), );