OneLauncher-Cracked/packages/oneclient_app/src/view/app/clusters/instance/details.rs

579 lines
19 KiB
Rust

use std::path::PathBuf;
use freya::prelude::*;
use oneclient_cluster::naming::{MAX_TAG_CHARS, NameProblem, is_allowed_name_char, validate_tag};
use crate::components::{
ART_PREVIEW_EDGE, Button, DynamicArt, GALLERY_COVER_EDGE, Icon, IconType, LocalImage,
TextInput, VersionArtGallery,
};
use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_RADIUS: f32 = 12.;
const TAG_ROW_H: f32 = 28.;
const PREVIEW_W: f32 = 132.;
const PREVIEW_H: f32 = 74.;
const PRESET_TAGS: [&str; 9] = [
"PVP",
"Performance",
"QoL",
"Skyblock",
"Bedwars",
"Test",
"Fun",
"Experimental",
"Shenanigans",
];
#[derive(Clone, Copy)]
pub struct DetailsState {
pub name: State<String>,
pub name_touched: State<bool>,
pub description: State<String>,
pub tags: State<Vec<String>>,
pub tag_draft: State<String>,
pub tag_open: State<bool>,
pub cover: State<Option<PathBuf>>,
pub cover_cleared: State<bool>,
pub gallery_open: State<bool>,
pub gallery_pending: State<Option<String>>,
pub gallery_error: State<Option<String>>,
}
impl DetailsState {
pub fn blank() -> Self {
Self::init(String::new(), String::new(), Vec::new(), false)
}
pub fn seeded(name: &str, description: Option<&str>, tags: &[String]) -> Self {
Self::init(
name.to_string(),
description.unwrap_or_default().to_string(),
tags.to_vec(),
true,
)
}
fn init(name: String, description: String, tags: Vec<String>, touched: bool) -> Self {
Self {
name: use_state(move || name.clone()),
name_touched: use_state(move || touched),
description: use_state(move || description.clone()),
tags: use_state(move || tags.clone()),
tag_draft: use_state(String::new),
tag_open: use_state(|| false),
cover: use_state(|| None::<PathBuf>),
cover_cleared: use_state(|| false),
gallery_open: use_state(|| false),
gallery_pending: use_state(|| None::<String>),
gallery_error: use_state(|| None::<String>),
}
}
pub fn effective_name(&self, suggested: &str) -> String {
let typed = self.typed_name();
if *self.name_touched.read() && !typed.is_empty() {
typed
} else {
suggested.to_string()
}
}
pub fn typed_name(&self) -> String {
self.name.read().trim().to_string()
}
pub fn description_value(&self) -> Option<String> {
let text = self.description.read().trim().to_string();
(!text.is_empty()).then_some(text)
}
pub fn preview_cover(&self, existing: Option<PathBuf>) -> Option<(PathBuf, bool)> {
if let Some(picked) = self.cover.read().clone() {
return Some((picked, true));
}
if *self.cover_cleared.read() {
return None;
}
existing.map(|path| (path, false))
}
}
pub fn details_body(
state: DetailsState,
placeholder: String,
existing_cover: Option<PathBuf>,
version_art: DynamicArt,
name_problem: Option<NameProblem>,
name_limit: Option<usize>,
) -> Element {
let mut touched = state.name_touched;
let name = state.name;
let tags = state.tags.read().clone();
let tag_open = *state.tag_open.read();
let preview = state.preview_cover(existing_cover);
rect()
.vertical()
.width(Size::fill())
.spacing(18.)
.child(field(
"Name",
rect()
.vertical()
.width(Size::fill())
.spacing(6.)
.child(
TextInput::new(state.name)
.placeholder(placeholder)
.width(Size::fill())
.on_validate(move |validator: InputValidator| {
touched.set(true);
let next = validator.text();
let current = name.peek();
validator.set_valid(
name_limit.is_none_or(|limit| within_limit(&next, &current, limit))
&& no_new_forbidden(&next, &current),
);
}),
)
.maybe_child(name_problem.map(|problem| {
label()
.text(problem.message())
.font_size(12.)
.color(colors::danger())
.into_element()
}))
.into_element(),
))
.child(field(
"Description",
TextInput::new(state.description)
.placeholder("What is this instance for?")
.multiline(true)
.width(Size::fill())
.height(Size::px(76.))
.into_element(),
))
.child(field("Tags", tag_field(state, &tags, tag_open)))
.child(field(
"Background image",
cover_field(state, preview, version_art),
))
.into_element()
}
fn no_new_forbidden(next: &str, current: &str) -> bool {
let forbidden = |text: &str| text.chars().filter(|c| !is_allowed_name_char(*c)).count();
forbidden(next) <= forbidden(current)
}
fn within_limit(next: &str, current: &str, limit: usize) -> bool {
let count = next.chars().count();
count <= limit || count < current.chars().count()
}
fn add_tag(state: DetailsState, tag: &str) {
let mut all_tags = state.tags;
let tag = tag.trim();
if validate_tag(tag).is_err() || already_taken(&all_tags.read(), tag) {
return;
}
let mut next = all_tags.read().clone();
next.push(tag.to_string());
all_tags.set(next);
}
fn already_taken(tags: &[String], candidate: &str) -> bool {
tags.iter()
.any(|tag| tag.eq_ignore_ascii_case(candidate.trim()))
}
fn commit_tag(state: DetailsState) {
let mut tag_draft = state.tag_draft;
let mut tag_open = state.tag_open;
let draft = tag_draft.read().clone();
add_tag(state, &draft);
tag_draft.set(String::new());
tag_open.set(false);
}
fn tag_field(state: DetailsState, tags: &[String], open: bool) -> Element {
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.child(chosen_tags(state, tags, open))
.maybe_child(preset_tags(state, tags))
.into_element()
}
fn preset_tags(state: DetailsState, tags: &[String]) -> Option<Element> {
let free: Vec<&'static str> = PRESET_TAGS
.into_iter()
.filter(|preset| !already_taken(tags, preset))
.collect();
if free.is_empty() {
return None;
}
Some(
rect()
.vertical()
.width(Size::fill())
.spacing(6.)
.child(
label()
.text("Suggested")
.font_size(11.)
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.content(Content::wrap_spacing(6.))
.spacing(6.)
.children(free.into_iter().map(|preset| {
PresetChip {
tags: state.tags,
preset,
}
.into_element()
})),
)
.into_element(),
)
}
#[derive(PartialEq)]
struct PresetChip {
tags: State<Vec<String>>,
preset: &'static str,
}
impl Component for PresetChip {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let hovered = *hovering.read();
let mut tags = self.tags;
let preset = self.preset;
rect()
.key(preset)
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.height(Size::px(26.))
.padding(Gaps::new_symmetric(0., 10.))
.corner_radius(CornerRadius::new_all(8.))
.background(if hovered {
colors::component_bg_hover()
} else {
colors::component_bg()
})
.border(border_all_color(
1.,
if hovered {
colors::component_border_hover()
} else {
colors::component_border()
},
))
.cursor(CursorIcon::Pointer)
.on_press(move |_| {
if !already_taken(&tags.read(), preset) {
let mut next = tags.read().clone();
next.push(preset.to_string());
tags.set(next);
}
})
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.child(
Icon::new(IconType::Plus)
.size(9.)
.color(colors::fg_secondary()),
)
.child(
label()
.text(preset)
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_secondary()),
)
}
}
fn chosen_tags(state: DetailsState, tags: &[String], open: bool) -> Element {
let mut tag_draft = state.tag_draft;
let mut tag_open = state.tag_open;
let mut all_tags = state.tags;
let draft_valid = validate_tag(&tag_draft.read()).is_ok();
rect()
.horizontal()
.width(Size::fill())
.content(Content::wrap_spacing(8.))
.cross_align(Alignment::Center)
.spacing(8.)
.children(tags.iter().map(|tag| {
let removed = tag.clone();
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.height(Size::px(TAG_ROW_H))
.padding(Gaps::new_symmetric(0., 10.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::brand())
.cursor(CursorIcon::Pointer)
.on_press(move |_| {
let next: Vec<String> = all_tags
.read()
.iter()
.filter(|tag| *tag != &removed)
.cloned()
.collect();
all_tags.set(next);
})
.child(
label()
.text(tag.clone())
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.color(Color::WHITE),
)
.child(Icon::new(IconType::XClose).size(10.).color(Color::WHITE))
.into_element()
}))
.child(if open {
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
rect().width(Size::px(132.)).child(
TextInput::new(tag_draft)
.compact()
.placeholder("Add a tag")
.auto_focus(true)
.width(Size::fill())
.height(Size::px(TAG_ROW_H))
.on_validate(move |validator: InputValidator| {
let fits = within_limit(
&validator.text(),
&tag_draft.peek(),
MAX_TAG_CHARS,
);
validator.set_valid(fits);
})
.on_submit(move |_| commit_tag(state))
.into_element(),
),
)
.child(
Button::new()
.primary()
.icon()
.alt("Add this tag")
.width(Size::px(TAG_ROW_H))
.height(Size::px(TAG_ROW_H))
.enabled(draft_valid)
.on_press(move |_| commit_tag(state))
.child(Icon::new(IconType::Check).size(13.)),
)
.into_element()
} else {
Button::new()
.secondary()
.icon()
.alt("Add a tag")
.width(Size::px(TAG_ROW_H))
.height(Size::px(TAG_ROW_H))
.on_press(move |_| {
tag_draft.set(String::new());
tag_open.set(true);
})
.child(Icon::new(IconType::Plus).size(14.))
.into_element()
})
.into_element()
}
fn cover_field(
state: DetailsState,
preview: Option<(PathBuf, bool)>,
version_art: DynamicArt,
) -> Element {
let mut cover = state.cover;
let mut cleared = state.cover_cleared;
let mut gallery_open = state.gallery_open;
let mut gallery_pending = state.gallery_pending;
let mut gallery_error = state.gallery_error;
let pick = move |_| {
spawn(async move {
if let Some(handle) = rfd::AsyncFileDialog::new()
.set_title("Choose a background image")
.add_filter("Image", &["png", "jpg", "jpeg", "webp", "gif"])
.pick_file()
.await
{
cover.set(Some(handle.path().to_path_buf()));
cleared.set(false);
}
});
};
let pick_from_gallery = move |url: String| {
spawn(async move {
let mut failed = |message: &str| {
gallery_pending.set(None);
gallery_error.set(Some(message.to_string()));
};
let state = match crate::launcher::state() {
Ok(state) => state,
Err(err) => {
tracing::warn!(error = %err, "cannot reach the launcher state to pick gallery art");
failed("The launcher is still starting up. Try again in a moment.");
return;
}
};
match state
.images
.ensure_on_disk(&state.services.requester, &url, GALLERY_COVER_EDGE)
.await
{
Ok(path) => {
cover.set(Some(path));
cleared.set(false);
gallery_pending.set(None);
gallery_error.set(None);
gallery_open.set(false);
}
Err(err) => {
tracing::warn!(error = %err, "failed to cache the chosen gallery art");
failed("That artwork could not be downloaded. Check your connection.");
}
}
});
};
let has_cover = preview.is_some();
let gallery = (*gallery_open.read()).then(|| {
rect()
.width(Size::px(0.))
.height(Size::px(0.))
.child(VersionArtGallery::new(
gallery_pending,
gallery_error,
pick_from_gallery,
move |()| {
gallery_pending.set(None);
gallery_error.set(None);
gallery_open.set(false);
},
))
.into_element()
});
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(14.)
.padding(Gaps::new_all(12.))
.corner_radius(CornerRadius::new_all(CARD_RADIUS))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.child(
rect()
.width(Size::px(PREVIEW_W))
.height(Size::px(PREVIEW_H))
.corner_radius(CornerRadius::new_all(8.))
.overflow(Overflow::Clip)
.border(border_all_color(1., colors::component_border()))
.center()
.child(match preview {
Some((path, picked)) => LocalImage::new(path, ART_PREVIEW_EDGE, true)
.picked(picked)
.skeleton(true)
.into_element(),
None => version_art.max_edge(ART_PREVIEW_EDGE).into_element(),
}),
)
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(8.)
.child(
label()
.text("Shown behind the instance on the home page and on its card. PNG or JPG, 1280x720 or larger.")
.font_size(12.)
.line_height(1.4)
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.spacing(8.)
.cross_align(Alignment::Center)
.child(
Button::new()
.secondary()
.small()
.on_press(pick)
.text("Choose file"),
)
.child(
Button::new()
.secondary()
.small()
.on_press(move |_| gallery_open.set(true))
.text("Browse gallery"),
)
.maybe_child(has_cover.then(|| {
Button::new()
.ghost()
.small()
.on_press(move |_| {
cover.set(None);
cleared.set(true);
})
.text("Use version art")
.into_element()
})),
),
)
.maybe_child(gallery)
.into_element()
}
pub fn field(title: &'static str, control: Element) -> Element {
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.child(
label()
.text(title)
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_primary()),
)
.child(control)
.into_element()
}