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 diff --git a/Cargo.lock b/Cargo.lock index 71ccbc6..cc709c0 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", @@ -3685,6 +3924,7 @@ dependencies = [ "throbber-widgets-tui", "tokio", "toml", + "toml_edit", "tracing", "tracing-appender", "tracing-subscriber", @@ -4170,6 +4410,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" @@ -4683,6 +4948,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -5294,6 +5560,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 +5818,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 +5865,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 +5912,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 +5973,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 +6089,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 +6335,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..0862deb 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"] } @@ -44,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..567e80c 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,39 @@ 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 = [854, 480] + +[ui] +image_protocol = "auto" +hidden_shortcut_hints = [] # all, main, instances, content, accounts, settings, popups + +[content] +preferred_provider = "modrinth" +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 88bf449..04ef1e5 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -1,21 +1,32 @@ -[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 = [854, 480] [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" +# [] 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/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..e155c3c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -27,10 +27,10 @@ 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(); + 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 caaaad4..77c657a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -6,8 +6,9 @@ use config::{Config as ConfigLoader, ConfigError, File}; use std::fs; +use std::io; use std::path::PathBuf; -use std::sync::LazyLock; +use std::sync::{LazyLock, RwLock, RwLockReadGuard}; pub mod settings; pub mod theme; @@ -53,23 +54,307 @@ 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) } -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(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, + pub restart_changed: bool, + pub provider_changed: bool, + pub modpack_updates_enabled: bool, + pub content_updates_enabled: bool, +} + +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) -> io::Result { + let path = get_config_path().join("config.toml"); + let current = self.read().clone(); + 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; + 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 { + let mut config = load_config(&get_config_path().join("config.toml"))?; + let outcome = { + let current = self.read(); + 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 + .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; + 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; + crate::feedback::errors::set_max_error_events(self.read().ui.max_error_events); + Ok(outcome) + } +} + +pub static SETTINGS: LazyLock = LazyLock::new(|| { + let config = { + let path = ensure_config_exists(); + load_config(&path).unwrap_or_else(|e| { + tracing::error!("Config load failed, using defaults: {}", e); + 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<()> { + 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 = 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()); + 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) { + 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()); + } + } +} + +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/settings.rs b/src/config/settings.rs index b38d932..623d47b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -4,29 +4,103 @@ // 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, BTreeSet}, + fmt, + path::PathBuf, +}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] +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 { + #[default] + Auto, Halfblocks, Quadrants, - #[default] Kitty, Iterm2, } -#[derive(Debug, Deserialize, Default)] -pub struct General {} +#[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 { + 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, +} -#[derive(Debug, Deserialize)] +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 +113,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 +137,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 +162,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" @@ -119,13 +201,16 @@ impl Content { } } -#[derive(Debug, Deserialize)] +#[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 +223,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 +236,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 { @@ -185,12 +278,21 @@ impl Paths { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Defaults { #[serde(default = "default_memory_min")] 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 = "default_resolution")] + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option<(u32, u32)>, } fn default_memory_min() -> String { @@ -200,21 +302,31 @@ 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 { memory_min: default_memory_min(), memory_max: default_memory_max(), + jvm_args: Vec::new(), + environment: BTreeMap::new(), + window_mode: WindowMode::default(), + resolution: default_resolution(), } } } -#[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 { #[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")] @@ -242,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(), @@ -250,7 +363,40 @@ impl Default for Ui { } } -#[derive(Debug, Deserialize)] +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)] pub general: General, @@ -264,6 +410,46 @@ 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); + } + 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); + 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..94672fd 100644 --- a/src/config/tests/loading.rs +++ b/src/config/tests/loading.rs @@ -54,3 +54,161 @@ 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); + assert_eq!( + config.defaults.resolution, + Some(settings::DEFAULT_RESOLUTION) + ); +} + +#[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(), + resolution: Some((0, 1080)), + ..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.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); +} + +#[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")); +} + +#[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(); + let config = load_config(&path).unwrap(); + + assert_eq!( + settings::resolve_path(&config.paths.instances_dir), + data.join("rmcl").join("instances"), + "{migrated}" + ); + assert_eq!( + settings::resolve_path(&config.paths.meta_dir), + data.join("rmcl").join("meta"), + "{migrated}" + ); +} diff --git a/src/config/tests/settings.rs b/src/config/tests/settings.rs index 619ed44..41e6bde 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] @@ -105,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 = toml::from_str("hidden_shortcut_hints = []").unwrap(); + assert!( + ShortcutHintScope::AREAS + .iter() + .all(|scope| ui.show_shortcut_hints(*scope)) + ); + + ui = toml::from_str("hidden_shortcut_hints = [\"main\"]").unwrap(); + 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/config/theme.rs b/src/config/theme.rs index 27fea15..49d55fa 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,136 @@ 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(|| { + let config = THEME_CONFIG + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + BorderStyleStore(RwLock::new(config.border_style.clone())) +}); -pub static BORDER_STYLE: LazyLock = - LazyLock::new(|| THEME_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(); + super::write_merged_toml_document( + &super::get_config_path().join("theme.toml"), + &config, + |document| { + if config.custom.is_none() { + document.as_table_mut().remove("custom"); + } + }, + )?; + 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/feedback/errors.rs b/src/feedback/errors.rs index 3743a7b..29d96d5 100644 --- a/src/feedback/errors.rs +++ b/src/feedback/errors.rs @@ -9,15 +9,25 @@ // 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) { + 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)] pub struct ErrorEvent { @@ -38,7 +48,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(); @@ -49,6 +60,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/feedback/tests/errors.rs b/src/feedback/tests/errors.rs index dd5f72d..9852e18 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")); @@ -86,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/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..3c9da05 100644 --- a/src/instance/content/reconcile.rs +++ b/src/instance/content/reconcile.rs @@ -151,8 +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.content.unmatched_retry_hours; - let max_fingerprint_size_mib = crate::config::SETTINGS.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 || { @@ -400,6 +405,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 +559,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/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/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/java.rs b/src/instance/java.rs index 08aec24..3d57f8d 100644 --- a/src/instance/java.rs +++ b/src/instance/java.rs @@ -3,6 +3,20 @@ // java runtime discovery shared by launching, loader installation, and settings. +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + process::Command, +}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JavaInstallation { + pub path: PathBuf, + pub version: Option, +} + #[must_use] pub fn detect_java_path() -> String { if let Ok(java_home) = std::env::var("JAVA_HOME") { @@ -32,3 +46,259 @@ 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(|| inspect_installation(&path)) + .flatten() + }) + .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 +} + +/// 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") { + 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); + } + } +} + +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), + 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()) + }) +} + +pub(crate) fn java_major(version: Option<&str>) -> u32 { + let Some(version) = version else { + return 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, + } +} + +#[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); + 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] + 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 dc676e4..1fef4bd 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), } @@ -59,26 +61,23 @@ fn build_game_args( Ok((rendered.jvm, rendered.game)) } -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(); +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()]); + } +} - match parts.as_slice() { - [1, legacy_major, ..] => Some(*legacy_major), - [major, ..] => Some(*major), - [] => None, +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()); } } @@ -87,16 +86,29 @@ async fn check_java_version(java: &str, required: Option) -> Result<(), Lau 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), @@ -104,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 { @@ -171,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)) } @@ -274,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)) } @@ -306,6 +324,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, } @@ -363,6 +382,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 @@ -384,6 +410,7 @@ pub async fn build_launch_invocation( let current_features = FeatureSet { is_quick_play_singleplayer: quick_play_world.map(|_| true), + has_custom_resolution: resolution.map(|_| true), ..Default::default() }; let host_os_version = system::mojang_os_version(); @@ -403,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 @@ -539,6 +561,7 @@ pub async fn build_launch_invocation( .clone() .or_else(|| { crate::config::SETTINGS + .read() .paths .effective_java_path() .map(str::to_owned) @@ -560,6 +583,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 = resolution.map(|(width, _)| width.to_string()); + let resolution_height = resolution.map(|(_, height)| height.to_string()); let template_ctx = TemplateContext { library_directory, classpath_separator: sep, @@ -580,18 +605,43 @@ 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, resolution); + apply_window_mode(&mut game_args, window_mode); + + let (memory_min, memory_max, global_jvm_args, global_environment) = { + 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()), + 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, @@ -601,10 +651,94 @@ pub async fn build_launch_invocation( main_class, extra_args, game_args, + environment, 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, @@ -619,7 +753,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())); }; @@ -673,6 +809,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, @@ -689,6 +826,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); @@ -721,6 +876,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()); @@ -729,6 +885,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)); } @@ -751,6 +914,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 @@ -852,15 +1019,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/loader/forge.rs b/src/instance/loader/forge.rs index 0959912..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,11 +96,14 @@ impl ModLoaderInstaller for ForgeInstaller { } } else { // modern forge: run the java installer - let java_path = crate::config::SETTINGS - .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 7be7494..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,11 +76,14 @@ impl ModLoaderInstaller for NeoForgeInstaller { neoforge_api::download_neoforge_installer(client, loader_version, &installer_jar).await?; - let java_path = crate::config::SETTINGS - .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 9968033..482928d 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?; @@ -262,6 +246,7 @@ impl InstanceManager { } })?; + let defaults = crate::config::SETTINGS.read().defaults.clone(); let config = InstanceConfig { name: name.to_string(), game_version: game_version.to_string(), @@ -273,7 +258,15 @@ impl InstanceManager { memory_max: None, memory_min: None, jvm_args: vec![], - resolution: None, + environment: Default::default(), + 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(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }; @@ -306,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 @@ -327,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?; @@ -347,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(); @@ -382,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!( @@ -407,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); @@ -441,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!( @@ -453,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(()) @@ -559,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, @@ -587,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/mod.rs b/src/instance/mod.rs index 7aa53c4..0265d4f 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, ModLoader, WindowMode, normalize_memory_value}; diff --git a/src/instance/models.rs b/src/instance/models.rs index 5bd456b..323721a 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, @@ -40,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, @@ -48,14 +85,97 @@ pub struct InstanceConfig { pub memory_min: Option, #[serde(default)] pub jvm_args: Vec, - #[serde(default)] + #[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, skip_serializing_if = "is_false")] + pub inherit_window_mode: bool, + #[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, + 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, + deserialize_with = "deserialize_optional_non_empty_string", + skip_serializing_if = "Option::is_none" + )] + pub glfw_path: Option, #[serde(default)] pub config_sync_profile: Option, #[serde(default)] 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 +} + +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() + .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() { @@ -84,6 +204,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/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/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index c6b0bf5..77d60bf 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -244,7 +244,15 @@ fn instance() -> InstanceConfig { memory_max: None, memory_min: None, 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(), + 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..05bf6b1 100644 --- a/src/instance/tests/content/reconcile.rs +++ b/src/instance/tests/content/reconcile.rs @@ -49,7 +49,15 @@ fn job(name: &str) -> ReconcileJob { memory_max: None, memory_min: None, 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(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }, 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/launch/pipeline.rs b/src/instance/tests/launch/pipeline.rs index dc7c75f..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] @@ -57,6 +60,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 +94,145 @@ 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); +} + +#[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(), + 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, + }; + 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 @@ -180,7 +324,15 @@ 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(), + 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, }; @@ -229,7 +381,15 @@ 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(), + 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, }; 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 diff --git a/src/instance/tests/manager.rs b/src/instance/tests/manager.rs index c33ab4e..16c8216 100644 --- a/src/instance/tests/manager.rs +++ b/src/instance/tests/manager.rs @@ -27,7 +27,15 @@ fn dummy_config(name: &str) -> InstanceConfig { memory_max: None, memory_min: None, 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(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } @@ -44,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] @@ -53,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(); @@ -161,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(); diff --git a/src/instance/tests/models.rs b/src/instance/tests/models.rs index 62a41c8..77d17e8 100644 --- a/src/instance/tests/models.rs +++ b/src/instance/tests/models.rs @@ -16,7 +16,15 @@ 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(), + inherit_window_mode: false, resolution: Some((1920, 1080)), + 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: Some(crate::instance::ProviderProject { provider: "modrinth".to_owned(), @@ -47,6 +55,57 @@ 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!(!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] @@ -76,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); @@ -84,3 +185,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/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/storage.rs b/src/storage.rs index 81b42d3..914905d 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) } @@ -137,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/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/tests/storage.rs b/src/tests/storage.rs index ae50dc3..9d06657 100644 --- a/src/tests/storage.rs +++ b/src/tests/storage.rs @@ -32,12 +32,34 @@ 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") ); } +#[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/app.rs b/src/tui/app.rs index f25793b..fd7d7e8 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -19,6 +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, @@ -42,7 +47,11 @@ pub struct App { 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) 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, @@ -89,6 +98,8 @@ pub enum FocusedArea { ImportPopup, ErrorPopup, ConfirmDelete, + InstanceSettings, + GlobalSettings, } impl App { @@ -114,15 +125,19 @@ impl App { supported } - 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(); + 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(); let _ = std::fs::create_dir_all(&instances_dir); let _ = std::fs::create_dir_all(&meta_dir); 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); @@ -169,7 +184,10 @@ 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()), + settings_state, + instance_settings: None, + pending_instance_settings_updates: HashSet::new(), + global_settings: None, screenshots_state: { let mut s = widgets::screenshots_grid::ScreenshotsState::default(); let font_size = picker.font_size(); @@ -177,6 +195,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/event.rs b/src/tui/event.rs index b1d06f9..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, 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,8 @@ 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(); if let Some(state) = self.modpack_versions_state.as_mut() { @@ -209,6 +214,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 @@ -415,7 +422,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, @@ -505,7 +512,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 +579,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 +615,63 @@ 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 completed_updates = COMPLETED_INSTANCE_SETTINGS_UPDATES.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(); + 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, + message: format!("Failed to update instance '{}': {error}", previous.name), + pushed_at: std::time::Instant::now(), + }); + return; + } + }; + + 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, + level: tracing::Level::ERROR, + message: format!("Instance saved, but shortcut update failed: {error}"), + pushed_at: std::time::Instant::now(), + }); + } + if let Ok(mut pending) = completed_updates.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 +753,53 @@ 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(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, + 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 @@ -725,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) => { @@ -776,7 +893,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(); } @@ -786,11 +903,73 @@ impl App { } fn drain_pending_instances(&mut self) { - if let Ok(mut pending) = PENDING_INSTANCES.lock() { - for config in pending.drain(..) { - self.forget_instance_content(&config.name); - widgets::instances::spawn_modpack_update_check(&config); - 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) { + 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); } } } @@ -840,6 +1019,56 @@ 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?; + + if crate::instance::runtime::is_active(&previous.name) { + color_eyre::eyre::bail!("instance started while its runtime was being updated"); + } + + 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 f3be45b..ae47789 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -205,7 +205,34 @@ 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()) + { + 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!( + "Failed to reset preferred account for '{}': {error}", + instance.name + ), + ); + } else { + *instance = updated; + } + } + } + } if count > 1 { self.account_state.list_state.selected = Some(index.min( self.account_state.store.accounts.len().saturating_sub(1), @@ -223,6 +250,8 @@ impl App { message: e.to_string(), pushed_at: std::time::Instant::now(), }); + } else { + self.settings_state.remove_profile(&profile); } FocusedArea::Settings } @@ -259,6 +288,83 @@ 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::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(); + 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", + ); + } + 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, + .. + }) => { + 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); + } + } + 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, + )) = action + { + self.apply_global_settings(*config); + } + } + } + self.focused + } None => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -275,6 +381,28 @@ impl App { Some(confirm_popup::ConfirmTarget::ConfigProfile { .. }) => { FocusedArea::Settings } + Some(confirm_popup::ConfirmTarget::InstanceRuntime { .. }) => { + 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 + } + Some(confirm_popup::ConfirmTarget::LauncherCache) => { + FocusedArea::GlobalSettings + } + Some(confirm_popup::ConfirmTarget::AutomaticSelection { + instance, .. + }) => { + if instance.is_some() { + FocusedArea::InstanceSettings + } else { + FocusedArea::GlobalSettings + } + } _ => FocusedArea::Instances, }; confirm_popup::clear_pending(); @@ -561,64 +689,56 @@ impl App { &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) => { - self.pending_editor = Some(path); + widgets::settings::SettingsAction::OpenInstance => { + self.open_instance_settings(FocusedArea::Settings); return Ok(()); } - 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) => { - 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(e) => { - tracing::error!("Failed to toggle desktop shortcut: {}", e); - } - } - } + widgets::settings::SettingsAction::OpenGlobal => { + self.pre_overlay_focused = FocusedArea::Settings; + self.global_settings = Some( + widgets::popups::global_settings::State::with_detected_image_protocol( + self.detected_image_protocol, + ), + ); + self.focused = FocusedArea::GlobalSettings; 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); + 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( - &inst.name, - inst.config_sync_profile.as_deref(), + &instance.name, + instance.config_sync_profile.as_deref(), profile.as_deref(), &self.instance_manager.meta_dir, &instance_dir, ) { Ok(selected) => { - let mut updated = inst.clone(); + let mut updated = instance.clone(); updated.config_sync_profile = selected; - if let Err(e) = self.instance_manager.save(&updated) { - tracing::error!("Failed to save config profile: {}", e); + if let Err(error) = self.instance_manager.save(&updated) { + tracing::error!("Failed to save config profile: {error}"); } else { - self.instances_state.replace_instance(&inst.name, updated); + self.instances_state + .replace_instance(&instance.name, updated); } } - Err(e) => { + Err(error) => { 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(), }); } @@ -649,6 +769,111 @@ impl App { } } + 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::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::ConfirmJavaAuto => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::AutomaticSelection { + setting: confirm_popup::AutomaticSetting::Java, + instance: None, + }); + 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; + } + widgets::popups::global_settings::Action::OpenRaw(path) => { + self.pending_editor = Some(path); + self.global_settings = None; + self.focused = self.pre_overlay_focused; + } + widgets::popups::global_settings::Action::Save(config) => { + self.apply_global_settings(*config); + } + } + 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::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::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; + } + 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::Save(updated, desktop) => { + self.apply_instance_settings(*updated, desktop); + } + widgets::popups::instance_settings::Action::ConfirmRuntime { name } => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::InstanceRuntime { + name, + }); + self.focused = FocusedArea::ConfirmDelete; + } + 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::ConfirmAccountAuto { name } => { + confirm_popup::set_pending(confirm_popup::ConfirmTarget::AutomaticSelection { + setting: confirm_popup::AutomaticSetting::Account, + instance: Some(name), + }); + self.focused = FocusedArea::ConfirmDelete; + } + } + return Ok(()); + } + match self.focused { FocusedArea::Popup => { new_instance::handle_key(&key_event, &mut self.instances_state); @@ -732,6 +957,18 @@ 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') => { + self.open_instance_settings(self.focused); + } + KeyCode::Char('G') => { + self.pre_overlay_focused = self.focused; + self.global_settings = Some( + widgets::popups::global_settings::State::with_detected_image_protocol( + self.detected_image_protocol, + ), + ); + self.focused = FocusedArea::GlobalSettings; + } KeyCode::Char('O') => { self.pre_overlay_focused = self.focused; self.focused = FocusedArea::OverviewExpanded; @@ -779,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 @@ -834,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 @@ -874,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; }; @@ -884,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; }; @@ -1783,6 +2058,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 +2086,6 @@ impl App { } crate::instance::config_sync::delete_profile(&self.instance_manager.meta_dir, profile)?; - self.settings_state.remove_profile(profile); Ok(()) } @@ -1918,6 +2201,168 @@ 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) { + let result = crate::config::SETTINGS.save_launcher_settings(config); + match result { + 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 && outcome.restart_changed { + error_buffer::push_message( + tracing::Level::INFO, + "Restart rmcl to apply storage and image changes", + ); + } + crate::feedback::request_redraw(); + } + 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()); + } + } + } + + pub(super) fn apply_instance_settings( + &mut self, + mut 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(), + }); + if let Some(state) = self.instance_settings.as_mut() { + let remaining = state.cancel_runtime_change(); + if let Some((updated, desktop)) = remaining { + self.apply_instance_settings(*updated, desktop); + } + } + return; + } + self.pending_instance_settings_updates + .insert(previous.name.clone()); + self.spawn_instance_settings_update(previous, updated, desktop); + if let Some(state) = self.instance_settings.as_mut() { + state.mark_runtime_update_pending(); + } + 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 = crate::instance::desktop::set_enabled(&updated, desktop); + 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, saved_desktop); + } + } + 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(), + }); + } + } + } + + 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 ac1bb29..f79d343 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -49,7 +49,8 @@ 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::Auto => detected_protocol, crate::config::settings::ImageProtocol::Halfblocks | crate::config::settings::ImageProtocol::Quadrants => { ratatui_image::picker::ProtocolType::Halfblocks @@ -68,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(()), } @@ -106,13 +107,20 @@ 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(); + 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 { diff --git a/src/tui/render.rs b/src/tui/render.rs index 44db0a8..6218de9 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -137,7 +137,6 @@ impl App { self.focused, &mut self.settings_state, self.instances_state.selected_instance(), - &self.instance_manager.instances_dir, ); widgets::status::render( frame, @@ -150,6 +149,20 @@ impl App { self.render_log_overlay(frame); } + if self.focused == FocusedArea::InstanceSettings + && 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_mut() + { + let area = widgets::popups::global_settings::popup_rect(frame.area(), state); + 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); @@ -331,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, @@ -343,9 +356,13 @@ 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, 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 @@ -430,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/event.rs b/src/tui/tests/event.rs index 377157c..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,288 @@ 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(); + 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(); + 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"); + 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}; + 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(), + 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(), + glfw_path: 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", + "arguments": { "game": [], "jvm": [] }, + "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(), + 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(), + glfw_path: 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..ee54a81 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); @@ -562,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')); @@ -571,22 +574,504 @@ 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] -fn settings_profile_can_be_created_from_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('a')); - for character in "qConfig".chars() { + 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("Instance Settings *")); + assert!(ui.screen().contains("settings-test")); + 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("● enabled")); + assert!(!ui.screen().contains("Integration")); + assert!(!ui.screen().contains('▰')); + 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); + for _ in 0..6 { + ui.key(KeyCode::Down); + } + ui.key(KeyCode::Enter); + for character in "-Xfoo".chars() { ui.key(KeyCode::Char(character)); } + 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::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("Appearance")); + assert!(ui.screen().contains("Image rendering")); + assert!(ui.screen().contains("Memory max")); + assert!(ui.screen().contains('◆')); + assert!(!ui.screen().contains('█')); + assert!(!ui.screen().contains('▰')); ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("Theme")); + ui.key(KeyCode::Esc); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::Settings); +} + +#[test] +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')); + + 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..24 { + 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(); + 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(); + + for _ in 0..5 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Char('l')); + + 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("Target:")); + assert!( + 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" + }) + ); - assert!(!ui.app.exit); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); + ui.key(KeyCode::Esc); assert_eq!(ui.app.focused, FocusedArea::Settings); - assert_eq!(ui.app.settings_state.profiles, ["qConfig"]); +} + +#[test] +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..7 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Char('d')); + + assert_eq!(ui.app.focused, FocusedArea::InstanceSettings); + assert_eq!( + ui.app + .instance_settings + .as_ref() + .unwrap() + .draft + .jvm_args + .len(), + 2 + ); + ui.draw(); + assert!(!ui.screen().contains("[d] clear")); +} + +#[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..4 { + 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::Java, + instance: Some(name), + .. + }) if name == "java-auto-test" + )); + ui.draw(); + assert!(ui.screen().contains("Enable automatic Java")); + 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); + 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 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(); + 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 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..4 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("Java Runtime")); + 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); + + 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!( + ui.app + .instance_settings + .as_ref() + .unwrap() + .draft + .memory_min + .as_deref(), + Some("1G") + ); + assert_eq!( + ui.app + .instances_state + .selected_instance() + .unwrap() + .memory_min + .as_deref(), + Some("1G") + ); + + for _ in 0..6 { + 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("Preset")); + assert!(!ui.screen().contains("Inherit")); + assert!(!ui.screen().contains("custom")); + ui.key(KeyCode::Esc); + ui.key(KeyCode::Esc); + + ui.key(KeyCode::Char('G')); + for _ in 0..6 { + ui.key(KeyCode::Char('j')); + } + ui.key(KeyCode::Enter); + ui.draw(); + assert!(ui.screen().contains("Java Runtime")); + assert!(!ui.screen().contains("auto")); + assert!(!ui.screen().contains("Automatic")); + ui.key(KeyCode::Esc); + 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(); + 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] diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index 227914c..3f3753a 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -26,6 +26,18 @@ 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(); + 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 @@ -83,7 +95,11 @@ impl UiHarness { logs_state: widgets::logs_viewer::LogsState::default(), account_state, settings_state: widgets::settings::SettingsState::new(meta_dir), + instance_settings: None, + 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, @@ -127,7 +143,15 @@ impl UiHarness { memory_max: None, memory_min: None, 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(), + glfw_path: None, config_sync_profile: None, modpack_source: None, }; 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__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/content/discovery.rs b/src/tui/tests/widgets/content/discovery.rs index 3869577..82c2272 100644 --- a/src/tui/tests/widgets/content/discovery.rs +++ b/src/tui/tests/widgets/content/discovery.rs @@ -43,7 +43,15 @@ fn instance(name: &str, version: &str) -> InstanceConfig { memory_max: None, memory_min: None, 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(), + 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..29253a1 100644 --- a/src/tui/tests/widgets/instances.rs +++ b/src/tui/tests/widgets/instances.rs @@ -47,7 +47,15 @@ fn synthetic_instance(name: &str) -> InstanceConfig { memory_max: None, memory_min: None, 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(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } diff --git a/src/tui/tests/widgets/popups/error/area.rs b/src/tui/tests/widgets/popups/error/area.rs index dd40919..02f7f7e 100644 --- a/src/tui/tests/widgets/popups/error/area.rs +++ b/src/tui/tests/widgets/popups/error/area.rs @@ -11,10 +11,22 @@ 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()); } +#[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/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/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/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/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..ca0abef 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(); @@ -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 87ba426..35450b5 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(" "), ]; @@ -458,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), )); @@ -504,7 +506,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 +676,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, @@ -992,7 +1000,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/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/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/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/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/confirm.rs b/src/tui/widgets/popups/confirm.rs index 93d1a7f..2ad0189 100644 --- a/src/tui/widgets/popups/confirm.rs +++ b/src/tui/widgets/popups/confirm.rs @@ -45,12 +45,45 @@ pub enum ConfirmTarget { OrphanDependencies { paths: Vec, }, + InstanceRuntime { + name: String, + }, + LauncherCache, + AutomaticSelection { + setting: AutomaticSetting, + instance: Option, + }, +} + +#[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::LauncherCache => " Clear caches ".to_owned(), + Self::AutomaticSelection { setting, .. } => setting.title().to_owned(), _ => format!(" Delete '{}' ", self.name()), } } @@ -89,6 +122,13 @@ impl ConfirmTarget { }) .collect::>() .join("\n"), + 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(), } } @@ -99,6 +139,11 @@ impl ConfirmTarget { ConfirmTarget::ConfigProfile { profile } => profile, 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") + } } } @@ -106,6 +151,9 @@ impl ConfirmTarget { match self { 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/error.rs b/src/tui/widgets/popups/error.rs index 33e0283..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.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; } diff --git a/src/tui/widgets/popups/global_settings.rs b/src/tui/widgets/popups/global_settings.rs new file mode 100644 index 0000000..5b77d1f --- /dev/null +++ b/src/tui/widgets/popups/global_settings.rs @@ -0,0 +1,1625 @@ +// 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::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, ListItem, Paragraph}, +}; +use ratatui_textarea::TextArea; + +use crate::{ + config::{ + Config, + settings::{ContentProvider, DEFAULT_RESOLUTION, ImageProtocol, ShortcutHintScope}, + theme::{BORDER_STYLE, BorderStyle, THEME, ThemeConfig}, + }, + 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, + 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, +}; + +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); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChoicePicker { + ImageProtocol, + Resolution, + ShortcutHints, +} + +pub struct State { + pub config: Config, + pub theme: ThemeConfig, + selected: usize, + editing: Option>, + error: Option, + save_pending: bool, + themes: Vec, + theme_picker: bool, + theme_index: usize, + java_picker_open: bool, + java_picker: JavaPicker, + choice_picker: Option, + settings_picker: SettingsPicker, + choice_index: usize, + display_resolutions: Vec, + detected_image_protocol: &'static str, +} + +pub enum Action { + None, + Save(Box), + Error(String), + ConfirmJavaAuto, + ClearCache, + OpenRaw(std::path::PathBuf), + Close, +} + +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 + .iter() + .position(|candidate| candidate == &theme.theme) + .unwrap_or(0); + 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.set_current(config.paths.java_path.as_deref()); + Self { + config, + theme, + selected: 0, + editing: None, + error: None, + save_pending: false, + themes, + theme_picker: false, + theme_index, + java_picker_open: false, + java_picker, + choice_picker: None, + 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", + }, + } + } + + fn value(&self, field: usize) -> String { + match field { + 0 => self.theme.theme.clone(), + 1 => format!("{:?}", self.theme.border_style).to_lowercase(), + 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 => 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), + 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(), + 24 => shortcut_hint_mode(&self.config.ui).to_owned(), + _ => String::new(), + } + } + + 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 + .java_path + .as_deref() + .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() => { + 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), + } + } + + 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(settings_text_area(editor.lines().to_vec())); + }; + match self.selected { + 3 | 4 if normalize_memory_value(value).is_none() => invalid( + self, + "Use a positive memory value ending in K, M, or G.".to_owned(), + ), + 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) { + self.config.defaults.memory_max = value; + } + self.save_pending = true; + } + 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) { + self.config.defaults.memory_min = value; + } + self.save_pending = true; + } + 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 => 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; + 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()), + }, + _ => {} + } + } + + fn select_theme(&mut self) { + 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; + self.theme_index = self + .themes + .iter() + .position(|candidate| candidate == &self.theme.theme) + .unwrap_or(0); + } + } + + 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; + 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); + } + KeyCode::Char('k') | KeyCode::Up => { + self.theme_index = self.theme_index.saturating_sub(1); + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { + self.select_theme(); + if self.error.is_none() { + self.theme_picker = false; + } + } + _ => {} + } + } + + fn cycle_border(&mut self, forward: bool) { + let previous = self.theme.border_style.clone(); + 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( + self.theme.theme.clone(), + self.theme.border_style.clone(), + ) { + self.error = Some(error.to_string()); + self.theme.border_style = previous; + } + } + + fn image_protocol_options(&self) -> 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, + }; + [ + ( + "kitty", + image_protocol_title(ImageProtocol::Kitty), + "Use Kitty graphics when supported", + ), + ( + "iterm2", + image_protocol_title(ImageProtocol::Iterm2), + "Use the iTerm2 image protocol when supported", + ), + ( + "quadrants", + image_protocol_title(ImageProtocol::Quadrants), + "Render images with quadrant characters", + ), + ( + "halfblocks", + image_protocol_title(ImageProtocol::Halfblocks), + "Render images with half-block characters", + ), + ] + .into_iter() + .map(|(key, title, description)| option(key, title, description)) + .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: None, + leading: None, + badge: None, + active: visible, + } + }) + .collect() + } + + fn open_choice_picker(&mut self, picker: ChoicePicker) { + 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); + } + ChoicePicker::ShortcutHints => { + self.settings_picker.reset(); + self.settings_picker + .sync(self.shortcut_hint_options(), None); + } + } + 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.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 => { + let Some(picker) = self.choice_picker else { + return; + }; + 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, + "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.choice_picker = None; + } + SettingsPickerAction::None => {} + } + } + + fn resolution_choices(&self) -> Vec { + resolution_choices(self.config.defaults.resolution, &self.display_resolutions) + } + + fn apply_default_resolution(&mut self) { + let resolution = Some(default_resolution()); + if self.config.defaults.resolution != resolution { + self.config.defaults.resolution = resolution; + self.save_pending = true; + } + self.error = None; + } + + 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 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()); + self.java_picker.initialize(); + self.java_picker_open = true; + } + + 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 self.java_picker.automatic_change(current) { + return Action::ConfirmJavaAuto; + } + 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())) + } + + 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) { + self.java_picker.initialize(); + 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) { + self.config.paths.java_path = Some(path); + self.save_pending = true; + } + } + } + self.java_picker_open = false; + } + SettingsPickerAction::None => {} + } + } + + 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 { + &self.config.defaults.memory_max + }; + let value = adjust_memory(value, forward); + 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; + } + } 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.save_pending |= self.config.defaults.memory_min != previous_min + || self.config.defaults.memory_max != previous_max; + 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) { + 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; + self.config = self.config.clone().normalize(); + return Action::Save(Box::new(self.config.clone())); + } + Action::None + } + + 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; + } + if self.theme_picker { + self.handle_theme_picker_key(key); + return Action::None; + } + if let Some(input) = &mut self.editing { + match key.code { + KeyCode::Enter => self.commit_edit(), + KeyCode::Esc => self.editing = None, + _ => handle_text_area_input(input, key), + } + return Action::None; + } + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + 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('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) => { + self.adjust_selected_memory(false); + } + 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::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), + 2 => self.open_choice_picker(ChoicePicker::ImageProtocol), + 3 | 4 => { + self.editing = Some(settings_text_area(vec![self.value(self.selected)])); + } + 5 => self.open_java_picker(), + 6 => self.cycle_window_mode(), + 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)])), + }, + 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)])); + } + 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.apply_default_resolution(); + } + 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 => return Action::Close, + _ => {} + } + Action::None + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +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 +} + +pub fn popup_rect(area: Rect, state: &State) -> Rect { + let form_width = (area.width * 86 / 100).saturating_sub(2); + 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), + 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 { + form_height + }; + let width = match state.choice_picker { + Some(ChoicePicker::Resolution) => 64, + Some(ChoicePicker::ImageProtocol | ChoicePicker::ShortcutHints) => 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)), + ) +} + +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.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) { + super::keybind_line(&[("h/l", " adjust"), ("Enter", " exact"), ("Esc", " back")]) + } else if state.selected == 5 { + super::keybind_line(&[ + ("Enter", " runtimes"), + ("a", " auto"), + ("c", " custom"), + ("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"), + ("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(&[ + ("h/l", " adjust"), + ("Enter", " themes"), + ("E", " raw"), + ("Esc", " back"), + ]) + } else if state.selected == 1 { + super::keybind_line(&[ + ("h/l", " adjust"), + ("Enter", " next"), + ("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", ""), + ("Enter", " edit"), + ("E", " raw"), + ("Esc", " back"), + ]) + }; + let title = if state.theme_picker { + " Theme " + } else if state.java_picker_open { + " Java Runtime " + } else if let Some(picker) = state.choice_picker { + match picker { + ChoicePicker::ImageProtocol => " Image Rendering ", + ChoicePicker::Resolution => " Default Resolution ", + ChoicePicker::ShortcutHints => " Shortcut Guides ", + } + } else { + " Launcher Settings " + }; + let 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()); + let inner = block.inner(area); + frame.render_widget(block, area); + if state.theme_picker { + render_picker(frame, inner, &state.themes, state.theme_index); + return; + } + if state.java_picker_open { + render_java_picker(frame, inner, state); + return; + } + if state.choice_picker.is_some() { + 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); +} + +fn render_picker(frame: &mut Frame, area: Rect, values: &[String], selected: usize) { + let theme = THEME.as_ref(); + let items = values + .iter() + .map(|name| { + ListItem::new(Line::from(Span::styled( + name.clone(), + Style::default().fg(theme.text()), + ))) + }) + .collect(); + super::select_list::render(items, selected, area, frame.buffer_mut()); +} + +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.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), + } + } + render_settings_picker(state.java_picker.selection(), list_area, frame.buffer_mut()); +} + +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, 24]), + ("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, + ), + 23 => vec![maintenance_line(state)], + _ => vec![global_field_line(state, *index, label)], + }; + rows.push((Some(*index), lines)); + } + } + + let mut row_start = 0u16; + for (field, lines) in rows { + let height = lines.len() as u16; + if row_start >= area.height { + break; + } + 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() + .take(visible_height as usize) + .collect::>(), + ) + .style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })), + Rect { + y, + height: visible_height, + ..area + }, + ); + 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), + width: area.width.saturating_sub(21), + height: 1, + }, + &value, + value.clone(), + state.selected == field, + ); + } + if field == Some(state.selected) + && 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), + width: area.width.saturating_sub(20), + height: 1, + }, + ); + } + 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()), + ), + Span::styled( + "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 { + theme.text_dim() + }), + ), + ]) +} + +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 => "Preferred 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 => "", + 24 => "Shortcut guides", + _ => "", + } +} + +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(); + let mut spans = vec![ + Span::styled( + if selected { "▌ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{label:<18}"), + Style::default().fg(theme.text_dim()), + ), + 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 { + state.display_value(index) + }, + Style::default() + .fg(if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ]; + 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 == 7 && !editing && is_default_resolution(state.config.defaults.resolution) { + spans.extend([Span::raw(" "), default_label()]); + } + if index == 10 && !editing { + spans.push(Span::raw(" ")); + spans.push(provider_badge(state.config.content.preferred_provider)); + } + 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 { + theme.surface() + })) +} + +fn border_preview(style: &BorderStyle) -> &'static str { + match style { + BorderStyle::Plain => "┌─", + BorderStyle::Rounded => "╭─", + BorderStyle::Double => "╔═", + BorderStyle::Thick => "┏━", + } +} + +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", + 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 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() +} + +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::*; + use crate::instance::models::WindowMode; + + #[test] + fn launcher_memory_uses_slider_and_java_uses_picker() { + let mut state = State::new(); + state.selected = 3; + let original = state.config.defaults.memory_min.clone(); + 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()); + + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert!(state.editing.is_some()); + + state.editing = None; + state.selected = 5; + 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 = 5; + 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); + } + + #[test] + fn java_picker_does_not_toggle_auto_mode() { + let mut state = State::new(); + state.selected = 5; + 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 = 5; + 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") + ); + } + + #[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)); + 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_eq!(state.display_value(2), "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; + 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 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 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(); + 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()) + ); + } + + #[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, + 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((854, 480))); + 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!( + state.resolution_choices().first(), + Some(ResolutionChoice::Display(display)) if display.name == "DP-4" + )); + } + + #[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); + 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 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(); + 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] + fn expanded_form_keeps_all_sections_visible_without_scrolling() { + 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 + .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("Kitty")); + 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); + } +} 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..48e0905 --- /dev/null +++ b/src/tui/widgets/popups/instance_settings.rs @@ -0,0 +1,2372 @@ +// 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}; +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, ListItem, Paragraph}, +}; +use ratatui_textarea::TextArea; +use std::sync::{Arc, Mutex}; + +use crate::{ + auth::{Account, AccountType}, + config::{ + SETTINGS, + theme::{BORDER_STYLE, THEME}, + }, + instance::loader::GameVersion, + instance::models::{ + InstanceConfig, LaunchCommand, ModLoader, memory_kib, normalize_memory_value, + parse_resolution, + }, + tui::widgets::{ + popups::{ + LoadState, + settings_controls::{ + DisplayResolution, GlfwChoice, GlfwPicker, JavaChoice, JavaPicker, + 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, 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, + }, + }, + search::SearchState, + status_badge, + }, +}; + +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)] +enum VersionPicker { + Game, + Loader, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChoicePicker { + Loader, + Java, + Resolution, + Account, + Glfw, +} + +enum PickerLoad { + Idle, + Loading, + Loaded, + Error, +} + +#[derive(Debug, Clone)] +pub struct State { + original: InstanceConfig, + pub draft: InstanceConfig, + selected: usize, + editing: Option>, + pub desktop: bool, + original_desktop: bool, + error: Option, + picker: Option, + picker_index: usize, + picker_initialized: bool, + picker_search: SearchState, + show_snapshots: bool, + game_versions: SharedLoad>, + loader_versions: SharedLoad>, + 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, + save_retry_pending: bool, +} + +pub enum Action { + None, + Save(Box, bool), + Error(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 { + 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.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, + original_desktop: desktop, + error: None, + picker: None, + picker_index: 0, + picker_initialized: 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, + 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, + save_retry_pending: false, + } + } + + 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; + let settings = SETTINGS.read(); + if self.draft.game_version.trim().is_empty() { + self.error = Some("Game version is required.".to_owned()); + } else if self.draft.loader != ModLoader::Vanilla + && self + .draft + .loader_version + .as_deref() + .is_none_or(str::is_empty) + { + self.error = Some("Select a loader version.".to_owned()); + } else if let (Some(min), Some(max)) = ( + 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()); + } 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() + } + + 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 => 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) + .map(|(w, h)| format!("{w}x{h}")) + .unwrap_or_default(), + 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(), + } + } + + fn display_value(&self, field: usize) -> String { + match field { + 2 if self.draft.loader == ModLoader::Vanilla => "not applicable".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.environment.is_empty() => "no variables".to_owned(), + 7 => self.value(7), + 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(), + 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(), + 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(), + 3 => self.open_choice_picker(ChoicePicker::Java), + 4 | 5 => { + 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 => { + let current = self + .draft + .effective_window_mode(SETTINGS.read().defaults.window_mode); + self.draft.inherit_window_mode = false; + self.draft.window_mode = toggle_window_mode(current); + } + 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)])); + } + 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); + 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(); + } + ChoicePicker::Resolution => { + let resolution = self + .draft + .effective_resolution(SETTINGS.read().defaults.resolution); + self.choice_index = self + .resolution_choices() + .iter() + .position(|choice| choice.resolution() == 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 { + 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 => super::select_list::MOD_LOADERS + .iter() + .map(ToString::to_string) + .collect(), + ChoicePicker::Java => self.java_picker.labels(), + ChoicePicker::Resolution => self + .resolution_choices() + .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::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(); + 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 { + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => 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 | KeyCode::Char('l') | KeyCode::Right => self.apply_choice(), + _ => {} + } + } + + 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; + 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)); + open_loader_versions = loader != ModLoader::Vanilla; + } + } + 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() + .get(self.choice_index) + .and_then(ResolutionChoice::resolution); + if let Some(resolution) = selected { + self.draft.resolution = Some(resolution); + self.draft.inherit_resolution = false; + } + } + 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; + if open_loader_versions { + self.open_loader_picker(); + } + } + + fn open_game_picker(&mut self) { + self.picker = Some(VersionPicker::Game); + self.picker_index = 0; + self.picker_initialized = false; + self.picker_search.deactivate(); + 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; + 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; + } + } + } + + fn open_loader_picker(&mut self) { + self.picker = Some(VersionPicker::Loader); + self.picker_index = 0; + self.picker_initialized = false; + self.picker_search.deactivate(); + 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(); + 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) => { + crate::feedback::errors::push_message( + tracing::Level::ERROR, + format!("Failed to load loader versions: {error}"), + ); + LoadState::Error(error) + } + }; + crate::feedback::request_redraw(); + }); + } else { + *load = LoadState::Idle; + } + } + } + + fn visible_game_versions(&self) -> Vec { + 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| self.picker_search.matches(&version.id)) + .cloned() + .collect(), + _ => Vec::new(), + } + } + + fn visible_loader_versions(&self) -> Vec { + match &*self + .loader_versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(versions) => versions + .iter() + .filter(|version| self.picker_search.matches(version)) + .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 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 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 + .effective_resolution(SETTINGS.read().defaults.resolution), + &self.display_resolutions, + ) + } + + 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) { + self.draft.resolution = Some(default_resolution()); + self.draft.inherit_resolution = false; + self.error = None; + } + + 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()); + self.error = None; + return Action::None; + }; + if self.java_picker.automatic_change(current) { + return Action::ConfirmJavaAuto { + name: self.draft.name.clone(), + }; + } + self.enable_auto_java(); + Action::None + } + + pub fn enable_auto_java(&mut self) { + self.draft.java_path = None; + 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() + && self.draft.loader_version.is_none(); + self.picker = None; + self.picker_search.deactivate(); + if cancel_runtime_change { + let _ = self.cancel_runtime_change(); + } + } + + fn handle_picker_key(&mut self, key: &KeyEvent) { + self.initialize_picker_index(); + if self.picker_search.active { + match key.code { + KeyCode::Esc => { + self.picker_search.deactivate(); + return; + } + KeyCode::Backspace => { + 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_search.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.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; + } + 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 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) { + 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; + if self.draft.loader != ModLoader::Vanilla { + self.open_loader_picker(); + } + } else { + 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(settings_text_area(editor.lines().to_vec())); + }; + match self.selected { + 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, + "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)), + 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), + }, + 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); + self.draft.inherit_resolution = false; + } + 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); + 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.save_retry_pending) + || !self.dirty() + { + return Action::None; + } + 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::Error( + self.error + .take() + .unwrap_or_else(|| "invalid instance settings".to_owned()), + ); + } + return Action::ConfirmRuntime { + name: self.draft.name.clone(), + }; + } + if self.validate_before_save() { + self.save_retry_pending = false; + Action::Save(Box::new(self.draft.clone()), self.desktop) + } else { + Action::Error( + self.error + .take() + .unwrap_or_else(|| "invalid instance settings".to_owned()), + ) + } + } + + fn handle_key_inner(&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.editing { + match key.code { + KeyCode::Enter => self.commit_edit(), + KeyCode::Esc => self.editing = None, + _ => handle_text_area_input(input, key), + } + return Action::None; + } + + match key.code { + 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); + } + KeyCode::Char('l') | KeyCode::Right if matches!(self.selected, 4 | 5) => { + self.adjust_selected_memory(true); + } + KeyCode::Enter => self.begin_edit(), + KeyCode::Char('d') if matches!(self.selected, 4 | 5) => { + 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)])); + } + KeyCode::Char('a') if self.selected == 3 => return self.toggle_auto_java(), + KeyCode::Char('c') if self.selected == 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, + _ => {} + } + Action::None + } + + pub fn confirmed_save(&mut self) -> Option<(Box, bool)> { + self.validate_before_save() + .then(|| (Box::new(self.draft.clone()), self.desktop)) + } + + 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) { + self.original = saved.clone(); + 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.save_retry_pending = false; + self.error = None; + } + + 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(); + 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; + (self.dirty() && self.validate_before_save()) + .then(|| (Box::new(self.draft.clone()), self.desktop)) + } +} + +pub fn popup_rect(area: Rect, state: &State) -> Rect { + 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 * 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 | ChoicePicker::Glfw) => 72, + Some(ChoicePicker::Resolution) => 64, + _ => 68, + }; + area.centered( + Constraint::Percentage(width), + Constraint::Length(height.min(area.height.saturating_sub(4))), + ) +} + +pub fn render(frame: &mut Frame, area: Rect, state: &mut State) { + if state.choice_picker == Some(ChoicePicker::Java) { + state.java_picker.initialize(); + } + 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); + 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", + }; + 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) { + 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 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")]) + } 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"), + ("d", " default"), + ("Esc", " back"), + ]) + } else if state.selected == 3 { + super::keybind_line(&[ + ("Enter", " runtimes"), + ("a", " auto"), + ("c", " custom"), + ("Esc", " back"), + ]) + } else if state.selected == 9 { + super::keybind_line(&[ + ("Enter", " presets"), + ("c", " custom"), + ("d", " default"), + ("i", " global"), + ("Esc", " back"), + ]) + } else if state.selected == 10 { + super::keybind_line(&[ + ("Enter", " accounts"), + ("a", " auto"), + ("E", " raw"), + ("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"), + ("i", " global"), + ("E", " raw"), + ("Esc", " back"), + ]) + } else if state.selected == 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", ""), + ("Enter", " edit"), + ("E", " raw"), + ("Esc", " back"), + ]) + }; + 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); + + 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; + } + + render_settings_list(frame, inner, state); +} + +fn render_settings_list(frame: &mut Frame, area: Rect, state: &State) { + let theme = THEME.as_ref(); + 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 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_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 + .into_iter() + .skip(skip) + .take(visible_height as usize) + .collect::>(), + ) + .style(Style::default().bg(if selected { + theme.stripe() + } else { + theme.surface() + })), + row_area, + ); + + 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: area.y.saturating_add(row_start.saturating_sub(scroll)), + width: area.width.saturating_sub(21), + height: 1, + }, + &value, + state.display_value(index), + 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(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, + }, + ); + } + 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..=7) { + String::new() + } else { + state.display_value(index) + }; + let mut spans = vec![ + Span::styled( + if selected { "▌ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{label:<18}"), + Style::default().fg(theme.text_dim()), + ), + Span::styled( + value, + Style::default() + .fg(if index == 11 { + if state.desktop { + theme.text() + } else { + theme.text_dim() + } + } else if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ]; + 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()]); + } + if index == 9 + && !editing + && !state.draft.inherit_resolution + && is_default_resolution(state.draft.resolution) + { + spans.extend([Span::raw(" "), default_label()]); + } + 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) +} + +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 selected = state.selected == index; + 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) { + let theme = THEME.as_ref(); + let mut list_area = area; + if state.choice_picker == Some(ChoicePicker::Java) + && 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), + } + } + 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::Resolution) => { + resolution_items(&state.resolution_choices(), state.choice_index) + } + Some(ChoicePicker::Java | ChoicePicker::Account | ChoicePicker::Glfw) => Vec::new(), + _ => state + .choice_values() + .iter() + .map(|value| { + ListItem::new(Line::from(Span::styled( + value.clone(), + Style::default().fg(theme.text()), + ))) + }) + .collect(), + }; + 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()); + } +} + +fn render_version_picker(frame: &mut Frame, area: Rect, state: &State) { + let theme = THEME.as_ref(); + 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(_) => PickerLoad::Error, + }, + 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(_) => PickerLoad::Error, + }, + None => return, + }; + match status { + PickerLoad::Idle | PickerLoad::Loading => frame.render_widget( + Paragraph::new("Loading versions...").style(Style::default().fg(theme.text_dim())), + area, + ), + PickerLoad::Error => frame.render_widget( + Paragraph::new("Reopen to retry").style(Style::default().fg(theme.text_dim())), + area, + ), + 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().into_iter().collect() + } + None => Vec::new(), + }; + if versions.is_empty() { + frame.render_widget(Paragraph::new("No matching versions."), area); + } else { + 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()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::instance::WindowMode; + 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(), + 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(), + glfw_path: 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(settings_text_area(vec!["2048m".to_owned()])); + state.commit_edit(); + assert_eq!(state.draft.memory_min.as_deref(), Some("2048M")); + + state.selected = 9; + state.editing = Some(settings_text_area(vec!["1920X1080".to_owned()])); + state.commit_edit(); + assert_eq!(state.draft.resolution, Some((1920, 1080))); + } + + #[test] + 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::Enter)); + 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 -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"]); + } + + #[test] + fn runtime_changes_require_confirmation_before_save() { + let temp = tempfile::tempdir().unwrap(); + let mut state = State::new(&instance(), temp.path()); + *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::Enter)), + Action::ConfirmRuntime { .. } + )); + assert!(state.confirmed_save().is_some()); + } + + #[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 popup_changes_do_not_replace_the_existing_config_profile() { + let temp = tempfile::tempdir().unwrap(); + let mut config = instance(); + config.config_sync_profile = Some("shared".to_owned()); + let mut state = State::new(&config, temp.path()); + state.selected = 11; + + let Action::Save(config, _) = state.handle_key(&KeyEvent::from(KeyCode::Enter)) else { + panic!("expected settings save"); + }; + + assert_eq!(config.config_sync_profile.as_deref(), Some("shared")); + } + + #[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()); + } + + #[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(); + let mut state = State::new(&instance(), temp.path()); + + state.selected = 1; + 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); + assert_eq!(state.picker, Some(VersionPicker::Loader)); + + state.picker = None; + state.selected = 11; + let desktop = state.desktop; + state.handle_key(&KeyEvent::from(KeyCode::Enter)); + assert_ne!(state.desktop, desktop); + } + + #[test] + 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.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()); + 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 = 9; + state.begin_edit(); + assert!(state.editing.is_none()); + assert_eq!(state.choice_picker, Some(ChoicePicker::Resolution)); + state.choice_index = state + .resolution_choices() + .iter() + .position(|choice| choice.resolution() == Some((1920, 1080))) + .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)); + 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()); + state.handle_choice_key(&KeyEvent::from(KeyCode::Esc)); + state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); + 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(); + let mut state = State::new(&instance(), temp.path()); + + state.selected = 3; + state.draft.java_path = Some("/custom/java".to_owned()); + 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 = 9; + 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, Some((854, 480))); + 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…")); + + state.handle_key(&KeyEvent::from(KeyCode::Esc)); + state.handle_key(&KeyEvent::from(KeyCode::Char('c'))); + assert!(state.editing.is_some()); + } + + #[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(); + 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(11); + 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[0], ResolutionChoice::Display(_))); + assert_eq!( + choices + .iter() + .filter(|choice| choice.resolution() == Some((1920, 1080))) + .count(), + 1 + ); + } + + #[test] + 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 { + width: 2560, + height: 1440, + name: "DP-4".to_owned(), + primary: true, + }]; + + 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), + window_mode_title(SETTINGS.read().defaults.window_mode) + ); + + 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] + 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(); + 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("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] + 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 = tagged_field_lines( + &state, + 6, + "JVM args", + &state.draft.jvm_args, + "no arguments", + 48, + ); + + assert!(lines.len() > 1); + 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/mod.rs b/src/tui/widgets/popups/mod.rs index 10f2794..9cf6889 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -7,15 +7,22 @@ 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(crate) mod select_list; +pub(crate) mod settings_controls; +pub mod version_lists; 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 @@ -80,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}, @@ -104,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 { @@ -116,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/popups/new_instance/mod.rs b/src/tui/widgets/popups/new_instance/mod.rs index 677f19b..97f7535 100644 --- a/src/tui/widgets/popups/new_instance/mod.rs +++ b/src/tui/widgets/popups/new_instance/mod.rs @@ -4,8 +4,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 098a558..7afe6aa 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; @@ -88,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 { @@ -405,12 +395,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 +429,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 +451,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/select_list.rs b/src/tui/widgets/popups/select_list.rs new file mode 100644 index 0000000..d7b3f22 --- /dev/null +++ b/src/tui/widgets/popups/select_list.rs @@ -0,0 +1,94 @@ +// 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()) + .bg(THEME.as_ref().stripe()) + .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); +} + +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 new file mode 100644 index 0000000..3d0b798 --- /dev/null +++ b/src/tui/widgets/popups/settings_controls.rs @@ -0,0 +1,1599 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +// Reusable interactive controls shared by instance and launcher settings. + +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{LineGauge, ListItem, Paragraph}, +}; +use ratatui_textarea::{CursorMove, TextArea}; + +use crate::{ + config::{settings::DEFAULT_RESOLUTION, theme::THEME}, + instance::{ + java::JavaInstallation, + models::{WindowMode, memory_kib}, + }, + tui::widgets::{popups::LoadState, status_badge}, +}; + +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, + } +} + +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, + 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, + picker: SettingsPicker, + cache_path: Option, + refresh_started: bool, + generation: Arc, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +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, + pub height: u32, + pub name: String, + 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 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()) + } + + 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( + cached.map_or(LoadState::Idle, LoadState::Loaded), + )), + current: None, + detected, + picker: SettingsPicker::default(), + cache_path, + refresh_started: false, + generation: Arc::new(AtomicU64::new(0)), + } + } + + 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; + } + 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(_)) { + *load = LoadState::Loading; + } + 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(); + 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); + } + } + 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}"); + } + *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; + } + } + } + + pub(crate) fn choices(&self) -> Vec { + let mut paths = Vec::new(); + if let LoadState::Loaded(installations) = &*self + .load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + paths.extend( + installations + .iter() + .map(|installation| installation.path.to_string_lossy().into_owned()), + ); + } + 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 { + 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() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + LoadState::Loaded(installations) => Some(installations.clone()), + _ => None, + }; + self.choices() + .into_iter() + .map(|choice| match choice { + JavaChoice::Installation(path) => { + let installation = installations.as_ref().and_then(|items| { + items + .iter() + .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(), 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), + } + } + }) + .collect() + } + + pub(crate) fn take_status(&mut self) -> Option> { + let mut load = self + .load + .lock() + .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) => { + let error = error.clone(); + *load = LoadState::Loaded(Vec::new()); + Some(Err(error)) + } + } + } + + pub(crate) fn initialize(&mut self) { + 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 { + 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 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) + } + + 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 { + 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(), + } + } + + 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); + 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(); + }; + 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::Idle; + } + } + + 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()), + _ => 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(), + }; + 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, + } + } +} + +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 { + 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(); + 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") +} + +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; + } + 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( + || format!("Java {path}"), + |version| format!("{} {path}", java_title(version)), + ) +} + +fn java_title(version: &str) -> String { + let major = crate::instance::java::java_major(Some(version)); + if major == 0 { + "Java".to_owned() + } else { + format!("Java {major}") + } +} + +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 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.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text()) + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(label, value_style))), + value_area, + ); + + let gauge = LineGauge::default() + .ratio(ratio) + .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( + if thumb_offset + 1 < line_area.width { + "◆ " + } else { + "◆" + }, + Style::default().fg(if selected { + theme.accent() + } else { + theme.text_dim() + }), + )), + Rect { + x: line_area.x.saturating_add(thumb_offset), + width: 2.min(line_area.width.saturating_sub(thumb_offset)), + ..line_area + }, + ); + } +} + +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 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 { + 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 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 parse_tag_values(value)? { + 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()) +} + +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().warning()) +} + +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(); + }; + 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 default_resolution() -> (u32, u32) { + DEFAULT_RESOLUTION +} + +pub(crate) fn is_default_resolution(resolution: Option<(u32, u32)>) -> bool { + resolution == Some(default_resolution()) +} + +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 [ + DEFAULT_RESOLUTION, + (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() + }), + )]; + if is_default_resolution(choice.resolution()) { + spans.extend([Span::raw(" "), default_label()]); + } + 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::*; + + #[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 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)))); + 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] + fn java_picker_preserves_semantic_selection_when_results_arrive() { + let mut picker = JavaPicker::new(); + picker.current = Some("/opt/jdk/bin/java".to_owned()); + picker.initialize(); + *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::Installation("/opt/jdk/bin/java".to_owned()) + ); + assert_eq!( + picker.display_label("/opt/jdk/bin/java"), + "Java 21 /opt/jdk/bin/java" + ); + } + + #[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()); + 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"); + assert_eq!(java_title("21-ea"), "Java 21"); + } + + #[test] + fn automatic_java_compares_selected_executables() { + let picker = JavaPicker::with_auto_path("/auto/java".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 + ); + 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] + 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_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_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") + ); + } + + #[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] + 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); + 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(), " "); + } + + #[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); + } +} 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..206cbe0 100644 --- a/src/tui/widgets/settings.rs +++ b/src/tui/widgets/settings.rs @@ -2,12 +2,9 @@ // 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. +// detailed instance and launcher configuration opens in the TUI popups. -use std::{ - path::{Path, PathBuf}, - process::Command, -}; +use std::path::{Path, PathBuf}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -53,7 +50,6 @@ pub struct SettingsState { active_profile: Option, instance_name: Option, java_key: Option, - java_source: Option, java_label: String, } @@ -72,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(); @@ -132,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; } } } @@ -151,39 +145,37 @@ fn java_path_key(instance: &InstanceConfig) -> String { .java_path .as_deref() .filter(|path| !path.is_empty()) - .or_else(|| SETTINGS.paths.effective_java_path()) - .unwrap_or("") - .to_string() + .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.paths.effective_java_path().map(str::to_string)) + .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 { + let Some(version) = crate::instance::java::java_version(Path::new(java_path)) 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}") @@ -196,7 +188,6 @@ pub fn render( focused: FocusedArea, state: &mut SettingsState, instance: Option<&InstanceConfig>, - _instances_dir: &Path, ) { state.update_for_instance(instance); @@ -224,14 +215,14 @@ pub fn render( ("Esc", " back"), ], SettingsPane::Info => &[ - ("e", " inst"), - ("g", " global"), - ("d", " desk"), + ("e", " instance"), + ("g", " launcher"), ("h/l", " tab"), ("Esc", " back"), ], }; Some(super::popups::keybind_line_fitted( + crate::config::settings::ShortcutHintScope::Settings, keybinds, area.width.saturating_sub(2), )) @@ -245,7 +236,13 @@ pub fn render( let inner = block.inner(area); frame.render_widget(block, area); - if inner.width >= 42 { + 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([ @@ -302,23 +299,24 @@ 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); + .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 }; - 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![ @@ -330,8 +328,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), ]), ]; @@ -460,9 +458,8 @@ fn popup_area(frame: &Frame, width: u16, height: u16) -> Rect { pub enum SettingsAction { None, - EditInstance(PathBuf), - EditGlobal(PathBuf), - ToggleDesktop, + OpenInstance, + OpenGlobal, SelectProfile(Option), ConfirmDeleteProfile(String), Error(String), @@ -472,7 +469,6 @@ 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 { @@ -558,18 +554,13 @@ pub fn handle_key( 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) + if instance.is_some() { + SettingsAction::OpenInstance } 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, + KeyCode::Char('g') if state.pane == SettingsPane::Info => SettingsAction::OpenGlobal, _ => SettingsAction::None, } } diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs index 0414522..b51d76f 100644 --- a/tests/launch_pipeline.rs +++ b/tests/launch_pipeline.rs @@ -56,7 +56,15 @@ fn make_config_with( memory_max: None, memory_min: None, 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(), + glfw_path: None, config_sync_profile: None, modpack_source: None, } @@ -663,6 +671,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"));