feat(hh): ratatui TUI client — chat, live roster, themes
- Connect subcommand: SRP auth then a ratatui UI over tokio + crossterm. - Async ws (tokio-tungstenite); reader task decrypts/parses frames into events. - Panes: top bar (e2e + house N/cap), chat scrollback, roster (self marked ⛧), input box. Undecryptable frames surface as a system line, not a silent drop. - Themes (vestments) via TOML --theme; default occult-monochrome + neon. - Verified live in tmux: render, chat round-trip, roster, join/leave. - Adds fernet python->rust interop regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bb1d662ee1
commit
14aa369fb2
510
hh/Cargo.lock
generated
510
hh/Cargo.lock
generated
|
|
@ -2,6 +2,12 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -103,6 +109,9 @@ name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
|
|
@ -131,6 +140,21 @@ version = "1.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cassowary"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "castaway"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.63"
|
version = "1.2.63"
|
||||||
|
|
@ -210,6 +234,21 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compact_str"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
|
||||||
|
dependencies = [
|
||||||
|
"castaway",
|
||||||
|
"cfg-if",
|
||||||
|
"itoa",
|
||||||
|
"rustversion",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -219,6 +258,32 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"futures-core",
|
||||||
|
"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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -229,6 +294,40 @@ dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||||
|
dependencies = [
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
|
|
@ -263,6 +362,28 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
|
[[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 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fernet"
|
name = "fernet"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -282,6 +403,12 @@ version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|
@ -334,6 +461,17 @@ version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -354,6 +492,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -405,21 +544,44 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"clap",
|
"clap",
|
||||||
|
"crossterm",
|
||||||
"fernet",
|
"fernet",
|
||||||
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"num-bigint",
|
"num-bigint",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
"ratatui",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
"toml",
|
||||||
"tungstenite",
|
"tungstenite",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
@ -630,6 +792,12 @@ dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -651,6 +819,38 @@ dependencies = [
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"indoc",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
|
|
@ -663,6 +863,15 @@ version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
|
|
@ -697,18 +906,42 @@ version = "0.2.186"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.30"
|
version = "0.4.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
|
|
@ -728,6 +961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
@ -809,6 +1043,35 @@ dependencies = [
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|
@ -983,6 +1246,37 @@ dependencies = [
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm",
|
||||||
|
"indoc",
|
||||||
|
"instability",
|
||||||
|
"itertools",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"serde",
|
||||||
|
"strum",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
|
|
@ -1043,6 +1337,19 @@ version = "2.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.40"
|
||||||
|
|
@ -1093,6 +1400,12 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -1136,6 +1449,15 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
|
@ -1176,6 +1498,37 @@ version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
|
|
@ -1204,12 +1557,40 @@ version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|
@ -1323,9 +1704,21 @@ dependencies = [
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
|
|
@ -1336,6 +1729,63 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tungstenite",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -1439,6 +1889,35 @@ version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-truncate"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
|
dependencies = [
|
||||||
|
"itertools",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width 0.1.14",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -1604,6 +2083,28 @@ dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -1766,6 +2267,15 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,14 @@ tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|
||||||
|
# async + tui
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
||||||
|
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
ratatui = { version = "0.29", features = ["serde"] }
|
||||||
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
# data
|
# data
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
|
||||||
163
hh/src/app.rs
Normal file
163
hh/src/app.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
//! TUI application state, network event model, and the async run loop.
|
||||||
|
|
||||||
|
use crate::net::{self, Session};
|
||||||
|
use crate::theme::Theme;
|
||||||
|
use crate::ui;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
|
use crossterm::terminal::{
|
||||||
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
};
|
||||||
|
use crossterm::execute;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||||
|
|
||||||
|
/// One rendered chat row.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ChatLine {
|
||||||
|
pub ts: String,
|
||||||
|
pub username: String,
|
||||||
|
pub text: String,
|
||||||
|
pub system: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct User {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded events arriving from the websocket reader task.
|
||||||
|
pub enum Net {
|
||||||
|
Init { lines: Vec<ChatLine>, users: Vec<User> },
|
||||||
|
Message(ChatLine),
|
||||||
|
Roster { users: Vec<User>, capacity: usize },
|
||||||
|
Joined(String),
|
||||||
|
Left(String),
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub me: String,
|
||||||
|
pub lines: Vec<ChatLine>,
|
||||||
|
pub users: Vec<User>,
|
||||||
|
pub capacity: usize,
|
||||||
|
pub input: String,
|
||||||
|
pub connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new(me: String) -> Self {
|
||||||
|
Self {
|
||||||
|
me,
|
||||||
|
lines: Vec::new(),
|
||||||
|
users: Vec::new(),
|
||||||
|
capacity: 0,
|
||||||
|
input: String::new(),
|
||||||
|
connected: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sys(&mut self, text: impl Into<String>) {
|
||||||
|
self.lines.push(ChatLine {
|
||||||
|
ts: String::new(),
|
||||||
|
username: String::new(),
|
||||||
|
text: text.into(),
|
||||||
|
system: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&mut self, n: Net) {
|
||||||
|
match n {
|
||||||
|
Net::Init { lines, users } => {
|
||||||
|
self.lines = lines;
|
||||||
|
self.users = users;
|
||||||
|
self.connected = true;
|
||||||
|
self.sys(format!("joined as {} ⛧", self.me));
|
||||||
|
}
|
||||||
|
Net::Message(l) => self.lines.push(l),
|
||||||
|
Net::Roster { users, capacity } => {
|
||||||
|
self.users = users;
|
||||||
|
self.capacity = capacity;
|
||||||
|
}
|
||||||
|
Net::Joined(name) => self.sys(format!("{name} entered the house")),
|
||||||
|
Net::Left(uid) => {
|
||||||
|
if let Some(p) = self.users.iter().position(|u| u.user_id == uid) {
|
||||||
|
let name = self.users.remove(p).username;
|
||||||
|
self.sys(format!("{name} left"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Net::Closed => {
|
||||||
|
self.connected = false;
|
||||||
|
self.sys("connection closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate already done; connect the websocket and drive the UI.
|
||||||
|
pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||||
|
let ws = net::connect(&session).await?;
|
||||||
|
let (mut write, read) = ws.split();
|
||||||
|
let (tx, mut rx) = unbounded_channel::<Net>();
|
||||||
|
tokio::spawn(net::reader(read, session.room.clone(), tx));
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = std::io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
|
||||||
|
|
||||||
|
let mut app = App::new(session.username.clone());
|
||||||
|
let mut events = EventStream::new();
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let result = loop {
|
||||||
|
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
|
||||||
|
break Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
maybe = events.next() => {
|
||||||
|
match maybe {
|
||||||
|
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
|
||||||
|
match (k.modifiers, k.code) {
|
||||||
|
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break Ok(()),
|
||||||
|
(_, KeyCode::Esc) => break Ok(()),
|
||||||
|
(_, KeyCode::Enter) => {
|
||||||
|
let line = app.input.trim().to_string();
|
||||||
|
app.input.clear();
|
||||||
|
if !line.is_empty() && app.connected {
|
||||||
|
let ct = session.room.encrypt(line.as_bytes());
|
||||||
|
if write.send(WsMsg::Text(ct)).await.is_err() {
|
||||||
|
app.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, KeyCode::Backspace) => { app.input.pop(); }
|
||||||
|
(_, KeyCode::Char(c)) => app.input.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Err(e)) => break Err(e.into()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
net = rx.recv() => {
|
||||||
|
match net {
|
||||||
|
Some(n) => app.apply(n),
|
||||||
|
None => break Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tick.tick() => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(term.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
term.show_cursor()?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
@ -235,3 +235,17 @@ mod tests {
|
||||||
assert_eq!(hex::encode(&ch.h_amk), HAMK_HEX, "H_AMK mismatch");
|
assert_eq!(hex::encode(&ch.h_amk), HAMK_HEX, "H_AMK mismatch");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod fernet_interop {
|
||||||
|
// Token produced by Python `cryptography` Fernet with key = urlsafe_b64(0x42*32).
|
||||||
|
const KEY: &str = "PulnLblVVdOu6iB0rjW8rQ2U2pwgsky3eod8I2OhLdE=";
|
||||||
|
const TOK: &str = "gAAAAABqG0ufNzHGkbfMWh4-46KVthUTnXUN9jVvGJ2UxklQFdBMIqBCMXmTmciEnB14kl_H613IOYm5w22bebVUhpu9ULuLf1fjq4jjaIK_ZHZNwCyqjy0=";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rust_decrypts_python_fernet() {
|
||||||
|
let f = fernet::Fernet::new(KEY).unwrap();
|
||||||
|
let pt = f.decrypt(TOK).expect("rust must decrypt python fernet token");
|
||||||
|
assert_eq!(pt, b"room key interop test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
//! speaks the same SRP / Fernet dialect as the Python Sanic server, plus the
|
//! speaks the same SRP / Fernet dialect as the Python Sanic server, plus the
|
||||||
//! ratatui UI built on top of that proven foundation.
|
//! ratatui UI built on top of that proven foundation.
|
||||||
|
|
||||||
|
mod app;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
|
mod net;
|
||||||
|
mod theme;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
|
@ -23,6 +27,23 @@ struct Cli {
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
|
/// Join a house: SRP auth then launch the ratatui UI.
|
||||||
|
Connect {
|
||||||
|
ip: String,
|
||||||
|
port: u16,
|
||||||
|
user: String,
|
||||||
|
#[arg(long)]
|
||||||
|
password: String,
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
no_tls: bool,
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
insecure: bool,
|
||||||
|
/// Path to a theme (vestments) TOML file.
|
||||||
|
#[arg(long)]
|
||||||
|
theme: Option<String>,
|
||||||
|
},
|
||||||
|
/// Debug: print the derived room Fernet key for a password + room_salt(hex).
|
||||||
|
Roomkey { password: String, room_salt_hex: String },
|
||||||
/// Run the offline SRP golden-vector self-test.
|
/// Run the offline SRP golden-vector self-test.
|
||||||
Selftest,
|
Selftest,
|
||||||
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
|
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
|
||||||
|
|
@ -51,6 +72,34 @@ enum Cmd {
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
match Cli::parse().cmd {
|
match Cli::parse().cmd {
|
||||||
|
Cmd::Connect {
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
no_tls,
|
||||||
|
insecure,
|
||||||
|
theme,
|
||||||
|
} => {
|
||||||
|
let session = net::authenticate(&ip, port, &user, &password, no_tls, insecure)?;
|
||||||
|
let theme = match theme {
|
||||||
|
Some(p) => theme::Theme::load(&p)?,
|
||||||
|
None => theme::Theme::default(),
|
||||||
|
};
|
||||||
|
tokio::runtime::Runtime::new()?.block_on(app::run(session, theme))
|
||||||
|
}
|
||||||
|
Cmd::Roomkey { password, room_salt_hex } => {
|
||||||
|
let salt = hex::decode(room_salt_hex)?;
|
||||||
|
let f = crypto::room_fernet(password.as_bytes(), &salt)?;
|
||||||
|
// fernet crate doesn't expose the key; re-derive + print the b64 key.
|
||||||
|
use base64::Engine;
|
||||||
|
let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), password.as_bytes());
|
||||||
|
let mut okm = [0u8; 32];
|
||||||
|
hk.expand(b"cmd-chat-room-key", &mut okm).unwrap();
|
||||||
|
println!("{}", base64::engine::general_purpose::URL_SAFE.encode(okm));
|
||||||
|
let _ = f;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Cmd::Selftest => selftest(),
|
Cmd::Selftest => selftest(),
|
||||||
Cmd::Srpm {
|
Cmd::Srpm {
|
||||||
a_hex,
|
a_hex,
|
||||||
|
|
|
||||||
184
hh/src/net.rs
Normal file
184
hh/src/net.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
//! SRP authentication (blocking, one-shot) + async websocket transport and the
|
||||||
|
//! reader task that decrypts/parses server frames into `Net` events.
|
||||||
|
|
||||||
|
use crate::app::{ChatLine, Net, User};
|
||||||
|
use crate::crypto;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||||
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||||
|
|
||||||
|
type Ws = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||||
|
|
||||||
|
pub struct Session {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub room: Arc<fernet::Fernet>,
|
||||||
|
pub ws_url: String,
|
||||||
|
pub no_tls: bool,
|
||||||
|
pub insecure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full SRP handshake against the Sanic server. Returns a ready Session
|
||||||
|
/// (room key derived, ws url built) but does not open the websocket.
|
||||||
|
pub fn authenticate(
|
||||||
|
ip: &str,
|
||||||
|
port: u16,
|
||||||
|
user: &str,
|
||||||
|
password: &str,
|
||||||
|
no_tls: bool,
|
||||||
|
insecure: bool,
|
||||||
|
) -> Result<Session> {
|
||||||
|
let scheme = if no_tls { "http" } else { "https" };
|
||||||
|
let base = format!("{scheme}://{ip}:{port}");
|
||||||
|
let http = reqwest::blocking::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(insecure && !no_tls)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let client = crypto::SrpClient::new(crypto::SRP_IDENTITY, password.as_bytes());
|
||||||
|
|
||||||
|
let init: Value = http
|
||||||
|
.post(format!("{base}/srp/init"))
|
||||||
|
.json(&json!({ "username": user, "A": STANDARD.encode(client.a_bytes()) }))
|
||||||
|
.send()
|
||||||
|
.context("srp/init request")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("srp/init rejected (name taken or house full?)")?
|
||||||
|
.json()?;
|
||||||
|
|
||||||
|
let user_id = init["user_id"].as_str().context("no user_id")?.to_string();
|
||||||
|
let b = STANDARD.decode(init["B"].as_str().context("no B")?)?;
|
||||||
|
let salt = STANDARD.decode(init["salt"].as_str().context("no salt")?)?;
|
||||||
|
let room_salt = STANDARD.decode(init["room_salt"].as_str().context("no room_salt")?)?;
|
||||||
|
|
||||||
|
let ch = client.process_challenge(&salt, &b)?;
|
||||||
|
|
||||||
|
let verify: Value = http
|
||||||
|
.post(format!("{base}/srp/verify"))
|
||||||
|
.json(&json!({ "user_id": user_id, "username": user, "M": STANDARD.encode(&ch.m) }))
|
||||||
|
.send()
|
||||||
|
.context("srp/verify request")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("srp/verify rejected — wrong room password?")?
|
||||||
|
.json()?;
|
||||||
|
|
||||||
|
let server_hamk = STANDARD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?;
|
||||||
|
anyhow::ensure!(server_hamk == ch.h_amk, "server identity check failed (H_AMK) — MITM?");
|
||||||
|
let ws_token = verify["ws_token"].as_str().context("no ws_token")?;
|
||||||
|
|
||||||
|
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
|
||||||
|
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
||||||
|
let ws_url =
|
||||||
|
format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
||||||
|
|
||||||
|
Ok(Session {
|
||||||
|
user_id,
|
||||||
|
username: user.to_string(),
|
||||||
|
room: Arc::new(fernet),
|
||||||
|
ws_url,
|
||||||
|
no_tls,
|
||||||
|
insecure,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(session: &Session) -> Result<Ws> {
|
||||||
|
if !session.no_tls && session.insecure {
|
||||||
|
anyhow::bail!(
|
||||||
|
"self-signed (insecure) wss is not yet wired in the TUI — \
|
||||||
|
use --no-tls or a trusted certificate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (ws, _) = tokio_tungstenite::connect_async(&session.ws_url)
|
||||||
|
.await
|
||||||
|
.context("websocket connect")?;
|
||||||
|
Ok(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_users(v: &Value) -> Vec<User> {
|
||||||
|
v.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|u| {
|
||||||
|
Some(User {
|
||||||
|
user_id: u["user_id"].as_str()?.to_string(),
|
||||||
|
username: u["username"].as_str().unwrap_or("?").to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one stored/broadcast message object into a ChatLine, or None to skip
|
||||||
|
/// (empty text, decrypt failure, or a file-transfer control frame).
|
||||||
|
fn decode_msg(room: &fernet::Fernet, m: &Value) -> Option<ChatLine> {
|
||||||
|
let ct = m["text"].as_str()?;
|
||||||
|
if ct.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (text, system) = match room.decrypt(ct) {
|
||||||
|
Ok(pt) => {
|
||||||
|
let t = String::from_utf8_lossy(&pt).to_string();
|
||||||
|
if t.starts_with("{\"_ft\":") {
|
||||||
|
return None; // file-transfer control frame — handled elsewhere (P5)
|
||||||
|
}
|
||||||
|
(t, false)
|
||||||
|
}
|
||||||
|
// Wrong room key / corrupt frame — surface, don't crash or hide silently.
|
||||||
|
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
|
||||||
|
};
|
||||||
|
let stamp = m["timestamp"].as_str().unwrap_or("");
|
||||||
|
let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() };
|
||||||
|
Some(ChatLine {
|
||||||
|
ts,
|
||||||
|
username: m["username"].as_str().unwrap_or("?").to_string(),
|
||||||
|
text,
|
||||||
|
system,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read websocket frames forever, forwarding decoded `Net` events to the UI.
|
||||||
|
pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin, room: Arc<fernet::Fernet>, tx: UnboundedSender<Net>) {
|
||||||
|
while let Some(frame) = read.next().await {
|
||||||
|
let txt = match frame {
|
||||||
|
Ok(WsMsg::Text(t)) => t,
|
||||||
|
Ok(WsMsg::Ping(_)) | Ok(WsMsg::Pong(_)) => continue,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
let v: Value = match serde_json::from_str(&txt) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let sent = match v["type"].as_str().unwrap_or("") {
|
||||||
|
"init" => {
|
||||||
|
let lines = v["messages"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|m| decode_msg(&room, m))
|
||||||
|
.collect();
|
||||||
|
tx.send(Net::Init { lines, users: parse_users(&v["users"]) })
|
||||||
|
}
|
||||||
|
"message" => match decode_msg(&room, &v["data"]) {
|
||||||
|
Some(l) => tx.send(Net::Message(l)),
|
||||||
|
None => Ok(()),
|
||||||
|
},
|
||||||
|
"roster" => tx.send(Net::Roster {
|
||||||
|
users: parse_users(&v["users"]),
|
||||||
|
capacity: v["capacity"].as_u64().unwrap_or(0) as usize,
|
||||||
|
}),
|
||||||
|
"user_joined" => tx.send(Net::Joined(v["username"].as_str().unwrap_or("?").to_string())),
|
||||||
|
"user_left" => tx.send(Net::Left(v["user_id"].as_str().unwrap_or("").to_string())),
|
||||||
|
_ => Ok(()),
|
||||||
|
};
|
||||||
|
if sent.is_err() {
|
||||||
|
return; // UI gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx.send(Net::Closed);
|
||||||
|
}
|
||||||
48
hh/src/theme.rs
Normal file
48
hh/src/theme.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
//! Loadable colour/layout themes ("vestments"). Default is the churchofmalware
|
||||||
|
//! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a
|
||||||
|
//! TOML file via `--theme <path>`.
|
||||||
|
|
||||||
|
use ratatui::style::Color;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub name: String,
|
||||||
|
pub border: Color,
|
||||||
|
pub title: Color,
|
||||||
|
pub accent: Color,
|
||||||
|
pub dim: Color,
|
||||||
|
pub me: Color,
|
||||||
|
pub other: Color,
|
||||||
|
pub system: Color,
|
||||||
|
pub input: Color,
|
||||||
|
pub roster_me: Color,
|
||||||
|
/// Width of the roster column.
|
||||||
|
pub roster_width: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Theme {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "crypt".into(),
|
||||||
|
border: Color::DarkGray,
|
||||||
|
title: Color::White,
|
||||||
|
accent: Color::White,
|
||||||
|
dim: Color::DarkGray,
|
||||||
|
me: Color::White,
|
||||||
|
other: Color::Gray,
|
||||||
|
system: Color::DarkGray,
|
||||||
|
input: Color::White,
|
||||||
|
roster_me: Color::White,
|
||||||
|
roster_width: 22,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn load(path: &str) -> anyhow::Result<Self> {
|
||||||
|
let s = std::fs::read_to_string(path)?;
|
||||||
|
Ok(toml::from_str(&s)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
hh/src/ui.rs
Normal file
121
hh/src/ui.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
//! ratatui rendering — top bar, chat, roster, input.
|
||||||
|
|
||||||
|
use crate::app::{App, ChatLine};
|
||||||
|
use crate::theme::Theme;
|
||||||
|
use ratatui::layout::{Constraint, Layout, Position};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, List, ListItem, Paragraph, Wrap};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
||||||
|
let rows = Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
draw_top(f, rows[0], app, theme);
|
||||||
|
|
||||||
|
let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)])
|
||||||
|
.split(rows[1]);
|
||||||
|
draw_chat(f, body[0], app, theme);
|
||||||
|
draw_roster(f, body[1], app, theme);
|
||||||
|
draw_input(f, rows[2], app, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||||
|
let cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
|
||||||
|
let status = if app.connected { "🔒 e2e" } else { "✖ closed" };
|
||||||
|
let bar = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" ⛧ hack-house ⛧ ",
|
||||||
|
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(format!("· {status} "), Style::default().fg(theme.dim)),
|
||||||
|
Span::styled(
|
||||||
|
format!("· house {}/{} ", app.users.len(), cap),
|
||||||
|
Style::default().fg(theme.title),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(Paragraph::new(bar), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
|
||||||
|
if l.system {
|
||||||
|
return Line::from(Span::styled(
|
||||||
|
format!(" ⛧ {}", l.text),
|
||||||
|
Style::default().fg(theme.system).add_modifier(Modifier::ITALIC),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let name_color = if l.username == app.me { theme.me } else { theme.other };
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
|
||||||
|
Span::styled(
|
||||||
|
l.username.clone(),
|
||||||
|
Style::default().fg(name_color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(": ", Style::default().fg(theme.dim)),
|
||||||
|
Span::styled(l.text.as_str(), Style::default().fg(theme.title)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||||
|
let visible = area.height.saturating_sub(2) as usize;
|
||||||
|
let start = app.lines.len().saturating_sub(visible);
|
||||||
|
let lines: Vec<Line> = app.lines[start..].iter().map(|l| fmt_line(l, app, theme)).collect();
|
||||||
|
let chat = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.border_style(Style::default().fg(theme.border))
|
||||||
|
.title(Span::styled(" chat ", Style::default().fg(theme.title))),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
f.render_widget(chat, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.map(|u| {
|
||||||
|
let me = u.username == app.me;
|
||||||
|
let mark = if me { "⛧" } else { "•" };
|
||||||
|
let color = if me { theme.roster_me } else { theme.other };
|
||||||
|
ListItem::new(Line::from(Span::styled(
|
||||||
|
format!(" {mark} {}", u.username),
|
||||||
|
Style::default().fg(color),
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let roster = List::new(items).block(
|
||||||
|
Block::bordered()
|
||||||
|
.border_style(Style::default().fg(theme.border))
|
||||||
|
.title(Span::styled(" coven ", Style::default().fg(theme.title))),
|
||||||
|
);
|
||||||
|
f.render_widget(roster, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||||
|
let input = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled("> ", Style::default().fg(theme.accent)),
|
||||||
|
Span::styled(app.input.as_str(), Style::default().fg(theme.input)),
|
||||||
|
]))
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.border_style(Style::default().fg(theme.border))
|
||||||
|
.title(Span::styled(
|
||||||
|
" message · enter send · esc quit ",
|
||||||
|
Style::default().fg(theme.dim),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
f.render_widget(input, area);
|
||||||
|
|
||||||
|
// Cursor after the "> " prompt + current input.
|
||||||
|
let cx = area.x + 3 + app.input.chars().count() as u16;
|
||||||
|
let cy = area.y + 1;
|
||||||
|
if cx < area.x + area.width.saturating_sub(1) {
|
||||||
|
f.set_cursor_position(Position::new(cx, cy));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
hh/themes/crypt.toml
Normal file
12
hh/themes/crypt.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# crypt — churchofmalware occult monochrome (default)
|
||||||
|
name = "crypt"
|
||||||
|
border = "darkgray"
|
||||||
|
title = "white"
|
||||||
|
accent = "white"
|
||||||
|
dim = "darkgray"
|
||||||
|
me = "white"
|
||||||
|
other = "gray"
|
||||||
|
system = "darkgray"
|
||||||
|
input = "white"
|
||||||
|
roster_me = "white"
|
||||||
|
roster_width = 22
|
||||||
12
hh/themes/neon.toml
Normal file
12
hh/themes/neon.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# neon — cyberpunk accents on black
|
||||||
|
name = "neon"
|
||||||
|
border = "#3a3a5a"
|
||||||
|
title = "#e0e0ff"
|
||||||
|
accent = "#ff2fd0"
|
||||||
|
dim = "#5a5a7a"
|
||||||
|
me = "#39ff14"
|
||||||
|
other = "#00e5ff"
|
||||||
|
system = "#7a5cff"
|
||||||
|
input = "#39ff14"
|
||||||
|
roster_me = "#ff2fd0"
|
||||||
|
roster_width = 24
|
||||||
Loading…
Reference in New Issue
Block a user