feat: package selection (#897)

This commit is contained in:
LynithDev 2026-09-30 21:45:46 +02:00 • committed by GitHub
parent 0512727c22
commit 65a9c670cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 640 additions and 106 deletions

View file

@ -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::<IconTint>);
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))

View file

@ -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,

View file

@ -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<EventHandler<()>>,
) -> 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<ClusterAction> {
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<oneclient_core::DisableWarnings>,
) -> Option<String> {
@ -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;

View file

@ -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<EventHandler<()>>,
on_select: Option<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)
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());

View file

@ -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,

View file

@ -191,6 +191,7 @@ pub enum ClusterAction {
DeleteInstance {
cluster_id: ClusterId,
},
Batch(Vec<ClusterAction>),
}
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;

View file

@ -0,0 +1,128 @@
use std::collections::HashSet;
use std::hash::Hash;
use freya::prelude::*;
#[derive(PartialEq)]
pub struct Selection<K: 'static> {
keys: State<HashSet<K>>,
anchor: State<Option<K>>,
modifiers: State<Modifiers>,
active: State<bool>,
}
impl<K> Clone for Selection<K> {
fn clone(&self) -> Self {
*self
}
}
impl<K> Copy for Selection<K> {}
pub fn use_selection<K: Clone + Eq + Hash + 'static>() -> Selection<K> {
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<K: Clone + Eq + Hash + 'static> Selection<K> {
pub fn track_modifiers<E: EventHandlersExt>(self, el: E) -> E {
let mut down = self.modifiers;
let mut up = self.modifiers;
el.on_global_key_down(move |e: Event<KeyboardEventData>| {
down.set_if_modified(held_after(&e, true));
})
.on_global_key_up(move |e: Event<KeyboardEventData>| {
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<K> {
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);
}
}

View file

@ -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::<String>();
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<String> = shown.iter().map(|p| p.package_id.clone()).collect();
let chosen: Vec<PackageEntry> = shown
.into_iter()
.filter(|p| selection.is_selected(&p.package_id))
.cloned()
.collect();
let deletable: Vec<String> = chosen
.iter()
.filter(|p| p.installed && !p.in_bundle())
.filter_map(|p| p.hash.clone())
.collect();
let set_enabled: EventHandler<bool> = {
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<ClusterAction> = 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<String> = 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)
}
}

View file

@ -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<String>,
pub(super) order: Vec<String>,
pub(super) count: usize,
pub(super) deletable: usize,
pub(super) set_enabled: EventHandler<bool>,
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<Element> {
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<f32>,
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<String>,
}
@ -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<bool> = 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 {

View file

@ -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::<PathBuf>::new);
let selection = use_selection::<PathBuf>();
let mut viewing = use_state(|| None::<usize>);
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<PathBuf> = 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<Element> = 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<PathBuf> = 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<PathBuf>,
view_mode: State<ViewLayout>,
mut edit_mode: State<bool>,
mut selected: State<HashSet<PathBuf>>,
editing: bool,
selection: Selection<PathBuf>,
count: usize,
mut confirm_delete: State<bool>,
) -> impl IntoElement {
let editing = *edit_mode.read();
let count = selected.read().len();
let all_paths: Vec<PathBuf> = 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"),
);