diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs new file mode 100644 index 00000000..707dba00 --- /dev/null +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -0,0 +1,529 @@ +//! Window-wide file drops. +//! +//! [`FileDropOverlay`] is mounted by the app shell and owns every drop: while +//! files hover the window it shows a veil, and once they land it asks which +//! cluster they belong to and what each one should be installed as. No view +//! handles drops itself, so dropping on the mods page and dropping on the home +//! screen behave identically. +//! +//! Freya emits one `FileDrop` event per file, all within the same batch, so the +//! shell handler accumulates them and the prompt's first render already sees +//! the whole drop. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; +use freya::prelude::*; +use freya::query::QueryStateData; +use freya::router::use_route; +use oneclient_core::clusters::Cluster; +use oneclient_core::packages::ContentType; +use oneclient_db::models::ClusterId; + +use crate::Route; +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{use_active_cluster_id, use_clusters, use_dispatch}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const DIALOG_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 480.; +const FILE_ROW_H: f32 = 34.; +const FILE_LIST_MAX_ROWS: usize = 5; +const TYPE_DROPDOWN_W: f32 = 108.; + +/// Content types a dropped entry can be imported as. Worlds are directories and +/// modpacks/datapacks have no view of their own, so they're deliberately absent. +const IMPORTABLE: [ContentType; 3] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, +]; + +/// Names that give away a shaderpack. Only a starting guess — every row has a +/// dropdown, so a wrong sniff costs the user one click. +const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"]; + +fn content_label(content_type: ContentType) -> &'static str { + match content_type { + ContentType::ResourcePack => "Textures", + ContentType::Shader => "Shaders", + _ => "Mods", + } +} + +fn extension(path: &Path) -> Option { + Some(path.extension()?.to_str()?.to_ascii_lowercase()) +} + +fn file_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +/// Best guess at what a dropped entry is. A `.jar` is only ever a mod; +/// everything else is ambiguous, so the page the drop landed on wins, then a +/// name sniff, then the most common case. +fn infer_content_type(path: &Path, route_type: Option) -> ContentType { + if extension(path).as_deref() == Some("jar") { + return ContentType::Mod; + } + if let Some(route_type) = route_type { + return route_type; + } + let name = file_name(path).to_lowercase(); + if SHADER_HINTS.iter().any(|hint| name.contains(hint)) { + ContentType::Shader + } else { + ContentType::ResourcePack + } +} + +fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { + match route { + Route::ClusterMods { cluster_id } => Some((*cluster_id, ContentType::Mod)), + Route::ClusterShaders { cluster_id } => Some((*cluster_id, ContentType::Shader)), + Route::ClusterTextures { cluster_id } => Some((*cluster_id, ContentType::ResourcePack)), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Overlay +// --------------------------------------------------------------------------- + +/// Renders the shell's drop affordance and, once files land, the prompt asking +/// where they should go. +/// +/// The state lives in the shell (which owns the bubbling `on_file_drop` +/// handler) and is passed down so the shell itself never re-renders on hover. +#[derive(PartialEq)] +pub struct FileDropOverlay { + pub hovering: State, + pub pending: State>, +} + +impl Component for FileDropOverlay { + fn render(&self) -> impl IntoElement { + let files = self.pending.read().clone(); + + if !files.is_empty() { + return DropPrompt { + files, + pending: self.pending, + } + .into_element(); + } + + if !*self.hovering.read() { + return rect().into_element(); + } + + DropVeil.into_element() + } +} + +/// The full-window "drop anywhere" affordance shown while files are dragged over. +#[derive(PartialEq)] +struct DropVeil; + +impl Component for DropVeil { + fn render(&self) -> impl IntoElement { + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(170) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let progress = intro.read().value(); + + rect() + .layer(Layer::Overlay) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + // Purely decorative: the drop must reach the shell handler beneath. + .interactive(false) + .center() + .opacity(progress) + .background(Color::from_argb(190, 10, 14, 20)) + .child( + rect() + .vertical() + .center() + .spacing(10.) + .padding(Gaps::new_symmetric(22., 28.)) + .max_width(Size::px(360.)) + .offset_y((1. - progress) * 12.) + .corner_radius(CornerRadius::new_all(16.)) + .background(DIALOG_BG) + .border(border_all_color(1., colors::brand().with_a(120))) + .shadow(Shadow::from(( + 0., + 16., + 44., + 0., + Color::from_argb(160, 0, 0, 0), + ))) + .child(icon_badge(IconType::FolderDownload, 52., 22.)) + .child( + label() + .text("Drop files to add them") + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("You'll pick where they go next.") + .font_size(12.5) + .color(colors::fg_secondary()), + ), + ) + } +} + +/// Brand-tinted circle holding an icon, used by the veil card and the dialog header. +fn icon_badge(icon: IconType, size: f32, icon_size: f32) -> impl IntoElement { + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .center() + .corner_radius(CornerRadius::new_all(size / 2.)) + .background(colors::brand().with_a(46)) + .child(Icon::new(icon).size(icon_size).color(colors::brand())) +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +#[derive(PartialEq)] +struct DropPrompt { + files: Vec, + pending: State>, +} + +impl Component for DropPrompt { + fn render(&self) -> impl IntoElement { + let route = use_route::(); + let active_cluster = use_active_cluster_id(); + let clusters_query = use_clusters(); + let dispatch = use_dispatch(); + + let files = self.files.clone(); + let mut pending = self.pending; + + let target = route_target(&route); + let initial_cluster = target.map(|(id, _)| id).or(*active_cluster.peek()); + let selected_cluster = use_state(move || initial_cluster); + // Only the rows the user actually changed. Everything else falls back to + // the inferred type, so a second drop landing mid-prompt still gets one. + let overrides = use_state(HashMap::::new); + + let clusters: Vec = { + let reader = clusters_query.read(); + let state = reader.state(); + match &*state { + QueryStateData::Settled { res: Ok(list), .. } => list.clone(), + QueryStateData::Loading { + res: Some(Ok(list)), + } => list.clone(), + _ => Vec::new(), + } + }; + + let body = if clusters.is_empty() { + no_clusters_body(pending) + } else { + prompt_body( + &files, + &clusters, + target.map(|(_, ct)| ct), + selected_cluster, + overrides, + dispatch, + pending, + ) + }; + + OverlayPopup::new() + .on_close(move |_| pending.set(Vec::new())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .max_width(Size::window_percent(94.)) + .padding(Gaps::new_all(22.)) + .spacing(16.) + .corner_radius(CornerRadius::new_all(16.)) + .background(DIALOG_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 18., + 52., + 0., + Color::from_argb(150, 0, 0, 0), + ))) + .child(body), + ), + ) + .into_element() + } +} + +fn dialog_header(title: String, subtitle: String) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + // Without Content::Flex the flexed text column doesn't resolve and the + // badge stops centring against it. + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .child(icon_badge(IconType::FilePlus02, 38., 18.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(title) + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) +} + +fn no_clusters_body(mut pending: State>) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(dialog_header( + "Nowhere to put these".to_string(), + "Create a cluster first, then drop the files again.".to_string(), + )) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .child( + Button::new() + .primary() + .on_press(move |_| pending.set(Vec::new())) + .text("Close"), + ), + ) + .into_element() +} + +fn prompt_body( + files: &[PathBuf], + clusters: &[Cluster], + route_type: Option, + selected_cluster: State>, + overrides: State>, + dispatch: crate::BridgeDispatch, + mut pending: State>, +) -> Element { + // The remembered cluster may have been deleted while the prompt was open. + let cluster_id = selected_cluster + .read() + .filter(|id| clusters.iter().any(|c| c.id == *id)) + .unwrap_or(clusters[0].id); + let cluster_idx = clusters + .iter() + .position(|c| c.id == cluster_id) + .unwrap_or_default(); + + let resolved: Vec<(PathBuf, ContentType)> = files + .iter() + .map(|path| { + let content_type = overrides + .read() + .get(path) + .copied() + .unwrap_or_else(|| infer_content_type(path, route_type)); + (path.clone(), content_type) + }) + .collect(); + + let import_list = resolved.clone(); + let import = move |_| { + for (path, content_type) in &import_list { + dispatch.import_local_file(cluster_id, *content_type, path.clone()); + } + pending.set(Vec::new()); + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(dialog_header( + format!( + "Add {} file{}", + files.len(), + if files.len() == 1 { "" } else { "s" } + ), + "Pick a cluster, then check what each one gets installed as.".to_string(), + )) + .child(cluster_field(clusters, cluster_idx, selected_cluster)) + .child(file_field(&resolved, overrides)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| pending.set(Vec::new())) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(import) + .child(Icon::new(IconType::Plus).size(15.)) + .text(match files.len() { + 1 => "Add file".to_string(), + n => format!("Add {n} files"), + }), + ), + ) + .into_element() +} + +fn field(title: &'static str, control: impl IntoElement) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(7.) + .child( + label() + .text(title) + .font_size(11.5) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child(control) +} + +fn cluster_field( + clusters: &[Cluster], + selected_idx: usize, + mut selected: State>, +) -> impl IntoElement { + let labels: Vec = clusters + .iter() + .map(|c| format!("{} · {}", c.name, c.mc_version)) + .collect(); + let ids: Vec = clusters.iter().map(|c| c.id).collect(); + let current = labels[selected_idx].clone(); + + field( + "Destination cluster", + Dropdown::new(current, labels) + .width(Size::fill()) + .height(Size::px(32.)) + .on_select(move |idx: usize| { + if let Some(id) = ids.get(idx) { + selected.set(Some(*id)); + } + }), + ) +} + +fn file_field( + resolved: &[(PathBuf, ContentType)], + overrides: State>, +) -> impl IntoElement { + let visible = resolved.len().clamp(1, FILE_LIST_MAX_ROWS); + let rows = resolved + .iter() + .map(|(path, content_type)| file_row(path, *content_type, overrides).into_element()) + .collect::>(); + + field( + "Files", + rect() + .width(Size::fill()) + .padding(Gaps::new_all(6.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page()) + .border(border_all_color(1., colors::component_border())) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::px(visible as f32 * FILE_ROW_H)) + .children(rows), + ), + ) +} + +fn file_row( + path: &Path, + content_type: ContentType, + mut overrides: State>, +) -> impl IntoElement { + let key = path.to_path_buf(); + let options: Vec = IMPORTABLE + .iter() + .map(|ct| content_label(*ct).to_string()) + .collect(); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(FILE_ROW_H)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 4.)) + .child( + Icon::new(IconType::File02) + .size(13.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(file_name(path)) + .font_size(12.5) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + Dropdown::new(content_label(content_type), options) + .width(Size::px(TYPE_DROPDOWN_W)) + .height(Size::px(24.)) + .on_select(move |idx: usize| { + if let Some(content_type) = IMPORTABLE.get(idx) { + overrides.write().insert(key.clone(), *content_type); + } + }), + ) +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index a52a285a..5977ebd9 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -8,6 +8,7 @@ mod cluster_update_popup; mod context_menu; mod dropdown; mod dynamic_art; +mod file_drop; mod icons; mod java_install_manager; mod java_prompt; @@ -44,6 +45,7 @@ pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; pub use dropdown::Dropdown; pub use dynamic_art::DynamicArt; +pub use file_drop::FileDropOverlay; pub use icons::{Icon, IconType}; pub use java_install_manager::JavaInstallManager; pub use java_prompt::JavaPromptOverlay; diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index e62f1da6..75666288 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -9,7 +9,9 @@ use skia_safe::sampling_options::CubicResampler; use crate::Route; use crate::components::Button; -use crate::components::{AppNavbar, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::components::{ + AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, ScrollArea, +}; use crate::layout::AnimatedAppOutlet; use crate::theme; use crate::use_settings_snapshot; @@ -23,6 +25,7 @@ use crate::hooks::{ use crate::theme::colors; use oneclient_core::notification::LaunchStage; use std::collections::HashMap; +use std::path::PathBuf; #[derive(PartialEq)] pub struct AppShell; @@ -38,6 +41,13 @@ impl Component for AppShell { let browser_state = use_state(HashMap::new); use_provide_browser_state(BrowserStateStore(browser_state)); + // Window-wide file drops. `FileDrop` bubbles, so anything a dedicated + // drop zone doesn't claim with `stop_propagation()` ends up here and the + // user gets asked where it should go. Only the overlay reads these, so + // hovering a file doesn't re-render the shell. + let mut drop_hovering = use_state(|| false); + let mut drop_pending = use_state(Vec::::new); + let game = use_game_snapshot(); let running_cluster = game @@ -60,6 +70,14 @@ impl Component for AppShell { .height(Size::fill()) .color(colors::fg_primary()) .overflow(Overflow::Clip) + .on_global_file_hover(move |_| drop_hovering.set(true)) + .on_global_file_hover_cancelled(move |_| drop_hovering.set(false)) + .on_file_drop(move |e: Event| { + drop_hovering.set(false); + if let Some(path) = &e.file_path { + drop_pending.write().push(path.clone()); + } + }) .child(AppNavbar) .child(AppHomeBackground) .child( @@ -68,6 +86,10 @@ impl Component for AppShell { .height(Size::fill()) .child(AnimatedRouter::::new(AnimatedAppOutlet)), ) + .child(FileDropOverlay { + hovering: drop_hovering, + pending: drop_pending, + }) .maybe_child( game.error .clone() diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 4b30c038..02d1228f 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -495,7 +495,6 @@ impl Component for ContentBox { let layout = self.layout; let dispatch = use_dispatch(); - let mut dropping = use_state(|| false); let count = items.len(); let scroll = (count > 0).then(|| match layout { @@ -551,8 +550,8 @@ impl Component for ContentBox { let mut bottom_corners = CornerRadius::new_all(0.); bottom_corners.fill_bottom(12.); - let drop_dispatch = dispatch.clone(); - + // File drops are handled window-wide by the app shell, which prompts for + // a destination — this box deliberately doesn't claim them. rect() .vertical() .width(Size::fill()) @@ -562,22 +561,6 @@ impl Component for ContentBox { .corner_radius(bottom_corners) .background(colors::page_elevated()) .overflow(Overflow::Clip) - .maybe(*dropping.read(), |el| { - el.border( - Border::new() - .fill(colors::brand()) - .width(2.) - .alignment(BorderAlignment::Inner), - ) - }) - .on_global_file_hover(move |_| dropping.set(true)) - .on_global_file_hover_cancelled(move |_| dropping.set(false)) - .on_file_drop(move |e: Event| { - dropping.set(false); - if let Some(path) = &e.file_path { - drop_dispatch.import_local_file(cluster_id, content_type, path.clone()); - } - }) .maybe_child(header) .maybe_child(scroll) .maybe_child(empty)