Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Move source files to root directory
Erik Kundt committed 2 years ago
commit 0ba4b81347a2ae7cf072bfd4493dfecc21571335
parent a102102d3edcf0397d6cb9b934078f7575afc0e1
58 files changed +6549 -4092
added .gitignore
@@ -0,0 +1 @@
+
/target
added .gitsigners
@@ -0,0 +1 @@
+
erikli ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBrJyJTwj/xG7F7qY0HDFXbb8A+xNNH8eILQ8hlvKW7/
added Cargo.lock
@@ -0,0 +1,2432 @@
+
# This file is automatically @generated by Cargo.
+
# It is not intended for manual editing.
+
version = 3
+

+
[[package]]
+
name = "adler"
+
version = "1.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+

+
[[package]]
+
name = "aes"
+
version = "0.8.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2"
+
dependencies = [
+
 "cfg-if",
+
 "cipher",
+
 "cpufeatures",
+
]
+

+
[[package]]
+
name = "aho-corasick"
+
version = "1.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
+
dependencies = [
+
 "memchr",
+
]
+

+
[[package]]
+
name = "amplify"
+
version = "4.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4707ab08f19a25ba492cbf61713591b7f022b54ee188f35457e6de22f367df4a"
+
dependencies = [
+
 "amplify_derive",
+
 "amplify_num",
+
 "ascii",
+
 "wasm-bindgen",
+
]
+

+
[[package]]
+
name = "amplify_derive"
+
version = "3.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c87df0f28e6eb1f2d355f29ba6793fa9ca643967528609608d5cbd70bd68f9d1"
+
dependencies = [
+
 "amplify_syn",
+
 "proc-macro2",
+
 "quote",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "amplify_num"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ddce3bc63e807ea02065e8d8b702695f3d302ae4158baddff8b0ce5c73947251"
+

+
[[package]]
+
name = "amplify_syn"
+
version = "2.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "android-tzdata"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+

+
[[package]]
+
name = "android_system_properties"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
+
name = "anstream"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163"
+
dependencies = [
+
 "anstyle",
+
 "anstyle-parse",
+
 "anstyle-query",
+
 "anstyle-wincon",
+
 "colorchoice",
+
 "is-terminal",
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd"
+

+
[[package]]
+
name = "anstyle-parse"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333"
+
dependencies = [
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle-query"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
+
dependencies = [
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "anstyle-wincon"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188"
+
dependencies = [
+
 "anstyle",
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "anyhow"
+
version = "1.0.72"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854"
+

+
[[package]]
+
name = "ascii"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+

+
[[package]]
+
name = "autocfg"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+

+
[[package]]
+
name = "base-x"
+
version = "0.2.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
+

+
[[package]]
+
name = "base16ct"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
+

+
[[package]]
+
name = "base32"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
+

+
[[package]]
+
name = "base64"
+
version = "0.13.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+

+
[[package]]
+
name = "base64"
+
version = "0.21.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d"
+

+
[[package]]
+
name = "base64ct"
+
version = "1.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
+

+
[[package]]
+
name = "bcrypt-pbkdf"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3806a8db60cf56efee531616a34a6aaa9a114d6da2add861b0fa4a188881b2c7"
+
dependencies = [
+
 "blowfish",
+
 "pbkdf2",
+
 "sha2 0.10.7",
+
]
+

+
[[package]]
+
name = "bitflags"
+
version = "1.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+

+
[[package]]
+
name = "bitflags"
+
version = "2.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42"
+

+
[[package]]
+
name = "block-buffer"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
+
dependencies = [
+
 "generic-array",
+
]
+

+
[[package]]
+
name = "block-buffer"
+
version = "0.10.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+
dependencies = [
+
 "generic-array",
+
]
+

+
[[package]]
+
name = "blowfish"
+
version = "0.9.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+
dependencies = [
+
 "byteorder",
+
 "cipher",
+
]
+

+
[[package]]
+
name = "bumpalo"
+
version = "3.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
+

+
[[package]]
+
name = "byteorder"
+
version = "1.4.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
+

+
[[package]]
+
name = "cassowary"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+

+
[[package]]
+
name = "cc"
+
version = "1.0.81"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c6b2562119bf28c3439f7f02db99faf0aa1a8cdfe5772a2ee155d32227239f0"
+
dependencies = [
+
 "jobserver",
+
 "libc",
+
]
+

+
[[package]]
+
name = "cfg-if"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+

+
[[package]]
+
name = "chrono"
+
version = "0.4.26"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5"
+
dependencies = [
+
 "android-tzdata",
+
 "iana-time-zone",
+
 "js-sys",
+
 "num-traits",
+
 "time",
+
 "wasm-bindgen",
+
 "winapi",
+
]
+

+
[[package]]
+
name = "cipher"
+
version = "0.4.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+
dependencies = [
+
 "crypto-common",
+
 "inout",
+
]
+

+
[[package]]
+
name = "colorchoice"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+

+
[[package]]
+
name = "colored"
+
version = "1.9.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5a5f741c91823341bebf717d4c71bda820630ce065443b58bd1b7451af008355"
+
dependencies = [
+
 "is-terminal",
+
 "lazy_static",
+
 "winapi",
+
]
+

+
[[package]]
+
name = "const-oid"
+
version = "0.9.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "795bc6e66a8e340f075fcf6227e417a2dc976b92b91f3cdc778bb858778b6747"
+

+
[[package]]
+
name = "core-foundation-sys"
+
version = "0.8.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
+

+
[[package]]
+
name = "cpufeatures"
+
version = "0.2.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
+
name = "crc32fast"
+
version = "1.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
+
dependencies = [
+
 "cfg-if",
+
]
+

+
[[package]]
+
name = "crossbeam-channel"
+
version = "0.5.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
+
dependencies = [
+
 "cfg-if",
+
 "crossbeam-utils",
+
]
+

+
[[package]]
+
name = "crossbeam-utils"
+
version = "0.8.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
+
dependencies = [
+
 "cfg-if",
+
]
+

+
[[package]]
+
name = "crypto-bigint"
+
version = "0.4.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef"
+
dependencies = [
+
 "generic-array",
+
 "rand_core 0.6.4",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "crypto-common"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+
dependencies = [
+
 "generic-array",
+
 "typenum",
+
]
+

+
[[package]]
+
name = "ct-codecs"
+
version = "1.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f3b7eb4404b8195a9abb6356f4ac07d8ba267045c8d6d220ac4dc992e6cc75df"
+

+
[[package]]
+
name = "ctr"
+
version = "0.9.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+
dependencies = [
+
 "cipher",
+
]
+

+
[[package]]
+
name = "curve25519-dalek"
+
version = "3.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61"
+
dependencies = [
+
 "byteorder",
+
 "digest 0.9.0",
+
 "rand_core 0.5.1",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "cypheraddr"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "41a05c461a9b86ba80542a5204924fd3cae3f47be011e00b1bbef9d71d95b3bb"
+
dependencies = [
+
 "amplify",
+
 "base32",
+
 "cyphergraphy",
+
 "sha3",
+
]
+

+
[[package]]
+
name = "cyphergraphy"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c8e45921460ef188da680742fd641f9b2a85d0de6bce12ce26e64c0f4913b41"
+
dependencies = [
+
 "amplify",
+
 "ec25519",
+
]
+

+
[[package]]
+
name = "cyphernet"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5c4cd4c5d6937b81f3df6bb26fe94b4d1c52dd2cfd85507d063d9892cd64448d"
+
dependencies = [
+
 "cypheraddr",
+
 "cyphergraphy",
+
 "eidolon-auth",
+
 "socks5-client",
+
]
+

+
[[package]]
+
name = "data-encoding"
+
version = "2.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
+

+
[[package]]
+
name = "data-encoding-macro"
+
version = "0.1.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c904b33cc60130e1aeea4956ab803d08a3f4a0ca82d64ed757afac3891f2bb99"
+
dependencies = [
+
 "data-encoding",
+
 "data-encoding-macro-internal",
+
]
+

+
[[package]]
+
name = "data-encoding-macro-internal"
+
version = "0.1.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8fdf3fce3ce863539ec1d7fd1b6dcc3c645663376b43ed376bbf887733e4f772"
+
dependencies = [
+
 "data-encoding",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "der"
+
version = "0.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de"
+
dependencies = [
+
 "const-oid",
+
 "pem-rfc7468",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "diff"
+
version = "0.1.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+

+
[[package]]
+
name = "digest"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
+
dependencies = [
+
 "generic-array",
+
]
+

+
[[package]]
+
name = "digest"
+
version = "0.10.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+
dependencies = [
+
 "block-buffer 0.10.4",
+
 "const-oid",
+
 "crypto-common",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "dyn-clone"
+
version = "1.0.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "304e6508efa593091e97a9abbc10f90aa7ca635b6d2784feff3c89d41dd12272"
+

+
[[package]]
+
name = "ec25519"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bdfd533a2fc01178c738c99412ae1f7e1ad2cb37c2e14bfd87e9d4618171c825"
+
dependencies = [
+
 "ct-codecs",
+
 "ed25519",
+
 "getrandom 0.2.10",
+
]
+

+
[[package]]
+
name = "ecdsa"
+
version = "0.14.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c"
+
dependencies = [
+
 "der",
+
 "elliptic-curve",
+
 "rfc6979",
+
 "signature",
+
]
+

+
[[package]]
+
name = "ed25519"
+
version = "1.5.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7"
+
dependencies = [
+
 "signature",
+
]
+

+
[[package]]
+
name = "ed25519-dalek"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d"
+
dependencies = [
+
 "curve25519-dalek",
+
 "ed25519",
+
 "rand 0.7.3",
+
 "serde",
+
 "sha2 0.9.9",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "eidolon-auth"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4a764411b1ee8bcacb5203d24e9f0b2d192be62b23ea1c16d0e3462e103f3ffc"
+
dependencies = [
+
 "amplify",
+
 "cyphergraphy",
+
]
+

+
[[package]]
+
name = "elliptic-curve"
+
version = "0.12.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3"
+
dependencies = [
+
 "base16ct",
+
 "crypto-bigint",
+
 "der",
+
 "digest 0.10.7",
+
 "ff",
+
 "generic-array",
+
 "group",
+
 "rand_core 0.6.4",
+
 "sec1",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "equivalent"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+

+
[[package]]
+
name = "errno"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f"
+
dependencies = [
+
 "errno-dragonfly",
+
 "libc",
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "errno-dragonfly"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
+
dependencies = [
+
 "cc",
+
 "libc",
+
]
+

+
[[package]]
+
name = "escargot"
+
version = "0.5.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "768064bd3a0e2bedcba91dc87ace90beea91acc41b6a01a3ca8e9aa8827461bf"
+
dependencies = [
+
 "log",
+
 "once_cell",
+
 "serde",
+
 "serde_json",
+
]
+

+
[[package]]
+
name = "fastrand"
+
version = "1.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
+
dependencies = [
+
 "instant",
+
]
+

+
[[package]]
+
name = "fastrand"
+
version = "2.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764"
+

+
[[package]]
+
name = "ff"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160"
+
dependencies = [
+
 "rand_core 0.6.4",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "filetime"
+
version = "0.2.21"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5cbc844cecaee9d4443931972e1289c8ff485cb4cc2767cb03ca139ed6885153"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "redox_syscall 0.2.16",
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "flate2"
+
version = "1.0.26"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743"
+
dependencies = [
+
 "crc32fast",
+
 "miniz_oxide",
+
]
+

+
[[package]]
+
name = "form_urlencoded"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652"
+
dependencies = [
+
 "percent-encoding",
+
]
+

+
[[package]]
+
name = "generic-array"
+
version = "0.14.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+
dependencies = [
+
 "typenum",
+
 "version_check",
+
]
+

+
[[package]]
+
name = "getrandom"
+
version = "0.1.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "wasi 0.9.0+wasi-snapshot-preview1",
+
]
+

+
[[package]]
+
name = "getrandom"
+
version = "0.2.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "wasi 0.11.0+wasi-snapshot-preview1",
+
]
+

+
[[package]]
+
name = "git-ref-format"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "137adab7111fcb575db6f07dae3a7d37f3c2630878954c9931f7135dfa33eeef"
+
dependencies = [
+
 "git-ref-format-core",
+
 "git-ref-format-macro",
+
]
+

+
[[package]]
+
name = "git-ref-format-core"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ebb6549ddc63ba5722acb98c823b0eccb7f8b979407bd2a8fd616f581ae50982"
+
dependencies = [
+
 "serde",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "git-ref-format-macro"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "18ffd0101a3bd9a3aba39602b8b20751ddb7ee11596debb58be3074209dad2ae"
+
dependencies = [
+
 "git-ref-format-core",
+
 "proc-macro-error",
+
 "quote",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "git2"
+
version = "0.17.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7b989d6a7ca95a362cf2cfc5ad688b3a467be1f87e480b8dad07fee8c79b0044"
+
dependencies = [
+
 "bitflags 1.3.2",
+
 "libc",
+
 "libgit2-sys",
+
 "log",
+
 "url",
+
]
+

+
[[package]]
+
name = "group"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7"
+
dependencies = [
+
 "ff",
+
 "rand_core 0.6.4",
+
 "subtle",
+
]
+

+
[[package]]
+
name = "hashbrown"
+
version = "0.12.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+

+
[[package]]
+
name = "hashbrown"
+
version = "0.14.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
+

+
[[package]]
+
name = "hermit-abi"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b"
+

+
[[package]]
+
name = "hmac"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+
dependencies = [
+
 "digest 0.10.7",
+
]
+

+
[[package]]
+
name = "iana-time-zone"
+
version = "0.1.57"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613"
+
dependencies = [
+
 "android_system_properties",
+
 "core-foundation-sys",
+
 "iana-time-zone-haiku",
+
 "js-sys",
+
 "wasm-bindgen",
+
 "windows",
+
]
+

+
[[package]]
+
name = "iana-time-zone-haiku"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+
dependencies = [
+
 "cc",
+
]
+

+
[[package]]
+
name = "idna"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
+
dependencies = [
+
 "unicode-bidi",
+
 "unicode-normalization",
+
]
+

+
[[package]]
+
name = "indexmap"
+
version = "1.9.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+
dependencies = [
+
 "autocfg",
+
 "hashbrown 0.12.3",
+
]
+

+
[[package]]
+
name = "indexmap"
+
version = "2.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
+
dependencies = [
+
 "equivalent",
+
 "hashbrown 0.14.0",
+
]
+

+
[[package]]
+
name = "inout"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
+
dependencies = [
+
 "generic-array",
+
]
+

+
[[package]]
+
name = "inquire"
+
version = "0.6.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c33e7c1ddeb15c9abcbfef6029d8e29f69b52b6d6c891031b88ed91b5065803b"
+
dependencies = [
+
 "bitflags 1.3.2",
+
 "dyn-clone",
+
 "lazy_static",
+
 "newline-converter",
+
 "tempfile",
+
 "termion 1.5.6",
+
 "thiserror",
+
 "unicode-segmentation",
+
 "unicode-width",
+
]
+

+
[[package]]
+
name = "instant"
+
version = "0.1.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
+
dependencies = [
+
 "cfg-if",
+
]
+

+
[[package]]
+
name = "is-terminal"
+
version = "0.4.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
+
dependencies = [
+
 "hermit-abi",
+
 "rustix",
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "isolang"
+
version = "2.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f80f221db1bc708b71128757b9396727c04de86968081e18e89b0575e03be071"
+
dependencies = [
+
 "phf",
+
]
+

+
[[package]]
+
name = "itoa"
+
version = "1.0.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
+

+
[[package]]
+
name = "jobserver"
+
version = "0.1.26"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
+
name = "js-sys"
+
version = "0.3.64"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a"
+
dependencies = [
+
 "wasm-bindgen",
+
]
+

+
[[package]]
+
name = "json-color"
+
version = "0.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6dc8c55175cad7234a98cc3e31ba3009e276800271692ed3ad2c2f1c574b6e8"
+
dependencies = [
+
 "colored",
+
 "serde",
+
 "serde_json",
+
]
+

+
[[package]]
+
name = "keccak"
+
version = "0.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8f6d5ed8676d904364de097082f4e7d240b571b67989ced0240f08b7f966f940"
+
dependencies = [
+
 "cpufeatures",
+
]
+

+
[[package]]
+
name = "lazy-regex"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ff63c423c68ea6814b7da9e88ce585f793c87ddd9e78f646970891769c8235d4"
+
dependencies = [
+
 "lazy-regex-proc_macros",
+
 "once_cell",
+
 "regex",
+
]
+

+
[[package]]
+
name = "lazy-regex-proc_macros"
+
version = "2.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8edfc11b8f56ce85e207e62ea21557cfa09bb24a8f6b04ae181b086ff8611c22"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "regex",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "lazy_static"
+
version = "1.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
dependencies = [
+
 "spin",
+
]
+

+
[[package]]
+
name = "lexopt"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "478ee9e62aaeaf5b140bd4138753d1f109765488581444218d3ddda43234f3e8"
+

+
[[package]]
+
name = "libc"
+
version = "0.2.147"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
+

+
[[package]]
+
name = "libgit2-sys"
+
version = "0.15.2+1.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a80df2e11fb4a61f4ba2ab42dbe7f74468da143f1a75c74e11dee7c813f694fa"
+
dependencies = [
+
 "cc",
+
 "libc",
+
 "libz-sys",
+
 "pkg-config",
+
]
+

+
[[package]]
+
name = "libm"
+
version = "0.2.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4"
+

+
[[package]]
+
name = "libz-sys"
+
version = "1.1.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b"
+
dependencies = [
+
 "cc",
+
 "libc",
+
 "pkg-config",
+
 "vcpkg",
+
]
+

+
[[package]]
+
name = "linked-hash-map"
+
version = "0.5.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+

+
[[package]]
+
name = "linux-raw-sys"
+
version = "0.4.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503"
+

+
[[package]]
+
name = "localtime"
+
version = "1.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "71c67b83b03434bb31132aef0b314b8a49a0db55ce195c7e3c29d27bbf003819"
+
dependencies = [
+
 "serde",
+
]
+

+
[[package]]
+
name = "log"
+
version = "0.4.19"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
+

+
[[package]]
+
name = "memchr"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
+

+
[[package]]
+
name = "miniz_oxide"
+
version = "0.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
+
dependencies = [
+
 "adler",
+
]
+

+
[[package]]
+
name = "multibase"
+
version = "0.9.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404"
+
dependencies = [
+
 "base-x",
+
 "data-encoding",
+
 "data-encoding-macro",
+
]
+

+
[[package]]
+
name = "newline-converter"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f"
+
dependencies = [
+
 "unicode-segmentation",
+
]
+

+
[[package]]
+
name = "nonempty"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ff7ac1e5ea23db6d61ad103e91864675049644bf47c35912336352fa4e9c109"
+

+
[[package]]
+
name = "nonempty"
+
version = "0.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "aeaf4ad7403de93e699c191202f017118df734d3850b01e13a3a8b2e6953d3c9"
+
dependencies = [
+
 "serde",
+
]
+

+
[[package]]
+
name = "normalize-line-endings"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+

+
[[package]]
+
name = "num-bigint-dig"
+
version = "0.8.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
+
dependencies = [
+
 "byteorder",
+
 "lazy_static",
+
 "libm",
+
 "num-integer",
+
 "num-iter",
+
 "num-traits",
+
 "rand 0.8.5",
+
 "smallvec",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "num-integer"
+
version = "0.1.45"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"
+
dependencies = [
+
 "autocfg",
+
 "num-traits",
+
]
+

+
[[package]]
+
name = "num-iter"
+
version = "0.1.43"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252"
+
dependencies = [
+
 "autocfg",
+
 "num-integer",
+
 "num-traits",
+
]
+

+
[[package]]
+
name = "num-traits"
+
version = "0.2.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2"
+
dependencies = [
+
 "autocfg",
+
 "libm",
+
]
+

+
[[package]]
+
name = "numtoa"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
+

+
[[package]]
+
name = "once_cell"
+
version = "1.18.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
+

+
[[package]]
+
name = "opaque-debug"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
+

+
[[package]]
+
name = "p256"
+
version = "0.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594"
+
dependencies = [
+
 "ecdsa",
+
 "elliptic-curve",
+
 "sha2 0.10.7",
+
]
+

+
[[package]]
+
name = "p384"
+
version = "0.11.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dfc8c5bf642dde52bb9e87c0ecd8ca5a76faac2eeed98dedb7c717997e1080aa"
+
dependencies = [
+
 "ecdsa",
+
 "elliptic-curve",
+
 "sha2 0.10.7",
+
]
+

+
[[package]]
+
name = "pbkdf2"
+
version = "0.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
+
dependencies = [
+
 "digest 0.10.7",
+
]
+

+
[[package]]
+
name = "pem-rfc7468"
+
version = "0.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "24d159833a9105500e0398934e205e0773f0b27529557134ecfc51c27646adac"
+
dependencies = [
+
 "base64ct",
+
]
+

+
[[package]]
+
name = "percent-encoding"
+
version = "2.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
+

+
[[package]]
+
name = "phf"
+
version = "0.11.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
+
dependencies = [
+
 "phf_shared",
+
]
+

+
[[package]]
+
name = "phf_shared"
+
version = "0.11.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
+
dependencies = [
+
 "siphasher",
+
]
+

+
[[package]]
+
name = "pkcs1"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eff33bdbdfc54cc98a2eca766ebdec3e1b8fb7387523d5c9c9a2891da856f719"
+
dependencies = [
+
 "der",
+
 "pkcs8",
+
 "spki",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "pkcs8"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba"
+
dependencies = [
+
 "der",
+
 "spki",
+
]
+

+
[[package]]
+
name = "pkg-config"
+
version = "0.3.27"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
+

+
[[package]]
+
name = "ppv-lite86"
+
version = "0.2.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+

+
[[package]]
+
name = "pretty_assertions"
+
version = "1.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66"
+
dependencies = [
+
 "diff",
+
 "yansi",
+
]
+

+
[[package]]
+
name = "proc-macro-error"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+
dependencies = [
+
 "proc-macro-error-attr",
+
 "proc-macro2",
+
 "quote",
+
 "syn 1.0.109",
+
 "version_check",
+
]
+

+
[[package]]
+
name = "proc-macro-error-attr"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "version_check",
+
]
+

+
[[package]]
+
name = "proc-macro2"
+
version = "1.0.66"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9"
+
dependencies = [
+
 "unicode-ident",
+
]
+

+
[[package]]
+
name = "quote"
+
version = "1.0.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965"
+
dependencies = [
+
 "proc-macro2",
+
]
+

+
[[package]]
+
name = "radicle"
+
version = "0.2.0"
+
dependencies = [
+
 "amplify",
+
 "crossbeam-channel",
+
 "cyphernet",
+
 "fastrand 1.9.0",
+
 "git2",
+
 "localtime",
+
 "log",
+
 "multibase",
+
 "nonempty 0.8.1",
+
 "once_cell",
+
 "radicle-cob",
+
 "radicle-crypto",
+
 "radicle-git-ext",
+
 "radicle-ssh",
+
 "serde",
+
 "serde_json",
+
 "siphasher",
+
 "sqlite",
+
 "tempfile",
+
 "thiserror",
+
 "unicode-normalization",
+
]
+

+
[[package]]
+
name = "radicle-cli"
+
version = "0.8.0"
+
dependencies = [
+
 "anyhow",
+
 "chrono",
+
 "git-ref-format",
+
 "json-color",
+
 "lexopt",
+
 "localtime",
+
 "log",
+
 "nonempty 0.8.1",
+
 "radicle",
+
 "radicle-cli-test",
+
 "radicle-cob",
+
 "radicle-crypto",
+
 "radicle-git-ext",
+
 "radicle-surf",
+
 "radicle-term",
+
 "serde",
+
 "serde_json",
+
 "serde_yaml",
+
 "similar",
+
 "thiserror",
+
 "timeago 0.3.1",
+
 "ureq",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "radicle-cli-test"
+
version = "0.1.1"
+
dependencies = [
+
 "escargot",
+
 "log",
+
 "pretty_assertions",
+
 "shlex",
+
 "snapbox",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "radicle-cob"
+
version = "0.1.0"
+
dependencies = [
+
 "fastrand 1.9.0",
+
 "git2",
+
 "log",
+
 "nonempty 0.8.1",
+
 "radicle-crypto",
+
 "radicle-dag",
+
 "radicle-git-ext",
+
 "serde",
+
 "serde_json",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "radicle-crypto"
+
version = "0.1.0"
+
dependencies = [
+
 "amplify",
+
 "cyphernet",
+
 "ec25519",
+
 "multibase",
+
 "radicle-git-ext",
+
 "radicle-ssh",
+
 "serde",
+
 "sqlite",
+
 "ssh-key",
+
 "thiserror",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "radicle-dag"
+
version = "0.1.0"
+
dependencies = [
+
 "fastrand 1.9.0",
+
]
+

+
[[package]]
+
name = "radicle-git-ext"
+
version = "0.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "90bc5efea239afa1fb374923be50c3a5f98cec92d53b5fa55bdf57ac76b16433"
+
dependencies = [
+
 "git-ref-format",
+
 "git2",
+
 "percent-encoding",
+
 "radicle-std-ext",
+
 "serde",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "radicle-ssh"
+
version = "0.2.0"
+
dependencies = [
+
 "byteorder",
+
 "log",
+
 "thiserror",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "radicle-std-ext"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "db20136bbc9ae63f3fec8e5a6c369f4902fac2244501b5dfc6d668e43475aaa4"
+

+
[[package]]
+
name = "radicle-surf"
+
version = "0.14.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b801a32980495a643bd380cc88f2074cf978862efbc8c085d5bd9f3be4caafd6"
+
dependencies = [
+
 "anyhow",
+
 "base64 0.13.1",
+
 "flate2",
+
 "git2",
+
 "log",
+
 "nonempty 0.5.0",
+
 "radicle-git-ext",
+
 "radicle-std-ext",
+
 "tar",
+
 "thiserror",
+
]
+

+
[[package]]
+
name = "radicle-term"
+
version = "0.1.0"
+
dependencies = [
+
 "anstyle-query",
+
 "anyhow",
+
 "inquire",
+
 "libc",
+
 "once_cell",
+
 "termion 2.0.1",
+
 "unicode-segmentation",
+
 "unicode-width",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "radicle-tui"
+
version = "0.1.0"
+
dependencies = [
+
 "anyhow",
+
 "lexopt",
+
 "radicle",
+
 "radicle-cli",
+
 "radicle-surf",
+
 "radicle-term",
+
 "timeago 0.4.1",
+
 "tui-realm-stdlib",
+
 "tuirealm",
+
]
+

+
[[package]]
+
name = "rand"
+
version = "0.7.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+
dependencies = [
+
 "getrandom 0.1.16",
+
 "libc",
+
 "rand_chacha 0.2.2",
+
 "rand_core 0.5.1",
+
 "rand_hc",
+
]
+

+
[[package]]
+
name = "rand"
+
version = "0.8.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+
dependencies = [
+
 "rand_chacha 0.3.1",
+
 "rand_core 0.6.4",
+
]
+

+
[[package]]
+
name = "rand_chacha"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+
dependencies = [
+
 "ppv-lite86",
+
 "rand_core 0.5.1",
+
]
+

+
[[package]]
+
name = "rand_chacha"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+
dependencies = [
+
 "ppv-lite86",
+
 "rand_core 0.6.4",
+
]
+

+
[[package]]
+
name = "rand_core"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+
dependencies = [
+
 "getrandom 0.1.16",
+
]
+

+
[[package]]
+
name = "rand_core"
+
version = "0.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
dependencies = [
+
 "getrandom 0.2.10",
+
]
+

+
[[package]]
+
name = "rand_hc"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
+
dependencies = [
+
 "rand_core 0.5.1",
+
]
+

+
[[package]]
+
name = "redox_syscall"
+
version = "0.2.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+
dependencies = [
+
 "bitflags 1.3.2",
+
]
+

+
[[package]]
+
name = "redox_syscall"
+
version = "0.3.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
+
dependencies = [
+
 "bitflags 1.3.2",
+
]
+

+
[[package]]
+
name = "redox_termios"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f"
+
dependencies = [
+
 "redox_syscall 0.2.16",
+
]
+

+
[[package]]
+
name = "regex"
+
version = "1.9.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
+
dependencies = [
+
 "aho-corasick",
+
 "memchr",
+
 "regex-automata",
+
 "regex-syntax",
+
]
+

+
[[package]]
+
name = "regex-automata"
+
version = "0.3.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b7b6d6190b7594385f61bd3911cd1be99dfddcfc365a4160cc2ab5bff4aed294"
+
dependencies = [
+
 "aho-corasick",
+
 "memchr",
+
 "regex-syntax",
+
]
+

+
[[package]]
+
name = "regex-syntax"
+
version = "0.7.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
+

+
[[package]]
+
name = "rfc6979"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb"
+
dependencies = [
+
 "crypto-bigint",
+
 "hmac",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "rsa"
+
version = "0.7.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "094052d5470cbcef561cb848a7209968c9f12dfa6d668f4bca048ac5de51099c"
+
dependencies = [
+
 "byteorder",
+
 "digest 0.10.7",
+
 "num-bigint-dig",
+
 "num-integer",
+
 "num-iter",
+
 "num-traits",
+
 "pkcs1",
+
 "pkcs8",
+
 "rand_core 0.6.4",
+
 "signature",
+
 "smallvec",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "rustix"
+
version = "0.38.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ee020b1716f0a80e2ace9b03441a749e402e86712f15f16fe8a8f75afac732f"
+
dependencies = [
+
 "bitflags 2.3.3",
+
 "errno",
+
 "libc",
+
 "linux-raw-sys",
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "ryu"
+
version = "1.0.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
+

+
[[package]]
+
name = "sec1"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928"
+
dependencies = [
+
 "base16ct",
+
 "der",
+
 "generic-array",
+
 "pkcs8",
+
 "subtle",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "serde"
+
version = "1.0.181"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6d3e73c93c3240c0bda063c239298e633114c69a888c3e37ca8bb33f343e9890"
+
dependencies = [
+
 "serde_derive",
+
]
+

+
[[package]]
+
name = "serde_derive"
+
version = "1.0.181"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "be02f6cb0cd3a5ec20bbcfbcbd749f57daddb1a0882dc2e46a6c236c90b977ed"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.28",
+
]
+

+
[[package]]
+
name = "serde_json"
+
version = "1.0.104"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "076066c5f1078eac5b722a31827a8832fe108bed65dfa75e233c89f8206e976c"
+
dependencies = [
+
 "indexmap 2.0.0",
+
 "itoa",
+
 "ryu",
+
 "serde",
+
]
+

+
[[package]]
+
name = "serde_yaml"
+
version = "0.8.26"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b"
+
dependencies = [
+
 "indexmap 1.9.3",
+
 "ryu",
+
 "serde",
+
 "yaml-rust",
+
]
+

+
[[package]]
+
name = "sha2"
+
version = "0.9.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
+
dependencies = [
+
 "block-buffer 0.9.0",
+
 "cfg-if",
+
 "cpufeatures",
+
 "digest 0.9.0",
+
 "opaque-debug",
+
]
+

+
[[package]]
+
name = "sha2"
+
version = "0.10.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8"
+
dependencies = [
+
 "cfg-if",
+
 "cpufeatures",
+
 "digest 0.10.7",
+
]
+

+
[[package]]
+
name = "sha3"
+
version = "0.10.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
+
dependencies = [
+
 "digest 0.10.7",
+
 "keccak",
+
]
+

+
[[package]]
+
name = "shlex"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3"
+

+
[[package]]
+
name = "signature"
+
version = "1.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
+
dependencies = [
+
 "digest 0.10.7",
+
 "rand_core 0.6.4",
+
]
+

+
[[package]]
+
name = "similar"
+
version = "2.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf"
+

+
[[package]]
+
name = "siphasher"
+
version = "0.3.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de"
+

+
[[package]]
+
name = "smallvec"
+
version = "1.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
+

+
[[package]]
+
name = "smawk"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043"
+

+
[[package]]
+
name = "snapbox"
+
version = "0.4.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f6bccd62078347f89a914e3004d94582e13824d4e3d8a816317862884c423835"
+
dependencies = [
+
 "anstream",
+
 "anstyle",
+
 "normalize-line-endings",
+
 "similar",
+
 "snapbox-macros",
+
]
+

+
[[package]]
+
name = "snapbox-macros"
+
version = "0.3.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eaaf09df9f0eeae82be96290918520214530e738a7fe5a351b0f24cf77c0ca31"
+
dependencies = [
+
 "anstream",
+
]
+

+
[[package]]
+
name = "socks5-client"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4091196d57cf9436ebecbec4c572b2be61373a7aaa632a3e93a5cb8555ec1b79"
+
dependencies = [
+
 "amplify",
+
 "cypheraddr",
+
]
+

+
[[package]]
+
name = "spin"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+

+
[[package]]
+
name = "spki"
+
version = "0.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b"
+
dependencies = [
+
 "base64ct",
+
 "der",
+
]
+

+
[[package]]
+
name = "sqlite"
+
version = "0.31.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f3ddda64c469a257a3b31298805427784d992c226c94b81003f96e8b122286ad"
+
dependencies = [
+
 "libc",
+
 "sqlite3-sys",
+
]
+

+
[[package]]
+
name = "sqlite3-src"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bfc95a51a1ee38839599371685b9d4a926abb51791f0bc3bf8c3bb7867e6e454"
+
dependencies = [
+
 "cc",
+
 "pkg-config",
+
]
+

+
[[package]]
+
name = "sqlite3-sys"
+
version = "0.15.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f2752c669433e40ebb08fde824146f50d9628aa0b66a3b7fc6be34db82a8063b"
+
dependencies = [
+
 "libc",
+
 "sqlite3-src",
+
]
+

+
[[package]]
+
name = "ssh-encoding"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "19cfdc32e0199062113edf41f344fbf784b8205a94600233c84eb838f45191e1"
+
dependencies = [
+
 "base64ct",
+
 "pem-rfc7468",
+
 "sha2 0.10.7",
+
]
+

+
[[package]]
+
name = "ssh-key"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "288d8f5562af5a3be4bda308dd374b2c807b940ac370b5efa1c99311da91d9a1"
+
dependencies = [
+
 "aes",
+
 "bcrypt-pbkdf",
+
 "ctr",
+
 "ed25519-dalek",
+
 "p256",
+
 "p384",
+
 "rand_core 0.6.4",
+
 "rsa",
+
 "sec1",
+
 "sha2 0.10.7",
+
 "signature",
+
 "ssh-encoding",
+
 "zeroize",
+
]
+

+
[[package]]
+
name = "subtle"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
+

+
[[package]]
+
name = "syn"
+
version = "1.0.109"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "unicode-ident",
+
]
+

+
[[package]]
+
name = "syn"
+
version = "2.0.28"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "04361975b3f5e348b2189d8dc55bc942f278b2d482a6a0365de5bdd62d351567"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "unicode-ident",
+
]
+

+
[[package]]
+
name = "tar"
+
version = "0.4.39"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ec96d2ffad078296368d46ff1cb309be1c23c513b4ab0e22a45de0185275ac96"
+
dependencies = [
+
 "filetime",
+
 "libc",
+
 "xattr",
+
]
+

+
[[package]]
+
name = "tempfile"
+
version = "3.7.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5486094ee78b2e5038a6382ed7645bc084dc2ec433426ca4c3cb61e2007b8998"
+
dependencies = [
+
 "cfg-if",
+
 "fastrand 2.0.0",
+
 "redox_syscall 0.3.5",
+
 "rustix",
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "termion"
+
version = "1.5.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e"
+
dependencies = [
+
 "libc",
+
 "numtoa",
+
 "redox_syscall 0.2.16",
+
 "redox_termios",
+
]
+

+
[[package]]
+
name = "termion"
+
version = "2.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90"
+
dependencies = [
+
 "libc",
+
 "numtoa",
+
 "redox_syscall 0.2.16",
+
 "redox_termios",
+
]
+

+
[[package]]
+
name = "textwrap"
+
version = "0.15.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d"
+
dependencies = [
+
 "smawk",
+
 "unicode-linebreak",
+
 "unicode-width",
+
]
+

+
[[package]]
+
name = "thiserror"
+
version = "1.0.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90"
+
dependencies = [
+
 "thiserror-impl",
+
]
+

+
[[package]]
+
name = "thiserror-impl"
+
version = "1.0.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.28",
+
]
+

+
[[package]]
+
name = "time"
+
version = "0.1.45"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a"
+
dependencies = [
+
 "libc",
+
 "wasi 0.10.0+wasi-snapshot-preview1",
+
 "winapi",
+
]
+

+
[[package]]
+
name = "timeago"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6ec32dde57efb15c035ac074118d7f32820451395f28cb0524a01d4e94983b26"
+

+
[[package]]
+
name = "timeago"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5082dc942361cdfb74eab98bf995762d6015e5bb3a20bf7c5c71213778b4fcb4"
+
dependencies = [
+
 "chrono",
+
 "isolang",
+
]
+

+
[[package]]
+
name = "tinyvec"
+
version = "1.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+
dependencies = [
+
 "tinyvec_macros",
+
]
+

+
[[package]]
+
name = "tinyvec_macros"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+

+
[[package]]
+
name = "tui"
+
version = "0.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1"
+
dependencies = [
+
 "bitflags 1.3.2",
+
 "cassowary",
+
 "termion 1.5.6",
+
 "unicode-segmentation",
+
 "unicode-width",
+
]
+

+
[[package]]
+
name = "tui-realm-stdlib"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "66f252bf8b07c6fd708ddd6349b5f044ae5b488b26929c745728d9c7e2cebfa6"
+
dependencies = [
+
 "textwrap",
+
 "tuirealm",
+
 "unicode-width",
+
]
+

+
[[package]]
+
name = "tuirealm"
+
version = "1.8.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "265411b5606f400459af94fbc5aae6a7bc0e98094d08cb5868390c932be88e26"
+
dependencies = [
+
 "bitflags 1.3.2",
+
 "lazy-regex",
+
 "termion 1.5.6",
+
 "thiserror",
+
 "tui",
+
 "tuirealm_derive",
+
]
+

+
[[package]]
+
name = "tuirealm_derive"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e0adcdaf59881626555558eae08f8a53003c8a1961723b4d7a10c51599abbc81"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 1.0.109",
+
]
+

+
[[package]]
+
name = "typenum"
+
version = "1.16.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba"
+

+
[[package]]
+
name = "unicode-bidi"
+
version = "0.3.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
+

+
[[package]]
+
name = "unicode-ident"
+
version = "1.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
+

+
[[package]]
+
name = "unicode-linebreak"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+

+
[[package]]
+
name = "unicode-normalization"
+
version = "0.1.22"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
+
dependencies = [
+
 "tinyvec",
+
]
+

+
[[package]]
+
name = "unicode-segmentation"
+
version = "1.10.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
+

+
[[package]]
+
name = "unicode-width"
+
version = "0.1.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
+

+
[[package]]
+
name = "ureq"
+
version = "2.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b11c96ac7ee530603dcdf68ed1557050f374ce55a5a07193ebf8cbc9f8927e9"
+
dependencies = [
+
 "base64 0.21.2",
+
 "log",
+
 "once_cell",
+
 "serde",
+
 "serde_json",
+
 "url",
+
]
+

+
[[package]]
+
name = "url"
+
version = "2.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb"
+
dependencies = [
+
 "form_urlencoded",
+
 "idna",
+
 "percent-encoding",
+
]
+

+
[[package]]
+
name = "utf8parse"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+

+
[[package]]
+
name = "vcpkg"
+
version = "0.2.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+

+
[[package]]
+
name = "version_check"
+
version = "0.9.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+

+
[[package]]
+
name = "wasi"
+
version = "0.9.0+wasi-snapshot-preview1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+

+
[[package]]
+
name = "wasi"
+
version = "0.10.0+wasi-snapshot-preview1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
+

+
[[package]]
+
name = "wasi"
+
version = "0.11.0+wasi-snapshot-preview1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+

+
[[package]]
+
name = "wasm-bindgen"
+
version = "0.2.87"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
+
dependencies = [
+
 "cfg-if",
+
 "wasm-bindgen-macro",
+
]
+

+
[[package]]
+
name = "wasm-bindgen-backend"
+
version = "0.2.87"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
+
dependencies = [
+
 "bumpalo",
+
 "log",
+
 "once_cell",
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.28",
+
 "wasm-bindgen-shared",
+
]
+

+
[[package]]
+
name = "wasm-bindgen-macro"
+
version = "0.2.87"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
+
dependencies = [
+
 "quote",
+
 "wasm-bindgen-macro-support",
+
]
+

+
[[package]]
+
name = "wasm-bindgen-macro-support"
+
version = "0.2.87"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.28",
+
 "wasm-bindgen-backend",
+
 "wasm-bindgen-shared",
+
]
+

+
[[package]]
+
name = "wasm-bindgen-shared"
+
version = "0.2.87"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
+

+
[[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"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
+
dependencies = [
+
 "windows-targets",
+
]
+

+
[[package]]
+
name = "windows-sys"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+
dependencies = [
+
 "windows-targets",
+
]
+

+
[[package]]
+
name = "windows-targets"
+
version = "0.48.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f"
+
dependencies = [
+
 "windows_aarch64_gnullvm",
+
 "windows_aarch64_msvc",
+
 "windows_i686_gnu",
+
 "windows_i686_msvc",
+
 "windows_x86_64_gnu",
+
 "windows_x86_64_gnullvm",
+
 "windows_x86_64_msvc",
+
]
+

+
[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
+

+
[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
+

+
[[package]]
+
name = "windows_i686_gnu"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
+

+
[[package]]
+
name = "windows_i686_msvc"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
+

+
[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
+

+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
+

+
[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
+

+
[[package]]
+
name = "xattr"
+
version = "0.2.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
+
name = "yaml-rust"
+
version = "0.4.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+
dependencies = [
+
 "linked-hash-map",
+
]
+

+
[[package]]
+
name = "yansi"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
+

+
[[package]]
+
name = "zeroize"
+
version = "1.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9"
+
dependencies = [
+
 "zeroize_derive",
+
]
+

+
[[package]]
+
name = "zeroize_derive"
+
version = "1.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.28",
+
]
added Cargo.toml
@@ -0,0 +1,31 @@
+
[package]
+
name = "radicle-tui"
+
license = "MIT OR Apache-2.0"
+
version = "0.1.0"
+
authors = ["Erik Kundt <erik@zirkular.io>"]
+
edition = "2021"
+
build = "build.rs"
+

+
[[bin]]
+
name = "radicle-tui"
+
path = "src/main.rs"
+

+
[dependencies]
+
anyhow = { version = "1" }
+
lexopt = { version = "0.2" }
+
radicle-surf = { version = "0.14.0" }
+
timeago = { version = "0.4.1" }
+
tuirealm = { version = "1.8.0", default-features = false, features = [ "with-termion" ] }
+
tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-termion" ] }
+

+
[dependencies.radicle]
+
version = "0"
+
path = "../heartwood/radicle"
+

+
[dependencies.radicle-cli]
+
version = "0"
+
path = "../heartwood/radicle-cli"
+

+
[dependencies.radicle-term]
+
version = "0"
+
path = "../heartwood/radicle-term"
added build.rs
@@ -0,0 +1,23 @@
+
use std::process::Command;
+

+
fn main() {
+
    // Set a build-time `GIT_HEAD` env var which includes the commit id;
+
    // such that we can tell which code is running.
+
    let hash = Command::new("git")
+
        .arg("rev-parse")
+
        .arg("--short")
+
        .arg("HEAD")
+
        .output()
+
        .ok()
+
        .and_then(|output| {
+
            if output.status.success() {
+
                String::from_utf8(output.stdout).ok()
+
            } else {
+
                None
+
            }
+
        })
+
        .unwrap_or_else(|| String::from("unknown"));
+

+
    println!("cargo:rustc-env=GIT_HEAD={hash}");
+
    println!("cargo:rustc-rerun-if-changed=.git/HEAD");
+
}
deleted radicle-tui/Cargo.toml
@@ -1,31 +0,0 @@
-
[package]
-
name = "radicle-tui"
-
license = "MIT OR Apache-2.0"
-
version = "0.1.0"
-
authors = ["Erik Kundt <erik@zirkular.io>"]
-
edition = "2021"
-
build = "../build.rs"
-

-
[[bin]]
-
name = "radicle-tui"
-
path = "src/main.rs"
-

-
[dependencies]
-
anyhow = { version = "1" }
-
lexopt = { version = "0.2" }
-
radicle-surf = { version = "0.14.0" }
-
timeago = { version = "0.4.1" }
-
tuirealm = { version = "1.8.0", default-features = false, features = [ "with-termion" ] }
-
tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-termion" ] }
-

-
[dependencies.radicle]
-
version = "0"
-
path = "../radicle"
-

-
[dependencies.radicle-cli]
-
version = "0"
-
path = "../radicle-cli"
-

-
[dependencies.radicle-term]
-
version = "0"
-
path = "../radicle-term"
deleted radicle-tui/src/app.rs
@@ -1,208 +0,0 @@
-
pub mod event;
-
pub mod page;
-
pub mod subscription;
-

-
use anyhow::Result;
-

-
use radicle::cob::issue::IssueId;
-
use radicle::cob::patch::PatchId;
-
use radicle::identity::{Id, Project};
-
use radicle::profile::Profile;
-

-
use tuirealm::application::PollStrategy;
-
use tuirealm::{Application, Frame, NoUserEvent};
-

-
use radicle_tui::ui::context::Context;
-
use radicle_tui::ui::theme::{self, Theme};
-
use radicle_tui::Tui;
-
use radicle_tui::{cob, ui};
-

-
use page::{HomeView, PatchView};
-

-
use self::page::{IssuePage, PageStack};
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum HomeCid {
-
    Header,
-
    Dashboard,
-
    IssueBrowser,
-
    PatchBrowser,
-
}
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum PatchCid {
-
    Header,
-
    Activity,
-
    Files,
-
}
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum IssueCid {
-
    Header,
-
    List,
-
    Details,
-
    Shortcuts,
-
}
-

-
/// All component ids known to this application.
-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum Cid {
-
    Home(HomeCid),
-
    Issue(IssueCid),
-
    Patch(PatchCid),
-
    GlobalListener,
-
}
-

-
/// Messages handled by this application.
-
#[derive(Debug, Eq, PartialEq)]
-
pub enum HomeMessage {}
-

-
#[derive(Debug, Eq, PartialEq)]
-
pub enum IssueMessage {
-
    Show(IssueId),
-
    Changed(IssueId),
-
    Focus(IssueCid),
-
    Leave,
-
}
-

-
#[derive(Debug, Eq, PartialEq)]
-
pub enum PatchMessage {
-
    Show(PatchId),
-
    Leave,
-
}
-

-
#[derive(Debug, Eq, PartialEq)]
-
pub enum Message {
-
    Home(HomeMessage),
-
    Issue(IssueMessage),
-
    Patch(PatchMessage),
-
    NavigationChanged(u16),
-
    Tick,
-
    Quit,
-
}
-

-
#[allow(dead_code)]
-
pub struct App {
-
    context: Context,
-
    pages: PageStack,
-
    theme: Theme,
-
    quit: bool,
-
}
-

-
/// Creates a new application using a tui-realm-application, mounts all
-
/// components and sets focus to a default one.
-
impl App {
-
    pub fn new(profile: Profile, id: Id, project: Project) -> Self {
-
        Self {
-
            context: Context::new(profile, id, project),
-
            pages: PageStack::default(),
-
            theme: theme::default_dark(),
-
            quit: false,
-
        }
-
    }
-

-
    fn view_home(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let home = Box::<HomeView>::default();
-
        self.pages.push(home, app, &self.context, theme)?;
-

-
        Ok(())
-
    }
-

-
    fn view_patch(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: PatchId,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-

-
        if let Some(patch) = cob::patch::find(repo, &id)? {
-
            let view = Box::new(PatchView::new((id, patch)));
-
            self.pages.push(view, app, &self.context, theme)?;
-

-
            Ok(())
-
        } else {
-
            Err(anyhow::anyhow!(
-
                "Could not mount 'page::PatchView'. Patch not found."
-
            ))
-
        }
-
    }
-

-
    fn view_issue(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: IssueId,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-

-
        if let Some(issue) = cob::issue::find(repo, &id)? {
-
            let view = Box::new(IssuePage::new((id, issue)));
-
            self.pages.push(view, app, &self.context, theme)?;
-

-
            Ok(())
-
        } else {
-
            Err(anyhow::anyhow!(
-
                "Could not mount 'page::IssueView'. Issue not found."
-
            ))
-
        }
-
    }
-
}
-

-
impl Tui<Cid, Message> for App {
-
    fn init(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        self.view_home(app, &self.theme.clone())?;
-

-
        // Add global key listener and subscribe to key events
-
        let global = ui::widget::common::global_listener().to_boxed();
-
        app.mount(Cid::GlobalListener, global, subscription::global())?;
-

-
        Ok(())
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        if let Ok(page) = self.pages.peek_mut() {
-
            page.view(app, frame);
-
        }
-
    }
-

-
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
-
        match app.tick(PollStrategy::Once) {
-
            Ok(messages) if !messages.is_empty() => {
-
                let theme = theme::default_dark();
-
                for message in messages {
-
                    match message {
-
                        Message::Issue(IssueMessage::Show(id)) => {
-
                            self.view_issue(app, id, &theme)?;
-
                        }
-
                        Message::Issue(IssueMessage::Leave) => {
-
                            self.pages.pop(app)?;
-
                        }
-
                        Message::Patch(PatchMessage::Show(id)) => {
-
                            self.view_patch(app, id, &theme)?;
-
                        }
-
                        Message::Patch(PatchMessage::Leave) => {
-
                            self.pages.pop(app)?;
-
                        }
-
                        Message::Quit => self.quit = true,
-
                        _ => {
-
                            self.pages
-
                                .peek_mut()?
-
                                .update(app, &self.context, &theme, message)?;
-
                        }
-
                    }
-
                }
-
                Ok(true)
-
            }
-
            _ => Ok(false),
-
        }
-
    }
-

-
    fn quit(&self) -> bool {
-
        self.quit
-
    }
-
}
deleted radicle-tui/src/app/event.rs
@@ -1,197 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
-
use tuirealm::event::{Event, Key, KeyEvent};
-
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
-

-
use radicle_tui::ui::widget::common::container::{AppHeader, GlobalListener, LabeledContainer};
-
use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts};
-
use radicle_tui::ui::widget::common::list::PropertyList;
-
use radicle_tui::ui::widget::home::{Dashboard, IssueBrowser, PatchBrowser};
-
use radicle_tui::ui::widget::{issue, patch};
-

-
use radicle_tui::ui::widget::Widget;
-

-
use super::{IssueMessage, Message, PatchMessage};
-

-
/// Since the framework does not know the type of messages that are being
-
/// passed around in the app, the following handlers need to be implemented for
-
/// each component used.
-
///
-
/// TODO: should handle `Event::WindowResize`, which is not emitted by `termion`.
-
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('q'),
-
                ..
-
            }) => Some(Message::Quit),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
-
                match self.perform(Cmd::Move(MoveDirection::Right)) {
-
                    CmdResult::Changed(State::One(StateValue::U16(index))) => {
-
                        Some(Message::NavigationChanged(index))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::LargeList> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Issue(IssueMessage::Leave))
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
-
                let result = self.perform(Cmd::Move(MoveDirection::Up));
-
                match result {
-
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            }) => {
-
                let result = self.perform(Cmd::Move(MoveDirection::Down));
-
                match result {
-
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::Details> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        None
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<PatchBrowser> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                let result = self.perform(Cmd::Submit);
-
                match result {
-
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Patch(PatchMessage::Show(item.id().to_owned())))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<IssueBrowser> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                let result = self.perform(Cmd::Submit);
-
                match result {
-
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Issue(IssueMessage::Show(item.id().to_owned())))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Dashboard> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        None
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Activity> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Patch(PatchMessage::Leave))
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Files> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Patch(PatchMessage::Leave))
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<LabeledContainer> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        None
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<PropertyList> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        None
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ContextBar> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        None
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Shortcuts> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        None
-
    }
-
}
deleted radicle-tui/src/app/page.rs
@@ -1,403 +0,0 @@
-
use anyhow::Result;
-

-
use radicle::cob::issue::{Issue, IssueId};
-
use radicle::cob::patch::{Patch, PatchId};
-

-
use radicle_tui::cob;
-
use tuirealm::{Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui::ui::context::Context;
-
use radicle_tui::ui::layout;
-
use radicle_tui::ui::theme::Theme;
-
use radicle_tui::ui::widget;
-

-
use super::{subscription, Application, Cid, HomeCid, IssueCid, IssueMessage, Message, PatchCid};
-

-
/// `tuirealm`'s event and prop system is designed to work with flat component hierarchies.
-
/// Building deep nested component hierarchies would need a lot more additional effort to
-
/// properly pass events and props down these hierarchies. This makes it hard to implement
-
/// full app views (home, patch details etc) as components.
-
///
-
/// View pages take into account these flat component hierarchies, and provide
-
/// switchable sets of components.
-
pub trait ViewPage {
-
    /// Will be called whenever a view page is pushed onto the page stack. Should create and mount all widgets.
-
    fn mount(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()>;
-

-
    /// Will be called whenever a view page is popped from the page stack. Should unmount all widgets.
-
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
-

-
    /// Will be called whenever a view page is on top of the stack and can be used to update its internal
-
    /// state depending on the message passed.
-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        message: Message,
-
    ) -> Result<()>;
-

-
    /// Will be called whenever a view page is on top of the page stack and needs to be rendered.
-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame);
-

-
    /// Will be called whenever this view page is pushed to the stack, or it is on top of the stack again
-
    /// after another view page was popped from the stack.
-
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
-

-
    /// Will be called whenever this view page is on top of the stack and another view page is pushed
-
    /// to the stack, or if this is popped from the stack.
-
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
-
}
-

-
///
-
/// Home
-
///
-
pub struct HomeView {
-
    active_component: Cid,
-
}
-

-
impl Default for HomeView {
-
    fn default() -> Self {
-
        HomeView {
-
            active_component: Cid::Home(HomeCid::Dashboard),
-
        }
-
    }
-
}
-

-
impl ViewPage for HomeView {
-
    fn mount(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = widget::home::navigation(theme);
-
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
-

-
        let dashboard = widget::home::dashboard(context, theme).to_boxed();
-
        let issue_browser = widget::home::issues(context, theme).to_boxed();
-
        let patch_browser = widget::home::patches(context, theme).to_boxed();
-

-
        app.remount(Cid::Home(HomeCid::Header), header, vec![])?;
-

-
        app.remount(Cid::Home(HomeCid::Dashboard), dashboard, vec![])?;
-
        app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?;
-
        app.remount(Cid::Home(HomeCid::PatchBrowser), patch_browser, vec![])?;
-

-
        app.active(&self.active_component)?;
-

-
        Ok(())
-
    }
-

-
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::Home(HomeCid::Header))?;
-
        app.umount(&Cid::Home(HomeCid::Dashboard))?;
-
        app.umount(&Cid::Home(HomeCid::IssueBrowser))?;
-
        app.umount(&Cid::Home(HomeCid::PatchBrowser))?;
-
        Ok(())
-
    }
-

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        _context: &Context,
-
        _theme: &Theme,
-
        message: Message,
-
    ) -> Result<()> {
-
        if let Message::NavigationChanged(index) = message {
-
            self.active_component = Cid::Home(HomeCid::from(index as usize));
-
            app.active(&self.active_component)?;
-
        }
-

-
        Ok(())
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let layout = layout::default_page(area);
-

-
        app.view(&Cid::Home(HomeCid::Header), frame, layout[0]);
-
        app.view(&self.active_component, frame, layout[1]);
-
    }
-

-
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.subscribe(
-
            &Cid::Home(HomeCid::Header),
-
            Sub::new(subscription::navigation_clause(), SubClause::Always),
-
        )?;
-

-
        Ok(())
-
    }
-

-
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.unsubscribe(
-
            &Cid::Home(HomeCid::Header),
-
            subscription::navigation_clause(),
-
        )?;
-

-
        Ok(())
-
    }
-
}
-

-
///
-
/// Issue detail page
-
///
-
pub struct IssuePage {
-
    issue: (IssueId, Issue),
-
}
-

-
impl IssuePage {
-
    pub fn new(issue: (IssueId, Issue)) -> Self {
-
        IssuePage { issue }
-
    }
-
}
-

-
impl ViewPage for IssuePage {
-
    fn mount(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let (id, issue) = &self.issue;
-
        let header = widget::common::app_header(context, theme, None).to_boxed();
-
        let list = widget::issue::list(context, theme, (*id, issue.clone())).to_boxed();
-
        let details = widget::issue::details(context, theme, (*id, issue.clone())).to_boxed();
-
        let shortcuts = widget::common::shortcuts(
-
            theme,
-
            vec![
-
                widget::common::shortcut(theme, "esc", "back"),
-
                widget::common::shortcut(theme, "q", "quit"),
-
            ],
-
        )
-
        .to_boxed();
-

-
        app.remount(Cid::Issue(IssueCid::Header), header, vec![])?;
-
        app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-
        app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
        app.remount(Cid::Issue(IssueCid::Shortcuts), shortcuts, vec![])?;
-

-
        app.active(&Cid::Issue(IssueCid::List))?;
-

-
        Ok(())
-
    }
-

-
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::Issue(IssueCid::Header))?;
-
        app.umount(&Cid::Issue(IssueCid::List))?;
-
        app.umount(&Cid::Issue(IssueCid::Details))?;
-
        app.umount(&Cid::Issue(IssueCid::Shortcuts))?;
-
        Ok(())
-
    }
-

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        message: Message,
-
    ) -> Result<()> {
-
        match message {
-
            Message::Issue(IssueMessage::Changed(id)) => {
-
                let repo = context.repository();
-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    let details = widget::issue::details(context, theme, (id, issue)).to_boxed();
-
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
                }
-
            }
-
            Message::Issue(IssueMessage::Focus(cid)) => {
-
                app.active(&Cid::Issue(cid))?;
-
            }
-
            _ => {}
-
        }
-

-
        Ok(())
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let shortcuts_h = 1u16;
-
        let layout = layout::issue_preview(area, shortcuts_h);
-

-
        app.view(&Cid::Issue(IssueCid::Header), frame, layout.header);
-
        app.view(&Cid::Issue(IssueCid::List), frame, layout.list);
-
        app.view(&Cid::Issue(IssueCid::Details), frame, layout.details);
-
        app.view(&Cid::Issue(IssueCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

-
    fn subscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        Ok(())
-
    }
-

-
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        Ok(())
-
    }
-
}
-

-
///
-
/// Patch detail page
-
///
-
pub struct PatchView {
-
    active_component: Cid,
-
    patch: (PatchId, Patch),
-
}
-

-
impl PatchView {
-
    pub fn new(patch: (PatchId, Patch)) -> Self {
-
        PatchView {
-
            active_component: Cid::Patch(PatchCid::Activity),
-
            patch,
-
        }
-
    }
-
}
-

-
impl ViewPage for PatchView {
-
    fn mount(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let (id, patch) = &self.patch;
-
        let navigation = widget::patch::navigation(theme);
-
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
-

-
        let activity = widget::patch::activity(theme, (*id, patch), context.profile()).to_boxed();
-
        let files = widget::patch::files(theme, (*id, patch), context.profile()).to_boxed();
-

-
        app.remount(Cid::Patch(PatchCid::Header), header, vec![])?;
-
        app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
-
        app.remount(Cid::Patch(PatchCid::Files), files, vec![])?;
-

-
        app.active(&self.active_component)?;
-

-
        Ok(())
-
    }
-

-
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::Patch(PatchCid::Header))?;
-
        app.umount(&Cid::Patch(PatchCid::Activity))?;
-
        app.umount(&Cid::Patch(PatchCid::Files))?;
-
        Ok(())
-
    }
-

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        _context: &Context,
-
        _theme: &Theme,
-
        message: Message,
-
    ) -> Result<()> {
-
        if let Message::NavigationChanged(index) = message {
-
            self.active_component = Cid::Patch(PatchCid::from(index as usize));
-
        }
-
        app.active(&self.active_component)?;
-

-
        Ok(())
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let layout = layout::default_page(area);
-

-
        app.view(&Cid::Patch(PatchCid::Header), frame, layout[0]);
-
        app.view(&self.active_component, frame, layout[1]);
-
    }
-

-
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.subscribe(
-
            &Cid::Patch(PatchCid::Header),
-
            Sub::new(subscription::navigation_clause(), SubClause::Always),
-
        )?;
-

-
        Ok(())
-
    }
-

-
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.unsubscribe(
-
            &Cid::Patch(PatchCid::Header),
-
            subscription::navigation_clause(),
-
        )?;
-

-
        Ok(())
-
    }
-
}
-

-
/// View pages need to preserve their state (e.g. selected navigation tab, contents
-
/// and the selected row of a table). Therefor they should not be (re-)created
-
/// each time they are displayed.
-
/// Instead the application can push a new page onto the page stack if it needs to
-
/// be displayed. Its components are then created using the internal state. If a
-
/// new page needs to be displayed, it will also be pushed onto the stack. Leaving
-
/// that page again will pop it from the stack. The application can then return to
-
/// the previously displayed page in the state it was left.
-
#[derive(Default)]
-
pub struct PageStack {
-
    pages: Vec<Box<dyn ViewPage>>,
-
}
-

-
impl PageStack {
-
    pub fn push(
-
        &mut self,
-
        page: Box<dyn ViewPage>,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        if let Some(page) = self.pages.last() {
-
            page.unsubscribe(app)?;
-
        }
-

-
        page.mount(app, context, theme)?;
-
        page.subscribe(app)?;
-

-
        self.pages.push(page);
-

-
        Ok(())
-
    }
-

-
    pub fn pop(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        self.peek_mut()?.unsubscribe(app)?;
-
        self.peek_mut()?.unmount(app)?;
-
        self.pages.pop();
-

-
        self.peek_mut()?.subscribe(app)?;
-

-
        Ok(())
-
    }
-

-
    pub fn peek_mut(&mut self) -> Result<&mut Box<dyn ViewPage>> {
-
        match self.pages.last_mut() {
-
            Some(page) => Ok(page),
-
            None => Err(anyhow::anyhow!(
-
                "Could not peek active page. Page stack is empty."
-
            )),
-
        }
-
    }
-
}
-

-
impl From<usize> for HomeCid {
-
    fn from(index: usize) -> Self {
-
        match index {
-
            0 => HomeCid::Dashboard,
-
            1 => HomeCid::IssueBrowser,
-
            2 => HomeCid::PatchBrowser,
-
            _ => HomeCid::Dashboard,
-
        }
-
    }
-
}
-

-
impl From<usize> for PatchCid {
-
    fn from(index: usize) -> Self {
-
        match index {
-
            0 => PatchCid::Activity,
-
            1 => PatchCid::Files,
-
            _ => PatchCid::Activity,
-
        }
-
    }
-
}
deleted radicle-tui/src/app/subscription.rs
@@ -1,31 +0,0 @@
-
use std::hash::Hash;
-

-
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
-
use tuirealm::{Sub, SubClause, SubEventClause};
-

-
pub fn navigation_clause<UserEvent>() -> SubEventClause<UserEvent>
-
where
-
    UserEvent: Clone + Eq + PartialEq + PartialOrd,
-
{
-
    SubEventClause::Keyboard(KeyEvent {
-
        code: Key::Tab,
-
        modifiers: KeyModifiers::NONE,
-
    })
-
}
-

-
pub fn global<Id, UserEvent>() -> Vec<Sub<Id, UserEvent>>
-
where
-
    Id: Clone + Hash + Eq + PartialEq,
-
    UserEvent: Clone + Eq + PartialEq + PartialOrd,
-
{
-
    vec![
-
        Sub::new(
-
            SubEventClause::Keyboard(KeyEvent {
-
                code: Key::Char('q'),
-
                modifiers: KeyModifiers::NONE,
-
            }),
-
            SubClause::Always,
-
        ),
-
        Sub::new(SubEventClause::WindowResize, SubClause::Always),
-
    ]
-
}
deleted radicle-tui/src/cob.rs
@@ -1,2 +0,0 @@
-
pub mod issue;
-
pub mod patch;
deleted radicle-tui/src/cob/issue.rs
@@ -1,19 +0,0 @@
-
use anyhow::Result;
-
use radicle::cob::issue::{Issue, IssueId, Issues};
-
use radicle::storage::git::Repository;
-

-
pub fn all(repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
-
    let patches = Issues::open(repository)?
-
        .all()
-
        .map(|iter| iter.flatten().collect::<Vec<_>>())?;
-

-
    Ok(patches
-
        .into_iter()
-
        .map(|(id, issue)| (id, issue))
-
        .collect::<Vec<_>>())
-
}
-

-
pub fn find(repository: &Repository, id: &IssueId) -> Result<Option<Issue>> {
-
    let issues = Issues::open(repository)?;
-
    Ok(issues.get(id)?)
-
}
deleted radicle-tui/src/cob/patch.rs
@@ -1,20 +0,0 @@
-
use anyhow::Result;
-

-
use radicle::cob::patch::{Patch, PatchId, Patches};
-
use radicle::storage::git::Repository;
-

-
pub fn all(repository: &Repository) -> Result<Vec<(PatchId, Patch)>> {
-
    let patches = Patches::open(repository)?
-
        .all()
-
        .map(|iter| iter.flatten().collect::<Vec<_>>())?;
-

-
    Ok(patches
-
        .into_iter()
-
        .map(|(id, patch)| (id, patch))
-
        .collect::<Vec<_>>())
-
}
-

-
pub fn find(repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
-
    let patches = Patches::open(repository)?;
-
    Ok(patches.get(id)?)
-
}
deleted radicle-tui/src/lib.rs
@@ -1,98 +0,0 @@
-
use std::hash::Hash;
-
use std::time::Duration;
-

-
use anyhow::Result;
-

-
use tuirealm::terminal::TerminalBridge;
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::Frame;
-
use tuirealm::{Application, EventListenerCfg, NoUserEvent};
-

-
pub mod cob;
-
pub mod ui;
-

-
/// Trait that must be implemented by client applications in order to be run
-
/// as tui-application using tui-realm. Implementors act as models to the
-
/// tui-realm application that can be polled for new messages, updated
-
/// accordingly and rendered with new state.
-
///
-
/// Please see `examples/` for further information on how to use it.
-
pub trait Tui<Id, Message>
-
where
-
    Id: Eq + PartialEq + Clone + Hash,
-
    Message: Eq,
-
{
-
    /// Should initialize an application by mounting and activating components.
-
    fn init(&mut self, app: &mut Application<Id, Message, NoUserEvent>) -> Result<()>;
-

-
    /// Should update the current state by handling a message from the view. Returns true
-
    /// if view should be updated (e.g. a message was received and the current state changed).
-
    fn update(&mut self, app: &mut Application<Id, Message, NoUserEvent>) -> Result<bool>;
-

-
    /// Should draw the application to a frame.
-
    fn view(&mut self, app: &mut Application<Id, Message, NoUserEvent>, frame: &mut Frame);
-

-
    /// Should return true if the application is requested to quit.
-
    fn quit(&self) -> bool;
-
}
-

-
/// A tui-window using the cross-platform Terminal helper provided
-
/// by tui-realm.
-
pub struct Window {
-
    /// Helper around `Terminal` to quickly setup and perform on terminal.
-
    pub terminal: TerminalBridge,
-
}
-

-
impl Default for Window {
-
    fn default() -> Self {
-
        Self::new()
-
    }
-
}
-

-
/// Provides a way to create and run a new tui-application.
-
impl Window {
-
    /// Creates a tui-window using the default cross-platform Terminal
-
    /// helper and panics if its creation fails.
-
    pub fn new() -> Self {
-
        Self {
-
            terminal: TerminalBridge::new().expect("Cannot create terminal bridge"),
-
        }
-
    }
-

-
    /// Runs this tui-window with the tui-application given and performs the
-
    /// following steps:
-
    /// 1. Enter alternative terminal screen
-
    /// 2. Run main loop until application should quit and with each iteration
-
    ///    - poll new events (tick or user event)
-
    ///    - update application state
-
    ///    - redraw view
-
    /// 3. Leave alternative terminal screen
-
    pub fn run<T, Id, Message>(&mut self, tui: &mut T, interval: u64) -> Result<()>
-
    where
-
        T: Tui<Id, Message>,
-
        Id: Eq + PartialEq + Clone + Hash,
-
        Message: Eq,
-
    {
-
        let mut update = true;
-
        let mut resize = false;
-
        let mut size = Rect::default();
-
        let mut app = Application::init(
-
            EventListenerCfg::default().default_input_listener(Duration::from_millis(interval)),
-
        );
-
        tui.init(&mut app)?;
-

-
        while !tui.quit() {
-
            if update || resize {
-
                self.terminal
-
                    .raw_mut()
-
                    .draw(|frame| tui.view(&mut app, frame))?;
-
            }
-
            update = tui.update(&mut app)?;
-

-
            resize = size != self.terminal.raw().size()?;
-
            size = self.terminal.raw().size()?;
-
        }
-

-
        Ok(())
-
    }
-
}
deleted radicle-tui/src/main.rs
@@ -1,84 +0,0 @@
-
use std::process;
-

-
use anyhow::{anyhow, Context};
-

-
use radicle::storage::ReadStorage;
-

-
use radicle_cli as cli;
-
use radicle_term as term;
-
use radicle_tui::Window;
-

-
mod app;
-

-
pub const NAME: &str = "radicle-tui";
-
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
-
pub const GIT_HEAD: &str = env!("GIT_HEAD");
-
pub const FPS: u64 = 60;
-

-
pub const HELP: &str = r#"
-
Usage
-

-
    radicle-tui [<option>...]
-

-
Options
-

-
    --version       Print version
-
    --help          Print help
-

-
"#;
-

-
struct Options;
-

-
impl Options {
-
    fn from_env() -> Result<Self, anyhow::Error> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_env();
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("version") => {
-
                    println!("{NAME} {VERSION}+{GIT_HEAD}");
-
                    process::exit(0);
-
                }
-
                Long("help") | Short('h') => {
-
                    println!("{HELP}");
-
                    process::exit(0);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok(Self {})
-
    }
-
}
-

-
fn execute() -> anyhow::Result<()> {
-
    let _ = Options::from_env()?;
-

-
    let (_, id) = radicle::rad::cwd()
-
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
-

-
    let profile = cli::terminal::profile()?;
-

-
    let signer = cli::terminal::signer(&profile)?;
-
    let storage = &profile.storage;
-

-
    let payload = storage
-
        .get(signer.public_key(), id)?
-
        .context("No project with such `id` exists")?;
-

-
    let project = payload.project()?;
-

-
    let mut window = Window::default();
-
    window.run(&mut app::App::new(profile, id, project), 1000 / FPS)?;
-

-
    Ok(())
-
}
-

-
fn main() {
-
    if let Err(err) = execute() {
-
        term::error(format!("Error: rad-tui: {err}"));
-
        process::exit(1);
-
    }
-
}
deleted radicle-tui/src/ui.rs
@@ -1,7 +0,0 @@
-
pub mod cob;
-
pub mod context;
-
pub mod ext;
-
pub mod layout;
-
pub mod state;
-
pub mod theme;
-
pub mod widget;
deleted radicle-tui/src/ui/cob.rs
@@ -1,360 +0,0 @@
-
use radicle_surf;
-

-
use cli::terminal::format;
-
use radicle_cli as cli;
-

-
use radicle::prelude::Did;
-
use radicle::storage::git::Repository;
-
use radicle::storage::{Oid, ReadRepository};
-
use radicle::Profile;
-

-
use radicle::cob::issue::{Issue, IssueId, State as IssueState};
-
use radicle::cob::patch::{Patch, PatchId, State as PatchState};
-
use radicle::cob::{Tag, Timestamp};
-

-
use tuirealm::props::{Color, Style};
-
use tuirealm::tui::text::{Span, Spans};
-
use tuirealm::tui::widgets::Cell;
-

-
use crate::ui::theme::Theme;
-
use crate::ui::widget::common::list::TableItem;
-

-
use super::widget::common::list::ListItem;
-

-
/// An author item that can be used in tables, list or trees.
-
///
-
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
-
/// would be needed if [`AuthorItem`] would be used directly.
-
#[derive(Clone)]
-
pub struct AuthorItem {
-
    /// The author's DID.
-
    did: Did,
-
    /// True if the author is the current user.
-
    is_you: bool,
-
}
-

-
impl AuthorItem {
-
    pub fn did(&self) -> Did {
-
        self.did
-
    }
-

-
    pub fn is_you(&self) -> bool {
-
        self.is_you
-
    }
-
}
-

-
/// A patch item that can be used in tables, list or trees.
-
///
-
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
-
/// would be needed if [`Patch`] would be used directly.
-
#[derive(Clone)]
-
pub struct PatchItem {
-
    /// Patch OID.
-
    id: PatchId,
-
    /// Patch state.
-
    state: PatchState,
-
    /// Patch title.
-
    title: String,
-
    /// Author of the latest revision.
-
    author: AuthorItem,
-
    /// Head of the latest revision.
-
    head: Oid,
-
    /// Lines added by the latest revision.
-
    added: u16,
-
    /// Lines removed by the latest revision.
-
    removed: u16,
-
    /// Time when patch was opened.
-
    timestamp: Timestamp,
-
}
-

-
impl PatchItem {
-
    pub fn id(&self) -> &PatchId {
-
        &self.id
-
    }
-

-
    pub fn state(&self) -> &PatchState {
-
        &self.state
-
    }
-

-
    pub fn title(&self) -> &String {
-
        &self.title
-
    }
-

-
    pub fn author(&self) -> &AuthorItem {
-
        &self.author
-
    }
-

-
    pub fn head(&self) -> &Oid {
-
        &self.head
-
    }
-

-
    pub fn added(&self) -> u16 {
-
        self.added
-
    }
-

-
    pub fn removed(&self) -> u16 {
-
        self.removed
-
    }
-

-
    pub fn timestamp(&self) -> &Timestamp {
-
        &self.timestamp
-
    }
-
}
-

-
impl TryFrom<(&Profile, &Repository, PatchId, Patch)> for PatchItem {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: (&Profile, &Repository, PatchId, Patch)) -> Result<Self, Self::Error> {
-
        let (profile, repo, id, patch) = value;
-
        let (_, rev) = patch.latest();
-
        let repo = radicle_surf::Repository::open(repo.path())?;
-
        let base = repo.commit(rev.base())?;
-
        let head = repo.commit(rev.head())?;
-
        let diff = repo.diff(base.id, head.id)?;
-

-
        Ok(PatchItem {
-
            id,
-
            state: patch.state().clone(),
-
            title: patch.title().into(),
-
            author: AuthorItem {
-
                did: patch.author().id,
-
                is_you: *patch.author().id == *profile.did(),
-
            },
-
            head: rev.head(),
-
            added: diff.stats().insertions as u16,
-
            removed: diff.stats().deletions as u16,
-
            timestamp: rev.timestamp(),
-
        })
-
    }
-
}
-

-
impl TableItem<8> for PatchItem {
-
    fn row(&self, theme: &Theme) -> [Cell; 8] {
-
        let (icon, color) = format_patch_state(&self.state);
-
        let state = Cell::from(icon).style(Style::default().fg(color));
-

-
        let id = Cell::from(format::cob(&self.id))
-
            .style(Style::default().fg(theme.colors.browser_list_id));
-

-
        let title = Cell::from(self.title.clone())
-
            .style(Style::default().fg(theme.colors.browser_list_title));
-

-
        let author = Cell::from(format_author(&self.author.did, self.author.is_you))
-
            .style(Style::default().fg(theme.colors.browser_list_author));
-

-
        let head = Cell::from(format::oid(self.head).item)
-
            .style(Style::default().fg(theme.colors.browser_patch_list_head));
-

-
        let added = Cell::from(format!("{}", self.added))
-
            .style(Style::default().fg(theme.colors.browser_patch_list_added));
-

-
        let removed = Cell::from(format!("{}", self.removed))
-
            .style(Style::default().fg(theme.colors.browser_patch_list_removed));
-

-
        let updated = Cell::from(format::timestamp(&self.timestamp).to_string())
-
            .style(Style::default().fg(theme.colors.browser_list_timestamp));
-

-
        [state, id, title, author, head, added, removed, updated]
-
    }
-
}
-

-
/// An issue item that can be used in tables, list or trees.
-
///
-
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
-
/// would be needed if [`Issue`] would be used directly.
-
#[derive(Clone)]
-
pub struct IssueItem {
-
    /// Issue OID.
-
    id: IssueId,
-
    /// Issue state.
-
    state: IssueState,
-
    /// Issue title.
-
    title: String,
-
    /// Issue author.
-
    author: AuthorItem,
-
    /// Issue tags.
-
    tags: Vec<Tag>,
-
    /// Issue assignees.
-
    assignees: Vec<AuthorItem>,
-
    /// Time when issue was opened.
-
    timestamp: Timestamp,
-
}
-

-
impl IssueItem {
-
    pub fn id(&self) -> &IssueId {
-
        &self.id
-
    }
-

-
    pub fn state(&self) -> &IssueState {
-
        &self.state
-
    }
-

-
    pub fn title(&self) -> &String {
-
        &self.title
-
    }
-

-
    pub fn author(&self) -> &AuthorItem {
-
        &self.author
-
    }
-

-
    pub fn tags(&self) -> &Vec<Tag> {
-
        &self.tags
-
    }
-

-
    pub fn assignees(&self) -> &Vec<AuthorItem> {
-
        &self.assignees
-
    }
-

-
    pub fn timestamp(&self) -> &Timestamp {
-
        &self.timestamp
-
    }
-
}
-

-
impl From<(&Profile, &Repository, IssueId, Issue)> for IssueItem {
-
    fn from(value: (&Profile, &Repository, IssueId, Issue)) -> Self {
-
        let (profile, _, id, issue) = value;
-

-
        IssueItem {
-
            id,
-
            state: *issue.state(),
-
            title: issue.title().into(),
-
            author: AuthorItem {
-
                did: issue.author().id,
-
                is_you: *issue.author().id == *profile.did(),
-
            },
-
            tags: issue.tags().cloned().collect(),
-
            assignees: issue
-
                .assigned()
-
                .map(|did| AuthorItem {
-
                    did,
-
                    is_you: did == profile.did(),
-
                })
-
                .collect::<Vec<_>>(),
-
            timestamp: issue.timestamp(),
-
        }
-
    }
-
}
-

-
impl TableItem<7> for IssueItem {
-
    fn row(&self, theme: &Theme) -> [Cell; 7] {
-
        let (icon, color) = format_issue_state(&self.state);
-
        let state = Cell::from(icon).style(Style::default().fg(color));
-

-
        let id = Cell::from(format::cob(&self.id))
-
            .style(Style::default().fg(theme.colors.browser_list_id));
-

-
        let title = Cell::from(self.title.clone())
-
            .style(Style::default().fg(theme.colors.browser_list_title));
-

-
        let author = Cell::from(format_author(&self.author.did, self.author.is_you))
-
            .style(Style::default().fg(theme.colors.browser_list_author));
-

-
        let tags = Cell::from(format_tags(&self.tags))
-
            .style(Style::default().fg(theme.colors.browser_list_tags));
-

-
        let assignees = self
-
            .assignees
-
            .iter()
-
            .map(|author| (author.did, author.is_you))
-
            .collect::<Vec<_>>();
-
        let assignees = Cell::from(format_assignees(&assignees))
-
            .style(Style::default().fg(theme.colors.browser_list_author));
-

-
        let opened = Cell::from(format::timestamp(&self.timestamp).to_string())
-
            .style(Style::default().fg(theme.colors.browser_list_timestamp));
-

-
        [state, id, title, author, tags, assignees, opened]
-
    }
-
}
-

-
impl ListItem for IssueItem {
-
    fn row(&self, theme: &Theme) -> tuirealm::tui::widgets::ListItem {
-
        let (state, state_color) = format_issue_state(&self.state);
-
        let lines = vec![
-
            Spans::from(vec![
-
                Span::styled(state, Style::default().fg(state_color)),
-
                Span::styled(
-
                    self.title.clone(),
-
                    Style::default().fg(theme.colors.browser_list_title),
-
                ),
-
            ]),
-
            Spans::from(vec![
-
                Span::raw(String::from("   ")),
-
                Span::styled(
-
                    format_author(&self.author.did, self.author.is_you),
-
                    Style::default().fg(theme.colors.browser_list_author),
-
                ),
-
                Span::styled(
-
                    format!(" {} ", theme.icons.property_divider),
-
                    Style::default().fg(theme.colors.property_divider_fg),
-
                ),
-
                Span::styled(
-
                    format::timestamp(&self.timestamp).to_string(),
-
                    Style::default().fg(theme.colors.browser_list_timestamp),
-
                ),
-
            ]),
-
        ];
-
        tuirealm::tui::widgets::ListItem::new(lines)
-
    }
-
}
-

-
impl PartialEq for IssueItem {
-
    fn eq(&self, other: &Self) -> bool {
-
        self.id == other.id
-
    }
-
}
-

-
pub fn format_patch_state(state: &PatchState) -> (String, Color) {
-
    match state {
-
        PatchState::Open { conflicts: _ } => (" ● ".into(), Color::Green),
-
        PatchState::Archived => (" ● ".into(), Color::Yellow),
-
        PatchState::Draft => (" ● ".into(), Color::Gray),
-
        PatchState::Merged {
-
            revision: _,
-
            commit: _,
-
        } => (" ✔ ".into(), Color::Blue),
-
    }
-
}
-

-
pub fn format_author(did: &Did, is_you: bool) -> String {
-
    if is_you {
-
        format!("{} (you)", format::did(did))
-
    } else {
-
        format!("{}", format::did(did))
-
    }
-
}
-

-
pub fn format_issue_state(state: &IssueState) -> (String, Color) {
-
    match state {
-
        IssueState::Open => (" ● ".into(), Color::Green),
-
        IssueState::Closed { reason: _ } => (" ● ".into(), Color::Red),
-
    }
-
}
-

-
pub fn format_tags(tags: &[Tag]) -> String {
-
    let mut output = String::new();
-
    let mut tags = tags.iter().peekable();
-

-
    while let Some(tag) = tags.next() {
-
        output.push_str(&tag.to_string());
-

-
        if tags.peek().is_some() {
-
            output.push(',');
-
        }
-
    }
-
    output
-
}
-

-
pub fn format_assignees(assignees: &[(Did, bool)]) -> String {
-
    let mut output = String::new();
-
    let mut assignees = assignees.iter().peekable();
-

-
    while let Some((assignee, is_you)) = assignees.next() {
-
        output.push_str(&format_author(assignee, *is_you));
-

-
        if assignees.peek().is_some() {
-
            output.push(',');
-
        }
-
    }
-
    output
-
}
deleted radicle-tui/src/ui/context.rs
@@ -1,40 +0,0 @@
-
use radicle::prelude::{Id, Project};
-
use radicle::Profile;
-

-
use radicle::storage::git::Repository;
-
use radicle::storage::ReadStorage;
-
pub struct Context {
-
    profile: Profile,
-
    id: Id,
-
    project: Project,
-
    repository: Repository,
-
}
-

-
impl Context {
-
    pub fn new(profile: Profile, id: Id, project: Project) -> Self {
-
        let repository = profile.storage.repository(id).unwrap();
-

-
        Self {
-
            id,
-
            profile,
-
            project,
-
            repository,
-
        }
-
    }
-

-
    pub fn profile(&self) -> &Profile {
-
        &self.profile
-
    }
-

-
    pub fn id(&self) -> &Id {
-
        &self.id
-
    }
-

-
    pub fn project(&self) -> &Project {
-
        &self.project
-
    }
-

-
    pub fn repository(&self) -> &Repository {
-
        &self.repository
-
    }
-
}
deleted radicle-tui/src/ui/ext.rs
@@ -1,113 +0,0 @@
-
use tuirealm::tui::buffer::Buffer;
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::tui::style::Style;
-
use tuirealm::tui::widgets::{BorderType, Borders, Widget};
-

-
pub struct HeaderBlock {
-
    /// Visible borders
-
    borders: Borders,
-
    /// Border style
-
    border_style: Style,
-
    /// Type of the border. The default is plain lines but one can choose to have rounded corners
-
    /// or doubled lines instead.
-
    border_type: BorderType,
-
    /// Widget style
-
    style: Style,
-
}
-

-
impl Default for HeaderBlock {
-
    fn default() -> HeaderBlock {
-
        HeaderBlock {
-
            borders: Borders::NONE,
-
            border_style: Default::default(),
-
            border_type: BorderType::Plain,
-
            style: Default::default(),
-
        }
-
    }
-
}
-

-
impl HeaderBlock {
-
    pub fn border_style(mut self, style: Style) -> HeaderBlock {
-
        self.border_style = style;
-
        self
-
    }
-

-
    pub fn style(mut self, style: Style) -> HeaderBlock {
-
        self.style = style;
-
        self
-
    }
-

-
    pub fn borders(mut self, flag: Borders) -> HeaderBlock {
-
        self.borders = flag;
-
        self
-
    }
-

-
    pub fn border_type(mut self, border_type: BorderType) -> HeaderBlock {
-
        self.border_type = border_type;
-
        self
-
    }
-
}
-

-
impl Widget for HeaderBlock {
-
    fn render(self, area: Rect, buf: &mut Buffer) {
-
        if area.area() == 0 {
-
            return;
-
        }
-
        buf.set_style(area, self.style);
-
        let symbols = BorderType::line_symbols(self.border_type);
-

-
        // Sides
-
        if self.borders.intersects(Borders::LEFT) {
-
            for y in area.top()..area.bottom() {
-
                buf.get_mut(area.left(), y)
-
                    .set_symbol(symbols.vertical)
-
                    .set_style(self.border_style);
-
            }
-
        }
-
        if self.borders.intersects(Borders::TOP) {
-
            for x in area.left()..area.right() {
-
                buf.get_mut(x, area.top())
-
                    .set_symbol(symbols.horizontal)
-
                    .set_style(self.border_style);
-
            }
-
        }
-
        if self.borders.intersects(Borders::RIGHT) {
-
            let x = area.right() - 1;
-
            for y in area.top()..area.bottom() {
-
                buf.get_mut(x, y)
-
                    .set_symbol(symbols.vertical)
-
                    .set_style(self.border_style);
-
            }
-
        }
-
        if self.borders.intersects(Borders::BOTTOM) {
-
            let y = area.bottom() - 1;
-
            for x in area.left()..area.right() {
-
                buf.get_mut(x, y)
-
                    .set_symbol(symbols.horizontal)
-
                    .set_style(self.border_style);
-
            }
-
        }
-

-
        // Corners
-
        if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
-
            buf.get_mut(area.right() - 1, area.bottom() - 1)
-
                .set_symbol(symbols.vertical_left)
-
                .set_style(self.border_style);
-
        }
-
        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
-
            buf.get_mut(area.right() - 1, area.top())
-
                .set_symbol(symbols.top_right)
-
                .set_style(self.border_style);
-
        }
-
        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
-
            buf.get_mut(area.left(), area.bottom() - 1)
-
                .set_symbol(symbols.vertical_right)
-
                .set_style(self.border_style);
-
        }
-
        if self.borders.contains(Borders::LEFT | Borders::TOP) {
-
            buf.get_mut(area.left(), area.top())
-
                .set_symbol(symbols.top_left)
-
                .set_style(self.border_style);
-
        }
-
    }
-
}
deleted radicle-tui/src/ui/layout.rs
@@ -1,210 +0,0 @@
-
use tuirealm::props::{AttrValue, Attribute};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::MockComponent;
-

-
pub struct AppHeader {
-
    pub nav: Rect,
-
    pub info: Rect,
-
    pub line: Rect,
-
}
-

-
pub struct IssuePreview {
-
    pub header: Rect,
-
    pub list: Rect,
-
    pub details: Rect,
-
    pub discussion: Rect,
-
    pub shortcuts: Rect,
-
}
-

-
pub fn v_stack(
-
    widgets: Vec<Box<dyn MockComponent>>,
-
    area: Rect,
-
) -> Vec<(Box<dyn MockComponent>, Rect)> {
-
    let constraints = widgets
-
        .iter()
-
        .map(|w| {
-
            Constraint::Length(
-
                w.query(Attribute::Height)
-
                    .unwrap_or(AttrValue::Size(0))
-
                    .unwrap_size(),
-
            )
-
        })
-
        .collect::<Vec<_>>();
-
    let layout = Layout::default()
-
        .direction(Direction::Vertical)
-
        .constraints(constraints)
-
        .split(area);
-

-
    widgets.into_iter().zip(layout.into_iter()).collect()
-
}
-

-
pub fn h_stack(
-
    widgets: Vec<Box<dyn MockComponent>>,
-
    area: Rect,
-
) -> Vec<(Box<dyn MockComponent>, Rect)> {
-
    let constraints = widgets
-
        .iter()
-
        .map(|w| {
-
            Constraint::Length(
-
                w.query(Attribute::Width)
-
                    .unwrap_or(AttrValue::Size(0))
-
                    .unwrap_size(),
-
            )
-
        })
-
        .collect::<Vec<_>>();
-
    let layout = Layout::default()
-
        .direction(Direction::Horizontal)
-
        .constraints(constraints)
-
        .split(area);
-

-
    widgets.into_iter().zip(layout.into_iter()).collect()
-
}
-

-
pub fn app_header(area: Rect, info_w: u16) -> AppHeader {
-
    let nav_w = area.width.saturating_sub(info_w);
-

-
    let layout = Layout::default()
-
        .direction(Direction::Vertical)
-
        .constraints(vec![
-
            Constraint::Length(1),
-
            Constraint::Length(1),
-
            Constraint::Length(1),
-
        ])
-
        .split(area);
-

-
    let top = Layout::default()
-
        .direction(Direction::Horizontal)
-
        .constraints([Constraint::Length(nav_w), Constraint::Length(info_w)].as_ref())
-
        .split(layout[1]);
-

-
    AppHeader {
-
        nav: top[0],
-
        info: top[1],
-
        line: layout[2],
-
    }
-
}
-

-
pub fn default_page(area: Rect) -> Vec<Rect> {
-
    let nav_h = 3u16;
-
    let margin_h = 1u16;
-
    let content_h = area.height.saturating_sub(nav_h.saturating_add(margin_h));
-

-
    Layout::default()
-
        .direction(Direction::Vertical)
-
        .horizontal_margin(margin_h)
-
        .constraints([Constraint::Length(nav_h), Constraint::Length(content_h)].as_ref())
-
        .split(area)
-
}
-

-
pub fn headerless_page(area: Rect) -> Vec<Rect> {
-
    let margin_h = 1u16;
-
    let content_h = area.height.saturating_sub(margin_h);
-

-
    Layout::default()
-
        .direction(Direction::Vertical)
-
        .horizontal_margin(margin_h)
-
        .constraints([Constraint::Length(content_h)].as_ref())
-
        .split(area)
-
}
-

-
pub fn root_component(area: Rect, shortcuts_h: u16) -> Vec<Rect> {
-
    let content_h = area.height.saturating_sub(shortcuts_h);
-

-
    Layout::default()
-
        .direction(Direction::Vertical)
-
        .constraints(
-
            [
-
                Constraint::Length(content_h),
-
                Constraint::Length(shortcuts_h),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(area)
-
}
-

-
pub fn root_component_with_context(area: Rect, context_h: u16, shortcuts_h: u16) -> Vec<Rect> {
-
    let content_h = area
-
        .height
-
        .saturating_sub(shortcuts_h.saturating_add(context_h));
-

-
    Layout::default()
-
        .direction(Direction::Vertical)
-
        .constraints(
-
            [
-
                Constraint::Length(content_h),
-
                Constraint::Length(context_h),
-
                Constraint::Length(shortcuts_h),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(area)
-
}
-

-
pub fn centered_label(label_w: u16, area: Rect) -> Rect {
-
    let label_h = 1u16;
-
    let spacer_w = area.width.saturating_sub(label_w).saturating_div(2);
-
    let spacer_h = area.height.saturating_sub(label_h).saturating_div(2);
-

-
    let layout = Layout::default()
-
        .direction(Direction::Vertical)
-
        .constraints(
-
            [
-
                Constraint::Length(spacer_h),
-
                Constraint::Length(label_h),
-
                Constraint::Length(spacer_h),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(area);
-

-
    Layout::default()
-
        .direction(Direction::Horizontal)
-
        .constraints(
-
            [
-
                Constraint::Length(spacer_w),
-
                Constraint::Length(label_w),
-
                Constraint::Length(spacer_w),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(layout[1])[1]
-
}
-

-
pub fn issue_preview(area: Rect, shortcuts_h: u16) -> IssuePreview {
-
    let header_h = 3u16;
-
    let content_h = area
-
        .height
-
        .saturating_sub(header_h)
-
        .saturating_sub(shortcuts_h);
-

-
    let root = Layout::default()
-
        .direction(Direction::Vertical)
-
        .horizontal_margin(1)
-
        .constraints(
-
            [
-
                Constraint::Length(header_h),
-
                Constraint::Length(content_h),
-
                Constraint::Length(shortcuts_h),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(area);
-

-
    let split = Layout::default()
-
        .direction(Direction::Horizontal)
-
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
-
        .split(root[1]);
-

-
    let right = Layout::default()
-
        .direction(Direction::Vertical)
-
        .constraints([Constraint::Length(6), Constraint::Min(0)].as_ref())
-
        .split(split[1]);
-

-
    IssuePreview {
-
        header: root[0],
-
        list: split[0],
-
        details: right[0],
-
        discussion: right[1],
-
        shortcuts: root[2],
-
    }
-
}
deleted radicle-tui/src/ui/state.rs
@@ -1,85 +0,0 @@
-
use tuirealm::tui::widgets::{ListState, TableState};
-

-
/// State that holds the index of a selected tab item and the count of all tab items.
-
/// The index can be increased and will start at 0, if length was reached.
-
#[derive(Clone, Default)]
-
pub struct TabState {
-
    pub selected: u16,
-
    pub len: u16,
-
}
-

-
impl TabState {
-
    pub fn incr_tab_index(&mut self, rewind: bool) {
-
        if self.selected + 1 < self.len {
-
            self.selected += 1;
-
        } else if rewind {
-
            self.selected = 0;
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
-
pub struct ItemState {
-
    selected: Option<usize>,
-
    len: usize,
-
}
-

-
impl ItemState {
-
    pub fn new(selected: Option<usize>, len: usize) -> Self {
-
        Self { selected, len }
-
    }
-

-
    pub fn selected(&self) -> Option<usize> {
-
        self.selected
-
    }
-

-
    pub fn select_previous(&mut self) -> Option<usize> {
-
        let old_index = self.selected();
-
        let new_index = match old_index {
-
            Some(selected) if selected == 0 => Some(0),
-
            Some(selected) => Some(selected.saturating_sub(1)),
-
            None => Some(0),
-
        };
-

-
        if old_index != new_index {
-
            self.selected = new_index;
-
            self.selected()
-
        } else {
-
            None
-
        }
-
    }
-

-
    pub fn select_next(&mut self) -> Option<usize> {
-
        let old_index = self.selected();
-
        let new_index = match old_index {
-
            Some(selected) if selected >= self.len.saturating_sub(1) => {
-
                Some(self.len.saturating_sub(1))
-
            }
-
            Some(selected) => Some(selected.saturating_add(1)),
-
            None => Some(0),
-
        };
-

-
        if old_index != new_index {
-
            self.selected = new_index;
-
            self.selected()
-
        } else {
-
            None
-
        }
-
    }
-
}
-

-
impl From<&ItemState> for TableState {
-
    fn from(value: &ItemState) -> Self {
-
        let mut state = TableState::default();
-
        state.select(value.selected);
-
        state
-
    }
-
}
-

-
impl From<&ItemState> for ListState {
-
    fn from(value: &ItemState) -> Self {
-
        let mut state = ListState::default();
-
        state.select(value.selected);
-
        state
-
    }
-
}
deleted radicle-tui/src/ui/theme.rs
@@ -1,122 +0,0 @@
-
use tuirealm::props::Color;
-

-
const COLOR_DEFAULT_FG: Color = Color::Rgb(200, 200, 200);
-
const COLOR_DEFAULT_DARK_FG: Color = Color::Rgb(150, 150, 150);
-
const COLOR_DEFAULT_DARK: Color = Color::Rgb(100, 100, 100);
-
const COLOR_DEFAULT_DARKER: Color = Color::Rgb(70, 70, 70);
-
const COLOR_DEFAULT_DARKEST: Color = Color::Rgb(40, 40, 40);
-
const COLOR_DEFAULT_FAINT: Color = Color::Rgb(20, 20, 20);
-

-
#[derive(Debug, Clone)]
-
pub struct Colors {
-
    pub default_fg: Color,
-
    pub tabs_highlighted_fg: Color,
-
    pub app_header_project_fg: Color,
-
    pub app_header_rid_fg: Color,
-
    pub labeled_container_bg: Color,
-
    pub item_list_highlighted_bg: Color,
-
    pub property_name_fg: Color,
-
    pub property_divider_fg: Color,
-
    pub shortcut_short_fg: Color,
-
    pub shortcut_long_fg: Color,
-
    pub shortcutbar_divider_fg: Color,
-
    pub browser_list_id: Color,
-
    pub browser_list_title: Color,
-
    pub browser_list_description: Color,
-
    pub browser_list_author: Color,
-
    pub browser_list_tags: Color,
-
    pub browser_list_comments: Color,
-
    pub browser_list_timestamp: Color,
-
    pub browser_patch_list_head: Color,
-
    pub browser_patch_list_added: Color,
-
    pub browser_patch_list_removed: Color,
-
    pub context_bg: Color,
-
    pub context_light_bg: Color,
-
    pub context_badge_bg: Color,
-
    pub context_id_fg: Color,
-
    pub context_id_bg: Color,
-
    pub context_id_author_fg: Color,
-
    pub container_border_fg: Color,
-
    pub container_border_focus_fg: Color,
-
}
-

-
#[derive(Debug, Clone)]
-
pub struct Icons {
-
    pub property_divider: char,
-
    pub shortcutbar_divider: char,
-
    pub tab_divider: char,
-
    pub tab_overline: char,
-
    pub whitespace: char,
-
}
-

-
#[derive(Debug, Clone)]
-
pub struct Tables {
-
    pub spacing: u16,
-
}
-

-
/// The Radicle TUI theme. Will be defined in a JSON config file in the
-
/// future. e.g.:
-
/// {
-
///     "name": "Default",
-
///     "colors": {
-
///         "foreground": "#ffffff",
-
///         "propertyForeground": "#ffffff",
-
///         "highlightedBackground": "#000000",
-
///     },
-
///     "icons": {
-
///         "workspaces.divider": "|",
-
///         "shortcuts.divider: "∙",
-
///     }
-
/// }
-
#[derive(Debug, Clone)]
-
pub struct Theme {
-
    pub name: String,
-
    pub colors: Colors,
-
    pub icons: Icons,
-
    pub tables: Tables,
-
}
-

-
pub fn default_dark() -> Theme {
-
    Theme {
-
        name: String::from("Default"),
-
        colors: Colors {
-
            default_fg: COLOR_DEFAULT_FG,
-
            tabs_highlighted_fg: Color::Magenta,
-
            app_header_project_fg: Color::Cyan,
-
            app_header_rid_fg: Color::Yellow,
-
            labeled_container_bg: COLOR_DEFAULT_FAINT,
-
            item_list_highlighted_bg: COLOR_DEFAULT_DARKER,
-
            property_name_fg: Color::Cyan,
-
            property_divider_fg: COLOR_DEFAULT_DARK,
-
            shortcut_short_fg: COLOR_DEFAULT_DARK,
-
            shortcut_long_fg: COLOR_DEFAULT_DARKER,
-
            shortcutbar_divider_fg: COLOR_DEFAULT_DARKER,
-
            browser_list_id: Color::Cyan,
-
            browser_list_title: COLOR_DEFAULT_FG,
-
            browser_list_description: COLOR_DEFAULT_DARK,
-
            browser_list_author: Color::Gray,
-
            browser_list_tags: Color::LightBlue,
-
            browser_list_comments: COLOR_DEFAULT_DARK_FG,
-
            browser_list_timestamp: COLOR_DEFAULT_DARK,
-
            browser_patch_list_head: Color::LightBlue,
-
            browser_patch_list_added: Color::Green,
-
            browser_patch_list_removed: Color::Red,
-
            context_bg: COLOR_DEFAULT_DARKEST,
-
            context_light_bg: Color::Gray,
-
            context_badge_bg: Color::LightRed,
-
            context_id_fg: Color::Cyan,
-
            context_id_bg: COLOR_DEFAULT_DARKEST,
-
            context_id_author_fg: Color::Gray,
-
            container_border_fg: COLOR_DEFAULT_DARKEST,
-
            container_border_focus_fg: COLOR_DEFAULT_DARK,
-
        },
-
        icons: Icons {
-
            property_divider: '∙',
-
            shortcutbar_divider: '∙',
-
            tab_divider: '|',
-
            tab_overline: '▔',
-
            whitespace: ' ',
-
        },
-
        tables: Tables { spacing: 2 },
-
    }
-
}
deleted radicle-tui/src/ui/widget.rs
@@ -1,106 +0,0 @@
-
pub mod common;
-
pub mod home;
-
pub mod issue;
-
pub mod patch;
-
mod utils;
-

-
use std::ops::Deref;
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, Color, Props};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{Frame, MockComponent, State};
-

-
pub type BoxedWidget<T> = Box<Widget<T>>;
-

-
pub trait WidgetComponent {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect);
-

-
    fn state(&self) -> State;
-

-
    fn perform(&mut self, properties: &Props, cmd: Cmd) -> CmdResult;
-
}
-

-
#[derive(Clone)]
-
pub struct Widget<T: WidgetComponent> {
-
    component: T,
-
    properties: Props,
-
}
-

-
impl<T: WidgetComponent> Deref for Widget<T> {
-
    type Target = T;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.component
-
    }
-
}
-

-
impl<T: WidgetComponent> Widget<T> {
-
    pub fn new(component: T) -> Self {
-
        Widget {
-
            component,
-
            properties: Props::default(),
-
        }
-
    }
-

-
    pub fn foreground(mut self, fg: Color) -> Self {
-
        self.attr(Attribute::Foreground, AttrValue::Color(fg));
-
        self
-
    }
-

-
    pub fn highlight(mut self, fg: Color) -> Self {
-
        self.attr(Attribute::HighlightedColor, AttrValue::Color(fg));
-
        self
-
    }
-

-
    pub fn background(mut self, bg: Color) -> Self {
-
        self.attr(Attribute::Background, AttrValue::Color(bg));
-
        self
-
    }
-

-
    pub fn height(mut self, h: u16) -> Self {
-
        self.attr(Attribute::Height, AttrValue::Size(h));
-
        self
-
    }
-

-
    pub fn width(mut self, w: u16) -> Self {
-
        self.attr(Attribute::Width, AttrValue::Size(w));
-
        self
-
    }
-

-
    pub fn content(mut self, content: AttrValue) -> Self {
-
        self.attr(Attribute::Content, content);
-
        self
-
    }
-

-
    pub fn custom(mut self, key: &'static str, value: AttrValue) -> Self {
-
        self.attr(Attribute::Custom(key), value);
-
        self
-
    }
-

-
    pub fn to_boxed(self) -> Box<Self> {
-
        Box::new(self)
-
    }
-
}
-

-
impl<T: WidgetComponent> MockComponent for Widget<T> {
-
    fn view(&mut self, frame: &mut Frame, area: Rect) {
-
        self.component.view(&self.properties, frame, area)
-
    }
-

-
    fn query(&self, attr: Attribute) -> Option<AttrValue> {
-
        self.properties.get(attr)
-
    }
-

-
    fn attr(&mut self, attr: Attribute, value: AttrValue) {
-
        self.properties.set(attr, value)
-
    }
-

-
    fn state(&self) -> State {
-
        self.component.state()
-
    }
-

-
    fn perform(&mut self, cmd: Cmd) -> CmdResult {
-
        self.component.perform(&self.properties, cmd)
-
    }
-
}
deleted radicle-tui/src/ui/widget/common.rs
@@ -1,154 +0,0 @@
-
pub mod container;
-
pub mod context;
-
pub mod label;
-
pub mod list;
-

-
use tuirealm::props::{AttrValue, Attribute};
-
use tuirealm::MockComponent;
-

-
use container::{GlobalListener, Header, LabeledContainer, Tabs};
-
use context::{Shortcut, Shortcuts};
-
use label::Label;
-
use list::{Property, PropertyList};
-

-
use self::container::{AppHeader, AppInfo, Container, VerticalLine};
-
use self::list::{ColumnWidth, PropertyTable};
-

-
use super::Widget;
-

-
use crate::ui::context::Context;
-
use crate::ui::theme::Theme;
-

-
pub fn global_listener() -> Widget<GlobalListener> {
-
    Widget::new(GlobalListener::default())
-
}
-

-
pub fn label(content: &str) -> Widget<Label> {
-
    // TODO: Remove when size constraints are implemented
-
    let width = content.chars().count() as u16;
-

-
    Widget::new(Label::default())
-
        .content(AttrValue::String(content.to_string()))
-
        .height(1)
-
        .width(width)
-
}
-

-
pub fn reversable_label(content: &str) -> Widget<Label> {
-
    let content = &format!(" {content} ");
-

-
    label(content)
-
}
-

-
pub fn container_header(theme: &Theme, label: Widget<Label>) -> Widget<Header<1>> {
-
    let header = Header::new([label], [ColumnWidth::Grow], theme.clone());
-

-
    Widget::new(header)
-
}
-

-
pub fn container(theme: &Theme, component: Box<dyn MockComponent>) -> Widget<Container> {
-
    let container = Container::new(component, theme.clone());
-
    Widget::new(container)
-
}
-

-
pub fn labeled_container(
-
    theme: &Theme,
-
    title: &str,
-
    component: Box<dyn MockComponent>,
-
) -> Widget<LabeledContainer> {
-
    let header = container_header(
-
        theme,
-
        label(&format!(" {title} ")).foreground(theme.colors.default_fg),
-
    );
-
    let container = LabeledContainer::new(header, component, theme.clone());
-

-
    Widget::new(container)
-
}
-

-
pub fn shortcut(theme: &Theme, short: &str, long: &str) -> Widget<Shortcut> {
-
    let short = label(short).foreground(theme.colors.shortcut_short_fg);
-
    let divider = label(&theme.icons.whitespace.to_string());
-
    let long = label(long).foreground(theme.colors.shortcut_long_fg);
-

-
    // TODO: Remove when size constraints are implemented
-
    let short_w = short.query(Attribute::Width).unwrap().unwrap_size();
-
    let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
-
    let long_w = long.query(Attribute::Width).unwrap().unwrap_size();
-
    let width = short_w.saturating_add(divider_w).saturating_add(long_w);
-

-
    let shortcut = Shortcut::new(short, divider, long);
-

-
    Widget::new(shortcut).height(1).width(width)
-
}
-

-
pub fn shortcuts(theme: &Theme, shortcuts: Vec<Widget<Shortcut>>) -> Widget<Shortcuts> {
-
    let divider = label(&format!(" {} ", theme.icons.shortcutbar_divider))
-
        .foreground(theme.colors.shortcutbar_divider_fg);
-
    let shortcut_bar = Shortcuts::new(shortcuts, divider);
-

-
    Widget::new(shortcut_bar).height(1)
-
}
-

-
pub fn property(theme: &Theme, name: &str, value: &str) -> Widget<Property> {
-
    let name = label(name).foreground(theme.colors.property_name_fg);
-
    let divider = label(&format!(" {} ", theme.icons.property_divider));
-
    let value = label(value).foreground(theme.colors.default_fg);
-

-
    // TODO: Remove when size constraints are implemented
-
    let name_w = name.query(Attribute::Width).unwrap().unwrap_size();
-
    let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
-
    let value_w = value.query(Attribute::Width).unwrap().unwrap_size();
-
    let width = name_w.saturating_add(divider_w).saturating_add(value_w);
-

-
    let property = Property::new(name, value).with_divider(divider);
-

-
    Widget::new(property).height(1).width(width)
-
}
-

-
pub fn property_list(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widget<PropertyList> {
-
    let property_list = PropertyList::new(properties);
-

-
    Widget::new(property_list)
-
}
-

-
pub fn property_table(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widget<PropertyTable> {
-
    let table = PropertyTable::new(properties);
-

-
    Widget::new(table)
-
}
-

-
pub fn tabs(_theme: &Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
-
    let tabs = Tabs::new(tabs);
-

-
    Widget::new(tabs).height(2)
-
}
-

-
pub fn app_info(context: &Context, theme: &Theme) -> Widget<AppInfo> {
-
    let project = label(context.project().name()).foreground(theme.colors.app_header_project_fg);
-
    let rid = label(&format!(" ({})", context.id())).foreground(theme.colors.app_header_rid_fg);
-

-
    let project_w = project
-
        .query(Attribute::Width)
-
        .unwrap_or(AttrValue::Size(0))
-
        .unwrap_size();
-
    let rid_w = rid
-
        .query(Attribute::Width)
-
        .unwrap_or(AttrValue::Size(0))
-
        .unwrap_size();
-

-
    let info = AppInfo::new(project, rid);
-
    Widget::new(info).width(project_w.saturating_add(rid_w))
-
}
-

-
pub fn app_header(
-
    context: &Context,
-
    theme: &Theme,
-
    nav: Option<Widget<Tabs>>,
-
) -> Widget<AppHeader> {
-
    let line =
-
        label(&theme.icons.tab_overline.to_string()).foreground(theme.colors.tabs_highlighted_fg);
-
    let line = Widget::new(VerticalLine::new(line));
-
    let info = app_info(context, theme);
-
    let header = AppHeader::new(nav, info, line);
-

-
    Widget::new(header)
-
}
deleted radicle-tui/src/ui/widget/common/container.rs
@@ -1,458 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Props, Style, TextModifiers};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::tui::widgets::{Block, Cell, Row};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
-

-
use crate::ui::ext::HeaderBlock;
-
use crate::ui::layout;
-
use crate::ui::state::TabState;
-
use crate::ui::theme::Theme;
-
use crate::ui::widget::{utils, Widget, WidgetComponent};
-

-
use super::label::Label;
-
use super::list::ColumnWidth;
-

-
/// Some user events need to be handled globally (e.g. user presses key `q` to quit
-
/// the application). This component can be used in conjunction with SubEventClause
-
/// to handle those events.
-
#[derive(Default)]
-
pub struct GlobalListener {}
-

-
impl WidgetComponent for GlobalListener {
-
    fn view(&mut self, _properties: &Props, _frame: &mut Frame, _area: Rect) {}
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A vertical separator.
-
#[derive(Clone)]
-
pub struct VerticalLine {
-
    line: Widget<Label>,
-
}
-

-
impl VerticalLine {
-
    pub fn new(line: Widget<Label>) -> Self {
-
        Self { line }
-
    }
-
}
-

-
impl WidgetComponent for VerticalLine {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            // Repeat and render line.
-
            let overlines = vec![self.line.clone(); area.width as usize];
-
            let overlines = overlines
-
                .iter()
-
                .map(|l| l.clone().to_boxed() as Box<dyn MockComponent>)
-
                .collect();
-
            let line_layout = layout::h_stack(overlines, area);
-
            for (mut line, area) in line_layout {
-
                line.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
////////////////////////////////////////////////
-

-
/// A tab header that displays all labels horizontally aligned and separated
-
/// by a divider. Highlights the label defined by the current tab index.
-
#[derive(Clone)]
-
pub struct Tabs {
-
    tabs: Vec<Widget<Label>>,
-
    state: TabState,
-
}
-

-
impl Tabs {
-
    pub fn new(tabs: Vec<Widget<Label>>) -> Self {
-
        let count = &tabs.len();
-
        Self {
-
            tabs,
-
            state: TabState {
-
                selected: 0,
-
                len: *count as u16,
-
            },
-
        }
-
    }
-
}
-

-
impl WidgetComponent for Tabs {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let selected = self.state().unwrap_one().unwrap_u16();
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            // Render tabs, highlighting the selected tab.
-
            let mut tabs = vec![];
-
            for (index, tab) in self.tabs.iter().enumerate() {
-
                let mut tab = tab.clone().to_boxed();
-
                if index == selected as usize {
-
                    tab.attr(
-
                        Attribute::TextProps,
-
                        AttrValue::TextModifiers(TextModifiers::REVERSED),
-
                    );
-
                }
-
                tabs.push(tab.clone().to_boxed() as Box<dyn MockComponent>);
-
            }
-
            tabs.push(Widget::new(Label::default()).to_boxed());
-

-
            let tab_layout = layout::h_stack(tabs, area);
-
            for (mut tab, area) in tab_layout {
-
                tab.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::One(StateValue::U16(self.state.selected))
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        use tuirealm::command::Direction;
-

-
        match cmd {
-
            Cmd::Move(Direction::Right) => {
-
                let prev = self.state.selected;
-
                self.state.incr_tab_index(true);
-
                if prev != self.state.selected {
-
                    CmdResult::Changed(self.state())
-
                } else {
-
                    CmdResult::None
-
                }
-
            }
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
-

-
/// An application info widget that renders project / branch information
-
/// and a separator line. Used in conjunction with [`Tabs`].
-
pub struct AppInfo {
-
    project: Widget<Label>,
-
    rid: Widget<Label>,
-
}
-

-
impl AppInfo {
-
    pub fn new(project: Widget<Label>, rid: Widget<Label>) -> Self {
-
        Self { project, rid }
-
    }
-
}
-

-
impl WidgetComponent for AppInfo {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        let project_w = self
-
            .project
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(10))
-
            .unwrap_size();
-

-
        let rid_w = self
-
            .rid
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(10))
-
            .unwrap_size();
-

-
        if display {
-
            let layout = Layout::default()
-
                .direction(Direction::Horizontal)
-
                .constraints(vec![
-
                    Constraint::Length(project_w),
-
                    Constraint::Length(rid_w),
-
                ])
-
                .split(area);
-

-
            self.project.view(frame, layout[0]);
-
            self.rid.view(frame, layout[1]);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A common application header that renders project / branch
-
/// information and an optional navigation.
-
pub struct AppHeader {
-
    nav: Option<Widget<Tabs>>,
-
    info: Widget<AppInfo>,
-
    line: Widget<VerticalLine>,
-
}
-

-
impl AppHeader {
-
    pub fn new(
-
        nav: Option<Widget<Tabs>>,
-
        info: Widget<AppInfo>,
-
        line: Widget<VerticalLine>,
-
    ) -> Self {
-
        Self { nav, info, line }
-
    }
-
}
-

-
impl WidgetComponent for AppHeader {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-
        let info_w = self
-
            .info
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(10))
-
            .unwrap_size();
-

-
        if display {
-
            let layout = layout::app_header(area, info_w);
-

-
            if let Some(nav) = self.nav.as_mut() {
-
                nav.view(frame, layout.nav);
-
            }
-
            self.info.view(frame, layout.info);
-
            self.line.view(frame, layout.line);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.nav
-
            .as_mut()
-
            .map(|nav| nav.perform(cmd))
-
            .unwrap_or(CmdResult::None)
-
    }
-
}
-

-
/// A labeled container header.
-
pub struct Header<const W: usize> {
-
    header: [Widget<Label>; W],
-
    widths: [ColumnWidth; W],
-
    theme: Theme,
-
}
-

-
impl<const W: usize> Header<W> {
-
    pub fn new(header: [Widget<Label>; W], widths: [ColumnWidth; W], theme: Theme) -> Self {
-
        Self {
-
            header,
-
            widths,
-
            theme,
-
        }
-
    }
-
}
-

-
impl<const W: usize> WidgetComponent for Header<W> {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-

-
        let color = if focus {
-
            self.theme.colors.container_border_focus_fg
-
        } else {
-
            self.theme.colors.container_border_fg
-
        };
-

-
        if display {
-
            let block = HeaderBlock::default()
-
                .borders(BorderSides::all())
-
                .border_style(Style::default().fg(color))
-
                .border_type(BorderType::Rounded);
-
            frame.render_widget(block, area);
-

-
            let layout = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints(vec![Constraint::Min(1)])
-
                .vertical_margin(1)
-
                .horizontal_margin(1)
-
                .split(area);
-

-
            let widths = utils::column_widths(area, &self.widths, self.theme.tables.spacing);
-
            let header: [Cell; W] = self
-
                .header
-
                .iter()
-
                .map(|label| {
-
                    let cell: Cell = label.into();
-
                    cell.style(Style::default().fg(self.theme.colors.default_fg))
-
                })
-
                .collect::<Vec<_>>()
-
                .try_into()
-
                .unwrap();
-
            let header: Row<'_> = Row::new(header);
-

-
            let table = tuirealm::tui::widgets::Table::new(vec![])
-
                .column_spacing(self.theme.tables.spacing)
-
                .header(header)
-
                .widths(&widths);
-
            frame.render_widget(table, layout[0]);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct Container {
-
    component: Box<dyn MockComponent>,
-
    theme: Theme,
-
}
-

-
impl Container {
-
    pub fn new(component: Box<dyn MockComponent>, theme: Theme) -> Self {
-
        Self { component, theme }
-
    }
-
}
-

-
impl WidgetComponent for Container {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-

-
        let color = if focus {
-
            self.theme.colors.container_border_focus_fg
-
        } else {
-
            self.theme.colors.container_border_fg
-
        };
-

-
        if display {
-
            // Make some space on the left
-
            let layout = Layout::default()
-
                .direction(Direction::Horizontal)
-
                .horizontal_margin(1)
-
                .vertical_margin(1)
-
                .constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
-
                .split(area);
-
            // reverse draw order: child needs to be drawn first?
-
            self.component.view(frame, layout[1]);
-

-
            let block = Block::default()
-
                .borders(BorderSides::ALL)
-
                .border_style(Style::default().fg(color))
-
                .border_type(BorderType::Rounded);
-
            frame.render_widget(block, area);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.component.perform(cmd)
-
    }
-
}
-

-
pub struct LabeledContainer {
-
    header: Widget<Header<1>>,
-
    component: Box<dyn MockComponent>,
-
    theme: Theme,
-
}
-

-
impl LabeledContainer {
-
    pub fn new(header: Widget<Header<1>>, component: Box<dyn MockComponent>, theme: Theme) -> Self {
-
        Self {
-
            header,
-
            component,
-
            theme,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for LabeledContainer {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-

-
        let color = if focus {
-
            self.theme.colors.container_border_focus_fg
-
        } else {
-
            self.theme.colors.container_border_fg
-
        };
-

-
        let header_height = self
-
            .header
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(3))
-
            .unwrap_size();
-

-
        if display {
-
            let layout = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints([Constraint::Length(header_height), Constraint::Length(0)].as_ref())
-
                .split(area);
-

-
            // Make some space on the left
-
            let inner_layout = Layout::default()
-
                .direction(Direction::Horizontal)
-
                .horizontal_margin(1)
-
                .constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
-
                .split(layout[1]);
-
            // reverse draw order: child needs to be drawn first?
-

-
            self.component
-
                .attr(Attribute::Focus, AttrValue::Flag(focus));
-
            self.component.view(frame, inner_layout[1]);
-

-
            let block = Block::default()
-
                .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
-
                .border_style(Style::default().fg(color))
-
                .border_type(BorderType::Rounded);
-
            frame.render_widget(block, layout[1]);
-

-
            self.header.attr(Attribute::Focus, AttrValue::Flag(focus));
-
            self.header.view(frame, layout[0]);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.component.perform(cmd)
-
    }
-
}
deleted radicle-tui/src/ui/widget/common/context.rs
@@ -1,175 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, Props};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{Frame, MockComponent, State};
-

-
use super::label::Label;
-

-
use crate::ui::layout;
-
use crate::ui::widget::{Widget, WidgetComponent};
-

-
/// A shortcut that consists of a label displaying the "hotkey", a label that displays
-
/// the action and a spacer between them.
-
#[derive(Clone)]
-
pub struct Shortcut {
-
    short: Widget<Label>,
-
    divider: Widget<Label>,
-
    long: Widget<Label>,
-
}
-

-
impl Shortcut {
-
    pub fn new(short: Widget<Label>, divider: Widget<Label>, long: Widget<Label>) -> Self {
-
        Self {
-
            short,
-
            divider,
-
            long,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for Shortcut {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let labels: Vec<Box<dyn MockComponent>> = vec![
-
                self.short.clone().to_boxed(),
-
                self.divider.clone().to_boxed(),
-
                self.long.clone().to_boxed(),
-
            ];
-

-
            let layout = layout::h_stack(labels, area);
-
            for (mut shortcut, area) in layout {
-
                shortcut.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A shortcut bar that displays multiple shortcuts and separates them with a
-
/// divider.
-
pub struct Shortcuts {
-
    shortcuts: Vec<Widget<Shortcut>>,
-
    divider: Widget<Label>,
-
}
-

-
impl Shortcuts {
-
    pub fn new(shortcuts: Vec<Widget<Shortcut>>, divider: Widget<Label>) -> Self {
-
        Self { shortcuts, divider }
-
    }
-
}
-

-
impl WidgetComponent for Shortcuts {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let mut widgets: Vec<Box<dyn MockComponent>> = vec![];
-
            let mut shortcuts = self.shortcuts.iter_mut().peekable();
-

-
            while let Some(shortcut) = shortcuts.next() {
-
                if shortcuts.peek().is_some() {
-
                    widgets.push(shortcut.clone().to_boxed());
-
                    widgets.push(self.divider.clone().to_boxed())
-
                } else {
-
                    widgets.push(shortcut.clone().to_boxed());
-
                }
-
            }
-

-
            let layout = layout::h_stack(widgets, area);
-
            for (mut widget, area) in layout {
-
                widget.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct ContextBar {
-
    context: Widget<Label>,
-
    id: Widget<Label>,
-
    author: Widget<Label>,
-
    title: Widget<Label>,
-
    comments: Widget<Label>,
-
}
-

-
impl ContextBar {
-
    pub fn new(
-
        context: Widget<Label>,
-
        id: Widget<Label>,
-
        author: Widget<Label>,
-
        title: Widget<Label>,
-
        comments: Widget<Label>,
-
    ) -> Self {
-
        Self {
-
            context,
-
            id,
-
            author,
-
            title,
-
            comments,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for ContextBar {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        let context_w = self.context.query(Attribute::Width).unwrap().unwrap_size();
-
        let id_w = self.id.query(Attribute::Width).unwrap().unwrap_size();
-
        let author_w = self.author.query(Attribute::Width).unwrap().unwrap_size();
-
        let count_w = self.comments.query(Attribute::Width).unwrap().unwrap_size();
-

-
        if display {
-
            let layout = layout::h_stack(
-
                vec![
-
                    self.context.clone().to_boxed(),
-
                    self.id.clone().to_boxed(),
-
                    self.title
-
                        .clone()
-
                        .width(
-
                            area.width
-
                                .saturating_sub(context_w + id_w + author_w + count_w),
-
                        )
-
                        .to_boxed(),
-
                    self.author.clone().to_boxed(),
-
                    self.comments.clone().to_boxed(),
-
                ],
-
                area,
-
            );
-

-
            for (mut component, area) in layout {
-
                component.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
deleted radicle-tui/src/ui/widget/common/label.rs
@@ -1,81 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, Color, Props, Style};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::tui::text::{Span, Text};
-
use tuirealm::{Frame, MockComponent, State};
-

-
use crate::ui::widget::{Widget, WidgetComponent};
-

-
/// A label that can be styled using a foreground color and text modifiers.
-
/// Its height is fixed, its width depends on the length of the text it displays.
-
#[derive(Clone, Default)]
-
pub struct Label;
-

-
impl WidgetComponent for Label {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        use tui_realm_stdlib::Label;
-

-
        let content = properties
-
            .get_or(Attribute::Content, AttrValue::String(String::default()))
-
            .unwrap_string();
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-
        let foreground = properties
-
            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-
        let background = properties
-
            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-

-
        if display {
-
            let mut label = match properties.get(Attribute::TextProps) {
-
                Some(modifiers) => Label::default()
-
                    .foreground(foreground)
-
                    .background(background)
-
                    .modifiers(modifiers.unwrap_text_modifiers())
-
                    .text(content),
-
                None => Label::default()
-
                    .foreground(foreground)
-
                    .background(background)
-
                    .text(content),
-
            };
-

-
            label.view(frame, area);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
impl From<&Widget<Label>> for Span<'_> {
-
    fn from(label: &Widget<Label>) -> Self {
-
        let content = label
-
            .query(Attribute::Content)
-
            .unwrap_or(AttrValue::String(String::default()))
-
            .unwrap_string();
-

-
        Span::styled(content, Style::default())
-
    }
-
}
-

-
impl From<&Widget<Label>> for Text<'_> {
-
    fn from(label: &Widget<Label>) -> Self {
-
        let content = label
-
            .query(Attribute::Content)
-
            .unwrap_or(AttrValue::String(String::default()))
-
            .unwrap_string();
-
        let foreground = label
-
            .query(Attribute::Foreground)
-
            .unwrap_or(AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-

-
        Text::styled(content, Style::default().fg(foreground))
-
    }
-
}
deleted radicle-tui/src/ui/widget/common/list.rs
@@ -1,376 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::tui::widgets::{Block, Cell, ListState, Row, TableState};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
-

-
use crate::ui::layout;
-
use crate::ui::state::ItemState;
-
use crate::ui::theme::Theme;
-
use crate::ui::widget::{utils, Widget, WidgetComponent};
-

-
use super::container::Header;
-
use super::label::Label;
-
use super::*;
-

-
/// A generic item that can be displayed in a table with [`const W: usize`] columns.
-
pub trait TableItem<const W: usize> {
-
    /// Should return fields as table cells.
-
    fn row(&self, theme: &Theme) -> [Cell; W];
-
}
-

-
/// A generic item that can be displayed in a list.
-
pub trait ListItem {
-
    /// Should return fields as list item.
-
    fn row(&self, theme: &Theme) -> tuirealm::tui::widgets::ListItem;
-
}
-

-
/// Grow behavior of a table column.
-
///
-
/// [`tuirealm::tui::widgets::Table`] does only support percental column widths.
-
/// A [`ColumnWidth`] is used to specify the grow behaviour of a table column
-
/// and a percental column width is calculated based on that.
-
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-
pub enum ColumnWidth {
-
    /// A fixed-size column.
-
    Fixed(u16),
-
    /// A growable column.
-
    Grow,
-
}
-

-
/// A component that displays a labeled property.
-
#[derive(Clone)]
-
pub struct Property {
-
    name: Widget<Label>,
-
    divider: Widget<Label>,
-
    value: Widget<Label>,
-
}
-

-
impl Property {
-
    pub fn new(name: Widget<Label>, value: Widget<Label>) -> Self {
-
        let divider = label("");
-
        Self {
-
            name,
-
            divider,
-
            value,
-
        }
-
    }
-

-
    pub fn with_divider(mut self, divider: Widget<Label>) -> Self {
-
        self.divider = divider;
-
        self
-
    }
-

-
    pub fn name(&self) -> &Widget<Label> {
-
        &self.name
-
    }
-

-
    pub fn value(&self) -> &Widget<Label> {
-
        &self.value
-
    }
-
}
-

-
impl WidgetComponent for Property {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let labels: Vec<Box<dyn MockComponent>> = vec![
-
                self.name.clone().to_boxed(),
-
                self.divider.clone().to_boxed(),
-
                self.value.clone().to_boxed(),
-
            ];
-

-
            let layout = layout::h_stack(labels, area);
-
            for (mut label, area) in layout {
-
                label.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A component that can display lists of labeled properties
-
#[derive(Default)]
-
pub struct PropertyList {
-
    properties: Vec<Widget<Property>>,
-
}
-

-
impl PropertyList {
-
    pub fn new(properties: Vec<Widget<Property>>) -> Self {
-
        Self { properties }
-
    }
-
}
-

-
impl WidgetComponent for PropertyList {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let properties = self
-
                .properties
-
                .iter()
-
                .map(|property| property.clone().to_boxed() as Box<dyn MockComponent>)
-
                .collect();
-

-
            let layout = layout::v_stack(properties, area);
-
            for (mut property, area) in layout {
-
                property.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct PropertyTable {
-
    properties: Vec<Widget<Property>>,
-
}
-

-
impl PropertyTable {
-
    pub fn new(properties: Vec<Widget<Property>>) -> Self {
-
        Self { properties }
-
    }
-
}
-

-
impl WidgetComponent for PropertyTable {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        use tuirealm::tui::widgets::Table;
-

-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let rows = self
-
                .properties
-
                .iter()
-
                .map(|p| Row::new([Cell::from(p.name()), Cell::from(p.value())]));
-

-
            let table = Table::new(rows)
-
                .widths([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref());
-
            frame.render_widget(table, area);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A table component that can display a list of [`TableItem`]s.
-
pub struct Table<V, const W: usize>
-
where
-
    V: TableItem<W> + Clone,
-
{
-
    /// Items hold by this model.
-
    items: Vec<V>,
-
    /// The table header.
-
    header: [Widget<Label>; W],
-
    /// Grow behavior of table columns.
-
    widths: [ColumnWidth; W],
-
    /// State that keeps track of the selection.
-
    state: ItemState,
-
    /// The current theme.
-
    theme: Theme,
-
}
-

-
impl<V, const W: usize> Table<V, W>
-
where
-
    V: TableItem<W> + Clone,
-
{
-
    pub fn new(
-
        items: &[V],
-
        header: [Widget<Label>; W],
-
        widths: [ColumnWidth; W],
-
        theme: Theme,
-
    ) -> Self {
-
        Self {
-
            items: items.to_vec(),
-
            header,
-
            widths,
-
            state: ItemState::new(Some(0), items.len()),
-
            theme,
-
        }
-
    }
-
}
-

-
impl<V, const W: usize> WidgetComponent for Table<V, W>
-
where
-
    V: TableItem<W> + Clone,
-
{
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let highlight = properties
-
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-

-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-

-
        let color = if focus {
-
            self.theme.colors.container_border_focus_fg
-
        } else {
-
            self.theme.colors.container_border_fg
-
        };
-

-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![Constraint::Length(3), Constraint::Min(1)])
-
            .split(area);
-

-
        let widths = utils::column_widths(area, &self.widths, self.theme.tables.spacing);
-
        let rows: Vec<Row<'_>> = self
-
            .items
-
            .iter()
-
            .map(|item| Row::new(item.row(&self.theme)))
-
            .collect();
-

-
        let table = tuirealm::tui::widgets::Table::new(rows)
-
            .block(
-
                Block::default()
-
                    .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
-
                    .border_style(Style::default().fg(color))
-
                    .border_type(BorderType::Rounded),
-
            )
-
            .highlight_style(Style::default().bg(highlight))
-
            .column_spacing(self.theme.tables.spacing)
-
            .widths(&widths);
-

-
        let mut header = Widget::new(Header::new(
-
            self.header.clone(),
-
            self.widths,
-
            self.theme.clone(),
-
        ));
-

-
        header.attr(Attribute::Focus, AttrValue::Flag(focus));
-
        header.view(frame, layout[0]);
-

-
        frame.render_stateful_widget(table, layout[1], &mut TableState::from(&self.state));
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        use tuirealm::command::Direction;
-
        match cmd {
-
            Cmd::Move(Direction::Up) => match self.state.select_previous() {
-
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Move(Direction::Down) => match self.state.select_next() {
-
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Submit => match self.state.selected() {
-
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
-

-
/// A list component that can display [`ListItem`]'s.
-
pub struct List<V>
-
where
-
    V: ListItem + Clone + PartialEq,
-
{
-
    /// Items held by this list.
-
    items: Vec<V>,
-
    /// State keeps track of the current selection.
-
    state: ItemState,
-
    /// The current theme.
-
    theme: Theme,
-
}
-

-
impl<V> List<V>
-
where
-
    V: ListItem + Clone + PartialEq,
-
{
-
    pub fn new(items: &[V], selected: Option<V>, theme: Theme) -> Self {
-
        let selected = match selected {
-
            Some(item) => items.iter().position(|i| i == &item),
-
            None => Some(0),
-
        };
-

-
        Self {
-
            items: items.to_vec(),
-
            state: ItemState::new(selected, items.len()),
-
            theme,
-
        }
-
    }
-
}
-

-
impl<V> WidgetComponent for List<V>
-
where
-
    V: ListItem + Clone + PartialEq,
-
{
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        use tuirealm::tui::widgets::{List, ListItem};
-

-
        let highlight = properties
-
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-

-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![Constraint::Min(1), Constraint::Length(1)])
-
            .split(area);
-

-
        let rows: Vec<ListItem> = self
-
            .items
-
            .iter()
-
            .map(|item| item.row(&self.theme))
-
            .collect();
-
        let list = List::new(rows).highlight_style(Style::default().bg(highlight));
-

-
        frame.render_stateful_widget(list, layout[0], &mut ListState::from(&self.state));
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        use tuirealm::command::Direction;
-
        match cmd {
-
            Cmd::Move(Direction::Up) => match self.state.select_previous() {
-
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Move(Direction::Down) => match self.state.select_next() {
-
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Submit => match self.state.selected() {
-
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
deleted radicle-tui/src/ui/widget/home.rs
@@ -1,284 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use super::common;
-
use super::common::container::{LabeledContainer, Tabs};
-
use super::common::context::Shortcuts;
-
use super::common::list::{ColumnWidth, Table};
-

-
use super::{Widget, WidgetComponent};
-

-
use crate::cob;
-
use crate::ui::cob::{IssueItem, PatchItem};
-
use crate::ui::context::Context;
-
use crate::ui::layout;
-
use crate::ui::theme::Theme;
-

-
pub struct Dashboard {
-
    about: Widget<LabeledContainer>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl Dashboard {
-
    pub fn new(about: Widget<LabeledContainer>, shortcuts: Widget<Shortcuts>) -> Self {
-
        Self { about, shortcuts }
-
    }
-
}
-

-
impl WidgetComponent for Dashboard {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let layout = layout::root_component(area, shortcuts_h);
-

-
        self.about.view(frame, layout[0]);
-
        self.shortcuts.view(frame, layout[1]);
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct IssueBrowser {
-
    items: Vec<IssueItem>,
-
    table: Widget<Table<IssueItem, 7>>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl IssueBrowser {
-
    pub fn new(context: &Context, theme: &Theme, shortcuts: Widget<Shortcuts>) -> Self {
-
        let header = [
-
            common::label(" ● "),
-
            common::label("ID"),
-
            common::label("Title"),
-
            common::label("Author"),
-
            common::label("Tags"),
-
            common::label("Assignees"),
-
            common::label("Opened"),
-
        ];
-

-
        let widths = [
-
            ColumnWidth::Fixed(3),
-
            ColumnWidth::Fixed(7),
-
            ColumnWidth::Grow,
-
            ColumnWidth::Fixed(21),
-
            ColumnWidth::Fixed(25),
-
            ColumnWidth::Fixed(21),
-
            ColumnWidth::Fixed(18),
-
        ];
-

-
        let repo = context.repository();
-
        let mut items = vec![];
-

-
        if let Ok(issues) = cob::issue::all(repo) {
-
            for (id, issue) in issues {
-
                if let Ok(item) = IssueItem::try_from((context.profile(), repo, id, issue)) {
-
                    items.push(item);
-
                }
-
            }
-
        }
-

-
        items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
-
        items.sort_by(|a, b| b.state().cmp(a.state()));
-

-
        let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
-
            .highlight(theme.colors.item_list_highlighted_bg);
-

-
        Self {
-
            items,
-
            table,
-
            shortcuts,
-
        }
-
    }
-

-
    pub fn items(&self) -> &Vec<IssueItem> {
-
        &self.items
-
    }
-
}
-

-
impl WidgetComponent for IssueBrowser {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-

-
        let layout = layout::root_component(area, shortcuts_h);
-

-
        self.table.attr(Attribute::Focus, AttrValue::Flag(focus));
-
        self.table.view(frame, layout[0]);
-
        self.shortcuts.view(frame, layout[1])
-
    }
-

-
    fn state(&self) -> State {
-
        self.table.state()
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.table.perform(cmd)
-
    }
-
}
-

-
pub struct PatchBrowser {
-
    items: Vec<PatchItem>,
-
    table: Widget<Table<PatchItem, 8>>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl PatchBrowser {
-
    pub fn new(context: &Context, theme: &Theme, shortcuts: Widget<Shortcuts>) -> Self {
-
        let header = [
-
            common::label(" ● "),
-
            common::label("ID"),
-
            common::label("Title"),
-
            common::label("Author"),
-
            common::label("Head"),
-
            common::label("+"),
-
            common::label("-"),
-
            common::label("Updated"),
-
        ];
-

-
        let widths = [
-
            ColumnWidth::Fixed(3),
-
            ColumnWidth::Fixed(7),
-
            ColumnWidth::Grow,
-
            ColumnWidth::Fixed(21),
-
            ColumnWidth::Fixed(7),
-
            ColumnWidth::Fixed(4),
-
            ColumnWidth::Fixed(4),
-
            ColumnWidth::Fixed(18),
-
        ];
-

-
        let repo = context.repository();
-
        let mut items = vec![];
-

-
        if let Ok(patches) = cob::patch::all(repo) {
-
            for (id, patch) in patches {
-
                if let Ok(item) = PatchItem::try_from((context.profile(), repo, id, patch)) {
-
                    items.push(item);
-
                }
-
            }
-
        }
-

-
        items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
-
        items.sort_by(|a, b| a.state().cmp(b.state()));
-

-
        let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
-
            .highlight(theme.colors.item_list_highlighted_bg);
-

-
        Self {
-
            items,
-
            table,
-
            shortcuts,
-
        }
-
    }
-

-
    pub fn items(&self) -> &Vec<PatchItem> {
-
        &self.items
-
    }
-
}
-

-
impl WidgetComponent for PatchBrowser {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-

-
        let layout = layout::root_component(area, shortcuts_h);
-

-
        self.table.attr(Attribute::Focus, AttrValue::Flag(focus));
-
        self.table.view(frame, layout[0]);
-
        self.shortcuts.view(frame, layout[1]);
-
    }
-

-
    fn state(&self) -> State {
-
        self.table.state()
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.table.perform(cmd)
-
    }
-
}
-

-
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
-
    common::tabs(
-
        theme,
-
        vec![
-
            common::reversable_label("dashboard").foreground(theme.colors.tabs_highlighted_fg),
-
            common::reversable_label("issues").foreground(theme.colors.tabs_highlighted_fg),
-
            common::reversable_label("patches").foreground(theme.colors.tabs_highlighted_fg),
-
        ],
-
    )
-
}
-

-
pub fn dashboard(context: &Context, theme: &Theme) -> Widget<Dashboard> {
-
    let about = common::labeled_container(
-
        theme,
-
        "about",
-
        common::property_list(
-
            theme,
-
            vec![
-
                common::property(theme, "id", &context.id().to_string()),
-
                common::property(theme, "name", context.project().name()),
-
                common::property(theme, "description", context.project().description()),
-
            ],
-
        )
-
        .to_boxed(),
-
    );
-
    let shortcuts = common::shortcuts(
-
        theme,
-
        vec![
-
            common::shortcut(theme, "tab", "section"),
-
            common::shortcut(theme, "q", "quit"),
-
        ],
-
    );
-
    let dashboard = Dashboard::new(about, shortcuts);
-

-
    Widget::new(dashboard)
-
}
-

-
pub fn patches(context: &Context, theme: &Theme) -> Widget<PatchBrowser> {
-
    let shortcuts = common::shortcuts(
-
        theme,
-
        vec![
-
            common::shortcut(theme, "tab", "section"),
-
            common::shortcut(theme, "↑/↓", "navigate"),
-
            common::shortcut(theme, "enter", "show"),
-
            common::shortcut(theme, "q", "quit"),
-
        ],
-
    );
-

-
    Widget::new(PatchBrowser::new(context, theme, shortcuts))
-
}
-

-
pub fn issues(context: &Context, theme: &Theme) -> Widget<IssueBrowser> {
-
    let shortcuts = common::shortcuts(
-
        theme,
-
        vec![
-
            common::shortcut(theme, "tab", "section"),
-
            common::shortcut(theme, "↑/↓", "navigate"),
-
            common::shortcut(theme, "enter", "show"),
-
            common::shortcut(theme, "q", "quit"),
-
        ],
-
    );
-

-
    Widget::new(IssueBrowser::new(context, theme, shortcuts))
-
}
deleted radicle-tui/src/ui/widget/issue.rs
@@ -1,183 +0,0 @@
-
use radicle_cli::terminal::format;
-

-
use radicle::cob::issue::Issue;
-
use radicle::cob::issue::IssueId;
-
use radicle::Profile;
-
use tuirealm::props::Color;
-

-
use super::common::container::Container;
-
use super::common::container::LabeledContainer;
-
use super::common::list::List;
-
use super::common::list::Property;
-
use super::Widget;
-

-
use crate::ui::cob;
-
use crate::ui::cob::IssueItem;
-
use crate::ui::context::Context;
-
use crate::ui::theme::Theme;
-
use crate::ui::widget::common::context::ContextBar;
-

-
use super::*;
-

-
pub struct LargeList {
-
    items: Vec<IssueItem>,
-
    list: Widget<LabeledContainer>,
-
}
-

-
impl LargeList {
-
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
-
        let repo = context.repository();
-
        let issues = crate::cob::issue::all(repo).unwrap_or_default();
-
        let mut items = issues
-
            .iter()
-
            .map(|(id, issue)| IssueItem::from((context.profile(), repo, *id, issue.clone())))
-
            .collect::<Vec<_>>();
-

-
        items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
-
        items.sort_by(|a, b| b.state().cmp(a.state()));
-

-
        let selected =
-
            selected.map(|(id, issue)| IssueItem::from((context.profile(), repo, id, issue)));
-

-
        let list = Widget::new(List::new(&items, selected, theme.clone()))
-
            .highlight(theme.colors.item_list_highlighted_bg);
-

-
        let container = common::labeled_container(theme, "Issues", list.to_boxed());
-

-
        Self {
-
            items,
-
            list: container,
-
        }
-
    }
-

-
    pub fn items(&self) -> &Vec<IssueItem> {
-
        &self.items
-
    }
-
}
-

-
impl WidgetComponent for LargeList {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-

-
        self.list.attr(Attribute::Focus, AttrValue::Flag(focus));
-
        self.list.view(frame, area);
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.list.perform(cmd)
-
    }
-
}
-

-
pub struct Details {
-
    container: Widget<Container>,
-
}
-

-
impl Details {
-
    pub fn new(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Self {
-
        let repo = context.repository();
-

-
        let (id, issue) = issue;
-
        let item = IssueItem::from((context.profile(), repo, id, issue));
-

-
        let title = Property::new(
-
            common::label("Title").foreground(theme.colors.property_name_fg),
-
            common::label(item.title()).foreground(theme.colors.browser_list_title),
-
        );
-

-
        let tags = Property::new(
-
            common::label("Tags").foreground(theme.colors.property_name_fg),
-
            common::label(&cob::format_tags(item.tags()))
-
                .foreground(theme.colors.browser_list_tags),
-
        );
-

-
        let assignees = Property::new(
-
            common::label("Assignees").foreground(theme.colors.property_name_fg),
-
            common::label(&cob::format_assignees(
-
                &item
-
                    .assignees()
-
                    .iter()
-
                    .map(|item| (item.did(), item.is_you()))
-
                    .collect::<Vec<_>>(),
-
            ))
-
            .foreground(theme.colors.browser_list_author),
-
        );
-

-
        let state = Property::new(
-
            common::label("Status").foreground(theme.colors.property_name_fg),
-
            common::label(&item.state().to_string()).foreground(theme.colors.browser_list_title),
-
        );
-

-
        // let table = common::property_table(theme, vec![title, tags, assignees, state]);
-
        let table = common::property_table(
-
            theme,
-
            vec![
-
                Widget::new(title),
-
                Widget::new(tags),
-
                Widget::new(assignees),
-
                Widget::new(state),
-
            ],
-
        );
-
        let container = common::container(theme, table.to_boxed());
-

-
        Self { container }
-
    }
-
}
-

-
impl WidgetComponent for Details {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        self.container.view(frame, area);
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub fn list(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<LargeList> {
-
    let list = LargeList::new(context, theme, Some(issue));
-

-
    Widget::new(list)
-
}
-

-
pub fn details(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<Details> {
-
    let details = Details::new(context, theme, issue);
-
    Widget::new(details)
-
}
-

-
pub fn context(theme: &Theme, issue: (IssueId, &Issue), profile: &Profile) -> Widget<ContextBar> {
-
    let (id, issue) = issue;
-
    let is_you = *issue.author().id() == profile.did();
-

-
    let id = format::cob(&id);
-
    let title = issue.title();
-
    let author = cob::format_author(issue.author().id(), is_you);
-
    let comments = issue.comments().count();
-

-
    let context = common::label(" issue ").background(theme.colors.context_badge_bg);
-
    let id = common::label(&format!(" {id} "))
-
        .foreground(theme.colors.context_id_fg)
-
        .background(theme.colors.context_id_bg);
-
    let title = common::label(&format!(" {title} "))
-
        .foreground(theme.colors.default_fg)
-
        .background(theme.colors.context_bg);
-
    let author = common::label(&format!(" {author} "))
-
        .foreground(theme.colors.context_id_author_fg)
-
        .background(theme.colors.context_bg);
-
    let comments = common::label(&format!(" {comments} "))
-
        .foreground(Color::Rgb(70, 70, 70))
-
        .background(theme.colors.context_light_bg);
-

-
    let context_bar = ContextBar::new(context, id, author, title, comments);
-

-
    Widget::new(context_bar).height(1)
-
}
deleted radicle-tui/src/ui/widget/patch.rs
@@ -1,202 +0,0 @@
-
use radicle::cob::patch::{Patch, PatchId};
-
use radicle::Profile;
-

-
use radicle_cli::terminal::format;
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::Color;
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use super::{Widget, WidgetComponent};
-

-
use super::common;
-
use super::common::container::Tabs;
-
use super::common::context::{ContextBar, Shortcuts};
-
use super::common::label::Label;
-

-
use crate::ui::theme::Theme;
-
use crate::ui::{cob, layout};
-

-
pub struct Activity {
-
    label: Widget<Label>,
-
    context: Widget<ContextBar>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl Activity {
-
    pub fn new(
-
        label: Widget<Label>,
-
        context: Widget<ContextBar>,
-
        shortcuts: Widget<Shortcuts>,
-
    ) -> Self {
-
        Self {
-
            label,
-
            context,
-
            shortcuts,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for Activity {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let label_w = self
-
            .label
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
-
        let context_h = self
-
            .context
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, layout[0]));
-
        self.context.view(frame, layout[1]);
-
        self.shortcuts.view(frame, layout[2]);
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct Files {
-
    label: Widget<Label>,
-
    context: Widget<ContextBar>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl Files {
-
    pub fn new(
-
        label: Widget<Label>,
-
        context: Widget<ContextBar>,
-
        shortcuts: Widget<Shortcuts>,
-
    ) -> Self {
-
        Self {
-
            label,
-
            context,
-
            shortcuts,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for Files {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let label_w = self
-
            .label
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
-
        let context_h = self
-
            .context
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, layout[0]));
-
        self.context.view(frame, layout[1]);
-
        self.shortcuts.view(frame, layout[2]);
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
-
    common::tabs(
-
        theme,
-
        vec![
-
            common::reversable_label("activity").foreground(theme.colors.tabs_highlighted_fg),
-
            common::reversable_label("files").foreground(theme.colors.tabs_highlighted_fg),
-
        ],
-
    )
-
}
-

-
pub fn activity(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<Activity> {
-
    let (id, patch) = patch;
-
    let shortcuts = common::shortcuts(
-
        theme,
-
        vec![
-
            common::shortcut(theme, "esc", "back"),
-
            common::shortcut(theme, "tab", "section"),
-
            common::shortcut(theme, "q", "quit"),
-
        ],
-
    );
-
    let context = context(theme, (id, patch), profile);
-

-
    let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
-
    let activity = Activity::new(not_implemented, context, shortcuts);
-

-
    Widget::new(activity)
-
}
-

-
pub fn files(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<Activity> {
-
    let (id, patch) = patch;
-
    let shortcuts = common::shortcuts(
-
        theme,
-
        vec![
-
            common::shortcut(theme, "esc", "back"),
-
            common::shortcut(theme, "tab", "section"),
-
            common::shortcut(theme, "q", "quit"),
-
        ],
-
    );
-
    let context = context(theme, (id, patch), profile);
-

-
    let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
-
    let files = Activity::new(not_implemented, context, shortcuts);
-

-
    Widget::new(files)
-
}
-

-
pub fn context(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<ContextBar> {
-
    let (id, patch) = patch;
-
    let (_, rev) = patch.latest();
-
    let is_you = *patch.author().id() == profile.did();
-

-
    let id = format::cob(&id);
-
    let title = patch.title();
-
    let author = cob::format_author(patch.author().id(), is_you);
-
    let comments = rev.discussion().len();
-

-
    let context = common::label(" patch ").background(theme.colors.context_badge_bg);
-
    let id = common::label(&format!(" {id} "))
-
        .foreground(theme.colors.context_id_fg)
-
        .background(theme.colors.context_id_bg);
-
    let title = common::label(&format!(" {title} "))
-
        .foreground(theme.colors.default_fg)
-
        .background(theme.colors.context_bg);
-
    let author = common::label(&format!(" {author} "))
-
        .foreground(theme.colors.context_id_author_fg)
-
        .background(theme.colors.context_bg);
-
    let comments = common::label(&format!(" {comments} "))
-
        .foreground(Color::Rgb(70, 70, 70))
-
        .background(theme.colors.context_light_bg);
-

-
    let context_bar = ContextBar::new(context, id, author, title, comments);
-

-
    Widget::new(context_bar).height(1)
-
}
deleted radicle-tui/src/ui/widget/utils.rs
@@ -1,43 +0,0 @@
-
use tuirealm::tui::layout::{Constraint, Rect};
-

-
use super::common::list::ColumnWidth;
-

-
/// Calculates `Constraint::Percentage` for each fixed column width in `widths`,
-
/// taking into account the available width in `area` and the column spacing given by `spacing`.
-
pub fn column_widths(area: Rect, widths: &[ColumnWidth], spacing: u16) -> Vec<Constraint> {
-
    let total_spacing = spacing.saturating_mul(widths.len() as u16);
-
    let fixed_width = widths
-
        .iter()
-
        .fold(0u16, |total, &width| match width {
-
            ColumnWidth::Fixed(w) => total + w,
-
            ColumnWidth::Grow => total,
-
        })
-
        .saturating_add(total_spacing);
-

-
    let grow_count = widths.iter().fold(0u16, |count, &w| {
-
        if w == ColumnWidth::Grow {
-
            count + 1
-
        } else {
-
            count
-
        }
-
    });
-
    let grow_width = area
-
        .width
-
        .saturating_sub(fixed_width)
-
        .checked_div(grow_count)
-
        .unwrap_or(0);
-

-
    widths
-
        .iter()
-
        .map(|width| match width {
-
            ColumnWidth::Fixed(w) => {
-
                let p: f64 = *w as f64 / area.width as f64 * 100_f64;
-
                Constraint::Percentage(p.ceil() as u16)
-
            }
-
            ColumnWidth::Grow => {
-
                let p: f64 = grow_width as f64 / area.width as f64 * 100_f64;
-
                Constraint::Percentage(p.floor() as u16)
-
            }
-
        })
-
        .collect()
-
}
added src/app.rs
@@ -0,0 +1,208 @@
+
pub mod event;
+
pub mod page;
+
pub mod subscription;
+

+
use anyhow::Result;
+

+
use radicle::cob::issue::IssueId;
+
use radicle::cob::patch::PatchId;
+
use radicle::identity::{Id, Project};
+
use radicle::profile::Profile;
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::{Application, Frame, NoUserEvent};
+

+
use radicle_tui::ui::context::Context;
+
use radicle_tui::ui::theme::{self, Theme};
+
use radicle_tui::Tui;
+
use radicle_tui::{cob, ui};
+

+
use page::{HomeView, PatchView};
+

+
use self::page::{IssuePage, PageStack};
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum HomeCid {
+
    Header,
+
    Dashboard,
+
    IssueBrowser,
+
    PatchBrowser,
+
}
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum PatchCid {
+
    Header,
+
    Activity,
+
    Files,
+
}
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum IssueCid {
+
    Header,
+
    List,
+
    Details,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    Home(HomeCid),
+
    Issue(IssueCid),
+
    Patch(PatchCid),
+
    GlobalListener,
+
}
+

+
/// Messages handled by this application.
+
#[derive(Debug, Eq, PartialEq)]
+
pub enum HomeMessage {}
+

+
#[derive(Debug, Eq, PartialEq)]
+
pub enum IssueMessage {
+
    Show(IssueId),
+
    Changed(IssueId),
+
    Focus(IssueCid),
+
    Leave,
+
}
+

+
#[derive(Debug, Eq, PartialEq)]
+
pub enum PatchMessage {
+
    Show(PatchId),
+
    Leave,
+
}
+

+
#[derive(Debug, Eq, PartialEq)]
+
pub enum Message {
+
    Home(HomeMessage),
+
    Issue(IssueMessage),
+
    Patch(PatchMessage),
+
    NavigationChanged(u16),
+
    Tick,
+
    Quit,
+
}
+

+
#[allow(dead_code)]
+
pub struct App {
+
    context: Context,
+
    pages: PageStack,
+
    theme: Theme,
+
    quit: bool,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
impl App {
+
    pub fn new(profile: Profile, id: Id, project: Project) -> Self {
+
        Self {
+
            context: Context::new(profile, id, project),
+
            pages: PageStack::default(),
+
            theme: theme::default_dark(),
+
            quit: false,
+
        }
+
    }
+

+
    fn view_home(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::<HomeView>::default();
+
        self.pages.push(home, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn view_patch(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: PatchId,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+

+
        if let Some(patch) = cob::patch::find(repo, &id)? {
+
            let view = Box::new(PatchView::new((id, patch)));
+
            self.pages.push(view, app, &self.context, theme)?;
+

+
            Ok(())
+
        } else {
+
            Err(anyhow::anyhow!(
+
                "Could not mount 'page::PatchView'. Patch not found."
+
            ))
+
        }
+
    }
+

+
    fn view_issue(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: IssueId,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+

+
        if let Some(issue) = cob::issue::find(repo, &id)? {
+
            let view = Box::new(IssuePage::new((id, issue)));
+
            self.pages.push(view, app, &self.context, theme)?;
+

+
            Ok(())
+
        } else {
+
            Err(anyhow::anyhow!(
+
                "Could not mount 'page::IssueView'. Issue not found."
+
            ))
+
        }
+
    }
+
}
+

+
impl Tui<Cid, Message> for App {
+
    fn init(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        self.view_home(app, &self.theme.clone())?;
+

+
        // Add global key listener and subscribe to key events
+
        let global = ui::widget::common::global_listener().to_boxed();
+
        app.mount(Cid::GlobalListener, global, subscription::global())?;
+

+
        Ok(())
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        if let Ok(page) = self.pages.peek_mut() {
+
            page.view(app, frame);
+
        }
+
    }
+

+
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
+
        match app.tick(PollStrategy::Once) {
+
            Ok(messages) if !messages.is_empty() => {
+
                let theme = theme::default_dark();
+
                for message in messages {
+
                    match message {
+
                        Message::Issue(IssueMessage::Show(id)) => {
+
                            self.view_issue(app, id, &theme)?;
+
                        }
+
                        Message::Issue(IssueMessage::Leave) => {
+
                            self.pages.pop(app)?;
+
                        }
+
                        Message::Patch(PatchMessage::Show(id)) => {
+
                            self.view_patch(app, id, &theme)?;
+
                        }
+
                        Message::Patch(PatchMessage::Leave) => {
+
                            self.pages.pop(app)?;
+
                        }
+
                        Message::Quit => self.quit = true,
+
                        _ => {
+
                            self.pages
+
                                .peek_mut()?
+
                                .update(app, &self.context, &theme, message)?;
+
                        }
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn quit(&self) -> bool {
+
        self.quit
+
    }
+
}
added src/app/event.rs
@@ -0,0 +1,197 @@
+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
+

+
use radicle_tui::ui::widget::common::container::{AppHeader, GlobalListener, LabeledContainer};
+
use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts};
+
use radicle_tui::ui::widget::common::list::PropertyList;
+
use radicle_tui::ui::widget::home::{Dashboard, IssueBrowser, PatchBrowser};
+
use radicle_tui::ui::widget::{issue, patch};
+

+
use radicle_tui::ui::widget::Widget;
+

+
use super::{IssueMessage, Message, PatchMessage};
+

+
/// Since the framework does not know the type of messages that are being
+
/// passed around in the app, the following handlers need to be implemented for
+
/// each component used.
+
///
+
/// TODO: should handle `Event::WindowResize`, which is not emitted by `termion`.
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('q'),
+
                ..
+
            }) => Some(Message::Quit),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
+
                match self.perform(Cmd::Move(MoveDirection::Right)) {
+
                    CmdResult::Changed(State::One(StateValue::U16(index))) => {
+
                        Some(Message::NavigationChanged(index))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::LargeList> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Issue(IssueMessage::Leave))
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
+
                let result = self.perform(Cmd::Move(MoveDirection::Up));
+
                match result {
+
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            }) => {
+
                let result = self.perform(Cmd::Move(MoveDirection::Down));
+
                match result {
+
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::Details> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<PatchBrowser> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => {
+
                let result = self.perform(Cmd::Submit);
+
                match result {
+
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Patch(PatchMessage::Show(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<IssueBrowser> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => {
+
                let result = self.perform(Cmd::Submit);
+
                match result {
+
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Issue(IssueMessage::Show(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Dashboard> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Activity> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Patch(PatchMessage::Leave))
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Files> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Patch(PatchMessage::Leave))
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<LabeledContainer> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<PropertyList> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ContextBar> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Shortcuts> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
added src/app/page.rs
@@ -0,0 +1,403 @@
+
use anyhow::Result;
+

+
use radicle::cob::issue::{Issue, IssueId};
+
use radicle::cob::patch::{Patch, PatchId};
+

+
use radicle_tui::cob;
+
use tuirealm::{Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui::ui::context::Context;
+
use radicle_tui::ui::layout;
+
use radicle_tui::ui::theme::Theme;
+
use radicle_tui::ui::widget;
+

+
use super::{subscription, Application, Cid, HomeCid, IssueCid, IssueMessage, Message, PatchCid};
+

+
/// `tuirealm`'s event and prop system is designed to work with flat component hierarchies.
+
/// Building deep nested component hierarchies would need a lot more additional effort to
+
/// properly pass events and props down these hierarchies. This makes it hard to implement
+
/// full app views (home, patch details etc) as components.
+
///
+
/// View pages take into account these flat component hierarchies, and provide
+
/// switchable sets of components.
+
pub trait ViewPage {
+
    /// Will be called whenever a view page is pushed onto the page stack. Should create and mount all widgets.
+
    fn mount(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()>;
+

+
    /// Will be called whenever a view page is popped from the page stack. Should unmount all widgets.
+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
+

+
    /// Will be called whenever a view page is on top of the stack and can be used to update its internal
+
    /// state depending on the message passed.
+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        message: Message,
+
    ) -> Result<()>;
+

+
    /// Will be called whenever a view page is on top of the page stack and needs to be rendered.
+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame);
+

+
    /// Will be called whenever this view page is pushed to the stack, or it is on top of the stack again
+
    /// after another view page was popped from the stack.
+
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
+

+
    /// Will be called whenever this view page is on top of the stack and another view page is pushed
+
    /// to the stack, or if this is popped from the stack.
+
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
+
}
+

+
///
+
/// Home
+
///
+
pub struct HomeView {
+
    active_component: Cid,
+
}
+

+
impl Default for HomeView {
+
    fn default() -> Self {
+
        HomeView {
+
            active_component: Cid::Home(HomeCid::Dashboard),
+
        }
+
    }
+
}
+

+
impl ViewPage for HomeView {
+
    fn mount(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = widget::home::navigation(theme);
+
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
+

+
        let dashboard = widget::home::dashboard(context, theme).to_boxed();
+
        let issue_browser = widget::home::issues(context, theme).to_boxed();
+
        let patch_browser = widget::home::patches(context, theme).to_boxed();
+

+
        app.remount(Cid::Home(HomeCid::Header), header, vec![])?;
+

+
        app.remount(Cid::Home(HomeCid::Dashboard), dashboard, vec![])?;
+
        app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?;
+
        app.remount(Cid::Home(HomeCid::PatchBrowser), patch_browser, vec![])?;
+

+
        app.active(&self.active_component)?;
+

+
        Ok(())
+
    }
+

+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::Home(HomeCid::Header))?;
+
        app.umount(&Cid::Home(HomeCid::Dashboard))?;
+
        app.umount(&Cid::Home(HomeCid::IssueBrowser))?;
+
        app.umount(&Cid::Home(HomeCid::PatchBrowser))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        _context: &Context,
+
        _theme: &Theme,
+
        message: Message,
+
    ) -> Result<()> {
+
        if let Message::NavigationChanged(index) = message {
+
            self.active_component = Cid::Home(HomeCid::from(index as usize));
+
            app.active(&self.active_component)?;
+
        }
+

+
        Ok(())
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let layout = layout::default_page(area);
+

+
        app.view(&Cid::Home(HomeCid::Header), frame, layout[0]);
+
        app.view(&self.active_component, frame, layout[1]);
+
    }
+

+
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.subscribe(
+
            &Cid::Home(HomeCid::Header),
+
            Sub::new(subscription::navigation_clause(), SubClause::Always),
+
        )?;
+

+
        Ok(())
+
    }
+

+
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.unsubscribe(
+
            &Cid::Home(HomeCid::Header),
+
            subscription::navigation_clause(),
+
        )?;
+

+
        Ok(())
+
    }
+
}
+

+
///
+
/// Issue detail page
+
///
+
pub struct IssuePage {
+
    issue: (IssueId, Issue),
+
}
+

+
impl IssuePage {
+
    pub fn new(issue: (IssueId, Issue)) -> Self {
+
        IssuePage { issue }
+
    }
+
}
+

+
impl ViewPage for IssuePage {
+
    fn mount(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let (id, issue) = &self.issue;
+
        let header = widget::common::app_header(context, theme, None).to_boxed();
+
        let list = widget::issue::list(context, theme, (*id, issue.clone())).to_boxed();
+
        let details = widget::issue::details(context, theme, (*id, issue.clone())).to_boxed();
+
        let shortcuts = widget::common::shortcuts(
+
            theme,
+
            vec![
+
                widget::common::shortcut(theme, "esc", "back"),
+
                widget::common::shortcut(theme, "q", "quit"),
+
            ],
+
        )
+
        .to_boxed();
+

+
        app.remount(Cid::Issue(IssueCid::Header), header, vec![])?;
+
        app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+
        app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
        app.remount(Cid::Issue(IssueCid::Shortcuts), shortcuts, vec![])?;
+

+
        app.active(&Cid::Issue(IssueCid::List))?;
+

+
        Ok(())
+
    }
+

+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::Issue(IssueCid::Header))?;
+
        app.umount(&Cid::Issue(IssueCid::List))?;
+
        app.umount(&Cid::Issue(IssueCid::Details))?;
+
        app.umount(&Cid::Issue(IssueCid::Shortcuts))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        message: Message,
+
    ) -> Result<()> {
+
        match message {
+
            Message::Issue(IssueMessage::Changed(id)) => {
+
                let repo = context.repository();
+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    let details = widget::issue::details(context, theme, (id, issue)).to_boxed();
+
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
                }
+
            }
+
            Message::Issue(IssueMessage::Focus(cid)) => {
+
                app.active(&Cid::Issue(cid))?;
+
            }
+
            _ => {}
+
        }
+

+
        Ok(())
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let shortcuts_h = 1u16;
+
        let layout = layout::issue_preview(area, shortcuts_h);
+

+
        app.view(&Cid::Issue(IssueCid::Header), frame, layout.header);
+
        app.view(&Cid::Issue(IssueCid::List), frame, layout.list);
+
        app.view(&Cid::Issue(IssueCid::Details), frame, layout.details);
+
        app.view(&Cid::Issue(IssueCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

+
    fn subscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        Ok(())
+
    }
+

+
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        Ok(())
+
    }
+
}
+

+
///
+
/// Patch detail page
+
///
+
pub struct PatchView {
+
    active_component: Cid,
+
    patch: (PatchId, Patch),
+
}
+

+
impl PatchView {
+
    pub fn new(patch: (PatchId, Patch)) -> Self {
+
        PatchView {
+
            active_component: Cid::Patch(PatchCid::Activity),
+
            patch,
+
        }
+
    }
+
}
+

+
impl ViewPage for PatchView {
+
    fn mount(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let (id, patch) = &self.patch;
+
        let navigation = widget::patch::navigation(theme);
+
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
+

+
        let activity = widget::patch::activity(theme, (*id, patch), context.profile()).to_boxed();
+
        let files = widget::patch::files(theme, (*id, patch), context.profile()).to_boxed();
+

+
        app.remount(Cid::Patch(PatchCid::Header), header, vec![])?;
+
        app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
+
        app.remount(Cid::Patch(PatchCid::Files), files, vec![])?;
+

+
        app.active(&self.active_component)?;
+

+
        Ok(())
+
    }
+

+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::Patch(PatchCid::Header))?;
+
        app.umount(&Cid::Patch(PatchCid::Activity))?;
+
        app.umount(&Cid::Patch(PatchCid::Files))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        _context: &Context,
+
        _theme: &Theme,
+
        message: Message,
+
    ) -> Result<()> {
+
        if let Message::NavigationChanged(index) = message {
+
            self.active_component = Cid::Patch(PatchCid::from(index as usize));
+
        }
+
        app.active(&self.active_component)?;
+

+
        Ok(())
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let layout = layout::default_page(area);
+

+
        app.view(&Cid::Patch(PatchCid::Header), frame, layout[0]);
+
        app.view(&self.active_component, frame, layout[1]);
+
    }
+

+
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.subscribe(
+
            &Cid::Patch(PatchCid::Header),
+
            Sub::new(subscription::navigation_clause(), SubClause::Always),
+
        )?;
+

+
        Ok(())
+
    }
+

+
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.unsubscribe(
+
            &Cid::Patch(PatchCid::Header),
+
            subscription::navigation_clause(),
+
        )?;
+

+
        Ok(())
+
    }
+
}
+

+
/// View pages need to preserve their state (e.g. selected navigation tab, contents
+
/// and the selected row of a table). Therefor they should not be (re-)created
+
/// each time they are displayed.
+
/// Instead the application can push a new page onto the page stack if it needs to
+
/// be displayed. Its components are then created using the internal state. If a
+
/// new page needs to be displayed, it will also be pushed onto the stack. Leaving
+
/// that page again will pop it from the stack. The application can then return to
+
/// the previously displayed page in the state it was left.
+
#[derive(Default)]
+
pub struct PageStack {
+
    pages: Vec<Box<dyn ViewPage>>,
+
}
+

+
impl PageStack {
+
    pub fn push(
+
        &mut self,
+
        page: Box<dyn ViewPage>,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        if let Some(page) = self.pages.last() {
+
            page.unsubscribe(app)?;
+
        }
+

+
        page.mount(app, context, theme)?;
+
        page.subscribe(app)?;
+

+
        self.pages.push(page);
+

+
        Ok(())
+
    }
+

+
    pub fn pop(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        self.peek_mut()?.unsubscribe(app)?;
+
        self.peek_mut()?.unmount(app)?;
+
        self.pages.pop();
+

+
        self.peek_mut()?.subscribe(app)?;
+

+
        Ok(())
+
    }
+

+
    pub fn peek_mut(&mut self) -> Result<&mut Box<dyn ViewPage>> {
+
        match self.pages.last_mut() {
+
            Some(page) => Ok(page),
+
            None => Err(anyhow::anyhow!(
+
                "Could not peek active page. Page stack is empty."
+
            )),
+
        }
+
    }
+
}
+

+
impl From<usize> for HomeCid {
+
    fn from(index: usize) -> Self {
+
        match index {
+
            0 => HomeCid::Dashboard,
+
            1 => HomeCid::IssueBrowser,
+
            2 => HomeCid::PatchBrowser,
+
            _ => HomeCid::Dashboard,
+
        }
+
    }
+
}
+

+
impl From<usize> for PatchCid {
+
    fn from(index: usize) -> Self {
+
        match index {
+
            0 => PatchCid::Activity,
+
            1 => PatchCid::Files,
+
            _ => PatchCid::Activity,
+
        }
+
    }
+
}
added src/app/subscription.rs
@@ -0,0 +1,31 @@
+
use std::hash::Hash;
+

+
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
+
use tuirealm::{Sub, SubClause, SubEventClause};
+

+
pub fn navigation_clause<UserEvent>() -> SubEventClause<UserEvent>
+
where
+
    UserEvent: Clone + Eq + PartialEq + PartialOrd,
+
{
+
    SubEventClause::Keyboard(KeyEvent {
+
        code: Key::Tab,
+
        modifiers: KeyModifiers::NONE,
+
    })
+
}
+

+
pub fn global<Id, UserEvent>() -> Vec<Sub<Id, UserEvent>>
+
where
+
    Id: Clone + Hash + Eq + PartialEq,
+
    UserEvent: Clone + Eq + PartialEq + PartialOrd,
+
{
+
    vec![
+
        Sub::new(
+
            SubEventClause::Keyboard(KeyEvent {
+
                code: Key::Char('q'),
+
                modifiers: KeyModifiers::NONE,
+
            }),
+
            SubClause::Always,
+
        ),
+
        Sub::new(SubEventClause::WindowResize, SubClause::Always),
+
    ]
+
}
added src/cob.rs
@@ -0,0 +1,2 @@
+
pub mod issue;
+
pub mod patch;
added src/cob/issue.rs
@@ -0,0 +1,19 @@
+
use anyhow::Result;
+
use radicle::cob::issue::{Issue, IssueId, Issues};
+
use radicle::storage::git::Repository;
+

+
pub fn all(repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
+
    let patches = Issues::open(repository)?
+
        .all()
+
        .map(|iter| iter.flatten().collect::<Vec<_>>())?;
+

+
    Ok(patches
+
        .into_iter()
+
        .map(|(id, issue)| (id, issue))
+
        .collect::<Vec<_>>())
+
}
+

+
pub fn find(repository: &Repository, id: &IssueId) -> Result<Option<Issue>> {
+
    let issues = Issues::open(repository)?;
+
    Ok(issues.get(id)?)
+
}
added src/cob/patch.rs
@@ -0,0 +1,20 @@
+
use anyhow::Result;
+

+
use radicle::cob::patch::{Patch, PatchId, Patches};
+
use radicle::storage::git::Repository;
+

+
pub fn all(repository: &Repository) -> Result<Vec<(PatchId, Patch)>> {
+
    let patches = Patches::open(repository)?
+
        .all()
+
        .map(|iter| iter.flatten().collect::<Vec<_>>())?;
+

+
    Ok(patches
+
        .into_iter()
+
        .map(|(id, patch)| (id, patch))
+
        .collect::<Vec<_>>())
+
}
+

+
pub fn find(repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
+
    let patches = Patches::open(repository)?;
+
    Ok(patches.get(id)?)
+
}
added src/lib.rs
@@ -0,0 +1,98 @@
+
use std::hash::Hash;
+
use std::time::Duration;
+

+
use anyhow::Result;
+

+
use tuirealm::terminal::TerminalBridge;
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::Frame;
+
use tuirealm::{Application, EventListenerCfg, NoUserEvent};
+

+
pub mod cob;
+
pub mod ui;
+

+
/// Trait that must be implemented by client applications in order to be run
+
/// as tui-application using tui-realm. Implementors act as models to the
+
/// tui-realm application that can be polled for new messages, updated
+
/// accordingly and rendered with new state.
+
///
+
/// Please see `examples/` for further information on how to use it.
+
pub trait Tui<Id, Message>
+
where
+
    Id: Eq + PartialEq + Clone + Hash,
+
    Message: Eq,
+
{
+
    /// Should initialize an application by mounting and activating components.
+
    fn init(&mut self, app: &mut Application<Id, Message, NoUserEvent>) -> Result<()>;
+

+
    /// Should update the current state by handling a message from the view. Returns true
+
    /// if view should be updated (e.g. a message was received and the current state changed).
+
    fn update(&mut self, app: &mut Application<Id, Message, NoUserEvent>) -> Result<bool>;
+

+
    /// Should draw the application to a frame.
+
    fn view(&mut self, app: &mut Application<Id, Message, NoUserEvent>, frame: &mut Frame);
+

+
    /// Should return true if the application is requested to quit.
+
    fn quit(&self) -> bool;
+
}
+

+
/// A tui-window using the cross-platform Terminal helper provided
+
/// by tui-realm.
+
pub struct Window {
+
    /// Helper around `Terminal` to quickly setup and perform on terminal.
+
    pub terminal: TerminalBridge,
+
}
+

+
impl Default for Window {
+
    fn default() -> Self {
+
        Self::new()
+
    }
+
}
+

+
/// Provides a way to create and run a new tui-application.
+
impl Window {
+
    /// Creates a tui-window using the default cross-platform Terminal
+
    /// helper and panics if its creation fails.
+
    pub fn new() -> Self {
+
        Self {
+
            terminal: TerminalBridge::new().expect("Cannot create terminal bridge"),
+
        }
+
    }
+

+
    /// Runs this tui-window with the tui-application given and performs the
+
    /// following steps:
+
    /// 1. Enter alternative terminal screen
+
    /// 2. Run main loop until application should quit and with each iteration
+
    ///    - poll new events (tick or user event)
+
    ///    - update application state
+
    ///    - redraw view
+
    /// 3. Leave alternative terminal screen
+
    pub fn run<T, Id, Message>(&mut self, tui: &mut T, interval: u64) -> Result<()>
+
    where
+
        T: Tui<Id, Message>,
+
        Id: Eq + PartialEq + Clone + Hash,
+
        Message: Eq,
+
    {
+
        let mut update = true;
+
        let mut resize = false;
+
        let mut size = Rect::default();
+
        let mut app = Application::init(
+
            EventListenerCfg::default().default_input_listener(Duration::from_millis(interval)),
+
        );
+
        tui.init(&mut app)?;
+

+
        while !tui.quit() {
+
            if update || resize {
+
                self.terminal
+
                    .raw_mut()
+
                    .draw(|frame| tui.view(&mut app, frame))?;
+
            }
+
            update = tui.update(&mut app)?;
+

+
            resize = size != self.terminal.raw().size()?;
+
            size = self.terminal.raw().size()?;
+
        }
+

+
        Ok(())
+
    }
+
}
added src/main.rs
@@ -0,0 +1,84 @@
+
use std::process;
+

+
use anyhow::{anyhow, Context};
+

+
use radicle::storage::ReadStorage;
+

+
use radicle_cli as cli;
+
use radicle_term as term;
+
use radicle_tui::Window;
+

+
mod app;
+

+
pub const NAME: &str = "radicle-tui";
+
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+
pub const GIT_HEAD: &str = env!("GIT_HEAD");
+
pub const FPS: u64 = 60;
+

+
pub const HELP: &str = r#"
+
Usage
+

+
    radicle-tui [<option>...]
+

+
Options
+

+
    --version       Print version
+
    --help          Print help
+

+
"#;
+

+
struct Options;
+

+
impl Options {
+
    fn from_env() -> Result<Self, anyhow::Error> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_env();
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("version") => {
+
                    println!("{NAME} {VERSION}+{GIT_HEAD}");
+
                    process::exit(0);
+
                }
+
                Long("help") | Short('h') => {
+
                    println!("{HELP}");
+
                    process::exit(0);
+
                }
+
                _ => anyhow::bail!(arg.unexpected()),
+
            }
+
        }
+

+
        Ok(Self {})
+
    }
+
}
+

+
fn execute() -> anyhow::Result<()> {
+
    let _ = Options::from_env()?;
+

+
    let (_, id) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

+
    let profile = cli::terminal::profile()?;
+

+
    let signer = cli::terminal::signer(&profile)?;
+
    let storage = &profile.storage;
+

+
    let payload = storage
+
        .get(signer.public_key(), id)?
+
        .context("No project with such `id` exists")?;
+

+
    let project = payload.project()?;
+

+
    let mut window = Window::default();
+
    window.run(&mut app::App::new(profile, id, project), 1000 / FPS)?;
+

+
    Ok(())
+
}
+

+
fn main() {
+
    if let Err(err) = execute() {
+
        term::error(format!("Error: rad-tui: {err}"));
+
        process::exit(1);
+
    }
+
}
added src/ui.rs
@@ -0,0 +1,7 @@
+
pub mod cob;
+
pub mod context;
+
pub mod ext;
+
pub mod layout;
+
pub mod state;
+
pub mod theme;
+
pub mod widget;
added src/ui/cob.rs
@@ -0,0 +1,360 @@
+
use radicle_surf;
+

+
use cli::terminal::format;
+
use radicle_cli as cli;
+

+
use radicle::prelude::Did;
+
use radicle::storage::git::Repository;
+
use radicle::storage::{Oid, ReadRepository};
+
use radicle::Profile;
+

+
use radicle::cob::issue::{Issue, IssueId, State as IssueState};
+
use radicle::cob::patch::{Patch, PatchId, State as PatchState};
+
use radicle::cob::{Tag, Timestamp};
+

+
use tuirealm::props::{Color, Style};
+
use tuirealm::tui::text::{Span, Spans};
+
use tuirealm::tui::widgets::Cell;
+

+
use crate::ui::theme::Theme;
+
use crate::ui::widget::common::list::TableItem;
+

+
use super::widget::common::list::ListItem;
+

+
/// An author item that can be used in tables, list or trees.
+
///
+
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
+
/// would be needed if [`AuthorItem`] would be used directly.
+
#[derive(Clone)]
+
pub struct AuthorItem {
+
    /// The author's DID.
+
    did: Did,
+
    /// True if the author is the current user.
+
    is_you: bool,
+
}
+

+
impl AuthorItem {
+
    pub fn did(&self) -> Did {
+
        self.did
+
    }
+

+
    pub fn is_you(&self) -> bool {
+
        self.is_you
+
    }
+
}
+

+
/// A patch item that can be used in tables, list or trees.
+
///
+
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
+
/// would be needed if [`Patch`] would be used directly.
+
#[derive(Clone)]
+
pub struct PatchItem {
+
    /// Patch OID.
+
    id: PatchId,
+
    /// Patch state.
+
    state: PatchState,
+
    /// Patch title.
+
    title: String,
+
    /// Author of the latest revision.
+
    author: AuthorItem,
+
    /// Head of the latest revision.
+
    head: Oid,
+
    /// Lines added by the latest revision.
+
    added: u16,
+
    /// Lines removed by the latest revision.
+
    removed: u16,
+
    /// Time when patch was opened.
+
    timestamp: Timestamp,
+
}
+

+
impl PatchItem {
+
    pub fn id(&self) -> &PatchId {
+
        &self.id
+
    }
+

+
    pub fn state(&self) -> &PatchState {
+
        &self.state
+
    }
+

+
    pub fn title(&self) -> &String {
+
        &self.title
+
    }
+

+
    pub fn author(&self) -> &AuthorItem {
+
        &self.author
+
    }
+

+
    pub fn head(&self) -> &Oid {
+
        &self.head
+
    }
+

+
    pub fn added(&self) -> u16 {
+
        self.added
+
    }
+

+
    pub fn removed(&self) -> u16 {
+
        self.removed
+
    }
+

+
    pub fn timestamp(&self) -> &Timestamp {
+
        &self.timestamp
+
    }
+
}
+

+
impl TryFrom<(&Profile, &Repository, PatchId, Patch)> for PatchItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Profile, &Repository, PatchId, Patch)) -> Result<Self, Self::Error> {
+
        let (profile, repo, id, patch) = value;
+
        let (_, rev) = patch.latest();
+
        let repo = radicle_surf::Repository::open(repo.path())?;
+
        let base = repo.commit(rev.base())?;
+
        let head = repo.commit(rev.head())?;
+
        let diff = repo.diff(base.id, head.id)?;
+

+
        Ok(PatchItem {
+
            id,
+
            state: patch.state().clone(),
+
            title: patch.title().into(),
+
            author: AuthorItem {
+
                did: patch.author().id,
+
                is_you: *patch.author().id == *profile.did(),
+
            },
+
            head: rev.head(),
+
            added: diff.stats().insertions as u16,
+
            removed: diff.stats().deletions as u16,
+
            timestamp: rev.timestamp(),
+
        })
+
    }
+
}
+

+
impl TableItem<8> for PatchItem {
+
    fn row(&self, theme: &Theme) -> [Cell; 8] {
+
        let (icon, color) = format_patch_state(&self.state);
+
        let state = Cell::from(icon).style(Style::default().fg(color));
+

+
        let id = Cell::from(format::cob(&self.id))
+
            .style(Style::default().fg(theme.colors.browser_list_id));
+

+
        let title = Cell::from(self.title.clone())
+
            .style(Style::default().fg(theme.colors.browser_list_title));
+

+
        let author = Cell::from(format_author(&self.author.did, self.author.is_you))
+
            .style(Style::default().fg(theme.colors.browser_list_author));
+

+
        let head = Cell::from(format::oid(self.head).item)
+
            .style(Style::default().fg(theme.colors.browser_patch_list_head));
+

+
        let added = Cell::from(format!("{}", self.added))
+
            .style(Style::default().fg(theme.colors.browser_patch_list_added));
+

+
        let removed = Cell::from(format!("{}", self.removed))
+
            .style(Style::default().fg(theme.colors.browser_patch_list_removed));
+

+
        let updated = Cell::from(format::timestamp(&self.timestamp).to_string())
+
            .style(Style::default().fg(theme.colors.browser_list_timestamp));
+

+
        [state, id, title, author, head, added, removed, updated]
+
    }
+
}
+

+
/// An issue item that can be used in tables, list or trees.
+
///
+
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
+
/// would be needed if [`Issue`] would be used directly.
+
#[derive(Clone)]
+
pub struct IssueItem {
+
    /// Issue OID.
+
    id: IssueId,
+
    /// Issue state.
+
    state: IssueState,
+
    /// Issue title.
+
    title: String,
+
    /// Issue author.
+
    author: AuthorItem,
+
    /// Issue tags.
+
    tags: Vec<Tag>,
+
    /// Issue assignees.
+
    assignees: Vec<AuthorItem>,
+
    /// Time when issue was opened.
+
    timestamp: Timestamp,
+
}
+

+
impl IssueItem {
+
    pub fn id(&self) -> &IssueId {
+
        &self.id
+
    }
+

+
    pub fn state(&self) -> &IssueState {
+
        &self.state
+
    }
+

+
    pub fn title(&self) -> &String {
+
        &self.title
+
    }
+

+
    pub fn author(&self) -> &AuthorItem {
+
        &self.author
+
    }
+

+
    pub fn tags(&self) -> &Vec<Tag> {
+
        &self.tags
+
    }
+

+
    pub fn assignees(&self) -> &Vec<AuthorItem> {
+
        &self.assignees
+
    }
+

+
    pub fn timestamp(&self) -> &Timestamp {
+
        &self.timestamp
+
    }
+
}
+

+
impl From<(&Profile, &Repository, IssueId, Issue)> for IssueItem {
+
    fn from(value: (&Profile, &Repository, IssueId, Issue)) -> Self {
+
        let (profile, _, id, issue) = value;
+

+
        IssueItem {
+
            id,
+
            state: *issue.state(),
+
            title: issue.title().into(),
+
            author: AuthorItem {
+
                did: issue.author().id,
+
                is_you: *issue.author().id == *profile.did(),
+
            },
+
            tags: issue.tags().cloned().collect(),
+
            assignees: issue
+
                .assigned()
+
                .map(|did| AuthorItem {
+
                    did,
+
                    is_you: did == profile.did(),
+
                })
+
                .collect::<Vec<_>>(),
+
            timestamp: issue.timestamp(),
+
        }
+
    }
+
}
+

+
impl TableItem<7> for IssueItem {
+
    fn row(&self, theme: &Theme) -> [Cell; 7] {
+
        let (icon, color) = format_issue_state(&self.state);
+
        let state = Cell::from(icon).style(Style::default().fg(color));
+

+
        let id = Cell::from(format::cob(&self.id))
+
            .style(Style::default().fg(theme.colors.browser_list_id));
+

+
        let title = Cell::from(self.title.clone())
+
            .style(Style::default().fg(theme.colors.browser_list_title));
+

+
        let author = Cell::from(format_author(&self.author.did, self.author.is_you))
+
            .style(Style::default().fg(theme.colors.browser_list_author));
+

+
        let tags = Cell::from(format_tags(&self.tags))
+
            .style(Style::default().fg(theme.colors.browser_list_tags));
+

+
        let assignees = self
+
            .assignees
+
            .iter()
+
            .map(|author| (author.did, author.is_you))
+
            .collect::<Vec<_>>();
+
        let assignees = Cell::from(format_assignees(&assignees))
+
            .style(Style::default().fg(theme.colors.browser_list_author));
+

+
        let opened = Cell::from(format::timestamp(&self.timestamp).to_string())
+
            .style(Style::default().fg(theme.colors.browser_list_timestamp));
+

+
        [state, id, title, author, tags, assignees, opened]
+
    }
+
}
+

+
impl ListItem for IssueItem {
+
    fn row(&self, theme: &Theme) -> tuirealm::tui::widgets::ListItem {
+
        let (state, state_color) = format_issue_state(&self.state);
+
        let lines = vec![
+
            Spans::from(vec![
+
                Span::styled(state, Style::default().fg(state_color)),
+
                Span::styled(
+
                    self.title.clone(),
+
                    Style::default().fg(theme.colors.browser_list_title),
+
                ),
+
            ]),
+
            Spans::from(vec![
+
                Span::raw(String::from("   ")),
+
                Span::styled(
+
                    format_author(&self.author.did, self.author.is_you),
+
                    Style::default().fg(theme.colors.browser_list_author),
+
                ),
+
                Span::styled(
+
                    format!(" {} ", theme.icons.property_divider),
+
                    Style::default().fg(theme.colors.property_divider_fg),
+
                ),
+
                Span::styled(
+
                    format::timestamp(&self.timestamp).to_string(),
+
                    Style::default().fg(theme.colors.browser_list_timestamp),
+
                ),
+
            ]),
+
        ];
+
        tuirealm::tui::widgets::ListItem::new(lines)
+
    }
+
}
+

+
impl PartialEq for IssueItem {
+
    fn eq(&self, other: &Self) -> bool {
+
        self.id == other.id
+
    }
+
}
+

+
pub fn format_patch_state(state: &PatchState) -> (String, Color) {
+
    match state {
+
        PatchState::Open { conflicts: _ } => (" ● ".into(), Color::Green),
+
        PatchState::Archived => (" ● ".into(), Color::Yellow),
+
        PatchState::Draft => (" ● ".into(), Color::Gray),
+
        PatchState::Merged {
+
            revision: _,
+
            commit: _,
+
        } => (" ✔ ".into(), Color::Blue),
+
    }
+
}
+

+
pub fn format_author(did: &Did, is_you: bool) -> String {
+
    if is_you {
+
        format!("{} (you)", format::did(did))
+
    } else {
+
        format!("{}", format::did(did))
+
    }
+
}
+

+
pub fn format_issue_state(state: &IssueState) -> (String, Color) {
+
    match state {
+
        IssueState::Open => (" ● ".into(), Color::Green),
+
        IssueState::Closed { reason: _ } => (" ● ".into(), Color::Red),
+
    }
+
}
+

+
pub fn format_tags(tags: &[Tag]) -> String {
+
    let mut output = String::new();
+
    let mut tags = tags.iter().peekable();
+

+
    while let Some(tag) = tags.next() {
+
        output.push_str(&tag.to_string());
+

+
        if tags.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
+

+
pub fn format_assignees(assignees: &[(Did, bool)]) -> String {
+
    let mut output = String::new();
+
    let mut assignees = assignees.iter().peekable();
+

+
    while let Some((assignee, is_you)) = assignees.next() {
+
        output.push_str(&format_author(assignee, *is_you));
+

+
        if assignees.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
added src/ui/context.rs
@@ -0,0 +1,40 @@
+
use radicle::prelude::{Id, Project};
+
use radicle::Profile;
+

+
use radicle::storage::git::Repository;
+
use radicle::storage::ReadStorage;
+
pub struct Context {
+
    profile: Profile,
+
    id: Id,
+
    project: Project,
+
    repository: Repository,
+
}
+

+
impl Context {
+
    pub fn new(profile: Profile, id: Id, project: Project) -> Self {
+
        let repository = profile.storage.repository(id).unwrap();
+

+
        Self {
+
            id,
+
            profile,
+
            project,
+
            repository,
+
        }
+
    }
+

+
    pub fn profile(&self) -> &Profile {
+
        &self.profile
+
    }
+

+
    pub fn id(&self) -> &Id {
+
        &self.id
+
    }
+

+
    pub fn project(&self) -> &Project {
+
        &self.project
+
    }
+

+
    pub fn repository(&self) -> &Repository {
+
        &self.repository
+
    }
+
}
added src/ui/ext.rs
@@ -0,0 +1,113 @@
+
use tuirealm::tui::buffer::Buffer;
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::tui::style::Style;
+
use tuirealm::tui::widgets::{BorderType, Borders, Widget};
+

+
pub struct HeaderBlock {
+
    /// Visible borders
+
    borders: Borders,
+
    /// Border style
+
    border_style: Style,
+
    /// Type of the border. The default is plain lines but one can choose to have rounded corners
+
    /// or doubled lines instead.
+
    border_type: BorderType,
+
    /// Widget style
+
    style: Style,
+
}
+

+
impl Default for HeaderBlock {
+
    fn default() -> HeaderBlock {
+
        HeaderBlock {
+
            borders: Borders::NONE,
+
            border_style: Default::default(),
+
            border_type: BorderType::Plain,
+
            style: Default::default(),
+
        }
+
    }
+
}
+

+
impl HeaderBlock {
+
    pub fn border_style(mut self, style: Style) -> HeaderBlock {
+
        self.border_style = style;
+
        self
+
    }
+

+
    pub fn style(mut self, style: Style) -> HeaderBlock {
+
        self.style = style;
+
        self
+
    }
+

+
    pub fn borders(mut self, flag: Borders) -> HeaderBlock {
+
        self.borders = flag;
+
        self
+
    }
+

+
    pub fn border_type(mut self, border_type: BorderType) -> HeaderBlock {
+
        self.border_type = border_type;
+
        self
+
    }
+
}
+

+
impl Widget for HeaderBlock {
+
    fn render(self, area: Rect, buf: &mut Buffer) {
+
        if area.area() == 0 {
+
            return;
+
        }
+
        buf.set_style(area, self.style);
+
        let symbols = BorderType::line_symbols(self.border_type);
+

+
        // Sides
+
        if self.borders.intersects(Borders::LEFT) {
+
            for y in area.top()..area.bottom() {
+
                buf.get_mut(area.left(), y)
+
                    .set_symbol(symbols.vertical)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::TOP) {
+
            for x in area.left()..area.right() {
+
                buf.get_mut(x, area.top())
+
                    .set_symbol(symbols.horizontal)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::RIGHT) {
+
            let x = area.right() - 1;
+
            for y in area.top()..area.bottom() {
+
                buf.get_mut(x, y)
+
                    .set_symbol(symbols.vertical)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::BOTTOM) {
+
            let y = area.bottom() - 1;
+
            for x in area.left()..area.right() {
+
                buf.get_mut(x, y)
+
                    .set_symbol(symbols.horizontal)
+
                    .set_style(self.border_style);
+
            }
+
        }
+

+
        // Corners
+
        if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
+
            buf.get_mut(area.right() - 1, area.bottom() - 1)
+
                .set_symbol(symbols.vertical_left)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
+
            buf.get_mut(area.right() - 1, area.top())
+
                .set_symbol(symbols.top_right)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
+
            buf.get_mut(area.left(), area.bottom() - 1)
+
                .set_symbol(symbols.vertical_right)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::LEFT | Borders::TOP) {
+
            buf.get_mut(area.left(), area.top())
+
                .set_symbol(symbols.top_left)
+
                .set_style(self.border_style);
+
        }
+
    }
+
}
added src/ui/layout.rs
@@ -0,0 +1,210 @@
+
use tuirealm::props::{AttrValue, Attribute};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
+
use tuirealm::MockComponent;
+

+
pub struct AppHeader {
+
    pub nav: Rect,
+
    pub info: Rect,
+
    pub line: Rect,
+
}
+

+
pub struct IssuePreview {
+
    pub header: Rect,
+
    pub list: Rect,
+
    pub details: Rect,
+
    pub discussion: Rect,
+
    pub shortcuts: Rect,
+
}
+

+
pub fn v_stack(
+
    widgets: Vec<Box<dyn MockComponent>>,
+
    area: Rect,
+
) -> Vec<(Box<dyn MockComponent>, Rect)> {
+
    let constraints = widgets
+
        .iter()
+
        .map(|w| {
+
            Constraint::Length(
+
                w.query(Attribute::Height)
+
                    .unwrap_or(AttrValue::Size(0))
+
                    .unwrap_size(),
+
            )
+
        })
+
        .collect::<Vec<_>>();
+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints(constraints)
+
        .split(area);
+

+
    widgets.into_iter().zip(layout.into_iter()).collect()
+
}
+

+
pub fn h_stack(
+
    widgets: Vec<Box<dyn MockComponent>>,
+
    area: Rect,
+
) -> Vec<(Box<dyn MockComponent>, Rect)> {
+
    let constraints = widgets
+
        .iter()
+
        .map(|w| {
+
            Constraint::Length(
+
                w.query(Attribute::Width)
+
                    .unwrap_or(AttrValue::Size(0))
+
                    .unwrap_size(),
+
            )
+
        })
+
        .collect::<Vec<_>>();
+
    let layout = Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints(constraints)
+
        .split(area);
+

+
    widgets.into_iter().zip(layout.into_iter()).collect()
+
}
+

+
pub fn app_header(area: Rect, info_w: u16) -> AppHeader {
+
    let nav_w = area.width.saturating_sub(info_w);
+

+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints(vec![
+
            Constraint::Length(1),
+
            Constraint::Length(1),
+
            Constraint::Length(1),
+
        ])
+
        .split(area);
+

+
    let top = Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints([Constraint::Length(nav_w), Constraint::Length(info_w)].as_ref())
+
        .split(layout[1]);
+

+
    AppHeader {
+
        nav: top[0],
+
        info: top[1],
+
        line: layout[2],
+
    }
+
}
+

+
pub fn default_page(area: Rect) -> Vec<Rect> {
+
    let nav_h = 3u16;
+
    let margin_h = 1u16;
+
    let content_h = area.height.saturating_sub(nav_h.saturating_add(margin_h));
+

+
    Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(margin_h)
+
        .constraints([Constraint::Length(nav_h), Constraint::Length(content_h)].as_ref())
+
        .split(area)
+
}
+

+
pub fn headerless_page(area: Rect) -> Vec<Rect> {
+
    let margin_h = 1u16;
+
    let content_h = area.height.saturating_sub(margin_h);
+

+
    Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(margin_h)
+
        .constraints([Constraint::Length(content_h)].as_ref())
+
        .split(area)
+
}
+

+
pub fn root_component(area: Rect, shortcuts_h: u16) -> Vec<Rect> {
+
    let content_h = area.height.saturating_sub(shortcuts_h);
+

+
    Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints(
+
            [
+
                Constraint::Length(content_h),
+
                Constraint::Length(shortcuts_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area)
+
}
+

+
pub fn root_component_with_context(area: Rect, context_h: u16, shortcuts_h: u16) -> Vec<Rect> {
+
    let content_h = area
+
        .height
+
        .saturating_sub(shortcuts_h.saturating_add(context_h));
+

+
    Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints(
+
            [
+
                Constraint::Length(content_h),
+
                Constraint::Length(context_h),
+
                Constraint::Length(shortcuts_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area)
+
}
+

+
pub fn centered_label(label_w: u16, area: Rect) -> Rect {
+
    let label_h = 1u16;
+
    let spacer_w = area.width.saturating_sub(label_w).saturating_div(2);
+
    let spacer_h = area.height.saturating_sub(label_h).saturating_div(2);
+

+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints(
+
            [
+
                Constraint::Length(spacer_h),
+
                Constraint::Length(label_h),
+
                Constraint::Length(spacer_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area);
+

+
    Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints(
+
            [
+
                Constraint::Length(spacer_w),
+
                Constraint::Length(label_w),
+
                Constraint::Length(spacer_w),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(layout[1])[1]
+
}
+

+
pub fn issue_preview(area: Rect, shortcuts_h: u16) -> IssuePreview {
+
    let header_h = 3u16;
+
    let content_h = area
+
        .height
+
        .saturating_sub(header_h)
+
        .saturating_sub(shortcuts_h);
+

+
    let root = Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(1)
+
        .constraints(
+
            [
+
                Constraint::Length(header_h),
+
                Constraint::Length(content_h),
+
                Constraint::Length(shortcuts_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area);
+

+
    let split = Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
+
        .split(root[1]);
+

+
    let right = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints([Constraint::Length(6), Constraint::Min(0)].as_ref())
+
        .split(split[1]);
+

+
    IssuePreview {
+
        header: root[0],
+
        list: split[0],
+
        details: right[0],
+
        discussion: right[1],
+
        shortcuts: root[2],
+
    }
+
}
added src/ui/state.rs
@@ -0,0 +1,85 @@
+
use tuirealm::tui::widgets::{ListState, TableState};
+

+
/// State that holds the index of a selected tab item and the count of all tab items.
+
/// The index can be increased and will start at 0, if length was reached.
+
#[derive(Clone, Default)]
+
pub struct TabState {
+
    pub selected: u16,
+
    pub len: u16,
+
}
+

+
impl TabState {
+
    pub fn incr_tab_index(&mut self, rewind: bool) {
+
        if self.selected + 1 < self.len {
+
            self.selected += 1;
+
        } else if rewind {
+
            self.selected = 0;
+
        }
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct ItemState {
+
    selected: Option<usize>,
+
    len: usize,
+
}
+

+
impl ItemState {
+
    pub fn new(selected: Option<usize>, len: usize) -> Self {
+
        Self { selected, len }
+
    }
+

+
    pub fn selected(&self) -> Option<usize> {
+
        self.selected
+
    }
+

+
    pub fn select_previous(&mut self) -> Option<usize> {
+
        let old_index = self.selected();
+
        let new_index = match old_index {
+
            Some(selected) if selected == 0 => Some(0),
+
            Some(selected) => Some(selected.saturating_sub(1)),
+
            None => Some(0),
+
        };
+

+
        if old_index != new_index {
+
            self.selected = new_index;
+
            self.selected()
+
        } else {
+
            None
+
        }
+
    }
+

+
    pub fn select_next(&mut self) -> Option<usize> {
+
        let old_index = self.selected();
+
        let new_index = match old_index {
+
            Some(selected) if selected >= self.len.saturating_sub(1) => {
+
                Some(self.len.saturating_sub(1))
+
            }
+
            Some(selected) => Some(selected.saturating_add(1)),
+
            None => Some(0),
+
        };
+

+
        if old_index != new_index {
+
            self.selected = new_index;
+
            self.selected()
+
        } else {
+
            None
+
        }
+
    }
+
}
+

+
impl From<&ItemState> for TableState {
+
    fn from(value: &ItemState) -> Self {
+
        let mut state = TableState::default();
+
        state.select(value.selected);
+
        state
+
    }
+
}
+

+
impl From<&ItemState> for ListState {
+
    fn from(value: &ItemState) -> Self {
+
        let mut state = ListState::default();
+
        state.select(value.selected);
+
        state
+
    }
+
}
added src/ui/theme.rs
@@ -0,0 +1,122 @@
+
use tuirealm::props::Color;
+

+
const COLOR_DEFAULT_FG: Color = Color::Rgb(200, 200, 200);
+
const COLOR_DEFAULT_DARK_FG: Color = Color::Rgb(150, 150, 150);
+
const COLOR_DEFAULT_DARK: Color = Color::Rgb(100, 100, 100);
+
const COLOR_DEFAULT_DARKER: Color = Color::Rgb(70, 70, 70);
+
const COLOR_DEFAULT_DARKEST: Color = Color::Rgb(40, 40, 40);
+
const COLOR_DEFAULT_FAINT: Color = Color::Rgb(20, 20, 20);
+

+
#[derive(Debug, Clone)]
+
pub struct Colors {
+
    pub default_fg: Color,
+
    pub tabs_highlighted_fg: Color,
+
    pub app_header_project_fg: Color,
+
    pub app_header_rid_fg: Color,
+
    pub labeled_container_bg: Color,
+
    pub item_list_highlighted_bg: Color,
+
    pub property_name_fg: Color,
+
    pub property_divider_fg: Color,
+
    pub shortcut_short_fg: Color,
+
    pub shortcut_long_fg: Color,
+
    pub shortcutbar_divider_fg: Color,
+
    pub browser_list_id: Color,
+
    pub browser_list_title: Color,
+
    pub browser_list_description: Color,
+
    pub browser_list_author: Color,
+
    pub browser_list_tags: Color,
+
    pub browser_list_comments: Color,
+
    pub browser_list_timestamp: Color,
+
    pub browser_patch_list_head: Color,
+
    pub browser_patch_list_added: Color,
+
    pub browser_patch_list_removed: Color,
+
    pub context_bg: Color,
+
    pub context_light_bg: Color,
+
    pub context_badge_bg: Color,
+
    pub context_id_fg: Color,
+
    pub context_id_bg: Color,
+
    pub context_id_author_fg: Color,
+
    pub container_border_fg: Color,
+
    pub container_border_focus_fg: Color,
+
}
+

+
#[derive(Debug, Clone)]
+
pub struct Icons {
+
    pub property_divider: char,
+
    pub shortcutbar_divider: char,
+
    pub tab_divider: char,
+
    pub tab_overline: char,
+
    pub whitespace: char,
+
}
+

+
#[derive(Debug, Clone)]
+
pub struct Tables {
+
    pub spacing: u16,
+
}
+

+
/// The Radicle TUI theme. Will be defined in a JSON config file in the
+
/// future. e.g.:
+
/// {
+
///     "name": "Default",
+
///     "colors": {
+
///         "foreground": "#ffffff",
+
///         "propertyForeground": "#ffffff",
+
///         "highlightedBackground": "#000000",
+
///     },
+
///     "icons": {
+
///         "workspaces.divider": "|",
+
///         "shortcuts.divider: "∙",
+
///     }
+
/// }
+
#[derive(Debug, Clone)]
+
pub struct Theme {
+
    pub name: String,
+
    pub colors: Colors,
+
    pub icons: Icons,
+
    pub tables: Tables,
+
}
+

+
pub fn default_dark() -> Theme {
+
    Theme {
+
        name: String::from("Default"),
+
        colors: Colors {
+
            default_fg: COLOR_DEFAULT_FG,
+
            tabs_highlighted_fg: Color::Magenta,
+
            app_header_project_fg: Color::Cyan,
+
            app_header_rid_fg: Color::Yellow,
+
            labeled_container_bg: COLOR_DEFAULT_FAINT,
+
            item_list_highlighted_bg: COLOR_DEFAULT_DARKER,
+
            property_name_fg: Color::Cyan,
+
            property_divider_fg: COLOR_DEFAULT_DARK,
+
            shortcut_short_fg: COLOR_DEFAULT_DARK,
+
            shortcut_long_fg: COLOR_DEFAULT_DARKER,
+
            shortcutbar_divider_fg: COLOR_DEFAULT_DARKER,
+
            browser_list_id: Color::Cyan,
+
            browser_list_title: COLOR_DEFAULT_FG,
+
            browser_list_description: COLOR_DEFAULT_DARK,
+
            browser_list_author: Color::Gray,
+
            browser_list_tags: Color::LightBlue,
+
            browser_list_comments: COLOR_DEFAULT_DARK_FG,
+
            browser_list_timestamp: COLOR_DEFAULT_DARK,
+
            browser_patch_list_head: Color::LightBlue,
+
            browser_patch_list_added: Color::Green,
+
            browser_patch_list_removed: Color::Red,
+
            context_bg: COLOR_DEFAULT_DARKEST,
+
            context_light_bg: Color::Gray,
+
            context_badge_bg: Color::LightRed,
+
            context_id_fg: Color::Cyan,
+
            context_id_bg: COLOR_DEFAULT_DARKEST,
+
            context_id_author_fg: Color::Gray,
+
            container_border_fg: COLOR_DEFAULT_DARKEST,
+
            container_border_focus_fg: COLOR_DEFAULT_DARK,
+
        },
+
        icons: Icons {
+
            property_divider: '∙',
+
            shortcutbar_divider: '∙',
+
            tab_divider: '|',
+
            tab_overline: '▔',
+
            whitespace: ' ',
+
        },
+
        tables: Tables { spacing: 2 },
+
    }
+
}
added src/ui/widget.rs
@@ -0,0 +1,106 @@
+
pub mod common;
+
pub mod home;
+
pub mod issue;
+
pub mod patch;
+
mod utils;
+

+
use std::ops::Deref;
+

+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Color, Props};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{Frame, MockComponent, State};
+

+
pub type BoxedWidget<T> = Box<Widget<T>>;
+

+
pub trait WidgetComponent {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect);
+

+
    fn state(&self) -> State;
+

+
    fn perform(&mut self, properties: &Props, cmd: Cmd) -> CmdResult;
+
}
+

+
#[derive(Clone)]
+
pub struct Widget<T: WidgetComponent> {
+
    component: T,
+
    properties: Props,
+
}
+

+
impl<T: WidgetComponent> Deref for Widget<T> {
+
    type Target = T;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.component
+
    }
+
}
+

+
impl<T: WidgetComponent> Widget<T> {
+
    pub fn new(component: T) -> Self {
+
        Widget {
+
            component,
+
            properties: Props::default(),
+
        }
+
    }
+

+
    pub fn foreground(mut self, fg: Color) -> Self {
+
        self.attr(Attribute::Foreground, AttrValue::Color(fg));
+
        self
+
    }
+

+
    pub fn highlight(mut self, fg: Color) -> Self {
+
        self.attr(Attribute::HighlightedColor, AttrValue::Color(fg));
+
        self
+
    }
+

+
    pub fn background(mut self, bg: Color) -> Self {
+
        self.attr(Attribute::Background, AttrValue::Color(bg));
+
        self
+
    }
+

+
    pub fn height(mut self, h: u16) -> Self {
+
        self.attr(Attribute::Height, AttrValue::Size(h));
+
        self
+
    }
+

+
    pub fn width(mut self, w: u16) -> Self {
+
        self.attr(Attribute::Width, AttrValue::Size(w));
+
        self
+
    }
+

+
    pub fn content(mut self, content: AttrValue) -> Self {
+
        self.attr(Attribute::Content, content);
+
        self
+
    }
+

+
    pub fn custom(mut self, key: &'static str, value: AttrValue) -> Self {
+
        self.attr(Attribute::Custom(key), value);
+
        self
+
    }
+

+
    pub fn to_boxed(self) -> Box<Self> {
+
        Box::new(self)
+
    }
+
}
+

+
impl<T: WidgetComponent> MockComponent for Widget<T> {
+
    fn view(&mut self, frame: &mut Frame, area: Rect) {
+
        self.component.view(&self.properties, frame, area)
+
    }
+

+
    fn query(&self, attr: Attribute) -> Option<AttrValue> {
+
        self.properties.get(attr)
+
    }
+

+
    fn attr(&mut self, attr: Attribute, value: AttrValue) {
+
        self.properties.set(attr, value)
+
    }
+

+
    fn state(&self) -> State {
+
        self.component.state()
+
    }
+

+
    fn perform(&mut self, cmd: Cmd) -> CmdResult {
+
        self.component.perform(&self.properties, cmd)
+
    }
+
}
added src/ui/widget/common.rs
@@ -0,0 +1,154 @@
+
pub mod container;
+
pub mod context;
+
pub mod label;
+
pub mod list;
+

+
use tuirealm::props::{AttrValue, Attribute};
+
use tuirealm::MockComponent;
+

+
use container::{GlobalListener, Header, LabeledContainer, Tabs};
+
use context::{Shortcut, Shortcuts};
+
use label::Label;
+
use list::{Property, PropertyList};
+

+
use self::container::{AppHeader, AppInfo, Container, VerticalLine};
+
use self::list::{ColumnWidth, PropertyTable};
+

+
use super::Widget;
+

+
use crate::ui::context::Context;
+
use crate::ui::theme::Theme;
+

+
pub fn global_listener() -> Widget<GlobalListener> {
+
    Widget::new(GlobalListener::default())
+
}
+

+
pub fn label(content: &str) -> Widget<Label> {
+
    // TODO: Remove when size constraints are implemented
+
    let width = content.chars().count() as u16;
+

+
    Widget::new(Label::default())
+
        .content(AttrValue::String(content.to_string()))
+
        .height(1)
+
        .width(width)
+
}
+

+
pub fn reversable_label(content: &str) -> Widget<Label> {
+
    let content = &format!(" {content} ");
+

+
    label(content)
+
}
+

+
pub fn container_header(theme: &Theme, label: Widget<Label>) -> Widget<Header<1>> {
+
    let header = Header::new([label], [ColumnWidth::Grow], theme.clone());
+

+
    Widget::new(header)
+
}
+

+
pub fn container(theme: &Theme, component: Box<dyn MockComponent>) -> Widget<Container> {
+
    let container = Container::new(component, theme.clone());
+
    Widget::new(container)
+
}
+

+
pub fn labeled_container(
+
    theme: &Theme,
+
    title: &str,
+
    component: Box<dyn MockComponent>,
+
) -> Widget<LabeledContainer> {
+
    let header = container_header(
+
        theme,
+
        label(&format!(" {title} ")).foreground(theme.colors.default_fg),
+
    );
+
    let container = LabeledContainer::new(header, component, theme.clone());
+

+
    Widget::new(container)
+
}
+

+
pub fn shortcut(theme: &Theme, short: &str, long: &str) -> Widget<Shortcut> {
+
    let short = label(short).foreground(theme.colors.shortcut_short_fg);
+
    let divider = label(&theme.icons.whitespace.to_string());
+
    let long = label(long).foreground(theme.colors.shortcut_long_fg);
+

+
    // TODO: Remove when size constraints are implemented
+
    let short_w = short.query(Attribute::Width).unwrap().unwrap_size();
+
    let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
+
    let long_w = long.query(Attribute::Width).unwrap().unwrap_size();
+
    let width = short_w.saturating_add(divider_w).saturating_add(long_w);
+

+
    let shortcut = Shortcut::new(short, divider, long);
+

+
    Widget::new(shortcut).height(1).width(width)
+
}
+

+
pub fn shortcuts(theme: &Theme, shortcuts: Vec<Widget<Shortcut>>) -> Widget<Shortcuts> {
+
    let divider = label(&format!(" {} ", theme.icons.shortcutbar_divider))
+
        .foreground(theme.colors.shortcutbar_divider_fg);
+
    let shortcut_bar = Shortcuts::new(shortcuts, divider);
+

+
    Widget::new(shortcut_bar).height(1)
+
}
+

+
pub fn property(theme: &Theme, name: &str, value: &str) -> Widget<Property> {
+
    let name = label(name).foreground(theme.colors.property_name_fg);
+
    let divider = label(&format!(" {} ", theme.icons.property_divider));
+
    let value = label(value).foreground(theme.colors.default_fg);
+

+
    // TODO: Remove when size constraints are implemented
+
    let name_w = name.query(Attribute::Width).unwrap().unwrap_size();
+
    let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
+
    let value_w = value.query(Attribute::Width).unwrap().unwrap_size();
+
    let width = name_w.saturating_add(divider_w).saturating_add(value_w);
+

+
    let property = Property::new(name, value).with_divider(divider);
+

+
    Widget::new(property).height(1).width(width)
+
}
+

+
pub fn property_list(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widget<PropertyList> {
+
    let property_list = PropertyList::new(properties);
+

+
    Widget::new(property_list)
+
}
+

+
pub fn property_table(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widget<PropertyTable> {
+
    let table = PropertyTable::new(properties);
+

+
    Widget::new(table)
+
}
+

+
pub fn tabs(_theme: &Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
+
    let tabs = Tabs::new(tabs);
+

+
    Widget::new(tabs).height(2)
+
}
+

+
pub fn app_info(context: &Context, theme: &Theme) -> Widget<AppInfo> {
+
    let project = label(context.project().name()).foreground(theme.colors.app_header_project_fg);
+
    let rid = label(&format!(" ({})", context.id())).foreground(theme.colors.app_header_rid_fg);
+

+
    let project_w = project
+
        .query(Attribute::Width)
+
        .unwrap_or(AttrValue::Size(0))
+
        .unwrap_size();
+
    let rid_w = rid
+
        .query(Attribute::Width)
+
        .unwrap_or(AttrValue::Size(0))
+
        .unwrap_size();
+

+
    let info = AppInfo::new(project, rid);
+
    Widget::new(info).width(project_w.saturating_add(rid_w))
+
}
+

+
pub fn app_header(
+
    context: &Context,
+
    theme: &Theme,
+
    nav: Option<Widget<Tabs>>,
+
) -> Widget<AppHeader> {
+
    let line =
+
        label(&theme.icons.tab_overline.to_string()).foreground(theme.colors.tabs_highlighted_fg);
+
    let line = Widget::new(VerticalLine::new(line));
+
    let info = app_info(context, theme);
+
    let header = AppHeader::new(nav, info, line);
+

+
    Widget::new(header)
+
}
added src/ui/widget/common/container.rs
@@ -0,0 +1,458 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Props, Style, TextModifiers};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
+
use tuirealm::tui::widgets::{Block, Cell, Row};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use crate::ui::ext::HeaderBlock;
+
use crate::ui::layout;
+
use crate::ui::state::TabState;
+
use crate::ui::theme::Theme;
+
use crate::ui::widget::{utils, Widget, WidgetComponent};
+

+
use super::label::Label;
+
use super::list::ColumnWidth;
+

+
/// Some user events need to be handled globally (e.g. user presses key `q` to quit
+
/// the application). This component can be used in conjunction with SubEventClause
+
/// to handle those events.
+
#[derive(Default)]
+
pub struct GlobalListener {}
+

+
impl WidgetComponent for GlobalListener {
+
    fn view(&mut self, _properties: &Props, _frame: &mut Frame, _area: Rect) {}
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A vertical separator.
+
#[derive(Clone)]
+
pub struct VerticalLine {
+
    line: Widget<Label>,
+
}
+

+
impl VerticalLine {
+
    pub fn new(line: Widget<Label>) -> Self {
+
        Self { line }
+
    }
+
}
+

+
impl WidgetComponent for VerticalLine {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            // Repeat and render line.
+
            let overlines = vec![self.line.clone(); area.width as usize];
+
            let overlines = overlines
+
                .iter()
+
                .map(|l| l.clone().to_boxed() as Box<dyn MockComponent>)
+
                .collect();
+
            let line_layout = layout::h_stack(overlines, area);
+
            for (mut line, area) in line_layout {
+
                line.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
////////////////////////////////////////////////
+

+
/// A tab header that displays all labels horizontally aligned and separated
+
/// by a divider. Highlights the label defined by the current tab index.
+
#[derive(Clone)]
+
pub struct Tabs {
+
    tabs: Vec<Widget<Label>>,
+
    state: TabState,
+
}
+

+
impl Tabs {
+
    pub fn new(tabs: Vec<Widget<Label>>) -> Self {
+
        let count = &tabs.len();
+
        Self {
+
            tabs,
+
            state: TabState {
+
                selected: 0,
+
                len: *count as u16,
+
            },
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Tabs {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let selected = self.state().unwrap_one().unwrap_u16();
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            // Render tabs, highlighting the selected tab.
+
            let mut tabs = vec![];
+
            for (index, tab) in self.tabs.iter().enumerate() {
+
                let mut tab = tab.clone().to_boxed();
+
                if index == selected as usize {
+
                    tab.attr(
+
                        Attribute::TextProps,
+
                        AttrValue::TextModifiers(TextModifiers::REVERSED),
+
                    );
+
                }
+
                tabs.push(tab.clone().to_boxed() as Box<dyn MockComponent>);
+
            }
+
            tabs.push(Widget::new(Label::default()).to_boxed());
+

+
            let tab_layout = layout::h_stack(tabs, area);
+
            for (mut tab, area) in tab_layout {
+
                tab.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::One(StateValue::U16(self.state.selected))
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        use tuirealm::command::Direction;
+

+
        match cmd {
+
            Cmd::Move(Direction::Right) => {
+
                let prev = self.state.selected;
+
                self.state.incr_tab_index(true);
+
                if prev != self.state.selected {
+
                    CmdResult::Changed(self.state())
+
                } else {
+
                    CmdResult::None
+
                }
+
            }
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
+

+
/// An application info widget that renders project / branch information
+
/// and a separator line. Used in conjunction with [`Tabs`].
+
pub struct AppInfo {
+
    project: Widget<Label>,
+
    rid: Widget<Label>,
+
}
+

+
impl AppInfo {
+
    pub fn new(project: Widget<Label>, rid: Widget<Label>) -> Self {
+
        Self { project, rid }
+
    }
+
}
+

+
impl WidgetComponent for AppInfo {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        let project_w = self
+
            .project
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(10))
+
            .unwrap_size();
+

+
        let rid_w = self
+
            .rid
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(10))
+
            .unwrap_size();
+

+
        if display {
+
            let layout = Layout::default()
+
                .direction(Direction::Horizontal)
+
                .constraints(vec![
+
                    Constraint::Length(project_w),
+
                    Constraint::Length(rid_w),
+
                ])
+
                .split(area);
+

+
            self.project.view(frame, layout[0]);
+
            self.rid.view(frame, layout[1]);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A common application header that renders project / branch
+
/// information and an optional navigation.
+
pub struct AppHeader {
+
    nav: Option<Widget<Tabs>>,
+
    info: Widget<AppInfo>,
+
    line: Widget<VerticalLine>,
+
}
+

+
impl AppHeader {
+
    pub fn new(
+
        nav: Option<Widget<Tabs>>,
+
        info: Widget<AppInfo>,
+
        line: Widget<VerticalLine>,
+
    ) -> Self {
+
        Self { nav, info, line }
+
    }
+
}
+

+
impl WidgetComponent for AppHeader {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let info_w = self
+
            .info
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(10))
+
            .unwrap_size();
+

+
        if display {
+
            let layout = layout::app_header(area, info_w);
+

+
            if let Some(nav) = self.nav.as_mut() {
+
                nav.view(frame, layout.nav);
+
            }
+
            self.info.view(frame, layout.info);
+
            self.line.view(frame, layout.line);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.nav
+
            .as_mut()
+
            .map(|nav| nav.perform(cmd))
+
            .unwrap_or(CmdResult::None)
+
    }
+
}
+

+
/// A labeled container header.
+
pub struct Header<const W: usize> {
+
    header: [Widget<Label>; W],
+
    widths: [ColumnWidth; W],
+
    theme: Theme,
+
}
+

+
impl<const W: usize> Header<W> {
+
    pub fn new(header: [Widget<Label>; W], widths: [ColumnWidth; W], theme: Theme) -> Self {
+
        Self {
+
            header,
+
            widths,
+
            theme,
+
        }
+
    }
+
}
+

+
impl<const W: usize> WidgetComponent for Header<W> {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        let color = if focus {
+
            self.theme.colors.container_border_focus_fg
+
        } else {
+
            self.theme.colors.container_border_fg
+
        };
+

+
        if display {
+
            let block = HeaderBlock::default()
+
                .borders(BorderSides::all())
+
                .border_style(Style::default().fg(color))
+
                .border_type(BorderType::Rounded);
+
            frame.render_widget(block, area);
+

+
            let layout = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints(vec![Constraint::Min(1)])
+
                .vertical_margin(1)
+
                .horizontal_margin(1)
+
                .split(area);
+

+
            let widths = utils::column_widths(area, &self.widths, self.theme.tables.spacing);
+
            let header: [Cell; W] = self
+
                .header
+
                .iter()
+
                .map(|label| {
+
                    let cell: Cell = label.into();
+
                    cell.style(Style::default().fg(self.theme.colors.default_fg))
+
                })
+
                .collect::<Vec<_>>()
+
                .try_into()
+
                .unwrap();
+
            let header: Row<'_> = Row::new(header);
+

+
            let table = tuirealm::tui::widgets::Table::new(vec![])
+
                .column_spacing(self.theme.tables.spacing)
+
                .header(header)
+
                .widths(&widths);
+
            frame.render_widget(table, layout[0]);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct Container {
+
    component: Box<dyn MockComponent>,
+
    theme: Theme,
+
}
+

+
impl Container {
+
    pub fn new(component: Box<dyn MockComponent>, theme: Theme) -> Self {
+
        Self { component, theme }
+
    }
+
}
+

+
impl WidgetComponent for Container {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        let color = if focus {
+
            self.theme.colors.container_border_focus_fg
+
        } else {
+
            self.theme.colors.container_border_fg
+
        };
+

+
        if display {
+
            // Make some space on the left
+
            let layout = Layout::default()
+
                .direction(Direction::Horizontal)
+
                .horizontal_margin(1)
+
                .vertical_margin(1)
+
                .constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
+
                .split(area);
+
            // reverse draw order: child needs to be drawn first?
+
            self.component.view(frame, layout[1]);
+

+
            let block = Block::default()
+
                .borders(BorderSides::ALL)
+
                .border_style(Style::default().fg(color))
+
                .border_type(BorderType::Rounded);
+
            frame.render_widget(block, area);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.component.perform(cmd)
+
    }
+
}
+

+
pub struct LabeledContainer {
+
    header: Widget<Header<1>>,
+
    component: Box<dyn MockComponent>,
+
    theme: Theme,
+
}
+

+
impl LabeledContainer {
+
    pub fn new(header: Widget<Header<1>>, component: Box<dyn MockComponent>, theme: Theme) -> Self {
+
        Self {
+
            header,
+
            component,
+
            theme,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for LabeledContainer {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        let color = if focus {
+
            self.theme.colors.container_border_focus_fg
+
        } else {
+
            self.theme.colors.container_border_fg
+
        };
+

+
        let header_height = self
+
            .header
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(3))
+
            .unwrap_size();
+

+
        if display {
+
            let layout = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints([Constraint::Length(header_height), Constraint::Length(0)].as_ref())
+
                .split(area);
+

+
            // Make some space on the left
+
            let inner_layout = Layout::default()
+
                .direction(Direction::Horizontal)
+
                .horizontal_margin(1)
+
                .constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
+
                .split(layout[1]);
+
            // reverse draw order: child needs to be drawn first?
+

+
            self.component
+
                .attr(Attribute::Focus, AttrValue::Flag(focus));
+
            self.component.view(frame, inner_layout[1]);
+

+
            let block = Block::default()
+
                .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
+
                .border_style(Style::default().fg(color))
+
                .border_type(BorderType::Rounded);
+
            frame.render_widget(block, layout[1]);
+

+
            self.header.attr(Attribute::Focus, AttrValue::Flag(focus));
+
            self.header.view(frame, layout[0]);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.component.perform(cmd)
+
    }
+
}
added src/ui/widget/common/context.rs
@@ -0,0 +1,175 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Props};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{Frame, MockComponent, State};
+

+
use super::label::Label;
+

+
use crate::ui::layout;
+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
/// A shortcut that consists of a label displaying the "hotkey", a label that displays
+
/// the action and a spacer between them.
+
#[derive(Clone)]
+
pub struct Shortcut {
+
    short: Widget<Label>,
+
    divider: Widget<Label>,
+
    long: Widget<Label>,
+
}
+

+
impl Shortcut {
+
    pub fn new(short: Widget<Label>, divider: Widget<Label>, long: Widget<Label>) -> Self {
+
        Self {
+
            short,
+
            divider,
+
            long,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Shortcut {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let labels: Vec<Box<dyn MockComponent>> = vec![
+
                self.short.clone().to_boxed(),
+
                self.divider.clone().to_boxed(),
+
                self.long.clone().to_boxed(),
+
            ];
+

+
            let layout = layout::h_stack(labels, area);
+
            for (mut shortcut, area) in layout {
+
                shortcut.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A shortcut bar that displays multiple shortcuts and separates them with a
+
/// divider.
+
pub struct Shortcuts {
+
    shortcuts: Vec<Widget<Shortcut>>,
+
    divider: Widget<Label>,
+
}
+

+
impl Shortcuts {
+
    pub fn new(shortcuts: Vec<Widget<Shortcut>>, divider: Widget<Label>) -> Self {
+
        Self { shortcuts, divider }
+
    }
+
}
+

+
impl WidgetComponent for Shortcuts {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let mut widgets: Vec<Box<dyn MockComponent>> = vec![];
+
            let mut shortcuts = self.shortcuts.iter_mut().peekable();
+

+
            while let Some(shortcut) = shortcuts.next() {
+
                if shortcuts.peek().is_some() {
+
                    widgets.push(shortcut.clone().to_boxed());
+
                    widgets.push(self.divider.clone().to_boxed())
+
                } else {
+
                    widgets.push(shortcut.clone().to_boxed());
+
                }
+
            }
+

+
            let layout = layout::h_stack(widgets, area);
+
            for (mut widget, area) in layout {
+
                widget.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct ContextBar {
+
    context: Widget<Label>,
+
    id: Widget<Label>,
+
    author: Widget<Label>,
+
    title: Widget<Label>,
+
    comments: Widget<Label>,
+
}
+

+
impl ContextBar {
+
    pub fn new(
+
        context: Widget<Label>,
+
        id: Widget<Label>,
+
        author: Widget<Label>,
+
        title: Widget<Label>,
+
        comments: Widget<Label>,
+
    ) -> Self {
+
        Self {
+
            context,
+
            id,
+
            author,
+
            title,
+
            comments,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for ContextBar {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        let context_w = self.context.query(Attribute::Width).unwrap().unwrap_size();
+
        let id_w = self.id.query(Attribute::Width).unwrap().unwrap_size();
+
        let author_w = self.author.query(Attribute::Width).unwrap().unwrap_size();
+
        let count_w = self.comments.query(Attribute::Width).unwrap().unwrap_size();
+

+
        if display {
+
            let layout = layout::h_stack(
+
                vec![
+
                    self.context.clone().to_boxed(),
+
                    self.id.clone().to_boxed(),
+
                    self.title
+
                        .clone()
+
                        .width(
+
                            area.width
+
                                .saturating_sub(context_w + id_w + author_w + count_w),
+
                        )
+
                        .to_boxed(),
+
                    self.author.clone().to_boxed(),
+
                    self.comments.clone().to_boxed(),
+
                ],
+
                area,
+
            );
+

+
            for (mut component, area) in layout {
+
                component.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
added src/ui/widget/common/label.rs
@@ -0,0 +1,81 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Color, Props, Style};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::tui::text::{Span, Text};
+
use tuirealm::{Frame, MockComponent, State};
+

+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
/// A label that can be styled using a foreground color and text modifiers.
+
/// Its height is fixed, its width depends on the length of the text it displays.
+
#[derive(Clone, Default)]
+
pub struct Label;
+

+
impl WidgetComponent for Label {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tui_realm_stdlib::Label;
+

+
        let content = properties
+
            .get_or(Attribute::Content, AttrValue::String(String::default()))
+
            .unwrap_string();
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let foreground = properties
+
            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+
        let background = properties
+
            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

+
        if display {
+
            let mut label = match properties.get(Attribute::TextProps) {
+
                Some(modifiers) => Label::default()
+
                    .foreground(foreground)
+
                    .background(background)
+
                    .modifiers(modifiers.unwrap_text_modifiers())
+
                    .text(content),
+
                None => Label::default()
+
                    .foreground(foreground)
+
                    .background(background)
+
                    .text(content),
+
            };
+

+
            label.view(frame, area);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
impl From<&Widget<Label>> for Span<'_> {
+
    fn from(label: &Widget<Label>) -> Self {
+
        let content = label
+
            .query(Attribute::Content)
+
            .unwrap_or(AttrValue::String(String::default()))
+
            .unwrap_string();
+

+
        Span::styled(content, Style::default())
+
    }
+
}
+

+
impl From<&Widget<Label>> for Text<'_> {
+
    fn from(label: &Widget<Label>) -> Self {
+
        let content = label
+
            .query(Attribute::Content)
+
            .unwrap_or(AttrValue::String(String::default()))
+
            .unwrap_string();
+
        let foreground = label
+
            .query(Attribute::Foreground)
+
            .unwrap_or(AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

+
        Text::styled(content, Style::default().fg(foreground))
+
    }
+
}
added src/ui/widget/common/list.rs
@@ -0,0 +1,376 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
+
use tuirealm::tui::widgets::{Block, Cell, ListState, Row, TableState};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use crate::ui::layout;
+
use crate::ui::state::ItemState;
+
use crate::ui::theme::Theme;
+
use crate::ui::widget::{utils, Widget, WidgetComponent};
+

+
use super::container::Header;
+
use super::label::Label;
+
use super::*;
+

+
/// A generic item that can be displayed in a table with [`const W: usize`] columns.
+
pub trait TableItem<const W: usize> {
+
    /// Should return fields as table cells.
+
    fn row(&self, theme: &Theme) -> [Cell; W];
+
}
+

+
/// A generic item that can be displayed in a list.
+
pub trait ListItem {
+
    /// Should return fields as list item.
+
    fn row(&self, theme: &Theme) -> tuirealm::tui::widgets::ListItem;
+
}
+

+
/// Grow behavior of a table column.
+
///
+
/// [`tuirealm::tui::widgets::Table`] does only support percental column widths.
+
/// A [`ColumnWidth`] is used to specify the grow behaviour of a table column
+
/// and a percental column width is calculated based on that.
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+
pub enum ColumnWidth {
+
    /// A fixed-size column.
+
    Fixed(u16),
+
    /// A growable column.
+
    Grow,
+
}
+

+
/// A component that displays a labeled property.
+
#[derive(Clone)]
+
pub struct Property {
+
    name: Widget<Label>,
+
    divider: Widget<Label>,
+
    value: Widget<Label>,
+
}
+

+
impl Property {
+
    pub fn new(name: Widget<Label>, value: Widget<Label>) -> Self {
+
        let divider = label("");
+
        Self {
+
            name,
+
            divider,
+
            value,
+
        }
+
    }
+

+
    pub fn with_divider(mut self, divider: Widget<Label>) -> Self {
+
        self.divider = divider;
+
        self
+
    }
+

+
    pub fn name(&self) -> &Widget<Label> {
+
        &self.name
+
    }
+

+
    pub fn value(&self) -> &Widget<Label> {
+
        &self.value
+
    }
+
}
+

+
impl WidgetComponent for Property {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let labels: Vec<Box<dyn MockComponent>> = vec![
+
                self.name.clone().to_boxed(),
+
                self.divider.clone().to_boxed(),
+
                self.value.clone().to_boxed(),
+
            ];
+

+
            let layout = layout::h_stack(labels, area);
+
            for (mut label, area) in layout {
+
                label.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A component that can display lists of labeled properties
+
#[derive(Default)]
+
pub struct PropertyList {
+
    properties: Vec<Widget<Property>>,
+
}
+

+
impl PropertyList {
+
    pub fn new(properties: Vec<Widget<Property>>) -> Self {
+
        Self { properties }
+
    }
+
}
+

+
impl WidgetComponent for PropertyList {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let properties = self
+
                .properties
+
                .iter()
+
                .map(|property| property.clone().to_boxed() as Box<dyn MockComponent>)
+
                .collect();
+

+
            let layout = layout::v_stack(properties, area);
+
            for (mut property, area) in layout {
+
                property.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct PropertyTable {
+
    properties: Vec<Widget<Property>>,
+
}
+

+
impl PropertyTable {
+
    pub fn new(properties: Vec<Widget<Property>>) -> Self {
+
        Self { properties }
+
    }
+
}
+

+
impl WidgetComponent for PropertyTable {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tuirealm::tui::widgets::Table;
+

+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let rows = self
+
                .properties
+
                .iter()
+
                .map(|p| Row::new([Cell::from(p.name()), Cell::from(p.value())]));
+

+
            let table = Table::new(rows)
+
                .widths([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref());
+
            frame.render_widget(table, area);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A table component that can display a list of [`TableItem`]s.
+
pub struct Table<V, const W: usize>
+
where
+
    V: TableItem<W> + Clone,
+
{
+
    /// Items hold by this model.
+
    items: Vec<V>,
+
    /// The table header.
+
    header: [Widget<Label>; W],
+
    /// Grow behavior of table columns.
+
    widths: [ColumnWidth; W],
+
    /// State that keeps track of the selection.
+
    state: ItemState,
+
    /// The current theme.
+
    theme: Theme,
+
}
+

+
impl<V, const W: usize> Table<V, W>
+
where
+
    V: TableItem<W> + Clone,
+
{
+
    pub fn new(
+
        items: &[V],
+
        header: [Widget<Label>; W],
+
        widths: [ColumnWidth; W],
+
        theme: Theme,
+
    ) -> Self {
+
        Self {
+
            items: items.to_vec(),
+
            header,
+
            widths,
+
            state: ItemState::new(Some(0), items.len()),
+
            theme,
+
        }
+
    }
+
}
+

+
impl<V, const W: usize> WidgetComponent for Table<V, W>
+
where
+
    V: TableItem<W> + Clone,
+
{
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let highlight = properties
+
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        let color = if focus {
+
            self.theme.colors.container_border_focus_fg
+
        } else {
+
            self.theme.colors.container_border_fg
+
        };
+

+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![Constraint::Length(3), Constraint::Min(1)])
+
            .split(area);
+

+
        let widths = utils::column_widths(area, &self.widths, self.theme.tables.spacing);
+
        let rows: Vec<Row<'_>> = self
+
            .items
+
            .iter()
+
            .map(|item| Row::new(item.row(&self.theme)))
+
            .collect();
+

+
        let table = tuirealm::tui::widgets::Table::new(rows)
+
            .block(
+
                Block::default()
+
                    .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
+
                    .border_style(Style::default().fg(color))
+
                    .border_type(BorderType::Rounded),
+
            )
+
            .highlight_style(Style::default().bg(highlight))
+
            .column_spacing(self.theme.tables.spacing)
+
            .widths(&widths);
+

+
        let mut header = Widget::new(Header::new(
+
            self.header.clone(),
+
            self.widths,
+
            self.theme.clone(),
+
        ));
+

+
        header.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        header.view(frame, layout[0]);
+

+
        frame.render_stateful_widget(table, layout[1], &mut TableState::from(&self.state));
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        use tuirealm::command::Direction;
+
        match cmd {
+
            Cmd::Move(Direction::Up) => match self.state.select_previous() {
+
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Move(Direction::Down) => match self.state.select_next() {
+
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Submit => match self.state.selected() {
+
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
+

+
/// A list component that can display [`ListItem`]'s.
+
pub struct List<V>
+
where
+
    V: ListItem + Clone + PartialEq,
+
{
+
    /// Items held by this list.
+
    items: Vec<V>,
+
    /// State keeps track of the current selection.
+
    state: ItemState,
+
    /// The current theme.
+
    theme: Theme,
+
}
+

+
impl<V> List<V>
+
where
+
    V: ListItem + Clone + PartialEq,
+
{
+
    pub fn new(items: &[V], selected: Option<V>, theme: Theme) -> Self {
+
        let selected = match selected {
+
            Some(item) => items.iter().position(|i| i == &item),
+
            None => Some(0),
+
        };
+

+
        Self {
+
            items: items.to_vec(),
+
            state: ItemState::new(selected, items.len()),
+
            theme,
+
        }
+
    }
+
}
+

+
impl<V> WidgetComponent for List<V>
+
where
+
    V: ListItem + Clone + PartialEq,
+
{
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tuirealm::tui::widgets::{List, ListItem};
+

+
        let highlight = properties
+
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![Constraint::Min(1), Constraint::Length(1)])
+
            .split(area);
+

+
        let rows: Vec<ListItem> = self
+
            .items
+
            .iter()
+
            .map(|item| item.row(&self.theme))
+
            .collect();
+
        let list = List::new(rows).highlight_style(Style::default().bg(highlight));
+

+
        frame.render_stateful_widget(list, layout[0], &mut ListState::from(&self.state));
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        use tuirealm::command::Direction;
+
        match cmd {
+
            Cmd::Move(Direction::Up) => match self.state.select_previous() {
+
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Move(Direction::Down) => match self.state.select_next() {
+
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Submit => match self.state.selected() {
+
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
added src/ui/widget/home.rs
@@ -0,0 +1,284 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
+

+
use super::common;
+
use super::common::container::{LabeledContainer, Tabs};
+
use super::common::context::Shortcuts;
+
use super::common::list::{ColumnWidth, Table};
+

+
use super::{Widget, WidgetComponent};
+

+
use crate::cob;
+
use crate::ui::cob::{IssueItem, PatchItem};
+
use crate::ui::context::Context;
+
use crate::ui::layout;
+
use crate::ui::theme::Theme;
+

+
pub struct Dashboard {
+
    about: Widget<LabeledContainer>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl Dashboard {
+
    pub fn new(about: Widget<LabeledContainer>, shortcuts: Widget<Shortcuts>) -> Self {
+
        Self { about, shortcuts }
+
    }
+
}
+

+
impl WidgetComponent for Dashboard {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let layout = layout::root_component(area, shortcuts_h);
+

+
        self.about.view(frame, layout[0]);
+
        self.shortcuts.view(frame, layout[1]);
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct IssueBrowser {
+
    items: Vec<IssueItem>,
+
    table: Widget<Table<IssueItem, 7>>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl IssueBrowser {
+
    pub fn new(context: &Context, theme: &Theme, shortcuts: Widget<Shortcuts>) -> Self {
+
        let header = [
+
            common::label(" ● "),
+
            common::label("ID"),
+
            common::label("Title"),
+
            common::label("Author"),
+
            common::label("Tags"),
+
            common::label("Assignees"),
+
            common::label("Opened"),
+
        ];
+

+
        let widths = [
+
            ColumnWidth::Fixed(3),
+
            ColumnWidth::Fixed(7),
+
            ColumnWidth::Grow,
+
            ColumnWidth::Fixed(21),
+
            ColumnWidth::Fixed(25),
+
            ColumnWidth::Fixed(21),
+
            ColumnWidth::Fixed(18),
+
        ];
+

+
        let repo = context.repository();
+
        let mut items = vec![];
+

+
        if let Ok(issues) = cob::issue::all(repo) {
+
            for (id, issue) in issues {
+
                if let Ok(item) = IssueItem::try_from((context.profile(), repo, id, issue)) {
+
                    items.push(item);
+
                }
+
            }
+
        }
+

+
        items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
+
        items.sort_by(|a, b| b.state().cmp(a.state()));
+

+
        let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
+
            .highlight(theme.colors.item_list_highlighted_bg);
+

+
        Self {
+
            items,
+
            table,
+
            shortcuts,
+
        }
+
    }
+

+
    pub fn items(&self) -> &Vec<IssueItem> {
+
        &self.items
+
    }
+
}
+

+
impl WidgetComponent for IssueBrowser {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        let layout = layout::root_component(area, shortcuts_h);
+

+
        self.table.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.table.view(frame, layout[0]);
+
        self.shortcuts.view(frame, layout[1])
+
    }
+

+
    fn state(&self) -> State {
+
        self.table.state()
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.table.perform(cmd)
+
    }
+
}
+

+
pub struct PatchBrowser {
+
    items: Vec<PatchItem>,
+
    table: Widget<Table<PatchItem, 8>>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl PatchBrowser {
+
    pub fn new(context: &Context, theme: &Theme, shortcuts: Widget<Shortcuts>) -> Self {
+
        let header = [
+
            common::label(" ● "),
+
            common::label("ID"),
+
            common::label("Title"),
+
            common::label("Author"),
+
            common::label("Head"),
+
            common::label("+"),
+
            common::label("-"),
+
            common::label("Updated"),
+
        ];
+

+
        let widths = [
+
            ColumnWidth::Fixed(3),
+
            ColumnWidth::Fixed(7),
+
            ColumnWidth::Grow,
+
            ColumnWidth::Fixed(21),
+
            ColumnWidth::Fixed(7),
+
            ColumnWidth::Fixed(4),
+
            ColumnWidth::Fixed(4),
+
            ColumnWidth::Fixed(18),
+
        ];
+

+
        let repo = context.repository();
+
        let mut items = vec![];
+

+
        if let Ok(patches) = cob::patch::all(repo) {
+
            for (id, patch) in patches {
+
                if let Ok(item) = PatchItem::try_from((context.profile(), repo, id, patch)) {
+
                    items.push(item);
+
                }
+
            }
+
        }
+

+
        items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
+
        items.sort_by(|a, b| a.state().cmp(b.state()));
+

+
        let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
+
            .highlight(theme.colors.item_list_highlighted_bg);
+

+
        Self {
+
            items,
+
            table,
+
            shortcuts,
+
        }
+
    }
+

+
    pub fn items(&self) -> &Vec<PatchItem> {
+
        &self.items
+
    }
+
}
+

+
impl WidgetComponent for PatchBrowser {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        let layout = layout::root_component(area, shortcuts_h);
+

+
        self.table.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.table.view(frame, layout[0]);
+
        self.shortcuts.view(frame, layout[1]);
+
    }
+

+
    fn state(&self) -> State {
+
        self.table.state()
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.table.perform(cmd)
+
    }
+
}
+

+
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
+
    common::tabs(
+
        theme,
+
        vec![
+
            common::reversable_label("dashboard").foreground(theme.colors.tabs_highlighted_fg),
+
            common::reversable_label("issues").foreground(theme.colors.tabs_highlighted_fg),
+
            common::reversable_label("patches").foreground(theme.colors.tabs_highlighted_fg),
+
        ],
+
    )
+
}
+

+
pub fn dashboard(context: &Context, theme: &Theme) -> Widget<Dashboard> {
+
    let about = common::labeled_container(
+
        theme,
+
        "about",
+
        common::property_list(
+
            theme,
+
            vec![
+
                common::property(theme, "id", &context.id().to_string()),
+
                common::property(theme, "name", context.project().name()),
+
                common::property(theme, "description", context.project().description()),
+
            ],
+
        )
+
        .to_boxed(),
+
    );
+
    let shortcuts = common::shortcuts(
+
        theme,
+
        vec![
+
            common::shortcut(theme, "tab", "section"),
+
            common::shortcut(theme, "q", "quit"),
+
        ],
+
    );
+
    let dashboard = Dashboard::new(about, shortcuts);
+

+
    Widget::new(dashboard)
+
}
+

+
pub fn patches(context: &Context, theme: &Theme) -> Widget<PatchBrowser> {
+
    let shortcuts = common::shortcuts(
+
        theme,
+
        vec![
+
            common::shortcut(theme, "tab", "section"),
+
            common::shortcut(theme, "↑/↓", "navigate"),
+
            common::shortcut(theme, "enter", "show"),
+
            common::shortcut(theme, "q", "quit"),
+
        ],
+
    );
+

+
    Widget::new(PatchBrowser::new(context, theme, shortcuts))
+
}
+

+
pub fn issues(context: &Context, theme: &Theme) -> Widget<IssueBrowser> {
+
    let shortcuts = common::shortcuts(
+
        theme,
+
        vec![
+
            common::shortcut(theme, "tab", "section"),
+
            common::shortcut(theme, "↑/↓", "navigate"),
+
            common::shortcut(theme, "enter", "show"),
+
            common::shortcut(theme, "q", "quit"),
+
        ],
+
    );
+

+
    Widget::new(IssueBrowser::new(context, theme, shortcuts))
+
}
added src/ui/widget/issue.rs
@@ -0,0 +1,183 @@
+
use radicle_cli::terminal::format;
+

+
use radicle::cob::issue::Issue;
+
use radicle::cob::issue::IssueId;
+
use radicle::Profile;
+
use tuirealm::props::Color;
+

+
use super::common::container::Container;
+
use super::common::container::LabeledContainer;
+
use super::common::list::List;
+
use super::common::list::Property;
+
use super::Widget;
+

+
use crate::ui::cob;
+
use crate::ui::cob::IssueItem;
+
use crate::ui::context::Context;
+
use crate::ui::theme::Theme;
+
use crate::ui::widget::common::context::ContextBar;
+

+
use super::*;
+

+
pub struct LargeList {
+
    items: Vec<IssueItem>,
+
    list: Widget<LabeledContainer>,
+
}
+

+
impl LargeList {
+
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
+
        let repo = context.repository();
+
        let issues = crate::cob::issue::all(repo).unwrap_or_default();
+
        let mut items = issues
+
            .iter()
+
            .map(|(id, issue)| IssueItem::from((context.profile(), repo, *id, issue.clone())))
+
            .collect::<Vec<_>>();
+

+
        items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
+
        items.sort_by(|a, b| b.state().cmp(a.state()));
+

+
        let selected =
+
            selected.map(|(id, issue)| IssueItem::from((context.profile(), repo, id, issue)));
+

+
        let list = Widget::new(List::new(&items, selected, theme.clone()))
+
            .highlight(theme.colors.item_list_highlighted_bg);
+

+
        let container = common::labeled_container(theme, "Issues", list.to_boxed());
+

+
        Self {
+
            items,
+
            list: container,
+
        }
+
    }
+

+
    pub fn items(&self) -> &Vec<IssueItem> {
+
        &self.items
+
    }
+
}
+

+
impl WidgetComponent for LargeList {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        self.list.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.list.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.list.perform(cmd)
+
    }
+
}
+

+
pub struct Details {
+
    container: Widget<Container>,
+
}
+

+
impl Details {
+
    pub fn new(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Self {
+
        let repo = context.repository();
+

+
        let (id, issue) = issue;
+
        let item = IssueItem::from((context.profile(), repo, id, issue));
+

+
        let title = Property::new(
+
            common::label("Title").foreground(theme.colors.property_name_fg),
+
            common::label(item.title()).foreground(theme.colors.browser_list_title),
+
        );
+

+
        let tags = Property::new(
+
            common::label("Tags").foreground(theme.colors.property_name_fg),
+
            common::label(&cob::format_tags(item.tags()))
+
                .foreground(theme.colors.browser_list_tags),
+
        );
+

+
        let assignees = Property::new(
+
            common::label("Assignees").foreground(theme.colors.property_name_fg),
+
            common::label(&cob::format_assignees(
+
                &item
+
                    .assignees()
+
                    .iter()
+
                    .map(|item| (item.did(), item.is_you()))
+
                    .collect::<Vec<_>>(),
+
            ))
+
            .foreground(theme.colors.browser_list_author),
+
        );
+

+
        let state = Property::new(
+
            common::label("Status").foreground(theme.colors.property_name_fg),
+
            common::label(&item.state().to_string()).foreground(theme.colors.browser_list_title),
+
        );
+

+
        // let table = common::property_table(theme, vec![title, tags, assignees, state]);
+
        let table = common::property_table(
+
            theme,
+
            vec![
+
                Widget::new(title),
+
                Widget::new(tags),
+
                Widget::new(assignees),
+
                Widget::new(state),
+
            ],
+
        );
+
        let container = common::container(theme, table.to_boxed());
+

+
        Self { container }
+
    }
+
}
+

+
impl WidgetComponent for Details {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        self.container.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub fn list(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<LargeList> {
+
    let list = LargeList::new(context, theme, Some(issue));
+

+
    Widget::new(list)
+
}
+

+
pub fn details(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<Details> {
+
    let details = Details::new(context, theme, issue);
+
    Widget::new(details)
+
}
+

+
pub fn context(theme: &Theme, issue: (IssueId, &Issue), profile: &Profile) -> Widget<ContextBar> {
+
    let (id, issue) = issue;
+
    let is_you = *issue.author().id() == profile.did();
+

+
    let id = format::cob(&id);
+
    let title = issue.title();
+
    let author = cob::format_author(issue.author().id(), is_you);
+
    let comments = issue.comments().count();
+

+
    let context = common::label(" issue ").background(theme.colors.context_badge_bg);
+
    let id = common::label(&format!(" {id} "))
+
        .foreground(theme.colors.context_id_fg)
+
        .background(theme.colors.context_id_bg);
+
    let title = common::label(&format!(" {title} "))
+
        .foreground(theme.colors.default_fg)
+
        .background(theme.colors.context_bg);
+
    let author = common::label(&format!(" {author} "))
+
        .foreground(theme.colors.context_id_author_fg)
+
        .background(theme.colors.context_bg);
+
    let comments = common::label(&format!(" {comments} "))
+
        .foreground(Color::Rgb(70, 70, 70))
+
        .background(theme.colors.context_light_bg);
+

+
    let context_bar = ContextBar::new(context, id, author, title, comments);
+

+
    Widget::new(context_bar).height(1)
+
}
added src/ui/widget/patch.rs
@@ -0,0 +1,202 @@
+
use radicle::cob::patch::{Patch, PatchId};
+
use radicle::Profile;
+

+
use radicle_cli::terminal::format;
+

+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::Color;
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
+

+
use super::{Widget, WidgetComponent};
+

+
use super::common;
+
use super::common::container::Tabs;
+
use super::common::context::{ContextBar, Shortcuts};
+
use super::common::label::Label;
+

+
use crate::ui::theme::Theme;
+
use crate::ui::{cob, layout};
+

+
pub struct Activity {
+
    label: Widget<Label>,
+
    context: Widget<ContextBar>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl Activity {
+
    pub fn new(
+
        label: Widget<Label>,
+
        context: Widget<ContextBar>,
+
        shortcuts: Widget<Shortcuts>,
+
    ) -> Self {
+
        Self {
+
            label,
+
            context,
+
            shortcuts,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Activity {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+
        let context_h = self
+
            .context
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, layout[0]));
+
        self.context.view(frame, layout[1]);
+
        self.shortcuts.view(frame, layout[2]);
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct Files {
+
    label: Widget<Label>,
+
    context: Widget<ContextBar>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl Files {
+
    pub fn new(
+
        label: Widget<Label>,
+
        context: Widget<ContextBar>,
+
        shortcuts: Widget<Shortcuts>,
+
    ) -> Self {
+
        Self {
+
            label,
+
            context,
+
            shortcuts,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Files {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+
        let context_h = self
+
            .context
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, layout[0]));
+
        self.context.view(frame, layout[1]);
+
        self.shortcuts.view(frame, layout[2]);
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
+
    common::tabs(
+
        theme,
+
        vec![
+
            common::reversable_label("activity").foreground(theme.colors.tabs_highlighted_fg),
+
            common::reversable_label("files").foreground(theme.colors.tabs_highlighted_fg),
+
        ],
+
    )
+
}
+

+
pub fn activity(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<Activity> {
+
    let (id, patch) = patch;
+
    let shortcuts = common::shortcuts(
+
        theme,
+
        vec![
+
            common::shortcut(theme, "esc", "back"),
+
            common::shortcut(theme, "tab", "section"),
+
            common::shortcut(theme, "q", "quit"),
+
        ],
+
    );
+
    let context = context(theme, (id, patch), profile);
+

+
    let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
+
    let activity = Activity::new(not_implemented, context, shortcuts);
+

+
    Widget::new(activity)
+
}
+

+
pub fn files(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<Activity> {
+
    let (id, patch) = patch;
+
    let shortcuts = common::shortcuts(
+
        theme,
+
        vec![
+
            common::shortcut(theme, "esc", "back"),
+
            common::shortcut(theme, "tab", "section"),
+
            common::shortcut(theme, "q", "quit"),
+
        ],
+
    );
+
    let context = context(theme, (id, patch), profile);
+

+
    let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
+
    let files = Activity::new(not_implemented, context, shortcuts);
+

+
    Widget::new(files)
+
}
+

+
pub fn context(theme: &Theme, patch: (PatchId, &Patch), profile: &Profile) -> Widget<ContextBar> {
+
    let (id, patch) = patch;
+
    let (_, rev) = patch.latest();
+
    let is_you = *patch.author().id() == profile.did();
+

+
    let id = format::cob(&id);
+
    let title = patch.title();
+
    let author = cob::format_author(patch.author().id(), is_you);
+
    let comments = rev.discussion().len();
+

+
    let context = common::label(" patch ").background(theme.colors.context_badge_bg);
+
    let id = common::label(&format!(" {id} "))
+
        .foreground(theme.colors.context_id_fg)
+
        .background(theme.colors.context_id_bg);
+
    let title = common::label(&format!(" {title} "))
+
        .foreground(theme.colors.default_fg)
+
        .background(theme.colors.context_bg);
+
    let author = common::label(&format!(" {author} "))
+
        .foreground(theme.colors.context_id_author_fg)
+
        .background(theme.colors.context_bg);
+
    let comments = common::label(&format!(" {comments} "))
+
        .foreground(Color::Rgb(70, 70, 70))
+
        .background(theme.colors.context_light_bg);
+

+
    let context_bar = ContextBar::new(context, id, author, title, comments);
+

+
    Widget::new(context_bar).height(1)
+
}
added src/ui/widget/utils.rs
@@ -0,0 +1,43 @@
+
use tuirealm::tui::layout::{Constraint, Rect};
+

+
use super::common::list::ColumnWidth;
+

+
/// Calculates `Constraint::Percentage` for each fixed column width in `widths`,
+
/// taking into account the available width in `area` and the column spacing given by `spacing`.
+
pub fn column_widths(area: Rect, widths: &[ColumnWidth], spacing: u16) -> Vec<Constraint> {
+
    let total_spacing = spacing.saturating_mul(widths.len() as u16);
+
    let fixed_width = widths
+
        .iter()
+
        .fold(0u16, |total, &width| match width {
+
            ColumnWidth::Fixed(w) => total + w,
+
            ColumnWidth::Grow => total,
+
        })
+
        .saturating_add(total_spacing);
+

+
    let grow_count = widths.iter().fold(0u16, |count, &w| {
+
        if w == ColumnWidth::Grow {
+
            count + 1
+
        } else {
+
            count
+
        }
+
    });
+
    let grow_width = area
+
        .width
+
        .saturating_sub(fixed_width)
+
        .checked_div(grow_count)
+
        .unwrap_or(0);
+

+
    widths
+
        .iter()
+
        .map(|width| match width {
+
            ColumnWidth::Fixed(w) => {
+
                let p: f64 = *w as f64 / area.width as f64 * 100_f64;
+
                Constraint::Percentage(p.ceil() as u16)
+
            }
+
            ColumnWidth::Grow => {
+
                let p: f64 = grow_width as f64 / area.width as f64 * 100_f64;
+
                Constraint::Percentage(p.floor() as u16)
+
            }
+
        })
+
        .collect()
+
}