OneLauncher-Cracked/packages/oneclient_app/src/components/file_drop.rs

691 lines
22 KiB
Rust

//! Freya emits one `FileDrop` event per file within a single batch so the shell
//! handler accumulates them before the prompt first renders
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation};
use freya::prelude::*;
use freya::router::use_route;
use oneclient_content::modpacks::detect_format;
use oneclient_content::packages::ContentType;
use oneclient_core::clusters::{Cluster, ModpackSource};
use oneclient_db::models::ClusterId;
use crate::Route;
use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea};
use crate::hooks::{
add_world_datapacks, settled_or_loading, spawn_world_task, try_cluster_worlds,
use_active_cluster_id, use_cluster_worlds, use_clusters, use_datapack_world, use_debounced,
use_dispatch,
};
use crate::launcher::off_ui;
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.;
/// Worlds are directories and modpacks have no view so both are excluded
const IMPORTABLE: [ContentType; 4] = [
ContentType::Mod,
ContentType::ResourcePack,
ContentType::Shader,
ContentType::DataPack,
];
const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"];
const DATAPACK_HINTS: [&str; 4] = ["datapack", "data pack", "data_pack", "data-pack"];
fn importable(mod_loader: bool) -> Vec<ContentType> {
IMPORTABLE
.into_iter()
.filter(|ct| mod_loader || !ct.needs_mod_loader())
.collect()
}
fn content_label(content_type: ContentType) -> &'static str {
match content_type {
ContentType::ResourcePack => "Textures",
ContentType::Shader => "Shaders",
ContentType::DataPack => "Data packs",
_ => "Mods",
}
}
fn extension(path: &Path) -> Option<String> {
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())
}
/// Precedence `.jar` is always a mod then the current route then a name sniff
fn infer_content_type(
path: &Path,
route_type: Option<ContentType>,
mod_loader: bool,
) -> Option<ContentType> {
if extension(path).as_deref() == Some("jar") {
return mod_loader.then_some(ContentType::Mod);
}
if let Some(route_type) = route_type {
if mod_loader || !route_type.needs_mod_loader() {
return Some(route_type);
}
if route_type == ContentType::Shader {
return None;
}
}
let name = file_name(path).to_lowercase();
if DATAPACK_HINTS.iter().any(|hint| name.contains(hint)) {
Some(ContentType::DataPack)
} else if mod_loader && SHADER_HINTS.iter().any(|hint| name.contains(hint)) {
Some(ContentType::Shader)
} else {
Some(ContentType::ResourcePack)
}
}
const DROP_SETTLE: Duration = Duration::from_millis(300);
pub fn accept_drop(
paths: &[PathBuf],
mut pending: State<Vec<PathBuf>>,
mut modpacks: State<Vec<PathBuf>>,
) {
for path in paths {
match extension(path).as_deref() {
Some("mrpack") => modpacks.write().push(path.clone()),
Some("zip") => {
let path = path.clone();
spawn(async move {
let probe = path.clone();
let is_modpack =
off_ui(async move { detect_format(&probe).await.is_ok() }).await;
if is_modpack {
modpacks.write().push(path);
} else {
pending.write().push(path);
}
});
}
_ => pending.write().push(path.clone()),
}
}
}
fn use_dropped_modpack_driver(pending: State<Vec<PathBuf>>, modpacks: State<Vec<PathBuf>>) {
let dispatch = use_dispatch();
let settled = use_debounced(
(pending.read().is_empty(), modpacks.read().len()),
DROP_SETTLE,
);
let settled = *settled.read();
use_side_effect_with_deps(&settled, move |&(prompt_closed, queued)| {
if !prompt_closed || queued == 0 || !pending.peek().is_empty() {
return;
}
let mut modpacks = modpacks;
let dropped = std::mem::take(&mut *modpacks.write());
for path in dropped {
dispatch.install_modpack(ModpackSource::File(path));
}
});
}
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)),
Route::ClusterDataPacks { cluster_id } => Some((*cluster_id, ContentType::DataPack)),
_ => None,
}
}
/// State lives in the shell (owner of the bubbling `on_file_drop`) so it never re-renders on hover
#[derive(PartialEq)]
pub struct FileDropOverlay {
pub hovering: State<bool>,
pub pending: State<Vec<PathBuf>>,
pub modpacks: State<Vec<PathBuf>>,
}
impl Component for FileDropOverlay {
fn render(&self) -> impl IntoElement {
use_dropped_modpack_driver(self.pending, self.modpacks);
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()
}
}
#[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()),
),
)
}
}
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()))
}
#[derive(PartialEq)]
struct DropPrompt {
files: Vec<PathBuf>,
pending: State<Vec<PathBuf>>,
}
impl Component for DropPrompt {
fn render(&self) -> impl IntoElement {
let route = use_route::<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);
let remembered_world = use_datapack_world();
// Absent entries fall back to the inferred type covering drops landing mid-prompt
let overrides = use_state(HashMap::<PathBuf, ContentType>::new);
let clusters: Vec<Cluster> = settled_or_loading(&clusters_query).unwrap_or_default();
// 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))
.or_else(|| clusters.first().map(|c| c.id));
let worlds_query = use_cluster_worlds(cluster_id.unwrap_or_default());
let worlds: Vec<String> = try_cluster_worlds(&worlds_query)
.unwrap_or_default()
.into_iter()
.map(|w| w.folder_name)
.collect();
let body = match cluster_id {
None => no_clusters_body(pending),
Some(cluster_id) => prompt_body(
&files,
&clusters,
cluster_id,
target.map(|(_, ct)| ct),
selected_cluster,
&worlds,
remembered_world,
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<Vec<PathBuf>>) -> 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()
}
#[allow(clippy::too_many_arguments)]
fn prompt_body(
files: &[PathBuf],
clusters: &[Cluster],
cluster_id: ClusterId,
route_type: Option<ContentType>,
selected_cluster: State<Option<ClusterId>>,
worlds: &[String],
remembered_world: State<HashMap<ClusterId, String>>,
overrides: State<HashMap<PathBuf, ContentType>>,
dispatch: crate::Actions,
mut pending: State<Vec<PathBuf>>,
) -> Element {
let cluster_idx = clusters
.iter()
.position(|c| c.id == cluster_id)
.unwrap_or_default();
let destination = clusters.get(cluster_idx);
let mod_loader = destination.is_none_or(|c| !c.lacks_mod_loader());
let mut rejected: Vec<PathBuf> = Vec::new();
let resolved: Vec<(PathBuf, ContentType)> = files
.iter()
.filter_map(|path| {
let content_type = overrides
.read()
.get(path)
.copied()
.filter(|ct| mod_loader || !ct.needs_mod_loader())
.or_else(|| infer_content_type(path, route_type, mod_loader));
match content_type {
Some(content_type) => Some((path.clone(), content_type)),
None => {
rejected.push(path.clone());
None
}
}
})
.collect();
let has_datapacks = resolved.iter().any(|(_, ct)| *ct == ContentType::DataPack);
let world = remembered_world
.read()
.get(&cluster_id)
.filter(|name| worlds.contains(name))
.cloned()
.or_else(|| worlds.first().cloned());
let can_import = !resolved.is_empty() && (!has_datapacks || world.is_some());
let skipped = rejected.len();
let destination_name = destination.map(|c| c.name.clone()).unwrap_or_default();
let import_list = resolved.clone();
let import_world = world.clone();
let import = move |_| {
if !can_import {
return;
}
let (datapacks, packages): (Vec<_>, Vec<_>) = import_list
.iter()
.cloned()
.partition(|(_, ct)| *ct == ContentType::DataPack);
dispatch.import_local_files(cluster_id, packages);
if let Some(world) = import_world.clone()
&& !datapacks.is_empty()
{
spawn_world_task(
dispatch.clone(),
"Couldn't add data packs",
add_world_datapacks(
cluster_id,
world,
datapacks.into_iter().map(|(path, _)| path).collect(),
),
);
}
if skipped > 0 {
dispatch
.notify("Some files weren't added")
.body(format!(
"{destination_name} has no mod loader, so {skipped} mod or shader file{} {} skipped.",
if skipped == 1 { "" } else { "s" },
if skipped == 1 { "was" } else { "were" },
))
.send();
}
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))
.maybe_child(
has_datapacks
.then(|| world_field(worlds, world.as_deref(), cluster_id, remembered_world)),
)
.child(file_field(&resolved, &rejected, mod_loader, 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()
.enabled(can_import)
.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<Option<ClusterId>>,
) -> impl IntoElement {
let labels: Vec<String> = clusters
.iter()
.map(|c| format!("{} · {}", c.name, c.mc_version))
.collect();
let ids: Vec<ClusterId> = 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 world_field(
worlds: &[String],
current: Option<&str>,
cluster_id: ClusterId,
mut remembered: State<HashMap<ClusterId, String>>,
) -> Element {
let Some(current) = current else {
return field(
"Destination world",
label()
.text("This cluster has no worlds yet. Create one in game, then drop the data packs again.")
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element();
};
let names = worlds.to_vec();
field(
"Destination world",
Dropdown::new(current.to_string(), worlds.to_vec())
.width(Size::fill())
.height(Size::px(32.))
.on_select(move |idx: usize| {
if let Some(name) = names.get(idx) {
remembered.write().insert(cluster_id, name.clone());
}
}),
)
.into_element()
}
fn file_field(
resolved: &[(PathBuf, ContentType)],
rejected: &[PathBuf],
mod_loader: bool,
overrides: State<HashMap<PathBuf, ContentType>>,
) -> impl IntoElement {
let visible = (resolved.len() + rejected.len()).clamp(1, FILE_LIST_MAX_ROWS);
let rows = resolved
.iter()
.map(|(path, content_type)| {
file_row(path, Some(*content_type), mod_loader, overrides).into_element()
})
.chain(
rejected
.iter()
.map(|path| file_row(path, None, mod_loader, overrides).into_element()),
)
.collect::<Vec<_>>();
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: Option<ContentType>,
mod_loader: bool,
mut overrides: State<HashMap<PathBuf, ContentType>>,
) -> impl IntoElement {
let key = path.to_path_buf();
let types = importable(mod_loader);
let options: Vec<String> = types
.iter()
.map(|ct| content_label(*ct).to_string())
.collect();
let control = match content_type {
Some(content_type) => 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) = types.get(idx) {
overrides.write().insert(key.clone(), *content_type);
}
})
.into_element(),
None => label()
.text("Needs a mod loader")
.font_size(12.)
.max_lines(1)
.color(colors::fg_secondary())
.into_element(),
};
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(control)
}