From ffb9c05de1c755dbddd8b67cca1d6023b213115f Mon Sep 17 00:00:00 2001 From: lancebord Date: Fri, 6 Mar 2026 18:09:52 -0500 Subject: initial commit --- .gitignore | 1 + Cargo.lock | 948 ++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 26 ++ LICENSE | 19 + src/client/event.rs | 50 +++ src/client/handler.rs | 250 +++++++++++++ src/client/mod.rs | 176 +++++++++ src/client/state.rs | 62 ++++ src/connection/mod.rs | 82 +++++ src/lib.rs | 3 + src/main.rs | 268 ++++++++++++++ src/proto/codec.rs | 98 +++++ src/proto/error.rs | 25 ++ src/proto/message.rs | 163 +++++++++ src/proto/mod.rs | 5 + src/proto/parser.rs | 170 +++++++++ src/proto/serializer.rs | 78 ++++ src/tui/app.rs | 130 +++++++ src/tui/mod.rs | 2 + src/tui/ui.rs | 252 +++++++++++++ 20 files changed, 2808 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 src/client/event.rs create mode 100644 src/client/handler.rs create mode 100644 src/client/mod.rs create mode 100644 src/client/state.rs create mode 100644 src/connection/mod.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/proto/codec.rs create mode 100644 src/proto/error.rs create mode 100644 src/proto/message.rs create mode 100644 src/proto/mod.rs create mode 100644 src/proto/parser.rs create mode 100644 src/proto/serializer.rs create mode 100644 src/tui/app.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/ui.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0e13054 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,948 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "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 = "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]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +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]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[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", + "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]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[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.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "speakez" +version = "0.1.0" +dependencies = [ + "bytes", + "crossterm", + "futures", + "ratatui", + "thiserror", + "tokio", + "tokio-test", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[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.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]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[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]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8f4f54d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "speakez" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "speakez" +path = "src/main.rs" + +[lib] +name = "irc_client" +path = "src/lib.rs" + +[dependencies] +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } +bytes = "1" +futures = "0.3" +thiserror = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +ratatui = "0.29" +crossterm = "0.28" + +[dev-dependencies] +tokio-test = "0.4" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ac4bbee --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 Lance Borden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/client/event.rs b/src/client/event.rs new file mode 100644 index 0000000..d34908d --- /dev/null +++ b/src/client/event.rs @@ -0,0 +1,50 @@ +/// Events produced by the IRC client and surfaced to your application. +/// Match on these in your main loop to drive UI, bot logic, etc. +#[derive(Debug, Clone)] +pub enum Event { + /// Successfully registered with the server (001 received) + Connected { server: String, nick: String }, + + /// A PRIVMSG or NOTICE in a channel or as a PM + Message { + from: String, + target: String, + text: String, + is_notice: bool, + }, + + /// We joined a channel + Joined { channel: String }, + + /// We or someone else left a channel + Parted { + channel: String, + nick: String, + reason: Option, + }, + + /// Someone quit the server + Quit { + nick: String, + reason: Option, + }, + + /// A nick change (could be ours) + NickChanged { old_nick: String, new_nick: String }, + + /// Channel topic was set or changed + Topic { channel: String, topic: String }, + + /// NAMES list entry (members of a channel) + Names { + channel: String, + members: Vec, + }, + + /// A raw message we didn't handle specifically + /// Useful for debugging or handling custom commands + Raw(crate::proto::message::IrcMessage), + + /// The connection was closed + Disconnected, +} diff --git a/src/client/handler.rs b/src/client/handler.rs new file mode 100644 index 0000000..91aa5ea --- /dev/null +++ b/src/client/handler.rs @@ -0,0 +1,250 @@ +use tracing::{debug, warn}; + +use crate::client::event::Event; +use crate::client::state::{ClientState, RegistrationState}; +use crate::connection::Sender; +use crate::proto::message::{Command, IrcMessage, Prefix}; +use crate::proto::serializer::serialize; + +/// Dispatch a single incoming `IrcMessage`, updating `state` and returning +/// zero or more `Event`s for the application to handle. +pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec { + let mut events = Vec::new(); + + match &msg.command { + // --- PING: must reply immediately or the server drops us --- + Command::Ping => { + let token = msg.params.first().cloned().unwrap_or_default(); + sender.send(IrcMessage::new(Command::Pong, vec![token])); + } + + // --- CAP: capability negotiation --- + Command::Cap => { + handle_cap(&msg, state, sender); + } + + // --- 001: welcome — registration complete --- + Command::Numeric(1) => { + let server = msg + .prefix + .as_ref() + .map(|p| match p { + Prefix::Server(s) => s.clone(), + Prefix::User { nick, .. } => nick.clone(), + }) + .unwrap_or_default(); + + // Server may have assigned us a different nick + if let Some(nick) = msg.params.first() { + state.nick = nick.clone(); + } + + state.reg = RegistrationState::Registered; + state.server_name = Some(server.clone()); + + events.push(Event::Connected { + server, + nick: state.nick.clone(), + }); + } + + // --- 353: NAMES reply (list of members in a channel) --- + Command::Numeric(353) => { + // params: [our_nick, ("=" / "*" / "@"), channel, ":member1 member2 ..."] + if let (Some(channel), Some(members_str)) = (msg.params.get(2), msg.params.get(3)) { + let members: Vec = members_str + .split_whitespace() + // Strip membership prefixes (@, +, etc.) + .map(|m| { + m.trim_start_matches(&['@', '+', '%', '~', '&'][..]) + .to_string() + }) + .collect(); + + let ch = state.channel_mut(channel); + ch.members.extend(members.clone()); + + events.push(Event::Names { + channel: channel.clone(), + members, + }); + } + } + + // --- 332: topic on join --- + Command::Numeric(332) => { + if let (Some(channel), Some(topic)) = (msg.params.get(1), msg.params.get(2)) { + state.channel_mut(channel).topic = Some(topic.clone()); + events.push(Event::Topic { + channel: channel.clone(), + topic: topic.clone(), + }); + } + } + + // --- JOIN --- + Command::Join => { + let nick = nick_from_prefix(&msg.prefix); + if let Some(channel) = msg.params.first() { + if nick == state.nick { + // We joined + state.channel_mut(channel); + events.push(Event::Joined { + channel: channel.clone(), + }); + } else { + // Someone else joined + state.channel_mut(channel).members.insert(nick); + } + } + } + + // --- PART --- + Command::Part => { + let nick = nick_from_prefix(&msg.prefix); + let channel = msg.params.first().cloned().unwrap_or_default(); + let reason = msg.params.get(1).cloned(); + + if nick == state.nick { + state.remove_channel(&channel); + } else { + state.channel_mut(&channel).members.remove(&nick); + } + + events.push(Event::Parted { + channel, + nick, + reason, + }); + } + + // --- QUIT --- + Command::Quit => { + let nick = nick_from_prefix(&msg.prefix); + let reason = msg.params.first().cloned(); + + // Remove them from all channels + for ch in state.channels.values_mut() { + ch.members.remove(&nick); + } + + events.push(Event::Quit { nick, reason }); + } + + // --- NICK --- + Command::Nick => { + let old_nick = nick_from_prefix(&msg.prefix); + let new_nick = msg.params.first().cloned().unwrap_or_default(); + + if old_nick == state.nick { + state.nick = new_nick.clone(); + } + + // Update in all channels + for ch in state.channels.values_mut() { + if ch.members.remove(&old_nick) { + ch.members.insert(new_nick.clone()); + } + } + + events.push(Event::NickChanged { old_nick, new_nick }); + } + + // --- PRIVMSG / NOTICE --- + Command::Privmsg | Command::Notice => { + let from = nick_from_prefix(&msg.prefix); + let target = msg.params.first().cloned().unwrap_or_default(); + let text = msg.params.get(1).cloned().unwrap_or_default(); + let is_notice = msg.command == Command::Notice; + + events.push(Event::Message { + from, + target, + text, + is_notice, + }); + } + + // --- TOPIC (live change) --- + Command::Topic => { + if let (Some(channel), Some(topic)) = (msg.params.first(), msg.params.get(1)) { + state.channel_mut(channel).topic = Some(topic.clone()); + events.push(Event::Topic { + channel: channel.clone(), + topic: topic.clone(), + }); + } + } + + // --- Everything else: surface as Raw --- + _ => { + debug!("Unhandled: {}", serialize(&msg)); + events.push(Event::Raw(msg)); + } + } + + events +} + +/// Handle CAP sub-commands during capability negotiation. +fn handle_cap(msg: &IrcMessage, state: &mut ClientState, sender: &Sender) { + // params: [target, subcommand, (optional "*",) params] + let subcommand = msg.params.get(1).map(|s| s.as_str()).unwrap_or(""); + + match subcommand { + "LS" => { + // Server listed its capabilities. + // For now, request a small set of common useful caps. + let want = ["multi-prefix", "away-notify", "server-time", "message-tags"]; + let server_caps = msg.params.last().map(|s| s.as_str()).unwrap_or(""); + + let to_request: Vec<&str> = want + .iter() + .copied() + .filter(|cap| server_caps.split_whitespace().any(|s| s == *cap)) + .collect(); + + if to_request.is_empty() { + sender.send(IrcMessage::new(Command::Cap, vec!["END".into()])); + state.reg = RegistrationState::WaitingForWelcome; + } else { + sender.send(IrcMessage::new( + Command::Cap, + vec!["REQ".into(), to_request.join(" ")], + )); + state.reg = RegistrationState::CapPending; + } + } + + "ACK" => { + // Server acknowledged our capability requests + if let Some(caps) = msg.params.last() { + for cap in caps.split_whitespace() { + state.caps.insert(cap.to_string()); + } + } + sender.send(IrcMessage::new(Command::Cap, vec!["END".into()])); + state.reg = RegistrationState::WaitingForWelcome; + } + + "NAK" => { + // Server rejected our request — just end negotiation + warn!("CAP NAK: {:?}", msg.params.last()); + sender.send(IrcMessage::new(Command::Cap, vec!["END".into()])); + state.reg = RegistrationState::WaitingForWelcome; + } + + other => { + debug!("Unhandled CAP subcommand: {}", other); + } + } +} + +/// Extract the nick from a message prefix, returning empty string if absent. +fn nick_from_prefix(prefix: &Option) -> String { + match prefix { + Some(Prefix::User { nick, .. }) => nick.clone(), + Some(Prefix::Server(s)) => s.clone(), + None => String::new(), + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..6d32017 --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,176 @@ +use tokio::sync::mpsc; +use tracing::info; + +use crate::client::event::Event; +use crate::client::handler::handle; +use crate::client::state::ClientState; +use crate::connection::{self, Sender}; +use crate::proto::message::{Command, IrcMessage}; + +pub mod event; +pub mod handler; +pub mod state; + +/// Configuration for the IRC client. +pub struct Config { + /// Server address, e.g. "irc.libera.chat:6667" + pub server: String, + /// Desired nick + pub nick: String, + /// IRC username (shown in /whois) + pub user: String, + /// Real name (shown in /whois) + pub realname: String, + /// Optional server password + pub password: Option, + /// Channels to auto-join after registration + pub autojoin: Vec, +} + +/// The main IRC client. +/// +/// Call `Client::connect` to establish a connection, then drive the event +/// loop with `client.next_event().await` in your application loop. +pub struct Client { + state: ClientState, + sender: Sender, + inbox: mpsc::UnboundedReceiver, + config: Config, +} + +impl Client { + /// Connect to the server and begin the registration handshake. + pub async fn connect(config: Config) -> Result { + let (sender, inbox) = connection::connect(&config.server).await?; + let state = ClientState::new(&config.nick); + + let client = Self { + state, + sender, + inbox, + config, + }; + client.register(); + Ok(client) + } + + /// Send a raw `IrcMessage` to the server. + pub fn send(&self, msg: IrcMessage) { + self.sender.send(msg); + } + + /// Send a PRIVMSG to a channel or user. + pub fn privmsg(&self, target: &str, text: &str) { + self.sender.send(IrcMessage::new( + Command::Privmsg, + vec![target.to_string(), text.to_string()], + )); + } + + /// Join a channel. + pub fn join(&self, channel: &str) { + self.sender + .send(IrcMessage::new(Command::Join, vec![channel.to_string()])); + } + + /// Part a channel. + pub fn part(&self, channel: &str, reason: Option<&str>) { + let mut params = vec![channel.to_string()]; + if let Some(r) = reason { + params.push(r.to_string()); + } + self.sender.send(IrcMessage::new(Command::Part, params)); + } + + /// Change nick. + pub fn nick(&self, new_nick: &str) { + self.sender + .send(IrcMessage::new(Command::Nick, vec![new_nick.to_string()])); + } + + /// Read-only view of current client state. + pub fn state(&self) -> &ClientState { + &self.state + } + + /// Wait for the next event from the server. + /// Returns `None` if the connection has closed. + pub async fn next_event(&mut self) -> Option { + loop { + let msg = self.inbox.recv().await?; + let events = handle(msg, &mut self.state, &self.sender); + + // Handle auto-join after registration + for event in &events { + if let Event::Connected { .. } = event { + for channel in &self.config.autojoin.clone() { + info!("Auto-joining {}", channel); + self.join(channel); + } + } + } + + // Return the first event; re-queue the rest + // (simple approach: process one at a time via recursive buffering) + if let Some(first) = events.into_iter().next() { + return Some(first); + } + // If no events were produced (e.g. a PING), loop and wait for next message + } + } + + /// Send the registration sequence to the server. + fn register(&self) { + // Optional server password + if let Some(pass) = &self.config.password { + self.sender + .send(IrcMessage::new(Command::Pass, vec![pass.clone()])); + } + + // Begin CAP negotiation first — lets us request IRCv3 caps + // before NICK/USER so the server doesn't rush past registration + self.sender.send(IrcMessage::new( + Command::Cap, + vec!["LS".into(), "302".into()], + )); + + self.sender.send(IrcMessage::new( + Command::Nick, + vec![self.config.nick.clone()], + )); + + self.sender.send(IrcMessage::new( + Command::User, + vec![ + self.config.user.clone(), + "0".into(), + "*".into(), + self.config.realname.clone(), + ], + )); + } +} + +impl Client { + /// Non-blocking version of `next_event`. + /// Returns `Some(event)` if one is immediately available, `None` otherwise. + /// Used by the TUI loop to drain events without blocking the render tick. + pub fn next_event_nowait(&mut self) -> Option { + loop { + let msg = self.inbox.try_recv().ok()?; + let mut events = handle(msg, &mut self.state, &self.sender); + + for event in &events { + if let Event::Connected { .. } = event { + for channel in &self.config.autojoin.clone() { + self.join(channel); + } + } + } + + if !events.is_empty() { + return Some(events.remove(0)); + } + } + } +} diff --git a/src/client/state.rs b/src/client/state.rs new file mode 100644 index 0000000..b987509 --- /dev/null +++ b/src/client/state.rs @@ -0,0 +1,62 @@ +use std::collections::{HashMap, HashSet}; + +/// The full state of a connected IRC client. +#[derive(Debug, Default)] +pub struct ClientState { + pub nick: String, + pub channels: HashMap, + pub caps: HashSet, + pub server_name: Option, + pub reg: RegistrationState, +} + +impl ClientState { + pub fn new(nick: impl Into) -> Self { + Self { + nick: nick.into(), + ..Default::default() + } + } + + pub fn channel(&self, name: &str) -> Option<&Channel> { + self.channels.get(&name.to_lowercase()) + } + + pub fn channel_mut(&mut self, name: &str) -> &mut Channel { + self.channels + .entry(name.to_lowercase()) + .or_insert_with(|| Channel::new(name)) + } + + pub fn remove_channel(&mut self, name: &str) { + self.channels.remove(&name.to_lowercase()); + } +} + +/// State of the registration handshake. +#[derive(Debug, Default, PartialEq, Eq)] +pub enum RegistrationState { + #[default] + CapNegotiation, + CapPending, + WaitingForWelcome, + Registered, +} + +/// A joined channel and its current state. +#[derive(Debug)] +pub struct Channel { + pub name: String, + pub members: HashSet, + pub topic: Option, +} + +impl Channel { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + members: HashSet::new(), + topic: None, + } + } +} diff --git a/src/connection/mod.rs b/src/connection/mod.rs new file mode 100644 index 0000000..bc837c0 --- /dev/null +++ b/src/connection/mod.rs @@ -0,0 +1,82 @@ +use futures::SinkExt; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio_util::codec::Framed; +use tracing::{debug, error, info}; + +use crate::proto::codec::IrcCodec; +use crate::proto::message::IrcMessage; + +/// A handle to send messages to the server. +/// Cheaply cloneable — pass it wherever you need to write. +#[derive(Clone)] +pub struct Sender { + tx: mpsc::UnboundedSender, +} + +impl Sender { + pub fn send(&self, msg: IrcMessage) { + // Only fails if the connection task has shut down + let _ = self.tx.send(msg); + } +} + +/// Establish a TCP connection and return: +/// - A `Sender` handle for writing messages +/// - An `mpsc::UnboundedReceiver` for reading incoming messages +/// +/// Two background tasks are spawned: +/// - A **writer task**: drains the sender channel and writes to the TCP stream +/// - A **reader task**: reads from the TCP stream and forwards to the inbox +/// +/// This split means the caller never has to hold a lock to send a message. +pub async fn connect( + addr: &str, +) -> Result<(Sender, mpsc::UnboundedReceiver), std::io::Error> { + info!("Connecting to {}", addr); + let stream = TcpStream::connect(addr).await?; + info!("TCP connected to {}", addr); + + let framed = Framed::new(stream, IrcCodec::new()); + let (mut sink, mut stream) = futures::StreamExt::split(framed); + + // Channel for outbound messages (caller → writer task) + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + // Channel for inbound messages (reader task → caller) + let (in_tx, in_rx) = mpsc::unbounded_channel::(); + + // Writer task: takes messages from out_rx and sends them to the server + tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + debug!("--> {}", crate::proto::serializer::serialize(&msg)); + if let Err(e) = sink.send(msg).await { + error!("Write error: {}", e); + break; + } + } + debug!("Writer task shut down"); + }); + + // Reader task: receives messages from the server and forwards to in_tx + tokio::spawn(async move { + use futures::StreamExt; + while let Some(result) = stream.next().await { + match result { + Ok(msg) => { + debug!("<-- {}", crate::proto::serializer::serialize(&msg)); + if in_tx.send(msg).is_err() { + break; // Receiver dropped, shut down + } + } + Err(e) => { + error!("Read error: {}", e); + break; + } + } + } + debug!("Reader task shut down"); + }); + + Ok((Sender { tx: out_tx }, in_rx)) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f625d00 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod connection; +pub mod proto; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b7d4365 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,268 @@ +use std::io; +use std::time::Duration; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; + +use irc_client::client::event::Event as IrcEvent; +use irc_client::client::{Client, Config}; +use irc_client::proto::message::{Command, IrcMessage}; +use tui::app::{AppState, CHANNEL}; +use tui::ui; +mod tui; + +const NICK: &str = ""; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // ── Terminal setup ──────────────────────────────────────────────────── + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + let result = run(&mut terminal).await; + + // ── Restore terminal ────────────────────────────────────────────────── + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture, + )?; + terminal.show_cursor()?; + + result +} + +async fn run( + terminal: &mut Terminal>, +) -> Result<(), Box> { + let mut app = AppState::new(NICK, CHANNEL); + + // ── Connect to IRC ──────────────────────────────────────────────────── + let config = Config { + server: "irc.libera.chat:6667".to_string(), + nick: NICK.to_string(), + user: "speakez".to_string(), + realname: "speakez".to_string(), + password: None, + autojoin: vec![CHANNEL.to_string()], + }; + + let mut client = Client::connect(config).await?; + + // ── Main event loop ─────────────────────────────────────────────────── + // We poll both IRC events and keyboard events with short timeouts so + // neither blocks the other. + loop { + // Draw + terminal.draw(|f| ui::draw(f, &app))?; + + // Poll crossterm for keyboard input (non-blocking, 20ms timeout) + if event::poll(Duration::from_millis(20))? { + if let Event::Key(key) = event::read()? { + match (key.modifiers, key.code) { + // Quit + (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + + // Send message / command + (_, KeyCode::Enter) => { + let text = app.take_input(); + if !text.is_empty() { + handle_input(&text, &mut app, &mut client); + } + } + + // Typing + (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => { + app.input_insert(c); + } + (_, KeyCode::Backspace) => app.input_backspace(), + (_, KeyCode::Left) => app.cursor_left(), + (_, KeyCode::Right) => app.cursor_right(), + (_, KeyCode::Home) => app.cursor = 0, + (_, KeyCode::End) => app.cursor = app.input.len(), + + // Scroll + (_, KeyCode::PageUp) => app.scroll = app.scroll.saturating_add(5), + (_, KeyCode::PageDown) => app.scroll = app.scroll.saturating_sub(5), + + _ => {} + } + } + } + + // Drain IRC events (non-blocking — try_recv drains without waiting) + // We use a small loop to process a burst without starving the UI + for _ in 0..16 { + match client.next_event_nowait() { + Some(irc_event) => handle_irc_event(irc_event, &mut app), + None => break, + } + } + } + + Ok(()) +} + +/// Handle a line entered in the input box. +fn handle_input(text: &str, app: &mut AppState, client: &mut Client) { + if let Some(cmd) = text.strip_prefix('/') { + // It's a command + let mut parts = cmd.splitn(2, ' '); + let verb = parts.next().unwrap_or("").to_uppercase(); + let args = parts.next().unwrap_or(""); + + match verb.as_str() { + "JOIN" => { + client.join(args.trim()); + } + "PART" => { + let channel = if args.is_empty() { + &app.channel + } else { + args.trim() + }; + client.part(channel, None); + } + "NICK" => { + client.nick(args.trim()); + } + "QUIT" => { + client.send(IrcMessage::new(Command::Quit, vec![args.to_string()])); + } + "ME" => { + // CTCP ACTION + let ctcp = format!("\x01ACTION {}\x01", args); + client.privmsg(&app.channel, &ctcp); + app.push_system(&format!("* {} {}", app.nick, args)); + } + "MSG" => { + let mut p = args.splitn(2, ' '); + if let (Some(target), Some(msg)) = (p.next(), p.next()) { + client.privmsg(target, msg); + app.push_system(&format!("→ {}: {}", target, msg)); + } + } + other => { + app.push_system(&format!("Unknown command: /{}", other)); + } + } + } else { + // Regular chat message to active channel + client.privmsg(&app.channel, text); + app.push_message(&app.nick.clone(), text, true); + } +} + +/// Apply an IRC event to the app state. +fn handle_irc_event(event: IrcEvent, app: &mut AppState) { + match event { + IrcEvent::Connected { server, nick } => { + app.nick = nick.clone(); + app.connected = true; + app.status = format!("connected to {}", server); + app.push_system(&format!("Connected to {} as {}", server, nick)); + } + + IrcEvent::Joined { channel } => { + app.push_system(&format!("You joined {}", channel)); + } + + IrcEvent::Message { + from, + target, + text, + is_notice: _, + } => { + // Only show messages for our active channel (or PMs to us) + let is_self = from == app.nick; + if !is_self { + // Don't re-echo our own messages (we already pushed them in handle_input) + if target == app.channel || target == app.nick { + app.push_message(&from, &text, false); + } + } + } + + IrcEvent::Parted { + channel, + nick, + reason, + } => { + app.members.retain(|m| { + let bare = m.trim_start_matches(&['@', '+', '%'][..]); + bare != nick + }); + app.push_system(&format!( + "{} left {}{}", + nick, + channel, + reason.map(|r| format!(" ({})", r)).unwrap_or_default() + )); + } + + IrcEvent::Quit { nick, reason } => { + app.members.retain(|m| { + let bare = m.trim_start_matches(&['@', '+', '%'][..]); + bare != nick + }); + app.push_system(&format!( + "{} quit{}", + nick, + reason.map(|r| format!(" ({})", r)).unwrap_or_default() + )); + } + + IrcEvent::NickChanged { old_nick, new_nick } => { + for m in &mut app.members { + let bare = m.trim_start_matches(&['@', '+', '%'][..]).to_string(); + if bare == old_nick { + let sigil: String = m.chars().take_while(|c| "@+%~&".contains(*c)).collect(); + *m = format!("{}{}", sigil, new_nick); + break; + } + } + if old_nick == app.nick { + app.nick = new_nick.clone(); + } + app.push_system(&format!("{} is now {}", old_nick, new_nick)); + } + + IrcEvent::Topic { channel, topic } => { + app.status = format!("{}: {}", channel, topic); + app.push_system(&format!("Topic: {}", topic)); + } + + IrcEvent::Names { + channel: _, + members, + } => { + for m in members { + if !app + .members + .iter() + .any(|existing| existing.trim_start_matches(&['@', '+', '%'][..]) == m) + { + app.members.push(m); + } + } + app.sort_members(); + } + + IrcEvent::Disconnected => { + app.connected = false; + app.status = "disconnected".to_string(); + app.push_system("--- Disconnected ---"); + } + + IrcEvent::Raw(_) => {} + } +} diff --git a/src/proto/codec.rs b/src/proto/codec.rs new file mode 100644 index 0000000..d8f9b10 --- /dev/null +++ b/src/proto/codec.rs @@ -0,0 +1,98 @@ +use bytes::{BufMut, BytesMut}; +use tokio_util::codec::{Decoder, Encoder}; + +use crate::proto::error::CodecError; +use crate::proto::message::IrcMessage; +use crate::proto::parser::parse; +use crate::proto::serializer::serialize; + +const MAX_LINE_LENGTH: usize = 512; + +pub struct IrcCodec { + max_line_length: usize, +} + +impl IrcCodec { + pub fn new() -> Self { + Self { + max_line_length: MAX_LINE_LENGTH, + } + } + + pub fn with_max_length(max_line_length: usize) -> Self { + Self { max_line_length } + } +} + +impl Default for IrcCodec { + fn default() -> Self { + Self::new() + } +} + +impl Decoder for IrcCodec { + type Item = IrcMessage; + type Error = CodecError; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + loop { + let newline_pos = src.iter().position(|&b| b == b'\n'); + + match newline_pos { + None => { + if src.len() > self.max_line_length { + return Err(CodecError::Parse( + crate::proto::error::ParseError::MessageTooLong, + )); + } + return Ok(None); + } + Some(pos) => { + let line_bytes = src.split_to(pos + 1); + + let line = &line_bytes[..line_bytes.len() - 1]; // strip \n + let line = if line.last() == Some(&b'\r') { + &line[..line.len() - 1] // strip \r + } else { + line + }; + + // Skip empty lines silently + if line.is_empty() { + continue; + } + + let line_str = std::str::from_utf8(line).map_err(|_| { + CodecError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "IRC message is not valid UTF-8", + )) + })?; + + let msg = parse(line_str)?; + return Ok(Some(msg)); + } + } + } + } +} + +impl Encoder for IrcCodec { + type Error = CodecError; + + fn encode(&mut self, msg: IrcMessage, dst: &mut BytesMut) -> Result<(), Self::Error> { + let line = serialize(&msg); + + // +2 for \r\n + if line.len() + 2 > self.max_line_length { + return Err(CodecError::Parse( + crate::proto::error::ParseError::MessageTooLong, + )); + } + + dst.reserve(line.len() + 2); + dst.put_slice(line.as_bytes()); + dst.put_slice(b"\r\n"); + Ok(()) + } +} diff --git a/src/proto/error.rs b/src/proto/error.rs new file mode 100644 index 0000000..8a901ce --- /dev/null +++ b/src/proto/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +#[derive(Debug, Error, PartialEq)] +pub enum ParseError { + #[error("message is empty")] + EmptyMessage, + + #[error("missing command")] + MissingCommand, + + #[error("invalid tag format: {0}")] + InvalidTag(String), + + #[error("line exceeds max message length")] + MessageTooLong, +} + +#[derive(Debug, Error)] +pub enum CodecError { + #[error("parse error: {0}")] + Parse(#[from] ParseError), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} diff --git a/src/proto/message.rs b/src/proto/message.rs new file mode 100644 index 0000000..dbe4a69 --- /dev/null +++ b/src/proto/message.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub struct IrcMessage { + pub tags: HashMap>, + pub prefix: Option, + pub command: Command, + pub params: Vec, +} + +impl IrcMessage { + pub fn trailing(&self) -> Option<&str> { + self.params.last().map(|s| s.as_str()) + } + + pub fn new(command: Command, params: Vec) -> Self { + Self { + tags: HashMap::new(), + prefix: None, + command, + params, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Prefix { + Server(String), + User { + nick: String, + user: Option, + host: Option, + }, +} + +impl fmt::Display for Prefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Prefix::Server(s) => write!(f, "{}", s), + Prefix::User { nick, user, host } => { + write!(f, "{}", nick)?; + if let Some(u) = user { + write!(f, "!{}", u)?; + } + if let Some(h) = host { + write!(f, "@{}", h)?; + } + Ok(()) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Command { + // connection + Cap, + Nick, + User, + Pass, + Quit, + Ping, + Pong, + + // channel operations + Join, + Part, + Kick, + Topic, + Names, + List, + Invite, + + // messaging + Privmsg, + Notice, + + // mode & status + Mode, + Who, + Whois, + Whowas, + + // Server + Oper, + Kill, + Rehash, + + // Numeric (001-999) + Numeric(u16), + + Other(String), +} + +impl Command { + pub fn from_str(s: &str) -> Self { + if s.len() == 3 && s.chars().all(|c| c.is_ascii_digit()) { + if let Ok(n) = s.parse::() { + return Command::Numeric(n); + } + } + + match s.to_ascii_uppercase().as_str() { + "CAP" => Command::Cap, + "NICK" => Command::Nick, + "USER" => Command::User, + "PASS" => Command::Pass, + "QUIT" => Command::Quit, + "PING" => Command::Ping, + "PONG" => Command::Pong, + "JOIN" => Command::Join, + "PART" => Command::Part, + "KICK" => Command::Kick, + "TOPIC" => Command::Topic, + "NAMES" => Command::Names, + "LIST" => Command::List, + "INVITE" => Command::Invite, + "PRIVMSG" => Command::Privmsg, + "NOTICE" => Command::Notice, + "MODE" => Command::Mode, + "WHO" => Command::Who, + "WHOIS" => Command::Whois, + "WHOWAS" => Command::Whowas, + "OPER" => Command::Oper, + "KILL" => Command::Kill, + "REHASH" => Command::Rehash, + other => Command::Other(other.to_string()), + } + } +} + +impl fmt::Display for Command { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Command::Cap => write!(f, "CAP"), + Command::Nick => write!(f, "NICK"), + Command::User => write!(f, "USER"), + Command::Pass => write!(f, "PASS"), + Command::Quit => write!(f, "QUIT"), + Command::Ping => write!(f, "PING"), + Command::Pong => write!(f, "PONG"), + Command::Join => write!(f, "JOIN"), + Command::Part => write!(f, "PART"), + Command::Kick => write!(f, "KICK"), + Command::Topic => write!(f, "TOPIC"), + Command::Names => write!(f, "NAMES"), + Command::List => write!(f, "LIST"), + Command::Invite => write!(f, "INVITE"), + Command::Privmsg => write!(f, "PRIVMSG"), + Command::Notice => write!(f, "NOTICE"), + Command::Mode => write!(f, "MODE"), + Command::Who => write!(f, "WHO"), + Command::Whois => write!(f, "WHOIS"), + Command::Whowas => write!(f, "WHOWAS"), + Command::Oper => write!(f, "OPER"), + Command::Kill => write!(f, "KILL"), + Command::Rehash => write!(f, "REHASH"), + Command::Numeric(n) => write!(f, "{:03}", n), + Command::Other(s) => write!(f, "{}", s), + } + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs new file mode 100644 index 0000000..e82784c --- /dev/null +++ b/src/proto/mod.rs @@ -0,0 +1,5 @@ +pub mod codec; +pub mod error; +pub mod message; +pub mod parser; +pub mod serializer; diff --git a/src/proto/parser.rs b/src/proto/parser.rs new file mode 100644 index 0000000..42f516d --- /dev/null +++ b/src/proto/parser.rs @@ -0,0 +1,170 @@ +use crate::proto::error::ParseError; +use crate::proto::message::{Command, IrcMessage, Prefix}; +use std::collections::HashMap; + +pub fn parse(line: &str) -> Result { + if line.is_empty() { + return Err(ParseError::EmptyMessage); + } + + let mut rest = line; + + // parse tags + let tags = if rest.starts_with('@') { + let (tag_str, remaining) = rest[1..] + .split_once(' ') + .ok_or(ParseError::MissingCommand)?; + rest = remaining; + parse_tags(tag_str)? + } else { + HashMap::new() + }; + + // parse prefix + let prefix = if rest.starts_with(':') { + let (prefix_str, remaining) = rest[1..] + .split_once(' ') + .ok_or(ParseError::MissingCommand)?; + rest = remaining; + Some(parse_prefix(prefix_str)) + } else { + None + }; + + // parse command + let (command_str, rest) = match rest.split_once(' ') { + Some((cmd, params)) => (cmd, params), + None => (rest, ""), + }; + + if command_str.is_empty() { + return Err(ParseError::MissingCommand); + } + + let command = Command::from_str(command_str); + + // parse params + let params = parse_params(rest); + + Ok(IrcMessage { + tags, + prefix, + command, + params, + }) +} + +fn parse_tags(tag_str: &str) -> Result>, ParseError> { + let mut tags = HashMap::new(); + + for tag in tag_str.split(';') { + if tag.is_empty() { + continue; + } + match tag.split_once('=') { + Some((key, value)) => { + tags.insert(key.to_string(), Some(unescape_tag_value(value))); + } + None => { + // boolean tags + tags.insert(tag.to_string(), None); + } + } + } + + Ok(tags) +} + +fn unescape_tag_value(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some(':') => result.push(';'), + Some('s') => result.push(' '), + Some('\\') => result.push('\\'), + Some('r') => result.push('\r'), + Some('n') => result.push('\n'), + Some(c) => { + result.push('\\'); + result.push(c); + } + None => result.push('\\'), + } + } else { + result.push(ch); + } + } + + result +} + +fn parse_prefix(prefix: &str) -> Prefix { + if prefix.contains('!') || prefix.contains('@') { + let (nick, rest) = prefix + .split_once('!') + .map(|(n, r)| (n, Some(r))) + .unwrap_or((prefix, None)); + + let (user, host) = match rest { + Some(r) => r + .split_once('@') + .map(|(u, h)| (Some(u.to_string()), Some(h.to_string()))) + .unwrap_or((Some(r.to_string()), None)), + None => { + // Could be nick@host with no user + if let Some((n2, h)) = nick.split_once('@') { + return Prefix::User { + nick: n2.to_string(), + user: None, + host: Some(h.to_string()), + }; + } + (None, None) + } + }; + + Prefix::User { + nick: nick.to_string(), + user, + host, + } + } else { + // Heuristic: if it contains a dot, it's likely a server name + // (nick-only prefixes are also possible but rare without user/host) + Prefix::Server(prefix.to_string()) + } +} + +fn parse_params(params_str: &str) -> Vec { + let mut params = Vec::new(); + let mut rest = params_str; + + loop { + rest = rest.trim_start_matches(' '); + + if rest.is_empty() { + break; + } + + if rest.starts_with(':') { + params.push(rest[1..].to_string()); + break; + } + + match rest.split_once(' ') { + Some((param, remaining)) => { + params.push(param.to_string()); + rest = remaining; + } + None => { + params.push(rest.to_string()); + break; + } + } + } + + params +} diff --git a/src/proto/serializer.rs b/src/proto/serializer.rs new file mode 100644 index 0000000..603cc0b --- /dev/null +++ b/src/proto/serializer.rs @@ -0,0 +1,78 @@ +use crate::proto::message::{IrcMessage, Prefix}; +use std::fmt::Write; + +pub fn serialize(msg: &IrcMessage) -> String { + let mut out = String::with_capacity(512); + + // tags + if !msg.tags.is_empty() { + out.push('@'); + let mut first = true; + for (key, value) in &msg.tags { + if !first { + out.push(';'); + } + first = false; + out.push_str(key); + if let Some(v) = value { + out.push('='); + escape_tag_value(&mut out, v); + } + } + out.push(' '); + } + + // prefix + if let Some(prefix) = &msg.prefix { + out.push(':'); + match prefix { + Prefix::Server(s) => out.push_str(s), + Prefix::User { nick, user, host } => { + out.push_str(nick); + if let Some(u) = user { + out.push('!'); + out.push_str(u); + } + if let Some(h) = host { + out.push('@'); + out.push_str(h); + } + } + } + out.push(' '); + } + + // command + let _ = write!(out, "{}", msg.command); + + // params + let last_idx = msg.params.len().saturating_sub(1); + for (i, param) in msg.params.iter().enumerate() { + out.push(' '); + // The last param must be trailing if it contains spaces or starts with ':' + let needs_trailing = i == last_idx + && (param.contains(' ') + || param.starts_with(':') + || param.is_empty() + || msg.params.len() > 1); + if needs_trailing { + out.push(':'); + } + out.push_str(param); + } + + out +} + +fn escape_tag_value(out: &mut String, value: &str) { + for ch in value.chars() { + match ch { + ';' => out.push_str(r"\:"), + ' ' => out.push_str(r"\s"), + '\\' => out.push_str(r"\\"), + '\r' => out.push_str(r"\r"), + '\n' => out.push_str(r"\n"), + c => out.push(c), + } + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..1e7d021 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,130 @@ +/// Compile-time channel to join on startup +pub const CHANNEL: &str = "#speakez"; + +/// A single chat message in the log +#[derive(Clone)] +pub struct ChatLine { + pub nick: String, + pub text: String, + /// true = server/system message (JOIN, PART, topic, etc.) + pub is_system: bool, + /// true = NOTICE + pub is_notice: bool, + /// true = this is our own message + pub is_self: bool, +} + +/// All mutable state the TUI needs to render and respond to input +pub struct AppState { + /// Our nick + pub nick: String, + /// The active channel name + pub channel: String, + /// Chat log for the active channel + pub messages: Vec, + /// Member list for the active channel + pub members: Vec, + /// Current contents of the input box + pub input: String, + /// Cursor position within `input` (byte index) + pub cursor: usize, + /// Scroll offset from the bottom (0 = pinned to latest) + pub scroll: usize, + /// Status line text (connection state, errors, etc.) + pub status: String, + /// Whether we've fully registered + pub connected: bool, +} + +impl AppState { + pub fn new(nick: impl Into, channel: impl Into) -> Self { + Self { + nick: nick.into(), + channel: channel.into(), + messages: Vec::new(), + members: Vec::new(), + input: String::new(), + cursor: 0, + scroll: 0, + status: "Connecting...".into(), + connected: false, + } + } + + /// Push a chat message + pub fn push_message(&mut self, nick: &str, text: &str, is_self: bool) { + self.messages.push(ChatLine { + nick: nick.to_string(), + text: text.to_string(), + is_system: false, + is_notice: false, + is_self, + }); + } + + /// Push a system/event line (joins, parts, topic changes) + pub fn push_system(&mut self, text: &str) { + self.messages.push(ChatLine { + nick: String::new(), + text: text.to_string(), + is_system: true, + is_notice: false, + is_self: false, + }); + } + + /// Insert a character at the cursor + pub fn input_insert(&mut self, ch: char) { + self.input.insert(self.cursor, ch); + self.cursor += ch.len_utf8(); + } + + /// Delete the character before the cursor + pub fn input_backspace(&mut self) { + if self.cursor == 0 { + return; + } + // Find the start of the previous character + let prev = self.input[..self.cursor] + .char_indices() + .last() + .map(|(i, _)| i) + .unwrap_or(0); + self.input.remove(prev); + self.cursor = prev; + } + + /// Move cursor left one character + pub fn cursor_left(&mut self) { + self.cursor = self.input[..self.cursor] + .char_indices() + .last() + .map(|(i, _)| i) + .unwrap_or(0); + } + + /// Move cursor right one character + pub fn cursor_right(&mut self) { + if self.cursor < self.input.len() { + let ch = self.input[self.cursor..].chars().next().unwrap(); + self.cursor += ch.len_utf8(); + } + } + + /// Take the current input, clear the box, return the text + pub fn take_input(&mut self) -> String { + self.cursor = 0; + std::mem::take(&mut self.input) + } + + /// Sort and deduplicate the member list + pub fn sort_members(&mut self) { + self.members.sort_by(|a, b| { + // Strip sigils for sorting (@, +, %) + let a = a.trim_start_matches(&['@', '+', '%', '~', '&'][..]); + let b = b.trim_start_matches(&['@', '+', '%', '~', '&'][..]); + a.to_lowercase().cmp(&b.to_lowercase()) + }); + self.members.dedup(); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..3e2facd --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,2 @@ +pub mod app; +pub mod ui; diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..a38834a --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,252 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap}, +}; + +use super::app::AppState; + +// ── Colour palette ──────────────────────────────────────────────────────────── +// Dark terminal aesthetic: near-black background, cool grey chrome, +// amber accent for our own nick, cyan for others, muted green for system. + +const BG: Color = Color::Rgb(16, 16, 16); +const ORANGE: Color = Color::Rgb(251, 84, 43); // border / panel bg +const FG: Color = Color::Rgb(204, 204, 204); // main foreground +const GRAY: Color = Color::Rgb(74, 74, 74); // other nicks +const LIGHT_ORANGE: Color = Color::Rgb(255, 122, 89); // system messages + +pub fn draw(f: &mut Frame, state: &AppState) { + let area = f.area(); + + // Fill background + f.render_widget(Block::default().style(Style::default().bg(BG)), area); + + // ── Outer layout: title bar + body + status bar ─────────────────────── + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title bar + Constraint::Min(0), // body + Constraint::Length(1), // status bar + ]) + .split(area); + + draw_titlebar(f, outer[0], state); + draw_body(f, outer[1], state); + draw_statusbar(f, outer[2], state); +} + +fn draw_titlebar(f: &mut Frame, area: Rect, state: &AppState) { + let title = Line::from(vec![ + Span::styled( + "  ", + Style::default().fg(ORANGE).add_modifier(Modifier::BOLD), + ), + Span::styled( + "speakez", + Style::default().fg(FG).add_modifier(Modifier::BOLD), + ), + Span::styled(" │ ", Style::default().fg(FG)), + Span::styled( + &state.channel, + Style::default().fg(FG).add_modifier(Modifier::BOLD), + ), + Span::styled(" │ ", Style::default().fg(FG)), + Span::styled(&state.nick, Style::default().fg(ORANGE)), + ]); + + f.render_widget(Paragraph::new(title).style(Style::default()), area); +} + +fn draw_body(f: &mut Frame, area: Rect, state: &AppState) { + // Body: [chat (fill)] | [members (18)] + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), // centre: chat log + input + Constraint::Length(18), // right: member list + ]) + .split(area); + + draw_center(f, cols[0], state); + draw_members_panel(f, cols[1], state); +} + +fn draw_center(f: &mut Frame, area: Rect, state: &AppState) { + // Centre column: chat log on top, input box on bottom + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), // chat log + Constraint::Length(3), // input box + ]) + .split(area); + + draw_chat_log(f, rows[0], state); + draw_input(f, rows[1], state); +} + +fn draw_chat_log(f: &mut Frame, area: Rect, state: &AppState) { + let inner_height = area.height.saturating_sub(2) as usize; // subtract borders + + // Build all rendered lines first so we can scroll from the bottom + let lines: Vec = state + .messages + .iter() + .map(|msg| render_chat_line(msg)) + .collect(); + + // Apply scroll offset + // scroll=0 means pinned to bottom (newest). scroll=N means N lines back from bottom. + let total = lines.len(); + let max_scroll = total.saturating_sub(inner_height); + let scroll = state.scroll.min(max_scroll); + let visible_start = total.saturating_sub(inner_height + scroll); + let visible: Vec = lines + .into_iter() + .skip(visible_start) + .take(inner_height) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(ORANGE)) + .style(Style::default().bg(BG)); + + f.render_widget( + Paragraph::new(Text::from(visible)) + .block(block) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn render_chat_line(msg: &super::app::ChatLine) -> Line<'static> { + if msg.is_system { + return Line::from(Span::styled( + format!(" ∙ {}", msg.text), + Style::default() + .fg(LIGHT_ORANGE) + .add_modifier(Modifier::DIM), + )); + } + + let nick_style = if msg.is_self { + Style::default().fg(ORANGE).add_modifier(Modifier::BOLD) + } else if msg.is_notice { + Style::default().fg(ORANGE) + } else { + Style::default().fg(GRAY) + }; + + let nick = format!("{}", msg.nick); + + Line::from(vec![ + Span::styled(nick, nick_style), + Span::styled(" ", Style::default()), + Span::styled(msg.text.clone(), Style::default().fg(FG)), + ]) +} + +fn draw_input(f: &mut Frame, area: Rect, state: &AppState) { + // Show a blinking cursor indicator at the cursor position + let before = &state.input[..state.cursor]; + let after = &state.input[state.cursor..]; + + let cursor_char = if after.is_empty() { + " " + } else { + &after[..after.chars().next().map(|c| c.len_utf8()).unwrap_or(1)] + }; + let after_cursor = if after.is_empty() { + "" + } else { + &after[cursor_char.len()..] + }; + + let line = Line::from(vec![ + Span::styled(before.to_string(), Style::default().fg(FG)), + Span::styled( + cursor_char.to_string(), + Style::default().bg(FG).fg(BG).add_modifier(Modifier::BOLD), + ), + Span::styled(after_cursor.to_string(), Style::default().fg(FG)), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(ORANGE)) + .title(Span::styled(" send ", Style::default().fg(FG))); + + f.render_widget(Paragraph::new(line).block(block), area); +} + +fn draw_members_panel(f: &mut Frame, area: Rect, state: &AppState) { + let items: Vec = state + .members + .iter() + .map(|nick| { + // Highlight ops (@) differently + let (sigil, rest) = if nick.starts_with('@') { + ("@", &nick[1..]) + } else if nick.starts_with('+') { + ("+", &nick[1..]) + } else { + ("", nick.as_str()) + }; + + let sigil_style = if sigil == "@" { + Style::default().fg(ORANGE) + } else { + Style::default().fg(FG) + }; + + ListItem::new(Line::from(vec![ + Span::styled(sigil.to_string(), sigil_style), + Span::styled(rest.to_string(), Style::default().fg(FG)), + ])) + }) + .collect(); + + let title = format!(" users ({}) ", state.members.len()); + let block = panel_block(&title); + + f.render_widget(List::new(items).block(block), area); +} + +fn draw_statusbar(f: &mut Frame, area: Rect, state: &AppState) { + let (status_text, status_style) = if state.connected { + ("● connected", Style::default().fg(LIGHT_ORANGE)) + } else { + ("○ connecting…", Style::default().fg(GRAY)) + }; + + let line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(status_text, status_style), + Span::styled(" │ ", Style::default().fg(FG)), + Span::styled(&state.status, Style::default().fg(FG)), + Span::styled(" │ ", Style::default().fg(FG)), + Span::styled("PgUp/Dn scroll Ctrl-C quit", Style::default().fg(FG)), + ]); + + f.render_widget(Paragraph::new(line).style(Style::default()), area); +} + +/// Consistent panel block style +fn panel_block(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(ORANGE)) + .title(Span::styled( + format!(" {} ", title), + Style::default().fg(FG), + )) + .style(Style::default().bg(BG)) +} -- cgit v1.2.3-59-g8ed1b