From c95f2d17ff64fd43cec74d25e74e46efa9209b77 Mon Sep 17 00:00:00 2001 From: objz Date: Thu, 3 Sep 2026 20:44:44 +0200 Subject: [PATCH 01/42] feat: add in-TUI settings editors --- src/cli/content.rs | 4 +- src/cli/import.rs | 4 +- src/cli/instance.rs | 28 +- src/cli/log.rs | 4 +- src/cli/mod.rs | 4 +- src/config/mod.rs | 83 +- src/config/settings.rs | 16 +- src/config/theme.rs | 134 +- src/instance/config_sync.rs | 2 +- src/instance/content/reconcile.rs | 20 +- src/instance/launch/mod.rs | 43 +- src/instance/loader/forge.rs | 1 + src/instance/loader/neoforge.rs | 1 + src/instance/models.rs | 19 + src/instance/runtime.rs | 8 + src/instance/tests/launch/pipeline.rs | 23 + src/instance/tests/models.rs | 13 + src/instance/tests/runtime.rs | 8 + src/launch_profile/templates.rs | 4 + src/launch_profile/tests/render.rs | 2 + src/launch_profile/tests/templates.rs | 14 + src/tui/app.rs | 12 +- src/tui/event.rs | 154 ++- src/tui/input.rs | 252 ++-- src/tui/mod.rs | 6 +- src/tui/render.rs | 22 +- src/tui/tests/event.rs | 174 +++ src/tui/tests/flows.rs | 35 +- src/tui/tests/harness.rs | 3 +- ..._empty_app_renders_the_complete_frame.snap | 2 +- ...nfirmation_renders_the_complete_frame.snap | 6 +- ...r_conflict_renders_the_complete_frame.snap | 2 +- src/tui/tests/widgets/popups/error/area.rs | 2 +- src/tui/tests/widgets/settings.rs | 19 - src/tui/widgets/content/discovery.rs | 10 +- src/tui/widgets/content/list.rs | 2 +- src/tui/widgets/content/tabs.rs | 10 +- src/tui/widgets/logs_viewer.rs | 23 +- src/tui/widgets/markdown.rs | 2 +- src/tui/widgets/popups/error.rs | 2 +- src/tui/widgets/popups/global_settings.rs | 435 +++++++ .../widgets/popups/import_modpack/state.rs | 13 +- src/tui/widgets/popups/instance_settings.rs | 1090 +++++++++++++++++ src/tui/widgets/popups/mod.rs | 3 + src/tui/widgets/popups/new_instance/state.rs | 20 +- src/tui/widgets/popups/version_lists.rs | 27 + src/tui/widgets/settings.rs | 536 +------- 47 files changed, 2565 insertions(+), 732 deletions(-) delete mode 100644 src/tui/tests/widgets/settings.rs create mode 100644 src/tui/widgets/popups/global_settings.rs create mode 100644 src/tui/widgets/popups/instance_settings.rs create mode 100644 src/tui/widgets/popups/version_lists.rs diff --git a/src/cli/content.rs b/src/cli/content.rs index efdd4e9..38b3d55 100644 --- a/src/cli/content.rs +++ b/src/cli/content.rs @@ -68,7 +68,7 @@ pub(crate) fn find_entry_by_stem<'a>( } fn list_entries(instance: &str, scan: Scanner) -> CliResult { - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); require_instance(&instances_dir, instance)?; let rows = scan(&instances_dir, instance) .into_iter() @@ -95,7 +95,7 @@ fn toggle_entry( kind: &str, scan: Scanner, ) -> CliResult { - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); require_instance(&instances_dir, instance)?; let entries = scan(&instances_dir, instance); let entry = find_entry_by_stem(&entries, target) diff --git a/src/cli/import.rs b/src/cli/import.rs index 086ef15..790ece1 100644 --- a/src/cli/import.rs +++ b/src/cli/import.rs @@ -16,8 +16,8 @@ pub async fn handle_import(matches: &ArgMatches) -> CliResult { let override_name = matches.get_one::("name"); let override_version = matches.get_one::("version"); - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); let manager = InstanceManager::new(instances_dir, meta_dir); let client = crate::net::HttpClient::new(); diff --git a/src/cli/instance.rs b/src/cli/instance.rs index bbed75d..d6d8603 100644 --- a/src/cli/instance.rs +++ b/src/cli/instance.rs @@ -11,7 +11,7 @@ use clap::ArgMatches; use super::utils::{confirm, required_arg}; use crate::cli::output::{format_datetime, print_table}; use crate::instance::runtime::RunState; -use crate::instance::{InstanceManager, ModLoader}; +use crate::instance::{InstanceManager, ModLoader, models::parse_resolution}; type CliResult = Result<(), Box>; const LOCAL_CONFIG_PROFILE: &str = "instance default"; @@ -44,27 +44,9 @@ pub(crate) fn parse_loader(input: &str) -> Result { } } -pub(crate) fn parse_resolution(input: &str) -> Result<(u32, u32), String> { - let (width, height) = input - .split_once('x') - .ok_or_else(|| "resolution must be in WxH format".to_string())?; - let width = width - .parse::() - .map_err(|_| "resolution width must be a positive integer".to_string())?; - let height = height - .parse::() - .map_err(|_| "resolution height must be a positive integer".to_string())?; - - if width == 0 || height == 0 { - return Err("resolution values must be greater than zero".to_string()); - } - - Ok((width, height)) -} - fn manager() -> InstanceManager { - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); InstanceManager::new(instances_dir, meta_dir) } @@ -135,8 +117,8 @@ fn rename_instance(matches: &ArgMatches) -> CliResult { async fn launch_instance(matches: &ArgMatches) -> CliResult { let name = required_arg(matches, "name")?; let manager = manager(); - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); let config = manager.load_one(name)?; crate::instance::launch::launch(&config, &instances_dir, &meta_dir, None) diff --git a/src/cli/log.rs b/src/cli/log.rs index c492b89..09599b5 100644 --- a/src/cli/log.rs +++ b/src/cli/log.rs @@ -21,7 +21,7 @@ pub async fn handle_log(matches: &ArgMatches) -> CliResult { } fn list_logs(instance: &str) -> CliResult { - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); require_instance(&instances_dir, instance)?; let rows = crate::instance::logs::files::scan_log_files(&instances_dir, instance) .into_iter() @@ -41,7 +41,7 @@ async fn show_log(matches: &ArgMatches) -> CliResult { let instance = required_arg(matches, "instance")?; let file = matches.get_one::("file").map(String::as_str); let follow = matches.get_flag("follow"); - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); require_instance(&instances_dir, instance)?; let path = resolve_log_path(&instances_dir, instance, file)?; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 493e114..f32af79 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -27,8 +27,8 @@ pub async fn init() { return; } - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); if crate::layout_migration::is_needed(&instances_dir, &meta_dir) { let config = crate::config::get_config_path().join("config.toml"); if let Err(error) = diff --git a/src/config/mod.rs b/src/config/mod.rs index caaaad4..e2e0ff9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -7,7 +7,7 @@ use config::{Config as ConfigLoader, ConfigError, File}; use std::fs; use std::path::PathBuf; -use std::sync::LazyLock; +use std::sync::{LazyLock, RwLock, RwLockReadGuard}; pub mod settings; pub mod theme; @@ -56,18 +56,75 @@ pub fn load_config(config_path: &std::path::Path) -> Result .try_deserialize() } -pub static SETTINGS: LazyLock = LazyLock::new(|| { - let path = ensure_config_exists(); - load_config(&path).unwrap_or_else(|e| { - tracing::error!("Config load failed, using defaults: {}", e); - Config { - general: settings::General::default(), - paths: settings::Paths::default(), - defaults: settings::Defaults::default(), - ui: settings::Ui::default(), - content: settings::Content::default(), - } - }) +pub struct ConfigStore(RwLock); + +impl ConfigStore { + pub fn read(&self) -> RwLockReadGuard<'_, Config> { + self.0 + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn save_launcher_settings(&self, edited: Config) -> std::io::Result<()> { + let path = get_config_path().join("config.toml"); + let current = self.read().clone(); + let mut persisted = load_config(&path).unwrap_or_else(|error| { + tracing::warn!("Failed to merge config.toml while saving settings: {error}"); + current.clone() + }); + persisted.defaults = edited.defaults.clone(); + persisted.paths.java_path = edited.paths.java_path.clone(); + let serialized = toml::to_string_pretty(&persisted).map_err(std::io::Error::other)?; + crate::storage::write_atomic(&path, serialized.as_bytes())?; + let mut runtime = current; + runtime.defaults = edited.defaults; + runtime.paths.java_path = edited.paths.java_path; + *self + .0 + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = runtime; + Ok(()) + } + + pub fn reload(&self) -> Result { + let mut config = load_config(&get_config_path().join("config.toml"))?; + let restart_required = { + let current = self.read(); + let changed = current.paths.instances_dir != config.paths.instances_dir + || current.paths.meta_dir != config.paths.meta_dir + || current.ui.image_protocol != config.ui.image_protocol; + // App owns a manager and several watchers rooted at these paths. + // Keep them stable for this process; persisted path edits apply on restart. + config + .paths + .instances_dir + .clone_from(¤t.paths.instances_dir); + config.paths.meta_dir.clone_from(¤t.paths.meta_dir); + config.ui.image_protocol = current.ui.image_protocol; + changed + }; + *self + .0 + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = config; + Ok(restart_required) + } +} + +pub static SETTINGS: LazyLock = LazyLock::new(|| { + ConfigStore(RwLock::new({ + let path = ensure_config_exists(); + load_config(&path).unwrap_or_else(|e| { + tracing::error!("Config load failed, using defaults: {}", e); + Config { + general: settings::General::default(), + paths: settings::Paths::default(), + defaults: settings::Defaults::default(), + ui: settings::Ui::default(), + content: settings::Content::default(), + } + }) + })) }); #[cfg(test)] diff --git a/src/config/settings.rs b/src/config/settings.rs index b38d932..807523a 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -6,9 +6,9 @@ use std::path::PathBuf; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ImageProtocol { Halfblocks, @@ -18,10 +18,10 @@ pub enum ImageProtocol { Iterm2, } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct General {} -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Content { #[serde(default = "default_true")] pub ask_on_provider_conflict: bool, @@ -119,7 +119,7 @@ impl Content { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Paths { #[serde(default = "default_instances_dir")] pub instances_dir: String, @@ -185,7 +185,7 @@ impl Paths { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Defaults { #[serde(default = "default_memory_min")] pub memory_min: String, @@ -209,7 +209,7 @@ impl Default for Defaults { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] // timing knobs for the error toast animation: show for 5s, start sliding at 3.5s, // fly off screen over 300ms. tweak these if the toasts feel too fast or slow. pub struct Ui { @@ -250,7 +250,7 @@ impl Default for Ui { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { #[serde(default)] pub general: General, diff --git a/src/config/theme.rs b/src/config/theme.rs index 27fea15..3629844 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -6,7 +6,7 @@ // config/theme/ directory or by absolute path. use std::path::Path; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock, RwLock}; use ratatui::style::Color; use ratatui::widgets::BorderType; @@ -34,7 +34,7 @@ impl BorderStyle { } } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ThemeOverrides { pub accent: Option, pub accent_dim: Option, @@ -53,7 +53,7 @@ pub struct ThemeOverrides { pub background: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThemeConfig { #[serde(default)] pub border_style: BorderStyle, @@ -167,12 +167,132 @@ fn load_base_theme(name: &str) -> Box { resolve_theme(name) } -static THEME_CONFIG: LazyLock = LazyLock::new(load_theme_config); +pub struct ThemeStore(RwLock>); -pub static THEME: LazyLock> = LazyLock::new(|| resolve_app_theme(&THEME_CONFIG)); +impl ThemeStore { + pub fn as_ref(&self) -> Arc { + self.0 + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn set(&self, theme: Box) { + *self + .0 + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::from(theme); + } +} + +pub struct BorderStyleStore(RwLock); + +impl BorderStyleStore { + pub fn to_border_type(&self) -> BorderType { + self.current().to_border_type() + } + + pub fn current(&self) -> BorderStyle { + self.0 + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn set(&self, style: BorderStyle) { + *self + .0 + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = style; + } +} + +static THEME_CONFIG: LazyLock> = + LazyLock::new(|| RwLock::new(load_theme_config())); + +pub static THEME: LazyLock = LazyLock::new(|| { + let config = THEME_CONFIG + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + ThemeStore(RwLock::new(Arc::from(resolve_app_theme(&config)))) +}); -pub static BORDER_STYLE: LazyLock = - LazyLock::new(|| THEME_CONFIG.border_style.clone()); +pub static BORDER_STYLE: LazyLock = LazyLock::new(|| { + let config = THEME_CONFIG + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + BorderStyleStore(RwLock::new(config.border_style.clone())) +}); + +pub fn current_theme_config() -> ThemeConfig { + THEME_CONFIG + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +pub fn apply_theme(theme: String, border_style: BorderStyle) -> std::io::Result<()> { + validate_theme_name(&theme)?; + let mut config = current_theme_config(); + config.theme = theme; + config.border_style = border_style.clone(); + let serialized = toml::to_string_pretty(&config).map_err(std::io::Error::other)?; + crate::storage::write_atomic( + &super::get_config_path().join("theme.toml"), + serialized.as_bytes(), + )?; + THEME.set(resolve_app_theme(&config)); + BORDER_STYLE.set(border_style); + *THEME_CONFIG + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = config; + crate::feedback::request_redraw(); + Ok(()) +} + +fn validate_theme_name(name: &str) -> std::io::Result<()> { + if ratatui_themekit::available_theme_ids().contains(&name) { + return Ok(()); + } + let path = if Path::new(name).is_absolute() { + std::path::PathBuf::from(name) + } else { + let directory = super::get_config_path().join("theme"); + let direct = directory.join(name); + if direct.exists() { + direct + } else { + directory.join(format!("{name}.toml")) + } + }; + let content = std::fs::read_to_string(&path).map_err(|error| { + std::io::Error::new( + error.kind(), + format!("failed to load theme {}: {error}", path.display()), + ) + })?; + toml::from_str::(&content).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid theme {}: {error}", path.display()), + ) + })?; + Ok(()) +} + +pub fn reload_theme() -> std::io::Result<()> { + let path = super::get_config_path().join("theme.toml"); + let content = std::fs::read_to_string(path)?; + let config: ThemeConfig = toml::from_str(&content) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + THEME.set(resolve_app_theme(&config)); + BORDER_STYLE.set(config.border_style.clone()); + *THEME_CONFIG + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = config; + crate::feedback::request_redraw(); + Ok(()) +} #[cfg(test)] #[path = "tests/theme.rs"] diff --git a/src/instance/config_sync.rs b/src/instance/config_sync.rs index 3ae13b3..21b0cd7 100644 --- a/src/instance/config_sync.rs +++ b/src/instance/config_sync.rs @@ -113,7 +113,7 @@ pub fn switch_profile( meta_dir: &Path, instance_dir: &Path, ) -> Result, ConfigSyncError> { - if crate::instance::runtime::get(instance_name).is_some() { + if crate::instance::runtime::is_active(instance_name) { return Err(ConfigSyncError::InstanceRunning { instance: instance_name.to_string(), }); diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 033ed74..8406e97 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -151,8 +151,11 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { let paths = InstancePaths::new(instances_dir.join(&instance_name)); let manifest_path = paths.content_manifest(); let minecraft_dir = paths.minecraft(); - let retry_hours = crate::config::SETTINGS.content.unmatched_retry_hours; - let max_fingerprint_size_mib = crate::config::SETTINGS.content.max_fingerprint_size_mib; + let retry_hours = crate::config::SETTINGS.read().content.unmatched_retry_hours; + let max_fingerprint_size_mib = crate::config::SETTINGS + .read() + .content + .max_fingerprint_size_mib; let inventory_progress = task.handle(); let inventory_minecraft_dir = minecraft_dir.clone(); let inventory = tokio::task::spawn_blocking(move || { @@ -400,6 +403,7 @@ fn reconcile_inventory( } let provider_unchecked = ["modrinth", "curseforge"].into_iter().any(|provider| { crate::config::SETTINGS + .read() .content .discovery_provider_enabled(provider) && provider_was_not_checked(&record, provider) @@ -553,8 +557,16 @@ async fn resolve_queries( }, Vec::new(), ), - _ if !crate::config::SETTINGS.content.ask_on_provider_conflict => { - let preferred = crate::config::SETTINGS.content.preferred_provider(); + _ if !crate::config::SETTINGS + .read() + .content + .ask_on_provider_conflict => + { + let preferred = crate::config::SETTINGS + .read() + .content + .preferred_provider() + .to_owned(); if let Some(project) = candidates .iter() .find(|project| project.provider == preferred) diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index dc676e4..54dcf34 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -59,6 +59,18 @@ fn build_game_args( Ok((rendered.jvm, rendered.game)) } +fn apply_custom_resolution(game_args: &mut Vec, resolution: Option<(u32, u32)>) { + let Some((width, height)) = resolution else { + return; + }; + if !game_args.iter().any(|arg| arg == "--width") { + game_args.extend(["--width".to_owned(), width.to_string()]); + } + if !game_args.iter().any(|arg| arg == "--height") { + game_args.extend(["--height".to_owned(), height.to_string()]); + } +} + fn parse_java_major_version(text: &str) -> Option { let quoted = text .split_once('"') @@ -384,6 +396,7 @@ pub async fn build_launch_invocation( let current_features = FeatureSet { is_quick_play_singleplayer: quick_play_world.map(|_| true), + has_custom_resolution: config.resolution.map(|_| true), ..Default::default() }; let host_os_version = system::mojang_os_version(); @@ -539,6 +552,7 @@ pub async fn build_launch_invocation( .clone() .or_else(|| { crate::config::SETTINGS + .read() .paths .effective_java_path() .map(str::to_owned) @@ -560,6 +574,8 @@ pub async fn build_launch_invocation( .join(&config.game_version) .join("natives"); let version_type = merged_profile.type_.as_deref().unwrap_or("release"); + let resolution_width = config.resolution.map(|(width, _)| width.to_string()); + let resolution_height = config.resolution.map(|(_, height)| height.to_string()); let template_ctx = TemplateContext { library_directory, classpath_separator: sep, @@ -580,15 +596,30 @@ pub async fn build_launch_invocation( launcher_version: env!("CARGO_PKG_VERSION"), clientid: "0", quick_play_singleplayer: quick_play_world, + resolution_width: resolution_width.as_deref(), + resolution_height: resolution_height.as_deref(), }; - let (upstream_jvm_args, game_args) = + let (upstream_jvm_args, mut game_args) = build_game_args(&merged_profile, &rule_ctx, &template_ctx)?; - - let mut jvm_args: Vec = vec![ - format!("-Xms{}", config.memory_min.as_deref().unwrap_or("512M")), - format!("-Xmx{}", config.memory_max.as_deref().unwrap_or("2G")), - ]; + // Modern Mojang profiles include feature-gated resolution arguments. + // Older and third-party profiles may not, so add them when absent. + apply_custom_resolution(&mut game_args, config.resolution); + + let (memory_min, memory_max) = { + let settings = crate::config::SETTINGS.read(); + ( + config + .memory_min + .clone() + .unwrap_or_else(|| settings.defaults.memory_min.clone()), + config + .memory_max + .clone() + .unwrap_or_else(|| settings.defaults.memory_max.clone()), + ) + }; + let mut jvm_args: Vec = vec![format!("-Xms{memory_min}"), format!("-Xmx{memory_max}")]; jvm_args.extend(patch_jvm_args); jvm_args.extend(upstream_jvm_args); jvm_args.extend(config.jvm_args.clone()); diff --git a/src/instance/loader/forge.rs b/src/instance/loader/forge.rs index 0959912..d72a436 100644 --- a/src/instance/loader/forge.rs +++ b/src/instance/loader/forge.rs @@ -77,6 +77,7 @@ impl ModLoaderInstaller for ForgeInstaller { } else { // modern forge: run the java installer let java_path = crate::config::SETTINGS + .read() .paths .effective_java_path() .map(str::to_owned) diff --git a/src/instance/loader/neoforge.rs b/src/instance/loader/neoforge.rs index 7be7494..221128a 100644 --- a/src/instance/loader/neoforge.rs +++ b/src/instance/loader/neoforge.rs @@ -57,6 +57,7 @@ impl ModLoaderInstaller for NeoForgeInstaller { neoforge_api::download_neoforge_installer(client, loader_version, &installer_jar).await?; let java_path = crate::config::SETTINGS + .read() .paths .effective_java_path() .map(str::to_owned) diff --git a/src/instance/models.rs b/src/instance/models.rs index 5bd456b..baa8051 100644 --- a/src/instance/models.rs +++ b/src/instance/models.rs @@ -56,6 +56,25 @@ pub struct InstanceConfig { pub modpack_source: Option, } +pub fn parse_resolution(input: &str) -> Result<(u32, u32), String> { + let (width, height) = input + .trim() + .split_once(['x', 'X']) + .ok_or_else(|| "resolution must be in WxH format".to_string())?; + let width = width + .parse::() + .map_err(|_| "resolution width must be a positive integer".to_string())?; + let height = height + .parse::() + .map_err(|_| "resolution height must be a positive integer".to_string())?; + + if width == 0 || height == 0 { + return Err("resolution values must be greater than zero".to_string()); + } + + Ok((width, height)) +} + pub fn normalize_memory_value(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { diff --git a/src/instance/runtime.rs b/src/instance/runtime.rs index 5cb2a8b..e358f26 100644 --- a/src/instance/runtime.rs +++ b/src/instance/runtime.rs @@ -52,6 +52,14 @@ pub fn get(name: &str) -> Option { RUNNING.lock().ok().and_then(|map| map.get(name).cloned()) } +#[must_use] +pub fn is_active(name: &str) -> bool { + matches!( + get(name), + Some(RunState::Authenticating | RunState::Starting | RunState::Running) + ) +} + #[must_use] pub fn all() -> Vec<(String, RunState)> { RUNNING diff --git a/src/instance/tests/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs index dc7c75f..a4aa5b0 100644 --- a/src/instance/tests/launch/pipeline.rs +++ b/src/instance/tests/launch/pipeline.rs @@ -57,6 +57,8 @@ fn build_game_args_renders_upstream_arguments() { launcher_version: "test", clientid: "0", quick_play_singleplayer: None, + resolution_width: None, + resolution_height: None, version_type: "release", }; let features = FeatureSet::default(); @@ -89,6 +91,27 @@ fn build_game_args_renders_upstream_arguments() { assert_eq!(game_args, vec!["--username", "Player"]); } +#[test] +fn custom_resolution_is_added_once() { + let mut args = vec!["--username".to_owned(), "Player".to_owned()]; + apply_custom_resolution(&mut args, Some((1920, 1080))); + assert_eq!( + args, + [ + "--username", + "Player", + "--width", + "1920", + "--height", + "1080" + ] + ); + + apply_custom_resolution(&mut args, Some((1280, 720))); + assert_eq!(args.iter().filter(|arg| *arg == "--width").count(), 1); + assert_eq!(args.iter().filter(|arg| *arg == "--height").count(), 1); +} + // exercises the early-return branch of migrate_legacy_meta_if_needed. // a profile with either arguments or minecraftArguments is not legacy // and must produce Ok(None) without touching the network. covers both diff --git a/src/instance/tests/models.rs b/src/instance/tests/models.rs index 62a41c8..88eb681 100644 --- a/src/instance/tests/models.rs +++ b/src/instance/tests/models.rs @@ -84,3 +84,16 @@ fn normalize_memory_value_rejects_invalid_values() { assert_eq!(normalize_memory_value("8GB"), None); assert_eq!(normalize_memory_value("banana"), None); } + +#[test] +fn parse_resolution_accepts_common_separators() { + assert_eq!(parse_resolution("1920x1080"), Ok((1920, 1080))); + assert_eq!(parse_resolution(" 1280X720 "), Ok((1280, 720))); +} + +#[test] +fn parse_resolution_rejects_invalid_values() { + assert!(parse_resolution("1920").is_err()); + assert!(parse_resolution("0x1080").is_err()); + assert!(parse_resolution("wide x tall").is_err()); +} diff --git a/src/instance/tests/runtime.rs b/src/instance/tests/runtime.rs index 24b9ec5..fc191db 100644 --- a/src/instance/tests/runtime.rs +++ b/src/instance/tests/runtime.rs @@ -41,6 +41,14 @@ fn crashed_state_stores_exit_code() { assert_eq!(get("run_test_crash"), Some(RunState::Crashed(Some(1)))); } +#[test] +fn crashed_instances_are_not_active() { + set_state("run_test_inactive_crash", RunState::Crashed(Some(1))); + assert!(!is_active("run_test_inactive_crash")); + set_state("run_test_active_start", RunState::Starting); + assert!(is_active("run_test_active_start")); +} + #[test] fn push_and_drain_last_played() { let time = Utc::now(); diff --git a/src/launch_profile/templates.rs b/src/launch_profile/templates.rs index d3353c2..25e9fd6 100644 --- a/src/launch_profile/templates.rs +++ b/src/launch_profile/templates.rs @@ -33,6 +33,8 @@ pub struct TemplateContext<'a> { pub launcher_version: &'a str, pub clientid: &'a str, pub quick_play_singleplayer: Option<&'a str>, + pub resolution_width: Option<&'a str>, + pub resolution_height: Option<&'a str>, } pub fn substitute(input: &str, ctx: &TemplateContext) -> String { @@ -88,6 +90,8 @@ fn lookup(name: &str, ctx: &TemplateContext) -> Option { "launcher_version" => ctx.launcher_version.to_string(), "clientid" => ctx.clientid.to_string(), "quickPlaySingleplayer" => ctx.quick_play_singleplayer?.to_owned(), + "resolution_width" => ctx.resolution_width?.to_owned(), + "resolution_height" => ctx.resolution_height?.to_owned(), _ => return None, }) } diff --git a/src/launch_profile/tests/render.rs b/src/launch_profile/tests/render.rs index 3a24e52..1bb3dec 100644 --- a/src/launch_profile/tests/render.rs +++ b/src/launch_profile/tests/render.rs @@ -50,6 +50,8 @@ impl Fixture { launcher_version: "0.3.0", clientid: "0", quick_play_singleplayer: None, + resolution_width: None, + resolution_height: None, } } diff --git a/src/launch_profile/tests/templates.rs b/src/launch_profile/tests/templates.rs index ae80610..db84c30 100644 --- a/src/launch_profile/tests/templates.rs +++ b/src/launch_profile/tests/templates.rs @@ -58,6 +58,8 @@ impl Fixture { launcher_version: "0.3.0", clientid: "0", quick_play_singleplayer: None, + resolution_width: None, + resolution_height: None, } } } @@ -115,3 +117,15 @@ fn quick_play_world_is_substituted_when_present() { "New World" ); } + +#[test] +fn custom_resolution_is_substituted_when_present() { + let fx = Fixture::unix(); + let mut context = fx.ctx(); + context.resolution_width = Some("1920"); + context.resolution_height = Some("1080"); + assert_eq!( + substitute("${resolution_width}x${resolution_height}", &context), + "1920x1080" + ); +} diff --git a/src/tui/app.rs b/src/tui/app.rs index f25793b..ae2d527 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -41,7 +41,8 @@ pub struct App { pub(super) screenshots_state: widgets::screenshots_grid::ScreenshotsState, pub(super) logs_state: widgets::logs_viewer::LogsState, pub(super) account_state: widgets::account::AccountState, - pub(super) settings_state: widgets::settings::SettingsState, + pub(super) instance_settings: Option, + pub(super) global_settings: Option, pub(super) picker: ratatui_image::picker::Picker, pub(super) instance_manager: InstanceManager, pub(super) log_overlay_scroll: usize, @@ -89,6 +90,8 @@ pub enum FocusedArea { ImportPopup, ErrorPopup, ConfirmDelete, + InstanceSettings, + GlobalSettings, } impl App { @@ -115,8 +118,8 @@ impl App { } pub fn new(picker: ratatui_image::picker::Picker) -> Self { - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); let _ = std::fs::create_dir_all(&instances_dir); let _ = std::fs::create_dir_all(&meta_dir); @@ -169,7 +172,8 @@ impl App { world_quick_play_support: None, logs_state: widgets::logs_viewer::LogsState::default(), account_state: widgets::account::AccountState::default(), - settings_state: widgets::settings::SettingsState::new(manager.meta_dir.clone()), + instance_settings: None, + global_settings: None, screenshots_state: { let mut s = widgets::screenshots_grid::ScreenshotsState::default(); let font_size = picker.font_size(); diff --git a/src/tui/event.rs b/src/tui/event.rs index b1d06f9..e04ac89 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -209,6 +209,8 @@ impl App { + usize::from(self.instances_state.show_import_popup) + usize::from(self.focused == super::app::FocusedArea::OverviewExpanded) + usize::from(self.focused == super::app::FocusedArea::ConfirmDelete) + + usize::from(self.focused == super::app::FocusedArea::InstanceSettings) + + usize::from(self.focused == super::app::FocusedArea::GlobalSettings) + usize::from(self.provider_conflict.is_some()) + usize::from( self.content_update_popup @@ -221,10 +223,6 @@ impl App { &self.account_state.add_mode, widgets::account::AddMode::None )) - + usize::from(!matches!( - &self.settings_state.add_mode, - widgets::settings::AddMode::None - )) + [ &self.mods_discovery_state, &self.resource_packs_discovery_state, @@ -505,7 +503,10 @@ impl App { } fn ensure_provider_conflict_popup(&mut self) { - if !crate::config::SETTINGS.content.ask_on_provider_conflict + if !crate::config::SETTINGS + .read() + .content + .ask_on_provider_conflict || self.focused != super::app::FocusedArea::Content || self.provider_conflict.is_some() { @@ -569,7 +570,7 @@ impl App { fn spawn_create(&self, params: new_instance::WizardParams) { let instances_dir = self.instance_manager.instances_dir.clone(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); let pending_instances = PENDING_INSTANCES.clone(); tokio::spawn(async move { @@ -605,9 +606,64 @@ impl App { }); } + pub(super) fn spawn_instance_settings_update( + &self, + previous: crate::instance::InstanceConfig, + mut updated: crate::instance::InstanceConfig, + desktop: bool, + ) { + let instances_dir = self.instance_manager.instances_dir.clone(); + let meta_dir = self.instance_manager.meta_dir.clone(); + let pending_instances = PENDING_INSTANCES.clone(); + + tokio::spawn(async move { + progress::set_action(format!("Updating instance '{}'...", updated.name)); + progress::set_sub_action(format!("{} {}", updated.game_version, updated.loader)); + let manager = InstanceManager::new(&instances_dir, &meta_dir); + updated = match apply_instance_settings_update(&manager, &previous, updated).await { + Ok(updated) => updated, + Err(error) => { + progress::clear(); + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!("Failed to update instance '{}': {error}", previous.name), + pushed_at: std::time::Instant::now(), + }); + return; + } + }; + + let shortcut_result = if desktop { + crate::instance::desktop::create(&updated).map(|_| ()) + } else { + crate::instance::desktop::remove(&updated.name) + }; + if let Err(error) = shortcut_result { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!("Instance saved, but shortcut update failed: {error}"), + pushed_at: std::time::Instant::now(), + }); + } + if let Ok(mut pending) = pending_instances.lock() { + pending.push(updated); + } + progress::clear(); + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::INFO, + message: format!("Updated instance '{}'", previous.name), + pushed_at: std::time::Instant::now(), + }); + crate::feedback::request_redraw(); + }); + } + fn spawn_import(&self, result: import_modpack::ImportResult) { let instances_dir = self.instance_manager.instances_dir.clone(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); let pending_instances = PENDING_INSTANCES.clone(); tokio::spawn(async move { @@ -689,8 +745,38 @@ impl App { } fn reload_edited_config(&mut self, path: &std::path::Path) { - if path.file_name().and_then(|n| n.to_str()) != Some("instance.json") { - return; + match path.file_name().and_then(|name| name.to_str()) { + Some("config.toml") => { + match crate::config::SETTINGS.reload() { + Ok(true) => error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::INFO, + message: "Path and image protocol changes apply after restart".to_owned(), + pushed_at: std::time::Instant::now(), + }), + Ok(false) => {} + Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!("Failed to reload config.toml: {error}"), + pushed_at: std::time::Instant::now(), + }), + } + return; + } + Some("theme.toml") => { + if let Err(error) = crate::config::theme::reload_theme() { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!("Failed to reload theme.toml: {error}"), + pushed_at: std::time::Instant::now(), + }); + } + return; + } + Some("instance.json") => {} + _ => return, } let Some(name) = path @@ -776,7 +862,7 @@ impl App { match error_buffer::peek_error() { Some(event) if event.pushed_at.elapsed().as_millis() - >= SETTINGS.ui.error_auto_dismiss_ms as u128 => + >= SETTINGS.read().ui.error_auto_dismiss_ms as u128 => { let _ = error_buffer::pop_error(); } @@ -790,7 +876,17 @@ impl App { for config in pending.drain(..) { self.forget_instance_content(&config.name); widgets::instances::spawn_modpack_update_check(&config); - self.instances_state.add_instance(config); + if self + .instances_state + .instances + .iter() + .any(|instance| instance.name == config.name) + { + let name = config.name.clone(); + self.instances_state.replace_instance(&name, config); + } else { + self.instances_state.add_instance(config); + } } } } @@ -840,6 +936,42 @@ impl App { } } +async fn apply_instance_settings_update( + manager: &InstanceManager, + previous: &crate::instance::InstanceConfig, + mut updated: crate::instance::InstanceConfig, +) -> color_eyre::Result { + manager.repair_runtime_cache(&updated).await?; + + let profile_changed = previous.config_sync_profile != updated.config_sync_profile; + if profile_changed { + updated.config_sync_profile = crate::instance::config_sync::switch_profile( + &previous.name, + previous.config_sync_profile.as_deref(), + updated.config_sync_profile.as_deref(), + &manager.meta_dir, + &manager.instances_dir.join(&previous.name), + )?; + } + + if let Err(error) = manager.save(&updated) { + if profile_changed + && let Err(rollback_error) = crate::instance::config_sync::switch_profile( + &previous.name, + updated.config_sync_profile.as_deref(), + previous.config_sync_profile.as_deref(), + &manager.meta_dir, + &manager.instances_dir.join(&previous.name), + ) + { + tracing::error!("Failed to roll back config profile: {rollback_error}"); + } + return Err(error.into()); + } + + Ok(updated) +} + fn mark_terminal_images(buffer: &mut Buffer, alternate: bool) { // toggling an invisible suffix lets the normal cell diff redraw exposed // images before later popup cells, without clearing or repainting the screen diff --git a/src/tui/input.rs b/src/tui/input.rs index f3be45b..a8fd94b 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -552,101 +552,185 @@ impl App { return Ok(()); } - if self.focused == FocusedArea::Settings { - let editing_profile = matches!( - &self.settings_state.add_mode, - widgets::settings::AddMode::ProfileName(_) - ); - match widgets::settings::handle_key( - &key_event, - &mut self.settings_state, - self.instances_state.selected_instance(), - &self.instance_manager.instances_dir, - ) { - widgets::settings::SettingsAction::EditInstance(path) - | widgets::settings::SettingsAction::EditGlobal(path) => { + if self.focused == FocusedArea::GlobalSettings { + let action = self + .global_settings + .as_mut() + .map(|state| state.handle_key(&key_event)) + .unwrap_or(widgets::popups::global_settings::Action::Close); + match action { + widgets::popups::global_settings::Action::None => {} + widgets::popups::global_settings::Action::Close => { + self.global_settings = None; + self.focused = self.pre_overlay_focused; + } + widgets::popups::global_settings::Action::OpenRaw(path) => { self.pending_editor = Some(path); - return Ok(()); + self.global_settings = None; + self.focused = self.pre_overlay_focused; + } + widgets::popups::global_settings::Action::Save(config, theme, border) => { + let result = crate::config::SETTINGS + .save_launcher_settings(*config) + .and_then(|()| crate::config::theme::apply_theme(theme, border)); + match result { + Ok(()) => { + self.global_settings = None; + self.focused = self.pre_overlay_focused; + } + Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: error.to_string(), + pushed_at: std::time::Instant::now(), + }), + } + } + } + return Ok(()); + } + + if self.focused == FocusedArea::InstanceSettings { + let action = self + .instance_settings + .as_mut() + .map(|state| state.handle_key(&key_event)) + .unwrap_or(widgets::popups::instance_settings::Action::Close); + match action { + widgets::popups::instance_settings::Action::None => {} + widgets::popups::instance_settings::Action::Close => { + self.instance_settings = None; + self.focused = self.pre_overlay_focused; } - widgets::settings::SettingsAction::ToggleDesktop => { - if let Some(inst) = self.instances_state.selected_instance() { - let name = inst.name.clone(); - match crate::instance::desktop::toggle(inst) { - Ok(true) => { + widgets::popups::instance_settings::Action::OpenRaw => { + if let Some(instance) = self.instances_state.selected_instance() { + self.pending_editor = Some( + self.instance_manager + .instances_dir + .join(&instance.name) + .join("instance.json"), + ); + } + self.instance_settings = None; + self.focused = self.pre_overlay_focused; + } + widgets::popups::instance_settings::Action::DeleteProfile(profile) => { + match self.delete_config_profile(&profile) { + Ok(()) => { + if let Some(state) = self.instance_settings.as_mut() { + state.profile_deleted(&profile); + } + } + Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: error.to_string(), + pushed_at: std::time::Instant::now(), + }), + } + } + widgets::popups::instance_settings::Action::Save(updated, desktop) => { + let mut updated = *updated; + if let Some(previous) = self.instances_state.selected_instance().cloned() { + let structural_change = previous.game_version != updated.game_version + || previous.loader != updated.loader + || previous.loader_version != updated.loader_version; + if structural_change { + if crate::instance::runtime::is_active(&previous.name) { error_buffer::push_error(error_buffer::ErrorEvent { id: 0, - level: tracing::Level::INFO, - message: format!("Desktop shortcut created for '{name}'"), + level: tracing::Level::ERROR, + message: "Stop the instance before changing its runtime" + .to_owned(), pushed_at: std::time::Instant::now(), }); + return Ok(()); + } + self.spawn_instance_settings_update(previous, updated, desktop); + self.instance_settings = None; + self.focused = self.pre_overlay_focused; + return Ok(()); + } + if previous.config_sync_profile != updated.config_sync_profile { + let instance_dir = + self.instance_manager.instances_dir.join(&previous.name); + match crate::instance::config_sync::switch_profile( + &previous.name, + previous.config_sync_profile.as_deref(), + updated.config_sync_profile.as_deref(), + &self.instance_manager.meta_dir, + &instance_dir, + ) { + Ok(profile) => updated.config_sync_profile = profile, + Err(error) => { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: error.to_string(), + pushed_at: std::time::Instant::now(), + }); + return Ok(()); + } } - Ok(false) => { + } + match self.instance_manager.save(&updated) { + Ok(()) => { + let shortcut_result = if desktop { + crate::instance::desktop::create(&updated).map(|_| ()) + } else { + crate::instance::desktop::remove(&updated.name) + }; + if let Err(error) = shortcut_result { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!( + "Instance saved, but shortcut update failed: {error}" + ), + pushed_at: std::time::Instant::now(), + }); + } + self.instances_state + .replace_instance(&previous.name, updated); error_buffer::push_error(error_buffer::ErrorEvent { id: 0, level: tracing::Level::INFO, - message: format!("Desktop shortcut removed for '{name}'"), + message: format!("Updated instance '{}'", previous.name), pushed_at: std::time::Instant::now(), }); + self.instance_settings = None; + self.focused = self.pre_overlay_focused; } - Err(e) => { - tracing::error!("Failed to toggle desktop shortcut: {}", e); - } - } - } - return Ok(()); - } - widgets::settings::SettingsAction::SelectProfile(profile) => { - if let Some(inst) = self.instances_state.selected_instance().cloned() { - let instance_dir = self.instance_manager.instances_dir.join(&inst.name); - match crate::instance::config_sync::switch_profile( - &inst.name, - inst.config_sync_profile.as_deref(), - profile.as_deref(), - &self.instance_manager.meta_dir, - &instance_dir, - ) { - Ok(selected) => { - let mut updated = inst.clone(); - updated.config_sync_profile = selected; - if let Err(e) = self.instance_manager.save(&updated) { - tracing::error!("Failed to save config profile: {}", e); - } else { - self.instances_state.replace_instance(&inst.name, updated); + Err(error) => { + if previous.config_sync_profile != updated.config_sync_profile { + let instance_dir = + self.instance_manager.instances_dir.join(&previous.name); + if let Err(rollback_error) = + crate::instance::config_sync::switch_profile( + &previous.name, + updated.config_sync_profile.as_deref(), + previous.config_sync_profile.as_deref(), + &self.instance_manager.meta_dir, + &instance_dir, + ) + { + tracing::error!( + "Failed to roll back config profile: {rollback_error}" + ); + } } - } - Err(e) => { error_buffer::push_error(error_buffer::ErrorEvent { id: 0, level: tracing::Level::ERROR, - message: e.to_string(), + message: error.to_string(), pushed_at: std::time::Instant::now(), }); } } } - return Ok(()); - } - widgets::settings::SettingsAction::ConfirmDeleteProfile(profile) => { - confirm_popup::set_pending(confirm_popup::ConfirmTarget::ConfigProfile { - profile, - }); - self.focused = FocusedArea::ConfirmDelete; - return Ok(()); } - widgets::settings::SettingsAction::Error(message) => { - error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message, - pushed_at: std::time::Instant::now(), - }); - return Ok(()); - } - widgets::settings::SettingsAction::None => {} - } - if editing_profile { - return Ok(()); } + return Ok(()); } match self.focused { @@ -732,6 +816,22 @@ impl App { KeyCode::Char('C') => self.focused = FocusedArea::Content, KeyCode::Char('A') => self.focused = FocusedArea::Account, KeyCode::Char('S') => self.focused = FocusedArea::Settings, + KeyCode::Char('E') => { + if let Some(instance) = self.instances_state.selected_instance() { + self.pre_overlay_focused = self.focused; + self.instance_settings = + Some(widgets::popups::instance_settings::State::new( + instance, + &self.instance_manager.meta_dir, + )); + self.focused = FocusedArea::InstanceSettings; + } + } + KeyCode::Char('G') => { + self.pre_overlay_focused = self.focused; + self.global_settings = Some(widgets::popups::global_settings::State::new()); + self.focused = FocusedArea::GlobalSettings; + } KeyCode::Char('O') => { self.pre_overlay_focused = self.focused; self.focused = FocusedArea::OverviewExpanded; @@ -1783,6 +1883,15 @@ impl App { fn delete_config_profile(&mut self, profile: &str) -> color_eyre::Result<()> { let instances = self.instance_manager.load_all(); + if let Some(instance) = instances.iter().find(|instance| { + instance.config_sync_profile.as_deref() == Some(profile) + && crate::instance::runtime::is_active(&instance.name) + }) { + return Err(color_eyre::eyre::eyre!( + "Stop '{}' before deleting its active config profile", + instance.name + )); + } for instance in instances .into_iter() .filter(|instance| instance.config_sync_profile.as_deref() == Some(profile)) @@ -1802,7 +1911,6 @@ impl App { } crate::instance::config_sync::delete_profile(&self.instance_manager.meta_dir, profile)?; - self.settings_state.remove_profile(profile); Ok(()) } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ac1bb29..7907b7a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -49,7 +49,7 @@ pub async fn show() -> color_eyre::Result<()> { let mut picker = ratatui_image::picker::Picker::from_query_stdio() .unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks()); let detected_protocol = picker.protocol_type(); - let requested_protocol = match crate::config::SETTINGS.ui.image_protocol { + let requested_protocol = match crate::config::SETTINGS.read().ui.image_protocol { crate::config::settings::ImageProtocol::Halfblocks | crate::config::settings::ImageProtocol::Quadrants => { ratatui_image::picker::ProtocolType::Halfblocks @@ -106,8 +106,8 @@ async fn run_layout_migration_screen( use std::sync::{Arc, Mutex}; use std::time::Duration; - let instances_dir = crate::config::SETTINGS.paths.resolve_instances_dir(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); if !crate::layout_migration::is_needed(&instances_dir, &meta_dir) { crate::layout_migration::initialize_new_layout(&meta_dir)?; return Ok(MigrationScreenOutcome::NotNeeded); diff --git a/src/tui/render.rs b/src/tui/render.rs index 44db0a8..e0f213e 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -135,9 +135,7 @@ impl App { frame, bottom_chunks[1], self.focused, - &mut self.settings_state, self.instances_state.selected_instance(), - &self.instance_manager.instances_dir, ); widgets::status::render( frame, @@ -150,6 +148,20 @@ impl App { self.render_log_overlay(frame); } + if self.focused == FocusedArea::InstanceSettings + && let Some(state) = self.instance_settings.as_ref() + { + let area = widgets::popups::instance_settings::popup_rect(frame.area()); + widgets::popups::instance_settings::render(frame, area, state); + } + + if self.focused == FocusedArea::GlobalSettings + && let Some(state) = self.global_settings.as_ref() + { + let area = widgets::popups::instance_settings::popup_rect(frame.area()); + widgets::popups::global_settings::render(frame, area, state); + } + // error toasts stack from the top, each one below the previous let all_errors = error_buffer::peek_all_errors(); self.sync_error_effects(&all_errors); @@ -343,9 +355,9 @@ impl App { use crate::config::theme::THEME; let theme = THEME.as_ref(); let bg = theme.background(); - let fly_out_ms = SETTINGS.ui.error_fly_out_ms as u128; - let fly_start_ms = SETTINGS.ui.error_auto_dismiss_ms as u128 - - fly_out_ms.min(SETTINGS.ui.error_auto_dismiss_ms as u128); + let fly_out_ms = SETTINGS.read().ui.error_fly_out_ms as u128; + let fly_start_ms = SETTINGS.read().ui.error_auto_dismiss_ms as u128 + - fly_out_ms.min(SETTINGS.read().ui.error_auto_dismiss_ms as u128); if elapsed_ms >= fly_start_ms { let entry = self diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index 377157c..12ffa58 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -57,6 +57,180 @@ fn completed_background_instance_is_drained_into_the_ui() { assert_eq!(ui.app.mods_state.loaded_for.as_deref(), Some("Pending")); } +#[test] +fn structural_settings_update_repairs_runtime_before_persisting() { + use sha1::{Digest, Sha1}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn sha1(bytes: &[u8]) -> String { + format!("{:x}", Sha1::digest(bytes)) + } + + let _guard = crate::tests::TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let server = MockServer::start().await; + let client_jar = b"client"; + let library_jar = b"library"; + for (endpoint, body) in [ + ("/client.jar", client_jar.as_slice()), + ("/library.jar", library_jar.as_slice()), + ] { + Mock::given(method("GET")) + .and(path(endpoint)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(body.to_vec())) + .expect(1) + .mount(&server) + .await; + } + Mock::given(method("GET")) + .and(path("/assets.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "objects": {} + }))) + .expect(1) + .mount(&server) + .await; + + let temp = tempfile::tempdir().unwrap(); + let instances_dir = temp.path().join("instances"); + let meta_dir = temp.path().join("meta"); + let instance_dir = instances_dir.join("Migrating"); + std::fs::create_dir_all(&instance_dir).unwrap(); + let manager = InstanceManager::new(&instances_dir, &meta_dir); + let mut previous = crate::instance::InstanceConfig { + name: "Migrating".to_owned(), + game_version: "1.20.1".to_owned(), + loader: crate::instance::ModLoader::Vanilla, + loader_version: None, + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + }; + manager.save(&previous).unwrap(); + + let metadata = crate::storage::MetadataPaths::new(&meta_dir); + let version_dir = metadata.versions().join("1.21.2"); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::write( + version_dir.join("meta.json"), + serde_json::to_vec(&serde_json::json!({ + "id": "1.21.2", + "mainClass": "net.minecraft.client.main.Main", + "assetIndex": { + "id": "1.21.2", + "url": format!("{}/assets.json", server.uri()), + "sha1": "unused" + }, + "downloads": { + "client": { + "url": format!("{}/client.jar", server.uri()), + "sha1": sha1(client_jar), + "size": client_jar.len() + } + }, + "libraries": [{ + "name": "example:test:1", + "downloads": { + "artifact": { + "url": format!("{}/library.jar", server.uri()), + "path": "example/test/1/test-1.jar", + "sha1": sha1(library_jar), + "size": library_jar.len() + } + } + }], + "javaVersion": { "majorVersion": 21 } + })) + .unwrap(), + ) + .unwrap(); + + let mut updated = previous.clone(); + updated.game_version = "1.21.2".to_owned(); + updated.memory_max = Some("4G".to_owned()); + let applied = apply_instance_settings_update(&manager, &previous, updated) + .await + .unwrap(); + + assert_eq!(applied.game_version, "1.21.2"); + previous = manager.load_one("Migrating").unwrap(); + assert_eq!(previous.game_version, "1.21.2"); + assert_eq!(previous.memory_max.as_deref(), Some("4G")); + assert_eq!( + std::fs::read(version_dir.join("1.21.2.jar")).unwrap(), + client_jar + ); + assert_eq!( + std::fs::read(metadata.libraries().join("example/test/1/test-1.jar")).unwrap(), + library_jar + ); + assert!(metadata.assets().join("indexes/1.21.2.json").exists()); + crate::feedback::progress::clear(); + }); +} + +#[test] +fn failed_structural_settings_update_keeps_previous_config() { + let _guard = crate::tests::TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let temp = tempfile::tempdir().unwrap(); + let instances_dir = temp.path().join("instances"); + let meta_dir = temp.path().join("meta"); + std::fs::create_dir_all(instances_dir.join("Stable")).unwrap(); + let manager = InstanceManager::new(&instances_dir, &meta_dir); + let previous = crate::instance::InstanceConfig { + name: "Stable".to_owned(), + game_version: "1.20.1".to_owned(), + loader: crate::instance::ModLoader::Vanilla, + loader_version: None, + created: chrono::Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + }; + manager.save(&previous).unwrap(); + + let metadata = crate::storage::MetadataPaths::new(&meta_dir); + let version_dir = metadata.versions().join("missing-runtime"); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::write(version_dir.join("meta.json"), b"not json").unwrap(); + let mut updated = previous.clone(); + updated.game_version = "missing-runtime".to_owned(); + + assert!( + apply_instance_settings_update(&manager, &previous, updated) + .await + .is_err() + ); + assert_eq!(manager.load_one("Stable").unwrap().game_version, "1.20.1"); + crate::feedback::progress::clear(); + }); +} + #[test] fn editor_kind_is_detected_from_the_executable_name() { assert!(editor_runs_in_terminal("/usr/bin/nvim")); diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index d9d82d4..524a7ef 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -54,9 +54,6 @@ fn escape_returns_through_nested_sections() { assert_eq!(ui.app.focused, FocusedArea::Instances); ui.app.focused = FocusedArea::Settings; - ui.key(KeyCode::Char('a')); - ui.key(KeyCode::Esc); - assert_eq!(ui.app.focused, FocusedArea::Settings); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Instances); @@ -574,19 +571,37 @@ fn confirmed_account_delete_updates_the_account_panel() { } #[test] -fn settings_profile_can_be_created_from_key_events() { +fn settings_popups_open_from_global_key_events() { let mut ui = UiHarness::new(); + ui.add_instance("settings-test"); ui.app.focused = FocusedArea::Settings; - ui.key(KeyCode::Char('a')); - for character in "qConfig".chars() { - ui.key(KeyCode::Char(character)); - } + ui.key(KeyCode::Char('E')); + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); + ui.draw(); + assert!(ui.screen().contains("Instance settings: settings-test")); + assert!(ui.screen().contains("Game version")); + assert!(ui.screen().contains("Desktop shortcut")); + ui.key(KeyCode::Down); ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("Select loader")); + assert!(ui.screen().contains("Fabric")); + ui.key(KeyCode::Esc); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Settings); - assert!(!ui.app.exit); + ui.key(KeyCode::Char('G')); + assert_eq!(ui.app.focused, FocusedArea::GlobalSettings); + ui.draw(); + assert!(ui.screen().contains("Launcher settings")); + assert!(ui.screen().contains("Default memory max")); + ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("Select theme")); + ui.key(KeyCode::Esc); + ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Settings); - assert_eq!(ui.app.settings_state.profiles, ["qConfig"]); } #[test] diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 227914c..587dfad 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -82,7 +82,8 @@ impl UiHarness { }, logs_state: widgets::logs_viewer::LogsState::default(), account_state, - settings_state: widgets::settings::SettingsState::new(meta_dir), + instance_settings: None, + global_settings: None, picker, instance_manager, log_overlay_scroll: 0, diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap index cc90885..c30f562 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__empty_app_renders_the_complete_frame.snap @@ -28,7 +28,7 @@ expression: ui.screen() "│ ││ │" "│ │╰[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" -"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││No accounts. ││No instance selected. ││Ready │" "│ ││ ││ ││ │" "│ ││ ││ ││ │" "╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap index bf31793..454390f 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap @@ -28,7 +28,7 @@ expression: ui.screen() "│ ││ │" "│ │╰──────────────────────────────────────────────────────────────────────────────╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" -"│ ││No accounts. ││▸ instance default ││Ready │" -"│ ││ ││ ││ │" -"│ ││ ││ ││ │" +"│ ││No accounts. ││Version 1.21.1 / Fabric ││Ready │" +"│ ││ ││Runtime 512M-2G, auto java ││ │" +"│ ││ ││Profile instance default / desk no││ │" "╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap index 02a964c..313e4de 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__provider_conflict_renders_the_complete_frame.snap @@ -28,7 +28,7 @@ expression: ui.screen() "│ ││ │" "│ │╰[l] launch [⏎] content [Shift+⏎] open dir [Esc] kill [a] add [m] modpacks╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" -"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││No accounts. ││No instance selected. ││Ready │" "│ ││ ││ ││ │" "│ ││ ││ ││ │" "╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/tests/widgets/popups/error/area.rs b/src/tui/tests/widgets/popups/error/area.rs index dd40919..494b31c 100644 --- a/src/tui/tests/widgets/popups/error/area.rs +++ b/src/tui/tests/widgets/popups/error/area.rs @@ -11,7 +11,7 @@ fn frame() -> Rect { #[test] fn returns_none_after_dismiss_timeout() { - let past_dismiss = SETTINGS.ui.error_auto_dismiss_ms as u128 + 1; + let past_dismiss = SETTINGS.read().ui.error_auto_dismiss_ms as u128 + 1; assert!(popup_area(frame(), "msg", 0, past_dismiss).is_none()); } diff --git a/src/tui/tests/widgets/settings.rs b/src/tui/tests/widgets/settings.rs deleted file mode 100644 index 4bac7f5..0000000 --- a/src/tui/tests/widgets/settings.rs +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Constantin Bauer -// SPDX-License-Identifier: GPL-3.0-only - -use super::*; - -#[test] -fn removing_selected_last_profile_clamps_selection() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = SettingsState::new(tmp.path().to_path_buf()); - state.profiles = vec!["first".to_string(), "second".to_string()]; - state.active_profile = Some("second".to_string()); - state.list_state.selected = Some(2); - - state.remove_profile("second"); - - assert_eq!(state.profiles, vec!["first"]); - assert_eq!(state.active_profile, None); - assert_eq!(state.list_state.selected, Some(1)); -} diff --git a/src/tui/widgets/content/discovery.rs b/src/tui/widgets/content/discovery.rs index 86f7fd4..e34c9c2 100644 --- a/src/tui/widgets/content/discovery.rs +++ b/src/tui/widgets/content/discovery.rs @@ -138,6 +138,7 @@ pub(crate) fn spawn_provider_search( search_provider( registry.get("modrinth"), crate::config::SETTINGS + .read() .content .discovery_provider_enabled("modrinth"), &target, @@ -148,6 +149,7 @@ pub(crate) fn spawn_provider_search( search_provider( registry.get("curseforge"), crate::config::SETTINGS + .read() .content .discovery_provider_enabled("curseforge"), &target, @@ -177,7 +179,7 @@ pub(crate) fn spawn_provider_search( } let mut merged = merge_provider_results( pages, - crate::config::SETTINGS.content.preferred_provider(), + crate::config::SETTINGS.read().content.preferred_provider(), known_projects, ); refresh_source_installed_versions(&mut merged.sources, &target); @@ -795,7 +797,11 @@ impl DiscoveryState { installed_path: Option, target_world: Option<(String, PathBuf)>, ) -> Option { - let preferred = crate::config::SETTINGS.content.preferred_provider(); + let preferred = crate::config::SETTINGS + .read() + .content + .preferred_provider() + .to_owned(); sources.sort_by_key(|source| source.provider != preferred); let source = sources.first()?.clone(); self.next_action_request_id = self.next_action_request_id.wrapping_add(1); diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index c9b6e70..7659277 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -542,7 +542,7 @@ impl ContentListState { let use_image_protocol = picker.protocol_type() != ratatui_image::picker::ProtocolType::Halfblocks; - let use_quadrants = crate::config::SETTINGS.ui.image_protocol + let use_quadrants = crate::config::SETTINGS.read().ui.image_protocol == crate::config::settings::ImageProtocol::Quadrants; if !use_image_protocol { self.image_protocols.clear(); diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 87ba426..8082434 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -504,7 +504,10 @@ pub fn render( } let loading_text = format!( "Searching {}...", - crate::config::SETTINGS.content.discovery_provider_label() + crate::config::SETTINGS + .read() + .content + .discovery_provider_label() ); render_discovery( frame, @@ -671,7 +674,10 @@ fn render_downloadable( if mode == ContentMode::Discover { let loading_text = format!( "Searching {}...", - crate::config::SETTINGS.content.discovery_provider_label() + crate::config::SETTINGS + .read() + .content + .discovery_provider_label() ); render_discovery( frame, diff --git a/src/tui/widgets/logs_viewer.rs b/src/tui/widgets/logs_viewer.rs index ab64426..229b8a1 100644 --- a/src/tui/widgets/logs_viewer.rs +++ b/src/tui/widgets/logs_viewer.rs @@ -509,33 +509,34 @@ fn render_list(frame: &mut Frame, area: Rect, state: &mut LogsState, is_focused: }) .collect(); let search = &state.search; + let success = theme.success(); + let accent = theme.accent(); + let text = theme.text(); + let background = theme.background(); + let stripe = theme.stripe(); let builder = ListBuilder::new(move |context| { let (name, is_live) = &entries_snapshot[context.index]; let show_selected = list_focused && context.is_selected; let style = if *is_live && show_selected { - Style::default() - .fg(theme.success()) - .add_modifier(Modifier::BOLD) + Style::default().fg(success).add_modifier(Modifier::BOLD) } else if *is_live { - Style::default().fg(theme.success()) + Style::default().fg(success) } else if show_selected { - Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD) + Style::default().fg(accent).add_modifier(Modifier::BOLD) } else { - Style::default().fg(theme.text()) + Style::default().fg(text) }; let bg = if context.index % 2 == 0 { - theme.background() + background } else { - theme.stripe() + stripe }; let selector = if show_selected { - Span::styled("\u{258c} ", Style::default().fg(theme.accent())) + Span::styled("\u{258c} ", Style::default().fg(accent)) } else { Span::raw(" ") }; diff --git a/src/tui/widgets/markdown.rs b/src/tui/widgets/markdown.rs index 47f043a..001c08a 100644 --- a/src/tui/widgets/markdown.rs +++ b/src/tui/widgets/markdown.rs @@ -1651,7 +1651,7 @@ fn render_image( width: area.width, height: full_height, protocol: picker.protocol_type(), - mode: crate::config::SETTINGS.ui.image_protocol, + mode: crate::config::SETTINGS.read().ui.image_protocol, }; if image .prepared diff --git a/src/tui/widgets/popups/error.rs b/src/tui/widgets/popups/error.rs index 33e0283..b6e1a32 100644 --- a/src/tui/widgets/popups/error.rs +++ b/src/tui/widgets/popups/error.rs @@ -74,7 +74,7 @@ pub fn popup_area(frame_area: Rect, message: &str, base_y: u16, elapsed_ms: u128 const MAX_W: usize = 58; const MIN_W: usize = 22; - if elapsed_ms >= SETTINGS.ui.error_auto_dismiss_ms as u128 { + if elapsed_ms >= SETTINGS.read().ui.error_auto_dismiss_ms as u128 { return None; } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs new file mode 100644 index 0000000..0566196 --- /dev/null +++ b/src/tui/widgets/popups/global_settings.rs @@ -0,0 +1,435 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +// modal editor for launcher-wide settings. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; +use ratatui_textarea::{CursorMove, TextArea}; + +use crate::{ + config::{ + Config, + theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, + }, + instance::models::normalize_memory_value, +}; + +pub struct State { + pub config: Config, + pub theme: ThemeConfig, + selected: usize, + editing: Option>, + error: Option, + config_dirty: bool, + confirm_close: bool, + themes: Vec, + theme_picker: bool, + theme_index: usize, +} + +pub enum Action { + None, + Save(Box, String, BorderStyle), + OpenRaw(std::path::PathBuf), + Close, +} + +impl State { + pub fn new() -> Self { + let theme = crate::config::theme::current_theme_config(); + let themes = available_themes(); + let theme_index = themes + .iter() + .position(|candidate| candidate == &theme.theme) + .unwrap_or(0); + Self { + config: crate::config::SETTINGS.read().clone(), + theme, + selected: 0, + editing: None, + error: None, + config_dirty: false, + confirm_close: false, + themes, + theme_picker: false, + theme_index, + } + } + + fn value(&self, field: usize) -> String { + match field { + 0 => self.theme.theme.clone(), + 1 => format!("{:?}", self.theme.border_style).to_lowercase(), + 2 => self.config.defaults.memory_min.clone(), + 3 => self.config.defaults.memory_max.clone(), + 4 => self.config.paths.java_path.clone().unwrap_or_default(), + _ => String::new(), + } + } + + fn display_value(&self, field: usize) -> String { + if field == 4 && self.config.paths.java_path.is_none() { + "auto-detect".to_owned() + } else { + self.value(field) + } + } + + fn commit_edit(&mut self) { + let Some(editor) = self.editing.take() else { + return; + }; + let raw = editor.lines().join(""); + let value = raw.trim(); + self.error = None; + let invalid = |state: &mut Self, message: String| { + state.error = Some(message); + state.editing = Some(new_text_area(editor.lines().to_vec())); + }; + match self.selected { + 2 | 3 if normalize_memory_value(value).is_none() => invalid( + self, + "memory must be a positive number with K, M, or G".to_owned(), + ), + 2 => { + self.config.defaults.memory_min = normalize_memory_value(value).unwrap(); + self.config_dirty = true; + } + 3 => { + self.config.defaults.memory_max = normalize_memory_value(value).unwrap(); + self.config_dirty = true; + } + 4 => { + self.config.paths.java_path = (!value.is_empty()).then(|| value.to_owned()); + self.config_dirty = true; + } + _ => {} + } + } + + fn select_theme(&mut self) { + let previous = self.theme.theme.clone(); + self.theme.theme = self.themes[self.theme_index].clone(); + self.error = None; + if let Err(error) = crate::config::theme::apply_theme( + self.theme.theme.clone(), + self.theme.border_style.clone(), + ) { + self.error = Some(error.to_string()); + self.theme.theme = previous; + } + } + + fn handle_theme_picker_key(&mut self, key: &KeyEvent) { + match key.code { + KeyCode::Esc => self.theme_picker = false, + KeyCode::Char('j') | KeyCode::Down => { + self.theme_index = (self.theme_index + 1).min(self.themes.len() - 1); + } + KeyCode::Char('k') | KeyCode::Up => { + self.theme_index = self.theme_index.saturating_sub(1); + } + KeyCode::Enter => { + self.select_theme(); + if self.error.is_none() { + self.theme_picker = false; + } + } + _ => {} + } + } + + fn cycle_border(&mut self) { + let previous = self.theme.border_style.clone(); + self.theme.border_style = match self.theme.border_style { + BorderStyle::Rounded => BorderStyle::Plain, + BorderStyle::Plain => BorderStyle::Double, + BorderStyle::Double => BorderStyle::Thick, + BorderStyle::Thick => BorderStyle::Rounded, + }; + self.error = None; + if let Err(error) = crate::config::theme::apply_theme( + self.theme.theme.clone(), + self.theme.border_style.clone(), + ) { + self.error = Some(error.to_string()); + self.theme.border_style = previous; + } + } + + fn validate_before_save(&mut self) -> bool { + self.error = None; + let min = memory_kib(&self.config.defaults.memory_min); + let max = memory_kib(&self.config.defaults.memory_max); + if min.zip(max).is_some_and(|(min, max)| min > max) { + self.error = Some("minimum memory cannot exceed maximum memory".to_owned()); + } + self.error.is_none() + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + if self.theme_picker { + self.handle_theme_picker_key(key); + return Action::None; + } + if self.confirm_close { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => return Action::Close, + KeyCode::Char('n') | KeyCode::Esc => self.confirm_close = false, + _ => {} + } + return Action::None; + } + if let Some(input) = &mut self.editing { + match key.code { + KeyCode::Enter => self.commit_edit(), + KeyCode::Esc => self.editing = None, + _ => { + input.input(*key); + } + } + return Action::None; + } + match key.code { + KeyCode::Char('j') | KeyCode::Down => self.selected = (self.selected + 1).min(4), + KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), + KeyCode::Enter => match self.selected { + 0 => self.theme_picker = true, + 1 => self.cycle_border(), + field => self.editing = Some(new_text_area(vec![self.value(field)])), + }, + KeyCode::Char('s') => { + if self.validate_before_save() { + return Action::Save( + Box::new(self.config.clone()), + self.theme.theme.clone(), + self.theme.border_style.clone(), + ); + } + } + KeyCode::Char('E') if self.config_dirty => { + self.error = Some( + "save or discard launcher defaults before opening the raw file".to_owned(), + ); + } + KeyCode::Char('E') => { + let file = if self.selected <= 1 { + "theme.toml" + } else { + "config.toml" + }; + return Action::OpenRaw(crate::config::get_config_path().join(file)); + } + KeyCode::Esc if self.config_dirty => self.confirm_close = true, + KeyCode::Esc => return Action::Close, + _ => {} + } + Action::None + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +fn memory_kib(value: &str) -> Option { + let normalized = normalize_memory_value(value)?; + let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); + let number = number.parse::().ok()?; + match suffix { + "K" => Some(number), + "M" => number.checked_mul(1024), + "G" => number.checked_mul(1024 * 1024), + _ => None, + } +} + +fn available_themes() -> Vec { + let mut themes: Vec = ratatui_themekit::available_theme_ids() + .into_iter() + .map(str::to_owned) + .collect(); + let theme_dir = crate::config::get_config_path().join("theme"); + if let Ok(entries) = std::fs::read_dir(theme_dir) { + themes.extend(entries.flatten().filter_map(|entry| { + let path = entry.path(); + (path.extension().and_then(|ext| ext.to_str()) == Some("toml")) + .then(|| path.file_stem()?.to_str().map(str::to_owned)) + .flatten() + })); + } + themes.sort(); + themes.dedup(); + let current = crate::config::theme::current_theme_config().theme; + if !themes.contains(¤t) { + themes.push(current); + themes.sort(); + } + themes +} + +fn new_text_area(lines: Vec) -> TextArea<'static> { + let theme = THEME.as_ref(); + let mut editor = TextArea::new(if lines.is_empty() { + vec![String::new()] + } else { + lines + }); + editor.set_style(Style::default().fg(theme.text()).bg(theme.surface())); + editor.set_cursor_line_style(Style::default()); + editor.set_cursor_style(Style::default().fg(theme.background()).bg(theme.accent())); + editor.move_cursor(CursorMove::Bottom); + editor.move_cursor(CursorMove::End); + editor +} + +pub fn render(frame: &mut Frame, area: Rect, state: &State) { + let theme = THEME.as_ref(); + frame.render_widget(Clear, area); + let keybinds = if state.theme_picker { + super::keybind_line(&[("j/k", " move"), ("Enter", " apply"), ("Esc", " back")]) + } else { + super::keybind_line(&[ + ("j/k", " field"), + ("Enter", " edit"), + ("s", " save"), + ("E", " raw"), + ("Esc", " back"), + ]) + }; + let block = Block::default() + .title(if state.config_dirty { + " Launcher settings * " + } else { + " Launcher settings " + }) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.background())) + .title_bottom(keybinds); + let inner = block.inner(area); + frame.render_widget(block, area); + if state.theme_picker { + let mut lines = vec![Line::from(Span::styled( + "Select theme", + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + ))]; + let visible_rows = inner.height.saturating_sub(1) as usize; + let start = state + .theme_index + .saturating_sub(visible_rows.saturating_sub(1)); + for (index, name) in state + .themes + .iter() + .enumerate() + .skip(start) + .take(visible_rows) + { + let selected = index == state.theme_index; + lines.push(Line::from(vec![ + Span::styled( + if selected { "▸ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + name.clone(), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ])); + } + frame.render_widget(Paragraph::new(lines), inner); + return; + } + let labels = [ + "Theme", + "Border style", + "Default memory min", + "Default memory max", + "Java path", + ]; + let mut lines = Vec::new(); + for (index, label) in labels.iter().enumerate() { + let selected = index == state.selected; + let displayed = if selected { + state.editing.as_ref().map_or_else( + || state.display_value(index), + |_| "editing below".to_owned(), + ) + } else { + state.display_value(index) + }; + lines.push(Line::from(vec![ + Span::styled( + if selected { "▸ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{label:<20}"), + Style::default().fg(theme.text_dim()), + ), + Span::styled( + displayed, + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ])); + } + if let Some(error) = &state.error { + lines.push(Line::from(Span::styled( + error, + Style::default().fg(theme.error()), + ))); + } else if state.confirm_close { + lines.push(Line::from(Span::styled( + "Discard unsaved launcher defaults? [y] yes [n] no", + Style::default().fg(theme.warning()), + ))); + } + frame.render_widget(Paragraph::new(lines), inner); + if let Some(editor) = state.editing.as_ref() { + let editor_area = Rect { + x: inner.x, + y: inner.y.saturating_add(7), + width: inner.width, + height: 3.min(inner.height.saturating_sub(7)).max(1), + }; + let editor_block = Block::default() + .title(" Value — Enter applies ") + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent())); + let editor_inner = editor_block.inner(editor_area); + frame.render_widget(editor_block, editor_area); + frame.render_widget(editor, editor_inner); + } +} diff --git a/src/tui/widgets/popups/import_modpack/state.rs b/src/tui/widgets/popups/import_modpack/state.rs index 72b00c3..351250a 100644 --- a/src/tui/widgets/popups/import_modpack/state.rs +++ b/src/tui/widgets/popups/import_modpack/state.rs @@ -480,7 +480,7 @@ async fn resolve_version_id( ) { match modrinth::fetch_version(client, version_id).await { Ok(version) => { - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); let tmp_dir = crate::storage::MetadataPaths::new(&meta_dir).temporary(); if let Err(e) = tokio::fs::create_dir_all(&tmp_dir).await { set_error_and_back( @@ -558,7 +558,7 @@ fn start_version_download(state: &mut ImportWizardState) { tokio::spawn(async move { let client = crate::net::HttpClient::new(); - let meta_dir = crate::config::SETTINGS.paths.resolve_meta_dir(); + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); let tmp_dir = crate::storage::MetadataPaths::new(&meta_dir).temporary(); if let Err(e) = tokio::fs::create_dir_all(&tmp_dir).await { set_error_and_back( @@ -618,7 +618,7 @@ fn spawn_discovery_request_with_query( crate::tui::widgets::content::discovery::spawn_provider_search( query, crate::tui::widgets::content::discovery::DiscoveryTarget::Modpacks, - crate::config::SETTINGS.paths.resolve_meta_dir(), + crate::config::SETTINGS.read().paths.resolve_meta_dir(), request, ); } @@ -667,9 +667,10 @@ fn start_discovered_download(request: crate::tui::widgets::content::discovery::I tokio::spawn(async move { let client = crate::net::HttpClient::new(); let registry = crate::instance::content::provider::ProviderRegistry::configured(client); - let tmp_dir = - crate::storage::MetadataPaths::new(crate::config::SETTINGS.paths.resolve_meta_dir()) - .temporary(); + let tmp_dir = crate::storage::MetadataPaths::new( + crate::config::SETTINGS.read().paths.resolve_meta_dir(), + ) + .temporary(); let result: Result = async { let source = crate::instance::ProviderProject { provider: request.provider.clone(), diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs new file mode 100644 index 0000000..d1205ee --- /dev/null +++ b/src/tui/widgets/popups/instance_settings.rs @@ -0,0 +1,1090 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +// modal editor for settings belonging to the selected Minecraft instance. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; +use ratatui_textarea::{CursorMove, TextArea}; +use std::sync::{Arc, Mutex}; + +use crate::{ + config::{ + SETTINGS, + theme::{BORDER_STYLE, THEME}, + }, + instance::loader::GameVersion, + instance::models::{InstanceConfig, ModLoader, normalize_memory_value, parse_resolution}, + tui::widgets::popups::LoadState, +}; + +const FIELD_COUNT: usize = 10; +type SharedLoad = Arc>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VersionPicker { + Game, + Loader, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChoicePicker { + Loader, + Profile, +} + +enum PickerLoad { + Idle, + Loading, + Loaded, + Error(String), +} + +#[derive(Debug, Clone)] +pub struct State { + original: InstanceConfig, + pub draft: InstanceConfig, + selected: usize, + editing: Option>, + profiles: Vec, + profile_input: Option>, + confirm_profile_delete: bool, + pub desktop: bool, + original_desktop: bool, + error: Option, + confirm_close: bool, + confirm_runtime_change: bool, + picker: Option, + picker_index: usize, + picker_initialized: bool, + picker_query: String, + picker_search: bool, + show_snapshots: bool, + game_versions: SharedLoad>, + loader_versions: SharedLoad>, + choice_picker: Option, + choice_index: usize, +} + +pub enum Action { + None, + Save(Box, bool), + DeleteProfile(String), + OpenRaw, + Close, +} + +impl State { + pub fn new(instance: &InstanceConfig, meta_dir: &std::path::Path) -> Self { + Self { + original: instance.clone(), + draft: instance.clone(), + selected: 0, + editing: None, + profiles: crate::instance::config_sync::list_profiles(meta_dir).unwrap_or_default(), + profile_input: None, + confirm_profile_delete: false, + desktop: crate::instance::desktop::exists(&instance.name), + original_desktop: crate::instance::desktop::exists(&instance.name), + error: None, + confirm_close: false, + confirm_runtime_change: false, + picker: None, + picker_index: 0, + picker_initialized: false, + picker_query: String::new(), + picker_search: false, + show_snapshots: false, + game_versions: Arc::new(Mutex::new(LoadState::Idle)), + loader_versions: Arc::new(Mutex::new(LoadState::Idle)), + choice_picker: None, + choice_index: 0, + } + } + + fn dirty(&self) -> bool { + self.draft != self.original || self.desktop != self.original_desktop + } + + fn runtime_changed(&self) -> bool { + self.draft.game_version != self.original.game_version + || self.draft.loader != self.original.loader + || self.draft.loader_version != self.original.loader_version + } + + fn validate_before_save(&mut self) -> bool { + self.error = None; + if self.draft.game_version.trim().is_empty() { + self.error = Some("game version cannot be empty".to_owned()); + } else if self.draft.loader != ModLoader::Vanilla + && self + .draft + .loader_version + .as_deref() + .is_none_or(str::is_empty) + { + self.error = Some("the selected loader requires a loader version".to_owned()); + } else if let (Some(min), Some(max)) = ( + self.draft.memory_min.as_deref().and_then(memory_kib), + self.draft.memory_max.as_deref().and_then(memory_kib), + ) && min > max + { + self.error = Some("minimum memory cannot exceed maximum memory".to_owned()); + } + self.error.is_none() + } + + fn value(&self, field: usize) -> String { + match field { + 0 => self.draft.game_version.clone(), + 1 => self.draft.loader.to_string(), + 2 => self.draft.loader_version.clone().unwrap_or_default(), + 3 => self.draft.java_path.clone().unwrap_or_default(), + 4 => self.draft.memory_min.clone().unwrap_or_default(), + 5 => self.draft.memory_max.clone().unwrap_or_default(), + 6 => self.draft.jvm_args.join("\n"), + 7 => self + .draft + .resolution + .map(|(w, h)| format!("{w}x{h}")) + .unwrap_or_default(), + 8 => self + .draft + .config_sync_profile + .clone() + .unwrap_or_else(|| "instance default".to_owned()), + 9 => if self.desktop { "yes" } else { "no" }.to_owned(), + _ => String::new(), + } + } + + fn display_value(&self, field: usize) -> String { + match field { + 2 if self.draft.loader == ModLoader::Vanilla => "not applicable".to_owned(), + 3 if self.draft.java_path.is_none() => "auto-detect".to_owned(), + 4 if self.draft.memory_min.is_none() => { + format!("default ({})", SETTINGS.read().defaults.memory_min) + } + 5 if self.draft.memory_max.is_none() => { + format!("default ({})", SETTINGS.read().defaults.memory_max) + } + 6 if self.draft.jvm_args.is_empty() => "none".to_owned(), + 7 if self.draft.resolution.is_none() => "default".to_owned(), + _ => self.value(field).replace('\n', " ↵ "), + } + } + + fn begin_edit(&mut self) { + match self.selected { + 0 => self.open_game_picker(), + 1 => self.open_choice_picker(ChoicePicker::Loader), + 2 if self.draft.loader == ModLoader::Vanilla => { + self.error = Some("Vanilla does not use a loader version".to_owned()); + } + 2 => self.open_loader_picker(), + 8 => self.open_choice_picker(ChoicePicker::Profile), + 9 => self.desktop = !self.desktop, + 6 => self.editing = Some(new_text_area(self.draft.jvm_args.clone())), + field => self.editing = Some(new_text_area(vec![self.value(field)])), + } + } + + fn open_choice_picker(&mut self, picker: ChoicePicker) { + self.choice_picker = Some(picker); + self.choice_index = match picker { + ChoicePicker::Loader => loaders() + .iter() + .position(|loader| *loader == self.draft.loader) + .unwrap_or(0), + ChoicePicker::Profile => self + .draft + .config_sync_profile + .as_deref() + .and_then(|selected| self.profiles.iter().position(|profile| profile == selected)) + .map_or(0, |index| index + 1), + }; + } + + fn choice_values(&self) -> Vec { + match self.choice_picker { + Some(ChoicePicker::Loader) => loaders().iter().map(ToString::to_string).collect(), + Some(ChoicePicker::Profile) => std::iter::once("instance default".to_owned()) + .chain(self.profiles.iter().cloned()) + .collect(), + None => Vec::new(), + } + } + + fn handle_choice_key(&mut self, key: &KeyEvent) { + let count = self.choice_values().len(); + match key.code { + KeyCode::Esc => self.choice_picker = None, + KeyCode::Char('j') | KeyCode::Down if count > 0 => { + self.choice_index = (self.choice_index + 1).min(count - 1); + } + KeyCode::Char('k') | KeyCode::Up => { + self.choice_index = self.choice_index.saturating_sub(1); + } + KeyCode::Enter => { + match self.choice_picker { + Some(ChoicePicker::Loader) => { + let available = loaders(); + let loader = available[self.choice_index.min(available.len() - 1)]; + if self.draft.loader != loader { + self.draft.loader = loader; + self.draft.loader_version = None; + self.loader_versions = Arc::new(Mutex::new(LoadState::Idle)); + self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); + } + } + Some(ChoicePicker::Profile) => { + self.draft.config_sync_profile = self + .choice_index + .checked_sub(1) + .and_then(|index| self.profiles.get(index).cloned()); + } + None => {} + } + self.choice_picker = None; + } + _ => {} + } + } + + fn open_game_picker(&mut self) { + self.picker = Some(VersionPicker::Game); + self.picker_index = 0; + self.picker_initialized = false; + self.picker_query.clear(); + self.picker_search = false; + let mut load = self + .game_versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if matches!(*load, LoadState::Idle | LoadState::Error(_)) { + *load = LoadState::Loading; + let target = self.game_versions.clone(); + let loader = self.draft.loader; + tokio::spawn(async move { + let result = super::version_lists::game_versions(loader).await; + *target + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = match result { + Ok(versions) => LoadState::Loaded(versions), + Err(error) => LoadState::Error(error), + }; + crate::feedback::request_redraw(); + }); + } + } + + fn open_loader_picker(&mut self) { + self.picker = Some(VersionPicker::Loader); + self.picker_index = 0; + self.picker_initialized = false; + self.picker_query.clear(); + self.picker_search = false; + let mut load = self + .loader_versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if matches!(*load, LoadState::Idle | LoadState::Error(_)) { + *load = LoadState::Loading; + let target = self.loader_versions.clone(); + let loader = self.draft.loader; + let game_version = self.draft.game_version.clone(); + tokio::spawn(async move { + let result = super::version_lists::loader_versions(loader, &game_version).await; + *target + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = match result { + Ok(versions) => LoadState::Loaded(versions), + Err(error) => LoadState::Error(error), + }; + crate::feedback::request_redraw(); + }); + } + } + + fn visible_game_versions(&self) -> Vec { + let query = self.picker_query.to_lowercase(); + match &*self + .game_versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(versions) => versions + .iter() + .filter(|version| self.show_snapshots || version.stable) + .filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query)) + .cloned() + .collect(), + _ => Vec::new(), + } + } + + fn visible_loader_versions(&self) -> Vec { + let query = self.picker_query.to_lowercase(); + match &*self + .loader_versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(versions) => versions + .iter() + .filter(|version| query.is_empty() || version.to_lowercase().contains(&query)) + .cloned() + .collect(), + _ => Vec::new(), + } + } + + fn initialize_picker_index(&mut self) { + if self.picker_initialized { + return; + } + let index = match self.picker { + Some(VersionPicker::Game) => self + .visible_game_versions() + .iter() + .position(|version| version.id == self.draft.game_version), + Some(VersionPicker::Loader) => { + self.draft.loader_version.as_deref().and_then(|selected| { + self.visible_loader_versions() + .iter() + .position(|version| version == selected) + }) + } + None => None, + }; + if let Some(index) = index { + self.picker_index = index; + self.picker_initialized = true; + } + } + + fn handle_picker_key(&mut self, key: &KeyEvent) { + self.initialize_picker_index(); + if self.picker_search { + match key.code { + KeyCode::Esc => { + self.picker_search = false; + return; + } + KeyCode::Backspace => { + self.picker_query.pop(); + self.picker_index = 0; + return; + } + KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('k') | KeyCode::Up => {} + KeyCode::Char(character) => { + self.picker_query.push(character); + self.picker_index = 0; + return; + } + _ => {} + } + } + let count = match self.picker { + Some(VersionPicker::Game) => self.visible_game_versions().len(), + Some(VersionPicker::Loader) => self.visible_loader_versions().len(), + None => 0, + }; + match key.code { + KeyCode::Esc => self.picker = None, + KeyCode::Char('/') => self.picker_search = true, + KeyCode::Char('s') if self.picker == Some(VersionPicker::Game) => { + self.show_snapshots = !self.show_snapshots; + self.picker_index = 0; + self.picker_initialized = false; + } + KeyCode::Char('j') | KeyCode::Down if count > 0 => { + self.picker_index = (self.picker_index + 1).min(count - 1); + } + KeyCode::Char('k') | KeyCode::Up => { + self.picker_index = self.picker_index.saturating_sub(1); + } + KeyCode::Enter => match self.picker { + Some(VersionPicker::Game) => { + if let Some(version) = self.visible_game_versions().get(self.picker_index) { + if self.draft.game_version != version.id { + self.draft.game_version = version.id.clone(); + self.draft.loader_version = None; + self.loader_versions = Arc::new(Mutex::new(LoadState::Idle)); + } + self.picker = None; + } + } + Some(VersionPicker::Loader) => { + if let Some(version) = self.visible_loader_versions().get(self.picker_index) { + self.draft.loader_version = Some(version.clone()); + self.picker = None; + } + } + None => {} + }, + _ => {} + } + } + + fn commit_edit(&mut self) { + let Some(editor) = self.editing.take() else { + return; + }; + let raw = editor.lines().join("\n"); + let value = raw.trim(); + self.error = None; + let invalid = |state: &mut Self, message: String| { + state.error = Some(message); + state.editing = Some(new_text_area(editor.lines().to_vec())); + }; + match self.selected { + 0 if value.is_empty() => invalid(self, "game version cannot be empty".to_owned()), + 0 => self.draft.game_version = value.to_owned(), + 2 => self.draft.loader_version = (!value.is_empty()).then(|| value.to_owned()), + 3 => self.draft.java_path = (!value.is_empty()).then(|| value.to_owned()), + 4 | 5 if !value.is_empty() && normalize_memory_value(value).is_none() => invalid( + self, + "memory must be a positive number with K, M, or G".to_owned(), + ), + 4 => self.draft.memory_min = normalize_memory_value(value), + 5 => self.draft.memory_max = normalize_memory_value(value), + 6 => { + self.draft.jvm_args = value + .lines() + .map(str::trim) + .filter(|argument| !argument.is_empty()) + .map(str::to_owned) + .collect(); + } + 7 if value.is_empty() => self.draft.resolution = None, + 7 => match parse_resolution(value) { + Ok(resolution) => self.draft.resolution = Some(resolution), + Err(error) => invalid(self, error), + }, + _ => {} + } + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + if self.choice_picker.is_some() { + self.handle_choice_key(key); + return Action::None; + } + if self.picker.is_some() { + self.handle_picker_key(key); + return Action::None; + } + if let Some(input) = &mut self.profile_input { + match key.code { + KeyCode::Enter => { + let name = input.lines().join("").trim().to_owned(); + self.profile_input = None; + if !name.is_empty() { + match crate::instance::config_sync::validate_profile(&name) { + Ok(()) => { + if !self.profiles.contains(&name) { + self.profiles.push(name.clone()); + self.profiles.sort_unstable(); + } + self.draft.config_sync_profile = Some(name); + } + Err(error) => self.error = Some(error.to_string()), + } + } + } + KeyCode::Esc => self.profile_input = None, + _ => { + input.input(*key); + } + } + return Action::None; + } + if self.confirm_profile_delete { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + self.confirm_profile_delete = false; + if let Some(profile) = self.draft.config_sync_profile.clone() + && (self.original.config_sync_profile.as_deref() == Some(&profile) + || self.profiles.iter().any(|candidate| candidate == &profile)) + { + return Action::DeleteProfile(profile); + } + } + KeyCode::Char('n') | KeyCode::Esc => self.confirm_profile_delete = false, + _ => {} + } + return Action::None; + } + if self.confirm_close { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => return Action::Close, + KeyCode::Char('n') | KeyCode::Esc => self.confirm_close = false, + _ => {} + } + return Action::None; + } + if self.confirm_runtime_change { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + if self.validate_before_save() { + return Action::Save(Box::new(self.draft.clone()), self.desktop); + } + self.confirm_runtime_change = false; + } + KeyCode::Char('n') | KeyCode::Esc => self.confirm_runtime_change = false, + _ => {} + } + return Action::None; + } + if let Some(input) = &mut self.editing { + match key.code { + KeyCode::Enter + if self.selected == 6 && !key.modifiers.contains(KeyModifiers::CONTROL) => + { + input.input(*key); + } + KeyCode::Enter => self.commit_edit(), + KeyCode::Esc => self.editing = None, + _ => { + input.input(*key); + } + } + return Action::None; + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + self.selected = (self.selected + 1).min(FIELD_COUNT - 1) + } + KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), + KeyCode::Enter => self.begin_edit(), + KeyCode::Char('a') if self.selected == 8 => { + self.profile_input = Some(new_text_area(vec![String::new()])); + } + KeyCode::Char('d') + if self.selected == 8 && self.draft.config_sync_profile.is_some() => + { + self.confirm_profile_delete = true; + } + KeyCode::Char('s') if self.dirty() => { + if self.validate_before_save() { + if self.runtime_changed() { + self.confirm_runtime_change = true; + } else { + return Action::Save(Box::new(self.draft.clone()), self.desktop); + } + } + } + KeyCode::Char('E') if self.dirty() => { + self.error = + Some("save or discard draft changes before opening the raw file".to_owned()); + } + KeyCode::Char('E') => return Action::OpenRaw, + KeyCode::Esc if self.dirty() => self.confirm_close = true, + KeyCode::Esc => return Action::Close, + _ => {} + } + Action::None + } + + pub fn profile_deleted(&mut self, profile: &str) { + self.profiles.retain(|candidate| candidate != profile); + if self.draft.config_sync_profile.as_deref() == Some(profile) { + self.draft.config_sync_profile = None; + } + if self.original.config_sync_profile.as_deref() == Some(profile) { + self.original.config_sync_profile = None; + } + } +} + +fn memory_kib(value: &str) -> Option { + let normalized = normalize_memory_value(value)?; + let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); + let number = number.parse::().ok()?; + match suffix { + "K" => Some(number), + "M" => number.checked_mul(1024), + "G" => number.checked_mul(1024 * 1024), + _ => None, + } +} + +fn loaders() -> [ModLoader; 5] { + [ + ModLoader::Vanilla, + ModLoader::Fabric, + ModLoader::Forge, + ModLoader::NeoForge, + ModLoader::Quilt, + ] +} + +fn new_text_area(lines: Vec) -> TextArea<'static> { + let theme = THEME.as_ref(); + let mut editor = TextArea::new(if lines.is_empty() { + vec![String::new()] + } else { + lines + }); + editor.set_style(Style::default().fg(theme.text()).bg(theme.surface())); + editor.set_cursor_line_style(Style::default()); + editor.set_cursor_style(Style::default().fg(theme.background()).bg(theme.accent())); + editor.move_cursor(CursorMove::Bottom); + editor.move_cursor(CursorMove::End); + editor +} + +pub fn popup_rect(area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(8), + Constraint::Percentage(84), + Constraint::Percentage(8), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(12), + Constraint::Percentage(76), + Constraint::Percentage(12), + ]) + .split(vertical[1])[1] +} + +pub fn render(frame: &mut Frame, area: Rect, state: &State) { + let theme = THEME.as_ref(); + frame.render_widget(Clear, area); + let title = format!( + " Instance settings: {} {}", + state.draft.name, + if state.dirty() { "*" } else { "" } + ); + let keybinds = if state.picker.is_some() { + super::keybind_line(&[ + ("j/k", " move"), + ("Enter", " select"), + ("/", " search"), + ("s", " snapshots"), + ("Esc", " back"), + ]) + } else if state.choice_picker.is_some() { + super::keybind_line(&[("j/k", " move"), ("Enter", " select"), ("Esc", " back")]) + } else { + super::keybind_line(&[ + ("j/k", " field"), + ("Enter", " edit"), + ("s", " save"), + ("E", " raw"), + ("Esc", " back"), + ]) + }; + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.background())) + .title_bottom(keybinds); + let inner = block.inner(area); + frame.render_widget(block, area); + + if state.picker.is_some() { + render_version_picker(frame, inner, state); + return; + } + if state.choice_picker.is_some() { + render_choice_picker(frame, inner, state); + return; + } + + let labels = [ + "Game version", + "Loader", + "Loader version", + "Java", + "Memory min", + "Memory max", + "JVM args", + "Resolution", + "Config profile", + "Desktop shortcut", + ]; + let mut lines = Vec::with_capacity(FIELD_COUNT + 2); + for (index, label) in labels.iter().enumerate() { + let selected = index == state.selected; + let marker = if selected { "▸ " } else { " " }; + let displayed = if selected && state.profile_input.is_some() { + "new profile (editing below)".to_owned() + } else if selected && state.editing.is_some() { + "editing below".to_owned() + } else { + state.display_value(index) + }; + lines.push(Line::from(vec![ + Span::styled(marker, Style::default().fg(theme.accent())), + Span::styled( + format!("{label:<18}"), + Style::default().fg(theme.text_dim()), + ), + Span::styled( + displayed, + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ])); + } + if state.confirm_profile_delete { + lines.push(Line::from(Span::styled( + "Delete the selected shared profile? [y/n]", + Style::default().fg(theme.warning()), + ))); + } else if let Some(error) = &state.error { + lines.push(Line::from(Span::styled( + error, + Style::default().fg(theme.error()), + ))); + } else if state.confirm_runtime_change { + lines.push(Line::from(Span::styled( + "Changing the runtime downloads files and may break mods. Continue? [y/n]", + Style::default().fg(theme.warning()), + ))); + } else if state.confirm_close { + lines.push(Line::from(Span::styled( + "Discard unsaved changes? [y] yes [n] no", + Style::default().fg(theme.warning()), + ))); + } else { + let settings = SETTINGS.read(); + let defaults = &settings.defaults; + lines.push(Line::from(Span::styled( + format!( + "Empty Java/memory values use launcher defaults ({}-{}).", + defaults.memory_min, defaults.memory_max + ), + Style::default().fg(theme.text_dim()), + ))); + } + frame.render_widget(Paragraph::new(lines), inner); + if let Some(editor) = state.editing.as_ref().or(state.profile_input.as_ref()) { + let editor_area = Rect { + x: inner.x, + y: inner.y.saturating_add(12), + width: inner.width, + height: inner.height.saturating_sub(12).max(1), + }; + let editor_block = Block::default() + .title(if state.selected == 6 { + " JVM arguments — one argument per line; Ctrl+Enter applies " + } else { + " Value — Enter applies " + }) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent())); + let editor_inner = editor_block.inner(editor_area); + frame.render_widget(editor_block, editor_area); + frame.render_widget(editor, editor_inner); + } +} + +fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { + let theme = THEME.as_ref(); + let title = match state.choice_picker { + Some(ChoicePicker::Loader) => "Select loader", + Some(ChoicePicker::Profile) => "Select config profile", + None => return, + }; + let mut lines = vec![Line::from(Span::styled( + title, + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + ))]; + let values = state.choice_values(); + let visible_rows = area.height.saturating_sub(1) as usize; + let start = state + .choice_index + .saturating_sub(visible_rows.saturating_sub(1)); + for (index, value) in values.iter().enumerate().skip(start).take(visible_rows) { + let selected = index == state.choice_index; + lines.push(Line::from(vec![ + Span::styled( + if selected { "▸ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + value.clone(), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ])); + } + frame.render_widget(Paragraph::new(lines), area); +} + +fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { + let theme = THEME.as_ref(); + let title = match state.picker { + Some(VersionPicker::Game) => "Minecraft version", + Some(VersionPicker::Loader) => "Loader version", + None => return, + }; + let status = match state.picker { + Some(VersionPicker::Game) => match &*state + .game_versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Idle => PickerLoad::Idle, + LoadState::Loading => PickerLoad::Loading, + LoadState::Loaded(_) => PickerLoad::Loaded, + LoadState::Error(error) => PickerLoad::Error(error.clone()), + }, + Some(VersionPicker::Loader) => match &*state + .loader_versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Idle => PickerLoad::Idle, + LoadState::Loading => PickerLoad::Loading, + LoadState::Loaded(_) => PickerLoad::Loaded, + LoadState::Error(error) => PickerLoad::Error(error.clone()), + }, + None => return, + }; + let mut lines = vec![Line::from(vec![ + Span::styled(format!("{title}: "), Style::default().fg(theme.text_dim())), + Span::styled( + if state.picker_search { + format!("/{}█", state.picker_query) + } else if state.picker_query.is_empty() { + "press / to search".to_owned() + } else { + format!("/{}", state.picker_query) + }, + Style::default().fg(theme.accent()), + ), + ])]; + match status { + PickerLoad::Idle | PickerLoad::Loading => lines.push(Line::from("Loading versions...")), + PickerLoad::Error(error) => lines.push(Line::from(Span::styled( + format!("Failed to load versions: {error}. Reopen to retry."), + Style::default().fg(theme.error()), + ))), + PickerLoad::Loaded => { + let versions: Vec = match state.picker { + Some(VersionPicker::Game) => state + .visible_game_versions() + .into_iter() + .map(|version| { + if version.stable { + version.id + } else { + format!("{} (snapshot)", version.id) + } + }) + .collect(), + Some(VersionPicker::Loader) => state.visible_loader_versions(), + None => Vec::new(), + }; + if versions.is_empty() { + lines.push(Line::from("No matching versions.")); + } else { + let visible_rows = area.height.saturating_sub(2) as usize; + let start = state + .picker_index + .saturating_sub(visible_rows.saturating_sub(1)); + for (index, version) in versions.iter().enumerate().skip(start).take(visible_rows) { + let selected = index == state.picker_index; + lines.push(Line::from(vec![ + Span::styled( + if selected { "▸ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + version.clone(), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ])); + } + } + } + } + frame.render_widget(Paragraph::new(lines), area); +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn instance() -> InstanceConfig { + InstanceConfig { + name: "test".to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Fabric, + loader_version: Some("0.16.0".to_owned()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + config_sync_profile: None, + modpack_source: None, + } + } + + #[test] + fn memory_and_resolution_inputs_are_normalized() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.selected = 4; + state.editing = Some(new_text_area(vec!["2048m".to_owned()])); + state.commit_edit(); + assert_eq!(state.draft.memory_min.as_deref(), Some("2048M")); + + state.selected = 7; + state.editing = Some(new_text_area(vec!["1920X1080".to_owned()])); + state.commit_edit(); + assert_eq!(state.draft.resolution, Some((1920, 1080))); + } + + #[test] + fn text_editor_supports_cursor_movement_and_multiline_jvm_arguments() { + let temp = tempfile::tempdir().unwrap(); + let mut config = instance(); + config.memory_min = Some("512M".to_owned()); + let mut state = State::new(&config, temp.path()); + state.selected = 4; + state.begin_edit(); + state.handle_key(&KeyEvent::from(KeyCode::Left)); + state.handle_key(&KeyEvent::from(KeyCode::Char('0'))); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.draft.memory_min.as_deref(), Some("5120M")); + + state.selected = 6; + state.begin_edit(); + for character in "-Xfoo".chars() { + state.handle_key(&KeyEvent::from(KeyCode::Char(character))); + } + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + for character in "-Xbar".chars() { + state.handle_key(&KeyEvent::from(KeyCode::Char(character))); + } + state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)); + assert_eq!(state.draft.jvm_args, ["-Xfoo", "-Xbar"]); + } + + #[test] + fn runtime_changes_require_confirmation_before_save() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.draft.game_version = "1.21.2".to_owned(); + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('s'))), + Action::None + )); + assert!(state.confirm_runtime_change); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('y'))), + Action::Save(_, _) + )); + } + + #[test] + fn version_picker_selects_loaded_version_and_clears_loader_version() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + *state.game_versions.lock().unwrap() = LoadState::Loaded(vec![ + GameVersion { + id: "1.21.2".to_owned(), + stable: true, + }, + GameVersion { + id: "1.21.1".to_owned(), + stable: true, + }, + ]); + state.picker = Some(VersionPicker::Game); + state.picker_initialized = true; + state.picker_index = 0; + + state.handle_picker_key(&KeyEvent::from(KeyCode::Enter)); + + assert_eq!(state.draft.game_version, "1.21.2"); + assert_eq!(state.draft.loader_version, None); + } + + #[test] + fn adding_profile_is_staged_until_settings_are_saved() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.profile_input = Some(new_text_area(vec!["new-profile".to_owned()])); + + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + + assert_eq!( + state.draft.config_sync_profile.as_deref(), + Some("new-profile") + ); + assert!( + crate::instance::config_sync::list_profiles(temp.path()) + .unwrap() + .is_empty() + ); + } + + #[test] + fn selecting_loader_clears_the_previous_loaders_version() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + + state.open_choice_picker(ChoicePicker::Loader); + state.handle_choice_key(&KeyEvent::from(KeyCode::Char('j'))); + state.handle_choice_key(&KeyEvent::from(KeyCode::Enter)); + + assert_eq!(state.draft.loader, ModLoader::Forge); + assert_eq!(state.draft.loader_version, None); + assert!(!state.validate_before_save()); + } +} diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index 10f2794..42f8403 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -7,10 +7,13 @@ pub mod base; pub mod confirm; pub mod error; +pub mod global_settings; pub mod import_modpack; +pub mod instance_settings; mod load_state; pub mod modpack_update; pub mod new_instance; +pub mod version_lists; pub use load_state::LoadState; diff --git a/src/tui/widgets/popups/new_instance/state.rs b/src/tui/widgets/popups/new_instance/state.rs index 098a558..5c49505 100644 --- a/src/tui/widgets/popups/new_instance/state.rs +++ b/src/tui/widgets/popups/new_instance/state.rs @@ -5,10 +5,7 @@ // flow: Name -> Loader -> Version -> LoaderVersion -> Confirm // version lists are fetched lazily from the network when you reach that step. -use crate::instance::{ - loader::{GameVersion, get_installer}, - models::ModLoader, -}; +use crate::instance::{loader::GameVersion, models::ModLoader}; use crate::tui::widgets::instances; use crossterm::event::{KeyCode, KeyEvent}; use std::sync::LazyLock; @@ -405,12 +402,9 @@ pub(crate) fn ensure_versions_loaded(state: &mut WizardState) { let versions_arc = WIZARD_STATE.clone(); let loader = state.selected_loader(); tokio::spawn(async move { - let client = crate::net::HttpClient::new(); - let installer = get_installer(loader); - match installer.get_game_versions(&client).await { - Ok(mut versions) => match versions_arc.lock() { + match super::super::version_lists::game_versions(loader).await { + Ok(versions) => match versions_arc.lock() { Ok(mut s) => { - sort_versions_semver(&mut versions); s.versions = LoadState::Loaded(versions); clamp_version_index(&mut s); } @@ -442,9 +436,7 @@ pub(crate) fn ensure_loader_versions_loaded( state.loader_versions = LoadState::Loading; let versions_arc = WIZARD_STATE.clone(); tokio::spawn(async move { - let client = crate::net::HttpClient::new(); - let installer = get_installer(loader); - match installer.get_versions(&client, &game_version).await { + match super::super::version_lists::loader_versions(loader, &game_version).await { Ok(versions) => match versions_arc.lock() { Ok(mut s) => { s.loader_versions = LoadState::Loaded(versions); @@ -466,10 +458,6 @@ pub(crate) fn ensure_loader_versions_loaded( }); } -fn sort_versions_semver(versions: &mut [GameVersion]) { - versions.sort_by(|a, b| super::super::compare_game_versions(&b.id, &a.id)); -} - #[cfg(test)] #[path = "../../../tests/widgets/popups/new_instance/state.rs"] mod tests; diff --git a/src/tui/widgets/popups/version_lists.rs b/src/tui/widgets/popups/version_lists.rs new file mode 100644 index 0000000..fdafbb5 --- /dev/null +++ b/src/tui/widgets/popups/version_lists.rs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +// shared network loading for Minecraft and mod-loader version pickers. + +use crate::instance::{ + loader::{GameVersion, get_installer}, + models::ModLoader, +}; + +pub async fn game_versions(loader: ModLoader) -> Result, String> { + let client = crate::net::HttpClient::new(); + let mut versions = get_installer(loader) + .get_game_versions(&client) + .await + .map_err(|error| error.to_string())?; + versions.sort_by(|a, b| super::compare_game_versions(&b.id, &a.id)); + Ok(versions) +} + +pub async fn loader_versions(loader: ModLoader, game_version: &str) -> Result, String> { + let client = crate::net::HttpClient::new(); + get_installer(loader) + .get_versions(&client, game_version) + .await + .map_err(|error| error.to_string()) +} diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index a477330..26bb397 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -1,23 +1,8 @@ // SPDX-FileCopyrightText: 2026 Constantin Bauer // SPDX-License-Identifier: GPL-3.0-only -// settings panel: manages config profiles and shows compact instance info. -// also provides keybinds to open config files in $EDITOR. - -use std::{ - path::{Path, PathBuf}, - process::Command, -}; - -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Widget}, -}; -use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; +// compact, read-only summary of the selected instance. editing lives in the +// instance and launcher settings popups. use crate::config::{ SETTINGS, @@ -25,181 +10,24 @@ use crate::config::{ }; use crate::instance::models::InstanceConfig; use crate::tui::app::FocusedArea; +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; use super::styled_title; const LOCAL_PROFILE_LABEL: &str = "instance default"; -#[derive(Default)] -pub enum AddMode { - #[default] - None, - ProfileName(String), -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum SettingsPane { - #[default] - Profile, - Info, -} - -pub struct SettingsState { - pub list_state: TuiListState, - pub profiles: Vec, - pub add_mode: AddMode, - pub pane: SettingsPane, - meta_dir: PathBuf, - active_profile: Option, - instance_name: Option, - java_key: Option, - java_source: Option, - java_label: String, -} - -impl SettingsState { - pub fn new(meta_dir: PathBuf) -> Self { - let profiles = crate::instance::config_sync::list_profiles(&meta_dir).unwrap_or_else(|e| { - tracing::warn!("Failed to load config sync profiles: {}", e); - Vec::new() - }); - let mut state = Self { - list_state: TuiListState::default(), - profiles, - add_mode: AddMode::None, - pane: SettingsPane::Profile, - meta_dir, - active_profile: None, - instance_name: None, - java_key: None, - java_source: None, - java_label: "unknown".to_string(), - }; - state.select_active(); - state - } - - fn reload_profiles(&mut self) { - match crate::instance::config_sync::list_profiles(&self.meta_dir) { - Ok(mut profiles) => { - add_active_profile(&mut profiles, self.active_profile.as_deref()); - self.profiles = profiles; - } - Err(e) => tracing::warn!("Failed to reload config sync profiles: {}", e), - } - } - - fn select_active(&mut self) { - self.list_state.selected = Some( - self.active_profile - .as_deref() - .and_then(|active| self.profiles.iter().position(|profile| profile == active)) - .map(|idx| idx + 1) - .unwrap_or(0), - ); - } - - fn count(&self) -> usize { - self.profiles.len() + 1 - } - - pub fn remove_profile(&mut self, profile: &str) { - self.profiles.retain(|candidate| candidate != profile); - if self.active_profile.as_deref() == Some(profile) { - self.active_profile = None; - } - let last = self.count().saturating_sub(1); - self.list_state.selected = Some(self.list_state.selected.unwrap_or(0).min(last)); - } - - fn update_for_instance(&mut self, instance: Option<&InstanceConfig>) { - let instance_name = instance.map(|inst| inst.name.clone()); - let active_profile = instance.and_then(|inst| inst.config_sync_profile.clone()); - let active_profile = active_profile - .filter(|profile| self.profiles.iter().any(|candidate| candidate == profile)); - if self.instance_name != instance_name || self.active_profile != active_profile { - self.instance_name = instance_name; - self.active_profile = active_profile; - add_active_profile(&mut self.profiles, self.active_profile.as_deref()); - self.select_active(); - } - - let java_key = instance.map(java_path_key); - if self.java_key != java_key { - let java_source = instance.map(effective_java_path); - self.java_label = java_source - .as_deref() - .map(java_version_label) - .unwrap_or_else(|| "unknown".to_string()); - self.java_key = java_key; - self.java_source = java_source; - } - } -} - -fn add_active_profile(profiles: &mut Vec, active_profile: Option<&str>) { - if let Some(active) = active_profile - && !profiles.iter().any(|profile| profile == active) - { - profiles.push(active.to_string()); - profiles.sort_unstable(); - } -} - -fn java_path_key(instance: &InstanceConfig) -> String { - instance - .java_path - .as_deref() - .filter(|path| !path.is_empty()) - .or_else(|| SETTINGS.paths.effective_java_path()) - .unwrap_or("") - .to_string() -} - -fn effective_java_path(instance: &InstanceConfig) -> String { - instance - .java_path - .clone() - .or_else(|| SETTINGS.paths.effective_java_path().map(str::to_string)) - .unwrap_or_else(crate::instance::java::detect_java_path) -} - -fn java_version_label(java_path: &str) -> String { - let output = Command::new(java_path).arg("-version").output(); - let Ok(output) = output else { - return "unknown".to_string(); - }; - let raw = String::from_utf8_lossy(if output.stderr.is_empty() { - &output.stdout - } else { - &output.stderr - }); - let first_line = raw.lines().next().unwrap_or_default(); - let Some(version) = first_line.split('"').nth(1) else { - return "unknown".to_string(); - }; - let major = if let Some(stripped) = version.strip_prefix("1.") { - stripped.split('.').next().unwrap_or(stripped) - } else { - version.split('.').next().unwrap_or(version) - }; - if major.is_empty() { - "unknown".to_string() - } else { - format!("jdk{major}") - } -} - pub fn render( frame: &mut Frame, area: Rect, focused: FocusedArea, - state: &mut SettingsState, instance: Option<&InstanceConfig>, - _instances_dir: &Path, ) { - state.update_for_instance(instance); - let theme = THEME.as_ref(); let color = if focused == FocusedArea::Settings { theme.accent() @@ -214,23 +42,7 @@ pub fn render( .border_style(Style::default().fg(color)); let keybind_line = if focused == FocusedArea::Settings { - let keybinds: &[(&str, &str)] = match state.pane { - SettingsPane::Profile => &[ - ("⏎", " select"), - ("a", " add"), - ("d", " del"), - ("j/k", " move"), - ("h/l", " tab"), - ("Esc", " back"), - ], - SettingsPane::Info => &[ - ("e", " inst"), - ("g", " global"), - ("d", " desk"), - ("h/l", " tab"), - ("Esc", " back"), - ], - }; + let keybinds: &[(&str, &str)] = &[("E", " instance"), ("G", " launcher"), ("Esc", " back")]; Some(super::popups::keybind_line_fitted( keybinds, area.width.saturating_sub(2), @@ -245,49 +57,10 @@ pub fn render( let inner = block.inner(area); frame.render_widget(block, area); - if inner.width >= 42 { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), - Constraint::Length(3), - Constraint::Percentage(50), - ]) - .split(inner); - render_profile_list(frame, chunks[0], focused, state); - render_separator(frame, chunks[1], focused, state.pane); - render_instance_info(frame, chunks[2], focused, state, instance); - } else { - render_profile_list(frame, inner, focused, state); - } - - if let AddMode::ProfileName(name) = &state.add_mode { - render_add_profile_popup(frame, name); - } + render_instance_info(frame, inner, instance); } -fn render_separator(frame: &mut Frame, area: Rect, focused: FocusedArea, pane: SettingsPane) { - let theme = THEME.as_ref(); - let color = if focused == FocusedArea::Settings && pane == SettingsPane::Info { - theme.accent() - } else { - theme.border() - }; - let line = if area.width >= 3 { - " │ \n".repeat(area.height as usize) - } else { - "│\n".repeat(area.height as usize) - }; - frame.render_widget(Paragraph::new(line).style(Style::default().fg(color)), area); -} - -fn render_instance_info( - frame: &mut Frame, - area: Rect, - focused: FocusedArea, - state: &SettingsState, - instance: Option<&InstanceConfig>, -) { +fn render_instance_info(frame: &mut Frame, area: Rect, instance: Option<&InstanceConfig>) { let theme = THEME.as_ref(); let label_style = Style::default().fg(theme.text_dim()); let value_style = Style::default() @@ -302,278 +75,59 @@ fn render_instance_info( return; }; + let settings = SETTINGS.read(); let memory_min = inst .memory_min .as_deref() - .unwrap_or(&SETTINGS.defaults.memory_min); + .unwrap_or(&settings.defaults.memory_min); let memory_max = inst .memory_max .as_deref() - .unwrap_or(&SETTINGS.defaults.memory_max); - let active_style = if focused == FocusedArea::Settings && state.pane == SettingsPane::Info { - value_style.fg(theme.accent()) - } else { - value_style - }; + .unwrap_or(&settings.defaults.memory_max); + let active_style = value_style; let desktop = if crate::instance::desktop::exists(&inst.name) { "yes" } else { "no" }; + let java_source = if inst + .java_path + .as_deref() + .is_some_and(|path| !path.is_empty()) + { + "instance java" + } else if settings.paths.effective_java_path().is_some() { + "global java" + } else { + "auto java" + }; let lines = vec![ Line::from(vec![ - Span::styled("Memory ", label_style), - Span::styled(format!("{memory_min} - {memory_max}"), active_style), + Span::styled("Version ", label_style), + Span::styled( + format!("{} / {}", inst.game_version, inst.loader), + active_style, + ), ]), Line::from(vec![ - Span::styled("Java ", label_style), - Span::styled(state.java_label.as_str(), active_style), + Span::styled("Runtime ", label_style), + Span::styled( + format!("{memory_min}-{memory_max}, {java_source}"), + active_style, + ), ]), Line::from(vec![ - Span::styled("Desktop ", label_style), + Span::styled("Profile ", label_style), + Span::styled( + inst.config_sync_profile + .as_deref() + .unwrap_or(LOCAL_PROFILE_LABEL), + active_style, + ), + Span::styled(" / desk ", label_style), Span::styled(desktop, active_style), ]), ]; frame.render_widget(Paragraph::new(lines), area); } - -fn render_profile_list( - frame: &mut Frame, - area: Rect, - focused: FocusedArea, - state: &mut SettingsState, -) { - let is_focused = focused == FocusedArea::Settings; - let pane = state.pane; - let active_profile = state.active_profile.clone(); - let profiles = state.profiles.clone(); - let count = profiles.len() + 1; - let last = count.saturating_sub(1); - state.list_state.selected = Some(state.list_state.selected.unwrap_or(0).min(last)); - - let builder = ListBuilder::new(move |context| { - let theme = THEME.as_ref(); - let name = if context.index == 0 { - LOCAL_PROFILE_LABEL.to_string() - } else { - profiles.get(context.index - 1).cloned().unwrap_or_default() - }; - let is_active = if context.index == 0 { - active_profile.is_none() - } else { - active_profile.as_deref() == Some(name.as_str()) - }; - let show_selected = is_focused && pane == SettingsPane::Profile && context.is_selected; - let marker = if is_active { "\u{25b8} " } else { " " }; - let background = if show_selected { - theme.stripe() - } else { - theme.background() - }; - let style = if show_selected { - Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD) - } else if is_active { - Style::default() - .fg(theme.text()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.text()) - }; - let line = Line::from(vec![ - Span::styled(marker, Style::default().fg(theme.success())), - Span::styled(name, style), - ]); - ( - ratatui::text::Text::from(line).style(Style::default().bg(background)), - 1, - ) - }); - - frame.render_stateful_widget(ListView::new(builder, count), area, &mut state.list_state); -} - -fn render_add_profile_popup(frame: &mut Frame, name: &str) { - use super::popups::{base::PopupFrame, keybind_line}; - let theme = THEME.as_ref(); - let area = popup_area(frame, 42, 5); - let name = name.to_string(); - - let border_color = theme.text_dim(); - let bg_color = theme.surface(); - let dim_color = theme.text_dim(); - let text_color = theme.text(); - - PopupFrame { - title: Line::from(Span::styled( - " Config Profile ", - Style::default() - .fg(border_color) - .add_modifier(Modifier::BOLD), - )) - .centered(), - border_color, - bg: Some(bg_color), - keybinds: Some(keybind_line(&[("Enter", " create"), ("Esc", " cancel")])), - search_line: None, - content: Box::new(move |inner, buf| { - let line = if name.is_empty() { - Line::from(vec![ - Span::styled("Profile name...", Style::default().fg(dim_color)), - Span::styled( - "\u{2588}", - Style::default() - .fg(border_color) - .add_modifier(Modifier::SLOW_BLINK), - ), - ]) - } else { - Line::from(vec![ - Span::styled(name.as_str(), Style::default().fg(text_color)), - Span::styled( - "\u{2588}", - Style::default() - .fg(border_color) - .add_modifier(Modifier::SLOW_BLINK), - ), - ]) - }; - Paragraph::new(line).render(inner, buf); - }), - } - .render(area, frame.buffer_mut()); -} - -fn popup_area(frame: &Frame, width: u16, height: u16) -> Rect { - let area = frame.area(); - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + (area.height.saturating_sub(height)) / 2; - Rect { - x, - y, - width: width.min(area.width), - height: height.min(area.height), - } -} - -pub enum SettingsAction { - None, - EditInstance(PathBuf), - EditGlobal(PathBuf), - ToggleDesktop, - SelectProfile(Option), - ConfirmDeleteProfile(String), - Error(String), -} - -pub fn handle_key( - key_event: &KeyEvent, - state: &mut SettingsState, - instance: Option<&InstanceConfig>, - instances_dir: &Path, -) -> SettingsAction { - if let AddMode::ProfileName(name) = &state.add_mode { - match key_event.code { - KeyCode::Enter => { - let name = name.trim().to_string(); - state.add_mode = AddMode::None; - if name.is_empty() { - return SettingsAction::None; - } - return match crate::instance::config_sync::create_profile(&state.meta_dir, &name) { - Ok(profile) => { - state.reload_profiles(); - state.active_profile = Some(profile); - state.select_active(); - SettingsAction::SelectProfile(state.active_profile.clone()) - } - Err(e) => SettingsAction::Error(e.to_string()), - }; - } - KeyCode::Esc => { - state.add_mode = AddMode::None; - return SettingsAction::None; - } - KeyCode::Backspace => { - let mut new_name = name.clone(); - super::search::backspace(&mut new_name, key_event.modifiers); - state.add_mode = AddMode::ProfileName(new_name); - return SettingsAction::None; - } - KeyCode::Char(c) => { - let mut new_name = name.clone(); - new_name.push(c); - state.add_mode = AddMode::ProfileName(new_name); - return SettingsAction::None; - } - _ => return SettingsAction::None, - } - } - - match key_event.code { - KeyCode::Char('h') | KeyCode::Left => { - state.pane = SettingsPane::Profile; - SettingsAction::None - } - KeyCode::Char('l') | KeyCode::Right => { - state.pane = SettingsPane::Info; - SettingsAction::None - } - KeyCode::Enter if state.pane == SettingsPane::Profile => { - let selected = state.list_state.selected.unwrap_or(0); - let profile = if selected == 0 { - None - } else { - state.profiles.get(selected - 1).cloned() - }; - SettingsAction::SelectProfile(profile) - } - KeyCode::Char('a') if state.pane == SettingsPane::Profile => { - state.add_mode = AddMode::ProfileName(String::new()); - SettingsAction::None - } - KeyCode::Char('d') if state.pane == SettingsPane::Profile => { - let selected = state.list_state.selected.unwrap_or(0); - if selected == 0 { - SettingsAction::None - } else if let Some(profile) = state.profiles.get(selected - 1) { - SettingsAction::ConfirmDeleteProfile(profile.clone()) - } else { - SettingsAction::None - } - } - KeyCode::Char('j') | KeyCode::Down if state.pane == SettingsPane::Profile => { - let count = state.count(); - if count > 0 { - let cur = state.list_state.selected.unwrap_or(0); - state.list_state.selected = Some((cur + 1).min(count - 1)); - } - SettingsAction::None - } - KeyCode::Char('k') | KeyCode::Up if state.pane == SettingsPane::Profile => { - let cur = state.list_state.selected.unwrap_or(0); - state.list_state.selected = Some(cur.saturating_sub(1)); - SettingsAction::None - } - KeyCode::Char('e') if state.pane == SettingsPane::Info => { - if let Some(inst) = instance { - let path = instances_dir.join(&inst.name).join("instance.json"); - SettingsAction::EditInstance(path) - } else { - SettingsAction::None - } - } - KeyCode::Char('g') if state.pane == SettingsPane::Info => { - let path = crate::config::get_config_path().join("config.toml"); - SettingsAction::EditGlobal(path) - } - KeyCode::Char('d') if state.pane == SettingsPane::Info => SettingsAction::ToggleDesktop, - _ => SettingsAction::None, - } -} - -#[cfg(test)] -#[path = "../tests/widgets/settings.rs"] -mod tests; From 94362f09476399cd4ddb7d49fff71aeb485eb41e Mon Sep 17 00:00:00 2001 From: objz Date: Thu, 3 Sep 2026 20:56:58 +0200 Subject: [PATCH 02/42] fix: restore settings panel workflow --- src/tui/app.rs | 3 + src/tui/event.rs | 4 + src/tui/input.rs | 109 ++++ src/tui/render.rs | 1 + src/tui/tests/flows.rs | 36 +- src/tui/tests/harness.rs | 1 + ...nfirmation_renders_the_complete_frame.snap | 6 +- src/tui/tests/widgets/settings.rs | 19 + src/tui/widgets/popups/global_settings.rs | 103 +++- src/tui/widgets/popups/instance_settings.rs | 147 +++-- src/tui/widgets/settings.rs | 540 ++++++++++++++++-- 11 files changed, 855 insertions(+), 114 deletions(-) create mode 100644 src/tui/tests/widgets/settings.rs diff --git a/src/tui/app.rs b/src/tui/app.rs index ae2d527..3c00e89 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -41,6 +41,7 @@ pub struct App { pub(super) screenshots_state: widgets::screenshots_grid::ScreenshotsState, pub(super) logs_state: widgets::logs_viewer::LogsState, pub(super) account_state: widgets::account::AccountState, + pub(super) settings_state: widgets::settings::SettingsState, pub(super) instance_settings: Option, pub(super) global_settings: Option, pub(super) picker: ratatui_image::picker::Picker, @@ -126,6 +127,7 @@ impl App { crate::instance::import::refresh::recover_interrupted(&instances_dir); let manager = InstanceManager::new(instances_dir, meta_dir); + let settings_state = widgets::settings::SettingsState::new(manager.meta_dir.clone()); let instances = manager.load_all(); instances::spawn_modpack_update_checks(&instances); let instances_state = instances::State::with_instances(instances); @@ -172,6 +174,7 @@ impl App { world_quick_play_support: None, logs_state: widgets::logs_viewer::LogsState::default(), account_state: widgets::account::AccountState::default(), + settings_state, instance_settings: None, global_settings: None, screenshots_state: { diff --git a/src/tui/event.rs b/src/tui/event.rs index e04ac89..28293f1 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -223,6 +223,10 @@ impl App { &self.account_state.add_mode, widgets::account::AddMode::None )) + + usize::from(!matches!( + &self.settings_state.add_mode, + widgets::settings::AddMode::None + )) + [ &self.mods_discovery_state, &self.resource_packs_discovery_state, diff --git a/src/tui/input.rs b/src/tui/input.rs index a8fd94b..10160b6 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -223,6 +223,8 @@ impl App { message: e.to_string(), pushed_at: std::time::Instant::now(), }); + } else { + self.settings_state.remove_profile(&profile); } FocusedArea::Settings } @@ -552,6 +554,112 @@ impl App { return Ok(()); } + if self.focused == FocusedArea::Settings { + let editing_profile = matches!( + &self.settings_state.add_mode, + widgets::settings::AddMode::ProfileName(_) + ); + match widgets::settings::handle_key( + &key_event, + &mut self.settings_state, + self.instances_state.selected_instance(), + ) { + widgets::settings::SettingsAction::OpenInstance => { + if let Some(instance) = self.instances_state.selected_instance() { + self.pre_overlay_focused = FocusedArea::Settings; + self.instance_settings = + Some(widgets::popups::instance_settings::State::new( + instance, + &self.instance_manager.meta_dir, + )); + self.focused = FocusedArea::InstanceSettings; + } + return Ok(()); + } + widgets::settings::SettingsAction::OpenGlobal => { + self.pre_overlay_focused = FocusedArea::Settings; + self.global_settings = Some(widgets::popups::global_settings::State::new()); + self.focused = FocusedArea::GlobalSettings; + return Ok(()); + } + widgets::settings::SettingsAction::ToggleDesktop => { + if let Some(instance) = self.instances_state.selected_instance() { + let name = instance.name.clone(); + match crate::instance::desktop::toggle(instance) { + Ok(true) => error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::INFO, + message: format!("Desktop shortcut created for '{name}'"), + pushed_at: std::time::Instant::now(), + }), + Ok(false) => error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::INFO, + message: format!("Desktop shortcut removed for '{name}'"), + pushed_at: std::time::Instant::now(), + }), + Err(error) => { + tracing::error!("Failed to toggle desktop shortcut: {error}"); + } + } + } + return Ok(()); + } + widgets::settings::SettingsAction::SelectProfile(profile) => { + if let Some(instance) = self.instances_state.selected_instance().cloned() { + let instance_dir = self.instance_manager.instances_dir.join(&instance.name); + match crate::instance::config_sync::switch_profile( + &instance.name, + instance.config_sync_profile.as_deref(), + profile.as_deref(), + &self.instance_manager.meta_dir, + &instance_dir, + ) { + Ok(selected) => { + let mut updated = instance.clone(); + updated.config_sync_profile = selected; + if let Err(error) = self.instance_manager.save(&updated) { + tracing::error!("Failed to save config profile: {error}"); + } else { + self.instances_state + .replace_instance(&instance.name, updated); + } + } + Err(error) => { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: error.to_string(), + pushed_at: std::time::Instant::now(), + }); + } + } + } + return Ok(()); + } + widgets::settings::SettingsAction::ConfirmDeleteProfile(profile) => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::ConfigProfile { + profile, + }); + self.focused = FocusedArea::ConfirmDelete; + return Ok(()); + } + widgets::settings::SettingsAction::Error(message) => { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message, + pushed_at: std::time::Instant::now(), + }); + return Ok(()); + } + widgets::settings::SettingsAction::None => {} + } + if editing_profile { + return Ok(()); + } + } + if self.focused == FocusedArea::GlobalSettings { let action = self .global_settings @@ -617,6 +725,7 @@ impl App { widgets::popups::instance_settings::Action::DeleteProfile(profile) => { match self.delete_config_profile(&profile) { Ok(()) => { + self.settings_state.remove_profile(&profile); if let Some(state) = self.instance_settings.as_mut() { state.profile_deleted(&profile); } diff --git a/src/tui/render.rs b/src/tui/render.rs index e0f213e..f087519 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -135,6 +135,7 @@ impl App { frame, bottom_chunks[1], self.focused, + &mut self.settings_state, self.instances_state.selected_instance(), ); widgets::status::render( diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 524a7ef..d96632b 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -571,17 +571,20 @@ fn confirmed_account_delete_updates_the_account_panel() { } #[test] -fn settings_popups_open_from_global_key_events() { +fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { let mut ui = UiHarness::new(); ui.add_instance("settings-test"); ui.app.focused = FocusedArea::Settings; - ui.key(KeyCode::Char('E')); + ui.key(KeyCode::Right); + ui.key(KeyCode::Char('e')); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); ui.draw(); assert!(ui.screen().contains("Instance settings: settings-test")); assert!(ui.screen().contains("Game version")); assert!(ui.screen().contains("Desktop shortcut")); + assert!(ui.screen().contains('‹')); + assert!(ui.screen().contains('›')); ui.key(KeyCode::Down); ui.key(KeyCode::Enter); ui.draw(); @@ -591,11 +594,13 @@ fn settings_popups_open_from_global_key_events() { ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Settings); - ui.key(KeyCode::Char('G')); + ui.key(KeyCode::Char('g')); assert_eq!(ui.app.focused, FocusedArea::GlobalSettings); ui.draw(); assert!(ui.screen().contains("Launcher settings")); assert!(ui.screen().contains("Default memory max")); + assert!(ui.screen().contains('‹')); + assert!(ui.screen().contains('›')); ui.key(KeyCode::Enter); ui.draw(); assert!(ui.screen().contains("Select theme")); @@ -604,6 +609,31 @@ fn settings_popups_open_from_global_key_events() { assert_eq!(ui.app.focused, FocusedArea::Settings); } +#[test] +fn settings_panel_keeps_direct_profile_management() { + let mut ui = UiHarness::new(); + ui.add_instance("profile-test"); + ui.app.focused = FocusedArea::Settings; + + ui.key(KeyCode::Char('a')); + for character in "main".chars() { + ui.key(KeyCode::Char(character)); + } + ui.key(KeyCode::Enter); + + assert_eq!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .config_sync_profile + .as_deref(), + Some("main") + ); + ui.draw(); + assert!(ui.screen().contains("main")); +} + #[test] fn instance_wizards_open_render_and_cancel_through_app_input() { let mut ui = UiHarness::new(); diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 587dfad..695cf5a 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -82,6 +82,7 @@ impl UiHarness { }, logs_state: widgets::logs_viewer::LogsState::default(), account_state, + settings_state: widgets::settings::SettingsState::new(meta_dir), instance_settings: None, global_settings: None, picker, diff --git a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap index 454390f..bf31793 100644 --- a/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap +++ b/src/tui/tests/snapshots/rmcl__tui__tests__snapshots__instance_delete_confirmation_renders_the_complete_frame.snap @@ -28,7 +28,7 @@ expression: ui.screen() "│ ││ │" "│ │╰──────────────────────────────────────────────────────────────────────────────╯" "│ │╭Accounts─────────╮╭Settings───────────────────────────╮╭Overview──────────────╮" -"│ ││No accounts. ││Version 1.21.1 / Fabric ││Ready │" -"│ ││ ││Runtime 512M-2G, auto java ││ │" -"│ ││ ││Profile instance default / desk no││ │" +"│ ││No accounts. ││▸ instance default ││Ready │" +"│ ││ ││ ││ │" +"│ ││ ││ ││ │" "╰──────────────────╯╰─────────────────╯╰───────────────────────────────────╯╰──────────────────────╯" diff --git a/src/tui/tests/widgets/settings.rs b/src/tui/tests/widgets/settings.rs new file mode 100644 index 0000000..4bac7f5 --- /dev/null +++ b/src/tui/tests/widgets/settings.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +use super::*; + +#[test] +fn removing_selected_last_profile_clamps_selection() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = SettingsState::new(tmp.path().to_path_buf()); + state.profiles = vec!["first".to_string(), "second".to_string()]; + state.active_profile = Some("second".to_string()); + state.list_state.selected = Some(2); + + state.remove_profile("second"); + + assert_eq!(state.profiles, vec!["first"]); + assert_eq!(state.active_profile, None); + assert_eq!(state.list_state.selected, Some(1)); +} diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 0566196..a5f49d2 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -75,10 +75,15 @@ impl State { } fn display_value(&self, field: usize) -> String { - if field == 4 && self.config.paths.java_path.is_none() { - "auto-detect".to_owned() - } else { - self.value(field) + match field { + 1 => match &self.theme.border_style { + BorderStyle::Rounded => "╭─╮ rounded".to_owned(), + BorderStyle::Plain => "┌─┐ plain".to_owned(), + BorderStyle::Double => "╔═╗ double".to_owned(), + BorderStyle::Thick => "┏━┓ thick".to_owned(), + }, + 4 if self.config.paths.java_path.is_none() => "auto-detect".to_owned(), + _ => self.value(field), } } @@ -146,13 +151,26 @@ impl State { } } - fn cycle_border(&mut self) { + fn cycle_theme(&mut self, forward: bool) { + let count = self.themes.len(); + if count == 0 { + return; + } + self.theme_index = if forward { + (self.theme_index + 1) % count + } else { + (self.theme_index + count - 1) % count + }; + self.select_theme(); + } + + fn cycle_border(&mut self, forward: bool) { let previous = self.theme.border_style.clone(); - self.theme.border_style = match self.theme.border_style { - BorderStyle::Rounded => BorderStyle::Plain, - BorderStyle::Plain => BorderStyle::Double, - BorderStyle::Double => BorderStyle::Thick, - BorderStyle::Thick => BorderStyle::Rounded, + self.theme.border_style = match (&self.theme.border_style, forward) { + (BorderStyle::Rounded, true) | (BorderStyle::Double, false) => BorderStyle::Plain, + (BorderStyle::Plain, true) | (BorderStyle::Thick, false) => BorderStyle::Double, + (BorderStyle::Double, true) | (BorderStyle::Rounded, false) => BorderStyle::Thick, + (BorderStyle::Thick, true) | (BorderStyle::Plain, false) => BorderStyle::Rounded, }; self.error = None; if let Err(error) = crate::config::theme::apply_theme( @@ -200,9 +218,19 @@ impl State { match key.code { KeyCode::Char('j') | KeyCode::Down => self.selected = (self.selected + 1).min(4), KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), + KeyCode::Left => match self.selected { + 0 => self.cycle_theme(false), + 1 => self.cycle_border(false), + _ => {} + }, + KeyCode::Right => match self.selected { + 0 => self.cycle_theme(true), + 1 => self.cycle_border(true), + _ => {} + }, KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, - 1 => self.cycle_border(), + 1 => self.cycle_border(true), field => self.editing = Some(new_text_area(vec![self.value(field)])), }, KeyCode::Char('s') => { @@ -301,6 +329,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { super::keybind_line(&[ ("j/k", " field"), ("Enter", " edit"), + ("←/→", " switch"), ("s", " save"), ("E", " raw"), ("Esc", " back"), @@ -380,7 +409,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { } else { state.display_value(index) }; - lines.push(Line::from(vec![ + let mut spans = vec![ Span::styled( if selected { "▸ " } else { " " }, Style::default().fg(theme.accent()), @@ -389,21 +418,41 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { format!("{label:<20}"), Style::default().fg(theme.text_dim()), ), - Span::styled( - displayed, - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ])); + ]; + let value_style = Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .bg(theme.surface()) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }); + if index <= 1 && state.editing.is_none() { + spans.push(Span::styled( + "‹ ", + Style::default().fg(if selected { + theme.accent() + } else { + theme.text_dim() + }), + )); + spans.push(Span::styled(format!(" {displayed} "), value_style)); + spans.push(Span::styled( + " ›", + Style::default().fg(if selected { + theme.accent() + } else { + theme.text_dim() + }), + )); + } else { + spans.push(Span::styled(format!(" {displayed} "), value_style)); + } + lines.push(Line::from(spans)); } if let Some(error) = &state.error { lines.push(Line::from(Span::styled( diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index d1205ee..82bcb0f 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -176,6 +176,8 @@ impl State { } 6 if self.draft.jvm_args.is_empty() => "none".to_owned(), 7 if self.draft.resolution.is_none() => "default".to_owned(), + 9 if self.desktop => "● enabled".to_owned(), + 9 => "○ disabled".to_owned(), _ => self.value(field).replace('\n', " ↵ "), } } @@ -225,35 +227,50 @@ impl State { let count = self.choice_values().len(); match key.code { KeyCode::Esc => self.choice_picker = None, - KeyCode::Char('j') | KeyCode::Down if count > 0 => { + KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { self.choice_index = (self.choice_index + 1).min(count - 1); } - KeyCode::Char('k') | KeyCode::Up => { + KeyCode::Char('k') | KeyCode::Up | KeyCode::Left => { self.choice_index = self.choice_index.saturating_sub(1); } - KeyCode::Enter => { - match self.choice_picker { - Some(ChoicePicker::Loader) => { - let available = loaders(); - let loader = available[self.choice_index.min(available.len() - 1)]; - if self.draft.loader != loader { - self.draft.loader = loader; - self.draft.loader_version = None; - self.loader_versions = Arc::new(Mutex::new(LoadState::Idle)); - self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); - } - } - Some(ChoicePicker::Profile) => { - self.draft.config_sync_profile = self - .choice_index - .checked_sub(1) - .and_then(|index| self.profiles.get(index).cloned()); - } - None => {} + KeyCode::Enter => self.apply_choice(), + _ => {} + } + } + + fn apply_choice(&mut self) { + match self.choice_picker { + Some(ChoicePicker::Loader) => { + let available = loaders(); + let loader = available[self.choice_index.min(available.len() - 1)]; + if self.draft.loader != loader { + self.draft.loader = loader; + self.draft.loader_version = None; + self.loader_versions = Arc::new(Mutex::new(LoadState::Idle)); + self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); } - self.choice_picker = None; } - _ => {} + Some(ChoicePicker::Profile) => { + self.draft.config_sync_profile = self + .choice_index + .checked_sub(1) + .and_then(|index| self.profiles.get(index).cloned()); + } + None => {} + } + self.choice_picker = None; + } + + fn rotate_choice(&mut self, picker: ChoicePicker, forward: bool) { + self.open_choice_picker(picker); + let count = self.choice_values().len(); + if count > 0 { + self.choice_index = if forward { + (self.choice_index + 1) % count + } else { + (self.choice_index + count - 1) % count + }; + self.apply_choice(); } } @@ -564,6 +581,18 @@ impl State { self.selected = (self.selected + 1).min(FIELD_COUNT - 1) } KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), + KeyCode::Left => match self.selected { + 1 => self.rotate_choice(ChoicePicker::Loader, false), + 8 => self.rotate_choice(ChoicePicker::Profile, false), + 9 => self.desktop = !self.desktop, + _ => {} + }, + KeyCode::Right => match self.selected { + 1 => self.rotate_choice(ChoicePicker::Loader, true), + 8 => self.rotate_choice(ChoicePicker::Profile, true), + 9 => self.desktop = !self.desktop, + _ => {} + }, KeyCode::Enter => self.begin_edit(), KeyCode::Char('a') if self.selected == 8 => { self.profile_input = Some(new_text_area(vec![String::new()])); @@ -683,6 +712,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { super::keybind_line(&[ ("j/k", " field"), ("Enter", " edit"), + ("←/→", " switch"), ("s", " save"), ("E", " raw"), ("Esc", " back"), @@ -730,27 +760,49 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { } else { state.display_value(index) }; - lines.push(Line::from(vec![ + let mut spans = vec![ Span::styled(marker, Style::default().fg(theme.accent())), Span::styled( format!("{label:<18}"), Style::default().fg(theme.text_dim()), ), - Span::styled( - displayed, - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ])); + ]; + let value_style = Style::default() + .fg(if index == 9 && state.desktop { + theme.success() + } else if selected { + theme.accent() + } else { + theme.text() + }) + .bg(theme.surface()) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }); + if matches!(index, 1 | 8 | 9) && state.editing.is_none() && state.profile_input.is_none() { + spans.push(Span::styled( + "‹ ", + Style::default().fg(if selected { + theme.accent() + } else { + theme.text_dim() + }), + )); + spans.push(Span::styled(format!(" {displayed} "), value_style)); + spans.push(Span::styled( + " ›", + Style::default().fg(if selected { + theme.accent() + } else { + theme.text_dim() + }), + )); + } else { + spans.push(Span::styled(format!(" {displayed} "), value_style)); + } + lines.push(Line::from(spans)); } if state.confirm_profile_delete { lines.push(Line::from(Span::styled( @@ -1087,4 +1139,21 @@ mod tests { assert_eq!(state.draft.loader_version, None); assert!(!state.validate_before_save()); } + + #[test] + fn arrow_keys_rotate_badged_choices() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + + state.selected = 1; + state.handle_key(&KeyEvent::from(KeyCode::Right)); + assert_eq!(state.draft.loader, ModLoader::Forge); + state.handle_key(&KeyEvent::from(KeyCode::Left)); + assert_eq!(state.draft.loader, ModLoader::Fabric); + + state.selected = 9; + let desktop = state.desktop; + state.handle_key(&KeyEvent::from(KeyCode::Right)); + assert_ne!(state.desktop, desktop); + } } diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 26bb397..a401894 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -1,8 +1,20 @@ // SPDX-FileCopyrightText: 2026 Constantin Bauer // SPDX-License-Identifier: GPL-3.0-only -// compact, read-only summary of the selected instance. editing lives in the -// instance and launcher settings popups. +// settings panel: manages config profiles and shows compact instance info. +// detailed instance and launcher configuration opens in the TUI popups. + +use std::{path::PathBuf, process::Command}; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Widget}, +}; +use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView}; use crate::config::{ SETTINGS, @@ -10,24 +22,192 @@ use crate::config::{ }; use crate::instance::models::InstanceConfig; use crate::tui::app::FocusedArea; -use ratatui::{ - Frame, - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; use super::styled_title; const LOCAL_PROFILE_LABEL: &str = "instance default"; +#[derive(Default)] +pub enum AddMode { + #[default] + None, + ProfileName(String), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SettingsPane { + #[default] + Profile, + Info, +} + +pub struct SettingsState { + pub list_state: TuiListState, + pub profiles: Vec, + pub add_mode: AddMode, + pub pane: SettingsPane, + meta_dir: PathBuf, + active_profile: Option, + instance_name: Option, + java_key: Option, + java_source: Option, + java_label: String, +} + +impl SettingsState { + pub fn new(meta_dir: PathBuf) -> Self { + let profiles = crate::instance::config_sync::list_profiles(&meta_dir).unwrap_or_else(|e| { + tracing::warn!("Failed to load config sync profiles: {}", e); + Vec::new() + }); + let mut state = Self { + list_state: TuiListState::default(), + profiles, + add_mode: AddMode::None, + pane: SettingsPane::Profile, + meta_dir, + active_profile: None, + instance_name: None, + java_key: None, + java_source: None, + java_label: "unknown".to_string(), + }; + state.select_active(); + state + } + + fn reload_profiles(&mut self) { + match crate::instance::config_sync::list_profiles(&self.meta_dir) { + Ok(mut profiles) => { + add_active_profile(&mut profiles, self.active_profile.as_deref()); + self.profiles = profiles; + } + Err(e) => tracing::warn!("Failed to reload config sync profiles: {}", e), + } + } + + fn select_active(&mut self) { + self.list_state.selected = Some( + self.active_profile + .as_deref() + .and_then(|active| self.profiles.iter().position(|profile| profile == active)) + .map(|idx| idx + 1) + .unwrap_or(0), + ); + } + + fn count(&self) -> usize { + self.profiles.len() + 1 + } + + pub fn remove_profile(&mut self, profile: &str) { + self.profiles.retain(|candidate| candidate != profile); + if self.active_profile.as_deref() == Some(profile) { + self.active_profile = None; + } + let last = self.count().saturating_sub(1); + self.list_state.selected = Some(self.list_state.selected.unwrap_or(0).min(last)); + } + + fn update_for_instance(&mut self, instance: Option<&InstanceConfig>) { + let instance_name = instance.map(|inst| inst.name.clone()); + let active_profile = instance.and_then(|inst| inst.config_sync_profile.clone()); + let active_profile = active_profile + .filter(|profile| self.profiles.iter().any(|candidate| candidate == profile)); + if self.instance_name != instance_name || self.active_profile != active_profile { + self.instance_name = instance_name; + self.active_profile = active_profile; + add_active_profile(&mut self.profiles, self.active_profile.as_deref()); + self.select_active(); + } + + let java_key = instance.map(java_path_key); + if self.java_key != java_key { + let java_source = instance.map(effective_java_path); + self.java_label = java_source + .as_deref() + .map(java_version_label) + .unwrap_or_else(|| "unknown".to_string()); + self.java_key = java_key; + self.java_source = java_source; + } + } +} + +fn add_active_profile(profiles: &mut Vec, active_profile: Option<&str>) { + if let Some(active) = active_profile + && !profiles.iter().any(|profile| profile == active) + { + profiles.push(active.to_string()); + profiles.sort_unstable(); + } +} + +fn java_path_key(instance: &InstanceConfig) -> String { + instance + .java_path + .as_deref() + .filter(|path| !path.is_empty()) + .map(str::to_owned) + .or_else(|| { + SETTINGS + .read() + .paths + .effective_java_path() + .map(str::to_owned) + }) + .unwrap_or_else(|| "".to_owned()) +} + +fn effective_java_path(instance: &InstanceConfig) -> String { + instance + .java_path + .clone() + .or_else(|| { + SETTINGS + .read() + .paths + .effective_java_path() + .map(str::to_owned) + }) + .unwrap_or_else(crate::instance::java::detect_java_path) +} + +fn java_version_label(java_path: &str) -> String { + let output = Command::new(java_path).arg("-version").output(); + let Ok(output) = output else { + return "unknown".to_string(); + }; + let raw = String::from_utf8_lossy(if output.stderr.is_empty() { + &output.stdout + } else { + &output.stderr + }); + let first_line = raw.lines().next().unwrap_or_default(); + let Some(version) = first_line.split('"').nth(1) else { + return "unknown".to_string(); + }; + let major = if let Some(stripped) = version.strip_prefix("1.") { + stripped.split('.').next().unwrap_or(stripped) + } else { + version.split('.').next().unwrap_or(version) + }; + if major.is_empty() { + "unknown".to_string() + } else { + format!("jdk{major}") + } +} + pub fn render( frame: &mut Frame, area: Rect, focused: FocusedArea, + state: &mut SettingsState, instance: Option<&InstanceConfig>, ) { + state.update_for_instance(instance); + let theme = THEME.as_ref(); let color = if focused == FocusedArea::Settings { theme.accent() @@ -42,7 +222,23 @@ pub fn render( .border_style(Style::default().fg(color)); let keybind_line = if focused == FocusedArea::Settings { - let keybinds: &[(&str, &str)] = &[("E", " instance"), ("G", " launcher"), ("Esc", " back")]; + let keybinds: &[(&str, &str)] = match state.pane { + SettingsPane::Profile => &[ + ("⏎", " select"), + ("a", " add"), + ("d", " del"), + ("j/k", " move"), + ("h/l", " tab"), + ("Esc", " back"), + ], + SettingsPane::Info => &[ + ("e", " instance"), + ("g", " launcher"), + ("d", " desk"), + ("h/l", " tab"), + ("Esc", " back"), + ], + }; Some(super::popups::keybind_line_fitted( keybinds, area.width.saturating_sub(2), @@ -57,10 +253,55 @@ pub fn render( let inner = block.inner(area); frame.render_widget(block, area); - render_instance_info(frame, inner, instance); + if instance.is_none() { + frame.render_widget( + Paragraph::new("No instance selected.") + .style(Style::default().fg(THEME.as_ref().text_dim())), + inner, + ); + } else if inner.width >= 42 { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), + Constraint::Length(3), + Constraint::Percentage(50), + ]) + .split(inner); + render_profile_list(frame, chunks[0], focused, state); + render_separator(frame, chunks[1], focused, state.pane); + render_instance_info(frame, chunks[2], focused, state, instance); + } else { + render_profile_list(frame, inner, focused, state); + } + + if let AddMode::ProfileName(name) = &state.add_mode { + render_add_profile_popup(frame, name); + } } -fn render_instance_info(frame: &mut Frame, area: Rect, instance: Option<&InstanceConfig>) { +fn render_separator(frame: &mut Frame, area: Rect, focused: FocusedArea, pane: SettingsPane) { + let theme = THEME.as_ref(); + let color = if focused == FocusedArea::Settings && pane == SettingsPane::Info { + theme.accent() + } else { + theme.border() + }; + let line = if area.width >= 3 { + " │ \n".repeat(area.height as usize) + } else { + "│\n".repeat(area.height as usize) + }; + frame.render_widget(Paragraph::new(line).style(Style::default().fg(color)), area); +} + +fn render_instance_info( + frame: &mut Frame, + area: Rect, + focused: FocusedArea, + state: &SettingsState, + instance: Option<&InstanceConfig>, +) { let theme = THEME.as_ref(); let label_style = Style::default().fg(theme.text_dim()); let value_style = Style::default() @@ -84,50 +325,265 @@ fn render_instance_info(frame: &mut Frame, area: Rect, instance: Option<&Instanc .memory_max .as_deref() .unwrap_or(&settings.defaults.memory_max); - let active_style = value_style; + let active_style = if focused == FocusedArea::Settings && state.pane == SettingsPane::Info { + value_style.fg(theme.accent()) + } else { + value_style + }; let desktop = if crate::instance::desktop::exists(&inst.name) { "yes" } else { "no" }; - let java_source = if inst - .java_path - .as_deref() - .is_some_and(|path| !path.is_empty()) - { - "instance java" - } else if settings.paths.effective_java_path().is_some() { - "global java" - } else { - "auto java" - }; let lines = vec![ Line::from(vec![ - Span::styled("Version ", label_style), - Span::styled( - format!("{} / {}", inst.game_version, inst.loader), - active_style, - ), + Span::styled("Memory ", label_style), + Span::styled(format!("{memory_min} - {memory_max}"), active_style), ]), Line::from(vec![ - Span::styled("Runtime ", label_style), - Span::styled( - format!("{memory_min}-{memory_max}, {java_source}"), - active_style, - ), + Span::styled("Java ", label_style), + Span::styled(state.java_label.as_str(), active_style), ]), Line::from(vec![ - Span::styled("Profile ", label_style), - Span::styled( - inst.config_sync_profile - .as_deref() - .unwrap_or(LOCAL_PROFILE_LABEL), - active_style, - ), - Span::styled(" / desk ", label_style), + Span::styled("Desktop ", label_style), Span::styled(desktop, active_style), ]), ]; frame.render_widget(Paragraph::new(lines), area); } + +fn render_profile_list( + frame: &mut Frame, + area: Rect, + focused: FocusedArea, + state: &mut SettingsState, +) { + let is_focused = focused == FocusedArea::Settings; + let pane = state.pane; + let active_profile = state.active_profile.clone(); + let profiles = state.profiles.clone(); + let count = profiles.len() + 1; + let last = count.saturating_sub(1); + state.list_state.selected = Some(state.list_state.selected.unwrap_or(0).min(last)); + + let builder = ListBuilder::new(move |context| { + let theme = THEME.as_ref(); + let name = if context.index == 0 { + LOCAL_PROFILE_LABEL.to_string() + } else { + profiles.get(context.index - 1).cloned().unwrap_or_default() + }; + let is_active = if context.index == 0 { + active_profile.is_none() + } else { + active_profile.as_deref() == Some(name.as_str()) + }; + let show_selected = is_focused && pane == SettingsPane::Profile && context.is_selected; + let marker = if is_active { "\u{25b8} " } else { " " }; + let background = if show_selected { + theme.stripe() + } else { + theme.background() + }; + let style = if show_selected { + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD) + } else if is_active { + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text()) + }; + let line = Line::from(vec![ + Span::styled(marker, Style::default().fg(theme.success())), + Span::styled(name, style), + ]); + ( + ratatui::text::Text::from(line).style(Style::default().bg(background)), + 1, + ) + }); + + frame.render_stateful_widget(ListView::new(builder, count), area, &mut state.list_state); +} + +fn render_add_profile_popup(frame: &mut Frame, name: &str) { + use super::popups::{base::PopupFrame, keybind_line}; + let theme = THEME.as_ref(); + let area = popup_area(frame, 42, 5); + let name = name.to_string(); + + let border_color = theme.text_dim(); + let bg_color = theme.surface(); + let dim_color = theme.text_dim(); + let text_color = theme.text(); + + PopupFrame { + title: Line::from(Span::styled( + " Config Profile ", + Style::default() + .fg(border_color) + .add_modifier(Modifier::BOLD), + )) + .centered(), + border_color, + bg: Some(bg_color), + keybinds: Some(keybind_line(&[("Enter", " create"), ("Esc", " cancel")])), + search_line: None, + content: Box::new(move |inner, buf| { + let line = if name.is_empty() { + Line::from(vec![ + Span::styled("Profile name...", Style::default().fg(dim_color)), + Span::styled( + "\u{2588}", + Style::default() + .fg(border_color) + .add_modifier(Modifier::SLOW_BLINK), + ), + ]) + } else { + Line::from(vec![ + Span::styled(name.as_str(), Style::default().fg(text_color)), + Span::styled( + "\u{2588}", + Style::default() + .fg(border_color) + .add_modifier(Modifier::SLOW_BLINK), + ), + ]) + }; + Paragraph::new(line).render(inner, buf); + }), + } + .render(area, frame.buffer_mut()); +} + +fn popup_area(frame: &Frame, width: u16, height: u16) -> Rect { + let area = frame.area(); + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + Rect { + x, + y, + width: width.min(area.width), + height: height.min(area.height), + } +} + +pub enum SettingsAction { + None, + OpenInstance, + OpenGlobal, + ToggleDesktop, + SelectProfile(Option), + ConfirmDeleteProfile(String), + Error(String), +} + +pub fn handle_key( + key_event: &KeyEvent, + state: &mut SettingsState, + instance: Option<&InstanceConfig>, +) -> SettingsAction { + if let AddMode::ProfileName(name) = &state.add_mode { + match key_event.code { + KeyCode::Enter => { + let name = name.trim().to_string(); + state.add_mode = AddMode::None; + if name.is_empty() { + return SettingsAction::None; + } + return match crate::instance::config_sync::create_profile(&state.meta_dir, &name) { + Ok(profile) => { + state.reload_profiles(); + state.active_profile = Some(profile); + state.select_active(); + SettingsAction::SelectProfile(state.active_profile.clone()) + } + Err(e) => SettingsAction::Error(e.to_string()), + }; + } + KeyCode::Esc => { + state.add_mode = AddMode::None; + return SettingsAction::None; + } + KeyCode::Backspace => { + let mut new_name = name.clone(); + super::search::backspace(&mut new_name, key_event.modifiers); + state.add_mode = AddMode::ProfileName(new_name); + return SettingsAction::None; + } + KeyCode::Char(c) => { + let mut new_name = name.clone(); + new_name.push(c); + state.add_mode = AddMode::ProfileName(new_name); + return SettingsAction::None; + } + _ => return SettingsAction::None, + } + } + + match key_event.code { + KeyCode::Char('h') | KeyCode::Left => { + state.pane = SettingsPane::Profile; + SettingsAction::None + } + KeyCode::Char('l') | KeyCode::Right => { + state.pane = SettingsPane::Info; + SettingsAction::None + } + KeyCode::Enter if state.pane == SettingsPane::Profile => { + let selected = state.list_state.selected.unwrap_or(0); + let profile = if selected == 0 { + None + } else { + state.profiles.get(selected - 1).cloned() + }; + SettingsAction::SelectProfile(profile) + } + KeyCode::Char('a') if state.pane == SettingsPane::Profile => { + state.add_mode = AddMode::ProfileName(String::new()); + SettingsAction::None + } + KeyCode::Char('d') if state.pane == SettingsPane::Profile => { + let selected = state.list_state.selected.unwrap_or(0); + if selected == 0 { + SettingsAction::None + } else if let Some(profile) = state.profiles.get(selected - 1) { + SettingsAction::ConfirmDeleteProfile(profile.clone()) + } else { + SettingsAction::None + } + } + KeyCode::Char('j') | KeyCode::Down if state.pane == SettingsPane::Profile => { + let count = state.count(); + if count > 0 { + let cur = state.list_state.selected.unwrap_or(0); + state.list_state.selected = Some((cur + 1).min(count - 1)); + } + SettingsAction::None + } + KeyCode::Char('k') | KeyCode::Up if state.pane == SettingsPane::Profile => { + let cur = state.list_state.selected.unwrap_or(0); + state.list_state.selected = Some(cur.saturating_sub(1)); + SettingsAction::None + } + KeyCode::Char('e') if state.pane == SettingsPane::Info => { + if instance.is_some() { + SettingsAction::OpenInstance + } else { + SettingsAction::None + } + } + KeyCode::Char('g') if state.pane == SettingsPane::Info => SettingsAction::OpenGlobal, + KeyCode::Char('d') if state.pane == SettingsPane::Info => SettingsAction::ToggleDesktop, + _ => SettingsAction::None, + } +} + +#[cfg(test)] +#[path = "../tests/widgets/settings.rs"] +mod tests; From 0b6618c66124a30bb30cdf11db24b4ccd47ebd83 Mon Sep 17 00:00:00 2001 From: objz Date: Thu, 3 Sep 2026 21:14:17 +0200 Subject: [PATCH 03/42] feat: redesign settings popup controls --- src/tui/render.rs | 4 +- src/tui/tests/flows.rs | 17 +- src/tui/widgets/popups/global_settings.rs | 564 ++++++++++++---- src/tui/widgets/popups/instance_settings.rs | 704 ++++++++++++++------ 4 files changed, 968 insertions(+), 321 deletions(-) diff --git a/src/tui/render.rs b/src/tui/render.rs index f087519..8ff2d29 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -152,14 +152,14 @@ impl App { if self.focused == FocusedArea::InstanceSettings && let Some(state) = self.instance_settings.as_ref() { - let area = widgets::popups::instance_settings::popup_rect(frame.area()); + let area = widgets::popups::instance_settings::popup_rect(frame.area(), state); widgets::popups::instance_settings::render(frame, area, state); } if self.focused == FocusedArea::GlobalSettings && let Some(state) = self.global_settings.as_ref() { - let area = widgets::popups::instance_settings::popup_rect(frame.area()); + let area = widgets::popups::global_settings::popup_rect(frame.area(), state); widgets::popups::global_settings::render(frame, area, state); } diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index d96632b..c5ecdf9 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -580,15 +580,17 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { ui.key(KeyCode::Char('e')); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); ui.draw(); - assert!(ui.screen().contains("Instance settings: settings-test")); - assert!(ui.screen().contains("Game version")); - assert!(ui.screen().contains("Desktop shortcut")); + assert!(ui.screen().contains("Instance Settings")); + assert!(ui.screen().contains("settings-test")); + assert!(ui.screen().contains("Runtime")); + assert!(ui.screen().contains("Version")); + assert!(ui.screen().contains("Desktop")); assert!(ui.screen().contains('‹')); assert!(ui.screen().contains('›')); ui.key(KeyCode::Down); ui.key(KeyCode::Enter); ui.draw(); - assert!(ui.screen().contains("Select loader")); + assert!(ui.screen().contains("Loader")); assert!(ui.screen().contains("Fabric")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); @@ -597,13 +599,14 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { ui.key(KeyCode::Char('g')); assert_eq!(ui.app.focused, FocusedArea::GlobalSettings); ui.draw(); - assert!(ui.screen().contains("Launcher settings")); - assert!(ui.screen().contains("Default memory max")); + assert!(ui.screen().contains("Launcher Settings")); + assert!(ui.screen().contains("Launch Defaults")); + assert!(ui.screen().contains("Max memory")); assert!(ui.screen().contains('‹')); assert!(ui.screen().contains('›')); ui.key(KeyCode::Enter); ui.draw(); - assert!(ui.screen().contains("Select theme")); + assert!(ui.screen().contains("Themes")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Settings); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index a5f49d2..0fd672a 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use ratatui_textarea::{CursorMove, TextArea}; @@ -21,6 +21,14 @@ use crate::{ instance::models::normalize_memory_value, }; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PresetPicker { + Border, + MemoryMin, + MemoryMax, + Java, +} + pub struct State { pub config: Config, pub theme: ThemeConfig, @@ -32,6 +40,9 @@ pub struct State { themes: Vec, theme_picker: bool, theme_index: usize, + preset_picker: Option, + preset_index: usize, + detected_java: String, } pub enum Action { @@ -60,6 +71,9 @@ impl State { themes, theme_picker: false, theme_index, + preset_picker: None, + preset_index: 0, + detected_java: crate::instance::java::detect_java_path(), } } @@ -82,7 +96,15 @@ impl State { BorderStyle::Double => "╔═╗ double".to_owned(), BorderStyle::Thick => "┏━┓ thick".to_owned(), }, - 4 if self.config.paths.java_path.is_none() => "auto-detect".to_owned(), + 4 if self + .config + .paths + .java_path + .as_deref() + .is_none_or(str::is_empty) => + { + "auto-detect".to_owned() + } _ => self.value(field), } } @@ -151,6 +173,150 @@ impl State { } } + fn preset_values_for(&self, picker: PresetPicker) -> Vec { + match picker { + PresetPicker::Border => vec![ + "╭─╮ rounded".to_owned(), + "┌─┐ plain".to_owned(), + "╔═╗ double".to_owned(), + "┏━┓ thick".to_owned(), + ], + PresetPicker::MemoryMin | PresetPicker::MemoryMax => { + let current = if picker == PresetPicker::MemoryMin { + &self.config.defaults.memory_min + } else { + &self.config.defaults.memory_max + }; + memory_choices(Some(current)) + } + PresetPicker::Java => { + let mut values = vec!["automatic detection".to_owned()]; + if !self.detected_java.is_empty() { + values.push(self.detected_java.clone()); + } + if let Some(current) = &self.config.paths.java_path + && !values.contains(current) + { + values.push(current.clone()); + } + values.push("custom path…".to_owned()); + values + } + } + } + + fn preset_values(&self) -> Vec { + self.preset_picker + .map_or_else(Vec::new, |picker| self.preset_values_for(picker)) + } + + fn open_preset_picker(&mut self, picker: PresetPicker) { + self.preset_picker = Some(picker); + let current = match picker { + PresetPicker::Border => None, + PresetPicker::MemoryMin => Some(self.config.defaults.memory_min.as_str()), + PresetPicker::MemoryMax => Some(self.config.defaults.memory_max.as_str()), + PresetPicker::Java => self.config.paths.java_path.as_deref(), + }; + self.preset_index = self + .preset_values_for(picker) + .iter() + .position(|value| match (picker, current) { + (PresetPicker::Border, _) => value == &self.display_value(1), + (PresetPicker::Java, None) => value == "automatic detection", + (_, Some(current)) => value == current, + _ => false, + }) + .unwrap_or(0); + } + + fn apply_preset(&mut self) { + let selected = self + .preset_values() + .get(self.preset_index) + .cloned() + .unwrap_or_default(); + match self.preset_picker { + Some(PresetPicker::Border) => { + let previous = self.theme.border_style.clone(); + self.theme.border_style = match self.preset_index { + 0 => BorderStyle::Rounded, + 1 => BorderStyle::Plain, + 2 => BorderStyle::Double, + _ => BorderStyle::Thick, + }; + self.error = None; + if let Err(error) = crate::config::theme::apply_theme( + self.theme.theme.clone(), + self.theme.border_style.clone(), + ) { + self.error = Some(error.to_string()); + self.theme.border_style = previous; + } + } + Some(PresetPicker::MemoryMin | PresetPicker::MemoryMax) + if selected == "custom memory…" => + { + let field = if self.preset_picker == Some(PresetPicker::MemoryMin) { + 2 + } else { + 3 + }; + self.editing = Some(new_text_area(vec![self.value(field)])); + } + Some(PresetPicker::MemoryMin) => { + if let Some(value) = normalize_memory_value(&selected) { + self.config.defaults.memory_min = value; + self.config_dirty = true; + } + } + Some(PresetPicker::MemoryMax) => { + if let Some(value) = normalize_memory_value(&selected) { + self.config.defaults.memory_max = value; + self.config_dirty = true; + } + } + Some(PresetPicker::Java) if selected == "custom path…" => { + self.editing = Some(new_text_area(vec![self.value(4)])); + } + Some(PresetPicker::Java) => { + self.config.paths.java_path = + (selected != "automatic detection").then_some(selected); + self.config_dirty = true; + } + None => {} + } + self.preset_picker = None; + } + + fn handle_preset_key(&mut self, key: &KeyEvent) { + let count = self.preset_values().len(); + match key.code { + KeyCode::Esc => self.preset_picker = None, + KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { + self.preset_index = (self.preset_index + 1).min(count - 1); + } + KeyCode::Char('k') | KeyCode::Up | KeyCode::Left => { + self.preset_index = self.preset_index.saturating_sub(1); + } + KeyCode::Enter => self.apply_preset(), + _ => {} + } + } + + fn rotate_preset(&mut self, picker: PresetPicker, forward: bool) { + self.open_preset_picker(picker); + let count = self.preset_values().len().saturating_sub(1); + if count > 0 { + self.preset_index = if forward { + (self.preset_index + 1) % count + } else { + (self.preset_index + count - 1) % count + }; + self.apply_preset(); + } + } + fn cycle_theme(&mut self, forward: bool) { let count = self.themes.len(); if count == 0 { @@ -193,6 +359,10 @@ impl State { } pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + if self.preset_picker.is_some() { + self.handle_preset_key(key); + return Action::None; + } if self.theme_picker { self.handle_theme_picker_key(key); return Action::None; @@ -221,17 +391,26 @@ impl State { KeyCode::Left => match self.selected { 0 => self.cycle_theme(false), 1 => self.cycle_border(false), + 2 => self.rotate_preset(PresetPicker::MemoryMin, false), + 3 => self.rotate_preset(PresetPicker::MemoryMax, false), + 4 => self.rotate_preset(PresetPicker::Java, false), _ => {} }, KeyCode::Right => match self.selected { 0 => self.cycle_theme(true), 1 => self.cycle_border(true), + 2 => self.rotate_preset(PresetPicker::MemoryMin, true), + 3 => self.rotate_preset(PresetPicker::MemoryMax, true), + 4 => self.rotate_preset(PresetPicker::Java, true), _ => {} }, KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, - 1 => self.cycle_border(true), - field => self.editing = Some(new_text_area(vec![self.value(field)])), + 1 => self.open_preset_picker(PresetPicker::Border), + 2 => self.open_preset_picker(PresetPicker::MemoryMin), + 3 => self.open_preset_picker(PresetPicker::MemoryMax), + 4 => self.open_preset_picker(PresetPicker::Java), + _ => {} }, KeyCode::Char('s') => { if self.validate_before_save() { @@ -305,6 +484,20 @@ fn available_themes() -> Vec { themes } +fn memory_choices(current: Option<&String>) -> Vec { + let mut values = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"] + .into_iter() + .map(str::to_owned) + .collect::>(); + if let Some(current) = current + && !values.contains(current) + { + values.push(current.clone()); + } + values.push("custom memory…".to_owned()); + values +} + fn new_text_area(lines: Vec) -> TextArea<'static> { let theme = THEME.as_ref(); let mut editor = TextArea::new(if lines.is_empty() { @@ -320,165 +513,288 @@ fn new_text_area(lines: Vec) -> TextArea<'static> { editor } +pub fn popup_rect(area: Rect, state: &State) -> Rect { + let height = if state.theme_picker || state.preset_picker.is_some() || state.editing.is_some() { + 14 + } else { + 12 + }; + area.centered( + ratatui::layout::Constraint::Percentage(68), + ratatui::layout::Constraint::Length(height.min(area.height.saturating_sub(4))), + ) +} + pub fn render(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); frame.render_widget(Clear, area); - let keybinds = if state.theme_picker { + let keybinds = if state.theme_picker || state.preset_picker.is_some() { super::keybind_line(&[("j/k", " move"), ("Enter", " apply"), ("Esc", " back")]) } else { super::keybind_line(&[ - ("j/k", " field"), - ("Enter", " edit"), + ("j/k", ""), + ("Enter", " open"), ("←/→", " switch"), ("s", " save"), ("E", " raw"), ("Esc", " back"), ]) }; + let mut title = vec![Span::styled( + " Launcher Settings ", + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + )]; + if state.config_dirty { + title.push(Span::styled( + "● modified ", + Style::default().fg(theme.warning()), + )); + } let block = Block::default() - .title(if state.config_dirty { - " Launcher settings * " - } else { - " Launcher settings " - }) + .title(Line::from(title)) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().bg(theme.background())) + .border_style(Style::default().fg(theme.text_dim())) + .style(Style::default().bg(theme.surface())) .title_bottom(keybinds); let inner = block.inner(area); frame.render_widget(block, area); if state.theme_picker { - let mut lines = vec![Line::from(Span::styled( - "Select theme", - Style::default() - .fg(theme.text()) - .add_modifier(Modifier::BOLD), - ))]; - let visible_rows = inner.height.saturating_sub(1) as usize; - let start = state - .theme_index - .saturating_sub(visible_rows.saturating_sub(1)); - for (index, name) in state - .themes - .iter() - .enumerate() - .skip(start) - .take(visible_rows) - { - let selected = index == state.theme_index; - lines.push(Line::from(vec![ + render_picker(frame, inner, " Themes ", &state.themes, state.theme_index); + return; + } + if let Some(picker) = state.preset_picker { + let title = match picker { + PresetPicker::Border => " Border Style ", + PresetPicker::MemoryMin => " Minimum Memory ", + PresetPicker::MemoryMax => " Maximum Memory ", + PresetPicker::Java => " Java Runtime ", + }; + render_picker( + frame, + inner, + title, + &state.preset_values(), + state.preset_index, + ); + return; + } + + let sections = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Length(4), + ratatui::layout::Constraint::Length(5), + ratatui::layout::Constraint::Min(1), + ]) + .split(inner); + render_global_card( + frame, + sections[0], + " Appearance ", + state, + &[(0, "Theme"), (1, "Borders")], + ); + render_global_card( + frame, + sections[1], + " Launch Defaults ", + state, + &[(2, "Min memory"), (3, "Max memory"), (4, "Java")], + ); + + if let Some(editor) = state.editing.as_ref() { + let editor_block = Block::default() + .title(match state.selected { + 2 => " Custom minimum memory (K/M/G) · Enter apply ", + 3 => " Custom maximum memory (K/M/G) · Enter apply ", + 4 => " Custom Java executable path · Enter apply ", + _ => " Custom value · Enter apply ", + }) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.surface())); + let editor_inner = editor_block.inner(sections[2]); + frame.render_widget(editor_block, sections[2]); + frame.render_widget(editor, editor_inner); + } else { + let status = if let Some(error) = &state.error { + Line::from(Span::styled( + format!(" × {error}"), + Style::default().fg(theme.error()), + )) + } else if state.confirm_close { + Line::from(Span::styled( + " ! Discard unsaved launcher defaults? [y] yes [n] no", + Style::default().fg(theme.warning()), + )) + } else { + Line::from(vec![ + Span::styled(" ◇ Live preview ", Style::default().fg(theme.info())), Span::styled( - if selected { "▸ " } else { " " }, + "theme and border changes apply immediately", + Style::default().fg(theme.text_dim()), + ), + ]) + }; + frame.render_widget( + Paragraph::new(status).wrap(Wrap { trim: true }), + sections[2], + ); + } +} + +fn render_picker( + frame: &mut Frame, + area: Rect, + title: &'static str, + values: &[String], + selected: usize, +) { + let theme = THEME.as_ref(); + let block = Block::default() + .title(Span::styled(title, Style::default().fg(theme.accent()))) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.surface())); + let inner = block.inner(area); + frame.render_widget(block, area); + let visible_rows = inner.height as usize; + let start = selected.saturating_sub(visible_rows.saturating_sub(1)); + let lines = values + .iter() + .enumerate() + .skip(start) + .take(visible_rows) + .map(|(index, name)| { + let focused = index == selected; + Line::from(vec![ + Span::styled( + if focused { "▶ " } else { " " }, Style::default().fg(theme.accent()), ), Span::styled( name.clone(), Style::default() - .fg(if selected { + .fg(if focused { theme.accent() } else { theme.text() }) - .add_modifier(if selected { + .add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() }), ), - ])); - } - frame.render_widget(Paragraph::new(lines), inner); - return; - } - let labels = [ - "Theme", - "Border style", - "Default memory min", - "Default memory max", - "Java path", - ]; - let mut lines = Vec::new(); - for (index, label) in labels.iter().enumerate() { - let selected = index == state.selected; - let displayed = if selected { - state.editing.as_ref().map_or_else( - || state.display_value(index), - |_| "editing below".to_owned(), - ) - } else { - state.display_value(index) - }; - let mut spans = vec![ - Span::styled( - if selected { "▸ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - format!("{label:<20}"), - Style::default().fg(theme.text_dim()), - ), - ]; - let value_style = Style::default() - .fg(if selected { - theme.accent() + ]) + .style(Style::default().bg(if focused { + theme.stripe() } else { - theme.text() - }) - .bg(theme.surface()) - .add_modifier(if selected { - Modifier::BOLD + theme.surface() + })) + }) + .collect::>(); + frame.render_widget(Paragraph::new(lines), inner); +} + +fn render_global_card( + frame: &mut Frame, + area: Rect, + title: &'static str, + state: &State, + fields: &[(usize, &str)], +) { + let theme = THEME.as_ref(); + let active = fields.iter().any(|(index, _)| *index == state.selected); + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(if active { + theme.accent() } else { - Modifier::empty() - }); - if index <= 1 && state.editing.is_none() { - spans.push(Span::styled( - "‹ ", - Style::default().fg(if selected { + theme.text_dim() + }), + )) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(if active { + theme.accent() + } else { + theme.border() + })) + .style(Style::default().bg(theme.surface())); + let inner = block.inner(area); + frame.render_widget(block, area); + let lines = fields + .iter() + .map(|(index, label)| global_field_line(state, *index, label)) + .collect::>(); + frame.render_widget(Paragraph::new(lines), inner); +} + +fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { + let theme = THEME.as_ref(); + let selected = index == state.selected; + let displayed = state.display_value(index); + Line::from(vec![ + Span::styled( + if selected { "▶ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{label:<13}"), + Style::default().fg(theme.text_dim()), + ), + Span::styled( + format!(" ‹ {displayed} › "), + Style::default() + .fg(if selected { theme.accent() } else { - theme.text_dim() - }), - )); - spans.push(Span::styled(format!(" {displayed} "), value_style)); - spans.push(Span::styled( - " ›", - Style::default().fg(if selected { - theme.accent() + theme.text() + }) + .bg(theme.background()) + .add_modifier(if selected { + Modifier::BOLD } else { - theme.text_dim() + Modifier::empty() }), - )); - } else { - spans.push(Span::styled(format!(" {displayed} "), value_style)); - } - lines.push(Line::from(spans)); - } - if let Some(error) = &state.error { - lines.push(Line::from(Span::styled( - error, - Style::default().fg(theme.error()), - ))); - } else if state.confirm_close { - lines.push(Line::from(Span::styled( - "Discard unsaved launcher defaults? [y] yes [n] no", - Style::default().fg(theme.warning()), - ))); - } - frame.render_widget(Paragraph::new(lines), inner); - if let Some(editor) = state.editing.as_ref() { - let editor_area = Rect { - x: inner.x, - y: inner.y.saturating_add(7), - width: inner.width, - height: 3.min(inner.height.saturating_sub(7)).max(1), - }; - let editor_block = Block::default() - .title(" Value — Enter applies ") - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.accent())); - let editor_inner = editor_block.inner(editor_area); - frame.render_widget(editor_block, editor_area); - frame.render_widget(editor, editor_inner); + ), + ]) + .style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launcher_defaults_open_purpose_built_pickers() { + let mut state = State::new(); + state.selected = 2; + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.preset_picker, Some(PresetPicker::MemoryMin)); + state.preset_index = state + .preset_values() + .iter() + .position(|value| value == "4G") + .unwrap(); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.config.defaults.memory_min, "4G"); + assert!(state.editing.is_none()); + + state.selected = 4; + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.preset_picker, Some(PresetPicker::Java)); + assert!(state.preset_values().contains(&"custom path…".to_owned())); } } diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 82bcb0f..1b4f674 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use ratatui_textarea::{CursorMove, TextArea}; use std::sync::{Arc, Mutex}; @@ -36,6 +36,10 @@ enum VersionPicker { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChoicePicker { Loader, + Java, + MemoryMin, + MemoryMax, + Resolution, Profile, } @@ -70,6 +74,7 @@ pub struct State { loader_versions: SharedLoad>, choice_picker: Option, choice_index: usize, + detected_java: String, } pub enum Action { @@ -105,6 +110,7 @@ impl State { loader_versions: Arc::new(Mutex::new(LoadState::Idle)), choice_picker: None, choice_index: 0, + detected_java: crate::instance::java::detect_java_path(), } } @@ -190,6 +196,10 @@ impl State { self.error = Some("Vanilla does not use a loader version".to_owned()); } 2 => self.open_loader_picker(), + 3 => self.open_choice_picker(ChoicePicker::Java), + 4 => self.open_choice_picker(ChoicePicker::MemoryMin), + 5 => self.open_choice_picker(ChoicePicker::MemoryMax), + 7 => self.open_choice_picker(ChoicePicker::Resolution), 8 => self.open_choice_picker(ChoicePicker::Profile), 9 => self.desktop = !self.desktop, 6 => self.editing = Some(new_text_area(self.draft.jvm_args.clone())), @@ -204,6 +214,32 @@ impl State { .iter() .position(|loader| *loader == self.draft.loader) .unwrap_or(0), + ChoicePicker::Java => self + .choice_values_for(picker) + .iter() + .position(|value| { + self.draft.java_path.as_deref().map_or_else( + || value == "automatic / launcher default", + |current| value == current, + ) + }) + .unwrap_or(0), + ChoicePicker::MemoryMin => { + self.memory_choice_index(picker, self.draft.memory_min.as_deref()) + } + ChoicePicker::MemoryMax => { + self.memory_choice_index(picker, self.draft.memory_max.as_deref()) + } + ChoicePicker::Resolution => self + .choice_values_for(picker) + .iter() + .position(|value| { + self.draft.resolution.map_or_else( + || value == "window default", + |(width, height)| value == &format!("{width}x{height}"), + ) + }) + .unwrap_or(0), ChoicePicker::Profile => self .draft .config_sync_profile @@ -214,15 +250,67 @@ impl State { } fn choice_values(&self) -> Vec { - match self.choice_picker { - Some(ChoicePicker::Loader) => loaders().iter().map(ToString::to_string).collect(), - Some(ChoicePicker::Profile) => std::iter::once("instance default".to_owned()) + self.choice_picker + .map_or_else(Vec::new, |picker| self.choice_values_for(picker)) + } + + fn choice_values_for(&self, picker: ChoicePicker) -> Vec { + match picker { + ChoicePicker::Loader => loaders().iter().map(ToString::to_string).collect(), + ChoicePicker::Java => { + let mut values = vec!["automatic / launcher default".to_owned()]; + if !self.detected_java.is_empty() { + values.push(self.detected_java.clone()); + } + if let Some(current) = &self.draft.java_path + && !values.contains(current) + { + values.push(current.clone()); + } + values.push("custom path…".to_owned()); + values + } + ChoicePicker::MemoryMin | ChoicePicker::MemoryMax => { + let current = if picker == ChoicePicker::MemoryMin { + self.draft.memory_min.as_ref() + } else { + self.draft.memory_max.as_ref() + }; + memory_choices(current, true) + } + ChoicePicker::Resolution => { + let mut values = vec![ + "window default".to_owned(), + "854x480".to_owned(), + "1280x720".to_owned(), + "1600x900".to_owned(), + "1920x1080".to_owned(), + "2560x1440".to_owned(), + ]; + if let Some((width, height)) = self.draft.resolution { + let current = format!("{width}x{height}"); + if !values.contains(¤t) { + values.push(current); + } + } + values.push("custom resolution…".to_owned()); + values + } + ChoicePicker::Profile => std::iter::once("instance default".to_owned()) .chain(self.profiles.iter().cloned()) .collect(), - None => Vec::new(), } } + fn memory_choice_index(&self, picker: ChoicePicker, current: Option<&str>) -> usize { + self.choice_values_for(picker) + .iter() + .position(|value| { + current.map_or(value == "launcher default", |current| value == current) + }) + .unwrap_or(0) + } + fn handle_choice_key(&mut self, key: &KeyEvent) { let count = self.choice_values().len(); match key.code { @@ -239,6 +327,11 @@ impl State { } fn apply_choice(&mut self) { + let selected = self + .choice_values() + .get(self.choice_index) + .cloned() + .unwrap_or_default(); match self.choice_picker { Some(ChoicePicker::Loader) => { let available = loaders(); @@ -250,6 +343,41 @@ impl State { self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); } } + Some(ChoicePicker::Java) if selected == "custom path…" => { + self.editing = Some(new_text_area(vec![self.value(3)])); + } + Some(ChoicePicker::Java) => { + self.draft.java_path = + (selected != "automatic / launcher default").then_some(selected); + } + Some(ChoicePicker::MemoryMin | ChoicePicker::MemoryMax) + if selected == "custom memory…" => + { + let field = if self.choice_picker == Some(ChoicePicker::MemoryMin) { + 4 + } else { + 5 + }; + self.editing = Some(new_text_area(vec![self.value(field)])); + } + Some(ChoicePicker::MemoryMin) => { + self.draft.memory_min = (selected != "launcher default") + .then(|| normalize_memory_value(&selected)) + .flatten(); + } + Some(ChoicePicker::MemoryMax) => { + self.draft.memory_max = (selected != "launcher default") + .then(|| normalize_memory_value(&selected)) + .flatten(); + } + Some(ChoicePicker::Resolution) if selected == "custom resolution…" => { + self.editing = Some(new_text_area(vec![self.value(7)])); + } + Some(ChoicePicker::Resolution) => { + self.draft.resolution = (selected != "window default") + .then(|| parse_resolution(&selected).ok()) + .flatten(); + } Some(ChoicePicker::Profile) => { self.draft.config_sync_profile = self .choice_index @@ -263,7 +391,16 @@ impl State { fn rotate_choice(&mut self, picker: ChoicePicker, forward: bool) { self.open_choice_picker(picker); - let count = self.choice_values().len(); + let count = self + .choice_values() + .len() + .saturating_sub(usize::from(matches!( + picker, + ChoicePicker::Java + | ChoicePicker::MemoryMin + | ChoicePicker::MemoryMax + | ChoicePicker::Resolution + ))); if count > 0 { self.choice_index = if forward { (self.choice_index + 1) % count @@ -583,12 +720,20 @@ impl State { KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), KeyCode::Left => match self.selected { 1 => self.rotate_choice(ChoicePicker::Loader, false), + 3 => self.rotate_choice(ChoicePicker::Java, false), + 4 => self.rotate_choice(ChoicePicker::MemoryMin, false), + 5 => self.rotate_choice(ChoicePicker::MemoryMax, false), + 7 => self.rotate_choice(ChoicePicker::Resolution, false), 8 => self.rotate_choice(ChoicePicker::Profile, false), 9 => self.desktop = !self.desktop, _ => {} }, KeyCode::Right => match self.selected { 1 => self.rotate_choice(ChoicePicker::Loader, true), + 3 => self.rotate_choice(ChoicePicker::Java, true), + 4 => self.rotate_choice(ChoicePicker::MemoryMin, true), + 5 => self.rotate_choice(ChoicePicker::MemoryMax, true), + 7 => self.rotate_choice(ChoicePicker::Resolution, true), 8 => self.rotate_choice(ChoicePicker::Profile, true), 9 => self.desktop = !self.desktop, _ => {} @@ -656,6 +801,25 @@ fn loaders() -> [ModLoader; 5] { ] } +fn memory_choices(current: Option<&String>, include_default: bool) -> Vec { + let mut values = Vec::new(); + if include_default { + values.push("launcher default".to_owned()); + } + values.extend( + ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"] + .into_iter() + .map(str::to_owned), + ); + if let Some(current) = current + && !values.contains(current) + { + values.push(current.clone()); + } + values.push("custom memory…".to_owned()); + values +} + fn new_text_area(lines: Vec) -> TextArea<'static> { let theme = THEME.as_ref(); let mut editor = TextArea::new(if lines.is_empty() { @@ -671,33 +835,43 @@ fn new_text_area(lines: Vec) -> TextArea<'static> { editor } -pub fn popup_rect(area: Rect) -> Rect { - let vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(8), - Constraint::Percentage(84), - Constraint::Percentage(8), - ]) - .split(area); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(12), - Constraint::Percentage(76), - Constraint::Percentage(12), - ]) - .split(vertical[1])[1] +pub fn popup_rect(area: Rect, state: &State) -> Rect { + let height = if state.picker.is_some() || state.choice_picker.is_some() { + 19 + } else if state.selected == 6 && state.editing.is_some() { + 18 + } else if state.editing.is_some() || state.profile_input.is_some() { + 14 + } else { + 12 + }; + area.centered( + Constraint::Percentage(76), + Constraint::Length(height.min(area.height.saturating_sub(4))), + ) } pub fn render(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); frame.render_widget(Clear, area); - let title = format!( - " Instance settings: {} {}", - state.draft.name, - if state.dirty() { "*" } else { "" } - ); + let mut title = vec![ + Span::styled( + " Instance Settings ", + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("· {} ", state.draft.name), + Style::default().fg(theme.text_dim()), + ), + ]; + if state.dirty() { + title.push(Span::styled( + "● modified ", + Style::default().fg(theme.warning()), + )); + } let keybinds = if state.picker.is_some() { super::keybind_line(&[ ("j/k", " move"), @@ -708,10 +882,20 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { ]) } else if state.choice_picker.is_some() { super::keybind_line(&[("j/k", " move"), ("Enter", " select"), ("Esc", " back")]) + } else if state.selected == 8 { + super::keybind_line(&[ + ("j/k", ""), + ("Enter", " choose"), + ("a", " add"), + ("d", " delete"), + ("s", " save"), + ("E", " raw"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ - ("j/k", " field"), - ("Enter", " edit"), + ("j/k", ""), + ("Enter", " open"), ("←/→", " switch"), ("s", " save"), ("E", " raw"), @@ -719,11 +903,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { ]) }; let block = Block::default() - .title(title) + .title(Line::from(title)) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().bg(theme.background())) + .border_style(Style::default().fg(theme.text_dim())) + .style(Style::default().bg(theme.surface())) .title_bottom(keybinds); let inner = block.inner(area); frame.render_widget(block, area); @@ -737,177 +921,283 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { return; } - let labels = [ - "Game version", - "Loader", - "Loader version", - "Java", - "Memory min", - "Memory max", - "JVM args", - "Resolution", - "Config profile", - "Desktop shortcut", - ]; - let mut lines = Vec::with_capacity(FIELD_COUNT + 2); - for (index, label) in labels.iter().enumerate() { - let selected = index == state.selected; - let marker = if selected { "▸ " } else { " " }; - let displayed = if selected && state.profile_input.is_some() { - "new profile (editing below)".to_owned() - } else if selected && state.editing.is_some() { - "editing below".to_owned() - } else { - state.display_value(index) - }; - let mut spans = vec![ - Span::styled(marker, Style::default().fg(theme.accent())), - Span::styled( - format!("{label:<18}"), - Style::default().fg(theme.text_dim()), - ), - ]; - let value_style = Style::default() - .fg(if index == 9 && state.desktop { - theme.success() - } else if selected { + render_settings_form(frame, inner, state); +} + +fn render_settings_form(frame: &mut Frame, area: Rect, state: &State) { + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), + Constraint::Length(3), + Constraint::Min(1), + ]) + .split(area); + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(sections[0]); + + render_field_card( + frame, + top[0], + " Runtime ", + state, + &[ + (0, "Version"), + (1, "Loader"), + (2, "Loader ver."), + (3, "Java"), + ], + ); + render_field_card( + frame, + top[1], + " Launch ", + state, + &[ + (4, "Min memory"), + (5, "Max memory"), + (6, "JVM args"), + (7, "Resolution"), + ], + ); + + let integration_block = settings_card(" Integration ", matches!(state.selected, 8 | 9)); + let integration_inner = integration_block.inner(sections[1]); + frame.render_widget(integration_block, sections[1]); + let integration = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(integration_inner); + frame.render_widget( + Paragraph::new(field_line(state, 8, "Profile", 9)), + integration[0], + ); + frame.render_widget( + Paragraph::new(field_line(state, 9, "Desktop", 9)), + integration[1], + ); + + if let Some(editor) = state.editing.as_ref().or(state.profile_input.as_ref()) { + let theme = THEME.as_ref(); + let editor_block = Block::default() + .title(match (state.selected, state.profile_input.is_some()) { + (_, true) => " New profile name · Enter create ", + (3, false) => " Custom Java executable path · Enter apply ", + (4, false) => " Custom minimum memory (K/M/G) · Enter apply ", + (5, false) => " Custom maximum memory (K/M/G) · Enter apply ", + (6, false) => " JVM arguments · one per line · Ctrl+Enter apply ", + (7, false) => " Custom resolution (WIDTHxHEIGHT) · Enter apply ", + _ => " Value · Enter apply ", + }) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.surface())); + let editor_inner = editor_block.inner(sections[2]); + frame.render_widget(editor_block, sections[2]); + frame.render_widget(editor, editor_inner); + } else { + frame.render_widget( + Paragraph::new(status_line(state)).wrap(Wrap { trim: true }), + sections[2], + ); + } +} + +fn settings_card(title: &'static str, active: bool) -> Block<'static> { + let theme = THEME.as_ref(); + Block::default() + .title(Span::styled( + title, + Style::default().fg(if active { theme.accent() } else { - theme.text() - }) - .bg(theme.surface()) - .add_modifier(if selected { + theme.text_dim() + }), + )) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(if active { + theme.accent() + } else { + theme.border() + })) + .style(Style::default().bg(theme.surface())) +} + +fn render_field_card( + frame: &mut Frame, + area: Rect, + title: &'static str, + state: &State, + fields: &[(usize, &str)], +) { + let active = fields.iter().any(|(index, _)| *index == state.selected); + let block = settings_card(title, active); + let inner = block.inner(area); + frame.render_widget(block, area); + let lines = fields + .iter() + .map(|(index, label)| field_line(state, *index, label, 12)) + .collect::>(); + frame.render_widget(Paragraph::new(lines), inner); +} + +fn field_line<'a>(state: &'a State, index: usize, label: &str, label_width: usize) -> Line<'a> { + let theme = THEME.as_ref(); + let selected = index == state.selected; + let displayed = state.display_value(index); + let value_color = if index == 9 && state.desktop { + theme.success() + } else if selected { + theme.accent() + } else { + theme.text() + }; + let mut spans = vec![ + Span::styled( + if selected { "▶ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{label: Line<'_> { + let theme = THEME.as_ref(); if state.confirm_profile_delete { - lines.push(Line::from(Span::styled( - "Delete the selected shared profile? [y/n]", + Line::from(Span::styled( + " ! Delete this shared profile? [y] yes [n] no", Style::default().fg(theme.warning()), - ))); + )) } else if let Some(error) = &state.error { - lines.push(Line::from(Span::styled( - error, + Line::from(Span::styled( + format!(" × {error}"), Style::default().fg(theme.error()), - ))); + )) } else if state.confirm_runtime_change { - lines.push(Line::from(Span::styled( - "Changing the runtime downloads files and may break mods. Continue? [y/n]", + Line::from(Span::styled( + " ! Runtime changes download files and may break mods. [y] continue [n] cancel", Style::default().fg(theme.warning()), - ))); + )) } else if state.confirm_close { - lines.push(Line::from(Span::styled( - "Discard unsaved changes? [y] yes [n] no", + Line::from(Span::styled( + " ! Discard unsaved changes? [y] yes [n] no", Style::default().fg(theme.warning()), - ))); + )) } else { let settings = SETTINGS.read(); - let defaults = &settings.defaults; - lines.push(Line::from(Span::styled( - format!( - "Empty Java/memory values use launcher defaults ({}-{}).", - defaults.memory_min, defaults.memory_max + Line::from(vec![ + Span::styled(" ◇ Defaults ", Style::default().fg(theme.info())), + Span::styled( + format!( + "empty Java or memory values inherit {} → {}", + settings.defaults.memory_min, settings.defaults.memory_max + ), + Style::default().fg(theme.text_dim()), ), - Style::default().fg(theme.text_dim()), - ))); - } - frame.render_widget(Paragraph::new(lines), inner); - if let Some(editor) = state.editing.as_ref().or(state.profile_input.as_ref()) { - let editor_area = Rect { - x: inner.x, - y: inner.y.saturating_add(12), - width: inner.width, - height: inner.height.saturating_sub(12).max(1), - }; - let editor_block = Block::default() - .title(if state.selected == 6 { - " JVM arguments — one argument per line; Ctrl+Enter applies " - } else { - " Value — Enter applies " - }) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.accent())); - let editor_inner = editor_block.inner(editor_area); - frame.render_widget(editor_block, editor_area); - frame.render_widget(editor, editor_inner); + ]) } } fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); let title = match state.choice_picker { - Some(ChoicePicker::Loader) => "Select loader", - Some(ChoicePicker::Profile) => "Select config profile", + Some(ChoicePicker::Loader) => " Loader ", + Some(ChoicePicker::Java) => " Java Runtime ", + Some(ChoicePicker::MemoryMin) => " Minimum Memory ", + Some(ChoicePicker::MemoryMax) => " Maximum Memory ", + Some(ChoicePicker::Resolution) => " Window Resolution ", + Some(ChoicePicker::Profile) => " Config Profile ", None => return, }; - let mut lines = vec![Line::from(Span::styled( - title, - Style::default() - .fg(theme.text()) - .add_modifier(Modifier::BOLD), - ))]; + let block = settings_card(title, true); + let inner = block.inner(area); + frame.render_widget(block, area); + let mut lines = Vec::new(); let values = state.choice_values(); - let visible_rows = area.height.saturating_sub(1) as usize; + let visible_rows = inner.height as usize; let start = state .choice_index .saturating_sub(visible_rows.saturating_sub(1)); for (index, value) in values.iter().enumerate().skip(start).take(visible_rows) { let selected = index == state.choice_index; - lines.push(Line::from(vec![ - Span::styled( - if selected { "▸ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - value.clone(), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ])); + lines.push( + Line::from(vec![ + Span::styled( + if selected { "▶ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + value.clone(), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ]) + .style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })), + ); } - frame.render_widget(Paragraph::new(lines), area); + frame.render_widget(Paragraph::new(lines), inner); } fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); let title = match state.picker { - Some(VersionPicker::Game) => "Minecraft version", - Some(VersionPicker::Loader) => "Loader version", + Some(VersionPicker::Game) => " Minecraft Version ", + Some(VersionPicker::Loader) => " Loader Version ", None => return, }; + let block = settings_card(title, true); + let inner = block.inner(area); + frame.render_widget(block, area); let status = match state.picker { Some(VersionPicker::Game) => match &*state .game_versions @@ -932,7 +1222,7 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { None => return, }; let mut lines = vec![Line::from(vec![ - Span::styled(format!("{title}: "), Style::default().fg(theme.text_dim())), + Span::styled("Search ", Style::default().fg(theme.text_dim())), Span::styled( if state.picker_search { format!("/{}█", state.picker_query) @@ -969,37 +1259,44 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { if versions.is_empty() { lines.push(Line::from("No matching versions.")); } else { - let visible_rows = area.height.saturating_sub(2) as usize; + let visible_rows = inner.height.saturating_sub(1) as usize; let start = state .picker_index .saturating_sub(visible_rows.saturating_sub(1)); for (index, version) in versions.iter().enumerate().skip(start).take(visible_rows) { let selected = index == state.picker_index; - lines.push(Line::from(vec![ - Span::styled( - if selected { "▸ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - version.clone(), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ])); + lines.push( + Line::from(vec![ + Span::styled( + if selected { "▶ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + version.clone(), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ]) + .style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })), + ); } } } } - frame.render_widget(Paragraph::new(lines), area); + frame.render_widget(Paragraph::new(lines), inner); } #[cfg(test)] @@ -1048,6 +1345,8 @@ mod tests { let mut state = State::new(&config, temp.path()); state.selected = 4; state.begin_edit(); + state.choice_index = state.choice_values().len() - 1; + state.handle_key(&KeyEvent::from(KeyCode::Enter)); state.handle_key(&KeyEvent::from(KeyCode::Left)); state.handle_key(&KeyEvent::from(KeyCode::Char('0'))); state.handle_key(&KeyEvent::from(KeyCode::Enter)); @@ -1156,4 +1455,33 @@ mod tests { state.handle_key(&KeyEvent::from(KeyCode::Right)); assert_ne!(state.desktop, desktop); } + + #[test] + fn typed_fields_use_presets_before_custom_input() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + + state.selected = 4; + state.begin_edit(); + assert_eq!(state.choice_picker, Some(ChoicePicker::MemoryMin)); + state.choice_index = state + .choice_values() + .iter() + .position(|value| value == "4G") + .unwrap(); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.draft.memory_min.as_deref(), Some("4G")); + assert!(state.editing.is_none()); + + state.selected = 7; + state.begin_edit(); + state.choice_index = state + .choice_values() + .iter() + .position(|value| value == "1920x1080") + .unwrap(); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.draft.resolution, Some((1920, 1080))); + assert!(state.editing.is_none()); + } } From 6a4cab9405cd6a67abee08032bc15ea62d623ec5 Mon Sep 17 00:00:00 2001 From: objz Date: Thu, 3 Sep 2026 21:33:31 +0200 Subject: [PATCH 04/42] refactor: streamline interactive settings controls --- src/tui/input.rs | 16 - src/tui/tests/flows.rs | 11 + src/tui/widgets/popups/global_settings.rs | 175 ++--- src/tui/widgets/popups/instance_settings.rs | 675 ++++++++++++-------- 4 files changed, 530 insertions(+), 347 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 10160b6..86c1e23 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -722,22 +722,6 @@ impl App { self.instance_settings = None; self.focused = self.pre_overlay_focused; } - widgets::popups::instance_settings::Action::DeleteProfile(profile) => { - match self.delete_config_profile(&profile) { - Ok(()) => { - self.settings_state.remove_profile(&profile); - if let Some(state) = self.instance_settings.as_mut() { - state.profile_deleted(&profile); - } - } - Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message: error.to_string(), - pushed_at: std::time::Instant::now(), - }), - } - } widgets::popups::instance_settings::Action::Save(updated, desktop) => { let mut updated = *updated; if let Some(previous) = self.instances_state.selected_instance().cloned() { diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index c5ecdf9..bf0007c 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -585,6 +585,8 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert!(ui.screen().contains("Runtime")); assert!(ui.screen().contains("Version")); assert!(ui.screen().contains("Desktop")); + assert!(!ui.screen().contains("Integration")); + assert!(ui.screen().contains('▰')); assert!(ui.screen().contains('‹')); assert!(ui.screen().contains('›')); ui.key(KeyCode::Down); @@ -593,6 +595,14 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert!(ui.screen().contains("Loader")); assert!(ui.screen().contains("Fabric")); ui.key(KeyCode::Esc); + for _ in 0..5 { + ui.key(KeyCode::Down); + } + ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("JVM Arguments")); + assert!(ui.screen().contains("No custom JVM arguments")); + ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Settings); @@ -602,6 +612,7 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert!(ui.screen().contains("Launcher Settings")); assert!(ui.screen().contains("Launch Defaults")); assert!(ui.screen().contains("Max memory")); + assert!(ui.screen().contains('▰')); assert!(ui.screen().contains('‹')); assert!(ui.screen().contains('›')); ui.key(KeyCode::Enter); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 0fd672a..3e7a11d 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -24,8 +24,6 @@ use crate::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PresetPicker { Border, - MemoryMin, - MemoryMax, Java, } @@ -181,14 +179,6 @@ impl State { "╔═╗ double".to_owned(), "┏━┓ thick".to_owned(), ], - PresetPicker::MemoryMin | PresetPicker::MemoryMax => { - let current = if picker == PresetPicker::MemoryMin { - &self.config.defaults.memory_min - } else { - &self.config.defaults.memory_max - }; - memory_choices(Some(current)) - } PresetPicker::Java => { let mut values = vec!["automatic detection".to_owned()]; if !self.detected_java.is_empty() { @@ -214,8 +204,6 @@ impl State { self.preset_picker = Some(picker); let current = match picker { PresetPicker::Border => None, - PresetPicker::MemoryMin => Some(self.config.defaults.memory_min.as_str()), - PresetPicker::MemoryMax => Some(self.config.defaults.memory_max.as_str()), PresetPicker::Java => self.config.paths.java_path.as_deref(), }; self.preset_index = self @@ -225,7 +213,6 @@ impl State { (PresetPicker::Border, _) => value == &self.display_value(1), (PresetPicker::Java, None) => value == "automatic detection", (_, Some(current)) => value == current, - _ => false, }) .unwrap_or(0); } @@ -254,28 +241,6 @@ impl State { self.theme.border_style = previous; } } - Some(PresetPicker::MemoryMin | PresetPicker::MemoryMax) - if selected == "custom memory…" => - { - let field = if self.preset_picker == Some(PresetPicker::MemoryMin) { - 2 - } else { - 3 - }; - self.editing = Some(new_text_area(vec![self.value(field)])); - } - Some(PresetPicker::MemoryMin) => { - if let Some(value) = normalize_memory_value(&selected) { - self.config.defaults.memory_min = value; - self.config_dirty = true; - } - } - Some(PresetPicker::MemoryMax) => { - if let Some(value) = normalize_memory_value(&selected) { - self.config.defaults.memory_max = value; - self.config_dirty = true; - } - } Some(PresetPicker::Java) if selected == "custom path…" => { self.editing = Some(new_text_area(vec![self.value(4)])); } @@ -317,6 +282,50 @@ impl State { } } + fn adjust_memory(&mut self, field: usize, forward: bool) { + let values = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"]; + let current = if field == 2 { + &self.config.defaults.memory_min + } else { + &self.config.defaults.memory_max + }; + let exact = values.iter().position(|value| *value == current); + let next = if let Some(index) = exact { + if forward { + (index + 1) % values.len() + } else { + (index + values.len() - 1) % values.len() + } + } else { + let current_kib = memory_kib(current).unwrap_or_default(); + if forward { + values + .iter() + .position(|value| memory_kib(value).is_some_and(|kib| kib > current_kib)) + .unwrap_or(0) + } else { + values + .iter() + .rposition(|value| memory_kib(value).is_some_and(|kib| kib < current_kib)) + .unwrap_or(values.len() - 1) + } + }; + let value = values[next].to_owned(); + if field == 2 { + self.config.defaults.memory_min = value.clone(); + if memory_kib(&value) > memory_kib(&self.config.defaults.memory_max) { + self.config.defaults.memory_max = value; + } + } else { + self.config.defaults.memory_max = value.clone(); + if memory_kib(&value) < memory_kib(&self.config.defaults.memory_min) { + self.config.defaults.memory_min = value; + } + } + self.config_dirty = true; + self.error = None; + } + fn cycle_theme(&mut self, forward: bool) { let count = self.themes.len(); if count == 0 { @@ -391,27 +400,27 @@ impl State { KeyCode::Left => match self.selected { 0 => self.cycle_theme(false), 1 => self.cycle_border(false), - 2 => self.rotate_preset(PresetPicker::MemoryMin, false), - 3 => self.rotate_preset(PresetPicker::MemoryMax, false), + 2 | 3 => self.adjust_memory(self.selected, false), 4 => self.rotate_preset(PresetPicker::Java, false), _ => {} }, KeyCode::Right => match self.selected { 0 => self.cycle_theme(true), 1 => self.cycle_border(true), - 2 => self.rotate_preset(PresetPicker::MemoryMin, true), - 3 => self.rotate_preset(PresetPicker::MemoryMax, true), + 2 | 3 => self.adjust_memory(self.selected, true), 4 => self.rotate_preset(PresetPicker::Java, true), _ => {} }, KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, 1 => self.open_preset_picker(PresetPicker::Border), - 2 => self.open_preset_picker(PresetPicker::MemoryMin), - 3 => self.open_preset_picker(PresetPicker::MemoryMax), + 2 | 3 => self.adjust_memory(self.selected, true), 4 => self.open_preset_picker(PresetPicker::Java), _ => {} }, + KeyCode::Char('c') if matches!(self.selected, 2 | 3) => { + self.editing = Some(new_text_area(vec![self.value(self.selected)])); + } KeyCode::Char('s') => { if self.validate_before_save() { return Action::Save( @@ -484,20 +493,6 @@ fn available_themes() -> Vec { themes } -fn memory_choices(current: Option<&String>) -> Vec { - let mut values = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"] - .into_iter() - .map(str::to_owned) - .collect::>(); - if let Some(current) = current - && !values.contains(current) - { - values.push(current.clone()); - } - values.push("custom memory…".to_owned()); - values -} - fn new_text_area(lines: Vec) -> TextArea<'static> { let theme = THEME.as_ref(); let mut editor = TextArea::new(if lines.is_empty() { @@ -530,6 +525,15 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { frame.render_widget(Clear, area); let keybinds = if state.theme_picker || state.preset_picker.is_some() { super::keybind_line(&[("j/k", " move"), ("Enter", " apply"), ("Esc", " back")]) + } else if matches!(state.selected, 2 | 3) { + super::keybind_line(&[ + ("j/k", ""), + ("←/→", " memory"), + ("c", " custom"), + ("s", " save"), + ("E", " raw"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -568,8 +572,6 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { if let Some(picker) = state.preset_picker { let title = match picker { PresetPicker::Border => " Border Style ", - PresetPicker::MemoryMin => " Minimum Memory ", - PresetPicker::MemoryMax => " Maximum Memory ", PresetPicker::Java => " Java Runtime ", }; render_picker( @@ -741,7 +743,7 @@ fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a let theme = THEME.as_ref(); let selected = index == state.selected; let displayed = state.display_value(index); - Line::from(vec![ + let mut spans = vec![ Span::styled( if selected { "▶ " } else { " " }, Style::default().fg(theme.accent()), @@ -750,7 +752,11 @@ fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a format!("{label:<13}"), Style::default().fg(theme.text_dim()), ), - Span::styled( + ]; + if matches!(index, 2 | 3) { + spans.push(global_memory_slider(&displayed, selected)); + } else { + spans.push(Span::styled( format!(" ‹ {displayed} › "), Style::default() .fg(if selected { @@ -764,32 +770,57 @@ fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a } else { Modifier::empty() }), - ), - ]) - .style(Style::default().bg(if selected { + )); + } + Line::from(spans).style(Style::default().bg(if selected { theme.stripe() } else { theme.surface() })) } +fn global_memory_slider(value: &str, selected: bool) -> Span<'static> { + let theme = THEME.as_ref(); + let thresholds = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"]; + let value_kib = memory_kib(value).unwrap_or_default(); + let step = thresholds + .iter() + .position(|threshold| memory_kib(threshold).is_some_and(|limit| value_kib <= limit)) + .unwrap_or(thresholds.len() - 1); + let filled = step + 1; + Span::styled( + format!( + " ◀ {}{} {value} ▶ ", + "▰".repeat(filled), + "▱".repeat(thresholds.len() - filled) + ), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .bg(theme.background()) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn launcher_defaults_open_purpose_built_pickers() { + fn launcher_defaults_use_memory_sliders_and_java_picker() { let mut state = State::new(); state.selected = 2; - state.handle_key(&KeyEvent::from(KeyCode::Enter)); - assert_eq!(state.preset_picker, Some(PresetPicker::MemoryMin)); - state.preset_index = state - .preset_values() - .iter() - .position(|value| value == "4G") - .unwrap(); - state.handle_key(&KeyEvent::from(KeyCode::Enter)); - assert_eq!(state.config.defaults.memory_min, "4G"); + let original = state.config.defaults.memory_min.clone(); + state.handle_key(&KeyEvent::from(KeyCode::Right)); + assert_ne!(state.config.defaults.memory_min, original); + assert!(state.preset_picker.is_none()); assert!(state.editing.is_none()); state.selected = 4; diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 1b4f674..5949312 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -3,7 +3,7 @@ // modal editor for settings belonging to the selected Minecraft instance. -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, @@ -24,7 +24,7 @@ use crate::{ tui::widgets::popups::LoadState, }; -const FIELD_COUNT: usize = 10; +const FIELD_COUNT: usize = 9; type SharedLoad = Arc>>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,10 +37,7 @@ enum VersionPicker { enum ChoicePicker { Loader, Java, - MemoryMin, - MemoryMax, Resolution, - Profile, } enum PickerLoad { @@ -56,9 +53,9 @@ pub struct State { pub draft: InstanceConfig, selected: usize, editing: Option>, - profiles: Vec, - profile_input: Option>, - confirm_profile_delete: bool, + jvm_args_open: bool, + jvm_arg_index: usize, + jvm_arg_input: Option<(Option, TextArea<'static>)>, pub desktop: bool, original_desktop: bool, error: Option, @@ -80,21 +77,20 @@ pub struct State { pub enum Action { None, Save(Box, bool), - DeleteProfile(String), OpenRaw, Close, } impl State { - pub fn new(instance: &InstanceConfig, meta_dir: &std::path::Path) -> Self { + pub fn new(instance: &InstanceConfig, _meta_dir: &std::path::Path) -> Self { Self { original: instance.clone(), draft: instance.clone(), selected: 0, editing: None, - profiles: crate::instance::config_sync::list_profiles(meta_dir).unwrap_or_default(), - profile_input: None, - confirm_profile_delete: false, + jvm_args_open: false, + jvm_arg_index: 0, + jvm_arg_input: None, desktop: crate::instance::desktop::exists(&instance.name), original_desktop: crate::instance::desktop::exists(&instance.name), error: None, @@ -126,6 +122,7 @@ impl State { fn validate_before_save(&mut self) -> bool { self.error = None; + let settings = SETTINGS.read(); if self.draft.game_version.trim().is_empty() { self.error = Some("game version cannot be empty".to_owned()); } else if self.draft.loader != ModLoader::Vanilla @@ -137,8 +134,18 @@ impl State { { self.error = Some("the selected loader requires a loader version".to_owned()); } else if let (Some(min), Some(max)) = ( - self.draft.memory_min.as_deref().and_then(memory_kib), - self.draft.memory_max.as_deref().and_then(memory_kib), + memory_kib( + self.draft + .memory_min + .as_deref() + .unwrap_or(&settings.defaults.memory_min), + ), + memory_kib( + self.draft + .memory_max + .as_deref() + .unwrap_or(&settings.defaults.memory_max), + ), ) && min > max { self.error = Some("minimum memory cannot exceed maximum memory".to_owned()); @@ -154,18 +161,13 @@ impl State { 3 => self.draft.java_path.clone().unwrap_or_default(), 4 => self.draft.memory_min.clone().unwrap_or_default(), 5 => self.draft.memory_max.clone().unwrap_or_default(), - 6 => self.draft.jvm_args.join("\n"), + 6 => self.draft.jvm_args.join(" "), 7 => self .draft .resolution .map(|(w, h)| format!("{w}x{h}")) .unwrap_or_default(), - 8 => self - .draft - .config_sync_profile - .clone() - .unwrap_or_else(|| "instance default".to_owned()), - 9 => if self.desktop { "yes" } else { "no" }.to_owned(), + 8 => if self.desktop { "yes" } else { "no" }.to_owned(), _ => String::new(), } } @@ -180,10 +182,11 @@ impl State { 5 if self.draft.memory_max.is_none() => { format!("default ({})", SETTINGS.read().defaults.memory_max) } - 6 if self.draft.jvm_args.is_empty() => "none".to_owned(), + 6 if self.draft.jvm_args.is_empty() => "no arguments".to_owned(), + 6 => format!("{} argument(s)", self.draft.jvm_args.len()), 7 if self.draft.resolution.is_none() => "default".to_owned(), - 9 if self.desktop => "● enabled".to_owned(), - 9 => "○ disabled".to_owned(), + 8 if self.desktop => "● enabled".to_owned(), + 8 => "○ disabled".to_owned(), _ => self.value(field).replace('\n', " ↵ "), } } @@ -197,12 +200,15 @@ impl State { } 2 => self.open_loader_picker(), 3 => self.open_choice_picker(ChoicePicker::Java), - 4 => self.open_choice_picker(ChoicePicker::MemoryMin), - 5 => self.open_choice_picker(ChoicePicker::MemoryMax), + 4 | 5 => self.adjust_memory(self.selected, true), + 6 => { + self.jvm_args_open = true; + self.jvm_arg_index = self + .jvm_arg_index + .min(self.draft.jvm_args.len().saturating_sub(1)); + } 7 => self.open_choice_picker(ChoicePicker::Resolution), - 8 => self.open_choice_picker(ChoicePicker::Profile), - 9 => self.desktop = !self.desktop, - 6 => self.editing = Some(new_text_area(self.draft.jvm_args.clone())), + 8 => self.desktop = !self.desktop, field => self.editing = Some(new_text_area(vec![self.value(field)])), } } @@ -224,12 +230,6 @@ impl State { ) }) .unwrap_or(0), - ChoicePicker::MemoryMin => { - self.memory_choice_index(picker, self.draft.memory_min.as_deref()) - } - ChoicePicker::MemoryMax => { - self.memory_choice_index(picker, self.draft.memory_max.as_deref()) - } ChoicePicker::Resolution => self .choice_values_for(picker) .iter() @@ -240,12 +240,6 @@ impl State { ) }) .unwrap_or(0), - ChoicePicker::Profile => self - .draft - .config_sync_profile - .as_deref() - .and_then(|selected| self.profiles.iter().position(|profile| profile == selected)) - .map_or(0, |index| index + 1), }; } @@ -270,14 +264,6 @@ impl State { values.push("custom path…".to_owned()); values } - ChoicePicker::MemoryMin | ChoicePicker::MemoryMax => { - let current = if picker == ChoicePicker::MemoryMin { - self.draft.memory_min.as_ref() - } else { - self.draft.memory_max.as_ref() - }; - memory_choices(current, true) - } ChoicePicker::Resolution => { let mut values = vec![ "window default".to_owned(), @@ -296,21 +282,9 @@ impl State { values.push("custom resolution…".to_owned()); values } - ChoicePicker::Profile => std::iter::once("instance default".to_owned()) - .chain(self.profiles.iter().cloned()) - .collect(), } } - fn memory_choice_index(&self, picker: ChoicePicker, current: Option<&str>) -> usize { - self.choice_values_for(picker) - .iter() - .position(|value| { - current.map_or(value == "launcher default", |current| value == current) - }) - .unwrap_or(0) - } - fn handle_choice_key(&mut self, key: &KeyEvent) { let count = self.choice_values().len(); match key.code { @@ -350,26 +324,6 @@ impl State { self.draft.java_path = (selected != "automatic / launcher default").then_some(selected); } - Some(ChoicePicker::MemoryMin | ChoicePicker::MemoryMax) - if selected == "custom memory…" => - { - let field = if self.choice_picker == Some(ChoicePicker::MemoryMin) { - 4 - } else { - 5 - }; - self.editing = Some(new_text_area(vec![self.value(field)])); - } - Some(ChoicePicker::MemoryMin) => { - self.draft.memory_min = (selected != "launcher default") - .then(|| normalize_memory_value(&selected)) - .flatten(); - } - Some(ChoicePicker::MemoryMax) => { - self.draft.memory_max = (selected != "launcher default") - .then(|| normalize_memory_value(&selected)) - .flatten(); - } Some(ChoicePicker::Resolution) if selected == "custom resolution…" => { self.editing = Some(new_text_area(vec![self.value(7)])); } @@ -378,12 +332,6 @@ impl State { .then(|| parse_resolution(&selected).ok()) .flatten(); } - Some(ChoicePicker::Profile) => { - self.draft.config_sync_profile = self - .choice_index - .checked_sub(1) - .and_then(|index| self.profiles.get(index).cloned()); - } None => {} } self.choice_picker = None; @@ -396,10 +344,7 @@ impl State { .len() .saturating_sub(usize::from(matches!( picker, - ChoicePicker::Java - | ChoicePicker::MemoryMin - | ChoicePicker::MemoryMax - | ChoicePicker::Resolution + ChoicePicker::Java | ChoicePicker::Resolution ))); if count > 0 { self.choice_index = if forward { @@ -411,6 +356,146 @@ impl State { } } + fn adjust_memory(&mut self, field: usize, forward: bool) { + let settings = SETTINGS.read(); + let current = if field == 4 { + self.draft + .memory_min + .as_deref() + .unwrap_or(&settings.defaults.memory_min) + } else { + self.draft + .memory_max + .as_deref() + .unwrap_or(&settings.defaults.memory_max) + }; + let values = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"]; + let exact = values.iter().position(|value| *value == current); + let next = if let Some(index) = exact { + if forward { + (index + 1) % values.len() + } else { + (index + values.len() - 1) % values.len() + } + } else { + let current_kib = memory_kib(current).unwrap_or_default(); + if forward { + values + .iter() + .position(|value| memory_kib(value).is_some_and(|kib| kib > current_kib)) + .unwrap_or(0) + } else { + values + .iter() + .rposition(|value| memory_kib(value).is_some_and(|kib| kib < current_kib)) + .unwrap_or(values.len() - 1) + } + }; + drop(settings); + self.set_memory(field, Some(values[next].to_owned())); + } + + fn set_memory(&mut self, field: usize, value: Option) { + if field == 4 { + self.draft.memory_min = value.clone(); + } else { + self.draft.memory_max = value.clone(); + } + + let settings = SETTINGS.read(); + let min = self + .draft + .memory_min + .as_deref() + .unwrap_or(&settings.defaults.memory_min); + let max = self + .draft + .memory_max + .as_deref() + .unwrap_or(&settings.defaults.memory_max); + if memory_kib(min) + .zip(memory_kib(max)) + .is_some_and(|(min, max)| min > max) + { + if field == 4 { + self.draft.memory_max = value; + } else { + self.draft.memory_min = value; + } + } + } + + fn handle_jvm_args_key(&mut self, key: &KeyEvent) { + if let Some((target, input)) = &mut self.jvm_arg_input { + match key.code { + KeyCode::Enter => { + let value = input.lines().join(" ").trim().to_owned(); + let target = *target; + self.jvm_arg_input = None; + if value.is_empty() { + return; + } + if let Some(index) = target { + if let Some(argument) = self.draft.jvm_args.get_mut(index) { + *argument = value; + } + } else { + self.draft.jvm_args.push(value); + self.jvm_arg_index = self.draft.jvm_args.len() - 1; + } + } + KeyCode::Esc => self.jvm_arg_input = None, + _ => { + input.input(*key); + } + } + return; + } + + match key.code { + KeyCode::Esc => self.jvm_args_open = false, + KeyCode::Char('j') | KeyCode::Down if !self.draft.jvm_args.is_empty() => { + self.jvm_arg_index = (self.jvm_arg_index + 1).min(self.draft.jvm_args.len() - 1); + } + KeyCode::Char('k') | KeyCode::Up => { + self.jvm_arg_index = self.jvm_arg_index.saturating_sub(1); + } + KeyCode::Left if self.jvm_arg_index > 0 => { + self.draft + .jvm_args + .swap(self.jvm_arg_index, self.jvm_arg_index - 1); + self.jvm_arg_index -= 1; + } + KeyCode::Right if self.jvm_arg_index + 1 < self.draft.jvm_args.len() => { + self.draft + .jvm_args + .swap(self.jvm_arg_index, self.jvm_arg_index + 1); + self.jvm_arg_index += 1; + } + KeyCode::Char('a') => { + self.jvm_arg_input = Some((None, new_text_area(vec![String::new()]))); + } + KeyCode::Enter if self.draft.jvm_args.is_empty() => { + self.jvm_arg_input = Some((None, new_text_area(vec![String::new()]))); + } + KeyCode::Enter if !self.draft.jvm_args.is_empty() => { + let index = self.jvm_arg_index.min(self.draft.jvm_args.len() - 1); + self.jvm_arg_input = Some(( + Some(index), + new_text_area(vec![self.draft.jvm_args[index].clone()]), + )); + } + KeyCode::Char('d') if !self.draft.jvm_args.is_empty() => { + let index = self.jvm_arg_index.min(self.draft.jvm_args.len() - 1); + self.draft.jvm_args.remove(index); + self.jvm_arg_index = self + .jvm_arg_index + .min(self.draft.jvm_args.len().saturating_sub(1)); + } + _ => {} + } + } + fn open_game_picker(&mut self) { self.picker = Some(VersionPicker::Game); self.picker_index = 0; @@ -609,14 +694,6 @@ impl State { ), 4 => self.draft.memory_min = normalize_memory_value(value), 5 => self.draft.memory_max = normalize_memory_value(value), - 6 => { - self.draft.jvm_args = value - .lines() - .map(str::trim) - .filter(|argument| !argument.is_empty()) - .map(str::to_owned) - .collect(); - } 7 if value.is_empty() => self.draft.resolution = None, 7 => match parse_resolution(value) { Ok(resolution) => self.draft.resolution = Some(resolution), @@ -627,6 +704,10 @@ impl State { } pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + if self.jvm_args_open { + self.handle_jvm_args_key(key); + return Action::None; + } if self.choice_picker.is_some() { self.handle_choice_key(key); return Action::None; @@ -635,47 +716,6 @@ impl State { self.handle_picker_key(key); return Action::None; } - if let Some(input) = &mut self.profile_input { - match key.code { - KeyCode::Enter => { - let name = input.lines().join("").trim().to_owned(); - self.profile_input = None; - if !name.is_empty() { - match crate::instance::config_sync::validate_profile(&name) { - Ok(()) => { - if !self.profiles.contains(&name) { - self.profiles.push(name.clone()); - self.profiles.sort_unstable(); - } - self.draft.config_sync_profile = Some(name); - } - Err(error) => self.error = Some(error.to_string()), - } - } - } - KeyCode::Esc => self.profile_input = None, - _ => { - input.input(*key); - } - } - return Action::None; - } - if self.confirm_profile_delete { - match key.code { - KeyCode::Char('y') | KeyCode::Enter => { - self.confirm_profile_delete = false; - if let Some(profile) = self.draft.config_sync_profile.clone() - && (self.original.config_sync_profile.as_deref() == Some(&profile) - || self.profiles.iter().any(|candidate| candidate == &profile)) - { - return Action::DeleteProfile(profile); - } - } - KeyCode::Char('n') | KeyCode::Esc => self.confirm_profile_delete = false, - _ => {} - } - return Action::None; - } if self.confirm_close { match key.code { KeyCode::Char('y') | KeyCode::Enter => return Action::Close, @@ -699,11 +739,6 @@ impl State { } if let Some(input) = &mut self.editing { match key.code { - KeyCode::Enter - if self.selected == 6 && !key.modifiers.contains(KeyModifiers::CONTROL) => - { - input.input(*key); - } KeyCode::Enter => self.commit_edit(), KeyCode::Esc => self.editing = None, _ => { @@ -721,31 +756,25 @@ impl State { KeyCode::Left => match self.selected { 1 => self.rotate_choice(ChoicePicker::Loader, false), 3 => self.rotate_choice(ChoicePicker::Java, false), - 4 => self.rotate_choice(ChoicePicker::MemoryMin, false), - 5 => self.rotate_choice(ChoicePicker::MemoryMax, false), + 4 | 5 => self.adjust_memory(self.selected, false), 7 => self.rotate_choice(ChoicePicker::Resolution, false), - 8 => self.rotate_choice(ChoicePicker::Profile, false), - 9 => self.desktop = !self.desktop, + 8 => self.desktop = !self.desktop, _ => {} }, KeyCode::Right => match self.selected { 1 => self.rotate_choice(ChoicePicker::Loader, true), 3 => self.rotate_choice(ChoicePicker::Java, true), - 4 => self.rotate_choice(ChoicePicker::MemoryMin, true), - 5 => self.rotate_choice(ChoicePicker::MemoryMax, true), + 4 | 5 => self.adjust_memory(self.selected, true), 7 => self.rotate_choice(ChoicePicker::Resolution, true), - 8 => self.rotate_choice(ChoicePicker::Profile, true), - 9 => self.desktop = !self.desktop, + 8 => self.desktop = !self.desktop, _ => {} }, KeyCode::Enter => self.begin_edit(), - KeyCode::Char('a') if self.selected == 8 => { - self.profile_input = Some(new_text_area(vec![String::new()])); + KeyCode::Char('c') if matches!(self.selected, 4 | 5 | 7) => { + self.editing = Some(new_text_area(vec![self.value(self.selected)])); } - KeyCode::Char('d') - if self.selected == 8 && self.draft.config_sync_profile.is_some() => - { - self.confirm_profile_delete = true; + KeyCode::Char('r') if matches!(self.selected, 4 | 5) => { + self.set_memory(self.selected, None); } KeyCode::Char('s') if self.dirty() => { if self.validate_before_save() { @@ -767,16 +796,6 @@ impl State { } Action::None } - - pub fn profile_deleted(&mut self, profile: &str) { - self.profiles.retain(|candidate| candidate != profile); - if self.draft.config_sync_profile.as_deref() == Some(profile) { - self.draft.config_sync_profile = None; - } - if self.original.config_sync_profile.as_deref() == Some(profile) { - self.original.config_sync_profile = None; - } - } } fn memory_kib(value: &str) -> Option { @@ -801,25 +820,6 @@ fn loaders() -> [ModLoader; 5] { ] } -fn memory_choices(current: Option<&String>, include_default: bool) -> Vec { - let mut values = Vec::new(); - if include_default { - values.push("launcher default".to_owned()); - } - values.extend( - ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"] - .into_iter() - .map(str::to_owned), - ); - if let Some(current) = current - && !values.contains(current) - { - values.push(current.clone()); - } - values.push("custom memory…".to_owned()); - values -} - fn new_text_area(lines: Vec) -> TextArea<'static> { let theme = THEME.as_ref(); let mut editor = TextArea::new(if lines.is_empty() { @@ -838,9 +838,9 @@ fn new_text_area(lines: Vec) -> TextArea<'static> { pub fn popup_rect(area: Rect, state: &State) -> Rect { let height = if state.picker.is_some() || state.choice_picker.is_some() { 19 - } else if state.selected == 6 && state.editing.is_some() { + } else if state.jvm_args_open { 18 - } else if state.editing.is_some() || state.profile_input.is_some() { + } else if state.editing.is_some() { 14 } else { 12 @@ -872,7 +872,20 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { Style::default().fg(theme.warning()), )); } - let keybinds = if state.picker.is_some() { + let keybinds = if state.jvm_args_open { + if state.jvm_arg_input.is_some() { + super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) + } else { + super::keybind_line(&[ + ("j/k", ""), + ("←/→", ""), + ("Enter", " edit"), + ("a", " add"), + ("d", " delete"), + ("Esc", " back"), + ]) + } + } else if state.picker.is_some() { super::keybind_line(&[ ("j/k", " move"), ("Enter", " select"), @@ -882,16 +895,32 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { ]) } else if state.choice_picker.is_some() { super::keybind_line(&[("j/k", " move"), ("Enter", " select"), ("Esc", " back")]) - } else if state.selected == 8 { + } else if matches!(state.selected, 4 | 5) { super::keybind_line(&[ ("j/k", ""), - ("Enter", " choose"), - ("a", " add"), - ("d", " delete"), + ("←/→", " memory"), + ("c", " custom"), + ("r", " default"), + ("s", ""), + ("E", ""), + ("Esc", ""), + ]) + } else if state.selected == 6 { + super::keybind_line(&[ + ("j/k", ""), + ("Enter", " manage args"), ("s", " save"), ("E", " raw"), ("Esc", " back"), ]) + } else if state.selected == 8 { + super::keybind_line(&[ + ("j/k", ""), + ("Enter", " toggle"), + ("←/→", " toggle"), + ("s", " save"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -912,6 +941,10 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { let inner = block.inner(area); frame.render_widget(block, area); + if state.jvm_args_open { + render_jvm_args(frame, inner, state); + return; + } if state.picker.is_some() { render_version_picker(frame, inner, state); return; @@ -963,32 +996,22 @@ fn render_settings_form(frame: &mut Frame, area: Rect, state: &State) { ], ); - let integration_block = settings_card(" Integration ", matches!(state.selected, 8 | 9)); - let integration_inner = integration_block.inner(sections[1]); - frame.render_widget(integration_block, sections[1]); - let integration = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) - .split(integration_inner); - frame.render_widget( - Paragraph::new(field_line(state, 8, "Profile", 9)), - integration[0], - ); + let desktop_block = settings_card(" Desktop ", state.selected == 8); + let desktop_inner = desktop_block.inner(sections[1]); + frame.render_widget(desktop_block, sections[1]); frame.render_widget( - Paragraph::new(field_line(state, 9, "Desktop", 9)), - integration[1], + Paragraph::new(field_line(state, 8, "Shortcut", 12)), + desktop_inner, ); - if let Some(editor) = state.editing.as_ref().or(state.profile_input.as_ref()) { + if let Some(editor) = state.editing.as_ref() { let theme = THEME.as_ref(); let editor_block = Block::default() - .title(match (state.selected, state.profile_input.is_some()) { - (_, true) => " New profile name · Enter create ", - (3, false) => " Custom Java executable path · Enter apply ", - (4, false) => " Custom minimum memory (K/M/G) · Enter apply ", - (5, false) => " Custom maximum memory (K/M/G) · Enter apply ", - (6, false) => " JVM arguments · one per line · Ctrl+Enter apply ", - (7, false) => " Custom resolution (WIDTHxHEIGHT) · Enter apply ", + .title(match state.selected { + 3 => " Custom Java executable path · Enter apply ", + 4 => " Custom minimum memory (K/M/G) · Enter apply ", + 5 => " Custom maximum memory (K/M/G) · Enter apply ", + 7 => " Custom resolution (WIDTHxHEIGHT) · Enter apply ", _ => " Value · Enter apply ", }) .borders(Borders::ALL) @@ -1049,7 +1072,7 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str, label_width: usiz let theme = THEME.as_ref(); let selected = index == state.selected; let displayed = state.display_value(index); - let value_color = if index == 9 && state.desktop { + let value_color = if index == 8 && state.desktop { theme.success() } else if selected { theme.accent() @@ -1066,10 +1089,9 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str, label_width: usiz Style::default().fg(theme.text_dim()), ), ]; - if matches!(index, 1 | 3 | 4 | 5 | 7 | 8 | 9) - && state.editing.is_none() - && state.profile_input.is_none() - { + if matches!(index, 4 | 5) { + spans.push(memory_slider(state, index, selected)); + } else if matches!(index, 1 | 3 | 7 | 8) && state.editing.is_none() { spans.push(Span::styled( format!(" ‹ {displayed} › "), Style::default() @@ -1098,14 +1120,147 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str, label_width: usiz })) } +fn memory_slider(state: &State, index: usize, selected: bool) -> Span<'static> { + let theme = THEME.as_ref(); + let settings = SETTINGS.read(); + let configured = if index == 4 { + state.draft.memory_min.as_deref() + } else { + state.draft.memory_max.as_deref() + }; + let effective = configured.unwrap_or_else(|| { + if index == 4 { + &settings.defaults.memory_min + } else { + &settings.defaults.memory_max + } + }); + let thresholds = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"]; + let effective_kib = memory_kib(effective).unwrap_or(0); + let step = thresholds + .iter() + .position(|value| memory_kib(value).is_some_and(|limit| effective_kib <= limit)) + .unwrap_or(thresholds.len() - 1); + let filled = ((step + 1) * 6).div_ceil(thresholds.len()).max(1); + let bar = format!("{}{}", "▰".repeat(filled), "▱".repeat(6 - filled)); + let label = if configured.is_none() { + format!("default {effective}") + } else { + effective.to_owned() + }; + Span::styled( + format!(" ◀ {bar} {label} ▶ "), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .bg(theme.background()) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ) +} + +fn render_jvm_args(frame: &mut Frame, area: Rect, state: &State) { + let theme = THEME.as_ref(); + let block = settings_card(" JVM Arguments ", true); + let inner = block.inner(area); + frame.render_widget(block, area); + + if let Some((target, editor)) = &state.jvm_arg_input { + let editor_block = Block::default() + .title(if target.is_some() { + " Edit argument · Enter apply " + } else { + " Add argument · Enter apply " + }) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())) + .style(Style::default().bg(theme.surface())); + let editor_inner = editor_block.inner(inner); + frame.render_widget(editor_block, inner); + frame.render_widget(editor, editor_inner); + return; + } + + if state.draft.jvm_args.is_empty() { + frame.render_widget( + Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + " No custom JVM arguments.", + Style::default().fg(theme.text_dim()), + )), + Line::from(vec![ + Span::styled( + " [a] ", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ), + Span::styled("add an argument", Style::default().fg(theme.text())), + ]), + ]), + inner, + ); + return; + } + + let visible_rows = inner.height as usize; + let start = state + .jvm_arg_index + .saturating_sub(visible_rows.saturating_sub(1)); + let lines = state + .draft + .jvm_args + .iter() + .enumerate() + .skip(start) + .take(visible_rows) + .map(|(index, argument)| { + let selected = index == state.jvm_arg_index; + Line::from(vec![ + Span::styled( + if selected { "▶ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{:02} ", index + 1), + Style::default().fg(theme.text_dim()), + ), + Span::styled( + argument.clone(), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ]) + .style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })) + }) + .collect::>(); + frame.render_widget(Paragraph::new(lines), inner); +} + fn status_line(state: &State) -> Line<'_> { let theme = THEME.as_ref(); - if state.confirm_profile_delete { - Line::from(Span::styled( - " ! Delete this shared profile? [y] yes [n] no", - Style::default().fg(theme.warning()), - )) - } else if let Some(error) = &state.error { + if let Some(error) = &state.error { Line::from(Span::styled( format!(" × {error}"), Style::default().fg(theme.error()), @@ -1140,10 +1295,7 @@ fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { let title = match state.choice_picker { Some(ChoicePicker::Loader) => " Loader ", Some(ChoicePicker::Java) => " Java Runtime ", - Some(ChoicePicker::MemoryMin) => " Minimum Memory ", - Some(ChoicePicker::MemoryMax) => " Maximum Memory ", Some(ChoicePicker::Resolution) => " Window Resolution ", - Some(ChoicePicker::Profile) => " Config Profile ", None => return, }; let block = settings_card(title, true); @@ -1338,15 +1490,13 @@ mod tests { } #[test] - fn text_editor_supports_cursor_movement_and_multiline_jvm_arguments() { + fn custom_memory_editor_and_jvm_argument_manager_preserve_values() { let temp = tempfile::tempdir().unwrap(); let mut config = instance(); config.memory_min = Some("512M".to_owned()); let mut state = State::new(&config, temp.path()); state.selected = 4; - state.begin_edit(); - state.choice_index = state.choice_values().len() - 1; - state.handle_key(&KeyEvent::from(KeyCode::Enter)); + state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); state.handle_key(&KeyEvent::from(KeyCode::Left)); state.handle_key(&KeyEvent::from(KeyCode::Char('0'))); state.handle_key(&KeyEvent::from(KeyCode::Enter)); @@ -1354,15 +1504,26 @@ mod tests { state.selected = 6; state.begin_edit(); + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); for character in "-Xfoo".chars() { state.handle_key(&KeyEvent::from(KeyCode::Char(character))); } state.handle_key(&KeyEvent::from(KeyCode::Enter)); + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); for character in "-Xbar".chars() { state.handle_key(&KeyEvent::from(KeyCode::Char(character))); } - state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert_eq!(state.draft.jvm_args, ["-Xfoo", "-Xbar"]); + + state.handle_key(&KeyEvent::from(KeyCode::Left)); + assert_eq!(state.draft.jvm_args, ["-Xbar", "-Xfoo"]); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + state.handle_key(&KeyEvent::from(KeyCode::Char('2'))); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.draft.jvm_args, ["-Xbar2", "-Xfoo"]); + state.handle_key(&KeyEvent::from(KeyCode::Char('d'))); + assert_eq!(state.draft.jvm_args, ["-Xfoo"]); } #[test] @@ -1407,22 +1568,18 @@ mod tests { } #[test] - fn adding_profile_is_staged_until_settings_are_saved() { + fn popup_changes_do_not_replace_the_existing_config_profile() { let temp = tempfile::tempdir().unwrap(); - let mut state = State::new(&instance(), temp.path()); - state.profile_input = Some(new_text_area(vec!["new-profile".to_owned()])); + let mut config = instance(); + config.config_sync_profile = Some("shared".to_owned()); + let mut state = State::new(&config, temp.path()); + state.desktop = !state.desktop; - state.handle_key(&KeyEvent::from(KeyCode::Enter)); + let Action::Save(config, _) = state.handle_key(&KeyEvent::from(KeyCode::Char('s'))) else { + panic!("expected settings save"); + }; - assert_eq!( - state.draft.config_sync_profile.as_deref(), - Some("new-profile") - ); - assert!( - crate::instance::config_sync::list_profiles(temp.path()) - .unwrap() - .is_empty() - ); + assert_eq!(config.config_sync_profile.as_deref(), Some("shared")); } #[test] @@ -1450,29 +1607,29 @@ mod tests { state.handle_key(&KeyEvent::from(KeyCode::Left)); assert_eq!(state.draft.loader, ModLoader::Fabric); - state.selected = 9; + state.selected = 8; let desktop = state.desktop; state.handle_key(&KeyEvent::from(KeyCode::Right)); assert_ne!(state.desktop, desktop); } #[test] - fn typed_fields_use_presets_before_custom_input() { + fn memory_slider_and_resolution_picker_use_structured_values() { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); state.selected = 4; state.begin_edit(); - assert_eq!(state.choice_picker, Some(ChoicePicker::MemoryMin)); - state.choice_index = state - .choice_values() - .iter() - .position(|value| value == "4G") - .unwrap(); - state.handle_key(&KeyEvent::from(KeyCode::Enter)); - assert_eq!(state.draft.memory_min.as_deref(), Some("4G")); + assert!(state.draft.memory_min.is_some()); + assert!(state.choice_picker.is_none()); assert!(state.editing.is_none()); + state.draft.memory_min = Some("4G".to_owned()); + state.draft.memory_max = Some("4G".to_owned()); + state.handle_key(&KeyEvent::from(KeyCode::Right)); + assert_eq!(state.draft.memory_min.as_deref(), Some("6G")); + assert_eq!(state.draft.memory_max.as_deref(), Some("6G")); + state.selected = 7; state.begin_edit(); state.choice_index = state From ff64920b7bee432ccf362b4fe9273fed1fc8b6ae Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 12:02:06 +0200 Subject: [PATCH 05/42] refactor: simplify settings editors --- src/tui/input.rs | 230 ++--- src/tui/tests/flows.rs | 67 +- src/tui/tests/widgets/settings.rs | 12 + src/tui/widgets/popups/confirm.rs | 25 + src/tui/widgets/popups/global_settings.rs | 509 ++-------- src/tui/widgets/popups/instance_settings.rs | 1008 ++++--------------- src/tui/widgets/settings.rs | 3 - 7 files changed, 454 insertions(+), 1400 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 86c1e23..9d76038 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -261,6 +261,25 @@ impl App { } FocusedArea::Content } + Some(confirm_popup::ConfirmTarget::InstanceRuntime { .. }) => { + self.focused = FocusedArea::InstanceSettings; + let confirmed = self + .instance_settings + .as_mut() + .and_then(|state| state.confirmed_save()); + if let Some((updated, desktop)) = confirmed { + self.apply_instance_settings(*updated, desktop); + } + self.focused + } + Some(confirm_popup::ConfirmTarget::DiscardInstanceSettings) => { + self.instance_settings = None; + self.pre_overlay_focused + } + Some(confirm_popup::ConfirmTarget::DiscardLauncherSettings) => { + self.global_settings = None; + self.pre_overlay_focused + } None => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -277,6 +296,13 @@ impl App { Some(confirm_popup::ConfirmTarget::ConfigProfile { .. }) => { FocusedArea::Settings } + Some(confirm_popup::ConfirmTarget::InstanceRuntime { .. }) + | Some(confirm_popup::ConfirmTarget::DiscardInstanceSettings) => { + FocusedArea::InstanceSettings + } + Some(confirm_popup::ConfirmTarget::DiscardLauncherSettings) => { + FocusedArea::GlobalSettings + } _ => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -582,29 +608,6 @@ impl App { self.focused = FocusedArea::GlobalSettings; return Ok(()); } - widgets::settings::SettingsAction::ToggleDesktop => { - if let Some(instance) = self.instances_state.selected_instance() { - let name = instance.name.clone(); - match crate::instance::desktop::toggle(instance) { - Ok(true) => error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::INFO, - message: format!("Desktop shortcut created for '{name}'"), - pushed_at: std::time::Instant::now(), - }), - Ok(false) => error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::INFO, - message: format!("Desktop shortcut removed for '{name}'"), - pushed_at: std::time::Instant::now(), - }), - Err(error) => { - tracing::error!("Failed to toggle desktop shortcut: {error}"); - } - } - } - return Ok(()); - } widgets::settings::SettingsAction::SelectProfile(profile) => { if let Some(instance) = self.instances_state.selected_instance().cloned() { let instance_dir = self.instance_manager.instances_dir.join(&instance.name); @@ -672,6 +675,12 @@ impl App { self.global_settings = None; self.focused = self.pre_overlay_focused; } + widgets::popups::global_settings::Action::ConfirmClose => { + confirm_popup::set_pending( + confirm_popup::ConfirmTarget::DiscardLauncherSettings, + ); + self.focused = FocusedArea::ConfirmDelete; + } widgets::popups::global_settings::Action::OpenRaw(path) => { self.pending_editor = Some(path); self.global_settings = None; @@ -723,104 +732,21 @@ impl App { self.focused = self.pre_overlay_focused; } widgets::popups::instance_settings::Action::Save(updated, desktop) => { - let mut updated = *updated; - if let Some(previous) = self.instances_state.selected_instance().cloned() { - let structural_change = previous.game_version != updated.game_version - || previous.loader != updated.loader - || previous.loader_version != updated.loader_version; - if structural_change { - if crate::instance::runtime::is_active(&previous.name) { - error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message: "Stop the instance before changing its runtime" - .to_owned(), - pushed_at: std::time::Instant::now(), - }); - return Ok(()); - } - self.spawn_instance_settings_update(previous, updated, desktop); - self.instance_settings = None; - self.focused = self.pre_overlay_focused; - return Ok(()); - } - if previous.config_sync_profile != updated.config_sync_profile { - let instance_dir = - self.instance_manager.instances_dir.join(&previous.name); - match crate::instance::config_sync::switch_profile( - &previous.name, - previous.config_sync_profile.as_deref(), - updated.config_sync_profile.as_deref(), - &self.instance_manager.meta_dir, - &instance_dir, - ) { - Ok(profile) => updated.config_sync_profile = profile, - Err(error) => { - error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message: error.to_string(), - pushed_at: std::time::Instant::now(), - }); - return Ok(()); - } - } - } - match self.instance_manager.save(&updated) { - Ok(()) => { - let shortcut_result = if desktop { - crate::instance::desktop::create(&updated).map(|_| ()) - } else { - crate::instance::desktop::remove(&updated.name) - }; - if let Err(error) = shortcut_result { - error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message: format!( - "Instance saved, but shortcut update failed: {error}" - ), - pushed_at: std::time::Instant::now(), - }); - } - self.instances_state - .replace_instance(&previous.name, updated); - error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::INFO, - message: format!("Updated instance '{}'", previous.name), - pushed_at: std::time::Instant::now(), - }); - self.instance_settings = None; - self.focused = self.pre_overlay_focused; - } - Err(error) => { - if previous.config_sync_profile != updated.config_sync_profile { - let instance_dir = - self.instance_manager.instances_dir.join(&previous.name); - if let Err(rollback_error) = - crate::instance::config_sync::switch_profile( - &previous.name, - updated.config_sync_profile.as_deref(), - previous.config_sync_profile.as_deref(), - &self.instance_manager.meta_dir, - &instance_dir, - ) - { - tracing::error!( - "Failed to roll back config profile: {rollback_error}" - ); - } - } - error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message: error.to_string(), - pushed_at: std::time::Instant::now(), - }); - } - } - } + self.apply_instance_settings(*updated, desktop); + } + widgets::popups::instance_settings::Action::ConfirmRuntime { name, from, to } => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::InstanceRuntime { + name, + from, + to, + }); + self.focused = FocusedArea::ConfirmDelete; + } + widgets::popups::instance_settings::Action::ConfirmClose => { + confirm_popup::set_pending( + confirm_popup::ConfirmTarget::DiscardInstanceSettings, + ); + self.focused = FocusedArea::ConfirmDelete; } } return Ok(()); @@ -2119,6 +2045,68 @@ impl App { ); } } + + fn apply_instance_settings( + &mut self, + updated: crate::instance::models::InstanceConfig, + desktop: bool, + ) { + let Some(previous) = self.instances_state.selected_instance().cloned() else { + return; + }; + let structural_change = previous.game_version != updated.game_version + || previous.loader != updated.loader + || previous.loader_version != updated.loader_version; + if structural_change { + if crate::instance::runtime::is_active(&previous.name) { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: "Stop the instance before changing its runtime".to_owned(), + pushed_at: std::time::Instant::now(), + }); + return; + } + self.spawn_instance_settings_update(previous, updated, desktop); + self.instance_settings = None; + self.focused = self.pre_overlay_focused; + return; + } + + match self.instance_manager.save(&updated) { + Ok(()) => { + let shortcut_result = if desktop { + crate::instance::desktop::create(&updated).map(|_| ()) + } else { + crate::instance::desktop::remove(&updated.name) + }; + if let Err(error) = shortcut_result { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!("Instance saved, but shortcut update failed: {error}"), + pushed_at: std::time::Instant::now(), + }); + } + self.instances_state + .replace_instance(&previous.name, updated); + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::INFO, + message: format!("Updated instance '{}'", previous.name), + pushed_at: std::time::Instant::now(), + }); + self.instance_settings = None; + self.focused = self.pre_overlay_focused; + } + Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: error.to_string(), + pushed_at: std::time::Instant::now(), + }), + } + } } fn delete_content_path(path: &std::path::Path) -> std::io::Result<()> { diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index bf0007c..5e22625 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -582,47 +582,84 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { ui.draw(); assert!(ui.screen().contains("Instance Settings")); assert!(ui.screen().contains("settings-test")); - assert!(ui.screen().contains("Runtime")); - assert!(ui.screen().contains("Version")); + assert!(ui.screen().contains("Game version")); + assert!(ui.screen().contains("Memory min")); assert!(ui.screen().contains("Desktop")); assert!(!ui.screen().contains("Integration")); - assert!(ui.screen().contains('▰')); - assert!(ui.screen().contains('‹')); - assert!(ui.screen().contains('›')); + assert!(!ui.screen().contains('▰')); ui.key(KeyCode::Down); ui.key(KeyCode::Enter); ui.draw(); - assert!(ui.screen().contains("Loader")); assert!(ui.screen().contains("Fabric")); + assert!(ui.screen().contains("Forge")); ui.key(KeyCode::Esc); for _ in 0..5 { ui.key(KeyCode::Down); } ui.key(KeyCode::Enter); + for character in "-Xfoo".chars() { + ui.key(KeyCode::Char(character)); + } ui.draw(); - assert!(ui.screen().contains("JVM Arguments")); - assert!(ui.screen().contains("No custom JVM arguments")); - ui.key(KeyCode::Esc); + assert!(ui.screen().contains("-Xfoo")); + ui.key(KeyCode::Enter); ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + ui.draw(); + assert!(ui.screen().contains("Discard changes")); + ui.key(KeyCode::Enter); assert_eq!(ui.app.focused, FocusedArea::Settings); ui.key(KeyCode::Char('g')); assert_eq!(ui.app.focused, FocusedArea::GlobalSettings); ui.draw(); assert!(ui.screen().contains("Launcher Settings")); - assert!(ui.screen().contains("Launch Defaults")); - assert!(ui.screen().contains("Max memory")); - assert!(ui.screen().contains('▰')); - assert!(ui.screen().contains('‹')); - assert!(ui.screen().contains('›')); + assert!(ui.screen().contains("Memory max")); + assert!(!ui.screen().contains('▰')); ui.key(KeyCode::Enter); ui.draw(); - assert!(ui.screen().contains("Themes")); + assert!(ui.screen().contains("green")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Settings); } +#[test] +fn runtime_settings_use_the_shared_confirmation_popup() { + let mut ui = UiHarness::new(); + ui.add_instance("runtime-test"); + ui.app.focused = FocusedArea::Settings; + ui.key(KeyCode::Right); + ui.key(KeyCode::Char('e')); + ui.app + .instance_settings + .as_mut() + .unwrap() + .draft + .game_version = "1.21.2".to_owned(); + + ui.key(KeyCode::Char('s')); + + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::InstanceRuntime { name, .. }) if name == "runtime-test" + )); + ui.draw(); + assert!(ui.screen().contains("Change runtime")); + assert!(ui.screen().contains("Runtime files will be downloaded")); + + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); + ui.key(KeyCode::Esc); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::DiscardInstanceSettings) + )); + ui.key(KeyCode::Enter); + assert_eq!(ui.app.focused, FocusedArea::Settings); +} + #[test] fn settings_panel_keeps_direct_profile_management() { let mut ui = UiHarness::new(); diff --git a/src/tui/tests/widgets/settings.rs b/src/tui/tests/widgets/settings.rs index 4bac7f5..519faed 100644 --- a/src/tui/tests/widgets/settings.rs +++ b/src/tui/tests/widgets/settings.rs @@ -17,3 +17,15 @@ fn removing_selected_last_profile_clamps_selection() { assert_eq!(state.active_profile, None); assert_eq!(state.list_state.selected, Some(1)); } + +#[test] +fn info_pane_does_not_bind_desktop_toggle() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = SettingsState::new(tmp.path().to_path_buf()); + state.pane = SettingsPane::Info; + + assert!(matches!( + handle_key(&KeyEvent::from(KeyCode::Char('d')), &mut state, None), + SettingsAction::None + )); +} diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 93d1a7f..f5651ac 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -45,12 +45,23 @@ pub enum ConfirmTarget { OrphanDependencies { paths: Vec, }, + InstanceRuntime { + name: String, + from: String, + to: String, + }, + DiscardInstanceSettings, + DiscardLauncherSettings, } impl ConfirmTarget { fn title(&self) -> String { match self { Self::OrphanDependencies { .. } => " Remove unused dependencies ".to_owned(), + Self::InstanceRuntime { .. } => " Change runtime ".to_owned(), + Self::DiscardInstanceSettings | Self::DiscardLauncherSettings => { + " Discard changes ".to_owned() + } _ => format!(" Delete '{}' ", self.name()), } } @@ -89,6 +100,15 @@ impl ConfirmTarget { }) .collect::>() .join("\n"), + ConfirmTarget::InstanceRuntime { from, to, .. } => format!( + "{from} → {to}\nRuntime files will be downloaded before saving.\n! Existing mods may be incompatible." + ), + ConfirmTarget::DiscardInstanceSettings => { + "Unsaved instance settings will be lost.".to_owned() + } + ConfirmTarget::DiscardLauncherSettings => { + "Unsaved launcher settings will be lost.".to_owned() + } } } @@ -99,6 +119,9 @@ impl ConfirmTarget { ConfirmTarget::ConfigProfile { profile } => profile, ConfirmTarget::Content { name, .. } => name, ConfirmTarget::OrphanDependencies { .. } => "unused dependencies", + ConfirmTarget::InstanceRuntime { name, .. } => name, + ConfirmTarget::DiscardInstanceSettings => "instance settings", + ConfirmTarget::DiscardLauncherSettings => "launcher settings", } } @@ -106,6 +129,8 @@ impl ConfirmTarget { match self { Self::Content { dependents, .. } if !dependents.is_empty() => " delete anyway", Self::OrphanDependencies { .. } => " remove all", + Self::InstanceRuntime { .. } => " change", + Self::DiscardInstanceSettings | Self::DiscardLauncherSettings => " discard", _ => " confirm", } } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 3e7a11d..deb25ac 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, Paragraph}, }; use ratatui_textarea::{CursorMove, TextArea}; @@ -21,12 +21,6 @@ use crate::{ instance::models::normalize_memory_value, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PresetPicker { - Border, - Java, -} - pub struct State { pub config: Config, pub theme: ThemeConfig, @@ -34,18 +28,15 @@ pub struct State { editing: Option>, error: Option, config_dirty: bool, - confirm_close: bool, themes: Vec, theme_picker: bool, theme_index: usize, - preset_picker: Option, - preset_index: usize, - detected_java: String, } pub enum Action { None, Save(Box, String, BorderStyle), + ConfirmClose, OpenRaw(std::path::PathBuf), Close, } @@ -65,13 +56,9 @@ impl State { editing: None, error: None, config_dirty: false, - confirm_close: false, themes, theme_picker: false, theme_index, - preset_picker: None, - preset_index: 0, - detected_java: crate::instance::java::detect_java_path(), } } @@ -88,12 +75,7 @@ impl State { fn display_value(&self, field: usize) -> String { match field { - 1 => match &self.theme.border_style { - BorderStyle::Rounded => "╭─╮ rounded".to_owned(), - BorderStyle::Plain => "┌─┐ plain".to_owned(), - BorderStyle::Double => "╔═╗ double".to_owned(), - BorderStyle::Thick => "┏━┓ thick".to_owned(), - }, + 1 => format!("{:?}", self.theme.border_style).to_lowercase(), 4 if self .config .paths @@ -154,14 +136,14 @@ impl State { fn handle_theme_picker_key(&mut self, key: &KeyEvent) { match key.code { - KeyCode::Esc => self.theme_picker = false, + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.theme_picker = false, KeyCode::Char('j') | KeyCode::Down => { self.theme_index = (self.theme_index + 1).min(self.themes.len() - 1); } KeyCode::Char('k') | KeyCode::Up => { self.theme_index = self.theme_index.saturating_sub(1); } - KeyCode::Enter => { + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { self.select_theme(); if self.error.is_none() { self.theme_picker = false; @@ -171,174 +153,6 @@ impl State { } } - fn preset_values_for(&self, picker: PresetPicker) -> Vec { - match picker { - PresetPicker::Border => vec![ - "╭─╮ rounded".to_owned(), - "┌─┐ plain".to_owned(), - "╔═╗ double".to_owned(), - "┏━┓ thick".to_owned(), - ], - PresetPicker::Java => { - let mut values = vec!["automatic detection".to_owned()]; - if !self.detected_java.is_empty() { - values.push(self.detected_java.clone()); - } - if let Some(current) = &self.config.paths.java_path - && !values.contains(current) - { - values.push(current.clone()); - } - values.push("custom path…".to_owned()); - values - } - } - } - - fn preset_values(&self) -> Vec { - self.preset_picker - .map_or_else(Vec::new, |picker| self.preset_values_for(picker)) - } - - fn open_preset_picker(&mut self, picker: PresetPicker) { - self.preset_picker = Some(picker); - let current = match picker { - PresetPicker::Border => None, - PresetPicker::Java => self.config.paths.java_path.as_deref(), - }; - self.preset_index = self - .preset_values_for(picker) - .iter() - .position(|value| match (picker, current) { - (PresetPicker::Border, _) => value == &self.display_value(1), - (PresetPicker::Java, None) => value == "automatic detection", - (_, Some(current)) => value == current, - }) - .unwrap_or(0); - } - - fn apply_preset(&mut self) { - let selected = self - .preset_values() - .get(self.preset_index) - .cloned() - .unwrap_or_default(); - match self.preset_picker { - Some(PresetPicker::Border) => { - let previous = self.theme.border_style.clone(); - self.theme.border_style = match self.preset_index { - 0 => BorderStyle::Rounded, - 1 => BorderStyle::Plain, - 2 => BorderStyle::Double, - _ => BorderStyle::Thick, - }; - self.error = None; - if let Err(error) = crate::config::theme::apply_theme( - self.theme.theme.clone(), - self.theme.border_style.clone(), - ) { - self.error = Some(error.to_string()); - self.theme.border_style = previous; - } - } - Some(PresetPicker::Java) if selected == "custom path…" => { - self.editing = Some(new_text_area(vec![self.value(4)])); - } - Some(PresetPicker::Java) => { - self.config.paths.java_path = - (selected != "automatic detection").then_some(selected); - self.config_dirty = true; - } - None => {} - } - self.preset_picker = None; - } - - fn handle_preset_key(&mut self, key: &KeyEvent) { - let count = self.preset_values().len(); - match key.code { - KeyCode::Esc => self.preset_picker = None, - KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { - self.preset_index = (self.preset_index + 1).min(count - 1); - } - KeyCode::Char('k') | KeyCode::Up | KeyCode::Left => { - self.preset_index = self.preset_index.saturating_sub(1); - } - KeyCode::Enter => self.apply_preset(), - _ => {} - } - } - - fn rotate_preset(&mut self, picker: PresetPicker, forward: bool) { - self.open_preset_picker(picker); - let count = self.preset_values().len().saturating_sub(1); - if count > 0 { - self.preset_index = if forward { - (self.preset_index + 1) % count - } else { - (self.preset_index + count - 1) % count - }; - self.apply_preset(); - } - } - - fn adjust_memory(&mut self, field: usize, forward: bool) { - let values = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"]; - let current = if field == 2 { - &self.config.defaults.memory_min - } else { - &self.config.defaults.memory_max - }; - let exact = values.iter().position(|value| *value == current); - let next = if let Some(index) = exact { - if forward { - (index + 1) % values.len() - } else { - (index + values.len() - 1) % values.len() - } - } else { - let current_kib = memory_kib(current).unwrap_or_default(); - if forward { - values - .iter() - .position(|value| memory_kib(value).is_some_and(|kib| kib > current_kib)) - .unwrap_or(0) - } else { - values - .iter() - .rposition(|value| memory_kib(value).is_some_and(|kib| kib < current_kib)) - .unwrap_or(values.len() - 1) - } - }; - let value = values[next].to_owned(); - if field == 2 { - self.config.defaults.memory_min = value.clone(); - if memory_kib(&value) > memory_kib(&self.config.defaults.memory_max) { - self.config.defaults.memory_max = value; - } - } else { - self.config.defaults.memory_max = value.clone(); - if memory_kib(&value) < memory_kib(&self.config.defaults.memory_min) { - self.config.defaults.memory_min = value; - } - } - self.config_dirty = true; - self.error = None; - } - - fn cycle_theme(&mut self, forward: bool) { - let count = self.themes.len(); - if count == 0 { - return; - } - self.theme_index = if forward { - (self.theme_index + 1) % count - } else { - (self.theme_index + count - 1) % count - }; - self.select_theme(); - } - fn cycle_border(&mut self, forward: bool) { let previous = self.theme.border_style.clone(); self.theme.border_style = match (&self.theme.border_style, forward) { @@ -368,22 +182,10 @@ impl State { } pub fn handle_key(&mut self, key: &KeyEvent) -> Action { - if self.preset_picker.is_some() { - self.handle_preset_key(key); - return Action::None; - } if self.theme_picker { self.handle_theme_picker_key(key); return Action::None; } - if self.confirm_close { - match key.code { - KeyCode::Char('y') | KeyCode::Enter => return Action::Close, - KeyCode::Char('n') | KeyCode::Esc => self.confirm_close = false, - _ => {} - } - return Action::None; - } if let Some(input) = &mut self.editing { match key.code { KeyCode::Enter => self.commit_edit(), @@ -397,30 +199,13 @@ impl State { match key.code { KeyCode::Char('j') | KeyCode::Down => self.selected = (self.selected + 1).min(4), KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), - KeyCode::Left => match self.selected { - 0 => self.cycle_theme(false), - 1 => self.cycle_border(false), - 2 | 3 => self.adjust_memory(self.selected, false), - 4 => self.rotate_preset(PresetPicker::Java, false), - _ => {} - }, - KeyCode::Right => match self.selected { - 0 => self.cycle_theme(true), - 1 => self.cycle_border(true), - 2 | 3 => self.adjust_memory(self.selected, true), - 4 => self.rotate_preset(PresetPicker::Java, true), - _ => {} - }, + KeyCode::Char('h') | KeyCode::Left if self.selected == 1 => self.cycle_border(false), + KeyCode::Char('l') | KeyCode::Right if self.selected == 1 => self.cycle_border(true), KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, - 1 => self.open_preset_picker(PresetPicker::Border), - 2 | 3 => self.adjust_memory(self.selected, true), - 4 => self.open_preset_picker(PresetPicker::Java), - _ => {} + 1 => self.cycle_border(true), + field => self.editing = Some(new_text_area(vec![self.value(field)])), }, - KeyCode::Char('c') if matches!(self.selected, 2 | 3) => { - self.editing = Some(new_text_area(vec![self.value(self.selected)])); - } KeyCode::Char('s') => { if self.validate_before_save() { return Action::Save( @@ -443,7 +228,7 @@ impl State { }; return Action::OpenRaw(crate::config::get_config_path().join(file)); } - KeyCode::Esc if self.config_dirty => self.confirm_close = true, + KeyCode::Esc if self.config_dirty => return Action::ConfirmClose, KeyCode::Esc => return Action::Close, _ => {} } @@ -509,13 +294,13 @@ fn new_text_area(lines: Vec) -> TextArea<'static> { } pub fn popup_rect(area: Rect, state: &State) -> Rect { - let height = if state.theme_picker || state.preset_picker.is_some() || state.editing.is_some() { - 14 + let height = if state.theme_picker { + (area.height * 2 / 3).max(10) } else { - 12 + 7 + u16::from(state.error.is_some()) }; area.centered( - ratatui::layout::Constraint::Percentage(68), + ratatui::layout::Constraint::Percentage(52), ratatui::layout::Constraint::Length(height.min(area.height.saturating_sub(4))), ) } @@ -523,149 +308,43 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { pub fn render(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); frame.render_widget(Clear, area); - let keybinds = if state.theme_picker || state.preset_picker.is_some() { - super::keybind_line(&[("j/k", " move"), ("Enter", " apply"), ("Esc", " back")]) - } else if matches!(state.selected, 2 | 3) { - super::keybind_line(&[ - ("j/k", ""), - ("←/→", " memory"), - ("c", " custom"), - ("s", " save"), - ("E", " raw"), - ("Esc", " back"), - ]) + let keybinds = if state.editing.is_some() { + super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) + } else if state.theme_picker { + super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else { super::keybind_line(&[ ("j/k", ""), - ("Enter", " open"), - ("←/→", " switch"), + ("Enter", " edit"), ("s", " save"), ("E", " raw"), ("Esc", " back"), ]) }; - let mut title = vec![Span::styled( - " Launcher Settings ", - Style::default() - .fg(theme.text()) - .add_modifier(Modifier::BOLD), - )]; - if state.config_dirty { - title.push(Span::styled( - "● modified ", - Style::default().fg(theme.warning()), - )); - } + let title = if state.config_dirty { + " Launcher Settings * " + } else { + " Launcher Settings " + }; let block = Block::default() - .title(Line::from(title)) + .title(title) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(theme.text_dim())) .style(Style::default().bg(theme.surface())) - .title_bottom(keybinds); + .title_bottom(keybinds.right_aligned()); let inner = block.inner(area); frame.render_widget(block, area); if state.theme_picker { - render_picker(frame, inner, " Themes ", &state.themes, state.theme_index); + render_picker(frame, inner, &state.themes, state.theme_index); return; } - if let Some(picker) = state.preset_picker { - let title = match picker { - PresetPicker::Border => " Border Style ", - PresetPicker::Java => " Java Runtime ", - }; - render_picker( - frame, - inner, - title, - &state.preset_values(), - state.preset_index, - ); - return; - } - - let sections = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Length(4), - ratatui::layout::Constraint::Length(5), - ratatui::layout::Constraint::Min(1), - ]) - .split(inner); - render_global_card( - frame, - sections[0], - " Appearance ", - state, - &[(0, "Theme"), (1, "Borders")], - ); - render_global_card( - frame, - sections[1], - " Launch Defaults ", - state, - &[(2, "Min memory"), (3, "Max memory"), (4, "Java")], - ); - - if let Some(editor) = state.editing.as_ref() { - let editor_block = Block::default() - .title(match state.selected { - 2 => " Custom minimum memory (K/M/G) · Enter apply ", - 3 => " Custom maximum memory (K/M/G) · Enter apply ", - 4 => " Custom Java executable path · Enter apply ", - _ => " Custom value · Enter apply ", - }) - .borders(Borders::ALL) - .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().bg(theme.surface())); - let editor_inner = editor_block.inner(sections[2]); - frame.render_widget(editor_block, sections[2]); - frame.render_widget(editor, editor_inner); - } else { - let status = if let Some(error) = &state.error { - Line::from(Span::styled( - format!(" × {error}"), - Style::default().fg(theme.error()), - )) - } else if state.confirm_close { - Line::from(Span::styled( - " ! Discard unsaved launcher defaults? [y] yes [n] no", - Style::default().fg(theme.warning()), - )) - } else { - Line::from(vec![ - Span::styled(" ◇ Live preview ", Style::default().fg(theme.info())), - Span::styled( - "theme and border changes apply immediately", - Style::default().fg(theme.text_dim()), - ), - ]) - }; - frame.render_widget( - Paragraph::new(status).wrap(Wrap { trim: true }), - sections[2], - ); - } + render_settings_list(frame, inner, state); } -fn render_picker( - frame: &mut Frame, - area: Rect, - title: &'static str, - values: &[String], - selected: usize, -) { +fn render_picker(frame: &mut Frame, area: Rect, values: &[String], selected: usize) { let theme = THEME.as_ref(); - let block = Block::default() - .title(Span::styled(title, Style::default().fg(theme.accent()))) - .borders(Borders::ALL) - .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().bg(theme.surface())); - let inner = block.inner(area); - frame.render_widget(block, area); - let visible_rows = inner.height as usize; + let visible_rows = area.height as usize; let start = selected.saturating_sub(visible_rows.saturating_sub(1)); let lines = values .iter() @@ -694,119 +373,70 @@ fn render_picker( }), ), ]) - .style(Style::default().bg(if focused { - theme.stripe() - } else { - theme.surface() - })) }) .collect::>(); - frame.render_widget(Paragraph::new(lines), inner); + frame.render_widget(Paragraph::new(lines), area); } -fn render_global_card( - frame: &mut Frame, - area: Rect, - title: &'static str, - state: &State, - fields: &[(usize, &str)], -) { +fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); - let active = fields.iter().any(|(index, _)| *index == state.selected); - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(if active { - theme.accent() - } else { - theme.text_dim() - }), - )) - .borders(Borders::ALL) - .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(if active { - theme.accent() - } else { - theme.border() - })) - .style(Style::default().bg(theme.surface())); - let inner = block.inner(area); - frame.render_widget(block, area); - let lines = fields + let labels = ["Theme", "Border style", "Memory min", "Memory max", "Java"]; + let lines = labels .iter() - .map(|(index, label)| global_field_line(state, *index, label)) + .enumerate() + .map(|(index, label)| global_field_line(state, index, label)) + .chain(state.error.iter().map(|error| { + Line::from(Span::styled( + format!(" {error}"), + Style::default().fg(theme.error()), + )) + })) .collect::>(); - frame.render_widget(Paragraph::new(lines), inner); + frame.render_widget(Paragraph::new(lines), area); + + if let Some(editor) = state.editing.as_ref() { + let edit_area = Rect { + x: area.x.saturating_add(20), + y: area.y.saturating_add(state.selected as u16), + width: area.width.saturating_sub(20), + height: 1, + }; + frame.render_widget(editor, edit_area); + } } fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { let theme = THEME.as_ref(); let selected = index == state.selected; - let displayed = state.display_value(index); - let mut spans = vec![ + let editing = selected && state.editing.is_some(); + Line::from(vec![ Span::styled( if selected { "▶ " } else { " " }, Style::default().fg(theme.accent()), ), Span::styled( - format!("{label:<13}"), + format!("{label:<18}"), Style::default().fg(theme.text_dim()), ), - ]; - if matches!(index, 2 | 3) { - spans.push(global_memory_slider(&displayed, selected)); - } else { - spans.push(Span::styled( - format!(" ‹ {displayed} › "), + Span::styled( + if editing { + String::new() + } else { + state.display_value(index) + }, Style::default() .fg(if selected { theme.accent() } else { theme.text() }) - .bg(theme.background()) .add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() }), - )); - } - Line::from(spans).style(Style::default().bg(if selected { - theme.stripe() - } else { - theme.surface() - })) -} - -fn global_memory_slider(value: &str, selected: bool) -> Span<'static> { - let theme = THEME.as_ref(); - let thresholds = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"]; - let value_kib = memory_kib(value).unwrap_or_default(); - let step = thresholds - .iter() - .position(|threshold| memory_kib(threshold).is_some_and(|limit| value_kib <= limit)) - .unwrap_or(thresholds.len() - 1); - let filled = step + 1; - Span::styled( - format!( - " ◀ {}{} {value} ▶ ", - "▰".repeat(filled), - "▱".repeat(thresholds.len() - filled) ), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .bg(theme.background()) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ) + ]) } #[cfg(test)] @@ -814,18 +444,15 @@ mod tests { use super::*; #[test] - fn launcher_defaults_use_memory_sliders_and_java_picker() { + fn launcher_memory_and_java_use_inline_editors() { let mut state = State::new(); state.selected = 2; - let original = state.config.defaults.memory_min.clone(); - state.handle_key(&KeyEvent::from(KeyCode::Right)); - assert_ne!(state.config.defaults.memory_min, original); - assert!(state.preset_picker.is_none()); - assert!(state.editing.is_none()); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert!(state.editing.is_some()); + state.editing = None; state.selected = 4; state.handle_key(&KeyEvent::from(KeyCode::Enter)); - assert_eq!(state.preset_picker, Some(PresetPicker::Java)); - assert!(state.preset_values().contains(&"custom path…".to_owned())); + assert!(state.editing.is_some()); } } diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 5949312..7fd8ab5 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -6,10 +6,10 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, - layout::{Constraint, Direction, Layout, Rect}, + layout::{Constraint, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, Paragraph}, }; use ratatui_textarea::{CursorMove, TextArea}; use std::sync::{Arc, Mutex}; @@ -36,8 +36,6 @@ enum VersionPicker { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChoicePicker { Loader, - Java, - Resolution, } enum PickerLoad { @@ -53,14 +51,9 @@ pub struct State { pub draft: InstanceConfig, selected: usize, editing: Option>, - jvm_args_open: bool, - jvm_arg_index: usize, - jvm_arg_input: Option<(Option, TextArea<'static>)>, pub desktop: bool, original_desktop: bool, error: Option, - confirm_close: bool, - confirm_runtime_change: bool, picker: Option, picker_index: usize, picker_initialized: bool, @@ -71,12 +64,17 @@ pub struct State { loader_versions: SharedLoad>, choice_picker: Option, choice_index: usize, - detected_java: String, } pub enum Action { None, Save(Box, bool), + ConfirmRuntime { + name: String, + from: String, + to: String, + }, + ConfirmClose, OpenRaw, Close, } @@ -88,14 +86,9 @@ impl State { draft: instance.clone(), selected: 0, editing: None, - jvm_args_open: false, - jvm_arg_index: 0, - jvm_arg_input: None, desktop: crate::instance::desktop::exists(&instance.name), original_desktop: crate::instance::desktop::exists(&instance.name), error: None, - confirm_close: false, - confirm_runtime_change: false, picker: None, picker_index: 0, picker_initialized: false, @@ -106,7 +99,6 @@ impl State { loader_versions: Arc::new(Mutex::new(LoadState::Idle)), choice_picker: None, choice_index: 0, - detected_java: crate::instance::java::detect_java_path(), } } @@ -183,7 +175,7 @@ impl State { format!("default ({})", SETTINGS.read().defaults.memory_max) } 6 if self.draft.jvm_args.is_empty() => "no arguments".to_owned(), - 6 => format!("{} argument(s)", self.draft.jvm_args.len()), + 6 => self.draft.jvm_args.join(" "), 7 if self.draft.resolution.is_none() => "default".to_owned(), 8 if self.desktop => "● enabled".to_owned(), 8 => "○ disabled".to_owned(), @@ -199,15 +191,7 @@ impl State { self.error = Some("Vanilla does not use a loader version".to_owned()); } 2 => self.open_loader_picker(), - 3 => self.open_choice_picker(ChoicePicker::Java), - 4 | 5 => self.adjust_memory(self.selected, true), - 6 => { - self.jvm_args_open = true; - self.jvm_arg_index = self - .jvm_arg_index - .min(self.draft.jvm_args.len().saturating_sub(1)); - } - 7 => self.open_choice_picker(ChoicePicker::Resolution), + 3..=7 => self.editing = Some(new_text_area(vec![self.value(self.selected)])), 8 => self.desktop = !self.desktop, field => self.editing = Some(new_text_area(vec![self.value(field)])), } @@ -220,26 +204,6 @@ impl State { .iter() .position(|loader| *loader == self.draft.loader) .unwrap_or(0), - ChoicePicker::Java => self - .choice_values_for(picker) - .iter() - .position(|value| { - self.draft.java_path.as_deref().map_or_else( - || value == "automatic / launcher default", - |current| value == current, - ) - }) - .unwrap_or(0), - ChoicePicker::Resolution => self - .choice_values_for(picker) - .iter() - .position(|value| { - self.draft.resolution.map_or_else( - || value == "window default", - |(width, height)| value == &format!("{width}x{height}"), - ) - }) - .unwrap_or(0), }; } @@ -251,61 +215,25 @@ impl State { fn choice_values_for(&self, picker: ChoicePicker) -> Vec { match picker { ChoicePicker::Loader => loaders().iter().map(ToString::to_string).collect(), - ChoicePicker::Java => { - let mut values = vec!["automatic / launcher default".to_owned()]; - if !self.detected_java.is_empty() { - values.push(self.detected_java.clone()); - } - if let Some(current) = &self.draft.java_path - && !values.contains(current) - { - values.push(current.clone()); - } - values.push("custom path…".to_owned()); - values - } - ChoicePicker::Resolution => { - let mut values = vec![ - "window default".to_owned(), - "854x480".to_owned(), - "1280x720".to_owned(), - "1600x900".to_owned(), - "1920x1080".to_owned(), - "2560x1440".to_owned(), - ]; - if let Some((width, height)) = self.draft.resolution { - let current = format!("{width}x{height}"); - if !values.contains(¤t) { - values.push(current); - } - } - values.push("custom resolution…".to_owned()); - values - } } } fn handle_choice_key(&mut self, key: &KeyEvent) { let count = self.choice_values().len(); match key.code { - KeyCode::Esc => self.choice_picker = None, + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.choice_picker = None, KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { self.choice_index = (self.choice_index + 1).min(count - 1); } - KeyCode::Char('k') | KeyCode::Up | KeyCode::Left => { + KeyCode::Char('k') | KeyCode::Up => { self.choice_index = self.choice_index.saturating_sub(1); } - KeyCode::Enter => self.apply_choice(), + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.apply_choice(), _ => {} } } fn apply_choice(&mut self) { - let selected = self - .choice_values() - .get(self.choice_index) - .cloned() - .unwrap_or_default(); match self.choice_picker { Some(ChoicePicker::Loader) => { let available = loaders(); @@ -317,185 +245,11 @@ impl State { self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); } } - Some(ChoicePicker::Java) if selected == "custom path…" => { - self.editing = Some(new_text_area(vec![self.value(3)])); - } - Some(ChoicePicker::Java) => { - self.draft.java_path = - (selected != "automatic / launcher default").then_some(selected); - } - Some(ChoicePicker::Resolution) if selected == "custom resolution…" => { - self.editing = Some(new_text_area(vec![self.value(7)])); - } - Some(ChoicePicker::Resolution) => { - self.draft.resolution = (selected != "window default") - .then(|| parse_resolution(&selected).ok()) - .flatten(); - } None => {} } self.choice_picker = None; } - fn rotate_choice(&mut self, picker: ChoicePicker, forward: bool) { - self.open_choice_picker(picker); - let count = self - .choice_values() - .len() - .saturating_sub(usize::from(matches!( - picker, - ChoicePicker::Java | ChoicePicker::Resolution - ))); - if count > 0 { - self.choice_index = if forward { - (self.choice_index + 1) % count - } else { - (self.choice_index + count - 1) % count - }; - self.apply_choice(); - } - } - - fn adjust_memory(&mut self, field: usize, forward: bool) { - let settings = SETTINGS.read(); - let current = if field == 4 { - self.draft - .memory_min - .as_deref() - .unwrap_or(&settings.defaults.memory_min) - } else { - self.draft - .memory_max - .as_deref() - .unwrap_or(&settings.defaults.memory_max) - }; - let values = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"]; - let exact = values.iter().position(|value| *value == current); - let next = if let Some(index) = exact { - if forward { - (index + 1) % values.len() - } else { - (index + values.len() - 1) % values.len() - } - } else { - let current_kib = memory_kib(current).unwrap_or_default(); - if forward { - values - .iter() - .position(|value| memory_kib(value).is_some_and(|kib| kib > current_kib)) - .unwrap_or(0) - } else { - values - .iter() - .rposition(|value| memory_kib(value).is_some_and(|kib| kib < current_kib)) - .unwrap_or(values.len() - 1) - } - }; - drop(settings); - self.set_memory(field, Some(values[next].to_owned())); - } - - fn set_memory(&mut self, field: usize, value: Option) { - if field == 4 { - self.draft.memory_min = value.clone(); - } else { - self.draft.memory_max = value.clone(); - } - - let settings = SETTINGS.read(); - let min = self - .draft - .memory_min - .as_deref() - .unwrap_or(&settings.defaults.memory_min); - let max = self - .draft - .memory_max - .as_deref() - .unwrap_or(&settings.defaults.memory_max); - if memory_kib(min) - .zip(memory_kib(max)) - .is_some_and(|(min, max)| min > max) - { - if field == 4 { - self.draft.memory_max = value; - } else { - self.draft.memory_min = value; - } - } - } - - fn handle_jvm_args_key(&mut self, key: &KeyEvent) { - if let Some((target, input)) = &mut self.jvm_arg_input { - match key.code { - KeyCode::Enter => { - let value = input.lines().join(" ").trim().to_owned(); - let target = *target; - self.jvm_arg_input = None; - if value.is_empty() { - return; - } - if let Some(index) = target { - if let Some(argument) = self.draft.jvm_args.get_mut(index) { - *argument = value; - } - } else { - self.draft.jvm_args.push(value); - self.jvm_arg_index = self.draft.jvm_args.len() - 1; - } - } - KeyCode::Esc => self.jvm_arg_input = None, - _ => { - input.input(*key); - } - } - return; - } - - match key.code { - KeyCode::Esc => self.jvm_args_open = false, - KeyCode::Char('j') | KeyCode::Down if !self.draft.jvm_args.is_empty() => { - self.jvm_arg_index = (self.jvm_arg_index + 1).min(self.draft.jvm_args.len() - 1); - } - KeyCode::Char('k') | KeyCode::Up => { - self.jvm_arg_index = self.jvm_arg_index.saturating_sub(1); - } - KeyCode::Left if self.jvm_arg_index > 0 => { - self.draft - .jvm_args - .swap(self.jvm_arg_index, self.jvm_arg_index - 1); - self.jvm_arg_index -= 1; - } - KeyCode::Right if self.jvm_arg_index + 1 < self.draft.jvm_args.len() => { - self.draft - .jvm_args - .swap(self.jvm_arg_index, self.jvm_arg_index + 1); - self.jvm_arg_index += 1; - } - KeyCode::Char('a') => { - self.jvm_arg_input = Some((None, new_text_area(vec![String::new()]))); - } - KeyCode::Enter if self.draft.jvm_args.is_empty() => { - self.jvm_arg_input = Some((None, new_text_area(vec![String::new()]))); - } - KeyCode::Enter if !self.draft.jvm_args.is_empty() => { - let index = self.jvm_arg_index.min(self.draft.jvm_args.len() - 1); - self.jvm_arg_input = Some(( - Some(index), - new_text_area(vec![self.draft.jvm_args[index].clone()]), - )); - } - KeyCode::Char('d') if !self.draft.jvm_args.is_empty() => { - let index = self.jvm_arg_index.min(self.draft.jvm_args.len() - 1); - self.draft.jvm_args.remove(index); - self.jvm_arg_index = self - .jvm_arg_index - .min(self.draft.jvm_args.len().saturating_sub(1)); - } - _ => {} - } - } - fn open_game_picker(&mut self) { self.picker = Some(VersionPicker::Game); self.picker_index = 0; @@ -636,7 +390,7 @@ impl State { None => 0, }; match key.code { - KeyCode::Esc => self.picker = None, + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.picker = None, KeyCode::Char('/') => self.picker_search = true, KeyCode::Char('s') if self.picker == Some(VersionPicker::Game) => { self.show_snapshots = !self.show_snapshots; @@ -649,7 +403,7 @@ impl State { KeyCode::Char('k') | KeyCode::Up => { self.picker_index = self.picker_index.saturating_sub(1); } - KeyCode::Enter => match self.picker { + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => match self.picker { Some(VersionPicker::Game) => { if let Some(version) = self.visible_game_versions().get(self.picker_index) { if self.draft.game_version != version.id { @@ -694,6 +448,9 @@ impl State { ), 4 => self.draft.memory_min = normalize_memory_value(value), 5 => self.draft.memory_max = normalize_memory_value(value), + 6 => { + self.draft.jvm_args = value.split_whitespace().map(str::to_owned).collect(); + } 7 if value.is_empty() => self.draft.resolution = None, 7 => match parse_resolution(value) { Ok(resolution) => self.draft.resolution = Some(resolution), @@ -704,10 +461,6 @@ impl State { } pub fn handle_key(&mut self, key: &KeyEvent) -> Action { - if self.jvm_args_open { - self.handle_jvm_args_key(key); - return Action::None; - } if self.choice_picker.is_some() { self.handle_choice_key(key); return Action::None; @@ -716,27 +469,6 @@ impl State { self.handle_picker_key(key); return Action::None; } - if self.confirm_close { - match key.code { - KeyCode::Char('y') | KeyCode::Enter => return Action::Close, - KeyCode::Char('n') | KeyCode::Esc => self.confirm_close = false, - _ => {} - } - return Action::None; - } - if self.confirm_runtime_change { - match key.code { - KeyCode::Char('y') | KeyCode::Enter => { - if self.validate_before_save() { - return Action::Save(Box::new(self.draft.clone()), self.desktop); - } - self.confirm_runtime_change = false; - } - KeyCode::Char('n') | KeyCode::Esc => self.confirm_runtime_change = false, - _ => {} - } - return Action::None; - } if let Some(input) = &mut self.editing { match key.code { KeyCode::Enter => self.commit_edit(), @@ -753,33 +485,15 @@ impl State { self.selected = (self.selected + 1).min(FIELD_COUNT - 1) } KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), - KeyCode::Left => match self.selected { - 1 => self.rotate_choice(ChoicePicker::Loader, false), - 3 => self.rotate_choice(ChoicePicker::Java, false), - 4 | 5 => self.adjust_memory(self.selected, false), - 7 => self.rotate_choice(ChoicePicker::Resolution, false), - 8 => self.desktop = !self.desktop, - _ => {} - }, - KeyCode::Right => match self.selected { - 1 => self.rotate_choice(ChoicePicker::Loader, true), - 3 => self.rotate_choice(ChoicePicker::Java, true), - 4 | 5 => self.adjust_memory(self.selected, true), - 7 => self.rotate_choice(ChoicePicker::Resolution, true), - 8 => self.desktop = !self.desktop, - _ => {} - }, KeyCode::Enter => self.begin_edit(), - KeyCode::Char('c') if matches!(self.selected, 4 | 5 | 7) => { - self.editing = Some(new_text_area(vec![self.value(self.selected)])); - } - KeyCode::Char('r') if matches!(self.selected, 4 | 5) => { - self.set_memory(self.selected, None); - } KeyCode::Char('s') if self.dirty() => { if self.validate_before_save() { if self.runtime_changed() { - self.confirm_runtime_change = true; + return Action::ConfirmRuntime { + name: self.draft.name.clone(), + from: runtime_label(&self.original), + to: runtime_label(&self.draft), + }; } else { return Action::Save(Box::new(self.draft.clone()), self.desktop); } @@ -790,12 +504,30 @@ impl State { Some("save or discard draft changes before opening the raw file".to_owned()); } KeyCode::Char('E') => return Action::OpenRaw, - KeyCode::Esc if self.dirty() => self.confirm_close = true, + KeyCode::Esc if self.dirty() => return Action::ConfirmClose, KeyCode::Esc => return Action::Close, _ => {} } Action::None } + + pub fn confirmed_save(&mut self) -> Option<(Box, bool)> { + self.validate_before_save() + .then(|| (Box::new(self.draft.clone()), self.desktop)) + } +} + +fn runtime_label(config: &InstanceConfig) -> String { + if config.loader == ModLoader::Vanilla { + format!("{} / Vanilla", config.game_version) + } else { + format!( + "{} / {} {}", + config.game_version, + config.loader, + config.loader_version.as_deref().unwrap_or("unknown") + ) + } } fn memory_kib(value: &str) -> Option { @@ -836,17 +568,15 @@ fn new_text_area(lines: Vec) -> TextArea<'static> { } pub fn popup_rect(area: Rect, state: &State) -> Rect { - let height = if state.picker.is_some() || state.choice_picker.is_some() { - 19 - } else if state.jvm_args_open { - 18 - } else if state.editing.is_some() { - 14 + let height = if state.picker.is_some() { + (area.height * 2 / 3).max(10) + } else if state.choice_picker.is_some() { + 7 } else { - 12 + 11 + u16::from(state.error.is_some()) }; area.centered( - Constraint::Percentage(76), + Constraint::Percentage(58), Constraint::Length(height.min(area.height.saturating_sub(4))), ) } @@ -854,97 +584,41 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { pub fn render(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); frame.render_widget(Clear, area); - let mut title = vec![ - Span::styled( - " Instance Settings ", - Style::default() - .fg(theme.text()) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("· {} ", state.draft.name), - Style::default().fg(theme.text_dim()), - ), - ]; - if state.dirty() { - title.push(Span::styled( - "● modified ", - Style::default().fg(theme.warning()), - )); - } - let keybinds = if state.jvm_args_open { - if state.jvm_arg_input.is_some() { - super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) - } else { - super::keybind_line(&[ - ("j/k", ""), - ("←/→", ""), - ("Enter", " edit"), - ("a", " add"), - ("d", " delete"), - ("Esc", " back"), - ]) - } + let title = if state.dirty() { + " Instance Settings * " + } else { + " Instance Settings " + }; + let keybinds = if state.editing.is_some() { + super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) } else if state.picker.is_some() { super::keybind_line(&[ - ("j/k", " move"), - ("Enter", " select"), ("/", " search"), - ("s", " snapshots"), - ("Esc", " back"), + ("s", " snap"), + ("h", " back"), + ("Enter", " select"), ]) } else if state.choice_picker.is_some() { - super::keybind_line(&[("j/k", " move"), ("Enter", " select"), ("Esc", " back")]) - } else if matches!(state.selected, 4 | 5) { - super::keybind_line(&[ - ("j/k", ""), - ("←/→", " memory"), - ("c", " custom"), - ("r", " default"), - ("s", ""), - ("E", ""), - ("Esc", ""), - ]) - } else if state.selected == 6 { - super::keybind_line(&[ - ("j/k", ""), - ("Enter", " manage args"), - ("s", " save"), - ("E", " raw"), - ("Esc", " back"), - ]) - } else if state.selected == 8 { - super::keybind_line(&[ - ("j/k", ""), - ("Enter", " toggle"), - ("←/→", " toggle"), - ("s", " save"), - ("Esc", " back"), - ]) + super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else { super::keybind_line(&[ ("j/k", ""), - ("Enter", " open"), - ("←/→", " switch"), + ("Enter", " edit"), ("s", " save"), ("E", " raw"), ("Esc", " back"), ]) }; let block = Block::default() - .title(Line::from(title)) + .title(title) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(theme.text_dim())) .style(Style::default().bg(theme.surface())) - .title_bottom(keybinds); + .title_bottom(keybinds.right_aligned()); let inner = block.inner(area); frame.render_widget(block, area); - if state.jvm_args_open { - render_jvm_args(frame, inner, state); - return; - } if state.picker.is_some() { render_version_picker(frame, inner, state); return; @@ -954,402 +628,139 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { return; } - render_settings_form(frame, inner, state); + render_settings_list(frame, inner, state); } -fn render_settings_form(frame: &mut Frame, area: Rect, state: &State) { - let sections = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(6), - Constraint::Length(3), - Constraint::Min(1), - ]) - .split(area); - let top = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(sections[0]); - - render_field_card( - frame, - top[0], - " Runtime ", - state, - &[ - (0, "Version"), - (1, "Loader"), - (2, "Loader ver."), - (3, "Java"), - ], - ); - render_field_card( - frame, - top[1], - " Launch ", - state, - &[ - (4, "Min memory"), - (5, "Max memory"), - (6, "JVM args"), - (7, "Resolution"), - ], - ); - - let desktop_block = settings_card(" Desktop ", state.selected == 8); - let desktop_inner = desktop_block.inner(sections[1]); - frame.render_widget(desktop_block, sections[1]); - frame.render_widget( - Paragraph::new(field_line(state, 8, "Shortcut", 12)), - desktop_inner, - ); - - if let Some(editor) = state.editing.as_ref() { - let theme = THEME.as_ref(); - let editor_block = Block::default() - .title(match state.selected { - 3 => " Custom Java executable path · Enter apply ", - 4 => " Custom minimum memory (K/M/G) · Enter apply ", - 5 => " Custom maximum memory (K/M/G) · Enter apply ", - 7 => " Custom resolution (WIDTHxHEIGHT) · Enter apply ", - _ => " Value · Enter apply ", - }) - .borders(Borders::ALL) - .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().bg(theme.surface())); - let editor_inner = editor_block.inner(sections[2]); - frame.render_widget(editor_block, sections[2]); - frame.render_widget(editor, editor_inner); - } else { - frame.render_widget( - Paragraph::new(status_line(state)).wrap(Wrap { trim: true }), - sections[2], - ); - } -} - -fn settings_card(title: &'static str, active: bool) -> Block<'static> { +fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); - Block::default() - .title(Span::styled( - title, - Style::default().fg(if active { - theme.accent() - } else { - theme.text_dim() - }), - )) - .borders(Borders::ALL) - .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(if active { - theme.accent() - } else { - theme.border() - })) - .style(Style::default().bg(theme.surface())) -} - -fn render_field_card( - frame: &mut Frame, - area: Rect, - title: &'static str, - state: &State, - fields: &[(usize, &str)], -) { - let active = fields.iter().any(|(index, _)| *index == state.selected); - let block = settings_card(title, active); - let inner = block.inner(area); - frame.render_widget(block, area); - let lines = fields + let labels = [ + "Game version", + "Loader", + "Loader version", + "Java", + "Memory min", + "Memory max", + "JVM args", + "Resolution", + "Desktop shortcut", + ]; + let lines = labels .iter() - .map(|(index, label)| field_line(state, *index, label, 12)) + .enumerate() + .map(|(index, label)| field_line(state, index, label)) + .chain(state.error.iter().map(|error| { + Line::from(Span::styled( + format!(" {error}"), + Style::default().fg(theme.error()), + )) + })) .collect::>(); - frame.render_widget(Paragraph::new(lines), inner); + frame.render_widget(Paragraph::new(lines), area); + + if let Some(editor) = state.editing.as_ref() { + let edit_area = Rect { + x: area.x.saturating_add(20), + y: area.y.saturating_add(state.selected as u16), + width: area.width.saturating_sub(20), + height: 1, + }; + frame.render_widget(editor, edit_area); + } } -fn field_line<'a>(state: &'a State, index: usize, label: &str, label_width: usize) -> Line<'a> { +fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { let theme = THEME.as_ref(); let selected = index == state.selected; - let displayed = state.display_value(index); - let value_color = if index == 8 && state.desktop { - theme.success() - } else if selected { - theme.accent() + let editing = selected && state.editing.is_some(); + let value = if editing { + String::new() } else { - theme.text() + state.display_value(index) }; - let mut spans = vec![ + let dirty = field_dirty(state, index); + Line::from(vec![ Span::styled( if selected { "▶ " } else { " " }, Style::default().fg(theme.accent()), ), Span::styled( - format!("{label: Span<'static> { - let theme = THEME.as_ref(); - let settings = SETTINGS.read(); - let configured = if index == 4 { - state.draft.memory_min.as_deref() - } else { - state.draft.memory_max.as_deref() - }; - let effective = configured.unwrap_or_else(|| { - if index == 4 { - &settings.defaults.memory_min - } else { - &settings.defaults.memory_max - } - }); - let thresholds = ["512M", "1G", "2G", "4G", "6G", "8G", "12G", "16G"]; - let effective_kib = memory_kib(effective).unwrap_or(0); - let step = thresholds - .iter() - .position(|value| memory_kib(value).is_some_and(|limit| effective_kib <= limit)) - .unwrap_or(thresholds.len() - 1); - let filled = ((step + 1) * 6).div_ceil(thresholds.len()).max(1); - let bar = format!("{}{}", "▰".repeat(filled), "▱".repeat(6 - filled)); - let label = if configured.is_none() { - format!("default {effective}") - } else { - effective.to_owned() - }; - Span::styled( - format!(" ◀ {bar} {label} ▶ "), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .bg(theme.background()) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ) -} - -fn render_jvm_args(frame: &mut Frame, area: Rect, state: &State) { - let theme = THEME.as_ref(); - let block = settings_card(" JVM Arguments ", true); - let inner = block.inner(area); - frame.render_widget(block, area); - - if let Some((target, editor)) = &state.jvm_arg_input { - let editor_block = Block::default() - .title(if target.is_some() { - " Edit argument · Enter apply " - } else { - " Add argument · Enter apply " - }) - .borders(Borders::ALL) - .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())) - .style(Style::default().bg(theme.surface())); - let editor_inner = editor_block.inner(inner); - frame.render_widget(editor_block, inner); - frame.render_widget(editor, editor_inner); - return; - } - - if state.draft.jvm_args.is_empty() { - frame.render_widget( - Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - " No custom JVM arguments.", - Style::default().fg(theme.text_dim()), - )), - Line::from(vec![ - Span::styled( - " [a] ", - Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD), - ), - Span::styled("add an argument", Style::default().fg(theme.text())), - ]), - ]), - inner, - ); - return; - } - - let visible_rows = inner.height as usize; - let start = state - .jvm_arg_index - .saturating_sub(visible_rows.saturating_sub(1)); - let lines = state - .draft - .jvm_args - .iter() - .enumerate() - .skip(start) - .take(visible_rows) - .map(|(index, argument)| { - let selected = index == state.jvm_arg_index; - Line::from(vec![ - Span::styled( - if selected { "▶ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - format!("{:02} ", index + 1), - Style::default().fg(theme.text_dim()), - ), - Span::styled( - argument.clone(), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ]) - .style(Style::default().bg(if selected { - theme.stripe() - } else { - theme.surface() - })) - }) - .collect::>(); - frame.render_widget(Paragraph::new(lines), inner); + ), + Span::styled( + if dirty { " *" } else { "" }, + Style::default().fg(theme.accent()), + ), + ]) } -fn status_line(state: &State) -> Line<'_> { - let theme = THEME.as_ref(); - if let Some(error) = &state.error { - Line::from(Span::styled( - format!(" × {error}"), - Style::default().fg(theme.error()), - )) - } else if state.confirm_runtime_change { - Line::from(Span::styled( - " ! Runtime changes download files and may break mods. [y] continue [n] cancel", - Style::default().fg(theme.warning()), - )) - } else if state.confirm_close { - Line::from(Span::styled( - " ! Discard unsaved changes? [y] yes [n] no", - Style::default().fg(theme.warning()), - )) - } else { - let settings = SETTINGS.read(); - Line::from(vec![ - Span::styled(" ◇ Defaults ", Style::default().fg(theme.info())), - Span::styled( - format!( - "empty Java or memory values inherit {} → {}", - settings.defaults.memory_min, settings.defaults.memory_max - ), - Style::default().fg(theme.text_dim()), - ), - ]) +fn field_dirty(state: &State, index: usize) -> bool { + match index { + 0 => state.draft.game_version != state.original.game_version, + 1 => state.draft.loader != state.original.loader, + 2 => state.draft.loader_version != state.original.loader_version, + 3 => state.draft.java_path != state.original.java_path, + 4 => state.draft.memory_min != state.original.memory_min, + 5 => state.draft.memory_max != state.original.memory_max, + 6 => state.draft.jvm_args != state.original.jvm_args, + 7 => state.draft.resolution != state.original.resolution, + 8 => state.desktop != state.original_desktop, + _ => false, } } fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); - let title = match state.choice_picker { - Some(ChoicePicker::Loader) => " Loader ", - Some(ChoicePicker::Java) => " Java Runtime ", - Some(ChoicePicker::Resolution) => " Window Resolution ", - None => return, - }; - let block = settings_card(title, true); - let inner = block.inner(area); - frame.render_widget(block, area); - let mut lines = Vec::new(); let values = state.choice_values(); - let visible_rows = inner.height as usize; + let visible_rows = area.height as usize; let start = state .choice_index .saturating_sub(visible_rows.saturating_sub(1)); + let mut lines = Vec::new(); for (index, value) in values.iter().enumerate().skip(start).take(visible_rows) { let selected = index == state.choice_index; - lines.push( - Line::from(vec![ - Span::styled( - if selected { "▶ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - value.clone(), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ]) - .style(Style::default().bg(if selected { - theme.stripe() - } else { - theme.surface() - })), - ); + lines.push(Line::from(vec![ + Span::styled( + if selected { "▶ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + value.clone(), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ])); } - frame.render_widget(Paragraph::new(lines), inner); + frame.render_widget(Paragraph::new(lines), area); } fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); - let title = match state.picker { - Some(VersionPicker::Game) => " Minecraft Version ", - Some(VersionPicker::Loader) => " Loader Version ", - None => return, - }; - let block = settings_card(title, true); - let inner = block.inner(area); - frame.render_widget(block, area); let status = match state.picker { Some(VersionPicker::Game) => match &*state .game_versions @@ -1411,44 +822,37 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { if versions.is_empty() { lines.push(Line::from("No matching versions.")); } else { - let visible_rows = inner.height.saturating_sub(1) as usize; + let visible_rows = area.height.saturating_sub(1) as usize; let start = state .picker_index .saturating_sub(visible_rows.saturating_sub(1)); for (index, version) in versions.iter().enumerate().skip(start).take(visible_rows) { let selected = index == state.picker_index; - lines.push( - Line::from(vec![ - Span::styled( - if selected { "▶ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - version.clone(), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ]) - .style(Style::default().bg(if selected { - theme.stripe() - } else { - theme.surface() - })), - ); + lines.push(Line::from(vec![ + Span::styled( + if selected { "▶ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + version.clone(), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ])); } } } } - frame.render_widget(Paragraph::new(lines), inner); + frame.render_widget(Paragraph::new(lines), area); } #[cfg(test)] @@ -1490,13 +894,13 @@ mod tests { } #[test] - fn custom_memory_editor_and_jvm_argument_manager_preserve_values() { + fn memory_and_jvm_arguments_are_edited_inline() { let temp = tempfile::tempdir().unwrap(); let mut config = instance(); config.memory_min = Some("512M".to_owned()); let mut state = State::new(&config, temp.path()); state.selected = 4; - state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); state.handle_key(&KeyEvent::from(KeyCode::Left)); state.handle_key(&KeyEvent::from(KeyCode::Char('0'))); state.handle_key(&KeyEvent::from(KeyCode::Enter)); @@ -1504,26 +908,11 @@ mod tests { state.selected = 6; state.begin_edit(); - state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); - for character in "-Xfoo".chars() { - state.handle_key(&KeyEvent::from(KeyCode::Char(character))); - } - state.handle_key(&KeyEvent::from(KeyCode::Enter)); - state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); - for character in "-Xbar".chars() { + for character in "-Xfoo -Xbar".chars() { state.handle_key(&KeyEvent::from(KeyCode::Char(character))); } state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert_eq!(state.draft.jvm_args, ["-Xfoo", "-Xbar"]); - - state.handle_key(&KeyEvent::from(KeyCode::Left)); - assert_eq!(state.draft.jvm_args, ["-Xbar", "-Xfoo"]); - state.handle_key(&KeyEvent::from(KeyCode::Enter)); - state.handle_key(&KeyEvent::from(KeyCode::Char('2'))); - state.handle_key(&KeyEvent::from(KeyCode::Enter)); - assert_eq!(state.draft.jvm_args, ["-Xbar2", "-Xfoo"]); - state.handle_key(&KeyEvent::from(KeyCode::Char('d'))); - assert_eq!(state.draft.jvm_args, ["-Xfoo"]); } #[test] @@ -1534,13 +923,9 @@ mod tests { assert!(matches!( state.handle_key(&KeyEvent::from(KeyCode::Char('s'))), - Action::None - )); - assert!(state.confirm_runtime_change); - assert!(matches!( - state.handle_key(&KeyEvent::from(KeyCode::Char('y'))), - Action::Save(_, _) + Action::ConfirmRuntime { .. } )); + assert!(state.confirmed_save().is_some()); } #[test] @@ -1597,48 +982,31 @@ mod tests { } #[test] - fn arrow_keys_rotate_badged_choices() { + fn loader_picker_and_desktop_toggle_use_enter() { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); state.selected = 1; - state.handle_key(&KeyEvent::from(KeyCode::Right)); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + state.handle_key(&KeyEvent::from(KeyCode::Char('j'))); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert_eq!(state.draft.loader, ModLoader::Forge); - state.handle_key(&KeyEvent::from(KeyCode::Left)); - assert_eq!(state.draft.loader, ModLoader::Fabric); state.selected = 8; let desktop = state.desktop; - state.handle_key(&KeyEvent::from(KeyCode::Right)); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert_ne!(state.desktop, desktop); } #[test] - fn memory_slider_and_resolution_picker_use_structured_values() { + fn text_fields_open_inline_editors() { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); state.selected = 4; state.begin_edit(); - assert!(state.draft.memory_min.is_some()); + assert!(state.editing.is_some()); assert!(state.choice_picker.is_none()); - assert!(state.editing.is_none()); - - state.draft.memory_min = Some("4G".to_owned()); - state.draft.memory_max = Some("4G".to_owned()); - state.handle_key(&KeyEvent::from(KeyCode::Right)); - assert_eq!(state.draft.memory_min.as_deref(), Some("6G")); - assert_eq!(state.draft.memory_max.as_deref(), Some("6G")); - - state.selected = 7; - state.begin_edit(); - state.choice_index = state - .choice_values() - .iter() - .position(|value| value == "1920x1080") - .unwrap(); - state.handle_key(&KeyEvent::from(KeyCode::Enter)); - assert_eq!(state.draft.resolution, Some((1920, 1080))); - assert!(state.editing.is_none()); + assert!(state.picker.is_none()); } } diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index a401894..525ca02 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -234,7 +234,6 @@ pub fn render( SettingsPane::Info => &[ ("e", " instance"), ("g", " launcher"), - ("d", " desk"), ("h/l", " tab"), ("Esc", " back"), ], @@ -477,7 +476,6 @@ pub enum SettingsAction { None, OpenInstance, OpenGlobal, - ToggleDesktop, SelectProfile(Option), ConfirmDeleteProfile(String), Error(String), @@ -579,7 +577,6 @@ pub fn handle_key( } } KeyCode::Char('g') if state.pane == SettingsPane::Info => SettingsAction::OpenGlobal, - KeyCode::Char('d') if state.pane == SettingsPane::Info => SettingsAction::ToggleDesktop, _ => SettingsAction::None, } } From 4edc92091240c551b070e39a77ee086b0b032eb0 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 12:32:18 +0200 Subject: [PATCH 06/42] feat: add interactive settings controls --- src/instance/java.rs | 206 ++++++++ src/tui/render.rs | 4 +- src/tui/tests/flows.rs | 56 +++ src/tui/widgets/content/tabs.rs | 2 +- src/tui/widgets/popups/global_settings.rs | 225 +++++++-- src/tui/widgets/popups/instance_settings.rs | 474 +++++++++++++----- src/tui/widgets/popups/mod.rs | 2 + src/tui/widgets/popups/new_instance/mod.rs | 1 - src/tui/widgets/popups/new_instance/render.rs | 54 +- src/tui/widgets/popups/new_instance/state.rs | 9 +- src/tui/widgets/popups/select_list.rs | 40 ++ src/tui/widgets/popups/settings_controls.rs | 270 ++++++++++ 12 files changed, 1091 insertions(+), 252 deletions(-) create mode 100644 src/tui/widgets/popups/select_list.rs create mode 100644 src/tui/widgets/popups/settings_controls.rs diff --git a/src/instance/java.rs b/src/instance/java.rs index 08aec24..db6b4e5 100644 --- a/src/instance/java.rs +++ b/src/instance/java.rs @@ -3,6 +3,29 @@ // java runtime discovery shared by launching, loader installation, and settings. +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + process::Command, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaInstallation { + pub path: PathBuf, + pub version: Option, +} + +impl JavaInstallation { + #[must_use] + pub fn label(&self) -> String { + let version = self + .version + .as_deref() + .map_or_else(|| "Java".to_owned(), |version| format!("Java {version}")); + format!("{version} {}", self.path.display()) + } +} + #[must_use] pub fn detect_java_path() -> String { if let Ok(java_home) = std::env::var("JAVA_HOME") { @@ -32,3 +55,186 @@ pub fn detect_java_path() -> String { } } } + +/// Finds Java executables in the environment and the conventional installation +/// directories for the current platform. The search is deliberately bounded so +/// opening the selector never walks an entire drive. +#[must_use] +pub fn discover_installations() -> Vec { + let mut candidates = Vec::new(); + for variable in ["JAVA_HOME", "JDK_HOME"] { + if let Ok(java_home) = std::env::var(variable) { + add_java_home(Path::new(&java_home), &mut candidates); + } + } + if let Ok(path) = which::which("java") { + candidates.push(path); + } + if let Some(path) = std::env::var_os("PATH") { + for directory in std::env::split_paths(&path) { + let executable = if cfg!(target_os = "windows") { + "java.exe" + } else { + "java" + }; + candidates.push(directory.join(executable)); + } + } + + for root in java_roots() { + collect_java_executables(&root, 3, &mut candidates); + } + + let mut seen = HashSet::new(); + let mut installations = candidates + .into_iter() + .filter(|path| path.is_file()) + .filter_map(|path| { + let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + seen.insert(identity).then(|| JavaInstallation { + version: java_version(&path), + path, + }) + }) + .collect::>(); + installations.sort_by(|a, b| { + java_major(b.version.as_deref()) + .cmp(&java_major(a.version.as_deref())) + .then_with(|| a.path.cmp(&b.path)) + }); + installations +} + +fn java_roots() -> Vec { + let mut roots = Vec::new(); + if cfg!(target_os = "windows") { + for variable in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] { + if let Some(root) = std::env::var_os(variable) { + let root = PathBuf::from(root); + roots.extend([ + root.join("Java"), + root.join("Eclipse Adoptium"), + root.join("Programs/Eclipse Adoptium"), + root.join("Programs/Java"), + root.join("Microsoft"), + root.join("BellSoft"), + root.join("Zulu"), + root.join("Amazon Corretto"), + ]); + } + } + } else if cfg!(target_os = "macos") { + roots.push(PathBuf::from("/Library/Java/JavaVirtualMachines")); + for prefix in ["/opt/homebrew/opt", "/usr/local/opt"] { + for formula in [ + "openjdk", + "openjdk@8", + "openjdk@11", + "openjdk@17", + "openjdk@21", + ] { + roots.push(PathBuf::from(prefix).join(formula)); + } + } + if let Some(home) = dirs_next::home_dir() { + roots.push(home.join("Library/Java/JavaVirtualMachines")); + roots.push(home.join(".sdkman/candidates/java")); + roots.push(home.join(".asdf/installs/java")); + roots.push(home.join(".local/share/mise/installs/java")); + } + } else { + roots.extend([ + PathBuf::from("/usr/lib/jvm"), + PathBuf::from("/usr/java"), + PathBuf::from("/opt/java"), + PathBuf::from("/opt/jdk"), + ]); + if let Some(home) = dirs_next::home_dir() { + roots.push(home.join(".sdkman/candidates/java")); + roots.push(home.join(".jdks")); + roots.push(home.join(".asdf/installs/java")); + roots.push(home.join(".local/share/mise/installs/java")); + } + } + roots +} + +fn collect_java_executables(directory: &Path, depth: usize, candidates: &mut Vec) { + if depth == 0 || !directory.is_dir() { + return; + } + add_java_home(directory, candidates); + if let Ok(entries) = std::fs::read_dir(directory) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_java_executables(&path, depth - 1, candidates); + } + } + } +} + +fn add_java_home(home: &Path, candidates: &mut Vec) { + let executable = if cfg!(target_os = "windows") { + "java.exe" + } else { + "java" + }; + for path in [ + home.join("bin").join(executable), + home.join("Contents/Home/bin").join(executable), + ] { + if path.is_file() { + candidates.push(path); + } + } +} + +fn java_version(path: &Path) -> Option { + let output = Command::new(path).arg("-version").output().ok()?; + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + parse_java_version(&text) +} + +fn parse_java_version(output: &str) -> Option { + output.lines().find_map(|line| { + line.split_once('"') + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(version, _)| version.to_owned()) + }) +} + +fn java_major(version: Option<&str>) -> u32 { + let Some(version) = version else { + return 0; + }; + let mut parts = version.split(['.', '_']); + match (parts.next(), parts.next()) { + (Some("1"), Some(major)) => major.parse().unwrap_or(0), + (Some(major), _) => major.parse().unwrap_or(0), + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_modern_and_legacy_java_versions() { + assert_eq!( + parse_java_version("openjdk version \"21.0.4\" 2024-07-16"), + Some("21.0.4".to_owned()) + ); + assert_eq!( + parse_java_version("java version \"1.8.0_412\""), + Some("1.8.0_412".to_owned()) + ); + assert_eq!(java_major(Some("21.0.4")), 21); + assert_eq!(java_major(Some("1.8.0_412")), 8); + } +} diff --git a/src/tui/render.rs b/src/tui/render.rs index 8ff2d29..55c5648 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -150,14 +150,14 @@ impl App { } if self.focused == FocusedArea::InstanceSettings - && let Some(state) = self.instance_settings.as_ref() + && let Some(state) = self.instance_settings.as_mut() { let area = widgets::popups::instance_settings::popup_rect(frame.area(), state); widgets::popups::instance_settings::render(frame, area, state); } if self.focused == FocusedArea::GlobalSettings - && let Some(state) = self.global_settings.as_ref() + && let Some(state) = self.global_settings.as_mut() { let area = widgets::popups::global_settings::popup_rect(frame.area(), state); widgets::popups::global_settings::render(frame, area, state); diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 5e22625..e3cfdc9 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -585,6 +585,8 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert!(ui.screen().contains("Game version")); assert!(ui.screen().contains("Memory min")); assert!(ui.screen().contains("Desktop")); + assert!(ui.screen().contains('█')); + assert!(!ui.screen().contains("● enabled")); assert!(!ui.screen().contains("Integration")); assert!(!ui.screen().contains('▰')); ui.key(KeyCode::Down); @@ -615,6 +617,7 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { ui.draw(); assert!(ui.screen().contains("Launcher Settings")); assert!(ui.screen().contains("Memory max")); + assert!(ui.screen().contains('█')); assert!(!ui.screen().contains('▰')); ui.key(KeyCode::Enter); ui.draw(); @@ -660,6 +663,59 @@ fn runtime_settings_use_the_shared_confirmation_popup() { assert_eq!(ui.app.focused, FocusedArea::Settings); } +#[test] +fn settings_use_java_memory_and_resolution_controls() { + let mut ui = UiHarness::new(); + ui.add_instance("controls-test"); + ui.key(KeyCode::Char('E')); + + for _ in 0..3 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("Java Runtime")); + assert!(ui.screen().contains("Automatic")); + assert!(ui.screen().contains("Custom path")); + ui.key(KeyCode::Esc); + + ui.key(KeyCode::Char('j')); + ui.key(KeyCode::Char('l')); + assert_eq!( + ui.app + .instance_settings + .as_ref() + .unwrap() + .draft + .memory_min + .as_deref(), + Some("1G") + ); + + for _ in 0..3 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("Resolution")); + assert!(ui.screen().contains("1920x1080")); + assert!(ui.screen().contains("Custom")); + ui.key(KeyCode::Esc); + ui.key(KeyCode::Esc); + ui.key(KeyCode::Enter); + + ui.key(KeyCode::Char('G')); + for _ in 0..4 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("Java Runtime")); + assert!(ui.screen().contains("Automatic")); + ui.key(KeyCode::Esc); + ui.key(KeyCode::Esc); +} + #[test] fn settings_panel_keeps_direct_profile_management() { let mut ui = UiHarness::new(); diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 8082434..06d1544 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -998,7 +998,7 @@ pub(crate) fn render_version_popup( .style(Style::default().fg(THEME.as_ref().text_dim())) .render(area, buffer); } else { - crate::tui::widgets::popups::new_instance::render_select_list( + crate::tui::widgets::popups::select_list::render( items.clone(), selected, area, diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index deb25ac..743652e 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, ListItem, Paragraph}, }; use ratatui_textarea::{CursorMove, TextArea}; @@ -19,6 +19,9 @@ use crate::{ theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, }, instance::models::normalize_memory_value, + tui::widgets::popups::settings_controls::{ + JavaChoice, JavaPicker, adjust_memory, memory_kib, render_memory_gauge, + }, }; pub struct State { @@ -31,6 +34,8 @@ pub struct State { themes: Vec, theme_picker: bool, theme_index: usize, + java_picker_open: bool, + java_picker: JavaPicker, } pub enum Action { @@ -59,6 +64,8 @@ impl State { themes, theme_picker: false, theme_index, + java_picker_open: false, + java_picker: JavaPicker::new(), } } @@ -106,11 +113,19 @@ impl State { "memory must be a positive number with K, M, or G".to_owned(), ), 2 => { - self.config.defaults.memory_min = normalize_memory_value(value).unwrap(); + let value = normalize_memory_value(value).unwrap(); + self.config.defaults.memory_min = value.clone(); + if memory_kib(&value) > memory_kib(&self.config.defaults.memory_max) { + self.config.defaults.memory_max = value; + } self.config_dirty = true; } 3 => { - self.config.defaults.memory_max = normalize_memory_value(value).unwrap(); + let value = normalize_memory_value(value).unwrap(); + self.config.defaults.memory_max = value.clone(); + if memory_kib(&value) < memory_kib(&self.config.defaults.memory_min) { + self.config.defaults.memory_min = value; + } self.config_dirty = true; } 4 => { @@ -171,6 +186,69 @@ impl State { } } + fn open_java_picker(&mut self) { + self.java_picker + .open(self.config.paths.java_path.as_deref()); + self.java_picker.initialize(); + self.java_picker_open = true; + } + + fn handle_java_picker_key(&mut self, key: &KeyEvent) { + self.java_picker.initialize(); + let count = self.java_picker.labels().len(); + match key.code { + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.java_picker_open = false, + KeyCode::Char('j') | KeyCode::Down if count > 0 => { + self.java_picker.selected = (self.java_picker.selected + 1).min(count - 1); + } + KeyCode::Char('k') | KeyCode::Up => { + self.java_picker.selected = self.java_picker.selected.saturating_sub(1); + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { + match self.java_picker.selected_choice() { + JavaChoice::Automatic => { + if self.config.paths.java_path.take().is_some() { + self.config_dirty = true; + } + } + JavaChoice::Installation(path) => { + if self.config.paths.java_path.as_deref() != Some(&path) { + self.config.paths.java_path = Some(path); + self.config_dirty = true; + } + } + JavaChoice::Custom => { + self.editing = Some(new_text_area(vec![self.value(4)])); + } + } + self.java_picker_open = false; + } + _ => {} + } + } + + fn adjust_selected_memory(&mut self, forward: bool) { + let value = if self.selected == 2 { + &self.config.defaults.memory_min + } else { + &self.config.defaults.memory_max + }; + let value = adjust_memory(value, forward); + if self.selected == 2 { + self.config.defaults.memory_min = value.clone(); + if memory_kib(&value) > memory_kib(&self.config.defaults.memory_max) { + self.config.defaults.memory_max = value; + } + } else { + self.config.defaults.memory_max = value.clone(); + if memory_kib(&value) < memory_kib(&self.config.defaults.memory_min) { + self.config.defaults.memory_min = value; + } + } + self.config_dirty = true; + self.error = None; + } + fn validate_before_save(&mut self) -> bool { self.error = None; let min = memory_kib(&self.config.defaults.memory_min); @@ -182,6 +260,10 @@ impl State { } pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + if self.java_picker_open { + self.handle_java_picker_key(key); + return Action::None; + } if self.theme_picker { self.handle_theme_picker_key(key); return Action::None; @@ -201,11 +283,22 @@ impl State { KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), KeyCode::Char('h') | KeyCode::Left if self.selected == 1 => self.cycle_border(false), KeyCode::Char('l') | KeyCode::Right if self.selected == 1 => self.cycle_border(true), + KeyCode::Char('h') | KeyCode::Left if matches!(self.selected, 2 | 3) => { + self.adjust_selected_memory(false); + } + KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 2 | 3) => { + self.adjust_selected_memory(true); + } KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, 1 => self.cycle_border(true), + 2 | 3 => self.adjust_selected_memory(true), + 4 => self.open_java_picker(), field => self.editing = Some(new_text_area(vec![self.value(field)])), }, + KeyCode::Char('c') if matches!(self.selected, 2 | 3) => { + self.editing = Some(new_text_area(vec![self.value(self.selected)])); + } KeyCode::Char('s') => { if self.validate_before_save() { return Action::Save( @@ -242,18 +335,6 @@ impl Default for State { } } -fn memory_kib(value: &str) -> Option { - let normalized = normalize_memory_value(value)?; - let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); - let number = number.parse::().ok()?; - match suffix { - "K" => Some(number), - "M" => number.checked_mul(1024), - "G" => number.checked_mul(1024 * 1024), - _ => None, - } -} - fn available_themes() -> Vec { let mut themes: Vec = ratatui_themekit::available_theme_ids() .into_iter() @@ -294,7 +375,7 @@ fn new_text_area(lines: Vec) -> TextArea<'static> { } pub fn popup_rect(area: Rect, state: &State) -> Rect { - let height = if state.theme_picker { + let height = if state.theme_picker || state.java_picker_open { (area.height * 2 / 3).max(10) } else { 7 + u16::from(state.error.is_some()) @@ -305,13 +386,23 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { ) } -pub fn render(frame: &mut Frame, area: Rect, state: &State) { +pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { + if state.java_picker_open { + state.java_picker.initialize(); + } let theme = THEME.as_ref(); frame.render_widget(Clear, area); let keybinds = if state.editing.is_some() { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) - } else if state.theme_picker { + } else if state.theme_picker || state.java_picker_open { super::keybind_line(&[("h", " back"), ("Enter", " select")]) + } else if matches!(state.selected, 2 | 3) { + super::keybind_line(&[ + ("h/l", " adjust"), + ("c", " custom"), + ("s", " save"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -321,7 +412,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { ("Esc", " back"), ]) }; - let title = if state.config_dirty { + let title = if state.theme_picker { + " Theme " + } else if state.java_picker_open { + " Java Runtime " + } else if state.config_dirty { " Launcher Settings * " } else { " Launcher Settings " @@ -339,43 +434,48 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { render_picker(frame, inner, &state.themes, state.theme_index); return; } + if state.java_picker_open { + render_java_picker(frame, inner, state); + return; + } render_settings_list(frame, inner, state); } fn render_picker(frame: &mut Frame, area: Rect, values: &[String], selected: usize) { let theme = THEME.as_ref(); - let visible_rows = area.height as usize; - let start = selected.saturating_sub(visible_rows.saturating_sub(1)); - let lines = values + let items = values .iter() - .enumerate() - .skip(start) - .take(visible_rows) - .map(|(index, name)| { - let focused = index == selected; - Line::from(vec![ - Span::styled( - if focused { "▶ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - name.clone(), - Style::default() - .fg(if focused { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if focused { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ]) + .map(|name| { + ListItem::new(Line::from(Span::styled( + name.clone(), + Style::default().fg(theme.text()), + ))) }) - .collect::>(); - frame.render_widget(Paragraph::new(lines), area); + .collect(); + super::select_list::render(items, selected, area, frame.buffer_mut()); +} + +fn render_java_picker(frame: &mut Frame, area: Rect, state: &State) { + let theme = THEME.as_ref(); + let mut list_area = area; + if let Some(status) = state.java_picker.status() { + let (message, color) = match status { + Ok(message) => (message.to_owned(), theme.text_dim()), + Err(error) => (error, theme.error()), + }; + frame.render_widget( + Paragraph::new(message).style(Style::default().fg(color)), + Rect { height: 1, ..area }, + ); + list_area.y = list_area.y.saturating_add(1); + list_area.height = list_area.height.saturating_sub(1); + } + render_picker( + frame, + list_area, + &state.java_picker.labels(), + state.java_picker.selected, + ); } fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { @@ -394,6 +494,22 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { .collect::>(); frame.render_widget(Paragraph::new(lines), area); + for field in [2, 3] { + let value = state.value(field); + render_memory_gauge( + frame, + Rect { + x: area.x.saturating_add(20), + y: area.y.saturating_add(field as u16), + width: area.width.saturating_sub(21), + height: 1, + }, + &value, + value.clone(), + state.selected == field, + ); + } + if let Some(editor) = state.editing.as_ref() { let edit_area = Rect { x: area.x.saturating_add(20), @@ -419,7 +535,7 @@ fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a Style::default().fg(theme.text_dim()), ), Span::styled( - if editing { + if editing || matches!(index, 2 | 3) { String::new() } else { state.display_value(index) @@ -444,15 +560,16 @@ mod tests { use super::*; #[test] - fn launcher_memory_and_java_use_inline_editors() { + fn launcher_memory_uses_slider_and_java_uses_picker() { let mut state = State::new(); state.selected = 2; + let original = state.config.defaults.memory_min.clone(); state.handle_key(&KeyEvent::from(KeyCode::Enter)); - assert!(state.editing.is_some()); + assert_ne!(state.config.defaults.memory_min, original); + assert!(state.editing.is_none()); - state.editing = None; state.selected = 4; state.handle_key(&KeyEvent::from(KeyCode::Enter)); - assert!(state.editing.is_some()); + assert!(state.java_picker_open); } } diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 7fd8ab5..ba8ba15 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::{Constraint, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, ListItem, Paragraph}, }; use ratatui_textarea::{CursorMove, TextArea}; use std::sync::{Arc, Mutex}; @@ -21,7 +21,15 @@ use crate::{ }, instance::loader::GameVersion, instance::models::{InstanceConfig, ModLoader, normalize_memory_value, parse_resolution}, - tui::widgets::popups::LoadState, + tui::widgets::{ + popups::{ + LoadState, + settings_controls::{ + JavaChoice, JavaPicker, adjust_memory, memory_kib, render_memory_gauge, + }, + }, + search::SearchState, + }, }; const FIELD_COUNT: usize = 9; @@ -36,6 +44,8 @@ enum VersionPicker { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChoicePicker { Loader, + Java, + Resolution, } enum PickerLoad { @@ -57,13 +67,13 @@ pub struct State { picker: Option, picker_index: usize, picker_initialized: bool, - picker_query: String, - picker_search: bool, + picker_search: SearchState, show_snapshots: bool, game_versions: SharedLoad>, loader_versions: SharedLoad>, choice_picker: Option, choice_index: usize, + java_picker: JavaPicker, } pub enum Action { @@ -92,13 +102,13 @@ impl State { picker: None, picker_index: 0, picker_initialized: false, - picker_query: String::new(), - picker_search: false, + picker_search: SearchState::default(), show_snapshots: false, game_versions: Arc::new(Mutex::new(LoadState::Idle)), loader_versions: Arc::new(Mutex::new(LoadState::Idle)), choice_picker: None, choice_index: 0, + java_picker: JavaPicker::new(), } } @@ -177,8 +187,8 @@ impl State { 6 if self.draft.jvm_args.is_empty() => "no arguments".to_owned(), 6 => self.draft.jvm_args.join(" "), 7 if self.draft.resolution.is_none() => "default".to_owned(), - 8 if self.desktop => "● enabled".to_owned(), - 8 => "○ disabled".to_owned(), + 8 if self.desktop => "enabled".to_owned(), + 8 => "disabled".to_owned(), _ => self.value(field).replace('\n', " ↵ "), } } @@ -191,7 +201,10 @@ impl State { self.error = Some("Vanilla does not use a loader version".to_owned()); } 2 => self.open_loader_picker(), - 3..=7 => self.editing = Some(new_text_area(vec![self.value(self.selected)])), + 3 => self.open_choice_picker(ChoicePicker::Java), + 4 | 5 => self.adjust_selected_memory(true), + 6 => self.editing = Some(new_text_area(vec![self.value(self.selected)])), + 7 => self.open_choice_picker(ChoicePicker::Resolution), 8 => self.desktop = !self.desktop, field => self.editing = Some(new_text_area(vec![self.value(field)])), } @@ -200,10 +213,24 @@ impl State { fn open_choice_picker(&mut self, picker: ChoicePicker) { self.choice_picker = Some(picker); self.choice_index = match picker { - ChoicePicker::Loader => loaders() + ChoicePicker::Loader => super::select_list::MOD_LOADERS .iter() .position(|loader| *loader == self.draft.loader) .unwrap_or(0), + ChoicePicker::Java => { + self.java_picker.open(self.draft.java_path.as_deref()); + self.java_picker.initialize(); + self.java_picker.selected + } + ChoicePicker::Resolution => resolution_values(self.draft.resolution) + .iter() + .position(|value| { + self.draft.resolution.map_or_else( + || value == "Default", + |(width, height)| value == &format!("{width}x{height}"), + ) + }) + .unwrap_or(0), }; } @@ -214,11 +241,20 @@ impl State { fn choice_values_for(&self, picker: ChoicePicker) -> Vec { match picker { - ChoicePicker::Loader => loaders().iter().map(ToString::to_string).collect(), + ChoicePicker::Loader => super::select_list::MOD_LOADERS + .iter() + .map(ToString::to_string) + .collect(), + ChoicePicker::Java => self.java_picker.labels(), + ChoicePicker::Resolution => resolution_values(self.draft.resolution), } } fn handle_choice_key(&mut self, key: &KeyEvent) { + if self.choice_picker == Some(ChoicePicker::Java) { + self.java_picker.initialize(); + self.choice_index = self.java_picker.selected; + } let count = self.choice_values().len(); match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.choice_picker = None, @@ -231,12 +267,15 @@ impl State { KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.apply_choice(), _ => {} } + if self.choice_picker == Some(ChoicePicker::Java) { + self.java_picker.selected = self.choice_index; + } } fn apply_choice(&mut self) { match self.choice_picker { Some(ChoicePicker::Loader) => { - let available = loaders(); + let available = super::select_list::MOD_LOADERS; let loader = available[self.choice_index.min(available.len() - 1)]; if self.draft.loader != loader { self.draft.loader = loader; @@ -245,6 +284,29 @@ impl State { self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); } } + Some(ChoicePicker::Java) => { + self.java_picker.selected = self.choice_index; + match self.java_picker.selected_choice() { + JavaChoice::Automatic => self.draft.java_path = None, + JavaChoice::Installation(path) => self.draft.java_path = Some(path), + JavaChoice::Custom => { + self.editing = Some(new_text_area(vec![self.value(3)])); + } + } + } + Some(ChoicePicker::Resolution) => { + let selected = resolution_values(self.draft.resolution) + .get(self.choice_index) + .cloned() + .unwrap_or_else(|| "Default".to_owned()); + match selected.as_str() { + "Default" => self.draft.resolution = None, + "Custom…" => { + self.editing = Some(new_text_area(vec![self.value(7)])); + } + value => self.draft.resolution = parse_resolution(value).ok(), + } + } None => {} } self.choice_picker = None; @@ -254,8 +316,7 @@ impl State { self.picker = Some(VersionPicker::Game); self.picker_index = 0; self.picker_initialized = false; - self.picker_query.clear(); - self.picker_search = false; + self.picker_search.deactivate(); let mut load = self .game_versions .lock() @@ -281,8 +342,7 @@ impl State { self.picker = Some(VersionPicker::Loader); self.picker_index = 0; self.picker_initialized = false; - self.picker_query.clear(); - self.picker_search = false; + self.picker_search.deactivate(); let mut load = self .loader_versions .lock() @@ -306,7 +366,6 @@ impl State { } fn visible_game_versions(&self) -> Vec { - let query = self.picker_query.to_lowercase(); match &*self .game_versions .lock() @@ -315,7 +374,7 @@ impl State { LoadState::Loaded(versions) => versions .iter() .filter(|version| self.show_snapshots || version.stable) - .filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query)) + .filter(|version| self.picker_search.matches(&version.id)) .cloned() .collect(), _ => Vec::new(), @@ -323,7 +382,6 @@ impl State { } fn visible_loader_versions(&self) -> Vec { - let query = self.picker_query.to_lowercase(); match &*self .loader_versions .lock() @@ -331,7 +389,7 @@ impl State { { LoadState::Loaded(versions) => versions .iter() - .filter(|version| query.is_empty() || version.to_lowercase().contains(&query)) + .filter(|version| self.picker_search.matches(version)) .cloned() .collect(), _ => Vec::new(), @@ -362,22 +420,64 @@ impl State { } } + fn effective_memory(&self, field: usize) -> String { + let settings = SETTINGS.read(); + if field == 4 { + self.draft + .memory_min + .clone() + .unwrap_or_else(|| settings.defaults.memory_min.clone()) + } else { + self.draft + .memory_max + .clone() + .unwrap_or_else(|| settings.defaults.memory_max.clone()) + } + } + + fn set_memory(&mut self, field: usize, value: Option) { + if field == 4 { + self.draft.memory_min = value.clone(); + } else { + self.draft.memory_max = value.clone(); + } + + let min = self.effective_memory(4); + let max = self.effective_memory(5); + if memory_kib(&min) + .zip(memory_kib(&max)) + .is_some_and(|(min, max)| min > max) + { + if field == 4 { + self.draft.memory_max = value; + } else { + self.draft.memory_min = value; + } + } + } + + fn adjust_selected_memory(&mut self, forward: bool) { + let value = adjust_memory(&self.effective_memory(self.selected), forward); + self.set_memory(self.selected, Some(value)); + self.error = None; + } + fn handle_picker_key(&mut self, key: &KeyEvent) { self.initialize_picker_index(); - if self.picker_search { + if self.picker_search.active { match key.code { KeyCode::Esc => { - self.picker_search = false; + self.picker_search.deactivate(); return; } KeyCode::Backspace => { - self.picker_query.pop(); + self.picker_search.backspace(key.modifiers); self.picker_index = 0; return; } KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('k') | KeyCode::Up => {} KeyCode::Char(character) => { - self.picker_query.push(character); + self.picker_search.push(character); self.picker_index = 0; return; } @@ -390,8 +490,12 @@ impl State { None => 0, }; match key.code { - KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.picker = None, - KeyCode::Char('/') => self.picker_search = true, + KeyCode::Esc => self.picker = None, + KeyCode::Char('h') | KeyCode::Left if !self.picker_search.active => self.picker = None, + KeyCode::Char('/') if !self.picker_search.active => { + self.picker_search.activate(); + self.picker_index = 0; + } KeyCode::Char('s') if self.picker == Some(VersionPicker::Game) => { self.show_snapshots = !self.show_snapshots; self.picker_index = 0; @@ -403,6 +507,7 @@ impl State { KeyCode::Char('k') | KeyCode::Up => { self.picker_index = self.picker_index.saturating_sub(1); } + KeyCode::Enter if self.picker_search.active => self.picker_search.confirm(), KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => match self.picker { Some(VersionPicker::Game) => { if let Some(version) = self.visible_game_versions().get(self.picker_index) { @@ -446,8 +551,8 @@ impl State { self, "memory must be a positive number with K, M, or G".to_owned(), ), - 4 => self.draft.memory_min = normalize_memory_value(value), - 5 => self.draft.memory_max = normalize_memory_value(value), + 4 => self.set_memory(4, normalize_memory_value(value)), + 5 => self.set_memory(5, normalize_memory_value(value)), 6 => { self.draft.jvm_args = value.split_whitespace().map(str::to_owned).collect(); } @@ -485,7 +590,19 @@ impl State { self.selected = (self.selected + 1).min(FIELD_COUNT - 1) } KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), + KeyCode::Char('h') | KeyCode::Left if matches!(self.selected, 4 | 5) => { + self.adjust_selected_memory(false); + } + KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 4 | 5) => { + self.adjust_selected_memory(true); + } KeyCode::Enter => self.begin_edit(), + KeyCode::Char('c') if matches!(self.selected, 4 | 5) => { + self.editing = Some(new_text_area(vec![self.effective_memory(self.selected)])); + } + KeyCode::Char('r') if matches!(self.selected, 4 | 5) => { + self.set_memory(self.selected, None); + } KeyCode::Char('s') if self.dirty() => { if self.validate_before_save() { if self.runtime_changed() { @@ -530,26 +647,27 @@ fn runtime_label(config: &InstanceConfig) -> String { } } -fn memory_kib(value: &str) -> Option { - let normalized = normalize_memory_value(value)?; - let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); - let number = number.parse::().ok()?; - match suffix { - "K" => Some(number), - "M" => number.checked_mul(1024), - "G" => number.checked_mul(1024 * 1024), - _ => None, - } -} - -fn loaders() -> [ModLoader; 5] { - [ - ModLoader::Vanilla, - ModLoader::Fabric, - ModLoader::Forge, - ModLoader::NeoForge, - ModLoader::Quilt, +fn resolution_values(current: Option<(u32, u32)>) -> Vec { + let mut values = [ + "Default", + "854x480", + "1280x720", + "1600x900", + "1920x1080", + "2560x1440", + "3840x2160", + "Custom…", ] + .into_iter() + .map(str::to_owned) + .collect::>(); + if let Some((width, height)) = current { + let current = format!("{width}x{height}"); + if !values.contains(¤t) { + values.insert(values.len() - 1, current); + } + } + values } fn new_text_area(lines: Vec) -> TextArea<'static> { @@ -568,10 +686,10 @@ fn new_text_area(lines: Vec) -> TextArea<'static> { } pub fn popup_rect(area: Rect, state: &State) -> Rect { - let height = if state.picker.is_some() { + let height = if state.picker.is_some() || state.choice_picker == Some(ChoicePicker::Java) { (area.height * 2 / 3).max(10) } else if state.choice_picker.is_some() { - 7 + 10 } else { 11 + u16::from(state.error.is_some()) }; @@ -581,25 +699,43 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { ) } -pub fn render(frame: &mut Frame, area: Rect, state: &State) { +pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { + if state.choice_picker == Some(ChoicePicker::Java) { + state.java_picker.initialize(); + state.choice_index = state.java_picker.selected; + } let theme = THEME.as_ref(); frame.render_widget(Clear, area); - let title = if state.dirty() { - " Instance Settings * " - } else { - " Instance Settings " + let title = match (state.picker, state.choice_picker) { + (Some(VersionPicker::Game), _) => " Minecraft Version ", + (Some(VersionPicker::Loader), _) => " Loader Version ", + (_, Some(ChoicePicker::Loader)) => " Mod Loader ", + (_, Some(ChoicePicker::Java)) => " Java Runtime ", + (_, Some(ChoicePicker::Resolution)) => " Resolution ", + _ if state.dirty() => " Instance Settings * ", + _ => " Instance Settings ", }; let keybinds = if state.editing.is_some() { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) - } else if state.picker.is_some() { + } else if state.picker == Some(VersionPicker::Game) { super::keybind_line(&[ ("/", " search"), ("s", " snap"), ("h", " back"), ("Enter", " select"), ]) + } else if state.picker.is_some() { + super::keybind_line(&[("/", " search"), ("h", " back"), ("Enter", " select")]) } else if state.choice_picker.is_some() { super::keybind_line(&[("h", " back"), ("Enter", " select")]) + } else if matches!(state.selected, 4 | 5) { + super::keybind_line(&[ + ("h/l", " adjust"), + ("c", " custom"), + ("r", " default"), + ("s", " save"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -609,13 +745,18 @@ pub fn render(frame: &mut Frame, area: Rect, state: &State) { ("Esc", " back"), ]) }; - let block = Block::default() + let mut block = Block::default() .title(title) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(theme.text_dim())) .style(Style::default().bg(theme.surface())) .title_bottom(keybinds.right_aligned()); + if state.picker.is_some() + && let Some(search) = state.picker_search.title_line() + { + block = block.title_top(search); + } let inner = block.inner(area); frame.render_widget(block, area); @@ -657,6 +798,23 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { .collect::>(); frame.render_widget(Paragraph::new(lines), area); + for field in [4, 5] { + let value = state.effective_memory(field); + let gauge_area = Rect { + x: area.x.saturating_add(20), + y: area.y.saturating_add(field as u16), + width: area.width.saturating_sub(21), + height: 1, + }; + render_memory_gauge( + frame, + gauge_area, + &value, + state.display_value(field), + state.selected == field, + ); + } + if let Some(editor) = state.editing.as_ref() { let edit_area = Rect { x: area.x.saturating_add(20), @@ -672,13 +830,13 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { let theme = THEME.as_ref(); let selected = index == state.selected; let editing = selected && state.editing.is_some(); - let value = if editing { + let value = if editing || matches!(index, 4..=6) { String::new() } else { state.display_value(index) }; let dirty = field_dirty(state, index); - Line::from(vec![ + let mut spans = vec![ Span::styled( if selected { "▶ " } else { " " }, Style::default().fg(theme.accent()), @@ -707,7 +865,30 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { if dirty { " *" } else { "" }, Style::default().fg(theme.accent()), ), - ]) + ]; + if index == 6 && !editing { + if state.draft.jvm_args.is_empty() { + spans.push(Span::styled( + "no arguments", + Style::default().fg(theme.text_dim()), + )); + } else { + for (argument_index, argument) in state.draft.jvm_args.iter().enumerate() { + if argument_index > 0 { + spans.push(Span::styled(" ", Style::default().fg(theme.text_dim()))); + } + spans.push(Span::styled( + argument.clone(), + Style::default().fg(if selected { + theme.accent() + } else { + theme.text() + }), + )); + } + } + } + Line::from(spans) } fn field_dirty(state: &State, index: usize) -> bool { @@ -728,35 +909,31 @@ fn field_dirty(state: &State, index: usize) -> bool { fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); let values = state.choice_values(); - let visible_rows = area.height as usize; - let start = state - .choice_index - .saturating_sub(visible_rows.saturating_sub(1)); - let mut lines = Vec::new(); - for (index, value) in values.iter().enumerate().skip(start).take(visible_rows) { - let selected = index == state.choice_index; - lines.push(Line::from(vec![ - Span::styled( - if selected { "▶ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - value.clone(), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ])); + let mut list_area = area; + if state.choice_picker == Some(ChoicePicker::Java) + && let Some(status) = state.java_picker.status() + { + let (message, color) = match status { + Ok(message) => (message.to_owned(), theme.text_dim()), + Err(error) => (error, theme.error()), + }; + frame.render_widget( + Paragraph::new(message).style(Style::default().fg(color)), + Rect { height: 1, ..area }, + ); + list_area.y = list_area.y.saturating_add(1); + list_area.height = list_area.height.saturating_sub(1); } - frame.render_widget(Paragraph::new(lines), area); + let items = values + .iter() + .map(|value| { + ListItem::new(Line::from(Span::styled( + value.clone(), + Style::default().fg(theme.text()), + ))) + }) + .collect(); + super::select_list::render(items, state.choice_index, list_area, frame.buffer_mut()); } fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { @@ -784,25 +961,18 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { }, None => return, }; - let mut lines = vec![Line::from(vec![ - Span::styled("Search ", Style::default().fg(theme.text_dim())), - Span::styled( - if state.picker_search { - format!("/{}█", state.picker_query) - } else if state.picker_query.is_empty() { - "press / to search".to_owned() - } else { - format!("/{}", state.picker_query) - }, - Style::default().fg(theme.accent()), - ), - ])]; match status { - PickerLoad::Idle | PickerLoad::Loading => lines.push(Line::from("Loading versions...")), - PickerLoad::Error(error) => lines.push(Line::from(Span::styled( - format!("Failed to load versions: {error}. Reopen to retry."), - Style::default().fg(theme.error()), - ))), + PickerLoad::Idle | PickerLoad::Loading => frame.render_widget( + Paragraph::new("Loading versions...").style(Style::default().fg(theme.text_dim())), + area, + ), + PickerLoad::Error(error) => frame.render_widget( + Paragraph::new(format!( + "Failed to load versions: {error}. Reopen to retry." + )) + .style(Style::default().fg(theme.error())), + area, + ), PickerLoad::Loaded => { let versions: Vec = match state.picker { Some(VersionPicker::Game) => state @@ -816,43 +986,28 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { } }) .collect(), - Some(VersionPicker::Loader) => state.visible_loader_versions(), + Some(VersionPicker::Loader) => { + state.visible_loader_versions().into_iter().collect() + } None => Vec::new(), }; if versions.is_empty() { - lines.push(Line::from("No matching versions.")); + frame.render_widget(Paragraph::new("No matching versions."), area); } else { - let visible_rows = area.height.saturating_sub(1) as usize; - let start = state - .picker_index - .saturating_sub(visible_rows.saturating_sub(1)); - for (index, version) in versions.iter().enumerate().skip(start).take(visible_rows) { - let selected = index == state.picker_index; - lines.push(Line::from(vec![ - Span::styled( - if selected { "▶ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - version.clone(), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }), - ), - ])); - } + let items = versions + .iter() + .map(|version| { + ListItem::new( + state + .picker_search + .highlight_line(version, Style::default().fg(theme.text())), + ) + }) + .collect(); + super::select_list::render(items, state.picker_index, area, frame.buffer_mut()); } } } - frame.render_widget(Paragraph::new(lines), area); } #[cfg(test)] @@ -900,7 +1055,7 @@ mod tests { config.memory_min = Some("512M".to_owned()); let mut state = State::new(&config, temp.path()); state.selected = 4; - state.handle_key(&KeyEvent::from(KeyCode::Enter)); + state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); state.handle_key(&KeyEvent::from(KeyCode::Left)); state.handle_key(&KeyEvent::from(KeyCode::Char('0'))); state.handle_key(&KeyEvent::from(KeyCode::Enter)); @@ -999,14 +1154,59 @@ mod tests { } #[test] - fn text_fields_open_inline_editors() { + fn memory_uses_slider_and_jvm_args_use_inline_editor() { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); state.selected = 4; state.begin_edit(); + assert!(state.editing.is_none()); + assert!(state.draft.memory_min.is_some()); + + state.selected = 6; + state.begin_edit(); assert!(state.editing.is_some()); assert!(state.choice_picker.is_none()); assert!(state.picker.is_none()); } + + #[test] + fn resolution_and_java_use_selectors() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + + state.selected = 7; + state.begin_edit(); + assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); + state.choice_index = resolution_values(state.draft.resolution) + .iter() + .position(|value| value == "1920x1080") + .unwrap(); + state.apply_choice(); + assert_eq!(state.draft.resolution, Some((1920, 1080))); + + state.selected = 3; + state.begin_edit(); + assert_eq!(state.choice_picker, Some(ChoicePicker::Java)); + state.choice_index = state.choice_values().len() - 1; + state.apply_choice(); + assert!(state.editing.is_some()); + } + + #[test] + fn memory_slider_keeps_bounds_linked_and_desktop_has_no_dot() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.draft.memory_min = Some("4G".to_owned()); + state.draft.memory_max = Some("4G".to_owned()); + state.selected = 4; + + state.adjust_selected_memory(true); + + assert_eq!(state.draft.memory_min.as_deref(), Some("6G")); + assert_eq!(state.draft.memory_max.as_deref(), Some("6G")); + let desktop = state.display_value(8); + assert!(matches!(desktop.as_str(), "enabled" | "disabled")); + assert!(!desktop.contains('●') && !desktop.contains('○')); + } } diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index 42f8403..0f934ea 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -13,6 +13,8 @@ pub mod instance_settings; mod load_state; pub mod modpack_update; pub mod new_instance; +pub(crate) mod select_list; +pub(crate) mod settings_controls; pub mod version_lists; pub use load_state::LoadState; diff --git a/src/tui/widgets/popups/new_instance/mod.rs b/src/tui/widgets/popups/new_instance/mod.rs index 677f19b..d77a911 100644 --- a/src/tui/widgets/popups/new_instance/mod.rs +++ b/src/tui/widgets/popups/new_instance/mod.rs @@ -5,7 +5,6 @@ mod render; mod state; pub use super::LoadState; -pub(crate) use render::render_select_list; pub use render::{popup_rect, render}; pub use state::{WizardParams, WizardState, WizardStep, handle_key, take_result}; diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index a3a1c64..2e50f21 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -18,7 +18,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap}, + widgets::{ListItem, Paragraph, Widget, Wrap}, }; use tui_prompts::State as PromptState; @@ -202,40 +202,14 @@ fn render_version_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe }) .collect(); - render_select_list(items, state.version_idx, area, buf); + super::super::select_list::render(items, state.version_idx, area, buf); } } } -pub(crate) fn render_select_list( - items: Vec>, - selected: usize, - area: Rect, - buffer: &mut ratatui::buffer::Buffer, -) { - let list = List::new(items) - .highlight_style(Style::default().add_modifier(Modifier::BOLD)) - .highlight_symbol(Span::styled( - "▶ ", - Style::default() - .fg(THEME.as_ref().accent()) - .add_modifier(Modifier::BOLD), - )); - let mut state = ListState::default().with_selected(Some(selected)); - StatefulWidget::render(list, area, buffer, &mut state); -} - fn render_loader_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { let theme = THEME.as_ref(); - let loaders = [ - ModLoader::Vanilla, - ModLoader::Fabric, - ModLoader::Forge, - ModLoader::NeoForge, - ModLoader::Quilt, - ]; - - let items: Vec = loaders + let items: Vec = super::super::select_list::MOD_LOADERS .into_iter() .map(|loader| { ListItem::new(Line::from(Span::styled( @@ -245,16 +219,7 @@ fn render_loader_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffer }) .collect(); - let list = List::new(items) - .highlight_style( - Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("▶ "); - - let mut list_state = ListState::default().with_selected(Some(state.loader_idx)); - StatefulWidget::render(list, area, buf, &mut list_state); + super::super::select_list::render(items, state.loader_idx, area, buf); } fn render_loader_version_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { @@ -289,16 +254,7 @@ fn render_loader_version_step(state: &WizardState, area: Rect, buf: &mut ratatui }) .collect(); - let list = List::new(items) - .highlight_style( - Style::default() - .fg(theme.accent()) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("▶ "); - - let mut list_state = ListState::default().with_selected(Some(state.loader_version_idx)); - StatefulWidget::render(list, area, buf, &mut list_state); + super::super::select_list::render(items, state.loader_version_idx, area, buf); } } } diff --git a/src/tui/widgets/popups/new_instance/state.rs b/src/tui/widgets/popups/new_instance/state.rs index 5c49505..7afe6aa 100644 --- a/src/tui/widgets/popups/new_instance/state.rs +++ b/src/tui/widgets/popups/new_instance/state.rs @@ -85,14 +85,7 @@ impl WizardState { } pub fn selected_loader(&self) -> ModLoader { - const LOADERS: [ModLoader; 5] = [ - ModLoader::Vanilla, - ModLoader::Fabric, - ModLoader::Forge, - ModLoader::NeoForge, - ModLoader::Quilt, - ]; - LOADERS[self.loader_idx % 5] + super::super::select_list::MOD_LOADERS[self.loader_idx % 5] } pub fn selected_loader_version(&self) -> Option { diff --git a/src/tui/widgets/popups/select_list.rs b/src/tui/widgets/popups/select_list.rs new file mode 100644 index 0000000..e69cc66 --- /dev/null +++ b/src/tui/widgets/popups/select_list.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +// Shared selection list used by the new-instance wizard and settings pickers. + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::Span, + widgets::{List, ListItem, ListState, StatefulWidget}, +}; + +use crate::config::theme::THEME; +use crate::instance::models::ModLoader; + +pub(crate) const MOD_LOADERS: [ModLoader; 5] = [ + ModLoader::Vanilla, + ModLoader::Fabric, + ModLoader::Forge, + ModLoader::NeoForge, + ModLoader::Quilt, +]; + +pub(crate) fn render(items: Vec>, selected: usize, area: Rect, buffer: &mut Buffer) { + let list = List::new(items) + .highlight_style( + Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(Span::styled( + "▶ ", + Style::default() + .fg(THEME.as_ref().accent()) + .add_modifier(Modifier::BOLD), + )); + let mut state = ListState::default().with_selected(Some(selected)); + StatefulWidget::render(list, area, buffer, &mut state); +} diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs new file mode 100644 index 0000000..e659bfc --- /dev/null +++ b/src/tui/widgets/popups/settings_controls.rs @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +// Reusable interactive controls shared by instance and launcher settings. + +use std::sync::{Arc, Mutex}; + +use ratatui::{Frame, layout::Rect, style::Style, text::Span, widgets::Gauge}; + +use crate::{ + config::theme::THEME, instance::java::JavaInstallation, tui::widgets::popups::LoadState, +}; + +const MEMORY_STEPS: [&str; 12] = [ + "512M", "1G", "2G", "3G", "4G", "6G", "8G", "12G", "16G", "24G", "32G", "64G", +]; + +#[derive(Debug, Clone)] +pub(crate) struct JavaPicker { + load: Arc>>>, + current: Option, + detected: String, + pub selected: usize, + previous_choices: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum JavaChoice { + Automatic, + Installation(String), + Custom, +} + +impl JavaPicker { + pub(crate) fn new() -> Self { + Self { + load: Arc::new(Mutex::new(LoadState::Idle)), + current: None, + detected: crate::instance::java::detect_java_path(), + selected: 0, + previous_choices: Vec::new(), + } + } + + pub(crate) fn open(&mut self, current: Option<&str>) { + self.current = current.map(str::to_owned); + self.previous_choices.clear(); + let mut load = self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(*load, LoadState::Idle | LoadState::Error(_)) { + return; + } + *load = LoadState::Loading; + drop(load); + let target = self.load.clone(); + let discover = move || { + let installations = crate::instance::java::discover_installations(); + *target + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + LoadState::Loaded(installations); + crate::feedback::request_redraw(); + }; + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn_blocking(discover); + } else { + *self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = LoadState::Loaded(Vec::new()); + } + } + + pub(crate) fn choices(&self) -> Vec { + let mut choices = vec![JavaChoice::Automatic]; + if let LoadState::Loaded(installations) = &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + choices.extend(installations.iter().map(|installation| { + JavaChoice::Installation(installation.path.to_string_lossy().into_owned()) + })); + } + if let Some(current) = &self.current + && !choices + .iter() + .any(|choice| matches!(choice, JavaChoice::Installation(path) if path == current)) + { + choices.push(JavaChoice::Installation(current.clone())); + } + choices.push(JavaChoice::Custom); + choices + } + + pub(crate) fn labels(&self) -> Vec { + let installations = match &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(installations) => Some(installations.clone()), + _ => None, + }; + self.choices() + .into_iter() + .map(|choice| match choice { + JavaChoice::Automatic => format!("Automatic {}", self.detected), + JavaChoice::Installation(path) => installations + .as_ref() + .and_then(|items| { + items + .iter() + .find(|item| item.path.to_string_lossy() == path) + }) + .map_or_else(|| format!("Java {path}"), JavaInstallation::label), + JavaChoice::Custom => "Custom path…".to_owned(), + }) + .collect() + } + + pub(crate) fn status(&self) -> Option> { + match &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Idle => None, + LoadState::Loading => Some(Ok("Detecting installed Java runtimes…")), + LoadState::Loaded(installations) if installations.is_empty() => { + Some(Ok("No additional Java runtimes found.")) + } + LoadState::Loaded(_) => None, + LoadState::Error(error) => Some(Err(error.clone())), + } + } + + pub(crate) fn initialize(&mut self) { + let choices = self.choices(); + if choices == self.previous_choices { + return; + } + let selected = self + .previous_choices + .get(self.selected) + .cloned() + .or_else(|| { + self.current + .as_ref() + .map(|current| JavaChoice::Installation(current.clone())) + }) + .unwrap_or(JavaChoice::Automatic); + self.selected = choices + .iter() + .position(|choice| choice == &selected) + .unwrap_or(0); + self.previous_choices = choices; + } + + pub(crate) fn selected_choice(&self) -> JavaChoice { + self.choices() + .get(self.selected) + .cloned() + .unwrap_or(JavaChoice::Automatic) + } +} + +impl Default for JavaPicker { + fn default() -> Self { + Self::new() + } +} + +pub(crate) fn memory_kib(value: &str) -> Option { + let normalized = crate::instance::models::normalize_memory_value(value)?; + let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); + let number = number.parse::().ok()?; + match suffix { + "K" => Some(number), + "M" => number.checked_mul(1024), + "G" => number.checked_mul(1024 * 1024), + _ => None, + } +} + +pub(crate) fn adjust_memory(value: &str, forward: bool) -> String { + let current = memory_kib(value).unwrap_or_default(); + let exact = MEMORY_STEPS.iter().position(|step| *step == value); + let index = match (exact, forward) { + (Some(index), true) => (index + 1).min(MEMORY_STEPS.len() - 1), + (Some(index), false) => index.saturating_sub(1), + (None, true) => MEMORY_STEPS + .iter() + .position(|step| memory_kib(step).is_some_and(|amount| amount > current)) + .unwrap_or(MEMORY_STEPS.len() - 1), + (None, false) => MEMORY_STEPS + .iter() + .rposition(|step| memory_kib(step).is_some_and(|amount| amount < current)) + .unwrap_or(0), + }; + MEMORY_STEPS[index].to_owned() +} + +pub(crate) fn render_memory_gauge( + frame: &mut Frame, + area: Rect, + value: &str, + label: String, + selected: bool, +) { + let theme = THEME.as_ref(); + let current = memory_kib(value).unwrap_or_default(); + let index = MEMORY_STEPS + .iter() + .position(|step| memory_kib(step).is_some_and(|amount| current <= amount)) + .unwrap_or(MEMORY_STEPS.len() - 1); + let ratio = (index + 1) as f64 / MEMORY_STEPS.len() as f64; + let gauge = Gauge::default() + .ratio(ratio) + .use_unicode(true) + .label(Span::styled( + label, + Style::default().fg(if selected { + theme.text() + } else { + theme.text_dim() + }), + )) + .gauge_style( + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text_dim() + }) + .bg(theme.background()), + ); + frame.render_widget(gauge, area); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn memory_steps_move_and_clamp() { + assert_eq!(adjust_memory("2G", true), "3G"); + assert_eq!(adjust_memory("2G", false), "1G"); + assert_eq!(adjust_memory("64G", true), "64G"); + assert_eq!(adjust_memory("512M", false), "512M"); + } + + #[test] + fn java_picker_preserves_semantic_selection_when_results_arrive() { + let mut picker = JavaPicker::new(); + picker.current = None; + picker.initialize(); + picker.selected = picker.choices().len() - 1; + *picker.load.lock().unwrap() = LoadState::Loaded(vec![JavaInstallation { + path: "/opt/jdk/bin/java".into(), + version: Some("21".to_owned()), + }]); + + picker.initialize(); + + assert_eq!(picker.selected_choice(), JavaChoice::Custom); + } +} From d5d77c222e723d4838d166a68ae6858577a79f91 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 12:48:48 +0200 Subject: [PATCH 07/42] feat: polish settings controls --- Cargo.lock | 464 +++++++++++++++++++- Cargo.toml | 1 + src/tui/tests/flows.rs | 10 +- src/tui/widgets/popups/global_settings.rs | 31 +- src/tui/widgets/popups/instance_settings.rs | 271 +++++++++--- src/tui/widgets/popups/select_list.rs | 1 + src/tui/widgets/popups/settings_controls.rs | 174 +++++++- 7 files changed, 855 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 71ccbc6..46b1e4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,6 +297,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bon" version = "3.9.1" @@ -789,6 +798,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "darling" version = "0.21.3" @@ -1019,6 +1034,37 @@ dependencies = [ "winapi", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + +[[package]] +name = "display-info" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e0aca670967c2528799e316f9f97913efcc034867614d55681dd41a1c2f7830" +dependencies = [ + "fxhash", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "scopeguard", + "smithay-client-toolkit", + "thiserror 2.0.18", + "widestring", + "windows 0.62.2", + "xcb", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1048,6 +1094,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.15.0" @@ -1392,6 +1444,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "gdb-command" version = "0.7.8" @@ -2623,6 +2684,174 @@ dependencies = [ "url", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -3150,6 +3379,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3366,7 +3604,7 @@ dependencies = [ "rustix 0.38.44", "self_cell", "thiserror 1.0.69", - "windows", + "windows 0.58.0", ] [[package]] @@ -3653,6 +3891,7 @@ dependencies = [ "crossterm 0.29.0", "dirs", "dirs-next", + "display-info", "fast-strip-ansi", "fastnbt", "flate2", @@ -4170,6 +4409,31 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + [[package]] name = "socket2" version = "0.6.3" @@ -5294,6 +5558,124 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -5434,6 +5816,12 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -5475,6 +5863,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -5501,6 +5910,17 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.58.0" @@ -5551,6 +5971,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -5657,6 +6087,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5894,6 +6333,29 @@ dependencies = [ "tap", ] +[[package]] +name = "xcb" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.1", + "libc", + "quick-xml", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "xmlwriter" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index bd8d2de..e828a24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ clap = "4.5.27" color-eyre = "0.6.3" config = "0.15.6" crossterm = "0.29.0" +display-info = "0.5.9" dirs-next = "2.0.0" log = "0.4" ratatui = { version = "0.30.0", features = ["serde", "unstable-rendered-line-info"] } diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index e3cfdc9..c0c0eee 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -585,7 +585,8 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert!(ui.screen().contains("Game version")); assert!(ui.screen().contains("Memory min")); assert!(ui.screen().contains("Desktop")); - assert!(ui.screen().contains('█')); + assert!(ui.screen().contains('◆')); + assert!(!ui.screen().contains('█')); assert!(!ui.screen().contains("● enabled")); assert!(!ui.screen().contains("Integration")); assert!(!ui.screen().contains('▰')); @@ -617,7 +618,8 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { ui.draw(); assert!(ui.screen().contains("Launcher Settings")); assert!(ui.screen().contains("Memory max")); - assert!(ui.screen().contains('█')); + assert!(ui.screen().contains('◆')); + assert!(!ui.screen().contains('█')); assert!(!ui.screen().contains('▰')); ui.key(KeyCode::Enter); ui.draw(); @@ -699,6 +701,10 @@ fn settings_use_java_memory_and_resolution_controls() { ui.draw(); assert!(ui.screen().contains("Resolution")); assert!(ui.screen().contains("1920x1080")); + for _ in 0..20 { + ui.key(KeyCode::Char('j')); + } + ui.draw(); assert!(ui.screen().contains("Custom")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 743652e..bbe15b4 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -292,13 +292,12 @@ impl State { KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, 1 => self.cycle_border(true), - 2 | 3 => self.adjust_selected_memory(true), + 2 | 3 => { + self.editing = Some(new_text_area(vec![self.value(self.selected)])); + } 4 => self.open_java_picker(), field => self.editing = Some(new_text_area(vec![self.value(field)])), }, - KeyCode::Char('c') if matches!(self.selected, 2 | 3) => { - self.editing = Some(new_text_area(vec![self.value(self.selected)])); - } KeyCode::Char('s') => { if self.validate_before_save() { return Action::Save( @@ -380,8 +379,9 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { } else { 7 + u16::from(state.error.is_some()) }; + let width = if state.java_picker_open { 72 } else { 52 }; area.centered( - ratatui::layout::Constraint::Percentage(52), + ratatui::layout::Constraint::Percentage(width), ratatui::layout::Constraint::Length(height.min(area.height.saturating_sub(4))), ) } @@ -399,7 +399,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { } else if matches!(state.selected, 2 | 3) { super::keybind_line(&[ ("h/l", " adjust"), - ("c", " custom"), + ("Enter", " exact"), ("s", " save"), ("Esc", " back"), ]) @@ -470,11 +470,11 @@ fn render_java_picker(frame: &mut Frame, area: Rect, state: &State) { list_area.y = list_area.y.saturating_add(1); list_area.height = list_area.height.saturating_sub(1); } - render_picker( - frame, - list_area, - &state.java_picker.labels(), + super::select_list::render( + state.java_picker.items(), state.java_picker.selected, + list_area, + frame.buffer_mut(), ); } @@ -553,6 +553,11 @@ fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a }), ), ]) + .style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })) } #[cfg(test)] @@ -564,10 +569,14 @@ mod tests { let mut state = State::new(); state.selected = 2; let original = state.config.defaults.memory_min.clone(); - state.handle_key(&KeyEvent::from(KeyCode::Enter)); + state.handle_key(&KeyEvent::from(KeyCode::Char('l'))); assert_ne!(state.config.defaults.memory_min, original); assert!(state.editing.is_none()); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert!(state.editing.is_some()); + + state.editing = None; state.selected = 4; state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert!(state.java_picker_open); diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index ba8ba15..056d3c9 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -25,7 +25,8 @@ use crate::{ popups::{ LoadState, settings_controls::{ - JavaChoice, JavaPicker, adjust_memory, memory_kib, render_memory_gauge, + DisplayResolution, JavaChoice, JavaPicker, adjust_memory, badge, + display_resolutions, memory_kib, render_memory_gauge, }, }, search::SearchState, @@ -48,6 +49,38 @@ enum ChoicePicker { Resolution, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum ResolutionChoice { + Default, + Display(DisplayResolution), + Preset(u32, u32), + Configured(u32, u32), + Custom, +} + +impl ResolutionChoice { + fn resolution(&self) -> Option<(u32, u32)> { + match self { + Self::Display(display) => Some((display.width, display.height)), + Self::Preset(width, height) | Self::Configured(width, height) => { + Some((*width, *height)) + } + Self::Default | Self::Custom => None, + } + } + + fn label(&self) -> String { + self.resolution().map_or_else( + || match self { + Self::Default => "Default".to_owned(), + Self::Custom => "Custom…".to_owned(), + _ => String::new(), + }, + |(width, height)| format!("{width}x{height}"), + ) + } +} + enum PickerLoad { Idle, Loading, @@ -74,6 +107,7 @@ pub struct State { choice_picker: Option, choice_index: usize, java_picker: JavaPicker, + display_resolutions: Vec, } pub enum Action { @@ -109,6 +143,7 @@ impl State { choice_picker: None, choice_index: 0, java_picker: JavaPicker::new(), + display_resolutions: display_resolutions(), } } @@ -202,7 +237,9 @@ impl State { } 2 => self.open_loader_picker(), 3 => self.open_choice_picker(ChoicePicker::Java), - 4 | 5 => self.adjust_selected_memory(true), + 4 | 5 => { + self.editing = Some(new_text_area(vec![self.effective_memory(self.selected)])); + } 6 => self.editing = Some(new_text_area(vec![self.value(self.selected)])), 7 => self.open_choice_picker(ChoicePicker::Resolution), 8 => self.desktop = !self.desktop, @@ -222,14 +259,10 @@ impl State { self.java_picker.initialize(); self.java_picker.selected } - ChoicePicker::Resolution => resolution_values(self.draft.resolution) + ChoicePicker::Resolution => self + .resolution_choices() .iter() - .position(|value| { - self.draft.resolution.map_or_else( - || value == "Default", - |(width, height)| value == &format!("{width}x{height}"), - ) - }) + .position(|choice| choice.resolution() == self.draft.resolution) .unwrap_or(0), }; } @@ -246,7 +279,11 @@ impl State { .map(ToString::to_string) .collect(), ChoicePicker::Java => self.java_picker.labels(), - ChoicePicker::Resolution => resolution_values(self.draft.resolution), + ChoicePicker::Resolution => self + .resolution_choices() + .iter() + .map(ResolutionChoice::label) + .collect(), } } @@ -295,16 +332,17 @@ impl State { } } Some(ChoicePicker::Resolution) => { - let selected = resolution_values(self.draft.resolution) + let selected = self + .resolution_choices() .get(self.choice_index) .cloned() - .unwrap_or_else(|| "Default".to_owned()); - match selected.as_str() { - "Default" => self.draft.resolution = None, - "Custom…" => { + .unwrap_or(ResolutionChoice::Default); + match selected { + ResolutionChoice::Default => self.draft.resolution = None, + ResolutionChoice::Custom => { self.editing = Some(new_text_area(vec![self.value(7)])); } - value => self.draft.resolution = parse_resolution(value).ok(), + choice => self.draft.resolution = choice.resolution(), } } None => {} @@ -462,6 +500,10 @@ impl State { self.error = None; } + fn resolution_choices(&self) -> Vec { + resolution_choices(self.draft.resolution, &self.display_resolutions) + } + fn handle_picker_key(&mut self, key: &KeyEvent) { self.initialize_picker_index(); if self.picker_search.active { @@ -597,9 +639,6 @@ impl State { self.adjust_selected_memory(true); } KeyCode::Enter => self.begin_edit(), - KeyCode::Char('c') if matches!(self.selected, 4 | 5) => { - self.editing = Some(new_text_area(vec![self.effective_memory(self.selected)])); - } KeyCode::Char('r') if matches!(self.selected, 4 | 5) => { self.set_memory(self.selected, None); } @@ -647,27 +686,36 @@ fn runtime_label(config: &InstanceConfig) -> String { } } -fn resolution_values(current: Option<(u32, u32)>) -> Vec { - let mut values = [ - "Default", - "854x480", - "1280x720", - "1600x900", - "1920x1080", - "2560x1440", - "3840x2160", - "Custom…", - ] - .into_iter() - .map(str::to_owned) - .collect::>(); - if let Some((width, height)) = current { - let current = format!("{width}x{height}"); - if !values.contains(¤t) { - values.insert(values.len() - 1, current); +fn resolution_choices( + current: Option<(u32, u32)>, + displays: &[DisplayResolution], +) -> Vec { + let mut choices = vec![ResolutionChoice::Default]; + choices.extend(displays.iter().cloned().map(ResolutionChoice::Display)); + for (width, height) in [ + (854, 480), + (1280, 720), + (1600, 900), + (1920, 1080), + (2560, 1440), + (3840, 2160), + ] { + if !choices + .iter() + .any(|choice| choice.resolution() == Some((width, height))) + { + choices.push(ResolutionChoice::Preset(width, height)); } } - values + if let Some((width, height)) = current + && !choices + .iter() + .any(|choice| choice.resolution() == Some((width, height))) + { + choices.push(ResolutionChoice::Configured(width, height)); + } + choices.push(ResolutionChoice::Custom); + choices } fn new_text_area(lines: Vec) -> TextArea<'static> { @@ -693,8 +741,13 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { } else { 11 + u16::from(state.error.is_some()) }; + let width = match state.choice_picker { + Some(ChoicePicker::Java) => 72, + Some(ChoicePicker::Resolution) => 64, + _ => 58, + }; area.centered( - Constraint::Percentage(58), + Constraint::Percentage(width), Constraint::Length(height.min(area.height.saturating_sub(4))), ) } @@ -731,7 +784,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { } else if matches!(state.selected, 4 | 5) { super::keybind_line(&[ ("h/l", " adjust"), - ("c", " custom"), + ("Enter", " exact"), ("r", " default"), ("s", " save"), ("Esc", " back"), @@ -842,9 +895,13 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { Style::default().fg(theme.accent()), ), Span::styled( - format!("{label:<18}"), + format!("{label:<16}"), Style::default().fg(theme.text_dim()), ), + Span::styled( + if dirty { "* " } else { " " }, + Style::default().fg(theme.accent()), + ), Span::styled( value, Style::default() @@ -861,10 +918,6 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { Modifier::empty() }), ), - Span::styled( - if dirty { " *" } else { "" }, - Style::default().fg(theme.accent()), - ), ]; if index == 6 && !editing { if state.draft.jvm_args.is_empty() { @@ -875,20 +928,27 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { } else { for (argument_index, argument) in state.draft.jvm_args.iter().enumerate() { if argument_index > 0 { - spans.push(Span::styled(" ", Style::default().fg(theme.text_dim()))); + spans.push(Span::raw(" ")); } spans.push(Span::styled( - argument.clone(), - Style::default().fg(if selected { - theme.accent() - } else { - theme.text() - }), + format!(" {argument} "), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .bg(theme.background()) + .add_modifier(Modifier::BOLD), )); } } } - Line::from(spans) + Line::from(spans).style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })) } fn field_dirty(state: &State, index: usize) -> bool { @@ -908,7 +968,6 @@ fn field_dirty(state: &State, index: usize) -> bool { fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); - let values = state.choice_values(); let mut list_area = area; if state.choice_picker == Some(ChoicePicker::Java) && let Some(status) = state.java_picker.status() @@ -924,16 +983,72 @@ fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { list_area.y = list_area.y.saturating_add(1); list_area.height = list_area.height.saturating_sub(1); } - let items = values + let items = match state.choice_picker { + Some(ChoicePicker::Java) => state.java_picker.items(), + Some(ChoicePicker::Resolution) => resolution_items(&state.resolution_choices()), + _ => state + .choice_values() + .iter() + .map(|value| { + ListItem::new(Line::from(Span::styled( + value.clone(), + Style::default().fg(theme.text()), + ))) + }) + .collect(), + }; + super::select_list::render(items, state.choice_index, list_area, frame.buffer_mut()); +} + +fn resolution_items(choices: &[ResolutionChoice]) -> Vec> { + let theme = THEME.as_ref(); + choices .iter() - .map(|value| { - ListItem::new(Line::from(Span::styled( - value.clone(), + .map(|choice| { + let mut spans = vec![Span::styled( + choice.label(), Style::default().fg(theme.text()), - ))) + )]; + match choice { + ResolutionChoice::Default => { + spans.extend([Span::raw(" "), badge(" Inherit ", theme.info())]); + } + ResolutionChoice::Display(display) => { + spans.extend([ + Span::raw(" "), + badge( + if display.primary { + " Primary " + } else { + " Current " + }, + if display.primary { + theme.success() + } else { + theme.info() + }, + ), + ]); + if !display.name.is_empty() { + spans.push(Span::styled( + format!(" {}", display.name), + Style::default().fg(theme.text_dim()), + )); + } + } + ResolutionChoice::Preset(_, _) => { + spans.extend([Span::raw(" "), badge(" Preset ", theme.info())]); + } + ResolutionChoice::Configured(_, _) => { + spans.extend([Span::raw(" "), badge(" Configured ", theme.success())]); + } + ResolutionChoice::Custom => { + spans.extend([Span::raw(" "), badge(" Manual ", theme.warning())]); + } + } + ListItem::new(Line::from(spans)) }) - .collect(); - super::select_list::render(items, state.choice_index, list_area, frame.buffer_mut()); + .collect() } fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { @@ -1055,7 +1170,7 @@ mod tests { config.memory_min = Some("512M".to_owned()); let mut state = State::new(&config, temp.path()); state.selected = 4; - state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); state.handle_key(&KeyEvent::from(KeyCode::Left)); state.handle_key(&KeyEvent::from(KeyCode::Char('0'))); state.handle_key(&KeyEvent::from(KeyCode::Enter)); @@ -1159,10 +1274,12 @@ mod tests { let mut state = State::new(&instance(), temp.path()); state.selected = 4; - state.begin_edit(); - assert!(state.editing.is_none()); + state.handle_key(&KeyEvent::from(KeyCode::Char('l'))); assert!(state.draft.memory_min.is_some()); + state.begin_edit(); + assert!(state.editing.is_some()); + state.editing = None; state.selected = 6; state.begin_edit(); assert!(state.editing.is_some()); @@ -1178,9 +1295,10 @@ mod tests { state.selected = 7; state.begin_edit(); assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); - state.choice_index = resolution_values(state.draft.resolution) + state.choice_index = state + .resolution_choices() .iter() - .position(|value| value == "1920x1080") + .position(|choice| choice.resolution() == Some((1920, 1080))) .unwrap(); state.apply_choice(); assert_eq!(state.draft.resolution, Some((1920, 1080))); @@ -1209,4 +1327,25 @@ mod tests { assert!(matches!(desktop.as_str(), "enabled" | "disabled")); assert!(!desktop.contains('●') && !desktop.contains('○')); } + + #[test] + fn detected_resolutions_are_listed_before_presets_without_duplicates() { + let displays = vec![DisplayResolution { + width: 1920, + height: 1080, + name: "Primary display".to_owned(), + primary: true, + }]; + + let choices = resolution_choices(None, &displays); + + assert!(matches!(choices[1], ResolutionChoice::Display(_))); + assert_eq!( + choices + .iter() + .filter(|choice| choice.resolution() == Some((1920, 1080))) + .count(), + 1 + ); + } } diff --git a/src/tui/widgets/popups/select_list.rs b/src/tui/widgets/popups/select_list.rs index e69cc66..68938ed 100644 --- a/src/tui/widgets/popups/select_list.rs +++ b/src/tui/widgets/popups/select_list.rs @@ -27,6 +27,7 @@ pub(crate) fn render(items: Vec>, selected: usize, area: Rect, buff .highlight_style( Style::default() .fg(THEME.as_ref().accent()) + .bg(THEME.as_ref().stripe()) .add_modifier(Modifier::BOLD), ) .highlight_symbol(Span::styled( diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index e659bfc..05f8d62 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -5,7 +5,13 @@ use std::sync::{Arc, Mutex}; -use ratatui::{Frame, layout::Rect, style::Style, text::Span, widgets::Gauge}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{LineGauge, ListItem, Paragraph}, +}; use crate::{ config::theme::THEME, instance::java::JavaInstallation, tui::widgets::popups::LoadState, @@ -31,6 +37,14 @@ pub(crate) enum JavaChoice { Custom, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DisplayResolution { + pub width: u32, + pub height: u32, + pub name: String, + pub primary: bool, +} + impl JavaPicker { pub(crate) fn new() -> Self { Self { @@ -121,6 +135,59 @@ impl JavaPicker { .collect() } + pub(crate) fn items(&self) -> Vec> { + let theme = THEME.as_ref(); + let installations = match &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(installations) => installations.clone(), + _ => Vec::new(), + }; + self.choices() + .into_iter() + .map(|choice| match choice { + JavaChoice::Automatic => ListItem::new(Line::from(vec![ + Span::styled("Automatic", Style::default().fg(theme.text())), + Span::raw(" "), + badge(" Auto ", theme.info()), + Span::styled( + format!(" {}", self.detected), + Style::default().fg(theme.text_dim()), + ), + ])), + JavaChoice::Installation(path) => { + let installation = installations + .iter() + .find(|installation| installation.path.to_string_lossy() == path); + let version = installation + .and_then(|installation| installation.version.as_deref()) + .map_or_else(|| "Java".to_owned(), |version| format!("Java {version}")); + let current = self.current.as_deref() == Some(path.as_str()); + ListItem::new(Line::from(vec![ + Span::styled(version, Style::default().fg(theme.text())), + Span::raw(" "), + badge( + if current { " Current " } else { " Detected " }, + if current { + theme.success() + } else { + theme.info() + }, + ), + Span::styled(format!(" {path}"), Style::default().fg(theme.text_dim())), + ])) + } + JavaChoice::Custom => ListItem::new(Line::from(vec![ + Span::styled("Custom path…", Style::default().fg(theme.text())), + Span::raw(" "), + badge(" Manual ", theme.warning()), + ])), + }) + .collect() + } + pub(crate) fn status(&self) -> Option> { match &*self .load @@ -217,27 +284,100 @@ pub(crate) fn render_memory_gauge( .position(|step| memory_kib(step).is_some_and(|amount| current <= amount)) .unwrap_or(MEMORY_STEPS.len() - 1); let ratio = (index + 1) as f64 / MEMORY_STEPS.len() as f64; - let gauge = Gauge::default() + let value_width = 17.min(area.width); + let value_area = Rect { + width: value_width, + ..area + }; + let line_area = Rect { + x: area.x.saturating_add(value_width).saturating_add(1), + width: area.width.saturating_sub(value_width.saturating_add(1)), + ..area + }; + let value_style = if selected { + Style::default() + .fg(theme.background()) + .bg(theme.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text()).bg(theme.background()) + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {label} "), value_style))), + value_area, + ); + + let gauge = LineGauge::default() .ratio(ratio) - .use_unicode(true) - .label(Span::styled( - label, - Style::default().fg(if selected { - theme.text() - } else { - theme.text_dim() - }), - )) - .gauge_style( - Style::default() - .fg(if selected { + .label("") + .filled_symbol("━") + .unfilled_symbol("─") + .filled_style(Style::default().fg(if selected { + theme.accent() + } else { + theme.text_dim() + })) + .unfilled_style(Style::default().fg(theme.border())); + frame.render_widget(gauge, line_area); + if line_area.width > 0 { + let thumb_offset = ((line_area.width.saturating_sub(1)) as f64 * ratio).round() as u16; + frame.render_widget( + Paragraph::new(Span::styled( + "◆", + Style::default().fg(if selected { theme.accent() } else { theme.text_dim() - }) - .bg(theme.background()), + }), + )), + Rect { + x: line_area.x.saturating_add(thumb_offset), + width: 1, + ..line_area + }, ); - frame.render_widget(gauge, area); + } +} + +pub(crate) fn display_resolutions() -> Vec { + let Ok(displays) = display_info::DisplayInfo::all() else { + return Vec::new(); + }; + let mut resolutions = Vec::::new(); + for display in displays { + if display.width == 0 || display.height == 0 { + continue; + } + if let Some(existing) = resolutions.iter_mut().find(|resolution| { + resolution.width == display.width && resolution.height == display.height + }) { + existing.primary |= display.is_primary; + continue; + } + let name = if display.friendly_name.is_empty() { + display.name + } else { + display.friendly_name + }; + resolutions.push(DisplayResolution { + width: display.width, + height: display.height, + name, + primary: display.is_primary, + }); + } + resolutions.sort_by_key(|resolution| !resolution.primary); + resolutions +} + +pub(crate) fn badge(text: &'static str, color: Color) -> Span<'static> { + Span::styled( + text, + Style::default() + .fg(THEME.as_ref().background()) + .bg(color) + .add_modifier(Modifier::BOLD), + ) } #[cfg(test)] From 84ec724d4f31fd556ca5ff347aca689861671792 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 13:28:39 +0200 Subject: [PATCH 08/42] fix: refine settings popup visuals --- src/tui/tests/flows.rs | 3 + src/tui/widgets/popups/instance_settings.rs | 272 +++++++++++++------- src/tui/widgets/popups/settings_controls.rs | 103 ++++---- 3 files changed, 229 insertions(+), 149 deletions(-) diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index c0c0eee..3d49b98 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -679,6 +679,7 @@ fn settings_use_java_memory_and_resolution_controls() { assert!(ui.screen().contains("Java Runtime")); assert!(ui.screen().contains("Automatic")); assert!(ui.screen().contains("Custom path")); + assert!(!ui.screen().contains("Manual")); ui.key(KeyCode::Esc); ui.key(KeyCode::Char('j')); @@ -701,6 +702,8 @@ fn settings_use_java_memory_and_resolution_controls() { ui.draw(); assert!(ui.screen().contains("Resolution")); assert!(ui.screen().contains("1920x1080")); + assert!(!ui.screen().contains("Preset")); + assert!(!ui.screen().contains("Inherit")); for _ in 0..20 { ui.key(KeyCode::Char('j')); } diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 056d3c9..59b5b88 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -25,8 +25,8 @@ use crate::{ popups::{ LoadState, settings_controls::{ - DisplayResolution, JavaChoice, JavaPicker, adjust_memory, badge, - display_resolutions, memory_kib, render_memory_gauge, + DisplayResolution, JavaChoice, JavaPicker, adjust_memory, display_resolutions, + memory_kib, render_memory_gauge, }, }, search::SearchState, @@ -739,7 +739,9 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { } else if state.choice_picker.is_some() { 10 } else { - 11 + u16::from(state.error.is_some()) + let form_width = (area.width * 58 / 100).saturating_sub(2); + 11 + jvm_row_count(state, form_width).saturating_sub(1) as u16 + + u16::from(state.error.is_some()) }; let width = match state.choice_picker { Some(ChoicePicker::Java) => 72, @@ -838,48 +840,73 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { "Resolution", "Desktop shortcut", ]; - let lines = labels - .iter() - .enumerate() - .map(|(index, label)| field_line(state, index, label)) - .chain(state.error.iter().map(|error| { - Line::from(Span::styled( - format!(" {error}"), - Style::default().fg(theme.error()), - )) - })) - .collect::>(); - frame.render_widget(Paragraph::new(lines), area); - - for field in [4, 5] { - let value = state.effective_memory(field); - let gauge_area = Rect { - x: area.x.saturating_add(20), - y: area.y.saturating_add(field as u16), - width: area.width.saturating_sub(21), - height: 1, + let mut y = area.y; + for (index, label) in labels.iter().enumerate() { + let lines = if index == 6 { + jvm_field_lines(state, area.width) + } else { + vec![field_line(state, index, label)] }; - render_memory_gauge( - frame, - gauge_area, - &value, - state.display_value(field), - state.selected == field, + let height = lines.len() as u16; + let row_area = Rect { y, height, ..area }; + frame.render_widget( + Paragraph::new(lines).style(Style::default().bg(if state.selected == index { + theme.stripe() + } else { + theme.surface() + })), + row_area, ); + + if matches!(index, 4 | 5) { + let value = state.effective_memory(index); + render_memory_gauge( + frame, + Rect { + x: area.x.saturating_add(20), + y, + width: area.width.saturating_sub(21), + height: 1, + }, + &value, + state.display_value(index), + state.selected == index, + ); + } + if state.selected == index + && let Some(editor) = state.editing.as_ref() + { + frame.render_widget( + editor, + Rect { + x: area.x.saturating_add(20), + y, + width: area.width.saturating_sub(20), + height: 1, + }, + ); + } + y = y.saturating_add(height); } - if let Some(editor) = state.editing.as_ref() { - let edit_area = Rect { - x: area.x.saturating_add(20), - y: area.y.saturating_add(state.selected as u16), - width: area.width.saturating_sub(20), - height: 1, - }; - frame.render_widget(editor, edit_area); + if let Some(error) = &state.error + && y < area.bottom() + { + frame.render_widget( + Paragraph::new(Span::styled( + format!(" {error}"), + Style::default().fg(theme.error()), + )), + Rect { + y, + height: 1, + ..area + }, + ); } } -fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { +fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { let theme = THEME.as_ref(); let selected = index == state.selected; let editing = selected && state.editing.is_some(); @@ -889,7 +916,7 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { state.display_value(index) }; let dirty = field_dirty(state, index); - let mut spans = vec![ + Line::from(vec![ Span::styled( if selected { "▶ " } else { " " }, Style::default().fg(theme.accent()), @@ -918,37 +945,74 @@ fn field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { Modifier::empty() }), ), - ]; - if index == 6 && !editing { - if state.draft.jvm_args.is_empty() { - spans.push(Span::styled( - "no arguments", - Style::default().fg(theme.text_dim()), - )); - } else { - for (argument_index, argument) in state.draft.jvm_args.iter().enumerate() { - if argument_index > 0 { - spans.push(Span::raw(" ")); - } - spans.push(Span::styled( - format!(" {argument} "), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .bg(theme.background()) - .add_modifier(Modifier::BOLD), - )); - } + ]) +} + +fn jvm_field_lines(state: &State, width: u16) -> Vec> { + let theme = THEME.as_ref(); + let selected = state.selected == 6; + let mut prefix = field_line(state, 6, "JVM args").spans; + if selected && state.editing.is_some() { + return vec![Line::from(prefix)]; + } + if state.draft.jvm_args.is_empty() { + prefix.push(Span::styled( + "no arguments", + Style::default().fg(theme.text_dim()), + )); + return vec![Line::from(prefix)]; + } + + let available = width.saturating_sub(20) as usize; + let mut lines = Vec::new(); + let mut spans = prefix; + let mut used = 0usize; + for argument in &state.draft.jvm_args { + let badge_width = argument.chars().count() + 2; + let separator = usize::from(used > 0); + if used > 0 && used + separator + badge_width > available { + lines.push(Line::from(spans)); + spans = vec![Span::raw(" ".repeat(20))]; + used = 0; + } + if used > 0 { + spans.push(Span::raw(" ")); + used += 1; } + spans.push(Span::styled( + format!(" {argument} "), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .bg(theme.background()) + .add_modifier(Modifier::BOLD), + )); + used += badge_width; } - Line::from(spans).style(Style::default().bg(if selected { - theme.stripe() - } else { - theme.surface() - })) + lines.push(Line::from(spans)); + lines +} + +fn jvm_row_count(state: &State, width: u16) -> usize { + if state.draft.jvm_args.is_empty() || state.selected == 6 && state.editing.is_some() { + return 1; + } + let available = width.saturating_sub(20) as usize; + let mut rows = 1; + let mut used = 0usize; + for argument in &state.draft.jvm_args { + let badge_width = argument.chars().count() + 2; + let separator = usize::from(used > 0); + if used > 0 && used + separator + badge_width > available { + rows += 1; + used = 0; + } + used += usize::from(used > 0) + badge_width; + } + rows } fn field_dirty(state: &State, index: usize) -> bool { @@ -1010,40 +1074,36 @@ fn resolution_items(choices: &[ResolutionChoice]) -> Vec> { Style::default().fg(theme.text()), )]; match choice { - ResolutionChoice::Default => { - spans.extend([Span::raw(" "), badge(" Inherit ", theme.info())]); - } + ResolutionChoice::Default | ResolutionChoice::Preset(_, _) => {} ResolutionChoice::Display(display) => { - spans.extend([ - Span::raw(" "), - badge( - if display.primary { - " Primary " - } else { - " Current " - }, - if display.primary { - theme.success() - } else { - theme.info() - }, - ), - ]); if !display.name.is_empty() { - spans.push(Span::styled( - format!(" {}", display.name), - Style::default().fg(theme.text_dim()), - )); + spans.extend([ + Span::raw(" "), + Span::styled( + format!(" {} ", display.name), + Style::default() + .fg(if display.primary { + theme.success() + } else { + theme.info() + }) + .bg(theme.stripe()), + ), + ]); } } - ResolutionChoice::Preset(_, _) => { - spans.extend([Span::raw(" "), badge(" Preset ", theme.info())]); - } ResolutionChoice::Configured(_, _) => { - spans.extend([Span::raw(" "), badge(" Configured ", theme.success())]); + spans.push(Span::styled( + " configured", + Style::default().fg(theme.text_dim()), + )); } ResolutionChoice::Custom => { - spans.extend([Span::raw(" "), badge(" Manual ", theme.warning())]); + spans.clear(); + spans.push(Span::styled( + choice.label(), + Style::default().fg(theme.warning()), + )); } } ListItem::new(Line::from(spans)) @@ -1348,4 +1408,26 @@ mod tests { 1 ); } + + #[test] + fn jvm_argument_badges_wrap_without_hiding_following_fields() { + let temp = tempfile::tempdir().unwrap(); + let mut config = instance(); + config.jvm_args = [ + "-Xmx4G", + "-XX:+UseG1GC", + "-Dexample.first=true", + "-Dexample.second=true", + ] + .into_iter() + .map(str::to_owned) + .collect(); + let state = State::new(&config, temp.path()); + + let lines = jvm_field_lines(&state, 48); + + assert!(lines.len() > 1); + assert_eq!(jvm_row_count(&state, 48), lines.len()); + assert!(lines.iter().any(|line| line.to_string().contains("second"))); + } } diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 05f8d62..154ff1f 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use ratatui::{ Frame, layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{LineGauge, ListItem, Paragraph}, }; @@ -149,9 +149,12 @@ impl JavaPicker { .into_iter() .map(|choice| match choice { JavaChoice::Automatic => ListItem::new(Line::from(vec![ - Span::styled("Automatic", Style::default().fg(theme.text())), - Span::raw(" "), - badge(" Auto ", theme.info()), + Span::styled( + "Automatic", + Style::default() + .fg(theme.info()) + .add_modifier(Modifier::BOLD), + ), Span::styled( format!(" {}", self.detected), Style::default().fg(theme.text_dim()), @@ -164,26 +167,15 @@ impl JavaPicker { let version = installation .and_then(|installation| installation.version.as_deref()) .map_or_else(|| "Java".to_owned(), |version| format!("Java {version}")); - let current = self.current.as_deref() == Some(path.as_str()); ListItem::new(Line::from(vec![ Span::styled(version, Style::default().fg(theme.text())), - Span::raw(" "), - badge( - if current { " Current " } else { " Detected " }, - if current { - theme.success() - } else { - theme.info() - }, - ), Span::styled(format!(" {path}"), Style::default().fg(theme.text_dim())), ])) } - JavaChoice::Custom => ListItem::new(Line::from(vec![ - Span::styled("Custom path…", Style::default().fg(theme.text())), - Span::raw(" "), - badge(" Manual ", theme.warning()), - ])), + JavaChoice::Custom => ListItem::new(Line::from(Span::styled( + "Custom path…", + Style::default().fg(theme.warning()), + ))), }) .collect() } @@ -323,7 +315,11 @@ pub(crate) fn render_memory_gauge( let thumb_offset = ((line_area.width.saturating_sub(1)) as f64 * ratio).round() as u16; frame.render_widget( Paragraph::new(Span::styled( - "◆", + if thumb_offset + 1 < line_area.width { + "◆ " + } else { + "◆" + }, Style::default().fg(if selected { theme.accent() } else { @@ -332,7 +328,7 @@ pub(crate) fn render_memory_gauge( )), Rect { x: line_area.x.saturating_add(thumb_offset), - width: 1, + width: 2.min(line_area.width.saturating_sub(thumb_offset)), ..line_area }, ); @@ -343,43 +339,27 @@ pub(crate) fn display_resolutions() -> Vec { let Ok(displays) = display_info::DisplayInfo::all() else { return Vec::new(); }; - let mut resolutions = Vec::::new(); - for display in displays { - if display.width == 0 || display.height == 0 { - continue; - } - if let Some(existing) = resolutions.iter_mut().find(|resolution| { - resolution.width == display.width && resolution.height == display.height - }) { - existing.primary |= display.is_primary; - continue; - } - let name = if display.friendly_name.is_empty() { - display.name - } else { - display.friendly_name - }; - resolutions.push(DisplayResolution { - width: display.width, - height: display.height, - name, - primary: display.is_primary, - }); - } + let mut resolutions = displays + .into_iter() + .filter(|display| display.width > 0 && display.height > 0) + .map(|display| { + let name = if display.name.is_empty() { + display.friendly_name + } else { + display.name + }; + DisplayResolution { + width: display.width, + height: display.height, + name, + primary: display.is_primary, + } + }) + .collect::>(); resolutions.sort_by_key(|resolution| !resolution.primary); resolutions } -pub(crate) fn badge(text: &'static str, color: Color) -> Span<'static> { - Span::styled( - text, - Style::default() - .fg(THEME.as_ref().background()) - .bg(color) - .add_modifier(Modifier::BOLD), - ) -} - #[cfg(test)] mod tests { use super::*; @@ -407,4 +387,19 @@ mod tests { assert_eq!(picker.selected_choice(), JavaChoice::Custom); } + + #[test] + fn memory_thumb_clears_the_first_unfilled_cell() { + let backend = ratatui::backend::TestBackend::new(40, 1); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render_memory_gauge(frame, frame.area(), "8G", "8G".to_owned(), true)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let thumb = (0..40) + .find(|x| buffer[(*x, 0)].symbol() == "◆") + .expect("slider thumb"); + + assert_eq!(buffer[(thumb + 1, 0)].symbol(), " "); + } } From 6ce48b640a16ab1389398cad6873fb4dad1780d1 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 14:18:52 +0200 Subject: [PATCH 09/42] fix: refine settings interactions --- src/tui/tests/flows.rs | 21 +- src/tui/widgets/popups/confirm.rs | 2 +- src/tui/widgets/popups/global_settings.rs | 65 ++++-- src/tui/widgets/popups/instance_settings.rs | 213 +++++++++++++++----- src/tui/widgets/popups/settings_controls.rs | 127 ++++++++---- 5 files changed, 307 insertions(+), 121 deletions(-) diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 3d49b98..c12db6e 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -581,6 +581,7 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); ui.draw(); assert!(ui.screen().contains("Instance Settings")); + assert!(!ui.screen().contains("Instance Settings *")); assert!(ui.screen().contains("settings-test")); assert!(ui.screen().contains("Game version")); assert!(ui.screen().contains("Memory min")); @@ -623,7 +624,7 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert!(!ui.screen().contains('▰')); ui.key(KeyCode::Enter); ui.draw(); - assert!(ui.screen().contains("green")); + assert!(ui.screen().contains("Theme")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Settings); @@ -653,6 +654,7 @@ fn runtime_settings_use_the_shared_confirmation_popup() { ui.draw(); assert!(ui.screen().contains("Change runtime")); assert!(ui.screen().contains("Runtime files will be downloaded")); + assert!(ui.screen().contains("Installed mods may not load")); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); @@ -677,8 +679,10 @@ fn settings_use_java_memory_and_resolution_controls() { ui.key(KeyCode::Enter); ui.draw(); assert!(ui.screen().contains("Java Runtime")); - assert!(ui.screen().contains("Automatic")); - assert!(ui.screen().contains("Custom path")); + assert!(ui.screen().contains("auto")); + assert!(ui.screen().contains("custom")); + assert!(!ui.screen().contains("Automatic")); + assert!(!ui.screen().contains("Custom path")); assert!(!ui.screen().contains("Manual")); ui.key(KeyCode::Esc); @@ -698,17 +702,13 @@ fn settings_use_java_memory_and_resolution_controls() { for _ in 0..3 { ui.key(KeyCode::Char('j')); } - ui.key(KeyCode::Enter); + ui.key(KeyCode::Char('l')); ui.draw(); assert!(ui.screen().contains("Resolution")); assert!(ui.screen().contains("1920x1080")); assert!(!ui.screen().contains("Preset")); assert!(!ui.screen().contains("Inherit")); - for _ in 0..20 { - ui.key(KeyCode::Char('j')); - } - ui.draw(); - assert!(ui.screen().contains("Custom")); + assert!(ui.screen().contains("custom")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); ui.key(KeyCode::Enter); @@ -720,7 +720,8 @@ fn settings_use_java_memory_and_resolution_controls() { ui.key(KeyCode::Enter); ui.draw(); assert!(ui.screen().contains("Java Runtime")); - assert!(ui.screen().contains("Automatic")); + assert!(ui.screen().contains("auto")); + assert!(!ui.screen().contains("Automatic")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); } diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index f5651ac..9aa042f 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -101,7 +101,7 @@ impl ConfirmTarget { .collect::>() .join("\n"), ConfirmTarget::InstanceRuntime { from, to, .. } => format!( - "{from} → {to}\nRuntime files will be downloaded before saving.\n! Existing mods may be incompatible." + "{from} → {to}\nRuntime files will be downloaded before saving.\n! Installed mods may not load and can be incompatible with the new runtime." ), ConfirmTarget::DiscardInstanceSettings => { "Unsaved instance settings will be lost.".to_owned() diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index bbe15b4..00cc7c7 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -20,7 +20,8 @@ use crate::{ }, instance::models::normalize_memory_value, tui::widgets::popups::settings_controls::{ - JavaChoice, JavaPicker, adjust_memory, memory_kib, render_memory_gauge, + JavaChoice, JavaPicker, adjust_memory, handle_text_area_input, memory_kib, + render_memory_gauge, subtle_tag, }, }; @@ -90,7 +91,7 @@ impl State { .as_deref() .is_none_or(str::is_empty) => { - "auto-detect".to_owned() + self.java_picker.detected_path().to_owned() } _ => self.value(field), } @@ -198,6 +199,16 @@ impl State { let count = self.java_picker.labels().len(); match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.java_picker_open = false, + KeyCode::Char('a') => { + if self.config.paths.java_path.take().is_some() { + self.config_dirty = true; + } + self.java_picker_open = false; + } + KeyCode::Char('c') => { + self.java_picker_open = false; + self.editing = Some(new_text_area(vec![self.value(4)])); + } KeyCode::Char('j') | KeyCode::Down if count > 0 => { self.java_picker.selected = (self.java_picker.selected + 1).min(count - 1); } @@ -206,20 +217,12 @@ impl State { } KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { match self.java_picker.selected_choice() { - JavaChoice::Automatic => { - if self.config.paths.java_path.take().is_some() { - self.config_dirty = true; - } - } JavaChoice::Installation(path) => { if self.config.paths.java_path.as_deref() != Some(&path) { self.config.paths.java_path = Some(path); self.config_dirty = true; } } - JavaChoice::Custom => { - self.editing = Some(new_text_area(vec![self.value(4)])); - } } self.java_picker_open = false; } @@ -272,9 +275,7 @@ impl State { match key.code { KeyCode::Enter => self.commit_edit(), KeyCode::Esc => self.editing = None, - _ => { - input.input(*key); - } + _ => handle_text_area_input(input, key), } return Action::None; } @@ -298,6 +299,14 @@ impl State { 4 => self.open_java_picker(), field => self.editing = Some(new_text_area(vec![self.value(field)])), }, + KeyCode::Char('a') if self.selected == 4 => { + if self.config.paths.java_path.take().is_some() { + self.config_dirty = true; + } + } + KeyCode::Char('c') if self.selected == 4 => { + self.editing = Some(new_text_area(vec![self.value(4)])); + } KeyCode::Char('s') => { if self.validate_before_save() { return Action::Save( @@ -394,7 +403,14 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { frame.render_widget(Clear, area); let keybinds = if state.editing.is_some() { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) - } else if state.theme_picker || state.java_picker_open { + } else if state.java_picker_open { + super::keybind_line(&[ + ("a", " auto"), + ("c", " custom"), + ("h", " back"), + ("Enter", " select"), + ]) + } else if state.theme_picker { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if matches!(state.selected, 2 | 3) { super::keybind_line(&[ @@ -403,6 +419,14 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("s", " save"), ("Esc", " back"), ]) + } else if state.selected == 4 { + super::keybind_line(&[ + ("Enter", " runtimes"), + ("a", " auto"), + ("c", " custom"), + ("s", " save"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -416,8 +440,6 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { " Theme " } else if state.java_picker_open { " Java Runtime " - } else if state.config_dirty { - " Launcher Settings * " } else { " Launcher Settings " }; @@ -521,11 +543,11 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { } } -fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a> { +fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> { let theme = THEME.as_ref(); let selected = index == state.selected; let editing = selected && state.editing.is_some(); - Line::from(vec![ + let mut spans = vec![ Span::styled( if selected { "▶ " } else { " " }, Style::default().fg(theme.accent()), @@ -552,8 +574,11 @@ fn global_field_line<'a>(state: &'a State, index: usize, label: &str) -> Line<'a Modifier::empty() }), ), - ]) - .style(Style::default().bg(if selected { + ]; + if index == 4 && state.config.paths.java_path.is_none() && !editing { + spans.extend([Span::raw(" "), subtle_tag("Auto", theme.info())]); + } + Line::from(spans).style(Style::default().bg(if selected { theme.stripe() } else { theme.surface() diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 59b5b88..ac1c33e 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -26,7 +26,7 @@ use crate::{ LoadState, settings_controls::{ DisplayResolution, JavaChoice, JavaPicker, adjust_memory, display_resolutions, - memory_kib, render_memory_gauge, + handle_text_area_input, memory_kib, render_memory_gauge, subtle_tag, }, }, search::SearchState, @@ -51,11 +51,9 @@ enum ChoicePicker { #[derive(Debug, Clone, PartialEq, Eq)] enum ResolutionChoice { - Default, Display(DisplayResolution), Preset(u32, u32), Configured(u32, u32), - Custom, } impl ResolutionChoice { @@ -65,19 +63,13 @@ impl ResolutionChoice { Self::Preset(width, height) | Self::Configured(width, height) => { Some((*width, *height)) } - Self::Default | Self::Custom => None, } } fn label(&self) -> String { - self.resolution().map_or_else( - || match self { - Self::Default => "Default".to_owned(), - Self::Custom => "Custom…".to_owned(), - _ => String::new(), - }, - |(width, height)| format!("{width}x{height}"), - ) + self.resolution() + .map(|(width, height)| format!("{width}x{height}")) + .unwrap_or_default() } } @@ -212,7 +204,7 @@ impl State { fn display_value(&self, field: usize) -> String { match field { 2 if self.draft.loader == ModLoader::Vanilla => "not applicable".to_owned(), - 3 if self.draft.java_path.is_none() => "auto-detect".to_owned(), + 3 if self.draft.java_path.is_none() => self.java_picker.detected_path().to_owned(), 4 if self.draft.memory_min.is_none() => { format!("default ({})", SETTINGS.read().defaults.memory_min) } @@ -221,7 +213,10 @@ impl State { } 6 if self.draft.jvm_args.is_empty() => "no arguments".to_owned(), 6 => self.draft.jvm_args.join(" "), - 7 if self.draft.resolution.is_none() => "default".to_owned(), + 7 if self.draft.resolution.is_none() => self.default_resolution().map_or_else( + || "default".to_owned(), + |(width, height)| format!("{width}x{height}"), + ), 8 if self.desktop => "enabled".to_owned(), 8 => "disabled".to_owned(), _ => self.value(field).replace('\n', " ↵ "), @@ -241,7 +236,7 @@ impl State { self.editing = Some(new_text_area(vec![self.effective_memory(self.selected)])); } 6 => self.editing = Some(new_text_area(vec![self.value(self.selected)])), - 7 => self.open_choice_picker(ChoicePicker::Resolution), + 7 => self.editing = Some(new_text_area(vec![self.value(7)])), 8 => self.desktop = !self.desktop, field => self.editing = Some(new_text_area(vec![self.value(field)])), } @@ -295,6 +290,22 @@ impl State { let count = self.choice_values().len(); match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.choice_picker = None, + KeyCode::Char('a') if self.choice_picker == Some(ChoicePicker::Java) => { + self.draft.java_path = None; + self.choice_picker = None; + } + KeyCode::Char('c') if self.choice_picker == Some(ChoicePicker::Java) => { + self.choice_picker = None; + self.editing = Some(new_text_area(vec![self.value(3)])); + } + KeyCode::Char('c') if self.choice_picker == Some(ChoicePicker::Resolution) => { + self.choice_picker = None; + self.editing = Some(new_text_area(vec![self.value(7)])); + } + KeyCode::Char('d') if self.choice_picker == Some(ChoicePicker::Resolution) => { + self.draft.resolution = None; + self.choice_picker = None; + } KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { self.choice_index = (self.choice_index + 1).min(count - 1); } @@ -324,11 +335,7 @@ impl State { Some(ChoicePicker::Java) => { self.java_picker.selected = self.choice_index; match self.java_picker.selected_choice() { - JavaChoice::Automatic => self.draft.java_path = None, JavaChoice::Installation(path) => self.draft.java_path = Some(path), - JavaChoice::Custom => { - self.editing = Some(new_text_area(vec![self.value(3)])); - } } } Some(ChoicePicker::Resolution) => { @@ -336,13 +343,9 @@ impl State { .resolution_choices() .get(self.choice_index) .cloned() - .unwrap_or(ResolutionChoice::Default); - match selected { - ResolutionChoice::Default => self.draft.resolution = None, - ResolutionChoice::Custom => { - self.editing = Some(new_text_area(vec![self.value(7)])); - } - choice => self.draft.resolution = choice.resolution(), + .and_then(|choice| choice.resolution()); + if let Some(resolution) = selected { + self.draft.resolution = Some(resolution); } } None => {} @@ -504,6 +507,12 @@ impl State { resolution_choices(self.draft.resolution, &self.display_resolutions) } + fn default_resolution(&self) -> Option<(u32, u32)> { + self.display_resolutions + .first() + .map(|display| (display.width, display.height)) + } + fn handle_picker_key(&mut self, key: &KeyEvent) { self.initialize_picker_index(); if self.picker_search.active { @@ -620,9 +629,7 @@ impl State { match key.code { KeyCode::Enter => self.commit_edit(), KeyCode::Esc => self.editing = None, - _ => { - input.input(*key); - } + _ => handle_text_area_input(input, key), } return Action::None; } @@ -638,10 +645,18 @@ impl State { KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 4 | 5) => { self.adjust_selected_memory(true); } + KeyCode::Char('l') | KeyCode::Right if self.selected == 7 => { + self.open_choice_picker(ChoicePicker::Resolution); + } KeyCode::Enter => self.begin_edit(), - KeyCode::Char('r') if matches!(self.selected, 4 | 5) => { + KeyCode::Char('d') if matches!(self.selected, 4 | 5) => { self.set_memory(self.selected, None); } + KeyCode::Char('d') if self.selected == 7 => self.draft.resolution = None, + KeyCode::Char('a') if self.selected == 3 => self.draft.java_path = None, + KeyCode::Char('c') if self.selected == 3 => { + self.editing = Some(new_text_area(vec![self.value(3)])); + } KeyCode::Char('s') if self.dirty() => { if self.validate_before_save() { if self.runtime_changed() { @@ -690,7 +705,7 @@ fn resolution_choices( current: Option<(u32, u32)>, displays: &[DisplayResolution], ) -> Vec { - let mut choices = vec![ResolutionChoice::Default]; + let mut choices = Vec::new(); choices.extend(displays.iter().cloned().map(ResolutionChoice::Display)); for (width, height) in [ (854, 480), @@ -714,7 +729,6 @@ fn resolution_choices( { choices.push(ResolutionChoice::Configured(width, height)); } - choices.push(ResolutionChoice::Custom); choices } @@ -741,6 +755,7 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { } else { let form_width = (area.width * 58 / 100).saturating_sub(2); 11 + jvm_row_count(state, form_width).saturating_sub(1) as u16 + + u16::from(state.runtime_changed()) + u16::from(state.error.is_some()) }; let width = match state.choice_picker { @@ -767,7 +782,6 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { (_, Some(ChoicePicker::Loader)) => " Mod Loader ", (_, Some(ChoicePicker::Java)) => " Java Runtime ", (_, Some(ChoicePicker::Resolution)) => " Resolution ", - _ if state.dirty() => " Instance Settings * ", _ => " Instance Settings ", }; let keybinds = if state.editing.is_some() { @@ -781,13 +795,43 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ]) } else if state.picker.is_some() { super::keybind_line(&[("/", " search"), ("h", " back"), ("Enter", " select")]) + } else if state.choice_picker == Some(ChoicePicker::Java) { + super::keybind_line(&[ + ("a", " auto"), + ("c", " custom"), + ("h", " back"), + ("Enter", " select"), + ]) + } else if state.choice_picker == Some(ChoicePicker::Resolution) { + super::keybind_line(&[ + ("d", " default"), + ("c", " custom"), + ("h", " back"), + ("Enter", " select"), + ]) } else if state.choice_picker.is_some() { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if matches!(state.selected, 4 | 5) { super::keybind_line(&[ ("h/l", " adjust"), ("Enter", " exact"), - ("r", " default"), + ("d", " default"), + ("s", " save"), + ("Esc", " back"), + ]) + } else if state.selected == 3 { + super::keybind_line(&[ + ("Enter", " runtimes"), + ("a", " auto"), + ("c", " custom"), + ("s", " save"), + ("Esc", " back"), + ]) + } else if state.selected == 7 { + super::keybind_line(&[ + ("l", " presets"), + ("Enter", " custom"), + ("d", " default"), ("s", " save"), ("Esc", " back"), ]) @@ -889,6 +933,21 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { y = y.saturating_add(height); } + if state.runtime_changed() && y < area.bottom() { + frame.render_widget( + Paragraph::new(Span::styled( + " ! Runtime change may break installed mods.", + Style::default().fg(theme.warning()), + )), + Rect { + y, + height: 1, + ..area + }, + ); + y = y.saturating_add(1); + } + if let Some(error) = &state.error && y < area.bottom() { @@ -916,7 +975,7 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { state.display_value(index) }; let dirty = field_dirty(state, index); - Line::from(vec![ + let mut spans = vec![ Span::styled( if selected { "▶ " } else { " " }, Style::default().fg(theme.accent()), @@ -945,7 +1004,16 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { Modifier::empty() }), ), - ]) + ]; + if index == 3 && state.draft.java_path.is_none() && !editing { + spans.extend([Span::raw(" "), subtle_tag("Auto", theme.info())]); + } else if index == 7 && state.draft.resolution.is_none() && !editing { + spans.push(Span::styled( + " default", + Style::default().fg(theme.text_dim()), + )); + } + Line::from(spans) } fn jvm_field_lines(state: &State, width: u16) -> Vec> { @@ -1074,7 +1142,7 @@ fn resolution_items(choices: &[ResolutionChoice]) -> Vec> { Style::default().fg(theme.text()), )]; match choice { - ResolutionChoice::Default | ResolutionChoice::Preset(_, _) => {} + ResolutionChoice::Preset(_, _) => {} ResolutionChoice::Display(display) => { if !display.name.is_empty() { spans.extend([ @@ -1098,13 +1166,6 @@ fn resolution_items(choices: &[ResolutionChoice]) -> Vec> { Style::default().fg(theme.text_dim()), )); } - ResolutionChoice::Custom => { - spans.clear(); - spans.push(Span::styled( - choice.label(), - Style::default().fg(theme.warning()), - )); - } } ListItem::new(Line::from(spans)) }) @@ -1354,6 +1415,9 @@ mod tests { state.selected = 7; state.begin_edit(); + assert!(state.editing.is_some()); + state.editing = None; + state.handle_key(&KeyEvent::from(KeyCode::Char('l'))); assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); state.choice_index = state .resolution_choices() @@ -1366,11 +1430,29 @@ mod tests { state.selected = 3; state.begin_edit(); assert_eq!(state.choice_picker, Some(ChoicePicker::Java)); - state.choice_index = state.choice_values().len() - 1; - state.apply_choice(); + state.handle_choice_key(&KeyEvent::from(KeyCode::Char('c'))); assert!(state.editing.is_some()); } + #[test] + fn java_and_resolution_defaults_are_direct_actions_not_list_rows() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + + state.selected = 3; + state.draft.java_path = Some("/custom/java".to_owned()); + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); + assert_eq!(state.draft.java_path, None); + + state.selected = 7; + state.draft.resolution = Some((1920, 1080)); + state.handle_key(&KeyEvent::from(KeyCode::Char('d'))); + assert_eq!(state.draft.resolution, None); + state.handle_key(&KeyEvent::from(KeyCode::Char('l'))); + assert!(!state.choice_values().iter().any(|value| value == "Default")); + assert!(!state.choice_values().iter().any(|value| value == "Custom…")); + } + #[test] fn memory_slider_keeps_bounds_linked_and_desktop_has_no_dot() { let temp = tempfile::tempdir().unwrap(); @@ -1399,7 +1481,7 @@ mod tests { let choices = resolution_choices(None, &displays); - assert!(matches!(choices[1], ResolutionChoice::Display(_))); + assert!(matches!(choices[0], ResolutionChoice::Display(_))); assert_eq!( choices .iter() @@ -1409,6 +1491,43 @@ mod tests { ); } + #[test] + fn inherited_resolution_displays_the_detected_primary_size() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.display_resolutions = vec![DisplayResolution { + width: 2560, + height: 1440, + name: "DP-4".to_owned(), + primary: true, + }]; + + assert_eq!(state.display_value(7), "2560x1440"); + } + + #[test] + fn runtime_changes_show_an_inline_compatibility_warning() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.draft.game_version = "1.21.2".to_owned(); + let backend = ratatui::backend::TestBackend::new(80, 20); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + let area = popup_rect(frame.area(), &state); + render(frame, area, &mut state); + }) + .unwrap(); + + assert!( + terminal + .backend() + .to_string() + .contains("Runtime change may break installed mods") + ); + } + #[test] fn jvm_argument_badges_wrap_without_hiding_following_fields() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 154ff1f..dc52094 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, layout::Rect, @@ -12,6 +13,7 @@ use ratatui::{ text::{Line, Span}, widgets::{LineGauge, ListItem, Paragraph}, }; +use ratatui_textarea::TextArea; use crate::{ config::theme::THEME, instance::java::JavaInstallation, tui::widgets::popups::LoadState, @@ -32,9 +34,7 @@ pub(crate) struct JavaPicker { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum JavaChoice { - Automatic, Installation(String), - Custom, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -88,25 +88,27 @@ impl JavaPicker { } pub(crate) fn choices(&self) -> Vec { - let mut choices = vec![JavaChoice::Automatic]; + let mut paths = Vec::new(); if let LoadState::Loaded(installations) = &*self .load .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) { - choices.extend(installations.iter().map(|installation| { - JavaChoice::Installation(installation.path.to_string_lossy().into_owned()) - })); + paths.extend( + installations + .iter() + .map(|installation| installation.path.to_string_lossy().into_owned()), + ); + } + if !paths.contains(&self.detected) { + paths.insert(0, self.detected.clone()); } if let Some(current) = &self.current - && !choices - .iter() - .any(|choice| matches!(choice, JavaChoice::Installation(path) if path == current)) + && !paths.contains(current) { - choices.push(JavaChoice::Installation(current.clone())); + paths.push(current.clone()); } - choices.push(JavaChoice::Custom); - choices + paths.into_iter().map(JavaChoice::Installation).collect() } pub(crate) fn labels(&self) -> Vec { @@ -121,7 +123,6 @@ impl JavaPicker { self.choices() .into_iter() .map(|choice| match choice { - JavaChoice::Automatic => format!("Automatic {}", self.detected), JavaChoice::Installation(path) => installations .as_ref() .and_then(|items| { @@ -130,7 +131,6 @@ impl JavaPicker { .find(|item| item.path.to_string_lossy() == path) }) .map_or_else(|| format!("Java {path}"), JavaInstallation::label), - JavaChoice::Custom => "Custom path…".to_owned(), }) .collect() } @@ -148,18 +148,6 @@ impl JavaPicker { self.choices() .into_iter() .map(|choice| match choice { - JavaChoice::Automatic => ListItem::new(Line::from(vec![ - Span::styled( - "Automatic", - Style::default() - .fg(theme.info()) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {}", self.detected), - Style::default().fg(theme.text_dim()), - ), - ])), JavaChoice::Installation(path) => { let installation = installations .iter() @@ -167,15 +155,15 @@ impl JavaPicker { let version = installation .and_then(|installation| installation.version.as_deref()) .map_or_else(|| "Java".to_owned(), |version| format!("Java {version}")); - ListItem::new(Line::from(vec![ + let mut spans = vec![ Span::styled(version, Style::default().fg(theme.text())), Span::styled(format!(" {path}"), Style::default().fg(theme.text_dim())), - ])) + ]; + if self.current.is_none() && path == self.detected { + spans.extend([Span::raw(" "), subtle_tag("Auto", theme.info())]); + } + ListItem::new(Line::from(spans)) } - JavaChoice::Custom => ListItem::new(Line::from(Span::styled( - "Custom path…", - Style::default().fg(theme.warning()), - ))), }) .collect() } @@ -206,11 +194,13 @@ impl JavaPicker { .get(self.selected) .cloned() .or_else(|| { - self.current - .as_ref() - .map(|current| JavaChoice::Installation(current.clone())) + Some(JavaChoice::Installation( + self.current + .clone() + .unwrap_or_else(|| self.detected.clone()), + )) }) - .unwrap_or(JavaChoice::Automatic); + .unwrap_or_else(|| JavaChoice::Installation(self.detected.clone())); self.selected = choices .iter() .position(|choice| choice == &selected) @@ -222,7 +212,11 @@ impl JavaPicker { self.choices() .get(self.selected) .cloned() - .unwrap_or(JavaChoice::Automatic) + .unwrap_or_else(|| JavaChoice::Installation(self.detected.clone())) + } + + pub(crate) fn detected_path(&self) -> &str { + &self.detected } } @@ -288,11 +282,10 @@ pub(crate) fn render_memory_gauge( }; let value_style = if selected { Style::default() - .fg(theme.background()) - .bg(theme.accent()) + .fg(theme.accent()) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(theme.text()).bg(theme.background()) + Style::default().fg(theme.text()) }; frame.render_widget( Paragraph::new(Line::from(Span::styled(format!(" {label} "), value_style))), @@ -335,6 +328,21 @@ pub(crate) fn render_memory_gauge( } } +pub(crate) fn handle_text_area_input(input: &mut TextArea<'_>, key: &KeyEvent) { + if key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::CONTROL) { + input.delete_word(); + } else { + input.input(*key); + } +} + +pub(crate) fn subtle_tag(label: impl Into, color: ratatui::style::Color) -> Span<'static> { + Span::styled( + format!(" {} ", label.into()), + Style::default().fg(color).bg(THEME.as_ref().stripe()), + ) +} + pub(crate) fn display_resolutions() -> Vec { let Ok(displays) = display_info::DisplayInfo::all() else { return Vec::new(); @@ -375,9 +383,8 @@ mod tests { #[test] fn java_picker_preserves_semantic_selection_when_results_arrive() { let mut picker = JavaPicker::new(); - picker.current = None; + picker.current = Some("/opt/jdk/bin/java".to_owned()); picker.initialize(); - picker.selected = picker.choices().len() - 1; *picker.load.lock().unwrap() = LoadState::Loaded(vec![JavaInstallation { path: "/opt/jdk/bin/java".into(), version: Some("21".to_owned()), @@ -385,7 +392,23 @@ mod tests { picker.initialize(); - assert_eq!(picker.selected_choice(), JavaChoice::Custom); + assert_eq!( + picker.selected_choice(), + JavaChoice::Installation("/opt/jdk/bin/java".to_owned()) + ); + } + + #[test] + fn settings_text_input_deletes_the_previous_word() { + let mut input = TextArea::from(["one two"]); + input.move_cursor(ratatui_textarea::CursorMove::End); + + handle_text_area_input( + &mut input, + &KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), + ); + + assert_eq!(input.lines(), ["one "]); } #[test] @@ -402,4 +425,22 @@ mod tests { assert_eq!(buffer[(thumb + 1, 0)].symbol(), " "); } + + #[test] + fn memory_value_uses_the_existing_row_background() { + let backend = ratatui::backend::TestBackend::new(40, 1); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let surface = THEME.as_ref().surface(); + terminal + .draw(|frame| { + frame.render_widget( + ratatui::widgets::Block::default().style(Style::default().bg(surface)), + frame.area(), + ); + render_memory_gauge(frame, frame.area(), "6G", "6G".to_owned(), false); + }) + .unwrap(); + + assert_eq!(terminal.backend().buffer()[(1, 0)].bg, surface); + } } From fa6220649f9fc61ac3016ac720aaed9e040d536b Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 14:26:24 +0200 Subject: [PATCH 10/42] feat: autosave settings changes --- src/tui/input.rs | 60 ++++------- src/tui/tests/flows.rs | 31 +++--- src/tui/widgets/popups/confirm.rs | 16 +-- src/tui/widgets/popups/global_settings.rs | 71 ++++++------- src/tui/widgets/popups/instance_settings.rs | 107 +++++++++++--------- 5 files changed, 128 insertions(+), 157 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 9d76038..6a9c3cf 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -268,18 +268,10 @@ impl App { .as_mut() .and_then(|state| state.confirmed_save()); if let Some((updated, desktop)) = confirmed { - self.apply_instance_settings(*updated, desktop); + self.apply_instance_settings(*updated, desktop, true); } self.focused } - Some(confirm_popup::ConfirmTarget::DiscardInstanceSettings) => { - self.instance_settings = None; - self.pre_overlay_focused - } - Some(confirm_popup::ConfirmTarget::DiscardLauncherSettings) => { - self.global_settings = None; - self.pre_overlay_focused - } None => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -296,13 +288,12 @@ impl App { Some(confirm_popup::ConfirmTarget::ConfigProfile { .. }) => { FocusedArea::Settings } - Some(confirm_popup::ConfirmTarget::InstanceRuntime { .. }) - | Some(confirm_popup::ConfirmTarget::DiscardInstanceSettings) => { + Some(confirm_popup::ConfirmTarget::InstanceRuntime { .. }) => { + if let Some(state) = self.instance_settings.as_mut() { + state.cancel_runtime_change(); + } FocusedArea::InstanceSettings } - Some(confirm_popup::ConfirmTarget::DiscardLauncherSettings) => { - FocusedArea::GlobalSettings - } _ => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -675,12 +666,6 @@ impl App { self.global_settings = None; self.focused = self.pre_overlay_focused; } - widgets::popups::global_settings::Action::ConfirmClose => { - confirm_popup::set_pending( - confirm_popup::ConfirmTarget::DiscardLauncherSettings, - ); - self.focused = FocusedArea::ConfirmDelete; - } widgets::popups::global_settings::Action::OpenRaw(path) => { self.pending_editor = Some(path); self.global_settings = None; @@ -692,8 +677,7 @@ impl App { .and_then(|()| crate::config::theme::apply_theme(theme, border)); match result { Ok(()) => { - self.global_settings = None; - self.focused = self.pre_overlay_focused; + crate::feedback::request_redraw(); } Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { id: 0, @@ -732,7 +716,7 @@ impl App { self.focused = self.pre_overlay_focused; } widgets::popups::instance_settings::Action::Save(updated, desktop) => { - self.apply_instance_settings(*updated, desktop); + self.apply_instance_settings(*updated, desktop, false); } widgets::popups::instance_settings::Action::ConfirmRuntime { name, from, to } => { confirm_popup::set_pending(confirm_popup::ConfirmTarget::InstanceRuntime { @@ -742,12 +726,6 @@ impl App { }); self.focused = FocusedArea::ConfirmDelete; } - widgets::popups::instance_settings::Action::ConfirmClose => { - confirm_popup::set_pending( - confirm_popup::ConfirmTarget::DiscardInstanceSettings, - ); - self.focused = FocusedArea::ConfirmDelete; - } } return Ok(()); } @@ -2050,6 +2028,7 @@ impl App { &mut self, updated: crate::instance::models::InstanceConfig, desktop: bool, + close: bool, ) { let Some(previous) = self.instances_state.selected_instance().cloned() else { return; @@ -2068,8 +2047,10 @@ impl App { return; } self.spawn_instance_settings_update(previous, updated, desktop); - self.instance_settings = None; - self.focused = self.pre_overlay_focused; + if close { + self.instance_settings = None; + self.focused = self.pre_overlay_focused; + } return; } @@ -2089,15 +2070,14 @@ impl App { }); } self.instances_state - .replace_instance(&previous.name, updated); - error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::INFO, - message: format!("Updated instance '{}'", previous.name), - pushed_at: std::time::Instant::now(), - }); - self.instance_settings = None; - self.focused = self.pre_overlay_focused; + .replace_instance(&previous.name, updated.clone()); + if let Some(state) = self.instance_settings.as_mut() { + state.mark_saved(&updated, desktop); + } + if close { + self.instance_settings = None; + self.focused = self.pre_overlay_focused; + } } Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { id: 0, diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index c12db6e..bb27a7c 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -607,11 +607,12 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { ui.draw(); assert!(ui.screen().contains("-Xfoo")); ui.key(KeyCode::Enter); + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); + assert_eq!( + ui.app.instances_state.selected_instance().unwrap().jvm_args, + ["-Xfoo"] + ); ui.key(KeyCode::Esc); - assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); - ui.draw(); - assert!(ui.screen().contains("Discard changes")); - ui.key(KeyCode::Enter); assert_eq!(ui.app.focused, FocusedArea::Settings); ui.key(KeyCode::Char('g')); @@ -644,7 +645,10 @@ fn runtime_settings_use_the_shared_confirmation_popup() { .draft .game_version = "1.21.2".to_owned(); - ui.key(KeyCode::Char('s')); + for _ in 0..4 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Char('l')); assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); assert!(matches!( @@ -653,17 +657,12 @@ fn runtime_settings_use_the_shared_confirmation_popup() { )); ui.draw(); assert!(ui.screen().contains("Change runtime")); - assert!(ui.screen().contains("Runtime files will be downloaded")); + assert!(ui.screen().contains("downloaded before applying")); assert!(ui.screen().contains("Installed mods may not load")); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); ui.key(KeyCode::Esc); - assert!(matches!( - confirm::pending_target(), - Some(confirm::ConfirmTarget::DiscardInstanceSettings) - )); - ui.key(KeyCode::Enter); assert_eq!(ui.app.focused, FocusedArea::Settings); } @@ -698,6 +697,15 @@ fn settings_use_java_memory_and_resolution_controls() { .as_deref(), Some("1G") ); + assert_eq!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .memory_min + .as_deref(), + Some("1G") + ); for _ in 0..3 { ui.key(KeyCode::Char('j')); @@ -711,7 +719,6 @@ fn settings_use_java_memory_and_resolution_controls() { assert!(ui.screen().contains("custom")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); - ui.key(KeyCode::Enter); ui.key(KeyCode::Char('G')); for _ in 0..4 { diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 9aa042f..15adb23 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -50,8 +50,6 @@ pub enum ConfirmTarget { from: String, to: String, }, - DiscardInstanceSettings, - DiscardLauncherSettings, } impl ConfirmTarget { @@ -59,9 +57,6 @@ impl ConfirmTarget { match self { Self::OrphanDependencies { .. } => " Remove unused dependencies ".to_owned(), Self::InstanceRuntime { .. } => " Change runtime ".to_owned(), - Self::DiscardInstanceSettings | Self::DiscardLauncherSettings => { - " Discard changes ".to_owned() - } _ => format!(" Delete '{}' ", self.name()), } } @@ -101,14 +96,8 @@ impl ConfirmTarget { .collect::>() .join("\n"), ConfirmTarget::InstanceRuntime { from, to, .. } => format!( - "{from} → {to}\nRuntime files will be downloaded before saving.\n! Installed mods may not load and can be incompatible with the new runtime." + "{from} → {to}\nRuntime files will be downloaded before applying the change.\n! Installed mods may not load and can be incompatible with the new runtime." ), - ConfirmTarget::DiscardInstanceSettings => { - "Unsaved instance settings will be lost.".to_owned() - } - ConfirmTarget::DiscardLauncherSettings => { - "Unsaved launcher settings will be lost.".to_owned() - } } } @@ -120,8 +109,6 @@ impl ConfirmTarget { ConfirmTarget::Content { name, .. } => name, ConfirmTarget::OrphanDependencies { .. } => "unused dependencies", ConfirmTarget::InstanceRuntime { name, .. } => name, - ConfirmTarget::DiscardInstanceSettings => "instance settings", - ConfirmTarget::DiscardLauncherSettings => "launcher settings", } } @@ -130,7 +117,6 @@ impl ConfirmTarget { Self::Content { dependents, .. } if !dependents.is_empty() => " delete anyway", Self::OrphanDependencies { .. } => " remove all", Self::InstanceRuntime { .. } => " change", - Self::DiscardInstanceSettings | Self::DiscardLauncherSettings => " discard", _ => " confirm", } } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 00cc7c7..2b12471 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -31,7 +31,7 @@ pub struct State { selected: usize, editing: Option>, error: Option, - config_dirty: bool, + save_pending: bool, themes: Vec, theme_picker: bool, theme_index: usize, @@ -42,7 +42,6 @@ pub struct State { pub enum Action { None, Save(Box, String, BorderStyle), - ConfirmClose, OpenRaw(std::path::PathBuf), Close, } @@ -61,7 +60,7 @@ impl State { selected: 0, editing: None, error: None, - config_dirty: false, + save_pending: false, themes, theme_picker: false, theme_index, @@ -119,7 +118,7 @@ impl State { if memory_kib(&value) > memory_kib(&self.config.defaults.memory_max) { self.config.defaults.memory_max = value; } - self.config_dirty = true; + self.save_pending = true; } 3 => { let value = normalize_memory_value(value).unwrap(); @@ -127,11 +126,11 @@ impl State { if memory_kib(&value) < memory_kib(&self.config.defaults.memory_min) { self.config.defaults.memory_min = value; } - self.config_dirty = true; + self.save_pending = true; } 4 => { self.config.paths.java_path = (!value.is_empty()).then(|| value.to_owned()); - self.config_dirty = true; + self.save_pending = true; } _ => {} } @@ -147,6 +146,8 @@ impl State { ) { self.error = Some(error.to_string()); self.theme.theme = previous; + } else if self.theme.theme != previous { + self.save_pending = true; } } @@ -184,6 +185,8 @@ impl State { ) { self.error = Some(error.to_string()); self.theme.border_style = previous; + } else if self.theme.border_style != previous { + self.save_pending = true; } } @@ -201,7 +204,7 @@ impl State { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.java_picker_open = false, KeyCode::Char('a') => { if self.config.paths.java_path.take().is_some() { - self.config_dirty = true; + self.save_pending = true; } self.java_picker_open = false; } @@ -220,7 +223,7 @@ impl State { JavaChoice::Installation(path) => { if self.config.paths.java_path.as_deref() != Some(&path) { self.config.paths.java_path = Some(path); - self.config_dirty = true; + self.save_pending = true; } } } @@ -248,21 +251,24 @@ impl State { self.config.defaults.memory_min = value; } } - self.config_dirty = true; + self.save_pending = true; self.error = None; } - fn validate_before_save(&mut self) -> bool { - self.error = None; - let min = memory_kib(&self.config.defaults.memory_min); - let max = memory_kib(&self.config.defaults.memory_max); - if min.zip(max).is_some_and(|(min, max)| min > max) { - self.error = Some("minimum memory cannot exceed maximum memory".to_owned()); + pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + let action = self.handle_key_inner(key); + if matches!(action, Action::None) && self.save_pending && self.editing.is_none() { + self.save_pending = false; + return Action::Save( + Box::new(self.config.clone()), + self.theme.theme.clone(), + self.theme.border_style.clone(), + ); } - self.error.is_none() + action } - pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + fn handle_key_inner(&mut self, key: &KeyEvent) -> Action { if self.java_picker_open { self.handle_java_picker_key(key); return Action::None; @@ -301,26 +307,12 @@ impl State { }, KeyCode::Char('a') if self.selected == 4 => { if self.config.paths.java_path.take().is_some() { - self.config_dirty = true; + self.save_pending = true; } } KeyCode::Char('c') if self.selected == 4 => { self.editing = Some(new_text_area(vec![self.value(4)])); } - KeyCode::Char('s') => { - if self.validate_before_save() { - return Action::Save( - Box::new(self.config.clone()), - self.theme.theme.clone(), - self.theme.border_style.clone(), - ); - } - } - KeyCode::Char('E') if self.config_dirty => { - self.error = Some( - "save or discard launcher defaults before opening the raw file".to_owned(), - ); - } KeyCode::Char('E') => { let file = if self.selected <= 1 { "theme.toml" @@ -329,7 +321,6 @@ impl State { }; return Action::OpenRaw(crate::config::get_config_path().join(file)); } - KeyCode::Esc if self.config_dirty => return Action::ConfirmClose, KeyCode::Esc => return Action::Close, _ => {} } @@ -413,25 +404,18 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { } else if state.theme_picker { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if matches!(state.selected, 2 | 3) { - super::keybind_line(&[ - ("h/l", " adjust"), - ("Enter", " exact"), - ("s", " save"), - ("Esc", " back"), - ]) + super::keybind_line(&[("h/l", " adjust"), ("Enter", " exact"), ("Esc", " back")]) } else if state.selected == 4 { super::keybind_line(&[ ("Enter", " runtimes"), ("a", " auto"), ("c", " custom"), - ("s", " save"), ("Esc", " back"), ]) } else { super::keybind_line(&[ ("j/k", ""), ("Enter", " edit"), - ("s", " save"), ("E", " raw"), ("Esc", " back"), ]) @@ -594,7 +578,10 @@ mod tests { let mut state = State::new(); state.selected = 2; let original = state.config.defaults.memory_min.clone(); - state.handle_key(&KeyEvent::from(KeyCode::Char('l'))); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('l'))), + Action::Save(..) + )); assert_ne!(state.config.defaults.memory_min, original); assert!(state.editing.is_none()); diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index ac1c33e..3cd5162 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -110,7 +110,6 @@ pub enum Action { from: String, to: String, }, - ConfirmClose, OpenRaw, Close, } @@ -617,6 +616,42 @@ impl State { } pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + let before = self.draft.clone(); + let desktop_before = self.desktop; + let action = self.handle_key_inner(key); + if !matches!(action, Action::None) + || before == self.draft && desktop_before == self.desktop + || !self.dirty() + { + return action; + } + if self.runtime_changed() { + if self.draft.loader != ModLoader::Vanilla + && self + .draft + .loader_version + .as_deref() + .is_none_or(str::is_empty) + { + return Action::None; + } + if !self.validate_before_save() { + return Action::None; + } + return Action::ConfirmRuntime { + name: self.draft.name.clone(), + from: runtime_label(&self.original), + to: runtime_label(&self.draft), + }; + } + if self.validate_before_save() { + Action::Save(Box::new(self.draft.clone()), self.desktop) + } else { + Action::None + } + } + + fn handle_key_inner(&mut self, key: &KeyEvent) -> Action { if self.choice_picker.is_some() { self.handle_choice_key(key); return Action::None; @@ -657,25 +692,7 @@ impl State { KeyCode::Char('c') if self.selected == 3 => { self.editing = Some(new_text_area(vec![self.value(3)])); } - KeyCode::Char('s') if self.dirty() => { - if self.validate_before_save() { - if self.runtime_changed() { - return Action::ConfirmRuntime { - name: self.draft.name.clone(), - from: runtime_label(&self.original), - to: runtime_label(&self.draft), - }; - } else { - return Action::Save(Box::new(self.draft.clone()), self.desktop); - } - } - } - KeyCode::Char('E') if self.dirty() => { - self.error = - Some("save or discard draft changes before opening the raw file".to_owned()); - } KeyCode::Char('E') => return Action::OpenRaw, - KeyCode::Esc if self.dirty() => return Action::ConfirmClose, KeyCode::Esc => return Action::Close, _ => {} } @@ -686,6 +703,21 @@ impl State { self.validate_before_save() .then(|| (Box::new(self.draft.clone()), self.desktop)) } + + pub fn mark_saved(&mut self, saved: &InstanceConfig, desktop: bool) { + self.original = saved.clone(); + self.draft = saved.clone(); + self.original_desktop = desktop; + self.desktop = desktop; + self.error = None; + } + + pub fn cancel_runtime_change(&mut self) { + self.draft.game_version = self.original.game_version.clone(); + self.draft.loader = self.original.loader; + self.draft.loader_version = self.original.loader_version.clone(); + self.error = None; + } } fn runtime_label(config: &InstanceConfig) -> String { @@ -816,7 +848,6 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("h/l", " adjust"), ("Enter", " exact"), ("d", " default"), - ("s", " save"), ("Esc", " back"), ]) } else if state.selected == 3 { @@ -824,7 +855,6 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("Enter", " runtimes"), ("a", " auto"), ("c", " custom"), - ("s", " save"), ("Esc", " back"), ]) } else if state.selected == 7 { @@ -832,14 +862,12 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("l", " presets"), ("Enter", " custom"), ("d", " default"), - ("s", " save"), ("Esc", " back"), ]) } else { super::keybind_line(&[ ("j/k", ""), ("Enter", " edit"), - ("s", " save"), ("E", " raw"), ("Esc", " back"), ]) @@ -974,20 +1002,15 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { } else { state.display_value(index) }; - let dirty = field_dirty(state, index); let mut spans = vec![ Span::styled( if selected { "▶ " } else { " " }, Style::default().fg(theme.accent()), ), Span::styled( - format!("{label:<16}"), + format!("{label:<18}"), Style::default().fg(theme.text_dim()), ), - Span::styled( - if dirty { "* " } else { " " }, - Style::default().fg(theme.accent()), - ), Span::styled( value, Style::default() @@ -1083,21 +1106,6 @@ fn jvm_row_count(state: &State, width: u16) -> usize { rows } -fn field_dirty(state: &State, index: usize) -> bool { - match index { - 0 => state.draft.game_version != state.original.game_version, - 1 => state.draft.loader != state.original.loader, - 2 => state.draft.loader_version != state.original.loader_version, - 3 => state.draft.java_path != state.original.java_path, - 4 => state.draft.memory_min != state.original.memory_min, - 5 => state.draft.memory_max != state.original.memory_max, - 6 => state.draft.jvm_args != state.original.jvm_args, - 7 => state.draft.resolution != state.original.resolution, - 8 => state.desktop != state.original_desktop, - _ => false, - } -} - fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); let mut list_area = area; @@ -1310,10 +1318,13 @@ mod tests { fn runtime_changes_require_confirmation_before_save() { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); - state.draft.game_version = "1.21.2".to_owned(); + *state.loader_versions.lock().unwrap() = LoadState::Loaded(vec!["0.16.15".to_owned()]); + state.picker = Some(VersionPicker::Loader); + state.picker_initialized = true; + state.picker_index = 0; assert!(matches!( - state.handle_key(&KeyEvent::from(KeyCode::Char('s'))), + state.handle_key(&KeyEvent::from(KeyCode::Enter)), Action::ConfirmRuntime { .. } )); assert!(state.confirmed_save().is_some()); @@ -1349,9 +1360,9 @@ mod tests { let mut config = instance(); config.config_sync_profile = Some("shared".to_owned()); let mut state = State::new(&config, temp.path()); - state.desktop = !state.desktop; + state.selected = 8; - let Action::Save(config, _) = state.handle_key(&KeyEvent::from(KeyCode::Char('s'))) else { + let Action::Save(config, _) = state.handle_key(&KeyEvent::from(KeyCode::Enter)) else { panic!("expected settings save"); }; From f97e3a3780dc1b5bf69ac37eeeb0f091ed5a7f0f Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 14:42:37 +0200 Subject: [PATCH 11/42] fix: align settings selection behavior --- src/tui/input.rs | 16 ++ src/tui/tests/flows.rs | 24 ++- src/tui/widgets/popups/global_settings.rs | 76 +++++-- src/tui/widgets/popups/instance_settings.rs | 224 +++++++++++++++----- 4 files changed, 264 insertions(+), 76 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 6a9c3cf..6934c25 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -662,6 +662,14 @@ impl App { .unwrap_or(widgets::popups::global_settings::Action::Close); match action { widgets::popups::global_settings::Action::None => {} + widgets::popups::global_settings::Action::Error(message) => { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message, + pushed_at: std::time::Instant::now(), + }); + } widgets::popups::global_settings::Action::Close => { self.global_settings = None; self.focused = self.pre_overlay_focused; @@ -699,6 +707,14 @@ impl App { .unwrap_or(widgets::popups::instance_settings::Action::Close); match action { widgets::popups::instance_settings::Action::None => {} + widgets::popups::instance_settings::Action::Error(message) => { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message, + pushed_at: std::time::Instant::now(), + }); + } widgets::popups::instance_settings::Action::Close => { self.instance_settings = None; self.focused = self.pre_overlay_focused; diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index bb27a7c..ddc86c9 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -666,6 +666,28 @@ fn runtime_settings_use_the_shared_confirmation_popup() { assert_eq!(ui.app.focused, FocusedArea::Settings); } +#[test] +fn instance_settings_validation_errors_use_the_toast_buffer() { + let mut ui = UiHarness::new(); + ui.add_instance("toast-test"); + ui.key(KeyCode::Char('E')); + let state = ui.app.instance_settings.as_mut().unwrap(); + state.draft.loader = crate::instance::ModLoader::Vanilla; + state.draft.loader_version = None; + + ui.key(KeyCode::Char('j')); + ui.key(KeyCode::Char('j')); + ui.key(KeyCode::Enter); + + assert!( + crate::feedback::errors::ERROR_EVENTS + .lock() + .unwrap() + .iter() + .any(|error| error.message == "Vanilla does not use a loader version") + ); +} + #[test] fn settings_use_java_memory_and_resolution_controls() { let mut ui = UiHarness::new(); @@ -710,7 +732,7 @@ fn settings_use_java_memory_and_resolution_controls() { for _ in 0..3 { ui.key(KeyCode::Char('j')); } - ui.key(KeyCode::Char('l')); + ui.key(KeyCode::Char('p')); ui.draw(); assert!(ui.screen().contains("Resolution")); assert!(ui.screen().contains("1920x1080")); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 2b12471..c1e5b76 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -42,6 +42,7 @@ pub struct State { pub enum Action { None, Save(Box, String, BorderStyle), + Error(String), OpenRaw(std::path::PathBuf), Close, } @@ -197,15 +198,23 @@ impl State { self.java_picker_open = true; } + fn toggle_auto_java(&mut self) { + self.config.paths.java_path = if self.config.paths.java_path.is_none() { + Some(self.java_picker.detected_path().to_owned()) + } else { + None + }; + self.save_pending = true; + self.error = None; + } + fn handle_java_picker_key(&mut self, key: &KeyEvent) { self.java_picker.initialize(); let count = self.java_picker.labels().len(); match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.java_picker_open = false, KeyCode::Char('a') => { - if self.config.paths.java_path.take().is_some() { - self.save_pending = true; - } + self.toggle_auto_java(); self.java_picker_open = false; } KeyCode::Char('c') => { @@ -257,7 +266,13 @@ impl State { pub fn handle_key(&mut self, key: &KeyEvent) -> Action { let action = self.handle_key_inner(key); - if matches!(action, Action::None) && self.save_pending && self.editing.is_none() { + if !matches!(action, Action::None) { + return action; + } + if let Some(error) = self.error.take() { + return Action::Error(error); + } + if self.save_pending && self.editing.is_none() { self.save_pending = false; return Action::Save( Box::new(self.config.clone()), @@ -265,7 +280,7 @@ impl State { self.theme.border_style.clone(), ); } - action + Action::None } fn handle_key_inner(&mut self, key: &KeyEvent) -> Action { @@ -306,9 +321,7 @@ impl State { field => self.editing = Some(new_text_area(vec![self.value(field)])), }, KeyCode::Char('a') if self.selected == 4 => { - if self.config.paths.java_path.take().is_some() { - self.save_pending = true; - } + self.toggle_auto_java(); } KeyCode::Char('c') if self.selected == 4 => { self.editing = Some(new_text_area(vec![self.value(4)])); @@ -377,7 +390,7 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { let height = if state.theme_picker || state.java_picker_open { (area.height * 2 / 3).max(10) } else { - 7 + u16::from(state.error.is_some()) + 7 }; let width = if state.java_picker_open { 72 } else { 52 }; area.centered( @@ -412,6 +425,20 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("c", " custom"), ("Esc", " back"), ]) + } else if state.selected == 0 { + super::keybind_line(&[ + ("j/k", ""), + ("Enter", " select"), + ("E", " raw"), + ("Esc", " back"), + ]) + } else if state.selected == 1 { + super::keybind_line(&[ + ("h/l", " adjust"), + ("Enter", " next"), + ("E", " raw"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -485,18 +512,11 @@ fn render_java_picker(frame: &mut Frame, area: Rect, state: &State) { } fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { - let theme = THEME.as_ref(); let labels = ["Theme", "Border style", "Memory min", "Memory max", "Java"]; let lines = labels .iter() .enumerate() .map(|(index, label)| global_field_line(state, index, label)) - .chain(state.error.iter().map(|error| { - Line::from(Span::styled( - format!(" {error}"), - Style::default().fg(theme.error()), - )) - })) .collect::>(); frame.render_widget(Paragraph::new(lines), area); @@ -533,7 +553,7 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> let editing = selected && state.editing.is_some(); let mut spans = vec![ Span::styled( - if selected { "▶ " } else { " " }, + if selected { "▌ " } else { " " }, Style::default().fg(theme.accent()), ), Span::styled( @@ -593,4 +613,26 @@ mod tests { state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert!(state.java_picker_open); } + + #[test] + fn java_auto_mode_toggles_to_and_from_the_detected_path() { + let mut state = State::new(); + state.selected = 4; + state.config.paths.java_path = None; + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), + Action::Save(..) + )); + assert_eq!( + state.config.paths.java_path.as_deref(), + Some(state.java_picker.detected_path()) + ); + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), + Action::Save(..) + )); + assert_eq!(state.config.paths.java_path, None); + } } diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 3cd5162..ede5494 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -105,6 +105,7 @@ pub struct State { pub enum Action { None, Save(Box, bool), + Error(String), ConfirmRuntime { name: String, from: String, @@ -204,16 +205,12 @@ impl State { match field { 2 if self.draft.loader == ModLoader::Vanilla => "not applicable".to_owned(), 3 if self.draft.java_path.is_none() => self.java_picker.detected_path().to_owned(), - 4 if self.draft.memory_min.is_none() => { - format!("default ({})", SETTINGS.read().defaults.memory_min) - } - 5 if self.draft.memory_max.is_none() => { - format!("default ({})", SETTINGS.read().defaults.memory_max) - } + 4 if self.draft.memory_min.is_none() => SETTINGS.read().defaults.memory_min.clone(), + 5 if self.draft.memory_max.is_none() => SETTINGS.read().defaults.memory_max.clone(), 6 if self.draft.jvm_args.is_empty() => "no arguments".to_owned(), 6 => self.draft.jvm_args.join(" "), 7 if self.draft.resolution.is_none() => self.default_resolution().map_or_else( - || "default".to_owned(), + || "not detected".to_owned(), |(width, height)| format!("{width}x{height}"), ), 8 if self.desktop => "enabled".to_owned(), @@ -290,7 +287,7 @@ impl State { match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.choice_picker = None, KeyCode::Char('a') if self.choice_picker == Some(ChoicePicker::Java) => { - self.draft.java_path = None; + self.toggle_auto_java(); self.choice_picker = None; } KeyCode::Char('c') if self.choice_picker == Some(ChoicePicker::Java) => { @@ -302,7 +299,7 @@ impl State { self.editing = Some(new_text_area(vec![self.value(7)])); } KeyCode::Char('d') if self.choice_picker == Some(ChoicePicker::Resolution) => { - self.draft.resolution = None; + self.apply_default_resolution(); self.choice_picker = None; } KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { @@ -320,6 +317,7 @@ impl State { } fn apply_choice(&mut self) { + let mut open_loader_versions = false; match self.choice_picker { Some(ChoicePicker::Loader) => { let available = super::select_list::MOD_LOADERS; @@ -329,6 +327,7 @@ impl State { self.draft.loader_version = None; self.loader_versions = Arc::new(Mutex::new(LoadState::Idle)); self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); + open_loader_versions = loader != ModLoader::Vanilla; } } Some(ChoicePicker::Java) => { @@ -350,6 +349,9 @@ impl State { None => {} } self.choice_picker = None; + if open_loader_versions { + self.open_loader_picker(); + } } fn open_game_picker(&mut self) { @@ -392,16 +394,20 @@ impl State { let target = self.loader_versions.clone(); let loader = self.draft.loader; let game_version = self.draft.game_version.clone(); - tokio::spawn(async move { - let result = super::version_lists::loader_versions(loader, &game_version).await; - *target - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = match result { - Ok(versions) => LoadState::Loaded(versions), - Err(error) => LoadState::Error(error), - }; - crate::feedback::request_redraw(); - }); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let result = super::version_lists::loader_versions(loader, &game_version).await; + *target + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = match result { + Ok(versions) => LoadState::Loaded(versions), + Err(error) => LoadState::Error(error), + }; + crate::feedback::request_redraw(); + }); + } else { + *load = LoadState::Idle; + } } } @@ -512,6 +518,34 @@ impl State { .map(|display| (display.width, display.height)) } + fn apply_default_memory(&mut self) { + let settings = SETTINGS.read(); + if self.selected == 4 { + self.draft.memory_min = Some(settings.defaults.memory_min.clone()); + } else { + self.draft.memory_max = Some(settings.defaults.memory_max.clone()); + } + self.error = None; + } + + fn apply_default_resolution(&mut self) { + if let Some(resolution) = self.default_resolution() { + self.draft.resolution = Some(resolution); + self.error = None; + } else { + self.error = Some("could not detect a default display resolution".to_owned()); + } + } + + fn toggle_auto_java(&mut self) { + self.draft.java_path = if self.draft.java_path.is_none() { + Some(self.java_picker.detected_path().to_owned()) + } else { + None + }; + self.error = None; + } + fn handle_picker_key(&mut self, key: &KeyEvent) { self.initialize_picker_index(); if self.picker_search.active { @@ -565,8 +599,13 @@ impl State { self.draft.game_version = version.id.clone(); self.draft.loader_version = None; self.loader_versions = Arc::new(Mutex::new(LoadState::Idle)); + self.picker = None; + if self.draft.loader != ModLoader::Vanilla { + self.open_loader_picker(); + } + } else { + self.picker = None; } - self.picker = None; } } Some(VersionPicker::Loader) => { @@ -619,12 +658,15 @@ impl State { let before = self.draft.clone(); let desktop_before = self.desktop; let action = self.handle_key_inner(key); - if !matches!(action, Action::None) - || before == self.draft && desktop_before == self.desktop - || !self.dirty() - { + if !matches!(action, Action::None) { return action; } + if let Some(error) = self.error.take() { + return Action::Error(error); + } + if before == self.draft && desktop_before == self.desktop || !self.dirty() { + return Action::None; + } if self.runtime_changed() { if self.draft.loader != ModLoader::Vanilla && self @@ -636,7 +678,11 @@ impl State { return Action::None; } if !self.validate_before_save() { - return Action::None; + return Action::Error( + self.error + .take() + .unwrap_or_else(|| "invalid instance settings".to_owned()), + ); } return Action::ConfirmRuntime { name: self.draft.name.clone(), @@ -647,7 +693,11 @@ impl State { if self.validate_before_save() { Action::Save(Box::new(self.draft.clone()), self.desktop) } else { - Action::None + Action::Error( + self.error + .take() + .unwrap_or_else(|| "invalid instance settings".to_owned()), + ) } } @@ -680,15 +730,15 @@ impl State { KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 4 | 5) => { self.adjust_selected_memory(true); } - KeyCode::Char('l') | KeyCode::Right if self.selected == 7 => { + KeyCode::Char('p') if self.selected == 7 => { self.open_choice_picker(ChoicePicker::Resolution); } KeyCode::Enter => self.begin_edit(), KeyCode::Char('d') if matches!(self.selected, 4 | 5) => { - self.set_memory(self.selected, None); + self.apply_default_memory(); } - KeyCode::Char('d') if self.selected == 7 => self.draft.resolution = None, - KeyCode::Char('a') if self.selected == 3 => self.draft.java_path = None, + KeyCode::Char('d') if self.selected == 7 => self.apply_default_resolution(), + KeyCode::Char('a') if self.selected == 3 => self.toggle_auto_java(), KeyCode::Char('c') if self.selected == 3 => { self.editing = Some(new_text_area(vec![self.value(3)])); } @@ -788,7 +838,6 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { let form_width = (area.width * 58 / 100).saturating_sub(2); 11 + jvm_row_count(state, form_width).saturating_sub(1) as u16 + u16::from(state.runtime_changed()) - + u16::from(state.error.is_some()) }; let width = match state.choice_picker { Some(ChoicePicker::Java) => 72, @@ -859,11 +908,25 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ]) } else if state.selected == 7 { super::keybind_line(&[ - ("l", " presets"), + ("p", " presets"), ("Enter", " custom"), ("d", " default"), ("Esc", " back"), ]) + } else if matches!(state.selected, 0..=2) { + super::keybind_line(&[ + ("j/k", ""), + ("Enter", " select"), + ("E", " raw"), + ("Esc", " back"), + ]) + } else if state.selected == 8 { + super::keybind_line(&[ + ("j/k", ""), + ("Enter", " toggle"), + ("E", " raw"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -973,23 +1036,6 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { ..area }, ); - y = y.saturating_add(1); - } - - if let Some(error) = &state.error - && y < area.bottom() - { - frame.render_widget( - Paragraph::new(Span::styled( - format!(" {error}"), - Style::default().fg(theme.error()), - )), - Rect { - y, - height: 1, - ..area - }, - ); } } @@ -1004,7 +1050,7 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { }; let mut spans = vec![ Span::styled( - if selected { "▶ " } else { " " }, + if selected { "▌ " } else { " " }, Style::default().fg(theme.accent()), ), Span::styled( @@ -1030,11 +1076,6 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { ]; if index == 3 && state.draft.java_path.is_none() && !editing { spans.extend([Span::raw(" "), subtle_tag("Auto", theme.info())]); - } else if index == 7 && state.draft.resolution.is_none() && !editing { - spans.push(Span::styled( - " default", - Style::default().fg(theme.text_dim()), - )); } Line::from(spans) } @@ -1383,6 +1424,44 @@ mod tests { assert!(!state.validate_before_save()); } + #[test] + fn loader_change_selects_a_version_then_requests_confirmation() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.open_choice_picker(ChoicePicker::Loader); + state.handle_key(&KeyEvent::from(KeyCode::Char('j'))); + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::None + )); + assert_eq!(state.picker, Some(VersionPicker::Loader)); + + *state.loader_versions.lock().unwrap() = LoadState::Loaded(vec!["1.0.0".to_owned()]); + state.picker_initialized = true; + state.picker_index = 0; + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::ConfirmRuntime { .. } + )); + } + + #[test] + fn invalid_field_action_is_returned_for_toast_display() { + let temp = tempfile::tempdir().unwrap(); + let mut config = instance(); + config.loader = ModLoader::Vanilla; + config.loader_version = None; + let mut state = State::new(&config, temp.path()); + state.selected = 2; + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Error(message) if message == "Vanilla does not use a loader version" + )); + assert!(state.error.is_none()); + } + #[test] fn loader_picker_and_desktop_toggle_use_enter() { let temp = tempfile::tempdir().unwrap(); @@ -1393,7 +1472,9 @@ mod tests { state.handle_key(&KeyEvent::from(KeyCode::Char('j'))); state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert_eq!(state.draft.loader, ModLoader::Forge); + assert_eq!(state.picker, Some(VersionPicker::Loader)); + state.picker = None; state.selected = 8; let desktop = state.desktop; state.handle_key(&KeyEvent::from(KeyCode::Enter)); @@ -1428,7 +1509,7 @@ mod tests { state.begin_edit(); assert!(state.editing.is_some()); state.editing = None; - state.handle_key(&KeyEvent::from(KeyCode::Char('l'))); + state.handle_key(&KeyEvent::from(KeyCode::Char('p'))); assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); state.choice_index = state .resolution_choices() @@ -1454,16 +1535,43 @@ mod tests { state.draft.java_path = Some("/custom/java".to_owned()); state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); assert_eq!(state.draft.java_path, None); + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); + assert_eq!( + state.draft.java_path.as_deref(), + Some(state.java_picker.detected_path()) + ); state.selected = 7; + state.display_resolutions = vec![DisplayResolution { + width: 2560, + height: 1440, + name: "DP-4".to_owned(), + primary: true, + }]; state.draft.resolution = Some((1920, 1080)); state.handle_key(&KeyEvent::from(KeyCode::Char('d'))); - assert_eq!(state.draft.resolution, None); - state.handle_key(&KeyEvent::from(KeyCode::Char('l'))); + assert_eq!(state.draft.resolution, Some((2560, 1440))); + state.handle_key(&KeyEvent::from(KeyCode::Char('p'))); assert!(!state.choice_values().iter().any(|value| value == "Default")); assert!(!state.choice_values().iter().any(|value| value == "Custom…")); } + #[test] + fn memory_default_copies_only_the_selected_launcher_value() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.draft.memory_min = Some("6G".to_owned()); + state.draft.memory_max = Some("42G".to_owned()); + state.selected = 4; + let expected = SETTINGS.read().defaults.memory_min.clone(); + + state.handle_key(&KeyEvent::from(KeyCode::Char('d'))); + + assert_eq!(state.draft.memory_min.as_deref(), Some(expected.as_str())); + assert_eq!(state.draft.memory_max.as_deref(), Some("42G")); + assert!(!state.display_value(4).contains("default")); + } + #[test] fn memory_slider_keeps_bounds_linked_and_desktop_has_no_dot() { let temp = tempfile::tempdir().unwrap(); From 8739ab2421ece9c166c1805199041d85ea2df7e4 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 15:01:47 +0200 Subject: [PATCH 12/42] fix: simplify runtime change flow --- src/tui/input.rs | 3 +- src/tui/tests/flows.rs | 7 +- src/tui/widgets/popups/confirm.rs | 15 ++-- src/tui/widgets/popups/global_settings.rs | 2 +- src/tui/widgets/popups/instance_settings.rs | 86 ++++++++++++--------- 5 files changed, 67 insertions(+), 46 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 6934c25..2d3cda5 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -734,10 +734,9 @@ impl App { widgets::popups::instance_settings::Action::Save(updated, desktop) => { self.apply_instance_settings(*updated, desktop, false); } - widgets::popups::instance_settings::Action::ConfirmRuntime { name, from, to } => { + widgets::popups::instance_settings::Action::ConfirmRuntime { name, to } => { confirm_popup::set_pending(confirm_popup::ConfirmTarget::InstanceRuntime { name, - from, to, }); self.focused = FocusedArea::ConfirmDelete; diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index ddc86c9..58b54b7 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -657,8 +657,11 @@ fn runtime_settings_use_the_shared_confirmation_popup() { )); ui.draw(); assert!(ui.screen().contains("Change runtime")); - assert!(ui.screen().contains("downloaded before applying")); - assert!(ui.screen().contains("Installed mods may not load")); + assert!(ui.screen().contains("Target:")); + assert!( + ui.screen() + .contains("Some installed mods may be incompatible") + ); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 15adb23..dfa1d5f 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -47,7 +47,6 @@ pub enum ConfirmTarget { }, InstanceRuntime { name: String, - from: String, to: String, }, } @@ -95,9 +94,9 @@ impl ConfirmTarget { }) .collect::>() .join("\n"), - ConfirmTarget::InstanceRuntime { from, to, .. } => format!( - "{from} → {to}\nRuntime files will be downloaded before applying the change.\n! Installed mods may not load and can be incompatible with the new runtime." - ), + ConfirmTarget::InstanceRuntime { to, .. } => { + format!("Target: {to}\nSome installed mods may be incompatible.") + } } } @@ -183,6 +182,7 @@ pub struct ConfirmPopup { title: String, body: String, confirm_label: &'static str, + accent_border: bool, } impl ConfirmPopup { @@ -191,6 +191,7 @@ impl ConfirmPopup { title: target.title(), body: target.body(), confirm_label: target.confirm_label(), + accent_border: matches!(target, ConfirmTarget::InstanceRuntime { .. }), } } } @@ -208,7 +209,11 @@ impl Widget for ConfirmPopup { )]); let kb = keybind_line(&[("Esc", " cancel"), ("Enter", self.confirm_label)]); - let border_color = theme.text_dim(); + let border_color = if self.accent_border { + theme.accent() + } else { + theme.text_dim() + }; let bg_color = theme.surface(); let accent = theme.accent(); let text = theme.text(); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index c1e5b76..35f2f5c 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -111,7 +111,7 @@ impl State { match self.selected { 2 | 3 if normalize_memory_value(value).is_none() => invalid( self, - "memory must be a positive number with K, M, or G".to_owned(), + "Use a positive memory value ending in K, M, or G.".to_owned(), ), 2 => { let value = normalize_memory_value(value).unwrap(); diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index ede5494..bd81fa4 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -106,11 +106,7 @@ pub enum Action { None, Save(Box, bool), Error(String), - ConfirmRuntime { - name: String, - from: String, - to: String, - }, + ConfirmRuntime { name: String, to: String }, OpenRaw, Close, } @@ -153,7 +149,7 @@ impl State { self.error = None; let settings = SETTINGS.read(); if self.draft.game_version.trim().is_empty() { - self.error = Some("game version cannot be empty".to_owned()); + self.error = Some("Game version is required.".to_owned()); } else if self.draft.loader != ModLoader::Vanilla && self .draft @@ -161,7 +157,7 @@ impl State { .as_deref() .is_none_or(str::is_empty) { - self.error = Some("the selected loader requires a loader version".to_owned()); + self.error = Some("Select a loader version.".to_owned()); } else if let (Some(min), Some(max)) = ( memory_kib( self.draft @@ -177,7 +173,7 @@ impl State { ), ) && min > max { - self.error = Some("minimum memory cannot exceed maximum memory".to_owned()); + self.error = Some("Minimum memory cannot exceed maximum memory.".to_owned()); } self.error.is_none() } @@ -533,7 +529,7 @@ impl State { self.draft.resolution = Some(resolution); self.error = None; } else { - self.error = Some("could not detect a default display resolution".to_owned()); + self.error = Some("Display resolution could not be detected.".to_owned()); } } @@ -546,6 +542,17 @@ impl State { self.error = None; } + fn close_version_picker(&mut self) { + let cancel_runtime_change = self.picker == Some(VersionPicker::Loader) + && self.runtime_changed() + && self.draft.loader_version.is_none(); + self.picker = None; + self.picker_search.deactivate(); + if cancel_runtime_change { + self.cancel_runtime_change(); + } + } + fn handle_picker_key(&mut self, key: &KeyEvent) { self.initialize_picker_index(); if self.picker_search.active { @@ -574,8 +581,10 @@ impl State { None => 0, }; match key.code { - KeyCode::Esc => self.picker = None, - KeyCode::Char('h') | KeyCode::Left if !self.picker_search.active => self.picker = None, + KeyCode::Esc => self.close_version_picker(), + KeyCode::Char('h') | KeyCode::Left if !self.picker_search.active => { + self.close_version_picker(); + } KeyCode::Char('/') if !self.picker_search.active => { self.picker_search.activate(); self.picker_index = 0; @@ -632,13 +641,13 @@ impl State { state.editing = Some(new_text_area(editor.lines().to_vec())); }; match self.selected { - 0 if value.is_empty() => invalid(self, "game version cannot be empty".to_owned()), + 0 if value.is_empty() => invalid(self, "Game version is required.".to_owned()), 0 => self.draft.game_version = value.to_owned(), 2 => self.draft.loader_version = (!value.is_empty()).then(|| value.to_owned()), 3 => self.draft.java_path = (!value.is_empty()).then(|| value.to_owned()), 4 | 5 if !value.is_empty() && normalize_memory_value(value).is_none() => invalid( self, - "memory must be a positive number with K, M, or G".to_owned(), + "Use a positive memory value ending in K, M, or G.".to_owned(), ), 4 => self.set_memory(4, normalize_memory_value(value)), 5 => self.set_memory(5, normalize_memory_value(value)), @@ -686,7 +695,6 @@ impl State { } return Action::ConfirmRuntime { name: self.draft.name.clone(), - from: runtime_label(&self.original), to: runtime_label(&self.draft), }; } @@ -766,6 +774,9 @@ impl State { self.draft.game_version = self.original.game_version.clone(); self.draft.loader = self.original.loader; self.draft.loader_version = self.original.loader_version.clone(); + self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); + self.loader_versions = Arc::new(Mutex::new(LoadState::Idle)); + self.picker_initialized = false; self.error = None; } } @@ -837,7 +848,6 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { } else { let form_width = (area.width * 58 / 100).saturating_sub(2); 11 + jvm_row_count(state, form_width).saturating_sub(1) as u16 - + u16::from(state.runtime_changed()) }; let width = match state.choice_picker { Some(ChoicePicker::Java) => 72, @@ -1023,20 +1033,6 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { } y = y.saturating_add(height); } - - if state.runtime_changed() && y < area.bottom() { - frame.render_widget( - Paragraph::new(Span::styled( - " ! Runtime change may break installed mods.", - Style::default().fg(theme.warning()), - )), - Rect { - y, - height: 1, - ..area - }, - ); - } } fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { @@ -1625,7 +1621,7 @@ mod tests { } #[test] - fn runtime_changes_show_an_inline_compatibility_warning() { + fn runtime_changes_do_not_render_an_inline_notification() { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); state.draft.game_version = "1.21.2".to_owned(); @@ -1639,12 +1635,30 @@ mod tests { }) .unwrap(); - assert!( - terminal - .backend() - .to_string() - .contains("Runtime change may break installed mods") - ); + assert!(!terminal.backend().to_string().contains("installed mods")); + } + + #[test] + fn cancelling_chained_loader_version_restores_the_original_runtime() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + let original = state.original.clone(); + *state.game_versions.lock().unwrap() = LoadState::Loaded(vec![GameVersion { + id: "1.21.2".to_owned(), + stable: true, + }]); + state.picker = Some(VersionPicker::Game); + state.picker_initialized = true; + + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.picker, Some(VersionPicker::Loader)); + assert_eq!(state.draft.loader_version, None); + + state.handle_key(&KeyEvent::from(KeyCode::Esc)); + assert_eq!(state.draft.game_version, original.game_version); + assert_eq!(state.draft.loader, original.loader); + assert_eq!(state.draft.loader_version, original.loader_version); + assert_eq!(state.picker, None); } #[test] From 5f99de36ba8164d953212878465ebfe39515a324 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 15:09:18 +0200 Subject: [PATCH 13/42] fix: refine runtime and resolution controls --- src/tui/tests/flows.rs | 5 ++-- src/tui/widgets/popups/confirm.rs | 8 ++++- src/tui/widgets/popups/instance_settings.rs | 33 ++++++++------------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 58b54b7..c7b0b50 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -662,6 +662,7 @@ fn runtime_settings_use_the_shared_confirmation_popup() { ui.screen() .contains("Some installed mods may be incompatible") ); + assert!(!ui.screen().contains("incompatible.")); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); @@ -735,13 +736,13 @@ fn settings_use_java_memory_and_resolution_controls() { for _ in 0..3 { ui.key(KeyCode::Char('j')); } - ui.key(KeyCode::Char('p')); + ui.key(KeyCode::Enter); ui.draw(); assert!(ui.screen().contains("Resolution")); assert!(ui.screen().contains("1920x1080")); assert!(!ui.screen().contains("Preset")); assert!(!ui.screen().contains("Inherit")); - assert!(ui.screen().contains("custom")); + assert!(!ui.screen().contains("custom")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index dfa1d5f..4a74e1e 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -95,7 +95,7 @@ impl ConfirmTarget { .collect::>() .join("\n"), ConfirmTarget::InstanceRuntime { to, .. } => { - format!("Target: {to}\nSome installed mods may be incompatible.") + format!("Target: {to}\nSome installed mods may be incompatible") } } } @@ -183,6 +183,7 @@ pub struct ConfirmPopup { body: String, confirm_label: &'static str, accent_border: bool, + runtime_confirmation: bool, } impl ConfirmPopup { @@ -192,6 +193,7 @@ impl ConfirmPopup { body: target.body(), confirm_label: target.confirm_label(), accent_border: matches!(target, ConfirmTarget::InstanceRuntime { .. }), + runtime_confirmation: matches!(target, ConfirmTarget::InstanceRuntime { .. }), } } } @@ -219,7 +221,9 @@ impl Widget for ConfirmPopup { let text = theme.text(); let text_dim = theme.text_dim(); let error = theme.error(); + let warning = theme.warning(); let body = self.body; + let runtime_confirmation = self.runtime_confirmation; let styled_list = body.contains("• "); let popup = PopupFrame { title, @@ -236,6 +240,8 @@ impl Widget for ConfirmPopup { Span::styled("• ", Style::default().fg(accent)), Span::styled(value.to_owned(), Style::default().fg(text)), ]) + } else if runtime_confirmation && line.starts_with("Some installed mods") { + Line::styled(line.to_owned(), Style::default().fg(warning)) } else if line.starts_with('!') { Line::styled(line.to_owned(), Style::default().fg(error)) } else { diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index bd81fa4..02411d9 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -228,7 +228,7 @@ impl State { self.editing = Some(new_text_area(vec![self.effective_memory(self.selected)])); } 6 => self.editing = Some(new_text_area(vec![self.value(self.selected)])), - 7 => self.editing = Some(new_text_area(vec![self.value(7)])), + 7 => self.open_choice_picker(ChoicePicker::Resolution), 8 => self.desktop = !self.desktop, field => self.editing = Some(new_text_area(vec![self.value(field)])), } @@ -290,10 +290,6 @@ impl State { self.choice_picker = None; self.editing = Some(new_text_area(vec![self.value(3)])); } - KeyCode::Char('c') if self.choice_picker == Some(ChoicePicker::Resolution) => { - self.choice_picker = None; - self.editing = Some(new_text_area(vec![self.value(7)])); - } KeyCode::Char('d') if self.choice_picker == Some(ChoicePicker::Resolution) => { self.apply_default_resolution(); self.choice_picker = None; @@ -738,14 +734,14 @@ impl State { KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 4 | 5) => { self.adjust_selected_memory(true); } - KeyCode::Char('p') if self.selected == 7 => { - self.open_choice_picker(ChoicePicker::Resolution); - } KeyCode::Enter => self.begin_edit(), KeyCode::Char('d') if matches!(self.selected, 4 | 5) => { self.apply_default_memory(); } KeyCode::Char('d') if self.selected == 7 => self.apply_default_resolution(), + KeyCode::Char('c') if self.selected == 7 => { + self.editing = Some(new_text_area(vec![self.value(7)])); + } KeyCode::Char('a') if self.selected == 3 => self.toggle_auto_java(), KeyCode::Char('c') if self.selected == 3 => { self.editing = Some(new_text_area(vec![self.value(3)])); @@ -894,12 +890,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("Enter", " select"), ]) } else if state.choice_picker == Some(ChoicePicker::Resolution) { - super::keybind_line(&[ - ("d", " default"), - ("c", " custom"), - ("h", " back"), - ("Enter", " select"), - ]) + super::keybind_line(&[("d", " default"), ("h", " back"), ("Enter", " select")]) } else if state.choice_picker.is_some() { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if matches!(state.selected, 4 | 5) { @@ -918,8 +909,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ]) } else if state.selected == 7 { super::keybind_line(&[ - ("p", " presets"), - ("Enter", " custom"), + ("Enter", " presets"), + ("c", " custom"), ("d", " default"), ("Esc", " back"), ]) @@ -1503,9 +1494,7 @@ mod tests { state.selected = 7; state.begin_edit(); - assert!(state.editing.is_some()); - state.editing = None; - state.handle_key(&KeyEvent::from(KeyCode::Char('p'))); + assert!(state.editing.is_none()); assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); state.choice_index = state .resolution_choices() @@ -1547,9 +1536,13 @@ mod tests { state.draft.resolution = Some((1920, 1080)); state.handle_key(&KeyEvent::from(KeyCode::Char('d'))); assert_eq!(state.draft.resolution, Some((2560, 1440))); - state.handle_key(&KeyEvent::from(KeyCode::Char('p'))); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert!(!state.choice_values().iter().any(|value| value == "Default")); assert!(!state.choice_values().iter().any(|value| value == "Custom…")); + + state.handle_key(&KeyEvent::from(KeyCode::Esc)); + state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); + assert!(state.editing.is_some()); } #[test] From 45d9b85abfca081d9ae859121bfa7167c824cf87 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 15:22:52 +0200 Subject: [PATCH 14/42] fix: normalize settings popup styling --- src/tui/input.rs | 3 +- src/tui/tests/flows.rs | 4 +- src/tui/widgets/popups/confirm.rs | 13 ++---- src/tui/widgets/popups/global_settings.rs | 17 ++------ src/tui/widgets/popups/instance_settings.rs | 45 +++++++-------------- src/tui/widgets/popups/settings_controls.rs | 12 +++--- 6 files changed, 32 insertions(+), 62 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index 2d3cda5..18eeca5 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -734,10 +734,9 @@ impl App { widgets::popups::instance_settings::Action::Save(updated, desktop) => { self.apply_instance_settings(*updated, desktop, false); } - widgets::popups::instance_settings::Action::ConfirmRuntime { name, to } => { + widgets::popups::instance_settings::Action::ConfirmRuntime { name } => { confirm_popup::set_pending(confirm_popup::ConfirmTarget::InstanceRuntime { name, - to, }); self.focused = FocusedArea::ConfirmDelete; } diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index c7b0b50..d6066f4 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -657,7 +657,7 @@ fn runtime_settings_use_the_shared_confirmation_popup() { )); ui.draw(); assert!(ui.screen().contains("Change runtime")); - assert!(ui.screen().contains("Target:")); + assert!(!ui.screen().contains("Target:")); assert!( ui.screen() .contains("Some installed mods may be incompatible") @@ -705,7 +705,7 @@ fn settings_use_java_memory_and_resolution_controls() { ui.draw(); assert!(ui.screen().contains("Java Runtime")); assert!(ui.screen().contains("auto")); - assert!(ui.screen().contains("custom")); + assert!(!ui.screen().contains("custom")); assert!(!ui.screen().contains("Automatic")); assert!(!ui.screen().contains("Custom path")); assert!(!ui.screen().contains("Manual")); diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 4a74e1e..015f58a 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -47,7 +47,6 @@ pub enum ConfirmTarget { }, InstanceRuntime { name: String, - to: String, }, } @@ -94,8 +93,8 @@ impl ConfirmTarget { }) .collect::>() .join("\n"), - ConfirmTarget::InstanceRuntime { to, .. } => { - format!("Target: {to}\nSome installed mods may be incompatible") + ConfirmTarget::InstanceRuntime { .. } => { + "Some installed mods may be incompatible".to_owned() } } } @@ -182,7 +181,6 @@ pub struct ConfirmPopup { title: String, body: String, confirm_label: &'static str, - accent_border: bool, runtime_confirmation: bool, } @@ -192,7 +190,6 @@ impl ConfirmPopup { title: target.title(), body: target.body(), confirm_label: target.confirm_label(), - accent_border: matches!(target, ConfirmTarget::InstanceRuntime { .. }), runtime_confirmation: matches!(target, ConfirmTarget::InstanceRuntime { .. }), } } @@ -211,11 +208,7 @@ impl Widget for ConfirmPopup { )]); let kb = keybind_line(&[("Esc", " cancel"), ("Enter", self.confirm_label)]); - let border_color = if self.accent_border { - theme.accent() - } else { - theme.text_dim() - }; + let border_color = theme.text_dim(); let bg_color = theme.surface(); let accent = theme.accent(); let text = theme.text(); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 35f2f5c..6f70208 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -20,8 +20,8 @@ use crate::{ }, instance::models::normalize_memory_value, tui::widgets::popups::settings_controls::{ - JavaChoice, JavaPicker, adjust_memory, handle_text_area_input, memory_kib, - render_memory_gauge, subtle_tag, + JavaChoice, JavaPicker, adjust_memory, auto_label, handle_text_area_input, memory_kib, + render_memory_gauge, }, }; @@ -217,10 +217,6 @@ impl State { self.toggle_auto_java(); self.java_picker_open = false; } - KeyCode::Char('c') => { - self.java_picker_open = false; - self.editing = Some(new_text_area(vec![self.value(4)])); - } KeyCode::Char('j') | KeyCode::Down if count > 0 => { self.java_picker.selected = (self.java_picker.selected + 1).min(count - 1); } @@ -408,12 +404,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { let keybinds = if state.editing.is_some() { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) } else if state.java_picker_open { - super::keybind_line(&[ - ("a", " auto"), - ("c", " custom"), - ("h", " back"), - ("Enter", " select"), - ]) + super::keybind_line(&[("a", " auto"), ("h", " back"), ("Enter", " select")]) } else if state.theme_picker { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if matches!(state.selected, 2 | 3) { @@ -580,7 +571,7 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> ), ]; if index == 4 && state.config.paths.java_path.is_none() && !editing { - spans.extend([Span::raw(" "), subtle_tag("Auto", theme.info())]); + spans.extend([Span::raw(" "), auto_label()]); } Line::from(spans).style(Style::default().bg(if selected { theme.stripe() diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 02411d9..55af6b6 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -25,8 +25,8 @@ use crate::{ popups::{ LoadState, settings_controls::{ - DisplayResolution, JavaChoice, JavaPicker, adjust_memory, display_resolutions, - handle_text_area_input, memory_kib, render_memory_gauge, subtle_tag, + DisplayResolution, JavaChoice, JavaPicker, adjust_memory, auto_label, + display_resolutions, handle_text_area_input, memory_kib, render_memory_gauge, }, }, search::SearchState, @@ -106,7 +106,7 @@ pub enum Action { None, Save(Box, bool), Error(String), - ConfirmRuntime { name: String, to: String }, + ConfirmRuntime { name: String }, OpenRaw, Close, } @@ -286,10 +286,6 @@ impl State { self.toggle_auto_java(); self.choice_picker = None; } - KeyCode::Char('c') if self.choice_picker == Some(ChoicePicker::Java) => { - self.choice_picker = None; - self.editing = Some(new_text_area(vec![self.value(3)])); - } KeyCode::Char('d') if self.choice_picker == Some(ChoicePicker::Resolution) => { self.apply_default_resolution(); self.choice_picker = None; @@ -691,7 +687,6 @@ impl State { } return Action::ConfirmRuntime { name: self.draft.name.clone(), - to: runtime_label(&self.draft), }; } if self.validate_before_save() { @@ -777,19 +772,6 @@ impl State { } } -fn runtime_label(config: &InstanceConfig) -> String { - if config.loader == ModLoader::Vanilla { - format!("{} / Vanilla", config.game_version) - } else { - format!( - "{} / {} {}", - config.game_version, - config.loader, - config.loader_version.as_deref().unwrap_or("unknown") - ) - } -} - fn resolution_choices( current: Option<(u32, u32)>, displays: &[DisplayResolution], @@ -883,12 +865,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { } else if state.picker.is_some() { super::keybind_line(&[("/", " search"), ("h", " back"), ("Enter", " select")]) } else if state.choice_picker == Some(ChoicePicker::Java) { - super::keybind_line(&[ - ("a", " auto"), - ("c", " custom"), - ("h", " back"), - ("Enter", " select"), - ]) + super::keybind_line(&[("a", " auto"), ("h", " back"), ("Enter", " select")]) } else if state.choice_picker == Some(ChoicePicker::Resolution) { super::keybind_line(&[("d", " default"), ("h", " back"), ("Enter", " select")]) } else if state.choice_picker.is_some() { @@ -1047,8 +1024,12 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { Span::styled( value, Style::default() - .fg(if index == 8 && state.desktop { - theme.success() + .fg(if index == 8 { + if state.desktop { + theme.text() + } else { + theme.text_dim() + } } else if selected { theme.accent() } else { @@ -1062,7 +1043,7 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { ), ]; if index == 3 && state.draft.java_path.is_none() && !editing { - spans.extend([Span::raw(" "), subtle_tag("Auto", theme.info())]); + spans.extend([Span::raw(" "), auto_label()]); } Line::from(spans) } @@ -1508,6 +1489,10 @@ mod tests { state.begin_edit(); assert_eq!(state.choice_picker, Some(ChoicePicker::Java)); state.handle_choice_key(&KeyEvent::from(KeyCode::Char('c'))); + assert_eq!(state.choice_picker, Some(ChoicePicker::Java)); + assert!(state.editing.is_none()); + state.handle_choice_key(&KeyEvent::from(KeyCode::Esc)); + state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); assert!(state.editing.is_some()); } diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index dc52094..5b83ede 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -160,7 +160,7 @@ impl JavaPicker { Span::styled(format!(" {path}"), Style::default().fg(theme.text_dim())), ]; if self.current.is_none() && path == self.detected { - spans.extend([Span::raw(" "), subtle_tag("Auto", theme.info())]); + spans.extend([Span::raw(" "), auto_label()]); } ListItem::new(Line::from(spans)) } @@ -288,7 +288,7 @@ pub(crate) fn render_memory_gauge( Style::default().fg(theme.text()) }; frame.render_widget( - Paragraph::new(Line::from(Span::styled(format!(" {label} "), value_style))), + Paragraph::new(Line::from(Span::styled(label, value_style))), value_area, ); @@ -336,10 +336,12 @@ pub(crate) fn handle_text_area_input(input: &mut TextArea<'_>, key: &KeyEvent) { } } -pub(crate) fn subtle_tag(label: impl Into, color: ratatui::style::Color) -> Span<'static> { +pub(crate) fn auto_label() -> Span<'static> { Span::styled( - format!(" {} ", label.into()), - Style::default().fg(color).bg(THEME.as_ref().stripe()), + "Auto", + Style::default() + .fg(THEME.as_ref().text_dim()) + .add_modifier(Modifier::ITALIC), ) } From 17aff8ec6d0908a9397347fb16de89b3616e61fc Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 15:34:05 +0200 Subject: [PATCH 15/42] feat: refine settings feedback and controls --- src/feedback/errors.rs | 9 +++ src/tui/input.rs | 22 +++++++ src/tui/tests/flows.rs | 55 +++++++++++++++- src/tui/widgets/popups/confirm.rs | 17 +++-- src/tui/widgets/popups/global_settings.rs | 25 +++---- src/tui/widgets/popups/instance_settings.rs | 72 ++++++++++++++------- src/tui/widgets/popups/settings_controls.rs | 21 +++--- src/tui/widgets/settings.rs | 10 +-- 8 files changed, 174 insertions(+), 57 deletions(-) diff --git a/src/feedback/errors.rs b/src/feedback/errors.rs index 3743a7b..d916883 100644 --- a/src/feedback/errors.rs +++ b/src/feedback/errors.rs @@ -49,6 +49,15 @@ pub fn push_error(event: ErrorEvent) { } } +pub fn push_message(level: Level, message: impl Into) { + push_error(ErrorEvent { + id: 0, + level, + message: message.into(), + pushed_at: Instant::now(), + }); +} + #[must_use] pub fn has_errors() -> bool { match ERROR_EVENTS.lock() { diff --git a/src/tui/input.rs b/src/tui/input.rs index 18eeca5..efe8c1f 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -272,6 +272,17 @@ impl App { } self.focused } + Some(confirm_popup::ConfirmTarget::JvmArguments { .. }) => { + self.focused = FocusedArea::InstanceSettings; + let confirmed = self.instance_settings.as_mut().and_then(|state| { + state.clear_jvm_args(); + state.confirmed_save() + }); + if let Some((updated, desktop)) = confirmed { + self.apply_instance_settings(*updated, desktop, false); + } + self.focused + } None => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -294,6 +305,9 @@ impl App { } FocusedArea::InstanceSettings } + Some(confirm_popup::ConfirmTarget::JvmArguments { .. }) => { + FocusedArea::InstanceSettings + } _ => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -735,11 +749,19 @@ impl App { self.apply_instance_settings(*updated, desktop, false); } widgets::popups::instance_settings::Action::ConfirmRuntime { name } => { + error_buffer::push_message( + tracing::Level::WARN, + "Some installed mods may be incompatible", + ); confirm_popup::set_pending(confirm_popup::ConfirmTarget::InstanceRuntime { name, }); self.focused = FocusedArea::ConfirmDelete; } + widgets::popups::instance_settings::Action::ConfirmClearJvmArgs { name } => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::JvmArguments { name }); + self.focused = FocusedArea::ConfirmDelete; + } } return Ok(()); } diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index d6066f4..23b5572 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -658,11 +658,22 @@ fn runtime_settings_use_the_shared_confirmation_popup() { ui.draw(); assert!(ui.screen().contains("Change runtime")); assert!(!ui.screen().contains("Target:")); + assert!(ui.screen().contains("Apply this runtime change")); assert!( - ui.screen() + !ui.screen() .contains("Some installed mods may be incompatible") ); assert!(!ui.screen().contains("incompatible.")); + assert!( + crate::feedback::errors::ERROR_EVENTS + .lock() + .unwrap() + .iter() + .any(|event| { + event.level == tracing::Level::WARN + && event.message == "Some installed mods may be incompatible" + }) + ); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); @@ -670,6 +681,48 @@ fn runtime_settings_use_the_shared_confirmation_popup() { assert_eq!(ui.app.focused, FocusedArea::Settings); } +#[test] +fn clearing_jvm_arguments_requires_confirmation_and_autosaves() { + let mut ui = UiHarness::new(); + ui.add_instance("jvm-clear-test"); + ui.key(KeyCode::Char('E')); + ui.app.instance_settings.as_mut().unwrap().draft.jvm_args = + vec!["-XX:+UseG1GC".to_owned(), "-Xss1M".to_owned()]; + + for _ in 0..6 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Char('d')); + + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::JvmArguments { name }) if name == "jvm-clear-test" + )); + assert_eq!( + ui.app + .instance_settings + .as_ref() + .unwrap() + .draft + .jvm_args + .len(), + 2 + ); + + ui.key(KeyCode::Enter); + + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); + assert!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .jvm_args + .is_empty() + ); +} + #[test] fn instance_settings_validation_errors_use_the_toast_buffer() { let mut ui = UiHarness::new(); diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 015f58a..98bac8d 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -48,6 +48,9 @@ pub enum ConfirmTarget { InstanceRuntime { name: String, }, + JvmArguments { + name: String, + }, } impl ConfirmTarget { @@ -55,6 +58,7 @@ impl ConfirmTarget { match self { Self::OrphanDependencies { .. } => " Remove unused dependencies ".to_owned(), Self::InstanceRuntime { .. } => " Change runtime ".to_owned(), + Self::JvmArguments { .. } => " Clear JVM arguments ".to_owned(), _ => format!(" Delete '{}' ", self.name()), } } @@ -93,9 +97,8 @@ impl ConfirmTarget { }) .collect::>() .join("\n"), - ConfirmTarget::InstanceRuntime { .. } => { - "Some installed mods may be incompatible".to_owned() - } + ConfirmTarget::InstanceRuntime { .. } => "Apply this runtime change".to_owned(), + ConfirmTarget::JvmArguments { .. } => "Remove all JVM arguments".to_owned(), } } @@ -107,6 +110,7 @@ impl ConfirmTarget { ConfirmTarget::Content { name, .. } => name, ConfirmTarget::OrphanDependencies { .. } => "unused dependencies", ConfirmTarget::InstanceRuntime { name, .. } => name, + ConfirmTarget::JvmArguments { name, .. } => name, } } @@ -115,6 +119,7 @@ impl ConfirmTarget { Self::Content { dependents, .. } if !dependents.is_empty() => " delete anyway", Self::OrphanDependencies { .. } => " remove all", Self::InstanceRuntime { .. } => " change", + Self::JvmArguments { .. } => " clear", _ => " confirm", } } @@ -181,7 +186,6 @@ pub struct ConfirmPopup { title: String, body: String, confirm_label: &'static str, - runtime_confirmation: bool, } impl ConfirmPopup { @@ -190,7 +194,6 @@ impl ConfirmPopup { title: target.title(), body: target.body(), confirm_label: target.confirm_label(), - runtime_confirmation: matches!(target, ConfirmTarget::InstanceRuntime { .. }), } } } @@ -214,9 +217,7 @@ impl Widget for ConfirmPopup { let text = theme.text(); let text_dim = theme.text_dim(); let error = theme.error(); - let warning = theme.warning(); let body = self.body; - let runtime_confirmation = self.runtime_confirmation; let styled_list = body.contains("• "); let popup = PopupFrame { title, @@ -233,8 +234,6 @@ impl Widget for ConfirmPopup { Span::styled("• ", Style::default().fg(accent)), Span::styled(value.to_owned(), Style::default().fg(text)), ]) - } else if runtime_confirmation && line.starts_with("Some installed mods") { - Line::styled(line.to_owned(), Style::default().fg(warning)) } else if line.starts_with('!') { Line::styled(line.to_owned(), Style::default().fg(error)) } else { diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 6f70208..fca3288 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -479,20 +479,21 @@ fn render_picker(frame: &mut Frame, area: Rect, values: &[String], selected: usi super::select_list::render(items, selected, area, frame.buffer_mut()); } -fn render_java_picker(frame: &mut Frame, area: Rect, state: &State) { +fn render_java_picker(frame: &mut Frame, area: Rect, state: &mut State) { let theme = THEME.as_ref(); let mut list_area = area; - if let Some(status) = state.java_picker.status() { - let (message, color) = match status { - Ok(message) => (message.to_owned(), theme.text_dim()), - Err(error) => (error, theme.error()), - }; - frame.render_widget( - Paragraph::new(message).style(Style::default().fg(color)), - Rect { height: 1, ..area }, - ); - list_area.y = list_area.y.saturating_add(1); - list_area.height = list_area.height.saturating_sub(1); + if let Some(status) = state.java_picker.take_status() { + match status { + Ok(message) => { + frame.render_widget( + Paragraph::new(message).style(Style::default().fg(theme.text_dim())), + Rect { height: 1, ..area }, + ); + list_area.y = list_area.y.saturating_add(1); + list_area.height = list_area.height.saturating_sub(1); + } + Err(error) => crate::feedback::errors::push_message(tracing::Level::ERROR, error), + } } super::select_list::render( state.java_picker.items(), diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 55af6b6..5936618 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -77,7 +77,7 @@ enum PickerLoad { Idle, Loading, Loaded, - Error(String), + Error, } #[derive(Debug, Clone)] @@ -107,6 +107,7 @@ pub enum Action { Save(Box, bool), Error(String), ConfirmRuntime { name: String }, + ConfirmClearJvmArgs { name: String }, OpenRaw, Close, } @@ -361,7 +362,13 @@ impl State { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = match result { Ok(versions) => LoadState::Loaded(versions), - Err(error) => LoadState::Error(error), + Err(error) => { + crate::feedback::errors::push_message( + tracing::Level::ERROR, + format!("Failed to load game versions: {error}"), + ); + LoadState::Error(error) + } }; crate::feedback::request_redraw(); }); @@ -389,7 +396,13 @@ impl State { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = match result { Ok(versions) => LoadState::Loaded(versions), - Err(error) => LoadState::Error(error), + Err(error) => { + crate::feedback::errors::push_message( + tracing::Level::ERROR, + format!("Failed to load loader versions: {error}"), + ); + LoadState::Error(error) + } }; crate::feedback::request_redraw(); }); @@ -734,6 +747,11 @@ impl State { self.apply_default_memory(); } KeyCode::Char('d') if self.selected == 7 => self.apply_default_resolution(), + KeyCode::Char('d') if self.selected == 6 && !self.draft.jvm_args.is_empty() => { + return Action::ConfirmClearJvmArgs { + name: self.draft.name.clone(), + }; + } KeyCode::Char('c') if self.selected == 7 => { self.editing = Some(new_text_area(vec![self.value(7)])); } @@ -753,6 +771,11 @@ impl State { .then(|| (Box::new(self.draft.clone()), self.desktop)) } + pub fn clear_jvm_args(&mut self) { + self.draft.jvm_args.clear(); + self.error = None; + } + pub fn mark_saved(&mut self, saved: &InstanceConfig, desktop: bool) { self.original = saved.clone(); self.draft = saved.clone(); @@ -891,6 +914,13 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("d", " default"), ("Esc", " back"), ]) + } else if state.selected == 6 { + super::keybind_line(&[ + ("j/k", ""), + ("Enter", " edit"), + ("d", " clear"), + ("Esc", " back"), + ]) } else if matches!(state.selected, 0..=2) { super::keybind_line(&[ ("j/k", ""), @@ -1115,22 +1145,23 @@ fn jvm_row_count(state: &State, width: u16) -> usize { rows } -fn render_choice_picker(frame: &mut Frame, area: Rect, state: &State) { +fn render_choice_picker(frame: &mut Frame, area: Rect, state: &mut State) { let theme = THEME.as_ref(); let mut list_area = area; if state.choice_picker == Some(ChoicePicker::Java) - && let Some(status) = state.java_picker.status() + && let Some(status) = state.java_picker.take_status() { - let (message, color) = match status { - Ok(message) => (message.to_owned(), theme.text_dim()), - Err(error) => (error, theme.error()), - }; - frame.render_widget( - Paragraph::new(message).style(Style::default().fg(color)), - Rect { height: 1, ..area }, - ); - list_area.y = list_area.y.saturating_add(1); - list_area.height = list_area.height.saturating_sub(1); + match status { + Ok(message) => { + frame.render_widget( + Paragraph::new(message).style(Style::default().fg(theme.text_dim())), + Rect { height: 1, ..area }, + ); + list_area.y = list_area.y.saturating_add(1); + list_area.height = list_area.height.saturating_sub(1); + } + Err(error) => crate::feedback::errors::push_message(tracing::Level::ERROR, error), + } } let items = match state.choice_picker { Some(ChoicePicker::Java) => state.java_picker.items(), @@ -1200,7 +1231,7 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { LoadState::Idle => PickerLoad::Idle, LoadState::Loading => PickerLoad::Loading, LoadState::Loaded(_) => PickerLoad::Loaded, - LoadState::Error(error) => PickerLoad::Error(error.clone()), + LoadState::Error(_) => PickerLoad::Error, }, Some(VersionPicker::Loader) => match &*state .loader_versions @@ -1210,7 +1241,7 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { LoadState::Idle => PickerLoad::Idle, LoadState::Loading => PickerLoad::Loading, LoadState::Loaded(_) => PickerLoad::Loaded, - LoadState::Error(error) => PickerLoad::Error(error.clone()), + LoadState::Error(_) => PickerLoad::Error, }, None => return, }; @@ -1219,11 +1250,8 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { Paragraph::new("Loading versions...").style(Style::default().fg(theme.text_dim())), area, ), - PickerLoad::Error(error) => frame.render_widget( - Paragraph::new(format!( - "Failed to load versions: {error}. Reopen to retry." - )) - .style(Style::default().fg(theme.error())), + PickerLoad::Error => frame.render_widget( + Paragraph::new("Reopen to retry").style(Style::default().fg(theme.text_dim())), area, ), PickerLoad::Loaded => { diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 5b83ede..3c005b7 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -168,19 +168,23 @@ impl JavaPicker { .collect() } - pub(crate) fn status(&self) -> Option> { - match &*self + pub(crate) fn take_status(&mut self) -> Option> { + let mut load = self .load .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - { + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*load { LoadState::Idle => None, LoadState::Loading => Some(Ok("Detecting installed Java runtimes…")), LoadState::Loaded(installations) if installations.is_empty() => { Some(Ok("No additional Java runtimes found.")) } LoadState::Loaded(_) => None, - LoadState::Error(error) => Some(Err(error.clone())), + LoadState::Error(error) => { + let error = error.clone(); + *load = LoadState::Loaded(Vec::new()); + Some(Err(error)) + } } } @@ -338,10 +342,11 @@ pub(crate) fn handle_text_area_input(input: &mut TextArea<'_>, key: &KeyEvent) { pub(crate) fn auto_label() -> Span<'static> { Span::styled( - "Auto", + " Auto ", Style::default() - .fg(THEME.as_ref().text_dim()) - .add_modifier(Modifier::ITALIC), + .fg(THEME.as_ref().text_bright()) + .bg(THEME.as_ref().info()) + .add_modifier(Modifier::BOLD), ) } diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 525ca02..e8ca15b 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -329,10 +329,10 @@ fn render_instance_info( } else { value_style }; - let desktop = if crate::instance::desktop::exists(&inst.name) { - "yes" + let shortcut = if crate::instance::desktop::exists(&inst.name) { + "enabled" } else { - "no" + "disabled" }; let lines = vec![ Line::from(vec![ @@ -344,8 +344,8 @@ fn render_instance_info( Span::styled(state.java_label.as_str(), active_style), ]), Line::from(vec![ - Span::styled("Desktop ", label_style), - Span::styled(desktop, active_style), + Span::styled("Shortcut ", label_style), + Span::styled(shortcut, active_style), ]), ]; From 3566d6b50d23c4c4c88b3b04727724bada01932e Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 15:46:29 +0200 Subject: [PATCH 16/42] fix: preserve status badges in selectors --- src/tui/widgets/content/list.rs | 5 +- src/tui/widgets/content/tabs.rs | 5 +- src/tui/widgets/mod.rs | 13 ++++- src/tui/widgets/popups/global_settings.rs | 2 +- src/tui/widgets/popups/instance_settings.rs | 24 ++++++++-- src/tui/widgets/popups/select_list.rs | 53 +++++++++++++++++++++ src/tui/widgets/popups/settings_controls.rs | 34 ++++++++----- 7 files changed, 110 insertions(+), 26 deletions(-) diff --git a/src/tui/widgets/content/list.rs b/src/tui/widgets/content/list.rs index 7659277..ca0abef 100644 --- a/src/tui/widgets/content/list.rs +++ b/src/tui/widgets/content/list.rs @@ -1685,10 +1685,7 @@ pub fn render( theme.success() } }); - let title_suffix_style = Style::default() - .fg(theme.background()) - .bg(title_suffix_color) - .add_modifier(Modifier::BOLD); + let title_suffix_style = crate::tui::widgets::status_badge_style(title_suffix_color); let footer_label_style = Style::default().fg(if world_details.is_some() { theme.text_dim() } else { diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 06d1544..a2963ed 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -248,10 +248,7 @@ pub fn render( let mut content_titles = vec![ Span::styled( mode_label(mode), - Style::default() - .fg(theme.background()) - .bg(mode_background) - .add_modifier(Modifier::BOLD), + crate::tui::widgets::status_badge_style(mode_background), ), Span::raw(" "), ]; diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 99f4a19..b00cba9 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -6,7 +6,7 @@ use crate::config::theme::THEME; use crossterm::event::KeyEvent; use ratatui::{ - style::Style, + style::{Color, Modifier, Style}, text::{Line, Span}, }; @@ -38,6 +38,17 @@ pub fn styled_title(title: &str, highlight: bool) -> Line<'_> { } } +pub(crate) fn status_badge_style(color: Color) -> Style { + Style::default() + .fg(THEME.as_ref().background()) + .bg(color) + .add_modifier(Modifier::BOLD) +} + +pub(crate) fn status_badge(label: impl Into, color: Color) -> Span<'static> { + Span::styled(format!(" {} ", label.into()), status_badge_style(color)) +} + pub trait WidgetKey { fn handle_key(&mut self, key_event: &KeyEvent); } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index fca3288..ae8a19e 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -495,7 +495,7 @@ fn render_java_picker(frame: &mut Frame, area: Rect, state: &mut State) { Err(error) => crate::feedback::errors::push_message(tracing::Level::ERROR, error), } } - super::select_list::render( + super::select_list::render_styled( state.java_picker.items(), state.java_picker.selected, list_area, diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 5936618..77b70a4 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -1165,7 +1165,9 @@ fn render_choice_picker(frame: &mut Frame, area: Rect, state: &mut State) { } let items = match state.choice_picker { Some(ChoicePicker::Java) => state.java_picker.items(), - Some(ChoicePicker::Resolution) => resolution_items(&state.resolution_choices()), + Some(ChoicePicker::Resolution) => { + resolution_items(&state.resolution_choices(), state.choice_index) + } _ => state .choice_values() .iter() @@ -1177,17 +1179,29 @@ fn render_choice_picker(frame: &mut Frame, area: Rect, state: &mut State) { }) .collect(), }; - super::select_list::render(items, state.choice_index, list_area, frame.buffer_mut()); + if matches!( + state.choice_picker, + Some(ChoicePicker::Java | ChoicePicker::Resolution) + ) { + super::select_list::render_styled(items, state.choice_index, list_area, frame.buffer_mut()); + } else { + super::select_list::render(items, state.choice_index, list_area, frame.buffer_mut()); + } } -fn resolution_items(choices: &[ResolutionChoice]) -> Vec> { +fn resolution_items(choices: &[ResolutionChoice], selected: usize) -> Vec> { let theme = THEME.as_ref(); choices .iter() - .map(|choice| { + .enumerate() + .map(|(index, choice)| { let mut spans = vec![Span::styled( choice.label(), - Style::default().fg(theme.text()), + Style::default().fg(if index == selected { + theme.accent() + } else { + theme.text() + }), )]; match choice { ResolutionChoice::Preset(_, _) => {} diff --git a/src/tui/widgets/popups/select_list.rs b/src/tui/widgets/popups/select_list.rs index 68938ed..d7b3f22 100644 --- a/src/tui/widgets/popups/select_list.rs +++ b/src/tui/widgets/popups/select_list.rs @@ -39,3 +39,56 @@ pub(crate) fn render(items: Vec>, selected: usize, area: Rect, buff let mut state = ListState::default().with_selected(Some(selected)); StatefulWidget::render(list, area, buffer, &mut state); } + +pub(crate) fn render_styled( + items: Vec>, + selected: usize, + area: Rect, + buffer: &mut Buffer, +) { + let theme = THEME.as_ref(); + let items = items + .into_iter() + .enumerate() + .map(|(index, item)| { + if index == selected { + item.style(Style::default().bg(theme.stripe())) + } else { + item + } + }) + .collect::>(); + let list = List::new(items) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol(Span::styled( + "▶ ", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + )); + let mut state = ListState::default().with_selected(Some(selected)); + StatefulWidget::render(list, area, buffer, &mut state); +} + +#[cfg(test)] +mod tests { + use ratatui::text::Line; + + use super::*; + use crate::tui::widgets::status_badge; + + #[test] + fn styled_list_preserves_badges_on_the_selected_row() { + let theme = THEME.as_ref(); + let area = Rect::new(0, 0, 30, 1); + let mut buffer = Buffer::empty(area); + let badge = status_badge("Auto", theme.success()); + let items = vec![ListItem::new(Line::from(vec![Span::raw("Java "), badge]))]; + + render_styled(items, 0, area, &mut buffer); + + let badge_cell = buffer.cell((8, 0)).unwrap(); + assert_eq!(badge_cell.bg, theme.success()); + assert_eq!(badge_cell.fg, theme.background()); + } +} diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 3c005b7..f3bee3e 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -16,7 +16,9 @@ use ratatui::{ use ratatui_textarea::TextArea; use crate::{ - config::theme::THEME, instance::java::JavaInstallation, tui::widgets::popups::LoadState, + config::theme::THEME, + instance::java::JavaInstallation, + tui::widgets::{popups::LoadState, status_badge}, }; const MEMORY_STEPS: [&str; 12] = [ @@ -147,7 +149,8 @@ impl JavaPicker { }; self.choices() .into_iter() - .map(|choice| match choice { + .enumerate() + .map(|(index, choice)| match choice { JavaChoice::Installation(path) => { let installation = installations .iter() @@ -155,9 +158,24 @@ impl JavaPicker { let version = installation .and_then(|installation| installation.version.as_deref()) .map_or_else(|| "Java".to_owned(), |version| format!("Java {version}")); + let selected = index == self.selected; let mut spans = vec![ - Span::styled(version, Style::default().fg(theme.text())), - Span::styled(format!(" {path}"), Style::default().fg(theme.text_dim())), + Span::styled( + version, + Style::default().fg(if selected { + theme.accent() + } else { + theme.text() + }), + ), + Span::styled( + format!(" {path}"), + Style::default().fg(if selected { + theme.accent() + } else { + theme.text_dim() + }), + ), ]; if self.current.is_none() && path == self.detected { spans.extend([Span::raw(" "), auto_label()]); @@ -341,13 +359,7 @@ pub(crate) fn handle_text_area_input(input: &mut TextArea<'_>, key: &KeyEvent) { } pub(crate) fn auto_label() -> Span<'static> { - Span::styled( - " Auto ", - Style::default() - .fg(THEME.as_ref().text_bright()) - .bg(THEME.as_ref().info()) - .add_modifier(Modifier::BOLD), - ) + status_badge("Auto", THEME.as_ref().success()) } pub(crate) fn display_resolutions() -> Vec { From 59407ee01d955c7e1812d1a73aa7963e366748c0 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 16:16:15 +0200 Subject: [PATCH 17/42] feat: confirm automatic Java changes --- src/tui/input.rs | 79 +++++++++++++++++---- src/tui/tests/flows.rs | 55 +++++++++++++- src/tui/widgets/popups/confirm.rs | 11 +++ src/tui/widgets/popups/global_settings.rs | 77 ++++++++++++++++---- src/tui/widgets/popups/instance_settings.rs | 70 ++++++++++++++---- src/tui/widgets/popups/settings_controls.rs | 69 +++++++++++++++++- 6 files changed, 314 insertions(+), 47 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index efe8c1f..027adf3 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -283,6 +283,32 @@ impl App { } self.focused } + Some(confirm_popup::ConfirmTarget::JavaAuto { instance, .. }) => { + if instance.is_some() { + self.focused = FocusedArea::InstanceSettings; + let confirmed = self.instance_settings.as_mut().and_then(|state| { + state.enable_auto_java(); + state.confirmed_save() + }); + if let Some((updated, desktop)) = confirmed { + self.apply_instance_settings(*updated, desktop, false); + } + } else { + self.focused = FocusedArea::GlobalSettings; + let action = self.global_settings.as_mut().map( + widgets::popups::global_settings::State::confirm_auto_java, + ); + if let Some(widgets::popups::global_settings::Action::Save( + config, + theme, + border, + )) = action + { + self.apply_global_settings(*config, theme, border); + } + } + self.focused + } None => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -308,6 +334,13 @@ impl App { Some(confirm_popup::ConfirmTarget::JvmArguments { .. }) => { FocusedArea::InstanceSettings } + Some(confirm_popup::ConfirmTarget::JavaAuto { instance, .. }) => { + if instance.is_some() { + FocusedArea::InstanceSettings + } else { + FocusedArea::GlobalSettings + } + } _ => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -684,6 +717,14 @@ impl App { pushed_at: std::time::Instant::now(), }); } + widgets::popups::global_settings::Action::ConfirmJavaAuto { from, to } => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::JavaAuto { + instance: None, + from, + to, + }); + self.focused = FocusedArea::ConfirmDelete; + } widgets::popups::global_settings::Action::Close => { self.global_settings = None; self.focused = self.pre_overlay_focused; @@ -694,20 +735,7 @@ impl App { self.focused = self.pre_overlay_focused; } widgets::popups::global_settings::Action::Save(config, theme, border) => { - let result = crate::config::SETTINGS - .save_launcher_settings(*config) - .and_then(|()| crate::config::theme::apply_theme(theme, border)); - match result { - Ok(()) => { - crate::feedback::request_redraw(); - } - Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message: error.to_string(), - pushed_at: std::time::Instant::now(), - }), - } + self.apply_global_settings(*config, theme, border); } } return Ok(()); @@ -762,6 +790,14 @@ impl App { confirm_popup::set_pending(confirm_popup::ConfirmTarget::JvmArguments { name }); self.focused = FocusedArea::ConfirmDelete; } + widgets::popups::instance_settings::Action::ConfirmJavaAuto { name, from, to } => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::JavaAuto { + instance: Some(name), + from, + to, + }); + self.focused = FocusedArea::ConfirmDelete; + } } return Ok(()); } @@ -2060,6 +2096,21 @@ impl App { } } + fn apply_global_settings( + &mut self, + config: crate::config::Config, + theme: String, + border: crate::config::theme::BorderStyle, + ) { + let result = crate::config::SETTINGS + .save_launcher_settings(config) + .and_then(|()| crate::config::theme::apply_theme(theme, border)); + match result { + Ok(()) => crate::feedback::request_redraw(), + Err(error) => error_buffer::push_message(tracing::Level::ERROR, error.to_string()), + } + } + fn apply_instance_settings( &mut self, updated: crate::instance::models::InstanceConfig, diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 23b5572..e7704f1 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -723,6 +723,57 @@ fn clearing_jvm_arguments_requires_confirmation_and_autosaves() { ); } +#[test] +fn enabling_a_different_automatic_java_requires_confirmation() { + let mut ui = UiHarness::new(); + ui.add_instance("java-auto-test"); + ui.key(KeyCode::Char('E')); + ui.app.instance_settings.as_mut().unwrap().draft.java_path = Some("/custom/java".to_owned()); + + for _ in 0..3 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Char('a')); + + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::JavaAuto { + instance: Some(name), + .. + }) if name == "java-auto-test" + )); + ui.draw(); + assert!(ui.screen().contains("Enable automatic Java")); + assert!(ui.screen().contains("Java runtime will change")); + + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); + assert_eq!( + ui.app + .instance_settings + .as_ref() + .unwrap() + .draft + .java_path + .as_deref(), + Some("/custom/java") + ); + ui.key(KeyCode::Char('a')); + + ui.key(KeyCode::Enter); + + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); + assert_eq!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .java_path, + None + ); +} + #[test] fn instance_settings_validation_errors_use_the_toast_buffer() { let mut ui = UiHarness::new(); @@ -757,7 +808,7 @@ fn settings_use_java_memory_and_resolution_controls() { ui.key(KeyCode::Enter); ui.draw(); assert!(ui.screen().contains("Java Runtime")); - assert!(ui.screen().contains("auto")); + assert!(!ui.screen().contains("auto")); assert!(!ui.screen().contains("custom")); assert!(!ui.screen().contains("Automatic")); assert!(!ui.screen().contains("Custom path")); @@ -806,7 +857,7 @@ fn settings_use_java_memory_and_resolution_controls() { ui.key(KeyCode::Enter); ui.draw(); assert!(ui.screen().contains("Java Runtime")); - assert!(ui.screen().contains("auto")); + assert!(!ui.screen().contains("auto")); assert!(!ui.screen().contains("Automatic")); ui.key(KeyCode::Esc); ui.key(KeyCode::Esc); diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 98bac8d..68dfc47 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -51,6 +51,11 @@ pub enum ConfirmTarget { JvmArguments { name: String, }, + JavaAuto { + instance: Option, + from: String, + to: String, + }, } impl ConfirmTarget { @@ -59,6 +64,7 @@ impl ConfirmTarget { Self::OrphanDependencies { .. } => " Remove unused dependencies ".to_owned(), Self::InstanceRuntime { .. } => " Change runtime ".to_owned(), Self::JvmArguments { .. } => " Clear JVM arguments ".to_owned(), + Self::JavaAuto { .. } => " Enable automatic Java ".to_owned(), _ => format!(" Delete '{}' ", self.name()), } } @@ -99,6 +105,9 @@ impl ConfirmTarget { .join("\n"), ConfirmTarget::InstanceRuntime { .. } => "Apply this runtime change".to_owned(), ConfirmTarget::JvmArguments { .. } => "Remove all JVM arguments".to_owned(), + ConfirmTarget::JavaAuto { from, to, .. } => { + format!("Java runtime will change:\n{from} → {to}") + } } } @@ -111,6 +120,7 @@ impl ConfirmTarget { ConfirmTarget::OrphanDependencies { .. } => "unused dependencies", ConfirmTarget::InstanceRuntime { name, .. } => name, ConfirmTarget::JvmArguments { name, .. } => name, + ConfirmTarget::JavaAuto { instance, .. } => instance.as_deref().unwrap_or("launcher"), } } @@ -120,6 +130,7 @@ impl ConfirmTarget { Self::OrphanDependencies { .. } => " remove all", Self::InstanceRuntime { .. } => " change", Self::JvmArguments { .. } => " clear", + Self::JavaAuto { .. } => " enable", _ => " confirm", } } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index ae8a19e..f116176 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -43,6 +43,7 @@ pub enum Action { None, Save(Box, String, BorderStyle), Error(String), + ConfirmJavaAuto { from: String, to: String }, OpenRaw(std::path::PathBuf), Close, } @@ -198,25 +199,41 @@ impl State { self.java_picker_open = true; } - fn toggle_auto_java(&mut self) { - self.config.paths.java_path = if self.config.paths.java_path.is_none() { - Some(self.java_picker.detected_path().to_owned()) - } else { - None + fn toggle_auto_java(&mut self) -> Action { + let Some(current) = self.config.paths.java_path.as_deref() else { + self.config.paths.java_path = Some(self.java_picker.detected_path().to_owned()); + self.save_pending = true; + self.error = None; + return Action::None; }; + if let Some((from, to)) = self.java_picker.automatic_change(current) { + return Action::ConfirmJavaAuto { from, to }; + } + self.enable_auto_java(); + Action::None + } + + fn enable_auto_java(&mut self) { + self.config.paths.java_path = None; self.save_pending = true; self.error = None; } + pub fn confirm_auto_java(&mut self) -> Action { + self.enable_auto_java(); + self.save_pending = false; + Action::Save( + Box::new(self.config.clone()), + self.theme.theme.clone(), + self.theme.border_style.clone(), + ) + } + fn handle_java_picker_key(&mut self, key: &KeyEvent) { self.java_picker.initialize(); let count = self.java_picker.labels().len(); match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.java_picker_open = false, - KeyCode::Char('a') => { - self.toggle_auto_java(); - self.java_picker_open = false; - } KeyCode::Char('j') | KeyCode::Down if count > 0 => { self.java_picker.selected = (self.java_picker.selected + 1).min(count - 1); } @@ -316,9 +333,7 @@ impl State { 4 => self.open_java_picker(), field => self.editing = Some(new_text_area(vec![self.value(field)])), }, - KeyCode::Char('a') if self.selected == 4 => { - self.toggle_auto_java(); - } + KeyCode::Char('a') if self.selected == 4 => return self.toggle_auto_java(), KeyCode::Char('c') if self.selected == 4 => { self.editing = Some(new_text_area(vec![self.value(4)])); } @@ -403,9 +418,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { frame.render_widget(Clear, area); let keybinds = if state.editing.is_some() { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) - } else if state.java_picker_open { - super::keybind_line(&[("a", " auto"), ("h", " back"), ("Enter", " select")]) - } else if state.theme_picker { + } else if state.java_picker_open || state.theme_picker { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if matches!(state.selected, 2 | 3) { super::keybind_line(&[("h/l", " adjust"), ("Enter", " exact"), ("Esc", " back")]) @@ -627,4 +640,38 @@ mod tests { )); assert_eq!(state.config.paths.java_path, None); } + + #[test] + fn java_picker_does_not_toggle_auto_mode() { + let mut state = State::new(); + state.selected = 4; + state.config.paths.java_path = Some("/custom/java".to_owned()); + state.open_java_picker(); + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), + Action::None + )); + assert!(state.java_picker_open); + assert_eq!( + state.config.paths.java_path.as_deref(), + Some("/custom/java") + ); + } + + #[test] + fn changing_runtime_to_auto_requests_confirmation() { + let mut state = State::new(); + state.selected = 4; + state.config.paths.java_path = Some("/custom/java".to_owned()); + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), + Action::ConfirmJavaAuto { .. } + )); + assert_eq!( + state.config.paths.java_path.as_deref(), + Some("/custom/java") + ); + } } diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 77b70a4..2f70925 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -106,14 +106,29 @@ pub enum Action { None, Save(Box, bool), Error(String), - ConfirmRuntime { name: String }, - ConfirmClearJvmArgs { name: String }, + ConfirmRuntime { + name: String, + }, + ConfirmClearJvmArgs { + name: String, + }, + ConfirmJavaAuto { + name: String, + from: String, + to: String, + }, OpenRaw, Close, } impl State { pub fn new(instance: &InstanceConfig, _meta_dir: &std::path::Path) -> Self { + let auto_java_path = SETTINGS + .read() + .paths + .effective_java_path() + .map(str::to_owned) + .unwrap_or_else(crate::instance::java::detect_java_path); Self { original: instance.clone(), draft: instance.clone(), @@ -131,7 +146,7 @@ impl State { loader_versions: Arc::new(Mutex::new(LoadState::Idle)), choice_picker: None, choice_index: 0, - java_picker: JavaPicker::new(), + java_picker: JavaPicker::with_auto_path(auto_java_path), display_resolutions: display_resolutions(), } } @@ -283,10 +298,6 @@ impl State { let count = self.choice_values().len(); match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.choice_picker = None, - KeyCode::Char('a') if self.choice_picker == Some(ChoicePicker::Java) => { - self.toggle_auto_java(); - self.choice_picker = None; - } KeyCode::Char('d') if self.choice_picker == Some(ChoicePicker::Resolution) => { self.apply_default_resolution(); self.choice_picker = None; @@ -538,12 +549,25 @@ impl State { } } - fn toggle_auto_java(&mut self) { - self.draft.java_path = if self.draft.java_path.is_none() { - Some(self.java_picker.detected_path().to_owned()) - } else { - None + fn toggle_auto_java(&mut self) -> Action { + let Some(current) = self.draft.java_path.as_deref() else { + self.draft.java_path = Some(self.java_picker.detected_path().to_owned()); + self.error = None; + return Action::None; }; + if let Some((from, to)) = self.java_picker.automatic_change(current) { + return Action::ConfirmJavaAuto { + name: self.draft.name.clone(), + from, + to, + }; + } + self.enable_auto_java(); + Action::None + } + + pub fn enable_auto_java(&mut self) { + self.draft.java_path = None; self.error = None; } @@ -755,7 +779,7 @@ impl State { KeyCode::Char('c') if self.selected == 7 => { self.editing = Some(new_text_area(vec![self.value(7)])); } - KeyCode::Char('a') if self.selected == 3 => self.toggle_auto_java(), + KeyCode::Char('a') if self.selected == 3 => return self.toggle_auto_java(), KeyCode::Char('c') if self.selected == 3 => { self.editing = Some(new_text_area(vec![self.value(3)])); } @@ -888,7 +912,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { } else if state.picker.is_some() { super::keybind_line(&[("/", " search"), ("h", " back"), ("Enter", " select")]) } else if state.choice_picker == Some(ChoicePicker::Java) { - super::keybind_line(&[("a", " auto"), ("h", " back"), ("Enter", " select")]) + super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if state.choice_picker == Some(ChoicePicker::Resolution) { super::keybind_line(&[("d", " default"), ("h", " back"), ("Enter", " select")]) } else if state.choice_picker.is_some() { @@ -1530,6 +1554,10 @@ mod tests { state.selected = 3; state.begin_edit(); assert_eq!(state.choice_picker, Some(ChoicePicker::Java)); + let original_java = state.draft.java_path.clone(); + state.handle_choice_key(&KeyEvent::from(KeyCode::Char('a'))); + assert_eq!(state.choice_picker, Some(ChoicePicker::Java)); + assert_eq!(state.draft.java_path, original_java); state.handle_choice_key(&KeyEvent::from(KeyCode::Char('c'))); assert_eq!(state.choice_picker, Some(ChoicePicker::Java)); assert!(state.editing.is_none()); @@ -1545,13 +1573,25 @@ mod tests { state.selected = 3; state.draft.java_path = Some("/custom/java".to_owned()); - state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), + Action::ConfirmJavaAuto { .. } + )); + assert_eq!(state.draft.java_path.as_deref(), Some("/custom/java")); + state.enable_auto_java(); assert_eq!(state.draft.java_path, None); state.handle_key(&KeyEvent::from(KeyCode::Char('a'))); assert_eq!( state.draft.java_path.as_deref(), Some(state.java_picker.detected_path()) ); + let saved = state.draft.clone(); + state.mark_saved(&saved, state.desktop); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), + Action::Save(..) + )); + assert_eq!(state.draft.java_path, None); state.selected = 7; state.display_resolutions = vec![DisplayResolution { diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index f3bee3e..37e291b 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -49,10 +49,14 @@ pub(crate) struct DisplayResolution { impl JavaPicker { pub(crate) fn new() -> Self { + Self::with_auto_path(crate::instance::java::detect_java_path()) + } + + pub(crate) fn with_auto_path(detected: String) -> Self { Self { load: Arc::new(Mutex::new(LoadState::Idle)), current: None, - detected: crate::instance::java::detect_java_path(), + detected, selected: 0, previous_choices: Vec::new(), } @@ -240,6 +244,33 @@ impl JavaPicker { pub(crate) fn detected_path(&self) -> &str { &self.detected } + + pub(crate) fn automatic_change(&self, current: &str) -> Option<(String, String)> { + if same_executable(current, &self.detected) { + return None; + } + + let load = self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let installations = match &*load { + LoadState::Loaded(installations) => installations, + _ => return Some((current.to_owned(), self.detected.clone())), + }; + let version_for = |path: &str| { + installations + .iter() + .find(|installation| same_executable(&installation.path.to_string_lossy(), path)) + .and_then(|installation| installation.version.clone()) + }; + let current_version = version_for(current); + let detected_version = version_for(&self.detected); + Some(( + java_runtime_label(current, current_version.as_deref()), + java_runtime_label(&self.detected, detected_version.as_deref()), + )) + } } impl Default for JavaPicker { @@ -248,6 +279,23 @@ impl Default for JavaPicker { } } +fn same_executable(left: &str, right: &str) -> bool { + if left == right { + return true; + } + std::fs::canonicalize(left) + .ok() + .zip(std::fs::canonicalize(right).ok()) + .is_some_and(|(left, right)| left == right) +} + +fn java_runtime_label(path: &str, version: Option<&str>) -> String { + version.map_or_else( + || path.to_owned(), + |version| format!("Java {version} {path}"), + ) +} + pub(crate) fn memory_kib(value: &str) -> Option { let normalized = crate::instance::models::normalize_memory_value(value)?; let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); @@ -417,6 +465,25 @@ mod tests { ); } + #[test] + fn automatic_java_compares_selected_executables() { + let picker = JavaPicker::with_auto_path("/auto/java".to_owned()); + *picker.load.lock().unwrap() = LoadState::Loaded(vec![ + JavaInstallation { + path: "/current/java".into(), + version: Some("21.0.8".to_owned()), + }, + JavaInstallation { + path: "/auto/java".into(), + version: Some("21.0.8".to_owned()), + }, + ]); + + assert!(picker.automatic_change("/auto/java").is_none()); + assert!(picker.automatic_change("/current/java").is_some()); + assert!(picker.automatic_change("/other/java").is_some()); + } + #[test] fn settings_text_input_deletes_the_previous_word() { let mut input = TextArea::from(["one two"]); From 8de0cbaa099d8c7e8f76f27b6ce9d67d97816a5a Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 19:56:20 +0200 Subject: [PATCH 18/42] feat: add instance launch settings --- src/instance/java.rs | 54 +- src/instance/launch/mod.rs | 161 +++- src/instance/manager.rs | 6 + src/instance/mod.rs | 2 +- src/instance/models.rs | 51 +- src/instance/tests/content/dependencies.rs | 6 + src/instance/tests/content/reconcile.rs | 6 + src/instance/tests/launch/pipeline.rs | 128 +++ src/instance/tests/manager.rs | 6 + src/instance/tests/models.rs | 12 + src/storage.rs | 4 + src/tests/storage.rs | 4 + src/tui/app.rs | 4 + src/tui/event.rs | 27 +- src/tui/input.rs | 208 +++-- src/tui/tests/event.rs | 63 ++ src/tui/tests/flows.rs | 207 ++++- src/tui/tests/harness.rs | 11 + src/tui/tests/widgets/content/discovery.rs | 6 + src/tui/tests/widgets/instances.rs | 6 + src/tui/widgets/popups/confirm.rs | 46 +- src/tui/widgets/popups/global_settings.rs | 87 +- src/tui/widgets/popups/instance_settings.rs | 967 ++++++++++++++++---- src/tui/widgets/popups/settings_controls.rs | 831 ++++++++++++++--- tests/launch_pipeline.rs | 38 + 25 files changed, 2461 insertions(+), 480 deletions(-) diff --git a/src/instance/java.rs b/src/instance/java.rs index db6b4e5..8b139d1 100644 --- a/src/instance/java.rs +++ b/src/instance/java.rs @@ -9,7 +9,9 @@ use std::{ process::Command, }; -#[derive(Debug, Clone, PartialEq, Eq)] +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct JavaInstallation { pub path: PathBuf, pub version: Option, @@ -91,10 +93,9 @@ pub fn discover_installations() -> Vec { .filter(|path| path.is_file()) .filter_map(|path| { let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); - seen.insert(identity).then(|| JavaInstallation { - version: java_version(&path), - path, - }) + seen.insert(identity) + .then(|| inspect_installation(&path)) + .flatten() }) .collect::>(); installations.sort_by(|a, b| { @@ -105,6 +106,31 @@ pub fn discover_installations() -> Vec { installations } +/// Reads the version reported by one Java executable. +#[must_use] +pub fn inspect_installation(path: &Path) -> Option { + path.is_file().then(|| JavaInstallation { + version: java_version(path), + path: path.to_path_buf(), + }) +} + +#[must_use] +pub fn load_installation_cache(path: &Path) -> Option> { + let mut installations = + serde_json::from_slice::>(&std::fs::read(path).ok()?).ok()?; + installations.retain(|installation| installation.path.is_file()); + (!installations.is_empty()).then_some(installations) +} + +pub fn save_installation_cache( + path: &Path, + installations: &[JavaInstallation], +) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(installations).map_err(std::io::Error::other)?; + crate::storage::write_atomic(path, &bytes) +} + fn java_roots() -> Vec { let mut roots = Vec::new(); if cfg!(target_os = "windows") { @@ -237,4 +263,22 @@ mod tests { assert_eq!(java_major(Some("21.0.4")), 21); assert_eq!(java_major(Some("1.8.0_412")), 8); } + + #[test] + fn installation_cache_round_trips_existing_paths() { + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("java"); + std::fs::write(&executable, b"java").unwrap(); + let cache = temp.path().join("cache/java/installations.json"); + let installations = vec![JavaInstallation { + path: executable.clone(), + version: Some("25.0.1".to_owned()), + }]; + + save_installation_cache(&cache, &installations).unwrap(); + assert_eq!(load_installation_cache(&cache), Some(installations)); + + std::fs::remove_file(executable).unwrap(); + assert_eq!(load_installation_cache(&cache), None); + } } diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 54dcf34..69187c0 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use thiserror::Error; use crate::auth::AccountType; -use crate::instance::models::{InstanceConfig, ModLoader}; +use crate::instance::models::{InstanceConfig, LaunchCommand, ModLoader, WindowMode}; use crate::launch_profile::model::{Argument, LaunchProfile}; use crate::launch_profile::rules::{self, FeatureSet, RuleAction, RuleContext}; use crate::launch_profile::templates::TemplateContext; @@ -45,6 +45,8 @@ pub enum LaunchError { }, #[error("{0}")] Auth(String), + #[error("{phase} command failed: {reason}")] + Command { phase: &'static str, reason: String }, #[error("Config sync error: {0}")] ConfigSync(#[from] crate::instance::config_sync::ConfigSyncError), } @@ -71,6 +73,14 @@ fn apply_custom_resolution(game_args: &mut Vec, resolution: Option<(u32, } } +fn apply_window_mode(game_args: &mut Vec, window_mode: WindowMode) { + if window_mode == WindowMode::Windowed { + game_args.retain(|argument| argument != "--fullscreen"); + } else if !game_args.iter().any(|arg| arg == "--fullscreen") { + game_args.push("--fullscreen".to_owned()); + } +} + fn parse_java_major_version(text: &str) -> Option { let quoted = text .split_once('"') @@ -318,6 +328,7 @@ pub struct LaunchInvocation { pub main_class: String, pub extra_args: Vec, pub game_args: Vec, + pub environment: std::collections::BTreeMap, pub working_dir: PathBuf, } @@ -605,6 +616,7 @@ pub async fn build_launch_invocation( // Modern Mojang profiles include feature-gated resolution arguments. // Older and third-party profiles may not, so add them when absent. apply_custom_resolution(&mut game_args, config.resolution); + apply_window_mode(&mut game_args, config.window_mode); let (memory_min, memory_max) = { let settings = crate::config::SETTINGS.read(); @@ -623,6 +635,9 @@ pub async fn build_launch_invocation( jvm_args.extend(patch_jvm_args); jvm_args.extend(upstream_jvm_args); jvm_args.extend(config.jvm_args.clone()); + if let Some(glfw_path) = config.glfw_path.as_deref() { + jvm_args.push(format!("-Dorg.lwjgl.glfw.libname={glfw_path}")); + } Ok(LaunchInvocation { java, @@ -632,10 +647,94 @@ pub async fn build_launch_invocation( main_class, extra_args, game_args, + environment: config.environment.clone(), working_dir: minecraft_dir, }) } +fn command_process(command: &str) -> tokio::process::Command { + #[cfg(windows)] + { + let mut process = tokio::process::Command::new("cmd"); + process.args(["/C", command]); + process + } + #[cfg(not(windows))] + { + let mut process = tokio::process::Command::new("/bin/sh"); + process.args(["-c", command]); + process + } +} + +fn select_launch_account<'a>( + accounts: &'a [crate::auth::Account], + preferred: Option<&str>, +) -> Option<&'a crate::auth::Account> { + preferred + .and_then(|uuid| accounts.iter().find(|account| account.uuid == uuid)) + .or_else(|| accounts.iter().find(|account| account.active)) +} + +async fn run_launch_command( + phase: &'static str, + command: &LaunchCommand, + config: &InstanceConfig, + invocation: &LaunchInvocation, + instance_dir: &Path, +) -> Result<(), LaunchError> { + if !command.enabled || command.command.trim().is_empty() { + return Ok(()); + } + + tracing::info!("[{}] Running {} command", config.name, phase.to_lowercase()); + let mut process = command_process(&command.command); + process + .current_dir(&invocation.working_dir) + .envs(&invocation.environment) + .env("INST_NAME", &config.name) + .env("INST_ID", &config.name) + .env("INST_DIR", instance_dir) + .env("INST_MC_DIR", &invocation.working_dir) + .env("INST_JAVA", &invocation.java) + .env("INST_JAVA_ARGS", invocation.jvm_args.join(" ")); + let output = process + .output() + .await + .map_err(|error| LaunchError::Command { + phase, + reason: error.to_string(), + })?; + for line in String::from_utf8_lossy(&output.stdout).lines() { + tracing::info!("[{}] [{}] {}", config.name, phase, line); + } + for line in String::from_utf8_lossy(&output.stderr).lines() { + tracing::warn!("[{}] [{}] {}", config.name, phase, line); + } + if output.status.success() { + Ok(()) + } else { + Err(LaunchError::Command { + phase, + reason: output.status.to_string(), + }) + } +} + +fn finish_config_sync( + active: bool, + profile: Option<&str>, + meta_dir: &Path, + minecraft_dir: &Path, + instance: &str, +) { + if active + && let Err(error) = crate::instance::config_sync::finish(profile, meta_dir, minecraft_dir) + { + tracing::warn!("Failed to sync config for '{}': {}", instance, error); + } +} + // resolves auth credentials, then builds the launch invocation and spawns // the java process. only thin wrapper logic lives here: token refresh, // process spawn, child supervision. all the heavy lifting (profile loading, @@ -650,7 +749,9 @@ pub async fn launch( // resolve auth credentials, refreshing the microsoft token if needed. let mut account_store = crate::auth::AccountStore::load(); - let Some(acc) = account_store.active_account().cloned() else { + let account = + select_launch_account(&account_store.accounts, config.preferred_account.as_deref()); + let Some(acc) = account.cloned() else { return Err(LaunchError::Auth("No account selected".to_owned())); }; @@ -704,6 +805,7 @@ pub async fn launch( let invocation = build_launch_invocation(config, instances_dir, meta_dir, &auth, quick_play_world).await?; + let instance_dir = instances_dir.join(&config.name); tracing::debug!( "[{}] Prepared launch invocation: working_dir={} classpath_entries={} jvm_args={} extra_args={} game_args={} main_class={}", name, @@ -720,6 +822,24 @@ pub async fn launch( meta_dir, &invocation.working_dir, )?; + if let Err(error) = run_launch_command( + "Pre-launch", + &config.pre_launch_command, + config, + &invocation, + &instance_dir, + ) + .await + { + finish_config_sync( + config_sync_active, + config_sync_profile.as_deref(), + meta_dir, + &invocation.working_dir, + &name, + ); + return Err(error); + } let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); crate::instance::runtime::register_kill(&name, kill_tx); @@ -752,6 +872,7 @@ pub async fn launch( cmd.args(&invocation.extra_args); cmd.args(&invocation.game_args); cmd.current_dir(&invocation.working_dir); + cmd.envs(&invocation.environment); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); @@ -760,6 +881,13 @@ pub async fn launch( Err(e) => { crate::instance::runtime::cleanup_kill_sender(&name); crate::instance::runtime::remove(&name); + finish_config_sync( + config_sync_active, + config_sync_profile.as_deref(), + meta_dir, + &invocation.working_dir, + &name, + ); tracing::error!("[{}] Failed to spawn Minecraft process: {}", name, e); return Err(LaunchError::Io(e)); } @@ -782,6 +910,10 @@ pub async fn launch( let instances_dir_owned = instances_dir.to_path_buf(); let meta_dir_owned = meta_dir.to_path_buf(); let minecraft_dir_owned = invocation.working_dir.clone(); + let instance_dir_owned = instances_dir.join(&config.name); + let post_exit_command = config.post_exit_command.clone(); + let config_for_post_exit = config.clone(); + let invocation_for_post_exit = invocation.clone(); // spawn a background task to babysit the child process: capture stdout/stderr // into both the TUI log viewer and a timestamped log file on disk @@ -883,15 +1015,26 @@ pub async fn launch( let _ = parser_task.await; tracing::info!("[{}] Exited with code {:?}", name_for_task, code); - if config_sync_active - && let Err(e) = crate::instance::config_sync::finish( - config_sync_profile.as_deref(), - &meta_dir_owned, - &minecraft_dir_owned, - ) + if let Err(error) = run_launch_command( + "Post-exit", + &post_exit_command, + &config_for_post_exit, + &invocation_for_post_exit, + &instance_dir_owned, + ) + .await { - tracing::warn!("Failed to sync config for '{}': {}", name_for_task, e); + tracing::warn!("[{}] {}", name_for_task, error); + crate::feedback::errors::push_message(tracing::Level::WARN, error.to_string()); } + + finish_config_sync( + config_sync_active, + config_sync_profile.as_deref(), + &meta_dir_owned, + &minecraft_dir_owned, + &name_for_task, + ); if code == Some(0) || killed_by_user { crate::instance::runtime::remove(&name_for_task); tracing::debug!( diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 9968033..0e91ded 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -273,7 +273,13 @@ impl InstanceManager { memory_max: None, memory_min: None, jvm_args: vec![], + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }; diff --git a/src/instance/mod.rs b/src/instance/mod.rs index 7aa53c4..e47bd0a 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -27,4 +27,4 @@ pub use content::{ pub use launch::LaunchError; pub use loader::{GameVersion, ModLoaderInstaller, VanillaInstaller, get_installer}; pub use manager::{InstanceError, InstanceManager}; -pub use models::{InstanceConfig, ModLoader, normalize_memory_value}; +pub use models::{InstanceConfig, LaunchCommand, ModLoader, WindowMode, normalize_memory_value}; diff --git a/src/instance/models.rs b/src/instance/models.rs index baa8051..de294f8 100644 --- a/src/instance/models.rs +++ b/src/instance/models.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::fmt; +use std::{collections::BTreeMap, fmt}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -31,6 +31,43 @@ impl fmt::Display for ModLoader { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WindowMode { + #[default] + Windowed, + Fullscreen, +} + +impl WindowMode { + fn is_windowed(&self) -> bool { + *self == Self::Windowed + } +} + +impl fmt::Display for WindowMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Windowed => write!(f, "windowed"), + Self::Fullscreen => write!(f, "fullscreen"), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaunchCommand { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub command: String, +} + +impl LaunchCommand { + fn is_default(&self) -> bool { + !self.enabled && self.command.is_empty() + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct InstanceConfig { pub name: String, @@ -48,8 +85,20 @@ pub struct InstanceConfig { pub memory_min: Option, #[serde(default)] pub jvm_args: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub environment: BTreeMap, + #[serde(default, skip_serializing_if = "WindowMode::is_windowed")] + pub window_mode: WindowMode, #[serde(default)] pub resolution: Option<(u32, u32)>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_account: Option, + #[serde(default, skip_serializing_if = "LaunchCommand::is_default")] + pub pre_launch_command: LaunchCommand, + #[serde(default, skip_serializing_if = "LaunchCommand::is_default")] + pub post_exit_command: LaunchCommand, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glfw_path: Option, #[serde(default)] pub config_sync_profile: Option, #[serde(default)] diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index c6b0bf5..18d9aee 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -244,7 +244,13 @@ fn instance() -> InstanceConfig { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs index 5efc68c..a69aeff 100644 --- a/src/instance/tests/content/reconcile.rs +++ b/src/instance/tests/content/reconcile.rs @@ -49,7 +49,13 @@ fn job(name: &str) -> ReconcileJob { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }, diff --git a/src/instance/tests/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs index a4aa5b0..bd86deb 100644 --- a/src/instance/tests/launch/pipeline.rs +++ b/src/instance/tests/launch/pipeline.rs @@ -112,6 +112,122 @@ fn custom_resolution_is_added_once() { assert_eq!(args.iter().filter(|arg| *arg == "--height").count(), 1); } +#[test] +fn fullscreen_is_added_once() { + let mut args = vec!["--username".to_owned(), "Player".to_owned()]; + apply_window_mode(&mut args, WindowMode::Fullscreen); + apply_window_mode(&mut args, WindowMode::Fullscreen); + assert_eq!( + args.iter() + .filter(|argument| *argument == "--fullscreen") + .count(), + 1 + ); + + let mut windowed = vec![ + "--username".to_owned(), + "Player".to_owned(), + "--fullscreen".to_owned(), + ]; + apply_window_mode(&mut windowed, WindowMode::Windowed); + assert_eq!(windowed, ["--username", "Player"]); +} + +#[test] +fn preferred_account_falls_back_to_the_active_account() { + let accounts = vec![ + crate::auth::Account { + uuid: "active".to_owned(), + username: "Active".to_owned(), + account_type: AccountType::Microsoft, + active: true, + refresh_token: None, + cached_mc_token: None, + cached_mc_token_expires_at: None, + }, + crate::auth::Account { + uuid: "preferred".to_owned(), + username: "Preferred".to_owned(), + account_type: AccountType::Offline, + active: false, + refresh_token: None, + cached_mc_token: None, + cached_mc_token_expires_at: None, + }, + ]; + + assert_eq!( + select_launch_account(&accounts, Some("preferred")).map(|account| account.uuid.as_str()), + Some("preferred") + ); + assert_eq!( + select_launch_account(&accounts, Some("removed")).map(|account| account.uuid.as_str()), + Some("active") + ); + assert_eq!( + select_launch_account(&accounts, None).map(|account| account.uuid.as_str()), + Some("active") + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn launch_commands_receive_instance_environment() { + use chrono::Utc; + + let temp = tempfile::tempdir().unwrap(); + let minecraft = temp.path().join("minecraft"); + std::fs::create_dir_all(&minecraft).unwrap(); + let mut config = InstanceConfig { + name: "Hook Test".to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Vanilla, + loader_version: None, + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), + resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, + config_sync_profile: None, + modpack_source: None, + }; + config + .environment + .insert("CUSTOM_VALUE".to_owned(), "available".to_owned()); + let invocation = LaunchInvocation { + java: "/usr/bin/java".to_owned(), + jvm_args: vec!["-Xmx2G".to_owned()], + classpath: Vec::new(), + classpath_string: String::new(), + main_class: String::new(), + extra_args: Vec::new(), + game_args: Vec::new(), + environment: config.environment.clone(), + working_dir: minecraft.clone(), + }; + let command = LaunchCommand { + enabled: true, + command: "printf '%s|%s' \"$CUSTOM_VALUE\" \"$INST_NAME\" > hook-result".to_owned(), + }; + + run_launch_command("Pre-launch", &command, &config, &invocation, temp.path()) + .await + .unwrap(); + + assert_eq!( + std::fs::read_to_string(minecraft.join("hook-result")).unwrap(), + "available|Hook Test" + ); +} + // exercises the early-return branch of migrate_legacy_meta_if_needed. // a profile with either arguments or minecraftArguments is not legacy // and must produce Ok(None) without touching the network. covers both @@ -203,7 +319,13 @@ async fn migrate_legacy_loader_profile_skips_modern_with_inherits_from() { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }; @@ -252,7 +374,13 @@ async fn migrate_legacy_loader_profile_skips_fabric() { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }; diff --git a/src/instance/tests/manager.rs b/src/instance/tests/manager.rs index c33ab4e..3c786f8 100644 --- a/src/instance/tests/manager.rs +++ b/src/instance/tests/manager.rs @@ -27,7 +27,13 @@ fn dummy_config(name: &str) -> InstanceConfig { memory_max: None, memory_min: None, jvm_args: vec![], + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } diff --git a/src/instance/tests/models.rs b/src/instance/tests/models.rs index 88eb681..ff5f47f 100644 --- a/src/instance/tests/models.rs +++ b/src/instance/tests/models.rs @@ -16,7 +16,13 @@ fn instance_config_roundtrips_through_json() { memory_max: Some("4G".to_string()), memory_min: Some("512M".to_string()), jvm_args: vec![], + environment: Default::default(), + window_mode: Default::default(), resolution: Some((1920, 1080)), + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: Some(crate::instance::ProviderProject { provider: "modrinth".to_owned(), @@ -47,6 +53,12 @@ fn instance_config_accepts_numeric_memory() { let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); assert_eq!(parsed.memory_max.as_deref(), Some("8G")); assert_eq!(parsed.memory_min.as_deref(), Some("512M")); + assert!(parsed.environment.is_empty()); + assert_eq!(parsed.window_mode, WindowMode::Windowed); + assert_eq!(parsed.preferred_account, None); + assert_eq!(parsed.pre_launch_command, LaunchCommand::default()); + assert_eq!(parsed.post_exit_command, LaunchCommand::default()); + assert_eq!(parsed.glfw_path, None); } #[test] diff --git a/src/storage.rs b/src/storage.rs index 81b42d3..21a398b 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -104,6 +104,10 @@ impl MetadataPaths { self.cache().join("loaders").join("profiles") } + pub fn java_installations(&self) -> PathBuf { + self.cache().join("java").join("installations.json") + } + pub fn provider_cache(&self, provider: &str) -> PathBuf { self.cache().join("providers").join(provider) } diff --git a/src/tests/storage.rs b/src/tests/storage.rs index ae50dc3..1ae54f3 100644 --- a/src/tests/storage.rs +++ b/src/tests/storage.rs @@ -32,6 +32,10 @@ fn metadata_paths_separate_state_and_cache() { paths.versions(), PathBuf::from("/meta/cache/minecraft/versions") ); + assert_eq!( + paths.java_installations(), + PathBuf::from("/meta/cache/java/installations.json") + ); assert_eq!( paths.migration_journal(), PathBuf::from("/meta/state/migration.json") diff --git a/src/tui/app.rs b/src/tui/app.rs index 3c00e89..20c31b4 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -19,6 +19,8 @@ use crate::instance::{InstanceConfig, InstanceManager}; // so the main loop can pick them up without blocking pub(super) static PENDING_INSTANCES: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); +pub(super) static FAILED_INSTANCE_SETTINGS_UPDATES: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); pub struct App { pub(super) exit: bool, @@ -43,6 +45,7 @@ pub struct App { pub(super) account_state: widgets::account::AccountState, pub(super) settings_state: widgets::settings::SettingsState, pub(super) instance_settings: Option, + pub(super) pending_instance_settings_updates: HashSet, pub(super) global_settings: Option, pub(super) picker: ratatui_image::picker::Picker, pub(super) instance_manager: InstanceManager, @@ -176,6 +179,7 @@ impl App { account_state: widgets::account::AccountState::default(), settings_state, instance_settings: None, + pending_instance_settings_updates: HashSet::new(), global_settings: None, screenshots_state: { let mut s = widgets::screenshots_grid::ScreenshotsState::default(); diff --git a/src/tui/event.rs b/src/tui/event.rs index 28293f1..ece7018 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -10,7 +10,7 @@ use ratatui::{ use std::time::Duration; use super::Tui; -use super::app::{App, FocusedArea, PENDING_INSTANCES}; +use super::app::{App, FAILED_INSTANCE_SETTINGS_UPDATES, FocusedArea, PENDING_INSTANCES}; use super::widgets::{self, popups::import_modpack, popups::new_instance}; use crate::feedback::errors as error_buffer; use crate::feedback::progress; @@ -43,6 +43,7 @@ impl App { // every content type has its own pending queue because they each // get scanned/loaded on separate tokio tasks self.drain_pending_instances(); + self.drain_failed_instance_settings_updates(); self.instances_state.drain_modpack_updates(); self.drain_pending_last_played(); if let Some(state) = self.modpack_versions_state.as_mut() { @@ -628,6 +629,9 @@ impl App { Ok(updated) => updated, Err(error) => { progress::clear(); + if let Ok(mut failed) = FAILED_INSTANCE_SETTINGS_UPDATES.lock() { + failed.push(previous.name.clone()); + } error_buffer::push_error(error_buffer::ErrorEvent { id: 0, level: tracing::Level::ERROR, @@ -878,6 +882,14 @@ impl App { fn drain_pending_instances(&mut self) { if let Ok(mut pending) = PENDING_INSTANCES.lock() { for config in pending.drain(..) { + let settings_update = self.pending_instance_settings_updates.remove(&config.name); + if settings_update + && let Some(state) = self.instance_settings.as_mut() + && state.runtime_update_pending_for(&config.name) + { + let desktop = crate::instance::desktop::exists(&config.name); + state.mark_saved(&config, desktop); + } self.forget_instance_content(&config.name); widgets::instances::spawn_modpack_update_check(&config); if self @@ -895,6 +907,19 @@ impl App { } } + fn drain_failed_instance_settings_updates(&mut self) { + if let Ok(mut failed) = FAILED_INSTANCE_SETTINGS_UPDATES.lock() { + for name in failed.drain(..) { + self.pending_instance_settings_updates.remove(&name); + if let Some(state) = self.instance_settings.as_mut() + && state.runtime_update_pending_for(&name) + { + state.cancel_runtime_change(); + } + } + } + } + pub(super) fn forget_instance_content(&mut self, instance_name: &str) { for state in [ &mut self.mods_state, diff --git a/src/tui/input.rs b/src/tui/input.rs index 027adf3..c1bcca7 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -205,7 +205,31 @@ impl App { } Some(confirm_popup::ConfirmTarget::Account { index, .. }) => { let count = self.account_state.store.accounts.len(); + let removed_uuid = self + .account_state + .store + .accounts + .get(index) + .map(|account| account.uuid.clone()); self.account_state.store.remove(index); + if let Some(removed_uuid) = removed_uuid { + for instance in &mut self.instances_state.instances { + if instance.preferred_account.as_deref() + == Some(removed_uuid.as_str()) + { + instance.preferred_account = None; + if let Err(error) = self.instance_manager.save(instance) { + error_buffer::push_message( + tracing::Level::ERROR, + format!( + "Failed to reset preferred account for '{}': {error}", + instance.name + ), + ); + } + } + } + } if count > 1 { self.account_state.list_state.selected = Some(index.min( self.account_state.store.accounts.len().saturating_sub(1), @@ -268,43 +292,51 @@ impl App { .as_mut() .and_then(|state| state.confirmed_save()); if let Some((updated, desktop)) = confirmed { - self.apply_instance_settings(*updated, desktop, true); + self.apply_instance_settings(*updated, desktop); } self.focused } - Some(confirm_popup::ConfirmTarget::JvmArguments { .. }) => { - self.focused = FocusedArea::InstanceSettings; - let confirmed = self.instance_settings.as_mut().and_then(|state| { - state.clear_jvm_args(); - state.confirmed_save() - }); - if let Some((updated, desktop)) = confirmed { - self.apply_instance_settings(*updated, desktop, false); - } - self.focused - } - Some(confirm_popup::ConfirmTarget::JavaAuto { instance, .. }) => { - if instance.is_some() { - self.focused = FocusedArea::InstanceSettings; - let confirmed = self.instance_settings.as_mut().and_then(|state| { - state.enable_auto_java(); - state.confirmed_save() - }); - if let Some((updated, desktop)) = confirmed { - self.apply_instance_settings(*updated, desktop, false); + Some(confirm_popup::ConfirmTarget::AutomaticSelection { + setting, + instance, + .. + }) => { + match setting { + confirm_popup::AutomaticSetting::Java if instance.is_some() => { + self.focused = FocusedArea::InstanceSettings; + let confirmed = + self.instance_settings.as_mut().and_then(|state| { + state.enable_auto_java(); + state.confirmed_save() + }); + if let Some((updated, desktop)) = confirmed { + self.apply_instance_settings(*updated, desktop); + } } - } else { - self.focused = FocusedArea::GlobalSettings; - let action = self.global_settings.as_mut().map( - widgets::popups::global_settings::State::confirm_auto_java, - ); - if let Some(widgets::popups::global_settings::Action::Save( - config, - theme, - border, - )) = action - { - self.apply_global_settings(*config, theme, border); + confirm_popup::AutomaticSetting::Account => { + self.focused = FocusedArea::InstanceSettings; + let confirmed = + self.instance_settings.as_mut().and_then(|state| { + state.enable_auto_account(); + state.confirmed_save() + }); + if let Some((updated, desktop)) = confirmed { + self.apply_instance_settings(*updated, desktop); + } + } + confirm_popup::AutomaticSetting::Java => { + self.focused = FocusedArea::GlobalSettings; + let action = self.global_settings.as_mut().map( + widgets::popups::global_settings::State::confirm_auto_java, + ); + if let Some(widgets::popups::global_settings::Action::Save( + config, + theme, + border, + )) = action + { + self.apply_global_settings(*config, theme, border); + } } } self.focused @@ -331,10 +363,9 @@ impl App { } FocusedArea::InstanceSettings } - Some(confirm_popup::ConfirmTarget::JvmArguments { .. }) => { - FocusedArea::InstanceSettings - } - Some(confirm_popup::ConfirmTarget::JavaAuto { instance, .. }) => { + Some(confirm_popup::ConfirmTarget::AutomaticSelection { + instance, .. + }) => { if instance.is_some() { FocusedArea::InstanceSettings } else { @@ -629,15 +660,7 @@ impl App { self.instances_state.selected_instance(), ) { widgets::settings::SettingsAction::OpenInstance => { - if let Some(instance) = self.instances_state.selected_instance() { - self.pre_overlay_focused = FocusedArea::Settings; - self.instance_settings = - Some(widgets::popups::instance_settings::State::new( - instance, - &self.instance_manager.meta_dir, - )); - self.focused = FocusedArea::InstanceSettings; - } + self.open_instance_settings(FocusedArea::Settings); return Ok(()); } widgets::settings::SettingsAction::OpenGlobal => { @@ -717,11 +740,10 @@ impl App { pushed_at: std::time::Instant::now(), }); } - widgets::popups::global_settings::Action::ConfirmJavaAuto { from, to } => { - confirm_popup::set_pending(confirm_popup::ConfirmTarget::JavaAuto { + widgets::popups::global_settings::Action::ConfirmJavaAuto => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::AutomaticSelection { + setting: confirm_popup::AutomaticSetting::Java, instance: None, - from, - to, }); self.focused = FocusedArea::ConfirmDelete; } @@ -757,6 +779,9 @@ impl App { pushed_at: std::time::Instant::now(), }); } + widgets::popups::instance_settings::Action::Warning(message) => { + error_buffer::push_message(tracing::Level::WARN, message); + } widgets::popups::instance_settings::Action::Close => { self.instance_settings = None; self.focused = self.pre_overlay_focused; @@ -774,7 +799,7 @@ impl App { self.focused = self.pre_overlay_focused; } widgets::popups::instance_settings::Action::Save(updated, desktop) => { - self.apply_instance_settings(*updated, desktop, false); + self.apply_instance_settings(*updated, desktop); } widgets::popups::instance_settings::Action::ConfirmRuntime { name } => { error_buffer::push_message( @@ -786,15 +811,17 @@ impl App { }); self.focused = FocusedArea::ConfirmDelete; } - widgets::popups::instance_settings::Action::ConfirmClearJvmArgs { name } => { - confirm_popup::set_pending(confirm_popup::ConfirmTarget::JvmArguments { name }); + widgets::popups::instance_settings::Action::ConfirmJavaAuto { name } => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::AutomaticSelection { + setting: confirm_popup::AutomaticSetting::Java, + instance: Some(name), + }); self.focused = FocusedArea::ConfirmDelete; } - widgets::popups::instance_settings::Action::ConfirmJavaAuto { name, from, to } => { - confirm_popup::set_pending(confirm_popup::ConfirmTarget::JavaAuto { + widgets::popups::instance_settings::Action::ConfirmAccountAuto { name } => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::AutomaticSelection { + setting: confirm_popup::AutomaticSetting::Account, instance: Some(name), - from, - to, }); self.focused = FocusedArea::ConfirmDelete; } @@ -886,15 +913,7 @@ impl App { KeyCode::Char('A') => self.focused = FocusedArea::Account, KeyCode::Char('S') => self.focused = FocusedArea::Settings, KeyCode::Char('E') => { - if let Some(instance) = self.instances_state.selected_instance() { - self.pre_overlay_focused = self.focused; - self.instance_settings = - Some(widgets::popups::instance_settings::State::new( - instance, - &self.instance_manager.meta_dir, - )); - self.focused = FocusedArea::InstanceSettings; - } + self.open_instance_settings(self.focused); } KeyCode::Char('G') => { self.pre_overlay_focused = self.focused; @@ -2096,6 +2115,26 @@ impl App { } } + fn open_instance_settings(&mut self, return_focus: FocusedArea) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let mut state = widgets::popups::instance_settings::State::with_accounts( + &instance, + &self.instance_manager.meta_dir, + self.account_state.store.accounts.clone(), + ); + if self + .pending_instance_settings_updates + .contains(&instance.name) + { + state.mark_runtime_update_pending(); + } + self.pre_overlay_focused = return_focus; + self.instance_settings = Some(state); + self.focused = FocusedArea::InstanceSettings; + } + fn apply_global_settings( &mut self, config: crate::config::Config, @@ -2115,7 +2154,6 @@ impl App { &mut self, updated: crate::instance::models::InstanceConfig, desktop: bool, - close: bool, ) { let Some(previous) = self.instances_state.selected_instance().cloned() else { return; @@ -2131,12 +2169,16 @@ impl App { message: "Stop the instance before changing its runtime".to_owned(), pushed_at: std::time::Instant::now(), }); + if let Some(state) = self.instance_settings.as_mut() { + state.cancel_runtime_change(); + } return; } + self.pending_instance_settings_updates + .insert(previous.name.clone()); self.spawn_instance_settings_update(previous, updated, desktop); - if close { - self.instance_settings = None; - self.focused = self.pre_overlay_focused; + if let Some(state) = self.instance_settings.as_mut() { + state.mark_runtime_update_pending(); } return; } @@ -2148,22 +2190,22 @@ impl App { } else { crate::instance::desktop::remove(&updated.name) }; - if let Err(error) = shortcut_result { - error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message: format!("Instance saved, but shortcut update failed: {error}"), - pushed_at: std::time::Instant::now(), - }); - } + let saved_desktop = match shortcut_result { + Ok(()) => desktop, + Err(error) => { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: format!("Instance saved, but shortcut update failed: {error}"), + pushed_at: std::time::Instant::now(), + }); + crate::instance::desktop::exists(&updated.name) + } + }; self.instances_state .replace_instance(&previous.name, updated.clone()); if let Some(state) = self.instance_settings.as_mut() { - state.mark_saved(&updated, desktop); - } - if close { - self.instance_settings = None; - self.focused = self.pre_overlay_focused; + state.mark_saved(&updated, saved_desktop); } } Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index 12ffa58..de048dd 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -57,6 +57,57 @@ fn completed_background_instance_is_drained_into_the_ui() { assert_eq!(ui.app.mods_state.loaded_for.as_deref(), Some("Pending")); } +#[test] +fn runtime_settings_update_keeps_the_editor_open_and_handles_results() { + let mut ui = UiHarness::new(); + ui.add_instance("Runtime Settings"); + ui.key(crossterm::event::KeyCode::Char('E')); + let state = ui.app.instance_settings.as_mut().unwrap(); + state.draft.game_version = "1.21.2".to_owned(); + state.mark_runtime_update_pending(); + ui.app + .pending_instance_settings_updates + .insert("Runtime Settings".to_owned()); + + ui.key(crossterm::event::KeyCode::Esc); + assert!(ui.app.instance_settings.is_none()); + ui.key(crossterm::event::KeyCode::Char('E')); + assert!( + ui.app + .instance_settings + .as_ref() + .unwrap() + .runtime_update_pending_for("Runtime Settings") + ); + + let mut updated = ui.app.instances_state.selected_instance().unwrap().clone(); + updated.game_version = "1.21.2".to_owned(); + PENDING_INSTANCES.lock().unwrap().push(updated); + ui.app.drain_pending_instances(); + + let state = ui.app.instance_settings.as_ref().unwrap(); + assert_eq!(state.draft.game_version, "1.21.2"); + assert!(!state.runtime_update_pending_for("Runtime Settings")); + assert!(ui.app.pending_instance_settings_updates.is_empty()); + + let state = ui.app.instance_settings.as_mut().unwrap(); + state.draft.game_version = "1.21.3".to_owned(); + state.mark_runtime_update_pending(); + ui.app + .pending_instance_settings_updates + .insert("Runtime Settings".to_owned()); + FAILED_INSTANCE_SETTINGS_UPDATES + .lock() + .unwrap() + .push("Runtime Settings".to_owned()); + ui.app.drain_failed_instance_settings_updates(); + + let state = ui.app.instance_settings.as_ref().unwrap(); + assert_eq!(state.draft.game_version, "1.21.2"); + assert!(!state.runtime_update_pending_for("Runtime Settings")); + assert!(ui.app.pending_instance_settings_updates.is_empty()); +} + #[test] fn structural_settings_update_repairs_runtime_before_persisting() { use sha1::{Digest, Sha1}; @@ -115,7 +166,13 @@ fn structural_settings_update_repairs_runtime_before_persisting() { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }; @@ -208,7 +265,13 @@ fn failed_structural_settings_update_keeps_previous_config() { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }; diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index e7704f1..a65c8ea 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -559,7 +559,13 @@ fn deleting_a_required_library_warns_but_can_continue() { #[test] fn confirmed_account_delete_updates_the_account_panel() { let mut ui = UiHarness::new(); + ui.add_instance("preferred-account-test"); ui.add_account("Player"); + ui.app.instances_state.instances[0].preferred_account = Some("Player".to_owned()); + ui.app + .instance_manager + .save(&ui.app.instances_state.instances[0]) + .unwrap(); ui.app.focused = FocusedArea::Account; ui.key(KeyCode::Char('d')); @@ -568,6 +574,22 @@ fn confirmed_account_delete_updates_the_account_panel() { assert_eq!(ui.app.focused, FocusedArea::Account); assert!(ui.app.account_state.store.accounts.is_empty()); assert_eq!(ui.app.account_state.list_state.selected, None); + assert_eq!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .preferred_account, + None + ); + assert_eq!( + ui.app + .instance_manager + .load_one("preferred-account-test") + .unwrap() + .preferred_account, + None + ); } #[test] @@ -597,7 +619,7 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert!(ui.screen().contains("Fabric")); assert!(ui.screen().contains("Forge")); ui.key(KeyCode::Esc); - for _ in 0..5 { + for _ in 0..6 { ui.key(KeyCode::Down); } ui.key(KeyCode::Enter); @@ -645,7 +667,7 @@ fn runtime_settings_use_the_shared_confirmation_popup() { .draft .game_version = "1.21.2".to_owned(); - for _ in 0..4 { + for _ in 0..5 { ui.key(KeyCode::Char('j')); } ui.key(KeyCode::Char('l')); @@ -682,23 +704,19 @@ fn runtime_settings_use_the_shared_confirmation_popup() { } #[test] -fn clearing_jvm_arguments_requires_confirmation_and_autosaves() { +fn jvm_arguments_use_the_same_editor_controls_as_environment() { let mut ui = UiHarness::new(); ui.add_instance("jvm-clear-test"); ui.key(KeyCode::Char('E')); ui.app.instance_settings.as_mut().unwrap().draft.jvm_args = vec!["-XX:+UseG1GC".to_owned(), "-Xss1M".to_owned()]; - for _ in 0..6 { + for _ in 0..7 { ui.key(KeyCode::Char('j')); } ui.key(KeyCode::Char('d')); - assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); - assert!(matches!( - confirm::pending_target(), - Some(confirm::ConfirmTarget::JvmArguments { name }) if name == "jvm-clear-test" - )); + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); assert_eq!( ui.app .instance_settings @@ -709,18 +727,8 @@ fn clearing_jvm_arguments_requires_confirmation_and_autosaves() { .len(), 2 ); - - ui.key(KeyCode::Enter); - - assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); - assert!( - ui.app - .instances_state - .selected_instance() - .unwrap() - .jvm_args - .is_empty() - ); + ui.draw(); + assert!(!ui.screen().contains("[d] clear")); } #[test] @@ -730,7 +738,7 @@ fn enabling_a_different_automatic_java_requires_confirmation() { ui.key(KeyCode::Char('E')); ui.app.instance_settings.as_mut().unwrap().draft.java_path = Some("/custom/java".to_owned()); - for _ in 0..3 { + for _ in 0..4 { ui.key(KeyCode::Char('j')); } ui.key(KeyCode::Char('a')); @@ -738,14 +746,19 @@ fn enabling_a_different_automatic_java_requires_confirmation() { assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); assert!(matches!( confirm::pending_target(), - Some(confirm::ConfirmTarget::JavaAuto { + Some(confirm::ConfirmTarget::AutomaticSelection { + setting: confirm::AutomaticSetting::Java, instance: Some(name), .. }) if name == "java-auto-test" )); ui.draw(); assert!(ui.screen().contains("Enable automatic Java")); - assert!(ui.screen().contains("Java runtime will change")); + assert!( + ui.screen() + .contains("Use the automatically selected Java runtime") + ); + assert!(!ui.screen().contains("/custom/java")); ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); @@ -774,6 +787,72 @@ fn enabling_a_different_automatic_java_requires_confirmation() { ); } +#[test] +fn enabling_a_different_automatic_account_requires_confirmation() { + let mut ui = UiHarness::new(); + ui.add_instance("account-auto-test"); + ui.add_account("Active"); + ui.app + .account_state + .store + .accounts + .push(crate::auth::Account { + uuid: "preferred".to_owned(), + username: "Preferred".to_owned(), + account_type: crate::auth::AccountType::Microsoft, + active: false, + refresh_token: Some("refresh".to_owned()), + cached_mc_token: None, + cached_mc_token_expires_at: None, + }); + ui.app.instances_state.instances[0].preferred_account = Some("preferred".to_owned()); + ui.app + .instance_manager + .save(&ui.app.instances_state.instances[0]) + .unwrap(); + ui.key(KeyCode::Char('E')); + for _ in 0..3 { + ui.key(KeyCode::Char('j')); + } + + ui.key(KeyCode::Char('a')); + + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::AutomaticSelection { + setting: confirm::AutomaticSetting::Account, + instance: Some(name), + }) if name == "account-auto-test" + )); + ui.draw(); + assert!(ui.screen().contains("Enable automatic account")); + assert!(ui.screen().contains("Use the currently active account")); + assert!(!ui.screen().contains("Preferred → Active")); + + ui.key(KeyCode::Esc); + assert_eq!( + ui.app + .instance_settings + .as_ref() + .unwrap() + .draft + .preferred_account + .as_deref(), + Some("preferred") + ); + ui.key(KeyCode::Char('a')); + ui.key(KeyCode::Enter); + assert_eq!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .preferred_account, + None + ); +} + #[test] fn instance_settings_validation_errors_use_the_toast_buffer() { let mut ui = UiHarness::new(); @@ -796,13 +875,36 @@ fn instance_settings_validation_errors_use_the_toast_buffer() { ); } +#[test] +fn enabling_an_empty_launch_hook_uses_a_warning_toast() { + let mut ui = UiHarness::new(); + ui.add_instance("empty-hook-test"); + ui.key(KeyCode::Char('E')); + for _ in 0..13 { + ui.key(KeyCode::Char('j')); + } + + ui.key(KeyCode::Char(' ')); + + assert!( + crate::feedback::errors::ERROR_EVENTS + .lock() + .unwrap() + .iter() + .any(|event| { + event.level == tracing::Level::WARN + && event.message == "Enter a pre-launch command before enabling it" + }) + ); +} + #[test] fn settings_use_java_memory_and_resolution_controls() { let mut ui = UiHarness::new(); ui.add_instance("controls-test"); ui.key(KeyCode::Char('E')); - for _ in 0..3 { + for _ in 0..4 { ui.key(KeyCode::Char('j')); } ui.key(KeyCode::Enter); @@ -815,6 +917,7 @@ fn settings_use_java_memory_and_resolution_controls() { assert!(!ui.screen().contains("Manual")); ui.key(KeyCode::Esc); + ui.app.instance_settings.as_mut().unwrap().draft.memory_min = Some("512M".to_owned()); ui.key(KeyCode::Char('j')); ui.key(KeyCode::Char('l')); assert_eq!( @@ -837,7 +940,7 @@ fn settings_use_java_memory_and_resolution_controls() { Some("1G") ); - for _ in 0..3 { + for _ in 0..6 { ui.key(KeyCode::Char('j')); } ui.key(KeyCode::Enter); @@ -863,6 +966,58 @@ fn settings_use_java_memory_and_resolution_controls() { ui.key(KeyCode::Esc); } +#[test] +fn instance_launch_options_autosave_through_the_editor() { + let mut ui = UiHarness::new(); + ui.add_instance("launch-options-test"); + ui.add_account("Player"); + ui.key(KeyCode::Char('E')); + + for _ in 0..3 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Enter); + ui.key(KeyCode::Enter); + + for _ in 0..5 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Enter); + for character in "MESA_LOADER_DRIVER_OVERRIDE=zink".chars() { + ui.key(KeyCode::Char(character)); + } + ui.key(KeyCode::Enter); + + ui.key(KeyCode::Char('j')); + ui.key(KeyCode::Char('j')); + ui.key(KeyCode::Enter); + + ui.key(KeyCode::Char('k')); + ui.draw(); + assert!(ui.screen().contains("Java")); + ui.key(KeyCode::Char('c')); + for character in "/opt/lib/libglfw.so.3".chars() { + ui.key(KeyCode::Char(character)); + } + ui.key(KeyCode::Enter); + + let saved = ui + .app + .instance_manager + .load_one("launch-options-test") + .unwrap(); + assert_eq!( + saved + .environment + .get("MESA_LOADER_DRIVER_OVERRIDE") + .map(String::as_str), + Some("zink") + ); + assert_eq!(saved.window_mode, crate::instance::WindowMode::Fullscreen); + assert_eq!(saved.preferred_account.as_deref(), Some("Player")); + assert_eq!(saved.glfw_path.as_deref(), Some("/opt/lib/libglfw.so.3")); +} + #[test] fn settings_panel_keeps_direct_profile_management() { let mut ui = UiHarness::new(); diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 695cf5a..88f28bd 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -26,6 +26,10 @@ impl UiHarness { pub fn new() -> Self { let guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); widgets::popups::confirm::clear_pending(); + crate::tui::app::FAILED_INSTANCE_SETTINGS_UPDATES + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); widgets::popups::new_instance::reset_for_test(); widgets::popups::import_modpack::reset_for_test(); crate::feedback::errors::ERROR_EVENTS @@ -84,6 +88,7 @@ impl UiHarness { account_state, settings_state: widgets::settings::SettingsState::new(meta_dir), instance_settings: None, + pending_instance_settings_updates: Default::default(), global_settings: None, picker, instance_manager, @@ -129,7 +134,13 @@ impl UiHarness { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }; diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 3869577..9a8ecb1 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -43,7 +43,13 @@ fn instance(name: &str, version: &str) -> InstanceConfig { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } diff --git a/src/tui/tests/widgets/instances.rs b/src/tui/tests/widgets/instances.rs index f680f4d..1f26344 100644 --- a/src/tui/tests/widgets/instances.rs +++ b/src/tui/tests/widgets/instances.rs @@ -47,7 +47,13 @@ fn synthetic_instance(name: &str) -> InstanceConfig { memory_max: None, memory_min: None, jvm_args: vec![], + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 68dfc47..854d5f3 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -48,23 +48,40 @@ pub enum ConfirmTarget { InstanceRuntime { name: String, }, - JvmArguments { - name: String, - }, - JavaAuto { + AutomaticSelection { + setting: AutomaticSetting, instance: Option, - from: String, - to: String, }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutomaticSetting { + Java, + Account, +} + +impl AutomaticSetting { + fn title(self) -> &'static str { + match self { + Self::Java => " Enable automatic Java ", + Self::Account => " Enable automatic account ", + } + } + + fn description(self) -> &'static str { + match self { + Self::Java => "Use the automatically selected Java runtime", + Self::Account => "Use the currently active account", + } + } +} + impl ConfirmTarget { fn title(&self) -> String { match self { Self::OrphanDependencies { .. } => " Remove unused dependencies ".to_owned(), Self::InstanceRuntime { .. } => " Change runtime ".to_owned(), - Self::JvmArguments { .. } => " Clear JVM arguments ".to_owned(), - Self::JavaAuto { .. } => " Enable automatic Java ".to_owned(), + Self::AutomaticSelection { setting, .. } => setting.title().to_owned(), _ => format!(" Delete '{}' ", self.name()), } } @@ -104,10 +121,7 @@ impl ConfirmTarget { .collect::>() .join("\n"), ConfirmTarget::InstanceRuntime { .. } => "Apply this runtime change".to_owned(), - ConfirmTarget::JvmArguments { .. } => "Remove all JVM arguments".to_owned(), - ConfirmTarget::JavaAuto { from, to, .. } => { - format!("Java runtime will change:\n{from} → {to}") - } + ConfirmTarget::AutomaticSelection { setting, .. } => setting.description().to_owned(), } } @@ -119,8 +133,9 @@ impl ConfirmTarget { ConfirmTarget::Content { name, .. } => name, ConfirmTarget::OrphanDependencies { .. } => "unused dependencies", ConfirmTarget::InstanceRuntime { name, .. } => name, - ConfirmTarget::JvmArguments { name, .. } => name, - ConfirmTarget::JavaAuto { instance, .. } => instance.as_deref().unwrap_or("launcher"), + ConfirmTarget::AutomaticSelection { instance, .. } => { + instance.as_deref().unwrap_or("launcher") + } } } @@ -129,8 +144,7 @@ impl ConfirmTarget { Self::Content { dependents, .. } if !dependents.is_empty() => " delete anyway", Self::OrphanDependencies { .. } => " remove all", Self::InstanceRuntime { .. } => " change", - Self::JvmArguments { .. } => " clear", - Self::JavaAuto { .. } => " enable", + Self::AutomaticSelection { .. } => " enable", _ => " confirm", } } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index f116176..0a16297 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -11,7 +11,7 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Clear, ListItem, Paragraph}, }; -use ratatui_textarea::{CursorMove, TextArea}; +use ratatui_textarea::TextArea; use crate::{ config::{ @@ -20,8 +20,9 @@ use crate::{ }, instance::models::normalize_memory_value, tui::widgets::popups::settings_controls::{ - JavaChoice, JavaPicker, adjust_memory, auto_label, handle_text_area_input, memory_kib, - render_memory_gauge, + JavaChoice, JavaPicker, SettingsPickerAction, adjust_memory, auto_label, + handle_text_area_input, memory_kib, render_memory_gauge, render_settings_picker, + settings_text_area, }, }; @@ -43,7 +44,7 @@ pub enum Action { None, Save(Box, String, BorderStyle), Error(String), - ConfirmJavaAuto { from: String, to: String }, + ConfirmJavaAuto, OpenRaw(std::path::PathBuf), Close, } @@ -56,8 +57,14 @@ impl State { .iter() .position(|candidate| candidate == &theme.theme) .unwrap_or(0); + let config = crate::config::SETTINGS.read().clone(); + let java_cache = crate::storage::MetadataPaths::new(config.paths.resolve_meta_dir()) + .java_installations(); + let mut java_picker = + JavaPicker::with_cache(crate::instance::java::detect_java_path(), Some(java_cache)); + java_picker.open(config.paths.java_path.as_deref()); Self { - config: crate::config::SETTINGS.read().clone(), + config, theme, selected: 0, editing: None, @@ -67,7 +74,7 @@ impl State { theme_picker: false, theme_index, java_picker_open: false, - java_picker: JavaPicker::new(), + java_picker, } } @@ -85,15 +92,14 @@ impl State { fn display_value(&self, field: usize) -> String { match field { 1 => format!("{:?}", self.theme.border_style).to_lowercase(), - 4 if self - .config - .paths - .java_path - .as_deref() - .is_none_or(str::is_empty) => - { - self.java_picker.detected_path().to_owned() - } + 4 => self.java_picker.display_label( + self.config + .paths + .java_path + .as_deref() + .filter(|path| !path.is_empty()) + .unwrap_or_else(|| self.java_picker.detected_path()), + ), _ => self.value(field), } } @@ -107,7 +113,7 @@ impl State { self.error = None; let invalid = |state: &mut Self, message: String| { state.error = Some(message); - state.editing = Some(new_text_area(editor.lines().to_vec())); + state.editing = Some(settings_text_area(editor.lines().to_vec())); }; match self.selected { 2 | 3 if normalize_memory_value(value).is_none() => invalid( @@ -206,8 +212,8 @@ impl State { self.error = None; return Action::None; }; - if let Some((from, to)) = self.java_picker.automatic_change(current) { - return Action::ConfirmJavaAuto { from, to }; + if self.java_picker.automatic_change(current) { + return Action::ConfirmJavaAuto; } self.enable_auto_java(); Action::None @@ -231,16 +237,9 @@ impl State { fn handle_java_picker_key(&mut self, key: &KeyEvent) { self.java_picker.initialize(); - let count = self.java_picker.labels().len(); - match key.code { - KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.java_picker_open = false, - KeyCode::Char('j') | KeyCode::Down if count > 0 => { - self.java_picker.selected = (self.java_picker.selected + 1).min(count - 1); - } - KeyCode::Char('k') | KeyCode::Up => { - self.java_picker.selected = self.java_picker.selected.saturating_sub(1); - } - KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { + match self.java_picker.selection_mut().handle_key(key) { + SettingsPickerAction::Back => self.java_picker_open = false, + SettingsPickerAction::Select => { match self.java_picker.selected_choice() { JavaChoice::Installation(path) => { if self.config.paths.java_path.as_deref() != Some(&path) { @@ -251,7 +250,7 @@ impl State { } self.java_picker_open = false; } - _ => {} + SettingsPickerAction::None => {} } } @@ -328,14 +327,14 @@ impl State { 0 => self.theme_picker = true, 1 => self.cycle_border(true), 2 | 3 => { - self.editing = Some(new_text_area(vec![self.value(self.selected)])); + self.editing = Some(settings_text_area(vec![self.value(self.selected)])); } 4 => self.open_java_picker(), - field => self.editing = Some(new_text_area(vec![self.value(field)])), + field => self.editing = Some(settings_text_area(vec![self.value(field)])), }, KeyCode::Char('a') if self.selected == 4 => return self.toggle_auto_java(), KeyCode::Char('c') if self.selected == 4 => { - self.editing = Some(new_text_area(vec![self.value(4)])); + self.editing = Some(settings_text_area(vec![self.value(4)])); } KeyCode::Char('E') => { let file = if self.selected <= 1 { @@ -382,21 +381,6 @@ fn available_themes() -> Vec { themes } -fn new_text_area(lines: Vec) -> TextArea<'static> { - let theme = THEME.as_ref(); - let mut editor = TextArea::new(if lines.is_empty() { - vec![String::new()] - } else { - lines - }); - editor.set_style(Style::default().fg(theme.text()).bg(theme.surface())); - editor.set_cursor_line_style(Style::default()); - editor.set_cursor_style(Style::default().fg(theme.background()).bg(theme.accent())); - editor.move_cursor(CursorMove::Bottom); - editor.move_cursor(CursorMove::End); - editor -} - pub fn popup_rect(area: Rect, state: &State) -> Rect { let height = if state.theme_picker || state.java_picker_open { (area.height * 2 / 3).max(10) @@ -508,12 +492,7 @@ fn render_java_picker(frame: &mut Frame, area: Rect, state: &mut State) { Err(error) => crate::feedback::errors::push_message(tracing::Level::ERROR, error), } } - super::select_list::render_styled( - state.java_picker.items(), - state.java_picker.selected, - list_area, - frame.buffer_mut(), - ); + render_settings_picker(state.java_picker.selection(), list_area, frame.buffer_mut()); } fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { @@ -667,7 +646,7 @@ mod tests { assert!(matches!( state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), - Action::ConfirmJavaAuto { .. } + Action::ConfirmJavaAuto )); assert_eq!( state.config.paths.java_path.as_deref(), diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 2f70925..9b0ab84 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -11,29 +11,40 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Clear, ListItem, Paragraph}, }; -use ratatui_textarea::{CursorMove, TextArea}; -use std::sync::{Arc, Mutex}; +use ratatui_textarea::TextArea; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; use crate::{ + auth::{Account, AccountType}, config::{ SETTINGS, theme::{BORDER_STYLE, THEME}, }, instance::loader::GameVersion, - instance::models::{InstanceConfig, ModLoader, normalize_memory_value, parse_resolution}, + instance::models::{ + InstanceConfig, LaunchCommand, ModLoader, WindowMode, normalize_memory_value, + parse_resolution, + }, tui::widgets::{ popups::{ LoadState, settings_controls::{ - DisplayResolution, JavaChoice, JavaPicker, adjust_memory, auto_label, + DisplayResolution, GlfwChoice, GlfwPicker, JavaChoice, JavaPicker, SettingsPicker, + SettingsPickerAction, SettingsPickerBadge, SettingsPickerOption, adjust_memory, + auto_label, bundled_glfw_version, bundled_label as bundled_badge, display_resolutions, handle_text_area_input, memory_kib, render_memory_gauge, + render_settings_picker, settings_text_area, }, }, search::SearchState, }, }; -const FIELD_COUNT: usize = 9; +const FIELD_COUNT: usize = 15; +const FIELD_ORDER: [usize; FIELD_COUNT] = [0, 1, 2, 10, 3, 4, 5, 6, 7, 14, 8, 9, 11, 12, 13]; type SharedLoad = Arc>>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -47,6 +58,8 @@ enum ChoicePicker { Loader, Java, Resolution, + Account, + Glfw, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -99,36 +112,49 @@ pub struct State { choice_picker: Option, choice_index: usize, java_picker: JavaPicker, + glfw_picker: GlfwPicker, + account_picker: SettingsPicker, + accounts: Vec, + meta_dir: std::path::PathBuf, display_resolutions: Vec, + runtime_update_pending: bool, } pub enum Action { None, Save(Box, bool), Error(String), - ConfirmRuntime { - name: String, - }, - ConfirmClearJvmArgs { - name: String, - }, - ConfirmJavaAuto { - name: String, - from: String, - to: String, - }, + Warning(String), + ConfirmRuntime { name: String }, + ConfirmJavaAuto { name: String }, + ConfirmAccountAuto { name: String }, OpenRaw, Close, } impl State { - pub fn new(instance: &InstanceConfig, _meta_dir: &std::path::Path) -> Self { + pub fn new(instance: &InstanceConfig, meta_dir: &std::path::Path) -> Self { + Self::with_accounts( + instance, + meta_dir, + crate::auth::AccountStore::load().accounts, + ) + } + + pub fn with_accounts( + instance: &InstanceConfig, + meta_dir: &std::path::Path, + accounts: Vec, + ) -> Self { let auto_java_path = SETTINGS .read() .paths .effective_java_path() .map(str::to_owned) .unwrap_or_else(crate::instance::java::detect_java_path); + let java_cache = crate::storage::MetadataPaths::new(meta_dir).java_installations(); + let mut java_picker = JavaPicker::with_cache(auto_java_path, Some(java_cache)); + java_picker.open(instance.java_path.as_deref()); Self { original: instance.clone(), draft: instance.clone(), @@ -146,8 +172,16 @@ impl State { loader_versions: Arc::new(Mutex::new(LoadState::Idle)), choice_picker: None, choice_index: 0, - java_picker: JavaPicker::with_auto_path(auto_java_path), + java_picker, + glfw_picker: GlfwPicker::with_bundled_version(bundled_glfw_version( + meta_dir, + &instance.game_version, + )), + account_picker: SettingsPicker::default(), + accounts, + meta_dir: meta_dir.to_path_buf(), display_resolutions: display_resolutions(), + runtime_update_pending: false, } } @@ -190,6 +224,14 @@ impl State { ) && min > max { self.error = Some("Minimum memory cannot exceed maximum memory.".to_owned()); + } else if self.draft.pre_launch_command.enabled + && self.draft.pre_launch_command.command.trim().is_empty() + { + self.error = Some("Enter a pre-launch command before enabling it.".to_owned()); + } else if self.draft.post_exit_command.enabled + && self.draft.post_exit_command.command.trim().is_empty() + { + self.error = Some("Enter a post-exit command before enabling it.".to_owned()); } self.error.is_none() } @@ -204,11 +246,23 @@ impl State { 5 => self.draft.memory_max.clone().unwrap_or_default(), 6 => self.draft.jvm_args.join(" "), 7 => self + .draft + .environment + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(" "), + 8 => self.draft.window_mode.to_string(), + 9 => self .draft .resolution .map(|(w, h)| format!("{w}x{h}")) .unwrap_or_default(), - 8 => if self.desktop { "yes" } else { "no" }.to_owned(), + 10 => self.draft.preferred_account.clone().unwrap_or_default(), + 11 => if self.desktop { "yes" } else { "no" }.to_owned(), + 12 => self.draft.pre_launch_command.command.clone(), + 13 => self.draft.post_exit_command.command.clone(), + 14 => self.draft.glfw_path.clone().unwrap_or_default(), _ => String::new(), } } @@ -216,21 +270,119 @@ impl State { fn display_value(&self, field: usize) -> String { match field { 2 if self.draft.loader == ModLoader::Vanilla => "not applicable".to_owned(), - 3 if self.draft.java_path.is_none() => self.java_picker.detected_path().to_owned(), + 3 => self.java_picker.display_label( + self.draft + .java_path + .as_deref() + .unwrap_or_else(|| self.java_picker.detected_path()), + ), 4 if self.draft.memory_min.is_none() => SETTINGS.read().defaults.memory_min.clone(), 5 if self.draft.memory_max.is_none() => SETTINGS.read().defaults.memory_max.clone(), 6 if self.draft.jvm_args.is_empty() => "no arguments".to_owned(), 6 => self.draft.jvm_args.join(" "), - 7 if self.draft.resolution.is_none() => self.default_resolution().map_or_else( + 7 if self.draft.environment.is_empty() => "no variables".to_owned(), + 7 => self.value(7), + 9 if self.draft.resolution.is_none() => self.default_resolution().map_or_else( || "not detected".to_owned(), |(width, height)| format!("{width}x{height}"), ), - 8 if self.desktop => "enabled".to_owned(), - 8 => "disabled".to_owned(), + 10 => self.preferred_account_label(), + 11 if self.desktop => "enabled".to_owned(), + 11 => "disabled".to_owned(), + 12 if self.draft.pre_launch_command.command.is_empty() => "no command".to_owned(), + 13 if self.draft.post_exit_command.command.is_empty() => "no command".to_owned(), + 14 => self + .glfw_picker + .display_label(self.draft.glfw_path.as_deref()), _ => self.value(field).replace('\n', " ↵ "), } } + fn preferred_account_label(&self) -> String { + if let Some(username) = self + .draft + .preferred_account + .as_deref() + .and_then(|uuid| self.accounts.iter().find(|account| account.uuid == uuid)) + .map(|account| account.username.clone()) + { + return username; + } + self.accounts + .iter() + .find(|account| account.active) + .map(|account| account.username.clone()) + .unwrap_or_else(|| "no active account".to_owned()) + } + + fn account_is_auto(&self) -> bool { + self.draft + .preferred_account + .as_deref() + .is_none_or(|uuid| !self.accounts.iter().any(|account| account.uuid == uuid)) + } + + fn launch_command(&self, field: usize) -> Option<&LaunchCommand> { + match field { + 12 => Some(&self.draft.pre_launch_command), + 13 => Some(&self.draft.post_exit_command), + _ => None, + } + } + + fn launch_command_mut(&mut self, field: usize) -> Option<&mut LaunchCommand> { + match field { + 12 => Some(&mut self.draft.pre_launch_command), + 13 => Some(&mut self.draft.post_exit_command), + _ => None, + } + } + + fn toggle_selected_command(&mut self) -> Action { + let phase = if self.selected == 12 { + "pre-launch" + } else { + "post-exit" + }; + let Some(command) = self.launch_command_mut(self.selected) else { + return Action::None; + }; + if !command.enabled && command.command.trim().is_empty() { + return Action::Warning(format!("Enter a {phase} command before enabling it")); + } + command.enabled = !command.enabled; + Action::None + } + + fn sync_account_picker(&mut self) { + let preferred = self + .draft + .preferred_account + .as_deref() + .filter(|uuid| self.accounts.iter().any(|account| account.uuid == *uuid)) + .or_else(|| { + self.accounts + .iter() + .find(|account| account.active) + .map(|account| account.uuid.as_str()) + }); + let automatic = self.account_is_auto(); + let options = self + .accounts + .iter() + .map(|account| SettingsPickerOption { + key: account.uuid.clone(), + title: account.username.clone(), + detail: (account.account_type == AccountType::Offline) + .then(|| "(Offline)".to_owned()), + leading: Some(if account.active { "▸ " } else { " " }.to_owned()), + active: account.active, + badge: (automatic && account.active).then_some(SettingsPickerBadge::Auto), + }) + .collect(); + self.account_picker.sync(options, preferred); + } + fn begin_edit(&mut self) { match self.selected { 0 => self.open_game_picker(), @@ -241,33 +393,58 @@ impl State { 2 => self.open_loader_picker(), 3 => self.open_choice_picker(ChoicePicker::Java), 4 | 5 => { - self.editing = Some(new_text_area(vec![self.effective_memory(self.selected)])); + self.editing = Some(settings_text_area(vec![ + self.effective_memory(self.selected), + ])); + } + 6 => self.editing = Some(settings_text_area(vec![self.value(self.selected)])), + 7 => self.editing = Some(settings_text_area(vec![self.value(7)])), + 8 => { + self.draft.window_mode = match self.draft.window_mode { + WindowMode::Windowed => WindowMode::Fullscreen, + WindowMode::Fullscreen => WindowMode::Windowed, + }; + } + 9 => self.open_choice_picker(ChoicePicker::Resolution), + 10 => self.open_choice_picker(ChoicePicker::Account), + 11 => self.desktop = !self.desktop, + 12 | 13 => { + self.editing = Some(settings_text_area(vec![self.value(self.selected)])); } - 6 => self.editing = Some(new_text_area(vec![self.value(self.selected)])), - 7 => self.open_choice_picker(ChoicePicker::Resolution), - 8 => self.desktop = !self.desktop, - field => self.editing = Some(new_text_area(vec![self.value(field)])), + 14 => self.open_choice_picker(ChoicePicker::Glfw), + field => self.editing = Some(settings_text_area(vec![self.value(field)])), } } fn open_choice_picker(&mut self, picker: ChoicePicker) { self.choice_picker = Some(picker); - self.choice_index = match picker { - ChoicePicker::Loader => super::select_list::MOD_LOADERS - .iter() - .position(|loader| *loader == self.draft.loader) - .unwrap_or(0), + match picker { + ChoicePicker::Loader => { + self.choice_index = super::select_list::MOD_LOADERS + .iter() + .position(|loader| *loader == self.draft.loader) + .unwrap_or(0); + } ChoicePicker::Java => { self.java_picker.open(self.draft.java_path.as_deref()); self.java_picker.initialize(); - self.java_picker.selected } - ChoicePicker::Resolution => self - .resolution_choices() - .iter() - .position(|choice| choice.resolution() == self.draft.resolution) - .unwrap_or(0), - }; + ChoicePicker::Resolution => { + self.choice_index = self + .resolution_choices() + .iter() + .position(|choice| choice.resolution() == self.draft.resolution) + .unwrap_or(0); + } + ChoicePicker::Account => { + self.account_picker.reset(); + self.sync_account_picker(); + } + ChoicePicker::Glfw => { + self.glfw_picker.open(self.draft.glfw_path.as_deref()); + self.glfw_picker.initialize(); + } + } } fn choice_values(&self) -> Vec { @@ -287,13 +464,34 @@ impl State { .iter() .map(ResolutionChoice::label) .collect(), + ChoicePicker::Account => self.account_picker.labels(), + ChoicePicker::Glfw => self.glfw_picker.labels(), } } fn handle_choice_key(&mut self, key: &KeyEvent) { - if self.choice_picker == Some(ChoicePicker::Java) { - self.java_picker.initialize(); - self.choice_index = self.java_picker.selected; + let picker_action = match self.choice_picker { + Some(ChoicePicker::Java) => { + self.java_picker.initialize(); + Some(self.java_picker.selection_mut().handle_key(key)) + } + Some(ChoicePicker::Account) => { + self.sync_account_picker(); + Some(self.account_picker.handle_key(key)) + } + Some(ChoicePicker::Glfw) => { + self.glfw_picker.initialize(); + Some(self.glfw_picker.selection_mut().handle_key(key)) + } + _ => None, + }; + if let Some(action) = picker_action { + match action { + SettingsPickerAction::Back => self.choice_picker = None, + SettingsPickerAction::Select => self.apply_choice(), + SettingsPickerAction::None => {} + } + return; } let count = self.choice_values().len(); match key.code { @@ -311,9 +509,6 @@ impl State { KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.apply_choice(), _ => {} } - if self.choice_picker == Some(ChoicePicker::Java) { - self.java_picker.selected = self.choice_index; - } } fn apply_choice(&mut self) { @@ -330,12 +525,9 @@ impl State { open_loader_versions = loader != ModLoader::Vanilla; } } - Some(ChoicePicker::Java) => { - self.java_picker.selected = self.choice_index; - match self.java_picker.selected_choice() { - JavaChoice::Installation(path) => self.draft.java_path = Some(path), - } - } + Some(ChoicePicker::Java) => match self.java_picker.selected_choice() { + JavaChoice::Installation(path) => self.draft.java_path = Some(path), + }, Some(ChoicePicker::Resolution) => { let selected = self .resolution_choices() @@ -346,6 +538,16 @@ impl State { self.draft.resolution = Some(resolution); } } + Some(ChoicePicker::Account) => { + self.draft.preferred_account = + self.account_picker.selected_key().map(str::to_owned); + } + Some(ChoicePicker::Glfw) => { + self.draft.glfw_path = match self.glfw_picker.selected_choice() { + GlfwChoice::Bundled => None, + GlfwChoice::System(path) => Some(path), + }; + } None => {} } self.choice_picker = None; @@ -520,6 +722,19 @@ impl State { self.error = None; } + fn move_selection(&mut self, forward: bool) { + let position = FIELD_ORDER + .iter() + .position(|field| *field == self.selected) + .unwrap_or(0); + let position = if forward { + (position + 1).min(FIELD_ORDER.len() - 1) + } else { + position.saturating_sub(1) + }; + self.selected = FIELD_ORDER[position]; + } + fn resolution_choices(&self) -> Vec { resolution_choices(self.draft.resolution, &self.display_resolutions) } @@ -555,11 +770,9 @@ impl State { self.error = None; return Action::None; }; - if let Some((from, to)) = self.java_picker.automatic_change(current) { + if self.java_picker.automatic_change(current) { return Action::ConfirmJavaAuto { name: self.draft.name.clone(), - from, - to, }; } self.enable_auto_java(); @@ -571,6 +784,40 @@ impl State { self.error = None; } + fn toggle_auto_account(&mut self) -> Action { + if self.account_is_auto() { + if let Some(account) = self.accounts.iter().find(|account| account.active) { + self.draft.preferred_account = Some(account.uuid.clone()); + self.error = None; + } else { + self.error = Some("No active account is available.".to_owned()); + } + return Action::None; + } + let Some(preferred_uuid) = self.draft.preferred_account.as_deref() else { + return Action::None; + }; + let preferred = self + .accounts + .iter() + .find(|account| account.uuid == preferred_uuid); + let active = self.accounts.iter().find(|account| account.active); + if let (Some(preferred), Some(active)) = (preferred, active) + && preferred.uuid != active.uuid + { + return Action::ConfirmAccountAuto { + name: self.draft.name.clone(), + }; + } + self.enable_auto_account(); + Action::None + } + + pub fn enable_auto_account(&mut self) { + self.draft.preferred_account = None; + self.error = None; + } + fn close_version_picker(&mut self) { let cancel_runtime_change = self.picker == Some(VersionPicker::Loader) && self.runtime_changed() @@ -667,7 +914,7 @@ impl State { self.error = None; let invalid = |state: &mut Self, message: String| { state.error = Some(message); - state.editing = Some(new_text_area(editor.lines().to_vec())); + state.editing = Some(settings_text_area(editor.lines().to_vec())); }; match self.selected { 0 if value.is_empty() => invalid(self, "Game version is required.".to_owned()), @@ -683,16 +930,36 @@ impl State { 6 => { self.draft.jvm_args = value.split_whitespace().map(str::to_owned).collect(); } - 7 if value.is_empty() => self.draft.resolution = None, - 7 => match parse_resolution(value) { + 7 => match parse_environment(value) { + Ok(environment) => self.draft.environment = environment, + Err(error) => invalid(self, error), + }, + 9 if value.is_empty() => self.draft.resolution = None, + 9 => match parse_resolution(value) { Ok(resolution) => self.draft.resolution = Some(resolution), Err(error) => invalid(self, error), }, + 12 | 13 => { + if let Some(command) = self.launch_command_mut(self.selected) { + command.command = value.to_owned(); + if value.is_empty() { + command.enabled = false; + } + } + } + 14 => self.draft.glfw_path = (!value.is_empty()).then(|| value.to_owned()), _ => {} } } pub fn handle_key(&mut self, key: &KeyEvent) -> Action { + if self.runtime_update_pending { + return if key.code == KeyCode::Esc { + Action::Close + } else { + Action::None + }; + } let before = self.draft.clone(); let desktop_before = self.desktop; let action = self.handle_key_inner(key); @@ -756,10 +1023,8 @@ impl State { } match key.code { - KeyCode::Char('j') | KeyCode::Down => { - self.selected = (self.selected + 1).min(FIELD_COUNT - 1) - } - KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), + KeyCode::Char('j') | KeyCode::Down => self.move_selection(true), + KeyCode::Char('k') | KeyCode::Up => self.move_selection(false), KeyCode::Char('h') | KeyCode::Left if matches!(self.selected, 4 | 5) => { self.adjust_selected_memory(false); } @@ -770,18 +1035,20 @@ impl State { KeyCode::Char('d') if matches!(self.selected, 4 | 5) => { self.apply_default_memory(); } - KeyCode::Char('d') if self.selected == 7 => self.apply_default_resolution(), - KeyCode::Char('d') if self.selected == 6 && !self.draft.jvm_args.is_empty() => { - return Action::ConfirmClearJvmArgs { - name: self.draft.name.clone(), - }; - } - KeyCode::Char('c') if self.selected == 7 => { - self.editing = Some(new_text_area(vec![self.value(7)])); + KeyCode::Char('d') if self.selected == 9 => self.apply_default_resolution(), + KeyCode::Char('c') if self.selected == 9 => { + self.editing = Some(settings_text_area(vec![self.value(9)])); } KeyCode::Char('a') if self.selected == 3 => return self.toggle_auto_java(), KeyCode::Char('c') if self.selected == 3 => { - self.editing = Some(new_text_area(vec![self.value(3)])); + self.editing = Some(settings_text_area(vec![self.value(3)])); + } + KeyCode::Char('a') if self.selected == 10 => return self.toggle_auto_account(), + KeyCode::Char(' ') if matches!(self.selected, 12 | 13) => { + return self.toggle_selected_command(); + } + KeyCode::Char('c') if self.selected == 14 => { + self.editing = Some(settings_text_area(vec![self.value(14)])); } KeyCode::Char('E') => return Action::OpenRaw, KeyCode::Esc => return Action::Close, @@ -795,9 +1062,12 @@ impl State { .then(|| (Box::new(self.draft.clone()), self.desktop)) } - pub fn clear_jvm_args(&mut self) { - self.draft.jvm_args.clear(); - self.error = None; + pub fn mark_runtime_update_pending(&mut self) { + self.runtime_update_pending = true; + } + + pub fn runtime_update_pending_for(&self, name: &str) -> bool { + self.runtime_update_pending && self.draft.name == name } pub fn mark_saved(&mut self, saved: &InstanceConfig, desktop: bool) { @@ -805,6 +1075,9 @@ impl State { self.draft = saved.clone(); self.original_desktop = desktop; self.desktop = desktop; + self.glfw_picker + .set_bundled_version(bundled_glfw_version(&self.meta_dir, &saved.game_version)); + self.runtime_update_pending = false; self.error = None; } @@ -815,6 +1088,7 @@ impl State { self.game_versions = Arc::new(Mutex::new(LoadState::Idle)); self.loader_versions = Arc::new(Mutex::new(LoadState::Idle)); self.picker_initialized = false; + self.runtime_update_pending = false; self.error = None; } } @@ -850,34 +1124,46 @@ fn resolution_choices( choices } -fn new_text_area(lines: Vec) -> TextArea<'static> { - let theme = THEME.as_ref(); - let mut editor = TextArea::new(if lines.is_empty() { - vec![String::new()] - } else { - lines - }); - editor.set_style(Style::default().fg(theme.text()).bg(theme.surface())); - editor.set_cursor_line_style(Style::default()); - editor.set_cursor_style(Style::default().fg(theme.background()).bg(theme.accent())); - editor.move_cursor(CursorMove::Bottom); - editor.move_cursor(CursorMove::End); - editor +fn parse_environment(value: &str) -> Result, String> { + let mut environment = BTreeMap::new(); + for assignment in value.split_whitespace() { + let Some((key, value)) = assignment.split_once('=') else { + return Err(format!( + "Environment variable '{assignment}' must use KEY=value." + )); + }; + if key.is_empty() || key.contains('\0') || value.contains('\0') { + return Err("Environment variable names cannot be empty.".to_owned()); + } + if environment + .insert(key.to_owned(), value.to_owned()) + .is_some() + { + return Err(format!("Environment variable '{key}' is repeated.")); + } + } + Ok(environment) } pub fn popup_rect(area: Rect, state: &State) -> Rect { - let height = if state.picker.is_some() || state.choice_picker == Some(ChoicePicker::Java) { + let height = if state.picker.is_some() + || matches!( + state.choice_picker, + Some(ChoicePicker::Java | ChoicePicker::Glfw) + ) { (area.height * 2 / 3).max(10) } else if state.choice_picker.is_some() { 10 } else { - let form_width = (area.width * 58 / 100).saturating_sub(2); - 11 + jvm_row_count(state, form_width).saturating_sub(1) as u16 + let form_width = (area.width * 68 / 100).saturating_sub(2); + 24 + tagged_row_count(&state.draft.jvm_args, form_width).saturating_sub(1) as u16 + + tagged_row_count(&environment_labels(&state.draft.environment), form_width) + .saturating_sub(1) as u16 }; let width = match state.choice_picker { - Some(ChoicePicker::Java) => 72, + Some(ChoicePicker::Java | ChoicePicker::Glfw) => 72, Some(ChoicePicker::Resolution) => 64, - _ => 58, + _ => 68, }; area.centered( Constraint::Percentage(width), @@ -888,7 +1174,12 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { if state.choice_picker == Some(ChoicePicker::Java) { state.java_picker.initialize(); - state.choice_index = state.java_picker.selected; + } + if state.choice_picker == Some(ChoicePicker::Account) { + state.sync_account_picker(); + } + if state.choice_picker == Some(ChoicePicker::Glfw) { + state.glfw_picker.initialize(); } let theme = THEME.as_ref(); frame.render_widget(Clear, area); @@ -898,6 +1189,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { (_, Some(ChoicePicker::Loader)) => " Mod Loader ", (_, Some(ChoicePicker::Java)) => " Java Runtime ", (_, Some(ChoicePicker::Resolution)) => " Resolution ", + (_, Some(ChoicePicker::Account)) => " Preferred Account ", + (_, Some(ChoicePicker::Glfw)) => " GLFW Library ", _ => " Instance Settings ", }; let keybinds = if state.editing.is_some() { @@ -911,7 +1204,10 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ]) } else if state.picker.is_some() { super::keybind_line(&[("/", " search"), ("h", " back"), ("Enter", " select")]) - } else if state.choice_picker == Some(ChoicePicker::Java) { + } else if matches!( + state.choice_picker, + Some(ChoicePicker::Java | ChoicePicker::Account | ChoicePicker::Glfw) + ) { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if state.choice_picker == Some(ChoicePicker::Resolution) { super::keybind_line(&[("d", " default"), ("h", " back"), ("Enter", " select")]) @@ -931,18 +1227,18 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("c", " custom"), ("Esc", " back"), ]) - } else if state.selected == 7 { + } else if state.selected == 9 { super::keybind_line(&[ ("Enter", " presets"), ("c", " custom"), ("d", " default"), ("Esc", " back"), ]) - } else if state.selected == 6 { + } else if state.selected == 10 { super::keybind_line(&[ - ("j/k", ""), - ("Enter", " edit"), - ("d", " clear"), + ("Enter", " accounts"), + ("a", " auto"), + ("E", " raw"), ("Esc", " back"), ]) } else if matches!(state.selected, 0..=2) { @@ -952,13 +1248,30 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("E", " raw"), ("Esc", " back"), ]) - } else if state.selected == 8 { + } else if matches!(state.selected, 8 | 11) { super::keybind_line(&[ ("j/k", ""), ("Enter", " toggle"), ("E", " raw"), ("Esc", " back"), ]) + } else if matches!(state.selected, 12 | 13) { + let enabled = state + .launch_command(state.selected) + .is_some_and(|command| command.enabled); + super::keybind_line(&[ + ("Enter", " command"), + ("Space", if enabled { " disable" } else { " enable" }), + ("E", " raw"), + ("Esc", " back"), + ]) + } else if state.selected == 14 { + super::keybind_line(&[ + ("Enter", " libraries"), + ("c", " custom"), + ("E", " raw"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -996,28 +1309,86 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); - let labels = [ - "Game version", - "Loader", - "Loader version", - "Java", - "Memory min", - "Memory max", - "JVM args", - "Resolution", - "Desktop shortcut", + let sections: [(&str, &[usize]); 4] = [ + ("Game", &[0, 1, 2, 10]), + ("Java", &[3, 4, 5, 6, 7, 14]), + ("Window", &[8, 9, 11]), + ("Commands", &[12, 13]), ]; - let mut y = area.y; - for (index, label) in labels.iter().enumerate() { - let lines = if index == 6 { - jvm_field_lines(state, area.width) - } else { - vec![field_line(state, index, label)] - }; + let mut rows: Vec<(Option, Vec>)> = Vec::new(); + for (section_index, (title, fields)) in sections.iter().enumerate() { + if section_index > 0 { + rows.push((None, vec![Line::default()])); + } + rows.push((None, vec![section_line(title)])); + for index in *fields { + let label = field_label(*index); + let lines = match index { + 6 => tagged_field_lines( + state, + 6, + label, + &state.draft.jvm_args, + "no arguments", + area.width, + ), + 7 => tagged_field_lines( + state, + 7, + label, + &environment_labels(&state.draft.environment), + "no variables", + area.width, + ), + 12 | 13 => command_field_lines(state, *index, label), + _ => vec![field_line(state, *index, label)], + }; + rows.push((Some(*index), lines)); + } + } + + let mut selected_end = 0u16; + let mut cursor = 0u16; + for (field, lines) in &rows { + let height = lines.len() as u16; + if *field == Some(state.selected) { + selected_end = cursor.saturating_add(height); + } + cursor = cursor.saturating_add(height); + } + let scroll = if selected_end > area.height { + selected_end.saturating_sub(area.height) + } else { + 0 + }; + + let mut row_start = 0u16; + for (field, lines) in rows { let height = lines.len() as u16; - let row_area = Rect { y, height, ..area }; + let row_end = row_start.saturating_add(height); + if row_end <= scroll || row_start >= scroll.saturating_add(area.height) { + row_start = row_end; + continue; + } + let skip = scroll.saturating_sub(row_start) as usize; + let y = area.y.saturating_add(row_start.saturating_sub(scroll)); + let visible_height = (height.saturating_sub(skip as u16)) + .min(area.y.saturating_add(area.height).saturating_sub(y)); + let row_area = Rect { + y, + height: visible_height, + ..area + }; + let selected = field == Some(state.selected); frame.render_widget( - Paragraph::new(lines).style(Style::default().bg(if state.selected == index { + Paragraph::new( + lines + .into_iter() + .skip(skip) + .take(visible_height as usize) + .collect::>(), + ) + .style(Style::default().bg(if selected { theme.stripe() } else { theme.surface() @@ -1025,13 +1396,15 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { row_area, ); - if matches!(index, 4 | 5) { + if let Some(index @ (4 | 5)) = field + && row_start >= scroll + { let value = state.effective_memory(index); render_memory_gauge( frame, Rect { x: area.x.saturating_add(20), - y, + y: area.y.saturating_add(row_start.saturating_sub(scroll)), width: area.width.saturating_sub(21), height: 1, }, @@ -1040,28 +1413,67 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { state.selected == index, ); } - if state.selected == index + if field == Some(state.selected) + && row_start >= scroll && let Some(editor) = state.editing.as_ref() { + let (edit_x, edit_offset) = if matches!(field, Some(12 | 13)) { + (22, 0) + } else { + (20, 0) + }; frame.render_widget( editor, Rect { - x: area.x.saturating_add(20), - y, - width: area.width.saturating_sub(20), + x: area.x.saturating_add(edit_x), + y: area + .y + .saturating_add(row_start.saturating_sub(scroll)) + .saturating_add(edit_offset), + width: area.width.saturating_sub(edit_x), height: 1, }, ); } - y = y.saturating_add(height); + row_start = row_end; } } +fn field_label(index: usize) -> &'static str { + match index { + 0 => "Game version", + 1 => "Loader", + 2 => "Loader version", + 3 => "Java runtime", + 4 => "Memory min", + 5 => "Memory max", + 6 => "JVM args", + 7 => "Environment", + 8 => "Window mode", + 9 => "Resolution", + 10 => "Preferred account", + 11 => "Desktop shortcut", + 12 => "Pre-launch", + 13 => "Post-exit", + 14 => "GLFW", + _ => "", + } +} + +fn section_line(title: &str) -> Line<'static> { + Line::from(Span::styled( + format!(" {title}"), + Style::default() + .fg(THEME.as_ref().text()) + .add_modifier(Modifier::BOLD), + )) +} + fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { let theme = THEME.as_ref(); let selected = index == state.selected; let editing = selected && state.editing.is_some(); - let value = if editing || matches!(index, 4..=6) { + let value = if editing || matches!(index, 4..=7) { String::new() } else { state.display_value(index) @@ -1078,7 +1490,7 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { Span::styled( value, Style::default() - .fg(if index == 8 { + .fg(if index == 11 { if state.desktop { theme.text() } else { @@ -1099,19 +1511,91 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { if index == 3 && state.draft.java_path.is_none() && !editing { spans.extend([Span::raw(" "), auto_label()]); } + if index == 10 && state.account_is_auto() { + spans.extend([Span::raw(" "), auto_label()]); + } + if index == 14 && state.draft.glfw_path.is_none() && !editing { + spans.extend([Span::raw(" "), bundled_badge()]); + } Line::from(spans) } -fn jvm_field_lines(state: &State, width: u16) -> Vec> { +fn command_field_lines(state: &State, index: usize, label: &str) -> Vec> { + let theme = THEME.as_ref(); + let selected = state.selected == index; + let enabled = state + .launch_command(index) + .is_some_and(|command| command.enabled); + let label_style = Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }); + let checkbox_style = Style::default() + .fg(if enabled { + theme.success() + } else { + theme.text_dim() + }) + .add_modifier(if enabled { + Modifier::BOLD + } else { + Modifier::empty() + }); + let mut spans = vec![ + Span::styled( + if selected { "▌ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled(if enabled { "[✓] " } else { "[ ] " }, checkbox_style), + Span::styled(format!("{label:<16}"), label_style), + ]; + if selected && state.editing.is_some() { + return vec![Line::from(spans)]; + } + let command = state.display_value(index); + spans.push(Span::styled( + command, + Style::default() + .fg(if !enabled || state.value(index).is_empty() { + theme.text_dim() + } else if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected && !state.value(index).is_empty() { + Modifier::BOLD + } else { + Modifier::empty() + }), + )); + vec![Line::from(spans)] +} + +fn tagged_field_lines( + state: &State, + index: usize, + label: &str, + values: &[String], + empty: &str, + width: u16, +) -> Vec> { let theme = THEME.as_ref(); - let selected = state.selected == 6; - let mut prefix = field_line(state, 6, "JVM args").spans; + let selected = state.selected == index; + let mut prefix = field_line(state, index, label).spans; if selected && state.editing.is_some() { return vec![Line::from(prefix)]; } - if state.draft.jvm_args.is_empty() { + if values.is_empty() { prefix.push(Span::styled( - "no arguments", + empty.to_owned(), Style::default().fg(theme.text_dim()), )); return vec![Line::from(prefix)]; @@ -1121,7 +1605,7 @@ fn jvm_field_lines(state: &State, width: u16) -> Vec> { let mut lines = Vec::new(); let mut spans = prefix; let mut used = 0usize; - for argument in &state.draft.jvm_args { + for argument in values { let badge_width = argument.chars().count() + 2; let separator = usize::from(used > 0); if used > 0 && used + separator + badge_width > available { @@ -1150,14 +1634,14 @@ fn jvm_field_lines(state: &State, width: u16) -> Vec> { lines } -fn jvm_row_count(state: &State, width: u16) -> usize { - if state.draft.jvm_args.is_empty() || state.selected == 6 && state.editing.is_some() { +fn tagged_row_count(values: &[String], width: u16) -> usize { + if values.is_empty() { return 1; } let available = width.saturating_sub(20) as usize; let mut rows = 1; let mut used = 0usize; - for argument in &state.draft.jvm_args { + for argument in values { let badge_width = argument.chars().count() + 2; let separator = usize::from(used > 0); if used > 0 && used + separator + badge_width > available { @@ -1169,6 +1653,13 @@ fn jvm_row_count(state: &State, width: u16) -> usize { rows } +fn environment_labels(environment: &BTreeMap) -> Vec { + environment + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect() +} + fn render_choice_picker(frame: &mut Frame, area: Rect, state: &mut State) { let theme = THEME.as_ref(); let mut list_area = area; @@ -1187,11 +1678,28 @@ fn render_choice_picker(frame: &mut Frame, area: Rect, state: &mut State) { Err(error) => crate::feedback::errors::push_message(tracing::Level::ERROR, error), } } + if state.choice_picker == Some(ChoicePicker::Glfw) + && let Some(status) = state.glfw_picker.take_status() + { + frame.render_widget( + Paragraph::new(status).style(Style::default().fg(theme.text_dim())), + Rect { height: 1, ..area }, + ); + list_area.y = list_area.y.saturating_add(1); + list_area.height = list_area.height.saturating_sub(1); + } + if state.choice_picker == Some(ChoicePicker::Account) && state.accounts.is_empty() { + frame.render_widget( + Paragraph::new("No accounts.").style(Style::default().fg(theme.text_dim())), + list_area, + ); + return; + } let items = match state.choice_picker { - Some(ChoicePicker::Java) => state.java_picker.items(), Some(ChoicePicker::Resolution) => { resolution_items(&state.resolution_choices(), state.choice_index) } + Some(ChoicePicker::Java | ChoicePicker::Account | ChoicePicker::Glfw) => Vec::new(), _ => state .choice_values() .iter() @@ -1203,10 +1711,15 @@ fn render_choice_picker(frame: &mut Frame, area: Rect, state: &mut State) { }) .collect(), }; - if matches!( - state.choice_picker, - Some(ChoicePicker::Java | ChoicePicker::Resolution) - ) { + let settings_picker = match state.choice_picker { + Some(ChoicePicker::Java) => Some(state.java_picker.selection()), + Some(ChoicePicker::Account) => Some(&state.account_picker), + Some(ChoicePicker::Glfw) => Some(state.glfw_picker.selection()), + _ => None, + }; + if let Some(picker) = settings_picker { + render_settings_picker(picker, list_area, frame.buffer_mut()); + } else if state.choice_picker == Some(ChoicePicker::Resolution) { super::select_list::render_styled(items, state.choice_index, list_area, frame.buffer_mut()); } else { super::select_list::render(items, state.choice_index, list_area, frame.buffer_mut()); @@ -1346,7 +1859,13 @@ mod tests { memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } @@ -1357,12 +1876,12 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); state.selected = 4; - state.editing = Some(new_text_area(vec!["2048m".to_owned()])); + state.editing = Some(settings_text_area(vec!["2048m".to_owned()])); state.commit_edit(); assert_eq!(state.draft.memory_min.as_deref(), Some("2048M")); - state.selected = 7; - state.editing = Some(new_text_area(vec!["1920X1080".to_owned()])); + state.selected = 9; + state.editing = Some(settings_text_area(vec!["1920X1080".to_owned()])); state.commit_edit(); assert_eq!(state.draft.resolution, Some((1920, 1080))); } @@ -1435,7 +1954,7 @@ mod tests { let mut config = instance(); config.config_sync_profile = Some("shared".to_owned()); let mut state = State::new(&config, temp.path()); - state.selected = 8; + state.selected = 11; let Action::Save(config, _) = state.handle_key(&KeyEvent::from(KeyCode::Enter)) else { panic!("expected settings save"); @@ -1509,7 +2028,7 @@ mod tests { assert_eq!(state.picker, Some(VersionPicker::Loader)); state.picker = None; - state.selected = 8; + state.selected = 11; let desktop = state.desktop; state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert_ne!(state.desktop, desktop); @@ -1539,7 +2058,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); - state.selected = 7; + state.selected = 9; state.begin_edit(); assert!(state.editing.is_none()); assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); @@ -1593,7 +2112,7 @@ mod tests { )); assert_eq!(state.draft.java_path, None); - state.selected = 7; + state.selected = 9; state.display_resolutions = vec![DisplayResolution { width: 2560, height: 1440, @@ -1640,7 +2159,7 @@ mod tests { assert_eq!(state.draft.memory_min.as_deref(), Some("6G")); assert_eq!(state.draft.memory_max.as_deref(), Some("6G")); - let desktop = state.display_value(8); + let desktop = state.display_value(11); assert!(matches!(desktop.as_str(), "enabled" | "disabled")); assert!(!desktop.contains('●') && !desktop.contains('○')); } @@ -1677,7 +2196,7 @@ mod tests { primary: true, }]; - assert_eq!(state.display_value(7), "2560x1440"); + assert_eq!(state.display_value(9), "2560x1440"); } #[test] @@ -1736,10 +2255,152 @@ mod tests { .collect(); let state = State::new(&config, temp.path()); - let lines = jvm_field_lines(&state, 48); + let lines = tagged_field_lines( + &state, + 6, + "JVM args", + &state.draft.jvm_args, + "no arguments", + 48, + ); assert!(lines.len() > 1); - assert_eq!(jvm_row_count(&state, 48), lines.len()); + assert_eq!(tagged_row_count(&state.draft.jvm_args, 48), lines.len()); assert!(lines.iter().any(|line| line.to_string().contains("second"))); } + + #[test] + fn environment_variables_are_parsed_and_validated() { + assert_eq!( + parse_environment("MESA_LOADER_DRIVER_OVERRIDE=zink FOO=bar=baz") + .unwrap() + .get("FOO") + .map(String::as_str), + Some("bar=baz") + ); + assert!(parse_environment("MISSING_VALUE").is_err()); + assert!(parse_environment("FOO=one FOO=two").is_err()); + } + + #[test] + fn window_mode_and_commands_autosave() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.selected = 8; + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + assert_eq!(state.draft.window_mode, WindowMode::Fullscreen); + + let saved = state.draft.clone(); + state.mark_saved(&saved, state.desktop); + state.selected = 12; + state.editing = Some(settings_text_area(vec!["echo ready".to_owned()])); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + let saved = state.draft.clone(); + state.mark_saved(&saved, state.desktop); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char(' '))), + Action::Save(..) + )); + assert!(state.draft.pre_launch_command.enabled); + + let saved = state.draft.clone(); + state.mark_saved(&saved, state.desktop); + state.editing = Some(settings_text_area(vec![String::new()])); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + assert!(state.draft.pre_launch_command.command.is_empty()); + assert!(!state.draft.pre_launch_command.enabled); + } + + #[test] + fn preferred_account_picker_uses_accounts_and_main_row_auto() { + let temp = tempfile::tempdir().unwrap(); + let accounts = vec![Account { + uuid: "account-id".to_owned(), + username: "Player".to_owned(), + account_type: AccountType::Microsoft, + active: true, + refresh_token: None, + cached_mc_token: None, + cached_mc_token_expires_at: None, + }]; + let mut state = State::with_accounts(&instance(), temp.path(), accounts); + state.selected = 10; + state.begin_edit(); + assert_eq!(state.choice_index, 0); + assert!( + !state + .choice_values() + .iter() + .any(|value| value == "Current active account") + ); + state.handle_choice_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.draft.preferred_account.as_deref(), Some("account-id")); + + state.toggle_auto_account(); + assert_eq!(state.draft.preferred_account, None); + + state.draft.preferred_account = Some("removed-account".to_owned()); + state.toggle_auto_account(); + assert_eq!(state.draft.preferred_account.as_deref(), Some("account-id")); + } + + #[test] + fn command_control_uses_one_compact_checkbox_row() { + assert_eq!(section_line("Commands").to_string(), " Commands"); + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.selected = 12; + + let lines = command_field_lines(&state, 12, "Pre-launch"); + assert_eq!(lines.len(), 1); + assert_eq!( + lines[0].to_string().trim(), + "▌ [ ] Pre-launch no command" + ); + assert!(!lines[0].to_string().contains("disabled")); + + state.draft.pre_launch_command.command = "echo ready".to_owned(); + state.draft.pre_launch_command.enabled = true; + let enabled = command_field_lines(&state, 12, "Pre-launch"); + assert!(enabled[0].to_string().contains("[✓] Pre-launch")); + assert!(enabled[0].to_string().ends_with("echo ready")); + } + + #[test] + fn java_and_custom_glfw_values_include_their_titles() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.draft.java_path = Some("/custom/java".to_owned()); + state.draft.glfw_path = Some("/usr/lib/libglfw.so.3.5".to_owned()); + + assert_eq!(state.display_value(3), "Java /custom/java"); + assert_eq!(state.display_value(14), "GLFW 3.5 /usr/lib/libglfw.so.3.5"); + } + + #[test] + fn glfw_picker_offers_bundled_and_custom_modes() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.selected = 14; + state.begin_edit(); + assert_eq!(state.choice_picker, Some(ChoicePicker::Glfw)); + assert!( + state + .choice_values() + .iter() + .any(|value| value.contains("GLFW")) + ); + state.handle_choice_key(&KeyEvent::from(KeyCode::Esc)); + state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); + assert!(state.editing.is_some()); + } } diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 37e291b..5450517 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -3,7 +3,11 @@ // Reusable interactive controls shared by instance and launcher settings. -use std::sync::{Arc, Mutex}; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ @@ -13,7 +17,7 @@ use ratatui::{ text::{Line, Span}, widgets::{LineGauge, ListItem, Paragraph}, }; -use ratatui_textarea::TextArea; +use ratatui_textarea::{CursorMove, TextArea}; use crate::{ config::theme::THEME, @@ -25,13 +29,162 @@ const MEMORY_STEPS: [&str; 12] = [ "512M", "1G", "2G", "3G", "4G", "6G", "8G", "12G", "16G", "24G", "32G", "64G", ]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SettingsPickerBadge { + Auto, + Bundled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SettingsPickerOption { + pub key: String, + pub title: String, + pub detail: Option, + pub leading: Option, + pub active: bool, + pub badge: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SettingsPickerAction { + None, + Back, + Select, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SettingsPicker { + options: Vec, + selected: usize, +} + +impl SettingsPicker { + pub(crate) fn reset(&mut self) { + self.options.clear(); + self.selected = 0; + } + + pub(crate) fn sync(&mut self, options: Vec, preferred: Option<&str>) { + let selected_key = self + .options + .get(self.selected) + .map(|option| option.key.clone()) + .or_else(|| preferred.map(str::to_owned)); + self.options = options; + self.selected = selected_key + .as_deref() + .and_then(|key| self.options.iter().position(|option| option.key == key)) + .unwrap_or(0); + } + + pub(crate) fn handle_key(&mut self, key: &KeyEvent) -> SettingsPickerAction { + match key.code { + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => SettingsPickerAction::Back, + KeyCode::Char('j') | KeyCode::Down if !self.options.is_empty() => { + self.selected = (self.selected + 1).min(self.options.len() - 1); + SettingsPickerAction::None + } + KeyCode::Char('k') | KeyCode::Up => { + self.selected = self.selected.saturating_sub(1); + SettingsPickerAction::None + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right if !self.options.is_empty() => { + SettingsPickerAction::Select + } + _ => SettingsPickerAction::None, + } + } + + pub(crate) fn selected(&self) -> usize { + self.selected + } + + pub(crate) fn selected_key(&self) -> Option<&str> { + self.options + .get(self.selected) + .map(|option| option.key.as_str()) + } + + pub(crate) fn labels(&self) -> Vec { + self.options + .iter() + .map(|option| { + option.detail.as_ref().map_or_else( + || option.title.clone(), + |detail| format!("{} {detail}", option.title), + ) + }) + .collect() + } + + pub(crate) fn items(&self) -> Vec> { + let theme = THEME.as_ref(); + self.options + .iter() + .enumerate() + .map(|(index, option)| { + let selected = index == self.selected; + let title_style = if selected { + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD) + } else if option.active { + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text()) + }; + let mut spans = Vec::new(); + if let Some(leading) = &option.leading { + spans.push(Span::styled( + leading.clone(), + Style::default().fg(theme.success()), + )); + } + spans.push(Span::styled(option.title.clone(), title_style)); + if let Some(detail) = &option.detail { + spans.push(Span::styled( + format!(" {detail}"), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text_dim() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + )); + } + if let Some(badge) = option.badge { + spans.extend([ + Span::raw(" "), + status_badge( + match badge { + SettingsPickerBadge::Auto => "Auto", + SettingsPickerBadge::Bundled => "Bundled", + }, + theme.success(), + ), + ]); + } + ListItem::new(Line::from(spans)) + }) + .collect() + } +} + #[derive(Debug, Clone)] pub(crate) struct JavaPicker { load: Arc>>>, current: Option, detected: String, - pub selected: usize, - previous_choices: Vec, + picker: SettingsPicker, + cache_path: Option, + refresh_started: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -39,6 +192,26 @@ pub(crate) enum JavaChoice { Installation(String), } +#[derive(Debug, Clone)] +pub(crate) struct GlfwPicker { + load: Arc>>>, + current: Option, + bundled_version: Option, + picker: SettingsPicker, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum GlfwChoice { + Bundled, + System(String), +} + +#[derive(Debug, Clone)] +struct GlfwInstallation { + path: PathBuf, + version: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DisplayResolution { pub width: u32, @@ -53,30 +226,63 @@ impl JavaPicker { } pub(crate) fn with_auto_path(detected: String) -> Self { + Self::with_cache(detected, None) + } + + pub(crate) fn with_cache(detected: String, cache_path: Option) -> Self { + let cached = cache_path + .as_deref() + .and_then(crate::instance::java::load_installation_cache); Self { - load: Arc::new(Mutex::new(LoadState::Idle)), + load: Arc::new(Mutex::new( + cached.map_or(LoadState::Idle, LoadState::Loaded), + )), current: None, detected, - selected: 0, - previous_choices: Vec::new(), + picker: SettingsPicker::default(), + cache_path, + refresh_started: false, } } pub(crate) fn open(&mut self, current: Option<&str>) { self.current = current.map(str::to_owned); - self.previous_choices.clear(); + self.picker.reset(); + if self.refresh_started { + return; + } + self.refresh_started = true; let mut load = self .load .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if !matches!(*load, LoadState::Idle | LoadState::Error(_)) { - return; + if matches!(*load, LoadState::Idle | LoadState::Error(_)) { + *load = LoadState::Loading; } - *load = LoadState::Loading; drop(load); let target = self.load.clone(); + let cache_path = self.cache_path.clone(); + let selected_paths = [Some(self.detected.clone()), self.current.clone()]; let discover = move || { - let installations = crate::instance::java::discover_installations(); + let mut installations = crate::instance::java::discover_installations(); + for path in selected_paths.into_iter().flatten() { + if installations.iter().any(|installation| { + same_executable(&installation.path.to_string_lossy(), &path) + }) { + continue; + } + if let Some(installation) = + crate::instance::java::inspect_installation(Path::new(&path)) + { + installations.push(installation); + } + } + if let Some(cache_path) = cache_path + && let Err(error) = + crate::instance::java::save_installation_cache(&cache_path, &installations) + { + tracing::debug!("Could not cache Java installations: {error}"); + } *target .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = @@ -86,10 +292,7 @@ impl JavaPicker { if let Ok(runtime) = tokio::runtime::Handle::try_current() { runtime.spawn_blocking(discover); } else { - *self - .load - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = LoadState::Loaded(Vec::new()); + self.refresh_started = false; } } @@ -106,18 +309,29 @@ impl JavaPicker { .map(|installation| installation.path.to_string_lossy().into_owned()), ); } - if !paths.contains(&self.detected) { + if let Some(index) = paths + .iter() + .position(|path| same_executable(path, &self.detected)) + { + paths[index].clone_from(&self.detected); + } else { paths.insert(0, self.detected.clone()); } - if let Some(current) = &self.current - && !paths.contains(current) - { - paths.push(current.clone()); + if let Some(current) = &self.current { + if let Some(index) = paths.iter().position(|path| same_executable(path, current)) { + paths[index].clone_from(current); + } else { + paths.push(current.clone()); + } } paths.into_iter().map(JavaChoice::Installation).collect() } pub(crate) fn labels(&self) -> Vec { + self.picker.labels() + } + + fn options(&self) -> Vec { let installations = match &*self .load .lock() @@ -129,62 +343,24 @@ impl JavaPicker { self.choices() .into_iter() .map(|choice| match choice { - JavaChoice::Installation(path) => installations - .as_ref() - .and_then(|items| { + JavaChoice::Installation(path) => { + let installation = installations.as_ref().and_then(|items| { items .iter() - .find(|item| item.path.to_string_lossy() == path) - }) - .map_or_else(|| format!("Java {path}"), JavaInstallation::label), - }) - .collect() - } - - pub(crate) fn items(&self) -> Vec> { - let theme = THEME.as_ref(); - let installations = match &*self - .load - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - { - LoadState::Loaded(installations) => installations.clone(), - _ => Vec::new(), - }; - self.choices() - .into_iter() - .enumerate() - .map(|(index, choice)| match choice { - JavaChoice::Installation(path) => { - let installation = installations - .iter() - .find(|installation| installation.path.to_string_lossy() == path); - let version = installation + .find(|item| same_executable(&item.path.to_string_lossy(), &path)) + }); + let title = installation .and_then(|installation| installation.version.as_deref()) - .map_or_else(|| "Java".to_owned(), |version| format!("Java {version}")); - let selected = index == self.selected; - let mut spans = vec![ - Span::styled( - version, - Style::default().fg(if selected { - theme.accent() - } else { - theme.text() - }), - ), - Span::styled( - format!(" {path}"), - Style::default().fg(if selected { - theme.accent() - } else { - theme.text_dim() - }), - ), - ]; - if self.current.is_none() && path == self.detected { - spans.extend([Span::raw(" "), auto_label()]); + .map_or_else(|| "Java".to_owned(), java_title); + SettingsPickerOption { + key: path.clone(), + title, + detail: Some(path.clone()), + leading: None, + active: false, + badge: (self.current.is_none() && path == self.detected) + .then_some(SettingsPickerBadge::Auto), } - ListItem::new(Line::from(spans)) } }) .collect() @@ -211,74 +387,353 @@ impl JavaPicker { } pub(crate) fn initialize(&mut self) { - let choices = self.choices(); - if choices == self.previous_choices { - return; - } - let selected = self - .previous_choices - .get(self.selected) - .cloned() - .or_else(|| { - Some(JavaChoice::Installation( - self.current - .clone() - .unwrap_or_else(|| self.detected.clone()), - )) - }) - .unwrap_or_else(|| JavaChoice::Installation(self.detected.clone())); - self.selected = choices - .iter() - .position(|choice| choice == &selected) - .unwrap_or(0); - self.previous_choices = choices; + let preferred = self + .current + .clone() + .unwrap_or_else(|| self.detected.clone()); + self.picker.sync(self.options(), Some(&preferred)); } pub(crate) fn selected_choice(&self) -> JavaChoice { - self.choices() - .get(self.selected) - .cloned() - .unwrap_or_else(|| JavaChoice::Installation(self.detected.clone())) + JavaChoice::Installation( + self.picker + .selected_key() + .unwrap_or(&self.detected) + .to_owned(), + ) + } + + pub(crate) fn selection(&self) -> &SettingsPicker { + &self.picker + } + + pub(crate) fn selection_mut(&mut self) -> &mut SettingsPicker { + &mut self.picker } pub(crate) fn detected_path(&self) -> &str { &self.detected } - pub(crate) fn automatic_change(&self, current: &str) -> Option<(String, String)> { - if same_executable(current, &self.detected) { - return None; + pub(crate) fn display_label(&self, path: &str) -> String { + let version = match &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(installations) => installations + .iter() + .find(|installation| same_executable(&installation.path.to_string_lossy(), path)) + .and_then(|installation| installation.version.clone()), + _ => None, + }; + java_runtime_label(path, version.as_deref()) + } + + pub(crate) fn automatic_change(&self, current: &str) -> bool { + !same_executable(current, &self.detected) + } +} + +impl Default for JavaPicker { + fn default() -> Self { + Self::new() + } +} + +impl GlfwPicker { + pub(crate) fn new() -> Self { + Self::with_bundled_version(None) + } + + pub(crate) fn with_bundled_version(bundled_version: Option) -> Self { + Self { + load: Arc::new(Mutex::new(LoadState::Idle)), + current: None, + bundled_version, + picker: SettingsPicker::default(), } + } - let load = self + pub(crate) fn open(&mut self, current: Option<&str>) { + self.current = current.map(str::to_owned); + self.picker.reset(); + let mut load = self .load .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let installations = match &*load { - LoadState::Loaded(installations) => installations, - _ => return Some((current.to_owned(), self.detected.clone())), + if !matches!(*load, LoadState::Idle | LoadState::Error(_)) { + return; + } + *load = LoadState::Loading; + drop(load); + let target = self.load.clone(); + let discover = move || { + let installations = discover_glfw_installations(); + *target + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + LoadState::Loaded(installations); + crate::feedback::request_redraw(); }; - let version_for = |path: &str| { - installations + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn_blocking(discover); + } else { + *self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = LoadState::Loaded(Vec::new()); + } + } + + pub(crate) fn set_bundled_version(&mut self, version: Option) { + self.bundled_version = version; + } + + pub(crate) fn choices(&self) -> Vec { + let mut choices = vec![GlfwChoice::Bundled]; + if let LoadState::Loaded(installations) = &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + choices.extend(installations.iter().map(|installation| { + GlfwChoice::System(installation.path.to_string_lossy().into_owned()) + })); + } + if let Some(current) = &self.current { + if let Some(choice) = choices.iter_mut().find(|choice| { + matches!(choice, GlfwChoice::System(path) if same_executable(path, current)) + }) { + *choice = GlfwChoice::System(current.clone()); + } else { + choices.push(GlfwChoice::System(current.clone())); + } + } + choices + } + + pub(crate) fn initialize(&mut self) { + let preferred = self.current.as_deref().unwrap_or(BUNDLED_GLFW_KEY); + self.picker.sync(self.options(), Some(preferred)); + } + + pub(crate) fn selected_choice(&self) -> GlfwChoice { + match self.picker.selected_key() { + Some(BUNDLED_GLFW_KEY) | None => GlfwChoice::Bundled, + Some(path) => GlfwChoice::System(path.to_owned()), + } + } + + pub(crate) fn labels(&self) -> Vec { + self.picker.labels() + } + + pub(crate) fn bundled_label(&self) -> String { + self.bundled_version.as_ref().map_or_else( + || "Minecraft GLFW".to_owned(), + |version| format!("LWJGL GLFW {version}"), + ) + } + + pub(crate) fn display_label(&self, path: Option<&str>) -> String { + let Some(path) = path else { + return self.bundled_label(); + }; + let detected_version = match &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(installations) => installations .iter() - .find(|installation| same_executable(&installation.path.to_string_lossy(), path)) - .and_then(|installation| installation.version.clone()) + .find(|installation| installation.path.to_string_lossy() == path) + .and_then(|installation| installation.version.clone()), + _ => None, + }; + detected_version + .or_else(|| glfw_version_from_path(Path::new(path))) + .map_or_else( + || format!("System GLFW {path}"), + |version| format!("GLFW {version} {path}"), + ) + } + + fn options(&self) -> Vec { + let installations = match &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(installations) => installations.clone(), + _ => Vec::new(), }; - let current_version = version_for(current); - let detected_version = version_for(&self.detected); - Some(( - java_runtime_label(current, current_version.as_deref()), - java_runtime_label(&self.detected, detected_version.as_deref()), - )) + self.choices() + .into_iter() + .map(|choice| match choice { + GlfwChoice::Bundled => SettingsPickerOption { + key: BUNDLED_GLFW_KEY.to_owned(), + title: self.bundled_label(), + detail: None, + leading: None, + active: false, + badge: Some(SettingsPickerBadge::Bundled), + }, + GlfwChoice::System(path) => { + let version = installations + .iter() + .find(|installation| { + same_executable(&installation.path.to_string_lossy(), &path) + }) + .and_then(|installation| installation.version.as_deref()); + SettingsPickerOption { + key: path.clone(), + title: version.map_or_else( + || "System GLFW".to_owned(), + |version| format!("GLFW {version}"), + ), + detail: Some(path), + leading: None, + active: false, + badge: None, + } + } + }) + .collect() + } + + pub(crate) fn selection(&self) -> &SettingsPicker { + &self.picker + } + + pub(crate) fn selection_mut(&mut self) -> &mut SettingsPicker { + &mut self.picker + } + + pub(crate) fn take_status(&mut self) -> Option<&'static str> { + match &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loading => Some("Detecting installed GLFW libraries…"), + LoadState::Loaded(installations) if installations.is_empty() => { + Some("No system GLFW libraries found; bundled remains available.") + } + _ => None, + } } } -impl Default for JavaPicker { +const BUNDLED_GLFW_KEY: &str = "\0bundled-glfw"; + +impl Default for GlfwPicker { fn default() -> Self { Self::new() } } +pub(crate) fn bundled_glfw_version(meta_dir: &Path, game_version: &str) -> Option { + let path = crate::storage::MetadataPaths::new(meta_dir) + .versions() + .join(game_version) + .join("meta.json"); + let profile: serde_json::Value = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; + profile + .get("libraries")? + .as_array()? + .iter() + .filter_map(|library| library.get("name")?.as_str()) + .find_map(|coordinate| { + let mut parts = coordinate.split(':'); + match (parts.next(), parts.next(), parts.next()) { + (Some("org.lwjgl"), Some("lwjgl-glfw"), Some(version)) => Some(version.to_owned()), + _ => None, + } + }) +} + +fn discover_glfw_installations() -> Vec { + let mut directories = Vec::::new(); + for variable in ["LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "PATH"] { + if let Some(value) = std::env::var_os(variable) { + directories.extend(std::env::split_paths(&value)); + } + } + directories.extend( + [ + "/usr/lib", + "/usr/lib64", + "/usr/local/lib", + "/lib", + "/lib64", + "/opt/homebrew/lib", + "/opt/local/lib", + ] + .into_iter() + .map(PathBuf::from), + ); + + let mut nested = Vec::new(); + for directory in &directories { + if let Ok(entries) = std::fs::read_dir(directory) { + nested.extend( + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()), + ); + } + } + directories.extend(nested); + + let mut seen = BTreeSet::new(); + let mut installations = Vec::new(); + for directory in directories { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for path in entries.flatten().map(|entry| entry.path()) { + if !is_glfw_library(&path) { + continue; + } + let canonical = std::fs::canonicalize(&path).unwrap_or(path); + if seen.insert(canonical.clone()) { + installations.push(GlfwInstallation { + version: glfw_version_from_path(&canonical), + path: canonical, + }); + } + } + } + installations.sort_by(|left, right| left.path.cmp(&right.path)); + installations +} + +fn is_glfw_library(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let name = name.to_ascii_lowercase(); + name.starts_with("libglfw") && (name.contains(".so") || name.ends_with(".dylib")) + || name.starts_with("glfw") && name.ends_with(".dll") +} + +fn glfw_version_from_path(path: &Path) -> Option { + let name = path.file_name()?.to_str()?.to_ascii_lowercase(); + if let Some((_, suffix)) = name.rsplit_once(".so.") { + return (!suffix.is_empty()).then(|| suffix.to_owned()); + } + let stem = name + .strip_prefix("libglfw.") + .and_then(|name| name.strip_suffix(".dylib")) + .or_else(|| { + name.strip_prefix("glfw") + .and_then(|name| name.strip_suffix(".dll")) + })?; + (!stem.is_empty()).then(|| stem.trim_start_matches(['-', '.']).to_owned()) +} + fn same_executable(left: &str, right: &str) -> bool { if left == right { return true; @@ -291,11 +746,22 @@ fn same_executable(left: &str, right: &str) -> bool { fn java_runtime_label(path: &str, version: Option<&str>) -> String { version.map_or_else( - || path.to_owned(), - |version| format!("Java {version} {path}"), + || format!("Java {path}"), + |version| format!("{} {path}", java_title(version)), ) } +fn java_title(version: &str) -> String { + let mut parts = version.split(['.', '_']); + let first = parts.next().unwrap_or(version); + let major = if first == "1" { + parts.next().unwrap_or(first) + } else { + first + }; + format!("Java {major}") +} + pub(crate) fn memory_kib(value: &str) -> Option { let normalized = crate::instance::models::normalize_memory_value(value)?; let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); @@ -406,10 +872,37 @@ pub(crate) fn handle_text_area_input(input: &mut TextArea<'_>, key: &KeyEvent) { } } +pub(crate) fn settings_text_area(lines: Vec) -> TextArea<'static> { + let theme = THEME.as_ref(); + let mut editor = TextArea::new(if lines.is_empty() { + vec![String::new()] + } else { + lines + }); + editor.set_style(Style::default().fg(theme.text()).bg(theme.surface())); + editor.set_cursor_line_style(Style::default()); + editor.set_cursor_style(Style::default().fg(theme.background()).bg(theme.accent())); + editor.move_cursor(CursorMove::Bottom); + editor.move_cursor(CursorMove::End); + editor +} + pub(crate) fn auto_label() -> Span<'static> { status_badge("Auto", THEME.as_ref().success()) } +pub(crate) fn bundled_label() -> Span<'static> { + status_badge("Bundled", THEME.as_ref().success()) +} + +pub(crate) fn render_settings_picker( + picker: &SettingsPicker, + area: Rect, + buffer: &mut ratatui::buffer::Buffer, +) { + super::select_list::render_styled(picker.items(), picker.selected(), area, buffer); +} + pub(crate) fn display_resolutions() -> Vec { let Ok(displays) = display_info::DisplayInfo::all() else { return Vec::new(); @@ -463,25 +956,101 @@ mod tests { picker.selected_choice(), JavaChoice::Installation("/opt/jdk/bin/java".to_owned()) ); + assert_eq!( + picker.display_label("/opt/jdk/bin/java"), + "Java 21 /opt/jdk/bin/java" + ); + } + + #[test] + fn java_title_is_minimal_and_uses_the_major_version() { + let picker = JavaPicker::with_auto_path("/opt/jdk-25/bin/java".to_owned()); + assert_eq!( + picker.display_label("/opt/jdk-25/bin/java"), + "Java /opt/jdk-25/bin/java" + ); + *picker.load.lock().unwrap() = LoadState::Loaded(vec![JavaInstallation { + path: "/opt/jdk-25/bin/java".into(), + version: Some("25.0.1".to_owned()), + }]); + assert_eq!( + picker.display_label("/opt/jdk-25/bin/java"), + "Java 25 /opt/jdk-25/bin/java" + ); + assert_eq!(java_title("1.8.0_412"), "Java 8"); } #[test] fn automatic_java_compares_selected_executables() { let picker = JavaPicker::with_auto_path("/auto/java".to_owned()); - *picker.load.lock().unwrap() = LoadState::Loaded(vec![ - JavaInstallation { - path: "/current/java".into(), - version: Some("21.0.8".to_owned()), - }, - JavaInstallation { - path: "/auto/java".into(), - version: Some("21.0.8".to_owned()), - }, - ]); + assert!(!picker.automatic_change("/auto/java")); + assert!(picker.automatic_change("/current/java")); + assert!(picker.automatic_change("/other/java")); + } + + #[test] + fn settings_picker_shares_navigation_and_preserves_selection() { + let option = |key: &str| SettingsPickerOption { + key: key.to_owned(), + title: key.to_owned(), + detail: None, + leading: None, + active: false, + badge: None, + }; + let mut picker = SettingsPicker::default(); + picker.sync(vec![option("one"), option("two")], Some("one")); + assert_eq!( + picker.handle_key(&KeyEvent::from(KeyCode::Char('j'))), + SettingsPickerAction::None + ); + assert_eq!(picker.selected_key(), Some("two")); + + picker.sync( + vec![option("zero"), option("one"), option("two")], + Some("one"), + ); + assert_eq!(picker.selected_key(), Some("two")); + assert_eq!( + picker.handle_key(&KeyEvent::from(KeyCode::Enter)), + SettingsPickerAction::Select + ); + } + + #[test] + fn bundled_glfw_version_comes_from_minecraft_metadata() { + let temp = tempfile::tempdir().unwrap(); + let versions = crate::storage::MetadataPaths::new(temp.path()).versions(); + std::fs::create_dir_all(versions.join("1.21.1")).unwrap(); + std::fs::write( + versions.join("1.21.1/meta.json"), + r#"{"libraries":[{"name":"org.lwjgl:lwjgl-glfw:3.3.3"}]}"#, + ) + .unwrap(); - assert!(picker.automatic_change("/auto/java").is_none()); - assert!(picker.automatic_change("/current/java").is_some()); - assert!(picker.automatic_change("/other/java").is_some()); + assert_eq!( + bundled_glfw_version(temp.path(), "1.21.1").as_deref(), + Some("3.3.3") + ); + let mut picker = GlfwPicker::with_bundled_version(Some("3.3.3".to_owned())); + picker.initialize(); + assert_eq!(picker.labels()[0], "LWJGL GLFW 3.3.3"); + assert_eq!( + picker.selection().options[0].badge, + Some(SettingsPickerBadge::Bundled) + ); + } + + #[test] + fn glfw_library_names_are_recognized() { + assert!(is_glfw_library(Path::new("/usr/lib/libglfw.so.3"))); + assert!(is_glfw_library(Path::new("/usr/lib/libglfw.3.dylib"))); + assert!(is_glfw_library(Path::new("C:/bin/glfw3.dll"))); + assert!(!is_glfw_library(Path::new("/usr/lib/libGL.so"))); + assert_eq!( + glfw_version_from_path(Path::new("/usr/lib/libglfw.so.3.4")).as_deref(), + Some("3.4") + ); } #[test] diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs index 0414522..285c5dd 100644 --- a/tests/launch_pipeline.rs +++ b/tests/launch_pipeline.rs @@ -56,7 +56,13 @@ fn make_config_with( memory_max: None, memory_min: None, jvm_args: Vec::new(), + environment: Default::default(), + window_mode: Default::default(), resolution: None, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } @@ -663,6 +669,38 @@ async fn xms_xmx_use_config_memory() { assert!(inv.jvm_args.iter().any(|a| a == "-Xmx4G")); } +#[tokio::test] +async fn instance_launch_options_are_injected() { + let fx = Fixture::new("options", "1.20.1", modern_vanilla_meta("1.20.1")); + let mut config = make_config("options", "1.20.1", ModLoader::Vanilla); + config.window_mode = rmcl::instance::WindowMode::Fullscreen; + config.glfw_path = Some("/usr/lib/libglfw.so.3".to_owned()); + config + .environment + .insert("MESA_LOADER_DRIVER_OVERRIDE".to_owned(), "zink".to_owned()); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth(), None) + .await + .unwrap(); + + assert!( + inv.game_args + .iter() + .any(|argument| argument == "--fullscreen") + ); + assert!( + inv.jvm_args + .iter() + .any(|argument| argument == "-Dorg.lwjgl.glfw.libname=/usr/lib/libglfw.so.3") + ); + assert_eq!( + inv.environment + .get("MESA_LOADER_DRIVER_OVERRIDE") + .map(String::as_str), + Some("zink") + ); +} + #[tokio::test] async fn default_memory_used_when_unset() { let fx = Fixture::new("memdef", "1.20.1", modern_vanilla_meta("1.20.1")); From e8d67322b07a633392a4eb06b34873e9da2feac8 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 20:13:04 +0200 Subject: [PATCH 19/42] fix: refine runtime change confirmation --- src/tui/input.rs | 4 ---- src/tui/tests/flows.rs | 5 ++--- src/tui/widgets/popups/confirm.rs | 4 +++- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/tui/input.rs b/src/tui/input.rs index c1bcca7..c2384ab 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -802,10 +802,6 @@ impl App { self.apply_instance_settings(*updated, desktop); } widgets::popups::instance_settings::Action::ConfirmRuntime { name } => { - error_buffer::push_message( - tracing::Level::WARN, - "Some installed mods may be incompatible", - ); confirm_popup::set_pending(confirm_popup::ConfirmTarget::InstanceRuntime { name, }); diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index a65c8ea..fbf5104 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -680,14 +680,13 @@ fn runtime_settings_use_the_shared_confirmation_popup() { ui.draw(); assert!(ui.screen().contains("Change runtime")); assert!(!ui.screen().contains("Target:")); - assert!(ui.screen().contains("Apply this runtime change")); assert!( - !ui.screen() + ui.screen() .contains("Some installed mods may be incompatible") ); assert!(!ui.screen().contains("incompatible.")); assert!( - crate::feedback::errors::ERROR_EVENTS + !crate::feedback::errors::ERROR_EVENTS .lock() .unwrap() .iter() diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index 854d5f3..d6cb320 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -120,7 +120,9 @@ impl ConfirmTarget { }) .collect::>() .join("\n"), - ConfirmTarget::InstanceRuntime { .. } => "Apply this runtime change".to_owned(), + ConfirmTarget::InstanceRuntime { .. } => { + "Some installed mods may be incompatible".to_owned() + } ConfirmTarget::AutomaticSelection { setting, .. } => setting.description().to_owned(), } } From 53191bc23b5944e6a1da51d6642a213d7fa5a780 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 20:57:07 +0200 Subject: [PATCH 20/42] feat: expand launcher settings --- Cargo.lock | 2 + Cargo.toml | 1 + README.md | 29 + assets/config.toml | 30 +- src/config/mod.rs | 168 ++++- src/config/settings.rs | 142 ++++- src/config/tests/loading.rs | 73 +++ src/config/tests/settings.rs | 5 + src/feedback/errors.rs | 11 +- src/feedback/tests/errors.rs | 6 +- src/instance/launch/mod.rs | 27 +- src/instance/manager.rs | 7 +- src/instance/mod.rs | 4 +- src/instance/models.rs | 38 ++ src/instance/tests/content/dependencies.rs | 2 + src/instance/tests/content/reconcile.rs | 2 + src/instance/tests/launch/pipeline.rs | 6 + src/instance/tests/manager.rs | 2 + src/instance/tests/models.rs | 47 ++ src/storage.rs | 12 + src/tests/storage.rs | 18 + src/tui/event.rs | 31 +- src/tui/input.rs | 81 ++- src/tui/mod.rs | 1 + src/tui/tests/event.rs | 4 + src/tui/tests/flows.rs | 33 +- src/tui/tests/harness.rs | 2 + src/tui/tests/widgets/content/discovery.rs | 2 + src/tui/tests/widgets/instances.rs | 2 + src/tui/widgets/instances.rs | 6 + src/tui/widgets/popups/confirm.rs | 7 + src/tui/widgets/popups/global_settings.rs | 670 ++++++++++++++++++-- src/tui/widgets/popups/instance_settings.rs | 215 ++++--- src/tui/widgets/popups/settings_controls.rs | 112 +++- tests/launch_pipeline.rs | 2 + 35 files changed, 1537 insertions(+), 263 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46b1e4b..cc709c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3924,6 +3924,7 @@ dependencies = [ "throbber-widgets-tui", "tokio", "toml", + "toml_edit", "tracing", "tracing-appender", "tracing-subscriber", @@ -4947,6 +4948,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index e828a24..0862deb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ serde_json = "1.0" sha1 = "0.10" sha2 = "0.10" toml = "1.1.2" +toml_edit = "0.25" tachyonfx = "0.25" throbber-widgets-tui = "0.11" thiserror = "2" diff --git a/README.md b/README.md index f733d8c..7ad30ee 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,35 @@ settings, accounts, instances, and cached game metadata. each instance has an `instance.json` for its config and a `.minecraft/` directory with the actual game files. +Launcher settings can be edited from the TUI or in `config.toml`. Changes to +`instances_dir`, `meta_dir`, and `image_protocol` apply after restarting rmcl; +other launcher settings apply immediately. Omitting `java_path` enables automatic +Java selection. Global JVM arguments and environment variables are applied before +per-instance values. Instances can explicitly inherit the launcher window mode +and resolution defaults. + +```toml +[general] +check_modpack_updates = true +check_content_updates = true + +[defaults] +memory_min = "512M" +memory_max = "2G" +jvm_args = [] +environment = {} +window_mode = "windowed" +# resolution = [1920, 1080] + +[ui] +image_protocol = "auto" + +[content] +preferred_provider = "modrinth" +preferred_provider_only = false +ask_on_provider_conflict = true +``` + ### logs launcher logs are per-session and contain rmcl's own output. instance launch logs capture game stdout/stderr per launch. diff --git a/assets/config.toml b/assets/config.toml index 88bf449..935fafc 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -1,21 +1,29 @@ -[paths] -# where instances are stored -instances_dir = "~/.local/share/rmcl/instances" - -# metadata, cache, whatever else ends up there -meta_dir = "~/.local/share/rmcl/meta" +[general] +# check imported modpacks and managed content for updates automatically +check_modpack_updates = true +check_content_updates = true -# optional java override (empty = uses JAVA_HOME) -java_path = "" +[paths] +# instances_dir and meta_dir use platform defaults when omitted +# java_path is omitted to select Java automatically +# instances_dir = "/path/to/minecraft/instances" +# meta_dir = "/path/to/rmcl/meta" +# java_path = "/usr/bin/java" [defaults] -# default heap settings +# inherited by instances that do not override their memory memory_min = "512M" memory_max = "2G" +# appended to every instance; instance values take precedence where applicable +jvm_args = [] +environment = {} +# inherited by instances that use launcher window defaults +window_mode = "windowed" +# resolution = [1920, 1080] # omitted = Minecraft default [ui] -# image protocol for content icons and screenshots: halfblocks, quadrants, kitty, or iterm2 -image_protocol = "kitty" +# image protocol: auto, halfblocks, quadrants, kitty, or iterm2 +image_protocol = "auto" # error popup timing (in ms) error_auto_dismiss_ms = 5000 error_slide_start_ms = 3500 diff --git a/src/config/mod.rs b/src/config/mod.rs index e2e0ff9..745649f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -6,6 +6,7 @@ use config::{Config as ConfigLoader, ConfigError, File}; use std::fs; +use std::io; use std::path::PathBuf; use std::sync::{LazyLock, RwLock, RwLockReadGuard}; @@ -53,7 +54,17 @@ pub fn load_config(config_path: &std::path::Path) -> Result ConfigLoader::builder() .add_source(File::from(config_path).required(false)) .build()? - .try_deserialize() + .try_deserialize::() + .map(Config::normalize) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LauncherSettingsSave { + pub restart_required: bool, + pub restart_changed: bool, + pub provider_changed: bool, + pub modpack_updates_enabled: bool, + pub content_updates_enabled: bool, } pub struct ConfigStore(RwLock); @@ -65,34 +76,62 @@ impl ConfigStore { .unwrap_or_else(std::sync::PoisonError::into_inner) } - pub fn save_launcher_settings(&self, edited: Config) -> std::io::Result<()> { + pub fn save_launcher_settings(&self, edited: Config) -> io::Result { let path = get_config_path().join("config.toml"); let current = self.read().clone(); - let mut persisted = load_config(&path).unwrap_or_else(|error| { - tracing::warn!("Failed to merge config.toml while saving settings: {error}"); - current.clone() - }); - persisted.defaults = edited.defaults.clone(); - persisted.paths.java_path = edited.paths.java_path.clone(); - let serialized = toml::to_string_pretty(&persisted).map_err(std::io::Error::other)?; - crate::storage::write_atomic(&path, serialized.as_bytes())?; - let mut runtime = current; - runtime.defaults = edited.defaults; - runtime.paths.java_path = edited.paths.java_path; + let edited = edited.normalize(); + let persisted = load_config(&path).unwrap_or_else(|_| current.clone()); + let restart_required = current.paths.instances_dir != edited.paths.instances_dir + || current.paths.meta_dir != edited.paths.meta_dir + || current.ui.image_protocol != edited.ui.image_protocol; + let restart_changed = persisted.paths.instances_dir != edited.paths.instances_dir + || persisted.paths.meta_dir != edited.paths.meta_dir + || persisted.ui.image_protocol != edited.ui.image_protocol; + let provider_changed = current.content.preferred_provider + != edited.content.preferred_provider + || current.content.preferred_provider_only != edited.content.preferred_provider_only; + let modpack_updates_enabled = + !current.general.check_modpack_updates && edited.general.check_modpack_updates; + let content_updates_enabled = + !current.general.check_content_updates && edited.general.check_content_updates; + write_config_document(&path, &edited)?; + + let mut runtime = edited; + runtime + .paths + .instances_dir + .clone_from(¤t.paths.instances_dir); + runtime.paths.meta_dir.clone_from(¤t.paths.meta_dir); + runtime.ui.image_protocol = current.ui.image_protocol; *self .0 .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = runtime; - Ok(()) + crate::feedback::errors::set_max_error_events(self.read().ui.max_error_events); + Ok(LauncherSettingsSave { + restart_required, + restart_changed, + provider_changed, + modpack_updates_enabled, + content_updates_enabled, + }) } - pub fn reload(&self) -> Result { + pub fn reload(&self) -> Result { let mut config = load_config(&get_config_path().join("config.toml"))?; - let restart_required = { + let outcome = { let current = self.read(); - let changed = current.paths.instances_dir != config.paths.instances_dir + let restart_required = current.paths.instances_dir != config.paths.instances_dir || current.paths.meta_dir != config.paths.meta_dir || current.ui.image_protocol != config.ui.image_protocol; + let provider_changed = current.content.preferred_provider + != config.content.preferred_provider + || current.content.preferred_provider_only + != config.content.preferred_provider_only; + let modpack_updates_enabled = + !current.general.check_modpack_updates && config.general.check_modpack_updates; + let content_updates_enabled = + !current.general.check_content_updates && config.general.check_content_updates; // App owns a manager and several watchers rooted at these paths. // Keep them stable for this process; persisted path edits apply on restart. config @@ -101,32 +140,105 @@ impl ConfigStore { .clone_from(¤t.paths.instances_dir); config.paths.meta_dir.clone_from(¤t.paths.meta_dir); config.ui.image_protocol = current.ui.image_protocol; - changed + LauncherSettingsSave { + restart_required, + restart_changed: restart_required, + provider_changed, + modpack_updates_enabled, + content_updates_enabled, + } }; *self .0 .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = config; - Ok(restart_required) + crate::feedback::errors::set_max_error_events(self.read().ui.max_error_events); + Ok(outcome) } } pub static SETTINGS: LazyLock = LazyLock::new(|| { - ConfigStore(RwLock::new({ + let config = { let path = ensure_config_exists(); load_config(&path).unwrap_or_else(|e| { tracing::error!("Config load failed, using defaults: {}", e); - Config { - general: settings::General::default(), - paths: settings::Paths::default(), - defaults: settings::Defaults::default(), - ui: settings::Ui::default(), - content: settings::Content::default(), - } + Config::default() }) - })) + }; + crate::feedback::errors::set_max_error_events(config.ui.max_error_events); + ConfigStore(RwLock::new(config)) }); +fn write_config_document(path: &std::path::Path, config: &Config) -> io::Result<()> { + let source = match fs::read_to_string(path) { + Ok(source) => source, + Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error), + }; + let mut document = source.parse::().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("cannot update {}: {error}", path.display()), + ) + })?; + let generated = toml::to_string_pretty(config) + .map_err(io::Error::other)? + .parse::() + .map_err(io::Error::other)?; + merge_table(document.as_table_mut(), generated.as_table()); + if config.paths.java_path.is_none() + && let Some(paths) = document + .get_mut("paths") + .and_then(|item| item.as_table_mut()) + { + paths.remove("java_path"); + } + let default_paths = settings::Paths::default(); + if let Some(paths) = document + .get_mut("paths") + .and_then(|item| item.as_table_mut()) + { + if config.paths.instances_dir == default_paths.instances_dir { + paths.remove("instances_dir"); + } + if config.paths.meta_dir == default_paths.meta_dir { + paths.remove("meta_dir"); + } + } + if config.defaults.resolution.is_none() + && let Some(defaults) = document + .get_mut("defaults") + .and_then(|item| item.as_table_mut()) + { + defaults.remove("resolution"); + } + crate::storage::write_atomic(path, document.to_string().as_bytes()) +} + +fn merge_table(target: &mut toml_edit::Table, source: &toml_edit::Table) { + for (key, source_item) in source { + if let Some(target_item) = target.get_mut(key) { + if let (Some(target_table), Some(source_table)) = + (target_item.as_table_mut(), source_item.as_table()) + { + merge_table(target_table, source_table); + continue; + } + if let (Some(target_value), Some(source_value)) = + (target_item.as_value_mut(), source_item.as_value()) + { + let decor = target_value.decor().clone(); + *target_value = source_value.clone(); + *target_value.decor_mut() = decor; + continue; + } + *target_item = source_item.clone(); + } else { + target.insert(key, source_item.clone()); + } + } +} + #[cfg(test)] #[path = "tests/loading.rs"] mod tests; diff --git a/src/config/settings.rs b/src/config/settings.rs index 807523a..af53c31 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -4,29 +4,84 @@ // all the config structs that map to sections in config.toml. // everything has sane defaults so a blank file (or no file) still works. -use std::path::PathBuf; +use std::{collections::BTreeMap, fmt, path::PathBuf}; use serde::{Deserialize, Serialize}; +use crate::instance::models::{WindowMode, memory_kib, normalize_memory_value}; + #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ImageProtocol { + #[default] + Auto, Halfblocks, Quadrants, - #[default] Kitty, Iterm2, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct General {} +impl fmt::Display for ImageProtocol { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Auto => formatter.write_str("auto"), + Self::Halfblocks => formatter.write_str("halfblocks"), + Self::Quadrants => formatter.write_str("quadrants"), + Self::Kitty => formatter.write_str("kitty"), + Self::Iterm2 => formatter.write_str("iterm2"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct General { + #[serde(default = "default_true")] + pub check_modpack_updates: bool, + #[serde(default = "default_true")] + pub check_content_updates: bool, +} + +impl Default for General { + fn default() -> Self { + Self { + check_modpack_updates: true, + check_content_updates: true, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContentProvider { + #[default] + Modrinth, + CurseForge, +} + +impl ContentProvider { + pub fn as_str(self) -> &'static str { + match self { + Self::Modrinth => "modrinth", + Self::CurseForge => "curseforge", + } + } +} + +impl fmt::Display for ContentProvider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Modrinth => formatter.write_str("Modrinth"), + Self::CurseForge => formatter.write_str("CurseForge"), + } + } +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Content { #[serde(default = "default_true")] pub ask_on_provider_conflict: bool, - #[serde(default = "default_provider")] - pub preferred_provider: String, + #[serde(default, deserialize_with = "deserialize_content_provider")] + pub preferred_provider: ContentProvider, #[serde(default)] pub preferred_provider_only: bool, #[serde(default = "default_unmatched_retry_hours")] @@ -39,8 +94,16 @@ fn default_true() -> bool { true } -fn default_provider() -> String { - "modrinth".to_owned() +fn deserialize_content_provider<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + Ok(if value.eq_ignore_ascii_case("curseforge") { + ContentProvider::CurseForge + } else { + ContentProvider::Modrinth + }) } fn default_unmatched_retry_hours() -> u64 { @@ -55,7 +118,7 @@ impl Default for Content { fn default() -> Self { Self { ask_on_provider_conflict: true, - preferred_provider: default_provider(), + preferred_provider: ContentProvider::default(), preferred_provider_only: false, unmatched_retry_hours: default_unmatched_retry_hours(), max_fingerprint_size_mib: default_max_fingerprint_size_mib(), @@ -80,7 +143,7 @@ impl Content { } fn preferred_provider_with_curseforge(&self, curseforge_available: bool) -> &'static str { - if self.preferred_provider.eq_ignore_ascii_case("curseforge") && curseforge_available { + if self.preferred_provider == ContentProvider::CurseForge && curseforge_available { "curseforge" } else { "modrinth" @@ -122,10 +185,13 @@ impl Content { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Paths { #[serde(default = "default_instances_dir")] + #[serde(skip_serializing_if = "is_default_instances_dir")] pub instances_dir: String, #[serde(default = "default_meta_dir")] + #[serde(skip_serializing_if = "is_default_meta_dir")] pub meta_dir: String, #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] pub java_path: Option, } @@ -138,6 +204,10 @@ fn default_instances_dir() -> String { .into_owned() } +fn is_default_instances_dir(path: &str) -> bool { + path == default_instances_dir() +} + fn default_meta_dir() -> String { dirs_next::data_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -147,6 +217,10 @@ fn default_meta_dir() -> String { .into_owned() } +fn is_default_meta_dir(path: &str) -> bool { + path == default_meta_dir() +} + impl Default for Paths { fn default() -> Self { Self { @@ -191,6 +265,15 @@ pub struct Defaults { pub memory_min: String, #[serde(default = "default_memory_max")] pub memory_max: String, + #[serde(default)] + pub jvm_args: Vec, + #[serde(default)] + pub environment: BTreeMap, + #[serde(default)] + pub window_mode: WindowMode, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option<(u32, u32)>, } fn default_memory_min() -> String { @@ -205,6 +288,10 @@ impl Default for Defaults { Self { memory_min: default_memory_min(), memory_max: default_memory_max(), + jvm_args: Vec::new(), + environment: BTreeMap::new(), + window_mode: WindowMode::default(), + resolution: None, } } } @@ -250,7 +337,7 @@ impl Default for Ui { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { #[serde(default)] pub general: General, @@ -264,6 +351,39 @@ pub struct Config { pub content: Content, } +impl Config { + pub fn normalize(mut self) -> Self { + if self.paths.instances_dir.trim().is_empty() { + self.paths.instances_dir = default_instances_dir(); + } + if self.paths.meta_dir.trim().is_empty() { + self.paths.meta_dir = default_meta_dir(); + } + self.paths.java_path = self + .paths + .java_path + .take() + .and_then(|path| (!path.trim().is_empty()).then(|| path.trim().to_owned())); + self.defaults.memory_min = + normalize_memory_value(&self.defaults.memory_min).unwrap_or_else(default_memory_min); + self.defaults.memory_max = + normalize_memory_value(&self.defaults.memory_max).unwrap_or_else(default_memory_max); + if memory_kib(&self.defaults.memory_min) > memory_kib(&self.defaults.memory_max) { + self.defaults + .memory_max + .clone_from(&self.defaults.memory_min); + } + self.ui.error_auto_dismiss_ms = self.ui.error_auto_dismiss_ms.max(1); + self.ui.error_slide_start_ms = self + .ui + .error_slide_start_ms + .min(self.ui.error_auto_dismiss_ms); + self.ui.error_fly_out_ms = self.ui.error_fly_out_ms.min(self.ui.error_auto_dismiss_ms); + self.ui.max_error_events = self.ui.max_error_events.max(1); + self + } +} + #[cfg(test)] #[path = "tests/settings.rs"] mod tests; diff --git a/src/config/tests/loading.rs b/src/config/tests/loading.rs index ebcb278..aabd59d 100644 --- a/src/config/tests/loading.rs +++ b/src/config/tests/loading.rs @@ -54,3 +54,76 @@ fn load_config_partial_sections() { assert_eq!(config.paths.instances_dir, "/custom/path"); assert!(config.paths.java_path.is_none()); } + +#[test] +fn bundled_config_uses_platform_paths_and_automatic_java() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write(&path, include_str!("../../../assets/config.toml")).unwrap(); + let config = load_config(&path).unwrap(); + assert_eq!( + config.paths.instances_dir, + settings::Paths::default().instances_dir + ); + assert_eq!(config.paths.meta_dir, settings::Paths::default().meta_dir); + assert!(config.paths.java_path.is_none()); + assert_eq!(config.ui.image_protocol, settings::ImageProtocol::Auto); +} + +#[test] +fn config_normalizes_memory_java_and_notification_bounds() { + let config = Config { + paths: settings::Paths { + instances_dir: String::new(), + meta_dir: " ".to_owned(), + java_path: Some(" ".to_owned()), + }, + defaults: settings::Defaults { + memory_min: "invalid".to_owned(), + memory_max: "1G".to_owned(), + ..Default::default() + }, + ui: settings::Ui { + error_auto_dismiss_ms: 100, + error_slide_start_ms: 200, + error_fly_out_ms: 300, + max_error_events: 0, + ..Default::default() + }, + ..Default::default() + } + .normalize(); + assert_eq!( + config.paths.instances_dir, + settings::Paths::default().instances_dir + ); + assert_eq!(config.paths.meta_dir, settings::Paths::default().meta_dir); + assert!(config.paths.java_path.is_none()); + assert_eq!(config.defaults.memory_min, "512M"); + assert_eq!(config.defaults.memory_max, "1G"); + assert_eq!(config.ui.error_slide_start_ms, 100); + assert_eq!(config.ui.error_fly_out_ms, 100); + assert_eq!(config.ui.max_error_events, 1); +} + +#[test] +fn settings_writer_preserves_comments_and_unknown_keys() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write( + &path, + "# keep this comment\n[defaults]\n# heap comment\nmemory_max = \"2G\"\n\n[future]\nvalue = 42\n", + ) + .unwrap(); + let mut config = Config::default(); + config.defaults.memory_max = "8G".to_owned(); + write_config_document(&path, &config).unwrap(); + let saved = std::fs::read_to_string(path).unwrap(); + assert!(saved.contains("# keep this comment"), "{saved}"); + assert!(saved.contains("# heap comment"), "{saved}"); + assert!(saved.contains("memory_max = \"8G\"")); + assert!(saved.contains("[future]")); + assert!(saved.contains("value = 42")); + assert!(!saved.contains("instances_dir")); + assert!(!saved.contains("meta_dir")); +} diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs index 619ed44..21f26fb 100644 --- a/src/config/tests/settings.rs +++ b/src/config/tests/settings.rs @@ -66,6 +66,11 @@ fn content_provider_settings_are_normalized() { merged.discovery_provider_label_with_curseforge(true), "providers" ); + + let legacy: Content = toml::from_str("preferred_provider = \"CurseForge\"").unwrap(); + assert_eq!(legacy.preferred_provider, ContentProvider::CurseForge); + let unknown: Content = toml::from_str("preferred_provider = \"unknown\"").unwrap(); + assert_eq!(unknown.preferred_provider, ContentProvider::Modrinth); } #[test] diff --git a/src/feedback/errors.rs b/src/feedback/errors.rs index d916883..66aa65c 100644 --- a/src/feedback/errors.rs +++ b/src/feedback/errors.rs @@ -9,15 +9,19 @@ // used by the render layer to track per-toast animation state. use std::collections::VecDeque; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; use std::sync::LazyLock; use tracing::Level; -const MAX_ERROR_EVENTS: usize = 50; static NEXT_ERROR_ID: AtomicU64 = AtomicU64::new(1); +static MAX_ERROR_EVENTS: AtomicUsize = AtomicUsize::new(50); + +pub(crate) fn set_max_error_events(max_events: usize) { + MAX_ERROR_EVENTS.store(max_events.max(1), Ordering::Relaxed); +} #[derive(Debug, Clone)] pub struct ErrorEvent { @@ -38,7 +42,8 @@ pub fn push_error(event: ErrorEvent) { events.push_back(event); - while events.len() > MAX_ERROR_EVENTS { + let max_events = MAX_ERROR_EVENTS.load(Ordering::Relaxed); + while events.len() > max_events { events.pop_front(); } super::request_redraw(); diff --git a/src/feedback/tests/errors.rs b/src/feedback/tests/errors.rs index dd5f72d..27bc357 100644 --- a/src/feedback/tests/errors.rs +++ b/src/feedback/tests/errors.rs @@ -72,13 +72,15 @@ fn overflow_drops_oldest() { let _guard = TEST_LOCK.lock().unwrap(); clear_errors_for_test(); - for i in 0..(MAX_ERROR_EVENTS + 10) { + let max_error_events = 50; + set_max_error_events(max_error_events); + for i in 0..(max_error_events + 10) { push_error(make_event(&format!("overflow_{i}"))); } let all = peek_all_errors(); - assert_eq!(all.len(), MAX_ERROR_EVENTS); + assert_eq!(all.len(), max_error_events); assert!(!all.iter().any(|e| e.message == "overflow_0")); assert!(!all.iter().any(|e| e.message == "overflow_9")); diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 69187c0..f7f80ff 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -386,6 +386,13 @@ pub async fn build_launch_invocation( ) -> Result { let instance_dir = instances_dir.join(&config.name); let minecraft_dir = instance_dir.join(crate::storage::MINECRAFT_DIR_NAME); + let (window_mode, resolution) = { + let defaults = &crate::config::SETTINGS.read().defaults; + ( + config.effective_window_mode(defaults.window_mode), + config.effective_resolution(defaults.resolution), + ) + }; let metadata_paths = crate::storage::MetadataPaths::new(meta_dir); let meta_path = metadata_paths @@ -407,7 +414,7 @@ pub async fn build_launch_invocation( let current_features = FeatureSet { is_quick_play_singleplayer: quick_play_world.map(|_| true), - has_custom_resolution: config.resolution.map(|_| true), + has_custom_resolution: resolution.map(|_| true), ..Default::default() }; let host_os_version = system::mojang_os_version(); @@ -585,8 +592,8 @@ pub async fn build_launch_invocation( .join(&config.game_version) .join("natives"); let version_type = merged_profile.type_.as_deref().unwrap_or("release"); - let resolution_width = config.resolution.map(|(width, _)| width.to_string()); - let resolution_height = config.resolution.map(|(_, height)| height.to_string()); + let resolution_width = resolution.map(|(width, _)| width.to_string()); + let resolution_height = resolution.map(|(_, height)| height.to_string()); let template_ctx = TemplateContext { library_directory, classpath_separator: sep, @@ -615,10 +622,10 @@ pub async fn build_launch_invocation( build_game_args(&merged_profile, &rule_ctx, &template_ctx)?; // Modern Mojang profiles include feature-gated resolution arguments. // Older and third-party profiles may not, so add them when absent. - apply_custom_resolution(&mut game_args, config.resolution); - apply_window_mode(&mut game_args, config.window_mode); + apply_custom_resolution(&mut game_args, resolution); + apply_window_mode(&mut game_args, window_mode); - let (memory_min, memory_max) = { + let (memory_min, memory_max, global_jvm_args, global_environment) = { let settings = crate::config::SETTINGS.read(); ( config @@ -629,16 +636,22 @@ pub async fn build_launch_invocation( .memory_max .clone() .unwrap_or_else(|| settings.defaults.memory_max.clone()), + settings.defaults.jvm_args.clone(), + settings.defaults.environment.clone(), ) }; let mut jvm_args: Vec = vec![format!("-Xms{memory_min}"), format!("-Xmx{memory_max}")]; jvm_args.extend(patch_jvm_args); jvm_args.extend(upstream_jvm_args); + jvm_args.extend(global_jvm_args); jvm_args.extend(config.jvm_args.clone()); if let Some(glfw_path) = config.glfw_path.as_deref() { jvm_args.push(format!("-Dorg.lwjgl.glfw.libname={glfw_path}")); } + let mut environment = global_environment; + environment.extend(config.environment.clone()); + Ok(LaunchInvocation { java, jvm_args, @@ -647,7 +660,7 @@ pub async fn build_launch_invocation( main_class, extra_args, game_args, - environment: config.environment.clone(), + environment, working_dir: minecraft_dir, }) } diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 0e91ded..4822aba 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -262,6 +262,7 @@ impl InstanceManager { } })?; + let defaults = crate::config::SETTINGS.read().defaults.clone(); let config = InstanceConfig { name: name.to_string(), game_version: game_version.to_string(), @@ -274,8 +275,10 @@ impl InstanceManager { memory_min: None, jvm_args: vec![], environment: Default::default(), - window_mode: Default::default(), - resolution: None, + window_mode: defaults.window_mode, + inherit_window_mode: true, + resolution: defaults.resolution, + inherit_resolution: true, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/instance/mod.rs b/src/instance/mod.rs index e47bd0a..86a2b69 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -27,4 +27,6 @@ pub use content::{ pub use launch::LaunchError; pub use loader::{GameVersion, ModLoaderInstaller, VanillaInstaller, get_installer}; pub use manager::{InstanceError, InstanceManager}; -pub use models::{InstanceConfig, LaunchCommand, ModLoader, WindowMode, normalize_memory_value}; +pub use models::{ + InstanceConfig, LaunchCommand, ModLoader, WindowMode, memory_kib, normalize_memory_value, +}; diff --git a/src/instance/models.rs b/src/instance/models.rs index de294f8..5abd1bf 100644 --- a/src/instance/models.rs +++ b/src/instance/models.rs @@ -89,8 +89,12 @@ pub struct InstanceConfig { pub environment: BTreeMap, #[serde(default, skip_serializing_if = "WindowMode::is_windowed")] pub window_mode: WindowMode, + #[serde(default, skip_serializing_if = "is_false")] + pub inherit_window_mode: bool, #[serde(default)] pub resolution: Option<(u32, u32)>, + #[serde(default, skip_serializing_if = "is_false")] + pub inherit_resolution: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub preferred_account: Option, #[serde(default, skip_serializing_if = "LaunchCommand::is_default")] @@ -105,6 +109,28 @@ pub struct InstanceConfig { pub modpack_source: Option, } +impl InstanceConfig { + pub fn effective_window_mode(&self, global: WindowMode) -> WindowMode { + if self.inherit_window_mode { + global + } else { + self.window_mode + } + } + + pub fn effective_resolution(&self, global: Option<(u32, u32)>) -> Option<(u32, u32)> { + if self.inherit_resolution { + global + } else { + self.resolution + } + } +} + +fn is_false(value: &bool) -> bool { + !value +} + pub fn parse_resolution(input: &str) -> Result<(u32, u32), String> { let (width, height) = input .trim() @@ -152,6 +178,18 @@ pub fn normalize_memory_value(raw: &str) -> Option { } } +pub fn memory_kib(value: &str) -> Option { + let normalized = normalize_memory_value(value)?; + let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); + let number = number.parse::().ok()?; + match suffix { + "K" => Some(number), + "M" => number.checked_mul(1024), + "G" => number.checked_mul(1024 * 1024), + _ => None, + } +} + fn deserialize_optional_memory<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index 18d9aee..77d60bf 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -246,7 +246,9 @@ fn instance() -> InstanceConfig { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/instance/tests/content/reconcile.rs b/src/instance/tests/content/reconcile.rs index a69aeff..05bf6b1 100644 --- a/src/instance/tests/content/reconcile.rs +++ b/src/instance/tests/content/reconcile.rs @@ -51,7 +51,9 @@ fn job(name: &str) -> ReconcileJob { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/instance/tests/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs index bd86deb..08e6d82 100644 --- a/src/instance/tests/launch/pipeline.rs +++ b/src/instance/tests/launch/pipeline.rs @@ -191,7 +191,9 @@ async fn launch_commands_receive_instance_environment() { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), @@ -321,7 +323,9 @@ async fn migrate_legacy_loader_profile_skips_modern_with_inherits_from() { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), @@ -376,7 +380,9 @@ async fn migrate_legacy_loader_profile_skips_fabric() { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/instance/tests/manager.rs b/src/instance/tests/manager.rs index 3c786f8..389a1db 100644 --- a/src/instance/tests/manager.rs +++ b/src/instance/tests/manager.rs @@ -29,7 +29,9 @@ fn dummy_config(name: &str) -> InstanceConfig { jvm_args: vec![], environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/instance/tests/models.rs b/src/instance/tests/models.rs index ff5f47f..b8c88e2 100644 --- a/src/instance/tests/models.rs +++ b/src/instance/tests/models.rs @@ -18,7 +18,9 @@ fn instance_config_roundtrips_through_json() { jvm_args: vec![], environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: Some((1920, 1080)), + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), @@ -55,12 +57,57 @@ fn instance_config_accepts_numeric_memory() { assert_eq!(parsed.memory_min.as_deref(), Some("512M")); assert!(parsed.environment.is_empty()); assert_eq!(parsed.window_mode, WindowMode::Windowed); + assert!(!parsed.inherit_window_mode); + assert!(!parsed.inherit_resolution); assert_eq!(parsed.preferred_account, None); assert_eq!(parsed.pre_launch_command, LaunchCommand::default()); assert_eq!(parsed.post_exit_command, LaunchCommand::default()); assert_eq!(parsed.glfw_path, None); } +#[test] +fn window_defaults_are_inherited_only_when_explicitly_enabled() { + let mut config = InstanceConfig { + name: "test".to_owned(), + game_version: "1.21.1".to_owned(), + loader: ModLoader::Vanilla, + loader_version: None, + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + environment: Default::default(), + window_mode: WindowMode::Windowed, + inherit_window_mode: false, + resolution: None, + inherit_resolution: false, + preferred_account: None, + pre_launch_command: Default::default(), + post_exit_command: Default::default(), + glfw_path: None, + config_sync_profile: None, + modpack_source: None, + }; + assert_eq!( + config.effective_window_mode(WindowMode::Fullscreen), + WindowMode::Windowed + ); + assert_eq!(config.effective_resolution(Some((1920, 1080))), None); + + config.inherit_window_mode = true; + config.inherit_resolution = true; + assert_eq!( + config.effective_window_mode(WindowMode::Fullscreen), + WindowMode::Fullscreen + ); + assert_eq!( + config.effective_resolution(Some((1920, 1080))), + Some((1920, 1080)) + ); +} + #[test] fn normalize_memory_value_handles_bare_numbers() { assert_eq!(normalize_memory_value("8").as_deref(), Some("8G")); diff --git a/src/storage.rs b/src/storage.rs index 21a398b..914905d 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -141,6 +141,18 @@ impl MetadataPaths { } } +pub fn clear_disposable_caches(meta_dir: &Path) -> io::Result<()> { + let paths = MetadataPaths::new(meta_dir); + for path in [paths.cache().join("providers"), paths.cache().join("java")] { + match std::fs::remove_dir_all(&path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { let parent = path .parent() diff --git a/src/tests/storage.rs b/src/tests/storage.rs index 1ae54f3..9d06657 100644 --- a/src/tests/storage.rs +++ b/src/tests/storage.rs @@ -42,6 +42,24 @@ fn metadata_paths_separate_state_and_cache() { ); } +#[test] +fn disposable_cache_cleanup_preserves_runtime_metadata() { + let temp = tempfile::tempdir().unwrap(); + let paths = MetadataPaths::new(temp.path()); + std::fs::create_dir_all(paths.provider_icons("modrinth")).unwrap(); + std::fs::create_dir_all(paths.java_installations().parent().unwrap()).unwrap(); + std::fs::create_dir_all(paths.temporary()).unwrap(); + std::fs::create_dir_all(paths.versions()).unwrap(); + std::fs::write(paths.java_installations(), "[]").unwrap(); + + clear_disposable_caches(temp.path()).unwrap(); + + assert!(!paths.cache().join("providers").exists()); + assert!(!paths.cache().join("java").exists()); + assert!(paths.temporary().exists()); + assert!(paths.versions().exists()); +} + #[test] fn atomic_write_replaces_existing_file() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/tui/event.rs b/src/tui/event.rs index ece7018..c61df29 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -418,7 +418,7 @@ impl App { cached.map(|snapshot| (result.instance_name.clone(), snapshot)); self.content_manifest = Some((result.instance_name.clone(), result.manifest.clone())); self.apply_content_update_snapshot(); - if stale { + if stale && crate::config::SETTINGS.read().general.check_content_updates { crate::instance::content::updates::spawn( selected, result.manifest, @@ -756,13 +756,28 @@ impl App { match path.file_name().and_then(|name| name.to_str()) { Some("config.toml") => { match crate::config::SETTINGS.reload() { - Ok(true) => error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::INFO, - message: "Path and image protocol changes apply after restart".to_owned(), - pushed_at: std::time::Instant::now(), - }), - Ok(false) => {} + Ok(outcome) => { + if outcome.provider_changed { + self.reset_discovery_states(); + } + if outcome.modpack_updates_enabled { + widgets::instances::spawn_modpack_update_checks( + &self.instances_state.instances, + ); + } + if outcome.content_updates_enabled { + self.spawn_selected_content_update_check(); + } + if outcome.restart_required { + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::INFO, + message: "Path and image protocol changes apply after restart" + .to_owned(), + pushed_at: std::time::Instant::now(), + }); + } + } Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { id: 0, level: tracing::Level::ERROR, diff --git a/src/tui/input.rs b/src/tui/input.rs index c2384ab..11fc7a5 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -296,6 +296,23 @@ impl App { } self.focused } + Some(confirm_popup::ConfirmTarget::LauncherCache) => { + let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); + match crate::storage::clear_disposable_caches(&meta_dir) { + Ok(()) => { + self.reset_discovery_states(); + error_buffer::push_message( + tracing::Level::INFO, + "Cleared launcher caches", + ); + } + Err(error) => error_buffer::push_message( + tracing::Level::ERROR, + format!("Failed to clear launcher caches: {error}"), + ), + } + FocusedArea::GlobalSettings + } Some(confirm_popup::ConfirmTarget::AutomaticSelection { setting, instance, @@ -363,6 +380,9 @@ impl App { } FocusedArea::InstanceSettings } + Some(confirm_popup::ConfirmTarget::LauncherCache) => { + FocusedArea::GlobalSettings + } Some(confirm_popup::ConfirmTarget::AutomaticSelection { instance, .. }) => { @@ -747,6 +767,10 @@ impl App { }); self.focused = FocusedArea::ConfirmDelete; } + widgets::popups::global_settings::Action::ClearCache => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::LauncherCache); + self.focused = FocusedArea::ConfirmDelete; + } widgets::popups::global_settings::Action::Close => { self.global_settings = None; self.focused = self.pre_overlay_focused; @@ -2137,11 +2161,32 @@ impl App { theme: String, border: crate::config::theme::BorderStyle, ) { - let result = crate::config::SETTINGS - .save_launcher_settings(config) - .and_then(|()| crate::config::theme::apply_theme(theme, border)); + let result = crate::config::SETTINGS.save_launcher_settings(config); match result { - Ok(()) => crate::feedback::request_redraw(), + Ok(outcome) => { + if let Err(error) = crate::config::theme::apply_theme(theme, border) { + error_buffer::push_message(tracing::Level::ERROR, error.to_string()); + return; + } + if outcome.provider_changed { + self.reset_discovery_states(); + } + if outcome.modpack_updates_enabled { + widgets::instances::spawn_modpack_update_checks( + &self.instances_state.instances, + ); + } + if outcome.content_updates_enabled { + self.spawn_selected_content_update_check(); + } + if outcome.restart_required && outcome.restart_changed { + error_buffer::push_message( + tracing::Level::INFO, + "Restart rmcl to apply storage and image changes", + ); + } + crate::feedback::request_redraw(); + } Err(error) => error_buffer::push_message(tracing::Level::ERROR, error.to_string()), } } @@ -2212,6 +2257,34 @@ impl App { }), } } + + pub(super) fn reset_discovery_states(&mut self) { + self.mods_discovery_state = + widgets::content::DiscoveryState::new(crate::instance::ContentKind::Mod); + self.resource_packs_discovery_state = + widgets::content::DiscoveryState::new(crate::instance::ContentKind::ResourcePack); + self.shaders_discovery_state = + widgets::content::DiscoveryState::new(crate::instance::ContentKind::Shader); + self.datapacks_discovery_state = + widgets::content::DiscoveryState::new(crate::instance::ContentKind::DataPack); + } + + pub(super) fn spawn_selected_content_update_check(&self) { + let Some(instance) = self.instances_state.selected_instance().cloned() else { + return; + }; + let Some((name, manifest)) = self.content_manifest.as_ref() else { + return; + }; + if name != &instance.name { + return; + } + let path = crate::storage::InstancePaths::new( + self.instance_manager.instances_dir.join(&instance.name), + ) + .content_updates(); + crate::instance::content::updates::spawn(instance, manifest.clone(), path); + } } fn delete_content_path(path: &std::path::Path) -> std::io::Result<()> { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 7907b7a..1628583 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -50,6 +50,7 @@ pub async fn show() -> color_eyre::Result<()> { .unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks()); let detected_protocol = picker.protocol_type(); let requested_protocol = match crate::config::SETTINGS.read().ui.image_protocol { + crate::config::settings::ImageProtocol::Auto => detected_protocol, crate::config::settings::ImageProtocol::Halfblocks | crate::config::settings::ImageProtocol::Quadrants => { ratatui_image::picker::ProtocolType::Halfblocks diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index de048dd..88d1042 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -168,7 +168,9 @@ fn structural_settings_update_repairs_runtime_before_persisting() { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), @@ -267,7 +269,9 @@ fn failed_structural_settings_update_keeps_previous_config() { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index fbf5104..e4d576d 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -641,6 +641,8 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert_eq!(ui.app.focused, FocusedArea::GlobalSettings); ui.draw(); assert!(ui.screen().contains("Launcher Settings")); + assert!(ui.screen().contains("Appearance")); + assert!(ui.screen().contains("Image rendering")); assert!(ui.screen().contains("Memory max")); assert!(ui.screen().contains('◆')); assert!(!ui.screen().contains('█')); @@ -653,6 +655,35 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { assert_eq!(ui.app.focused, FocusedArea::Settings); } +#[test] +fn launcher_settings_scroll_to_storage_and_confirm_cache_cleanup() { + let mut ui = UiHarness::new(); + ui.add_instance("global-settings-test"); + ui.key(KeyCode::Char('G')); + + for _ in 0..18 { + ui.key(KeyCode::Char('j')); + } + ui.draw(); + assert!(ui.screen().contains("Storage")); + assert!(ui.screen().contains("Instances")); + assert!(ui.screen().contains("Metadata")); + + for _ in 18..23 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Enter); + assert_eq!(ui.app.focused, FocusedArea::ConfirmDelete); + assert!(matches!( + confirm::pending_target(), + Some(confirm::ConfirmTarget::LauncherCache) + )); + ui.draw(); + assert!(ui.screen().contains("Clear caches")); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::GlobalSettings); +} + #[test] fn runtime_settings_use_the_shared_confirmation_popup() { let mut ui = UiHarness::new(); @@ -953,7 +984,7 @@ fn settings_use_java_memory_and_resolution_controls() { ui.key(KeyCode::Esc); ui.key(KeyCode::Char('G')); - for _ in 0..4 { + for _ in 0..5 { ui.key(KeyCode::Char('j')); } ui.key(KeyCode::Enter); diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 88f28bd..99fcd69 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -136,7 +136,9 @@ impl UiHarness { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/tui/tests/widgets/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 9a8ecb1..82c2272 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -45,7 +45,9 @@ fn instance(name: &str, version: &str) -> InstanceConfig { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/tui/tests/widgets/instances.rs b/src/tui/tests/widgets/instances.rs index 1f26344..29253a1 100644 --- a/src/tui/tests/widgets/instances.rs +++ b/src/tui/tests/widgets/instances.rs @@ -49,7 +49,9 @@ fn synthetic_instance(name: &str) -> InstanceConfig { jvm_args: vec![], environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), diff --git a/src/tui/widgets/instances.rs b/src/tui/widgets/instances.rs index 7b73a22..d59381d 100644 --- a/src/tui/widgets/instances.rs +++ b/src/tui/widgets/instances.rs @@ -36,12 +36,18 @@ static MODPACK_UPDATE_SLOTS: LazyLock> = LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4))); pub fn spawn_modpack_update_checks(instances: &[InstanceConfig]) { + if !crate::config::SETTINGS.read().general.check_modpack_updates { + return; + } for instance in instances { spawn_modpack_update_check(instance); } } pub fn spawn_modpack_update_check(instance: &InstanceConfig) { + if !crate::config::SETTINGS.read().general.check_modpack_updates { + return; + } let Some(source) = instance.modpack_source.clone() else { return; }; diff --git a/src/tui/widgets/popups/confirm.rs b/src/tui/widgets/popups/confirm.rs index d6cb320..2ad0189 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -48,6 +48,7 @@ pub enum ConfirmTarget { InstanceRuntime { name: String, }, + LauncherCache, AutomaticSelection { setting: AutomaticSetting, instance: Option, @@ -81,6 +82,7 @@ impl ConfirmTarget { match self { Self::OrphanDependencies { .. } => " Remove unused dependencies ".to_owned(), Self::InstanceRuntime { .. } => " Change runtime ".to_owned(), + Self::LauncherCache => " Clear caches ".to_owned(), Self::AutomaticSelection { setting, .. } => setting.title().to_owned(), _ => format!(" Delete '{}' ", self.name()), } @@ -123,6 +125,9 @@ impl ConfirmTarget { ConfirmTarget::InstanceRuntime { .. } => { "Some installed mods may be incompatible".to_owned() } + ConfirmTarget::LauncherCache => { + "Provider metadata and Java detection will be rebuilt".to_owned() + } ConfirmTarget::AutomaticSelection { setting, .. } => setting.description().to_owned(), } } @@ -135,6 +140,7 @@ impl ConfirmTarget { ConfirmTarget::Content { name, .. } => name, ConfirmTarget::OrphanDependencies { .. } => "unused dependencies", ConfirmTarget::InstanceRuntime { name, .. } => name, + ConfirmTarget::LauncherCache => "launcher caches", ConfirmTarget::AutomaticSelection { instance, .. } => { instance.as_deref().unwrap_or("launcher") } @@ -146,6 +152,7 @@ impl ConfirmTarget { Self::Content { dependents, .. } if !dependents.is_empty() => " delete anyway", Self::OrphanDependencies { .. } => " remove all", Self::InstanceRuntime { .. } => " change", + Self::LauncherCache => " clear", Self::AutomaticSelection { .. } => " enable", _ => " confirm", } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 0a16297..72a8086 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -16,16 +16,29 @@ use ratatui_textarea::TextArea; use crate::{ config::{ Config, + settings::{ContentProvider, ImageProtocol}, theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, }, - instance::models::normalize_memory_value, + instance::models::{WindowMode, normalize_memory_value, parse_resolution}, tui::widgets::popups::settings_controls::{ - JavaChoice, JavaPicker, SettingsPickerAction, adjust_memory, auto_label, - handle_text_area_input, memory_kib, render_memory_gauge, render_settings_picker, - settings_text_area, + JavaChoice, JavaPicker, SettingsPicker, SettingsPickerAction, SettingsPickerOption, + adjust_memory, auto_label, display_resolutions, environment_labels, handle_text_area_input, + memory_kib, parse_environment, render_memory_gauge, render_settings_picker, + settings_text_area, tagged_value_lines, }, + tui::widgets::status_badge, }; +const FIELD_COUNT: usize = 24; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChoicePicker { + ImageProtocol, + WindowMode, + Resolution, + Provider, +} + pub struct State { pub config: Config, pub theme: ThemeConfig, @@ -38,6 +51,9 @@ pub struct State { theme_index: usize, java_picker_open: bool, java_picker: JavaPicker, + choice_picker: Option, + settings_picker: SettingsPicker, + resolutions: Vec<(u32, u32)>, } pub enum Action { @@ -45,6 +61,7 @@ pub enum Action { Save(Box, String, BorderStyle), Error(String), ConfirmJavaAuto, + ClearCache, OpenRaw(std::path::PathBuf), Close, } @@ -57,12 +74,34 @@ impl State { .iter() .position(|candidate| candidate == &theme.theme) .unwrap_or(0); - let config = crate::config::SETTINGS.read().clone(); - let java_cache = crate::storage::MetadataPaths::new(config.paths.resolve_meta_dir()) - .java_installations(); + let runtime_config = crate::config::SETTINGS.read().clone(); + let config_path = crate::config::get_config_path().join("config.toml"); + let config = crate::config::load_config(&config_path).unwrap_or_else(|error| { + tracing::warn!("Failed to load persisted launcher settings: {error}"); + runtime_config.clone() + }); + let java_cache = + crate::storage::MetadataPaths::new(runtime_config.paths.resolve_meta_dir()) + .java_installations(); let mut java_picker = JavaPicker::with_cache(crate::instance::java::detect_java_path(), Some(java_cache)); java_picker.open(config.paths.java_path.as_deref()); + let mut resolutions = display_resolutions() + .into_iter() + .map(|display| (display.width, display.height)) + .collect::>(); + resolutions.extend([ + (854, 480), + (1280, 720), + (1920, 1080), + (2560, 1440), + (3840, 2160), + ]); + if let Some(resolution) = config.defaults.resolution { + resolutions.push(resolution); + } + resolutions.sort_unstable(); + resolutions.dedup(); Self { config, theme, @@ -75,6 +114,9 @@ impl State { theme_index, java_picker_open: false, java_picker, + choice_picker: None, + settings_picker: SettingsPicker::default(), + resolutions, } } @@ -82,17 +124,39 @@ impl State { match field { 0 => self.theme.theme.clone(), 1 => format!("{:?}", self.theme.border_style).to_lowercase(), - 2 => self.config.defaults.memory_min.clone(), - 3 => self.config.defaults.memory_max.clone(), - 4 => self.config.paths.java_path.clone().unwrap_or_default(), + 2 => self.config.ui.image_protocol.to_string(), + 3 => self.config.defaults.memory_min.clone(), + 4 => self.config.defaults.memory_max.clone(), + 5 => self.config.paths.java_path.clone().unwrap_or_default(), + 6 => self.config.defaults.window_mode.to_string(), + 7 => self + .config + .defaults + .resolution + .map(|(width, height)| format!("{width}x{height}")) + .unwrap_or_default(), + 8 => self.config.defaults.jvm_args.join(" "), + 9 => environment_labels(&self.config.defaults.environment).join(" "), + 10 => self.config.content.preferred_provider.to_string(), + 11 => status(self.config.content.preferred_provider_only), + 12 => status(self.config.content.ask_on_provider_conflict), + 13 => status(self.config.general.check_modpack_updates), + 14 => status(self.config.general.check_content_updates), + 15 => self.config.content.unmatched_retry_hours.to_string(), + 16 => self.config.content.max_fingerprint_size_mib.to_string(), + 17 => self.config.paths.instances_dir.clone(), + 18 => self.config.paths.meta_dir.clone(), + 19 => self.config.ui.error_auto_dismiss_ms.to_string(), + 20 => self.config.ui.error_slide_start_ms.to_string(), + 21 => self.config.ui.error_fly_out_ms.to_string(), + 22 => self.config.ui.max_error_events.to_string(), _ => String::new(), } } fn display_value(&self, field: usize) -> String { match field { - 1 => format!("{:?}", self.theme.border_style).to_lowercase(), - 4 => self.java_picker.display_label( + 5 => self.java_picker.display_label( self.config .paths .java_path @@ -100,6 +164,9 @@ impl State { .filter(|path| !path.is_empty()) .unwrap_or_else(|| self.java_picker.detected_path()), ), + 7 if self.config.defaults.resolution.is_none() => "game default".to_owned(), + 16 if self.config.content.max_fingerprint_size_mib == 0 => "unlimited".to_owned(), + 23 => "provider and Java metadata".to_owned(), _ => self.value(field), } } @@ -116,11 +183,11 @@ impl State { state.editing = Some(settings_text_area(editor.lines().to_vec())); }; match self.selected { - 2 | 3 if normalize_memory_value(value).is_none() => invalid( + 3 | 4 if normalize_memory_value(value).is_none() => invalid( self, "Use a positive memory value ending in K, M, or G.".to_owned(), ), - 2 => { + 3 => { let value = normalize_memory_value(value).unwrap(); self.config.defaults.memory_min = value.clone(); if memory_kib(&value) > memory_kib(&self.config.defaults.memory_max) { @@ -128,7 +195,7 @@ impl State { } self.save_pending = true; } - 3 => { + 4 => { let value = normalize_memory_value(value).unwrap(); self.config.defaults.memory_max = value.clone(); if memory_kib(&value) < memory_kib(&self.config.defaults.memory_min) { @@ -136,10 +203,77 @@ impl State { } self.save_pending = true; } - 4 => { + 5 => { self.config.paths.java_path = (!value.is_empty()).then(|| value.to_owned()); self.save_pending = true; } + 7 if value.is_empty() => { + self.config.defaults.resolution = None; + self.save_pending = true; + } + 7 => match parse_resolution(value) { + Ok(resolution) => { + self.config.defaults.resolution = Some(resolution); + self.save_pending = true; + } + Err(error) => invalid(self, error), + }, + 8 => { + self.config.defaults.jvm_args = + value.split_whitespace().map(str::to_owned).collect(); + self.save_pending = true; + } + 9 => match parse_environment(value) { + Ok(environment) => { + self.config.defaults.environment = environment; + self.save_pending = true; + } + Err(error) => invalid(self, error), + }, + 15 => match value.parse::() { + Ok(hours) => { + self.config.content.unmatched_retry_hours = hours; + self.save_pending = true; + } + Err(_) => invalid(self, "Use a non-negative number of hours.".to_owned()), + }, + 16 => match value.parse::() { + Ok(size) => { + self.config.content.max_fingerprint_size_mib = size; + self.save_pending = true; + } + Err(_) => invalid(self, "Use a non-negative size in MiB.".to_owned()), + }, + 17 | 18 if value.is_empty() => { + invalid(self, "Storage paths cannot be empty.".to_owned()); + } + 17 => { + self.config.paths.instances_dir = value.to_owned(); + self.save_pending = true; + } + 18 => { + self.config.paths.meta_dir = value.to_owned(); + self.save_pending = true; + } + 19..=21 => match value.parse::() { + Ok(milliseconds) if milliseconds > 0 => { + match self.selected { + 19 => self.config.ui.error_auto_dismiss_ms = milliseconds, + 20 => self.config.ui.error_slide_start_ms = milliseconds, + 21 => self.config.ui.error_fly_out_ms = milliseconds, + _ => {} + } + self.save_pending = true; + } + _ => invalid(self, "Use a positive duration in milliseconds.".to_owned()), + }, + 22 => match value.parse::() { + Ok(count) if count > 0 => { + self.config.ui.max_error_events = count; + self.save_pending = true; + } + _ => invalid(self, "Keep at least one notification.".to_owned()), + }, _ => {} } } @@ -198,6 +332,143 @@ impl State { } } + fn choice_options(&self, picker: ChoicePicker) -> Vec { + let option = |key: &str, title: &str, description: &str| SettingsPickerOption { + key: key.to_owned(), + title: title.to_owned(), + detail: Some(description.to_owned()), + leading: None, + badge: None, + active: false, + }; + match picker { + ChoicePicker::ImageProtocol => [ + ( + "auto", + "Auto", + "Use the protocol detected for this terminal", + ), + ("kitty", "Kitty", "Use Kitty graphics when supported"), + ( + "iterm2", + "iTerm2", + "Use the iTerm2 image protocol when supported", + ), + ( + "quadrants", + "Quadrants", + "Render images with quadrant characters", + ), + ( + "halfblocks", + "Halfblocks", + "Render images with half-block characters", + ), + ] + .into_iter() + .map(|(key, title, description)| option(key, title, description)) + .collect(), + ChoicePicker::WindowMode => [ + ("windowed", "windowed", "Launch new instances in a window"), + ( + "fullscreen", + "fullscreen", + "Launch new instances in fullscreen", + ), + ] + .into_iter() + .map(|(key, title, description)| option(key, title, description)) + .collect(), + ChoicePicker::Resolution => std::iter::once(option( + "default", + "Game default", + "Let Minecraft choose the initial window size", + )) + .chain(self.resolutions.iter().map(|(width, height)| { + let value = format!("{width}x{height}"); + option(&value, &value, "Use this size for newly created instances") + })) + .collect(), + ChoicePicker::Provider => { + let mut options = vec![option( + "modrinth", + "Modrinth", + "Prefer Modrinth when projects exist on multiple providers", + )]; + if crate::net::curseforge::api_key().is_some() { + options.push(option( + "curseforge", + "CurseForge", + "Prefer CurseForge when projects exist on multiple providers", + )); + } + options + } + } + } + + fn open_choice_picker(&mut self, picker: ChoicePicker) { + let preferred = match picker { + ChoicePicker::ImageProtocol => self.config.ui.image_protocol.to_string(), + ChoicePicker::WindowMode => self.config.defaults.window_mode.to_string(), + ChoicePicker::Resolution => self.value(7), + ChoicePicker::Provider => self.config.content.preferred_provider.as_str().to_owned(), + }; + self.settings_picker.reset(); + self.settings_picker + .sync(self.choice_options(picker), Some(&preferred)); + self.choice_picker = Some(picker); + } + + fn handle_choice_picker_key(&mut self, key: &KeyEvent) { + match self.settings_picker.handle_key(key) { + SettingsPickerAction::Back => self.choice_picker = None, + SettingsPickerAction::Select => { + let Some(picker) = self.choice_picker else { + return; + }; + let Some(value) = self.settings_picker.selected_key().map(str::to_owned) else { + return; + }; + match picker { + ChoicePicker::ImageProtocol => { + self.config.ui.image_protocol = match value.as_str() { + "kitty" => ImageProtocol::Kitty, + "iterm2" => ImageProtocol::Iterm2, + "quadrants" => ImageProtocol::Quadrants, + "halfblocks" => ImageProtocol::Halfblocks, + _ => ImageProtocol::Auto, + }; + } + ChoicePicker::WindowMode => { + self.config.defaults.window_mode = if value == "fullscreen" { + WindowMode::Fullscreen + } else { + WindowMode::Windowed + }; + } + ChoicePicker::Resolution => { + self.config.defaults.resolution = if value == "default" { + None + } else { + parse_resolution(&value).ok() + }; + } + ChoicePicker::Provider => { + self.config.content.preferred_provider = if value == "curseforge" { + ContentProvider::CurseForge + } else { + ContentProvider::Modrinth + }; + } + } + self.save_pending = true; + self.choice_picker = None; + } + SettingsPickerAction::None => {} + } + } + fn open_java_picker(&mut self) { self.java_picker .open(self.config.paths.java_path.as_deref()); @@ -255,13 +526,13 @@ impl State { } fn adjust_selected_memory(&mut self, forward: bool) { - let value = if self.selected == 2 { + let value = if self.selected == 3 { &self.config.defaults.memory_min } else { &self.config.defaults.memory_max }; let value = adjust_memory(value, forward); - if self.selected == 2 { + if self.selected == 3 { self.config.defaults.memory_min = value.clone(); if memory_kib(&value) > memory_kib(&self.config.defaults.memory_max) { self.config.defaults.memory_max = value; @@ -276,6 +547,30 @@ impl State { self.error = None; } + fn toggle_selected(&mut self) { + match self.selected { + 11 => { + self.config.content.preferred_provider_only = + !self.config.content.preferred_provider_only; + } + 12 => { + self.config.content.ask_on_provider_conflict = + !self.config.content.ask_on_provider_conflict; + } + 13 => { + self.config.general.check_modpack_updates = + !self.config.general.check_modpack_updates; + } + 14 => { + self.config.general.check_content_updates = + !self.config.general.check_content_updates; + } + _ => return, + } + self.save_pending = true; + self.error = None; + } + pub fn handle_key(&mut self, key: &KeyEvent) -> Action { let action = self.handle_key_inner(key); if !matches!(action, Action::None) { @@ -286,6 +581,7 @@ impl State { } if self.save_pending && self.editing.is_none() { self.save_pending = false; + self.config = self.config.clone().normalize(); return Action::Save( Box::new(self.config.clone()), self.theme.theme.clone(), @@ -296,6 +592,10 @@ impl State { } fn handle_key_inner(&mut self, key: &KeyEvent) -> Action { + if self.choice_picker.is_some() { + self.handle_choice_picker_key(key); + return Action::None; + } if self.java_picker_open { self.handle_java_picker_key(key); return Action::None; @@ -313,28 +613,43 @@ impl State { return Action::None; } match key.code { - KeyCode::Char('j') | KeyCode::Down => self.selected = (self.selected + 1).min(4), + KeyCode::Char('j') | KeyCode::Down => { + self.selected = (self.selected + 1).min(FIELD_COUNT - 1); + } KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), KeyCode::Char('h') | KeyCode::Left if self.selected == 1 => self.cycle_border(false), KeyCode::Char('l') | KeyCode::Right if self.selected == 1 => self.cycle_border(true), - KeyCode::Char('h') | KeyCode::Left if matches!(self.selected, 2 | 3) => { + KeyCode::Char('h') | KeyCode::Left if matches!(self.selected, 3 | 4) => { self.adjust_selected_memory(false); } - KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 2 | 3) => { + KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 3 | 4) => { self.adjust_selected_memory(true); } KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, 1 => self.cycle_border(true), - 2 | 3 => { + 2 => self.open_choice_picker(ChoicePicker::ImageProtocol), + 3 | 4 => { self.editing = Some(settings_text_area(vec![self.value(self.selected)])); } - 4 => self.open_java_picker(), + 5 => self.open_java_picker(), + 6 => self.open_choice_picker(ChoicePicker::WindowMode), + 7 => self.open_choice_picker(ChoicePicker::Resolution), + 10 => self.open_choice_picker(ChoicePicker::Provider), + 11..=14 => self.toggle_selected(), + 23 => return Action::ClearCache, field => self.editing = Some(settings_text_area(vec![self.value(field)])), }, - KeyCode::Char('a') if self.selected == 4 => return self.toggle_auto_java(), - KeyCode::Char('c') if self.selected == 4 => { - self.editing = Some(settings_text_area(vec![self.value(4)])); + KeyCode::Char('a') if self.selected == 5 => return self.toggle_auto_java(), + KeyCode::Char('c') if self.selected == 5 => { + self.editing = Some(settings_text_area(vec![self.value(5)])); + } + KeyCode::Char('c') if self.selected == 7 => { + self.editing = Some(settings_text_area(vec![self.value(7)])); + } + KeyCode::Char('d') if self.selected == 7 => { + self.config.defaults.resolution = None; + self.save_pending = true; } KeyCode::Char('E') => { let file = if self.selected <= 1 { @@ -382,12 +697,16 @@ fn available_themes() -> Vec { } pub fn popup_rect(area: Rect, state: &State) -> Rect { - let height = if state.theme_picker || state.java_picker_open { + let height = if state.theme_picker || state.java_picker_open || state.choice_picker.is_some() { (area.height * 2 / 3).max(10) } else { - 7 + 26 + }; + let width = if state.java_picker_open || state.choice_picker.is_some() { + 72 + } else { + 68 }; - let width = if state.java_picker_open { 72 } else { 52 }; area.centered( ratatui::layout::Constraint::Percentage(width), ratatui::layout::Constraint::Length(height.min(area.height.saturating_sub(4))), @@ -402,17 +721,30 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { frame.render_widget(Clear, area); let keybinds = if state.editing.is_some() { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) - } else if state.java_picker_open || state.theme_picker { + } else if state.java_picker_open || state.theme_picker || state.choice_picker.is_some() { super::keybind_line(&[("h", " back"), ("Enter", " select")]) - } else if matches!(state.selected, 2 | 3) { + } else if matches!(state.selected, 3 | 4) { super::keybind_line(&[("h/l", " adjust"), ("Enter", " exact"), ("Esc", " back")]) - } else if state.selected == 4 { + } else if state.selected == 5 { super::keybind_line(&[ ("Enter", " runtimes"), ("a", " auto"), ("c", " custom"), ("Esc", " back"), ]) + } else if matches!(state.selected, 2 | 6 | 10) { + super::keybind_line(&[("Enter", " select"), ("E", " raw"), ("Esc", " back")]) + } else if state.selected == 7 { + super::keybind_line(&[ + ("Enter", " presets"), + ("c", " custom"), + ("d", " default"), + ("Esc", " back"), + ]) + } else if matches!(state.selected, 11..=14) { + super::keybind_line(&[("Enter", " toggle"), ("E", " raw"), ("Esc", " back")]) + } else if state.selected == 23 { + super::keybind_line(&[("Enter", " clear"), ("Esc", " back")]) } else if state.selected == 0 { super::keybind_line(&[ ("j/k", ""), @@ -439,6 +771,13 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { " Theme " } else if state.java_picker_open { " Java Runtime " + } else if let Some(picker) = state.choice_picker { + match picker { + ChoicePicker::ImageProtocol => " Image Rendering ", + ChoicePicker::WindowMode => " Default Window Mode ", + ChoicePicker::Resolution => " Default Resolution ", + ChoicePicker::Provider => " Preferred Provider ", + } } else { " Launcher Settings " }; @@ -459,6 +798,10 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { render_java_picker(frame, inner, state); return; } + if state.choice_picker.is_some() { + render_settings_picker(&state.settings_picker, inner, frame.buffer_mut()); + return; + } render_settings_list(frame, inner, state); } @@ -496,38 +839,162 @@ fn render_java_picker(frame: &mut Frame, area: Rect, state: &mut State) { } fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { - let labels = ["Theme", "Border style", "Memory min", "Memory max", "Java"]; - let lines = labels - .iter() - .enumerate() - .map(|(index, label)| global_field_line(state, index, label)) - .collect::>(); - frame.render_widget(Paragraph::new(lines), area); - - for field in [2, 3] { - let value = state.value(field); - render_memory_gauge( - frame, + let theme = THEME.as_ref(); + let sections: [(&str, &[usize]); 6] = [ + ("Appearance", &[0, 1, 2]), + ("Launch Defaults", &[3, 4, 5, 6, 7, 8, 9]), + ("Content", &[10, 11, 12, 13, 14, 15, 16]), + ("Storage", &[17, 18]), + ("Notifications", &[19, 20, 21, 22]), + ("Maintenance", &[23]), + ]; + let jvm_args = &state.config.defaults.jvm_args; + let environment = environment_labels(&state.config.defaults.environment); + let mut rows: Vec<(Option, Vec>)> = Vec::new(); + for (section_index, (title, fields)) in sections.iter().enumerate() { + if section_index > 0 { + rows.push((None, vec![Line::default()])); + } + rows.push((None, vec![section_line(title)])); + for index in *fields { + let label = field_label(*index); + let lines = match index { + 8 => tagged_value_lines( + global_field_line(state, *index, label).spans, + state.selected == *index, + state.editing.is_some(), + jvm_args, + "no arguments", + area.width, + ), + 9 => tagged_value_lines( + global_field_line(state, *index, label).spans, + state.selected == *index, + state.editing.is_some(), + &environment, + "no variables", + area.width, + ), + _ => vec![global_field_line(state, *index, label)], + }; + rows.push((Some(*index), lines)); + } + } + + let mut selected_end = 0u16; + let mut cursor = 0u16; + for (field, lines) in &rows { + let height = lines.len() as u16; + if *field == Some(state.selected) { + selected_end = cursor.saturating_add(height); + } + cursor = cursor.saturating_add(height); + } + let scroll = selected_end.saturating_sub(area.height); + let mut row_start = 0u16; + for (field, lines) in rows { + let height = lines.len() as u16; + let row_end = row_start.saturating_add(height); + if row_end <= scroll || row_start >= scroll.saturating_add(area.height) { + row_start = row_end; + continue; + } + let skip = scroll.saturating_sub(row_start) as usize; + let y = area.y.saturating_add(row_start.saturating_sub(scroll)); + let visible_height = height + .saturating_sub(skip as u16) + .min(area.y.saturating_add(area.height).saturating_sub(y)); + let selected = field == Some(state.selected); + frame.render_widget( + Paragraph::new( + lines + .into_iter() + .skip(skip) + .take(visible_height as usize) + .collect::>(), + ) + .style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })), Rect { - x: area.x.saturating_add(20), - y: area.y.saturating_add(field as u16), - width: area.width.saturating_sub(21), - height: 1, + y, + height: visible_height, + ..area }, - &value, - value.clone(), - state.selected == field, ); + if let Some(field @ (3 | 4)) = field + && row_start >= scroll + { + let value = state.value(field); + render_memory_gauge( + frame, + Rect { + x: area.x.saturating_add(20), + y: area.y.saturating_add(row_start.saturating_sub(scroll)), + width: area.width.saturating_sub(21), + height: 1, + }, + &value, + value.clone(), + state.selected == field, + ); + } + if field == Some(state.selected) + && row_start >= scroll + && let Some(editor) = state.editing.as_ref() + { + frame.render_widget( + editor, + Rect { + x: area.x.saturating_add(20), + y: area.y.saturating_add(row_start.saturating_sub(scroll)), + width: area.width.saturating_sub(20), + height: 1, + }, + ); + } + row_start = row_end; } +} - if let Some(editor) = state.editing.as_ref() { - let edit_area = Rect { - x: area.x.saturating_add(20), - y: area.y.saturating_add(state.selected as u16), - width: area.width.saturating_sub(20), - height: 1, - }; - frame.render_widget(editor, edit_area); +fn section_line(title: &str) -> Line<'static> { + Line::from(Span::styled( + format!(" {title}"), + Style::default() + .fg(THEME.as_ref().text()) + .add_modifier(Modifier::BOLD), + )) +} + +fn field_label(index: usize) -> &'static str { + match index { + 0 => "Theme", + 1 => "Border style", + 2 => "Image rendering", + 3 => "Memory min", + 4 => "Memory max", + 5 => "Java", + 6 => "Window mode", + 7 => "Resolution", + 8 => "JVM arguments", + 9 => "Environment", + 10 => "Provider", + 11 => "Provider only", + 12 => "Ask on conflict", + 13 => "Modpack updates", + 14 => "Content updates", + 15 => "Retry hours", + 16 => "Fingerprint MiB", + 17 => "Instances", + 18 => "Metadata", + 19 => "Dismiss ms", + 20 => "Slide start ms", + 21 => "Fly-out ms", + 22 => "Max notifications", + 23 => "Clear caches", + _ => "", } } @@ -545,7 +1012,7 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> Style::default().fg(theme.text_dim()), ), Span::styled( - if editing || matches!(index, 2 | 3) { + if editing || matches!(index, 3 | 4) { String::new() } else { state.display_value(index) @@ -563,9 +1030,15 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> }), ), ]; - if index == 4 && state.config.paths.java_path.is_none() && !editing { + if index == 5 && state.config.paths.java_path.is_none() && !editing { spans.extend([Span::raw(" "), auto_label()]); } + if restart_required_for(state, index) { + spans.extend([ + Span::raw(" "), + status_badge("Restart", THEME.as_ref().warning()), + ]); + } Line::from(spans).style(Style::default().bg(if selected { theme.stripe() } else { @@ -573,6 +1046,20 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> })) } +fn restart_required_for(state: &State, index: usize) -> bool { + let current = crate::config::SETTINGS.read(); + match index { + 2 => state.config.ui.image_protocol != current.ui.image_protocol, + 17 => state.config.paths.instances_dir != current.paths.instances_dir, + 18 => state.config.paths.meta_dir != current.paths.meta_dir, + _ => false, + } +} + +fn status(enabled: bool) -> String { + if enabled { "enabled" } else { "disabled" }.to_owned() +} + #[cfg(test)] mod tests { use super::*; @@ -580,7 +1067,7 @@ mod tests { #[test] fn launcher_memory_uses_slider_and_java_uses_picker() { let mut state = State::new(); - state.selected = 2; + state.selected = 3; let original = state.config.defaults.memory_min.clone(); assert!(matches!( state.handle_key(&KeyEvent::from(KeyCode::Char('l'))), @@ -593,7 +1080,7 @@ mod tests { assert!(state.editing.is_some()); state.editing = None; - state.selected = 4; + state.selected = 5; state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert!(state.java_picker_open); } @@ -601,7 +1088,7 @@ mod tests { #[test] fn java_auto_mode_toggles_to_and_from_the_detected_path() { let mut state = State::new(); - state.selected = 4; + state.selected = 5; state.config.paths.java_path = None; assert!(matches!( @@ -623,7 +1110,7 @@ mod tests { #[test] fn java_picker_does_not_toggle_auto_mode() { let mut state = State::new(); - state.selected = 4; + state.selected = 5; state.config.paths.java_path = Some("/custom/java".to_owned()); state.open_java_picker(); @@ -641,7 +1128,7 @@ mod tests { #[test] fn changing_runtime_to_auto_requests_confirmation() { let mut state = State::new(); - state.selected = 4; + state.selected = 5; state.config.paths.java_path = Some("/custom/java".to_owned()); assert!(matches!( @@ -653,4 +1140,55 @@ mod tests { Some("/custom/java") ); } + + #[test] + fn launcher_choices_toggles_and_maintenance_are_interactive() { + let mut state = State::new(); + state.selected = 2; + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.choice_picker, Some(ChoicePicker::ImageProtocol)); + state.handle_key(&KeyEvent::from(KeyCode::Char('j'))); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + assert_eq!(state.config.ui.image_protocol, ImageProtocol::Kitty); + + state.selected = 11; + let previous = state.config.content.preferred_provider_only; + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + assert_eq!(state.config.content.preferred_provider_only, !previous); + + state.selected = 23; + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::ClearCache + )); + } + + #[test] + fn launcher_jvm_and_environment_defaults_share_tag_editing() { + let mut state = State::new(); + state.selected = 8; + state.editing = Some(settings_text_area(vec!["-Xfoo -Xbar".to_owned()])); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + assert_eq!(state.config.defaults.jvm_args, ["-Xfoo", "-Xbar"]); + + state.selected = 9; + state.editing = Some(settings_text_area(vec!["FOO=bar BAZ=qux".to_owned()])); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + assert_eq!( + state.config.defaults.environment.get("FOO"), + Some(&"bar".to_owned()) + ); + } } diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 9b0ab84..3ed5a82 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -12,10 +12,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, ListItem, Paragraph}, }; use ratatui_textarea::TextArea; -use std::{ - collections::BTreeMap, - sync::{Arc, Mutex}, -}; +use std::sync::{Arc, Mutex}; use crate::{ auth::{Account, AccountType}, @@ -35,11 +32,13 @@ use crate::{ DisplayResolution, GlfwChoice, GlfwPicker, JavaChoice, JavaPicker, SettingsPicker, SettingsPickerAction, SettingsPickerBadge, SettingsPickerOption, adjust_memory, auto_label, bundled_glfw_version, bundled_label as bundled_badge, - display_resolutions, handle_text_area_input, memory_kib, render_memory_gauge, - render_settings_picker, settings_text_area, + display_resolutions, environment_labels, handle_text_area_input, memory_kib, + parse_environment, render_memory_gauge, render_settings_picker, settings_text_area, + tagged_row_count, tagged_value_lines, }, }, search::SearchState, + status_badge, }, }; @@ -252,10 +251,13 @@ impl State { .map(|(key, value)| format!("{key}={value}")) .collect::>() .join(" "), - 8 => self.draft.window_mode.to_string(), + 8 => self + .draft + .effective_window_mode(SETTINGS.read().defaults.window_mode) + .to_string(), 9 => self .draft - .resolution + .effective_resolution(SETTINGS.read().defaults.resolution) .map(|(w, h)| format!("{w}x{h}")) .unwrap_or_default(), 10 => self.draft.preferred_account.clone().unwrap_or_default(), @@ -282,10 +284,11 @@ impl State { 6 => self.draft.jvm_args.join(" "), 7 if self.draft.environment.is_empty() => "no variables".to_owned(), 7 => self.value(7), - 9 if self.draft.resolution.is_none() => self.default_resolution().map_or_else( - || "not detected".to_owned(), + 9 if self.draft.inherit_resolution => SETTINGS.read().defaults.resolution.map_or_else( + || "game default".to_owned(), |(width, height)| format!("{width}x{height}"), ), + 9 if self.draft.resolution.is_none() => "game default".to_owned(), 10 => self.preferred_account_label(), 11 if self.desktop => "enabled".to_owned(), 11 => "disabled".to_owned(), @@ -400,7 +403,11 @@ impl State { 6 => self.editing = Some(settings_text_area(vec![self.value(self.selected)])), 7 => self.editing = Some(settings_text_area(vec![self.value(7)])), 8 => { - self.draft.window_mode = match self.draft.window_mode { + let current = self + .draft + .effective_window_mode(SETTINGS.read().defaults.window_mode); + self.draft.inherit_window_mode = false; + self.draft.window_mode = match current { WindowMode::Windowed => WindowMode::Fullscreen, WindowMode::Fullscreen => WindowMode::Windowed, }; @@ -430,10 +437,13 @@ impl State { self.java_picker.initialize(); } ChoicePicker::Resolution => { + let resolution = self + .draft + .effective_resolution(SETTINGS.read().defaults.resolution); self.choice_index = self .resolution_choices() .iter() - .position(|choice| choice.resolution() == self.draft.resolution) + .position(|choice| choice.resolution() == resolution) .unwrap_or(0); } ChoicePicker::Account => { @@ -536,6 +546,7 @@ impl State { .and_then(|choice| choice.resolution()); if let Some(resolution) = selected { self.draft.resolution = Some(resolution); + self.draft.inherit_resolution = false; } } Some(ChoicePicker::Account) => { @@ -736,7 +747,11 @@ impl State { } fn resolution_choices(&self) -> Vec { - resolution_choices(self.draft.resolution, &self.display_resolutions) + resolution_choices( + self.draft + .effective_resolution(SETTINGS.read().defaults.resolution), + &self.display_resolutions, + ) } fn default_resolution(&self) -> Option<(u32, u32)> { @@ -758,12 +773,28 @@ impl State { fn apply_default_resolution(&mut self) { if let Some(resolution) = self.default_resolution() { self.draft.resolution = Some(resolution); + self.draft.inherit_resolution = false; self.error = None; } else { self.error = Some("Display resolution could not be detected.".to_owned()); } } + fn toggle_global_window_default(&mut self) { + if self.selected == 8 { + if self.draft.inherit_window_mode { + self.draft.window_mode = SETTINGS.read().defaults.window_mode; + } + self.draft.inherit_window_mode = !self.draft.inherit_window_mode; + } else if self.selected == 9 { + if self.draft.inherit_resolution { + self.draft.resolution = SETTINGS.read().defaults.resolution; + } + self.draft.inherit_resolution = !self.draft.inherit_resolution; + } + self.error = None; + } + fn toggle_auto_java(&mut self) -> Action { let Some(current) = self.draft.java_path.as_deref() else { self.draft.java_path = Some(self.java_picker.detected_path().to_owned()); @@ -934,9 +965,15 @@ impl State { Ok(environment) => self.draft.environment = environment, Err(error) => invalid(self, error), }, - 9 if value.is_empty() => self.draft.resolution = None, + 9 if value.is_empty() => { + self.draft.resolution = None; + self.draft.inherit_resolution = false; + } 9 => match parse_resolution(value) { - Ok(resolution) => self.draft.resolution = Some(resolution), + Ok(resolution) => { + self.draft.resolution = Some(resolution); + self.draft.inherit_resolution = false; + } Err(error) => invalid(self, error), }, 12 | 13 => { @@ -1036,6 +1073,9 @@ impl State { self.apply_default_memory(); } KeyCode::Char('d') if self.selected == 9 => self.apply_default_resolution(), + KeyCode::Char('i') if matches!(self.selected, 8 | 9) => { + self.toggle_global_window_default(); + } KeyCode::Char('c') if self.selected == 9 => { self.editing = Some(settings_text_area(vec![self.value(9)])); } @@ -1124,27 +1164,6 @@ fn resolution_choices( choices } -fn parse_environment(value: &str) -> Result, String> { - let mut environment = BTreeMap::new(); - for assignment in value.split_whitespace() { - let Some((key, value)) = assignment.split_once('=') else { - return Err(format!( - "Environment variable '{assignment}' must use KEY=value." - )); - }; - if key.is_empty() || key.contains('\0') || value.contains('\0') { - return Err("Environment variable names cannot be empty.".to_owned()); - } - if environment - .insert(key.to_owned(), value.to_owned()) - .is_some() - { - return Err(format!("Environment variable '{key}' is repeated.")); - } - } - Ok(environment) -} - pub fn popup_rect(area: Rect, state: &State) -> Rect { let height = if state.picker.is_some() || matches!( @@ -1232,6 +1251,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("Enter", " presets"), ("c", " custom"), ("d", " default"), + ("i", " global"), ("Esc", " back"), ]) } else if state.selected == 10 { @@ -1248,7 +1268,15 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("E", " raw"), ("Esc", " back"), ]) - } else if matches!(state.selected, 8 | 11) { + } else if state.selected == 8 { + super::keybind_line(&[ + ("j/k", ""), + ("Enter", " toggle"), + ("i", " global"), + ("E", " raw"), + ("Esc", " back"), + ]) + } else if state.selected == 11 { super::keybind_line(&[ ("j/k", ""), ("Enter", " toggle"), @@ -1517,6 +1545,11 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { if index == 14 && state.draft.glfw_path.is_none() && !editing { spans.extend([Span::raw(" "), bundled_badge()]); } + if (index == 8 && state.draft.inherit_window_mode) + || (index == 9 && state.draft.inherit_resolution) + { + spans.extend([Span::raw(" "), status_badge("Global", theme.accent())]); + } Line::from(spans) } @@ -1587,77 +1620,15 @@ fn tagged_field_lines( empty: &str, width: u16, ) -> Vec> { - let theme = THEME.as_ref(); let selected = state.selected == index; - let mut prefix = field_line(state, index, label).spans; - if selected && state.editing.is_some() { - return vec![Line::from(prefix)]; - } - if values.is_empty() { - prefix.push(Span::styled( - empty.to_owned(), - Style::default().fg(theme.text_dim()), - )); - return vec![Line::from(prefix)]; - } - - let available = width.saturating_sub(20) as usize; - let mut lines = Vec::new(); - let mut spans = prefix; - let mut used = 0usize; - for argument in values { - let badge_width = argument.chars().count() + 2; - let separator = usize::from(used > 0); - if used > 0 && used + separator + badge_width > available { - lines.push(Line::from(spans)); - spans = vec![Span::raw(" ".repeat(20))]; - used = 0; - } - if used > 0 { - spans.push(Span::raw(" ")); - used += 1; - } - spans.push(Span::styled( - format!(" {argument} "), - Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .bg(theme.background()) - .add_modifier(Modifier::BOLD), - )); - used += badge_width; - } - lines.push(Line::from(spans)); - lines -} - -fn tagged_row_count(values: &[String], width: u16) -> usize { - if values.is_empty() { - return 1; - } - let available = width.saturating_sub(20) as usize; - let mut rows = 1; - let mut used = 0usize; - for argument in values { - let badge_width = argument.chars().count() + 2; - let separator = usize::from(used > 0); - if used > 0 && used + separator + badge_width > available { - rows += 1; - used = 0; - } - used += usize::from(used > 0) + badge_width; - } - rows -} - -fn environment_labels(environment: &BTreeMap) -> Vec { - environment - .iter() - .map(|(key, value)| format!("{key}={value}")) - .collect() + tagged_value_lines( + field_line(state, index, label).spans, + selected, + state.editing.is_some(), + values, + empty, + width, + ) } fn render_choice_picker(frame: &mut Frame, area: Rect, state: &mut State) { @@ -1861,7 +1832,9 @@ mod tests { jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), @@ -2186,7 +2159,7 @@ mod tests { } #[test] - fn inherited_resolution_displays_the_detected_primary_size() { + fn explicit_game_default_resolution_is_labeled_clearly() { let temp = tempfile::tempdir().unwrap(); let mut state = State::new(&instance(), temp.path()); state.display_resolutions = vec![DisplayResolution { @@ -2196,7 +2169,31 @@ mod tests { primary: true, }]; - assert_eq!(state.display_value(9), "2560x1440"); + assert_eq!(state.display_value(9), "game default"); + } + + #[test] + fn window_settings_can_explicitly_inherit_launcher_defaults() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + + state.selected = 8; + state.handle_key(&KeyEvent::from(KeyCode::Char('i'))); + assert!(state.draft.inherit_window_mode); + assert_eq!( + state.display_value(8), + SETTINGS.read().defaults.window_mode.to_string() + ); + + state.selected = 9; + state.handle_key(&KeyEvent::from(KeyCode::Char('i'))); + assert!(state.draft.inherit_resolution); + assert_eq!( + state + .draft + .effective_resolution(SETTINGS.read().defaults.resolution), + SETTINGS.read().defaults.resolution + ); } #[test] diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 5450517..be2c101 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -4,7 +4,7 @@ // Reusable interactive controls shared by instance and launcher settings. use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, sync::{Arc, Mutex}, }; @@ -763,15 +763,7 @@ fn java_title(version: &str) -> String { } pub(crate) fn memory_kib(value: &str) -> Option { - let normalized = crate::instance::models::normalize_memory_value(value)?; - let (number, suffix) = normalized.split_at(normalized.len().saturating_sub(1)); - let number = number.parse::().ok()?; - match suffix { - "K" => Some(number), - "M" => number.checked_mul(1024), - "G" => number.checked_mul(1024 * 1024), - _ => None, - } + crate::instance::models::memory_kib(value) } pub(crate) fn adjust_memory(value: &str, forward: bool) -> String { @@ -887,6 +879,106 @@ pub(crate) fn settings_text_area(lines: Vec) -> TextArea<'static> { editor } +pub(crate) fn parse_environment(value: &str) -> Result, String> { + let mut environment = BTreeMap::new(); + for assignment in value.split_whitespace() { + let Some((key, value)) = assignment.split_once('=') else { + return Err(format!( + "Environment variable '{assignment}' must use KEY=value." + )); + }; + if key.is_empty() || key.contains('\0') || value.contains('\0') { + return Err("Environment variable names cannot be empty.".to_owned()); + } + if environment + .insert(key.to_owned(), value.to_owned()) + .is_some() + { + return Err(format!("Environment variable '{key}' is repeated.")); + } + } + Ok(environment) +} + +pub(crate) fn environment_labels(environment: &BTreeMap) -> Vec { + environment + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect() +} + +pub(crate) fn tagged_value_lines( + mut prefix: Vec>, + selected: bool, + editing: bool, + values: &[String], + empty: &str, + width: u16, +) -> Vec> { + let theme = THEME.as_ref(); + if selected && editing { + return vec![Line::from(prefix)]; + } + if values.is_empty() { + prefix.push(Span::styled( + empty.to_owned(), + Style::default().fg(theme.text_dim()), + )); + return vec![Line::from(prefix)]; + } + + let available = width.saturating_sub(20) as usize; + let mut lines = Vec::new(); + let mut spans = prefix; + let mut used = 0usize; + for value in values { + let badge_width = value.chars().count() + 2; + let separator = usize::from(used > 0); + if used > 0 && used + separator + badge_width > available { + lines.push(Line::from(spans)); + spans = vec![Span::raw(" ".repeat(20))]; + used = 0; + } + if used > 0 { + spans.push(Span::raw(" ")); + used += 1; + } + spans.push(Span::styled( + format!(" {value} "), + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .bg(theme.background()) + .add_modifier(Modifier::BOLD), + )); + used += badge_width; + } + lines.push(Line::from(spans)); + lines +} + +pub(crate) fn tagged_row_count(values: &[String], width: u16) -> usize { + if values.is_empty() { + return 1; + } + let available = width.saturating_sub(20) as usize; + let mut rows = 1; + let mut used = 0usize; + for value in values { + let badge_width = value.chars().count() + 2; + let separator = usize::from(used > 0); + if used > 0 && used + separator + badge_width > available { + rows += 1; + used = 0; + } + used += usize::from(used > 0) + badge_width; + } + rows +} + pub(crate) fn auto_label() -> Span<'static> { status_badge("Auto", THEME.as_ref().success()) } diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs index 285c5dd..b51d76f 100644 --- a/tests/launch_pipeline.rs +++ b/tests/launch_pipeline.rs @@ -58,7 +58,9 @@ fn make_config_with( jvm_args: Vec::new(), environment: Default::default(), window_mode: Default::default(), + inherit_window_mode: false, resolution: None, + inherit_resolution: false, preferred_account: None, pre_launch_command: Default::default(), post_exit_command: Default::default(), From 5ba1e70e391c22e21ee4b672d9ceb11dfbbfd262 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 21:18:09 +0200 Subject: [PATCH 21/42] fix: align launcher settings controls --- src/tui/tests/flows.rs | 2 +- src/tui/widgets/popups/global_settings.rs | 514 +++++++++++++------- src/tui/widgets/popups/instance_settings.rs | 140 +----- src/tui/widgets/popups/settings_controls.rs | 141 +++++- 4 files changed, 499 insertions(+), 298 deletions(-) diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index e4d576d..e631e33 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -656,7 +656,7 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { } #[test] -fn launcher_settings_scroll_to_storage_and_confirm_cache_cleanup() { +fn launcher_settings_expand_to_storage_and_confirm_cache_cleanup() { let mut ui = UiHarness::new(); ui.add_instance("global-settings-test"); ui.key(KeyCode::Char('G')); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 72a8086..59117f4 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -19,12 +19,14 @@ use crate::{ settings::{ContentProvider, ImageProtocol}, theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, }, - instance::models::{WindowMode, normalize_memory_value, parse_resolution}, + instance::models::{normalize_memory_value, parse_resolution}, tui::widgets::popups::settings_controls::{ - JavaChoice, JavaPicker, SettingsPicker, SettingsPickerAction, SettingsPickerOption, - adjust_memory, auto_label, display_resolutions, environment_labels, handle_text_area_input, - memory_kib, parse_environment, render_memory_gauge, render_settings_picker, - settings_text_area, tagged_value_lines, + DisplayResolution, JavaChoice, JavaPicker, ResolutionChoice, ResolutionPickerAction, + SettingsPicker, SettingsPickerAction, SettingsPickerOption, adjust_memory, auto_label, + display_resolutions, environment_labels, handle_resolution_picker_key, + handle_text_area_input, memory_kib, parse_environment, render_memory_gauge, + render_settings_picker, resolution_choices, resolution_items, settings_text_area, + tagged_row_count, tagged_value_lines, toggle_window_mode, }, tui::widgets::status_badge, }; @@ -34,9 +36,7 @@ const FIELD_COUNT: usize = 24; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChoicePicker { ImageProtocol, - WindowMode, Resolution, - Provider, } pub struct State { @@ -53,7 +53,8 @@ pub struct State { java_picker: JavaPicker, choice_picker: Option, settings_picker: SettingsPicker, - resolutions: Vec<(u32, u32)>, + choice_index: usize, + display_resolutions: Vec, } pub enum Action { @@ -86,22 +87,6 @@ impl State { let mut java_picker = JavaPicker::with_cache(crate::instance::java::detect_java_path(), Some(java_cache)); java_picker.open(config.paths.java_path.as_deref()); - let mut resolutions = display_resolutions() - .into_iter() - .map(|display| (display.width, display.height)) - .collect::>(); - resolutions.extend([ - (854, 480), - (1280, 720), - (1920, 1080), - (2560, 1440), - (3840, 2160), - ]); - if let Some(resolution) = config.defaults.resolution { - resolutions.push(resolution); - } - resolutions.sort_unstable(); - resolutions.dedup(); Self { config, theme, @@ -116,7 +101,8 @@ impl State { java_picker, choice_picker: None, settings_picker: SettingsPicker::default(), - resolutions, + choice_index: 0, + display_resolutions: display_resolutions(), } } @@ -175,7 +161,7 @@ impl State { let Some(editor) = self.editing.take() else { return; }; - let raw = editor.lines().join(""); + let raw = editor.lines().join("\n"); let value = raw.trim(); self.error = None; let invalid = |state: &mut Self, message: String| { @@ -293,6 +279,20 @@ impl State { } } + fn cycle_theme(&mut self, forward: bool) { + if self.themes.is_empty() { + return; + } + self.theme_index = if forward { + (self.theme_index + 1) % self.themes.len() + } else { + self.theme_index + .checked_sub(1) + .unwrap_or(self.themes.len() - 1) + }; + self.select_theme(); + } + fn handle_theme_picker_key(&mut self, key: &KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.theme_picker = false, @@ -332,7 +332,7 @@ impl State { } } - fn choice_options(&self, picker: ChoicePicker) -> Vec { + fn image_protocol_options(&self) -> Vec { let option = |key: &str, title: &str, description: &str| SettingsPickerOption { key: key.to_owned(), title: title.to_owned(), @@ -341,86 +341,72 @@ impl State { badge: None, active: false, }; - match picker { - ChoicePicker::ImageProtocol => [ - ( - "auto", - "Auto", - "Use the protocol detected for this terminal", - ), - ("kitty", "Kitty", "Use Kitty graphics when supported"), - ( - "iterm2", - "iTerm2", - "Use the iTerm2 image protocol when supported", - ), - ( - "quadrants", - "Quadrants", - "Render images with quadrant characters", - ), - ( - "halfblocks", - "Halfblocks", - "Render images with half-block characters", - ), - ] - .into_iter() - .map(|(key, title, description)| option(key, title, description)) - .collect(), - ChoicePicker::WindowMode => [ - ("windowed", "windowed", "Launch new instances in a window"), - ( - "fullscreen", - "fullscreen", - "Launch new instances in fullscreen", - ), - ] - .into_iter() - .map(|(key, title, description)| option(key, title, description)) - .collect(), - ChoicePicker::Resolution => std::iter::once(option( - "default", - "Game default", - "Let Minecraft choose the initial window size", - )) - .chain(self.resolutions.iter().map(|(width, height)| { - let value = format!("{width}x{height}"); - option(&value, &value, "Use this size for newly created instances") - })) - .collect(), - ChoicePicker::Provider => { - let mut options = vec![option( - "modrinth", - "Modrinth", - "Prefer Modrinth when projects exist on multiple providers", - )]; - if crate::net::curseforge::api_key().is_some() { - options.push(option( - "curseforge", - "CurseForge", - "Prefer CurseForge when projects exist on multiple providers", - )); - } - options - } - } + [ + ("kitty", "Kitty", "Use Kitty graphics when supported"), + ( + "iterm2", + "iTerm2", + "Use the iTerm2 image protocol when supported", + ), + ( + "quadrants", + "Quadrants", + "Render images with quadrant characters", + ), + ( + "halfblocks", + "Halfblocks", + "Render images with half-block characters", + ), + ] + .into_iter() + .map(|(key, title, description)| option(key, title, description)) + .collect() } fn open_choice_picker(&mut self, picker: ChoicePicker) { - let preferred = match picker { - ChoicePicker::ImageProtocol => self.config.ui.image_protocol.to_string(), - ChoicePicker::WindowMode => self.config.defaults.window_mode.to_string(), - ChoicePicker::Resolution => self.value(7), - ChoicePicker::Provider => self.config.content.preferred_provider.as_str().to_owned(), - }; - self.settings_picker.reset(); - self.settings_picker - .sync(self.choice_options(picker), Some(&preferred)); + match picker { + ChoicePicker::ImageProtocol => { + let preferred = self.config.ui.image_protocol.to_string(); + self.settings_picker.reset(); + self.settings_picker + .sync(self.image_protocol_options(), Some(&preferred)); + } + ChoicePicker::Resolution => { + self.choice_index = self + .resolution_choices() + .iter() + .position(|choice| choice.resolution() == self.config.defaults.resolution) + .unwrap_or(0); + } + } self.choice_picker = Some(picker); } fn handle_choice_picker_key(&mut self, key: &KeyEvent) { + if self.choice_picker == Some(ChoicePicker::Resolution) { + let count = self.resolution_choices().len(); + match handle_resolution_picker_key(&mut self.choice_index, count, key) { + ResolutionPickerAction::Back => self.choice_picker = None, + ResolutionPickerAction::Default => { + self.apply_default_resolution(); + self.choice_picker = None; + } + ResolutionPickerAction::Select => { + if let Some(resolution) = self + .resolution_choices() + .get(self.choice_index) + .and_then(|choice| choice.resolution()) + { + self.config.defaults.resolution = Some(resolution); + self.save_pending = true; + } + self.choice_picker = None; + } + ResolutionPickerAction::None => {} + } + return; + } match self.settings_picker.handle_key(key) { SettingsPickerAction::Back => self.choice_picker = None, SettingsPickerAction::Select => { @@ -430,37 +416,14 @@ impl State { let Some(value) = self.settings_picker.selected_key().map(str::to_owned) else { return; }; - match picker { - ChoicePicker::ImageProtocol => { - self.config.ui.image_protocol = match value.as_str() { - "kitty" => ImageProtocol::Kitty, - "iterm2" => ImageProtocol::Iterm2, - "quadrants" => ImageProtocol::Quadrants, - "halfblocks" => ImageProtocol::Halfblocks, - _ => ImageProtocol::Auto, - }; - } - ChoicePicker::WindowMode => { - self.config.defaults.window_mode = if value == "fullscreen" { - WindowMode::Fullscreen - } else { - WindowMode::Windowed - }; - } - ChoicePicker::Resolution => { - self.config.defaults.resolution = if value == "default" { - None - } else { - parse_resolution(&value).ok() - }; - } - ChoicePicker::Provider => { - self.config.content.preferred_provider = if value == "curseforge" { - ContentProvider::CurseForge - } else { - ContentProvider::Modrinth - }; - } + if picker == ChoicePicker::ImageProtocol { + self.config.ui.image_protocol = match value.as_str() { + "kitty" => ImageProtocol::Kitty, + "iterm2" => ImageProtocol::Iterm2, + "quadrants" => ImageProtocol::Quadrants, + "halfblocks" => ImageProtocol::Halfblocks, + _ => ImageProtocol::Kitty, + }; } self.save_pending = true; self.choice_picker = None; @@ -469,6 +432,43 @@ impl State { } } + fn resolution_choices(&self) -> Vec { + resolution_choices(self.config.defaults.resolution, &self.display_resolutions) + } + + fn apply_default_resolution(&mut self) { + if let Some(display) = self.display_resolutions.first() { + self.config.defaults.resolution = Some((display.width, display.height)); + self.save_pending = true; + self.error = None; + } else { + self.error = Some("Display resolution could not be detected.".to_owned()); + } + } + + fn enable_auto_image_protocol(&mut self) { + if self.config.ui.image_protocol != ImageProtocol::Auto { + self.config.ui.image_protocol = ImageProtocol::Auto; + self.save_pending = true; + } + self.error = None; + } + + fn cycle_provider(&mut self) { + self.config.content.preferred_provider = match self.config.content.preferred_provider { + ContentProvider::Modrinth => ContentProvider::CurseForge, + ContentProvider::CurseForge => ContentProvider::Modrinth, + }; + self.save_pending = true; + self.error = None; + } + + fn cycle_window_mode(&mut self) { + self.config.defaults.window_mode = toggle_window_mode(self.config.defaults.window_mode); + self.save_pending = true; + self.error = None; + } + fn open_java_picker(&mut self) { self.java_picker .open(self.config.paths.java_path.as_deref()); @@ -617,6 +617,8 @@ impl State { self.selected = (self.selected + 1).min(FIELD_COUNT - 1); } KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), + KeyCode::Char('h') | KeyCode::Left if self.selected == 0 => self.cycle_theme(false), + KeyCode::Char('l') | KeyCode::Right if self.selected == 0 => self.cycle_theme(true), KeyCode::Char('h') | KeyCode::Left if self.selected == 1 => self.cycle_border(false), KeyCode::Char('l') | KeyCode::Right if self.selected == 1 => self.cycle_border(true), KeyCode::Char('h') | KeyCode::Left if matches!(self.selected, 3 | 4) => { @@ -625,6 +627,10 @@ impl State { KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 3 | 4) => { self.adjust_selected_memory(true); } + KeyCode::Char('h') | KeyCode::Left if self.selected == 6 => self.cycle_window_mode(), + KeyCode::Char('l') | KeyCode::Right if self.selected == 6 => self.cycle_window_mode(), + KeyCode::Char('h') | KeyCode::Left if self.selected == 10 => self.cycle_provider(), + KeyCode::Char('l') | KeyCode::Right if self.selected == 10 => self.cycle_provider(), KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, 1 => self.cycle_border(true), @@ -633,13 +639,14 @@ impl State { self.editing = Some(settings_text_area(vec![self.value(self.selected)])); } 5 => self.open_java_picker(), - 6 => self.open_choice_picker(ChoicePicker::WindowMode), + 6 => self.cycle_window_mode(), 7 => self.open_choice_picker(ChoicePicker::Resolution), - 10 => self.open_choice_picker(ChoicePicker::Provider), + 10 => self.cycle_provider(), 11..=14 => self.toggle_selected(), 23 => return Action::ClearCache, field => self.editing = Some(settings_text_area(vec![self.value(field)])), }, + KeyCode::Char('a') if self.selected == 2 => self.enable_auto_image_protocol(), KeyCode::Char('a') if self.selected == 5 => return self.toggle_auto_java(), KeyCode::Char('c') if self.selected == 5 => { self.editing = Some(settings_text_area(vec![self.value(5)])); @@ -648,8 +655,7 @@ impl State { self.editing = Some(settings_text_area(vec![self.value(7)])); } KeyCode::Char('d') if self.selected == 7 => { - self.config.defaults.resolution = None; - self.save_pending = true; + self.apply_default_resolution(); } KeyCode::Char('E') => { let file = if self.selected <= 1 { @@ -697,19 +703,33 @@ fn available_themes() -> Vec { } pub fn popup_rect(area: Rect, state: &State) -> Rect { - let height = if state.theme_picker || state.java_picker_open || state.choice_picker.is_some() { + let form_width = (area.width * 86 / 100).saturating_sub(2); + let form_height = 37 + + tagged_row_count(&state.config.defaults.jvm_args, form_width).saturating_sub(1) as u16 + + tagged_row_count( + &environment_labels(&state.config.defaults.environment), + form_width, + ) + .saturating_sub(1) as u16; + let height = if state.theme_picker || state.java_picker_open { (area.height * 2 / 3).max(10) + } else if state.choice_picker == Some(ChoicePicker::Resolution) { + 10 + } else if state.choice_picker.is_some() { + (area.height / 2).max(10) } else { - 26 + form_height }; - let width = if state.java_picker_open || state.choice_picker.is_some() { - 72 - } else { - 68 + let width = match state.choice_picker { + Some(ChoicePicker::Resolution) => 64, + Some(ChoicePicker::ImageProtocol) => 60, + None if state.java_picker_open => 72, + None if state.theme_picker => 52, + None => 86, }; area.centered( ratatui::layout::Constraint::Percentage(width), - ratatui::layout::Constraint::Length(height.min(area.height.saturating_sub(4))), + ratatui::layout::Constraint::Length(height.min(area.height.saturating_sub(2))), ) } @@ -721,6 +741,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { frame.render_widget(Clear, area); let keybinds = if state.editing.is_some() { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) + } else if state.choice_picker == Some(ChoicePicker::Resolution) { + super::keybind_line(&[("d", " default"), ("h", " back"), ("Enter", " select")]) } else if state.java_picker_open || state.theme_picker || state.choice_picker.is_some() { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if matches!(state.selected, 3 | 4) { @@ -732,8 +754,15 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("c", " custom"), ("Esc", " back"), ]) - } else if matches!(state.selected, 2 | 6 | 10) { - super::keybind_line(&[("Enter", " select"), ("E", " raw"), ("Esc", " back")]) + } else if state.selected == 2 { + super::keybind_line(&[ + ("Enter", " protocols"), + ("a", " auto"), + ("E", " raw"), + ("Esc", " back"), + ]) + } else if matches!(state.selected, 6 | 10) { + super::keybind_line(&[("h/l", " adjust"), ("Enter", " next"), ("Esc", " back")]) } else if state.selected == 7 { super::keybind_line(&[ ("Enter", " presets"), @@ -747,8 +776,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { super::keybind_line(&[("Enter", " clear"), ("Esc", " back")]) } else if state.selected == 0 { super::keybind_line(&[ - ("j/k", ""), - ("Enter", " select"), + ("h/l", " adjust"), + ("Enter", " themes"), ("E", " raw"), ("Esc", " back"), ]) @@ -774,9 +803,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { } else if let Some(picker) = state.choice_picker { match picker { ChoicePicker::ImageProtocol => " Image Rendering ", - ChoicePicker::WindowMode => " Default Window Mode ", ChoicePicker::Resolution => " Default Resolution ", - ChoicePicker::Provider => " Preferred Provider ", } } else { " Launcher Settings " @@ -799,7 +826,12 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { return; } if state.choice_picker.is_some() { - render_settings_picker(&state.settings_picker, inner, frame.buffer_mut()); + if state.choice_picker == Some(ChoicePicker::Resolution) { + let items = resolution_items(&state.resolution_choices(), state.choice_index); + super::select_list::render_styled(items, state.choice_index, inner, frame.buffer_mut()); + } else { + render_settings_picker(&state.settings_picker, inner, frame.buffer_mut()); + } return; } render_settings_list(frame, inner, state); @@ -875,41 +907,26 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { "no variables", area.width, ), + 23 => vec![maintenance_line(state)], _ => vec![global_field_line(state, *index, label)], }; rows.push((Some(*index), lines)); } } - let mut selected_end = 0u16; - let mut cursor = 0u16; - for (field, lines) in &rows { - let height = lines.len() as u16; - if *field == Some(state.selected) { - selected_end = cursor.saturating_add(height); - } - cursor = cursor.saturating_add(height); - } - let scroll = selected_end.saturating_sub(area.height); let mut row_start = 0u16; for (field, lines) in rows { let height = lines.len() as u16; - let row_end = row_start.saturating_add(height); - if row_end <= scroll || row_start >= scroll.saturating_add(area.height) { - row_start = row_end; - continue; + if row_start >= area.height { + break; } - let skip = scroll.saturating_sub(row_start) as usize; - let y = area.y.saturating_add(row_start.saturating_sub(scroll)); - let visible_height = height - .saturating_sub(skip as u16) - .min(area.y.saturating_add(area.height).saturating_sub(y)); + let y = area.y.saturating_add(row_start); + let visible_height = height.min(area.height.saturating_sub(row_start)); let selected = field == Some(state.selected); frame.render_widget( Paragraph::new( lines .into_iter() - .skip(skip) .take(visible_height as usize) .collect::>(), ) @@ -924,15 +941,13 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { ..area }, ); - if let Some(field @ (3 | 4)) = field - && row_start >= scroll - { + if let Some(field @ (3 | 4)) = field { let value = state.value(field); render_memory_gauge( frame, Rect { x: area.x.saturating_add(20), - y: area.y.saturating_add(row_start.saturating_sub(scroll)), + y: area.y.saturating_add(row_start), width: area.width.saturating_sub(21), height: 1, }, @@ -942,23 +957,42 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { ); } if field == Some(state.selected) - && row_start >= scroll && let Some(editor) = state.editing.as_ref() { frame.render_widget( editor, Rect { x: area.x.saturating_add(20), - y: area.y.saturating_add(row_start.saturating_sub(scroll)), + y: area.y.saturating_add(row_start), width: area.width.saturating_sub(20), height: 1, }, ); } - row_start = row_end; + row_start = row_start.saturating_add(height); } } +fn maintenance_line(state: &State) -> Line<'static> { + let theme = THEME.as_ref(); + let selected = state.selected == 23; + Line::from(vec![ + Span::styled( + if selected { "▌ " } else { " " }, + Style::default().fg(theme.accent()), + ), + status_badge("Clear caches", theme.warning()), + Span::styled( + " Rebuild provider and Java metadata", + Style::default().fg(if selected { + theme.text() + } else { + theme.text_dim() + }), + ), + ]) +} + fn section_line(title: &str) -> Line<'static> { Line::from(Span::styled( format!(" {title}"), @@ -980,7 +1014,7 @@ fn field_label(index: usize) -> &'static str { 7 => "Resolution", 8 => "JVM arguments", 9 => "Environment", - 10 => "Provider", + 10 => "Preferred provider", 11 => "Provider only", 12 => "Ask on conflict", 13 => "Modpack updates", @@ -993,7 +1027,7 @@ fn field_label(index: usize) -> &'static str { 20 => "Slide start ms", 21 => "Fly-out ms", 22 => "Max notifications", - 23 => "Clear caches", + 23 => "", _ => "", } } @@ -1012,8 +1046,16 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> Style::default().fg(theme.text_dim()), ), Span::styled( - if editing || matches!(index, 3 | 4) { + if editing || matches!(index, 3 | 4 | 8 | 9 | 10) { String::new() + } else if index == 1 { + format!( + "{} {}", + border_preview(&state.theme.border_style), + state.display_value(index) + ) + } else if index == 2 && state.config.ui.image_protocol == ImageProtocol::Auto { + "terminal detected".to_owned() } else { state.display_value(index) }, @@ -1033,6 +1075,15 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> if index == 5 && state.config.paths.java_path.is_none() && !editing { spans.extend([Span::raw(" "), auto_label()]); } + if index == 2 && state.config.ui.image_protocol == ImageProtocol::Auto && !editing { + spans.extend([Span::raw(" "), auto_label()]); + } + if index == 10 && !editing { + spans.push(match state.config.content.preferred_provider { + ContentProvider::Modrinth => status_badge("Modrinth", theme.success()), + ContentProvider::CurseForge => status_badge("CurseForge", theme.warning()), + }); + } if restart_required_for(state, index) { spans.extend([ Span::raw(" "), @@ -1046,6 +1097,15 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> })) } +fn border_preview(style: &BorderStyle) -> &'static str { + match style { + BorderStyle::Plain => "┌──┐", + BorderStyle::Rounded => "╭──╮", + BorderStyle::Double => "╔══╗", + BorderStyle::Thick => "┏━━┓", + } +} + fn restart_required_for(state: &State, index: usize) -> bool { let current = crate::config::SETTINGS.read(); match index { @@ -1147,12 +1207,32 @@ mod tests { state.selected = 2; state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert_eq!(state.choice_picker, Some(ChoicePicker::ImageProtocol)); - state.handle_key(&KeyEvent::from(KeyCode::Char('j'))); + assert!( + !state + .settings_picker + .labels() + .iter() + .any(|label| label == "Auto") + ); assert!(matches!( state.handle_key(&KeyEvent::from(KeyCode::Enter)), Action::Save(..) )); assert_eq!(state.config.ui.image_protocol, ImageProtocol::Kitty); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), + Action::Save(..) + )); + assert_eq!(state.config.ui.image_protocol, ImageProtocol::Auto); + + state.selected = 10; + let provider = state.config.content.preferred_provider; + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + assert!(state.choice_picker.is_none()); + assert_ne!(state.config.content.preferred_provider, provider); state.selected = 11; let previous = state.config.content.preferred_provider_only; @@ -1191,4 +1271,78 @@ mod tests { Some(&"bar".to_owned()) ); } + + #[test] + fn launcher_resolution_reuses_display_and_preset_choices() { + let mut state = State::new(); + state.display_resolutions = vec![DisplayResolution { + width: 2560, + height: 1440, + name: "DP-4".to_owned(), + primary: true, + }]; + state.selected = 7; + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Char('d'))), + Action::Save(..) + )); + assert_eq!(state.config.defaults.resolution, Some((2560, 1440))); + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); + assert!(matches!( + state.resolution_choices().first(), + Some(ResolutionChoice::Display(display)) if display.name == "DP-4" + )); + } + + #[test] + fn appearance_rows_use_inline_controls_and_previews() { + let mut state = State::new(); + assert!(matches!( + border_preview(&state.theme.border_style), + "┌──┐" | "╭──╮" | "╔══╗" | "┏━━┓" + )); + + state.selected = 0; + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert!(state.theme_picker); + state.handle_key(&KeyEvent::from(KeyCode::Esc)); + assert!(!state.theme_picker); + } + + #[test] + fn expanded_form_keeps_all_sections_visible_without_scrolling() { + let mut state = State::new(); + state.selected = 23; + state.config.defaults.jvm_args = vec!["-Xfoo".to_owned()]; + state + .config + .defaults + .environment + .insert("FOO".to_owned(), "bar".to_owned()); + let backend = ratatui::backend::TestBackend::new(120, 45); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + let area = popup_rect(frame.area(), &state); + assert!(area.height >= 37); + render(frame, area, &mut state); + }) + .unwrap(); + + let screen = terminal.backend().to_string(); + assert!(screen.contains("Appearance")); + assert!(screen.contains("Maintenance")); + assert!(screen.contains("Clear caches")); + assert!(screen.contains("Rebuild provider and Java metadata")); + assert!( + ["┌──┐", "╭──╮", "╔══╗", "┏━━┓"] + .iter() + .any(|preview| screen.contains(preview)) + ); + assert_eq!(screen.matches("-Xfoo").count(), 1); + assert_eq!(screen.matches("FOO=bar").count(), 1); + } } diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 3ed5a82..c491f5f 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -22,19 +22,20 @@ use crate::{ }, instance::loader::GameVersion, instance::models::{ - InstanceConfig, LaunchCommand, ModLoader, WindowMode, normalize_memory_value, - parse_resolution, + InstanceConfig, LaunchCommand, ModLoader, normalize_memory_value, parse_resolution, }, tui::widgets::{ popups::{ LoadState, settings_controls::{ - DisplayResolution, GlfwChoice, GlfwPicker, JavaChoice, JavaPicker, SettingsPicker, - SettingsPickerAction, SettingsPickerBadge, SettingsPickerOption, adjust_memory, - auto_label, bundled_glfw_version, bundled_label as bundled_badge, - display_resolutions, environment_labels, handle_text_area_input, memory_kib, - parse_environment, render_memory_gauge, render_settings_picker, settings_text_area, - tagged_row_count, tagged_value_lines, + DisplayResolution, GlfwChoice, GlfwPicker, JavaChoice, JavaPicker, + ResolutionChoice, ResolutionPickerAction, SettingsPicker, SettingsPickerAction, + SettingsPickerBadge, SettingsPickerOption, adjust_memory, auto_label, + bundled_glfw_version, bundled_label as bundled_badge, display_resolutions, + environment_labels, handle_resolution_picker_key, handle_text_area_input, + memory_kib, parse_environment, render_memory_gauge, render_settings_picker, + resolution_choices, resolution_items, settings_text_area, tagged_row_count, + tagged_value_lines, toggle_window_mode, }, }, search::SearchState, @@ -61,30 +62,6 @@ enum ChoicePicker { Glfw, } -#[derive(Debug, Clone, PartialEq, Eq)] -enum ResolutionChoice { - Display(DisplayResolution), - Preset(u32, u32), - Configured(u32, u32), -} - -impl ResolutionChoice { - fn resolution(&self) -> Option<(u32, u32)> { - match self { - Self::Display(display) => Some((display.width, display.height)), - Self::Preset(width, height) | Self::Configured(width, height) => { - Some((*width, *height)) - } - } - } - - fn label(&self) -> String { - self.resolution() - .map(|(width, height)| format!("{width}x{height}")) - .unwrap_or_default() - } -} - enum PickerLoad { Idle, Loading, @@ -407,10 +384,7 @@ impl State { .draft .effective_window_mode(SETTINGS.read().defaults.window_mode); self.draft.inherit_window_mode = false; - self.draft.window_mode = match current { - WindowMode::Windowed => WindowMode::Fullscreen, - WindowMode::Fullscreen => WindowMode::Windowed, - }; + self.draft.window_mode = toggle_window_mode(current); } 9 => self.open_choice_picker(ChoicePicker::Resolution), 10 => self.open_choice_picker(ChoicePicker::Account), @@ -480,6 +454,19 @@ impl State { } fn handle_choice_key(&mut self, key: &KeyEvent) { + if self.choice_picker == Some(ChoicePicker::Resolution) { + let count = self.resolution_choices().len(); + match handle_resolution_picker_key(&mut self.choice_index, count, key) { + ResolutionPickerAction::Back => self.choice_picker = None, + ResolutionPickerAction::Default => { + self.apply_default_resolution(); + self.choice_picker = None; + } + ResolutionPickerAction::Select => self.apply_choice(), + ResolutionPickerAction::None => {} + } + return; + } let picker_action = match self.choice_picker { Some(ChoicePicker::Java) => { self.java_picker.initialize(); @@ -506,10 +493,6 @@ impl State { let count = self.choice_values().len(); match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.choice_picker = None, - KeyCode::Char('d') if self.choice_picker == Some(ChoicePicker::Resolution) => { - self.apply_default_resolution(); - self.choice_picker = None; - } KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { self.choice_index = (self.choice_index + 1).min(count - 1); } @@ -1133,37 +1116,6 @@ impl State { } } -fn resolution_choices( - current: Option<(u32, u32)>, - displays: &[DisplayResolution], -) -> Vec { - let mut choices = Vec::new(); - choices.extend(displays.iter().cloned().map(ResolutionChoice::Display)); - for (width, height) in [ - (854, 480), - (1280, 720), - (1600, 900), - (1920, 1080), - (2560, 1440), - (3840, 2160), - ] { - if !choices - .iter() - .any(|choice| choice.resolution() == Some((width, height))) - { - choices.push(ResolutionChoice::Preset(width, height)); - } - } - if let Some((width, height)) = current - && !choices - .iter() - .any(|choice| choice.resolution() == Some((width, height))) - { - choices.push(ResolutionChoice::Configured(width, height)); - } - choices -} - pub fn popup_rect(area: Rect, state: &State) -> Rect { let height = if state.picker.is_some() || matches!( @@ -1697,51 +1649,6 @@ fn render_choice_picker(frame: &mut Frame, area: Rect, state: &mut State) { } } -fn resolution_items(choices: &[ResolutionChoice], selected: usize) -> Vec> { - let theme = THEME.as_ref(); - choices - .iter() - .enumerate() - .map(|(index, choice)| { - let mut spans = vec![Span::styled( - choice.label(), - Style::default().fg(if index == selected { - theme.accent() - } else { - theme.text() - }), - )]; - match choice { - ResolutionChoice::Preset(_, _) => {} - ResolutionChoice::Display(display) => { - if !display.name.is_empty() { - spans.extend([ - Span::raw(" "), - Span::styled( - format!(" {} ", display.name), - Style::default() - .fg(if display.primary { - theme.success() - } else { - theme.info() - }) - .bg(theme.stripe()), - ), - ]); - } - } - ResolutionChoice::Configured(_, _) => { - spans.push(Span::styled( - " configured", - Style::default().fg(theme.text_dim()), - )); - } - } - ListItem::new(Line::from(spans)) - }) - .collect() -} - fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); let status = match state.picker { @@ -1816,6 +1723,7 @@ fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { #[cfg(test)] mod tests { use super::*; + use crate::instance::WindowMode; use chrono::Utc; fn instance() -> InstanceConfig { diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index be2c101..3c5f628 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -21,7 +21,7 @@ use ratatui_textarea::{CursorMove, TextArea}; use crate::{ config::theme::THEME, - instance::java::JavaInstallation, + instance::{WindowMode, java::JavaInstallation}, tui::widgets::{popups::LoadState, status_badge}, }; @@ -29,6 +29,13 @@ const MEMORY_STEPS: [&str; 12] = [ "512M", "1G", "2G", "3G", "4G", "6G", "8G", "12G", "16G", "24G", "32G", "64G", ]; +pub(crate) fn toggle_window_mode(mode: WindowMode) -> WindowMode { + match mode { + WindowMode::Windowed => WindowMode::Fullscreen, + WindowMode::Fullscreen => WindowMode::Windowed, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingsPickerBadge { Auto, @@ -220,6 +227,59 @@ pub(crate) struct DisplayResolution { pub primary: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ResolutionChoice { + Display(DisplayResolution), + Preset(u32, u32), + Configured(u32, u32), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResolutionPickerAction { + None, + Back, + Default, + Select, +} + +pub(crate) fn handle_resolution_picker_key( + selected: &mut usize, + count: usize, + key: &KeyEvent, +) -> ResolutionPickerAction { + match key.code { + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => ResolutionPickerAction::Back, + KeyCode::Char('d') => ResolutionPickerAction::Default, + KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { + *selected = (*selected + 1).min(count - 1); + ResolutionPickerAction::None + } + KeyCode::Char('k') | KeyCode::Up => { + *selected = selected.saturating_sub(1); + ResolutionPickerAction::None + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => ResolutionPickerAction::Select, + _ => ResolutionPickerAction::None, + } +} + +impl ResolutionChoice { + pub(crate) fn resolution(&self) -> Option<(u32, u32)> { + match self { + Self::Display(display) => Some((display.width, display.height)), + Self::Preset(width, height) | Self::Configured(width, height) => { + Some((*width, *height)) + } + } + } + + pub(crate) fn label(&self) -> String { + self.resolution() + .map(|(width, height)| format!("{width}x{height}")) + .unwrap_or_default() + } +} + impl JavaPicker { pub(crate) fn new() -> Self { Self::with_auto_path(crate::instance::java::detect_java_path()) @@ -1020,6 +1080,85 @@ pub(crate) fn display_resolutions() -> Vec { resolutions } +pub(crate) fn resolution_choices( + current: Option<(u32, u32)>, + displays: &[DisplayResolution], +) -> Vec { + let mut choices = Vec::new(); + choices.extend(displays.iter().cloned().map(ResolutionChoice::Display)); + for (width, height) in [ + (854, 480), + (1280, 720), + (1600, 900), + (1920, 1080), + (2560, 1440), + (3840, 2160), + ] { + if !choices + .iter() + .any(|choice| choice.resolution() == Some((width, height))) + { + choices.push(ResolutionChoice::Preset(width, height)); + } + } + if let Some((width, height)) = current + && !choices + .iter() + .any(|choice| choice.resolution() == Some((width, height))) + { + choices.push(ResolutionChoice::Configured(width, height)); + } + choices +} + +pub(crate) fn resolution_items( + choices: &[ResolutionChoice], + selected: usize, +) -> Vec> { + let theme = THEME.as_ref(); + choices + .iter() + .enumerate() + .map(|(index, choice)| { + let mut spans = vec![Span::styled( + choice.label(), + Style::default().fg(if index == selected { + theme.accent() + } else { + theme.text() + }), + )]; + match choice { + ResolutionChoice::Preset(_, _) => {} + ResolutionChoice::Display(display) => { + if !display.name.is_empty() { + spans.extend([ + Span::raw(" "), + Span::styled( + format!(" {} ", display.name), + Style::default() + .fg(if display.primary { + theme.success() + } else { + theme.info() + }) + .bg(theme.stripe()), + ), + ]); + } + } + ResolutionChoice::Configured(_, _) => { + spans.push(Span::styled( + " configured", + Style::default().fg(theme.text_dim()), + )); + } + } + ListItem::new(Line::from(spans)) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; From 8f7e1174388c42c76e2638ce9582085af4259ba1 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 21:28:04 +0200 Subject: [PATCH 22/42] fix: polish launcher settings rows --- src/tui/app.rs | 7 +- src/tui/input.rs | 12 ++- src/tui/mod.rs | 4 +- src/tui/tests/harness.rs | 1 + src/tui/widgets/popups/global_settings.rs | 92 ++++++++++++++++++----- 5 files changed, 93 insertions(+), 23 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 20c31b4..4a91950 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -48,6 +48,7 @@ pub struct App { pub(super) pending_instance_settings_updates: HashSet, pub(super) global_settings: Option, pub(super) picker: ratatui_image::picker::Picker, + pub(super) detected_image_protocol: ratatui_image::picker::ProtocolType, pub(super) instance_manager: InstanceManager, pub(super) log_overlay_scroll: usize, pub(super) log_overlay_max_scroll: usize, @@ -121,7 +122,10 @@ impl App { supported } - pub fn new(picker: ratatui_image::picker::Picker) -> Self { + pub fn new( + picker: ratatui_image::picker::Picker, + detected_image_protocol: ratatui_image::picker::ProtocolType, + ) -> Self { let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); @@ -188,6 +192,7 @@ impl App { s }, picker, + detected_image_protocol, instance_manager: manager, log_overlay_scroll: 0, log_overlay_max_scroll: 0, diff --git a/src/tui/input.rs b/src/tui/input.rs index 11fc7a5..8ff6635 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -685,7 +685,11 @@ impl App { } widgets::settings::SettingsAction::OpenGlobal => { self.pre_overlay_focused = FocusedArea::Settings; - self.global_settings = Some(widgets::popups::global_settings::State::new()); + self.global_settings = Some( + widgets::popups::global_settings::State::with_detected_image_protocol( + self.detected_image_protocol, + ), + ); self.focused = FocusedArea::GlobalSettings; return Ok(()); } @@ -937,7 +941,11 @@ impl App { } KeyCode::Char('G') => { self.pre_overlay_focused = self.focused; - self.global_settings = Some(widgets::popups::global_settings::State::new()); + self.global_settings = Some( + widgets::popups::global_settings::State::with_detected_image_protocol( + self.detected_image_protocol, + ), + ); self.focused = FocusedArea::GlobalSettings; } KeyCode::Char('O') => { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1628583..b37d678 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -69,14 +69,14 @@ pub async fn show() -> color_eyre::Result<()> { }; picker.set_protocol_type(requested_protocol); - let mut app = app::App::new(picker); + let mut app = app::App::new(picker, detected_protocol); match run_layout_migration_screen(&mut terminal, &mut app).await? { MigrationScreenOutcome::NotNeeded => {} MigrationScreenOutcome::Migrated => { // the modal uses pre-migration state as its background. rebuild the // app after confirmation so no instance or profile data stays stale let picker = app.into_picker(); - app = app::App::new(picker); + app = app::App::new(picker, detected_protocol); } MigrationScreenOutcome::Quit => return Ok(()), } diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 99fcd69..9f6311c 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -91,6 +91,7 @@ impl UiHarness { pending_instance_settings_updates: Default::default(), global_settings: None, picker, + detected_image_protocol: ratatui_image::picker::ProtocolType::Halfblocks, instance_manager, log_overlay_scroll: 0, log_overlay_max_scroll: 0, diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 59117f4..d7fee47 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -55,6 +55,7 @@ pub struct State { settings_picker: SettingsPicker, choice_index: usize, display_resolutions: Vec, + detected_image_protocol: &'static str, } pub enum Action { @@ -69,6 +70,12 @@ pub enum Action { impl State { pub fn new() -> Self { + Self::with_detected_image_protocol(ratatui_image::picker::ProtocolType::Halfblocks) + } + + pub fn with_detected_image_protocol( + detected_image_protocol: ratatui_image::picker::ProtocolType, + ) -> Self { let theme = crate::config::theme::current_theme_config(); let themes = available_themes(); let theme_index = themes @@ -103,6 +110,12 @@ impl State { settings_picker: SettingsPicker::default(), choice_index: 0, display_resolutions: display_resolutions(), + detected_image_protocol: match detected_image_protocol { + ratatui_image::picker::ProtocolType::Halfblocks => "halfblocks", + ratatui_image::picker::ProtocolType::Sixel => "sixel", + ratatui_image::picker::ProtocolType::Kitty => "kitty", + ratatui_image::picker::ProtocolType::Iterm2 => "iterm2", + }, } } @@ -704,7 +717,7 @@ fn available_themes() -> Vec { pub fn popup_rect(area: Rect, state: &State) -> Rect { let form_width = (area.width * 86 / 100).saturating_sub(2); - let form_height = 37 + let form_height = 38 + tagged_row_count(&state.config.defaults.jvm_args, form_width).saturating_sub(1) as u16 + tagged_row_count( &environment_labels(&state.config.defaults.environment), @@ -729,7 +742,7 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { }; area.centered( ratatui::layout::Constraint::Percentage(width), - ratatui::layout::Constraint::Length(height.min(area.height.saturating_sub(2))), + ratatui::layout::Constraint::Length(height.min(area.height)), ) } @@ -891,6 +904,7 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { for index in *fields { let label = field_label(*index); let lines = match index { + 1 => border_field_lines(state), 8 => tagged_value_lines( global_field_line(state, *index, label).spans, state.selected == *index, @@ -981,9 +995,18 @@ fn maintenance_line(state: &State) -> Line<'static> { if selected { "▌ " } else { " " }, Style::default().fg(theme.accent()), ), - status_badge("Clear caches", theme.warning()), Span::styled( - " Rebuild provider and Java metadata", + "Clear caches", + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.warning() + }) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " Rebuild provider and Java metadata", Style::default().fg(if selected { theme.text() } else { @@ -993,6 +1016,41 @@ fn maintenance_line(state: &State) -> Line<'static> { ]) } +fn border_field_lines(state: &State) -> Vec> { + let theme = THEME.as_ref(); + let selected = state.selected == 1; + let value_style = Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }); + let (top, bottom) = border_preview(&state.theme.border_style); + vec![ + Line::from(vec![ + Span::styled( + if selected { "▌ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{:<18}", field_label(1)), + Style::default().fg(theme.text_dim()), + ), + Span::styled(top, value_style), + ]), + Line::from(vec![ + Span::raw(" ".repeat(20)), + Span::styled(bottom, value_style), + Span::styled(format!(" {}", state.display_value(1)), value_style), + ]), + ] +} + fn section_line(title: &str) -> Line<'static> { Line::from(Span::styled( format!(" {title}"), @@ -1048,14 +1106,8 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> Span::styled( if editing || matches!(index, 3 | 4 | 8 | 9 | 10) { String::new() - } else if index == 1 { - format!( - "{} {}", - border_preview(&state.theme.border_style), - state.display_value(index) - ) } else if index == 2 && state.config.ui.image_protocol == ImageProtocol::Auto { - "terminal detected".to_owned() + state.detected_image_protocol.to_owned() } else { state.display_value(index) }, @@ -1079,6 +1131,7 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> spans.extend([Span::raw(" "), auto_label()]); } if index == 10 && !editing { + spans.push(Span::raw(" ")); spans.push(match state.config.content.preferred_provider { ContentProvider::Modrinth => status_badge("Modrinth", theme.success()), ContentProvider::CurseForge => status_badge("CurseForge", theme.warning()), @@ -1097,12 +1150,12 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> })) } -fn border_preview(style: &BorderStyle) -> &'static str { +fn border_preview(style: &BorderStyle) -> (&'static str, &'static str) { match style { - BorderStyle::Plain => "┌──┐", - BorderStyle::Rounded => "╭──╮", - BorderStyle::Double => "╔══╗", - BorderStyle::Thick => "┏━━┓", + BorderStyle::Plain => ("┌──┐", "└──┘"), + BorderStyle::Rounded => ("╭──╮", "╰──╯"), + BorderStyle::Double => ("╔══╗", "╚══╝"), + BorderStyle::Thick => ("┏━━┓", "┗━━┛"), } } @@ -1301,7 +1354,7 @@ mod tests { let mut state = State::new(); assert!(matches!( border_preview(&state.theme.border_style), - "┌──┐" | "╭──╮" | "╔══╗" | "┏━━┓" + ("┌──┐", "└──┘") | ("╭──╮", "╰──╯") | ("╔══╗", "╚══╝") | ("┏━━┓", "┗━━┛") )); state.selected = 0; @@ -1313,7 +1366,8 @@ mod tests { #[test] fn expanded_form_keeps_all_sections_visible_without_scrolling() { - let mut state = State::new(); + let mut state = + State::with_detected_image_protocol(ratatui_image::picker::ProtocolType::Kitty); state.selected = 23; state.config.defaults.jvm_args = vec!["-Xfoo".to_owned()]; state @@ -1335,6 +1389,8 @@ mod tests { let screen = terminal.backend().to_string(); assert!(screen.contains("Appearance")); assert!(screen.contains("Maintenance")); + assert!(screen.contains("kitty")); + assert!(screen.contains("Auto")); assert!(screen.contains("Clear caches")); assert!(screen.contains("Rebuild provider and Java metadata")); assert!( From f38cd24fae901dd54a7f0e201c716a74dfed1724 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 21:49:00 +0200 Subject: [PATCH 23/42] fix: simplify border style preview --- src/tui/widgets/popups/global_settings.rs | 47 ++++++++++------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index d7fee47..8f93558 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -717,7 +717,7 @@ fn available_themes() -> Vec { pub fn popup_rect(area: Rect, state: &State) -> Rect { let form_width = (area.width * 86 / 100).saturating_sub(2); - let form_height = 38 + let form_height = 37 + tagged_row_count(&state.config.defaults.jvm_args, form_width).saturating_sub(1) as u16 + tagged_row_count( &environment_labels(&state.config.defaults.environment), @@ -1030,25 +1030,18 @@ fn border_field_lines(state: &State) -> Vec> { } else { Modifier::empty() }); - let (top, bottom) = border_preview(&state.theme.border_style); - vec![ - Line::from(vec![ - Span::styled( - if selected { "▌ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - format!("{:<18}", field_label(1)), - Style::default().fg(theme.text_dim()), - ), - Span::styled(top, value_style), - ]), - Line::from(vec![ - Span::raw(" ".repeat(20)), - Span::styled(bottom, value_style), - Span::styled(format!(" {}", state.display_value(1)), value_style), - ]), - ] + vec![Line::from(vec![ + Span::styled( + if selected { "▌ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{:<18}", field_label(1)), + Style::default().fg(theme.text_dim()), + ), + Span::styled(border_preview(&state.theme.border_style), value_style), + Span::styled(format!(" {}", state.display_value(1)), value_style), + ])] } fn section_line(title: &str) -> Line<'static> { @@ -1150,12 +1143,12 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> })) } -fn border_preview(style: &BorderStyle) -> (&'static str, &'static str) { +fn border_preview(style: &BorderStyle) -> &'static str { match style { - BorderStyle::Plain => ("┌──┐", "└──┘"), - BorderStyle::Rounded => ("╭──╮", "╰──╯"), - BorderStyle::Double => ("╔══╗", "╚══╝"), - BorderStyle::Thick => ("┏━━┓", "┗━━┛"), + BorderStyle::Plain => "┌ ─ │ ┘", + BorderStyle::Rounded => "╭ ─ │ ╯", + BorderStyle::Double => "╔ ═ ║ ╝", + BorderStyle::Thick => "┏ ━ ┃ ┛", } } @@ -1354,7 +1347,7 @@ mod tests { let mut state = State::new(); assert!(matches!( border_preview(&state.theme.border_style), - ("┌──┐", "└──┘") | ("╭──╮", "╰──╯") | ("╔══╗", "╚══╝") | ("┏━━┓", "┗━━┛") + "┌ ─ │ ┘" | "╭ ─ │ ╯" | "╔ ═ ║ ╝" | "┏ ━ ┃ ┛" )); state.selected = 0; @@ -1394,7 +1387,7 @@ mod tests { assert!(screen.contains("Clear caches")); assert!(screen.contains("Rebuild provider and Java metadata")); assert!( - ["┌──┐", "╭──╮", "╔══╗", "┏━━┓"] + ["┌ ─ │ ┘", "╭ ─ │ ╯", "╔ ═ ║ ╝", "┏ ━ ┃ ┛"] .iter() .any(|preview| screen.contains(preview)) ); From ad9902bc9ae2cb1ef9a82a1d0d8948e8349ba42f Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 21:52:04 +0200 Subject: [PATCH 24/42] fix: clean up border style row --- src/tui/widgets/popups/global_settings.rs | 50 +---------------------- 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 8f93558..dd84bff 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -904,7 +904,6 @@ fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { for index in *fields { let label = field_label(*index); let lines = match index { - 1 => border_field_lines(state), 8 => tagged_value_lines( global_field_line(state, *index, label).spans, state.selected == *index, @@ -1016,34 +1015,6 @@ fn maintenance_line(state: &State) -> Line<'static> { ]) } -fn border_field_lines(state: &State) -> Vec> { - let theme = THEME.as_ref(); - let selected = state.selected == 1; - let value_style = Style::default() - .fg(if selected { - theme.accent() - } else { - theme.text() - }) - .add_modifier(if selected { - Modifier::BOLD - } else { - Modifier::empty() - }); - vec![Line::from(vec![ - Span::styled( - if selected { "▌ " } else { " " }, - Style::default().fg(theme.accent()), - ), - Span::styled( - format!("{:<18}", field_label(1)), - Style::default().fg(theme.text_dim()), - ), - Span::styled(border_preview(&state.theme.border_style), value_style), - Span::styled(format!(" {}", state.display_value(1)), value_style), - ])] -} - fn section_line(title: &str) -> Line<'static> { Line::from(Span::styled( format!(" {title}"), @@ -1143,15 +1114,6 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> })) } -fn border_preview(style: &BorderStyle) -> &'static str { - match style { - BorderStyle::Plain => "┌ ─ │ ┘", - BorderStyle::Rounded => "╭ ─ │ ╯", - BorderStyle::Double => "╔ ═ ║ ╝", - BorderStyle::Thick => "┏ ━ ┃ ┛", - } -} - fn restart_required_for(state: &State, index: usize) -> bool { let current = crate::config::SETTINGS.read(); match index { @@ -1343,13 +1305,8 @@ mod tests { } #[test] - fn appearance_rows_use_inline_controls_and_previews() { + fn appearance_rows_use_inline_controls() { let mut state = State::new(); - assert!(matches!( - border_preview(&state.theme.border_style), - "┌ ─ │ ┘" | "╭ ─ │ ╯" | "╔ ═ ║ ╝" | "┏ ━ ┃ ┛" - )); - state.selected = 0; state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert!(state.theme_picker); @@ -1386,11 +1343,6 @@ mod tests { assert!(screen.contains("Auto")); assert!(screen.contains("Clear caches")); assert!(screen.contains("Rebuild provider and Java metadata")); - assert!( - ["┌ ─ │ ┘", "╭ ─ │ ╯", "╔ ═ ║ ╝", "┏ ━ ┃ ┛"] - .iter() - .any(|preview| screen.contains(preview)) - ); assert_eq!(screen.matches("-Xfoo").count(), 1); assert_eq!(screen.matches("FOO=bar").count(), 1); } From 773e57dd763044b0b35326bfe6ecac2cd52dd0f3 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 21:53:05 +0200 Subject: [PATCH 25/42] fix: format detected image protocol labels --- src/tui/widgets/popups/global_settings.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index dd84bff..e713a94 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -111,10 +111,10 @@ impl State { choice_index: 0, display_resolutions: display_resolutions(), detected_image_protocol: match detected_image_protocol { - ratatui_image::picker::ProtocolType::Halfblocks => "halfblocks", - ratatui_image::picker::ProtocolType::Sixel => "sixel", - ratatui_image::picker::ProtocolType::Kitty => "kitty", - ratatui_image::picker::ProtocolType::Iterm2 => "iterm2", + ratatui_image::picker::ProtocolType::Halfblocks => "Halfblocks", + ratatui_image::picker::ProtocolType::Sixel => "Sixel", + ratatui_image::picker::ProtocolType::Kitty => "Kitty", + ratatui_image::picker::ProtocolType::Iterm2 => "iTerm2", }, } } @@ -1339,7 +1339,7 @@ mod tests { let screen = terminal.backend().to_string(); assert!(screen.contains("Appearance")); assert!(screen.contains("Maintenance")); - assert!(screen.contains("kitty")); + assert!(screen.contains("Kitty")); assert!(screen.contains("Auto")); assert!(screen.contains("Clear caches")); assert!(screen.contains("Rebuild provider and Java metadata")); From 31d56f80fa6565b7cb7c0689b5c7b439d7ce17a0 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 21:55:59 +0200 Subject: [PATCH 26/42] fix: restore compact border preview --- src/tui/widgets/popups/global_settings.rs | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index e713a94..6a3ab3d 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -1070,6 +1070,12 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> Span::styled( if editing || matches!(index, 3 | 4 | 8 | 9 | 10) { String::new() + } else if index == 1 { + format!( + "{} {}", + border_preview(&state.theme.border_style), + state.display_value(index) + ) } else if index == 2 && state.config.ui.image_protocol == ImageProtocol::Auto { state.detected_image_protocol.to_owned() } else { @@ -1114,6 +1120,15 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> })) } +fn border_preview(style: &BorderStyle) -> &'static str { + match style { + BorderStyle::Plain => "┌─", + BorderStyle::Rounded => "╭─", + BorderStyle::Double => "╔═", + BorderStyle::Thick => "┏━", + } +} + fn restart_required_for(state: &State, index: usize) -> bool { let current = crate::config::SETTINGS.read(); match index { @@ -1307,6 +1322,11 @@ mod tests { #[test] fn appearance_rows_use_inline_controls() { let mut state = State::new(); + assert!(matches!( + border_preview(&state.theme.border_style), + "┌─" | "╭─" | "╔═" | "┏━" + )); + state.selected = 0; state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert!(state.theme_picker); @@ -1343,6 +1363,11 @@ mod tests { assert!(screen.contains("Auto")); assert!(screen.contains("Clear caches")); assert!(screen.contains("Rebuild provider and Java metadata")); + assert!( + ["┌─", "╭─", "╔═", "┏━"] + .iter() + .any(|preview| screen.contains(preview)) + ); assert_eq!(screen.matches("-Xfoo").count(), 1); assert_eq!(screen.matches("FOO=bar").count(), 1); } From 033a8b144aabe89a1481dde08ccd54a53a0baceb Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 22:00:27 +0200 Subject: [PATCH 27/42] fix: capitalize launcher choice values --- src/tui/widgets/popups/global_settings.rs | 58 +++++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 6a3ab3d..63fdf65 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -19,7 +19,7 @@ use crate::{ settings::{ContentProvider, ImageProtocol}, theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, }, - instance::models::{normalize_memory_value, parse_resolution}, + instance::models::{WindowMode, normalize_memory_value, parse_resolution}, tui::widgets::popups::settings_controls::{ DisplayResolution, JavaChoice, JavaPicker, ResolutionChoice, ResolutionPickerAction, SettingsPicker, SettingsPickerAction, SettingsPickerOption, adjust_memory, auto_label, @@ -155,6 +155,8 @@ impl State { fn display_value(&self, field: usize) -> String { match field { + 1 => border_style_title(&self.theme.border_style).to_owned(), + 2 => image_protocol_title(self.config.ui.image_protocol).to_owned(), 5 => self.java_picker.display_label( self.config .paths @@ -163,6 +165,7 @@ impl State { .filter(|path| !path.is_empty()) .unwrap_or_else(|| self.java_picker.detected_path()), ), + 6 => window_mode_title(self.config.defaults.window_mode).to_owned(), 7 if self.config.defaults.resolution.is_none() => "game default".to_owned(), 16 if self.config.content.max_fingerprint_size_mib == 0 => "unlimited".to_owned(), 23 => "provider and Java metadata".to_owned(), @@ -355,20 +358,24 @@ impl State { active: false, }; [ - ("kitty", "Kitty", "Use Kitty graphics when supported"), + ( + "kitty", + image_protocol_title(ImageProtocol::Kitty), + "Use Kitty graphics when supported", + ), ( "iterm2", - "iTerm2", + image_protocol_title(ImageProtocol::Iterm2), "Use the iTerm2 image protocol when supported", ), ( "quadrants", - "Quadrants", + image_protocol_title(ImageProtocol::Quadrants), "Render images with quadrant characters", ), ( "halfblocks", - "Halfblocks", + image_protocol_title(ImageProtocol::Halfblocks), "Render images with half-block characters", ), ] @@ -1129,6 +1136,32 @@ fn border_preview(style: &BorderStyle) -> &'static str { } } +fn border_style_title(style: &BorderStyle) -> &'static str { + match style { + BorderStyle::Plain => "Plain", + BorderStyle::Rounded => "Rounded", + BorderStyle::Double => "Double", + BorderStyle::Thick => "Thick", + } +} + +fn image_protocol_title(protocol: ImageProtocol) -> &'static str { + match protocol { + ImageProtocol::Auto => "Auto", + ImageProtocol::Halfblocks => "Halfblocks", + ImageProtocol::Quadrants => "Quadrants", + ImageProtocol::Kitty => "Kitty", + ImageProtocol::Iterm2 => "iTerm2", + } +} + +fn window_mode_title(mode: WindowMode) -> &'static str { + match mode { + WindowMode::Windowed => "Windowed", + WindowMode::Fullscreen => "Fullscreen", + } +} + fn restart_required_for(state: &State, index: usize) -> bool { let current = crate::config::SETTINGS.read(); match index { @@ -1242,6 +1275,7 @@ mod tests { Action::Save(..) )); assert_eq!(state.config.ui.image_protocol, ImageProtocol::Kitty); + assert_eq!(state.display_value(2), "Kitty"); assert!(matches!( state.handle_key(&KeyEvent::from(KeyCode::Char('a'))), Action::Save(..) @@ -1272,6 +1306,20 @@ mod tests { )); } + #[test] + fn choice_values_use_their_display_titles() { + let mut state = State::new(); + state.config.ui.image_protocol = ImageProtocol::Halfblocks; + state.config.defaults.window_mode = WindowMode::Fullscreen; + + assert_eq!( + state.display_value(1), + border_style_title(&state.theme.border_style) + ); + assert_eq!(state.display_value(2), "Halfblocks"); + assert_eq!(state.display_value(6), "Fullscreen"); + } + #[test] fn launcher_jvm_and_environment_defaults_share_tag_editing() { let mut state = State::new(); From 78ea2f0f54c3d6e04b0c968e10f94a073ec6d6ed Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 22:16:35 +0200 Subject: [PATCH 28/42] fix: use concrete launcher resolution default --- README.md | 2 +- assets/config.toml | 2 +- src/config/settings.rs | 13 +++++++++++-- src/config/tests/loading.rs | 9 +++++++++ src/tui/widgets/popups/global_settings.rs | 8 ++++++-- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7ad30ee..804c6ad 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ memory_max = "2G" jvm_args = [] environment = {} window_mode = "windowed" -# resolution = [1920, 1080] +resolution = [854, 480] [ui] image_protocol = "auto" diff --git a/assets/config.toml b/assets/config.toml index 935fafc..e923cc7 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -19,7 +19,7 @@ jvm_args = [] environment = {} # inherited by instances that use launcher window defaults window_mode = "windowed" -# resolution = [1920, 1080] # omitted = Minecraft default +resolution = [854, 480] [ui] # image protocol: auto, halfblocks, quadrants, kitty, or iterm2 diff --git a/src/config/settings.rs b/src/config/settings.rs index af53c31..3da5741 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize}; use crate::instance::models::{WindowMode, memory_kib, normalize_memory_value}; +pub const DEFAULT_RESOLUTION: (u32, u32) = (854, 480); + #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ImageProtocol { @@ -271,7 +273,7 @@ pub struct Defaults { pub environment: BTreeMap, #[serde(default)] pub window_mode: WindowMode, - #[serde(default)] + #[serde(default = "default_resolution")] #[serde(skip_serializing_if = "Option::is_none")] pub resolution: Option<(u32, u32)>, } @@ -283,6 +285,10 @@ fn default_memory_max() -> String { "2G".to_owned() } +fn default_resolution() -> Option<(u32, u32)> { + Some(DEFAULT_RESOLUTION) +} + impl Default for Defaults { fn default() -> Self { Self { @@ -291,7 +297,7 @@ impl Default for Defaults { jvm_args: Vec::new(), environment: BTreeMap::new(), window_mode: WindowMode::default(), - resolution: None, + resolution: default_resolution(), } } } @@ -373,6 +379,9 @@ impl Config { .memory_max .clone_from(&self.defaults.memory_min); } + if self.defaults.resolution.is_none() { + self.defaults.resolution = default_resolution(); + } self.ui.error_auto_dismiss_ms = self.ui.error_auto_dismiss_ms.max(1); self.ui.error_slide_start_ms = self .ui diff --git a/src/config/tests/loading.rs b/src/config/tests/loading.rs index aabd59d..47b7e28 100644 --- a/src/config/tests/loading.rs +++ b/src/config/tests/loading.rs @@ -68,6 +68,10 @@ fn bundled_config_uses_platform_paths_and_automatic_java() { assert_eq!(config.paths.meta_dir, settings::Paths::default().meta_dir); assert!(config.paths.java_path.is_none()); assert_eq!(config.ui.image_protocol, settings::ImageProtocol::Auto); + assert_eq!( + config.defaults.resolution, + Some(settings::DEFAULT_RESOLUTION) + ); } #[test] @@ -81,6 +85,7 @@ fn config_normalizes_memory_java_and_notification_bounds() { defaults: settings::Defaults { memory_min: "invalid".to_owned(), memory_max: "1G".to_owned(), + resolution: None, ..Default::default() }, ui: settings::Ui { @@ -101,6 +106,10 @@ fn config_normalizes_memory_java_and_notification_bounds() { assert!(config.paths.java_path.is_none()); assert_eq!(config.defaults.memory_min, "512M"); assert_eq!(config.defaults.memory_max, "1G"); + assert_eq!( + config.defaults.resolution, + Some(settings::DEFAULT_RESOLUTION) + ); assert_eq!(config.ui.error_slide_start_ms, 100); assert_eq!(config.ui.error_fly_out_ms, 100); assert_eq!(config.ui.max_error_events, 1); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 63fdf65..d3dbc39 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -16,7 +16,7 @@ use ratatui_textarea::TextArea; use crate::{ config::{ Config, - settings::{ContentProvider, ImageProtocol}, + settings::{ContentProvider, DEFAULT_RESOLUTION, ImageProtocol}, theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, }, instance::models::{WindowMode, normalize_memory_value, parse_resolution}, @@ -166,7 +166,9 @@ impl State { .unwrap_or_else(|| self.java_picker.detected_path()), ), 6 => window_mode_title(self.config.defaults.window_mode).to_owned(), - 7 if self.config.defaults.resolution.is_none() => "game default".to_owned(), + 7 if self.config.defaults.resolution.is_none() => { + format!("{}x{}", DEFAULT_RESOLUTION.0, DEFAULT_RESOLUTION.1) + } 16 if self.config.content.max_fingerprint_size_mib == 0 => "unlimited".to_owned(), 23 => "provider and Java metadata".to_owned(), _ => self.value(field), @@ -1346,6 +1348,8 @@ mod tests { #[test] fn launcher_resolution_reuses_display_and_preset_choices() { let mut state = State::new(); + state.config.defaults.resolution = None; + assert_eq!(state.display_value(7), "854x480"); state.display_resolutions = vec![DisplayResolution { width: 2560, height: 1440, From 429e46b7b7fa315a44dfd66047609ca5bf3ac852 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 22:24:24 +0200 Subject: [PATCH 29/42] fix: label detected default resolution --- src/tui/widgets/popups/global_settings.rs | 24 ++++++++++++---- src/tui/widgets/popups/instance_settings.rs | 25 +++++++++++------ src/tui/widgets/popups/settings_controls.rs | 31 +++++++++++++++++++++ 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index d3dbc39..46514dd 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -23,10 +23,11 @@ use crate::{ tui::widgets::popups::settings_controls::{ DisplayResolution, JavaChoice, JavaPicker, ResolutionChoice, ResolutionPickerAction, SettingsPicker, SettingsPickerAction, SettingsPickerOption, adjust_memory, auto_label, - display_resolutions, environment_labels, handle_resolution_picker_key, - handle_text_area_input, memory_kib, parse_environment, render_memory_gauge, - render_settings_picker, resolution_choices, resolution_items, settings_text_area, - tagged_row_count, tagged_value_lines, toggle_window_mode, + default_label, default_resolution, display_resolutions, environment_labels, + handle_resolution_picker_key, handle_text_area_input, is_default_resolution, memory_kib, + parse_environment, render_memory_gauge, render_settings_picker, resolution_choices, + resolution_items, settings_text_area, tagged_row_count, tagged_value_lines, + toggle_window_mode, }, tui::widgets::status_badge, }; @@ -459,8 +460,8 @@ impl State { } fn apply_default_resolution(&mut self) { - if let Some(display) = self.display_resolutions.first() { - self.config.defaults.resolution = Some((display.width, display.height)); + if let Some(resolution) = default_resolution(&self.display_resolutions) { + self.config.defaults.resolution = Some(resolution); self.save_pending = true; self.error = None; } else { @@ -1109,6 +1110,12 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> if index == 2 && state.config.ui.image_protocol == ImageProtocol::Auto && !editing { spans.extend([Span::raw(" "), auto_label()]); } + if index == 7 + && !editing + && is_default_resolution(state.config.defaults.resolution, &state.display_resolutions) + { + spans.extend([Span::raw(" "), default_label()]); + } if index == 10 && !editing { spans.push(Span::raw(" ")); spans.push(match state.config.content.preferred_provider { @@ -1363,6 +1370,11 @@ mod tests { Action::Save(..) )); assert_eq!(state.config.defaults.resolution, Some((2560, 1440))); + assert!( + global_field_line(&state, 7, field_label(7)) + .to_string() + .contains("Default") + ); state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); assert!(matches!( diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index c491f5f..9e10d0b 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -31,8 +31,9 @@ use crate::{ DisplayResolution, GlfwChoice, GlfwPicker, JavaChoice, JavaPicker, ResolutionChoice, ResolutionPickerAction, SettingsPicker, SettingsPickerAction, SettingsPickerBadge, SettingsPickerOption, adjust_memory, auto_label, - bundled_glfw_version, bundled_label as bundled_badge, display_resolutions, - environment_labels, handle_resolution_picker_key, handle_text_area_input, + bundled_glfw_version, bundled_label as bundled_badge, default_label, + default_resolution, display_resolutions, environment_labels, + handle_resolution_picker_key, handle_text_area_input, is_default_resolution, memory_kib, parse_environment, render_memory_gauge, render_settings_picker, resolution_choices, resolution_items, settings_text_area, tagged_row_count, tagged_value_lines, toggle_window_mode, @@ -737,12 +738,6 @@ impl State { ) } - fn default_resolution(&self) -> Option<(u32, u32)> { - self.display_resolutions - .first() - .map(|display| (display.width, display.height)) - } - fn apply_default_memory(&mut self) { let settings = SETTINGS.read(); if self.selected == 4 { @@ -754,7 +749,7 @@ impl State { } fn apply_default_resolution(&mut self) { - if let Some(resolution) = self.default_resolution() { + if let Some(resolution) = default_resolution(&self.display_resolutions) { self.draft.resolution = Some(resolution); self.draft.inherit_resolution = false; self.error = None; @@ -1497,6 +1492,13 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { if index == 14 && state.draft.glfw_path.is_none() && !editing { spans.extend([Span::raw(" "), bundled_badge()]); } + if index == 9 + && !editing + && !state.draft.inherit_resolution + && is_default_resolution(state.draft.resolution, &state.display_resolutions) + { + spans.extend([Span::raw(" "), default_label()]); + } if (index == 8 && state.draft.inherit_window_mode) || (index == 9 && state.draft.inherit_resolution) { @@ -2003,6 +2005,11 @@ mod tests { state.draft.resolution = Some((1920, 1080)); state.handle_key(&KeyEvent::from(KeyCode::Char('d'))); assert_eq!(state.draft.resolution, Some((2560, 1440))); + assert!( + field_line(&state, 9, field_label(9)) + .to_string() + .contains("Default") + ); state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert!(!state.choice_values().iter().any(|value| value == "Default")); assert!(!state.choice_values().iter().any(|value| value == "Custom…")); diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 3c5f628..554d2e0 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -1047,6 +1047,10 @@ pub(crate) fn bundled_label() -> Span<'static> { status_badge("Bundled", THEME.as_ref().success()) } +pub(crate) fn default_label() -> Span<'static> { + status_badge("Default", THEME.as_ref().success()) +} + pub(crate) fn render_settings_picker( picker: &SettingsPicker, area: Rect, @@ -1080,6 +1084,19 @@ pub(crate) fn display_resolutions() -> Vec { resolutions } +pub(crate) fn default_resolution(displays: &[DisplayResolution]) -> Option<(u32, u32)> { + displays + .first() + .map(|display| (display.width, display.height)) +} + +pub(crate) fn is_default_resolution( + resolution: Option<(u32, u32)>, + displays: &[DisplayResolution], +) -> bool { + default_resolution(displays).is_some_and(|default| resolution == Some(default)) +} + pub(crate) fn resolution_choices( current: Option<(u32, u32)>, displays: &[DisplayResolution], @@ -1171,6 +1188,20 @@ mod tests { assert_eq!(adjust_memory("512M", false), "512M"); } + #[test] + fn first_detected_display_is_the_default_resolution() { + let displays = vec![DisplayResolution { + width: 1440, + height: 2560, + name: "DP-1".to_owned(), + primary: true, + }]; + + assert_eq!(default_resolution(&displays), Some((1440, 2560))); + assert!(is_default_resolution(Some((1440, 2560)), &displays)); + assert!(!is_default_resolution(Some((854, 480)), &displays)); + } + #[test] fn java_picker_preserves_semantic_selection_when_results_arrive() { let mut picker = JavaPicker::new(); From a5fdbaf74abc06f78d0cf99059200f0f9d71595b Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 22:26:14 +0200 Subject: [PATCH 30/42] fix: restore Minecraft default resolution action --- src/tui/widgets/popups/global_settings.rs | 17 ++++-------- src/tui/widgets/popups/instance_settings.rs | 14 ++++------ src/tui/widgets/popups/settings_controls.rs | 30 +++++++-------------- 3 files changed, 19 insertions(+), 42 deletions(-) diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 46514dd..fc23afa 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -460,13 +460,9 @@ impl State { } fn apply_default_resolution(&mut self) { - if let Some(resolution) = default_resolution(&self.display_resolutions) { - self.config.defaults.resolution = Some(resolution); - self.save_pending = true; - self.error = None; - } else { - self.error = Some("Display resolution could not be detected.".to_owned()); - } + self.config.defaults.resolution = Some(default_resolution()); + self.save_pending = true; + self.error = None; } fn enable_auto_image_protocol(&mut self) { @@ -1110,10 +1106,7 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> if index == 2 && state.config.ui.image_protocol == ImageProtocol::Auto && !editing { spans.extend([Span::raw(" "), auto_label()]); } - if index == 7 - && !editing - && is_default_resolution(state.config.defaults.resolution, &state.display_resolutions) - { + if index == 7 && !editing && is_default_resolution(state.config.defaults.resolution) { spans.extend([Span::raw(" "), default_label()]); } if index == 10 && !editing { @@ -1369,7 +1362,7 @@ mod tests { state.handle_key(&KeyEvent::from(KeyCode::Char('d'))), Action::Save(..) )); - assert_eq!(state.config.defaults.resolution, Some((2560, 1440))); + assert_eq!(state.config.defaults.resolution, Some((854, 480))); assert!( global_field_line(&state, 7, field_label(7)) .to_string() diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 9e10d0b..c4106fa 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -749,13 +749,9 @@ impl State { } fn apply_default_resolution(&mut self) { - if let Some(resolution) = default_resolution(&self.display_resolutions) { - self.draft.resolution = Some(resolution); - self.draft.inherit_resolution = false; - self.error = None; - } else { - self.error = Some("Display resolution could not be detected.".to_owned()); - } + self.draft.resolution = Some(default_resolution()); + self.draft.inherit_resolution = false; + self.error = None; } fn toggle_global_window_default(&mut self) { @@ -1495,7 +1491,7 @@ fn field_line(state: &State, index: usize, label: &str) -> Line<'static> { if index == 9 && !editing && !state.draft.inherit_resolution - && is_default_resolution(state.draft.resolution, &state.display_resolutions) + && is_default_resolution(state.draft.resolution) { spans.extend([Span::raw(" "), default_label()]); } @@ -2004,7 +2000,7 @@ mod tests { }]; state.draft.resolution = Some((1920, 1080)); state.handle_key(&KeyEvent::from(KeyCode::Char('d'))); - assert_eq!(state.draft.resolution, Some((2560, 1440))); + assert_eq!(state.draft.resolution, Some((854, 480))); assert!( field_line(&state, 9, field_label(9)) .to_string() diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 554d2e0..cf92c93 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -20,7 +20,7 @@ use ratatui::{ use ratatui_textarea::{CursorMove, TextArea}; use crate::{ - config::theme::THEME, + config::{settings::DEFAULT_RESOLUTION, theme::THEME}, instance::{WindowMode, java::JavaInstallation}, tui::widgets::{popups::LoadState, status_badge}, }; @@ -1084,17 +1084,12 @@ pub(crate) fn display_resolutions() -> Vec { resolutions } -pub(crate) fn default_resolution(displays: &[DisplayResolution]) -> Option<(u32, u32)> { - displays - .first() - .map(|display| (display.width, display.height)) +pub(crate) fn default_resolution() -> (u32, u32) { + DEFAULT_RESOLUTION } -pub(crate) fn is_default_resolution( - resolution: Option<(u32, u32)>, - displays: &[DisplayResolution], -) -> bool { - default_resolution(displays).is_some_and(|default| resolution == Some(default)) +pub(crate) fn is_default_resolution(resolution: Option<(u32, u32)>) -> bool { + resolution == Some(default_resolution()) } pub(crate) fn resolution_choices( @@ -1189,17 +1184,10 @@ mod tests { } #[test] - fn first_detected_display_is_the_default_resolution() { - let displays = vec![DisplayResolution { - width: 1440, - height: 2560, - name: "DP-1".to_owned(), - primary: true, - }]; - - assert_eq!(default_resolution(&displays), Some((1440, 2560))); - assert!(is_default_resolution(Some((1440, 2560)), &displays)); - assert!(!is_default_resolution(Some((854, 480)), &displays)); + fn minecraft_window_size_is_the_default_resolution() { + assert_eq!(default_resolution(), (854, 480)); + assert!(is_default_resolution(Some((854, 480)))); + assert!(!is_default_resolution(Some((1440, 2560)))); } #[test] From 5853ce4fd30a833b9e5db3015d185dee6156c8ce Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 22:30:29 +0200 Subject: [PATCH 31/42] fix: refine default and provider badges --- src/tui/widgets/popups/global_settings.rs | 36 ++++++++++++++++++--- src/tui/widgets/popups/settings_controls.rs | 19 ++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index fc23afa..3d21a2a 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -7,7 +7,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::Rect, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, ListItem, Paragraph}, }; @@ -33,6 +33,9 @@ use crate::{ }; const FIELD_COUNT: usize = 24; +const MODRINTH_COLOR: Color = Color::Rgb(0x1B, 0xD9, 0x6A); +const CURSEFORGE_COLOR: Color = Color::Rgb(0xF1, 0x64, 0x36); +const PROVIDER_BADGE_TEXT: Color = Color::Rgb(0x10, 0x10, 0x10); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChoicePicker { @@ -1111,10 +1114,7 @@ fn global_field_line(state: &State, index: usize, label: &str) -> Line<'static> } if index == 10 && !editing { spans.push(Span::raw(" ")); - spans.push(match state.config.content.preferred_provider { - ContentProvider::Modrinth => status_badge("Modrinth", theme.success()), - ContentProvider::CurseForge => status_badge("CurseForge", theme.warning()), - }); + spans.push(provider_badge(state.config.content.preferred_provider)); } if restart_required_for(state, index) { spans.extend([ @@ -1138,6 +1138,20 @@ fn border_preview(style: &BorderStyle) -> &'static str { } } +fn provider_badge(provider: ContentProvider) -> Span<'static> { + let (label, color) = match provider { + ContentProvider::Modrinth => ("Modrinth", MODRINTH_COLOR), + ContentProvider::CurseForge => ("CurseForge", CURSEFORGE_COLOR), + }; + Span::styled( + format!(" {label} "), + Style::default() + .fg(PROVIDER_BADGE_TEXT) + .bg(color) + .add_modifier(Modifier::BOLD), + ) +} + fn border_style_title(style: &BorderStyle) -> &'static str { match style { BorderStyle::Plain => "Plain", @@ -1322,6 +1336,18 @@ mod tests { assert_eq!(state.display_value(6), "Fullscreen"); } + #[test] + fn provider_badges_use_fixed_brand_colors() { + assert_eq!( + provider_badge(ContentProvider::Modrinth).style.bg, + Some(MODRINTH_COLOR) + ); + assert_eq!( + provider_badge(ContentProvider::CurseForge).style.bg, + Some(CURSEFORGE_COLOR) + ); + } + #[test] fn launcher_jvm_and_environment_defaults_share_tag_editing() { let mut state = State::new(); diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index cf92c93..66f83b3 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -1048,7 +1048,7 @@ pub(crate) fn bundled_label() -> Span<'static> { } pub(crate) fn default_label() -> Span<'static> { - status_badge("Default", THEME.as_ref().success()) + status_badge("Default", THEME.as_ref().warning()) } pub(crate) fn render_settings_picker( @@ -1140,6 +1140,9 @@ pub(crate) fn resolution_items( theme.text() }), )]; + if is_default_resolution(choice.resolution()) { + spans.extend([Span::raw(" "), default_label()]); + } match choice { ResolutionChoice::Preset(_, _) => {} ResolutionChoice::Display(display) => { @@ -1188,6 +1191,20 @@ mod tests { assert_eq!(default_resolution(), (854, 480)); assert!(is_default_resolution(Some((854, 480)))); assert!(!is_default_resolution(Some((1440, 2560)))); + assert_eq!(default_label().style.bg, Some(THEME.as_ref().warning())); + + let choices = resolution_choices(Some((854, 480)), &[]); + let backend = ratatui::backend::TestBackend::new(40, 6); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + frame.render_widget( + ratatui::widgets::List::new(resolution_items(&choices, 0)), + frame.area(), + ); + }) + .unwrap(); + assert!(terminal.backend().to_string().contains("Default")); } #[test] From d078035394d0c4fc8b448f0646bd52102a87a335 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 23:53:27 +0200 Subject: [PATCH 32/42] fix(notifications): honor queue and slide timing --- src/feedback/errors.rs | 8 +++++++- src/feedback/tests/errors.rs | 18 ++++++++++++++++++ src/tui/render.rs | 12 ++++++++---- src/tui/tests/widgets/popups/error/area.rs | 12 ++++++++++++ src/tui/widgets/popups/error.rs | 11 ++++++++++- 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/feedback/errors.rs b/src/feedback/errors.rs index 66aa65c..29d96d5 100644 --- a/src/feedback/errors.rs +++ b/src/feedback/errors.rs @@ -20,7 +20,13 @@ static NEXT_ERROR_ID: AtomicU64 = AtomicU64::new(1); static MAX_ERROR_EVENTS: AtomicUsize = AtomicUsize::new(50); pub(crate) fn set_max_error_events(max_events: usize) { - MAX_ERROR_EVENTS.store(max_events.max(1), Ordering::Relaxed); + let max_events = max_events.max(1); + MAX_ERROR_EVENTS.store(max_events, Ordering::Relaxed); + if let Ok(mut events) = ERROR_EVENTS.lock() { + while events.len() > max_events { + events.pop_front(); + } + } } #[derive(Debug, Clone)] diff --git a/src/feedback/tests/errors.rs b/src/feedback/tests/errors.rs index 27bc357..9852e18 100644 --- a/src/feedback/tests/errors.rs +++ b/src/feedback/tests/errors.rs @@ -88,3 +88,21 @@ fn overflow_drops_oldest() { assert!(all.iter().any(|e| e.message == "overflow_10")); assert!(all.iter().any(|e| e.message == "overflow_59")); } + +#[test] +fn lowering_limit_trims_existing_events_immediately() { + let _guard = TEST_LOCK.lock().unwrap(); + clear_errors_for_test(); + set_max_error_events(5); + for i in 0..5 { + push_error(make_event(&format!("trim_{i}"))); + } + + set_max_error_events(2); + let all = peek_all_errors(); + + assert_eq!(all.len(), 2); + assert_eq!(all[0].message, "trim_4"); + assert_eq!(all[1].message, "trim_3"); + set_max_error_events(50); +} diff --git a/src/tui/render.rs b/src/tui/render.rs index 55c5648..af74840 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -344,7 +344,7 @@ impl App { } // drives the slide-in / idle / slide-out state machine for each error toast. - // transitions to FadingOut once it's within fly_out_ms of auto-dismiss time + // transitions to FadingOut at the configured slide-start time fn render_error_effect( &mut self, frame: &mut Frame, @@ -356,9 +356,13 @@ impl App { use crate::config::theme::THEME; let theme = THEME.as_ref(); let bg = theme.background(); - let fly_out_ms = SETTINGS.read().ui.error_fly_out_ms as u128; - let fly_start_ms = SETTINGS.read().ui.error_auto_dismiss_ms as u128 - - fly_out_ms.min(SETTINGS.read().ui.error_auto_dismiss_ms as u128); + let (fly_out_ms, fly_start_ms) = { + let settings = SETTINGS.read(); + ( + settings.ui.error_fly_out_ms as u128, + settings.ui.error_slide_start_ms as u128, + ) + }; if elapsed_ms >= fly_start_ms { let entry = self diff --git a/src/tui/tests/widgets/popups/error/area.rs b/src/tui/tests/widgets/popups/error/area.rs index 494b31c..02f7f7e 100644 --- a/src/tui/tests/widgets/popups/error/area.rs +++ b/src/tui/tests/widgets/popups/error/area.rs @@ -15,6 +15,18 @@ fn returns_none_after_dismiss_timeout() { assert!(popup_area(frame(), "msg", 0, past_dismiss).is_none()); } +#[test] +fn returns_none_after_slide_out_finishes() { + let settings = SETTINGS.read(); + let after_slide = settings + .ui + .error_slide_start_ms + .saturating_add(settings.ui.error_fly_out_ms) as u128; + drop(settings); + + assert!(popup_area(frame(), "msg", 0, after_slide).is_none()); +} + #[test] fn returns_some_inside_dismiss_window() { assert!(popup_area(frame(), "msg", 0, 0).is_some()); diff --git a/src/tui/widgets/popups/error.rs b/src/tui/widgets/popups/error.rs index b6e1a32..ee8cb08 100644 --- a/src/tui/widgets/popups/error.rs +++ b/src/tui/widgets/popups/error.rs @@ -74,7 +74,16 @@ pub fn popup_area(frame_area: Rect, message: &str, base_y: u16, elapsed_ms: u128 const MAX_W: usize = 58; const MIN_W: usize = 22; - if elapsed_ms >= SETTINGS.read().ui.error_auto_dismiss_ms as u128 { + let visible_until = { + let settings = SETTINGS.read(); + settings.ui.error_auto_dismiss_ms.min( + settings + .ui + .error_slide_start_ms + .saturating_add(settings.ui.error_fly_out_ms), + ) as u128 + }; + if elapsed_ms >= visible_until { return None; } From 1079680513dbb26c511b0cc6d60925202d6e485c Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 23:53:35 +0200 Subject: [PATCH 33/42] fix(config): preserve values during schema upgrades --- src/cli/mod.rs | 4 +- src/config/mod.rs | 182 ++++++++++++++++++++++++++++------ src/config/tests/loading.rs | 75 +++++++++++++- src/config/theme.rs | 10 +- src/layout_migration.rs | 19 +++- src/migrate.rs | 7 ++ src/tests/layout_migration.rs | 7 ++ src/tui/mod.rs | 9 +- 8 files changed, 273 insertions(+), 40 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f32af79..e155c3c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -29,8 +29,8 @@ pub async fn init() { let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); + let config = crate::config::get_config_path().join("config.toml"); if crate::layout_migration::is_needed(&instances_dir, &meta_dir) { - let config = crate::config::get_config_path().join("config.toml"); if let Err(error) = crate::layout_migration::run(&instances_dir, &meta_dir, &config, |progress| { eprintln!( @@ -45,6 +45,8 @@ pub async fn init() { } else if let Err(error) = crate::layout_migration::initialize_new_layout(&meta_dir) { eprintln!("error: cannot initialize metadata layout: {error}"); std::process::exit(1); + } else if let Err(error) = crate::config::upgrade_config_file(&config) { + eprintln!("warning: cannot upgrade launcher config: {error}"); } if crate::layout_migration::cache_rebuild_pending(&meta_dir) { let manager = crate::instance::InstanceManager::new(&instances_dir, &meta_dir); diff --git a/src/config/mod.rs b/src/config/mod.rs index 745649f..77c657a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -58,6 +58,90 @@ pub fn load_config(config_path: &std::path::Path) -> Result .map(Config::normalize) } +pub(crate) fn upgrade_config_file(path: &std::path::Path) -> io::Result { + let source = match fs::read_to_string(path) { + Ok(source) => source, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + crate::storage::write_atomic( + path, + include_str!("../../assets/config.toml").as_bytes(), + )?; + return Ok(true); + } + Err(error) => return Err(error), + }; + let mut document = parse_toml_document(path, &source)?; + let template = include_str!("../../assets/config.toml") + .parse::() + .map_err(io::Error::other)?; + merge_missing_table(document.as_table_mut(), template.as_table()); + let upgraded = document.to_string(); + toml::from_str::(&upgraded).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("cannot upgrade {}: {error}", path.display()), + ) + })?; + if upgraded == source { + return Ok(false); + } + crate::storage::write_atomic(path, upgraded.as_bytes())?; + Ok(true) +} + +pub(crate) fn migrate_legacy_data_paths(path: &std::path::Path) -> io::Result { + let Some(data_dir) = dirs_next::data_dir() else { + return Ok(false); + }; + migrate_legacy_data_paths_from(path, &data_dir) +} + +fn migrate_legacy_data_paths_from( + path: &std::path::Path, + data_dir: &std::path::Path, +) -> io::Result { + let source = match fs::read_to_string(path) { + Ok(source) => source, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let mut document = parse_toml_document(path, &source)?; + let Some(paths) = document + .get_mut("paths") + .and_then(toml_edit::Item::as_table_mut) + else { + return Ok(false); + }; + let old_root = data_dir.join("mcl"); + let new_root = data_dir.join("rmcl"); + let mut changed = false; + for (key, old_path, replacement) in [ + ( + "instances_dir", + old_root.join("instances"), + new_root.join("instances"), + ), + ("meta_dir", old_root.join("meta"), new_root.join("meta")), + ] { + let Some(value) = paths.get_mut(key).and_then(toml_edit::Item::as_value_mut) else { + continue; + }; + if value + .as_str() + .is_some_and(|raw| settings::resolve_path(raw) == old_path) + { + let decor = value.decor().clone(); + *value = toml_edit::Value::from(replacement.to_string_lossy().into_owned()); + *value.decor_mut() = decor; + changed = true; + } + } + if changed { + crate::storage::write_atomic(path, document.to_string().as_bytes())?; + } + Ok(changed) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LauncherSettingsSave { pub restart_required: bool, @@ -170,51 +254,69 @@ pub static SETTINGS: LazyLock = LazyLock::new(|| { }); fn write_config_document(path: &std::path::Path, config: &Config) -> io::Result<()> { + write_merged_toml_document(path, config, |document| { + if config.paths.java_path.is_none() + && let Some(paths) = document + .get_mut("paths") + .and_then(|item| item.as_table_mut()) + { + paths.remove("java_path"); + } + let default_paths = settings::Paths::default(); + if let Some(paths) = document + .get_mut("paths") + .and_then(|item| item.as_table_mut()) + { + if config.paths.instances_dir == default_paths.instances_dir { + paths.remove("instances_dir"); + } + if config.paths.meta_dir == default_paths.meta_dir { + paths.remove("meta_dir"); + } + } + if config.defaults.resolution.is_none() + && let Some(defaults) = document + .get_mut("defaults") + .and_then(|item| item.as_table_mut()) + { + defaults.remove("resolution"); + } + }) +} + +pub(crate) fn write_merged_toml_document( + path: &std::path::Path, + value: &T, + edit: F, +) -> io::Result<()> +where + T: serde::Serialize, + F: FnOnce(&mut toml_edit::DocumentMut), +{ let source = match fs::read_to_string(path) { Ok(source) => source, Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(), Err(error) => return Err(error), }; - let mut document = source.parse::().map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("cannot update {}: {error}", path.display()), - ) - })?; - let generated = toml::to_string_pretty(config) + let mut document = parse_toml_document(path, &source)?; + let generated = toml::to_string_pretty(value) .map_err(io::Error::other)? .parse::() .map_err(io::Error::other)?; merge_table(document.as_table_mut(), generated.as_table()); - if config.paths.java_path.is_none() - && let Some(paths) = document - .get_mut("paths") - .and_then(|item| item.as_table_mut()) - { - paths.remove("java_path"); - } - let default_paths = settings::Paths::default(); - if let Some(paths) = document - .get_mut("paths") - .and_then(|item| item.as_table_mut()) - { - if config.paths.instances_dir == default_paths.instances_dir { - paths.remove("instances_dir"); - } - if config.paths.meta_dir == default_paths.meta_dir { - paths.remove("meta_dir"); - } - } - if config.defaults.resolution.is_none() - && let Some(defaults) = document - .get_mut("defaults") - .and_then(|item| item.as_table_mut()) - { - defaults.remove("resolution"); - } + edit(&mut document); crate::storage::write_atomic(path, document.to_string().as_bytes()) } +fn parse_toml_document(path: &std::path::Path, source: &str) -> io::Result { + source.parse::().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("cannot update {}: {error}", path.display()), + ) + }) +} + fn merge_table(target: &mut toml_edit::Table, source: &toml_edit::Table) { for (key, source_item) in source { if let Some(target_item) = target.get_mut(key) { @@ -239,6 +341,20 @@ fn merge_table(target: &mut toml_edit::Table, source: &toml_edit::Table) { } } +fn merge_missing_table(target: &mut toml_edit::Table, defaults: &toml_edit::Table) { + for (key, default_item) in defaults { + if let Some(target_item) = target.get_mut(key) { + if let (Some(target_table), Some(default_table)) = + (target_item.as_table_mut(), default_item.as_table()) + { + merge_missing_table(target_table, default_table); + } + } else { + target.insert(key, default_item.clone()); + } + } +} + #[cfg(test)] #[path = "tests/loading.rs"] mod tests; diff --git a/src/config/tests/loading.rs b/src/config/tests/loading.rs index 47b7e28..94f0e35 100644 --- a/src/config/tests/loading.rs +++ b/src/config/tests/loading.rs @@ -85,7 +85,7 @@ fn config_normalizes_memory_java_and_notification_bounds() { defaults: settings::Defaults { memory_min: "invalid".to_owned(), memory_max: "1G".to_owned(), - resolution: None, + resolution: Some((0, 1080)), ..Default::default() }, ui: settings::Ui { @@ -136,3 +136,76 @@ fn settings_writer_preserves_comments_and_unknown_keys() { assert!(!saved.contains("instances_dir")); assert!(!saved.contains("meta_dir")); } + +#[test] +fn config_upgrade_adds_new_defaults_without_replacing_old_values() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + let old = r#"# user comment +[paths] +instances_dir = "/custom/instances" +meta_dir = "/custom/meta" +java_path = "/custom/java" + +[defaults] +memory_min = "1G" +memory_max = "8G" + +[ui] +image_protocol = "kitty" + +[future] +value = 42 +"#; + std::fs::write(&path, old).unwrap(); + + assert!(upgrade_config_file(&path).unwrap()); + let upgraded = std::fs::read_to_string(&path).unwrap(); + let config = load_config(&path).unwrap(); + + assert!(upgraded.contains("# user comment"), "{upgraded}"); + assert!(upgraded.contains("[future]"), "{upgraded}"); + assert!(upgraded.contains("value = 42"), "{upgraded}"); + assert!(upgraded.contains("jvm_args = []"), "{upgraded}"); + assert!( + upgraded.contains("check_modpack_updates = true"), + "{upgraded}" + ); + assert_eq!(config.paths.instances_dir, "/custom/instances"); + assert_eq!(config.paths.meta_dir, "/custom/meta"); + assert_eq!(config.paths.java_path.as_deref(), Some("/custom/java")); + assert_eq!(config.defaults.memory_min, "1G"); + assert_eq!(config.defaults.memory_max, "8G"); + assert_eq!(config.ui.image_protocol, settings::ImageProtocol::Kitty); + + assert!(!upgrade_config_file(&path).unwrap()); + assert_eq!(std::fs::read_to_string(path).unwrap(), upgraded); +} + +#[test] +fn legacy_default_data_paths_follow_the_renamed_data_directory() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + let data = tmp.path().join("data"); + std::fs::write( + &path, + format!( + "[paths]\ninstances_dir = {:?}\nmeta_dir = {:?}\n", + data.join("mcl/instances").to_string_lossy(), + data.join("mcl/meta").to_string_lossy() + ), + ) + .unwrap(); + + assert!(migrate_legacy_data_paths_from(&path, &data).unwrap()); + let migrated = std::fs::read_to_string(path).unwrap(); + + assert!( + migrated.contains(&data.join("rmcl/instances").to_string_lossy().to_string()), + "{migrated}" + ); + assert!( + migrated.contains(&data.join("rmcl/meta").to_string_lossy().to_string()), + "{migrated}" + ); +} diff --git a/src/config/theme.rs b/src/config/theme.rs index 3629844..49d55fa 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -236,10 +236,14 @@ pub fn apply_theme(theme: String, border_style: BorderStyle) -> std::io::Result< let mut config = current_theme_config(); config.theme = theme; config.border_style = border_style.clone(); - let serialized = toml::to_string_pretty(&config).map_err(std::io::Error::other)?; - crate::storage::write_atomic( + super::write_merged_toml_document( &super::get_config_path().join("theme.toml"), - serialized.as_bytes(), + &config, + |document| { + if config.custom.is_none() { + document.as_table_mut().remove("custom"); + } + }, )?; THEME.set(resolve_app_theme(&config)); BORDER_STYLE.set(border_style); diff --git a/src/layout_migration.rs b/src/layout_migration.rs index 826a1e3..7a9d97f 100644 --- a/src/layout_migration.rs +++ b/src/layout_migration.rs @@ -138,9 +138,11 @@ pub fn run( if marker_version(&metadata.layout_marker()) == Some(LAYOUT_VERSION) && metadata.cache_rebuild_pending().exists() { + crate::config::upgrade_config_file(config_file)?; return Ok(latest_layout_backup(&metadata).unwrap_or_else(|| metadata.backups())); } if !is_needed(instances_dir, meta_dir) { + crate::config::upgrade_config_file(config_file)?; initialize_new_layout(meta_dir)?; return Ok(metadata.backups()); } @@ -149,7 +151,7 @@ pub fn run( let mut journal = load_or_create_journal(instances_dir, &metadata)?; let instances = instance_directories(instances_dir)?; - let total = instances.len() as u64 + 7; + let total = instances.len() as u64 + 8; let mut current = journal.completed.len() as u64; if !is_complete(&journal, "backup") { @@ -176,6 +178,21 @@ pub fn run( current += 1; } + if !is_complete(&journal, "config") { + report( + MigrationProgress::new( + "Upgrading launcher config", + config_file.display().to_string(), + current, + total, + ) + .with_backup(&journal.backup_dir), + ); + crate::config::upgrade_config_file(config_file)?; + complete(&mut journal, &metadata, "config")?; + current += 1; + } + validate_migration_conflicts(&instances, meta_dir)?; for instance in &instances { diff --git a/src/migrate.rs b/src/migrate.rs index c47743c..2c64ff1 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -27,6 +27,13 @@ pub fn run_legacy_rename() { if let (Some(desk), Some(data)) = (dirs::desktop_dir(), dirs_next::data_dir()) { rewrite_native_desktop_shortcuts(&desk, &data.join(NEW_NAME).join("instances")); } + if let (Some(config_dir), Some(data_dir)) = (dirs_next::config_dir(), dirs_next::data_dir()) + && !data_dir.join(OLD_NAME).exists() + && let Err(error) = + crate::config::migrate_legacy_data_paths(&config_dir.join(NEW_NAME).join("config.toml")) + { + eprintln!("rmcl migration: failed to update legacy config paths: {error}"); + } } fn rename_top_level(old: &Path, new: &Path) { diff --git a/src/tests/layout_migration.rs b/src/tests/layout_migration.rs index ff54e72..479fe0c 100644 --- a/src/tests/layout_migration.rs +++ b/src/tests/layout_migration.rs @@ -38,6 +38,13 @@ fn migration_backs_up_and_renames_instance_directories() { b"options" ); assert!(backup.join("instances/Example/.minecraft").exists()); + assert_eq!( + fs::read_to_string(backup.join("config/config.toml")).unwrap(), + "[paths]" + ); + let upgraded_config = fs::read_to_string(&config).unwrap(); + assert!(upgraded_config.contains("check_modpack_updates = true")); + assert!(upgraded_config.contains("resolution = [854, 480]")); assert!(backup.join("profiles/legacy/main/options.txt").exists()); assert!( backup diff --git a/src/tui/mod.rs b/src/tui/mod.rs index b37d678..f79d343 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -109,11 +109,18 @@ async fn run_layout_migration_screen( let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir(); let meta_dir = crate::config::SETTINGS.read().paths.resolve_meta_dir(); + let config = crate::config::get_config_path().join("config.toml"); if !crate::layout_migration::is_needed(&instances_dir, &meta_dir) { crate::layout_migration::initialize_new_layout(&meta_dir)?; + if let Err(error) = crate::config::upgrade_config_file(&config) { + tracing::warn!("Could not upgrade launcher config: {error}"); + crate::feedback::errors::push_message( + tracing::Level::ERROR, + format!("Could not upgrade config.toml: {error}"), + ); + } return Ok(MigrationScreenOutcome::NotNeeded); } - let config = crate::config::get_config_path().join("config.toml"); loop { let progress = Arc::new(Mutex::new(crate::layout_migration::MigrationProgress { From a25f7d20c8673c20a95b99acebadb5d8dff76686 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 23:53:57 +0200 Subject: [PATCH 34/42] fix(runtime): validate and repair launch caches --- src/instance/java.rs | 56 ++++++---- src/instance/launch/mod.rs | 73 ++++++------- src/instance/loader/forge.rs | 34 ++++-- src/instance/loader/mod.rs | 50 ++++++++- src/instance/loader/neoforge.rs | 34 ++++-- src/instance/manager.rs | 135 ++++++++++++++---------- src/instance/tests/launch/pipeline.rs | 5 +- src/instance/tests/loader/installers.rs | 45 ++++++++ 8 files changed, 301 insertions(+), 131 deletions(-) diff --git a/src/instance/java.rs b/src/instance/java.rs index 8b139d1..3d57f8d 100644 --- a/src/instance/java.rs +++ b/src/instance/java.rs @@ -17,17 +17,6 @@ pub struct JavaInstallation { pub version: Option, } -impl JavaInstallation { - #[must_use] - pub fn label(&self) -> String { - let version = self - .version - .as_deref() - .map_or_else(|| "Java".to_owned(), |version| format!("Java {version}")); - format!("{version} {}", self.path.display()) - } -} - #[must_use] pub fn detect_java_path() -> String { if let Ok(java_home) = std::env::var("JAVA_HOME") { @@ -216,8 +205,11 @@ fn add_java_home(home: &Path, candidates: &mut Vec) { } } -fn java_version(path: &Path) -> Option { +pub(crate) fn java_version(path: &Path) -> Option { let output = Command::new(path).arg("-version").output().ok()?; + if !output.status.success() { + return None; + } let text = format!( "{}{}", String::from_utf8_lossy(&output.stderr), @@ -234,15 +226,37 @@ fn parse_java_version(output: &str) -> Option { }) } -fn java_major(version: Option<&str>) -> u32 { +pub(crate) fn java_major(version: Option<&str>) -> u32 { let Some(version) = version else { return 0; }; - let mut parts = version.split(['.', '_']); - match (parts.next(), parts.next()) { - (Some("1"), Some(major)) => major.parse().unwrap_or(0), - (Some(major), _) => major.parse().unwrap_or(0), - _ => 0, + let parts = version + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse::().ok()) + .collect::>(); + match parts.as_slice() { + [1, legacy_major, ..] => *legacy_major, + [major, ..] => *major, + [] => 0, + } +} + +pub(crate) fn parse_java_major_version(output: &str) -> Option { + if let Some(version) = parse_java_version(output) { + return Some(java_major(Some(&version))).filter(|major| *major > 0); + } + + let start = output.find(|character: char| character.is_ascii_digit())?; + let parts = output[start..] + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse::().ok()) + .collect::>(); + match parts.as_slice() { + [1, legacy_major, ..] => Some(*legacy_major), + [major, ..] => Some(*major), + [] => None, } } @@ -262,6 +276,12 @@ mod tests { ); assert_eq!(java_major(Some("21.0.4")), 21); assert_eq!(java_major(Some("1.8.0_412")), 8); + assert_eq!(java_major(Some("21-ea")), 21); + assert_eq!( + parse_java_major_version("openjdk version \"21-ea\" 2026-03-17"), + Some(21) + ); + assert_eq!(parse_java_major_version("openjdk 17"), Some(17)); } #[test] diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index f7f80ff..1fef4bd 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -81,44 +81,34 @@ fn apply_window_mode(game_args: &mut Vec, window_mode: WindowMode) { } } -fn parse_java_major_version(text: &str) -> Option { - let quoted = text - .split_once('"') - .and_then(|(_, rest)| rest.split_once('"').map(|(version, _)| version)); - - let token = quoted.or_else(|| { - let start = text.find(|c: char| c.is_ascii_digit())?; - Some(&text[start..]) - })?; - - let parts: Vec = token - .split(|c: char| !c.is_ascii_digit()) - .filter(|part| !part.is_empty()) - .filter_map(|part| part.parse::().ok()) - .collect(); - - match parts.as_slice() { - [1, legacy_major, ..] => Some(*legacy_major), - [major, ..] => Some(*major), - [] => None, - } -} - async fn check_java_version(java: &str, required: Option) -> Result<(), LaunchError> { let Some(required) = required.filter(|major| *major > 0) else { return Ok(()); }; - let output = tokio::process::Command::new(java) - .arg("-version") - .output() + let mut command = tokio::process::Command::new(java); + command.arg("-version").kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(10), command.output()) .await + .map_err(|_| LaunchError::JavaCheckFailed { + java: java.to_owned(), + required, + reason: "version check timed out".to_owned(), + })? .map_err(|e| LaunchError::JavaCheckFailed { java: java.to_owned(), required, reason: e.to_string(), })?; + if !output.status.success() { + return Err(LaunchError::JavaCheckFailed { + java: java.to_owned(), + required, + reason: format!("`java -version` exited with {}", output.status), + }); + } + let version_text = format!( "{}{}", String::from_utf8_lossy(&output.stdout), @@ -126,10 +116,12 @@ async fn check_java_version(java: &str, required: Option) -> Result<(), Lau ); let detected = - parse_java_major_version(&version_text).ok_or_else(|| LaunchError::JavaCheckFailed { - java: java.to_owned(), - required, - reason: format!("could not parse `java -version` output: {version_text:?}"), + crate::instance::java::parse_java_major_version(&version_text).ok_or_else(|| { + LaunchError::JavaCheckFailed { + java: java.to_owned(), + required, + reason: format!("could not parse `java -version` output: {version_text:?}"), + } })?; if detected < required { @@ -193,10 +185,15 @@ async fn migrate_legacy_meta_if_needed( } }; - tokio::fs::write(meta_path, &raw).await?; - let refreshed: LaunchProfile = serde_json::from_slice(&raw) .map_err(|e| LaunchError::Parse(format!("Failed to parse refreshed meta: {e}")))?; + if refreshed.arguments.is_none() && refreshed.minecraft_arguments.is_none() { + tracing::warn!( + "Refetched metadata for {game_version} still has no launch arguments; keeping the cached profile" + ); + return Ok(None); + } + crate::storage::write_atomic(meta_path, &raw)?; Ok(Some(refreshed)) } @@ -296,11 +293,10 @@ async fn migrate_legacy_loader_profile_if_needed( ); let raw = tokio::fs::read(&installer_json_path).await?; - tokio::fs::write(profile_path, &raw).await?; - let refreshed: LaunchProfile = serde_json::from_slice(&raw).map_err(|e| { LaunchError::Parse(format!("Failed to parse refreshed loader profile: {e}")) })?; + crate::storage::write_atomic(profile_path, &raw)?; Ok(Some(refreshed)) } @@ -434,13 +430,8 @@ pub async fn build_launch_invocation( let lib_dir = metadata_paths.libraries(); let lv = config.loader_version.as_deref().unwrap_or("unknown"); - let profile_filename = match config.loader { - ModLoader::Vanilla => None, - ModLoader::Fabric => Some(format!("fabric-{}-{}.json", config.game_version, lv)), - ModLoader::Quilt => Some(format!("quilt-{}-{}.json", config.game_version, lv)), - ModLoader::Forge => Some(format!("forge-{}-{}.json", config.game_version, lv)), - ModLoader::NeoForge => Some(format!("neoforge-{}.json", lv)), - }; + let profile_filename = + crate::instance::loader::profile_filename(config.loader, &config.game_version, lv); // load the loader profile (if any), migrate from the old stripped format // if needed, and resolve `inheritsFrom` against the vanilla parent (which diff --git a/src/instance/loader/forge.rs b/src/instance/loader/forge.rs index d72a436..44b9559 100644 --- a/src/instance/loader/forge.rs +++ b/src/instance/loader/forge.rs @@ -48,6 +48,26 @@ impl ModLoaderInstaller for ForgeInstaller { loader_version: &str, instance_dir: &Path, meta_dir: &Path, + ) -> Result<(), InstallError> { + self.install_with_java( + client, + game_version, + loader_version, + instance_dir, + meta_dir, + None, + ) + .await + } + + async fn install_with_java( + &self, + client: &HttpClient, + game_version: &str, + loader_version: &str, + instance_dir: &Path, + meta_dir: &Path, + java_path: Option<&str>, ) -> Result<(), InstallError> { let installer_jar = instance_dir .join(crate::storage::MINECRAFT_DIR_NAME) @@ -76,12 +96,14 @@ impl ModLoaderInstaller for ForgeInstaller { } } else { // modern forge: run the java installer - let java_path = crate::config::SETTINGS - .read() - .paths - .effective_java_path() - .map(str::to_owned) - .unwrap_or_else(crate::instance::java::detect_java_path); + let java_path = java_path.map(str::to_owned).unwrap_or_else(|| { + crate::config::SETTINGS + .read() + .paths + .effective_java_path() + .map(str::to_owned) + .unwrap_or_else(crate::instance::java::detect_java_path) + }); tracing::debug!("Running Forge installer with Java {}", java_path); if let Err(e) = run_forge_installer(&installer_jar, instance_dir, &java_path).await { let _ = tokio::fs::remove_file(&installer_jar).await; diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index 62bab98..8c52a6c 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -67,6 +67,20 @@ pub trait ModLoaderInstaller: Send + Sync { instance_dir: &Path, meta_dir: &Path, ) -> Result<(), InstallError>; + + async fn install_with_java( + &self, + client: &HttpClient, + game_version: &str, + loader_version: &str, + instance_dir: &Path, + meta_dir: &Path, + java_path: Option<&str>, + ) -> Result<(), InstallError> { + let _ = java_path; + self.install(client, game_version, loader_version, instance_dir, meta_dir) + .await + } } // writes raw profile JSON bytes to meta_dir/loader-profiles/. @@ -80,7 +94,21 @@ pub(crate) fn save_profile_bytes( ) -> std::io::Result<()> { let profiles_dir = crate::storage::MetadataPaths::new(meta_dir).loader_profiles(); std::fs::create_dir_all(&profiles_dir)?; - std::fs::write(profiles_dir.join(filename), bytes) + crate::storage::write_atomic(&profiles_dir.join(filename), bytes) +} + +pub(crate) fn profile_filename( + loader: ModLoader, + game_version: &str, + loader_version: &str, +) -> Option { + match loader { + ModLoader::Vanilla => None, + ModLoader::Fabric => Some(format!("fabric-{game_version}-{loader_version}.json")), + ModLoader::Quilt => Some(format!("quilt-{game_version}-{loader_version}.json")), + ModLoader::Forge => Some(format!("forge-{game_version}-{loader_version}.json")), + ModLoader::NeoForge => Some(format!("neoforge-{loader_version}.json")), + } } // used by forge/neoforge. their java installer drops a version json into @@ -118,11 +146,29 @@ pub(crate) fn save_installer_profile( ver_json_path.display() ); let raw = std::fs::read(&ver_json_path)?; + let profile: crate::launch_profile::model::LaunchProfile = serde_json::from_slice(&raw) + .map_err(|error| { + InstallerError::Profile(format!( + "Invalid installer profile {}: {error}", + ver_json_path.display() + )) + })?; + if profile.id.trim().is_empty() + || profile + .main_class + .as_deref() + .is_none_or(|main_class| main_class.trim().is_empty()) + { + return Err(InstallerError::Profile(format!( + "Installer profile {} is missing id or mainClass", + ver_json_path.display() + ))); + } let profiles_dir = crate::storage::MetadataPaths::new(meta_dir).loader_profiles(); std::fs::create_dir_all(&profiles_dir)?; let profile_path = profiles_dir.join(profile_filename); - std::fs::write(&profile_path, &raw)?; + crate::storage::write_atomic(&profile_path, &raw)?; tracing::debug!( "Saved installer profile to {} ({} bytes)", profile_path.display(), diff --git a/src/instance/loader/neoforge.rs b/src/instance/loader/neoforge.rs index 221128a..8692423 100644 --- a/src/instance/loader/neoforge.rs +++ b/src/instance/loader/neoforge.rs @@ -41,12 +41,32 @@ impl ModLoaderInstaller for NeoForgeInstaller { } async fn install( + &self, + client: &HttpClient, + game_version: &str, + loader_version: &str, + instance_dir: &Path, + meta_dir: &Path, + ) -> Result<(), InstallError> { + self.install_with_java( + client, + game_version, + loader_version, + instance_dir, + meta_dir, + None, + ) + .await + } + + async fn install_with_java( &self, client: &HttpClient, _game_version: &str, loader_version: &str, instance_dir: &Path, meta_dir: &Path, + java_path: Option<&str>, ) -> Result<(), InstallError> { let installer_jar = instance_dir .join(crate::storage::MINECRAFT_DIR_NAME) @@ -56,12 +76,14 @@ impl ModLoaderInstaller for NeoForgeInstaller { neoforge_api::download_neoforge_installer(client, loader_version, &installer_jar).await?; - let java_path = crate::config::SETTINGS - .read() - .paths - .effective_java_path() - .map(str::to_owned) - .unwrap_or_else(crate::instance::java::detect_java_path); + let java_path = java_path.map(str::to_owned).unwrap_or_else(|| { + crate::config::SETTINGS + .read() + .paths + .effective_java_path() + .map(str::to_owned) + .unwrap_or_else(crate::instance::java::detect_java_path) + }); tracing::debug!("Running NeoForge installer with Java {}", java_path); if let Err(e) = run_neoforge_installer(&installer_jar, instance_dir, &java_path).await { let _ = tokio::fs::remove_file(&installer_jar).await; diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 4822aba..cd2f182 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -206,24 +206,8 @@ impl InstanceManager { .versions() .join(game_version) .join("meta.json"); - if let Some(parent) = meta_json_path.parent() - && let Err(e) = std::fs::create_dir_all(parent) - { - tracing::warn!( - "Failed to ensure meta dir {} exists: {}", - parent.display(), - e - ); - } - if let Err(e) = std::fs::write(&meta_json_path, &raw_meta_bytes) { - tracing::warn!( - "Failed to save version meta {}: {}", - meta_json_path.display(), - e - ); - } else { - tracing::debug!("Saved version meta to {}", meta_json_path.display()); - } + crate::storage::write_atomic(&meta_json_path, &raw_meta_bytes)?; + tracing::debug!("Saved version meta to {}", meta_json_path.display()); crate::net::mojang::download_libraries(&self.client, &version_meta, &self.meta_dir).await?; @@ -315,15 +299,40 @@ impl InstanceManager { .versions() .join(&config.game_version) .join("meta.json"); - let (version_meta, raw_meta) = if meta_path.exists() { - let raw = std::fs::read(&meta_path)?; - let parsed = serde_json::from_slice(&raw).map_err(|error| { - InstanceError::InvalidName(format!( - "Cached metadata for Minecraft {} is invalid: {error}", - config.game_version - )) - })?; - (parsed, raw) + let cached_meta = match std::fs::read(&meta_path) { + Ok(raw) => match serde_json::from_slice(&raw) { + Ok(parsed) + if serde_json::from_slice::(&raw) + .ok() + .is_some_and(|value| { + value.as_object().is_some_and(|object| { + object.contains_key("arguments") + || object.contains_key("minecraftArguments") + }) + }) => + { + Some((parsed, raw)) + } + Ok(_) => { + tracing::warn!( + "Cached metadata for Minecraft {} is incomplete; downloading it again", + config.game_version + ); + None + } + Err(error) => { + tracing::warn!( + "Cached metadata for Minecraft {} is invalid; downloading it again: {error}", + config.game_version + ); + None + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + let (version_meta, raw_meta, fetched_meta) = if let Some((parsed, raw)) = cached_meta { + (parsed, raw, false) } else { let manifest = crate::net::mojang::fetch_version_manifest(&self.client).await?; let version_entry = manifest @@ -336,14 +345,17 @@ impl InstanceManager { config.game_version )) })?; - crate::net::mojang::fetch_version_meta_with_raw(&self.client, version_entry).await? + let (parsed, raw) = + crate::net::mojang::fetch_version_meta_with_raw(&self.client, version_entry) + .await?; + (parsed, raw, true) }; crate::net::mojang::download_client_jar(&self.client, &version_meta, &self.meta_dir) .await?; if let Some(parent) = meta_path.parent() { std::fs::create_dir_all(parent)?; } - if !meta_path.exists() { + if fetched_meta { crate::storage::write_atomic(&meta_path, &raw_meta)?; } crate::net::mojang::download_libraries(&self.client, &version_meta, &self.meta_dir).await?; @@ -356,34 +368,43 @@ impl InstanceManager { config.name, config.loader )) })?; - let profile_name = match config.loader { - ModLoader::Fabric => { - format!("fabric-{}-{loader_version}.json", config.game_version) - } - ModLoader::Quilt => { - format!("quilt-{}-{loader_version}.json", config.game_version) - } - ModLoader::Forge => { - format!("forge-{}-{loader_version}.json", config.game_version) - } - ModLoader::NeoForge => format!("neoforge-{loader_version}.json"), - ModLoader::Vanilla => unreachable!(), - }; - if !metadata_paths.loader_profiles().join(profile_name).exists() { - task.set_sub_action(format!("{} {}", config.loader, loader_version)); - crate::instance::loader::get_installer(config.loader) - .install( - &self.client, - &config.game_version, - loader_version, - &self.instances_dir.join(&config.name), - &self.meta_dir, - ) - .await - .map_err(|error| match error { - InstallError::Download(error) => InstanceError::Download(error), - InstallError::Installer(error) => InstanceError::InstallerError(error), - })?; + task.set_sub_action(format!("{} {}", config.loader, loader_version)); + crate::instance::loader::get_installer(config.loader) + .install_with_java( + &self.client, + &config.game_version, + loader_version, + &self.instances_dir.join(&config.name), + &self.meta_dir, + config.java_path.as_deref(), + ) + .await + .map_err(|error| match error { + InstallError::Download(error) => InstanceError::Download(error), + InstallError::Installer(error) => InstanceError::InstallerError(error), + })?; + let profile_filename = crate::instance::loader::profile_filename( + config.loader, + &config.game_version, + loader_version, + ) + .expect("non-vanilla loaders have profile filenames"); + let profile_path = metadata_paths.loader_profiles().join(profile_filename); + let profile: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(&std::fs::read(&profile_path)?)?; + if profile.id.trim().is_empty() + || profile + .main_class + .as_deref() + .is_none_or(|main_class| main_class.trim().is_empty()) + { + return Err(InstanceError::InstallerError(InstallerError::Profile( + format!( + "Installed {} profile {} is missing id or mainClass", + config.loader, + profile_path.display() + ), + ))); } } task.finish(); diff --git a/src/instance/tests/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs index 08e6d82..1017018 100644 --- a/src/instance/tests/launch/pipeline.rs +++ b/src/instance/tests/launch/pipeline.rs @@ -12,7 +12,10 @@ fn parse_java_major_version_handles_common_outputs( #[case] output: &str, #[case] expected: Option, ) { - assert_eq!(parse_java_major_version(output), expected); + assert_eq!( + crate::instance::java::parse_java_major_version(output), + expected + ); } #[test] diff --git a/src/instance/tests/loader/installers.rs b/src/instance/tests/loader/installers.rs index 013be9e..9ba0fde 100644 --- a/src/instance/tests/loader/installers.rs +++ b/src/instance/tests/loader/installers.rs @@ -66,6 +66,51 @@ fn save_installer_profile_copies_raw_bytes_verbatim() { ); } +#[test] +fn save_installer_profile_rejects_invalid_json_without_overwriting_cache() { + let tmp = tempfile::tempdir().unwrap(); + let instance_dir = tmp.path().join("instance"); + let meta_dir = tmp.path().join("meta"); + let version_name = "1.20.1-forge-broken"; + let version_dir = instance_dir + .join(crate::storage::MINECRAFT_DIR_NAME) + .join("versions") + .join(version_name); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::write( + version_dir.join(format!("{version_name}.json")), + b"not json", + ) + .unwrap(); + let cached = crate::storage::MetadataPaths::new(&meta_dir) + .loader_profiles() + .join("forge-broken.json"); + std::fs::create_dir_all(cached.parent().unwrap()).unwrap(); + std::fs::write(&cached, b"previous").unwrap(); + + assert!( + save_installer_profile(&instance_dir, &meta_dir, version_name, "forge-broken.json") + .is_err() + ); + assert_eq!(std::fs::read(cached).unwrap(), b"previous"); +} + +#[rstest::rstest] +#[case(ModLoader::Vanilla, None)] +#[case(ModLoader::Fabric, Some("fabric-1.21.1-1.0.json"))] +#[case(ModLoader::Quilt, Some("quilt-1.21.1-1.0.json"))] +#[case(ModLoader::Forge, Some("forge-1.21.1-1.0.json"))] +#[case(ModLoader::NeoForge, Some("neoforge-1.0.json"))] +fn profile_filenames_match_launch_cache_names( + #[case] loader: ModLoader, + #[case] expected: Option<&str>, +) { + assert_eq!( + profile_filename(loader, "1.21.1", "1.0").as_deref(), + expected + ); +} + // shape-pinning test: a synthetic versionInfo from a 1.7.10 forge // install_profile.json must deserialise as a LaunchProfile so the // launch flow's render_args + resolve pipeline can consume it. no From 9a408769c5b174e059c3ec591e9558602de9c7b9 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 23:54:03 +0200 Subject: [PATCH 35/42] fix(instance): harden config and shortcut persistence --- src/instance/desktop.rs | 88 +++++++++++++++++++++++++++-------- src/instance/manager.rs | 43 ++++++++++------- src/instance/tests/desktop.rs | 13 ++++++ src/instance/tests/manager.rs | 32 +++++++++++++ 4 files changed, 141 insertions(+), 35 deletions(-) diff --git a/src/instance/desktop.rs b/src/instance/desktop.rs index 21bd615..1846758 100644 --- a/src/instance/desktop.rs +++ b/src/instance/desktop.rs @@ -45,7 +45,7 @@ pub fn icon_path() -> Option { // lazily writes the bundled svg icon to disk the first time a shortcut needs it fn ensure_icon() -> Option { let path = icon_path()?; - if path.exists() { + if std::fs::read(&path).ok().as_deref() == Some(ICON_BYTES) { return Some(path); } let parent = path.parent()?; @@ -53,7 +53,7 @@ fn ensure_icon() -> Option { tracing::warn!("Failed to create icon directory: {}", e); return None; } - if let Err(e) = std::fs::write(&path, ICON_BYTES) { + if let Err(e) = crate::storage::write_atomic(&path, ICON_BYTES) { tracing::warn!("Failed to write bundled icon: {}", e); return None; } @@ -74,7 +74,9 @@ pub fn create(config: &InstanceConfig) -> std::io::Result { let icon = ensure_icon(); let content = build_content(&config.name, icon.as_deref()); - std::fs::write(&path, content)?; + if std::fs::read_to_string(&path).ok().as_deref() != Some(content.as_str()) { + crate::storage::write_atomic(&path, content.as_bytes())?; + } #[cfg(unix)] { @@ -96,22 +98,28 @@ pub fn remove(name: &str) -> std::io::Result<()> { Ok(()) } -pub fn toggle(config: &InstanceConfig) -> std::io::Result { - if exists(&config.name) { - remove(&config.name)?; - Ok(false) +pub fn set_enabled(config: &InstanceConfig, enabled: bool) -> std::io::Result<()> { + if enabled { + create(config).map(|_| ()) } else { - create(config)?; - Ok(true) + remove(&config.name) } } +pub fn toggle(config: &InstanceConfig) -> std::io::Result { + let enabled = !exists(&config.name); + set_enabled(config, enabled)?; + Ok(enabled) +} + pub fn rename(old_name: &str, new_config: &InstanceConfig) -> std::io::Result<()> { - if !exists(old_name) { + let Some(old_path) = desktop_path(old_name).filter(|path| path.exists()) else { return Ok(()); - } - remove(old_name)?; + }; create(new_config)?; + if desktop_path(&new_config.name).as_ref() != Some(&old_path) { + std::fs::remove_file(old_path)?; + } Ok(()) } @@ -148,7 +156,10 @@ fn build_linux_desktop(name: &str, icon: Option<&Path>) -> String { out.push_str("Type=Application\n"); out.push_str(&format!("Name=Minecraft - {name}\n")); out.push_str(&format!("Comment=Launch {name} Minecraft instance\n")); - out.push_str(&format!("Exec=rmcl instance launch \"{name}\"\n")); + out.push_str(&format!( + "Exec=rmcl instance launch {}\n", + quote_desktop_exec_arg(name) + )); if let Some(icon) = icon { out.push_str(&format!("Icon={}\n", icon.display())); } @@ -159,14 +170,12 @@ fn build_linux_desktop(name: &str, icon: Option<&Path>) -> String { #[cfg(target_os = "windows")] fn build_windows_shortcut(name: &str) -> String { - let escaped_name = name.replace('"', "\"\""); + let command = format!("rmcl instance launch {}", quote_windows_arg(name)); + let escaped_command = command.replace('"', "\"\""); let mut out = String::new(); out.push_str("Set shell = CreateObject(\"WScript.Shell\")\r\n"); - out.push_str(&format!( - "shell.Run \"rmcl instance launch \"\"{}\"\"\", 0, False\r\n", - escaped_name - )); + out.push_str(&format!("shell.Run \"{escaped_command}\", 0, False\r\n")); out } @@ -175,10 +184,51 @@ fn build_macos_command(name: &str) -> String { let mut out = String::new(); out.push_str("#!/bin/bash\n"); out.push_str(&format!("# Launch Minecraft instance: {name}\n")); - out.push_str(&format!("rmcl instance launch \"{name}\"\n")); + out.push_str(&format!("rmcl instance launch {}\n", quote_shell_arg(name))); out } +fn quote_desktop_exec_arg(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + for character in value.chars() { + if matches!(character, '"' | '`' | '$' | '\\') { + escaped.push('\\'); + } + escaped.push(character); + } + escaped.push('"'); + escaped +} + +#[cfg(any(target_os = "macos", test))] +fn quote_shell_arg(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(any(target_os = "windows", test))] +fn quote_windows_arg(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('"'); + let mut backslashes = 0; + for character in value.chars() { + if character == '\\' { + backslashes += 1; + } else if character == '"' { + quoted.extend(std::iter::repeat_n('\\', backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } else { + quoted.extend(std::iter::repeat_n('\\', backslashes)); + quoted.push(character); + backslashes = 0; + } + } + quoted.extend(std::iter::repeat_n('\\', backslashes * 2)); + quoted.push('"'); + quoted +} + // replaces anything that isn't alphanumeric, dash, or underscore with _ fn sanitize(name: &str) -> String { name.chars() diff --git a/src/instance/manager.rs b/src/instance/manager.rs index cd2f182..482928d 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -412,6 +412,7 @@ impl InstanceManager { } pub fn delete(&self, name: &str) -> Result<(), InstanceError> { + validate_name(name)?; let instance_dir = self.instances_dir.join(name); if !instance_dir.exists() { tracing::warn!( @@ -437,6 +438,7 @@ impl InstanceManager { } pub fn rename(&self, old_name: &str, new_name: &str) -> Result<(), InstanceError> { + validate_name(old_name)?; let new_name = new_name.trim(); if new_name.is_empty() { tracing::warn!("Cannot rename instance '{}': new name is empty", old_name); @@ -471,6 +473,9 @@ impl InstanceManager { ); return Err(InstanceError::AlreadyExists(new_name.to_string())); } + let mut config = self.load_one(old_name)?; + config.name = new_name.to_owned(); + let json = serde_json::to_vec_pretty(&config)?; tracing::info!("Renaming instance '{}' to '{}'", old_name, new_name); if let Err(e) = std::fs::rename(&old_dir, &new_dir) { tracing::error!( @@ -483,21 +488,19 @@ impl InstanceManager { } let config_path = new_dir.join("instance.json"); - if let Ok(data) = std::fs::read_to_string(&config_path) - && let Ok(mut config) = serde_json::from_str::(&data) - { - config.name = new_name.to_string(); - if let Ok(json) = serde_json::to_string_pretty(&config) { - let _ = std::fs::write(&config_path, json); - } - if let Err(e) = crate::instance::desktop::rename(old_name, &config) { - tracing::warn!("Failed to rename desktop shortcut: {}", e); + if let Err(error) = crate::storage::write_atomic(&config_path, &json) { + if let Err(rollback_error) = std::fs::rename(&new_dir, &old_dir) { + tracing::error!( + "Failed to roll back instance rename from {} to {}: {}", + new_dir.display(), + old_dir.display(), + rollback_error + ); } - } else { - tracing::warn!( - "Renamed instance directory but could not update config at {}", - config_path.display() - ); + return Err(error.into()); + } + if let Err(e) = crate::instance::desktop::rename(old_name, &config) { + tracing::warn!("Failed to rename desktop shortcut: {}", e); } Ok(()) @@ -589,10 +592,14 @@ impl InstanceManager { } pub fn save(&self, instance: &InstanceConfig) -> Result<(), InstanceError> { + validate_name(&instance.name)?; let instance_dir = self.instances_dir.join(&instance.name); + if !instance_dir.is_dir() { + return Err(InstanceError::NotFound(instance.name.clone())); + } let config_path = instance_dir.join("instance.json"); let json = serde_json::to_string_pretty(instance)?; - std::fs::write(&config_path, &json)?; + crate::storage::write_atomic(&config_path, json.as_bytes())?; tracing::debug!( "Saved instance '{}' config to {}", instance.name, @@ -617,7 +624,11 @@ fn validate_name(name: &str) -> Result<(), InstanceError> { name ))); } - if name.contains('/') || name.contains('\\') || name.starts_with('.') { + if name.contains('/') + || name.contains('\\') + || name.starts_with('.') + || name.chars().any(char::is_control) + { return Err(InstanceError::InvalidName(format!( "Name contains invalid characters: {:?}", name diff --git a/src/instance/tests/desktop.rs b/src/instance/tests/desktop.rs index 7c8924b..dd9b89c 100644 --- a/src/instance/tests/desktop.rs +++ b/src/instance/tests/desktop.rs @@ -31,3 +31,16 @@ fn build_content_linux_with_icon() { let content = build_content("TestPack", Some(&icon)); assert!(content.contains("Icon=/tmp/icon.png")); } + +#[test] +fn shortcut_arguments_escape_platform_metacharacters() { + assert_eq!( + quote_desktop_exec_arg("Pack \"$HOME`\\"), + "\"Pack \\\"\\$HOME\\`\\\\\"" + ); + assert_eq!(quote_shell_arg("Pack 'quoted'"), "'Pack '\\''quoted'\\'''"); + assert_eq!( + quote_windows_arg("Pack \\\"quoted"), + "\"Pack \\\\\\\"quoted\"" + ); +} diff --git a/src/instance/tests/manager.rs b/src/instance/tests/manager.rs index 389a1db..16c8216 100644 --- a/src/instance/tests/manager.rs +++ b/src/instance/tests/manager.rs @@ -52,6 +52,7 @@ fn validate_name_rejects_empty_traversal_and_hidden() { assert!(validate_name("").is_err()); assert!(validate_name("path/traversal").is_err()); assert!(validate_name(".hidden").is_err()); + assert!(validate_name("line\nbreak").is_err()); } #[test] @@ -61,6 +62,23 @@ fn delete_missing_instance_returns_not_found() { assert!(matches!(result, Err(InstanceError::NotFound(_)))); } +#[test] +fn delete_rejects_path_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let instances = tmp.path().join("instances"); + let meta = tmp.path().join("meta"); + std::fs::create_dir_all(&instances).unwrap(); + std::fs::create_dir_all(&meta).unwrap(); + let manager = InstanceManager::new(instances, meta); + let outside = tmp.path().join("outside-instance"); + std::fs::create_dir_all(&outside).unwrap(); + + let result = manager.delete("../outside-instance"); + + assert!(matches!(result, Err(InstanceError::InvalidName(_)))); + assert!(outside.exists()); +} + #[test] fn save_then_load_all_round_trips_config() { let (manager, tmp) = test_manager(); @@ -169,6 +187,20 @@ fn rename_target_exists_errors() { assert!(matches!(err, InstanceError::AlreadyExists(_))); } +#[test] +fn rename_with_invalid_source_is_rejected() { + let (manager, _tmp) = test_manager(); + let err = manager.rename("../outside", "safe-name").unwrap_err(); + assert!(matches!(err, InstanceError::InvalidName(_))); +} + +#[test] +fn save_rejects_invalid_instance_name() { + let (manager, _tmp) = test_manager(); + let err = manager.save(&dummy_config("../outside")).unwrap_err(); + assert!(matches!(err, InstanceError::InvalidName(_))); +} + #[test] fn touch_last_played_updates_field() { let (manager, tmp) = test_manager(); From ccc09419fb3f1b7bda2442f1bc554c5b47bffa2e Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 23:54:26 +0200 Subject: [PATCH 36/42] fix(settings): normalize persisted values --- src/config/settings.rs | 15 +++++-------- src/instance/models.rs | 34 +++++++++++++++++++++++++---- src/instance/tests/models.rs | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 3da5741..aca8387 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -60,15 +60,6 @@ pub enum ContentProvider { CurseForge, } -impl ContentProvider { - pub fn as_str(self) -> &'static str { - match self { - Self::Modrinth => "modrinth", - Self::CurseForge => "curseforge", - } - } -} - impl fmt::Display for ContentProvider { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -379,7 +370,11 @@ impl Config { .memory_max .clone_from(&self.defaults.memory_min); } - if self.defaults.resolution.is_none() { + if self + .defaults + .resolution + .is_none_or(|(width, height)| width == 0 || height == 0) + { self.defaults.resolution = default_resolution(); } self.ui.error_auto_dismiss_ms = self.ui.error_auto_dismiss_ms.max(1); diff --git a/src/instance/models.rs b/src/instance/models.rs index 5abd1bf..323721a 100644 --- a/src/instance/models.rs +++ b/src/instance/models.rs @@ -77,7 +77,7 @@ pub struct InstanceConfig { pub created: DateTime, #[serde(default)] pub last_played: Option>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")] pub java_path: Option, #[serde(default, deserialize_with = "deserialize_optional_memory")] pub memory_max: Option, @@ -91,17 +91,25 @@ pub struct InstanceConfig { pub window_mode: WindowMode, #[serde(default, skip_serializing_if = "is_false")] pub inherit_window_mode: bool, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_resolution")] pub resolution: Option<(u32, u32)>, #[serde(default, skip_serializing_if = "is_false")] pub inherit_resolution: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_optional_non_empty_string", + skip_serializing_if = "Option::is_none" + )] pub preferred_account: Option, #[serde(default, skip_serializing_if = "LaunchCommand::is_default")] pub pre_launch_command: LaunchCommand, #[serde(default, skip_serializing_if = "LaunchCommand::is_default")] pub post_exit_command: LaunchCommand, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_optional_non_empty_string", + skip_serializing_if = "Option::is_none" + )] pub glfw_path: Option, #[serde(default)] pub config_sync_profile: Option, @@ -131,6 +139,24 @@ fn is_false(value: &bool) -> bool { !value } +fn deserialize_optional_resolution<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::<(u32, u32)>::deserialize(deserializer)? + .filter(|(width, height)| *width > 0 && *height > 0)) +} + +fn deserialize_optional_non_empty_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .and_then(|value| (!value.trim().is_empty()).then(|| value.trim().to_owned()))) +} + pub fn parse_resolution(input: &str) -> Result<(u32, u32), String> { let (width, height) = input .trim() diff --git a/src/instance/tests/models.rs b/src/instance/tests/models.rs index b8c88e2..77d17e8 100644 --- a/src/instance/tests/models.rs +++ b/src/instance/tests/models.rs @@ -135,6 +135,48 @@ fn instance_config_ignores_invalid_memory_values() { assert_eq!(parsed.memory_min, None); } +#[test] +fn instance_config_normalizes_optional_paths_and_resolution() { + let json = r#" + { + "name": "test", + "game_version": "1.21.1", + "loader": "vanilla", + "loader_version": null, + "created": "2026-04-20T18:04:25.567993893Z", + "java_path": " ", + "glfw_path": " /usr/lib/libglfw.so ", + "preferred_account": "", + "resolution": [0, 1080] + } + "#; + + let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); + + assert!(parsed.java_path.is_none()); + assert_eq!(parsed.glfw_path.as_deref(), Some("/usr/lib/libglfw.so")); + assert!(parsed.preferred_account.is_none()); + assert!(parsed.resolution.is_none()); +} + +#[test] +fn instance_config_rejects_zero_resolution_height() { + let json = r#" + { + "name": "test", + "game_version": "1.21.1", + "loader": "vanilla", + "loader_version": null, + "created": "2026-04-20T18:04:25.567993893Z", + "resolution": [1920, 0] + } + "#; + + let parsed: InstanceConfig = serde_json::from_str(json).expect("deserialize"); + + assert!(parsed.resolution.is_none()); +} + #[test] fn normalize_memory_value_rejects_invalid_values() { assert_eq!(normalize_memory_value("0"), None); From 31edc0dbf83b0f4cd5489e10b8a003763f0705b6 Mon Sep 17 00:00:00 2001 From: objz Date: Fri, 4 Sep 2026 23:54:38 +0200 Subject: [PATCH 37/42] fix(settings): keep editor updates consistent --- src/instance/content/reconcile.rs | 12 +- src/instance/import/refresh.rs | 4 +- src/instance/mod.rs | 4 +- src/tui/app.rs | 3 + src/tui/event.rs | 177 +++++++----- src/tui/input.rs | 144 +++++++--- src/tui/tests/event.rs | 65 ++++- src/tui/tests/harness.rs | 8 + src/tui/widgets/popups/global_settings.rs | 114 +++++--- src/tui/widgets/popups/instance_settings.rs | 147 +++++++--- src/tui/widgets/popups/new_instance/mod.rs | 1 - src/tui/widgets/popups/settings_controls.rs | 287 ++++++++++++++++++-- src/tui/widgets/settings.rs | 25 +- 13 files changed, 743 insertions(+), 248 deletions(-) diff --git a/src/instance/content/reconcile.rs b/src/instance/content/reconcile.rs index 8406e97..3c9da05 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -151,11 +151,13 @@ async fn reconcile(job: ReconcileJob, task: &ProgressTask) -> ReconcileResult { let paths = InstancePaths::new(instances_dir.join(&instance_name)); let manifest_path = paths.content_manifest(); let minecraft_dir = paths.minecraft(); - let retry_hours = crate::config::SETTINGS.read().content.unmatched_retry_hours; - let max_fingerprint_size_mib = crate::config::SETTINGS - .read() - .content - .max_fingerprint_size_mib; + let (retry_hours, max_fingerprint_size_mib) = { + let settings = crate::config::SETTINGS.read(); + ( + settings.content.unmatched_retry_hours, + settings.content.max_fingerprint_size_mib, + ) + }; let inventory_progress = task.handle(); let inventory_minecraft_dir = minecraft_dir.clone(); let inventory = tokio::task::spawn_blocking(move || { diff --git a/src/instance/import/refresh.rs b/src/instance/import/refresh.rs index ebf0ce0..bb08b58 100644 --- a/src/instance/import/refresh.rs +++ b/src/instance/import/refresh.rs @@ -55,9 +55,7 @@ pub async fn prepare( instance: &InstanceConfig, target: VersionInfo, ) -> Result { - if crate::instance::runtime::get(&instance.name) - .is_some_and(|state| !matches!(state, crate::instance::runtime::RunState::Crashed(_))) - { + if crate::instance::runtime::is_active(&instance.name) { return Err("Stop the instance before changing its modpack".to_owned()); } let source = instance diff --git a/src/instance/mod.rs b/src/instance/mod.rs index 86a2b69..0265d4f 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -27,6 +27,4 @@ pub use content::{ pub use launch::LaunchError; pub use loader::{GameVersion, ModLoaderInstaller, VanillaInstaller, get_installer}; pub use manager::{InstanceError, InstanceManager}; -pub use models::{ - InstanceConfig, LaunchCommand, ModLoader, WindowMode, memory_kib, normalize_memory_value, -}; +pub use models::{InstanceConfig, ModLoader, WindowMode, normalize_memory_value}; diff --git a/src/tui/app.rs b/src/tui/app.rs index 4a91950..fd7d7e8 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -19,8 +19,11 @@ use crate::instance::{InstanceConfig, InstanceManager}; // so the main loop can pick them up without blocking pub(super) static PENDING_INSTANCES: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); +pub(super) static COMPLETED_INSTANCE_SETTINGS_UPDATES: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); pub(super) static FAILED_INSTANCE_SETTINGS_UPDATES: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(Vec::new()))); +pub(super) const RUNTIME_UPDATE_PENDING_MESSAGE: &str = "Wait for the runtime update to finish"; pub struct App { pub(super) exit: bool, diff --git a/src/tui/event.rs b/src/tui/event.rs index c61df29..5f50973 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -10,7 +10,10 @@ use ratatui::{ use std::time::Duration; use super::Tui; -use super::app::{App, FAILED_INSTANCE_SETTINGS_UPDATES, FocusedArea, PENDING_INSTANCES}; +use super::app::{ + App, COMPLETED_INSTANCE_SETTINGS_UPDATES, FAILED_INSTANCE_SETTINGS_UPDATES, FocusedArea, + PENDING_INSTANCES, RUNTIME_UPDATE_PENDING_MESSAGE, +}; use super::widgets::{self, popups::import_modpack, popups::new_instance}; use crate::feedback::errors as error_buffer; use crate::feedback::progress; @@ -43,6 +46,7 @@ impl App { // every content type has its own pending queue because they each // get scanned/loaded on separate tokio tasks self.drain_pending_instances(); + self.drain_completed_instance_settings_updates(); self.drain_failed_instance_settings_updates(); self.instances_state.drain_modpack_updates(); self.drain_pending_last_played(); @@ -619,7 +623,7 @@ impl App { ) { let instances_dir = self.instance_manager.instances_dir.clone(); let meta_dir = self.instance_manager.meta_dir.clone(); - let pending_instances = PENDING_INSTANCES.clone(); + let completed_updates = COMPLETED_INSTANCE_SETTINGS_UPDATES.clone(); tokio::spawn(async move { progress::set_action(format!("Updating instance '{}'...", updated.name)); @@ -642,11 +646,7 @@ impl App { } }; - let shortcut_result = if desktop { - crate::instance::desktop::create(&updated).map(|_| ()) - } else { - crate::instance::desktop::remove(&updated.name) - }; + let shortcut_result = crate::instance::desktop::set_enabled(&updated, desktop); if let Err(error) = shortcut_result { error_buffer::push_error(error_buffer::ErrorEvent { id: 0, @@ -655,7 +655,7 @@ impl App { pushed_at: std::time::Instant::now(), }); } - if let Ok(mut pending) = pending_instances.lock() { + if let Ok(mut pending) = completed_updates.lock() { pending.push(updated); } progress::clear(); @@ -834,6 +834,14 @@ impl App { use crate::instance::launch; use crate::instance::runtime; + if self + .pending_instance_settings_updates + .contains(&instance.name) + { + error_buffer::push_message(tracing::Level::WARN, RUNTIME_UPDATE_PENDING_MESSAGE); + return; + } + let instance = match self.instance_manager.load_one(&instance.name) { Ok(config) => config, Err(e) => { @@ -895,42 +903,73 @@ impl App { } fn drain_pending_instances(&mut self) { - if let Ok(mut pending) = PENDING_INSTANCES.lock() { - for config in pending.drain(..) { - let settings_update = self.pending_instance_settings_updates.remove(&config.name); - if settings_update - && let Some(state) = self.instance_settings.as_mut() - && state.runtime_update_pending_for(&config.name) - { - let desktop = crate::instance::desktop::exists(&config.name); - state.mark_saved(&config, desktop); - } - self.forget_instance_content(&config.name); - widgets::instances::spawn_modpack_update_check(&config); - if self - .instances_state - .instances - .iter() - .any(|instance| instance.name == config.name) - { - let name = config.name.clone(); - self.instances_state.replace_instance(&name, config); - } else { - self.instances_state.add_instance(config); - } + let pending = PENDING_INSTANCES + .lock() + .map(|mut pending| pending.drain(..).collect::>()) + .unwrap_or_default(); + for config in pending { + self.apply_pending_instance(config, false); + } + } + + fn drain_completed_instance_settings_updates(&mut self) { + let completed = COMPLETED_INSTANCE_SETTINGS_UPDATES + .lock() + .map(|mut pending| pending.drain(..).collect::>()) + .unwrap_or_default(); + for config in completed { + self.apply_pending_instance(config, true); + } + } + + fn apply_pending_instance( + &mut self, + config: crate::instance::InstanceConfig, + settings_update: bool, + ) { + let config = self + .instance_manager + .load_one(&config.name) + .unwrap_or(config); + if settings_update { + self.pending_instance_settings_updates.remove(&config.name); + if let Some(state) = self.instance_settings.as_mut() + && state.runtime_update_pending_for(&config.name) + { + let desktop = crate::instance::desktop::exists(&config.name); + state.mark_saved(&config, desktop); } } + self.forget_instance_content(&config.name); + widgets::instances::spawn_modpack_update_check(&config); + if self + .instances_state + .instances + .iter() + .any(|instance| instance.name == config.name) + { + let name = config.name.clone(); + self.instances_state.replace_instance(&name, config); + } else { + self.instances_state.add_instance(config); + } } fn drain_failed_instance_settings_updates(&mut self) { - if let Ok(mut failed) = FAILED_INSTANCE_SETTINGS_UPDATES.lock() { - for name in failed.drain(..) { - self.pending_instance_settings_updates.remove(&name); - if let Some(state) = self.instance_settings.as_mut() - && state.runtime_update_pending_for(&name) - { - state.cancel_runtime_change(); - } + let failed = FAILED_INSTANCE_SETTINGS_UPDATES + .lock() + .map(|mut failed| failed.drain(..).collect::>()) + .unwrap_or_default(); + for name in failed { + self.pending_instance_settings_updates.remove(&name); + let remaining = self.instance_settings.as_mut().and_then(|state| { + state + .runtime_update_pending_for(&name) + .then(|| state.cancel_runtime_change()) + .flatten() + }); + if let Some((updated, desktop)) = remaining { + self.apply_instance_settings(*updated, desktop); } } } @@ -987,35 +1026,49 @@ async fn apply_instance_settings_update( ) -> color_eyre::Result { manager.repair_runtime_cache(&updated).await?; - let profile_changed = previous.config_sync_profile != updated.config_sync_profile; - if profile_changed { - updated.config_sync_profile = crate::instance::config_sync::switch_profile( - &previous.name, - previous.config_sync_profile.as_deref(), - updated.config_sync_profile.as_deref(), - &manager.meta_dir, - &manager.instances_dir.join(&previous.name), - )?; + if crate::instance::runtime::is_active(&previous.name) { + color_eyre::eyre::bail!("instance started while its runtime was being updated"); } - if let Err(error) = manager.save(&updated) { - if profile_changed - && let Err(rollback_error) = crate::instance::config_sync::switch_profile( - &previous.name, - updated.config_sync_profile.as_deref(), - previous.config_sync_profile.as_deref(), - &manager.meta_dir, - &manager.instances_dir.join(&previous.name), - ) - { - tracing::error!("Failed to roll back config profile: {rollback_error}"); - } - return Err(error.into()); - } + let current = manager.load_one(&previous.name)?; + updated = merge_instance_settings(previous, &updated, current); + manager.save(&updated)?; Ok(updated) } +pub(super) fn merge_instance_settings( + previous: &crate::instance::InstanceConfig, + updated: &crate::instance::InstanceConfig, + mut current: crate::instance::InstanceConfig, +) -> crate::instance::InstanceConfig { + macro_rules! apply_changed { + ($field:ident) => { + if previous.$field != updated.$field { + current.$field.clone_from(&updated.$field); + } + }; + } + + apply_changed!(game_version); + apply_changed!(loader); + apply_changed!(loader_version); + apply_changed!(java_path); + apply_changed!(memory_max); + apply_changed!(memory_min); + apply_changed!(jvm_args); + apply_changed!(environment); + apply_changed!(window_mode); + apply_changed!(inherit_window_mode); + apply_changed!(resolution); + apply_changed!(inherit_resolution); + apply_changed!(preferred_account); + apply_changed!(pre_launch_command); + apply_changed!(post_exit_command); + apply_changed!(glfw_path); + current +} + fn mark_terminal_images(buffer: &mut Buffer, alternate: bool) { // toggling an invisible suffix lets the normal cell diff redraw exposed // images before later popup cells, without clearing or repainting the screen diff --git a/src/tui/input.rs b/src/tui/input.rs index 8ff6635..ae47789 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -217,8 +217,9 @@ impl App { if instance.preferred_account.as_deref() == Some(removed_uuid.as_str()) { - instance.preferred_account = None; - if let Err(error) = self.instance_manager.save(instance) { + let mut updated = instance.clone(); + updated.preferred_account = None; + if let Err(error) = self.instance_manager.save(&updated) { error_buffer::push_message( tracing::Level::ERROR, format!( @@ -226,6 +227,8 @@ impl App { instance.name ), ); + } else { + *instance = updated; } } } @@ -301,6 +304,12 @@ impl App { match crate::storage::clear_disposable_caches(&meta_dir) { Ok(()) => { self.reset_discovery_states(); + if let Some(state) = self.global_settings.as_mut() { + state.invalidate_java_cache(); + } + if let Some(state) = self.instance_settings.as_mut() { + state.invalidate_java_cache(); + } error_buffer::push_message( tracing::Level::INFO, "Cleared launcher caches", @@ -348,11 +357,9 @@ impl App { ); if let Some(widgets::popups::global_settings::Action::Save( config, - theme, - border, )) = action { - self.apply_global_settings(*config, theme, border); + self.apply_global_settings(*config); } } } @@ -375,8 +382,12 @@ impl App { FocusedArea::Settings } Some(confirm_popup::ConfirmTarget::InstanceRuntime { .. }) => { - if let Some(state) = self.instance_settings.as_mut() { - state.cancel_runtime_change(); + let remaining = self + .instance_settings + .as_mut() + .and_then(|state| state.cancel_runtime_change()); + if let Some((updated, desktop)) = remaining { + self.apply_instance_settings(*updated, desktop); } FocusedArea::InstanceSettings } @@ -695,6 +706,16 @@ impl App { } widgets::settings::SettingsAction::SelectProfile(profile) => { if let Some(instance) = self.instances_state.selected_instance().cloned() { + if self + .pending_instance_settings_updates + .contains(&instance.name) + { + error_buffer::push_message( + tracing::Level::WARN, + super::app::RUNTIME_UPDATE_PENDING_MESSAGE, + ); + return Ok(()); + } let instance_dir = self.instance_manager.instances_dir.join(&instance.name); match crate::instance::config_sync::switch_profile( &instance.name, @@ -784,8 +805,8 @@ impl App { self.global_settings = None; self.focused = self.pre_overlay_focused; } - widgets::popups::global_settings::Action::Save(config, theme, border) => { - self.apply_global_settings(*config, theme, border); + widgets::popups::global_settings::Action::Save(config) => { + self.apply_global_settings(*config); } } return Ok(()); @@ -995,8 +1016,15 @@ impl App { { if let Some(instance) = self.instances_state.selected_instance() { let name = instance.name.clone(); - confirm_popup::set_pending_instance_delete(&name); - self.focused = FocusedArea::ConfirmDelete; + if self.pending_instance_settings_updates.contains(&name) { + error_buffer::push_message( + tracing::Level::WARN, + super::app::RUNTIME_UPDATE_PENDING_MESSAGE, + ); + } else { + confirm_popup::set_pending_instance_delete(&name); + self.focused = FocusedArea::ConfirmDelete; + } } } // shift+enter = open .minecraft folder in file manager @@ -1050,7 +1078,14 @@ impl App { && !self.instances_state.search.active => { if let Some(inst) = self.instances_state.selected_instance() { - self.instances_state.renaming = Some(inst.name.clone()); + if self.pending_instance_settings_updates.contains(&inst.name) { + error_buffer::push_message( + tracing::Level::WARN, + super::app::RUNTIME_UPDATE_PENDING_MESSAGE, + ); + } else { + self.instances_state.renaming = Some(inst.name.clone()); + } } } // esc = kill running instance. brutal but effective @@ -1090,6 +1125,20 @@ impl App { } fn spawn_modpack_update(&mut self) { + if self + .instances_state + .selected_instance() + .is_some_and(|instance| { + self.pending_instance_settings_updates + .contains(&instance.name) + }) + { + error_buffer::push_message( + tracing::Level::WARN, + super::app::RUNTIME_UPDATE_PENDING_MESSAGE, + ); + return; + } let Some(target) = self.instances_state.selected_modpack_update() else { return; }; @@ -1100,6 +1149,16 @@ impl App { let Some(instance) = self.instances_state.selected_instance() else { return; }; + if self + .pending_instance_settings_updates + .contains(&instance.name) + { + error_buffer::push_message( + tracing::Level::WARN, + super::app::RUNTIME_UPDATE_PENDING_MESSAGE, + ); + return; + } let Some(source) = instance.modpack_source.clone() else { return; }; @@ -2163,19 +2222,10 @@ impl App { self.focused = FocusedArea::InstanceSettings; } - fn apply_global_settings( - &mut self, - config: crate::config::Config, - theme: String, - border: crate::config::theme::BorderStyle, - ) { + fn apply_global_settings(&mut self, config: crate::config::Config) { let result = crate::config::SETTINGS.save_launcher_settings(config); match result { Ok(outcome) => { - if let Err(error) = crate::config::theme::apply_theme(theme, border) { - error_buffer::push_message(tracing::Level::ERROR, error.to_string()); - return; - } if outcome.provider_changed { self.reset_discovery_states(); } @@ -2195,13 +2245,18 @@ impl App { } crate::feedback::request_redraw(); } - Err(error) => error_buffer::push_message(tracing::Level::ERROR, error.to_string()), + Err(error) => { + if let Some(state) = self.global_settings.as_mut() { + state.mark_save_failed(); + } + error_buffer::push_message(tracing::Level::ERROR, error.to_string()); + } } } - fn apply_instance_settings( + pub(super) fn apply_instance_settings( &mut self, - updated: crate::instance::models::InstanceConfig, + mut updated: crate::instance::models::InstanceConfig, desktop: bool, ) { let Some(previous) = self.instances_state.selected_instance().cloned() else { @@ -2219,7 +2274,10 @@ impl App { pushed_at: std::time::Instant::now(), }); if let Some(state) = self.instance_settings.as_mut() { - state.cancel_runtime_change(); + let remaining = state.cancel_runtime_change(); + if let Some((updated, desktop)) = remaining { + self.apply_instance_settings(*updated, desktop); + } } return; } @@ -2232,13 +2290,20 @@ impl App { return; } + let current = match self.instance_manager.load_one(&previous.name) { + Ok(current) => current, + Err(error) => { + if let Some(state) = self.instance_settings.as_mut() { + state.mark_save_failed(); + } + error_buffer::push_message(tracing::Level::ERROR, error.to_string()); + return; + } + }; + updated = super::event::merge_instance_settings(&previous, &updated, current); match self.instance_manager.save(&updated) { Ok(()) => { - let shortcut_result = if desktop { - crate::instance::desktop::create(&updated).map(|_| ()) - } else { - crate::instance::desktop::remove(&updated.name) - }; + let shortcut_result = crate::instance::desktop::set_enabled(&updated, desktop); let saved_desktop = match shortcut_result { Ok(()) => desktop, Err(error) => { @@ -2257,12 +2322,17 @@ impl App { state.mark_saved(&updated, saved_desktop); } } - Err(error) => error_buffer::push_error(error_buffer::ErrorEvent { - id: 0, - level: tracing::Level::ERROR, - message: error.to_string(), - pushed_at: std::time::Instant::now(), - }), + Err(error) => { + if let Some(state) = self.instance_settings.as_mut() { + state.mark_save_failed(); + } + error_buffer::push_error(error_buffer::ErrorEvent { + id: 0, + level: tracing::Level::ERROR, + message: error.to_string(), + pushed_at: std::time::Instant::now(), + }); + } } } diff --git a/src/tui/tests/event.rs b/src/tui/tests/event.rs index 88d1042..3465d3b 100644 --- a/src/tui/tests/event.rs +++ b/src/tui/tests/event.rs @@ -26,6 +26,26 @@ fn edited_instance_config_reloads_into_the_ui() { ); } +#[test] +fn runtime_update_merges_only_confirmed_fields_into_latest_config() { + let mut ui = UiHarness::new(); + ui.add_instance("Merge Runtime"); + let previous = ui.app.instances_state.selected_instance().unwrap().clone(); + let mut updated = previous.clone(); + updated.game_version = "1.21.2".to_owned(); + updated.memory_max = Some("8G".to_owned()); + let mut current = previous.clone(); + current.config_sync_profile = Some("shared".to_owned()); + current.preferred_account = Some("newer-account".to_owned()); + + let merged = merge_instance_settings(&previous, &updated, current); + + assert_eq!(merged.game_version, "1.21.2"); + assert_eq!(merged.memory_max.as_deref(), Some("8G")); + assert_eq!(merged.config_sync_profile.as_deref(), Some("shared")); + assert_eq!(merged.preferred_account.as_deref(), Some("newer-account")); +} + #[test] fn completed_background_instance_is_drained_into_the_ui() { let mut ui = UiHarness::new(); @@ -57,6 +77,42 @@ fn completed_background_instance_is_drained_into_the_ui() { assert_eq!(ui.app.mods_state.loaded_for.as_deref(), Some("Pending")); } +#[test] +fn unrelated_background_result_does_not_complete_runtime_update() { + let mut ui = UiHarness::new(); + ui.add_instance("Pending Runtime"); + ui.app + .pending_instance_settings_updates + .insert("Pending Runtime".to_owned()); + let config = ui.app.instances_state.selected_instance().unwrap().clone(); + PENDING_INSTANCES.lock().unwrap().push(config); + + ui.app.drain_pending_instances(); + + assert!( + ui.app + .pending_instance_settings_updates + .contains("Pending Runtime") + ); +} + +#[test] +fn pending_runtime_update_blocks_instance_rename() { + let mut ui = UiHarness::new(); + ui.add_instance("Pending Rename"); + ui.app + .pending_instance_settings_updates + .insert("Pending Rename".to_owned()); + + ui.key(crossterm::event::KeyCode::Char('r')); + + assert!(ui.app.instances_state.renaming.is_none()); + assert_eq!( + crate::feedback::errors::peek_error().map(|error| error.message), + Some(RUNTIME_UPDATE_PENDING_MESSAGE.to_owned()) + ); +} + #[test] fn runtime_settings_update_keeps_the_editor_open_and_handles_results() { let mut ui = UiHarness::new(); @@ -82,8 +138,12 @@ fn runtime_settings_update_keeps_the_editor_open_and_handles_results() { let mut updated = ui.app.instances_state.selected_instance().unwrap().clone(); updated.game_version = "1.21.2".to_owned(); - PENDING_INSTANCES.lock().unwrap().push(updated); - ui.app.drain_pending_instances(); + ui.app.instance_manager.save(&updated).unwrap(); + COMPLETED_INSTANCE_SETTINGS_UPDATES + .lock() + .unwrap() + .push(updated); + ui.app.drain_completed_instance_settings_updates(); let state = ui.app.instance_settings.as_ref().unwrap(); assert_eq!(state.draft.game_version, "1.21.2"); @@ -188,6 +248,7 @@ fn structural_settings_update_repairs_runtime_before_persisting() { serde_json::to_vec(&serde_json::json!({ "id": "1.21.2", "mainClass": "net.minecraft.client.main.Main", + "arguments": { "game": [], "jvm": [] }, "assetIndex": { "id": "1.21.2", "url": format!("{}/assets.json", server.uri()), diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 9f6311c..3f3753a 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -30,6 +30,14 @@ impl UiHarness { .lock() .unwrap_or_else(|error| error.into_inner()) .clear(); + crate::tui::app::COMPLETED_INSTANCE_SETTINGS_UPDATES + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + crate::tui::app::PENDING_INSTANCES + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); widgets::popups::new_instance::reset_for_test(); widgets::popups::import_modpack::reset_for_test(); crate::feedback::errors::ERROR_EVENTS diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 3d21a2a..7acfd2e 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -19,15 +19,15 @@ use crate::{ settings::{ContentProvider, DEFAULT_RESOLUTION, ImageProtocol}, theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, }, - instance::models::{WindowMode, normalize_memory_value, parse_resolution}, + instance::models::{memory_kib, normalize_memory_value, parse_resolution}, tui::widgets::popups::settings_controls::{ DisplayResolution, JavaChoice, JavaPicker, ResolutionChoice, ResolutionPickerAction, SettingsPicker, SettingsPickerAction, SettingsPickerOption, adjust_memory, auto_label, default_label, default_resolution, display_resolutions, environment_labels, - handle_resolution_picker_key, handle_text_area_input, is_default_resolution, memory_kib, - parse_environment, render_memory_gauge, render_settings_picker, resolution_choices, - resolution_items, settings_text_area, tagged_row_count, tagged_value_lines, - toggle_window_mode, + format_tag_values, handle_resolution_picker_key, handle_text_area_input, + is_default_resolution, parse_environment, parse_tag_values, render_memory_gauge, + render_settings_picker, resolution_choices, resolution_items, settings_text_area, + tagged_row_count, tagged_value_lines, toggle_window_mode, window_mode_title, }, tui::widgets::status_badge, }; @@ -64,7 +64,7 @@ pub struct State { pub enum Action { None, - Save(Box, String, BorderStyle), + Save(Box), Error(String), ConfirmJavaAuto, ClearCache, @@ -97,7 +97,7 @@ impl State { .java_installations(); let mut java_picker = JavaPicker::with_cache(crate::instance::java::detect_java_path(), Some(java_cache)); - java_picker.open(config.paths.java_path.as_deref()); + java_picker.set_current(config.paths.java_path.as_deref()); Self { config, theme, @@ -138,8 +138,8 @@ impl State { .resolution .map(|(width, height)| format!("{width}x{height}")) .unwrap_or_default(), - 8 => self.config.defaults.jvm_args.join(" "), - 9 => environment_labels(&self.config.defaults.environment).join(" "), + 8 => format_tag_values(&self.config.defaults.jvm_args), + 9 => format_tag_values(&environment_labels(&self.config.defaults.environment)), 10 => self.config.content.preferred_provider.to_string(), 11 => status(self.config.content.preferred_provider_only), 12 => status(self.config.content.ask_on_provider_conflict), @@ -226,11 +226,13 @@ impl State { } Err(error) => invalid(self, error), }, - 8 => { - self.config.defaults.jvm_args = - value.split_whitespace().map(str::to_owned).collect(); - self.save_pending = true; - } + 8 => match parse_tag_values(value) { + Ok(arguments) => { + self.config.defaults.jvm_args = arguments; + self.save_pending = true; + } + Err(error) => invalid(self, error), + }, 9 => match parse_environment(value) { Ok(environment) => { self.config.defaults.environment = environment; @@ -290,14 +292,20 @@ impl State { let previous = self.theme.theme.clone(); self.theme.theme = self.themes[self.theme_index].clone(); self.error = None; + if self.theme.theme == previous { + return; + } if let Err(error) = crate::config::theme::apply_theme( self.theme.theme.clone(), self.theme.border_style.clone(), ) { self.error = Some(error.to_string()); self.theme.theme = previous; - } else if self.theme.theme != previous { - self.save_pending = true; + self.theme_index = self + .themes + .iter() + .position(|candidate| candidate == &self.theme.theme) + .unwrap_or(0); } } @@ -317,7 +325,14 @@ impl State { fn handle_theme_picker_key(&mut self, key: &KeyEvent) { match key.code { - KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.theme_picker = false, + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => { + self.theme_picker = false; + self.theme_index = self + .themes + .iter() + .position(|candidate| candidate == &self.theme.theme) + .unwrap_or(0); + } KeyCode::Char('j') | KeyCode::Down => { self.theme_index = (self.theme_index + 1).min(self.themes.len() - 1); } @@ -349,8 +364,6 @@ impl State { ) { self.error = Some(error.to_string()); self.theme.border_style = previous; - } else if self.theme.border_style != previous { - self.save_pending = true; } } @@ -423,6 +436,7 @@ impl State { .resolution_choices() .get(self.choice_index) .and_then(|choice| choice.resolution()) + && self.config.defaults.resolution != Some(resolution) { self.config.defaults.resolution = Some(resolution); self.save_pending = true; @@ -443,15 +457,18 @@ impl State { return; }; if picker == ChoicePicker::ImageProtocol { - self.config.ui.image_protocol = match value.as_str() { + let protocol = match value.as_str() { "kitty" => ImageProtocol::Kitty, "iterm2" => ImageProtocol::Iterm2, "quadrants" => ImageProtocol::Quadrants, "halfblocks" => ImageProtocol::Halfblocks, _ => ImageProtocol::Kitty, }; + if self.config.ui.image_protocol != protocol { + self.config.ui.image_protocol = protocol; + self.save_pending = true; + } } - self.save_pending = true; self.choice_picker = None; } SettingsPickerAction::None => {} @@ -463,8 +480,11 @@ impl State { } fn apply_default_resolution(&mut self) { - self.config.defaults.resolution = Some(default_resolution()); - self.save_pending = true; + let resolution = Some(default_resolution()); + if self.config.defaults.resolution != resolution { + self.config.defaults.resolution = resolution; + self.save_pending = true; + } self.error = None; } @@ -521,11 +541,15 @@ impl State { pub fn confirm_auto_java(&mut self) -> Action { self.enable_auto_java(); self.save_pending = false; - Action::Save( - Box::new(self.config.clone()), - self.theme.theme.clone(), - self.theme.border_style.clone(), - ) + Action::Save(Box::new(self.config.clone())) + } + + pub fn mark_save_failed(&mut self) { + self.save_pending = true; + } + + pub fn invalidate_java_cache(&mut self) { + self.java_picker.invalidate_cache(); } fn handle_java_picker_key(&mut self, key: &KeyEvent) { @@ -548,6 +572,8 @@ impl State { } fn adjust_selected_memory(&mut self, forward: bool) { + let previous_min = self.config.defaults.memory_min.clone(); + let previous_max = self.config.defaults.memory_max.clone(); let value = if self.selected == 3 { &self.config.defaults.memory_min } else { @@ -565,7 +591,8 @@ impl State { self.config.defaults.memory_min = value; } } - self.save_pending = true; + self.save_pending |= self.config.defaults.memory_min != previous_min + || self.config.defaults.memory_max != previous_max; self.error = None; } @@ -604,11 +631,7 @@ impl State { if self.save_pending && self.editing.is_none() { self.save_pending = false; self.config = self.config.clone().normalize(); - return Action::Save( - Box::new(self.config.clone()), - self.theme.theme.clone(), - self.theme.border_style.clone(), - ); + return Action::Save(Box::new(self.config.clone())); } Action::None } @@ -1171,13 +1194,6 @@ fn image_protocol_title(protocol: ImageProtocol) -> &'static str { } } -fn window_mode_title(mode: WindowMode) -> &'static str { - match mode { - WindowMode::Windowed => "Windowed", - WindowMode::Fullscreen => "Fullscreen", - } -} - fn restart_required_for(state: &State, index: usize) -> bool { let current = crate::config::SETTINGS.read(); match index { @@ -1195,6 +1211,7 @@ fn status(enabled: bool) -> String { #[cfg(test)] mod tests { use super::*; + use crate::instance::models::WindowMode; #[test] fn launcher_memory_uses_slider_and_java_uses_picker() { @@ -1413,8 +1430,23 @@ mod tests { state.selected = 0; state.handle_key(&KeyEvent::from(KeyCode::Enter)); assert!(state.theme_picker); + let active_theme = state.theme.theme.clone(); + state.handle_key(&KeyEvent::from(KeyCode::Down)); state.handle_key(&KeyEvent::from(KeyCode::Esc)); assert!(!state.theme_picker); + assert_eq!(state.themes[state.theme_index], active_theme); + } + + #[test] + fn failed_launcher_save_remains_pending() { + let mut state = State::new(); + state.config.defaults.jvm_args.push("-Xretry".to_owned()); + state.mark_save_failed(); + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Down)), + Action::Save(..) + )); } #[test] diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index c4106fa..2eedc0a 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -22,7 +22,8 @@ use crate::{ }, instance::loader::GameVersion, instance::models::{ - InstanceConfig, LaunchCommand, ModLoader, normalize_memory_value, parse_resolution, + InstanceConfig, LaunchCommand, ModLoader, memory_kib, normalize_memory_value, + parse_resolution, }, tui::widgets::{ popups::{ @@ -32,11 +33,11 @@ use crate::{ ResolutionChoice, ResolutionPickerAction, SettingsPicker, SettingsPickerAction, SettingsPickerBadge, SettingsPickerOption, adjust_memory, auto_label, bundled_glfw_version, bundled_label as bundled_badge, default_label, - default_resolution, display_resolutions, environment_labels, + default_resolution, display_resolutions, environment_labels, format_tag_values, handle_resolution_picker_key, handle_text_area_input, is_default_resolution, - memory_kib, parse_environment, render_memory_gauge, render_settings_picker, + parse_environment, parse_tag_values, render_memory_gauge, render_settings_picker, resolution_choices, resolution_items, settings_text_area, tagged_row_count, - tagged_value_lines, toggle_window_mode, + tagged_value_lines, toggle_window_mode, window_mode_title, }, }, search::SearchState, @@ -95,6 +96,7 @@ pub struct State { meta_dir: std::path::PathBuf, display_resolutions: Vec, runtime_update_pending: bool, + save_retry_pending: bool, } pub enum Action { @@ -131,14 +133,15 @@ impl State { .unwrap_or_else(crate::instance::java::detect_java_path); let java_cache = crate::storage::MetadataPaths::new(meta_dir).java_installations(); let mut java_picker = JavaPicker::with_cache(auto_java_path, Some(java_cache)); - java_picker.open(instance.java_path.as_deref()); + java_picker.set_current(instance.java_path.as_deref()); + let desktop = crate::instance::desktop::exists(&instance.name); Self { original: instance.clone(), draft: instance.clone(), selected: 0, editing: None, - desktop: crate::instance::desktop::exists(&instance.name), - original_desktop: crate::instance::desktop::exists(&instance.name), + desktop, + original_desktop: desktop, error: None, picker: None, picker_index: 0, @@ -159,6 +162,7 @@ impl State { meta_dir: meta_dir.to_path_buf(), display_resolutions: display_resolutions(), runtime_update_pending: false, + save_retry_pending: false, } } @@ -221,18 +225,13 @@ impl State { 3 => self.draft.java_path.clone().unwrap_or_default(), 4 => self.draft.memory_min.clone().unwrap_or_default(), 5 => self.draft.memory_max.clone().unwrap_or_default(), - 6 => self.draft.jvm_args.join(" "), - 7 => self - .draft - .environment - .iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::>() - .join(" "), - 8 => self - .draft - .effective_window_mode(SETTINGS.read().defaults.window_mode) - .to_string(), + 6 => format_tag_values(&self.draft.jvm_args), + 7 => format_tag_values(&environment_labels(&self.draft.environment)), + 8 => window_mode_title( + self.draft + .effective_window_mode(SETTINGS.read().defaults.window_mode), + ) + .to_owned(), 9 => self .draft .effective_resolution(SETTINGS.read().defaults.resolution) @@ -494,7 +493,7 @@ impl State { let count = self.choice_values().len(); match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => self.choice_picker = None, - KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { + KeyCode::Char('j') | KeyCode::Down if count > 0 => { self.choice_index = (self.choice_index + 1).min(count - 1); } KeyCode::Char('k') | KeyCode::Up => { @@ -526,8 +525,7 @@ impl State { let selected = self .resolution_choices() .get(self.choice_index) - .cloned() - .and_then(|choice| choice.resolution()); + .and_then(ResolutionChoice::resolution); if let Some(resolution) = selected { self.draft.resolution = Some(resolution); self.draft.inherit_resolution = false; @@ -564,22 +562,26 @@ impl State { *load = LoadState::Loading; let target = self.game_versions.clone(); let loader = self.draft.loader; - tokio::spawn(async move { - let result = super::version_lists::game_versions(loader).await; - *target - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = match result { - Ok(versions) => LoadState::Loaded(versions), - Err(error) => { - crate::feedback::errors::push_message( - tracing::Level::ERROR, - format!("Failed to load game versions: {error}"), - ); - LoadState::Error(error) - } - }; - crate::feedback::request_redraw(); - }); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let result = super::version_lists::game_versions(loader).await; + *target + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = match result { + Ok(versions) => LoadState::Loaded(versions), + Err(error) => { + crate::feedback::errors::push_message( + tracing::Level::ERROR, + format!("Failed to load game versions: {error}"), + ); + LoadState::Error(error) + } + }; + crate::feedback::request_redraw(); + }); + } else { + *load = LoadState::Idle; + } } } @@ -830,7 +832,7 @@ impl State { self.picker = None; self.picker_search.deactivate(); if cancel_runtime_change { - self.cancel_runtime_change(); + let _ = self.cancel_runtime_change(); } } @@ -932,9 +934,10 @@ impl State { ), 4 => self.set_memory(4, normalize_memory_value(value)), 5 => self.set_memory(5, normalize_memory_value(value)), - 6 => { - self.draft.jvm_args = value.split_whitespace().map(str::to_owned).collect(); - } + 6 => match parse_tag_values(value) { + Ok(arguments) => self.draft.jvm_args = arguments, + Err(error) => invalid(self, error), + }, 7 => match parse_environment(value) { Ok(environment) => self.draft.environment = environment, Err(error) => invalid(self, error), @@ -980,7 +983,9 @@ impl State { if let Some(error) = self.error.take() { return Action::Error(error); } - if before == self.draft && desktop_before == self.desktop || !self.dirty() { + if (before == self.draft && desktop_before == self.desktop && !self.save_retry_pending) + || !self.dirty() + { return Action::None; } if self.runtime_changed() { @@ -1005,6 +1010,7 @@ impl State { }; } if self.validate_before_save() { + self.save_retry_pending = false; Action::Save(Box::new(self.draft.clone()), self.desktop) } else { Action::Error( @@ -1092,10 +1098,19 @@ impl State { self.glfw_picker .set_bundled_version(bundled_glfw_version(&self.meta_dir, &saved.game_version)); self.runtime_update_pending = false; + self.save_retry_pending = false; self.error = None; } - pub fn cancel_runtime_change(&mut self) { + pub fn mark_save_failed(&mut self) { + self.save_retry_pending = true; + } + + pub fn invalidate_java_cache(&mut self) { + self.java_picker.invalidate_cache(); + } + + pub fn cancel_runtime_change(&mut self) -> Option<(Box, bool)> { self.draft.game_version = self.original.game_version.clone(); self.draft.loader = self.original.loader; self.draft.loader_version = self.original.loader_version.clone(); @@ -1104,6 +1119,8 @@ impl State { self.picker_initialized = false; self.runtime_update_pending = false; self.error = None; + (self.dirty() && self.validate_before_save()) + .then(|| (Box::new(self.draft.clone()), self.desktop)) } } @@ -1964,6 +1981,46 @@ mod tests { assert!(state.editing.is_some()); } + #[test] + fn right_arrow_selects_loader_without_moving_down() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.selected = 1; + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.choice_picker, Some(ChoicePicker::Loader)); + + state.handle_key(&KeyEvent::from(KeyCode::Right)); + + assert!(state.choice_picker.is_none()); + assert_eq!(state.draft.loader, ModLoader::Fabric); + } + + #[test] + fn failed_instance_save_retries_without_another_edit() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.draft.jvm_args.push("-Xretry".to_owned()); + state.mark_save_failed(); + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Down)), + Action::Save(..) + )); + } + + #[test] + fn cancelling_runtime_change_keeps_other_edits_ready_to_save() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + state.draft.game_version = "1.21.2".to_owned(); + state.draft.jvm_args.push("-Xkeep".to_owned()); + + let (saved, _) = state.cancel_runtime_change().expect("remaining edit"); + + assert_eq!(saved.game_version, state.original.game_version); + assert_eq!(saved.jvm_args, ["-Xkeep"]); + } + #[test] fn java_and_resolution_defaults_are_direct_actions_not_list_rows() { let temp = tempfile::tempdir().unwrap(); @@ -2093,7 +2150,7 @@ mod tests { assert!(state.draft.inherit_window_mode); assert_eq!( state.display_value(8), - SETTINGS.read().defaults.window_mode.to_string() + window_mode_title(SETTINGS.read().defaults.window_mode) ); state.selected = 9; diff --git a/src/tui/widgets/popups/new_instance/mod.rs b/src/tui/widgets/popups/new_instance/mod.rs index d77a911..97f7535 100644 --- a/src/tui/widgets/popups/new_instance/mod.rs +++ b/src/tui/widgets/popups/new_instance/mod.rs @@ -4,7 +4,6 @@ mod render; mod state; -pub use super::LoadState; pub use render::{popup_rect, render}; pub use state::{WizardParams, WizardState, WizardStep, handle_key, take_result}; diff --git a/src/tui/widgets/popups/settings_controls.rs b/src/tui/widgets/popups/settings_controls.rs index 66f83b3..3d0b798 100644 --- a/src/tui/widgets/popups/settings_controls.rs +++ b/src/tui/widgets/popups/settings_controls.rs @@ -6,7 +6,10 @@ use std::{ collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -21,7 +24,10 @@ use ratatui_textarea::{CursorMove, TextArea}; use crate::{ config::{settings::DEFAULT_RESOLUTION, theme::THEME}, - instance::{WindowMode, java::JavaInstallation}, + instance::{ + java::JavaInstallation, + models::{WindowMode, memory_kib}, + }, tui::widgets::{popups::LoadState, status_badge}, }; @@ -36,6 +42,13 @@ pub(crate) fn toggle_window_mode(mode: WindowMode) -> WindowMode { } } +pub(crate) fn window_mode_title(mode: WindowMode) -> &'static str { + match mode { + WindowMode::Windowed => "Windowed", + WindowMode::Fullscreen => "Fullscreen", + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingsPickerBadge { Auto, @@ -192,6 +205,7 @@ pub(crate) struct JavaPicker { picker: SettingsPicker, cache_path: Option, refresh_started: bool, + generation: Arc, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -250,7 +264,7 @@ pub(crate) fn handle_resolution_picker_key( match key.code { KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => ResolutionPickerAction::Back, KeyCode::Char('d') => ResolutionPickerAction::Default, - KeyCode::Char('j') | KeyCode::Down | KeyCode::Right if count > 0 => { + KeyCode::Char('j') | KeyCode::Down if count > 0 => { *selected = (*selected + 1).min(count - 1); ResolutionPickerAction::None } @@ -302,12 +316,17 @@ impl JavaPicker { picker: SettingsPicker::default(), cache_path, refresh_started: false, + generation: Arc::new(AtomicU64::new(0)), } } - pub(crate) fn open(&mut self, current: Option<&str>) { + pub(crate) fn set_current(&mut self, current: Option<&str>) { self.current = current.map(str::to_owned); self.picker.reset(); + } + + pub(crate) fn open(&mut self, current: Option<&str>) { + self.set_current(current); if self.refresh_started { return; } @@ -322,6 +341,8 @@ impl JavaPicker { drop(load); let target = self.load.clone(); let cache_path = self.cache_path.clone(); + let refresh_generation = self.generation.load(Ordering::Relaxed); + let generation = self.generation.clone(); let selected_paths = [Some(self.detected.clone()), self.current.clone()]; let discover = move || { let mut installations = crate::instance::java::discover_installations(); @@ -337,22 +358,33 @@ impl JavaPicker { installations.push(installation); } } + let mut load = target + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if generation.load(Ordering::Relaxed) != refresh_generation { + return; + } if let Some(cache_path) = cache_path && let Err(error) = crate::instance::java::save_installation_cache(&cache_path, &installations) { tracing::debug!("Could not cache Java installations: {error}"); } - *target - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - LoadState::Loaded(installations); + *load = LoadState::Loaded(installations); + drop(load); crate::feedback::request_redraw(); }; if let Ok(runtime) = tokio::runtime::Handle::try_current() { runtime.spawn_blocking(discover); } else { self.refresh_started = false; + let mut load = self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if matches!(*load, LoadState::Loading) { + *load = LoadState::Idle; + } } } @@ -493,6 +525,20 @@ impl JavaPicker { pub(crate) fn automatic_change(&self, current: &str) -> bool { !same_executable(current, &self.detected) } + + pub(crate) fn invalidate_cache(&mut self) { + self.generation.fetch_add(1, Ordering::Relaxed); + let mut load = self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(cache_path) = &self.cache_path { + let _ = std::fs::remove_file(cache_path); + } + *load = LoadState::Idle; + self.picker.reset(); + self.refresh_started = false; + } } impl Default for JavaPicker { @@ -542,7 +588,7 @@ impl GlfwPicker { *self .load .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = LoadState::Loaded(Vec::new()); + .unwrap_or_else(std::sync::PoisonError::into_inner) = LoadState::Idle; } } @@ -607,7 +653,7 @@ impl GlfwPicker { { LoadState::Loaded(installations) => installations .iter() - .find(|installation| installation.path.to_string_lossy() == path) + .find(|installation| same_executable(&installation.path.to_string_lossy(), path)) .and_then(|installation| installation.version.clone()), _ => None, }; @@ -771,11 +817,25 @@ fn discover_glfw_installations() -> Vec { } fn is_glfw_library(path: &Path) -> bool { + if !path.is_file() { + return false; + } let Some(name) = path.file_name().and_then(|name| name.to_str()) else { return false; }; + is_glfw_library_name(name) +} + +fn is_glfw_library_name(name: &str) -> bool { let name = name.to_ascii_lowercase(); - name.starts_with("libglfw") && (name.contains(".so") || name.ends_with(".dylib")) + let versioned_so = name.strip_prefix("libglfw.so.").is_some_and(|version| { + version.split('.').all(|part| { + !part.is_empty() && part.chars().all(|character| character.is_ascii_digit()) + }) + }); + name == "libglfw.so" + || versioned_so + || name.starts_with("libglfw.") && name.ends_with(".dylib") || name.starts_with("glfw") && name.ends_with(".dll") } @@ -812,18 +872,12 @@ fn java_runtime_label(path: &str, version: Option<&str>) -> String { } fn java_title(version: &str) -> String { - let mut parts = version.split(['.', '_']); - let first = parts.next().unwrap_or(version); - let major = if first == "1" { - parts.next().unwrap_or(first) + let major = crate::instance::java::java_major(Some(version)); + if major == 0 { + "Java".to_owned() } else { - first - }; - format!("Java {major}") -} - -pub(crate) fn memory_kib(value: &str) -> Option { - crate::instance::models::memory_kib(value) + format!("Java {major}") + } } pub(crate) fn adjust_memory(value: &str, forward: bool) -> String { @@ -926,6 +980,10 @@ pub(crate) fn handle_text_area_input(input: &mut TextArea<'_>, key: &KeyEvent) { pub(crate) fn settings_text_area(lines: Vec) -> TextArea<'static> { let theme = THEME.as_ref(); + let lines = lines + .into_iter() + .flat_map(|line| line.split('\n').map(str::to_owned).collect::>()) + .collect::>(); let mut editor = TextArea::new(if lines.is_empty() { vec![String::new()] } else { @@ -939,9 +997,79 @@ pub(crate) fn settings_text_area(lines: Vec) -> TextArea<'static> { editor } +pub(crate) fn parse_tag_values(input: &str) -> Result, String> { + #[derive(Clone, Copy, PartialEq, Eq)] + enum Quote { + None, + Single, + Double, + } + + let mut values = Vec::new(); + let mut current = String::new(); + let mut quote = Quote::None; + let mut started = false; + let mut characters = input.chars().peekable(); + while let Some(character) = characters.next() { + match (quote, character) { + (Quote::None, character) if character.is_whitespace() => { + if started { + values.push(std::mem::take(&mut current)); + started = false; + } + } + (Quote::None, '\'') => { + quote = Quote::Single; + started = true; + } + (Quote::None, '"') => { + quote = Quote::Double; + started = true; + } + (Quote::Single, '\'') => quote = Quote::None, + (Quote::Double, '"') => quote = Quote::None, + (Quote::Double, '\\') if matches!(characters.peek(), Some('"' | '\\')) => { + current.push(characters.next().unwrap_or_default()); + started = true; + } + (_, character) => { + current.push(character); + started = true; + } + } + } + if quote != Quote::None { + return Err("Quoted value is missing its closing quote.".to_owned()); + } + if started { + values.push(current); + } + let mut seen = BTreeSet::new(); + values.retain(|value| seen.insert(value.clone())); + Ok(values) +} + +pub(crate) fn format_tag_values(values: &[String]) -> String { + values + .iter() + .map(|value| { + if !value.is_empty() + && !value + .chars() + .any(|character| character.is_whitespace() || matches!(character, '\'' | '"')) + { + value.clone() + } else { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) + } + }) + .collect::>() + .join(" ") +} + pub(crate) fn parse_environment(value: &str) -> Result, String> { let mut environment = BTreeMap::new(); - for assignment in value.split_whitespace() { + for assignment in parse_tag_values(value)? { let Some((key, value)) = assignment.split_once('=') else { return Err(format!( "Environment variable '{assignment}' must use KEY=value." @@ -1099,7 +1227,7 @@ pub(crate) fn resolution_choices( let mut choices = Vec::new(); choices.extend(displays.iter().cloned().map(ResolutionChoice::Display)); for (width, height) in [ - (854, 480), + DEFAULT_RESOLUTION, (1280, 720), (1600, 900), (1920, 1080), @@ -1229,6 +1357,52 @@ mod tests { ); } + #[test] + fn setting_java_value_does_not_start_discovery() { + let mut picker = JavaPicker::with_auto_path("/auto/java".to_owned()); + + picker.set_current(Some("/custom/java")); + + assert!(!picker.refresh_started); + assert!(matches!( + *picker + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + LoadState::Idle + )); + + picker.open(Some("/custom/java")); + assert!(!picker.refresh_started); + assert!(matches!( + *picker + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + LoadState::Idle + )); + } + + #[test] + fn invalidating_java_picker_drops_memory_and_disk_cache() { + let temp = tempfile::tempdir().unwrap(); + let cache = temp.path().join("cache/java/installations.json"); + std::fs::create_dir_all(cache.parent().unwrap()).unwrap(); + std::fs::write(&cache, "[]").unwrap(); + let mut picker = JavaPicker::with_cache("/auto/java".to_owned(), Some(cache.clone())); + *picker.load.lock().unwrap() = LoadState::Loaded(vec![JavaInstallation { + path: "/cached/java".into(), + version: Some("21".to_owned()), + }]); + picker.refresh_started = true; + + picker.invalidate_cache(); + + assert!(!cache.exists()); + assert!(!picker.refresh_started); + assert!(matches!(*picker.load.lock().unwrap(), LoadState::Idle)); + } + #[test] fn java_title_is_minimal_and_uses_the_major_version() { let picker = JavaPicker::with_auto_path("/opt/jdk-25/bin/java".to_owned()); @@ -1245,6 +1419,7 @@ mod tests { "Java 25 /opt/jdk-25/bin/java" ); assert_eq!(java_title("1.8.0_412"), "Java 8"); + assert_eq!(java_title("21-ea"), "Java 21"); } #[test] @@ -1282,6 +1457,20 @@ mod tests { picker.handle_key(&KeyEvent::from(KeyCode::Enter)), SettingsPickerAction::Select ); + assert_eq!( + picker.handle_key(&KeyEvent::from(KeyCode::Right)), + SettingsPickerAction::Select + ); + } + + #[test] + fn right_arrow_selects_resolution_instead_of_moving_down() { + let mut selected = 0; + assert_eq!( + handle_resolution_picker_key(&mut selected, 3, &KeyEvent::from(KeyCode::Right)), + ResolutionPickerAction::Select + ); + assert_eq!(selected, 0); } #[test] @@ -1310,10 +1499,11 @@ mod tests { #[test] fn glfw_library_names_are_recognized() { - assert!(is_glfw_library(Path::new("/usr/lib/libglfw.so.3"))); - assert!(is_glfw_library(Path::new("/usr/lib/libglfw.3.dylib"))); - assert!(is_glfw_library(Path::new("C:/bin/glfw3.dll"))); - assert!(!is_glfw_library(Path::new("/usr/lib/libGL.so"))); + assert!(is_glfw_library_name("libglfw.so.3")); + assert!(is_glfw_library_name("libglfw.3.dylib")); + assert!(is_glfw_library_name("glfw3.dll")); + assert!(!is_glfw_library_name("libglfw.so.backup")); + assert!(!is_glfw_library_name("libGL.so")); assert_eq!( glfw_version_from_path(Path::new("/usr/lib/libglfw.so.3.4")).as_deref(), Some("3.4") @@ -1333,6 +1523,47 @@ mod tests { assert_eq!(input.lines(), ["one "]); } + #[test] + fn tag_values_with_spaces_and_quotes_round_trip() { + let values = vec![ + "-Xmx2G".to_owned(), + "-Dlabel=hello world".to_owned(), + r"C:\Program Files\Java".to_owned(), + "-Dquote=\"value\"".to_owned(), + String::new(), + ]; + + let formatted = format_tag_values(&values); + + assert_eq!(parse_tag_values(&formatted).unwrap(), values); + assert!(parse_tag_values("\"unterminated").is_err()); + } + + #[test] + fn tag_values_drop_exact_duplicates_without_reordering() { + assert_eq!( + parse_tag_values("-Xmx2G '-Dlabel=hello world' -Xmx2G").unwrap(), + ["-Xmx2G", "-Dlabel=hello world"] + ); + } + + #[test] + fn environment_values_can_contain_spaces() { + let environment = + parse_environment(r#"LABEL="hello world" PATH="C:\Program Files""#).unwrap(); + + assert_eq!( + environment.get("LABEL").map(String::as_str), + Some("hello world") + ); + assert_eq!( + environment.get("PATH").map(String::as_str), + Some(r"C:\Program Files") + ); + assert!(parse_environment("=missing").is_err()); + assert!(parse_environment("KEY=one KEY=two").is_err()); + } + #[test] fn memory_thumb_clears_the_first_unfilled_cell() { let backend = ratatui::backend::TestBackend::new(40, 1); diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index e8ca15b..335e7f3 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -4,7 +4,7 @@ // settings panel: manages config profiles and shows compact instance info. // detailed instance and launcher configuration opens in the TUI popups. -use std::{path::PathBuf, process::Command}; +use std::path::{Path, PathBuf}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -50,7 +50,6 @@ pub struct SettingsState { active_profile: Option, instance_name: Option, java_key: Option, - java_source: Option, java_label: String, } @@ -69,7 +68,6 @@ impl SettingsState { active_profile: None, instance_name: None, java_key: None, - java_source: None, java_label: "unknown".to_string(), }; state.select_active(); @@ -129,7 +127,6 @@ impl SettingsState { .map(java_version_label) .unwrap_or_else(|| "unknown".to_string()); self.java_key = java_key; - self.java_source = java_source; } } } @@ -174,25 +171,11 @@ fn effective_java_path(instance: &InstanceConfig) -> String { } fn java_version_label(java_path: &str) -> String { - let output = Command::new(java_path).arg("-version").output(); - let Ok(output) = output else { + let Some(version) = crate::instance::java::java_version(Path::new(java_path)) else { return "unknown".to_string(); }; - let raw = String::from_utf8_lossy(if output.stderr.is_empty() { - &output.stdout - } else { - &output.stderr - }); - let first_line = raw.lines().next().unwrap_or_default(); - let Some(version) = first_line.split('"').nth(1) else { - return "unknown".to_string(); - }; - let major = if let Some(stripped) = version.strip_prefix("1.") { - stripped.split('.').next().unwrap_or(stripped) - } else { - version.split('.').next().unwrap_or(version) - }; - if major.is_empty() { + let major = crate::instance::java::java_major(Some(&version)); + if major == 0 { "unknown".to_string() } else { format!("jdk{major}") From cd234db2999f20b6fe7f6f76ef634e2e8b4b2ccf Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 5 Sep 2026 00:00:41 +0200 Subject: [PATCH 38/42] fix(ci): install xcb for Linux builds --- .github/dist-build-setup.yml | 3 +++ .github/workflows/build-release.yml | 3 +++ .github/workflows/checks.yml | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/.github/dist-build-setup.yml b/.github/dist-build-setup.yml index fb05694..ddb98ba 100644 --- a/.github/dist-build-setup.yml +++ b/.github/dist-build-setup.yml @@ -3,3 +3,6 @@ with: distribution: temurin java-version: "21" +- name: install Linux build dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes libxcb1-dev diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index d1a55fa..bea0f88 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -103,6 +103,9 @@ jobs: with: "distribution": "temurin" "java-version": "21" + - name: install Linux build dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes libxcb1-dev - name: Install dist run: ${{ matrix.install_dist.run }} # Get the dist-manifest diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 620227c..8f7e943 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -68,6 +68,9 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.os }} + - name: install Linux build dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes libxcb1-dev - run: cargo build --locked - run: cargo test --locked --all-targets @@ -86,4 +89,6 @@ jobs: distribution: temurin java-version: "21" - uses: Swatinem/rust-cache@v2 + - name: install Linux build dependencies + run: sudo apt-get update && sudo apt-get install --yes libxcb1-dev - run: cargo test --locked --all-targets -- --ignored From ea63cf7b40c5b2e652787767dca03d0406bf21f2 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 5 Sep 2026 00:10:31 +0200 Subject: [PATCH 39/42] test(config): compare migrated paths semantically --- src/config/tests/loading.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/config/tests/loading.rs b/src/config/tests/loading.rs index 94f0e35..94672fd 100644 --- a/src/config/tests/loading.rs +++ b/src/config/tests/loading.rs @@ -198,14 +198,17 @@ fn legacy_default_data_paths_follow_the_renamed_data_directory() { .unwrap(); assert!(migrate_legacy_data_paths_from(&path, &data).unwrap()); - let migrated = std::fs::read_to_string(path).unwrap(); + let migrated = std::fs::read_to_string(&path).unwrap(); + let config = load_config(&path).unwrap(); - assert!( - migrated.contains(&data.join("rmcl/instances").to_string_lossy().to_string()), + assert_eq!( + settings::resolve_path(&config.paths.instances_dir), + data.join("rmcl").join("instances"), "{migrated}" ); - assert!( - migrated.contains(&data.join("rmcl/meta").to_string_lossy().to_string()), + assert_eq!( + settings::resolve_path(&config.paths.meta_dir), + data.join("rmcl").join("meta"), "{migrated}" ); } From 8b46a5168faeaab786b5af779b89fec259ac0025 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 5 Sep 2026 13:37:45 +0200 Subject: [PATCH 40/42] feat(settings): control shortcut guides --- README.md | 4 + assets/config.toml | 3 + src/config/settings.rs | 64 ++++++++- src/config/tests/settings.rs | 25 ++++ src/tui/render.rs | 19 +-- src/tui/tests/flows.rs | 4 +- src/tui/tests/widgets/popups/mod.rs | 6 +- src/tui/widgets/account.rs | 1 + src/tui/widgets/content/tabs.rs | 5 + src/tui/widgets/popups/base.rs | 6 +- src/tui/widgets/popups/global_settings.rs | 150 +++++++++++++++++++++- src/tui/widgets/popups/mod.rs | 28 +++- src/tui/widgets/popups/modpack_update.rs | 8 +- src/tui/widgets/settings.rs | 1 + 14 files changed, 299 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 804c6ad..567e80c 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ resolution = [854, 480] [ui] image_protocol = "auto" +hidden_shortcut_hints = [] # all, main, instances, content, accounts, settings, popups [content] preferred_provider = "modrinth" @@ -158,6 +159,9 @@ preferred_provider_only = false ask_on_provider_conflict = true ``` +Use `hidden_shortcut_hints = ["main"]` to keep only popup guides, +`["all"]` to hide every guide, or list individual areas to hide them selectively. + ### logs launcher logs are per-session and contain rmcl's own output. instance launch logs capture game stdout/stderr per launch. diff --git a/assets/config.toml b/assets/config.toml index e923cc7..04ef1e5 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -24,6 +24,9 @@ resolution = [854, 480] [ui] # image protocol: auto, halfblocks, quadrants, kitty, or iterm2 image_protocol = "auto" +# [] shows all guides; ["main"] keeps popup guides; ["all"] hides every guide +# areas can also be hidden selectively: instances, content, accounts, settings, popups +hidden_shortcut_hints = [] # error popup timing (in ms) error_auto_dismiss_ms = 5000 error_slide_start_ms = 3500 diff --git a/src/config/settings.rs b/src/config/settings.rs index aca8387..623d47b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -4,7 +4,11 @@ // all the config structs that map to sections in config.toml. // everything has sane defaults so a blank file (or no file) still works. -use std::{collections::BTreeMap, fmt, path::PathBuf}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + path::PathBuf, +}; use serde::{Deserialize, Serialize}; @@ -23,6 +27,28 @@ pub enum ImageProtocol { Iterm2, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ShortcutHintScope { + All, + Main, + Instances, + Content, + Accounts, + Settings, + Popups, +} + +impl ShortcutHintScope { + pub const AREAS: [Self; 5] = [ + Self::Instances, + Self::Content, + Self::Accounts, + Self::Settings, + Self::Popups, + ]; +} + impl fmt::Display for ImageProtocol { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -299,6 +325,8 @@ impl Default for Defaults { pub struct Ui { #[serde(default)] pub image_protocol: ImageProtocol, + #[serde(default)] + pub hidden_shortcut_hints: BTreeSet, #[serde(default = "default_error_auto_dismiss_ms")] pub error_auto_dismiss_ms: u64, #[serde(default = "default_error_slide_start_ms")] @@ -326,6 +354,7 @@ impl Default for Ui { fn default() -> Self { Self { image_protocol: ImageProtocol::default(), + hidden_shortcut_hints: BTreeSet::new(), error_auto_dismiss_ms: default_error_auto_dismiss_ms(), error_slide_start_ms: default_error_slide_start_ms(), error_fly_out_ms: default_error_fly_out_ms(), @@ -334,6 +363,39 @@ impl Default for Ui { } } +impl Ui { + pub fn show_shortcut_hints(&self, scope: ShortcutHintScope) -> bool { + !self.hidden_shortcut_hints.contains(&ShortcutHintScope::All) + && !(scope != ShortcutHintScope::Popups + && self + .hidden_shortcut_hints + .contains(&ShortcutHintScope::Main)) + && !self.hidden_shortcut_hints.contains(&scope) + } + + pub fn set_shortcut_hints(&mut self, scope: ShortcutHintScope, visible: bool) { + let mut hidden = ShortcutHintScope::AREAS + .into_iter() + .filter(|area| !self.show_shortcut_hints(*area)) + .collect::>(); + if visible { + hidden.remove(&scope); + } else { + hidden.insert(scope); + } + self.hidden_shortcut_hints = if hidden.len() == ShortcutHintScope::AREAS.len() { + [ShortcutHintScope::All].into_iter().collect() + } else if ShortcutHintScope::AREAS[..4] + .iter() + .all(|area| hidden.contains(area)) + { + [ShortcutHintScope::Main].into_iter().collect() + } else { + hidden + }; + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { #[serde(default)] diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs index 21f26fb..3bb0496 100644 --- a/src/config/tests/settings.rs +++ b/src/config/tests/settings.rs @@ -110,3 +110,28 @@ memory_max = "8G" assert_eq!(config.defaults.memory_max, "8G"); assert_eq!(config.defaults.memory_min, "512M"); } + +#[test] +fn shortcut_hints_support_global_main_and_selective_hiding() { + let mut ui = Ui::default(); + assert!( + ShortcutHintScope::AREAS + .iter() + .all(|scope| ui.show_shortcut_hints(*scope)) + ); + + ui.hidden_shortcut_hints = [ShortcutHintScope::Main].into_iter().collect(); + assert!(!ui.show_shortcut_hints(ShortcutHintScope::Instances)); + assert!(ui.show_shortcut_hints(ShortcutHintScope::Popups)); + + ui.set_shortcut_hints(ShortcutHintScope::Content, true); + assert!(ui.show_shortcut_hints(ShortcutHintScope::Content)); + assert!(!ui.show_shortcut_hints(ShortcutHintScope::Accounts)); + + ui.hidden_shortcut_hints = [ShortcutHintScope::All].into_iter().collect(); + assert!( + ShortcutHintScope::AREAS + .iter() + .all(|scope| !ui.show_shortcut_hints(*scope)) + ); +} diff --git a/src/tui/render.rs b/src/tui/render.rs index af74840..6218de9 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -447,15 +447,18 @@ fn render_provider_conflict(frame: &mut Frame, conflict: &super::app::ProviderCo ))) }); let mut state = ListState::default().with_selected(Some(conflict.selected)); + let mut block = Block::default() + .title(Line::from(format!(" Choose provider for {title} "))) + .borders(Borders::ALL) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.accent())); + if crate::tui::widgets::popups::shortcut_hints_visible( + crate::config::settings::ShortcutHintScope::Popups, + ) { + block = block.title_bottom(Line::from(" [j/k] select [Enter] use [Esc] later ")); + } let list = List::new(items) - .block( - Block::default() - .title(Line::from(format!(" Choose provider for {title} "))) - .title_bottom(Line::from(" [j/k] select [Enter] use [Esc] later ")) - .borders(Borders::ALL) - .border_type(BORDER_STYLE.to_border_type()) - .border_style(Style::default().fg(theme.accent())), - ) + .block(block) .style(Style::default().fg(theme.text()).bg(theme.surface())) .highlight_style( Style::default() diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index e631e33..44e69fb 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -669,7 +669,7 @@ fn launcher_settings_expand_to_storage_and_confirm_cache_cleanup() { assert!(ui.screen().contains("Instances")); assert!(ui.screen().contains("Metadata")); - for _ in 18..23 { + for _ in 18..24 { ui.key(KeyCode::Char('j')); } ui.key(KeyCode::Enter); @@ -984,7 +984,7 @@ fn settings_use_java_memory_and_resolution_controls() { ui.key(KeyCode::Esc); ui.key(KeyCode::Char('G')); - for _ in 0..5 { + for _ in 0..6 { ui.key(KeyCode::Char('j')); } ui.key(KeyCode::Enter); diff --git a/src/tui/tests/widgets/popups/mod.rs b/src/tui/tests/widgets/popups/mod.rs index 37d9167..c063491 100644 --- a/src/tui/tests/widgets/popups/mod.rs +++ b/src/tui/tests/widgets/popups/mod.rs @@ -8,7 +8,11 @@ use super::{ #[test] fn fitted_keybinds_use_terminal_width_and_omit_overflow() { - let line = keybind_line_fitted(&[("⏎", " select"), ("a", " add")], 10); + let line = keybind_line_fitted( + crate::config::settings::ShortcutHintScope::Accounts, + &[("⏎", " select"), ("a", " add")], + 10, + ); assert_eq!(line.width(), 10); assert_eq!(line.to_string(), "[⏎] select"); diff --git a/src/tui/widgets/account.rs b/src/tui/widgets/account.rs index f569ca1..ff1e843 100644 --- a/src/tui/widgets/account.rs +++ b/src/tui/widgets/account.rs @@ -225,6 +225,7 @@ pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut A let keybind_line = if focused == FocusedArea::Account { Some(super::popups::keybind_line_fitted( + crate::config::settings::ShortcutHintScope::Accounts, &[ ("⏎", " select"), ("a", " add"), diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index a2963ed..35450b5 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -455,6 +455,11 @@ pub fn render( } // The main panel footer stays on its border; lower-priority hints are omitted when narrow. block = block.title_bottom(crate::tui::widgets::popups::keybind_line_fitted( + if focused == FocusedArea::Instances { + crate::config::settings::ShortcutHintScope::Instances + } else { + crate::config::settings::ShortcutHintScope::Content + }, &keybinds, area.width.saturating_sub(2), )); diff --git a/src/tui/widgets/popups/base.rs b/src/tui/widgets/popups/base.rs index 64b3389..d008657 100644 --- a/src/tui/widgets/popups/base.rs +++ b/src/tui/widgets/popups/base.rs @@ -13,7 +13,7 @@ use ratatui::{ widgets::{Block, Clear, Paragraph, Widget}, }; -use crate::config::theme::BORDER_STYLE; +use crate::config::{settings::ShortcutHintScope, theme::BORDER_STYLE}; type ContentFn<'a> = Box; @@ -56,7 +56,9 @@ impl<'a> Widget for PopupFrame<'a> { block = block.title_top(sl.alignment(Alignment::Right)); } - if let Some(kb) = self.keybinds { + if let Some(kb) = self.keybinds + && super::shortcut_hints_visible(ShortcutHintScope::Popups) + { block = block.title_bottom(kb.alignment(Alignment::Right)); } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index 7acfd2e..f785767 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -16,7 +16,7 @@ use ratatui_textarea::TextArea; use crate::{ config::{ Config, - settings::{ContentProvider, DEFAULT_RESOLUTION, ImageProtocol}, + settings::{ContentProvider, DEFAULT_RESOLUTION, ImageProtocol, ShortcutHintScope}, theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, }, instance::models::{memory_kib, normalize_memory_value, parse_resolution}, @@ -32,7 +32,9 @@ use crate::{ tui::widgets::status_badge, }; -const FIELD_COUNT: usize = 24; +const FIELD_ORDER: [usize; 25] = [ + 0, 1, 2, 24, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, +]; const MODRINTH_COLOR: Color = Color::Rgb(0x1B, 0xD9, 0x6A); const CURSEFORGE_COLOR: Color = Color::Rgb(0xF1, 0x64, 0x36); const PROVIDER_BADGE_TEXT: Color = Color::Rgb(0x10, 0x10, 0x10); @@ -41,6 +43,7 @@ const PROVIDER_BADGE_TEXT: Color = Color::Rgb(0x10, 0x10, 0x10); enum ChoicePicker { ImageProtocol, Resolution, + ShortcutHints, } pub struct State { @@ -153,6 +156,7 @@ impl State { 20 => self.config.ui.error_slide_start_ms.to_string(), 21 => self.config.ui.error_fly_out_ms.to_string(), 22 => self.config.ui.max_error_events.to_string(), + 24 => shortcut_hint_mode(&self.config.ui).to_owned(), _ => String::new(), } } @@ -403,6 +407,29 @@ impl State { .collect() } + fn shortcut_hint_options(&self) -> Vec { + [ + (ShortcutHintScope::Instances, "instances", "Instances"), + (ShortcutHintScope::Content, "content", "Content"), + (ShortcutHintScope::Accounts, "accounts", "Accounts"), + (ShortcutHintScope::Settings, "settings", "Settings"), + (ShortcutHintScope::Popups, "popups", "Popups"), + ] + .into_iter() + .map(|(scope, key, title)| { + let visible = self.config.ui.show_shortcut_hints(scope); + SettingsPickerOption { + key: key.to_owned(), + title: format!("{} {title}", if visible { "●" } else { "○" }), + detail: Some(if visible { "Shown" } else { "Hidden" }.to_owned()), + leading: None, + badge: None, + active: visible, + } + }) + .collect() + } + fn open_choice_picker(&mut self, picker: ChoicePicker) { match picker { ChoicePicker::ImageProtocol => { @@ -418,6 +445,11 @@ impl State { .position(|choice| choice.resolution() == self.config.defaults.resolution) .unwrap_or(0); } + ChoicePicker::ShortcutHints => { + self.settings_picker.reset(); + self.settings_picker + .sync(self.shortcut_hint_options(), None); + } } self.choice_picker = Some(picker); } @@ -456,6 +488,22 @@ impl State { let Some(value) = self.settings_picker.selected_key().map(str::to_owned) else { return; }; + if picker == ChoicePicker::ShortcutHints { + let scope = match value.as_str() { + "instances" => ShortcutHintScope::Instances, + "content" => ShortcutHintScope::Content, + "accounts" => ShortcutHintScope::Accounts, + "settings" => ShortcutHintScope::Settings, + "popups" => ShortcutHintScope::Popups, + _ => return, + }; + let visible = self.config.ui.show_shortcut_hints(scope); + self.config.ui.set_shortcut_hints(scope, !visible); + self.settings_picker + .sync(self.shortcut_hint_options(), Some(&value)); + self.save_pending = true; + return; + } if picker == ChoicePicker::ImageProtocol { let protocol = match value.as_str() { "kitty" => ImageProtocol::Kitty, @@ -511,6 +559,27 @@ impl State { self.error = None; } + fn cycle_shortcut_hints(&mut self, forward: bool) { + let current = shortcut_hint_mode(&self.config.ui); + let index = ["All", "Popups only", "Hidden"] + .iter() + .position(|mode| *mode == current); + let index = match (index, forward) { + (Some(2), true) | (Some(0), false) => 0, + (Some(index), true) => index + 1, + (Some(index), false) => index - 1, + (None, true) => 0, + (None, false) => 2, + }; + self.config.ui.hidden_shortcut_hints = match index { + 1 => [ShortcutHintScope::Main].into_iter().collect(), + 2 => [ShortcutHintScope::All].into_iter().collect(), + _ => Default::default(), + }; + self.save_pending = true; + self.error = None; + } + fn open_java_picker(&mut self) { self.java_picker .open(self.config.paths.java_path.as_deref()); @@ -659,9 +728,19 @@ impl State { } match key.code { KeyCode::Char('j') | KeyCode::Down => { - self.selected = (self.selected + 1).min(FIELD_COUNT - 1); + let position = FIELD_ORDER + .iter() + .position(|field| *field == self.selected) + .unwrap_or(0); + self.selected = FIELD_ORDER[(position + 1).min(FIELD_ORDER.len() - 1)]; + } + KeyCode::Char('k') | KeyCode::Up => { + let position = FIELD_ORDER + .iter() + .position(|field| *field == self.selected) + .unwrap_or(0); + self.selected = FIELD_ORDER[position.saturating_sub(1)]; } - KeyCode::Char('k') | KeyCode::Up => self.selected = self.selected.saturating_sub(1), KeyCode::Char('h') | KeyCode::Left if self.selected == 0 => self.cycle_theme(false), KeyCode::Char('l') | KeyCode::Right if self.selected == 0 => self.cycle_theme(true), KeyCode::Char('h') | KeyCode::Left if self.selected == 1 => self.cycle_border(false), @@ -676,6 +755,12 @@ impl State { KeyCode::Char('l') | KeyCode::Right if self.selected == 6 => self.cycle_window_mode(), KeyCode::Char('h') | KeyCode::Left if self.selected == 10 => self.cycle_provider(), KeyCode::Char('l') | KeyCode::Right if self.selected == 10 => self.cycle_provider(), + KeyCode::Char('h') | KeyCode::Left if self.selected == 24 => { + self.cycle_shortcut_hints(false); + } + KeyCode::Char('l') | KeyCode::Right if self.selected == 24 => { + self.cycle_shortcut_hints(true); + } KeyCode::Enter => match self.selected { 0 => self.theme_picker = true, 1 => self.cycle_border(true), @@ -688,6 +773,7 @@ impl State { 7 => self.open_choice_picker(ChoicePicker::Resolution), 10 => self.cycle_provider(), 11..=14 => self.toggle_selected(), + 24 => self.open_choice_picker(ChoicePicker::ShortcutHints), 23 => return Action::ClearCache, field => self.editing = Some(settings_text_area(vec![self.value(field)])), }, @@ -749,7 +835,7 @@ fn available_themes() -> Vec { pub fn popup_rect(area: Rect, state: &State) -> Rect { let form_width = (area.width * 86 / 100).saturating_sub(2); - let form_height = 37 + let form_height = 38 + tagged_row_count(&state.config.defaults.jvm_args, form_width).saturating_sub(1) as u16 + tagged_row_count( &environment_labels(&state.config.defaults.environment), @@ -767,7 +853,7 @@ pub fn popup_rect(area: Rect, state: &State) -> Rect { }; let width = match state.choice_picker { Some(ChoicePicker::Resolution) => 64, - Some(ChoicePicker::ImageProtocol) => 60, + Some(ChoicePicker::ImageProtocol | ChoicePicker::ShortcutHints) => 60, None if state.java_picker_open => 72, None if state.theme_picker => 52, None => 86, @@ -788,6 +874,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) } else if state.choice_picker == Some(ChoicePicker::Resolution) { super::keybind_line(&[("d", " default"), ("h", " back"), ("Enter", " select")]) + } else if state.choice_picker == Some(ChoicePicker::ShortcutHints) { + super::keybind_line(&[("Enter", " toggle"), ("h", " back")]) } else if state.java_picker_open || state.theme_picker || state.choice_picker.is_some() { super::keybind_line(&[("h", " back"), ("Enter", " select")]) } else if matches!(state.selected, 3 | 4) { @@ -833,6 +921,13 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { ("E", " raw"), ("Esc", " back"), ]) + } else if state.selected == 24 { + super::keybind_line(&[ + ("h/l", " adjust"), + ("Enter", " customize"), + ("E", " raw"), + ("Esc", " back"), + ]) } else { super::keybind_line(&[ ("j/k", ""), @@ -849,6 +944,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { match picker { ChoicePicker::ImageProtocol => " Image Rendering ", ChoicePicker::Resolution => " Default Resolution ", + ChoicePicker::ShortcutHints => " Shortcut Guides ", } } else { " Launcher Settings " @@ -918,7 +1014,7 @@ fn render_java_picker(frame: &mut Frame, area: Rect, state: &mut State) { fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { let theme = THEME.as_ref(); let sections: [(&str, &[usize]); 6] = [ - ("Appearance", &[0, 1, 2]), + ("Appearance", &[0, 1, 2, 24]), ("Launch Defaults", &[3, 4, 5, 6, 7, 8, 9]), ("Content", &[10, 11, 12, 13, 14, 15, 16]), ("Storage", &[17, 18]), @@ -1082,6 +1178,7 @@ fn field_label(index: usize) -> &'static str { 21 => "Fly-out ms", 22 => "Max notifications", 23 => "", + 24 => "Shortcut guides", _ => "", } } @@ -1208,6 +1305,16 @@ fn status(enabled: bool) -> String { if enabled { "enabled" } else { "disabled" }.to_owned() } +fn shortcut_hint_mode(ui: &crate::config::settings::Ui) -> &'static str { + let visible = ShortcutHintScope::AREAS.map(|scope| ui.show_shortcut_hints(scope)); + match visible { + [true, true, true, true, true] => "All", + [false, false, false, false, true] => "Popups only", + [false, false, false, false, false] => "Hidden", + _ => "Custom", + } +} + #[cfg(test)] mod tests { use super::*; @@ -1437,6 +1544,35 @@ mod tests { assert_eq!(state.themes[state.theme_index], active_theme); } + #[test] + fn shortcut_guides_offer_presets_and_selective_toggles() { + let mut state = State::new(); + state.config.ui.hidden_shortcut_hints.clear(); + state.selected = 2; + state.handle_key(&KeyEvent::from(KeyCode::Down)); + assert_eq!(state.selected, 24); + + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Right)), + Action::Save(..) + )); + assert_eq!(shortcut_hint_mode(&state.config.ui), "Popups only"); + + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_eq!(state.choice_picker, Some(ChoicePicker::ShortcutHints)); + assert!(matches!( + state.handle_key(&KeyEvent::from(KeyCode::Enter)), + Action::Save(..) + )); + assert_eq!(shortcut_hint_mode(&state.config.ui), "Custom"); + assert!( + state + .config + .ui + .show_shortcut_hints(ShortcutHintScope::Instances) + ); + } + #[test] fn failed_launcher_save_remains_pending() { let mut state = State::new(); diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index 0f934ea..9cf6889 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -21,6 +21,8 @@ pub use load_state::LoadState; use ratatui::{layout::Rect, text::Span}; +use crate::config::settings::ShortcutHintScope; + pub(crate) fn compare_game_versions(a: &str, b: &str) -> std::cmp::Ordering { let parse_parts = |version: &str| { version @@ -85,6 +87,13 @@ pub fn top_right_rect(frame: Rect, inner_w: usize, inner_h: usize) -> Rect { } pub fn keybind_line(binds: &[(&str, &str)]) -> ratatui::text::Line<'static> { + if !shortcut_hints_visible(ShortcutHintScope::Popups) { + return ratatui::text::Line::default(); + } + styled_keybind_line(binds) +} + +fn styled_keybind_line(binds: &[(&str, &str)]) -> ratatui::text::Line<'static> { use crate::config::theme::THEME; use ratatui::{ style::{Modifier, Style}, @@ -109,7 +118,18 @@ pub fn keybind_line(binds: &[(&str, &str)]) -> ratatui::text::Line<'static> { Line::from(spans) } -pub fn keybind_line_fitted(binds: &[(&str, &str)], max_width: u16) -> ratatui::text::Line<'static> { +pub fn keybind_line_fitted( + scope: ShortcutHintScope, + binds: &[(&str, &str)], + max_width: u16, +) -> ratatui::text::Line<'static> { + if !shortcut_hints_visible(scope) { + return ratatui::text::Line::default(); + } + fitted_keybind_line(binds, max_width) +} + +fn fitted_keybind_line(binds: &[(&str, &str)], max_width: u16) -> ratatui::text::Line<'static> { let mut width = 0; let mut count = 0; for (key, label) in binds { @@ -121,7 +141,11 @@ pub fn keybind_line_fitted(binds: &[(&str, &str)], max_width: u16) -> ratatui::t count += 1; } - keybind_line(&binds[..count]).right_aligned() + styled_keybind_line(&binds[..count]).right_aligned() +} + +pub(crate) fn shortcut_hints_visible(scope: ShortcutHintScope) -> bool { + crate::config::SETTINGS.read().ui.show_shortcut_hints(scope) } #[cfg(test)] diff --git a/src/tui/widgets/popups/modpack_update.rs b/src/tui/widgets/popups/modpack_update.rs index 226fb17..0b6eb27 100644 --- a/src/tui/widgets/popups/modpack_update.rs +++ b/src/tui/widgets/popups/modpack_update.rs @@ -146,13 +146,17 @@ pub fn render(frame: &mut Frame, state: &State) { Action::Reinstall => (" Reinstalling modpack ", " Please wait "), }, }; - let block = Block::default() + let mut block = Block::default() .title(title) - .title_bottom(Line::from(footer).centered()) .borders(Borders::ALL) .border_type(BORDER_STYLE.to_border_type()) .border_style(Style::default().fg(theme.accent())) .style(Style::default().fg(theme.text()).bg(theme.surface())); + if state.phase == Phase::Applying + || super::shortcut_hints_visible(crate::config::settings::ShortcutHintScope::Popups) + { + block = block.title_bottom(Line::from(footer).centered()); + } match state.phase { Phase::Preparing => frame.render_widget( Paragraph::new(format!("Preparing the selected modpack {action}…")).block(block), diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs index 335e7f3..206cbe0 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -222,6 +222,7 @@ pub fn render( ], }; Some(super::popups::keybind_line_fitted( + crate::config::settings::ShortcutHintScope::Settings, keybinds, area.width.saturating_sub(2), )) From a31ed6e51051d7ae6ed88730eb40082ac059031f Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 5 Sep 2026 13:47:45 +0200 Subject: [PATCH 41/42] fix(settings): simplify shortcut guide picker --- src/config/tests/settings.rs | 4 ++-- src/tui/widgets/popups/global_settings.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs index 3bb0496..41e6bde 100644 --- a/src/config/tests/settings.rs +++ b/src/config/tests/settings.rs @@ -113,14 +113,14 @@ memory_max = "8G" #[test] fn shortcut_hints_support_global_main_and_selective_hiding() { - let mut ui = Ui::default(); + let mut ui: Ui = toml::from_str("hidden_shortcut_hints = []").unwrap(); assert!( ShortcutHintScope::AREAS .iter() .all(|scope| ui.show_shortcut_hints(*scope)) ); - ui.hidden_shortcut_hints = [ShortcutHintScope::Main].into_iter().collect(); + ui = toml::from_str("hidden_shortcut_hints = [\"main\"]").unwrap(); assert!(!ui.show_shortcut_hints(ShortcutHintScope::Instances)); assert!(ui.show_shortcut_hints(ShortcutHintScope::Popups)); diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs index f785767..5b77d1f 100644 --- a/src/tui/widgets/popups/global_settings.rs +++ b/src/tui/widgets/popups/global_settings.rs @@ -421,7 +421,7 @@ impl State { SettingsPickerOption { key: key.to_owned(), title: format!("{} {title}", if visible { "●" } else { "○" }), - detail: Some(if visible { "Shown" } else { "Hidden" }.to_owned()), + detail: None, leading: None, badge: None, active: visible, From 95670fc3d9472e56ab2a8c8f32f379bdca5dadb6 Mon Sep 17 00:00:00 2001 From: objz Date: Sat, 5 Sep 2026 14:06:19 +0200 Subject: [PATCH 42/42] fix(settings): show instance name in editor title --- src/tui/tests/flows.rs | 3 ++- src/tui/widgets/popups/instance_settings.rs | 17 +++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/tui/tests/flows.rs b/src/tui/tests/flows.rs index 44e69fb..ee54a81 100644 --- a/src/tui/tests/flows.rs +++ b/src/tui/tests/flows.rs @@ -602,7 +602,7 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { ui.key(KeyCode::Char('e')); assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); ui.draw(); - assert!(ui.screen().contains("Instance Settings")); + assert!(ui.screen().contains("Instance Settings · settings-test")); assert!(!ui.screen().contains("Instance Settings *")); assert!(ui.screen().contains("settings-test")); assert!(ui.screen().contains("Game version")); @@ -616,6 +616,7 @@ fn settings_panel_routes_legacy_edit_keys_to_tui_popups() { ui.key(KeyCode::Down); ui.key(KeyCode::Enter); ui.draw(); + assert!(ui.screen().contains("Mod Loader · settings-test")); assert!(ui.screen().contains("Fabric")); assert!(ui.screen().contains("Forge")); ui.key(KeyCode::Esc); diff --git a/src/tui/widgets/popups/instance_settings.rs b/src/tui/widgets/popups/instance_settings.rs index 2eedc0a..48e0905 100644 --- a/src/tui/widgets/popups/instance_settings.rs +++ b/src/tui/widgets/popups/instance_settings.rs @@ -1163,15 +1163,16 @@ pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { let theme = THEME.as_ref(); frame.render_widget(Clear, area); let title = match (state.picker, state.choice_picker) { - (Some(VersionPicker::Game), _) => " Minecraft Version ", - (Some(VersionPicker::Loader), _) => " Loader Version ", - (_, Some(ChoicePicker::Loader)) => " Mod Loader ", - (_, Some(ChoicePicker::Java)) => " Java Runtime ", - (_, Some(ChoicePicker::Resolution)) => " Resolution ", - (_, Some(ChoicePicker::Account)) => " Preferred Account ", - (_, Some(ChoicePicker::Glfw)) => " GLFW Library ", - _ => " Instance Settings ", + (Some(VersionPicker::Game), _) => "Minecraft Version", + (Some(VersionPicker::Loader), _) => "Loader Version", + (_, Some(ChoicePicker::Loader)) => "Mod Loader", + (_, Some(ChoicePicker::Java)) => "Java Runtime", + (_, Some(ChoicePicker::Resolution)) => "Resolution", + (_, Some(ChoicePicker::Account)) => "Preferred Account", + (_, Some(ChoicePicker::Glfw)) => "GLFW Library", + _ => "Instance Settings", }; + let title = format!(" {title} · {} ", state.draft.name); let keybinds = if state.editing.is_some() { super::keybind_line(&[("Enter", " apply"), ("Esc", " cancel")]) } else if state.picker == Some(VersionPicker::Game) {