feat: package selection (#897)
This commit is contained in:
parent
0512727c22
commit
65a9c670cc
10 changed files with 640 additions and 106 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
128
packages/oneclient_app/src/hooks/selection.rs
Normal file
128
packages/oneclient_app/src/hooks/selection.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue