From 27694c58fc5de62fbc3c79d1b9e42219415e2dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:20:31 -0300 Subject: [PATCH 01/15] chore(tui): scaffold solverlab-tui crate with ratatui 0.30 --- solverlab-tui/Cargo.lock | 2416 +++++++++++++++++++++++++++++++++++++ solverlab-tui/Cargo.toml | 22 + solverlab-tui/src/main.rs | 21 + 3 files changed, 2459 insertions(+) create mode 100644 solverlab-tui/Cargo.lock create mode 100644 solverlab-tui/Cargo.toml create mode 100644 solverlab-tui/src/main.rs diff --git a/solverlab-tui/Cargo.lock b/solverlab-tui/Cargo.lock new file mode 100644 index 0000000..6a8e9f0 --- /dev/null +++ b/solverlab-tui/Cargo.lock @@ -0,0 +1,2416 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a" +dependencies = [ + "nix 0.30.1", + "windows-sys", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pest" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.4", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "solver_core" +version = "0.1.0" +dependencies = [ + "ahash", + "rand", + "thiserror 1.0.69", + "uniffi", +] + +[[package]] +name = "solverlab-tui" +version = "0.1.0" +dependencies = [ + "color-eyre", + "ratatui", + "solver_core", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher 1.0.4", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "smawk", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "once_cell", + "paste", + "serde", + "textwrap", + "toml", + "uniffi_meta", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7cf32576e08104b7dc2a6a5d815f37616e66c6866c2a639fe16e6d2286b75b" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher 0.3.11", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6400fc4bb7426f6faac3fb07566122bb18622e3022bd2c36426a92ed489b1dbe" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99c27cc7525bad4df59a45e073c45b2c2aa820efb588234cc23a547ec69a34db" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/solverlab-tui/Cargo.toml b/solverlab-tui/Cargo.toml new file mode 100644 index 0000000..7c460e2 --- /dev/null +++ b/solverlab-tui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "solverlab-tui" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Terminal console for SolverLab solitaire solver simulations" + +[[bin]] +name = "solverlab-tui" +path = "src/main.rs" + +[dependencies] +solver_core = { path = "../solver-core-rs" } +ratatui = "0.30" +color-eyre = "0.6" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +# Keep unwinding (unlike the UniFFI build) so the terminal is always restored +# and a panicking game can be recorded instead of killing the whole run. diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs new file mode 100644 index 0000000..aa8452c --- /dev/null +++ b/solverlab-tui/src/main.rs @@ -0,0 +1,21 @@ +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::widgets::{Block, Paragraph}; + +fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + ratatui::run(|terminal| -> color_eyre::Result<()> { + loop { + terminal.draw(|frame| { + let body = Paragraph::new("SolverLab TUI — press q to quit") + .block(Block::bordered().title(" SolverLab ")); + frame.render_widget(body, frame.area()); + })?; + if let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) + { + return Ok(()); + } + } + }) +} From a33b97bec416c706d0af1dab8f9edb41bf7eb873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:21:05 -0300 Subject: [PATCH 02/15] feat(tui): port simulation domain models --- solverlab-tui/Cargo.lock | 2 + solverlab-tui/Cargo.toml | 2 + solverlab-tui/src/main.rs | 4 + solverlab-tui/src/model.rs | 265 +++++++++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 solverlab-tui/src/model.rs diff --git a/solverlab-tui/Cargo.lock b/solverlab-tui/Cargo.lock index 6a8e9f0..e6828e3 100644 --- a/solverlab-tui/Cargo.lock +++ b/solverlab-tui/Cargo.lock @@ -1714,6 +1714,8 @@ version = "0.1.0" dependencies = [ "color-eyre", "ratatui", + "serde", + "serde_json", "solver_core", ] diff --git a/solverlab-tui/Cargo.toml b/solverlab-tui/Cargo.toml index 7c460e2..f53ac9d 100644 --- a/solverlab-tui/Cargo.toml +++ b/solverlab-tui/Cargo.toml @@ -13,6 +13,8 @@ path = "src/main.rs" solver_core = { path = "../solver-core-rs" } ratatui = "0.30" color-eyre = "0.6" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" [profile.release] opt-level = 3 diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index aa8452c..5c46bc3 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -1,3 +1,7 @@ +// Modules are wired into the UI incrementally; allow unused items until then. +#[allow(dead_code)] +mod model; + use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::widgets::{Block, Paragraph}; diff --git a/solverlab-tui/src/model.rs b/solverlab-tui/src/model.rs new file mode 100644 index 0000000..7f5b3c0 --- /dev/null +++ b/solverlab-tui/src/model.rs @@ -0,0 +1,265 @@ +//! Simulation domain models, ported from `SimModels.swift`. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum GameType { + #[default] + #[serde(rename = "Klondike Draw 1")] + KlondikeDraw1, + #[serde(rename = "Klondike Draw 3")] + KlondikeDraw3, + #[serde(rename = "FreeCell")] + FreeCell, + #[serde(rename = "Pyramid")] + Pyramid, + #[serde(rename = "TriPeaks")] + TriPeaks, + #[serde(rename = "Spider 1 Suit")] + Spider1Suit, + #[serde(rename = "Spider 2 Suits")] + Spider2Suit, + #[serde(rename = "Spider 4 Suits")] + Spider4Suit, +} + +impl GameType { + pub const ALL: [GameType; 8] = [ + GameType::KlondikeDraw1, + GameType::KlondikeDraw3, + GameType::FreeCell, + GameType::Pyramid, + GameType::TriPeaks, + GameType::Spider1Suit, + GameType::Spider2Suit, + GameType::Spider4Suit, + ]; + + /// Same as the Swift `rawValue`; used in the CSV and the default file name. + pub fn raw_value(self) -> &'static str { + match self { + GameType::KlondikeDraw1 => "Klondike Draw 1", + GameType::KlondikeDraw3 => "Klondike Draw 3", + GameType::FreeCell => "FreeCell", + GameType::Pyramid => "Pyramid", + GameType::TriPeaks => "TriPeaks", + GameType::Spider1Suit => "Spider 1 Suit", + GameType::Spider2Suit => "Spider 2 Suits", + GameType::Spider4Suit => "Spider 4 Suits", + } + } + + pub fn ui_label(self) -> &'static str { + self.raw_value() + } + + pub fn strategy_label(self) -> &'static str { + match self { + GameType::KlondikeDraw1 | GameType::KlondikeDraw3 => "IDA*", + GameType::FreeCell => "A*", + GameType::Pyramid => "DFS", + GameType::TriPeaks => "A*", + GameType::Spider1Suit | GameType::Spider2Suit | GameType::Spider4Suit => "A*", + } + } + + fn index(self) -> usize { + Self::ALL.iter().position(|&g| g == self).unwrap_or(0) + } + + pub fn next(self) -> Self { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + pub fn prev(self) -> Self { + Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StopReason { + Win, + Stalled, + Timeout, +} + +impl StopReason { + pub fn raw_value(self) -> &'static str { + match self { + StopReason::Win => "win", + StopReason::Stalled => "stalled", + StopReason::Timeout => "timeout", + } + } + + pub fn label(self) -> &'static str { + match self { + StopReason::Win => "won", + StopReason::Stalled => "stalled", + StopReason::Timeout => "timeout", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SimResult { + pub id: u32, + pub move_count: i64, + pub undo_count: i64, + pub checkpoint_count: Option, + pub won: bool, + pub stop_reason: StopReason, + /// Seconds. + pub duration: f64, + pub score: i64, + pub moves_detail: String, +} + +impl SimResult { + pub fn result_label(&self) -> &'static str { + if self.won { "won" } else { "lost" } + } + + pub fn checkpoint_count_label(&self) -> String { + self.checkpoint_count + .map_or_else(|| "-".to_string(), |c| c.to_string()) + } + + pub fn checkpoint_sort_value(&self) -> i64 { + self.checkpoint_count.unwrap_or(-1) + } +} + +/// Keys match the Swift `Codable` encoding so the JSON stays interchangeable. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SimConfig { + pub game_type: GameType, + pub simulations: i64, + pub parallel_games: i64, + pub auto_parallel: bool, + pub max_undos: i64, + pub timeout_seconds: f64, + pub max_depth: i64, +} + +impl Default for SimConfig { + fn default() -> Self { + SimConfig { + game_type: GameType::KlondikeDraw1, + simulations: 0, + parallel_games: 0, + auto_parallel: false, + max_undos: -1, + timeout_seconds: 0.0, + max_depth: 150, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BatchStats { + pub completed: usize, + pub wins: usize, + pub timeouts: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_values_and_strategies_match_swift() { + let expected = [ + (GameType::KlondikeDraw1, "Klondike Draw 1", "IDA*"), + (GameType::KlondikeDraw3, "Klondike Draw 3", "IDA*"), + (GameType::FreeCell, "FreeCell", "A*"), + (GameType::Pyramid, "Pyramid", "DFS"), + (GameType::TriPeaks, "TriPeaks", "A*"), + (GameType::Spider1Suit, "Spider 1 Suit", "A*"), + (GameType::Spider2Suit, "Spider 2 Suits", "A*"), + (GameType::Spider4Suit, "Spider 4 Suits", "A*"), + ]; + for (game, raw, strategy) in expected { + assert_eq!(game.raw_value(), raw); + assert_eq!(game.ui_label(), raw); + assert_eq!(game.strategy_label(), strategy); + } + } + + #[test] + fn next_and_prev_cycle_all_games() { + let mut game = GameType::KlondikeDraw1; + for expected in GameType::ALL.iter().cycle().skip(1).take(8) { + game = game.next(); + assert_eq!(game, *expected); + } + assert_eq!(GameType::KlondikeDraw1.prev(), GameType::Spider4Suit); + assert_eq!(GameType::Spider4Suit.next(), GameType::KlondikeDraw1); + } + + #[test] + fn stop_reason_labels() { + assert_eq!(StopReason::Win.raw_value(), "win"); + assert_eq!(StopReason::Win.label(), "won"); + assert_eq!(StopReason::Stalled.label(), "stalled"); + assert_eq!(StopReason::Timeout.raw_value(), "timeout"); + } + + #[test] + fn default_config_matches_swift() { + let config = SimConfig::default(); + assert_eq!(config.game_type, GameType::KlondikeDraw1); + assert_eq!(config.simulations, 0); + assert_eq!(config.parallel_games, 0); + assert!(!config.auto_parallel); + assert_eq!(config.max_undos, -1); + assert_eq!(config.timeout_seconds, 0.0); + assert_eq!(config.max_depth, 150); + } + + #[test] + fn config_serde_roundtrip_and_partial_json() { + let config = SimConfig { + game_type: GameType::Spider2Suit, + simulations: 10, + parallel_games: 3, + auto_parallel: true, + max_undos: 5, + timeout_seconds: 12.5, + max_depth: 88, + }; + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("\"gameType\":\"Spider 2 Suits\"")); + assert!(json.contains("\"parallelGames\":3")); + assert_eq!(serde_json::from_str::(&json).unwrap(), config); + + let partial: SimConfig = serde_json::from_str(r#"{"gameType":"FreeCell"}"#).unwrap(); + assert_eq!(partial.game_type, GameType::FreeCell); + assert_eq!(partial.max_depth, 150); + assert_eq!(partial.max_undos, -1); + } + + #[test] + fn result_labels() { + let mut result = SimResult { + id: 1, + move_count: 0, + undo_count: 0, + checkpoint_count: None, + won: false, + stop_reason: StopReason::Stalled, + duration: 0.0, + score: 0, + moves_detail: String::new(), + }; + assert_eq!(result.result_label(), "lost"); + assert_eq!(result.checkpoint_count_label(), "-"); + assert_eq!(result.checkpoint_sort_value(), -1); + result.won = true; + result.checkpoint_count = Some(4); + assert_eq!(result.result_label(), "won"); + assert_eq!(result.checkpoint_count_label(), "4"); + assert_eq!(result.checkpoint_sort_value(), 4); + } +} From 5037f94b9dbbcab796b8cbbdcaa958be871e8669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:21:48 -0300 Subject: [PATCH 03/15] feat(tui): port duration formatting and CSV export format --- solverlab-tui/Cargo.lock | 128 +++++++++++++++++++++++ solverlab-tui/Cargo.toml | 1 + solverlab-tui/src/csv.rs | 195 ++++++++++++++++++++++++++++++++++++ solverlab-tui/src/format.rs | 36 +++++++ solverlab-tui/src/main.rs | 4 + 5 files changed, 364 insertions(+) create mode 100644 solverlab-tui/src/csv.rs create mode 100644 solverlab-tui/src/format.rs diff --git a/solverlab-tui/Cargo.lock b/solverlab-tui/Cargo.lock index e6828e3..6f26181 100644 --- a/solverlab-tui/Cargo.lock +++ b/solverlab-tui/Cargo.lock @@ -45,6 +45,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -306,6 +315,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -318,6 +337,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.7" @@ -414,6 +446,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -626,6 +664,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "finl_unicode" version = "1.5.0" @@ -784,6 +828,30 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1637,6 +1705,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1712,6 +1786,7 @@ dependencies = [ name = "solverlab-tui" version = "0.1.0" dependencies = [ + "chrono", "color-eyre", "ratatui", "serde", @@ -2370,12 +2445,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/solverlab-tui/Cargo.toml b/solverlab-tui/Cargo.toml index f53ac9d..99c9adf 100644 --- a/solverlab-tui/Cargo.toml +++ b/solverlab-tui/Cargo.toml @@ -15,6 +15,7 @@ ratatui = "0.30" color-eyre = "0.6" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +chrono = "0.4.45" [profile.release] opt-level = 3 diff --git a/solverlab-tui/src/csv.rs b/solverlab-tui/src/csv.rs new file mode 100644 index 0000000..37e04f2 --- /dev/null +++ b/solverlab-tui/src/csv.rs @@ -0,0 +1,195 @@ +//! CSV export, byte-for-byte compatible with `SimulationViewModel.makeCSV`. + +use chrono::{DateTime, TimeZone}; + +use crate::model::{GameType, SimConfig, SimResult, StopReason}; + +pub fn csv_escape(raw: &str) -> String { + format!("\"{}\"", raw.replace('"', "\"\"")) +} + +pub fn make_csv( + rows: &[SimResult], + timeout_active_display: &str, + run_config: Option<&SimConfig>, +) -> String { + let mut lines: Vec = Vec::with_capacity(rows.len() + 12); + + lines.push("configuration".into()); + lines.push( + "game,search,simulations,parallel_games,auto_parallel,undos,timeout_s,max_depth".into(), + ); + match run_config { + Some(config) => lines.push(format!( + "{},{},{},{},{},{},{:.2},{}", + csv_escape(config.game_type.raw_value()), + csv_escape(config.game_type.strategy_label()), + config.simulations, + config.parallel_games, + config.auto_parallel, + config.max_undos, + config.timeout_seconds, + config.max_depth + )), + None => lines.push("\"(no_config)\",\"(no_config)\",0,0,false,0,0.00,0".into()), + } + lines.push(String::new()); + + let mut sorted: Vec<&SimResult> = rows.iter().collect(); + sorted.sort_by_key(|row| row.id); + let total_games = sorted.len(); + let count = |reason: StopReason| sorted.iter().filter(|r| r.stop_reason == reason).count(); + let win_count = count(StopReason::Win); + let stalled_count = count(StopReason::Stalled); + let timeout_count = count(StopReason::Timeout); + let total_moves: i64 = sorted.iter().map(|r| r.move_count).sum(); + let total_undos: i64 = sorted.iter().map(|r| r.undo_count).sum(); + let total_duration: f64 = sorted.iter().map(|r| r.duration).sum(); + let total_checkpoints: i64 = sorted.iter().map(|r| r.checkpoint_count.unwrap_or(0)).sum(); + let average = |total: f64| { + if total_games > 0 { + total / total_games as f64 + } else { + 0.0 + } + }; + let win_rate = average(win_count as f64) * 100.0; + + lines.push("summary".into()); + lines.push( + "games,active_timeout,win_rate,avg_moves,avg_undos,avg_checkpoints,avg_duration,wins,stalled,timeouts" + .into(), + ); + lines.push(format!( + "{total_games},{timeout_active_display},{win_rate:.2}%,{:.2},{:.2},{:.2},{:.2}s,{win_count},{stalled_count},{timeout_count}", + average(total_moves as f64), + average(total_undos as f64), + average(total_checkpoints as f64), + average(total_duration), + )); + lines.push(String::new()); + + lines.push("details".into()); + lines.push("game_id,moves,undos,checkpoints,result,stop_reason,duration_sec,score".into()); + for row in sorted { + let checkpoints = row + .checkpoint_count + .map(|c| c.to_string()) + .unwrap_or_default(); + lines.push(format!( + "{},{},{},{},{},{},{:.3},{}", + row.id, + row.move_count, + row.undo_count, + checkpoints, + row.result_label(), + row.stop_reason.raw_value(), + row.duration, + row.score + )); + } + + lines.join("\n") + "\n" +} + +/// `{GameName}_results_{yyyyMMdd_HHmmss}.csv`, where `GameName` keeps only the +/// alphanumeric tokens of the game's raw value. +pub fn default_csv_file_name(game: GameType, now: DateTime) -> String +where + Tz::Offset: std::fmt::Display, +{ + let name: String = game + .raw_value() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect(); + let name = if name.is_empty() { "Game".into() } else { name }; + format!("{name}_results_{}.csv", now.format("%Y%m%d_%H%M%S")) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + #[allow(clippy::too_many_arguments)] + fn result( + id: u32, + moves: i64, + undos: i64, + checkpoints: Option, + won: bool, + stop_reason: StopReason, + duration: f64, + score: i64, + ) -> SimResult { + SimResult { + id, + move_count: moves, + undo_count: undos, + checkpoint_count: checkpoints, + won, + stop_reason, + duration, + score, + moves_detail: String::new(), + } + } + + #[test] + fn csv_sorts_rows_summarizes_and_escapes_config() { + let rows = [ + result(2, 20, 4, None, false, StopReason::Timeout, 3.5, 7), + result(1, 10, 2, Some(6), true, StopReason::Win, 1.5, 9), + ]; + let config = SimConfig { + game_type: GameType::Spider2Suit, + simulations: 2, + parallel_games: 3, + auto_parallel: true, + max_undos: 5, + timeout_seconds: 12.345, + max_depth: 88, + }; + + let csv = make_csv(&rows, "00:12", Some(&config)); + + assert!(csv.contains("\"Spider 2 Suits\",\"A*\",2,3,true,5,12.35,88")); + assert!(csv.contains("2,00:12,50.00%,15.00,3.00,3.00,2.50s,1,0,1")); + let first = csv.find("\n1,10,2,6,won,win,1.500,9").unwrap(); + let second = csv.find("\n2,20,4,,lost,timeout,3.500,7").unwrap(); + assert!(first < second); + assert_eq!(csv_escape("a,\"b\""), "\"a,\"\"b\"\"\""); + } + + #[test] + fn csv_without_config_or_rows() { + let csv = make_csv(&[], "00:00", None); + assert_eq!( + csv, + "configuration\n\ + game,search,simulations,parallel_games,auto_parallel,undos,timeout_s,max_depth\n\ + \"(no_config)\",\"(no_config)\",0,0,false,0,0.00,0\n\ + \n\ + summary\n\ + games,active_timeout,win_rate,avg_moves,avg_undos,avg_checkpoints,avg_duration,wins,stalled,timeouts\n\ + 0,00:00,0.00%,0.00,0.00,0.00,0.00s,0,0,0\n\ + \n\ + details\n\ + game_id,moves,undos,checkpoints,result,stop_reason,duration_sec,score\n" + ); + } + + #[test] + fn default_file_name_strips_non_alphanumerics() { + let now = Utc.with_ymd_and_hms(2026, 10, 6, 9, 5, 7).unwrap(); + assert_eq!( + default_csv_file_name(GameType::Spider2Suit, now), + "Spider2Suits_results_20261006_090507.csv" + ); + assert_eq!( + default_csv_file_name(GameType::KlondikeDraw1, now), + "KlondikeDraw1_results_20261006_090507.csv" + ); + } +} diff --git a/solverlab-tui/src/format.rs b/solverlab-tui/src/format.rs new file mode 100644 index 0000000..62b31a2 --- /dev/null +++ b/solverlab-tui/src/format.rs @@ -0,0 +1,36 @@ +//! Display formatting shared by the UI, the CSV export and headless mode. + +/// Port of `SimulationViewModel.formatDuration`: seconds are rounded up and +/// clamped at zero; hours are shown only when non-zero. +pub fn format_duration(secs: f64) -> String { + let total = if secs.is_finite() { + secs.ceil().max(0.0) as i64 + } else { + 0 + }; + let hours = total / 3600; + let minutes = total / 60; + let seconds = total % 60; + if hours > 0 { + format!("{hours:02}:{:02}:{seconds:02}", minutes % 60) + } else { + format!("{minutes:02}:{seconds:02}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_matches_swift() { + assert_eq!(format_duration(-5.0), "00:00"); + assert_eq!(format_duration(61.1), "01:02"); + assert_eq!(format_duration(3661.0), "01:01:01"); + assert_eq!(format_duration(7322.0), "02:02:02"); + assert_eq!(format_duration(3599.2), "01:00:00"); + assert_eq!(format_duration(3598.5), "59:59"); + assert_eq!(format_duration(f64::NAN), "00:00"); + assert_eq!(format_duration(f64::INFINITY), "00:00"); + } +} diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index 5c46bc3..eaa69c6 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -1,5 +1,9 @@ // Modules are wired into the UI incrementally; allow unused items until then. #[allow(dead_code)] +mod csv; +#[allow(dead_code)] +mod format; +#[allow(dead_code)] mod model; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; From 79dc14fba4f957c5d26bfb806891fdc283cf37dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:22:21 -0300 Subject: [PATCH 04/15] feat(tui): add deck builders with optional reproducible seed --- solverlab-tui/Cargo.lock | 62 ++++++++++++++++-- solverlab-tui/Cargo.toml | 2 + solverlab-tui/src/deck.rs | 131 ++++++++++++++++++++++++++++++++++++++ solverlab-tui/src/main.rs | 2 + 4 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 solverlab-tui/src/deck.rs diff --git a/solverlab-tui/Cargo.lock b/solverlab-tui/Cargo.lock index 6f26181..81c6e50 100644 --- a/solverlab-tui/Cargo.lock +++ b/solverlab-tui/Cargo.lock @@ -337,6 +337,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -461,6 +472,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -769,6 +789,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1309,7 +1330,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.8", ] [[package]] @@ -1404,8 +1425,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1415,7 +1447,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", ] [[package]] @@ -1427,6 +1469,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.2" @@ -1692,7 +1740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1777,7 +1825,7 @@ name = "solver_core" version = "0.1.0" dependencies = [ "ahash", - "rand", + "rand 0.8.8", "thiserror 1.0.69", "uniffi", ] @@ -1788,6 +1836,8 @@ version = "0.1.0" dependencies = [ "chrono", "color-eyre", + "rand 0.10.3", + "rand_chacha 0.10.0", "ratatui", "serde", "serde_json", diff --git a/solverlab-tui/Cargo.toml b/solverlab-tui/Cargo.toml index 99c9adf..edc1307 100644 --- a/solverlab-tui/Cargo.toml +++ b/solverlab-tui/Cargo.toml @@ -16,6 +16,8 @@ color-eyre = "0.6" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" chrono = "0.4.45" +rand = "0.10.3" +rand_chacha = "0.10.0" [profile.release] opt-level = 3 diff --git a/solverlab-tui/src/deck.rs b/solverlab-tui/src/deck.rs new file mode 100644 index 0000000..5e9971f --- /dev/null +++ b/solverlab-tui/src/deck.rs @@ -0,0 +1,131 @@ +//! Deck builders matching the Swift console, plus shuffling with an optional +//! reproducible seed so runs can be compared across platforms. + +use rand::SeedableRng; +use rand::seq::SliceRandom; +use rand_chacha::ChaCha8Rng; +use solver_core::common::card::{Card, Suit}; + +const STANDARD_SUITS: [Suit; 4] = [Suit::Club, Suit::Diamond, Suit::Heart, Suit::Spade]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ShuffleSource { + #[default] + Random, + Seeded(u64), +} + +fn build(suits: &[Suit], repeats: usize) -> Vec { + let mut deck = Vec::with_capacity(suits.len() * repeats * 13); + for _ in 0..repeats { + for &suit in suits { + for value in 1..=13u8 { + deck.push(Card::new(suit, value)); + } + } + } + deck +} + +pub fn standard_deck() -> Vec { + build(&STANDARD_SUITS, 1) +} + +/// Spider deck in the Swift console order (not `Card::spider_deck`, whose order +/// differs and would change seeded deals). +pub fn spider_deck(suit_count: u32) -> Vec { + match suit_count { + 1 => build(&[Suit::Spade], 8), + 2 => build(&[Suit::Spade, Suit::Heart], 4), + _ => build(&STANDARD_SUITS, 2), + } +} + +fn splitmix64(mut state: u64) -> u64 { + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +pub fn shuffled(mut deck: Vec, source: ShuffleSource, game_id: u32) -> Vec { + match source { + ShuffleSource::Random => deck.shuffle(&mut rand::rng()), + ShuffleSource::Seeded(seed) => { + let mut rng = ChaCha8Rng::seed_from_u64(splitmix64(seed ^ u64::from(game_id))); + deck.shuffle(&mut rng); + } + } + deck +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn counts(deck: &[Card]) -> HashMap<(u8, u8), usize> { + let mut map = HashMap::new(); + for card in deck { + *map.entry((card.suit as u8, card.value)).or_default() += 1; + } + map + } + + #[test] + fn standard_deck_has_52_unique_cards_in_swift_order() { + let deck = standard_deck(); + assert_eq!(deck.len(), 52); + assert!(counts(&deck).values().all(|&c| c == 1)); + assert_eq!(deck[0], Card::new(Suit::Club, 1)); + assert_eq!(deck[13], Card::new(Suit::Diamond, 1)); + assert_eq!(deck[51], Card::new(Suit::Spade, 13)); + } + + #[test] + fn spider_decks_have_expected_composition() { + let one = spider_deck(1); + assert_eq!(one.len(), 104); + assert!(one.iter().all(|c| c.suit == Suit::Spade)); + assert!(counts(&one).values().all(|&c| c == 8)); + + let two = spider_deck(2); + assert_eq!(two.len(), 104); + assert_eq!(two.iter().filter(|c| c.suit == Suit::Heart).count(), 52); + assert!(counts(&two).values().all(|&c| c == 4)); + assert_eq!(two[13], Card::new(Suit::Heart, 1)); + + let four = spider_deck(4); + assert_eq!(four.len(), 104); + assert_eq!(counts(&four).len(), 52); + assert!(counts(&four).values().all(|&c| c == 2)); + } + + #[test] + fn seeded_shuffle_is_reproducible_per_game() { + let a = shuffled(standard_deck(), ShuffleSource::Seeded(42), 7); + let b = shuffled(standard_deck(), ShuffleSource::Seeded(42), 7); + let c = shuffled(standard_deck(), ShuffleSource::Seeded(42), 8); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!(counts(&a), counts(&standard_deck())); + } + + #[test] + fn random_shuffle_preserves_cards() { + let deck = shuffled(spider_deck(2), ShuffleSource::Random, 1); + assert_eq!(counts(&deck), counts(&spider_deck(2))); + } + + /// Golden values: if this passes on every platform, seeded runs deal the + /// same boards everywhere. + #[test] + fn seeded_shuffle_golden_values() { + let deck = shuffled(standard_deck(), ShuffleSource::Seeded(1), 1); + let head: Vec = deck.iter().take(5).map(|c| c.encode()).collect(); + assert_eq!(head, GOLDEN_HEAD); + } + + const GOLDEN_HEAD: [u8; 5] = [51, 55, 41, 19, 35]; +} diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index eaa69c6..503c08a 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -2,6 +2,8 @@ #[allow(dead_code)] mod csv; #[allow(dead_code)] +mod deck; +#[allow(dead_code)] mod format; #[allow(dead_code)] mod model; From 9b20f48445aa5d9a769d904d02317503d69dab56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:24:02 -0300 Subject: [PATCH 05/15] feat(tui): port Klondike and FreeCell game runners --- solverlab-tui/src/games/freecell.rs | 90 +++++++++++++++ solverlab-tui/src/games/klondike.rs | 166 ++++++++++++++++++++++++++++ solverlab-tui/src/games/mod.rs | 72 ++++++++++++ solverlab-tui/src/main.rs | 2 + 4 files changed, 330 insertions(+) create mode 100644 solverlab-tui/src/games/freecell.rs create mode 100644 solverlab-tui/src/games/klondike.rs create mode 100644 solverlab-tui/src/games/mod.rs diff --git a/solverlab-tui/src/games/freecell.rs b/solverlab-tui/src/games/freecell.rs new file mode 100644 index 0000000..9c93da4 --- /dev/null +++ b/solverlab-tui/src/games/freecell.rs @@ -0,0 +1,90 @@ +use std::time::Instant; + +use solver_core::FreeCellEngine; + +use super::{GameContext, classify_stop_reason, deal_failed}; +use crate::deck::{shuffled, standard_deck}; +use crate::model::SimResult; + +/// Solver timeout used when the config does not set one. +const DEFAULT_SOLVE_TIMEOUT_SECS: f64 = 15.0; + +pub fn run(ctx: &GameContext) -> SimResult { + let deck = shuffled(standard_deck(), ctx.shuffle, ctx.game_id); + let engine = FreeCellEngine::new(); + let Ok(board) = engine.deal(deck) else { + return deal_failed(ctx.game_id, None); + }; + + let start = Instant::now(); + let timeout_budget = ctx.timeout_budget(); + let mut final_board = board.clone(); + let mut score = 0; + let mut applied_moves = 0; + + let planned = engine.solve( + &final_board, + timeout_budget.unwrap_or(DEFAULT_SOLVE_TIMEOUT_SECS), + true, + ); + let checkpoints = i64::from(engine.last_checkpoints_adopted()); + + // Replay the plan to get the final board and the UI score (+10 per foundation move). + for m in planned.into_iter().flatten() { + let is_foundation = engine.is_foundation_move(m.clone()); + let Some(next) = engine.apply_move(&final_board, m) else { + break; + }; + final_board = next; + applied_moves += 1; + if is_foundation { + score += 10; + } + } + + let duration = start.elapsed().as_secs_f64(); + let won = engine.is_win(&final_board); + let timed_out = timeout_budget.is_some_and(|budget| duration >= budget); + let has_progress = engine.progress_token(&final_board) != engine.progress_token(&board); + + SimResult { + id: ctx.game_id, + move_count: applied_moves, + undo_count: 0, + checkpoint_count: Some(checkpoints), + won, + stop_reason: classify_stop_reason(won, timed_out, has_progress), + duration, + score, + moves_detail: String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::deck::ShuffleSource; + use crate::model::{GameType, SimConfig, StopReason}; + + #[test] + fn seeded_freecell_returns_consistent_result() { + let config = SimConfig { + game_type: GameType::FreeCell, + timeout_seconds: 2.0, + ..SimConfig::default() + }; + let ctx = GameContext { + game_id: 1, + config: &config, + shuffle: ShuffleSource::Seeded(1), + }; + let result = run(&ctx); + assert_eq!(result.id, 1); + assert!(result.move_count >= 0); + assert_eq!(result.won, result.stop_reason == StopReason::Win); + assert_eq!(result.score % 10, 0); + if result.won { + assert_eq!(result.score, 520); + } + } +} diff --git a/solverlab-tui/src/games/klondike.rs b/solverlab-tui/src/games/klondike.rs new file mode 100644 index 0000000..3af5336 --- /dev/null +++ b/solverlab-tui/src/games/klondike.rs @@ -0,0 +1,166 @@ +use std::time::Instant; + +use solver_core::KlondikeEngine; + +use super::{GameContext, classify_stop_reason, deal_failed}; +use crate::deck::{shuffled, standard_deck}; +use crate::model::SimResult; + +/// Klondike reports timeout against the solver's fixed 100s budget, not the config. +const TIMEOUT_SECS: f64 = 100.0; + +/// Score update for one applied move. Returns the new score and whether the +/// move counts as an undo (taking a card back from a foundation). +pub(crate) fn klondike_score( + score: i64, + to_foundation: bool, + to_column: bool, + from_foundation: bool, + face_down_before: Option, + face_down_after: Option, +) -> (i64, bool) { + let mut score = score; + if to_foundation { + score += 10; + } else if to_column { + if from_foundation { + score = (score - 15).max(0); + } else { + score += 5; + } + } + if let (Some(before), Some(after)) = (face_down_before, face_down_after) + && before >= 1 + && after == before - 1 + { + score += 5; + } + (score, from_foundation) +} + +pub fn run(ctx: &GameContext, draw_advance: i32) -> SimResult { + let deck = shuffled(standard_deck(), ctx.shuffle, ctx.game_id); + let engine = KlondikeEngine::new(draw_advance); + let Ok(board) = engine.deal(deck) else { + return deal_failed(ctx.game_id, Some(0)); + }; + + let start = Instant::now(); + let mut final_board = board.clone(); + let mut score = 0; + let mut undo_count = 0; + let mut applied_moves = 0; + + let planned = engine.solve(&final_board, true); + let checkpoints = i64::from(engine.last_checkpoints_adopted()); + + for m in planned.into_iter().flatten() { + let from_foundation = engine.is_from_foundation(m.clone()); + let to_foundation = engine.is_to_foundation(m.clone()); + let to_column = engine.is_to_column(m.clone()); + let source = m.source; + let tracks_column = (0..7).contains(&source) && m.move_type.starts_with("columnTo"); + let face_down_before = tracks_column.then(|| engine.face_down_count(&final_board, source)); + + let Some(next) = engine.apply_move(&final_board, m) else { + break; + }; + final_board = next; + applied_moves += 1; + + let face_down_after = tracks_column.then(|| engine.face_down_count(&final_board, source)); + let (next_score, undo) = klondike_score( + score, + to_foundation, + to_column, + from_foundation, + face_down_before, + face_down_after, + ); + score = next_score; + if undo { + undo_count += 1; + } + } + + let duration = start.elapsed().as_secs_f64(); + let won = engine.is_win(&final_board); + let timed_out = duration >= TIMEOUT_SECS; + let has_progress = engine.progress_token(&final_board) != engine.progress_token(&board); + + SimResult { + id: ctx.game_id, + move_count: applied_moves, + undo_count, + checkpoint_count: Some(checkpoints), + won, + stop_reason: classify_stop_reason(won, timed_out, has_progress), + duration, + score, + moves_detail: String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::deck::ShuffleSource; + use crate::model::{GameType, SimConfig, StopReason}; + + #[test] + fn score_to_foundation() { + assert_eq!( + klondike_score(0, true, false, false, None, None), + (10, false) + ); + } + + #[test] + fn score_column_move_that_reveals_a_card() { + assert_eq!( + klondike_score(10, false, true, false, Some(3), Some(2)), + (20, false) + ); + // No bonus when nothing was face down or nothing changed. + assert_eq!( + klondike_score(10, false, true, false, Some(0), Some(0)), + (15, false) + ); + assert_eq!( + klondike_score(10, false, true, false, Some(2), Some(2)), + (15, false) + ); + } + + #[test] + fn score_from_foundation_is_an_undo_with_floor() { + assert_eq!(klondike_score(20, false, true, true, None, None), (5, true)); + assert_eq!(klondike_score(4, false, true, true, None, None), (0, true)); + } + + #[test] + fn score_stock_to_column_has_no_reveal_bonus() { + assert_eq!( + klondike_score(0, false, true, false, None, None), + (5, false) + ); + } + + #[test] + #[ignore = "slow: runs the real Klondike solver (use --release)"] + fn smoke_seeded_klondike_draw1() { + let config = SimConfig { + game_type: GameType::KlondikeDraw1, + ..SimConfig::default() + }; + let ctx = GameContext { + game_id: 1, + config: &config, + shuffle: ShuffleSource::Seeded(1), + }; + let result = run(&ctx, 1); + assert_eq!(result.id, 1); + assert!(result.move_count >= 0); + assert_eq!(result.won, result.stop_reason == StopReason::Win); + } +} diff --git a/solverlab-tui/src/games/mod.rs b/solverlab-tui/src/games/mod.rs new file mode 100644 index 0000000..e46bb59 --- /dev/null +++ b/solverlab-tui/src/games/mod.rs @@ -0,0 +1,72 @@ +//! Per-game runners, ported from `SimulationViewModel+.swift`. +//! +//! Each runner deals a shuffled deck, asks the solver for a (possibly partial) +//! plan, replays it through `apply_move` and reports score and stop reason the +//! same way the Swift console does. + +pub mod freecell; +pub mod klondike; + +use crate::deck::ShuffleSource; +use crate::model::{SimConfig, SimResult, StopReason}; + +pub struct GameContext<'a> { + pub game_id: u32, + pub config: &'a SimConfig, + pub shuffle: ShuffleSource, +} + +impl GameContext<'_> { + /// `config.timeoutSeconds` when positive, as the Swift runners read it. + fn timeout_budget(&self) -> Option { + (self.config.timeout_seconds > 0.0).then_some(self.config.timeout_seconds) + } +} + +/// Timeout is reported only if useful progress was made; otherwise the game stalled. +pub fn classify_stop_reason(won: bool, timed_out: bool, has_progress: bool) -> StopReason { + if won { + StopReason::Win + } else if timed_out && has_progress { + StopReason::Timeout + } else { + StopReason::Stalled + } +} + +fn deal_failed(game_id: u32, checkpoint_count: Option) -> SimResult { + SimResult { + id: game_id, + move_count: 0, + undo_count: 0, + checkpoint_count, + won: false, + stop_reason: StopReason::Stalled, + duration: 0.0, + score: 0, + moves_detail: "deal_failed".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_stop_reason_table() { + use StopReason::*; + let cases = [ + (true, true, true, Win), + (true, true, false, Win), + (true, false, true, Win), + (true, false, false, Win), + (false, true, true, Timeout), + (false, true, false, Stalled), + (false, false, true, Stalled), + (false, false, false, Stalled), + ]; + for (won, timed_out, progress, expected) in cases { + assert_eq!(classify_stop_reason(won, timed_out, progress), expected); + } + } +} diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index 503c08a..6f9d65c 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -6,6 +6,8 @@ mod deck; #[allow(dead_code)] mod format; #[allow(dead_code)] +mod games; +#[allow(dead_code)] mod model; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; From 57efd91b8561d3c8e335d7247c82b6570d5d17be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:24:59 -0300 Subject: [PATCH 06/15] feat(tui): port Pyramid, TriPeaks and Spider runners --- solverlab-tui/src/games/mod.rs | 57 +++++++++++++- solverlab-tui/src/games/pyramid.rs | 111 ++++++++++++++++++++++++++++ solverlab-tui/src/games/spider.rs | 89 ++++++++++++++++++++++ solverlab-tui/src/games/tripeaks.rs | 101 +++++++++++++++++++++++++ 4 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 solverlab-tui/src/games/pyramid.rs create mode 100644 solverlab-tui/src/games/spider.rs create mode 100644 solverlab-tui/src/games/tripeaks.rs diff --git a/solverlab-tui/src/games/mod.rs b/solverlab-tui/src/games/mod.rs index e46bb59..160792b 100644 --- a/solverlab-tui/src/games/mod.rs +++ b/solverlab-tui/src/games/mod.rs @@ -6,9 +6,12 @@ pub mod freecell; pub mod klondike; +pub mod pyramid; +pub mod spider; +pub mod tripeaks; use crate::deck::ShuffleSource; -use crate::model::{SimConfig, SimResult, StopReason}; +use crate::model::{GameType, SimConfig, SimResult, StopReason}; pub struct GameContext<'a> { pub game_id: u32, @@ -23,6 +26,20 @@ impl GameContext<'_> { } } +/// Port of `runSingleGame`. +pub fn run_single_game(ctx: &GameContext) -> SimResult { + match ctx.config.game_type { + GameType::KlondikeDraw1 => klondike::run(ctx, 1), + GameType::KlondikeDraw3 => klondike::run(ctx, 3), + GameType::FreeCell => freecell::run(ctx), + GameType::Pyramid => pyramid::run(ctx), + GameType::TriPeaks => tripeaks::run(ctx), + GameType::Spider1Suit => spider::run(ctx, 1), + GameType::Spider2Suit => spider::run(ctx, 2), + GameType::Spider4Suit => spider::run(ctx, 4), + } +} + /// Timeout is reported only if useful progress was made; otherwise the game stalled. pub fn classify_stop_reason(won: bool, timed_out: bool, has_progress: bool) -> StopReason { if won { @@ -69,4 +86,42 @@ mod tests { assert_eq!(classify_stop_reason(won, timed_out, progress), expected); } } + + /// Seeded runs must be reproducible: these are the columns compared across platforms. + #[test] + #[ignore = "slow: runs real solvers twice (use --release)"] + fn seeded_runs_are_deterministic() { + for game_type in [GameType::FreeCell, GameType::Pyramid, GameType::TriPeaks] { + let config = SimConfig { + game_type, + ..SimConfig::default() + }; + for game_id in 1..=3 { + let ctx = GameContext { + game_id, + config: &config, + shuffle: ShuffleSource::Seeded(1234), + }; + let a = run_single_game(&ctx); + let b = run_single_game(&ctx); + assert_eq!( + ( + a.move_count, + a.undo_count, + a.checkpoint_count, + a.won, + a.score + ), + ( + b.move_count, + b.undo_count, + b.checkpoint_count, + b.won, + b.score + ), + "{game_type:?} game {game_id}" + ); + } + } + } } diff --git a/solverlab-tui/src/games/pyramid.rs b/solverlab-tui/src/games/pyramid.rs new file mode 100644 index 0000000..9a0e2d3 --- /dev/null +++ b/solverlab-tui/src/games/pyramid.rs @@ -0,0 +1,111 @@ +use std::time::Instant; + +use solver_core::PyramidEngine; + +use super::{GameContext, classify_stop_reason, deal_failed}; +use crate::deck::{shuffled, standard_deck}; +use crate::model::SimResult; + +pub fn run(ctx: &GameContext) -> SimResult { + let deck = shuffled(standard_deck(), ctx.shuffle, ctx.game_id); + let engine = PyramidEngine::new(); + let Ok(board) = engine.deal(deck) else { + return deal_failed(ctx.game_id, None); + }; + + let start = Instant::now(); + let timeout_budget = ctx.timeout_budget(); + let mut final_board = board.clone(); + let mut score = 0; + let mut applied_moves = 0; + + // A zero timeout means "no timeout" for the Pyramid solver. + let planned = engine.solve(&final_board, timeout_budget.unwrap_or(0.0), true); + let checkpoints = i64::from(engine.last_checkpoints_adopted()); + + for m in planned.into_iter().flatten() { + let is_king = engine.is_king_move(m.clone()); + let is_pair = engine.is_pair_move(m.clone()); + let Some(next) = engine.apply_move(&final_board, m) else { + break; + }; + final_board = next; + if is_king { + score += 10; + } else if is_pair { + score += 20; + } + applied_moves += 1; + } + + let duration = start.elapsed().as_secs_f64(); + let won = engine.is_win(&final_board); + let timed_out = timeout_budget.is_some_and(|budget| duration >= budget); + let has_progress = engine.progress_token(&final_board) != engine.progress_token(&board); + + SimResult { + id: ctx.game_id, + move_count: applied_moves, + undo_count: 0, + checkpoint_count: Some(checkpoints), + won, + stop_reason: classify_stop_reason(won, timed_out, has_progress), + duration, + score, + moves_detail: String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::deck::ShuffleSource; + use crate::model::{GameType, SimConfig, StopReason}; + use solver_core::PyramidMoveDesc; + + /// The Swift runner compares move type strings; the engine helpers must agree. + #[test] + fn engine_move_kinds_match_swift_string_checks() { + let engine = PyramidEngine::new(); + for move_type in [ + "stockAdvance", + "stockReset", + "pairWP", + "pairPP", + "kingPyramid", + "kingWaste", + "deal", + ] { + let m = PyramidMoveDesc { + move_type: move_type.into(), + index_a: -1, + index_b: -1, + }; + assert_eq!( + engine.is_king_move(m.clone()), + move_type == "kingPyramid" || move_type == "kingWaste" + ); + assert_eq!( + engine.is_pair_move(m), + move_type == "pairWP" || move_type == "pairPP" + ); + } + } + + #[test] + fn seeded_pyramid_returns_consistent_result() { + let config = SimConfig { + game_type: GameType::Pyramid, + timeout_seconds: 2.0, + ..SimConfig::default() + }; + let ctx = GameContext { + game_id: 1, + config: &config, + shuffle: ShuffleSource::Seeded(1), + }; + let result = run(&ctx); + assert_eq!(result.won, result.stop_reason == StopReason::Win); + assert_eq!(result.score % 10, 0); + } +} diff --git a/solverlab-tui/src/games/spider.rs b/solverlab-tui/src/games/spider.rs new file mode 100644 index 0000000..e587836 --- /dev/null +++ b/solverlab-tui/src/games/spider.rs @@ -0,0 +1,89 @@ +use std::time::Instant; + +use solver_core::SpiderEngine; + +use super::{GameContext, classify_stop_reason, deal_failed}; +use crate::deck::{shuffled, spider_deck}; +use crate::model::SimResult; + +const INITIAL_SCORE: i64 = 500; + +pub fn run(ctx: &GameContext, suit_count: u32) -> SimResult { + let deck = shuffled(spider_deck(suit_count), ctx.shuffle, ctx.game_id); + let engine = SpiderEngine::new(suit_count); + let Ok(board) = engine.deal(deck) else { + return deal_failed(ctx.game_id, None); + }; + + let start = Instant::now(); + let timeout_budget = ctx.timeout_budget(); + let mut final_board = board.clone(); + let mut score = INITIAL_SCORE; + let mut applied_moves = 0; + + let planned = engine.solve(&final_board, true); + let checkpoints = i64::from(engine.last_checkpoints_adopted()); + + // -1 per move, +100 per newly completed set. + for m in planned.into_iter().flatten() { + let sets_before = engine.completed_sets(&final_board); + let Some(next) = engine.apply_move(&final_board, m) else { + break; + }; + final_board = next; + applied_moves += 1; + score -= 1; + + let new_sets = engine.completed_sets(&final_board) - sets_before; + if new_sets > 0 { + score += i64::from(new_sets) * 100; + } + + if engine.is_win(&final_board) { + break; + } + } + + let duration = start.elapsed().as_secs_f64(); + let won = engine.is_win(&final_board); + let timed_out = timeout_budget.is_some_and(|budget| duration >= budget); + let has_progress = engine.progress_token(&final_board) != engine.progress_token(&board); + + SimResult { + id: ctx.game_id, + move_count: applied_moves, + undo_count: 0, + checkpoint_count: Some(checkpoints), + won, + stop_reason: classify_stop_reason(won, timed_out, has_progress), + duration, + score, + moves_detail: String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::deck::ShuffleSource; + use crate::model::{GameType, SimConfig, StopReason}; + + #[test] + #[ignore = "slow: runs the real Spider solver (use --release)"] + fn smoke_seeded_spider_one_suit() { + let config = SimConfig { + game_type: GameType::Spider1Suit, + ..SimConfig::default() + }; + let ctx = GameContext { + game_id: 1, + config: &config, + shuffle: ShuffleSource::Seeded(1), + }; + let result = run(&ctx, 1); + assert_eq!(result.won, result.stop_reason == StopReason::Win); + if result.won { + assert_eq!(result.score, INITIAL_SCORE - result.move_count + 800); + } + } +} diff --git a/solverlab-tui/src/games/tripeaks.rs b/solverlab-tui/src/games/tripeaks.rs new file mode 100644 index 0000000..91e3633 --- /dev/null +++ b/solverlab-tui/src/games/tripeaks.rs @@ -0,0 +1,101 @@ +use std::time::Instant; + +use solver_core::TriPeaksEngine; + +use super::{GameContext, classify_stop_reason, deal_failed}; +use crate::deck::{shuffled, standard_deck}; +use crate::model::SimResult; + +/// TriPeaks reports timeout against the solver's fixed 5s budget, not the config. +const TIMEOUT_SECS: f64 = 5.0; + +/// Tableau plays extend the streak and score `5 * streak`; a stock draw +/// breaks the streak and costs 5 points (floored at zero). +pub(crate) fn tripeaks_score(score: i64, streak: i64, is_tableau: bool) -> (i64, i64) { + if is_tableau { + let streak = streak + 1; + (score + 5 * streak, streak) + } else { + ((score - 5).max(0), 0) + } +} + +pub fn run(ctx: &GameContext) -> SimResult { + let deck = shuffled(standard_deck(), ctx.shuffle, ctx.game_id); + let engine = TriPeaksEngine::new(); + let Ok(board) = engine.deal(deck) else { + return deal_failed(ctx.game_id, None); + }; + + let start = Instant::now(); + let mut final_board = board.clone(); + let mut score = 0; + let mut streak = 0; + let mut applied_moves = 0; + + let planned = engine.solve(&final_board, true); + let checkpoints = i64::from(engine.last_checkpoints_adopted()); + + for m in planned.into_iter().flatten() { + let is_tableau = engine.is_tableau_move(m.clone()); + let Some(next) = engine.apply_move(&final_board, m) else { + break; + }; + (score, streak) = tripeaks_score(score, streak, is_tableau); + final_board = next; + applied_moves += 1; + } + + let duration = start.elapsed().as_secs_f64(); + let won = engine.is_win(&final_board); + let timed_out = duration >= TIMEOUT_SECS; + let has_progress = engine.progress_token(&final_board) != engine.progress_token(&board); + + SimResult { + id: ctx.game_id, + move_count: applied_moves, + undo_count: 0, + checkpoint_count: Some(checkpoints), + won, + stop_reason: classify_stop_reason(won, timed_out, has_progress), + duration, + score, + moves_detail: String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::deck::ShuffleSource; + use crate::model::{GameType, SimConfig, StopReason}; + + #[test] + fn streak_scoring() { + let (score, streak) = tripeaks_score(0, 0, true); + assert_eq!((score, streak), (5, 1)); + let (score, streak) = tripeaks_score(score, streak, true); + assert_eq!((score, streak), (15, 2)); + let (score, streak) = tripeaks_score(score, streak, true); + assert_eq!((score, streak), (30, 3)); + let (score, streak) = tripeaks_score(score, streak, false); + assert_eq!((score, streak), (25, 0)); + assert_eq!(tripeaks_score(3, 4, false), (0, 0)); + } + + #[test] + fn seeded_tripeaks_returns_consistent_result() { + let config = SimConfig { + game_type: GameType::TriPeaks, + ..SimConfig::default() + }; + let ctx = GameContext { + game_id: 1, + config: &config, + shuffle: ShuffleSource::Seeded(1), + }; + let result = run(&ctx); + assert_eq!(result.won, result.stop_reason == StopReason::Win); + assert!(result.score >= 0); + } +} From ba07fb4a477a011e6897852a8f7ddcb62144ea5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:26:03 -0300 Subject: [PATCH 07/15] feat(tui): add batched parallel simulation runtime --- solverlab-tui/src/main.rs | 2 + solverlab-tui/src/runtime.rs | 460 +++++++++++++++++++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 solverlab-tui/src/runtime.rs diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index 6f9d65c..04345f3 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -9,6 +9,8 @@ mod format; mod games; #[allow(dead_code)] mod model; +#[allow(dead_code)] +mod runtime; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::widgets::{Block, Paragraph}; diff --git a/solverlab-tui/src/runtime.rs b/solverlab-tui/src/runtime.rs new file mode 100644 index 0000000..87bcb08 --- /dev/null +++ b/solverlab-tui/src/runtime.rs @@ -0,0 +1,460 @@ +//! Batched parallel simulation runtime, ported from +//! `SimulationViewModel+Runtime.swift` and the auto-parallel heuristics in +//! `SimulationViewModel.swift`. +//! +//! A runner thread plays games in batches of `workers` threads, streams each +//! finished game over a channel and, in auto mode, resizes the next batch +//! from the timeout and win rates of the previous one. + +use std::collections::HashMap; +use std::panic::{self, AssertUnwindSafe}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Instant; + +use crate::deck::ShuffleSource; +use crate::games::{GameContext, run_single_game}; +use crate::model::{BatchStats, SimConfig, SimResult, StopReason}; + +/// Name prefix of the threads that run a single game (see the panic hook in `main`). +pub const GAME_THREAD_PREFIX: &str = "solverlab-game"; + +/// Hands out game ids `1..=total`. +pub struct Coordinator { + next: AtomicU64, + total: u64, +} + +impl Coordinator { + pub fn new(total: u32) -> Self { + Coordinator { + next: AtomicU64::new(1), + total: u64::from(total), + } + } + + pub fn take_next_game_id(&self) -> Option { + let id = self.next.fetch_add(1, Ordering::Relaxed); + if id <= self.total { + u32::try_from(id).ok() + } else { + None + } + } +} + +/// Start times of the games currently being solved; drives the active-timeout countdown. +#[derive(Clone, Default)] +pub struct ActiveGamesClock(Arc>>); + +impl ActiveGamesClock { + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn begin(&self, game_id: u32) { + self.lock().insert(game_id, Instant::now()); + } + + pub fn end(&self, game_id: u32) { + self.lock().remove(&game_id); + } + + pub fn clear(&self) { + self.lock().clear(); + } + + pub fn oldest_start(&self) -> Option { + self.lock().values().min().copied() + } +} + +#[derive(Debug)] +pub enum RunEvent { + WorkersChanged { run_id: u64, workers: usize }, + GameFinished { run_id: u64, result: SimResult }, + Finished { run_id: u64 }, +} + +pub struct RunHandle { + pub run_id: u64, + cancel: Arc, +} + +impl RunHandle { + /// Stops handing out new games. In-flight solves cannot be interrupted; + /// their results are dropped. + pub fn cancel(&self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +pub fn cpu_count() -> usize { + thread::available_parallelism().map_or(1, |n| n.get()) +} + +fn clamp_total(total_games: i64) -> usize { + usize::try_from(total_games.max(0)).unwrap_or(usize::MAX) +} + +/// Half the CPUs, capped by the number of games (0 games → 0 workers). +pub fn auto_parallel_initial(total_games: i64, cpu: usize) -> usize { + clamp_total(total_games).min((cpu / 2).max(1)) +} + +pub fn auto_parallel_next( + current: usize, + completed: u64, + total_games: i64, + stats: BatchStats, + cpu: usize, +) -> usize { + let total = clamp_total(total_games); + let hard_cap = total.min(cpu.max(1)); + if stats.completed == 0 { + return current.min(hard_cap).max(1); + } + + let timeout_rate = stats.timeouts as f64 / stats.completed as f64; + let win_rate = stats.wins as f64 / stats.completed as f64; + + let mut next = current; + if timeout_rate > 0.55 { + next = current.saturating_sub(1).max(1); + } else if timeout_rate < 0.25 && win_rate >= 0.25 { + next = hard_cap.min(current + 1); + } + + let completed = usize::try_from(completed).unwrap_or(usize::MAX); + let remaining = total.saturating_sub(completed).max(1); + next.min(remaining).min(hard_cap).max(1) +} + +fn initial_workers(config: &SimConfig, cpu: usize) -> usize { + if config.auto_parallel { + auto_parallel_initial(config.simulations, cpu) + } else { + let parallel = clamp_total(config.parallel_games); + parallel + .min(clamp_total(config.simulations)) + .min(cpu) + .max(1) + } +} + +fn panicked_result(game_id: u32) -> SimResult { + SimResult { + id: game_id, + move_count: 0, + undo_count: 0, + checkpoint_count: None, + won: false, + stop_reason: StopReason::Stalled, + duration: 0.0, + score: 0, + moves_detail: "panic".into(), + } +} + +fn play_game( + game_id: u32, + config: &SimConfig, + shuffle: ShuffleSource, + clock: &ActiveGamesClock, +) -> SimResult { + clock.begin(game_id); + let ctx = GameContext { + game_id, + config, + shuffle, + }; + let result = panic::catch_unwind(AssertUnwindSafe(|| run_single_game(&ctx))) + .unwrap_or_else(|_| panicked_result(game_id)); + clock.end(game_id); + result +} + +/// Port of `runSimulation`: runs on its own thread and reports through `tx`. +pub fn spawn_simulation( + config: SimConfig, + shuffle: ShuffleSource, + run_id: u64, + clock: ActiveGamesClock, + tx: Sender, +) -> RunHandle { + let cancel = Arc::new(AtomicBool::new(false)); + let handle = RunHandle { + run_id, + cancel: Arc::clone(&cancel), + }; + thread::Builder::new() + .name("solverlab-runner".into()) + .spawn(move || run_simulation(&config, shuffle, run_id, &clock, &tx, &cancel)) + .expect("failed to spawn the simulation runner thread"); + handle +} + +fn run_simulation( + config: &SimConfig, + shuffle: ShuffleSource, + run_id: u64, + clock: &ActiveGamesClock, + tx: &Sender, + cancel: &AtomicBool, +) { + let cpu = cpu_count(); + let total = u32::try_from(config.simulations.max(0)).unwrap_or(u32::MAX); + let coordinator = Coordinator::new(total); + let mut workers = initial_workers(config, cpu); + let mut completed: u64 = 0; + let _ = tx.send(RunEvent::WorkersChanged { run_id, workers }); + + while !cancel.load(Ordering::Relaxed) { + let batch: Vec = thread::scope(|scope| { + let handles: Vec<_> = (0..workers) + .map(|worker| { + let coordinator = &coordinator; + thread::Builder::new() + .name(format!("{GAME_THREAD_PREFIX}-{worker}")) + .spawn_scoped(scope, move || { + if cancel.load(Ordering::Relaxed) { + return None; + } + let game_id = coordinator.take_next_game_id()?; + let result = play_game(game_id, config, shuffle, clock); + if !cancel.load(Ordering::Relaxed) { + let _ = tx.send(RunEvent::GameFinished { + run_id, + result: result.clone(), + }); + } + Some(result) + }) + .expect("failed to spawn a game thread") + }) + .collect(); + handles + .into_iter() + .filter_map(|handle| handle.join().ok().flatten()) + .collect() + }); + + if batch.is_empty() { + break; + } + completed += batch.len() as u64; + + if config.auto_parallel { + let stats = BatchStats { + completed: batch.len(), + wins: batch.iter().filter(|r| r.won).count(), + timeouts: batch + .iter() + .filter(|r| r.stop_reason == StopReason::Timeout) + .count(), + }; + workers = auto_parallel_next(workers, completed, config.simulations, stats, cpu); + let _ = tx.send(RunEvent::WorkersChanged { run_id, workers }); + } + } + + clock.clear(); + let _ = tx.send(RunEvent::Finished { run_id }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::GameType; + use std::collections::HashSet; + use std::sync::mpsc; + use std::time::Duration; + + fn stats(completed: usize, wins: usize, timeouts: usize) -> BatchStats { + BatchStats { + completed, + wins, + timeouts, + } + } + + #[test] + fn auto_parallel_initial_uses_half_the_cpus() { + assert_eq!(auto_parallel_initial(0, 8), 0); + assert_eq!(auto_parallel_initial(-3, 8), 0); + assert_eq!(auto_parallel_initial(1, 8), 1); + assert_eq!(auto_parallel_initial(3, 8), 3); + assert_eq!(auto_parallel_initial(100, 8), 4); + assert_eq!(auto_parallel_initial(100, 1), 1); + } + + #[test] + fn auto_parallel_next_adjusts_by_batch_rates() { + // Many timeouts → shrink. + assert_eq!(auto_parallel_next(4, 10, 100, stats(10, 1, 8), 8), 3); + assert_eq!(auto_parallel_next(1, 10, 100, stats(10, 1, 8), 8), 1); + // Few timeouts and decent win rate → grow, capped by CPUs. + assert_eq!(auto_parallel_next(2, 10, 100, stats(10, 5, 1), 8), 3); + assert_eq!(auto_parallel_next(8, 10, 100, stats(10, 5, 1), 8), 8); + assert_eq!(auto_parallel_next(2, 10, 100, stats(10, 5, 1), 2), 2); + // In between → unchanged. + assert_eq!(auto_parallel_next(4, 10, 100, stats(10, 1, 4), 8), 4); + // Never more than the remaining games. + assert_eq!(auto_parallel_next(16, 98, 100, stats(10, 9, 0), 8), 2); + // Empty batch keeps current within the cap. + assert_eq!(auto_parallel_next(16, 0, 100, stats(0, 0, 0), 8), 8); + } + + #[test] + fn manual_workers_are_clamped() { + let config = |simulations, parallel_games| SimConfig { + simulations, + parallel_games, + ..SimConfig::default() + }; + assert_eq!(initial_workers(&config(0, 0), 8), 1); + assert_eq!(initial_workers(&config(100, 4), 8), 4); + assert_eq!(initial_workers(&config(100, 64), 8), 8); + assert_eq!(initial_workers(&config(2, 4), 8), 2); + } + + #[test] + fn coordinator_returns_sequential_ids_then_none() { + let coordinator = Coordinator::new(2); + assert_eq!(coordinator.take_next_game_id(), Some(1)); + assert_eq!(coordinator.take_next_game_id(), Some(2)); + assert_eq!(coordinator.take_next_game_id(), None); + assert_eq!(coordinator.take_next_game_id(), None); + } + + #[test] + fn coordinator_ids_are_unique_across_threads() { + let coordinator = Coordinator::new(1000); + let ids: Vec = thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + scope.spawn(|| { + let mut ids = Vec::new(); + while let Some(id) = coordinator.take_next_game_id() { + ids.push(id); + } + ids + }) + }) + .collect(); + handles + .into_iter() + .flat_map(|h| h.join().unwrap()) + .collect() + }); + let unique: HashSet = ids.iter().copied().collect(); + assert_eq!(ids.len(), 1000); + assert_eq!(unique, (1..=1000).collect()); + } + + #[test] + fn active_games_clock_tracks_oldest_start() { + let clock = ActiveGamesClock::default(); + assert!(clock.oldest_start().is_none()); + + clock.begin(1); + let first = clock.oldest_start().unwrap(); + thread::sleep(Duration::from_millis(5)); + clock.begin(2); + assert_eq!(clock.oldest_start(), Some(first)); + + clock.end(1); + assert!(clock.oldest_start().unwrap() > first); + + clock.clear(); + assert!(clock.oldest_start().is_none()); + } + + fn collect_until_finished(rx: &mpsc::Receiver) -> Vec { + let mut events = Vec::new(); + loop { + let event = rx.recv_timeout(Duration::from_secs(60)).unwrap(); + let done = matches!(event, RunEvent::Finished { .. }); + events.push(event); + if done { + return events; + } + } + } + + #[test] + fn zero_simulations_finish_without_games() { + let (tx, rx) = mpsc::channel(); + let config = SimConfig::default(); + spawn_simulation( + config, + ShuffleSource::Seeded(1), + 7, + ActiveGamesClock::default(), + tx, + ); + let events = collect_until_finished(&rx); + assert!(matches!( + events.as_slice(), + [ + RunEvent::WorkersChanged { + run_id: 7, + workers: 1 + }, + RunEvent::Finished { run_id: 7 } + ] + )); + } + + #[test] + fn runs_every_game_once() { + let (tx, rx) = mpsc::channel(); + let config = SimConfig { + game_type: GameType::TriPeaks, + simulations: 3, + parallel_games: 2, + ..SimConfig::default() + }; + let clock = ActiveGamesClock::default(); + spawn_simulation(config, ShuffleSource::Seeded(1), 1, clock.clone(), tx); + let events = collect_until_finished(&rx); + let ids: HashSet = events + .iter() + .filter_map(|e| match e { + RunEvent::GameFinished { result, .. } => Some(result.id), + _ => None, + }) + .collect(); + assert_eq!(ids, HashSet::from([1, 2, 3])); + assert!(clock.oldest_start().is_none()); + } + + #[test] + fn cancel_stops_handing_out_games() { + let (tx, rx) = mpsc::channel(); + let config = SimConfig { + game_type: GameType::TriPeaks, + simulations: 50, + parallel_games: 1, + ..SimConfig::default() + }; + let handle = spawn_simulation( + config, + ShuffleSource::Seeded(1), + 1, + ActiveGamesClock::default(), + tx, + ); + handle.cancel(); + let events = collect_until_finished(&rx); + let games = events + .iter() + .filter(|e| matches!(e, RunEvent::GameFinished { .. })) + .count(); + assert!(games <= 1, "got {games} games after cancel"); + } +} From 0d3fddc165b82a47ba79d2619ed0f52ad6d9293a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:27:10 -0300 Subject: [PATCH 08/15] feat(tui): add App state mirroring SimulationViewModel --- solverlab-tui/src/app.rs | 522 +++++++++++++++++++++++++++++++++++ solverlab-tui/src/main.rs | 2 + solverlab-tui/src/runtime.rs | 9 + 3 files changed, 533 insertions(+) create mode 100644 solverlab-tui/src/app.rs diff --git a/solverlab-tui/src/app.rs b/solverlab-tui/src/app.rs new file mode 100644 index 0000000..0510e88 --- /dev/null +++ b/solverlab-tui/src/app.rs @@ -0,0 +1,522 @@ +//! Application state, mirroring `SimulationViewModel` (metrics, run control, +//! countdown and ETA). The UI only reads it; input only calls its methods. + +use std::collections::VecDeque; +use std::sync::mpsc::Sender; +use std::time::Instant; + +use crate::deck::ShuffleSource; +use crate::format::format_duration; +use crate::model::{SimConfig, SimResult, StopReason}; +use crate::runtime::{ + ActiveGamesClock, RunEvent, RunHandle, auto_parallel_initial, cpu_count, spawn_simulation, +}; + +/// Rows kept for the results table (`allResults` keeps everything for the CSV). +pub const VISIBLE_RESULTS_CAP: usize = 1500; +/// Countdown shown when the config has no timeout. +const DEFAULT_TIMEOUT_SECS: f64 = 15.0; + +pub type Spawner = + Box) -> RunHandle>; + +pub struct App { + pub config: SimConfig, + pub shuffle: ShuffleSource, + pub is_running: bool, + pub completed_games: u64, + pub wins: u64, + pub total_moves: i64, + pub total_undos: i64, + pub total_duration: f64, + pub timeout_count: u64, + pub stalled_count: u64, + pub win_count: u64, + pub results: VecDeque, + pub all_results: Vec, + pub status_text: String, + pub countdown_remaining: f64, + pub estimated_remaining_total: f64, + pub current_parallel_used: usize, + pub(crate) last_run_config: Option, + run_started_at: Option, + run: Option, + next_run_id: u64, + clock: ActiveGamesClock, + tx: Sender, + spawner: Spawner, + cpu: usize, +} + +impl App { + pub fn new(config: SimConfig, shuffle: ShuffleSource, tx: Sender) -> Self { + Self::with_spawner(config, shuffle, tx, Box::new(spawn_simulation), cpu_count()) + } + + pub fn with_spawner( + config: SimConfig, + shuffle: ShuffleSource, + tx: Sender, + spawner: Spawner, + cpu: usize, + ) -> Self { + App { + config, + shuffle, + is_running: false, + completed_games: 0, + wins: 0, + total_moves: 0, + total_undos: 0, + total_duration: 0.0, + timeout_count: 0, + stalled_count: 0, + win_count: 0, + results: VecDeque::new(), + all_results: Vec::new(), + status_text: "Ready".into(), + countdown_remaining: 0.0, + estimated_remaining_total: 0.0, + current_parallel_used: 0, + last_run_config: None, + run_started_at: None, + run: None, + next_run_id: 1, + clock: ActiveGamesClock::default(), + tx, + spawner, + cpu, + } + } + + fn average(&self, total: f64) -> f64 { + if self.completed_games == 0 { + 0.0 + } else { + total / self.completed_games as f64 + } + } + + pub fn win_rate(&self) -> f64 { + self.average(self.wins as f64) + } + + pub fn average_moves(&self) -> f64 { + self.average(self.total_moves as f64) + } + + pub fn average_duration(&self) -> f64 { + self.average(self.total_duration) + } + + pub fn average_undos(&self) -> f64 { + self.average(self.total_undos as f64) + } + + pub fn progress_fraction(&self) -> f64 { + let total = self.config.simulations.max(1) as f64; + (self.completed_games as f64 / total).min(1.0) + } + + pub fn countdown_display(&self) -> String { + format_duration(self.countdown_remaining) + } + + pub fn estimated_remaining_display(&self) -> String { + format_duration(self.estimated_remaining_total) + } + + pub fn start(&mut self) { + if self.is_running { + return; + } + self.reset_metrics(); + self.is_running = true; + self.status_text = "Running...".into(); + + let config = self.config.clone(); + self.last_run_config = Some(config.clone()); + self.run_started_at = Some(Instant::now()); + self.countdown_remaining = timeout_or_default(&config); + self.estimated_remaining_total = 0.0; + + let run_id = self.next_run_id; + self.next_run_id += 1; + self.run = Some((self.spawner)( + config, + self.shuffle, + run_id, + self.clock.clone(), + self.tx.clone(), + )); + } + + fn cancel_run(&mut self) { + if let Some(run) = self.run.take() { + run.cancel(); + } + self.is_running = false; + self.run_started_at = None; + self.countdown_remaining = 0.0; + self.estimated_remaining_total = 0.0; + self.current_parallel_used = 0; + } + + pub fn stop(&mut self) { + self.cancel_run(); + self.status_text = "Stopped".into(); + } + + pub fn clear_data(&mut self) { + self.cancel_run(); + self.reset_metrics(); + self.status_text = "Data cleared".into(); + } + + fn reset_metrics(&mut self) { + self.completed_games = 0; + self.wins = 0; + self.total_moves = 0; + self.total_undos = 0; + self.total_duration = 0.0; + self.timeout_count = 0; + self.stalled_count = 0; + self.win_count = 0; + self.results.clear(); + self.all_results.clear(); + self.last_run_config = None; + self.run_started_at = None; + self.countdown_remaining = 0.0; + self.estimated_remaining_total = 0.0; + self.current_parallel_used = 0; + self.status_text = "Ready".into(); + } + + /// Applies a runtime event; events from stopped or older runs are ignored. + pub fn handle_event(&mut self, event: RunEvent) { + let current = self.run.as_ref().map(|run| run.run_id); + match event { + RunEvent::WorkersChanged { run_id, workers } if Some(run_id) == current => { + self.current_parallel_used = workers; + } + RunEvent::GameFinished { run_id, result } if Some(run_id) == current => { + self.record(result); + } + RunEvent::Finished { run_id } if Some(run_id) == current => { + self.run = None; + self.clock.clear(); + self.run_started_at = None; + self.countdown_remaining = 0.0; + self.estimated_remaining_total = 0.0; + self.current_parallel_used = 0; + self.is_running = false; + self.status_text = "Completed".into(); + } + _ => {} + } + } + + pub(crate) fn record(&mut self, result: SimResult) { + self.completed_games += 1; + if result.won { + self.wins += 1; + } + self.total_moves += result.move_count; + self.total_undos += result.undo_count; + self.total_duration += result.duration; + match result.stop_reason { + StopReason::Win => self.win_count += 1, + StopReason::Stalled => self.stalled_count += 1, + StopReason::Timeout => self.timeout_count += 1, + } + + self.all_results.push(result.clone()); + self.results.push_back(result); + while self.results.len() > VISIBLE_RESULTS_CAP { + self.results.pop_front(); + } + + let total = self + .last_run_config + .as_ref() + .map_or(self.config.simulations, |c| c.simulations); + self.status_text = format!("Running {}/{}", self.completed_games, total); + } + + /// Port of `runCountdown`: refreshes the active timeout and the ETA. + pub fn tick(&mut self, now: Instant) { + if !self.is_running { + return; + } + let Some(config) = self.last_run_config.clone() else { + return; + }; + let timeout = timeout_or_default(&config); + let total_games = config.simulations.max(1); + let configured_workers = config.parallel_games.min(total_games).max(1); + + self.countdown_remaining = self.clock.oldest_start().map_or(timeout, |oldest| { + (timeout - now.saturating_duration_since(oldest).as_secs_f64()).max(0.0) + }); + + let completed = i64::try_from(self.completed_games).unwrap_or(i64::MAX); + let remaining_games = (total_games - completed).max(0); + if remaining_games == 0 { + self.estimated_remaining_total = 0.0; + return; + } + + if let Some(started_at) = self.run_started_at + && self.completed_games > 0 + { + let elapsed = now.saturating_duration_since(started_at).as_secs_f64(); + let per_game = elapsed / self.completed_games as f64; + self.estimated_remaining_total = (remaining_games as f64 * per_game).max(0.0); + } else { + let fallback = if config.auto_parallel { + auto_parallel_initial(total_games, self.cpu) as i64 + } else { + configured_workers + }; + let dynamic = if self.current_parallel_used > 0 { + self.current_parallel_used as i64 + } else { + fallback + }; + let workers = dynamic.min(remaining_games).max(1); + self.estimated_remaining_total = + (remaining_games as f64 * timeout / workers as f64).max(0.0); + } + } +} + +fn timeout_or_default(config: &SimConfig) -> f64 { + if config.timeout_seconds > 0.0 { + config.timeout_seconds + } else { + DEFAULT_TIMEOUT_SECS + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::sync::mpsc::{self, Receiver}; + use std::time::Duration; + + pub(crate) fn result(id: u32, won: bool, stop_reason: StopReason) -> SimResult { + SimResult { + id, + move_count: 10, + undo_count: 1, + checkpoint_count: Some(2), + won, + stop_reason, + duration: 1.5, + score: 50, + moves_detail: String::new(), + } + } + + /// App whose runs never spawn threads; events are injected by the test. + pub(crate) fn idle_app(config: SimConfig) -> (App, Receiver) { + let (tx, rx) = mpsc::channel(); + let spawner: Spawner = Box::new(|_, _, run_id, _, _| RunHandle::idle(run_id)); + ( + App::with_spawner(config, ShuffleSource::Seeded(1), tx, spawner, 8), + rx, + ) + } + + #[test] + fn metrics() { + let (mut app, _rx) = idle_app(SimConfig::default()); + app.config.simulations = 4; + app.completed_games = 2; + app.wins = 1; + app.total_moves = 30; + app.total_duration = 12.0; + app.total_undos = 4; + + assert_eq!(app.win_rate(), 0.5); + assert_eq!(app.average_moves(), 15.0); + assert_eq!(app.average_duration(), 6.0); + assert_eq!(app.average_undos(), 2.0); + assert_eq!(app.progress_fraction(), 0.5); + } + + #[test] + fn stop_clears_running_state_and_timers() { + let (mut app, _rx) = idle_app(SimConfig::default()); + app.start(); + app.countdown_remaining = 10.0; + app.estimated_remaining_total = 20.0; + + app.stop(); + + assert!(!app.is_running); + assert_eq!(app.countdown_remaining, 0.0); + assert_eq!(app.estimated_remaining_total, 0.0); + assert!(app.run_started_at.is_none()); + assert_eq!(app.status_text, "Stopped"); + } + + #[test] + fn clear_data_resets_counters_and_results() { + let (mut app, _rx) = idle_app(SimConfig::default()); + app.completed_games = 3; + app.wins = 2; + app.total_moves = 50; + app.total_undos = 7; + app.total_duration = 9.0; + app.timeout_count = 1; + app.stalled_count = 1; + app.win_count = 1; + app.results.push_back(result(1, true, StopReason::Win)); + app.all_results = app.results.iter().cloned().collect(); + + app.clear_data(); + + assert_eq!(app.completed_games, 0); + assert_eq!(app.wins, 0); + assert_eq!(app.total_moves, 0); + assert_eq!(app.total_undos, 0); + assert_eq!(app.total_duration, 0.0); + assert_eq!(app.timeout_count, 0); + assert_eq!(app.stalled_count, 0); + assert_eq!(app.win_count, 0); + assert!(app.results.is_empty()); + assert!(app.all_results.is_empty()); + assert_eq!(app.status_text, "Data cleared"); + } + + #[test] + fn start_sets_running_state() { + let config = SimConfig { + simulations: 10, + timeout_seconds: 0.0, + ..SimConfig::default() + }; + let (mut app, _rx) = idle_app(config.clone()); + app.start(); + assert!(app.is_running); + assert_eq!(app.status_text, "Running..."); + assert_eq!(app.countdown_remaining, 15.0); + assert_eq!(app.last_run_config, Some(config)); + } + + #[test] + fn events_update_metrics_and_finish() { + let config = SimConfig { + simulations: 3, + ..SimConfig::default() + }; + let (mut app, _rx) = idle_app(config); + app.start(); + let run_id = app.run.as_ref().unwrap().run_id; + + app.handle_event(RunEvent::WorkersChanged { run_id, workers: 2 }); + assert_eq!(app.current_parallel_used, 2); + + app.handle_event(RunEvent::GameFinished { + run_id, + result: result(1, true, StopReason::Win), + }); + app.handle_event(RunEvent::GameFinished { + run_id, + result: result(2, false, StopReason::Timeout), + }); + assert_eq!(app.completed_games, 2); + assert_eq!( + (app.win_count, app.timeout_count, app.stalled_count), + (1, 1, 0) + ); + assert_eq!(app.status_text, "Running 2/3"); + + app.handle_event(RunEvent::Finished { run_id }); + assert!(!app.is_running); + assert_eq!(app.status_text, "Completed"); + assert_eq!(app.current_parallel_used, 0); + } + + #[test] + fn events_from_stopped_or_old_runs_are_ignored() { + let (mut app, _rx) = idle_app(SimConfig { + simulations: 5, + ..SimConfig::default() + }); + app.start(); + let old_run = app.run.as_ref().unwrap().run_id; + app.stop(); + app.handle_event(RunEvent::GameFinished { + run_id: old_run, + result: result(1, true, StopReason::Win), + }); + assert_eq!(app.completed_games, 0); + + app.start(); + app.handle_event(RunEvent::Finished { run_id: old_run }); + assert!(app.is_running); + } + + #[test] + fn record_caps_visible_results_but_keeps_all() { + let (mut app, _rx) = idle_app(SimConfig::default()); + for id in 1..=(VISIBLE_RESULTS_CAP as u32 + 5) { + app.record(result(id, false, StopReason::Stalled)); + } + assert_eq!(app.results.len(), VISIBLE_RESULTS_CAP); + assert_eq!(app.results.front().unwrap().id, 6); + assert_eq!(app.all_results.len(), VISIBLE_RESULTS_CAP + 5); + } + + #[test] + fn tick_without_completed_games_uses_conservative_estimate() { + let config = SimConfig { + simulations: 10, + parallel_games: 2, + timeout_seconds: 4.0, + ..SimConfig::default() + }; + let (mut app, _rx) = idle_app(config); + app.start(); + app.tick(Instant::now()); + assert_eq!(app.countdown_remaining, 4.0); + // 10 games * 4s / 2 workers. + assert_eq!(app.estimated_remaining_total, 20.0); + + app.current_parallel_used = 5; + app.tick(Instant::now()); + assert_eq!(app.estimated_remaining_total, 8.0); + } + + #[test] + fn tick_with_completed_games_uses_wall_time_per_game() { + let (mut app, _rx) = idle_app(SimConfig { + simulations: 4, + ..SimConfig::default() + }); + app.start(); + let started = app.run_started_at.unwrap(); + app.record(result(1, true, StopReason::Win)); + app.tick(started + Duration::from_secs(6)); + // 3 remaining games * 6s per game. + assert!((app.estimated_remaining_total - 18.0).abs() < 1e-9); + } + + #[test] + fn tick_counts_down_from_oldest_active_game() { + let (mut app, _rx) = idle_app(SimConfig { + simulations: 4, + timeout_seconds: 10.0, + ..SimConfig::default() + }); + app.start(); + app.clock.begin(1); + let oldest = app.clock.oldest_start().unwrap(); + app.tick(oldest + Duration::from_secs(3)); + assert!((app.countdown_remaining - 7.0).abs() < 1e-9); + app.tick(oldest + Duration::from_secs(30)); + assert_eq!(app.countdown_remaining, 0.0); + } +} diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index 04345f3..153cd65 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -1,5 +1,7 @@ // Modules are wired into the UI incrementally; allow unused items until then. #[allow(dead_code)] +mod app; +#[allow(dead_code)] mod csv; #[allow(dead_code)] mod deck; diff --git a/solverlab-tui/src/runtime.rs b/solverlab-tui/src/runtime.rs index 87bcb08..ef21ad6 100644 --- a/solverlab-tui/src/runtime.rs +++ b/solverlab-tui/src/runtime.rs @@ -91,6 +91,15 @@ impl RunHandle { pub fn cancel(&self) { self.cancel.store(true, Ordering::Relaxed); } + + /// Handle for a run that has no runner thread (tests). + #[cfg(test)] + pub(crate) fn idle(run_id: u64) -> Self { + RunHandle { + run_id, + cancel: Arc::new(AtomicBool::new(false)), + } + } } pub fn cpu_count() -> usize { From 8eb8f7ae71dbaef5c57525c519fa36546e77171c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:27:51 -0300 Subject: [PATCH 09/15] feat(tui): persist simulation config as JSON --- solverlab-tui/Cargo.lock | 67 +++++++++++++++++++ solverlab-tui/Cargo.toml | 4 ++ solverlab-tui/src/app.rs | 48 ++++++++++++++ solverlab-tui/src/main.rs | 2 + solverlab-tui/src/persist.rs | 125 +++++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 solverlab-tui/src/persist.rs diff --git a/solverlab-tui/Cargo.lock b/solverlab-tui/Cargo.lock index 81c6e50..adc177d 100644 --- a/solverlab-tui/Cargo.lock +++ b/solverlab-tui/Cargo.lock @@ -612,6 +612,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "document-features" version = "0.2.12" @@ -673,6 +694,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -974,6 +1001,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + [[package]] name = "line-clipping" version = "0.3.8" @@ -1184,6 +1220,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -1585,6 +1627,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "regex" version = "1.13.1" @@ -1836,12 +1888,14 @@ version = "0.1.0" dependencies = [ "chrono", "color-eyre", + "directories", "rand 0.10.3", "rand_chacha 0.10.0", "ratatui", "serde", "serde_json", "solver_core", + "tempfile", ] [[package]] @@ -1910,6 +1964,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termina" version = "0.3.3" diff --git a/solverlab-tui/Cargo.toml b/solverlab-tui/Cargo.toml index edc1307..61c3487 100644 --- a/solverlab-tui/Cargo.toml +++ b/solverlab-tui/Cargo.toml @@ -18,10 +18,14 @@ serde_json = "1.0.151" chrono = "0.4.45" rand = "0.10.3" rand_chacha = "0.10.0" +directories = "6.0.0" [profile.release] opt-level = 3 lto = "thin" codegen-units = 1 + +[dev-dependencies] +tempfile = "3.27.0" # Keep unwinding (unlike the UniFFI build) so the terminal is always restored # and a panicking game can be recorded instead of killing the whole run. diff --git a/solverlab-tui/src/app.rs b/solverlab-tui/src/app.rs index 0510e88..6f85f1a 100644 --- a/solverlab-tui/src/app.rs +++ b/solverlab-tui/src/app.rs @@ -8,6 +8,7 @@ use std::time::Instant; use crate::deck::ShuffleSource; use crate::format::format_duration; use crate::model::{SimConfig, SimResult, StopReason}; +use crate::persist::ConfigStore; use crate::runtime::{ ActiveGamesClock, RunEvent, RunHandle, auto_parallel_initial, cpu_count, spawn_simulation, }; @@ -46,6 +47,7 @@ pub struct App { tx: Sender, spawner: Spawner, cpu: usize, + store: ConfigStore, } impl App { @@ -86,6 +88,25 @@ impl App { tx, spawner, cpu, + store: ConfigStore::disabled(), + } + } + + /// Persists every config change to `store` (Swift: `didSet { persistConfig() }`). + pub fn with_store(mut self, store: ConfigStore) -> Self { + self.store = store; + self + } + + /// Mutates the config and saves it when something changed. + pub fn set_config(&mut self, update: impl FnOnce(&mut SimConfig)) { + let before = self.config.clone(); + update(&mut self.config); + if self.config == before { + return; + } + if let Err(err) = self.store.save(&self.config) { + self.status_text = format!("Config not saved: {err}"); } } @@ -328,6 +349,33 @@ pub(crate) mod tests { ) } + #[test] + fn set_config_persists_only_changes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + let (app, _rx) = idle_app(SimConfig::default()); + let mut app = app.with_store(ConfigStore::at(&path)); + + app.set_config(|_| {}); + assert!(!path.exists()); + + app.set_config(|c| c.simulations = 12); + assert_eq!(ConfigStore::at(&path).load().simulations, 12); + } + + #[test] + fn set_config_reports_save_errors() { + let dir = tempfile::tempdir().unwrap(); + let blocker = dir.path().join("file"); + std::fs::write(&blocker, "").unwrap(); + let (app, _rx) = idle_app(SimConfig::default()); + let mut app = app.with_store(ConfigStore::at(blocker.join("config.json"))); + + app.set_config(|c| c.simulations = 1); + assert_eq!(app.config.simulations, 1); + assert!(app.status_text.starts_with("Config not saved")); + } + #[test] fn metrics() { let (mut app, _rx) = idle_app(SimConfig::default()); diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index 153cd65..358d751 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -12,6 +12,8 @@ mod games; #[allow(dead_code)] mod model; #[allow(dead_code)] +mod persist; +#[allow(dead_code)] mod runtime; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; diff --git a/solverlab-tui/src/persist.rs b/solverlab-tui/src/persist.rs new file mode 100644 index 0000000..380a2c5 --- /dev/null +++ b/solverlab-tui/src/persist.rs @@ -0,0 +1,125 @@ +//! Config persistence, replacing the Swift `UserDefaults["solverlab.simconfig.v1"]`. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use directories::ProjectDirs; + +use crate::model::SimConfig; + +const FILE_NAME: &str = "simconfig.v1.json"; + +#[derive(Clone, Debug, Default)] +pub struct ConfigStore { + /// `None` disables persistence. + path: Option, +} + +impl ConfigStore { + pub fn disabled() -> Self { + ConfigStore { path: None } + } + + pub fn at(path: impl Into) -> Self { + ConfigStore { + path: Some(path.into()), + } + } + + /// Platform config dir, e.g. `~/.config/solverlab/simconfig.v1.json` on Linux. + pub fn default_path() -> Option { + ProjectDirs::from("com", "jfg", "solverlab").map(|dirs| dirs.config_dir().join(FILE_NAME)) + } + + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + /// Missing or unreadable files fall back to the defaults. + pub fn load(&self) -> SimConfig { + self.path + .as_ref() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() + } + + pub fn save(&self, config: &SimConfig) -> io::Result<()> { + let Some(path) = &self.path else { + return Ok(()); + }; + let json = serde_json::to_string_pretty(config).map_err(io::Error::other)?; + write_atomic(path, json.as_bytes()) + } +} + +/// Writes to a sibling temp file and renames it over `path`. +pub fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { + if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) { + fs::create_dir_all(dir)?; + } + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + fs::write(&tmp, contents)?; + fs::rename(&tmp, path).inspect_err(|_| { + let _ = fs::remove_file(&tmp); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::GameType; + + #[test] + fn save_and_load_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("nested/config.json")); + let config = SimConfig { + game_type: GameType::TriPeaks, + simulations: 25, + parallel_games: 3, + auto_parallel: true, + ..SimConfig::default() + }; + store.save(&config).unwrap(); + assert_eq!(store.load(), config); + } + + #[test] + fn missing_or_broken_file_loads_defaults() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + let store = ConfigStore::at(&path); + assert_eq!(store.load(), SimConfig::default()); + + fs::write(&path, "{not json").unwrap(); + assert_eq!(store.load(), SimConfig::default()); + } + + #[test] + fn legacy_partial_json_fills_defaults() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write( + &path, + r#"{"gameType":"Pyramid","simulations":7,"parallelGames":2,"autoParallel":false}"#, + ) + .unwrap(); + let config = ConfigStore::at(&path).load(); + assert_eq!(config.game_type, GameType::Pyramid); + assert_eq!(config.simulations, 7); + assert_eq!(config.max_depth, 150); + assert_eq!(config.max_undos, -1); + } + + #[test] + fn disabled_store_never_writes() { + let store = ConfigStore::disabled(); + assert!(store.path().is_none()); + store.save(&SimConfig::default()).unwrap(); + assert_eq!(store.load(), SimConfig::default()); + } +} From 17918cc2503212c3c48016782e702d16e24fedb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:30:33 -0300 Subject: [PATCH 10/15] feat(tui): render controls, progress, metrics and results table --- solverlab-tui/src/main.rs | 21 ++- solverlab-tui/src/ui/controls.rs | 152 +++++++++++++++ solverlab-tui/src/ui/metrics.rs | 140 ++++++++++++++ solverlab-tui/src/ui/mod.rs | 305 +++++++++++++++++++++++++++++++ solverlab-tui/src/ui/progress.rs | 26 +++ solverlab-tui/src/ui/table.rs | 166 +++++++++++++++++ 6 files changed, 804 insertions(+), 6 deletions(-) create mode 100644 solverlab-tui/src/ui/controls.rs create mode 100644 solverlab-tui/src/ui/metrics.rs create mode 100644 solverlab-tui/src/ui/mod.rs create mode 100644 solverlab-tui/src/ui/progress.rs create mode 100644 solverlab-tui/src/ui/table.rs diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index 358d751..9c6d7c1 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -15,19 +15,28 @@ mod model; mod persist; #[allow(dead_code)] mod runtime; +#[allow(dead_code)] +mod ui; + +use std::sync::mpsc; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use ratatui::widgets::{Block, Paragraph}; + +use app::App; +use deck::ShuffleSource; +use persist::ConfigStore; +use ui::ViewState; fn main() -> color_eyre::Result<()> { color_eyre::install()?; + let store = ConfigStore::default_path().map_or_else(ConfigStore::disabled, ConfigStore::at); + let (tx, _rx) = mpsc::channel(); + let app = App::new(store.load(), ShuffleSource::Random, tx).with_store(store); + let mut view = ViewState::new(false); + ratatui::run(|terminal| -> color_eyre::Result<()> { loop { - terminal.draw(|frame| { - let body = Paragraph::new("SolverLab TUI — press q to quit") - .block(Block::bordered().title(" SolverLab ")); - frame.render_widget(body, frame.area()); - })?; + terminal.draw(|frame| ui::draw(frame, &app, &mut view))?; if let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) diff --git a/solverlab-tui/src/ui/controls.rs b/solverlab-tui/src/ui/controls.rs new file mode 100644 index 0000000..3c9c36a --- /dev/null +++ b/solverlab-tui/src/ui/controls.rs @@ -0,0 +1,152 @@ +//! Top bar: game picker, numeric inputs, auto toggle, actions and status. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use super::{Focus, ViewState, focus_style}; +use crate::app::App; + +pub fn height(wide: bool) -> u16 { + if wide { 3 } else { 4 } +} + +/// Whether "Export CSV" is enabled (Swift: disabled when there are no results). +pub fn can_export(app: &App) -> bool { + !app.results.is_empty() +} + +/// Whether "Clear Data" is enabled. +pub fn can_clear(app: &App) -> bool { + !app.results.is_empty() || app.completed_games > 0 +} + +fn numeric_field<'a>( + label: &'a str, + value: i64, + focus: Focus, + view: &ViewState, + enabled: bool, +) -> Vec> { + let focused = view.focus == focus; + let text = match (&view.editing, focused) { + (Some(buffer), true) => format!("[{buffer}_]"), + _ => format!("[{value}]"), + }; + vec![ + Span::raw(format!("{label}: ")), + Span::styled(text, focus_style(focused, enabled)), + Span::raw(" "), + ] +} + +fn button<'a>( + label: String, + focus: Focus, + view: &ViewState, + enabled: bool, + base: Style, +) -> Span<'a> { + Span::styled( + format!(" {label} "), + base.patch(focus_style(view.focus == focus, enabled)), + ) +} + +pub fn render(frame: &mut Frame, area: Rect, app: &App, view: &ViewState, wide: bool) { + let glyphs = view.glyphs; + let config = &app.config; + let editable = !app.is_running; + + let mut settings = vec![Span::raw("Game: ")]; + settings.push(Span::styled( + format!( + "{}{}{}", + glyphs.picker_left, + config.game_type.ui_label(), + glyphs.picker_right + ), + focus_style(view.focus == Focus::Game, editable), + )); + settings.push(Span::raw(" ")); + settings.extend(numeric_field( + "Sims", + config.simulations, + Focus::Sims, + view, + editable, + )); + settings.extend(numeric_field( + "Parallel", + config.parallel_games, + Focus::Parallel, + view, + editable && !config.auto_parallel, + )); + settings.push(Span::raw("Auto: ")); + settings.push(Span::styled( + if config.auto_parallel { "[x]" } else { "[ ]" }, + focus_style(view.focus == Focus::Auto, editable), + )); + + let (start_label, start_style) = if app.is_running { + ( + format!("{} Stop", glyphs.stop), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + } else { + ( + format!("{} Start", glyphs.play), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + }; + let actions = vec![ + button(start_label, Focus::StartStop, view, true, start_style), + Span::raw(" "), + button( + "Export CSV".into(), + Focus::Export, + view, + can_export(app), + Style::default(), + ), + Span::raw(" "), + button( + "Clear Data".into(), + Focus::Clear, + view, + can_clear(app), + Style::default(), + ), + ]; + + let lines = if wide { + let mut line = settings; + line.push(Span::raw(format!(" {} ", glyphs.separator))); + line.extend(actions); + vec![Line::from(line)] + } else { + vec![Line::from(settings), Line::from(actions)] + }; + + let status_color = if app.is_running { + Color::Green + } else { + Color::Gray + }; + let status = Line::from(vec![ + Span::styled( + format!(" {} ", glyphs.dot), + Style::default().fg(status_color), + ), + Span::raw(format!("{} ", app.status_text)), + ]) + .right_aligned(); + + let block = Block::bordered().title(" SolverLab ").title(status); + frame.render_widget(Paragraph::new(lines).block(block), area); +} diff --git a/solverlab-tui/src/ui/metrics.rs b/solverlab-tui/src/ui/metrics.rs new file mode 100644 index 0000000..e482d2e --- /dev/null +++ b/solverlab-tui/src/ui/metrics.rs @@ -0,0 +1,140 @@ +//! The three metric groups (status, results, averages), like the Swift `MetricBox` rows. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use crate::app::App; + +struct Metric { + title: &'static str, + value: String, + tint: Color, +} + +fn metric(title: &'static str, value: String, tint: Color) -> Metric { + Metric { title, value, tint } +} + +fn groups(app: &App) -> [(&'static str, Vec); 3] { + [ + ( + "Status", + vec![ + metric("Games", app.completed_games.to_string(), Color::Blue), + metric( + "Parallel used", + app.current_parallel_used.to_string(), + Color::Blue, + ), + metric("ETA", app.estimated_remaining_display(), Color::Blue), + metric("Active timeout", app.countdown_display(), Color::Blue), + ], + ), + ( + "Results", + vec![ + metric( + "Win rate", + format!("{:.2}%", app.win_rate() * 100.0), + Color::Green, + ), + metric("Wins", app.win_count.to_string(), Color::Green), + metric("Stalled", app.stalled_count.to_string(), Color::Yellow), + metric("Timeout", app.timeout_count.to_string(), Color::Yellow), + ], + ), + ( + "Averages", + vec![ + metric( + "Avg moves", + format!("{:.2}", app.average_moves()), + Color::Magenta, + ), + metric( + "Avg undos", + format!("{:.2}", app.average_undos()), + Color::Magenta, + ), + metric( + "Avg duration", + format!("{:.2}s", app.average_duration()), + Color::Magenta, + ), + ], + ), + ] +} + +pub fn height(wide: bool) -> u16 { + if wide { 4 } else { 9 } +} + +fn group_block(title: &str, tint: Color) -> Block<'_> { + Block::bordered() + .title(format!(" {title} ")) + .border_style(Style::default().fg(tint)) +} + +/// Cell width in the wide layout: the title plus some breathing room. +fn cell_width(metric: &Metric) -> u16 { + metric.title.len().max(8) as u16 + 2 +} + +/// Wide: title above value in its own cell. Narrow: `title value` pairs on one line. +pub fn render(frame: &mut Frame, area: Rect, app: &App, wide: bool) { + let groups = groups(app); + if wide { + let areas = Layout::horizontal( + groups + .iter() + .map(|(_, metrics)| { + Constraint::Length(metrics.iter().map(cell_width).sum::() + 2) + }) + .chain([Constraint::Fill(1)]), + ) + .split(area); + for ((title, metrics), group_area) in groups.iter().zip(areas.iter()) { + let block = group_block(title, metrics[0].tint); + let inner = block.inner(*group_area); + frame.render_widget(block, *group_area); + let cells = + Layout::horizontal(metrics.iter().map(|m| Constraint::Length(cell_width(m)))) + .split(inner); + for (metric, cell) in metrics.iter().zip(cells.iter()) { + let text = vec![ + Line::styled( + metric.title, + Style::default().fg(metric.tint).add_modifier(Modifier::DIM), + ), + Line::styled( + metric.value.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]; + frame.render_widget(Paragraph::new(text), *cell); + } + } + } else { + let areas = Layout::vertical([Constraint::Length(3); 3]).split(area); + for ((title, metrics), group_area) in groups.iter().zip(areas.iter()) { + let mut spans = Vec::new(); + for metric in metrics { + spans.push(Span::styled( + format!("{} ", metric.title), + Style::default().fg(metric.tint), + )); + spans.push(Span::styled( + format!("{} ", metric.value), + Style::default().add_modifier(Modifier::BOLD), + )); + } + let paragraph = + Paragraph::new(Line::from(spans)).block(group_block(title, metrics[0].tint)); + frame.render_widget(paragraph, *group_area); + } + } +} diff --git a/solverlab-tui/src/ui/mod.rs b/solverlab-tui/src/ui/mod.rs new file mode 100644 index 0000000..7b960cc --- /dev/null +++ b/solverlab-tui/src/ui/mod.rs @@ -0,0 +1,305 @@ +//! Rendering, ported from `ContentView.swift`. `draw` is a pure function of +//! the app state plus view-only state (focus, sorting, scroll). + +mod controls; +mod metrics; +mod progress; +mod table; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::TableState; + +use crate::app::App; + +/// Terminal width from which metrics and controls fit on a single row. +const WIDE_LAYOUT_MIN_WIDTH: u16 = 130; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Focus { + Game, + Sims, + Parallel, + Auto, + StartStop, + Export, + Clear, + Table, +} + +impl Focus { + pub const ALL: [Focus; 8] = [ + Focus::Game, + Focus::Sims, + Focus::Parallel, + Focus::Auto, + Focus::StartStop, + Focus::Export, + Focus::Clear, + Focus::Table, + ]; + + fn index(self) -> usize { + Self::ALL.iter().position(|&f| f == self).unwrap_or(0) + } + + pub fn next(self) -> Self { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + pub fn prev(self) -> Self { + Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SortColumn { + #[default] + GameId, + Moves, + Undos, + Checkpoints, + Result, + StopReason, + Duration, + Score, +} + +impl SortColumn { + pub const ALL: [SortColumn; 8] = [ + SortColumn::GameId, + SortColumn::Moves, + SortColumn::Undos, + SortColumn::Checkpoints, + SortColumn::Result, + SortColumn::StopReason, + SortColumn::Duration, + SortColumn::Score, + ]; + + pub fn title(self) -> &'static str { + match self { + SortColumn::GameId => "Game ID", + SortColumn::Moves => "Moves", + SortColumn::Undos => "Undos", + SortColumn::Checkpoints => "Checkpoints", + SortColumn::Result => "Result", + SortColumn::StopReason => "Stop Reason", + SortColumn::Duration => "Duration", + SortColumn::Score => "Score", + } + } + + fn index(self) -> usize { + Self::ALL.iter().position(|&c| c == self).unwrap_or(0) + } + + pub fn next(self) -> Self { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + pub fn prev(self) -> Self { + Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +/// Unicode glyphs with an ASCII fallback for limited consoles (`--ascii`). +#[derive(Clone, Copy, Debug)] +pub struct Glyphs { + pub won: &'static str, + pub lost: &'static str, + pub play: &'static str, + pub stop: &'static str, + pub dot: &'static str, + pub picker_left: &'static str, + pub picker_right: &'static str, + pub descending: &'static str, + pub separator: &'static str, +} + +impl Glyphs { + pub const UNICODE: Glyphs = Glyphs { + won: "✓", + lost: "✗", + play: "▶", + stop: "■", + dot: "●", + picker_left: "‹", + picker_right: "›", + descending: "↓", + separator: "│", + }; + + pub const ASCII: Glyphs = Glyphs { + won: "+", + lost: "x", + play: ">", + stop: "#", + dot: "*", + picker_left: "<", + picker_right: ">", + descending: "v", + separator: "|", + }; +} + +pub struct ViewState { + pub focus: Focus, + /// Text buffer while a numeric field is being edited. + pub editing: Option, + pub sort_column: SortColumn, + pub table: TableState, + pub show_help: bool, + pub glyphs: Glyphs, +} + +impl ViewState { + pub fn new(ascii: bool) -> Self { + ViewState { + focus: Focus::StartStop, + editing: None, + sort_column: SortColumn::default(), + table: TableState::default(), + show_help: false, + glyphs: if ascii { + Glyphs::ASCII + } else { + Glyphs::UNICODE + }, + } + } +} + +pub(crate) fn focus_style(focused: bool, enabled: bool) -> Style { + let mut style = Style::default(); + if !enabled { + style = style.add_modifier(Modifier::DIM); + } + if focused { + style = style.add_modifier(Modifier::REVERSED); + } + style +} + +pub fn draw(frame: &mut Frame, app: &App, view: &mut ViewState) { + let area = frame.area(); + let wide = area.width >= WIDE_LAYOUT_MIN_WIDTH; + let [ + controls_area, + progress_area, + metrics_area, + table_area, + footer_area, + ] = Layout::vertical([ + Constraint::Length(controls::height(wide)), + Constraint::Length(progress::HEIGHT), + Constraint::Length(metrics::height(wide)), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(area); + + controls::render(frame, controls_area, app, view, wide); + progress::render(frame, progress_area, app); + metrics::render(frame, metrics_area, app, wide); + table::render(frame, table_area, app, view); + frame.render_widget(footer(), footer_area); +} + +fn footer() -> Line<'static> { + Line::from(" Tab focus · s start/stop · e export · c clear · [ ] sort · ? help · q quit") + .style(Style::default().add_modifier(Modifier::DIM)) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::app::tests::{idle_app, result}; + use crate::model::{GameType, SimConfig, StopReason}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + pub(crate) fn render(app: &App, view: &mut ViewState, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| draw(frame, app, view)).unwrap(); + let buffer = terminal.backend().buffer(); + let mut text = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + text.push_str(buffer[(x, y)].symbol()); + } + text.push('\n'); + } + text + } + + pub(crate) fn sample_app() -> App { + let (mut app, _rx) = idle_app(SimConfig { + game_type: GameType::FreeCell, + simulations: 3, + parallel_games: 2, + ..SimConfig::default() + }); + app.record(result(1, true, StopReason::Win)); + app.record(result(2, false, StopReason::Stalled)); + app.record(result(3, true, StopReason::Win)); + app + } + + #[test] + fn wide_layout_shows_all_sections() { + let app = sample_app(); + let mut view = ViewState::new(false); + let screen = render(&app, &mut view, 150, 40); + for needle in [ + "SolverLab", + "Running 3/3", + "FreeCell", + "Progress", + "3/3 (100%)", + "Win rate", + "66.67%", + "Avg duration", + "1.50s", + "Game ID", + "Stop Reason", + "✓ won", + "✗ lost", + "Start", + "Export CSV", + "Clear Data", + ] { + assert!(screen.contains(needle), "missing {needle:?} in:\n{screen}"); + } + } + + #[test] + fn narrow_layout_renders_without_panicking() { + let app = sample_app(); + let mut view = ViewState::new(true); + let screen = render(&app, &mut view, 80, 24); + assert!(screen.contains("Win rate")); + assert!(screen.contains("+ won")); + // Tiny terminals must not panic either. + render(&app, &mut view, 20, 5); + } + + #[test] + #[ignore = "prints the rendered screens for manual inspection"] + fn print_screens() { + let app = sample_app(); + let mut view = ViewState::new(false); + println!("{}", render(&app, &mut view, 150, 20)); + println!("{}", render(&app, &mut view, 80, 24)); + } + + #[test] + fn focus_and_sort_cycle() { + assert_eq!(Focus::Table.next(), Focus::Game); + assert_eq!(Focus::Game.prev(), Focus::Table); + assert_eq!(SortColumn::Score.next(), SortColumn::GameId); + assert_eq!(SortColumn::GameId.prev(), SortColumn::Score); + } +} diff --git a/solverlab-tui/src/ui/progress.rs b/solverlab-tui/src/ui/progress.rs new file mode 100644 index 0000000..b6de4c6 --- /dev/null +++ b/solverlab-tui/src/ui/progress.rs @@ -0,0 +1,26 @@ +//! Progress bar with `completed/total (pct%)`. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Gauge}; + +use crate::app::App; + +pub const HEIGHT: u16 = 3; + +pub fn label(app: &App) -> String { + let total = app.config.simulations.max(1); + let percent = (app.progress_fraction() * 100.0).round() as i64; + format!("{}/{} ({}%)", app.completed_games, total, percent) +} + +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let gauge = Gauge::default() + .block(Block::bordered().title(" Progress ")) + .gauge_style(Style::default().fg(Color::Cyan)) + .use_unicode(true) + .ratio(app.progress_fraction().clamp(0.0, 1.0)) + .label(label(app)); + frame.render_widget(gauge, area); +} diff --git a/solverlab-tui/src/ui/table.rs b/solverlab-tui/src/ui/table.rs new file mode 100644 index 0000000..6a81a14 --- /dev/null +++ b/solverlab-tui/src/ui/table.rs @@ -0,0 +1,166 @@ +//! Results table. Like the Swift `Table`, sorting is always descending. + +use std::cmp::Ordering; +use std::collections::VecDeque; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Margin, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Cell, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table}; + +use super::{Focus, SortColumn, ViewState}; +use crate::app::App; +use crate::model::SimResult; + +const WIDTHS: [u16; 8] = [9, 7, 7, 12, 8, 12, 9, 7]; + +fn compare(a: &SimResult, b: &SimResult, column: SortColumn) -> Ordering { + match column { + SortColumn::GameId => a.id.cmp(&b.id), + SortColumn::Moves => a.move_count.cmp(&b.move_count), + SortColumn::Undos => a.undo_count.cmp(&b.undo_count), + SortColumn::Checkpoints => a.checkpoint_sort_value().cmp(&b.checkpoint_sort_value()), + SortColumn::Result => a.result_label().cmp(b.result_label()), + SortColumn::StopReason => a.stop_reason.label().cmp(b.stop_reason.label()), + SortColumn::Duration => a.duration.total_cmp(&b.duration), + SortColumn::Score => a.score.cmp(&b.score), + } +} + +/// Rows sorted descending by `column`, ties broken by descending game id. +pub fn sorted_rows(results: &VecDeque, column: SortColumn) -> Vec<&SimResult> { + let mut rows: Vec<&SimResult> = results.iter().collect(); + rows.sort_by(|a, b| compare(b, a, column).then_with(|| b.id.cmp(&a.id))); + rows +} + +pub fn render(frame: &mut Frame, area: Rect, app: &App, view: &mut ViewState) { + let glyphs = view.glyphs; + let rows = sorted_rows(&app.results, view.sort_column); + + let header = Row::new(SortColumn::ALL.iter().map(|&column| { + let title = if column == view.sort_column { + format!("{} {}", column.title(), glyphs.descending) + } else { + column.title().to_string() + }; + Cell::from(title) + })) + .style(Style::default().add_modifier(Modifier::BOLD)); + + let body = rows.iter().map(|row| { + let (mark, color) = if row.won { + (glyphs.won, Color::Green) + } else { + (glyphs.lost, Color::Red) + }; + Row::new([ + Cell::from(row.id.to_string()), + Cell::from(row.move_count.to_string()), + Cell::from(row.undo_count.to_string()), + Cell::from(row.checkpoint_count_label()), + Cell::from(Line::styled( + format!("{mark} {}", row.result_label()), + Style::default().fg(color), + )), + Cell::from(row.stop_reason.label()), + Cell::from(format!("{:.2}s", row.duration)), + Cell::from(row.score.to_string()), + ]) + }); + + let focused = view.focus == Focus::Table; + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + let block = Block::bordered() + .border_style(border_style) + .title(format!( + " Results (sorted by {} {}) ", + view.sort_column.title(), + glyphs.descending + )) + .title(Line::from(format!(" {} rows ", rows.len())).right_aligned()); + + let highlight = if focused { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let table = Table::new(body, WIDTHS.map(Constraint::Length)) + .header(header) + .block(block) + .row_highlight_style(highlight); + + if view.table.selected().is_none() && !rows.is_empty() { + view.table.select(Some(0)); + } + if let Some(selected) = view.table.selected() + && selected >= rows.len() + { + view.table.select(rows.len().checked_sub(1)); + } + frame.render_stateful_widget(table, area, &mut view.table); + + let mut scrollbar_state = + ScrollbarState::new(rows.len()).position(view.table.selected().unwrap_or(0)); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::StopReason; + + fn row(id: u32, moves: i64, checkpoints: Option, duration: f64, won: bool) -> SimResult { + SimResult { + id, + move_count: moves, + undo_count: 0, + checkpoint_count: checkpoints, + won, + stop_reason: if won { + StopReason::Win + } else { + StopReason::Stalled + }, + duration, + score: moves * 2, + moves_detail: String::new(), + } + } + + fn ids(results: &VecDeque, column: SortColumn) -> Vec { + sorted_rows(results, column).iter().map(|r| r.id).collect() + } + + #[test] + fn sorting_is_always_descending() { + let results: VecDeque = [ + row(1, 30, Some(2), 0.5, true), + row(2, 10, None, 2.5, false), + row(3, 20, Some(5), 1.5, true), + ] + .into_iter() + .collect(); + + assert_eq!(ids(&results, SortColumn::GameId), [3, 2, 1]); + assert_eq!(ids(&results, SortColumn::Moves), [1, 3, 2]); + assert_eq!(ids(&results, SortColumn::Score), [1, 3, 2]); + assert_eq!(ids(&results, SortColumn::Checkpoints), [3, 1, 2]); + assert_eq!(ids(&results, SortColumn::Duration), [2, 3, 1]); + // "won" > "lost"; ties fall back to descending id. + assert_eq!(ids(&results, SortColumn::Result), [3, 1, 2]); + assert_eq!(ids(&results, SortColumn::Undos), [3, 2, 1]); + } +} From 491024543a44ec434d16529966c7338c9fcb3231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:33:11 -0300 Subject: [PATCH 11/15] feat(tui): wire keyboard input and event loop --- solverlab-tui/src/input.rs | 443 +++++++++++++++++++++++++++++++++++ solverlab-tui/src/main.rs | 80 +++++-- solverlab-tui/src/ui/help.rs | 49 ++++ solverlab-tui/src/ui/mod.rs | 9 +- 4 files changed, 560 insertions(+), 21 deletions(-) create mode 100644 solverlab-tui/src/input.rs create mode 100644 solverlab-tui/src/ui/help.rs diff --git a/solverlab-tui/src/input.rs b/solverlab-tui/src/input.rs new file mode 100644 index 0000000..7398751 --- /dev/null +++ b/solverlab-tui/src/input.rs @@ -0,0 +1,443 @@ +//! Keyboard handling: `map_key` turns key presses into actions (pure) and +//! `apply` runs them against the app and view state. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +use crate::app::App; +use crate::ui::controls::{can_clear, can_export}; +use crate::ui::{Focus, ViewState}; + +const MAX_DIGITS: usize = 9; +const PAGE_ROWS: u16 = 10; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Quit, + FocusNext, + FocusPrev, + Activate, + GameNext, + GamePrev, + ToggleAuto, + StartStop, + Clear, + Export, + EditChar(char), + EditBackspace, + EditCommit, + EditCancel, + Increment(i64), + ScrollUp, + ScrollDown, + PageUp, + PageDown, + Top, + Bottom, + SortNext, + SortPrev, + ToggleHelp, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Flow { + Continue, + Quit, +} + +fn is_numeric(focus: Focus) -> bool { + matches!(focus, Focus::Sims | Focus::Parallel) +} + +pub fn map_key(key: KeyEvent, view: &ViewState) -> Option { + // Windows reports releases too; only presses count. + if key.kind != KeyEventKind::Press { + return None; + } + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return Some(Action::Quit); + } + if view.show_help { + return matches!( + key.code, + KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter + ) + .then_some(Action::ToggleHelp); + } + + let numeric = is_numeric(view.focus); + if view.editing.is_some() { + match key.code { + KeyCode::Char(c) if c.is_ascii_digit() => return Some(Action::EditChar(c)), + KeyCode::Backspace => return Some(Action::EditBackspace), + KeyCode::Enter => return Some(Action::EditCommit), + KeyCode::Esc => return Some(Action::EditCancel), + _ => {} + } + } + + let step = if key.modifiers.contains(KeyModifiers::SHIFT) { + 10 + } else { + 1 + }; + Some(match key.code { + KeyCode::Tab => Action::FocusNext, + KeyCode::BackTab => Action::FocusPrev, + KeyCode::Enter | KeyCode::Char(' ') => Action::Activate, + KeyCode::Esc | KeyCode::Char('q') => Action::Quit, + KeyCode::Char('s') => Action::StartStop, + KeyCode::Char('c') => Action::Clear, + KeyCode::Char('e') => Action::Export, + KeyCode::Char('a') => Action::ToggleAuto, + KeyCode::Char('g') => Action::GameNext, + KeyCode::Char('G') => Action::GamePrev, + KeyCode::Char('?') => Action::ToggleHelp, + KeyCode::Char('[') => Action::SortPrev, + KeyCode::Char(']') => Action::SortNext, + KeyCode::Char(c) if numeric && c.is_ascii_digit() => Action::EditChar(c), + KeyCode::Backspace if numeric => Action::EditBackspace, + KeyCode::Left if view.focus == Focus::Game => Action::GamePrev, + KeyCode::Right if view.focus == Focus::Game => Action::GameNext, + KeyCode::Left => Action::FocusPrev, + KeyCode::Right => Action::FocusNext, + KeyCode::Up if numeric => Action::Increment(step), + KeyCode::Down if numeric => Action::Increment(-step), + KeyCode::Up => Action::ScrollUp, + KeyCode::Down => Action::ScrollDown, + KeyCode::PageUp => Action::PageUp, + KeyCode::PageDown => Action::PageDown, + KeyCode::Home => Action::Top, + KeyCode::End => Action::Bottom, + _ => return None, + }) +} + +fn is_edit(action: Action) -> bool { + matches!( + action, + Action::EditChar(_) | Action::EditBackspace | Action::EditCommit | Action::EditCancel + ) +} + +/// Config can only change while idle, and `Parallel` only without auto mode. +fn field_editable(app: &App, focus: Focus) -> bool { + match focus { + Focus::Sims => !app.is_running, + Focus::Parallel => !app.is_running && !app.config.auto_parallel, + _ => false, + } +} + +fn field_value(app: &App, focus: Focus) -> i64 { + match focus { + Focus::Parallel => app.config.parallel_games, + _ => app.config.simulations, + } +} + +fn set_field(app: &mut App, focus: Focus, value: i64) { + app.set_config(|config| match focus { + Focus::Sims => config.simulations = value, + Focus::Parallel => config.parallel_games = value, + _ => {} + }); +} + +/// Applies the edited text; empty or invalid input keeps the previous value. +fn commit_edit(app: &mut App, view: &mut ViewState) { + if let Some(buffer) = view.editing.take() + && let Ok(value) = buffer.parse::() + && field_editable(app, view.focus) + { + set_field(app, view.focus, value); + } +} + +fn start_stop(app: &mut App) { + if app.is_running { + app.stop(); + } else { + app.start(); + } +} + +pub fn apply(action: Action, app: &mut App, view: &mut ViewState) -> Flow { + if view.editing.is_some() && !is_edit(action) { + commit_edit(app, view); + } + let idle = !app.is_running; + + match action { + Action::Quit => { + if app.is_running { + app.stop(); + } + return Flow::Quit; + } + Action::FocusNext => view.focus = view.focus.next(), + Action::FocusPrev => view.focus = view.focus.prev(), + Action::Activate => match view.focus { + Focus::Game => return apply(Action::GameNext, app, view), + Focus::Sims | Focus::Parallel => { + if field_editable(app, view.focus) { + view.editing = Some(field_value(app, view.focus).to_string()); + } + } + Focus::Auto => return apply(Action::ToggleAuto, app, view), + Focus::StartStop => start_stop(app), + Focus::Export => return apply(Action::Export, app, view), + Focus::Clear => return apply(Action::Clear, app, view), + Focus::Table => {} + }, + Action::GameNext if idle => app.set_config(|c| c.game_type = c.game_type.next()), + Action::GamePrev if idle => app.set_config(|c| c.game_type = c.game_type.prev()), + Action::ToggleAuto if idle => app.set_config(|c| c.auto_parallel = !c.auto_parallel), + Action::StartStop => start_stop(app), + Action::Clear if can_clear(app) => app.clear_data(), + Action::Export if can_export(app) => {} + Action::EditChar(c) if field_editable(app, view.focus) => { + let buffer = view.editing.get_or_insert_with(String::new); + if buffer.len() < MAX_DIGITS { + buffer.push(c); + } + } + Action::EditBackspace if field_editable(app, view.focus) => { + let current = field_value(app, view.focus).to_string(); + view.editing.get_or_insert(current).pop(); + } + Action::EditCommit => commit_edit(app, view), + Action::EditCancel => view.editing = None, + Action::Increment(step) if field_editable(app, view.focus) => { + let value = field_value(app, view.focus).saturating_add(step).max(0); + set_field(app, view.focus, value); + } + Action::ScrollUp => view.table.select_previous(), + Action::ScrollDown => view.table.select_next(), + Action::PageUp => view.table.scroll_up_by(PAGE_ROWS), + Action::PageDown => view.table.scroll_down_by(PAGE_ROWS), + Action::Top => view.table.select_first(), + Action::Bottom => view.table.select_last(), + Action::SortNext => view.sort_column = view.sort_column.next(), + Action::SortPrev => view.sort_column = view.sort_column.prev(), + Action::ToggleHelp => view.show_help = !view.show_help, + _ => {} + } + Flow::Continue +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::tests::{idle_app, result}; + use crate::model::{GameType, SimConfig, StopReason}; + use ratatui::crossterm::event::KeyEventState; + + fn press(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn view_at(focus: Focus) -> ViewState { + let mut view = ViewState::new(false); + view.focus = focus; + view + } + + fn type_keys(app: &mut App, view: &mut ViewState, keys: &[KeyCode]) { + for &code in keys { + if let Some(action) = map_key(press(code), view) { + apply(action, app, view); + } + } + } + + #[test] + fn releases_are_ignored() { + let view = view_at(Focus::Game); + let release = KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + }; + assert_eq!(map_key(release, &view), None); + } + + #[test] + fn key_map() { + let game = view_at(Focus::Game); + let sims = view_at(Focus::Sims); + let table = view_at(Focus::Table); + assert_eq!(map_key(press(KeyCode::Tab), &game), Some(Action::FocusNext)); + assert_eq!( + map_key(press(KeyCode::Char('s')), &sims), + Some(Action::StartStop) + ); + assert_eq!( + map_key(press(KeyCode::Char('7')), &sims), + Some(Action::EditChar('7')) + ); + assert_eq!(map_key(press(KeyCode::Char('7')), &game), None); + assert_eq!( + map_key(press(KeyCode::Right), &game), + Some(Action::GameNext) + ); + assert_eq!( + map_key(press(KeyCode::Up), &sims), + Some(Action::Increment(1)) + ); + assert_eq!(map_key(press(KeyCode::Up), &table), Some(Action::ScrollUp)); + assert_eq!( + map_key( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + &table + ), + Some(Action::Quit) + ); + } + + #[test] + fn help_swallows_other_keys() { + let mut view = view_at(Focus::Game); + view.show_help = true; + assert_eq!(map_key(press(KeyCode::Char('s')), &view), None); + assert_eq!( + map_key(press(KeyCode::Esc), &view), + Some(Action::ToggleHelp) + ); + } + + #[test] + fn typing_digits_and_enter_sets_simulations() { + let (mut app, _rx) = idle_app(SimConfig::default()); + let mut view = view_at(Focus::Sims); + type_keys( + &mut app, + &mut view, + &[KeyCode::Char('1'), KeyCode::Char('2'), KeyCode::Enter], + ); + assert_eq!(app.config.simulations, 12); + assert!(view.editing.is_none()); + } + + #[test] + fn empty_input_and_escape_keep_previous_value() { + let (mut app, _rx) = idle_app(SimConfig { + simulations: 5, + ..SimConfig::default() + }); + let mut view = view_at(Focus::Sims); + type_keys(&mut app, &mut view, &[KeyCode::Backspace, KeyCode::Enter]); + assert_eq!(app.config.simulations, 5); + + type_keys(&mut app, &mut view, &[KeyCode::Char('9'), KeyCode::Esc]); + assert_eq!(app.config.simulations, 5); + assert!(view.editing.is_none()); + } + + #[test] + fn moving_focus_commits_the_edit() { + let (mut app, _rx) = idle_app(SimConfig::default()); + let mut view = view_at(Focus::Parallel); + type_keys(&mut app, &mut view, &[KeyCode::Char('3'), KeyCode::Tab]); + assert_eq!(app.config.parallel_games, 3); + assert_eq!(view.focus, Focus::Auto); + } + + #[test] + fn parallel_is_locked_in_auto_mode() { + let (mut app, _rx) = idle_app(SimConfig { + auto_parallel: true, + parallel_games: 2, + ..SimConfig::default() + }); + let mut view = view_at(Focus::Parallel); + type_keys( + &mut app, + &mut view, + &[KeyCode::Char('8'), KeyCode::Enter, KeyCode::Up], + ); + assert_eq!(app.config.parallel_games, 2); + } + + #[test] + fn config_is_locked_while_running() { + let (mut app, _rx) = idle_app(SimConfig { + simulations: 4, + ..SimConfig::default() + }); + let mut view = view_at(Focus::Sims); + apply(Action::StartStop, &mut app, &mut view); + assert!(app.is_running); + + type_keys( + &mut app, + &mut view, + &[KeyCode::Char('9'), KeyCode::Enter, KeyCode::Up], + ); + apply(Action::GameNext, &mut app, &mut view); + apply(Action::ToggleAuto, &mut app, &mut view); + assert_eq!(app.config.simulations, 4); + assert_eq!(app.config.game_type, GameType::KlondikeDraw1); + assert!(!app.config.auto_parallel); + + apply(Action::StartStop, &mut app, &mut view); + assert!(!app.is_running); + assert_eq!(app.status_text, "Stopped"); + } + + #[test] + fn clear_respects_disabled_state() { + let (mut app, _rx) = idle_app(SimConfig::default()); + let mut view = view_at(Focus::Clear); + apply(Action::Activate, &mut app, &mut view); + assert_eq!(app.status_text, "Ready"); + + app.record(result(1, true, StopReason::Win)); + apply(Action::Activate, &mut app, &mut view); + assert_eq!(app.status_text, "Data cleared"); + } + + #[test] + fn sort_and_help_toggle() { + let (mut app, _rx) = idle_app(SimConfig::default()); + let mut view = view_at(Focus::Table); + apply(Action::SortNext, &mut app, &mut view); + assert_eq!(view.sort_column, crate::ui::SortColumn::Moves); + apply(Action::ToggleHelp, &mut app, &mut view); + assert!(view.show_help); + } + + #[test] + fn quit_stops_a_running_simulation() { + let (mut app, _rx) = idle_app(SimConfig::default()); + let mut view = view_at(Focus::Game); + app.start(); + assert_eq!(apply(Action::Quit, &mut app, &mut view), Flow::Quit); + assert!(!app.is_running); + } + + #[test] + fn full_run_through_the_real_runtime() { + use crate::deck::ShuffleSource; + use std::sync::mpsc; + use std::time::Duration; + + let (tx, rx) = mpsc::channel(); + let config = SimConfig { + game_type: GameType::TriPeaks, + simulations: 2, + parallel_games: 2, + ..SimConfig::default() + }; + let mut app = App::new(config, ShuffleSource::Seeded(3), tx); + let mut view = view_at(Focus::StartStop); + apply(Action::Activate, &mut app, &mut view); + while app.is_running { + let event = rx.recv_timeout(Duration::from_secs(60)).unwrap(); + app.handle_event(event); + } + assert_eq!(app.status_text, "Completed"); + assert_eq!(app.completed_games, 2); + assert_eq!(app.all_results.len(), 2); + } +} diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index 9c6d7c1..e67744c 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -1,48 +1,90 @@ -// Modules are wired into the UI incrementally; allow unused items until then. -#[allow(dead_code)] mod app; +// CSV export, seeds and config paths are wired up by the export dialog and CLI. #[allow(dead_code)] mod csv; #[allow(dead_code)] mod deck; -#[allow(dead_code)] mod format; -#[allow(dead_code)] mod games; +mod input; #[allow(dead_code)] mod model; #[allow(dead_code)] mod persist; -#[allow(dead_code)] mod runtime; -#[allow(dead_code)] mod ui; +use std::panic; use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{self, Event}; use app::App; use deck::ShuffleSource; +use input::Flow; use persist::ConfigStore; +use runtime::{GAME_THREAD_PREFIX, RunEvent}; use ui::ViewState; +/// Countdown/ETA refresh period (Swift: `runCountdown` sleeps 200 ms). +const TICK: Duration = Duration::from_millis(200); +const MAX_POLL: Duration = Duration::from_millis(100); + fn main() -> color_eyre::Result<()> { color_eyre::install()?; let store = ConfigStore::default_path().map_or_else(ConfigStore::disabled, ConfigStore::at); - let (tx, _rx) = mpsc::channel(); - let app = App::new(store.load(), ShuffleSource::Random, tx).with_store(store); + let (tx, rx) = mpsc::channel(); + let mut app = App::new(store.load(), ShuffleSource::Random, tx).with_store(store); let mut view = ViewState::new(false); - ratatui::run(|terminal| -> color_eyre::Result<()> { - loop { - terminal.draw(|frame| ui::draw(frame, &app, &mut view))?; - if let Event::Key(key) = event::read()? - && key.kind == KeyEventKind::Press - && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) - { - return Ok(()); - } - } + ratatui::run(|terminal| { + silence_game_thread_panics(); + run(terminal, &mut app, &mut view, &rx) }) } + +/// A panicking game is recorded as stalled by the runtime; keep its panic +/// from restoring the terminal (ratatui's hook) while the UI is still running. +fn silence_game_thread_panics() { + let previous = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + let in_game_thread = thread::current() + .name() + .is_some_and(|name| name.starts_with(GAME_THREAD_PREFIX)); + if !in_game_thread { + previous(info); + } + })); +} + +fn run( + terminal: &mut DefaultTerminal, + app: &mut App, + view: &mut ViewState, + rx: &mpsc::Receiver, +) -> color_eyre::Result<()> { + let mut last_tick = Instant::now(); + loop { + terminal.draw(|frame| ui::draw(frame, app, view))?; + + let timeout = TICK.saturating_sub(last_tick.elapsed()).min(MAX_POLL); + if event::poll(timeout)? + && let Event::Key(key) = event::read()? + && let Some(action) = input::map_key(key, view) + && input::apply(action, app, view) == Flow::Quit + { + return Ok(()); + } + + while let Ok(event) = rx.try_recv() { + app.handle_event(event); + } + if last_tick.elapsed() >= TICK { + app.tick(Instant::now()); + last_tick = Instant::now(); + } + } +} diff --git a/solverlab-tui/src/ui/help.rs b/solverlab-tui/src/ui/help.rs new file mode 100644 index 0000000..bac6ccb --- /dev/null +++ b/solverlab-tui/src/ui/help.rs @@ -0,0 +1,49 @@ +//! Key bindings popup (`?`). + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Paragraph}; + +const BINDINGS: [(&str, &str); 15] = [ + ("Tab / Shift+Tab", "Move focus"), + ("Enter / Space", "Activate focused control"), + ("← →", "Change game (on Game) / move focus"), + ("g / G", "Next / previous game"), + ("0-9, Backspace", "Edit Sims / Parallel"), + ("↑ ↓ (Sims, Parallel)", "±1 (Shift: ±10)"), + ("a", "Toggle auto parallel"), + ("s", "Start / Stop"), + ("e", "Export CSV"), + ("c", "Clear data"), + ("↑ ↓ PgUp PgDn Home End", "Scroll results"), + ("[ ]", "Sort column (always descending)"), + ("?", "Toggle this help"), + ("q / Esc / Ctrl+C", "Quit"), + ("", "Config is locked while a run is active"), +]; + +pub fn render(frame: &mut Frame, area: Rect) { + let popup = area.centered( + Constraint::Length(64), + Constraint::Length(BINDINGS.len() as u16 + 2), + ); + let lines: Vec = BINDINGS + .iter() + .map(|(keys, description)| { + Line::from(vec![ + Span::styled( + format!(" {keys:<24}"), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(*description), + ]) + }) + .collect(); + frame.render_widget(Clear, popup); + frame.render_widget( + Paragraph::new(lines).block(Block::bordered().title(" Keys ")), + popup, + ); +} diff --git a/solverlab-tui/src/ui/mod.rs b/solverlab-tui/src/ui/mod.rs index 7b960cc..ee196a1 100644 --- a/solverlab-tui/src/ui/mod.rs +++ b/solverlab-tui/src/ui/mod.rs @@ -1,7 +1,8 @@ //! Rendering, ported from `ContentView.swift`. `draw` is a pure function of //! the app state plus view-only state (focus, sorting, scroll). -mod controls; +pub mod controls; +mod help; mod metrics; mod progress; mod table; @@ -158,7 +159,7 @@ pub struct ViewState { impl ViewState { pub fn new(ascii: bool) -> Self { ViewState { - focus: Focus::StartStop, + focus: Focus::Game, editing: None, sort_column: SortColumn::default(), table: TableState::default(), @@ -206,6 +207,10 @@ pub fn draw(frame: &mut Frame, app: &App, view: &mut ViewState) { metrics::render(frame, metrics_area, app, wide); table::render(frame, table_area, app, view); frame.render_widget(footer(), footer_area); + + if view.show_help { + help::render(frame, area); + } } fn footer() -> Line<'static> { From 1594f3bef20052d6e81f274a0636220ba546a4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:34:44 -0300 Subject: [PATCH 12/15] feat(tui): add CSV export dialog --- solverlab-tui/src/app.rs | 97 +++++++++++++++++- solverlab-tui/src/input.rs | 169 ++++++++++++++++++++++++++++++- solverlab-tui/src/main.rs | 4 +- solverlab-tui/src/ui/dialogs.rs | 174 ++++++++++++++++++++++++++++++++ solverlab-tui/src/ui/mod.rs | 7 +- 5 files changed, 444 insertions(+), 7 deletions(-) create mode 100644 solverlab-tui/src/ui/dialogs.rs diff --git a/solverlab-tui/src/app.rs b/solverlab-tui/src/app.rs index 6f85f1a..8991683 100644 --- a/solverlab-tui/src/app.rs +++ b/solverlab-tui/src/app.rs @@ -2,13 +2,16 @@ //! countdown and ETA). The UI only reads it; input only calls its methods. use std::collections::VecDeque; +use std::io; +use std::path::Path; use std::sync::mpsc::Sender; use std::time::Instant; +use crate::csv::{default_csv_file_name, make_csv}; use crate::deck::ShuffleSource; use crate::format::format_duration; use crate::model::{SimConfig, SimResult, StopReason}; -use crate::persist::ConfigStore; +use crate::persist::{ConfigStore, write_atomic}; use crate::runtime::{ ActiveGamesClock, RunEvent, RunHandle, auto_parallel_initial, cpu_count, spawn_simulation, }; @@ -147,6 +150,45 @@ impl App { format_duration(self.estimated_remaining_total) } + /// Default export file name, named after the last run's game (or the selected one). + pub fn default_export_file_name(&self) -> String { + let game = self + .last_run_config + .as_ref() + .map_or(self.config.game_type, |c| c.game_type); + default_csv_file_name(game, chrono::Local::now()) + } + + pub fn csv(&self) -> String { + make_csv( + &self.all_results, + &self.countdown_display(), + self.last_run_config.as_ref(), + ) + } + + /// Port of `exportCSV` once a destination is chosen. + pub fn export_csv(&mut self, path: &Path) -> io::Result<()> { + if self.all_results.is_empty() { + self.status_text = "No results to export".into(); + return Ok(()); + } + match write_atomic(path, self.csv().as_bytes()) { + Ok(()) => { + let name = path.file_name().map_or_else( + || path.display().to_string(), + |n| n.to_string_lossy().into_owned(), + ); + self.status_text = format!("CSV exported: {name}"); + Ok(()) + } + Err(err) => { + self.status_text = "Error exporting CSV".into(); + Err(err) + } + } + } + pub fn start(&mut self) { if self.is_running { return; @@ -376,6 +418,59 @@ pub(crate) mod tests { assert!(app.status_text.starts_with("Config not saved")); } + #[test] + fn export_csv_writes_make_csv_output() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.csv"); + let (mut app, _rx) = idle_app(SimConfig::default()); + + app.export_csv(&path).unwrap(); + assert_eq!(app.status_text, "No results to export"); + assert!(!path.exists()); + + app.start(); + app.record(result(1, true, StopReason::Win)); + app.export_csv(&path).unwrap(); + assert_eq!(app.status_text, "CSV exported: out.csv"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), app.csv()); + } + + #[test] + fn export_csv_reports_write_errors() { + let dir = tempfile::tempdir().unwrap(); + let blocker = dir.path().join("file"); + std::fs::write(&blocker, "").unwrap(); + let (mut app, _rx) = idle_app(SimConfig::default()); + app.record(result(1, true, StopReason::Win)); + assert!(app.export_csv(&blocker.join("out.csv")).is_err()); + assert_eq!(app.status_text, "Error exporting CSV"); + } + + #[test] + fn default_export_name_uses_last_run_game() { + use crate::model::GameType; + let (mut app, _rx) = idle_app(SimConfig { + game_type: GameType::FreeCell, + ..SimConfig::default() + }); + assert!( + app.default_export_file_name() + .starts_with("FreeCell_results_") + ); + app.start(); + app.stop(); + app.config.game_type = GameType::Pyramid; + assert!( + app.default_export_file_name() + .starts_with("FreeCell_results_") + ); + app.clear_data(); + assert!( + app.default_export_file_name() + .starts_with("Pyramid_results_") + ); + } + #[test] fn metrics() { let (mut app, _rx) = idle_app(SimConfig::default()); diff --git a/solverlab-tui/src/input.rs b/solverlab-tui/src/input.rs index 7398751..7a5a6f5 100644 --- a/solverlab-tui/src/input.rs +++ b/solverlab-tui/src/input.rs @@ -3,8 +3,11 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use std::path::PathBuf; + use crate::app::App; -use crate::ui::controls::{can_clear, can_export}; +use crate::ui::controls::can_clear; +use crate::ui::dialogs::ExportDialog; use crate::ui::{Focus, ViewState}; const MAX_DIGITS: usize = 9; @@ -36,6 +39,8 @@ pub enum Action { SortNext, SortPrev, ToggleHelp, + /// Key routed to the open export dialog. + Dialog(KeyCode), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -56,6 +61,9 @@ pub fn map_key(key: KeyEvent, view: &ViewState) -> Option { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { return Some(Action::Quit); } + if view.export.is_some() { + return Some(Action::Dialog(key.code)); + } if view.show_help { return matches!( key.code, @@ -161,6 +169,85 @@ fn start_stop(app: &mut App) { } } +fn open_export(app: &mut App, view: &mut ViewState) { + if app.all_results.is_empty() { + app.status_text = "No results to export".into(); + return; + } + let dir = std::env::current_dir().unwrap_or_default(); + let path = dir.join(app.default_export_file_name()); + view.export = Some(ExportDialog::new(path.display().to_string())); +} + +/// Expands a leading `~` to the home directory. +fn expand_home(raw: &str) -> PathBuf { + let home = || directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()); + if raw == "~" { + if let Some(home) = home() { + return home; + } + } else if let Some(rest) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) + && let Some(home) = home() + { + return home.join(rest); + } + PathBuf::from(raw) +} + +fn dialog_key(code: KeyCode, app: &mut App, view: &mut ViewState) { + let Some(dialog) = view.export.as_mut() else { + return; + }; + if dialog.confirm_overwrite { + match code { + KeyCode::Char('y') | KeyCode::Char('Y') => save_export(app, view), + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + dialog.confirm_overwrite = false; + } + _ => {} + } + return; + } + dialog.error = None; + match code { + KeyCode::Esc => view.export = None, + KeyCode::Enter => { + if dialog.path.value().trim().is_empty() { + return; + } + if expand_home(dialog.path.value()).exists() { + dialog.confirm_overwrite = true; + } else { + save_export(app, view); + } + } + KeyCode::Char(c) => dialog.path.insert(c), + KeyCode::Backspace => dialog.path.backspace(), + KeyCode::Delete => dialog.path.delete(), + KeyCode::Left => dialog.path.left(), + KeyCode::Right => dialog.path.right(), + KeyCode::Home => dialog.path.home(), + KeyCode::End => dialog.path.end(), + _ => {} + } +} + +/// Writes the CSV; on failure the dialog stays open with the error so the +/// path can be fixed. +fn save_export(app: &mut App, view: &mut ViewState) { + let Some(dialog) = view.export.as_mut() else { + return; + }; + let path = expand_home(dialog.path.value()); + match app.export_csv(&path) { + Ok(()) => view.export = None, + Err(err) => { + dialog.confirm_overwrite = false; + dialog.error = Some(format!("Error exporting CSV: {err}")); + } + } +} + pub fn apply(action: Action, app: &mut App, view: &mut ViewState) -> Flow { if view.editing.is_some() && !is_edit(action) { commit_edit(app, view); @@ -194,7 +281,8 @@ pub fn apply(action: Action, app: &mut App, view: &mut ViewState) -> Flow { Action::ToggleAuto if idle => app.set_config(|c| c.auto_parallel = !c.auto_parallel), Action::StartStop => start_stop(app), Action::Clear if can_clear(app) => app.clear_data(), - Action::Export if can_export(app) => {} + Action::Export => open_export(app, view), + Action::Dialog(code) => dialog_key(code, app, view), Action::EditChar(c) if field_editable(app, view.focus) => { let buffer = view.editing.get_or_insert_with(String::new); if buffer.len() < MAX_DIGITS { @@ -407,6 +495,83 @@ mod tests { assert!(view.show_help); } + #[test] + fn export_without_results_only_sets_status() { + let (mut app, _rx) = idle_app(SimConfig::default()); + let mut view = view_at(Focus::Game); + type_keys(&mut app, &mut view, &[KeyCode::Char('e')]); + assert!(view.export.is_none()); + assert_eq!(app.status_text, "No results to export"); + } + + #[test] + fn export_dialog_writes_and_confirms_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("results.csv"); + let (mut app, _rx) = idle_app(SimConfig::default()); + app.record(result(1, true, StopReason::Win)); + let mut view = view_at(Focus::Game); + + type_keys(&mut app, &mut view, &[KeyCode::Char('e')]); + let dialog = view.export.as_mut().unwrap(); + assert!(dialog.path.value().ends_with(".csv")); + dialog.path = crate::ui::dialogs::TextInput::new(path.display().to_string()); + + // Keys go to the dialog, not to global shortcuts. + type_keys( + &mut app, + &mut view, + &[KeyCode::Char('s'), KeyCode::Backspace], + ); + assert!(!app.is_running); + type_keys(&mut app, &mut view, &[KeyCode::Enter]); + assert!(view.export.is_none()); + assert_eq!(app.status_text, "CSV exported: results.csv"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), app.csv()); + + // Second export to the same file asks before overwriting. + type_keys(&mut app, &mut view, &[KeyCode::Char('e')]); + view.export.as_mut().unwrap().path = + crate::ui::dialogs::TextInput::new(path.display().to_string()); + type_keys(&mut app, &mut view, &[KeyCode::Enter]); + assert!(view.export.as_ref().unwrap().confirm_overwrite); + type_keys(&mut app, &mut view, &[KeyCode::Char('n')]); + assert!(!view.export.as_ref().unwrap().confirm_overwrite); + type_keys(&mut app, &mut view, &[KeyCode::Enter, KeyCode::Char('y')]); + assert!(view.export.is_none()); + } + + #[test] + fn export_dialog_keeps_open_on_error_and_esc_cancels() { + let dir = tempfile::tempdir().unwrap(); + let blocker = dir.path().join("file"); + std::fs::write(&blocker, "").unwrap(); + let (mut app, _rx) = idle_app(SimConfig::default()); + app.record(result(1, true, StopReason::Win)); + let mut view = view_at(Focus::Game); + + type_keys(&mut app, &mut view, &[KeyCode::Char('e')]); + view.export.as_mut().unwrap().path = + crate::ui::dialogs::TextInput::new(blocker.join("x.csv").display().to_string()); + type_keys(&mut app, &mut view, &[KeyCode::Enter]); + assert!(view.export.as_ref().unwrap().error.is_some()); + assert_eq!(app.status_text, "Error exporting CSV"); + + type_keys(&mut app, &mut view, &[KeyCode::Esc]); + assert!(view.export.is_none()); + } + + #[test] + fn expand_home_handles_tilde() { + let home = directories::BaseDirs::new() + .unwrap() + .home_dir() + .to_path_buf(); + assert_eq!(expand_home("~"), home); + assert_eq!(expand_home("~/a.csv"), home.join("a.csv")); + assert_eq!(expand_home("/tmp/a.csv"), PathBuf::from("/tmp/a.csv")); + } + #[test] fn quit_stops_a_running_simulation() { let (mut app, _rx) = idle_app(SimConfig::default()); diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index e67744c..c368a8e 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -1,13 +1,11 @@ mod app; -// CSV export, seeds and config paths are wired up by the export dialog and CLI. -#[allow(dead_code)] mod csv; +// Seeds and config paths are wired up by the CLI. #[allow(dead_code)] mod deck; mod format; mod games; mod input; -#[allow(dead_code)] mod model; #[allow(dead_code)] mod persist; diff --git a/solverlab-tui/src/ui/dialogs.rs b/solverlab-tui/src/ui/dialogs.rs new file mode 100644 index 0000000..01b11ff --- /dev/null +++ b/solverlab-tui/src/ui/dialogs.rs @@ -0,0 +1,174 @@ +//! CSV export dialog (replaces the Swift `NSSavePanel`). + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Clear, Paragraph}; + +/// Single-line text input with a cursor (in chars). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextInput { + value: String, + cursor: usize, +} + +impl TextInput { + pub fn new(value: impl Into) -> Self { + let value = value.into(); + let cursor = value.chars().count(); + TextInput { value, cursor } + } + + pub fn value(&self) -> &str { + &self.value + } + + fn byte_index(&self, char_index: usize) -> usize { + self.value + .char_indices() + .nth(char_index) + .map_or(self.value.len(), |(i, _)| i) + } + + pub fn insert(&mut self, c: char) { + let at = self.byte_index(self.cursor); + self.value.insert(at, c); + self.cursor += 1; + } + + pub fn backspace(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + let at = self.byte_index(self.cursor); + self.value.remove(at); + } + } + + pub fn delete(&mut self) { + if self.cursor < self.value.chars().count() { + let at = self.byte_index(self.cursor); + self.value.remove(at); + } + } + + pub fn left(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + pub fn right(&mut self) { + self.cursor = (self.cursor + 1).min(self.value.chars().count()); + } + + pub fn home(&mut self) { + self.cursor = 0; + } + + pub fn end(&mut self) { + self.cursor = self.value.chars().count(); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExportDialog { + pub path: TextInput, + /// The target exists and the user is asked to confirm overwriting it. + pub confirm_overwrite: bool, + pub error: Option, +} + +impl ExportDialog { + pub fn new(default_path: impl Into) -> Self { + ExportDialog { + path: TextInput::new(default_path), + confirm_overwrite: false, + error: None, + } + } +} + +pub fn render(frame: &mut Frame, area: Rect, dialog: &ExportDialog) { + let width = area.width.saturating_sub(4).min(100); + let popup = area.centered(Constraint::Length(width), Constraint::Length(7)); + frame.render_widget(Clear, popup); + let block = Block::bordered().title(" Export CSV "); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let [label_area, input_area, message_area, hint_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + + frame.render_widget(Line::from("Save results to:"), label_area); + + // Scroll horizontally so the cursor stays visible. + let visible = usize::from(input_area.width.max(1)); + let start = (dialog.path.cursor + 1).saturating_sub(visible); + let shown: String = dialog + .path + .value + .chars() + .skip(start) + .take(visible) + .collect(); + frame.render_widget( + Paragraph::new(shown).style(Style::default().add_modifier(Modifier::UNDERLINED)), + input_area, + ); + if !dialog.confirm_overwrite { + let x = input_area.x + u16::try_from(dialog.path.cursor - start).unwrap_or(0); + frame.set_cursor_position(Position::new(x, input_area.y)); + } + + if let Some(error) = &dialog.error { + frame.render_widget( + Line::styled(error.as_str(), Style::default().fg(Color::Red)), + message_area, + ); + } else if dialog.confirm_overwrite { + frame.render_widget( + Line::styled( + "File exists. Overwrite? (y/n)", + Style::default().fg(Color::Yellow), + ), + message_area, + ); + } + + let hint = if dialog.confirm_overwrite { + "y overwrite · n / Esc edit path" + } else { + "Enter save · Esc cancel" + }; + frame.render_widget( + Line::styled(hint, Style::default().add_modifier(Modifier::DIM)), + hint_area, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_input_edits_at_cursor() { + let mut input = TextInput::new("ab"); + input.left(); + input.insert('é'); + assert_eq!(input.value(), "aéb"); + input.home(); + input.delete(); + assert_eq!(input.value(), "éb"); + input.end(); + input.backspace(); + assert_eq!(input.value(), "é"); + input.right(); + input.backspace(); + input.backspace(); + assert_eq!(input.value(), ""); + } +} diff --git a/solverlab-tui/src/ui/mod.rs b/solverlab-tui/src/ui/mod.rs index ee196a1..9f94b4e 100644 --- a/solverlab-tui/src/ui/mod.rs +++ b/solverlab-tui/src/ui/mod.rs @@ -2,6 +2,7 @@ //! the app state plus view-only state (focus, sorting, scroll). pub mod controls; +pub mod dialogs; mod help; mod metrics; mod progress; @@ -153,6 +154,7 @@ pub struct ViewState { pub sort_column: SortColumn, pub table: TableState, pub show_help: bool, + pub export: Option, pub glyphs: Glyphs, } @@ -164,6 +166,7 @@ impl ViewState { sort_column: SortColumn::default(), table: TableState::default(), show_help: false, + export: None, glyphs: if ascii { Glyphs::ASCII } else { @@ -208,7 +211,9 @@ pub fn draw(frame: &mut Frame, app: &App, view: &mut ViewState) { table::render(frame, table_area, app, view); frame.render_widget(footer(), footer_area); - if view.show_help { + if let Some(dialog) = &view.export { + dialogs::render(frame, area, dialog); + } else if view.show_help { help::render(frame, area); } } From a4da35e0164d0e2b3efd3fb29a9875ec33afddd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:36:04 -0300 Subject: [PATCH 13/15] feat(tui): add CLI flags and headless mode --- solverlab-tui/Cargo.lock | 1 + solverlab-tui/Cargo.toml | 1 + solverlab-tui/src/cli.rs | 214 ++++++++++++++++++++++++++++++++ solverlab-tui/src/headless.rs | 71 +++++++++++ solverlab-tui/src/main.rs | 26 ++-- solverlab-tui/src/persist.rs | 1 + solverlab-tui/tests/headless.rs | 94 ++++++++++++++ 7 files changed, 399 insertions(+), 9 deletions(-) create mode 100644 solverlab-tui/src/cli.rs create mode 100644 solverlab-tui/src/headless.rs create mode 100644 solverlab-tui/tests/headless.rs diff --git a/solverlab-tui/Cargo.lock b/solverlab-tui/Cargo.lock index adc177d..c108274 100644 --- a/solverlab-tui/Cargo.lock +++ b/solverlab-tui/Cargo.lock @@ -1887,6 +1887,7 @@ name = "solverlab-tui" version = "0.1.0" dependencies = [ "chrono", + "clap", "color-eyre", "directories", "rand 0.10.3", diff --git a/solverlab-tui/Cargo.toml b/solverlab-tui/Cargo.toml index 61c3487..f1dc6dd 100644 --- a/solverlab-tui/Cargo.toml +++ b/solverlab-tui/Cargo.toml @@ -19,6 +19,7 @@ chrono = "0.4.45" rand = "0.10.3" rand_chacha = "0.10.0" directories = "6.0.0" +clap = { version = "4.6.7", features = ["derive"] } [profile.release] opt-level = 3 diff --git a/solverlab-tui/src/cli.rs b/solverlab-tui/src/cli.rs new file mode 100644 index 0000000..5cfd1b5 --- /dev/null +++ b/solverlab-tui/src/cli.rs @@ -0,0 +1,214 @@ +//! Command line flags. + +use std::path::PathBuf; + +use clap::{Parser, ValueEnum}; + +use crate::deck::ShuffleSource; +use crate::model::{GameType, SimConfig}; +use crate::persist::ConfigStore; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum GameArg { + Klondike1, + Klondike3, + Freecell, + Pyramid, + Tripeaks, + Spider1, + Spider2, + Spider4, +} + +impl From for GameType { + fn from(game: GameArg) -> Self { + match game { + GameArg::Klondike1 => GameType::KlondikeDraw1, + GameArg::Klondike3 => GameType::KlondikeDraw3, + GameArg::Freecell => GameType::FreeCell, + GameArg::Pyramid => GameType::Pyramid, + GameArg::Tripeaks => GameType::TriPeaks, + GameArg::Spider1 => GameType::Spider1Suit, + GameArg::Spider2 => GameType::Spider2Suit, + GameArg::Spider4 => GameType::Spider4Suit, + } + } +} + +/// SolverLab terminal console: batch solitaire solver simulations. +/// +/// Stopping a run does not interrupt games already being solved; it only +/// stops handing out new ones and discards their results. +#[derive(Debug, Parser)] +#[command(name = "solverlab-tui", version)] +pub struct Cli { + /// Game to simulate. + #[arg(long, value_enum)] + pub game: Option, + + /// Number of games to simulate. + #[arg(long)] + pub sims: Option, + + /// Games played in parallel (disables auto parallelism). + #[arg(long, conflicts_with = "auto")] + pub parallel: Option, + + /// Adjust parallelism automatically between batches. + #[arg(long)] + pub auto: bool, + + /// Timeout in seconds for games that honor it (FreeCell, Pyramid, Spider). + #[arg(long)] + pub timeout: Option, + + /// Seed for reproducible decks across runs and platforms. + #[arg(long)] + pub seed: Option, + + /// Config file to load and save (defaults to the platform config dir). + #[arg(long, value_name = "PATH")] + pub config: Option, + + /// Do not read or write the config file. + #[arg(long, conflicts_with = "config")] + pub no_persist: bool, + + /// Use ASCII instead of Unicode glyphs. + #[arg(long)] + pub ascii: bool, + + /// Run without the TUI and print the CSV when done. + #[arg(long)] + pub headless: bool, + + /// Headless: write the CSV here instead of stdout. + #[arg(long, value_name = "PATH", requires = "headless")] + pub csv: Option, + + /// Headless: no progress on stderr. + #[arg(long, requires = "headless")] + pub quiet: bool, +} + +impl Cli { + /// Headless runs only persist when a config file is given explicitly. + pub fn config_store(&self) -> ConfigStore { + if self.no_persist { + ConfigStore::disabled() + } else if let Some(path) = &self.config { + ConfigStore::at(path) + } else if self.headless { + ConfigStore::disabled() + } else { + ConfigStore::default_path().map_or_else(ConfigStore::disabled, ConfigStore::at) + } + } + + pub fn shuffle(&self) -> ShuffleSource { + self.seed + .map_or(ShuffleSource::Random, ShuffleSource::Seeded) + } + + pub fn apply_overrides(&self, config: &mut SimConfig) { + if let Some(game) = self.game { + config.game_type = game.into(); + } + if let Some(sims) = self.sims { + config.simulations = sims; + } + if let Some(parallel) = self.parallel { + config.parallel_games = parallel; + config.auto_parallel = false; + } + if self.auto { + config.auto_parallel = true; + } + if let Some(timeout) = self.timeout { + config.timeout_seconds = timeout; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + Cli::try_parse_from(std::iter::once("solverlab-tui").chain(args.iter().copied())) + } + + #[test] + fn overrides_apply_on_top_of_config() { + let cli = parse(&[ + "--game", + "spider2", + "--sims", + "40", + "--parallel", + "3", + "--timeout", + "7.5", + ]) + .unwrap(); + let mut config = SimConfig { + auto_parallel: true, + ..SimConfig::default() + }; + cli.apply_overrides(&mut config); + assert_eq!(config.game_type, GameType::Spider2Suit); + assert_eq!(config.simulations, 40); + assert_eq!(config.parallel_games, 3); + assert!(!config.auto_parallel); + assert_eq!(config.timeout_seconds, 7.5); + } + + #[test] + fn no_flags_keep_config() { + let cli = parse(&[]).unwrap(); + let mut config = SimConfig::default(); + cli.apply_overrides(&mut config); + assert_eq!(config, SimConfig::default()); + assert_eq!(cli.shuffle(), ShuffleSource::Random); + } + + #[test] + fn seed_selects_seeded_shuffle() { + assert_eq!( + parse(&["--seed", "9"]).unwrap().shuffle(), + ShuffleSource::Seeded(9) + ); + } + + #[test] + fn invalid_combinations_are_rejected() { + assert!(parse(&["--parallel", "2", "--auto"]).is_err()); + assert!(parse(&["--csv", "out.csv"]).is_err()); + assert!(parse(&["--quiet"]).is_err()); + assert!(parse(&["--no-persist", "--config", "x.json"]).is_err()); + assert!(parse(&["--game", "solitaire"]).is_err()); + } + + #[test] + fn headless_does_not_persist_unless_config_is_given() { + assert!( + parse(&["--headless"]) + .unwrap() + .config_store() + .path() + .is_none() + ); + assert!( + parse(&["--no-persist"]) + .unwrap() + .config_store() + .path() + .is_none() + ); + let explicit = parse(&["--headless", "--config", "c.json"]).unwrap(); + assert_eq!( + explicit.config_store().path(), + Some(std::path::Path::new("c.json")) + ); + } +} diff --git a/solverlab-tui/src/headless.rs b/solverlab-tui/src/headless.rs new file mode 100644 index 0000000..f1c2928 --- /dev/null +++ b/solverlab-tui/src/headless.rs @@ -0,0 +1,71 @@ +//! Headless mode: same runtime and CSV as the TUI, without a terminal UI. + +use std::io::{self, IsTerminal, Write}; +use std::path::Path; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::Instant; + +use crate::app::App; +use crate::persist::write_atomic; +use crate::runtime::RunEvent; + +pub fn run( + app: &mut App, + rx: &Receiver, + csv_path: Option<&Path>, + quiet: bool, +) -> io::Result<()> { + let tty = io::stderr().is_terminal(); + let total = app.config.simulations.max(0); + app.start(); + + let mut last_tick = Instant::now(); + while app.is_running { + match rx.recv_timeout(crate::TICK) { + Ok(RunEvent::GameFinished { run_id, result }) => { + if !quiet && !tty { + eprintln!( + "game {}: {} {} moves={} score={} {:.2}s", + result.id, + result.result_label(), + result.stop_reason.raw_value(), + result.move_count, + result.score, + result.duration + ); + } + app.handle_event(RunEvent::GameFinished { run_id, result }); + } + Ok(event) => app.handle_event(event), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + if last_tick.elapsed() >= crate::TICK { + app.tick(Instant::now()); + last_tick = Instant::now(); + if !quiet && tty { + eprint!( + "\r{}/{} games · win rate {:.2}% · ETA {} ", + app.completed_games, + total, + app.win_rate() * 100.0, + app.estimated_remaining_display() + ); + } + } + } + if !quiet && tty { + eprintln!( + "\r{}/{} games · win rate {:.2}% ", + app.completed_games, + total, + app.win_rate() * 100.0 + ); + } + + let csv = app.csv(); + match csv_path { + Some(path) => write_atomic(path, csv.as_bytes()), + None => io::stdout().lock().write_all(csv.as_bytes()), + } +} diff --git a/solverlab-tui/src/main.rs b/solverlab-tui/src/main.rs index c368a8e..a51b8bf 100644 --- a/solverlab-tui/src/main.rs +++ b/solverlab-tui/src/main.rs @@ -1,13 +1,12 @@ mod app; +mod cli; mod csv; -// Seeds and config paths are wired up by the CLI. -#[allow(dead_code)] mod deck; mod format; mod games; +mod headless; mod input; mod model; -#[allow(dead_code)] mod persist; mod runtime; mod ui; @@ -20,23 +19,32 @@ use std::time::{Duration, Instant}; use ratatui::DefaultTerminal; use ratatui::crossterm::event::{self, Event}; +use clap::Parser; + use app::App; -use deck::ShuffleSource; +use cli::Cli; use input::Flow; -use persist::ConfigStore; use runtime::{GAME_THREAD_PREFIX, RunEvent}; use ui::ViewState; /// Countdown/ETA refresh period (Swift: `runCountdown` sleeps 200 ms). -const TICK: Duration = Duration::from_millis(200); +pub(crate) const TICK: Duration = Duration::from_millis(200); const MAX_POLL: Duration = Duration::from_millis(100); fn main() -> color_eyre::Result<()> { color_eyre::install()?; - let store = ConfigStore::default_path().map_or_else(ConfigStore::disabled, ConfigStore::at); + let cli = Cli::parse(); + let store = cli.config_store(); let (tx, rx) = mpsc::channel(); - let mut app = App::new(store.load(), ShuffleSource::Random, tx).with_store(store); - let mut view = ViewState::new(false); + let mut app = App::new(store.load(), cli.shuffle(), tx).with_store(store); + app.set_config(|config| cli.apply_overrides(config)); + + if cli.headless { + headless::run(&mut app, &rx, cli.csv.as_deref(), cli.quiet)?; + return Ok(()); + } + + let mut view = ViewState::new(cli.ascii); ratatui::run(|terminal| { silence_game_thread_panics(); diff --git a/solverlab-tui/src/persist.rs b/solverlab-tui/src/persist.rs index 380a2c5..ee7e793 100644 --- a/solverlab-tui/src/persist.rs +++ b/solverlab-tui/src/persist.rs @@ -32,6 +32,7 @@ impl ConfigStore { ProjectDirs::from("com", "jfg", "solverlab").map(|dirs| dirs.config_dir().join(FILE_NAME)) } + #[cfg(test)] pub fn path(&self) -> Option<&Path> { self.path.as_deref() } diff --git a/solverlab-tui/tests/headless.rs b/solverlab-tui/tests/headless.rs new file mode 100644 index 0000000..00be2b6 --- /dev/null +++ b/solverlab-tui/tests/headless.rs @@ -0,0 +1,94 @@ +//! End-to-end checks of the headless mode through the real binary. + +use std::process::Command; + +fn run_headless(args: &[&str]) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_solverlab-tui")) + .args(["--headless", "--quiet"]) + .args(args) + .output() + .expect("failed to run solverlab-tui"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +/// `game_id,moves,undos,checkpoints,result,score` for each detail row: the +/// columns that must not depend on CPU speed. +fn deterministic_columns(csv: &str) -> Vec { + csv.lines() + .skip_while(|line| *line != "details") + .skip(2) + .map(|line| { + let fields: Vec<&str> = line.split(',').collect(); + [0, 1, 2, 3, 4, 7].map(|i| fields[i]).join(",") + }) + .collect() +} + +#[test] +fn headless_prints_the_csv() { + let csv = run_headless(&[ + "--game", + "tripeaks", + "--sims", + "3", + "--parallel", + "2", + "--seed", + "7", + ]); + assert!(csv.starts_with("configuration\n")); + assert!(csv.contains("\"TriPeaks\",\"A*\",3,2,false,-1,0.00,150")); + assert!(csv.contains("\nsummary\n")); + let rows = deterministic_columns(&csv); + assert_eq!(rows.len(), 3); + assert!(rows[0].starts_with("1,")); + assert!(rows[2].starts_with("3,")); +} + +#[test] +fn headless_writes_the_csv_to_a_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.csv"); + let stdout = run_headless(&[ + "--game", + "pyramid", + "--sims", + "2", + "--seed", + "1", + "--csv", + path.to_str().unwrap(), + ]); + assert!(stdout.is_empty()); + let csv = std::fs::read_to_string(&path).unwrap(); + assert_eq!(deterministic_columns(&csv).len(), 2); +} + +#[test] +fn seeded_runs_match_across_executions() { + let args = [ + "--game", + "tripeaks", + "--sims", + "4", + "--parallel", + "2", + "--seed", + "1234", + ]; + let first = deterministic_columns(&run_headless(&args)); + let second = deterministic_columns(&run_headless(&args)); + assert_eq!(first, second); +} + +#[test] +fn zero_simulations_produce_an_empty_report() { + let csv = run_headless(&["--game", "freecell", "--sims", "0"]); + assert!(csv.contains("\n0,00:00,0.00%,0.00,0.00,0.00,0.00s,0,0,0\n")); + assert!(deterministic_columns(&csv).is_empty()); +} From 9224db8143a036d70951a2011c45e8155f9a0858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 00:36:45 -0300 Subject: [PATCH 14/15] docs(tui): document TUI, add make targets and CI matrix --- .github/workflows/tui.yml | 87 +++++++++++++++++++++++++++++++++++++++ Makefile | 13 +++++- README.md | 58 ++++++++++++++++++++++++-- 3 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/tui.yml diff --git a/.github/workflows/tui.yml b/.github/workflows/tui.yml new file mode 100644 index 0000000..1f3ddac --- /dev/null +++ b/.github/workflows/tui.yml @@ -0,0 +1,87 @@ +name: TUI + +on: + push: + paths: + - "solverlab-tui/**" + - "solver-core-rs/**" + - ".github/workflows/tui.yml" + pull_request: + paths: + - "solverlab-tui/**" + - "solver-core-rs/**" + - ".github/workflows/tui.yml" + +defaults: + run: + working-directory: solverlab-tui + +jobs: + test: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: solverlab-tui + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo test + - name: Slow smoke tests + if: matrix.os == 'ubuntu-latest' + run: cargo test --release -- --ignored + + seeded-run: + name: Seeded run (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: solverlab-tui + - name: Run headless simulations + shell: bash + run: | + for game in freecell pyramid tripeaks; do + cargo run --release -- --headless --quiet --game "$game" --sims 20 \ + --parallel 2 --seed 1234 --csv "../seeded-$game.csv" + done + - uses: actions/upload-artifact@v4 + with: + name: seeded-${{ matrix.os }} + path: seeded-*.csv + + compare: + name: Compare seeded runs across platforms + needs: seeded-run + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - uses: actions/download-artifact@v4 + - name: Diff CPU-independent columns + shell: bash + run: | + status=0 + for game in freecell pyramid tripeaks; do + for os in macos-latest windows-latest; do + diff <(sed -n '/^details$/,$p' "seeded-ubuntu-latest/seeded-$game.csv" | tr -d '\r' | cut -d, -f1-5,8) \ + <(sed -n '/^details$/,$p' "seeded-$os/seeded-$game.csv" | tr -d '\r' | cut -d, -f1-5,8) \ + || { echo "::error::$game differs between ubuntu and $os"; status=1; } + done + done + exit $status diff --git a/Makefile b/Makefile index 98e5cc6..2db8dd9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build-rust generate-bindings xcframework clean check test coverage-rust +.PHONY: build-rust generate-bindings xcframework clean check test coverage-rust tui tui-run tui-test # Configuration MAKEFILE_DIR := $(CURDIR)/ @@ -6,6 +6,7 @@ CRATE_DIR = $(MAKEFILE_DIR)solver-core-rs LIB_NAME = solver_core OUT_DIR = $(MAKEFILE_DIR)Sources/SolverCoreRS FRAMEWORK_NAME = SolverCoreRS +TUI_DIR = $(MAKEFILE_DIR)solverlab-tui CARGO_TARGET_DIR ?= $(CRATE_DIR)/target # Default target @@ -23,6 +24,16 @@ test: coverage-rust: "$(MAKEFILE_DIR)scripts/coverage-rust.sh" +# Terminal console (ratatui) +tui: + cd "$(TUI_DIR)" && cargo build --release + +tui-run: + cd "$(TUI_DIR)" && cargo run --release + +tui-test: + cd "$(TUI_DIR)" && cargo test + # Build for both architectures build-rust: cd "$(CRATE_DIR)" && cargo build --release --target aarch64-apple-darwin diff --git a/README.md b/README.md index 8b3dfb4..85c1c76 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,8 @@ Win rates vary by game variant, timeout, search limits, and resource budget. ## Requirements -- macOS 13 or newer -- Xcode with Swift 6.1 toolchain support -- Rust stable toolchain with Cargo -- Apple Silicon or Intel Mac for local development +- Rust stable toolchain with Cargo (1.86 or newer for the terminal console) +- For the SwiftUI dashboard only: macOS 13 or newer, Xcode with Swift 6.1 toolchain support, Apple Silicon or Intel Mac ## Quick Start @@ -63,6 +61,51 @@ To rebuild the Rust core and regenerate the Swift bindings: make xcframework ``` +## Terminal Console (TUI) + +`solverlab-tui/` is a cross-platform copy of the SwiftUI simulation console built with [ratatui](https://ratatui.rs). It links the Rust core directly (no UniFFI) and offers the same controls, metrics, results table and CSV export, so the solvers can be exercised on Linux, Windows and macOS. + +```bash +cd solverlab-tui && cargo run --release +# or +make tui-run +``` + +| Keys | Action | +|---|---| +| `Tab` / `Shift+Tab` | Move focus | +| `Enter` / `Space` | Activate the focused control | +| `←` `→` on Game, `g` / `G` | Change game | +| digits, `Backspace`, `↑` `↓` on Sims / Parallel | Edit values (`Shift`: ±10) | +| `a` | Toggle auto parallelism | +| `s` | Start / Stop | +| `e` | Export CSV | +| `c` | Clear data | +| `↑` `↓` `PgUp` `PgDn` `Home` `End` | Scroll results | +| `[` `]` | Change sort column (always descending) | +| `?` | Help | +| `q` / `Esc` / `Ctrl+C` | Quit | + +Useful flags (`--help` lists all of them): `--game`, `--sims`, `--parallel`, `--auto`, `--timeout`, `--seed`, `--config`, `--no-persist`, `--ascii`. + +Headless mode runs without a terminal UI and prints the CSV, which is handy for CI or remote machines. With `--seed`, every platform deals the same boards, so results can be compared across machines: + +```bash +solverlab-tui --headless --game tripeaks --sims 200 --auto --seed 1234 --csv linux.csv +# same command on another platform → windows.csv, then compare the CPU-independent columns +sed -n '/^details$/,$p' linux.csv | cut -d, -f1-5,8 > a +sed -n '/^details$/,$p' windows.csv | cut -d, -f1-5,8 > b +diff a b +``` + +Differences from the SwiftUI app: + +- Config fields are locked while a run is active. +- `--seed` makes decks reproducible; without it decks are random, as in the app. +- CSV export asks for a path in the terminal instead of a save panel. +- The config is stored as JSON in the platform config directory, not in `UserDefaults`. +- Stopping a run, like in the app, does not interrupt games already being solved; their results are discarded. + ## Tests Run the default fast local test suite: @@ -72,6 +115,12 @@ cd solver-core-rs && cargo test swift test ``` +Run the terminal console tests: + +```bash +make tui-test +``` + Run the slower ignored Rust smoke tests when validating full solver behavior: ```bash @@ -83,6 +132,7 @@ cd solver-core-rs && cargo test --test integration_tests -- --ignored ```text Sources/ SwiftUI app and Swift bridge code solver-core-rs/ Rust solver core and UniFFI definitions +solverlab-tui/ Cross-platform terminal console (ratatui) SolverCoreRS.xcframework Packaged native solver library Tests/ Swift test target scripts/ Local helper scripts From 40a81cb8d96ef0543431a854b9b85ed2f0eb8df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 6 Oct 2026 01:03:45 -0300 Subject: [PATCH 15/15] docs: add sample Klondike Draw 1 TUI results --- .../KlondikeDraw1_results_20261006_010231.csv | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 docs/results/KlondikeDraw1_results_20261006_010231.csv diff --git a/docs/results/KlondikeDraw1_results_20261006_010231.csv b/docs/results/KlondikeDraw1_results_20261006_010231.csv new file mode 100644 index 0000000..c142b51 --- /dev/null +++ b/docs/results/KlondikeDraw1_results_20261006_010231.csv @@ -0,0 +1,510 @@ +configuration +game,search,simulations,parallel_games,auto_parallel,undos,timeout_s,max_depth +"Klondike Draw 1","IDA*",500,8,false,-1,0.00,150 + +summary +games,active_timeout,win_rate,avg_moves,avg_undos,avg_checkpoints,avg_duration,wins,stalled,timeouts +500,00:00,79.20%,116.40,0.62,1.28,7.92s,396,104,0 + +details +game_id,moves,undos,checkpoints,result,stop_reason,duration_sec,score +1,99,0,3,lost,stalled,9.666,485 +2,142,0,2,won,win,8.126,825 +3,109,0,1,won,win,7.928,750 +4,72,1,2,lost,stalled,10.271,230 +5,120,0,0,won,win,0.703,775 +6,118,0,0,won,win,0.745,760 +7,163,1,2,won,win,9.177,780 +8,41,0,0,lost,stalled,3.547,90 +9,153,4,3,won,win,10.960,840 +10,135,4,2,won,win,9.068,810 +11,137,1,2,won,win,7.918,770 +12,123,0,0,won,win,2.973,765 +13,110,1,1,won,win,7.755,720 +14,124,0,2,won,win,10.254,780 +15,127,2,1,won,win,7.225,790 +16,109,0,1,won,win,7.569,790 +17,116,0,1,won,win,7.922,745 +18,109,1,1,won,win,7.826,775 +19,57,0,1,lost,stalled,8.141,245 +20,113,0,0,won,win,4.782,735 +21,131,0,1,won,win,7.430,825 +22,134,1,3,won,win,8.956,830 +23,114,0,1,won,win,8.352,755 +24,114,0,2,won,win,8.298,720 +25,76,0,1,lost,stalled,8.598,340 +26,123,1,5,lost,stalled,13.460,450 +27,128,0,1,won,win,7.256,805 +28,110,0,0,won,win,1.344,720 +29,128,1,2,won,win,8.803,805 +30,131,0,1,won,win,7.843,785 +31,94,0,2,lost,stalled,11.072,275 +32,133,0,0,won,win,3.831,770 +33,103,1,0,won,win,0.404,720 +34,138,2,1,won,win,8.268,755 +35,130,0,1,won,win,7.080,775 +36,141,0,1,won,win,7.784,785 +37,123,0,1,won,win,7.457,795 +38,120,0,1,won,win,7.609,765 +39,127,0,3,lost,stalled,9.174,555 +40,125,0,3,won,win,10.235,830 +41,58,0,0,lost,stalled,2.613,90 +42,130,0,1,won,win,7.938,790 +43,119,1,1,won,win,8.551,780 +44,115,0,0,won,win,3.477,765 +45,147,4,2,won,win,9.594,785 +46,74,1,0,lost,stalled,1.047,125 +47,126,0,1,won,win,8.266,760 +48,156,2,2,won,win,8.400,815 +49,119,0,1,won,win,9.811,800 +50,132,0,1,won,win,7.389,770 +51,114,1,0,won,win,0.747,800 +52,121,0,1,won,win,7.643,750 +53,133,0,3,won,win,9.143,810 +54,128,0,2,won,win,7.510,805 +55,152,2,2,won,win,8.625,850 +56,138,3,1,won,win,7.393,780 +57,92,1,2,lost,stalled,9.515,385 +58,117,0,1,won,win,8.144,760 +59,126,0,2,won,win,8.917,755 +60,124,1,1,won,win,7.386,770 +61,118,0,1,won,win,10.148,755 +62,120,0,2,won,win,8.374,775 +63,120,1,1,won,win,9.122,780 +64,125,0,1,won,win,9.323,810 +65,126,2,1,won,win,6.700,740 +66,122,0,0,won,win,1.547,765 +67,87,1,2,lost,stalled,11.368,330 +68,110,0,0,won,win,3.513,725 +69,112,0,0,won,win,1.581,735 +70,104,0,0,won,win,1.641,765 +71,102,0,0,won,win,1.279,755 +72,133,0,1,won,win,8.117,775 +73,106,0,0,won,win,0.854,705 +74,90,0,2,lost,stalled,10.733,505 +75,114,0,0,won,win,5.715,750 +76,142,1,3,won,win,9.637,830 +77,123,1,0,won,win,5.467,770 +78,96,0,3,lost,stalled,11.060,355 +79,111,1,1,lost,stalled,8.332,575 +80,135,3,2,won,win,8.506,800 +81,134,0,1,won,win,7.954,820 +82,112,1,3,won,win,9.049,790 +83,118,0,1,won,win,9.023,765 +84,116,1,1,won,win,8.312,740 +85,125,0,1,won,win,7.401,800 +86,85,2,2,lost,stalled,8.437,400 +87,88,1,2,lost,stalled,9.832,315 +88,146,1,2,won,win,7.632,795 +89,147,2,2,won,win,9.132,790 +90,141,3,1,won,win,7.698,770 +91,148,3,1,won,win,8.319,800 +92,144,0,1,won,win,8.162,795 +93,108,0,1,won,win,6.986,735 +94,120,0,1,won,win,7.459,755 +95,122,0,1,won,win,7.493,735 +96,125,0,2,won,win,9.354,765 +97,105,0,1,won,win,7.118,695 +98,111,0,2,lost,stalled,11.277,340 +99,124,0,2,won,win,8.484,795 +100,119,0,0,won,win,2.609,755 +101,130,1,1,won,win,8.683,820 +102,45,0,1,lost,stalled,7.539,110 +103,128,0,1,won,win,8.898,775 +104,121,1,2,won,win,8.113,750 +105,117,0,1,won,win,7.826,775 +106,120,0,1,won,win,6.806,775 +107,139,0,0,won,win,1.379,780 +108,83,0,2,lost,stalled,10.362,330 +109,150,1,2,won,win,9.762,795 +110,132,0,1,won,win,7.903,770 +111,135,0,1,won,win,8.846,845 +112,110,0,0,won,win,0.057,755 +113,117,0,1,won,win,8.535,765 +114,114,3,1,won,win,7.705,710 +115,129,0,2,won,win,8.762,850 +116,136,0,1,won,win,8.093,780 +117,126,2,1,won,win,7.786,785 +118,128,0,1,won,win,7.845,815 +119,108,1,3,lost,stalled,11.222,420 +120,122,0,1,won,win,7.911,775 +121,46,0,1,lost,stalled,6.409,95 +122,107,0,1,won,win,7.506,780 +123,112,0,1,won,win,8.394,745 +124,116,0,1,won,win,7.750,770 +125,134,2,1,won,win,7.968,755 +126,136,0,2,won,win,7.568,785 +127,119,0,1,won,win,7.860,790 +128,79,0,1,lost,stalled,7.064,340 +129,119,1,2,won,win,8.465,745 +130,150,0,3,won,win,9.265,845 +131,124,0,1,won,win,6.943,785 +132,120,1,1,won,win,8.638,755 +133,122,0,1,won,win,6.956,785 +134,122,0,2,won,win,8.438,790 +135,104,0,0,won,win,6.595,765 +136,112,0,1,won,win,7.669,755 +137,93,4,2,lost,stalled,11.402,275 +138,116,1,1,won,win,7.034,760 +139,90,1,2,lost,stalled,11.285,335 +140,103,0,1,lost,stalled,10.965,295 +141,122,0,1,won,win,7.974,790 +142,77,2,2,lost,stalled,10.116,205 +143,123,0,1,won,win,8.575,745 +144,134,2,3,won,win,10.022,810 +145,104,0,1,won,win,7.116,765 +146,83,2,2,lost,stalled,9.038,320 +147,136,4,1,won,win,9.640,790 +148,119,0,0,won,win,1.313,775 +149,133,2,1,won,win,7.966,755 +150,161,7,4,won,win,10.036,765 +151,121,0,1,won,win,8.611,785 +152,122,0,1,won,win,7.004,755 +153,127,1,1,won,win,7.408,785 +154,163,2,1,won,win,7.287,790 +155,114,0,1,won,win,7.430,770 +156,55,1,1,lost,stalled,8.488,195 +157,134,1,1,won,win,8.184,765 +158,116,0,1,won,win,7.246,780 +159,128,1,2,won,win,7.838,780 +160,88,1,3,lost,stalled,11.692,360 +161,113,0,1,won,win,7.877,735 +162,91,3,2,lost,stalled,8.767,465 +163,117,0,1,won,win,8.123,780 +164,124,0,1,won,win,9.040,795 +165,123,4,1,won,win,7.738,735 +166,122,0,2,won,win,8.273,800 +167,123,0,1,won,win,7.472,780 +168,88,1,3,lost,stalled,10.761,370 +169,127,0,0,won,win,3.543,795 +170,122,0,1,won,win,6.823,770 +171,121,0,1,won,win,7.994,825 +172,93,1,2,lost,stalled,7.973,455 +173,134,0,1,won,win,8.360,770 +174,43,0,1,lost,stalled,8.189,145 +175,81,0,2,lost,stalled,9.154,255 +176,127,1,2,won,win,7.825,800 +177,144,1,2,won,win,8.452,835 +178,80,2,2,lost,stalled,9.883,345 +179,115,0,1,won,win,7.296,760 +180,87,0,1,lost,stalled,8.293,545 +181,72,0,2,lost,stalled,11.025,265 +182,117,0,1,won,win,7.630,775 +183,130,0,2,won,win,8.978,770 +184,82,0,2,lost,stalled,8.596,380 +185,123,0,2,won,win,8.091,795 +186,132,1,1,won,win,7.969,810 +187,115,4,1,won,win,7.824,700 +188,129,0,1,won,win,8.513,810 +189,123,0,0,won,win,4.338,795 +190,112,0,4,lost,stalled,11.958,540 +191,106,0,0,won,win,6.539,775 +192,99,3,2,lost,stalled,9.456,285 +193,124,0,1,won,win,8.079,770 +194,113,0,0,won,win,6.376,810 +195,77,0,2,lost,stalled,10.604,280 +196,111,0,1,won,win,7.648,740 +197,115,0,1,won,win,8.310,775 +198,119,0,2,won,win,7.941,755 +199,117,0,1,won,win,7.471,770 +200,103,0,0,won,win,4.360,760 +201,67,1,1,lost,stalled,10.791,220 +202,113,0,1,won,win,8.040,750 +203,128,0,1,won,win,7.737,795 +204,113,0,1,won,win,6.977,770 +205,124,1,0,won,win,1.357,775 +206,124,0,1,won,win,7.102,790 +207,117,0,1,won,win,7.811,785 +208,138,2,1,won,win,7.186,825 +209,117,1,1,won,win,8.664,755 +210,134,0,1,won,win,7.785,765 +211,112,0,1,won,win,7.074,750 +212,126,0,1,won,win,7.743,790 +213,128,1,1,won,win,8.549,785 +214,115,0,1,won,win,7.858,760 +215,120,0,1,won,win,8.273,795 +216,145,1,2,won,win,8.318,825 +217,119,1,1,won,win,8.578,760 +218,113,0,1,won,win,7.823,780 +219,144,0,1,won,win,7.447,805 +220,82,0,1,lost,stalled,8.506,350 +221,109,0,1,won,win,7.920,790 +222,109,0,1,won,win,7.630,730 +223,122,0,1,won,win,8.126,775 +224,125,0,1,won,win,7.449,795 +225,101,0,0,won,win,5.734,750 +226,103,0,0,won,win,2.874,760 +227,112,0,1,won,win,8.967,745 +228,132,2,1,won,win,6.885,760 +229,116,0,0,won,win,2.523,770 +230,127,0,2,won,win,8.194,760 +231,124,4,1,won,win,8.514,755 +232,120,2,2,won,win,8.688,780 +233,110,0,1,won,win,6.967,795 +234,120,0,0,won,win,0.903,760 +235,97,1,2,lost,stalled,13.177,215 +236,142,1,3,won,win,9.249,820 +237,123,0,1,won,win,7.927,755 +238,129,0,1,won,win,7.215,810 +239,116,0,1,won,win,7.721,775 +240,124,0,1,won,win,8.071,805 +241,111,0,1,won,win,8.217,800 +242,130,2,1,won,win,9.660,800 +243,116,0,1,won,win,8.550,760 +244,124,0,1,won,win,9.091,795 +245,115,0,2,won,win,7.620,770 +246,126,0,2,won,win,9.274,825 +247,144,4,1,won,win,6.892,770 +248,100,0,0,won,win,5.850,745 +249,118,1,1,won,win,7.610,820 +250,117,0,0,won,win,1.009,755 +251,111,0,0,won,win,2.146,800 +252,115,0,1,won,win,7.291,730 +253,152,6,2,won,win,9.346,790 +254,101,1,2,lost,stalled,10.379,405 +255,119,0,2,won,win,7.610,775 +256,105,0,3,lost,stalled,9.891,425 +257,111,0,2,lost,stalled,8.851,335 +258,78,1,2,lost,stalled,12.976,340 +259,110,1,2,lost,stalled,11.479,300 +260,124,0,1,won,win,7.439,790 +261,103,0,3,lost,stalled,11.003,445 +262,113,0,1,won,win,8.096,750 +263,119,0,0,won,win,0.758,790 +264,98,0,0,won,win,1.143,735 +265,136,1,1,won,win,8.199,760 +266,138,0,1,won,win,8.328,790 +267,66,0,2,lost,stalled,16.258,135 +268,138,0,2,won,win,8.525,775 +269,130,1,1,won,win,7.428,775 +270,110,2,1,won,win,8.143,765 +271,137,0,2,won,win,9.460,860 +272,79,0,1,lost,stalled,7.344,260 +273,126,0,2,won,win,8.377,755 +274,114,0,1,won,win,7.911,745 +275,155,1,2,won,win,8.116,840 +276,176,12,2,won,win,9.344,765 +277,114,0,1,won,win,7.429,770 +278,121,0,1,won,win,8.439,780 +279,134,1,2,won,win,8.139,775 +280,84,0,2,lost,stalled,9.628,400 +281,127,0,1,won,win,8.240,800 +282,139,0,2,won,win,9.372,815 +283,92,1,1,lost,stalled,8.473,405 +284,100,1,2,lost,stalled,9.579,440 +285,127,0,1,won,win,7.518,780 +286,112,1,1,won,win,8.102,740 +287,98,0,3,lost,stalled,10.605,450 +288,119,0,1,won,win,7.562,775 +289,85,0,1,lost,stalled,9.560,315 +290,123,0,1,won,win,7.671,810 +291,90,1,2,lost,stalled,9.419,330 +292,133,0,1,won,win,7.815,795 +293,88,0,2,lost,stalled,8.302,410 +294,124,0,2,lost,stalled,15.405,250 +295,155,0,3,won,win,8.534,845 +296,141,1,2,won,win,9.604,790 +297,85,0,2,lost,stalled,11.051,285 +298,132,0,1,won,win,8.437,760 +299,88,1,2,lost,stalled,10.272,415 +300,127,0,1,won,win,7.420,800 +301,116,0,1,won,win,8.834,760 +302,127,1,2,won,win,8.396,795 +303,118,0,0,won,win,6.041,770 +304,100,0,1,lost,stalled,7.895,180 +305,79,1,2,lost,stalled,9.586,345 +306,124,2,1,won,win,8.637,755 +307,135,3,1,won,win,9.058,770 +308,70,2,2,lost,stalled,12.030,240 +309,99,1,3,lost,stalled,12.008,260 +310,121,2,2,won,win,8.981,730 +311,112,0,1,won,win,7.537,765 +312,71,0,2,lost,stalled,13.373,205 +313,121,0,1,won,win,7.136,805 +314,122,0,1,won,win,7.239,765 +315,129,0,1,won,win,7.268,780 +316,150,1,3,won,win,11.130,830 +317,114,0,0,won,win,4.839,755 +318,138,1,1,won,win,7.656,785 +319,128,1,0,won,win,7.455,795 +320,101,1,2,lost,stalled,10.123,455 +321,120,0,1,won,win,7.844,790 +322,129,0,1,won,win,10.078,800 +323,126,0,0,won,win,0.499,790 +324,137,0,2,won,win,7.461,810 +325,65,0,2,lost,stalled,11.843,250 +326,107,0,0,won,win,4.001,710 +327,126,2,1,won,win,7.541,765 +328,125,2,1,won,win,8.154,775 +329,183,7,5,won,win,10.570,905 +330,140,2,2,won,win,8.659,825 +331,120,0,2,won,win,9.395,795 +332,91,0,2,lost,stalled,8.774,350 +333,132,0,1,won,win,7.417,785 +334,125,0,1,won,win,7.781,825 +335,118,0,0,won,win,0.308,780 +336,127,0,1,won,win,7.696,795 +337,132,0,1,won,win,8.745,780 +338,117,0,1,won,win,8.911,795 +339,115,0,1,won,win,7.915,755 +340,120,1,1,won,win,7.465,755 +341,146,0,2,won,win,8.621,775 +342,83,0,2,lost,stalled,9.898,320 +343,104,0,0,won,win,0.643,700 +344,83,0,2,lost,stalled,13.596,235 +345,77,1,1,lost,stalled,8.791,255 +346,146,0,2,won,win,9.228,775 +347,170,1,2,won,win,10.418,835 +348,129,1,1,won,win,7.782,820 +349,117,3,1,won,win,8.358,755 +350,123,0,1,won,win,8.180,750 +351,109,0,1,won,win,7.462,740 +352,112,0,0,won,win,0.845,740 +353,106,1,0,won,win,6.372,760 +354,102,1,3,lost,stalled,14.304,350 +355,143,1,2,won,win,11.302,820 +356,127,0,2,won,win,9.231,755 +357,122,0,1,won,win,7.102,780 +358,114,1,1,won,win,7.380,735 +359,124,0,1,won,win,7.559,765 +360,134,0,2,won,win,8.080,845 +361,112,0,0,won,win,0.939,740 +362,131,1,2,won,win,7.397,775 +363,117,3,1,won,win,7.179,785 +364,110,0,0,won,win,4.669,760 +365,105,0,0,won,win,1.575,770 +366,121,1,1,won,win,7.406,770 +367,115,0,1,won,win,7.473,760 +368,146,0,2,won,win,8.595,780 +369,133,1,3,won,win,8.822,855 +370,42,0,1,lost,stalled,9.698,140 +371,112,0,0,won,win,0.501,755 +372,125,0,0,won,win,2.600,785 +373,132,1,1,won,win,10.011,820 +374,114,0,1,won,win,7.867,755 +375,88,1,2,lost,stalled,9.033,360 +376,113,0,1,won,win,7.900,745 +377,98,0,1,lost,stalled,9.239,555 +378,106,2,1,lost,stalled,9.717,170 +379,124,1,1,won,win,7.222,730 +380,95,2,2,lost,stalled,11.682,380 +381,121,0,1,won,win,8.165,790 +382,92,0,2,lost,stalled,10.430,390 +383,134,1,2,won,win,7.800,780 +384,120,0,0,won,win,4.057,770 +385,112,0,1,won,win,7.747,765 +386,120,0,1,won,win,7.999,775 +387,90,2,2,lost,stalled,8.715,375 +388,125,0,1,won,win,7.273,740 +389,91,0,3,lost,stalled,9.980,470 +390,90,1,3,lost,stalled,10.524,370 +391,109,0,1,won,win,7.348,790 +392,116,1,1,won,win,7.457,745 +393,145,0,2,won,win,7.983,835 +394,157,1,1,won,win,9.015,830 +395,123,3,2,won,win,8.464,780 +396,105,2,0,won,win,4.453,740 +397,113,0,1,won,win,7.465,765 +398,123,0,1,won,win,7.182,785 +399,96,0,2,lost,stalled,8.782,365 +400,126,0,0,won,win,4.189,765 +401,120,0,0,won,win,6.241,765 +402,101,1,2,lost,stalled,9.523,305 +403,107,0,1,won,win,7.218,780 +404,142,2,2,won,win,8.511,810 +405,101,2,1,won,win,7.773,720 +406,111,1,1,won,win,6.995,785 +407,148,4,2,won,win,9.114,810 +408,110,0,1,won,win,7.599,755 +409,113,0,1,won,win,8.062,745 +410,126,1,1,won,win,7.722,765 +411,91,0,2,lost,stalled,10.285,340 +412,71,0,2,lost,stalled,9.810,290 +413,127,0,1,won,win,7.667,795 +414,123,0,0,won,win,2.904,775 +415,114,1,1,won,win,8.499,760 +416,126,0,0,won,win,3.299,770 +417,110,0,1,won,win,8.791,735 +418,132,0,1,won,win,7.410,785 +419,138,1,2,won,win,9.618,830 +420,119,0,1,won,win,7.805,790 +421,128,0,1,won,win,8.812,800 +422,69,0,2,lost,stalled,12.807,315 +423,118,0,1,won,win,7.974,765 +424,120,0,1,won,win,8.546,785 +425,123,0,1,won,win,9.392,780 +426,134,2,1,won,win,8.931,795 +427,116,0,1,won,win,8.863,775 +428,132,0,1,won,win,10.864,770 +429,150,2,3,won,win,11.736,835 +430,99,1,3,lost,stalled,14.209,390 +431,119,0,1,won,win,8.984,785 +432,124,1,1,won,win,8.985,765 +433,118,0,1,won,win,7.811,775 +434,118,1,1,won,win,9.116,765 +435,105,0,1,won,win,7.487,770 +436,111,0,1,won,win,7.858,800 +437,111,0,1,won,win,7.875,800 +438,115,0,1,won,win,8.504,760 +439,134,1,1,won,win,8.713,775 +440,114,0,1,won,win,8.471,815 +441,107,0,0,won,win,4.208,780 +442,118,0,1,won,win,8.644,755 +443,127,2,1,won,win,8.596,795 +444,94,0,2,lost,stalled,10.622,340 +445,113,0,1,won,win,6.794,765 +446,100,2,2,lost,stalled,9.573,360 +447,132,5,2,won,win,8.732,725 +448,92,1,3,lost,stalled,10.486,425 +449,126,0,1,won,win,9.752,780 +450,142,3,2,won,win,12.990,830 +451,122,0,1,won,win,10.305,775 +452,155,3,2,won,win,12.296,800 +453,90,0,2,lost,stalled,11.353,395 +454,122,0,1,won,win,10.914,795 +455,82,0,2,lost,stalled,13.110,305 +456,121,1,1,won,win,10.977,780 +457,120,0,1,won,win,8.389,785 +458,100,0,0,won,win,4.745,745 +459,118,0,0,won,win,2.149,790 +460,117,0,1,won,win,8.300,780 +461,116,0,1,won,win,7.794,780 +462,130,0,1,won,win,7.601,760 +463,113,0,0,won,win,3.407,730 +464,121,0,1,won,win,6.786,765 +465,118,1,1,won,win,8.711,755 +466,121,0,1,won,win,7.279,775 +467,123,1,1,won,win,8.450,760 +468,137,0,1,won,win,10.474,810 +469,123,0,1,won,win,7.573,735 +470,76,0,2,lost,stalled,10.415,370 +471,122,0,1,won,win,8.288,800 +472,117,0,1,won,win,8.134,775 +473,133,0,3,won,win,10.531,835 +474,116,0,1,won,win,8.639,775 +475,124,0,1,won,win,7.067,810 +476,123,1,1,won,win,7.769,805 +477,153,5,2,won,win,8.550,820 +478,114,0,1,won,win,7.803,755 +479,115,0,1,won,win,8.458,740 +480,59,0,2,lost,stalled,12.932,270 +481,90,1,2,lost,stalled,9.157,350 +482,62,1,0,lost,stalled,3.188,85 +483,120,0,1,won,win,6.957,760 +484,98,0,2,lost,stalled,11.084,345 +485,151,2,2,won,win,8.498,810 +486,130,1,2,won,win,7.875,745 +487,108,2,3,lost,stalled,8.970,495 +488,112,0,2,lost,stalled,8.170,440 +489,124,2,1,won,win,8.308,760 +490,120,0,1,won,win,7.612,785 +491,117,0,3,lost,stalled,9.363,410 +492,126,1,0,won,win,1.617,750 +493,98,0,0,won,win,1.305,735 +494,48,0,1,lost,stalled,7.418,115 +495,124,1,1,won,win,7.408,770 +496,140,0,2,won,win,9.288,765 +497,119,0,1,won,win,8.151,795 +498,131,4,2,won,win,8.174,770 +499,118,0,1,won,win,7.255,765 +500,110,0,0,won,win,0.502,735