use std::borrow::Cow; use std::cell::RefCell; use freya::animation::*; use freya::prelude::*; use freya::router::*; use freya::text_edit::Clipboard; use sysinfo::CpuRefreshKind; use sysinfo::MemoryRefreshKind; use sysinfo::RefreshKind; use sysinfo::System; use crate::components::{Icon, IconType, ScrollArea, ScrollAreaCtx, TextInput}; use crate::routes::Route; use crate::theme::colors; use crate::use_dispatch; const SIDEBAR_WIDTH_PX: f32 = 225.; const ITEM_HEIGHT_PX: f32 = 33.; const SEARCH_WIDTH_PX: f32 = 256.; const SEARCH_RESULTS_MAX: usize = 12; thread_local! { static SETTINGS_SCROLL_CTX: RefCell> = const { RefCell::new(None) }; static SETTINGS_SCROLL_CONTROLLER: RefCell> = const { RefCell::new(None) }; static PENDING_SETTING_FOCUS: RefCell> = const { RefCell::new(None) }; } pub(crate) fn set_pending_setting_focus(id: &'static str) { PENDING_SETTING_FOCUS.with(|cell| *cell.borrow_mut() = Some(id)); spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; PENDING_SETTING_FOCUS.with(|cell| { if *cell.borrow() == Some(id) { *cell.borrow_mut() = None; } }); }); } #[derive(Clone)] struct SearchItem { id: &'static str, icon: IconType, title: &'static str, description: &'static str, keywords: &'static [&'static str], route: Route, } // TODO: Replace this with inventory crate or something const SEARCH_INDEX: &[SearchItem] = &[ // Launcher SearchItem { id: "launcher.discord_rpc", icon: IconType::Link03, title: "Discord RPC", description: "Enable Discord Rich Presence.", keywords: &["discord", "rpc", "rich presence"], route: Route::SettingsLauncher {}, }, SearchItem { id: "launcher.folder", icon: IconType::Folder, title: "Launcher Folder", description: "Open the launcher data directory.", keywords: &["data dir", "directory", "folder", "open"], route: Route::SettingsLauncher {}, }, // Appearance SearchItem { id: "appearance.accent_color", icon: IconType::PaintPour, title: "Accent color", description: "The main color used across the launcher.", keywords: &["theme", "accent", "color", "brand"], route: Route::SettingsAppearance {}, }, SearchItem { id: "appearance.custom_theme", icon: IconType::Colors, title: "Custom theme", description: "Create, edit, and import launcher themes.", keywords: &["theme", "custom", "import", "export"], route: Route::SettingsAppearance {}, }, SearchItem { id: "appearance.parallax_background", icon: IconType::Eye, title: "Parallax background", description: "Make the home screen background drift with your cursor.", keywords: &["background", "parallax", "motion"], route: Route::SettingsAppearance {}, }, SearchItem { id: "appearance.animations", icon: IconType::Play, title: "Animations", description: "Toggle all animations in the launcher.", keywords: &["reduce motion", "animations", "effects"], route: Route::SettingsAppearance {}, }, // APIs SearchItem { id: "apis.modrinth_key", icon: IconType::Key01, title: "Modrinth API key", description: "Personal access token used for Modrinth requests.", keywords: &["modrinth", "token", "api key", "pat"], route: Route::SettingsApis {}, }, SearchItem { id: "apis.curseforge_key", icon: IconType::Key01, title: "CurseForge API key", description: "API key used for CurseForge requests.", keywords: &["curseforge", "api key"], route: Route::SettingsApis {}, }, SearchItem { id: "apis.custom_endpoint", icon: IconType::Globe01, title: "Custom API Endpoint", description: "Override the default OneClient backend endpoint.", keywords: &["endpoint", "api", "backend", "url"], route: Route::SettingsApis {}, }, // Minecraft settings SearchItem { id: "minecraft.force_fullscreen", icon: IconType::Maximize01, title: "Force Fullscreen", description: "Force Minecraft to start in fullscreen mode.", keywords: &["fullscreen", "window"], route: Route::SettingsMinecraft {}, }, SearchItem { id: "minecraft.resolution", icon: IconType::LayoutTop, title: "Resolution", description: "The game window resolution in pixels.", keywords: &["width", "height", "window size", "resolution"], route: Route::SettingsMinecraft {}, }, SearchItem { id: "minecraft.memory", icon: IconType::Database01, title: "Memory", description: "The amount of memory allocated for the game.", keywords: &["ram", "memory", "heap", "xmx"], route: Route::SettingsMinecraft {}, }, SearchItem { id: "minecraft.pre_launch", icon: IconType::FilePlus02, title: "Pre-Launch Command", description: "Command to run before launching the game.", keywords: &["hook", "command", "pre launch", "prelaunch"], route: Route::SettingsMinecraft {}, }, SearchItem { id: "minecraft.wrapper", icon: IconType::ParagraphWrap, title: "Wrapper Command", description: "Command to run when launching the game.", keywords: &["hook", "command", "wrapper", "gamescope"], route: Route::SettingsMinecraft {}, }, SearchItem { id: "minecraft.post_exit", icon: IconType::FileX02, title: "Post-Exit Command", description: "Command to run after exiting the game.", keywords: &["hook", "command", "post exit", "postexit"], route: Route::SettingsMinecraft {}, }, // Java (routing only, page isn't built from settings_row_focus yet) SearchItem { id: "java.install", icon: IconType::Download01, title: "Install Java", description: "Install a Java runtime from a provider.", keywords: &["java", "runtime", "temurin", "zulu", "install manager"], route: Route::SettingsJava {}, }, SearchItem { id: "java.add_folder", icon: IconType::Folder, title: "Add Java from folder", description: "Add an existing Java installation.", keywords: &["java", "runtime", "custom", "folder", "path"], route: Route::SettingsJava {}, }, // Language SearchItem { id: "language.select", icon: IconType::Globe01, title: "Language", description: "Choose the launcher language.", keywords: &["language", "locale", "english", "spanish", "french", "german", "russian", "japanese", "chinese"], route: Route::SettingsLanguage {}, }, // Developer SearchItem { id: "developer.compat_only", icon: IconType::SearchMd, title: "Compatible content only", description: "Filter the content browser to the active cluster.", keywords: &["browser", "compat", "filter"], route: Route::SettingsDeveloper {}, }, SearchItem { id: "developer.log_debug", icon: IconType::Terminal, title: "Log Debug Info", description: "Enable extra debug logging (requires restart).", keywords: &["debug", "logs", "logging"], route: Route::SettingsDeveloper {}, }, SearchItem { id: "developer.show_dev", icon: IconType::CodeSnippet02, title: "Show Dev stuff", description: "Enable dev tools and show the debug page.", keywords: &["devtools", "developer", "debug"], route: Route::SettingsDeveloper {}, }, SearchItem { id: "developer.debug_page", icon: IconType::CodeSnippet02, title: "Debug Page", description: "Open the debug page.", keywords: &["debug", "page"], route: Route::SettingsDeveloper {}, }, // Navigation-level items SearchItem { id: "nav.accounts", icon: IconType::Users01, title: "Accounts", description: "Manage Minecraft / Microsoft accounts.", keywords: &["account", "login", "microsoft", "msa"], route: Route::Accounts {}, }, SearchItem { id: "nav.changelog", icon: IconType::RefreshCcw02, title: "Changelog", description: "View what's new in OneClient.", keywords: &["changelog", "release notes", "updates"], route: Route::SettingsChangelog {}, }, ]; #[derive(PartialEq, Clone, Copy)] pub enum SettingsTab { MinecraftSettings, Accounts, LauncherSettings, Java, Appearance, Apis, Language, DeveloperOptions, Changelog, } impl SettingsTab { fn label(self) -> &'static str { match self { Self::MinecraftSettings => "Minecraft settings", Self::Accounts => "Accounts", Self::LauncherSettings => "Launcher settings", Self::Java => "Java", Self::Appearance => "Appearance", Self::Apis => "APIs", Self::Language => "Language", Self::DeveloperOptions => "Developer options", Self::Changelog => "Changelog", } } fn icon(self) -> IconType { match self { Self::MinecraftSettings => IconType::Sliders04, Self::Accounts => IconType::Users01, Self::LauncherSettings => IconType::Rocket02, Self::Java => IconType::CodeSnippet02, Self::Appearance => IconType::Brush01, Self::Apis => IconType::Key01, Self::Language => IconType::Globe01, Self::DeveloperOptions => IconType::CodeSnippet02, Self::Changelog => IconType::RefreshCcw02, } } fn route(self) -> Option { match self { Self::MinecraftSettings => Some(Route::SettingsMinecraft {}), Self::Accounts => Some(Route::Accounts {}), Self::LauncherSettings => Some(Route::SettingsLauncher {}), Self::Java => Some(Route::SettingsJava {}), Self::Appearance => Some(Route::SettingsAppearance {}), Self::Apis => Some(Route::SettingsApis {}), Self::Language => Some(Route::SettingsLanguage {}), Self::DeveloperOptions => Some(Route::SettingsDeveloper {}), Self::Changelog => Some(Route::SettingsChangelog {}), } } } struct SettingsGroup { label: &'static str, tabs: &'static [SettingsTab], } const GROUPS: &[SettingsGroup] = &[ SettingsGroup { label: "LAUNCHER SETTINGS", tabs: &[ SettingsTab::LauncherSettings, SettingsTab::Java, SettingsTab::Appearance, SettingsTab::Apis, SettingsTab::Language, ], }, SettingsGroup { label: "GAME SETTINGS", tabs: &[SettingsTab::MinecraftSettings, SettingsTab::Accounts], }, SettingsGroup { label: "ABOUT", tabs: &[ SettingsTab::DeveloperOptions, SettingsTab::Changelog, ], }, ]; #[derive(PartialEq)] pub struct SettingsShell; fn route_tab(route: &Route) -> SettingsTab { match route { Route::SettingsMinecraft {} => SettingsTab::MinecraftSettings, Route::SettingsLauncher {} => SettingsTab::LauncherSettings, Route::SettingsJava {} => SettingsTab::Java, Route::SettingsApis {} => SettingsTab::Apis, Route::SettingsLanguage {} => SettingsTab::Language, Route::SettingsDeveloper {} => SettingsTab::DeveloperOptions, Route::SettingsChangelog {} => SettingsTab::Changelog, _ => SettingsTab::Appearance, } } impl Component for SettingsShell { fn render(&self) -> impl IntoElement { let route = use_route::(); let active = route_tab(&route); let search = use_state(String::new); let query = search.read().trim().to_string(); let scroll = use_scroll_controller(ScrollConfig::default); SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll)); let anim = use_animation(|conf| { conf.on_creation(OnCreation::Run); AnimNum::new(0., 1.) .time(340) .ease(Ease::Out) .function(Function::Cubic) }); let mut last_route = use_state(|| route.clone()); if *last_route.peek() != route { last_route.set(route.clone()); anim.run(AnimDirection::Forward); } let p = anim.get().value(); let children: Vec = if query.is_empty() { const SHIFT_PX: f32 = 46.; vec![ rect() .width(Size::fill()) .offset_x(-(1.0 - p) * SHIFT_PX) .opacity(p) .child(Outlet::::new()) .into_element(), ] } else { search_results(query, search) }; rect() .horizontal() .width(Size::fill()) .height(Size::fill()) .overflow(Overflow::Clip) .padding(Gaps::new(0., 40., 40., 32.)) .spacing(40.) .child(sidebar(active)) .child( rect() .vertical() .width(Size::flex(1.0)) .height(Size::fill()) .overflow(Overflow::Clip) .spacing(24.) .child(content_header(active.label().to_string(), search)) .child( ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) .show_scrollbar(true) .spacing(4.) .scroll_controller(scroll) .on_ctx(|ctx| { SETTINGS_SCROLL_CTX.with(|cell| *cell.borrow_mut() = Some(ctx)); }) .children(children), ), ) } } fn content_header(title: String, search: State) -> impl IntoElement { rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .main_align(Alignment::SpaceBetween) .child( label() .text(title) .font_size(30.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) .child(search_box(search)) } fn search_box(mut search: State) -> impl IntoElement { let has_query = !search.read().trim().is_empty(); let base = TextInput::new(search) .placeholder("Search settings...") .width(Size::px(SEARCH_WIDTH_PX)) .leading( Icon::new(IconType::SearchMd) .size(16.) .color(colors::fg_secondary()) .into_element(), ); if !has_query { return base; } base.trailing( rect() .padding(Gaps::new_symmetric(2., 2.)) .corner_radius(CornerRadius::new_all(6.)) .background(Color::TRANSPARENT) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) .on_press(move |_| search.set(String::new())) .child( Icon::new(IconType::XClose) .size(16.) .color(colors::fg_secondary()), ) .into_element(), ) } fn search_results(query: String, mut search: State) -> Vec { let q = query.to_lowercase(); let mut matches: Vec = SEARCH_INDEX .iter() .filter(|&item| { item.title.to_lowercase().contains(&q) || item.description.to_lowercase().contains(&q) || item.keywords.iter().any(|k| k.to_lowercase().contains(&q)) }) .cloned() .collect(); matches.sort_by_key(|m| m.title.len()); matches.truncate(SEARCH_RESULTS_MAX); if matches.is_empty() { return vec![rect() .width(Size::fill()) .padding(Gaps::new_symmetric(10., 14.)) .corner_radius(CornerRadius::new_all(12.)) .background(colors::page_elevated()) .child( label() .text("No settings matched your search.") .font_size(12.) .color(colors::fg_secondary()), ) .into_element()]; } let mut out: Vec = Vec::with_capacity(matches.len() + 1); out.push( rect() .padding(Gaps::new(16., 0., 8., 2.)) .child( label() .text("SEARCH RESULTS") .font_size(11.) .font_weight(FontWeight::MEDIUM) .color(colors::fg_secondary()), ) .into_element(), ); for item in matches { let route = item.route; let id = item.id; let title = item.title; let desc = item.description; out.push( rect() .horizontal() .width(Size::fill()) .content(Content::Flex) .cross_align(Alignment::Center) .spacing(12.) .padding(Gaps::new_symmetric(12., 16.)) .corner_radius(CornerRadius::new_all(12.)) .background(colors::page_elevated()) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) .on_press(move |_| { set_pending_setting_focus(id); search.set(String::new()); let _ = RouterContext::get().push(route.clone()); }) .child(Icon::new(item.icon)) .child( rect() .vertical() .width(Size::flex(1.0)) .spacing(2.) .child( label() .text(title) .font_size(14.) .font_weight(FontWeight::MEDIUM) .color(colors::fg_primary()), ) .child( label() .text(desc) .font_size(11.) .max_lines(2) .color(colors::fg_secondary()), ), ) .child( Icon::new(IconType::ChevronRight) .size(18.) .color(colors::fg_secondary()), ) .into_element(), ); } out } fn sidebar(active: SettingsTab) -> impl IntoElement { rect() .vertical() .width(Size::px(SIDEBAR_WIDTH_PX)) .min_width(Size::px(SIDEBAR_WIDTH_PX)) .height(Size::fill()) .overflow(Overflow::Clip) .padding(Gaps::new(16., 0., 0., 0.)) .content(Content::Flex) .child( rect() .width(Size::fill()) .height(Size::flex(1.0)) .spacing(16.) .children(GROUPS.iter().map(|group| { rect() .vertical() .width(Size::fill()) .spacing(6.) .child( rect().padding(Gaps::new(0., 0., 2., 6.)).child( label() .text(group.label) .font_size(11.) .font_weight(FontWeight::MEDIUM) .color(colors::fg_secondary()), ), ) .children(group.tabs.iter().map(|tab| { SidebarItem { tab: *tab, active: *tab == active, } .into_element() })) .into_element() })), ) .child(SidebarInfo::new()) } #[derive(PartialEq)] struct SidebarInfo { kernel_version: String, ram_mb: u64, cpu_kind: String, } impl SidebarInfo { pub fn new() -> Self { let system = System::new_with_specifics( RefreshKind::nothing() .with_memory(MemoryRefreshKind::nothing().with_ram()) .with_cpu(CpuRefreshKind::nothing()), ); let kernel_version = System::kernel_long_version(); let ram_mb = system.total_memory() / 1024 / 1024; let cpu_kind = system .cpus() .first() .map(|cpu| cpu.brand()) .unwrap_or_default(); Self { kernel_version, ram_mb, cpu_kind: cpu_kind.to_string(), } } } impl Component for SidebarInfo { fn render(&self) -> impl IntoElement { let dispatch = use_dispatch(); let items: [Cow<'static, str>; 4] = [ Cow::Borrowed(concat!( "OneClient v", env!("CARGO_PKG_VERSION"), cfg_select! { debug_assertions => " (debug)", _ => " (release)" } )), Cow::Owned(self.kernel_version.clone()), Cow::Owned(self.cpu_kind.clone()), Cow::Owned(format!("RAM Total of {} MB", self.ram_mb)), ]; let text = items .iter() .map(|s| s.as_ref()) .collect::>() .join("\n"); let copy_to_clipboard = move |_| { if let Err(err) = Clipboard::set(text.clone()) { tracing::warn!("clipboard copy failed: {err:?}"); dispatch .notify("Copy failed") .body("Could not copy system information to the clipboard.") .error() .send(); } else { dispatch .notify("Copied to clipboard") .body("System information copied to your clipboard.") .info() .icon(IconType::ClipboardCheck) .send(); } }; use_drop(|| { Cursor::set(CursorIcon::default()); }); rect() .vertical() .width(Size::fill()) .spacing(4.) .font_size(12.) .color(colors::fg_secondary()) .children(items.into_iter().map(|item| { label() .text(item) .into_element() })) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) .on_press(copy_to_clipboard) } } #[derive(PartialEq)] struct SidebarItem { tab: SettingsTab, active: bool, } impl Component for SidebarItem { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); let a11y_id = use_a11y(); let focus = use_focus(a11y_id); let tab = self.tab; let active = self.active; let route = tab.route(); let background = if active { colors::page_elevated() } else if *hovering.read() { colors::ghost_overlay() } else { Color::TRANSPARENT }; let has_dot = matches!(tab, SettingsTab::Changelog); let mut el = rect() .horizontal() .content(Content::Flex) .width(Size::fill()) .height(Size::px(ITEM_HEIGHT_PX)) .cross_align(Alignment::Center) .spacing(10.) .padding(Gaps::new_symmetric(0., 12.)) .corner_radius(CornerRadius::new_all(7.)) .background(background) .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| { *hovering.write() = true; Cursor::set(CursorIcon::Pointer); }) .on_pointer_leave(move |_| { *hovering.write() = false; Cursor::set(CursorIcon::default()); }) .map(route, |el, route| { el.on_all_press(move |_| { let _ = RouterContext::get().push(route.clone()); }) }); if focus().is_focused() { el = el.border( Border::new() .fill(colors::brand()) .width(1.) .alignment(BorderAlignment::Inner), ); } el.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary())) .child( rect() .horizontal() .width(Size::flex(1.0)) .cross_align(Alignment::Center) .spacing(6.) .child( label() .text(tab.label()) .font_size(14.) .color(colors::fg_primary()), ) .maybe_child(has_dot.then(notification_dot)), ) } } fn notification_dot() -> impl IntoElement { rect() .width(Size::px(4.)) .height(Size::px(4.)) .corner_radius(CornerRadius::new_all(2.)) .background(colors::brand()) }