use freya::prelude::*; use freya::router::RouterContext; use oneclient_content::packages::ProviderId; use oneclient_core::SeenStatus; use crate::components::{ContextMenu, Icon, IconType, toggle_controlled}; use crate::essential::EssentialPackage; use crate::hooks::{ ClusterAction, EssentialGuardKind, PendingEssential, disable_warnings, loaded_image, use_cached_image, use_cluster_mutation, use_disable_warnings, use_essential_guard, }; use crate::routes::Route; use crate::theme::colors; use crate::ui::{ImageFallbackExt, border_all_color}; 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 = 112.; pub(crate) const GRID_GAP: f32 = 12.; pub(crate) const GRID_MIN_W: f32 = 290.; const GRID_CARD_PADDING: f32 = 14.; const BADGE_STRIP_H: f32 = 20.; const BADGE_STRIP_LIFT: f32 = 2.; #[derive(Clone, Copy, PartialEq)] pub enum CardLayout { List, Grid, } impl From for CardLayout { fn from(layout: oneclient_core::settings::ViewLayout) -> Self { match layout { oneclient_core::settings::ViewLayout::Grid => CardLayout::Grid, oneclient_core::settings::ViewLayout::List => CardLayout::List, } } } #[derive(Clone, PartialEq)] pub struct PackageEntry { pub package_id: String, pub bundle_name: Option, pub provider: ProviderId, pub github_hosted: bool, pub github_url: Option, pub name: String, pub file_name: String, pub author: String, pub version: Option, pub description: String, pub icon_url: Option, pub size: u64, pub categories: Vec, pub enabled: bool, pub installed: bool, pub hash: Option, pub manifest_default: bool, /// Private bundle dependency only set for bundle rows pub hidden: bool, /// False for uninstalled rows of bundles the cluster never took keeps them out of the All tab pub opted_in: bool, pub advanced: bool, /// Only set for browser-installed content bundle packages use the bundle update flow pub update_available: bool, pub shadowed: bool, /// Recency badge state cleared once the user views the list pub seen_status: SeenStatus, pub essential: Option<&'static EssentialPackage>, } impl PackageEntry { pub fn is_remote(&self) -> bool { self.provider != ProviderId::Local } pub fn in_bundle(&self) -> bool { self.bundle_name.is_some() } /// Bundle packages are never marked here so the two update flows cannot contradict pub fn is_outdated(&self) -> bool { self.update_available && self.is_remote() && !self.in_bundle() } pub fn recency_badge(&self) -> Option { match self.seen_status { SeenStatus::New => Some(new_badge()), SeenStatus::Updated => Some(updated_badge()), SeenStatus::Seen => None, } } } #[derive(PartialEq)] pub struct PackageRow { item: PackageEntry, cluster_id: i64, package_type: &'static str, layout: CardLayout, on_context: EventHandler<(f32, f32)>, selected: bool, selecting: bool, on_select: EventHandler<()>, key: DiffKey, } impl PackageRow { pub fn new(item: PackageEntry, cluster_id: i64, package_type: &'static str) -> Self { Self { item, cluster_id, package_type, layout: CardLayout::List, on_context: (|_| {}).into(), selected: false, selecting: false, on_select: (|()| {}).into(), key: DiffKey::None, } } pub fn layout(mut self, layout: CardLayout) -> Self { self.layout = layout; self } pub fn on_context(mut self, on_context: impl Into>) -> Self { self.on_context = on_context.into(); self } pub fn selection( mut self, selected: bool, selecting: bool, on_select: impl Into>, ) -> Self { self.selected = selected; self.selecting = selecting; self.on_select = on_select.into(); self } } impl KeyExt for PackageRow { fn write_key(&mut self) -> &mut DiffKey { &mut self.key } } impl Component for PackageRow { fn render(&self) -> impl IntoElement { let item = self.item.clone(); let cluster_id = self.cluster_id; let package_type = self.package_type; let layout = self.layout; let cluster = use_cluster_mutation(); let guard = use_essential_guard(); let warnings_query = use_disable_warnings(); let hovered = use_state(|| false); let warning_body = disable_warning_body(&item, disable_warnings(&warnings_query)); let icon_size = match layout { CardLayout::List => 44., CardLayout::Grid => 40., }; let icon_query = use_cached_image(item.icon_url.clone(), 256); let icon = package_icon(&item, &icon_query, icon_size); let on_toggle: EventHandler<()> = { let action = toggle_action(&item, cluster_id, !item.enabled); let enabled_now = item.enabled; let name = item.name.clone(); let mut guard = guard; (move |()| { let Some(action) = action.clone() else { return; }; match warning_body.clone().filter(|_| enabled_now) { Some(body) => guard.set(Some(PendingEssential { name: name.clone(), body, kind: EssentialGuardKind::Disable, action, })), None => cluster.mutate(action), } }) .into() }; let on_context = Some(self.on_context.clone()); 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) } } pub(crate) fn toggle_action( item: &PackageEntry, cluster_id: i64, enabled: bool, ) -> Option { set_enabled_action( cluster_id, item.hash.as_deref(), item.bundle_name.as_deref(), &item.package_id, item.manifest_default, enabled, ) } pub(crate) fn set_enabled_action( cluster_id: i64, hash: Option<&str>, bundle_name: Option<&str>, package_id: &str, manifest_default: bool, enabled: bool, ) -> Option { if let Some(hash) = hash { Some(ClusterAction::SetArtifactEnabled { cluster_id, hash: hash.to_string(), enabled, }) } else { bundle_name.map(|bundle_name| ClusterAction::SetBundlePackageEnabled { cluster_id, bundle_name: bundle_name.to_string(), package_id: package_id.to_string(), enabled, manifest_default, }) } } pub(crate) fn disable_warning_body( item: &PackageEntry, warnings: Option, ) -> Option { let bundled = item.in_bundle() && item.provider == ProviderId::Modrinth; match warnings { Some(warnings) if bundled => warnings.body_for(&item.package_id).map(str::to_string), _ => item .essential .map(|package| package.disable_body.to_string()), } } pub fn package_context_menu( x: f32, y: f32, item: &PackageEntry, 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()).action( IconType::Check, "Select", on_select, ); 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(url) = item.github_url.clone() { menu = menu.action(IconType::LinkExternal01, "View in browser", move |()| { crate::platform::open_url(&url); }); } if let Some(hash) = item.hash.clone() { menu = menu.action( IconType::Folder, "View in folder", EventHandler::new_current(move |()| reveal_in_store(hash.clone())), ); } if item.installed && !item.in_bundle() { let hash = item.hash.clone(); let name = item.name.clone(); menu = menu .separator() .danger_action(IconType::Trash01, "Delete", move |()| { if let Some(hash) = &hash { on_delete.call((name.clone(), hash.clone())); } }); } menu } pub(crate) fn on_secondary( handler: Option>, ) -> impl FnMut(Event) { move |e: Event| { 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)); } } } fn list_card( item: &PackageEntry, package_type: &'static str, cluster_id: i64, icon: impl IntoElement, on_toggle: EventHandler<()>, on_context: Option>, ) -> Element { rect() .horizontal() .width(Size::fill()) .height(Size::px(CARD_H)) .cross_align(Alignment::Center) .spacing(12.) .padding(Gaps::new_all(10.)) .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)) .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, cluster_id: i64, icon: impl IntoElement, on_toggle: EventHandler<()>, navigable: bool, // if true, it takes the user to the mod page on_context: Option>, mut hovered: State, ) -> Element { let enabled = item.enabled; let title = item.name.clone(); let hovering = *hovered.read(); let bg = match (enabled, hovering) { (_, true) => colors::component_bg_hover(), (true, false) => colors::component_bg(), (false, false) => colors::component_bg_disabled(), }; let alpha = if enabled { 255u8 } else { 115u8 }; let border = if !hovering { colors::component_border() } else { colors::component_border_hover() }; let header = rect() .horizontal() .width(Size::fill()) // .cross_align(Alignment::Center) .spacing(11.) .content(Content::Flex) .child(rect().opacity(alpha as f32 / 255.).child(icon)) .child( rect() .vertical() .width(Size::flex(1.0)) .spacing(3.) .child( label() .text(title) .font_size(14.) .font_weight(FontWeight::SEMI_BOLD) .max_lines(1) .text_overflow(TextOverflow::Ellipsis) .width(Size::fill()) .color(if enabled { Color::WHITE } else { CARD_NAME.with_a(alpha) }), ) .child(grid_meta(item, package_type, cluster_id, navigable, alpha)), ) .maybe_child(on_context.clone().map(kebab_button)) .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 badged = item.is_outdated() || item.shadowed || item.recency_badge().is_some(); let floating = badged.then(|| { rect() .horizontal() .width(Size::fill()) .height(Size::px(BADGE_STRIP_H)) .position( Position::new_absolute() .bottom(-(GRID_CARD_PADDING + BADGE_STRIP_H / 2.) + BADGE_STRIP_LIFT), ) .main_align(Alignment::End) .cross_align(Alignment::Center) .spacing(4.) .maybe_child(item.is_outdated().then(outdated_badge)) .maybe_child(item.shadowed.then(shadowed_badge)) .maybe_child(item.recency_badge()) .into_element() }); rect() .vertical() .width(Size::fill()) .height(Size::fill()) .spacing(9.) .padding(Gaps::new_all(GRID_CARD_PADDING)) .corner_radius(CornerRadius::new_all(6.)) .background(bg.with_a(alpha)) .border(border_all_color(1., border)) .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( rect() .vertical() .width(Size::fill()) .height(Size::flex(1.0)) .overflow(Overflow::Clip) .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 let Some(url) = item.github_url.clone() { SourceLink { text: "GitHub".to_string(), on_press: EventHandler::new(move |()| crate::platform::open_url(&url)), alpha, } .into_element() } else if item.github_hosted { meta_text("GitHub".to_string(), muted) } else if item.is_remote() && navigable { let provider = item.provider; let package_id = item.package_id.clone(); SourceLink { text: provider.to_string(), on_press: EventHandler::new(move |()| { let _ = RouterContext::get().push(Route::BrowserPackage { cluster_id, package_type: package_type.to_string(), package_id: format!("{}:{}", provider as u8, package_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 = 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 { text: String, on_press: EventHandler<()>, 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 on_press = self.on_press.clone(); 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| { e.stop_propagation(); pressed.set(false); on_press.call(()); }) .child(meta_text(self.text.clone(), color)) } } pub(crate) fn meta_text(text: String, color: Color) -> Element { label() .text(text) .font_size(11.) .max_lines(1) .color(color) .into_element() } pub(crate) 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| { 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, cluster_id: i64, icon: impl IntoElement, ) -> impl IntoElement { let remote = item.is_remote(); let provider = item.provider; let package_id = item.package_id.clone(); let package_type = package_type.to_string(); let title = item.name.clone(); rect() .horizontal() .width(Size::flex(1.0)) .cross_align(Alignment::Center) .spacing(12.) .content(Content::Flex) .maybe(remote, |el| { el.cursor(CursorIcon::Pointer).on_press(move |_| { let _ = RouterContext::get().push(Route::BrowserPackage { cluster_id, package_type: package_type.clone(), package_id: format!("{}:{}", provider as u8, package_id), }); }) }) .child(icon) .child( rect() .vertical() .width(Size::flex(1.0)) .spacing(3.) .child( rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(8.) .child( label() .text(title) .font_size(15.) .font_weight(FontWeight::MEDIUM) .max_lines(1) .text_overflow(TextOverflow::Ellipsis) .max_width(Size::percent(60.)) .color(CARD_NAME), ) .child(if let Some(url) = item.github_url.clone() { rect() .cursor(CursorIcon::Pointer) .on_press(move |e: Event| { e.stop_propagation(); crate::platform::open_url(&url); }) .child(github_badge()) .into_element() } else if item.github_hosted { github_badge() } else if remote { provider_badge(item.provider) } else { local_badge() }) .maybe_child(item.is_outdated().then(outdated_badge)) .maybe_child(item.shadowed.then(shadowed_badge)) .maybe_child(item.recency_badge()), ) .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()), ) }) .maybe(!item.description.is_empty(), |el| { el.child( label() .text(item.description.clone()) .font_size(11.) .max_lines(2) .text_overflow(TextOverflow::Ellipsis) .width(Size::fill()) .color(colors::fg_secondary()), ) }), ) .into_element() } pub(crate) fn package_icon( item: &PackageEntry, icon_query: &freya::query::UseQuery, size: f32, ) -> Element { remote_icon(item.icon_url.as_deref(), icon_query, size) } pub(crate) fn remote_icon( icon_url: Option<&str>, icon_query: &freya::query::UseQuery, size: f32, ) -> Element { let loaded = loaded_image(icon_url, icon_query); match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) .width(Size::px(size)) .height(Size::px(size)) .aspect_ratio(AspectRatio::Min) .corner_radius(CornerRadius::new_all(8.)) .fallback(icon_box(IconType::DotsGrid, size)) .into_element(), None if icon_url.is_some() => icon_box(IconType::DotsGrid, size), None => icon_box(IconType::HelpCircle, size), } } pub(crate) fn icon_box(icon: IconType, size: f32) -> Element { rect() .center() .width(Size::px(size)) .height(Size::px(size)) .corner_radius(CornerRadius::new_all(8.)) .background(colors::component_bg()) .child( Icon::new(icon) .size(size * 0.4) .color(colors::fg_secondary()), ) .into_element() } pub(crate) fn meta_size(size: u64) -> impl IntoElement { rect() .maybe_child((size > 0).then(|| { label() .text(format_size(size)) .font_size(11.) .color(colors::fg_secondary()) })) .into_element() } pub fn provider_badge(provider: ProviderId) -> Element { badge( Icon::new(provider).size(12.).into_element(), provider.to_string(), ) } pub fn github_badge() -> Element { badge( Icon::new(IconType::Github).size(12.).into_element(), "GitHub".to_string(), ) } fn outdated_badge() -> Element { status_tag("Update available", colors::brand()) } fn shadowed_badge() -> Element { status_tag("Using your copy", colors::brand()) } fn new_badge() -> Element { status_tag("New", colors::success()) } fn updated_badge() -> Element { status_tag("Updated", colors::success()) } fn status_tag(text: &'static str, accent: Color) -> Element { rect() .horizontal() .cross_align(Alignment::Center) .spacing(5.) .padding(Gaps::new(2., 6., 2., 6.)) .corner_radius(CornerRadius::new_all(4.)) .background(colors::component_bg()) .child( rect() .width(Size::px(6.)) .height(Size::px(6.)) .corner_radius(CornerRadius::new_all(3.)) .background(accent), ) .child( label() .text(text) .font_size(11.) .font_weight(FontWeight::MEDIUM) .color(colors::fg_secondary()), ) .into_element() } fn local_badge() -> Element { badge( Icon::new(IconType::File02) .size(12.) .color(colors::fg_secondary()) .into_element(), "Local file".to_string(), ) } pub(crate) fn badge(icon: impl IntoElement, text: String) -> Element { accent_badge(icon, text, colors::fg_secondary()) } fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element { pill(Some(icon.into_element()), text, accent) } pub(crate) fn pill(icon: Option, text: String, accent: Color) -> Element { rect() .horizontal() .cross_align(Alignment::Center) .spacing(4.) .padding(Gaps::new_symmetric(2., 8.)) .corner_radius(CornerRadius::new_all(999.)) .border(border_all_color(1., colors::component_border())) .background(colors::component_bg()) .maybe_child(icon) .child( label() .text(text) .font_size(10.) .font_weight(FontWeight::MEDIUM) .color(accent), ) .into_element() }