chore: redesigned package grid (#867)
This commit is contained in:
parent
36a925da06
commit
3a5f06e8ac
10 changed files with 544 additions and 255 deletions
|
|
@ -79,7 +79,7 @@ pub use notifications::NotificationCenter;
|
|||
pub use optional_mods_popup::OptionalModsPopup;
|
||||
pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost};
|
||||
pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon};
|
||||
pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge};
|
||||
pub use package_row::{CardLayout, PackageEntry, PackageRow, package_context_menu, provider_badge};
|
||||
pub use package_update_popup::PackageUpdatePopup;
|
||||
pub use pagination::Pagination;
|
||||
pub use player_model::PlayerModel;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
|||
const DIALOG_W: f32 = 720.;
|
||||
const DIALOG_PAD: f32 = 22.;
|
||||
const LIST_MAX_H: f32 = 306.;
|
||||
const ICON_SIZE: f32 = 52.;
|
||||
const ICON_SIZE: f32 = 40.;
|
||||
|
||||
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
|
||||
|
||||
|
|
@ -103,6 +103,7 @@ fn entry_from_item(
|
|||
author: cached
|
||||
.map(|cached| cached.author.clone())
|
||||
.unwrap_or_default(),
|
||||
version: None,
|
||||
description: cached
|
||||
.map(|cached| cached.summary.clone())
|
||||
.unwrap_or_default(),
|
||||
|
|
@ -350,6 +351,7 @@ impl Component for OptionalModCard {
|
|||
fn render(&self) -> impl IntoElement {
|
||||
let icon_query = use_cached_image(self.entry.icon_url.clone(), 256);
|
||||
let icon = package_icon(&self.entry, &icon_query, ICON_SIZE);
|
||||
let hovered = use_state(|| false);
|
||||
|
||||
grid_card(
|
||||
&self.entry,
|
||||
|
|
@ -358,6 +360,8 @@ impl Component for OptionalModCard {
|
|||
icon,
|
||||
self.on_toggle.clone(),
|
||||
false,
|
||||
None,
|
||||
hovered,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
use freya::prelude::*;
|
||||
use freya::query::UseMutation;
|
||||
use freya::router::RouterContext;
|
||||
use oneclient_content::packages::ProviderId;
|
||||
use oneclient_core::SeenStatus;
|
||||
|
||||
use crate::components::{Icon, IconType, toggle_controlled};
|
||||
use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation};
|
||||
use crate::components::{ContextMenu, Icon, IconType, toggle_controlled};
|
||||
use crate::hooks::{
|
||||
ClusterAction, ClusterMutation, loaded_image, use_cached_image, use_cluster_mutation,
|
||||
};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::{ImageFallbackExt, border_all_color};
|
||||
|
|
@ -13,9 +16,9 @@ use crate::utils::format_size;
|
|||
pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255);
|
||||
pub(crate) const CARD_H: f32 = 84.;
|
||||
pub(crate) const CARD_GRID_H: f32 = 148.;
|
||||
pub(crate) const GRID_GAP: f32 = 10.;
|
||||
pub(crate) const GRID_MIN_W: f32 = 260.;
|
||||
pub(crate) const CARD_GRID_H: f32 = 112.;
|
||||
pub(crate) const GRID_GAP: f32 = 12.;
|
||||
pub(crate) const GRID_MIN_W: f32 = 290.;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum CardLayout {
|
||||
|
|
@ -40,6 +43,7 @@ pub struct PackageEntry {
|
|||
pub name: String,
|
||||
pub file_name: String,
|
||||
pub author: String,
|
||||
pub version: Option<String>,
|
||||
pub description: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub size: u64,
|
||||
|
|
@ -85,6 +89,7 @@ pub struct PackageRow {
|
|||
cluster_id: i64,
|
||||
package_type: &'static str,
|
||||
layout: CardLayout,
|
||||
on_context: EventHandler<(f32, f32)>,
|
||||
key: DiffKey,
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +100,7 @@ impl PackageRow {
|
|||
cluster_id,
|
||||
package_type,
|
||||
layout: CardLayout::List,
|
||||
on_context: (|_| {}).into(),
|
||||
key: DiffKey::None,
|
||||
}
|
||||
}
|
||||
|
|
@ -103,6 +109,11 @@ impl PackageRow {
|
|||
self.layout = layout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_context(mut self, on_context: impl Into<EventHandler<(f32, f32)>>) -> Self {
|
||||
self.on_context = on_context.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyExt for PackageRow {
|
||||
|
|
@ -118,11 +129,11 @@ impl Component for PackageRow {
|
|||
let package_type = self.package_type;
|
||||
let layout = self.layout;
|
||||
let cluster = use_cluster_mutation();
|
||||
let remove_hover = use_state(|| false);
|
||||
let hovered = use_state(|| false);
|
||||
|
||||
let icon_size = match layout {
|
||||
CardLayout::List => 44.,
|
||||
CardLayout::Grid => 52.,
|
||||
CardLayout::Grid => 40.,
|
||||
};
|
||||
let icon_query = use_cached_image(item.icon_url.clone(), 256);
|
||||
let icon = package_icon(&item, &icon_query, icon_size);
|
||||
|
|
@ -153,45 +164,92 @@ impl Component for PackageRow {
|
|||
.into()
|
||||
};
|
||||
|
||||
let on_context = has_menu(&item).then(|| self.on_context.clone());
|
||||
|
||||
match layout {
|
||||
CardLayout::List => {
|
||||
let removable = !item.in_bundle();
|
||||
let can_remove = removable && item.installed;
|
||||
let rm_hash = item.hash.clone();
|
||||
let on_remove = move || {
|
||||
if let Some(h) = &rm_hash {
|
||||
cluster.mutate(ClusterAction::RemoveArtifact {
|
||||
cluster_id,
|
||||
hash: h.clone(),
|
||||
});
|
||||
}
|
||||
};
|
||||
list_card(
|
||||
&item,
|
||||
package_type,
|
||||
cluster_id,
|
||||
icon,
|
||||
on_toggle,
|
||||
can_remove,
|
||||
on_remove,
|
||||
remove_hover,
|
||||
)
|
||||
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),
|
||||
CardLayout::Grid => grid_card(
|
||||
&item,
|
||||
package_type,
|
||||
cluster_id,
|
||||
icon,
|
||||
on_toggle,
|
||||
true,
|
||||
on_context,
|
||||
hovered,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_menu(item: &PackageEntry) -> bool {
|
||||
item.is_remote() || item.hash.is_some()
|
||||
}
|
||||
|
||||
pub fn package_context_menu(
|
||||
x: f32,
|
||||
y: f32,
|
||||
item: &PackageEntry,
|
||||
cluster_id: i64,
|
||||
package_type: &'static str,
|
||||
cluster: UseMutation<ClusterMutation>,
|
||||
) -> ContextMenu {
|
||||
let mut menu = ContextMenu::new(x, y).title(item.name.clone());
|
||||
|
||||
if item.is_remote() {
|
||||
let provider = item.provider;
|
||||
let package_id = item.package_id.clone();
|
||||
let package_type = package_type.to_string();
|
||||
menu = menu.action(IconType::LinkExternal01, "View in browser", move |()| {
|
||||
let _ = RouterContext::get().push(Route::BrowserPackage {
|
||||
cluster_id,
|
||||
package_type: package_type.clone(),
|
||||
package_id: format!("{}:{}", provider as u8, package_id),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(hash) = item.hash.clone() {
|
||||
menu = menu.action(IconType::Folder, "View in folder", move |()| {
|
||||
reveal_in_store(hash.clone());
|
||||
});
|
||||
}
|
||||
|
||||
if item.installed && !item.in_bundle() {
|
||||
let hash = item.hash.clone();
|
||||
menu = menu
|
||||
.separator()
|
||||
.danger_action(IconType::Trash01, "Delete", move |()| {
|
||||
if let Some(hash) = &hash {
|
||||
cluster.mutate(ClusterAction::RemoveArtifact {
|
||||
cluster_id,
|
||||
hash: hash.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
menu
|
||||
}
|
||||
|
||||
fn on_secondary(handler: Option<EventHandler<(f32, f32)>>) -> impl FnMut(Event<PressEventData>) {
|
||||
move |e: Event<PressEventData>| {
|
||||
if let (Some(handler), PressEventData::Mouse(m)) = (handler.as_ref(), e.data()) {
|
||||
e.stop_propagation();
|
||||
handler.call((m.global_location.x as f32, m.global_location.y as f32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn list_card(
|
||||
item: &PackageEntry,
|
||||
package_type: &'static str,
|
||||
cluster_id: i64,
|
||||
icon: impl IntoElement,
|
||||
on_toggle: EventHandler<()>,
|
||||
can_remove: bool,
|
||||
on_remove: impl FnMut() + 'static,
|
||||
hovering: State<bool>,
|
||||
on_context: Option<EventHandler<(f32, f32)>>,
|
||||
) -> Element {
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
@ -203,13 +261,15 @@ fn list_card(
|
|||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(CARD_BG)
|
||||
.content(Content::Flex)
|
||||
.on_secondary_down(on_secondary(on_context.clone()))
|
||||
.child(package_info(item, package_type, cluster_id, icon))
|
||||
.child(meta_size(item.size))
|
||||
.child(toggle_controlled(item.enabled, on_toggle))
|
||||
.child(remove_button(can_remove, on_remove, hovering))
|
||||
.maybe_child(on_context.map(kebab_button))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn grid_card(
|
||||
item: &PackageEntry,
|
||||
package_type: &'static str,
|
||||
|
|
@ -217,46 +277,42 @@ pub(crate) fn grid_card(
|
|||
icon: impl IntoElement,
|
||||
on_toggle: EventHandler<()>,
|
||||
navigable: bool, // if true, it takes the user to the mod page
|
||||
on_context: Option<EventHandler<(f32, f32)>>,
|
||||
mut hovered: State<bool>,
|
||||
) -> Element {
|
||||
let remote = item.is_remote();
|
||||
let enabled = item.enabled;
|
||||
let title = item.name.clone();
|
||||
let hovering = *hovered.read();
|
||||
|
||||
let (bg, border, content_alpha) = if enabled {
|
||||
(colors::brand().with_a(38), colors::brand(), 255)
|
||||
} else {
|
||||
(CARD_BG, colors::component_border(), 140)
|
||||
let bg = match (enabled, hovering) {
|
||||
(_, true) => colors::component_bg_hover(),
|
||||
(true, false) => colors::component_bg(),
|
||||
(false, false) => colors::component_bg_disabled(),
|
||||
};
|
||||
|
||||
let badge = if remote && navigable {
|
||||
let provider = item.provider;
|
||||
let package_id = item.package_id.clone();
|
||||
let package_type_owned = package_type.to_string();
|
||||
rect()
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
let _ = RouterContext::get().push(Route::BrowserPackage {
|
||||
cluster_id,
|
||||
package_type: package_type_owned.clone(),
|
||||
package_id: format!("{}:{}", provider as u8, package_id),
|
||||
});
|
||||
})
|
||||
.child(provider_badge(item.provider))
|
||||
.into_element()
|
||||
} else if remote {
|
||||
provider_badge(item.provider)
|
||||
let alpha = if enabled {
|
||||
255u8
|
||||
} else {
|
||||
115u8
|
||||
};
|
||||
|
||||
let border = if !hovering {
|
||||
colors::component_border()
|
||||
} else {
|
||||
local_badge()
|
||||
colors::component_border_hover()
|
||||
};
|
||||
|
||||
let header = rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(10.)
|
||||
// .cross_align(Alignment::Center)
|
||||
.spacing(11.)
|
||||
.content(Content::Flex)
|
||||
.child(icon)
|
||||
.child(
|
||||
rect()
|
||||
.opacity(alpha as f32 / 255.)
|
||||
.child(icon)
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
|
|
@ -266,62 +322,59 @@ pub(crate) fn grid_card(
|
|||
label()
|
||||
.text(title)
|
||||
.font_size(14.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.max_lines(1)
|
||||
.text_overflow(TextOverflow::Ellipsis)
|
||||
.width(Size::fill())
|
||||
.color(CARD_NAME.with_a(content_alpha)),
|
||||
.color(if enabled {
|
||||
Color::WHITE
|
||||
} else {
|
||||
CARD_NAME.with_a(alpha)
|
||||
}),
|
||||
)
|
||||
.maybe(!item.author.is_empty(), |el| {
|
||||
el.child(
|
||||
label()
|
||||
.text(format!("by {}", item.author))
|
||||
.font_size(10.)
|
||||
.max_lines(1)
|
||||
.text_overflow(TextOverflow::Ellipsis)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_secondary().with_a(content_alpha)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(6.)
|
||||
.content(Content::wrap_spacing(4.))
|
||||
.child(badge)
|
||||
.maybe_child(item.is_outdated().then(outdated_badge))
|
||||
.maybe_child(item.recency_badge()),
|
||||
),
|
||||
.child(grid_meta(item, package_type, cluster_id, navigable, alpha)),
|
||||
)
|
||||
.maybe_child(on_context.clone().map(kebab_button))
|
||||
.into_element();
|
||||
|
||||
let description = if item.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
label()
|
||||
.text(item.description.clone())
|
||||
.font_size(11.)
|
||||
.max_lines(3)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_secondary().with_a(content_alpha))
|
||||
.into_element(),
|
||||
)
|
||||
};
|
||||
let description = (!item.description.is_empty()).then(|| {
|
||||
label()
|
||||
.text(item.description.clone())
|
||||
.font_size(11.)
|
||||
.line_height(1.45)
|
||||
.max_lines(2)
|
||||
.text_overflow(TextOverflow::Ellipsis)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_primary().with_a(scale_a(alpha, 0.72)))
|
||||
.into_element()
|
||||
});
|
||||
|
||||
let floating = (item.is_outdated() || item.recency_badge().is_some()).then(|| {
|
||||
rect()
|
||||
.horizontal()
|
||||
.position(Position::new_absolute().bottom(10.).right(10.))
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(4.)
|
||||
.maybe_child(item.is_outdated().then(outdated_badge))
|
||||
.maybe_child(item.recency_badge())
|
||||
.into_element()
|
||||
});
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.spacing(8.)
|
||||
.padding(Gaps::new_all(12.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(bg)
|
||||
.border(border_all_color(1.5, border))
|
||||
.spacing(9.)
|
||||
.padding(Gaps::new_all(14.))
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.background(bg.with_a(alpha))
|
||||
.border(border_all_color(1., border))
|
||||
.overflow(Overflow::Clip)
|
||||
.content(Content::Flex)
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.on_pointer_enter(move |_| hovered.set(true))
|
||||
.on_pointer_leave(move |_| hovered.set(false))
|
||||
.on_secondary_down(on_secondary(on_context))
|
||||
.on_press(move |_| on_toggle.call(()))
|
||||
.child(header)
|
||||
.child(
|
||||
|
|
@ -331,9 +384,191 @@ pub(crate) fn grid_card(
|
|||
.height(Size::flex(1.0))
|
||||
.maybe_child(description),
|
||||
)
|
||||
.maybe_child(floating)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn scale_a(alpha: u8, factor: f32) -> u8 {
|
||||
(alpha as f32 * factor) as u8
|
||||
}
|
||||
|
||||
fn grid_meta(
|
||||
item: &PackageEntry,
|
||||
package_type: &'static str,
|
||||
cluster_id: i64,
|
||||
navigable: bool,
|
||||
alpha: u8,
|
||||
) -> Element {
|
||||
let muted = CARD_NAME.with_a(scale_a(alpha, 0.5));
|
||||
|
||||
let source = if item.is_remote() && navigable {
|
||||
SourceLink {
|
||||
provider: item.provider,
|
||||
package_id: item.package_id.clone(),
|
||||
package_type,
|
||||
cluster_id,
|
||||
alpha,
|
||||
}
|
||||
.into_element()
|
||||
} else if item.is_remote() {
|
||||
meta_text(item.provider.to_string(), muted)
|
||||
} else {
|
||||
meta_text("Local file".to_string(), muted)
|
||||
};
|
||||
|
||||
let mut parts: Vec<Element> = Vec::new();
|
||||
if !item.author.is_empty() {
|
||||
parts.push(meta_text(format!("by {}", item.author), muted));
|
||||
}
|
||||
if let Some(version) = &item.version {
|
||||
parts.push(meta_text(version.clone(), muted));
|
||||
}
|
||||
parts.push(source);
|
||||
|
||||
let mut row = rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(5.)
|
||||
.overflow(Overflow::Clip);
|
||||
|
||||
for (i, part) in parts.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
row = row.child(meta_text("\u{b7}".to_string(), muted));
|
||||
}
|
||||
row = row.child(part);
|
||||
}
|
||||
|
||||
row.into_element()
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct SourceLink {
|
||||
provider: ProviderId,
|
||||
package_id: String,
|
||||
package_type: &'static str,
|
||||
cluster_id: i64,
|
||||
alpha: u8,
|
||||
}
|
||||
|
||||
impl Component for SourceLink {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut hovered = use_state(|| false);
|
||||
let mut pressed = use_state(|| false);
|
||||
|
||||
let color = if *pressed.read() {
|
||||
colors::fg_primary_pressed()
|
||||
} else if *hovered.read() {
|
||||
colors::fg_primary_hover()
|
||||
} else {
|
||||
CARD_NAME.with_a(scale_a(self.alpha, 0.68))
|
||||
};
|
||||
|
||||
let provider = self.provider;
|
||||
let package_id = self.package_id.clone();
|
||||
let package_type = self.package_type.to_string();
|
||||
let cluster_id = self.cluster_id;
|
||||
|
||||
rect()
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.on_pointer_enter(move |_| hovered.set(true))
|
||||
.on_pointer_leave(move |_| {
|
||||
hovered.set(false);
|
||||
pressed.set(false);
|
||||
})
|
||||
.on_pointer_down(move |_| pressed.set(true))
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
pressed.set(false);
|
||||
let _ = RouterContext::get().push(Route::BrowserPackage {
|
||||
cluster_id,
|
||||
package_type: package_type.clone(),
|
||||
package_id: format!("{}:{}", provider as u8, package_id),
|
||||
});
|
||||
})
|
||||
.child(meta_text(provider.to_string(), color))
|
||||
}
|
||||
}
|
||||
|
||||
fn meta_text(text: String, color: Color) -> Element {
|
||||
label()
|
||||
.text(text)
|
||||
.font_size(11.)
|
||||
.max_lines(1)
|
||||
.color(color)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn kebab_button(on_context: EventHandler<(f32, f32)>) -> Element {
|
||||
KebabButton { on_context }.into_element()
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct KebabButton {
|
||||
on_context: EventHandler<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl Component for KebabButton {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut hovered = use_state(|| false);
|
||||
let on_context = self.on_context.clone();
|
||||
|
||||
rect()
|
||||
.center()
|
||||
.width(Size::px(26.))
|
||||
.height(Size::px(26.))
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.background(if *hovered.read() {
|
||||
colors::ghost_overlay_hover()
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
})
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.a11y_role(AccessibilityRole::Button)
|
||||
.on_pointer_enter(move |_| hovered.set(true))
|
||||
.on_pointer_leave(move |_| hovered.set(false))
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
if let PressEventData::Mouse(m) = e.data() {
|
||||
on_context.call((m.global_location.x as f32, m.global_location.y as f32));
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconType::DotsVertical)
|
||||
.size(16.)
|
||||
.color(if *hovered.read() {
|
||||
colors::fg_primary()
|
||||
} else {
|
||||
colors::fg_secondary()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal_in_store(hash: String) {
|
||||
spawn(async move {
|
||||
let Ok(state) = crate::launcher::state() else {
|
||||
return;
|
||||
};
|
||||
let row = match oneclient_db::dao::artifact::get_artifact_by_hash(&state.services.db, &hash)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return,
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "failed to look up artifact for reveal");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Ok(path) = oneclient_content::packages::store::artifact_absolute_path(&row.path) else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
crate::platform::open_path(&dir.to_string_lossy());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn package_info(
|
||||
item: &PackageEntry,
|
||||
package_type: &'static str,
|
||||
|
|
@ -538,37 +773,3 @@ fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element
|
|||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn remove_button(
|
||||
enabled: bool,
|
||||
mut on_remove: impl FnMut() + 'static,
|
||||
mut hovering: State<bool>,
|
||||
) -> impl IntoElement {
|
||||
let hot = enabled && *hovering.read();
|
||||
let color = if !enabled {
|
||||
colors::fg_secondary().with_a(90)
|
||||
} else if hot {
|
||||
colors::danger()
|
||||
} else {
|
||||
colors::fg_secondary()
|
||||
};
|
||||
|
||||
rect()
|
||||
.center()
|
||||
.width(Size::px(30.))
|
||||
.height(Size::px(30.))
|
||||
.corner_radius(CornerRadius::new_all(7.))
|
||||
.background(if hot {
|
||||
colors::danger().with_a(30)
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
})
|
||||
.maybe(enabled, |el| {
|
||||
el.cursor(CursorIcon::Pointer)
|
||||
.on_pointer_enter(move |_| hovering.set(true))
|
||||
.on_pointer_leave(move |_| hovering.set(false))
|
||||
.on_press(move |_| on_remove())
|
||||
})
|
||||
.child(Icon::new(IconType::Trash01).size(14.).color(color))
|
||||
.into_element()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,6 +246,61 @@ impl ScrollArea {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn lazy_grid(
|
||||
mut self,
|
||||
count: usize,
|
||||
item_height: f32,
|
||||
gap: f32,
|
||||
min_width: f32,
|
||||
max_cols: usize,
|
||||
render: impl Fn(usize) -> Element + 'static,
|
||||
) -> Self {
|
||||
let slot = (item_height + gap).max(1.);
|
||||
self.builder = Some(Box::new(move |ctx: ScrollAreaCtx| {
|
||||
let cols = (((ctx.viewport_w + gap) / (min_width + gap)).floor() as usize)
|
||||
.clamp(1, max_cols.max(1));
|
||||
let rows_total = count.div_ceil(cols);
|
||||
|
||||
let first =
|
||||
(((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize;
|
||||
let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize;
|
||||
let last = (first + span).min(rows_total);
|
||||
|
||||
let top_pad = first as f32 * slot;
|
||||
let bottom_pad = rows_total.saturating_sub(last) as f32 * slot;
|
||||
|
||||
let mut container = rect().vertical().width(Size::fill());
|
||||
if top_pad > 0. {
|
||||
container = container.child(rect().width(Size::fill()).height(Size::px(top_pad)));
|
||||
}
|
||||
for r in first..last {
|
||||
let mut row = rect()
|
||||
.key(r)
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(slot))
|
||||
.spacing(gap)
|
||||
.content(Content::Flex);
|
||||
for c in 0..cols {
|
||||
let idx = r * cols + c;
|
||||
let cell = rect().width(Size::flex(1.0)).height(Size::px(item_height));
|
||||
row = row.child(if idx < count {
|
||||
cell.child(render(idx))
|
||||
} else {
|
||||
cell
|
||||
});
|
||||
}
|
||||
container = container.child(row);
|
||||
}
|
||||
if bottom_pad > 0. {
|
||||
container =
|
||||
container.child(rect().width(Size::fill()).height(Size::px(bottom_pad)));
|
||||
}
|
||||
container.into_element()
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
fn view(&self) -> impl IntoElement {
|
||||
let horizontal = self.horizontal;
|
||||
let content_w = self.content_width;
|
||||
|
|
|
|||
|
|
@ -117,7 +117,11 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement {
|
|||
FontWeight::NORMAL
|
||||
})
|
||||
.max_lines(1)
|
||||
.color(colors::fg_primary())
|
||||
.color(if active {
|
||||
colors::fg_primary()
|
||||
} else {
|
||||
colors::fg_secondary()
|
||||
})
|
||||
.text_align(TextAlign::Center),
|
||||
),
|
||||
)
|
||||
|
|
@ -221,12 +225,12 @@ impl Component for TabButton {
|
|||
.child(tab_label(&self.label, font_size, active))
|
||||
.child(
|
||||
rect()
|
||||
.height(Size::px(1.5))
|
||||
.height(Size::px(2.))
|
||||
.width(Size::fill_minimum())
|
||||
.margin(Gaps::new_symmetric(0., 4.0))
|
||||
.corner_radius(CornerRadius::new_all(2.))
|
||||
.background(if underline_on {
|
||||
colors::fg_primary()
|
||||
colors::brand()
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -27,14 +27,14 @@ pub use actions::{Actions, NotificationBuilder, PumpSignal};
|
|||
pub use queries::{
|
||||
AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery,
|
||||
CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles,
|
||||
ClusterLogsQuery, DiscardLeftoversKeys, DiscardLeftoversMutation, FinishMicrosoftLoginMutation,
|
||||
LeftoversQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery,
|
||||
RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, StorageAction,
|
||||
StorageActionMutation, StorageReportQuery, TermsQuery, UploadLogKeys, UploadLogMutation,
|
||||
UseDiscardLeftovers, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction,
|
||||
UseSetDefaultAccount, UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE,
|
||||
accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list,
|
||||
changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
|
||||
ClusterLogsQuery, ClusterMutation, DiscardLeftoversKeys, DiscardLeftoversMutation,
|
||||
FinishMicrosoftLoginMutation, LeftoversQuery, LogAction, LogContentQuery, MigrationQuery,
|
||||
OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction,
|
||||
SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery,
|
||||
UploadLogKeys, UploadLogMutation, UseDiscardLeftovers, UseLogAction, UseRefreshAccount,
|
||||
UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, UseUploadLog,
|
||||
VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items,
|
||||
category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
|
||||
content_type_for_slug, has_migration_data, invalidate_cluster_content_queries,
|
||||
invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries,
|
||||
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ pub use logs::{
|
|||
};
|
||||
pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration};
|
||||
pub use mutations::{
|
||||
ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries,
|
||||
ClusterAction, ClusterMutation, invalidate_cluster_content_queries, invalidate_cluster_queries,
|
||||
invalidate_profile_queries, use_cluster_mutation,
|
||||
};
|
||||
pub use package_updates::{package_updates, stale_hashes, use_package_updates};
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use crate::ui::entrance_motion_layer;
|
|||
use crate::view::app::launch_button_state;
|
||||
|
||||
const HEADER_HEIGHT: f32 = 64.;
|
||||
const TABS_HEIGHT: f32 = 44.;
|
||||
const TABS_HEIGHT: f32 = 38.;
|
||||
const BAR_SPACING: f32 = 16.;
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
|
|
@ -330,15 +330,22 @@ fn cluster_tabs(
|
|||
.width(Size::fill())
|
||||
.height(Size::px(TABS_HEIGHT))
|
||||
.cross_align(Alignment::Center)
|
||||
.padding(Gaps::new(0., 24., 0., 24.))
|
||||
.background(colors::page_elevated())
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.border(
|
||||
Border::new()
|
||||
.fill(colors::component_border())
|
||||
.width(BorderWidth {
|
||||
top: 0.,
|
||||
right: 0.,
|
||||
bottom: 1.,
|
||||
left: 0.,
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
TabBar::new()
|
||||
.width(Size::fill())
|
||||
.width(Size::auto())
|
||||
.height(Size::fill())
|
||||
.spacing(24.)
|
||||
.font_size(12.)
|
||||
.spacing(22.)
|
||||
.font_size(13.)
|
||||
.tabs(tab_items),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, tool
|
|||
|
||||
const CARD_H: f32 = 84.;
|
||||
const CARD_SPACING: f32 = 8.;
|
||||
const LAZY_OVERSCAN: i64 = 2;
|
||||
const GRID_MAX_COLS: usize = 5;
|
||||
|
||||
pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
|
||||
|
||||
|
|
@ -227,14 +227,10 @@ fn make_row(
|
|||
.map(|p| p.author.clone())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_default();
|
||||
let version = installed_info.and_then(|i| i.display_version.clone());
|
||||
let description = m
|
||||
.map(|p| p.summary.clone())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
installed_info
|
||||
.and_then(|i| i.display_version.clone())
|
||||
.map(|v| format!("Version {v}"))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
PackageEntry {
|
||||
|
|
@ -244,6 +240,7 @@ fn make_row(
|
|||
name,
|
||||
file_name,
|
||||
author,
|
||||
version,
|
||||
description,
|
||||
icon_url: m.and_then(|p| p.icon_url.clone()),
|
||||
size,
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ use oneclient_content::packages::ContentType;
|
|||
use oneclient_core::settings::ViewLayout;
|
||||
|
||||
use crate::components::{
|
||||
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx,
|
||||
Segment, SegmentedControl, TabBar, TabItem, TextInput,
|
||||
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, Segment,
|
||||
SegmentedControl, TextInput, package_context_menu,
|
||||
};
|
||||
use crate::hooks::{use_dispatch, use_overlay_claim};
|
||||
use crate::hooks::{use_cluster_mutation, use_dispatch, use_overlay_claim};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::{Actions, utils};
|
||||
|
|
@ -149,9 +149,14 @@ pub(super) fn toolbar_bar(
|
|||
package_type: &'static str,
|
||||
mut toolbar_width: State<f32>,
|
||||
) -> impl IntoElement {
|
||||
let tab_items = tabs.iter().enumerate().map(|(i, tab)| {
|
||||
let chips = tabs.iter().enumerate().map(|(i, tab)| {
|
||||
let mut active = active;
|
||||
TabItem::new(tab.label(), i == active_idx).on_press(move |_| *active.write() = i)
|
||||
CategoryChip {
|
||||
label: tab.label(),
|
||||
selected: i == active_idx,
|
||||
on_press: (move |_| *active.write() = i).into(),
|
||||
}
|
||||
.into_element()
|
||||
});
|
||||
|
||||
let mut top_corners = CornerRadius::new_all(0.);
|
||||
|
|
@ -163,21 +168,22 @@ pub(super) fn toolbar_bar(
|
|||
let filter_tabs = ScrollView::new()
|
||||
.direction(Direction::Horizontal)
|
||||
.invert_scroll_wheel(true)
|
||||
.show_scrollbar(stacked)
|
||||
.show_scrollbar(true)
|
||||
.scrollbar_theme(tabs_scrollbar_theme())
|
||||
.width(if stacked {
|
||||
Size::fill()
|
||||
} else {
|
||||
Size::flex(1.0)
|
||||
})
|
||||
.height(if stacked { Size::fill() } else { Size::auto() })
|
||||
.height(Size::px(TABS_ROW_H))
|
||||
.child(
|
||||
TabBar::new()
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::auto())
|
||||
.height(if stacked { Size::fill() } else { Size::auto() })
|
||||
.spacing(20.)
|
||||
.font_size(12.)
|
||||
.tabs(tab_items),
|
||||
.height(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(6.)
|
||||
.children(chips),
|
||||
)
|
||||
.into_element();
|
||||
|
||||
|
|
@ -273,6 +279,67 @@ pub(super) fn toolbar_bar(
|
|||
.into_element()
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct CategoryChip {
|
||||
label: String,
|
||||
selected: bool,
|
||||
on_press: EventHandler<Event<PressEventData>>,
|
||||
}
|
||||
|
||||
impl Component for CategoryChip {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut hovered = use_state(|| false);
|
||||
let selected = self.selected;
|
||||
|
||||
let a11y_id = use_a11y();
|
||||
let focus = use_focus(a11y_id);
|
||||
let focused = focus().is_focused();
|
||||
|
||||
let background = if selected {
|
||||
colors::brand()
|
||||
} else if *hovered.read() {
|
||||
colors::component_bg_hover()
|
||||
} else {
|
||||
colors::component_bg()
|
||||
};
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.height(Size::px(32.))
|
||||
.center()
|
||||
.padding(Gaps::new_symmetric(0., 12.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(background)
|
||||
.border(crate::ui::border_all_color(
|
||||
1.,
|
||||
if selected || focused {
|
||||
colors::brand()
|
||||
} else {
|
||||
colors::component_border()
|
||||
},
|
||||
))
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.a11y_id(a11y_id)
|
||||
.a11y_focusable(true)
|
||||
.a11y_role(AccessibilityRole::Tab)
|
||||
.on_pointer_enter(move |_| hovered.set(true))
|
||||
.on_pointer_leave(move |_| hovered.set(false))
|
||||
.on_press(self.on_press.clone())
|
||||
.child(
|
||||
label()
|
||||
.text(self.label.clone())
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.max_lines(1)
|
||||
.color(if selected {
|
||||
Color::WHITE
|
||||
} else {
|
||||
colors::fg_primary()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> Element {
|
||||
let text = match content_type {
|
||||
ContentType::ResourcePack => format!(
|
||||
|
|
@ -673,33 +740,42 @@ impl Component for ContentBox {
|
|||
let layout = self.layout;
|
||||
|
||||
let dispatch = use_dispatch();
|
||||
let cluster = use_cluster_mutation();
|
||||
let mut menu = use_state(|| None::<(f32, f32, PackageEntry)>);
|
||||
|
||||
let count = items.len();
|
||||
let scroll = (count > 0).then(|| match layout {
|
||||
CardLayout::List => {
|
||||
let items = items.clone();
|
||||
ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.scrollbar_gutter(true)
|
||||
.lazy(count, CARD_H, CARD_SPACING, move |i| {
|
||||
let item = items[i].clone();
|
||||
let key = item.package_id.clone();
|
||||
PackageRow::new(item, cluster_id, package_type)
|
||||
.layout(CardLayout::List)
|
||||
.key(key)
|
||||
.into_element()
|
||||
})
|
||||
let row = {
|
||||
let items = items.clone();
|
||||
move |i: usize| {
|
||||
let item: PackageEntry = items[i].clone();
|
||||
let key = item.package_id.clone();
|
||||
let for_menu = item.clone();
|
||||
PackageRow::new(item, cluster_id, package_type)
|
||||
.layout(layout)
|
||||
.on_context(move |(x, y)| menu.set(Some((x, y, for_menu.clone()))))
|
||||
.key(key)
|
||||
.into_element()
|
||||
}
|
||||
CardLayout::Grid => ScrollArea::new()
|
||||
};
|
||||
|
||||
let count = items.len();
|
||||
let scroll = (count > 0).then(|| {
|
||||
let area = ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.scrollbar_gutter(true)
|
||||
.content(move |ctx: ScrollAreaCtx| {
|
||||
grid_content(&items, package_type, cluster_id, ctx).into_element()
|
||||
})
|
||||
.into_element(),
|
||||
.scrollbar_gutter(true);
|
||||
match layout {
|
||||
CardLayout::List => area.lazy(count, CARD_H, CARD_SPACING, row),
|
||||
CardLayout::Grid => {
|
||||
area.lazy_grid(count, CARD_GRID_H, GRID_GAP, GRID_MIN_W, GRID_MAX_COLS, row)
|
||||
}
|
||||
}
|
||||
.into_element()
|
||||
});
|
||||
|
||||
let menu_overlay = menu.read().clone().map(|(x, y, item)| {
|
||||
package_context_menu(x, y, &item, cluster_id, package_type, cluster)
|
||||
.on_close(move |_| menu.set(None))
|
||||
.into_element()
|
||||
});
|
||||
|
||||
let empty = (count == 0).then(|| match kind {
|
||||
|
|
@ -737,14 +813,14 @@ impl Component for ContentBox {
|
|||
.vertical()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.spacing(8.)
|
||||
.padding(Gaps::new_all(8.))
|
||||
.padding(Gaps::new(0., 12., 12., 12.))
|
||||
.corner_radius(bottom_corners)
|
||||
.background(colors::page_elevated())
|
||||
.overflow(Overflow::Clip)
|
||||
.maybe_child(header)
|
||||
.maybe_child(scroll)
|
||||
.maybe_child(empty)
|
||||
.maybe_child(menu_overlay)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -758,61 +834,6 @@ fn action_header(button: impl IntoElement) -> impl IntoElement {
|
|||
.child(button)
|
||||
}
|
||||
|
||||
fn grid_content(
|
||||
items: &[PackageEntry],
|
||||
package_type: &'static str,
|
||||
cluster_id: i64,
|
||||
ctx: ScrollAreaCtx,
|
||||
) -> impl IntoElement {
|
||||
let count = items.len();
|
||||
let cols =
|
||||
(((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 5);
|
||||
let rows_total = count.div_ceil(cols);
|
||||
let slot = CARD_GRID_H + GRID_GAP;
|
||||
|
||||
let first_row = (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize;
|
||||
let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize;
|
||||
let last_row = (first_row + span).min(rows_total);
|
||||
|
||||
let top_pad = first_row as f32 * slot;
|
||||
let bottom_pad = rows_total.saturating_sub(last_row) as f32 * slot;
|
||||
|
||||
let mut container = rect().vertical().width(Size::fill());
|
||||
if top_pad > 0. {
|
||||
container = container.child(rect().width(Size::fill()).height(Size::px(top_pad)));
|
||||
}
|
||||
for r in first_row..last_row {
|
||||
let mut row = rect()
|
||||
.key(r)
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(slot))
|
||||
.spacing(GRID_GAP)
|
||||
.content(Content::Flex);
|
||||
for c in 0..cols {
|
||||
let idx = r * cols + c;
|
||||
let cell = rect().width(Size::flex(1.0)).height(Size::px(CARD_GRID_H));
|
||||
row = row.child(if idx < count {
|
||||
let item = items[idx].clone();
|
||||
let key = item.package_id.clone();
|
||||
cell.child(
|
||||
PackageRow::new(item, cluster_id, package_type)
|
||||
.layout(CardLayout::Grid)
|
||||
.key(key)
|
||||
.into_element(),
|
||||
)
|
||||
} else {
|
||||
cell
|
||||
});
|
||||
}
|
||||
container = container.child(row);
|
||||
}
|
||||
if bottom_pad > 0. {
|
||||
container = container.child(rect().width(Size::fill()).height(Size::px(bottom_pad)));
|
||||
}
|
||||
container.into_element()
|
||||
}
|
||||
|
||||
fn empty_shell(icon: IconType) -> Rect {
|
||||
rect()
|
||||
.vertical()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue