Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Implement interfaces w/ flux
Merged did:key:z6MkgFq6...nBGz opened 2 years ago
128 files changed +13222 -9695 78e18113 98b54a4f
modified Cargo.lock
@@ -3,6 +3,15 @@
version = 3

[[package]]
+
name = "addr2line"
+
version = "0.21.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
+
dependencies = [
+
 "gimli",
+
]
+

+
[[package]]
name = "adler"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -44,6 +53,18 @@ dependencies = [
]

[[package]]
+
name = "ahash"
+
version = "0.8.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "42cd52102d3df161c77a887b608d7a4897d7cc112886a9537b738a887a03aaff"
+
dependencies = [
+
 "cfg-if",
+
 "once_cell",
+
 "version_check",
+
 "zerocopy",
+
]
+

+
[[package]]
name = "aho-corasick"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -53,6 +74,12 @@ dependencies = [
]

[[package]]
+
name = "allocator-api2"
+
version = "0.2.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5"
+

+
[[package]]
name = "amplify"
version = "4.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -139,6 +166,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"

[[package]]
+
name = "backtrace"
+
version = "0.3.69"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
+
dependencies = [
+
 "addr2line",
+
 "cc",
+
 "cfg-if",
+
 "libc",
+
 "miniz_oxide",
+
 "object",
+
 "rustc-demangle",
+
]
+

+
[[package]]
name = "base-x"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -238,12 +280,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"

[[package]]
+
name = "bytes"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
+

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

[[package]]
+
name = "castaway"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc"
+
dependencies = [
+
 "rustversion",
+
]
+

+
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -289,7 +346,7 @@ dependencies = [
 "iana-time-zone",
 "js-sys",
 "num-traits",
-
 "time",
+
 "time 0.1.45",
 "wasm-bindgen",
 "winapi",
]
@@ -305,6 +362,19 @@ dependencies = [
]

[[package]]
+
name = "compact_str"
+
version = "0.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
+
dependencies = [
+
 "castaway",
+
 "cfg-if",
+
 "itoa",
+
 "ryu",
+
 "static_assertions",
+
]
+

+
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -460,6 +530,15 @@ dependencies = [
]

[[package]]
+
name = "deranged"
+
version = "0.3.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+
dependencies = [
+
 "powerfmt",
+
]
+

+
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -611,6 +690,12 @@ dependencies = [
]

[[package]]
+
name = "futures-core"
+
version = "0.3.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+

+
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -643,6 +728,12 @@ dependencies = [
]

[[package]]
+
name = "gimli"
+
version = "0.28.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
+

+
[[package]]
name = "git-ref-format"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -703,6 +794,10 @@ name = "hashbrown"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
+
dependencies = [
+
 "ahash",
+
 "allocator-api2",
+
]

[[package]]
name = "heck"
@@ -711,6 +806,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"

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

+
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -814,6 +915,15 @@ dependencies = [
]

[[package]]
+
name = "itertools"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+
dependencies = [
+
 "either",
+
]
+

+
[[package]]
name = "itoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -947,12 +1057,31 @@ dependencies = [
]

[[package]]
+
name = "lock_api"
+
version = "0.4.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
+
dependencies = [
+
 "autocfg",
+
 "scopeguard",
+
]
+

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

[[package]]
+
name = "lru"
+
version = "0.12.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "db2c024b41519440580066ba82aab04092b333e09066a5eb86c7c4890df31f22"
+
dependencies = [
+
 "hashbrown",
+
]
+

+
[[package]]
name = "memchr"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -968,6 +1097,17 @@ dependencies = [
]

[[package]]
+
name = "mio"
+
version = "0.8.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
+
dependencies = [
+
 "libc",
+
 "wasi 0.11.0+wasi-snapshot-preview1",
+
 "windows-sys 0.48.0",
+
]
+

+
[[package]]
name = "multibase"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1020,6 +1160,12 @@ dependencies = [
]

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

+
[[package]]
name = "num-integer"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1051,12 +1197,40 @@ dependencies = [
]

[[package]]
+
name = "num_cpus"
+
version = "1.16.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+
dependencies = [
+
 "hermit-abi",
+
 "libc",
+
]
+

+
[[package]]
+
name = "num_threads"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
+
dependencies = [
+
 "libc",
+
]
+

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

[[package]]
+
name = "object"
+
version = "0.32.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
+
dependencies = [
+
 "memchr",
+
]
+

+
[[package]]
name = "once_cell"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1107,6 +1281,29 @@ dependencies = [
]

[[package]]
+
name = "parking_lot"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
+
dependencies = [
+
 "lock_api",
+
 "parking_lot_core",
+
]
+

+
[[package]]
+
name = "parking_lot_core"
+
version = "0.9.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "redox_syscall 0.4.1",
+
 "smallvec",
+
 "windows-targets 0.48.1",
+
]
+

+
[[package]]
name = "paste"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1155,6 +1352,12 @@ dependencies = [
]

[[package]]
+
name = "pin-project-lite"
+
version = "0.2.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
+

+
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1205,6 +1408,12 @@ dependencies = [
]

[[package]]
+
name = "powerfmt"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+

+
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1408,17 +1617,22 @@ dependencies = [
 "anyhow",
 "inquire",
 "lexopt",
+
 "libc",
 "log",
 "radicle",
 "radicle-surf",
 "radicle-term",
+
 "ratatui 0.26.1",
 "serde",
 "serde_json",
+
 "signal-hook",
 "simple-logging",
 "termion 3.0.0",
 "textwrap",
 "thiserror",
 "timeago",
+
 "tokio",
+
 "tokio-stream",
 "tui-realm-stdlib",
 "tuirealm",
]
@@ -1461,15 +1675,35 @@ dependencies = [
 "bitflags 2.4.1",
 "cassowary",
 "indoc",
-
 "itertools",
+
 "itertools 0.11.0",
 "paste",
-
 "strum",
+
 "strum 0.25.0",
 "termion 2.0.1",
 "unicode-segmentation",
 "unicode-width",
]

[[package]]
+
name = "ratatui"
+
version = "0.26.1"
+
source = "git+https://github.com/erak/ratatui?branch=termion-cursor#e0fa5a0b27e2ef04ae84a5ba2b4e2050cc2037bb"
+
dependencies = [
+
 "bitflags 2.4.1",
+
 "cassowary",
+
 "compact_str",
+
 "indoc",
+
 "itertools 0.12.1",
+
 "lru",
+
 "paste",
+
 "stability",
+
 "strum 0.26.1",
+
 "termion 3.0.0",
+
 "time 0.3.34",
+
 "unicode-segmentation",
+
 "unicode-width",
+
]
+

+
[[package]]
name = "redox_syscall"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1569,6 +1803,12 @@ dependencies = [
]

[[package]]
+
name = "rustc-demangle"
+
version = "0.1.23"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
+

+
[[package]]
name = "rustix"
version = "0.38.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1594,6 +1834,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"

[[package]]
+
name = "scopeguard"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+

+
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1661,6 +1907,25 @@ dependencies = [
]

[[package]]
+
name = "signal-hook"
+
version = "0.3.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
+
dependencies = [
+
 "libc",
+
 "signal-hook-registry",
+
]
+

+
[[package]]
+
name = "signal-hook-registry"
+
version = "1.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
name = "signature"
version = "1.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1712,6 +1977,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043"

[[package]]
+
name = "socket2"
+
version = "0.5.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9"
+
dependencies = [
+
 "libc",
+
 "windows-sys 0.48.0",
+
]
+

+
[[package]]
name = "socks5-client"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1817,12 +2092,37 @@ dependencies = [
]

[[package]]
+
name = "stability"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce"
+
dependencies = [
+
 "quote",
+
 "syn 1.0.109",
+
]
+

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

+
[[package]]
name = "strum"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
dependencies = [
-
 "strum_macros",
+
 "strum_macros 0.25.3",
+
]
+

+
[[package]]
+
name = "strum"
+
version = "0.26.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f"
+
dependencies = [
+
 "strum_macros 0.26.1",
]

[[package]]
@@ -1839,6 +2139,19 @@ dependencies = [
]

[[package]]
+
name = "strum_macros"
+
version = "0.26.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18"
+
dependencies = [
+
 "heck",
+
 "proc-macro2",
+
 "quote",
+
 "rustversion",
+
 "syn 2.0.48",
+
]
+

+
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1980,6 +2293,27 @@ dependencies = [
]

[[package]]
+
name = "time"
+
version = "0.3.34"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749"
+
dependencies = [
+
 "deranged",
+
 "libc",
+
 "num-conv",
+
 "num_threads",
+
 "powerfmt",
+
 "serde",
+
 "time-core",
+
]
+

+
[[package]]
+
name = "time-core"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+

+
[[package]]
name = "timeago"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2005,6 +2339,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"

[[package]]
+
name = "tokio"
+
version = "1.36.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
+
dependencies = [
+
 "backtrace",
+
 "bytes",
+
 "libc",
+
 "mio",
+
 "num_cpus",
+
 "parking_lot",
+
 "pin-project-lite",
+
 "signal-hook-registry",
+
 "socket2",
+
 "tokio-macros",
+
 "windows-sys 0.48.0",
+
]
+

+
[[package]]
+
name = "tokio-macros"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.48",
+
]
+

+
[[package]]
+
name = "tokio-stream"
+
version = "0.1.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842"
+
dependencies = [
+
 "futures-core",
+
 "pin-project-lite",
+
 "tokio",
+
]
+

+
[[package]]
name = "tui"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2036,7 +2411,7 @@ checksum = "412447298ad477c25ff50c4a894ff5077b6ee3e25b913d42db30021d81b1af53"
dependencies = [
 "bitflags 2.4.1",
 "lazy-regex",
-
 "ratatui",
+
 "ratatui 0.23.0",
 "termion 2.0.1",
 "thiserror",
 "tui",
@@ -2380,6 +2755,26 @@ dependencies = [
]

[[package]]
+
name = "zerocopy"
+
version = "0.7.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
+
dependencies = [
+
 "zerocopy-derive",
+
]
+

+
[[package]]
+
name = "zerocopy-derive"
+
version = "0.7.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.48",
+
]
+

+
[[package]]
name = "zeroize"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -10,20 +10,30 @@ build = "build.rs"
name = "rad-tui"
path = "bin/main.rs"

+
[features]
+
default = ["flux"]
+
realm = ["dep:tuirealm", "dep:tui-realm-stdlib"]
+
flux = ["dep:tokio", "dep:tokio-stream"]
+

[dependencies]
anyhow = { version = "1" }
inquire = { version = "0.6.2", default-features = false, features = ["termion", "editor"] }
lexopt = { version = "0.3.0" }
+
libc = { version = "^0.2" }
log = { version = "0.4.19" }
radicle = { git = "https://github.com/radicle-dev/heartwood" }
radicle-term = { git = "https://github.com/radicle-dev/heartwood", package = "radicle-term" }
radicle-surf = { version = "0.18.0" }
+
ratatui = { git = "https://github.com/erak/ratatui", branch = "termion-cursor", default-features = false, features = ["all-widgets", "termion"] }
simple-logging = { version = "2.0.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
+
signal-hook = { version = "0.3.17" }
timeago = { version = "0.4.1" }
termion = { version = "3" }
textwrap = { version = "0.16.0" }
thiserror = { version = "1" }
-
tuirealm = { version = "^1.9.0", default-features = false, features = ["termion", "ratatui", "derive"] }
-
tui-realm-stdlib = { version = "1.3.1", default-features = false, features = ["termion", "ratatui"] }
+
tokio = { version = "1.32.0", features = ["full"], optional = true }
+
tokio-stream = { version = "0.1.14", optional = true }
+
tuirealm = { version = "^1.9.0", default-features = false, features = ["termion", "ratatui", "derive"], optional = true }
+
tui-realm-stdlib = { version = "1.3.1", default-features = false, features = ["termion", "ratatui"], optional = true }
modified bin/commands/inbox.rs
@@ -1,5 +1,11 @@
-
#[path = "inbox/select.rs"]
-
mod select;
+
#[path = "inbox/common.rs"]
+
mod common;
+
#[cfg(feature = "flux")]
+
#[path = "inbox/flux.rs"]
+
mod flux;
+
#[cfg(feature = "realm")]
+
#[path = "inbox/realm.rs"]
+
mod realm;

use std::ffi::OsString;

@@ -7,13 +13,11 @@ use anyhow::anyhow;

use radicle_tui as tui;

-
use tui::cob::inbox::{self};
-
use tui::{context, log, Window};
+
use tui::common::cob::inbox::{self};

use crate::terminal;
use crate::terminal::args::{Args, Error, Help};

-
pub const FPS: u64 = 60;
pub const HELP: Help = Help {
    name: "inbox",
    description: "Terminal interfaces for notifications",
@@ -51,7 +55,7 @@ pub enum OperationName {

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SelectOptions {
-
    mode: select::Mode,
+
    mode: common::Mode,
    filter: inbox::Filter,
    sort_by: inbox::SortBy,
}
@@ -78,8 +82,8 @@ impl Args for Options {
                    let val = val.to_str().unwrap_or_default();

                    select_opts.mode = match val {
-
                        "operation" => select::Mode::Operation,
-
                        "id" => select::Mode::Id,
+
                        "operation" => common::Mode::Operation,
+
                        "id" => common::Mode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
                }
@@ -92,7 +96,7 @@ impl Args for Options {

                    match terminal::args::string(&val).as_str() {
                        "timestamp" => field = Some("timestamp"),
-
                        "rowid" => field = Some("id"),
+
                        "id" => field = Some("id"),
                        other => anyhow::bail!("unknown sorting field '{other}'"),
                    }
                }
@@ -121,7 +125,13 @@ impl Args for Options {
    }
}

+
#[cfg(feature = "realm")]
pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    use tui::common::context;
+
    use tui::common::log;
+
    use tui::realm::Window;
+

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

@@ -132,7 +142,7 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>

            log::enable(context.profile(), "inbox", "select")?;

-
            let mut app = select::App::new(
+
            let mut app = realm::select::App::new(
                context,
                opts.mode.clone(),
                opts.filter.clone(),
@@ -140,6 +150,38 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>
            );
            let output = Window::default().run(&mut app, 1000 / FPS)?;

+
            eprint!("{:?}", output);
+
        }
+
    }
+

+
    Ok(())
+
}
+

+
#[cfg(feature = "flux")]
+
#[tokio::main]
+
pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    use radicle::storage::ReadStorage;
+
    use tui::common::log;
+

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

+
    match options.op {
+
        Operation::Select { opts } => {
+
            let profile = terminal::profile()?;
+
            let repository = profile.storage.repository(rid).unwrap();
+

+
            log::enable(&profile, "inbox", "select")?;
+

+
            let context = flux::select::Context {
+
                profile,
+
                repository,
+
                mode: opts.mode,
+
                filter: opts.filter.clone(),
+
                sort_by: opts.sort_by,
+
            };
+
            let output = flux::select::App::new(context).run().await?;
+

            let output = output
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
                .unwrap_or_default();
added bin/commands/inbox/common.rs
@@ -0,0 +1,10 @@
+
/// The application's subject. It tells the application
+
/// which widgets to render and which output to produce.
+
///
+
/// Depends on CLI arguments given by the user.
+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
+
pub enum Mode {
+
    Id,
+
    #[default]
+
    Operation,
+
}
added bin/commands/inbox/flux.rs
@@ -0,0 +1,2 @@
+
#[path = "flux/select.rs"]
+
pub mod select;
added bin/commands/inbox/flux/select.rs
@@ -0,0 +1,124 @@
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use anyhow::Result;
+

+
use radicle::node::notifications::NotificationId;
+

+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+
use radicle_tui as tui;
+

+
use tui::common::cob::inbox::{self};
+
use tui::flux::store::{State, Store};
+
use tui::flux::termination::{self, Interrupted};
+
use tui::flux::ui::cob::NotificationItem;
+
use tui::flux::ui::Frontend;
+
use tui::Exit;
+

+
use ui::ListPage;
+

+
use super::super::common;
+

+
type Selection = tui::Selection<NotificationId>;
+

+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: common::Mode,
+
    pub filter: inbox::Filter,
+
    pub sort_by: inbox::SortBy,
+
}
+

+
pub struct App {
+
    context: Context,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct InboxState {
+
    notifications: Vec<NotificationItem>,
+
    selected: Option<NotificationItem>,
+
    mode: common::Mode,
+
}
+

+
impl TryFrom<&Context> for InboxState {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let notifications = inbox::all(&context.repository, &context.profile)?;
+
        let mut items = vec![];
+

+
        // Convert into UI items
+
        for notif in &notifications {
+
            if let Ok(notif) =
+
                NotificationItem::try_from((&context.profile, &context.repository, notif))
+
            {
+
                items.push(notif);
+
            }
+
        }
+

+
        // Apply sorting
+
        match context.sort_by.field {
+
            "timestamp" => items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
+
            "id" => items.sort_by(|a, b| a.id.cmp(&b.id)),
+
            _ => {}
+
        }
+
        if context.sort_by.reverse {
+
            items.reverse();
+
        }
+
        let selected = items.first().cloned();
+

+
        Ok(Self {
+
            notifications: items,
+
            selected,
+
            mode: context.mode.clone(),
+
        })
+
    }
+
}
+

+
pub enum Action {
+
    Exit { selection: Option<Selection> },
+
    Select { item: NotificationItem },
+
}
+

+
impl State<Action, Selection> for InboxState {
+
    fn tick(&self) {}
+

+
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
+
        match action {
+
            Action::Select { item } => {
+
                self.selected = Some(item);
+
                None
+
            }
+
            Action::Exit { selection } => Some(Exit { value: selection }),
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self { context }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (store, state_rx) = Store::<Action, InboxState, Selection>::new();
+
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let state = InboxState::try_from(&self.context)?;
+

+
        tokio::try_join!(
+
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
+
            frontend
+
                .main_loop::<InboxState, ListPage, Selection>(state_rx, interrupt_rx.resubscribe()),
+
        )?;
+

+
        if let Ok(reason) = interrupt_rx.recv().await {
+
            match reason {
+
                Interrupted::User { payload } => Ok(payload),
+
                Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"),
+
            }
+
        } else {
+
            anyhow::bail!("exited because of an unexpected error");
+
        }
+
    }
+
}
added bin/commands/inbox/flux/select/ui.rs
@@ -0,0 +1,327 @@
+
use std::vec;
+

+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::backend::Backend;
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};
+

+
use radicle_tui as tui;
+

+
use tui::flux::ui::cob::NotificationItem;
+
use tui::flux::ui::span;
+
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::flux::ui::widget::{
+
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
+
};
+
use tui::Selection;
+

+
use super::common::Mode;
+
use super::{Action, InboxState};
+

+
pub struct ListPageProps {
+
    selected: Option<NotificationItem>,
+
    mode: Mode,
+
}
+

+
impl From<&InboxState> for ListPageProps {
+
    fn from(state: &InboxState) -> Self {
+
        Self {
+
            selected: state.selected.clone(),
+
            mode: state.mode.clone(),
+
        }
+
    }
+
}
+

+
pub struct ListPage {
+
    /// Action sender
+
    pub action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: ListPageProps,
+
    /// Notification widget
+
    notifications: Notifications,
+
    /// Shortcut widget
+
    shortcuts: Shortcuts<Action>,
+
}
+

+
impl Widget<InboxState, Action> for ListPage {
+
    fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: ListPageProps::from(state),
+
            notifications: Notifications::new(state, action_tx.clone()),
+
            shortcuts: Shortcuts::new(state, action_tx.clone()),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &InboxState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        ListPage {
+
            notifications: self.notifications.move_with_state(state),
+
            shortcuts: self.shortcuts.move_with_state(state),
+
            props: ListPageProps::from(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "list-page"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        match key {
+
            Key::Char('q') => {
+
                let _ = self.action_tx.send(Action::Exit { selection: None });
+
            }
+
            Key::Char('\n') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let selection = match self.props.mode {
+
                        Mode::Operation => Selection::default()
+
                            .with_operation("show".to_string())
+
                            .with_id(selected.id),
+
                        Mode::Id => Selection::default().with_id(selected.id),
+
                    };
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(selection),
+
                    });
+
                }
+
            }
+
            Key::Char('c') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(
+
                            Selection::default()
+
                                .with_operation("clear".to_string())
+
                                .with_id(selected.id),
+
                        ),
+
                    });
+
                }
+
            }
+
            _ => {
+
                <Notifications as Widget<InboxState, Action>>::handle_key_event(
+
                    &mut self.notifications,
+
                    key,
+
                );
+
            }
+
        }
+
    }
+
}
+

+
impl Render<()> for ListPage {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
        let area = frame.size();
+
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);
+

+
        let shortcuts = match self.props.mode {
+
            Mode::Id => vec![Shortcut::new("enter", "select"), Shortcut::new("q", "quit")],
+
            Mode::Operation => vec![
+
                Shortcut::new("enter", "show"),
+
                Shortcut::new("c", "clear"),
+
                Shortcut::new("q", "quit"),
+
            ],
+
        };
+

+
        self.notifications.render::<B>(frame, layout.component, ());
+
        self.shortcuts.render::<B>(
+
            frame,
+
            layout.shortcuts,
+
            ShortcutsProps {
+
                shortcuts,
+
                divider: '∙',
+
            },
+
        );
+
    }
+
}
+

+
struct NotificationsProps {
+
    notifications: Vec<NotificationItem>,
+
}
+

+
impl From<&InboxState> for NotificationsProps {
+
    fn from(state: &InboxState) -> Self {
+
        Self {
+
            notifications: state.notifications.clone(),
+
        }
+
    }
+
}
+

+
struct Notifications {
+
    /// Action sender
+
    action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: NotificationsProps,
+
    /// Table header
+
    header: Header<Action>,
+
    /// Notification table
+
    table: Table<Action>,
+
    /// Table footer
+
    footer: Footer<Action>,
+
}
+

+
impl Widget<InboxState, Action> for Notifications {
+
    fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: NotificationsProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
+
            table: Table::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
+
        }
+
    }
+

+
    fn move_with_state(self, state: &InboxState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            props: NotificationsProps::from(state),
+
            header: self.header.move_with_state(state),
+
            table: self.table.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "notifications"
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Up => {
+
                self.table.prev();
+

+
                let selected = self
+
                    .table
+
                    .selected()
+
                    .and_then(|selected| self.props.notifications.get(selected));
+

+
                // TODO: propagate error
+
                if let Some(notif) = selected {
+
                    let _ = self.action_tx.send(Action::Select {
+
                        item: notif.clone(),
+
                    });
+
                }
+
            }
+
            Key::Down => {
+
                self.table.next(self.props.notifications.len());
+

+
                let selected = self
+
                    .table
+
                    .selected()
+
                    .and_then(|selected| self.props.notifications.get(selected));
+

+
                // TODO: propagate error
+
                if let Some(notif) = selected {
+
                    let _ = self.action_tx.send(Action::Select {
+
                        item: notif.clone(),
+
                    });
+
                }
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
impl Render<()> for Notifications {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        let cutoff = 200;
+
        let cutoff_after = 8;
+
        let focus = false;
+

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

+
        let widths = [
+
            Constraint::Length(5),
+
            Constraint::Length(3),
+
            Constraint::Length(20),
+
            Constraint::Fill(1),
+
            Constraint::Length(8),
+
            Constraint::Length(10),
+
            Constraint::Length(15),
+
            Constraint::Length(18),
+
        ];
+

+
        let progress = {
+
            let step = self
+
                .table
+
                .selected()
+
                .map(|selected| selected.saturating_add(1).to_string())
+
                .unwrap_or("-".to_string());
+
            let length = self.props.notifications.len().to_string();
+

+
            span::badge(format!("{}/{}", step, length))
+
        };
+

+
        self.header.render::<B>(
+
            frame,
+
            layout[0],
+
            HeaderProps {
+
                cells: [
+
                    String::from("").into(),
+
                    String::from(" ● ").into(),
+
                    String::from("ID / Name").into(),
+
                    String::from("Summary").into(),
+
                    String::from("Type").into(),
+
                    String::from("Status").into(),
+
                    String::from("Author").into(),
+
                    String::from("Updated").into(),
+
                ],
+
                widths,
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.table.render::<B>(
+
            frame,
+
            layout[1],
+
            TableProps {
+
                items: self.props.notifications.to_vec(),
+
                has_header: true,
+
                has_footer: true,
+
                focus,
+
                widths,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.footer.render::<B>(
+
            frame,
+
            layout[2],
+
            FooterProps {
+
                cells: [
+
                    span::badge("/".to_string()),
+
                    String::from("").into(),
+
                    String::from("").into(),
+
                    progress.clone(),
+
                ],
+
                widths: [
+
                    Constraint::Length(3),
+
                    Constraint::Fill(1),
+
                    Constraint::Fill(1),
+
                    Constraint::Length(progress.width() as u16),
+
                ],
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+
    }
+
}
added bin/commands/inbox/realm.rs
@@ -0,0 +1,2 @@
+
#[path = "realm/select.rs"]
+
pub mod select;
added bin/commands/inbox/realm/select.rs
@@ -0,0 +1,201 @@
+
#[path = "select/event.rs"]
+
mod event;
+
#[path = "select/page.rs"]
+
mod page;
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use std::fmt::Display;
+
use std::hash::Hash;
+

+
use anyhow::Result;
+

+
use radicle::node::notifications::NotificationId;
+
use serde::Serialize;
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::inbox::{Filter, SortBy};
+
use tui::common::context::Context;
+
use tui::realm::ui::subscription;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::{PageStack, Tui};
+
use tui::Exit;
+

+
use page::ListView;
+

+
use super::super::common::Mode;
+

+
type Selection = tui::Selection<NotificationId>;
+

+
/// The selected issue operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum InboxOperation {
+
    Show,
+
    Clear,
+
}
+

+
impl Display for InboxOperation {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            InboxOperation::Show => {
+
                write!(f, "show")
+
            }
+
            InboxOperation::Clear => {
+
                write!(f, "clear")
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    NotificationBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    #[default]
+
    GlobalListener,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    #[default]
+
    Tick,
+
    Quit(Option<Selection>),
+
    Batch(Vec<Message>),
+
}
+

+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    mode: Mode,
+
    filter: Filter,
+
    sort_by: SortBy,
+
    quit: bool,
+
    output: Option<Selection>,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
#[allow(dead_code)]
+
impl App {
+
    pub fn new(context: Context, mode: Mode, filter: Filter, sort_by: SortBy) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: Theme::default(),
+
            mode,
+
            filter,
+
            sort_by,
+
            quit: false,
+
            output: None,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::new(ListView::new(
+
            self.mode.clone(),
+
            self.filter.clone(),
+
            self.sort_by,
+
        ));
+
        self.pages.push(home, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = Theme::default();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Quit(output) => {
+
                self.quit = true;
+
                self.output = output;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+
}
+

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

+
        // Add global key listener and subscribe to key events
+
        let global = tui::realm::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(
+
                subscription::quit_clause(tuirealm::event::Key::Char('q')),
+
                SubClause::Always,
+
            )],
+
        )?;
+

+
        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() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit<Selection>> {
+
        if self.quit {
+
            return Some(Exit {
+
                value: self.output.clone(),
+
            });
+
        }
+
        None
+
    }
+
}
added bin/commands/inbox/realm/select/event.rs
@@ -0,0 +1,170 @@
+
use radicle::node::notifications::NotificationId;
+

+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
+
use tui::realm::ui::widget::context::{ContextBar, Shortcuts};
+
use tui::realm::ui::widget::list::PropertyList;
+
use tui::realm::ui::widget::Widget;
+

+
use super::ui::{IdSelect, OperationSelect};
+
use super::{InboxOperation, Message};
+

+
type Selection = tui::Selection<NotificationId>;
+

+
/// 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)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<NotificationId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: None,
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<NotificationId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(InboxOperation::Show.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('c'),
+
                ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(InboxOperation::Clear.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        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 bin/commands/inbox/realm/select/page.rs
@@ -0,0 +1,168 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::inbox::{Filter, SortBy};
+
use tui::common::context::Context;
+
use tui::realm::ui::layout;
+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::ui::widget::context::{Progress, Shortcuts};
+
use tui::realm::ui::widget::Widget;
+
use tui::realm::ViewPage;
+

+
use super::{ui, Application, Cid, ListCid, Message, Mode};
+

+
///
+
/// Home
+
///
+
pub struct ListView {
+
    active_component: ListCid,
+
    mode: Mode,
+
    filter: Filter,
+
    sort_by: SortBy,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
}
+

+
impl ListView {
+
    pub fn new(mode: Mode, filter: Filter, sort_by: SortBy) -> Self {
+
        Self {
+
            active_component: ListCid::NotificationBrowser,
+
            mode,
+
            filter,
+
            sort_by,
+
            shortcuts: HashMap::default(),
+
        }
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::NotificationBrowser))?;
+
        let progress = match ItemState::try_from(state) {
+
            Ok(state) => Progress::Step(
+
                state
+
                    .selected()
+
                    .map(|s| s.saturating_add(1))
+
                    .unwrap_or_default(),
+
                state.len(),
+
            ),
+
            Err(_) => Progress::None,
+
        };
+

+
        let context = ui::browse_context(context, theme, self.filter.clone(), progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListView {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let browser = ui::operation_select(theme, context, self.filter.clone(), self.sort_by, None)
+
            .to_boxed();
+
        self.shortcuts = browser.as_ref().shortcuts();
+

+
        match self.mode {
+
            Mode::Id => {
+
                let notif_browser =
+
                    ui::id_select(theme, context, self.filter.clone(), self.sort_by, None)
+
                        .to_boxed();
+
                self.shortcuts = notif_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::NotificationBrowser), browser, vec![])?;
+
            }
+
            Mode::Operation => {
+
                let notif_browser =
+
                    ui::operation_select(theme, context, self.filter.clone(), self.sort_by, None)
+
                        .to_boxed();
+
                self.shortcuts = notif_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::NotificationBrowser), browser, vec![])?;
+
            }
+
        };
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::List(ListCid::NotificationBrowser))?;
+
        app.umount(&Cid::List(ListCid::Context))?;
+
        app.umount(&Cid::List(ListCid::Shortcuts))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        _message: Message,
+
    ) -> Result<Option<Message>> {
+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let context_h = app
+
            .query(&Cid::List(ListCid::Context), Attribute::Height)
+
            .unwrap_or_default()
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = 1u16;
+

+
        let layout = layout::default_page(area, context_h, shortcuts_h);
+

+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
+
        app.view(&Cid::List(ListCid::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(())
+
    }
+
}
added bin/commands/inbox/realm/select/ui.rs
@@ -0,0 +1,267 @@
+
use std::collections::HashMap;
+

+
use radicle::node::notifications::Notification;
+

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

+
use radicle_tui as tui;
+

+
use tui::common::cob::inbox::{Filter, SortBy};
+
use tui::common::context::Context;
+
use tui::realm::ui::cob::NotificationItem;
+
use tui::realm::ui::theme::{style, Theme};
+
use tui::realm::ui::widget::context::{ContextBar, Progress, Shortcuts};
+
use tui::realm::ui::widget::label::{self};
+
use tui::realm::ui::widget::list::{ColumnWidth, Table};
+
use tui::realm::ui::widget::{Widget, WidgetComponent};
+

+
use super::ListCid;
+

+
pub struct NotificationBrowser {
+
    items: Vec<NotificationItem>,
+
    table: Widget<Table<NotificationItem, 7>>,
+
}
+

+
impl NotificationBrowser {
+
    pub fn new(
+
        theme: &Theme,
+
        context: &Context,
+
        sort_by: SortBy,
+
        selected: Option<Notification>,
+
    ) -> Self {
+
        let header = [
+
            label::header(""),
+
            label::header(" ● "),
+
            label::header("Type"),
+
            label::header("Summary"),
+
            label::header("ID"),
+
            label::header("Status"),
+
            label::header("Updated"),
+
        ];
+
        let widths = [
+
            ColumnWidth::Fixed(5),
+
            ColumnWidth::Fixed(3),
+
            ColumnWidth::Fixed(6),
+
            ColumnWidth::Grow,
+
            ColumnWidth::Fixed(15),
+
            ColumnWidth::Fixed(10),
+
            ColumnWidth::Fixed(18),
+
        ];
+

+
        let mut items = vec![];
+
        for notification in context.notifications() {
+
            if let Ok(item) =
+
                NotificationItem::try_from((context.repository(), notification.clone()))
+
            {
+
                items.push(item);
+
            }
+
        }
+

+
        match sort_by.field {
+
            "timestamp" => items.sort_by(|a, b| b.timestamp().cmp(a.timestamp())),
+
            "id" => items.sort_by(|a, b| b.id().cmp(a.id())),
+
            _ => {}
+
        }
+
        if sort_by.reverse {
+
            items.reverse();
+
        }
+

+
        let selected = match selected {
+
            Some(notif) => {
+
                Some(NotificationItem::try_from((context.repository(), notif.clone())).unwrap())
+
            }
+
            _ => items.first().cloned(),
+
        };
+

+
        let table = Widget::new(Table::new(&items, selected, header, widths, theme.clone()));
+

+
        Self { items, table }
+
    }
+

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

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

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

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

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

+
pub struct IdSelect {
+
    theme: Theme,
+
    browser: Widget<NotificationBrowser>,
+
}
+

+
impl IdSelect {
+
    pub fn new(theme: Theme, browser: Widget<NotificationBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

+
    pub fn items(&self) -> &Vec<NotificationItem> {
+
        self.browser.items()
+
    }
+

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::NotificationBrowser,
+
            tui::realm::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::realm::ui::shortcut(&self.theme, "enter", "select"),
+
                    tui::realm::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

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

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

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

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

+
pub struct OperationSelect {
+
    theme: Theme,
+
    browser: Widget<NotificationBrowser>,
+
}
+

+
impl OperationSelect {
+
    pub fn new(theme: Theme, browser: Widget<NotificationBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

+
    pub fn items(&self) -> &Vec<NotificationItem> {
+
        self.browser.items()
+
    }
+

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::NotificationBrowser,
+
            tui::realm::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::realm::ui::shortcut(&self.theme, "enter", "show"),
+
                    tui::realm::ui::shortcut(&self.theme, "c", "clear"),
+
                    tui::realm::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

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

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

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

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

+
pub fn id_select(
+
    theme: &Theme,
+
    context: &Context,
+
    _filter: Filter,
+
    sort_by: SortBy,
+
    selected: Option<Notification>,
+
) -> Widget<IdSelect> {
+
    let browser = Widget::new(NotificationBrowser::new(theme, context, sort_by, selected));
+

+
    Widget::new(IdSelect::new(theme.clone(), browser))
+
}
+

+
pub fn operation_select(
+
    theme: &Theme,
+
    context: &Context,
+
    _filter: Filter,
+
    sort_by: SortBy,
+
    selected: Option<Notification>,
+
) -> Widget<OperationSelect> {
+
    let browser = Widget::new(NotificationBrowser::new(theme, context, sort_by, selected));
+

+
    Widget::new(OperationSelect::new(theme.clone(), browser))
+
}
+

+
pub fn browse_context(
+
    _context: &Context,
+
    _theme: &Theme,
+
    _filter: Filter,
+
    progress: Progress,
+
) -> Widget<ContextBar> {
+
    let context = label::reversable("/").style(style::magenta_reversed());
+
    let filter = label::default("").style(style::magenta_dim());
+

+
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
+

+
    let spacer = label::default("");
+
    let _divider = label::default(" | ");
+

+
    let context_bar = ContextBar::new(
+
        label::group(&[context]),
+
        label::group(&[filter]),
+
        label::group(&[spacer.clone()]),
+
        label::group(&[
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
        ]),
+
        label::group(&[progress]),
+
    );
+

+
    Widget::new(context_bar).height(1)
+
}
deleted bin/commands/inbox/select.rs
@@ -1,233 +0,0 @@
-
#[path = "select/event.rs"]
-
mod event;
-
#[path = "select/page.rs"]
-
mod page;
-
#[path = "select/ui.rs"]
-
mod ui;
-

-
use std::fmt::Display;
-
use std::hash::Hash;
-

-
use anyhow::Result;
-
use serde::{Serialize, Serializer};
-

-
use tuirealm::application::PollStrategy;
-
use tuirealm::event::Key;
-
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob::inbox::{Filter, SortBy};
-
use tui::context::Context;
-

-
use tui::ui::subscription;
-
use tui::ui::theme::Theme;
-
use tui::{Exit, PageStack, SelectionExit, Tui};
-

-
use page::ListView;
-

-
/// Wrapper around radicle's `PatchId` that serializes
-
/// to a human-readable string.
-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub struct PatchId(radicle::cob::patch::PatchId);
-

-
impl From<radicle::cob::patch::PatchId> for PatchId {
-
    fn from(value: radicle::cob::patch::PatchId) -> Self {
-
        PatchId(value)
-
    }
-
}
-

-
impl Display for PatchId {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        write!(f, "{}", self.0)
-
    }
-
}
-

-
impl Serialize for PatchId {
-
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-
    where
-
        S: Serializer,
-
    {
-
        serializer.serialize_str(&format!("{}", *self.0))
-
    }
-
}
-

-
/// The application's subject. It tells the application
-
/// which widgets to render and which output to produce.
-
///
-
/// Depends on CLI arguments given by the user.
-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Mode {
-
    #[default]
-
    Operation,
-
    #[allow(dead_code)]
-
    Id,
-
}
-

-
/// The selected issue operation returned by the operation
-
/// selection widget.
-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub enum InboxOperation {
-
    Show,
-
    Clear,
-
}
-

-
impl Display for InboxOperation {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            InboxOperation::Show => {
-
                write!(f, "show")
-
            }
-
            InboxOperation::Clear => {
-
                write!(f, "clear")
-
            }
-
        }
-
    }
-
}
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum ListCid {
-
    NotificationBrowser,
-
    Context,
-
    Shortcuts,
-
}
-

-
/// All component ids known to this application.
-
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
-
pub enum Cid {
-
    List(ListCid),
-
    #[default]
-
    GlobalListener,
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Message {
-
    #[default]
-
    Tick,
-
    Quit(Option<SelectionExit>),
-
    Batch(Vec<Message>),
-
}
-

-
pub struct App {
-
    context: Context,
-
    pages: PageStack<Cid, Message>,
-
    theme: Theme,
-
    mode: Mode,
-
    filter: Filter,
-
    sort_by: SortBy,
-
    output: Option<SelectionExit>,
-
    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(context: Context, mode: Mode, filter: Filter, sort_by: SortBy) -> Self {
-
        Self {
-
            context,
-
            pages: PageStack::default(),
-
            theme: Theme::default(),
-
            mode,
-
            filter,
-
            sort_by,
-
            output: None,
-
            quit: false,
-
        }
-
    }
-

-
    fn view_list(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let home = Box::new(ListView::new(
-
            self.mode.clone(),
-
            self.filter.clone(),
-
            self.sort_by,
-
        ));
-
        self.pages.push(home, app, &self.context, theme)?;
-

-
        Ok(())
-
    }
-

-
    fn process(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        let theme = Theme::default();
-
        match message {
-
            Message::Batch(messages) => {
-
                let mut results = vec![];
-
                for message in messages {
-
                    if let Some(result) = self.process(app, message)? {
-
                        results.push(result);
-
                    }
-
                }
-
                match results.len() {
-
                    0 => Ok(None),
-
                    1 => Ok(Some(results[0].to_owned())),
-
                    _ => Ok(Some(Message::Batch(results))),
-
                }
-
            }
-
            Message::Quit(output) => {
-
                self.quit = true;
-
                self.output = output;
-
                Ok(None)
-
            }
-
            _ => self
-
                .pages
-
                .peek_mut()?
-
                .update(app, &self.context, &theme, message),
-
        }
-
    }
-
}
-

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

-
        // Add global key listener and subscribe to key events
-
        let global = tui::ui::global_listener().to_boxed();
-
        app.mount(
-
            Cid::GlobalListener,
-
            global,
-
            vec![Sub::new(
-
                subscription::quit_clause(Key::Char('q')),
-
                SubClause::Always,
-
            )],
-
        )?;
-

-
        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() => {
-
                for message in messages {
-
                    let mut msg = Some(message);
-
                    while msg.is_some() {
-
                        msg = self.process(app, msg.unwrap())?;
-
                    }
-
                }
-
                Ok(true)
-
            }
-
            _ => Ok(false),
-
        }
-
    }
-

-
    fn exit(&self) -> Option<Exit<SelectionExit>> {
-
        if self.quit {
-
            return Some(Exit {
-
                value: self.output.clone(),
-
            });
-
        }
-
        None
-
    }
-
}
deleted bin/commands/inbox/select/event.rs
@@ -1,161 +0,0 @@
-
use radicle::node::notifications::NotificationId;
-

-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
-
use tuirealm::event::{Event, Key, KeyEvent};
-
use tuirealm::{MockComponent, NoUserEvent};
-

-
use radicle_tui as tui;
-

-
use tui::ui::state::ItemState;
-
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
-
use tui::ui::widget::context::{ContextBar, Shortcuts};
-
use tui::ui::widget::list::PropertyList;
-
use tui::ui::widget::Widget;
-
use tui::{Id, SelectionExit};
-

-
use super::ui::{IdSelect, OperationSelect};
-
use super::{InboxOperation, Message};
-

-
/// 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)),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<NotificationId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => submit().map(|id| {
-
                let output = SelectionExit::default().with_id(Id::Notification(id));
-
                Message::Quit(Some(output))
-
            }),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<NotificationId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(InboxOperation::Show.to_string())
-
                    .with_id(Id::Notification(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('c'),
-
                ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(InboxOperation::Clear.to_string())
-
                    .with_id(Id::Notification(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        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 bin/commands/inbox/select/page.rs
@@ -1,168 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent};
-

-
use radicle_tui as tui;
-

-
use tui::cob::inbox::{Filter, SortBy};
-
use tui::context::Context;
-
use tui::ui::layout;
-
use tui::ui::state::ItemState;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::context::{Progress, Shortcuts};
-
use tui::ui::widget::Widget;
-
use tui::ViewPage;
-

-
use super::{ui, Application, Cid, ListCid, Message, Mode};
-

-
///
-
/// Home
-
///
-
pub struct ListView {
-
    active_component: ListCid,
-
    mode: Mode,
-
    filter: Filter,
-
    sort_by: SortBy,
-
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
-
}
-

-
impl ListView {
-
    pub fn new(mode: Mode, filter: Filter, sort_by: SortBy) -> Self {
-
        Self {
-
            active_component: ListCid::NotificationBrowser,
-
            mode,
-
            filter,
-
            sort_by,
-
            shortcuts: HashMap::default(),
-
        }
-
    }
-

-
    fn update_context(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let state = app.state(&Cid::List(ListCid::NotificationBrowser))?;
-
        let progress = match ItemState::try_from(state) {
-
            Ok(state) => Progress::Step(
-
                state
-
                    .selected()
-
                    .map(|s| s.saturating_add(1))
-
                    .unwrap_or_default(),
-
                state.len(),
-
            ),
-
            Err(_) => Progress::None,
-
        };
-

-
        let context = ui::browse_context(context, theme, self.filter.clone(), progress);
-

-
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
-

-
        Ok(())
-
    }
-

-
    fn update_shortcuts(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: ListCid,
-
    ) -> Result<()> {
-
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
-
            app.remount(
-
                Cid::List(ListCid::Shortcuts),
-
                shortcuts.clone().to_boxed(),
-
                vec![],
-
            )?;
-
        }
-

-
        Ok(())
-
    }
-
}
-

-
impl ViewPage<Cid, Message> for ListView {
-
    fn mount(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let browser = ui::operation_select(theme, context, self.filter.clone(), self.sort_by, None)
-
            .to_boxed();
-
        self.shortcuts = browser.as_ref().shortcuts();
-

-
        match self.mode {
-
            Mode::Id => {
-
                let notif_browser =
-
                    ui::id_select(theme, context, self.filter.clone(), self.sort_by, None)
-
                        .to_boxed();
-
                self.shortcuts = notif_browser.as_ref().shortcuts();
-

-
                app.remount(Cid::List(ListCid::NotificationBrowser), browser, vec![])?;
-
            }
-
            Mode::Operation => {
-
                let notif_browser =
-
                    ui::operation_select(theme, context, self.filter.clone(), self.sort_by, None)
-
                        .to_boxed();
-
                self.shortcuts = notif_browser.as_ref().shortcuts();
-

-
                app.remount(Cid::List(ListCid::NotificationBrowser), browser, vec![])?;
-
            }
-
        };
-

-
        app.active(&Cid::List(self.active_component.clone()))?;
-
        self.update_shortcuts(app, self.active_component.clone())?;
-
        self.update_context(app, context, theme)?;
-

-
        Ok(())
-
    }
-

-
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::List(ListCid::NotificationBrowser))?;
-
        app.umount(&Cid::List(ListCid::Context))?;
-
        app.umount(&Cid::List(ListCid::Shortcuts))?;
-
        Ok(())
-
    }
-

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        _message: Message,
-
    ) -> Result<Option<Message>> {
-
        self.update_context(app, context, theme)?;
-

-
        Ok(None)
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let context_h = app
-
            .query(&Cid::List(ListCid::Context), Attribute::Height)
-
            .unwrap_or_default()
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = 1u16;
-

-
        let layout = layout::default_page(area, context_h, shortcuts_h);
-

-
        app.view(
-
            &Cid::List(self.active_component.clone()),
-
            frame,
-
            layout.component,
-
        );
-

-
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
-
        app.view(&Cid::List(ListCid::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(())
-
    }
-
}
deleted bin/commands/inbox/select/ui.rs
@@ -1,267 +0,0 @@
-
use std::collections::HashMap;
-

-
use radicle::node::notifications::Notification;
-

-
use tui::ui::cob::NotificationItem;
-
use tui::ui::widget::list::{ColumnWidth, Table};
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use radicle_tui as tui;
-

-
use tui::cob::inbox::{Filter, SortBy};
-
use tui::context::Context;
-
use tui::ui::theme::{style, Theme};
-
use tui::ui::widget::context::{ContextBar, Progress, Shortcuts};
-
use tui::ui::widget::label::{self};
-
use tui::ui::widget::{Widget, WidgetComponent};
-

-
use super::ListCid;
-

-
pub struct NotificationBrowser {
-
    items: Vec<NotificationItem>,
-
    table: Widget<Table<NotificationItem, 7>>,
-
}
-

-
impl NotificationBrowser {
-
    pub fn new(
-
        theme: &Theme,
-
        context: &Context,
-
        sort_by: SortBy,
-
        selected: Option<Notification>,
-
    ) -> Self {
-
        let header = [
-
            label::header(""),
-
            label::header(" ● "),
-
            label::header("Type"),
-
            label::header("Summary"),
-
            label::header("ID"),
-
            label::header("Status"),
-
            label::header("Updated"),
-
        ];
-
        let widths = [
-
            ColumnWidth::Fixed(5),
-
            ColumnWidth::Fixed(3),
-
            ColumnWidth::Fixed(6),
-
            ColumnWidth::Grow,
-
            ColumnWidth::Fixed(15),
-
            ColumnWidth::Fixed(10),
-
            ColumnWidth::Fixed(18),
-
        ];
-

-
        let mut items = vec![];
-
        for notification in context.notifications() {
-
            if let Ok(item) =
-
                NotificationItem::try_from((context.repository(), notification.clone()))
-
            {
-
                items.push(item);
-
            }
-
        }
-

-
        match sort_by.field {
-
            "timestamp" => items.sort_by(|a, b| b.timestamp().cmp(a.timestamp())),
-
            "id" => items.sort_by(|a, b| b.id().cmp(a.id())),
-
            _ => {}
-
        }
-
        if sort_by.reverse {
-
            items.reverse();
-
        }
-

-
        let selected = match selected {
-
            Some(notif) => {
-
                Some(NotificationItem::try_from((context.repository(), notif.clone())).unwrap())
-
            }
-
            _ => items.first().cloned(),
-
        };
-

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

-
        Self { items, table }
-
    }
-

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

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

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

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

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

-
pub struct IdSelect {
-
    theme: Theme,
-
    browser: Widget<NotificationBrowser>,
-
}
-

-
impl IdSelect {
-
    pub fn new(theme: Theme, browser: Widget<NotificationBrowser>) -> Self {
-
        Self { theme, browser }
-
    }
-

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

-
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::NotificationBrowser,
-
            tui::ui::shortcuts(
-
                &self.theme,
-
                vec![
-
                    tui::ui::shortcut(&self.theme, "enter", "select"),
-
                    tui::ui::shortcut(&self.theme, "q", "quit"),
-
                ],
-
            ),
-
        )]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-
}
-

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

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

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

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

-
pub struct OperationSelect {
-
    theme: Theme,
-
    browser: Widget<NotificationBrowser>,
-
}
-

-
impl OperationSelect {
-
    pub fn new(theme: Theme, browser: Widget<NotificationBrowser>) -> Self {
-
        Self { theme, browser }
-
    }
-

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

-
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::NotificationBrowser,
-
            tui::ui::shortcuts(
-
                &self.theme,
-
                vec![
-
                    tui::ui::shortcut(&self.theme, "enter", "show"),
-
                    tui::ui::shortcut(&self.theme, "c", "clear"),
-
                    tui::ui::shortcut(&self.theme, "q", "quit"),
-
                ],
-
            ),
-
        )]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-
}
-

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

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

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

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

-
pub fn id_select(
-
    theme: &Theme,
-
    context: &Context,
-
    _filter: Filter,
-
    sort_by: SortBy,
-
    selected: Option<Notification>,
-
) -> Widget<IdSelect> {
-
    let browser = Widget::new(NotificationBrowser::new(theme, context, sort_by, selected));
-

-
    Widget::new(IdSelect::new(theme.clone(), browser))
-
}
-

-
pub fn operation_select(
-
    theme: &Theme,
-
    context: &Context,
-
    _filter: Filter,
-
    sort_by: SortBy,
-
    selected: Option<Notification>,
-
) -> Widget<OperationSelect> {
-
    let browser = Widget::new(NotificationBrowser::new(theme, context, sort_by, selected));
-

-
    Widget::new(OperationSelect::new(theme.clone(), browser))
-
}
-

-
pub fn browse_context(
-
    _context: &Context,
-
    _theme: &Theme,
-
    _filter: Filter,
-
    progress: Progress,
-
) -> Widget<ContextBar> {
-
    let context = label::reversable("/").style(style::magenta_reversed());
-
    let filter = label::default("").style(style::magenta_dim());
-

-
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
-

-
    let spacer = label::default("");
-
    let _divider = label::default(" | ");
-

-
    let context_bar = ContextBar::new(
-
        label::group(&[context]),
-
        label::group(&[filter]),
-
        label::group(&[spacer.clone()]),
-
        label::group(&[
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
        ]),
-
        label::group(&[progress]),
-
    );
-

-
    Widget::new(context_bar).height(1)
-
}
modified bin/commands/issue.rs
@@ -1,9 +1,11 @@
#[path = "issue/common.rs"]
mod common;
-
#[path = "issue/select.rs"]
-
mod select;
-
#[path = "issue/suite.rs"]
-
mod suite;
+
#[cfg(feature = "flux")]
+
#[path = "issue/flux.rs"]
+
mod flux;
+
#[cfg(feature = "realm")]
+
#[path = "issue/realm.rs"]
+
mod realm;

use std::ffi::OsString;

@@ -11,13 +13,12 @@ use anyhow::anyhow;

use radicle_tui as tui;

-
use tui::cob::issue::{self, State};
-
use tui::{context, log, Window};
+
use tui::common::cob::issue::{self, State};
+
use tui::common::log;

use crate::terminal;
use crate::terminal::args::{Args, Error, Help};

-
pub const FPS: u64 = 60;
pub const HELP: Help = Help {
    name: "issue",
    description: "Terminal interfaces for issues",
@@ -55,7 +56,7 @@ pub enum OperationName {

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SelectOptions {
-
    mode: select::Mode,
+
    mode: common::Mode,
    filter: issue::Filter,
}

@@ -79,8 +80,8 @@ impl Args for Options {
                    let val = val.to_str().unwrap_or_default();

                    select_opts.mode = match val {
-
                        "operation" => select::Mode::Operation,
-
                        "id" => select::Mode::Id,
+
                        "operation" => common::Mode::Operation,
+
                        "id" => common::Mode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
                }
@@ -120,7 +121,12 @@ impl Args for Options {
    }
}

+
#[cfg(feature = "realm")]
pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    use tui::common::context;
+
    use tui::realm::Window;
+

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

@@ -131,7 +137,7 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>

            log::enable(context.profile(), "issue", "select")?;

-
            let mut app = select::App::new(context, opts.mode.clone(), opts.filter.clone());
+
            let mut app = realm::select::App::new(context, opts.mode.clone(), opts.filter.clone());
            let output = Window::default().run(&mut app, 1000 / FPS)?;

            let output = output
@@ -144,3 +150,37 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>

    Ok(())
}
+

+
#[cfg(feature = "flux")]
+
#[tokio::main]
+
pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    use radicle::storage::ReadStorage;
+

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

+
    match options.op {
+
        Operation::Select { opts } => {
+
            let profile = terminal::profile()?;
+
            let repository = profile.storage.repository(rid).unwrap();
+

+
            log::enable(&profile, "issue", "select")?;
+

+
            let context = flux::select::Context {
+
                profile,
+
                repository,
+
                mode: opts.mode,
+
                filter: opts.filter.clone(),
+
            };
+
            let output = flux::select::App::new(context).run().await?;
+

+
            let output = output
+
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
+
                .unwrap_or_default();
+

+
            eprint!("{output}");
+
        }
+
    }
+

+
    Ok(())
+
}
modified bin/commands/issue/common.rs
@@ -1,2 +1,42 @@
-
#[path = "common/ui.rs"]
-
pub mod ui;
+
use std::fmt::Display;
+

+
use serde::Serialize;
+

+
/// The application's mode. It tells the application
+
/// which widgets to render and which output to produce.
+
/// Depends on CLI arguments given by the user.
+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Mode {
+
    #[default]
+
    Operation,
+
    Id,
+
}
+

+
/// The selected issue operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum IssueOperation {
+
    Show,
+
    Delete,
+
    Edit,
+
    Comment,
+
}
+

+
impl Display for IssueOperation {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            IssueOperation::Show => {
+
                write!(f, "show")
+
            }
+
            IssueOperation::Delete => {
+
                write!(f, "delete")
+
            }
+
            IssueOperation::Edit => {
+
                write!(f, "edit")
+
            }
+
            IssueOperation::Comment => {
+
                write!(f, "comment")
+
            }
+
        }
+
    }
+
}
deleted bin/commands/issue/common/ui.rs
@@ -1,163 +0,0 @@
-
use radicle::issue::{Issue, IssueId};
-

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

-
use radicle_tui as tui;
-

-
use tui::cob::issue::Filter;
-
use tui::context::Context;
-
use tui::ui::cob::IssueItem;
-
use tui::ui::theme::{style, Theme};
-
use tui::ui::widget::context::{ContextBar, Progress};
-
use tui::ui::widget::{Widget, WidgetComponent};
-

-
use tui::ui::widget::label::{self};
-
use tui::ui::widget::list::{ColumnWidth, Table};
-

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

-
impl IssueBrowser {
-
    pub fn new(
-
        theme: &Theme,
-
        context: &Context,
-
        filter: Filter,
-
        selected: Option<(IssueId, Issue)>,
-
    ) -> Self {
-
        let header = [
-
            label::header(" ● "),
-
            label::header("ID"),
-
            label::header("Title"),
-
            label::header("Author"),
-
            label::header("Tags"),
-
            label::header("Assignees"),
-
            label::header("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 issues = context
-
            .issues()
-
            .as_ref()
-
            .unwrap()
-
            .iter()
-
            .filter(|(_, issue)| filter.matches(context.profile(), issue));
-

-
        let mut items = vec![];
-
        for (id, issue) in issues {
-
            if let Ok(item) = IssueItem::try_from((context.profile(), repo, *id, issue.clone())) {
-
                items.push(item);
-
            }
-
        }
-

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

-
        let selected = match selected {
-
            Some((id, issue)) => Some(IssueItem::from((context.profile(), repo, id, issue))),
-
            _ => items.first().cloned(),
-
        };
-

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

-
        Self { items, table }
-
    }
-

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

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

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

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

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

-
pub fn browse_context(
-
    context: &Context,
-
    _theme: &Theme,
-
    filter: Filter,
-
    progress: Progress,
-
) -> Widget<ContextBar> {
-
    use radicle::issue::State;
-

-
    let mut open = 0;
-
    let mut closed = 0;
-

-
    let issues = context
-
        .issues()
-
        .as_ref()
-
        .unwrap()
-
        .iter()
-
        .filter(|(_, issue)| filter.matches(context.profile(), issue));
-

-
    for (_, issue) in issues {
-
        match issue.state() {
-
            State::Open => open += 1,
-
            State::Closed { reason: _ } => closed += 1,
-
        }
-
    }
-

-
    let context = label::reversable("/").style(style::magenta_reversed());
-
    let filter = label::default(&filter.to_string()).style(style::magenta_dim());
-

-
    let open_n = label::default(&format!("{open}")).style(style::green());
-
    let open = label::default(" Open");
-

-
    let closed_n = label::default(&format!("{closed}")).style(style::cyan());
-
    let closed = label::default(" Closed ");
-

-
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
-

-
    let spacer = label::default("");
-
    let divider = label::default(" | ");
-

-
    let context_bar = ContextBar::new(
-
        label::group(&[context]),
-
        label::group(&[filter]),
-
        label::group(&[spacer.clone()]),
-
        label::group(&[
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer,
-
            open_n,
-
            open,
-
            divider,
-
            closed_n,
-
            closed,
-
        ]),
-
        label::group(&[progress]),
-
    );
-

-
    Widget::new(context_bar).height(1)
-
}
added bin/commands/issue/flux.rs
@@ -0,0 +1,2 @@
+
#[path = "flux/select.rs"]
+
pub mod select;
added bin/commands/issue/flux/select.rs
@@ -0,0 +1,122 @@
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use anyhow::Result;
+

+
use radicle::issue::IssueId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::issue::{self, Filter};
+
use tui::flux::store::{State, Store};
+
use tui::flux::termination::{self, Interrupted};
+
use tui::flux::ui::cob::IssueItem;
+
use tui::flux::ui::Frontend;
+
use tui::Exit;
+

+
use ui::ListPage;
+

+
use super::super::common::Mode;
+

+
type Selection = tui::Selection<IssueId>;
+

+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: Mode,
+
    pub filter: Filter,
+
}
+

+
pub struct App {
+
    context: Context,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct IssuesState {
+
    issues: Vec<IssueItem>,
+
    selected: Option<IssueItem>,
+
    mode: Mode,
+
    filter: Filter,
+
}
+

+
impl TryFrom<&Context> for IssuesState {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let issues = issue::all(&context.repository)?;
+
        let issues = issues
+
            .iter()
+
            .filter(|(_, issue)| context.filter.matches(&context.profile, issue));
+

+
        let mut items = vec![];
+

+
        // Convert into UI items
+
        for issue in issues {
+
            if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) {
+
                items.push(item);
+
            }
+
        }
+

+
        // Apply sorting
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+
        let selected = items.first().cloned();
+

+
        Ok(Self {
+
            issues: items,
+
            selected,
+
            mode: context.mode.clone(),
+
            filter: context.filter.clone(),
+
        })
+
    }
+
}
+

+
pub enum Action {
+
    Exit { selection: Option<Selection> },
+
    Select { item: IssueItem },
+
}
+

+
impl State<Action, Selection> for IssuesState {
+
    fn tick(&self) {}
+

+
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
+
        match action {
+
            Action::Select { item } => {
+
                self.selected = Some(item);
+
                None
+
            }
+
            Action::Exit { selection } => Some(Exit { value: selection }),
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self { context }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (store, state_rx) = Store::<Action, IssuesState, Selection>::new();
+
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let state = IssuesState::try_from(&self.context)?;
+

+
        tokio::try_join!(
+
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
+
            frontend.main_loop::<IssuesState, ListPage, Selection>(
+
                state_rx,
+
                interrupt_rx.resubscribe()
+
            ),
+
        )?;
+

+
        if let Ok(reason) = interrupt_rx.recv().await {
+
            match reason {
+
                Interrupted::User { payload } => Ok(payload),
+
                Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"),
+
            }
+
        } else {
+
            anyhow::bail!("exited because of an unexpected error");
+
        }
+
    }
+
}
added bin/commands/issue/flux/select/ui.rs
@@ -0,0 +1,356 @@
+
use std::vec;
+

+
use ratatui::style::Stylize;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::backend::Backend;
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::issue::Filter;
+
use tui::flux::ui::cob::IssueItem;
+
use tui::flux::ui::span;
+
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::flux::ui::widget::{
+
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
+
};
+
use tui::Selection;
+

+
use crate::tui_issue::common::IssueOperation;
+
use crate::tui_issue::common::Mode;
+

+
use super::{Action, IssuesState};
+

+
pub struct ListPageProps {
+
    selected: Option<IssueItem>,
+
    mode: Mode,
+
}
+

+
impl From<&IssuesState> for ListPageProps {
+
    fn from(state: &IssuesState) -> Self {
+
        Self {
+
            selected: state.selected.clone(),
+
            mode: state.mode.clone(),
+
        }
+
    }
+
}
+

+
pub struct ListPage {
+
    /// Action sender
+
    pub action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: ListPageProps,
+
    /// Notification widget
+
    issues: Issues,
+
    /// Shortcut widget
+
    shortcuts: Shortcuts<Action>,
+
}
+

+
impl Widget<IssuesState, Action> for ListPage {
+
    fn new(state: &IssuesState, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: ListPageProps::from(state),
+
            issues: Issues::new(state, action_tx.clone()),
+
            shortcuts: Shortcuts::new(state, action_tx.clone()),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &IssuesState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        ListPage {
+
            issues: self.issues.move_with_state(state),
+
            shortcuts: self.shortcuts.move_with_state(state),
+
            props: ListPageProps::from(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "list-page"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        match key {
+
            Key::Char('q') => {
+
                let _ = self.action_tx.send(Action::Exit { selection: None });
+
            }
+
            Key::Char('\n') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let operation = match self.props.mode {
+
                        Mode::Operation => Some(IssueOperation::Show.to_string()),
+
                        Mode::Id => None,
+
                    };
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation,
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('c') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(IssueOperation::Comment.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('e') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(IssueOperation::Edit.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('d') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(IssueOperation::Delete.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            _ => {
+
                <Issues as Widget<IssuesState, Action>>::handle_key_event(&mut self.issues, key);
+
            }
+
        }
+
    }
+
}
+

+
impl Render<()> for ListPage {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
        let area = frame.size();
+
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);
+

+
        let shortcuts = match self.props.mode {
+
            Mode::Id => vec![Shortcut::new("enter", "select"), Shortcut::new("q", "quit")],
+
            Mode::Operation => vec![
+
                Shortcut::new("enter", "show"),
+
                Shortcut::new("c", "comment"),
+
                Shortcut::new("e", "edit"),
+
                Shortcut::new("d", "delete"),
+
                Shortcut::new("q", "quit"),
+
            ],
+
        };
+

+
        self.issues.render::<B>(frame, layout.component, ());
+
        self.shortcuts.render::<B>(
+
            frame,
+
            layout.shortcuts,
+
            ShortcutsProps {
+
                shortcuts,
+
                divider: '∙',
+
            },
+
        );
+
    }
+
}
+

+
struct IssuesProps {
+
    issues: Vec<IssueItem>,
+
    filter: Filter,
+
}
+

+
impl From<&IssuesState> for IssuesProps {
+
    fn from(state: &IssuesState) -> Self {
+
        Self {
+
            issues: state.issues.clone(),
+
            filter: state.filter.clone(),
+
        }
+
    }
+
}
+

+
struct Issues {
+
    /// Action sender
+
    action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: IssuesProps,
+
    /// Header
+
    header: Header<Action>,
+
    /// Notification table
+
    table: Table<Action>,
+
    /// Footer
+
    footer: Footer<Action>,
+
}
+

+
impl Widget<IssuesState, Action> for Issues {
+
    fn new(state: &IssuesState, action_tx: UnboundedSender<Action>) -> Self {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: IssuesProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
+
            table: Table::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
+
        }
+
    }
+

+
    fn move_with_state(self, state: &IssuesState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            props: IssuesProps::from(state),
+
            table: self.table.move_with_state(state),
+
            header: self.header.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "issues"
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Up => {
+
                self.table.prev();
+

+
                let selected = self
+
                    .table
+
                    .selected()
+
                    .and_then(|selected| self.props.issues.get(selected));
+

+
                // TODO: propagate error
+
                if let Some(notif) = selected {
+
                    let _ = self.action_tx.send(Action::Select {
+
                        item: notif.clone(),
+
                    });
+
                }
+
            }
+
            Key::Down => {
+
                self.table.next(self.props.issues.len());
+

+
                let selected = self
+
                    .table
+
                    .selected()
+
                    .and_then(|selected| self.props.issues.get(selected));
+

+
                // TODO: propagate error
+
                if let Some(notif) = selected {
+
                    let _ = self.action_tx.send(Action::Select {
+
                        item: notif.clone(),
+
                    });
+
                }
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
impl Render<()> for Issues {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        let cutoff = 200;
+
        let cutoff_after = 5;
+
        let focus = false;
+

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

+
        let widths = [
+
            Constraint::Length(3),
+
            Constraint::Length(8),
+
            Constraint::Fill(5),
+
            Constraint::Length(16),
+
            Constraint::Length(16),
+
            Constraint::Fill(1),
+
            Constraint::Fill(1),
+
            Constraint::Length(16),
+
        ];
+

+
        let progress = {
+
            let step = self
+
                .table
+
                .selected()
+
                .map(|selected| selected.saturating_add(1).to_string())
+
                .unwrap_or("-".to_string());
+
            let length = self.props.issues.len().to_string();
+

+
            span::badge(format!("{}/{}", step, length))
+
        };
+

+
        self.header.render::<B>(
+
            frame,
+
            layout[0],
+
            HeaderProps {
+
                cells: [
+
                    String::from(" ● ").into(),
+
                    String::from("ID").into(),
+
                    String::from("Title").into(),
+
                    String::from("Author").into(),
+
                    String::from("").into(),
+
                    String::from("Labels").into(),
+
                    String::from("Assignees ").into(),
+
                    String::from("Opened").into(),
+
                ],
+
                widths,
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.table.render::<B>(
+
            frame,
+
            layout[1],
+
            TableProps {
+
                items: self.props.issues.to_vec(),
+
                has_footer: true,
+
                has_header: true,
+
                focus,
+
                widths,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.footer.render::<B>(
+
            frame,
+
            layout[2],
+
            FooterProps {
+
                cells: [
+
                    span::badge("/".to_string()),
+
                    span::default(self.props.filter.to_string()).magenta().dim(),
+
                    String::from("").into(),
+
                    progress.clone(),
+
                ],
+
                widths: [
+
                    Constraint::Length(3),
+
                    Constraint::Fill(1),
+
                    Constraint::Fill(1),
+
                    Constraint::Length(progress.width() as u16),
+
                ],
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+
    }
+
}
added bin/commands/issue/realm.rs
@@ -0,0 +1,6 @@
+
#[path = "realm/common.rs"]
+
pub mod common;
+
#[path = "realm/select.rs"]
+
pub mod select;
+
#[path = "realm/suite.rs"]
+
pub mod suite;
added bin/commands/issue/realm/common.rs
@@ -0,0 +1,2 @@
+
#[path = "common/ui.rs"]
+
pub mod ui;
added bin/commands/issue/realm/common/ui.rs
@@ -0,0 +1,162 @@
+
use radicle::issue::{Issue, IssueId};
+

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

+
use radicle_tui as tui;
+

+
use tui::common::cob::issue::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::cob::IssueItem;
+
use tui::realm::ui::theme::{style, Theme};
+
use tui::realm::ui::widget::context::{ContextBar, Progress};
+
use tui::realm::ui::widget::label::{self};
+
use tui::realm::ui::widget::list::{ColumnWidth, Table};
+
use tui::realm::ui::widget::{Widget, WidgetComponent};
+

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

+
impl IssueBrowser {
+
    pub fn new(
+
        theme: &Theme,
+
        context: &Context,
+
        filter: Filter,
+
        selected: Option<(IssueId, Issue)>,
+
    ) -> Self {
+
        let header = [
+
            label::header(" ● "),
+
            label::header("ID"),
+
            label::header("Title"),
+
            label::header("Author"),
+
            label::header("Tags"),
+
            label::header("Assignees"),
+
            label::header("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 issues = context
+
            .issues()
+
            .as_ref()
+
            .unwrap()
+
            .iter()
+
            .filter(|(_, issue)| filter.matches(context.profile(), issue));
+

+
        let mut items = vec![];
+
        for (id, issue) in issues {
+
            if let Ok(item) = IssueItem::try_from((context.profile(), repo, *id, issue.clone())) {
+
                items.push(item);
+
            }
+
        }
+

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

+
        let selected = match selected {
+
            Some((id, issue)) => Some(IssueItem::from((context.profile(), repo, id, issue))),
+
            _ => items.first().cloned(),
+
        };
+

+
        let table = Widget::new(Table::new(&items, selected, header, widths, theme.clone()));
+

+
        Self { items, table }
+
    }
+

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

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

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

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

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

+
pub fn browse_context(
+
    context: &Context,
+
    _theme: &Theme,
+
    filter: Filter,
+
    progress: Progress,
+
) -> Widget<ContextBar> {
+
    use radicle::issue::State;
+

+
    let mut open = 0;
+
    let mut closed = 0;
+

+
    let issues = context
+
        .issues()
+
        .as_ref()
+
        .unwrap()
+
        .iter()
+
        .filter(|(_, issue)| filter.matches(context.profile(), issue));
+

+
    for (_, issue) in issues {
+
        match issue.state() {
+
            State::Open => open += 1,
+
            State::Closed { reason: _ } => closed += 1,
+
        }
+
    }
+

+
    let context = label::reversable("/").style(style::magenta_reversed());
+
    let filter = label::default(&filter.to_string()).style(style::magenta_dim());
+

+
    let open_n = label::default(&format!("{open}")).style(style::green());
+
    let open = label::default(" Open");
+

+
    let closed_n = label::default(&format!("{closed}")).style(style::cyan());
+
    let closed = label::default(" Closed ");
+

+
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
+

+
    let spacer = label::default("");
+
    let divider = label::default(" | ");
+

+
    let context_bar = ContextBar::new(
+
        label::group(&[context]),
+
        label::group(&[filter]),
+
        label::group(&[spacer.clone()]),
+
        label::group(&[
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer,
+
            open_n,
+
            open,
+
            divider,
+
            closed_n,
+
            closed,
+
        ]),
+
        label::group(&[progress]),
+
    );
+

+
    Widget::new(context_bar).height(1)
+
}
added bin/commands/issue/realm/select.rs
@@ -0,0 +1,173 @@
+
#[path = "select/event.rs"]
+
mod event;
+
#[path = "select/page.rs"]
+
mod page;
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use std::hash::Hash;
+

+
use anyhow::Result;
+
use radicle::issue::IssueId;
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::event::Key;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::issue::Filter;
+
use tui::common::context::Context;
+

+
use tui::realm::ui::subscription;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::{PageStack, Tui};
+
use tui::Exit;
+

+
use page::ListView;
+

+
use super::super::common::Mode;
+

+
type Selection = tui::Selection<IssueId>;
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    Header,
+
    PatchBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    #[default]
+
    GlobalListener,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    #[default]
+
    Tick,
+
    Quit(Option<Selection>),
+
    Batch(Vec<Message>),
+
}
+

+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    quit: bool,
+
    mode: Mode,
+
    filter: Filter,
+
    output: Option<Selection>,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
impl App {
+
    pub fn new(context: Context, mode: Mode, filter: Filter) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: Theme::default(),
+
            quit: false,
+
            mode,
+
            filter,
+
            output: None,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::new(ListView::new(self.mode.clone(), self.filter.clone()));
+
        self.pages.push(home, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = Theme::default();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Quit(output) => {
+
                self.quit = true;
+
                self.output = output;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+
}
+

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

+
        // Add global key listener and subscribe to key events
+
        let global = tui::realm::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(
+
                subscription::quit_clause(Key::Char('q')),
+
                SubClause::Always,
+
            )],
+
        )?;
+

+
        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() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit<Selection>> {
+
        if self.quit {
+
            return Some(Exit {
+
                value: self.output.clone(),
+
            });
+
        }
+
        None
+
    }
+
}
added bin/commands/issue/realm/select/event.rs
@@ -0,0 +1,194 @@
+
use radicle::issue::IssueId;
+

+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
+
use tui::realm::ui::widget::context::{ContextBar, Shortcuts};
+
use tui::realm::ui::widget::list::PropertyList;
+
use tui::realm::ui::widget::Widget;
+

+
use crate::tui_issue::common::IssueOperation;
+

+
use super::ui::{IdSelect, OperationSelect};
+
use super::Message;
+

+
type Selection = tui::Selection<IssueId>;
+

+
/// 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)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: None,
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(IssueOperation::Show.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('d'),
+
                ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(IssueOperation::Delete.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('e'),
+
                ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(IssueOperation::Edit.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('m'),
+
                ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(IssueOperation::Comment.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        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 bin/commands/issue/realm/select/page.rs
@@ -0,0 +1,177 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::issue::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::ui::widget::context::{Progress, Shortcuts};
+
use tui::realm::ui::widget::Widget;
+
use tui::realm::ui::{layout, subscription};
+
use tui::realm::ViewPage;
+

+
use super::super::common;
+
use super::{ui, Application, Cid, ListCid, Message, Mode};
+

+
///
+
/// Home
+
///
+
pub struct ListView {
+
    active_component: ListCid,
+
    subject: Mode,
+
    filter: Filter,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
}
+

+
impl ListView {
+
    pub fn new(subject: Mode, filter: Filter) -> Self {
+
        Self {
+
            active_component: ListCid::PatchBrowser,
+
            subject,
+
            filter,
+
            shortcuts: HashMap::default(),
+
        }
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
+
        let progress = match ItemState::try_from(state) {
+
            Ok(state) => Progress::Step(
+
                state
+
                    .selected()
+
                    .map(|s| s.saturating_add(1))
+
                    .unwrap_or_default(),
+
                state.len(),
+
            ),
+
            Err(_) => Progress::None,
+
        };
+

+
        let context = common::ui::browse_context(context, theme, self.filter.clone(), progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListView {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::list_navigation(theme);
+
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
+

+
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
+

+
        match self.subject {
+
            Mode::Id => {
+
                let patch_browser =
+
                    ui::id_select(theme, context, self.filter.clone(), None).to_boxed();
+
                self.shortcuts = patch_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
+
            }
+
            Mode::Operation => {
+
                let patch_browser =
+
                    ui::operation_select(theme, context, self.filter.clone(), None).to_boxed();
+
                self.shortcuts = patch_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
+
            }
+
        };
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

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

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        _message: Message,
+
    ) -> Result<Option<Message>> {
+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let context_h = app
+
            .query(&Cid::List(ListCid::Context), Attribute::Height)
+
            .unwrap_or_default()
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = 1u16;
+

+
        let layout = layout::default_page(area, context_h, shortcuts_h);
+

+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
+
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

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

+
        Ok(())
+
    }
+

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

+
        Ok(())
+
    }
+
}
added bin/commands/issue/realm/select/ui.rs
@@ -0,0 +1,157 @@
+
use std::collections::HashMap;
+

+
use radicle::issue::{Issue, IssueId};
+

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

+
use radicle_tui as tui;
+

+
use tui::common::cob::issue::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::cob::IssueItem;
+
use tui::realm::ui::theme::{style, Theme};
+
use tui::realm::ui::widget::container::Tabs;
+
use tui::realm::ui::widget::context::Shortcuts;
+
use tui::realm::ui::widget::label::{self};
+
use tui::realm::ui::widget::{Widget, WidgetComponent};
+

+
use super::super::common;
+
use super::ListCid;
+

+
pub struct IdSelect {
+
    theme: Theme,
+
    browser: Widget<common::ui::IssueBrowser>,
+
}
+

+
impl IdSelect {
+
    pub fn new(theme: Theme, browser: Widget<common::ui::IssueBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

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

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            tui::realm::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::realm::ui::shortcut(&self.theme, "enter", "select"),
+
                    tui::realm::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

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

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

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

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

+
pub struct OperationSelect {
+
    theme: Theme,
+
    browser: Widget<common::ui::IssueBrowser>,
+
}
+

+
impl OperationSelect {
+
    pub fn new(theme: Theme, browser: Widget<common::ui::IssueBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

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

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            tui::realm::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::realm::ui::shortcut(&self.theme, "enter", "show"),
+
                    tui::realm::ui::shortcut(&self.theme, "m", "comment"),
+
                    tui::realm::ui::shortcut(&self.theme, "e", "edit"),
+
                    tui::realm::ui::shortcut(&self.theme, "d", "delete"),
+
                    tui::realm::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

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

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

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

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

+
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
+
    tui::realm::ui::tabs(
+
        theme,
+
        vec![label::reversable("Patches").style(style::cyan())],
+
    )
+
}
+

+
pub fn id_select(
+
    theme: &Theme,
+
    context: &Context,
+
    filter: Filter,
+
    selected: Option<(IssueId, Issue)>,
+
) -> Widget<IdSelect> {
+
    let browser = Widget::new(common::ui::IssueBrowser::new(
+
        theme, context, filter, selected,
+
    ));
+

+
    Widget::new(IdSelect::new(theme.clone(), browser))
+
}
+

+
pub fn operation_select(
+
    theme: &Theme,
+
    context: &Context,
+
    filter: Filter,
+
    selected: Option<(IssueId, Issue)>,
+
) -> Widget<OperationSelect> {
+
    let browser = Widget::new(common::ui::IssueBrowser::new(
+
        theme, context, filter, selected,
+
    ));
+

+
    Widget::new(OperationSelect::new(theme.clone(), browser))
+
}
added bin/commands/issue/realm/suite.rs
@@ -0,0 +1,354 @@
+
#[path = "suite/event.rs"]
+
mod event;
+
#[path = "suite/page.rs"]
+
mod page;
+
#[path = "suite/ui.rs"]
+
mod ui;
+

+
use anyhow::Result;
+

+
use radicle::cob::issue::IssueId;
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::event::Key;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob;
+
use tui::common::context::Context;
+
use tui::realm::ui::subscription;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::{PageStack, Tui};
+
use tui::Exit;
+

+
use page::{IssuePage, ListPage};
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    Header,
+
    IssueBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

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

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    Issue(IssueCid),
+
    #[default]
+
    GlobalListener,
+
    Popup,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum IssueCobMessage {
+
    Create {
+
        title: String,
+
        tags: String,
+
        assignees: String,
+
        description: String,
+
    },
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum IssueMessage {
+
    Show(Option<IssueId>),
+
    Changed(IssueId),
+
    Focus(IssueCid),
+
    Created(IssueId),
+
    Cob(IssueCobMessage),
+
    Reload(Option<IssueId>),
+
    OpenForm,
+
    HideForm,
+
    Leave(Option<IssueId>),
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum PopupMessage {
+
    Info(String),
+
    Warning(String),
+
    Error(String),
+
    Hide,
+
}
+

+
#[derive(Clone, Debug, Default, Eq, PartialEq)]
+
pub enum Message {
+
    Issue(IssueMessage),
+
    NavigationChanged(u16),
+
    FormSubmitted(String),
+
    Popup(PopupMessage),
+
    #[default]
+
    Tick,
+
    Quit,
+
    Batch(Vec<Message>),
+
}
+

+
#[allow(dead_code)]
+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    quit: bool,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
#[allow(dead_code)]
+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: Theme::default(),
+
            quit: false,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let list = Box::new(ListPage::new(theme.clone()));
+
        self.pages.push(list, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn view_issue(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: Option<IssueId>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+
        match id {
+
            Some(id) => {
+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
+
                    self.pages.push(view, app, &self.context, theme)?;
+

+
                    Ok(())
+
                } else {
+
                    Err(anyhow::anyhow!(
+
                        "Could not mount 'page::IssueView'. Issue not found."
+
                    ))
+
                }
+
            }
+
            None => {
+
                let view = Box::new(IssuePage::new(&self.context, theme, None));
+
                self.pages.push(view, app, &self.context, theme)?;
+

+
                Ok(())
+
            }
+
        }
+
    }
+

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = Theme::default();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Issue(IssueMessage::Cob(IssueCobMessage::Create {
+
                title,
+
                tags,
+
                assignees,
+
                description,
+
            })) => match self.create_issue(title, description, tags, assignees) {
+
                Ok(id) => {
+
                    self.context.reload_issues();
+

+
                    Ok(Some(Message::Batch(vec![
+
                        Message::Issue(IssueMessage::HideForm),
+
                        Message::Issue(IssueMessage::Created(id)),
+
                    ])))
+
                }
+
                Err(err) => {
+
                    let error = format!("{:?}", err);
+
                    self.show_error_popup(app, &theme, &error)?;
+

+
                    Ok(None)
+
                }
+
            },
+
            Message::Issue(IssueMessage::Show(id)) => {
+
                self.view_issue(app, id, &theme)?;
+
                Ok(None)
+
            }
+
            Message::Issue(IssueMessage::Leave(id)) => {
+
                self.pages.pop(app)?;
+
                Ok(Some(Message::Issue(IssueMessage::Reload(id))))
+
            }
+
            Message::Popup(PopupMessage::Info(info)) => {
+
                self.show_info_popup(app, &theme, &info)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Warning(warning)) => {
+
                self.show_warning_popup(app, &theme, &warning)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Error(error)) => {
+
                self.show_error_popup(app, &theme, &error)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Hide) => {
+
                self.hide_popup(app)?;
+
                Ok(None)
+
            }
+
            Message::Quit => {
+
                self.quit = true;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+

+
    fn show_info_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::realm::ui::info(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn show_warning_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::realm::ui::warning(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn show_error_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::realm::ui::error(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn hide_popup(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.blur()?;
+
        app.umount(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn create_issue(
+
        &mut self,
+
        title: String,
+
        description: String,
+
        labels: String,
+
        assignees: String,
+
    ) -> Result<IssueId> {
+
        let repository = self.context.repository();
+
        let signer = self.context.signer().as_ref().unwrap();
+

+
        let labels = cob::parse_labels(labels)?;
+
        let assignees = cob::parse_assignees(assignees)?;
+

+
        cob::issue::create(
+
            repository,
+
            signer,
+
            title,
+
            description,
+
            labels.as_slice(),
+
            assignees.as_slice(),
+
        )
+
    }
+
}
+

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

+
        // Add global key listener and subscribe to key events
+
        let global = tui::realm::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(
+
                subscription::quit_clause(Key::Char('q')),
+
                SubClause::Always,
+
            )],
+
        )?;
+

+
        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);
+
        }
+

+
        if app.mounted(&Cid::Popup) {
+
            app.view(&Cid::Popup, frame, frame.size());
+
        }
+
    }
+

+
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
+
        match app.tick(PollStrategy::Once) {
+
            Ok(messages) if !messages.is_empty() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit<()>> {
+
        if self.quit {
+
            return Some(Exit { value: None });
+
        }
+
        None
+
    }
+
}
added bin/commands/issue/realm/suite/event.rs
@@ -0,0 +1,322 @@
+
use radicle::cob::issue::IssueId;
+

+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection, Position};
+
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
+
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
+

+
use radicle_tui as tui;
+

+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer, Popup};
+
use tui::realm::ui::widget::context::{ContextBar, Shortcuts};
+
use tui::realm::ui::widget::form::Form;
+
use tui::realm::ui::widget::list::PropertyList;
+
use tui::realm::ui::widget::Widget;
+

+
use super::ui;
+
use super::{IssueCid, IssueMessage, Message, PopupMessage};
+

+
/// 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<ui::LargeList> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                let selected = ItemState::try_from(self.state()).ok()?.selected()?;
+
                let item = self.items().get(selected)?;
+

+
                Some(Message::Issue(IssueMessage::Leave(Some(
+
                    item.id().to_owned(),
+
                ))))
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => match self.perform(Cmd::Move(MoveDirection::Up)) {
+
                CmdResult::Changed(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+

+
                    Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
+
                }
+
                _ => None,
+
            },
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => match self.perform(Cmd::Move(MoveDirection::Down)) {
+
                CmdResult::Changed(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
+
                }
+
                _ => None,
+
            },
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => Some(Message::Issue(IssueMessage::Focus(IssueCid::Details))),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('o'),
+
                ..
+
            }) => Some(Message::Issue(IssueMessage::OpenForm)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueDetails> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Scroll(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Scroll(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Issue(IssueMessage::Focus(IssueCid::List)))
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Form> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Left, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Left));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Right, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Right));
+
                Some(Message::Tick)
+
            }
+
            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::Home, ..
+
            }) => {
+
                self.perform(Cmd::GoTo(Position::Begin));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
+
                self.perform(Cmd::GoTo(Position::End));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Delete, ..
+
            }) => {
+
                self.perform(Cmd::Cancel);
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Backspace,
+
                ..
+
            }) => {
+
                self.perform(Cmd::Delete);
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => {
+
                self.perform(Cmd::Custom(Form::CMD_NEWLINE));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('s'),
+
                modifiers: KeyModifiers::CONTROL,
+
            }) => {
+
                self.perform(Cmd::Submit);
+
                self.query(tuirealm::Attribute::Custom(Form::PROP_ID))
+
                    .map(|cid| Message::FormSubmitted(cid.unwrap_string()))
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Issue(IssueMessage::HideForm))
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::BackTab, ..
+
            }) => {
+
                self.perform(Cmd::Custom(Form::CMD_FOCUS_PREVIOUS));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
+
                self.perform(Cmd::Custom(Form::CMD_FOCUS_NEXT));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char(ch),
+
                modifiers: KeyModifiers::SHIFT,
+
            }) => {
+
                self.perform(Cmd::Type(ch.to_ascii_uppercase()));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('v'),
+
                modifiers: KeyModifiers::CONTROL,
+
            }) => {
+
                self.perform(Cmd::Custom(Form::CMD_PASTE));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char(ch),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Type(ch));
+
                Some(Message::Tick)
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueBrowser> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<IssueId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('o'),
+
                ..
+
            }) => {
+
                let id = submit();
+
                Some(Message::Batch(vec![
+
                    Message::Issue(IssueMessage::Show(id)),
+
                    Message::Issue(IssueMessage::OpenForm),
+
                ]))
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => {
+
                let id = submit();
+
                if id.is_some() {
+
                    Some(Message::Issue(IssueMessage::Show(id)))
+
                } else {
+
                    None
+
                }
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Popup> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Popup(PopupMessage::Hide))
+
            }
+
            _ => 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 bin/commands/issue/realm/suite/page.rs
@@ -0,0 +1,569 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use radicle::cob::issue::{Issue, IssueId};
+

+
use tuirealm::event::Key;
+
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, State, StateValue, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob;
+
use tui::common::context::Context;
+
use tui::realm::ui::layout;
+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::ui::widget::context::{Progress, Shortcuts};
+
use tui::realm::ui::widget::Widget;
+
use tui::realm::ViewPage;
+

+
use super::{
+
    Application, Cid, IssueCid, IssueCobMessage, IssueMessage, ListCid, Message, PopupMessage,
+
};
+

+
use super::subscription;
+
use super::ui;
+

+
///
+
/// Home
+
///
+
pub struct ListPage {
+
    active_component: ListCid,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
}
+

+
impl ListPage {
+
    pub fn new(theme: Theme) -> Self {
+
        let shortcuts = Self::build_shortcuts(&theme);
+
        Self {
+
            active_component: ListCid::IssueBrowser,
+
            shortcuts,
+
        }
+
    }
+

+
    fn activate(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        self.active_component = cid;
+
        let cid = Cid::List(self.active_component.clone());
+
        app.active(&cid)?;
+
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
+

+
        Ok(())
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::IssueBrowser,
+
            tui::realm::ui::shortcuts(
+
                theme,
+
                vec![
+
                    tui::realm::ui::shortcut(theme, "tab", "section"),
+
                    tui::realm::ui::shortcut(theme, "↑/↓", "navigate"),
+
                    tui::realm::ui::shortcut(theme, "enter", "show"),
+
                    tui::realm::ui::shortcut(theme, "o", "open"),
+
                    tui::realm::ui::shortcut(theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::IssueBrowser))?;
+
        let progress = match ItemState::try_from(state) {
+
            Ok(state) => Progress::Step(
+
                state
+
                    .selected()
+
                    .map(|s| s.saturating_add(1))
+
                    .unwrap_or_default(),
+
                state.len(),
+
            ),
+
            Err(_) => Progress::None,
+
        };
+
        let context = ui::browse_context(context, theme, progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListPage {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::list_navigation(theme);
+
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
+
        let issue_browser = ui::issues(context, theme, None).to_boxed();
+

+
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
+
        app.remount(Cid::List(ListCid::IssueBrowser), issue_browser, vec![])?;
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

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

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        if let Message::Issue(IssueMessage::Reload(id)) = message {
+
            let selected = match id {
+
                Some(id) => cob::issue::find(context.repository(), &id)?.map(|issue| (id, issue)),
+
                _ => None,
+
            };
+

+
            let issue_browser = ui::issues(context, theme, selected).to_boxed();
+
            app.remount(Cid::List(ListCid::IssueBrowser), issue_browser, vec![])?;
+

+
            self.activate(app, ListCid::IssueBrowser)?;
+
        }
+

+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let context_h = app
+
            .query(&Cid::List(ListCid::Context), Attribute::Height)
+
            .unwrap_or_default()
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = 1u16;
+

+
        let layout = layout::full_page(area, context_h, shortcuts_h);
+

+
        app.view(&Cid::List(ListCid::Header), frame, layout.navigation);
+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

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

+
        Ok(())
+
    }
+

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

+
        Ok(())
+
    }
+
}
+

+
///
+
/// Issue detail page
+
///
+
pub struct IssuePage {
+
    issue: Option<(IssueId, Issue)>,
+
    active_component: IssueCid,
+
    shortcuts: HashMap<IssueCid, Widget<Shortcuts>>,
+
}
+

+
impl IssuePage {
+
    pub fn new(_context: &Context, theme: &Theme, issue: Option<(IssueId, Issue)>) -> Self {
+
        let shortcuts = Self::build_shortcuts(theme);
+
        let active_component = IssueCid::List;
+

+
        Self {
+
            issue,
+
            active_component,
+
            shortcuts,
+
        }
+
    }
+

+
    fn activate(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: IssueCid,
+
    ) -> Result<()> {
+
        self.active_component = cid;
+
        let cid = Cid::Issue(self.active_component.clone());
+
        app.active(&cid)?;
+
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
+

+
        Ok(())
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<IssueCid, Widget<Shortcuts>> {
+
        [
+
            (
+
                IssueCid::List,
+
                tui::realm::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::realm::ui::shortcut(theme, "esc", "back"),
+
                        tui::realm::ui::shortcut(theme, "↑/↓", "navigate"),
+
                        tui::realm::ui::shortcut(theme, "enter", "show"),
+
                        tui::realm::ui::shortcut(theme, "o", "open"),
+
                        tui::realm::ui::shortcut(theme, "q", "quit"),
+
                    ],
+
                ),
+
            ),
+
            (
+
                IssueCid::Details,
+
                tui::realm::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::realm::ui::shortcut(theme, "esc", "back"),
+
                        tui::realm::ui::shortcut(theme, "↑/↓", "scroll"),
+
                        tui::realm::ui::shortcut(theme, "q", "quit"),
+
                    ],
+
                ),
+
            ),
+
            (
+
                IssueCid::Form,
+
                tui::realm::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::realm::ui::shortcut(theme, "esc", "back"),
+
                        tui::realm::ui::shortcut(theme, "shift + tab / tab", "navigate"),
+
                        tui::realm::ui::shortcut(theme, "ctrl + s", "submit"),
+
                    ],
+
                ),
+
            ),
+
        ]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        cid: IssueCid,
+
    ) -> Result<()> {
+
        let context = match cid {
+
            IssueCid::List => {
+
                let state = app.state(&Cid::Issue(IssueCid::List))?;
+
                let progress = match state {
+
                    State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => {
+
                        Progress::Step(step.saturating_add(1), total)
+
                    }
+
                    _ => Progress::None,
+
                };
+
                let context = ui::browse_context(context, theme, progress);
+
                Some(context)
+
            }
+
            IssueCid::Details => {
+
                let state = app.state(&Cid::Issue(IssueCid::Details))?;
+
                let progress = match state {
+
                    State::One(StateValue::Usize(scroll)) => Progress::Percentage(scroll),
+
                    _ => Progress::None,
+
                };
+
                let context = ui::description_context(context, theme, progress);
+
                Some(context)
+
            }
+
            IssueCid::Form => {
+
                let context = ui::form_context(context, theme, Progress::None);
+
                Some(context)
+
            }
+
            _ => None,
+
        };
+

+
        if let Some(context) = context {
+
            app.remount(Cid::Issue(IssueCid::Context), context.to_boxed(), vec![])?;
+
        }
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: IssueCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::Issue(IssueCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for IssuePage {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::list_navigation(theme);
+
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
+
        let list = ui::list(context, theme, self.issue.clone()).to_boxed();
+

+
        app.remount(Cid::Issue(IssueCid::Header), header, vec![])?;
+
        app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+

+
        if let Some((id, issue)) = &self.issue {
+
            let comments = issue.comments().collect::<Vec<_>>();
+
            let details = ui::details(
+
                context,
+
                theme,
+
                (*id, issue.clone()),
+
                comments.first().copied(),
+
            )
+
            .to_boxed();
+
            app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
        }
+

+
        app.active(&Cid::Issue(self.active_component.clone()))?;
+

+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme, self.active_component.clone())?;
+

+
        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::Context))?;
+
        app.umount(&Cid::Issue(IssueCid::Shortcuts))?;
+

+
        if app.mounted(&Cid::Issue(IssueCid::Details)) {
+
            app.umount(&Cid::Issue(IssueCid::Details))?;
+
        }
+

+
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
+
            app.umount(&Cid::Issue(IssueCid::Form))?;
+
        }
+

+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        match message {
+
            Message::Issue(IssueMessage::Created(id)) => {
+
                let repo = context.repository();
+

+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    self.issue = Some((id, issue.clone()));
+
                    let list = ui::list(context, theme, self.issue.clone()).to_boxed();
+
                    let comments = issue.comments().collect::<Vec<_>>();
+

+
                    let details = ui::details(
+
                        context,
+
                        theme,
+
                        (id, issue.clone()),
+
                        comments.first().copied(),
+
                    )
+
                    .to_boxed();
+

+
                    app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
                }
+
            }
+
            Message::Issue(IssueMessage::Changed(id)) => {
+
                let repo = context.repository();
+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    self.issue = Some((id, issue.clone()));
+
                    let comments = issue.comments().collect::<Vec<_>>();
+
                    let details = ui::details(
+
                        context,
+
                        theme,
+
                        (id, issue.clone()),
+
                        comments.first().copied(),
+
                    )
+
                    .to_boxed();
+
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
                }
+
            }
+
            Message::Issue(IssueMessage::Focus(cid)) => {
+
                self.activate(app, cid)?;
+
                self.update_shortcuts(app, self.active_component.clone())?;
+
            }
+
            Message::Issue(IssueMessage::OpenForm) => {
+
                let new_form = ui::new_form(context, theme).to_boxed();
+
                let list = ui::list(context, theme, None).to_boxed();
+

+
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+
                app.remount(Cid::Issue(IssueCid::Form), new_form, vec![])?;
+
                app.active(&Cid::Issue(IssueCid::Form))?;
+

+
                app.unsubscribe(
+
                    &Cid::GlobalListener,
+
                    subscription::quit_clause(Key::Char('q')),
+
                )?;
+

+
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::Form))));
+
            }
+
            Message::Issue(IssueMessage::HideForm) => {
+
                app.umount(&Cid::Issue(IssueCid::Form))?;
+

+
                let list = ui::list(context, theme, self.issue.clone()).to_boxed();
+
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+

+
                app.subscribe(
+
                    &Cid::GlobalListener,
+
                    Sub::new(subscription::quit_clause(Key::Char('q')), SubClause::Always),
+
                )?;
+

+
                if self.issue.is_none() {
+
                    return Ok(Some(Message::Issue(IssueMessage::Leave(None))));
+
                }
+
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::List))));
+
            }
+
            Message::FormSubmitted(id) => {
+
                if id == ui::FORM_ID_EDIT {
+
                    let state = app.state(&Cid::Issue(IssueCid::Form))?;
+
                    if let State::Linked(mut states) = state {
+
                        let mut missing_values = vec![];
+

+
                        let title = match states.front() {
+
                            Some(State::One(StateValue::String(title))) if !title.is_empty() => {
+
                                Some(title.clone())
+
                            }
+
                            _ => None,
+
                        };
+
                        states.pop_front();
+

+
                        let tags = match states.front() {
+
                            Some(State::One(StateValue::String(tags))) => Some(tags.clone()),
+
                            _ => Some(String::from("[]")),
+
                        };
+
                        states.pop_front();
+

+
                        let assignees = match states.front() {
+
                            Some(State::One(StateValue::String(assignees))) => {
+
                                Some(assignees.clone())
+
                            }
+
                            _ => Some(String::from("[]")),
+
                        };
+
                        states.pop_front();
+

+
                        let description = match states.front() {
+
                            Some(State::One(StateValue::String(description)))
+
                                if !description.is_empty() =>
+
                            {
+
                                Some(description.clone())
+
                            }
+
                            _ => None,
+
                        };
+
                        states.pop_front();
+

+
                        if title.is_none() {
+
                            missing_values.push("title");
+
                        }
+
                        if description.is_none() {
+
                            missing_values.push("description");
+
                        }
+

+
                        // show error popup if missing.
+
                        if !missing_values.is_empty() {
+
                            let error = format!("Missing fields: {:?}", missing_values);
+
                            return Ok(Some(Message::Popup(PopupMessage::Error(error))));
+
                        } else {
+
                            return Ok(Some(Message::Issue(IssueMessage::Cob(
+
                                IssueCobMessage::Create {
+
                                    title: title.unwrap(),
+
                                    tags: tags.unwrap(),
+
                                    assignees: assignees.unwrap(),
+
                                    description: description.unwrap(),
+
                                },
+
                            ))));
+
                        }
+
                    }
+
                }
+
            }
+
            _ => {}
+
        }
+

+
        self.update_context(app, context, theme, self.active_component.clone())?;
+

+
        Ok(None)
+
    }
+

+
    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_page(area, shortcuts_h);
+

+
        app.view(&Cid::Issue(IssueCid::Header), frame, layout.header);
+
        app.view(&Cid::Issue(IssueCid::List), frame, layout.left);
+

+
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
+
            app.view(&Cid::Issue(IssueCid::Form), frame, layout.right);
+
        } else if app.mounted(&Cid::Issue(IssueCid::Details)) {
+
            app.view(&Cid::Issue(IssueCid::Details), frame, layout.right);
+
        }
+

+
        app.view(&Cid::Issue(IssueCid::Context), frame, layout.context);
+
        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(())
+
    }
+
}
added bin/commands/issue/realm/suite/ui.rs
@@ -0,0 +1,435 @@
+
use radicle::node::AliasStore;
+

+
use radicle::cob::thread::Comment;
+
use radicle::cob::thread::CommentId;
+

+
use radicle::cob::issue::Issue;
+
use radicle::cob::issue::IssueId;
+

+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
+

+
use radicle_tui as tui;
+

+
use tui::common::context::Context;
+
use tui::realm::ui::cob;
+
use tui::realm::ui::cob::IssueItem;
+
use tui::realm::ui::theme::style;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::ui::widget::container::{Container, Tabs};
+
use tui::realm::ui::widget::context::{ContextBar, Progress};
+
use tui::realm::ui::widget::form::{Form, TextArea, TextField};
+
use tui::realm::ui::widget::label::{self, Textarea};
+
use tui::realm::ui::widget::list::{ColumnWidth, List, Property, Table};
+
use tui::realm::ui::widget::{Widget, WidgetComponent};
+

+
pub const FORM_ID_EDIT: &str = "edit-form";
+

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

+
impl IssueBrowser {
+
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
+
        let header = [
+
            label::header(" ● "),
+
            label::header("ID"),
+
            label::header("Title"),
+
            label::header("Author"),
+
            label::header("Tags"),
+
            label::header("Assignees"),
+
            label::header("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![];
+

+
        let issues = context.issues().as_ref().unwrap();
+
        for (id, issue) in issues {
+
            if let Ok(item) = IssueItem::try_from((context.profile(), repo, *id, issue.clone())) {
+
                items.push(item);
+
            }
+
        }
+

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

+
        let selected = match selected {
+
            Some((id, issue)) => Some(IssueItem::from((context.profile(), repo, id, issue))),
+
            _ => items.first().cloned(),
+
        };
+

+
        let table = Widget::new(Table::new(&items, selected, header, widths, theme.clone()));
+

+
        Self { items, table }
+
    }
+

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

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

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

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

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

+
pub struct LargeList {
+
    items: Vec<IssueItem>,
+
    list: Widget<Container>,
+
}
+

+
impl LargeList {
+
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
+
        let repo = context.repository();
+

+
        let issues = context.issues().as_ref().unwrap();
+
        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()));
+

+
        let container = tui::realm::ui::container(theme, 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 {
+
        self.list.state()
+
    }
+

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

+
pub struct IssueHeader {
+
    container: Widget<Container>,
+
}
+

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

+
        let (id, issue) = issue;
+
        let author = issue.author();
+
        let author = author.id();
+
        let alias = context.profile().aliases().alias(author);
+
        let by_you = *author == context.profile().did();
+
        let item = IssueItem::from((context.profile(), repo, id, issue.clone()));
+

+
        let title = Property::new(label::property("Title"), label::default(item.title()));
+

+
        let author = match alias {
+
            Some(_) => label::alias(&cob::format_author(issue.author().id(), &alias, by_you)),
+
            None => label::did(&cob::format_author(issue.author().id(), &alias, by_you)),
+
        };
+
        let author = Property::new(label::property("Author"), author);
+

+
        let issue_id = Property::new(
+
            label::property("Issue"),
+
            label::default(&id.to_string()).style(style::gray()),
+
        );
+

+
        let labels = Property::new(
+
            label::property("Labels"),
+
            label::labels(&cob::format_labels(item.labels())),
+
        );
+

+
        let assignees = Property::new(
+
            label::property("Assignees"),
+
            label::did(&cob::format_assignees(
+
                &item
+
                    .assignees()
+
                    .iter()
+
                    .map(|item| (item.did(), item.alias(), item.is_you()))
+
                    .collect::<Vec<_>>(),
+
            )),
+
        );
+

+
        let state = Property::new(
+
            label::property("Status"),
+
            label::default(&item.state().to_string()),
+
        );
+

+
        let table = tui::realm::ui::property_table(
+
            theme,
+
            vec![
+
                Widget::new(title),
+
                Widget::new(issue_id),
+
                Widget::new(author),
+
                Widget::new(labels),
+
                Widget::new(assignees),
+
                Widget::new(state),
+
            ],
+
        );
+
        let container = tui::realm::ui::container(theme, table.to_boxed());
+

+
        Self { container }
+
    }
+
}
+

+
impl WidgetComponent for IssueHeader {
+
    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 struct IssueDetails {
+
    header: Widget<IssueHeader>,
+
    description: Widget<CommentBody>,
+
}
+

+
impl IssueDetails {
+
    pub fn new(
+
        context: &Context,
+
        theme: &Theme,
+
        issue: (IssueId, Issue),
+
        description: Option<(&CommentId, &Comment)>,
+
    ) -> Self {
+
        Self {
+
            header: header(context, theme, issue),
+
            description: self::description(context, theme, description),
+
        }
+
    }
+
}
+

+
impl WidgetComponent for IssueDetails {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints([Constraint::Length(8), Constraint::Min(1)])
+
            .split(area);
+

+
        self.header.view(frame, layout[0]);
+

+
        self.description
+
            .attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.description.view(frame, layout[1]);
+
    }
+

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

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

+
pub struct CommentBody {
+
    textarea: Widget<Container>,
+
}
+

+
impl CommentBody {
+
    pub fn new(_context: &Context, theme: &Theme, comment: Option<(&CommentId, &Comment)>) -> Self {
+
        let content = match comment {
+
            Some((_, comment)) => comment.body().to_string(),
+
            None => String::new(),
+
        };
+
        let textarea = Widget::new(Textarea::default())
+
            .content(AttrValue::String(content))
+
            .style(style::reset());
+

+
        let textarea = tui::realm::ui::container(theme, textarea.to_boxed());
+

+
        Self { textarea }
+
    }
+
}
+

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

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

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

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

+
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
+
    tui::realm::ui::tabs(
+
        theme,
+
        vec![label::reversable("Issues").style(style::magenta())],
+
    )
+
}
+

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

+
    Widget::new(list)
+
}
+

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

+
pub fn description(
+
    context: &Context,
+
    theme: &Theme,
+
    comment: Option<(&CommentId, &Comment)>,
+
) -> Widget<CommentBody> {
+
    let body = CommentBody::new(context, theme, comment);
+
    Widget::new(body)
+
}
+

+
pub fn new_form(_context: &Context, theme: &Theme) -> Widget<Form> {
+
    use tuirealm::props::Layout;
+

+
    let title = Widget::new(TextField::new(theme.clone(), "Title")).to_boxed();
+
    let tags = Widget::new(TextField::new(theme.clone(), "Labels (bug, ...)")).to_boxed();
+
    let assignees = Widget::new(TextField::new(
+
        theme.clone(),
+
        "Assignees (z6MkvAdxCp1oLVVTsqYvev9YrhSN3gBQNUSM45hhy4pgkexk, ...)",
+
    ))
+
    .to_boxed();
+
    let description = Widget::new(TextArea::new(theme.clone(), "Description")).to_boxed();
+
    let inputs: Vec<Box<dyn MockComponent>> = vec![title, tags, assignees, description];
+

+
    let layout = Layout::default().constraints(
+
        [
+
            Constraint::Length(3),
+
            Constraint::Length(3),
+
            Constraint::Length(3),
+
            Constraint::Min(3),
+
        ]
+
        .as_ref(),
+
    );
+

+
    Widget::new(Form::new(theme.clone(), inputs))
+
        .custom(Form::PROP_ID, AttrValue::String(String::from(FORM_ID_EDIT)))
+
        .layout(layout)
+
}
+

+
pub fn details(
+
    context: &Context,
+
    theme: &Theme,
+
    issue: (IssueId, Issue),
+
    comment: Option<(&CommentId, &Comment)>,
+
) -> Widget<IssueDetails> {
+
    let discussion = IssueDetails::new(context, theme, issue, comment);
+
    Widget::new(discussion)
+
}
+

+
pub fn browse_context(context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
+
    use radicle::cob::issue::State;
+

+
    let issues = context.issues().as_ref().unwrap();
+
    let open = issues
+
        .iter()
+
        .filter(|issue| *issue.1.state() == State::Open)
+
        .collect::<Vec<_>>()
+
        .len();
+
    let closed = issues
+
        .iter()
+
        .filter(|issue| *issue.1.state() != State::Open)
+
        .collect::<Vec<_>>()
+
        .len();
+

+
    tui::realm::ui::widget::context::bar(
+
        theme,
+
        "Browse",
+
        "",
+
        "",
+
        &format!("{open} open | {closed} closed"),
+
        &progress.to_string(),
+
    )
+
}
+

+
pub fn description_context(
+
    _context: &Context,
+
    theme: &Theme,
+
    progress: Progress,
+
) -> Widget<ContextBar> {
+
    tui::realm::ui::widget::context::bar(theme, "Show", "", "", "", &progress.to_string())
+
}
+

+
pub fn form_context(_context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
+
    tui::realm::ui::widget::context::bar(theme, "Open", "", "", "", &progress.to_string())
+
        .custom(ContextBar::PROP_EDIT_MODE, AttrValue::Flag(true))
+
}
+

+
pub fn issues(
+
    context: &Context,
+
    theme: &Theme,
+
    selected: Option<(IssueId, Issue)>,
+
) -> Widget<IssueBrowser> {
+
    Widget::new(IssueBrowser::new(context, theme, selected))
+
}
deleted bin/commands/issue/select.rs
@@ -1,235 +0,0 @@
-
#[path = "select/event.rs"]
-
mod event;
-
#[path = "select/page.rs"]
-
mod page;
-
#[path = "select/ui.rs"]
-
mod ui;
-

-
use std::fmt::Display;
-
use std::hash::Hash;
-

-
use anyhow::Result;
-
use serde::{Serialize, Serializer};
-

-
use tuirealm::application::PollStrategy;
-
use tuirealm::event::Key;
-
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob::issue::Filter;
-
use tui::context::Context;
-

-
use tui::ui::subscription;
-
use tui::ui::theme::Theme;
-
use tui::{Exit, PageStack, SelectionExit, Tui};
-

-
use page::ListView;
-

-
/// Wrapper around radicle's `PatchId` that serializes
-
/// to a human-readable string.
-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub struct PatchId(radicle::cob::patch::PatchId);
-

-
impl From<radicle::cob::patch::PatchId> for PatchId {
-
    fn from(value: radicle::cob::patch::PatchId) -> Self {
-
        PatchId(value)
-
    }
-
}
-

-
impl Display for PatchId {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        write!(f, "{}", self.0)
-
    }
-
}
-

-
impl Serialize for PatchId {
-
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-
    where
-
        S: Serializer,
-
    {
-
        serializer.serialize_str(&format!("{}", *self.0))
-
    }
-
}
-

-
/// The application's subject. It tells the application
-
/// which widgets to render and which output to produce.
-
///
-
/// Depends on CLI arguments given by the user.
-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Mode {
-
    #[default]
-
    Operation,
-
    Id,
-
}
-

-
/// The selected issue operation returned by the operation
-
/// selection widget.
-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub enum IssueOperation {
-
    Show,
-
    Delete,
-
    Edit,
-
    Comment,
-
}
-

-
impl Display for IssueOperation {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            IssueOperation::Show => {
-
                write!(f, "show")
-
            }
-
            IssueOperation::Delete => {
-
                write!(f, "delete")
-
            }
-
            IssueOperation::Edit => {
-
                write!(f, "edit")
-
            }
-
            IssueOperation::Comment => {
-
                write!(f, "comment")
-
            }
-
        }
-
    }
-
}
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum ListCid {
-
    Header,
-
    PatchBrowser,
-
    Context,
-
    Shortcuts,
-
}
-

-
/// All component ids known to this application.
-
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
-
pub enum Cid {
-
    List(ListCid),
-
    #[default]
-
    GlobalListener,
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Message {
-
    #[default]
-
    Tick,
-
    Quit(Option<SelectionExit>),
-
    Batch(Vec<Message>),
-
}
-

-
pub struct App {
-
    context: Context,
-
    pages: PageStack<Cid, Message>,
-
    theme: Theme,
-
    quit: bool,
-
    mode: Mode,
-
    filter: Filter,
-
    output: Option<SelectionExit>,
-
}
-

-
/// Creates a new application using a tui-realm-application, mounts all
-
/// components and sets focus to a default one.
-
impl App {
-
    pub fn new(context: Context, mode: Mode, filter: Filter) -> Self {
-
        Self {
-
            context,
-
            pages: PageStack::default(),
-
            theme: Theme::default(),
-
            quit: false,
-
            mode,
-
            filter,
-
            output: None,
-
        }
-
    }
-

-
    fn view_list(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let home = Box::new(ListView::new(self.mode.clone(), self.filter.clone()));
-
        self.pages.push(home, app, &self.context, theme)?;
-

-
        Ok(())
-
    }
-

-
    fn process(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        let theme = Theme::default();
-
        match message {
-
            Message::Batch(messages) => {
-
                let mut results = vec![];
-
                for message in messages {
-
                    if let Some(result) = self.process(app, message)? {
-
                        results.push(result);
-
                    }
-
                }
-
                match results.len() {
-
                    0 => Ok(None),
-
                    1 => Ok(Some(results[0].to_owned())),
-
                    _ => Ok(Some(Message::Batch(results))),
-
                }
-
            }
-
            Message::Quit(output) => {
-
                self.quit = true;
-
                self.output = output;
-
                Ok(None)
-
            }
-
            _ => self
-
                .pages
-
                .peek_mut()?
-
                .update(app, &self.context, &theme, message),
-
        }
-
    }
-
}
-

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

-
        // Add global key listener and subscribe to key events
-
        let global = tui::ui::global_listener().to_boxed();
-
        app.mount(
-
            Cid::GlobalListener,
-
            global,
-
            vec![Sub::new(
-
                subscription::quit_clause(Key::Char('q')),
-
                SubClause::Always,
-
            )],
-
        )?;
-

-
        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() => {
-
                for message in messages {
-
                    let mut msg = Some(message);
-
                    while msg.is_some() {
-
                        msg = self.process(app, msg.unwrap())?;
-
                    }
-
                }
-
                Ok(true)
-
            }
-
            _ => Ok(false),
-
        }
-
    }
-

-
    fn exit(&self) -> Option<Exit<SelectionExit>> {
-
        if self.quit {
-
            return Some(Exit {
-
                value: self.output.clone(),
-
            });
-
        }
-
        None
-
    }
-
}
deleted bin/commands/issue/select/event.rs
@@ -1,177 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
-
use tuirealm::event::{Event, Key, KeyEvent};
-
use tuirealm::{MockComponent, NoUserEvent};
-

-
use radicle_tui as tui;
-

-
use tui::ui::state::ItemState;
-
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
-
use tui::ui::widget::context::{ContextBar, Shortcuts};
-
use tui::ui::widget::list::PropertyList;
-
use tui::ui::widget::Widget;
-
use tui::{Id, SelectionExit};
-

-
use super::ui::{IdSelect, OperationSelect};
-
use super::{IssueOperation, Message};
-

-
/// 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)),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => submit().map(|id| {
-
                let output = SelectionExit::default().with_id(Id::Object(id));
-
                Message::Quit(Some(output))
-
            }),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(IssueOperation::Show.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('d'),
-
                ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(IssueOperation::Delete.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('e'),
-
                ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(IssueOperation::Edit.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('m'),
-
                ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(IssueOperation::Comment.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        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 bin/commands/issue/select/page.rs
@@ -1,177 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

-
use tui::ui::state::ItemState;
-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob::issue::Filter;
-
use tui::context::Context;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::context::{Progress, Shortcuts};
-
use tui::ui::widget::Widget;
-
use tui::ui::{layout, subscription};
-
use tui::ViewPage;
-

-
use super::super::common;
-
use super::{ui, Application, Cid, ListCid, Message, Mode};
-

-
///
-
/// Home
-
///
-
pub struct ListView {
-
    active_component: ListCid,
-
    subject: Mode,
-
    filter: Filter,
-
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
-
}
-

-
impl ListView {
-
    pub fn new(subject: Mode, filter: Filter) -> Self {
-
        Self {
-
            active_component: ListCid::PatchBrowser,
-
            subject,
-
            filter,
-
            shortcuts: HashMap::default(),
-
        }
-
    }
-

-
    fn update_context(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
-
        let progress = match ItemState::try_from(state) {
-
            Ok(state) => Progress::Step(
-
                state
-
                    .selected()
-
                    .map(|s| s.saturating_add(1))
-
                    .unwrap_or_default(),
-
                state.len(),
-
            ),
-
            Err(_) => Progress::None,
-
        };
-

-
        let context = common::ui::browse_context(context, theme, self.filter.clone(), progress);
-

-
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
-

-
        Ok(())
-
    }
-

-
    fn update_shortcuts(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: ListCid,
-
    ) -> Result<()> {
-
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
-
            app.remount(
-
                Cid::List(ListCid::Shortcuts),
-
                shortcuts.clone().to_boxed(),
-
                vec![],
-
            )?;
-
        }
-

-
        Ok(())
-
    }
-
}
-

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

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

-
        match self.subject {
-
            Mode::Id => {
-
                let patch_browser =
-
                    ui::id_select(theme, context, self.filter.clone(), None).to_boxed();
-
                self.shortcuts = patch_browser.as_ref().shortcuts();
-

-
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
-
            }
-
            Mode::Operation => {
-
                let patch_browser =
-
                    ui::operation_select(theme, context, self.filter.clone(), None).to_boxed();
-
                self.shortcuts = patch_browser.as_ref().shortcuts();
-

-
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
-
            }
-
        };
-

-
        app.active(&Cid::List(self.active_component.clone()))?;
-
        self.update_shortcuts(app, self.active_component.clone())?;
-
        self.update_context(app, context, theme)?;
-

-
        Ok(())
-
    }
-

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

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        _message: Message,
-
    ) -> Result<Option<Message>> {
-
        self.update_context(app, context, theme)?;
-

-
        Ok(None)
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let context_h = app
-
            .query(&Cid::List(ListCid::Context), Attribute::Height)
-
            .unwrap_or_default()
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = 1u16;
-

-
        let layout = layout::default_page(area, context_h, shortcuts_h);
-

-
        app.view(
-
            &Cid::List(self.active_component.clone()),
-
            frame,
-
            layout.component,
-
        );
-

-
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
-
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

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

-
        Ok(())
-
    }
-

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

-
        Ok(())
-
    }
-
}
deleted bin/commands/issue/select/ui.rs
@@ -1,157 +0,0 @@
-
use std::collections::HashMap;
-

-
use radicle::issue::{Issue, IssueId};
-

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

-
use radicle_tui as tui;
-

-
use tui::cob::issue::Filter;
-
use tui::context::Context;
-
use tui::ui::cob::IssueItem;
-
use tui::ui::theme::{style, Theme};
-
use tui::ui::widget::container::Tabs;
-
use tui::ui::widget::context::Shortcuts;
-
use tui::ui::widget::label::{self};
-
use tui::ui::widget::{Widget, WidgetComponent};
-

-
use super::super::common;
-
use super::ListCid;
-

-
pub struct IdSelect {
-
    theme: Theme,
-
    browser: Widget<common::ui::IssueBrowser>,
-
}
-

-
impl IdSelect {
-
    pub fn new(theme: Theme, browser: Widget<common::ui::IssueBrowser>) -> Self {
-
        Self { theme, browser }
-
    }
-

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

-
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::PatchBrowser,
-
            tui::ui::shortcuts(
-
                &self.theme,
-
                vec![
-
                    tui::ui::shortcut(&self.theme, "enter", "select"),
-
                    tui::ui::shortcut(&self.theme, "q", "quit"),
-
                ],
-
            ),
-
        )]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-
}
-

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

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

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

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

-
pub struct OperationSelect {
-
    theme: Theme,
-
    browser: Widget<common::ui::IssueBrowser>,
-
}
-

-
impl OperationSelect {
-
    pub fn new(theme: Theme, browser: Widget<common::ui::IssueBrowser>) -> Self {
-
        Self { theme, browser }
-
    }
-

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

-
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::PatchBrowser,
-
            tui::ui::shortcuts(
-
                &self.theme,
-
                vec![
-
                    tui::ui::shortcut(&self.theme, "enter", "show"),
-
                    tui::ui::shortcut(&self.theme, "m", "comment"),
-
                    tui::ui::shortcut(&self.theme, "e", "edit"),
-
                    tui::ui::shortcut(&self.theme, "d", "delete"),
-
                    tui::ui::shortcut(&self.theme, "q", "quit"),
-
                ],
-
            ),
-
        )]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-
}
-

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

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

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

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

-
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
-
    tui::ui::tabs(
-
        theme,
-
        vec![label::reversable("Patches").style(style::cyan())],
-
    )
-
}
-

-
pub fn id_select(
-
    theme: &Theme,
-
    context: &Context,
-
    filter: Filter,
-
    selected: Option<(IssueId, Issue)>,
-
) -> Widget<IdSelect> {
-
    let browser = Widget::new(common::ui::IssueBrowser::new(
-
        theme, context, filter, selected,
-
    ));
-

-
    Widget::new(IdSelect::new(theme.clone(), browser))
-
}
-

-
pub fn operation_select(
-
    theme: &Theme,
-
    context: &Context,
-
    filter: Filter,
-
    selected: Option<(IssueId, Issue)>,
-
) -> Widget<OperationSelect> {
-
    let browser = Widget::new(common::ui::IssueBrowser::new(
-
        theme, context, filter, selected,
-
    ));
-

-
    Widget::new(OperationSelect::new(theme.clone(), browser))
-
}
deleted bin/commands/issue/suite.rs
@@ -1,353 +0,0 @@
-
#[path = "suite/event.rs"]
-
mod event;
-
#[path = "suite/page.rs"]
-
mod page;
-
#[path = "suite/ui.rs"]
-
mod ui;
-

-
use anyhow::Result;
-

-
use radicle::cob::issue::IssueId;
-

-
use tuirealm::application::PollStrategy;
-
use tuirealm::event::Key;
-
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob;
-
use tui::context::Context;
-
use tui::ui::subscription;
-
use tui::ui::theme::Theme;
-
use tui::{Exit, PageStack, Tui};
-

-
use page::{IssuePage, ListPage};
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum ListCid {
-
    Header,
-
    IssueBrowser,
-
    Context,
-
    Shortcuts,
-
}
-

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

-
/// All component ids known to this application.
-
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
-
pub enum Cid {
-
    List(ListCid),
-
    Issue(IssueCid),
-
    #[default]
-
    GlobalListener,
-
    Popup,
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueCobMessage {
-
    Create {
-
        title: String,
-
        tags: String,
-
        assignees: String,
-
        description: String,
-
    },
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueMessage {
-
    Show(Option<IssueId>),
-
    Changed(IssueId),
-
    Focus(IssueCid),
-
    Created(IssueId),
-
    Cob(IssueCobMessage),
-
    Reload(Option<IssueId>),
-
    OpenForm,
-
    HideForm,
-
    Leave(Option<IssueId>),
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum PopupMessage {
-
    Info(String),
-
    Warning(String),
-
    Error(String),
-
    Hide,
-
}
-

-
#[derive(Clone, Debug, Default, Eq, PartialEq)]
-
pub enum Message {
-
    Issue(IssueMessage),
-
    NavigationChanged(u16),
-
    FormSubmitted(String),
-
    Popup(PopupMessage),
-
    #[default]
-
    Tick,
-
    Quit,
-
    Batch(Vec<Message>),
-
}
-

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

-
/// Creates a new application using a tui-realm-application, mounts all
-
/// components and sets focus to a default one.
-
#[allow(dead_code)]
-
impl App {
-
    pub fn new(context: Context) -> Self {
-
        Self {
-
            context,
-
            pages: PageStack::default(),
-
            theme: Theme::default(),
-
            quit: false,
-
        }
-
    }
-

-
    fn view_list(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let list = Box::new(ListPage::new(theme.clone()));
-
        self.pages.push(list, app, &self.context, theme)?;
-

-
        Ok(())
-
    }
-

-
    fn view_issue(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: Option<IssueId>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-
        match id {
-
            Some(id) => {
-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
-
                    self.pages.push(view, app, &self.context, theme)?;
-

-
                    Ok(())
-
                } else {
-
                    Err(anyhow::anyhow!(
-
                        "Could not mount 'page::IssueView'. Issue not found."
-
                    ))
-
                }
-
            }
-
            None => {
-
                let view = Box::new(IssuePage::new(&self.context, theme, None));
-
                self.pages.push(view, app, &self.context, theme)?;
-

-
                Ok(())
-
            }
-
        }
-
    }
-

-
    fn process(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        let theme = Theme::default();
-
        match message {
-
            Message::Batch(messages) => {
-
                let mut results = vec![];
-
                for message in messages {
-
                    if let Some(result) = self.process(app, message)? {
-
                        results.push(result);
-
                    }
-
                }
-
                match results.len() {
-
                    0 => Ok(None),
-
                    1 => Ok(Some(results[0].to_owned())),
-
                    _ => Ok(Some(Message::Batch(results))),
-
                }
-
            }
-
            Message::Issue(IssueMessage::Cob(IssueCobMessage::Create {
-
                title,
-
                tags,
-
                assignees,
-
                description,
-
            })) => match self.create_issue(title, description, tags, assignees) {
-
                Ok(id) => {
-
                    self.context.reload_issues();
-

-
                    Ok(Some(Message::Batch(vec![
-
                        Message::Issue(IssueMessage::HideForm),
-
                        Message::Issue(IssueMessage::Created(id)),
-
                    ])))
-
                }
-
                Err(err) => {
-
                    let error = format!("{:?}", err);
-
                    self.show_error_popup(app, &theme, &error)?;
-

-
                    Ok(None)
-
                }
-
            },
-
            Message::Issue(IssueMessage::Show(id)) => {
-
                self.view_issue(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Issue(IssueMessage::Leave(id)) => {
-
                self.pages.pop(app)?;
-
                Ok(Some(Message::Issue(IssueMessage::Reload(id))))
-
            }
-
            Message::Popup(PopupMessage::Info(info)) => {
-
                self.show_info_popup(app, &theme, &info)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Warning(warning)) => {
-
                self.show_warning_popup(app, &theme, &warning)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Error(error)) => {
-
                self.show_error_popup(app, &theme, &error)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Hide) => {
-
                self.hide_popup(app)?;
-
                Ok(None)
-
            }
-
            Message::Quit => {
-
                self.quit = true;
-
                Ok(None)
-
            }
-
            _ => self
-
                .pages
-
                .peek_mut()?
-
                .update(app, &self.context, &theme, message),
-
        }
-
    }
-

-
    fn show_info_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::ui::info(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn show_warning_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::ui::warning(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn show_error_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::ui::error(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn hide_popup(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.blur()?;
-
        app.umount(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn create_issue(
-
        &mut self,
-
        title: String,
-
        description: String,
-
        labels: String,
-
        assignees: String,
-
    ) -> Result<IssueId> {
-
        let repository = self.context.repository();
-
        let signer = self.context.signer().as_ref().unwrap();
-

-
        let labels = cob::parse_labels(labels)?;
-
        let assignees = cob::parse_assignees(assignees)?;
-

-
        cob::issue::create(
-
            repository,
-
            signer,
-
            title,
-
            description,
-
            labels.as_slice(),
-
            assignees.as_slice(),
-
        )
-
    }
-
}
-

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

-
        // Add global key listener and subscribe to key events
-
        let global = tui::ui::global_listener().to_boxed();
-
        app.mount(
-
            Cid::GlobalListener,
-
            global,
-
            vec![Sub::new(
-
                subscription::quit_clause(Key::Char('q')),
-
                SubClause::Always,
-
            )],
-
        )?;
-

-
        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);
-
        }
-

-
        if app.mounted(&Cid::Popup) {
-
            app.view(&Cid::Popup, frame, frame.size());
-
        }
-
    }
-

-
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
-
        match app.tick(PollStrategy::Once) {
-
            Ok(messages) if !messages.is_empty() => {
-
                for message in messages {
-
                    let mut msg = Some(message);
-
                    while msg.is_some() {
-
                        msg = self.process(app, msg.unwrap())?;
-
                    }
-
                }
-
                Ok(true)
-
            }
-
            _ => Ok(false),
-
        }
-
    }
-

-
    fn exit(&self) -> Option<Exit<()>> {
-
        if self.quit {
-
            return Some(Exit { value: None });
-
        }
-
        None
-
    }
-
}
deleted bin/commands/issue/suite/event.rs
@@ -1,322 +0,0 @@
-
use radicle::cob::issue::IssueId;
-
use tui::ui::state::ItemState;
-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection, Position};
-
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
-
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
-

-
use radicle_tui as tui;
-

-
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer, Popup};
-
use tui::ui::widget::context::{ContextBar, Shortcuts};
-
use tui::ui::widget::form::Form;
-
use tui::ui::widget::list::PropertyList;
-

-
use tui::ui::widget::Widget;
-

-
use super::ui;
-
use super::{IssueCid, IssueMessage, Message, PopupMessage};
-

-
/// 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<ui::LargeList> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                let selected = ItemState::try_from(self.state()).ok()?.selected()?;
-
                let item = self.items().get(selected)?;
-

-
                Some(Message::Issue(IssueMessage::Leave(Some(
-
                    item.id().to_owned(),
-
                ))))
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => match self.perform(Cmd::Move(MoveDirection::Up)) {
-
                CmdResult::Changed(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-

-
                    Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
-
                }
-
                _ => None,
-
            },
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => match self.perform(Cmd::Move(MoveDirection::Down)) {
-
                CmdResult::Changed(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
-
                }
-
                _ => None,
-
            },
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => Some(Message::Issue(IssueMessage::Focus(IssueCid::Details))),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('o'),
-
                ..
-
            }) => Some(Message::Issue(IssueMessage::OpenForm)),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueDetails> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Scroll(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Scroll(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Issue(IssueMessage::Focus(IssueCid::List)))
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Form> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Left, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Left));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Right, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Right));
-
                Some(Message::Tick)
-
            }
-
            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::Home, ..
-
            }) => {
-
                self.perform(Cmd::GoTo(Position::Begin));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
-
                self.perform(Cmd::GoTo(Position::End));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Delete, ..
-
            }) => {
-
                self.perform(Cmd::Cancel);
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Backspace,
-
                ..
-
            }) => {
-
                self.perform(Cmd::Delete);
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_NEWLINE));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('s'),
-
                modifiers: KeyModifiers::CONTROL,
-
            }) => {
-
                self.perform(Cmd::Submit);
-
                self.query(tuirealm::Attribute::Custom(Form::PROP_ID))
-
                    .map(|cid| Message::FormSubmitted(cid.unwrap_string()))
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Issue(IssueMessage::HideForm))
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::BackTab, ..
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_FOCUS_PREVIOUS));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
-
                self.perform(Cmd::Custom(Form::CMD_FOCUS_NEXT));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char(ch),
-
                modifiers: KeyModifiers::SHIFT,
-
            }) => {
-
                self.perform(Cmd::Type(ch.to_ascii_uppercase()));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('v'),
-
                modifiers: KeyModifiers::CONTROL,
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_PASTE));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char(ch),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Type(ch));
-
                Some(Message::Tick)
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueBrowser> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<IssueId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('o'),
-
                ..
-
            }) => {
-
                let id = submit();
-
                Some(Message::Batch(vec![
-
                    Message::Issue(IssueMessage::Show(id)),
-
                    Message::Issue(IssueMessage::OpenForm),
-
                ]))
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                let id = submit();
-
                if id.is_some() {
-
                    Some(Message::Issue(IssueMessage::Show(id)))
-
                } else {
-
                    None
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Popup> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Popup(PopupMessage::Hide))
-
            }
-
            _ => 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 bin/commands/issue/suite/page.rs
@@ -1,569 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

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

-
use tui::ui::state::ItemState;
-
use tuirealm::event::Key;
-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, State, StateValue, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob;
-
use tui::context::Context;
-
use tui::ui::layout;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::context::{Progress, Shortcuts};
-
use tui::ui::widget::Widget;
-
use tui::ViewPage;
-

-
use super::{
-
    Application, Cid, IssueCid, IssueCobMessage, IssueMessage, ListCid, Message, PopupMessage,
-
};
-

-
use super::subscription;
-
use super::ui;
-

-
///
-
/// Home
-
///
-
pub struct ListPage {
-
    active_component: ListCid,
-
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
-
}
-

-
impl ListPage {
-
    pub fn new(theme: Theme) -> Self {
-
        let shortcuts = Self::build_shortcuts(&theme);
-
        Self {
-
            active_component: ListCid::IssueBrowser,
-
            shortcuts,
-
        }
-
    }
-

-
    fn activate(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: ListCid,
-
    ) -> Result<()> {
-
        self.active_component = cid;
-
        let cid = Cid::List(self.active_component.clone());
-
        app.active(&cid)?;
-
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
-

-
        Ok(())
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::IssueBrowser,
-
            tui::ui::shortcuts(
-
                theme,
-
                vec![
-
                    tui::ui::shortcut(theme, "tab", "section"),
-
                    tui::ui::shortcut(theme, "↑/↓", "navigate"),
-
                    tui::ui::shortcut(theme, "enter", "show"),
-
                    tui::ui::shortcut(theme, "o", "open"),
-
                    tui::ui::shortcut(theme, "q", "quit"),
-
                ],
-
            ),
-
        )]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-

-
    fn update_context(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let state = app.state(&Cid::List(ListCid::IssueBrowser))?;
-
        let progress = match ItemState::try_from(state) {
-
            Ok(state) => Progress::Step(
-
                state
-
                    .selected()
-
                    .map(|s| s.saturating_add(1))
-
                    .unwrap_or_default(),
-
                state.len(),
-
            ),
-
            Err(_) => Progress::None,
-
        };
-
        let context = ui::browse_context(context, theme, progress);
-

-
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
-

-
        Ok(())
-
    }
-

-
    fn update_shortcuts(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: ListCid,
-
    ) -> Result<()> {
-
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
-
            app.remount(
-
                Cid::List(ListCid::Shortcuts),
-
                shortcuts.clone().to_boxed(),
-
                vec![],
-
            )?;
-
        }
-
        Ok(())
-
    }
-
}
-

-
impl ViewPage<Cid, Message> for ListPage {
-
    fn mount(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = ui::list_navigation(theme);
-
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
-
        let issue_browser = ui::issues(context, theme, None).to_boxed();
-

-
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
-
        app.remount(Cid::List(ListCid::IssueBrowser), issue_browser, vec![])?;
-

-
        app.active(&Cid::List(self.active_component.clone()))?;
-
        self.update_shortcuts(app, self.active_component.clone())?;
-
        self.update_context(app, context, theme)?;
-

-
        Ok(())
-
    }
-

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

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        if let Message::Issue(IssueMessage::Reload(id)) = message {
-
            let selected = match id {
-
                Some(id) => cob::issue::find(context.repository(), &id)?.map(|issue| (id, issue)),
-
                _ => None,
-
            };
-

-
            let issue_browser = ui::issues(context, theme, selected).to_boxed();
-
            app.remount(Cid::List(ListCid::IssueBrowser), issue_browser, vec![])?;
-

-
            self.activate(app, ListCid::IssueBrowser)?;
-
        }
-

-
        self.update_context(app, context, theme)?;
-

-
        Ok(None)
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let context_h = app
-
            .query(&Cid::List(ListCid::Context), Attribute::Height)
-
            .unwrap_or_default()
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = 1u16;
-

-
        let layout = layout::full_page(area, context_h, shortcuts_h);
-

-
        app.view(&Cid::List(ListCid::Header), frame, layout.navigation);
-
        app.view(
-
            &Cid::List(self.active_component.clone()),
-
            frame,
-
            layout.component,
-
        );
-

-
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

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

-
        Ok(())
-
    }
-

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

-
        Ok(())
-
    }
-
}
-

-
///
-
/// Issue detail page
-
///
-
pub struct IssuePage {
-
    issue: Option<(IssueId, Issue)>,
-
    active_component: IssueCid,
-
    shortcuts: HashMap<IssueCid, Widget<Shortcuts>>,
-
}
-

-
impl IssuePage {
-
    pub fn new(_context: &Context, theme: &Theme, issue: Option<(IssueId, Issue)>) -> Self {
-
        let shortcuts = Self::build_shortcuts(theme);
-
        let active_component = IssueCid::List;
-

-
        Self {
-
            issue,
-
            active_component,
-
            shortcuts,
-
        }
-
    }
-

-
    fn activate(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: IssueCid,
-
    ) -> Result<()> {
-
        self.active_component = cid;
-
        let cid = Cid::Issue(self.active_component.clone());
-
        app.active(&cid)?;
-
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
-

-
        Ok(())
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<IssueCid, Widget<Shortcuts>> {
-
        [
-
            (
-
                IssueCid::List,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "↑/↓", "navigate"),
-
                        tui::ui::shortcut(theme, "enter", "show"),
-
                        tui::ui::shortcut(theme, "o", "open"),
-
                        tui::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                IssueCid::Details,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "↑/↓", "scroll"),
-
                        tui::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                IssueCid::Form,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "shift + tab / tab", "navigate"),
-
                        tui::ui::shortcut(theme, "ctrl + s", "submit"),
-
                    ],
-
                ),
-
            ),
-
        ]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-

-
    fn update_context(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        cid: IssueCid,
-
    ) -> Result<()> {
-
        let context = match cid {
-
            IssueCid::List => {
-
                let state = app.state(&Cid::Issue(IssueCid::List))?;
-
                let progress = match state {
-
                    State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => {
-
                        Progress::Step(step.saturating_add(1), total)
-
                    }
-
                    _ => Progress::None,
-
                };
-
                let context = ui::browse_context(context, theme, progress);
-
                Some(context)
-
            }
-
            IssueCid::Details => {
-
                let state = app.state(&Cid::Issue(IssueCid::Details))?;
-
                let progress = match state {
-
                    State::One(StateValue::Usize(scroll)) => Progress::Percentage(scroll),
-
                    _ => Progress::None,
-
                };
-
                let context = ui::description_context(context, theme, progress);
-
                Some(context)
-
            }
-
            IssueCid::Form => {
-
                let context = ui::form_context(context, theme, Progress::None);
-
                Some(context)
-
            }
-
            _ => None,
-
        };
-

-
        if let Some(context) = context {
-
            app.remount(Cid::Issue(IssueCid::Context), context.to_boxed(), vec![])?;
-
        }
-

-
        Ok(())
-
    }
-

-
    fn update_shortcuts(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: IssueCid,
-
    ) -> Result<()> {
-
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
-
            app.remount(
-
                Cid::Issue(IssueCid::Shortcuts),
-
                shortcuts.clone().to_boxed(),
-
                vec![],
-
            )?;
-
        }
-
        Ok(())
-
    }
-
}
-

-
impl ViewPage<Cid, Message> for IssuePage {
-
    fn mount(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = ui::list_navigation(theme);
-
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
-
        let list = ui::list(context, theme, self.issue.clone()).to_boxed();
-

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

-
        if let Some((id, issue)) = &self.issue {
-
            let comments = issue.comments().collect::<Vec<_>>();
-
            let details = ui::details(
-
                context,
-
                theme,
-
                (*id, issue.clone()),
-
                comments.first().copied(),
-
            )
-
            .to_boxed();
-
            app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
        }
-

-
        app.active(&Cid::Issue(self.active_component.clone()))?;
-

-
        self.update_shortcuts(app, self.active_component.clone())?;
-
        self.update_context(app, context, theme, self.active_component.clone())?;
-

-
        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::Context))?;
-
        app.umount(&Cid::Issue(IssueCid::Shortcuts))?;
-

-
        if app.mounted(&Cid::Issue(IssueCid::Details)) {
-
            app.umount(&Cid::Issue(IssueCid::Details))?;
-
        }
-

-
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
-
            app.umount(&Cid::Issue(IssueCid::Form))?;
-
        }
-

-
        Ok(())
-
    }
-

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        match message {
-
            Message::Issue(IssueMessage::Created(id)) => {
-
                let repo = context.repository();
-

-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    self.issue = Some((id, issue.clone()));
-
                    let list = ui::list(context, theme, self.issue.clone()).to_boxed();
-
                    let comments = issue.comments().collect::<Vec<_>>();
-

-
                    let details = ui::details(
-
                        context,
-
                        theme,
-
                        (id, issue.clone()),
-
                        comments.first().copied(),
-
                    )
-
                    .to_boxed();
-

-
                    app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
                }
-
            }
-
            Message::Issue(IssueMessage::Changed(id)) => {
-
                let repo = context.repository();
-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    self.issue = Some((id, issue.clone()));
-
                    let comments = issue.comments().collect::<Vec<_>>();
-
                    let details = ui::details(
-
                        context,
-
                        theme,
-
                        (id, issue.clone()),
-
                        comments.first().copied(),
-
                    )
-
                    .to_boxed();
-
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
                }
-
            }
-
            Message::Issue(IssueMessage::Focus(cid)) => {
-
                self.activate(app, cid)?;
-
                self.update_shortcuts(app, self.active_component.clone())?;
-
            }
-
            Message::Issue(IssueMessage::OpenForm) => {
-
                let new_form = ui::new_form(context, theme).to_boxed();
-
                let list = ui::list(context, theme, None).to_boxed();
-

-
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-
                app.remount(Cid::Issue(IssueCid::Form), new_form, vec![])?;
-
                app.active(&Cid::Issue(IssueCid::Form))?;
-

-
                app.unsubscribe(
-
                    &Cid::GlobalListener,
-
                    subscription::quit_clause(Key::Char('q')),
-
                )?;
-

-
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::Form))));
-
            }
-
            Message::Issue(IssueMessage::HideForm) => {
-
                app.umount(&Cid::Issue(IssueCid::Form))?;
-

-
                let list = ui::list(context, theme, self.issue.clone()).to_boxed();
-
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-

-
                app.subscribe(
-
                    &Cid::GlobalListener,
-
                    Sub::new(subscription::quit_clause(Key::Char('q')), SubClause::Always),
-
                )?;
-

-
                if self.issue.is_none() {
-
                    return Ok(Some(Message::Issue(IssueMessage::Leave(None))));
-
                }
-
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::List))));
-
            }
-
            Message::FormSubmitted(id) => {
-
                if id == ui::FORM_ID_EDIT {
-
                    let state = app.state(&Cid::Issue(IssueCid::Form))?;
-
                    if let State::Linked(mut states) = state {
-
                        let mut missing_values = vec![];
-

-
                        let title = match states.front() {
-
                            Some(State::One(StateValue::String(title))) if !title.is_empty() => {
-
                                Some(title.clone())
-
                            }
-
                            _ => None,
-
                        };
-
                        states.pop_front();
-

-
                        let tags = match states.front() {
-
                            Some(State::One(StateValue::String(tags))) => Some(tags.clone()),
-
                            _ => Some(String::from("[]")),
-
                        };
-
                        states.pop_front();
-

-
                        let assignees = match states.front() {
-
                            Some(State::One(StateValue::String(assignees))) => {
-
                                Some(assignees.clone())
-
                            }
-
                            _ => Some(String::from("[]")),
-
                        };
-
                        states.pop_front();
-

-
                        let description = match states.front() {
-
                            Some(State::One(StateValue::String(description)))
-
                                if !description.is_empty() =>
-
                            {
-
                                Some(description.clone())
-
                            }
-
                            _ => None,
-
                        };
-
                        states.pop_front();
-

-
                        if title.is_none() {
-
                            missing_values.push("title");
-
                        }
-
                        if description.is_none() {
-
                            missing_values.push("description");
-
                        }
-

-
                        // show error popup if missing.
-
                        if !missing_values.is_empty() {
-
                            let error = format!("Missing fields: {:?}", missing_values);
-
                            return Ok(Some(Message::Popup(PopupMessage::Error(error))));
-
                        } else {
-
                            return Ok(Some(Message::Issue(IssueMessage::Cob(
-
                                IssueCobMessage::Create {
-
                                    title: title.unwrap(),
-
                                    tags: tags.unwrap(),
-
                                    assignees: assignees.unwrap(),
-
                                    description: description.unwrap(),
-
                                },
-
                            ))));
-
                        }
-
                    }
-
                }
-
            }
-
            _ => {}
-
        }
-

-
        self.update_context(app, context, theme, self.active_component.clone())?;
-

-
        Ok(None)
-
    }
-

-
    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_page(area, shortcuts_h);
-

-
        app.view(&Cid::Issue(IssueCid::Header), frame, layout.header);
-
        app.view(&Cid::Issue(IssueCid::List), frame, layout.left);
-

-
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
-
            app.view(&Cid::Issue(IssueCid::Form), frame, layout.right);
-
        } else if app.mounted(&Cid::Issue(IssueCid::Details)) {
-
            app.view(&Cid::Issue(IssueCid::Details), frame, layout.right);
-
        }
-

-
        app.view(&Cid::Issue(IssueCid::Context), frame, layout.context);
-
        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(())
-
    }
-
}
deleted bin/commands/issue/suite/ui.rs
@@ -1,436 +0,0 @@
-
use radicle::node::AliasStore;
-

-
use radicle::cob::thread::Comment;
-
use radicle::cob::thread::CommentId;
-

-
use radicle::cob::issue::Issue;
-
use radicle::cob::issue::IssueId;
-

-
use tui::ui::theme::style;
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use radicle_tui as tui;
-

-
use tui::context::Context;
-
use tui::ui::cob;
-
use tui::ui::cob::IssueItem;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::{Widget, WidgetComponent};
-

-
use tui::ui::widget::container::{Container, Tabs};
-
use tui::ui::widget::context::{ContextBar, Progress};
-
use tui::ui::widget::form::{Form, TextArea, TextField};
-
use tui::ui::widget::label::{self, Textarea};
-
use tui::ui::widget::list::{ColumnWidth, List, Property, Table};
-

-
pub const FORM_ID_EDIT: &str = "edit-form";
-

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

-
impl IssueBrowser {
-
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
-
        let header = [
-
            label::header(" ● "),
-
            label::header("ID"),
-
            label::header("Title"),
-
            label::header("Author"),
-
            label::header("Tags"),
-
            label::header("Assignees"),
-
            label::header("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![];
-

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

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

-
        let selected = match selected {
-
            Some((id, issue)) => Some(IssueItem::from((context.profile(), repo, id, issue))),
-
            _ => items.first().cloned(),
-
        };
-

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

-
        Self { items, table }
-
    }
-

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

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

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

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

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

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

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

-
        let issues = context.issues().as_ref().unwrap();
-
        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()));
-

-
        let container = tui::ui::container(theme, 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 {
-
        self.list.state()
-
    }
-

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

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

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

-
        let (id, issue) = issue;
-
        let author = issue.author();
-
        let author = author.id();
-
        let alias = context.profile().aliases().alias(author);
-
        let by_you = *author == context.profile().did();
-
        let item = IssueItem::from((context.profile(), repo, id, issue.clone()));
-

-
        let title = Property::new(label::property("Title"), label::default(item.title()));
-

-
        let author = match alias {
-
            Some(_) => label::alias(&cob::format_author(issue.author().id(), &alias, by_you)),
-
            None => label::did(&cob::format_author(issue.author().id(), &alias, by_you)),
-
        };
-
        let author = Property::new(label::property("Author"), author);
-

-
        let issue_id = Property::new(
-
            label::property("Issue"),
-
            label::default(&id.to_string()).style(style::gray()),
-
        );
-

-
        let labels = Property::new(
-
            label::property("Labels"),
-
            label::labels(&cob::format_labels(item.labels())),
-
        );
-

-
        let assignees = Property::new(
-
            label::property("Assignees"),
-
            label::did(&cob::format_assignees(
-
                &item
-
                    .assignees()
-
                    .iter()
-
                    .map(|item| (item.did(), item.alias(), item.is_you()))
-
                    .collect::<Vec<_>>(),
-
            )),
-
        );
-

-
        let state = Property::new(
-
            label::property("Status"),
-
            label::default(&item.state().to_string()),
-
        );
-

-
        let table = tui::ui::property_table(
-
            theme,
-
            vec![
-
                Widget::new(title),
-
                Widget::new(issue_id),
-
                Widget::new(author),
-
                Widget::new(labels),
-
                Widget::new(assignees),
-
                Widget::new(state),
-
            ],
-
        );
-
        let container = tui::ui::container(theme, table.to_boxed());
-

-
        Self { container }
-
    }
-
}
-

-
impl WidgetComponent for IssueHeader {
-
    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 struct IssueDetails {
-
    header: Widget<IssueHeader>,
-
    description: Widget<CommentBody>,
-
}
-

-
impl IssueDetails {
-
    pub fn new(
-
        context: &Context,
-
        theme: &Theme,
-
        issue: (IssueId, Issue),
-
        description: Option<(&CommentId, &Comment)>,
-
    ) -> Self {
-
        Self {
-
            header: header(context, theme, issue),
-
            description: self::description(context, theme, description),
-
        }
-
    }
-
}
-

-
impl WidgetComponent for IssueDetails {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints([Constraint::Length(8), Constraint::Min(1)])
-
            .split(area);
-

-
        self.header.view(frame, layout[0]);
-

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

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

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

-
pub struct CommentBody {
-
    textarea: Widget<Container>,
-
}
-

-
impl CommentBody {
-
    pub fn new(_context: &Context, theme: &Theme, comment: Option<(&CommentId, &Comment)>) -> Self {
-
        let content = match comment {
-
            Some((_, comment)) => comment.body().to_string(),
-
            None => String::new(),
-
        };
-
        let textarea = Widget::new(Textarea::default())
-
            .content(AttrValue::String(content))
-
            .style(style::reset());
-

-
        let textarea = tui::ui::container(theme, textarea.to_boxed());
-

-
        Self { textarea }
-
    }
-
}
-

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

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

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

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

-
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
-
    tui::ui::tabs(
-
        theme,
-
        vec![label::reversable("Issues").style(style::magenta())],
-
    )
-
}
-

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

-
    Widget::new(list)
-
}
-

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

-
pub fn description(
-
    context: &Context,
-
    theme: &Theme,
-
    comment: Option<(&CommentId, &Comment)>,
-
) -> Widget<CommentBody> {
-
    let body = CommentBody::new(context, theme, comment);
-
    Widget::new(body)
-
}
-

-
pub fn new_form(_context: &Context, theme: &Theme) -> Widget<Form> {
-
    use tuirealm::props::Layout;
-

-
    let title = Widget::new(TextField::new(theme.clone(), "Title")).to_boxed();
-
    let tags = Widget::new(TextField::new(theme.clone(), "Labels (bug, ...)")).to_boxed();
-
    let assignees = Widget::new(TextField::new(
-
        theme.clone(),
-
        "Assignees (z6MkvAdxCp1oLVVTsqYvev9YrhSN3gBQNUSM45hhy4pgkexk, ...)",
-
    ))
-
    .to_boxed();
-
    let description = Widget::new(TextArea::new(theme.clone(), "Description")).to_boxed();
-
    let inputs: Vec<Box<dyn MockComponent>> = vec![title, tags, assignees, description];
-

-
    let layout = Layout::default().constraints(
-
        [
-
            Constraint::Length(3),
-
            Constraint::Length(3),
-
            Constraint::Length(3),
-
            Constraint::Min(3),
-
        ]
-
        .as_ref(),
-
    );
-

-
    Widget::new(Form::new(theme.clone(), inputs))
-
        .custom(Form::PROP_ID, AttrValue::String(String::from(FORM_ID_EDIT)))
-
        .layout(layout)
-
}
-

-
pub fn details(
-
    context: &Context,
-
    theme: &Theme,
-
    issue: (IssueId, Issue),
-
    comment: Option<(&CommentId, &Comment)>,
-
) -> Widget<IssueDetails> {
-
    let discussion = IssueDetails::new(context, theme, issue, comment);
-
    Widget::new(discussion)
-
}
-

-
pub fn browse_context(context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
-
    use radicle::cob::issue::State;
-

-
    let issues = context.issues().as_ref().unwrap();
-
    let open = issues
-
        .iter()
-
        .filter(|issue| *issue.1.state() == State::Open)
-
        .collect::<Vec<_>>()
-
        .len();
-
    let closed = issues
-
        .iter()
-
        .filter(|issue| *issue.1.state() != State::Open)
-
        .collect::<Vec<_>>()
-
        .len();
-

-
    tui::ui::widget::context::bar(
-
        theme,
-
        "Browse",
-
        "",
-
        "",
-
        &format!("{open} open | {closed} closed"),
-
        &progress.to_string(),
-
    )
-
}
-

-
pub fn description_context(
-
    _context: &Context,
-
    theme: &Theme,
-
    progress: Progress,
-
) -> Widget<ContextBar> {
-
    tui::ui::widget::context::bar(theme, "Show", "", "", "", &progress.to_string())
-
}
-

-
pub fn form_context(_context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
-
    tui::ui::widget::context::bar(theme, "Open", "", "", "", &progress.to_string())
-
        .custom(ContextBar::PROP_EDIT_MODE, AttrValue::Flag(true))
-
}
-

-
pub fn issues(
-
    context: &Context,
-
    theme: &Theme,
-
    selected: Option<(IssueId, Issue)>,
-
) -> Widget<IssueBrowser> {
-
    Widget::new(IssueBrowser::new(context, theme, selected))
-
}
modified bin/commands/patch.rs
@@ -1,9 +1,11 @@
#[path = "patch/common.rs"]
mod common;
-
#[path = "patch/select.rs"]
-
mod select;
-
#[path = "patch/suite.rs"]
-
mod suite;
+
#[cfg(feature = "flux")]
+
#[path = "patch/flux.rs"]
+
mod flux;
+
#[cfg(feature = "realm")]
+
#[path = "patch/realm.rs"]
+
mod realm;

use std::ffi::OsString;

@@ -11,13 +13,12 @@ use anyhow::anyhow;

use radicle_tui as tui;

-
use tui::cob::patch::{self, State};
-
use tui::{context, log, Window};
+
use tui::common::cob::patch::{self, State};
+
use tui::common::log;

use crate::terminal;
use crate::terminal::args::{Args, Error, Help};

-
pub const FPS: u64 = 60;
pub const HELP: Help = Help {
    name: "patch",
    description: "Terminal interfaces for patches",
@@ -64,7 +65,7 @@ pub enum OperationName {

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SelectOptions {
-
    mode: select::Mode,
+
    mode: common::Mode,
    filter: patch::Filter,
}

@@ -88,8 +89,8 @@ impl Args for Options {
                    let val = val.to_str().unwrap_or_default();

                    select_opts.mode = match val {
-
                        "operation" => select::Mode::Operation,
-
                        "id" => select::Mode::Id,
+
                        "operation" => common::Mode::Operation,
+
                        "id" => common::Mode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
                }
@@ -132,7 +133,12 @@ impl Args for Options {
    }
}

+
#[cfg(feature = "realm")]
pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    use tui::common::context;
+
    use tui::realm::Window;
+

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

@@ -143,7 +149,7 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>

            log::enable(context.profile(), "patch", "select")?;

-
            let mut app = select::App::new(context, opts.mode.clone(), opts.filter.clone());
+
            let mut app = realm::select::App::new(context, opts.mode.clone(), opts.filter.clone());
            let output = Window::default().run(&mut app, 1000 / FPS)?;

            let output = output
@@ -156,3 +162,37 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>

    Ok(())
}
+

+
#[cfg(feature = "flux")]
+
#[tokio::main]
+
pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    use radicle::storage::ReadStorage;
+

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

+
    match options.op {
+
        Operation::Select { opts } => {
+
            let profile = terminal::profile()?;
+
            let repository = profile.storage.repository(rid).unwrap();
+

+
            log::enable(&profile, "patch", "select")?;
+

+
            let context = flux::select::Context {
+
                profile,
+
                repository,
+
                mode: opts.mode,
+
                filter: opts.filter.clone(),
+
            };
+
            let output = flux::select::App::new(context).run().await?;
+

+
            let output = output
+
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
+
                .unwrap_or_default();
+

+
            eprint!("{output}");
+
        }
+
    }
+

+
    Ok(())
+
}
modified bin/commands/patch/common.rs
@@ -1,2 +1,47 @@
-
#[path = "common/ui.rs"]
-
pub mod ui;
+
use std::fmt::Display;
+

+
use serde::Serialize;
+

+
/// The application's mode. It tells the application
+
/// which widgets to render and which output to produce.
+
///
+
/// Depends on CLI arguments given by the user.
+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Mode {
+
    #[default]
+
    Operation,
+
    Id,
+
}
+

+
/// The selected patch operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum PatchOperation {
+
    Show,
+
    Checkout,
+
    Delete,
+
    Edit,
+
    Comment,
+
}
+

+
impl Display for PatchOperation {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            PatchOperation::Show => {
+
                write!(f, "show")
+
            }
+
            PatchOperation::Checkout => {
+
                write!(f, "checkout")
+
            }
+
            PatchOperation::Delete => {
+
                write!(f, "delete")
+
            }
+
            PatchOperation::Edit => {
+
                write!(f, "edit")
+
            }
+
            PatchOperation::Comment => {
+
                write!(f, "comment")
+
            }
+
        }
+
    }
+
}
deleted bin/commands/patch/common/ui.rs
@@ -1,180 +0,0 @@
-
use radicle::cob::patch::{Patch, PatchId};
-

-
use tui::ui::widget::context::{ContextBar, Progress};
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use radicle_tui as tui;
-

-
use tui::cob::patch::Filter;
-
use tui::context::Context;
-
use tui::ui::cob::PatchItem;
-
use tui::ui::theme::{style, Theme};
-
use tui::ui::widget::{Widget, WidgetComponent};
-

-
use tui::ui::widget::label::{self};
-
use tui::ui::widget::list::{ColumnWidth, Table};
-

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

-
impl PatchBrowser {
-
    pub fn new(
-
        theme: &Theme,
-
        context: &Context,
-
        filter: Filter,
-
        selected: Option<(PatchId, Patch)>,
-
    ) -> Self {
-
        let header = [
-
            label::header(" ● "),
-
            label::header("ID"),
-
            label::header("Title"),
-
            label::header("Author"),
-
            label::header("Head"),
-
            label::header("+"),
-
            label::header("-"),
-
            label::header("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 patches = context
-
            .patches()
-
            .as_ref()
-
            .unwrap()
-
            .iter()
-
            .filter(|(_, patch)| filter.matches(context.profile(), patch));
-

-
        let mut items = vec![];
-
        for (id, patch) in patches {
-
            if let Ok(item) = PatchItem::try_from((context.profile(), repo, *id, patch.clone())) {
-
                items.push(item);
-
            }
-
        }
-

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

-
        let selected = match selected {
-
            Some((id, patch)) => {
-
                Some(PatchItem::try_from((context.profile(), repo, id, patch)).unwrap())
-
            }
-
            _ => items.first().cloned(),
-
        };
-

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

-
        Self { items, table }
-
    }
-

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

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

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

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

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

-
pub fn browse_context(
-
    context: &Context,
-
    _theme: &Theme,
-
    filter: Filter,
-
    progress: Progress,
-
) -> Widget<ContextBar> {
-
    use radicle::cob::patch::State;
-

-
    let mut draft = 0;
-
    let mut open = 0;
-
    let mut archived = 0;
-
    let mut merged = 0;
-

-
    let patches = context
-
        .patches()
-
        .as_ref()
-
        .unwrap()
-
        .iter()
-
        .filter(|(_, patch)| filter.matches(context.profile(), patch));
-

-
    for (_, patch) in patches {
-
        match patch.state() {
-
            State::Draft => draft += 1,
-
            State::Open { conflicts: _ } => open += 1,
-
            State::Archived => archived += 1,
-
            State::Merged {
-
                commit: _,
-
                revision: _,
-
            } => merged += 1,
-
        }
-
    }
-

-
    let context = label::reversable("/").style(style::magenta_reversed());
-
    let filter = label::default(&filter.to_string()).style(style::magenta_dim());
-

-
    let draft_n = label::default(&format!("{draft}")).style(style::gray_dim());
-
    let draft = label::default(" Draft");
-

-
    let open_n = label::default(&format!("{open}")).style(style::green());
-
    let open = label::default(" Open");
-

-
    let archived_n = label::default(&format!("{archived}")).style(style::yellow());
-
    let archived = label::default(" Archived");
-

-
    let merged_n = label::default(&format!("{merged}")).style(style::cyan());
-
    let merged = label::default(" Merged ");
-

-
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
-

-
    let spacer = label::default("");
-
    let divider = label::default(" | ");
-

-
    let context_bar = ContextBar::new(
-
        label::group(&[context]),
-
        label::group(&[filter]),
-
        label::group(&[spacer]),
-
        label::group(&[
-
            draft_n,
-
            draft,
-
            divider.clone(),
-
            open_n,
-
            open,
-
            divider.clone(),
-
            archived_n,
-
            archived,
-
            divider,
-
            merged_n,
-
            merged,
-
        ]),
-
        label::group(&[progress]),
-
    );
-

-
    Widget::new(context_bar).height(1)
-
}
added bin/commands/patch/flux.rs
@@ -0,0 +1,2 @@
+
#[path = "flux/select.rs"]
+
pub mod select;
added bin/commands/patch/flux/select.rs
@@ -0,0 +1,122 @@
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use anyhow::Result;
+

+
use radicle::patch::PatchId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::{self, Filter};
+
use tui::flux::store::{State, Store};
+
use tui::flux::termination::{self, Interrupted};
+
use tui::flux::ui::cob::PatchItem;
+
use tui::flux::ui::Frontend;
+
use tui::Exit;
+

+
use ui::ListPage;
+

+
use super::super::common::Mode;
+

+
type Selection = tui::Selection<PatchId>;
+

+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: Mode,
+
    pub filter: Filter,
+
}
+

+
pub struct App {
+
    context: Context,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct PatchesState {
+
    patches: Vec<PatchItem>,
+
    selected: Option<PatchItem>,
+
    mode: Mode,
+
    filter: Filter,
+
}
+

+
impl TryFrom<&Context> for PatchesState {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let patches = patch::all(&context.repository)?;
+
        let patches = patches
+
            .iter()
+
            .filter(|(_, patch)| context.filter.matches(&context.profile, patch));
+

+
        let mut items = vec![];
+

+
        // Convert into UI items
+
        for patch in patches {
+
            if let Ok(item) = PatchItem::new(&context.profile, &context.repository, patch.clone()) {
+
                items.push(item);
+
            }
+
        }
+

+
        // Apply sorting
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+
        let selected = items.first().cloned();
+

+
        Ok(Self {
+
            patches: items,
+
            selected,
+
            mode: context.mode.clone(),
+
            filter: context.filter.clone(),
+
        })
+
    }
+
}
+

+
pub enum Action {
+
    Exit { selection: Option<Selection> },
+
    Select { item: PatchItem },
+
}
+

+
impl State<Action, Selection> for PatchesState {
+
    fn tick(&self) {}
+

+
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
+
        match action {
+
            Action::Select { item } => {
+
                self.selected = Some(item);
+
                None
+
            }
+
            Action::Exit { selection } => Some(Exit { value: selection }),
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self { context }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (store, state_rx) = Store::<Action, PatchesState, Selection>::new();
+
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let state = PatchesState::try_from(&self.context)?;
+

+
        tokio::try_join!(
+
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
+
            frontend.main_loop::<PatchesState, ListPage, Selection>(
+
                state_rx,
+
                interrupt_rx.resubscribe()
+
            ),
+
        )?;
+

+
        if let Ok(reason) = interrupt_rx.recv().await {
+
            match reason {
+
                Interrupted::User { payload } => Ok(payload),
+
                Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"),
+
            }
+
        } else {
+
            anyhow::bail!("exited because of an unexpected error");
+
        }
+
    }
+
}
added bin/commands/patch/flux/select/ui.rs
@@ -0,0 +1,370 @@
+
use std::vec;
+

+
use ratatui::style::Stylize;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::backend::Backend;
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::Filter;
+
use tui::flux::ui::cob::PatchItem;
+
use tui::flux::ui::span;
+
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::flux::ui::widget::{
+
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
+
};
+
use tui::Selection;
+

+
use crate::tui_patch::common::Mode;
+
use crate::tui_patch::common::PatchOperation;
+

+
use super::{Action, PatchesState};
+

+
pub struct ListPageProps {
+
    selected: Option<PatchItem>,
+
    mode: Mode,
+
}
+

+
impl From<&PatchesState> for ListPageProps {
+
    fn from(state: &PatchesState) -> Self {
+
        Self {
+
            selected: state.selected.clone(),
+
            mode: state.mode.clone(),
+
        }
+
    }
+
}
+

+
pub struct ListPage {
+
    /// Action sender
+
    pub action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: ListPageProps,
+
    /// Notification widget
+
    patches: Patches,
+
    /// Shortcut widget
+
    shortcuts: Shortcuts<Action>,
+
}
+

+
impl Widget<PatchesState, Action> for ListPage {
+
    fn new(state: &PatchesState, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: ListPageProps::from(state),
+
            patches: Patches::new(state, action_tx.clone()),
+
            shortcuts: Shortcuts::new(state, action_tx.clone()),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &PatchesState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        ListPage {
+
            patches: self.patches.move_with_state(state),
+
            shortcuts: self.shortcuts.move_with_state(state),
+
            props: ListPageProps::from(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "list-page"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        match key {
+
            Key::Char('q') => {
+
                let _ = self.action_tx.send(Action::Exit { selection: None });
+
            }
+
            Key::Char('\n') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let operation = match self.props.mode {
+
                        Mode::Operation => Some(PatchOperation::Show.to_string()),
+
                        Mode::Id => None,
+
                    };
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation,
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('c') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(PatchOperation::Checkout.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('m') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(PatchOperation::Comment.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('e') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(PatchOperation::Edit.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('d') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(PatchOperation::Delete.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            _ => {
+
                <Patches as Widget<PatchesState, Action>>::handle_key_event(&mut self.patches, key);
+
            }
+
        }
+
    }
+
}
+

+
impl Render<()> for ListPage {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
        let area = frame.size();
+
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);
+

+
        let shortcuts = match self.props.mode {
+
            Mode::Id => vec![Shortcut::new("enter", "select"), Shortcut::new("q", "quit")],
+
            Mode::Operation => vec![
+
                Shortcut::new("enter", "show"),
+
                Shortcut::new("c", "checkout"),
+
                Shortcut::new("m", "comment"),
+
                Shortcut::new("e", "edit"),
+
                Shortcut::new("d", "delete"),
+
                Shortcut::new("q", "quit"),
+
            ],
+
        };
+

+
        self.patches.render::<B>(frame, layout.component, ());
+
        self.shortcuts.render::<B>(
+
            frame,
+
            layout.shortcuts,
+
            ShortcutsProps {
+
                shortcuts,
+
                divider: '∙',
+
            },
+
        );
+
    }
+
}
+

+
struct PatchesProps {
+
    patches: Vec<PatchItem>,
+
    filter: Filter,
+
}
+

+
impl From<&PatchesState> for PatchesProps {
+
    fn from(state: &PatchesState) -> Self {
+
        Self {
+
            patches: state.patches.clone(),
+
            filter: state.filter.clone(),
+
        }
+
    }
+
}
+

+
struct Patches {
+
    /// Action sender
+
    action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: PatchesProps,
+
    /// Table header
+
    header: Header<Action>,
+
    /// Notification table
+
    table: Table<Action>,
+
    /// Table footer
+
    footer: Footer<Action>,
+
}
+

+
impl Widget<PatchesState, Action> for Patches {
+
    fn new(state: &PatchesState, action_tx: UnboundedSender<Action>) -> Self {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: PatchesProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
+
            table: Table::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
+
        }
+
    }
+

+
    fn move_with_state(self, state: &PatchesState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            props: PatchesProps::from(state),
+
            header: self.header.move_with_state(state),
+
            table: self.table.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "patches"
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Up => {
+
                self.table.prev();
+

+
                let selected = self
+
                    .table
+
                    .selected()
+
                    .and_then(|selected| self.props.patches.get(selected));
+

+
                // TODO: propagate error
+
                if let Some(notif) = selected {
+
                    let _ = self.action_tx.send(Action::Select {
+
                        item: notif.clone(),
+
                    });
+
                }
+
            }
+
            Key::Down => {
+
                self.table.next(self.props.patches.len());
+

+
                let selected = self
+
                    .table
+
                    .selected()
+
                    .and_then(|selected| self.props.patches.get(selected));
+

+
                // TODO: propagate error
+
                if let Some(notif) = selected {
+
                    let _ = self.action_tx.send(Action::Select {
+
                        item: notif.clone(),
+
                    });
+
                }
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
impl Render<()> for Patches {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        let cutoff = 200;
+
        let cutoff_after = 5;
+
        let focus = false;
+

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

+
        let widths = [
+
            Constraint::Length(3),
+
            Constraint::Length(8),
+
            Constraint::Fill(1),
+
            Constraint::Length(16),
+
            Constraint::Length(16),
+
            Constraint::Length(8),
+
            Constraint::Length(6),
+
            Constraint::Length(6),
+
            Constraint::Length(16),
+
        ];
+

+
        let progress = {
+
            let step = self
+
                .table
+
                .selected()
+
                .map(|selected| selected.saturating_add(1).to_string())
+
                .unwrap_or("-".to_string());
+
            let length = self.props.patches.len().to_string();
+

+
            span::badge(format!("{}/{}", step, length))
+
        };
+

+
        self.header.render::<B>(
+
            frame,
+
            layout[0],
+
            HeaderProps {
+
                cells: [
+
                    String::from(" ● ").into(),
+
                    String::from("ID").into(),
+
                    String::from("Title").into(),
+
                    String::from("Author").into(),
+
                    String::from("").into(),
+
                    String::from("Head").into(),
+
                    String::from("+").into(),
+
                    String::from("- ").into(),
+
                    String::from("Updated").into(),
+
                ],
+
                widths,
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.table.render::<B>(
+
            frame,
+
            layout[1],
+
            TableProps {
+
                items: self.props.patches.to_vec(),
+
                has_header: true,
+
                has_footer: true,
+
                focus,
+
                widths,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.footer.render::<B>(
+
            frame,
+
            layout[2],
+
            FooterProps {
+
                cells: [
+
                    span::badge("/".to_string()),
+
                    span::default(self.props.filter.to_string()).magenta().dim(),
+
                    String::from("").into(),
+
                    progress.clone(),
+
                ],
+
                widths: [
+
                    Constraint::Length(3),
+
                    Constraint::Fill(1),
+
                    Constraint::Fill(1),
+
                    Constraint::Length(progress.width() as u16),
+
                ],
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+
    }
+
}
added bin/commands/patch/realm.rs
@@ -0,0 +1,6 @@
+
#[path = "realm/common.rs"]
+
pub mod common;
+
#[path = "realm/select.rs"]
+
pub mod select;
+
#[path = "realm/suite.rs"]
+
pub mod suite;
added bin/commands/patch/realm/common.rs
@@ -0,0 +1,2 @@
+
#[path = "common/ui.rs"]
+
pub mod ui;
added bin/commands/patch/realm/common/ui.rs
@@ -0,0 +1,179 @@
+
use radicle::cob::patch::{Patch, PatchId};
+

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

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::cob::PatchItem;
+
use tui::realm::ui::theme::{style, Theme};
+
use tui::realm::ui::widget::context::{ContextBar, Progress};
+
use tui::realm::ui::widget::label::{self};
+
use tui::realm::ui::widget::list::{ColumnWidth, Table};
+
use tui::realm::ui::widget::{Widget, WidgetComponent};
+

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

+
impl PatchBrowser {
+
    pub fn new(
+
        theme: &Theme,
+
        context: &Context,
+
        filter: Filter,
+
        selected: Option<(PatchId, Patch)>,
+
    ) -> Self {
+
        let header = [
+
            label::header(" ● "),
+
            label::header("ID"),
+
            label::header("Title"),
+
            label::header("Author"),
+
            label::header("Head"),
+
            label::header("+"),
+
            label::header("-"),
+
            label::header("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 patches = context
+
            .patches()
+
            .as_ref()
+
            .unwrap()
+
            .iter()
+
            .filter(|(_, patch)| filter.matches(context.profile(), patch));
+

+
        let mut items = vec![];
+
        for (id, patch) in patches {
+
            if let Ok(item) = PatchItem::try_from((context.profile(), repo, *id, patch.clone())) {
+
                items.push(item);
+
            }
+
        }
+

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

+
        let selected = match selected {
+
            Some((id, patch)) => {
+
                Some(PatchItem::try_from((context.profile(), repo, id, patch)).unwrap())
+
            }
+
            _ => items.first().cloned(),
+
        };
+

+
        let table = Widget::new(Table::new(&items, selected, header, widths, theme.clone()));
+

+
        Self { items, table }
+
    }
+

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

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

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

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

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

+
pub fn browse_context(
+
    context: &Context,
+
    _theme: &Theme,
+
    filter: Filter,
+
    progress: Progress,
+
) -> Widget<ContextBar> {
+
    use radicle::cob::patch::State;
+

+
    let mut draft = 0;
+
    let mut open = 0;
+
    let mut archived = 0;
+
    let mut merged = 0;
+

+
    let patches = context
+
        .patches()
+
        .as_ref()
+
        .unwrap()
+
        .iter()
+
        .filter(|(_, patch)| filter.matches(context.profile(), patch));
+

+
    for (_, patch) in patches {
+
        match patch.state() {
+
            State::Draft => draft += 1,
+
            State::Open { conflicts: _ } => open += 1,
+
            State::Archived => archived += 1,
+
            State::Merged {
+
                commit: _,
+
                revision: _,
+
            } => merged += 1,
+
        }
+
    }
+

+
    let context = label::reversable("/").style(style::magenta_reversed());
+
    let filter = label::default(&filter.to_string()).style(style::magenta_dim());
+

+
    let draft_n = label::default(&format!("{draft}")).style(style::gray_dim());
+
    let draft = label::default(" Draft");
+

+
    let open_n = label::default(&format!("{open}")).style(style::green());
+
    let open = label::default(" Open");
+

+
    let archived_n = label::default(&format!("{archived}")).style(style::yellow());
+
    let archived = label::default(" Archived");
+

+
    let merged_n = label::default(&format!("{merged}")).style(style::cyan());
+
    let merged = label::default(" Merged ");
+

+
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
+

+
    let spacer = label::default("");
+
    let divider = label::default(" | ");
+

+
    let context_bar = ContextBar::new(
+
        label::group(&[context]),
+
        label::group(&[filter]),
+
        label::group(&[spacer]),
+
        label::group(&[
+
            draft_n,
+
            draft,
+
            divider.clone(),
+
            open_n,
+
            open,
+
            divider.clone(),
+
            archived_n,
+
            archived,
+
            divider,
+
            merged_n,
+
            merged,
+
        ]),
+
        label::group(&[progress]),
+
    );
+

+
    Widget::new(context_bar).height(1)
+
}
added bin/commands/patch/realm/select.rs
@@ -0,0 +1,173 @@
+
#[path = "select/event.rs"]
+
mod event;
+
#[path = "select/page.rs"]
+
mod page;
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use std::hash::Hash;
+

+
use anyhow::Result;
+
use radicle::patch::PatchId;
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::event::Key;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::Filter;
+
use tui::common::context::Context;
+

+
use tui::realm::ui::subscription;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::{PageStack, Tui};
+
use tui::Exit;
+

+
use page::ListView;
+

+
use super::super::common::Mode;
+

+
type Selection = tui::Selection<PatchId>;
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    Header,
+
    PatchBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    #[default]
+
    GlobalListener,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    #[default]
+
    Tick,
+
    Quit(Option<Selection>),
+
    Batch(Vec<Message>),
+
}
+

+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    quit: bool,
+
    mode: Mode,
+
    filter: Filter,
+
    output: Option<Selection>,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
impl App {
+
    pub fn new(context: Context, mode: Mode, filter: Filter) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: Theme::default(),
+
            quit: false,
+
            mode,
+
            filter,
+
            output: None,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::new(ListView::new(self.mode.clone(), self.filter.clone()));
+
        self.pages.push(home, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = Theme::default();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Quit(output) => {
+
                self.quit = true;
+
                self.output = output;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+
}
+

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

+
        // Add global key listener and subscribe to key events
+
        let global = tui::realm::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(
+
                subscription::quit_clause(Key::Char('q')),
+
                SubClause::Always,
+
            )],
+
        )?;
+

+
        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() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit<Selection>> {
+
        if self.quit {
+
            return Some(Exit {
+
                value: self.output.clone(),
+
            });
+
        }
+
        None
+
    }
+
}
added bin/commands/patch/realm/select/event.rs
@@ -0,0 +1,204 @@
+
use radicle::patch::PatchId;
+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
+
use tui::realm::ui::widget::context::{ContextBar, Shortcuts};
+
use tui::realm::ui::widget::list::PropertyList;
+
use tui::realm::ui::widget::Widget;
+

+
use crate::tui_patch::common::PatchOperation;
+

+
use super::ui::{IdSelect, OperationSelect};
+
use super::Message;
+

+
type Selection = tui::Selection<PatchId>;
+

+
/// 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)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: None,
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(PatchOperation::Show.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('c'),
+
                ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(PatchOperation::Checkout.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('d'),
+
                ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(PatchOperation::Delete.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('e'),
+
                ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(PatchOperation::Edit.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('m'),
+
                ..
+
            }) => submit().map(|id| {
+
                let selection = Selection {
+
                    operation: Some(PatchOperation::Comment.to_string()),
+
                    ids: vec![id],
+
                    args: vec![],
+
                };
+
                Message::Quit(Some(selection))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        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 bin/commands/patch/realm/select/page.rs
@@ -0,0 +1,177 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::ui::widget::context::{Progress, Shortcuts};
+
use tui::realm::ui::widget::Widget;
+
use tui::realm::ui::{layout, subscription};
+
use tui::realm::ViewPage;
+

+
use super::super::common;
+
use super::{ui, Application, Cid, ListCid, Message, Mode};
+

+
///
+
/// Home
+
///
+
pub struct ListView {
+
    active_component: ListCid,
+
    subject: Mode,
+
    filter: Filter,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
}
+

+
impl ListView {
+
    pub fn new(subject: Mode, filter: Filter) -> Self {
+
        Self {
+
            active_component: ListCid::PatchBrowser,
+
            subject,
+
            filter,
+
            shortcuts: HashMap::default(),
+
        }
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
+
        let progress = match ItemState::try_from(state) {
+
            Ok(state) => Progress::Step(
+
                state
+
                    .selected()
+
                    .map(|s| s.saturating_add(1))
+
                    .unwrap_or_default(),
+
                state.len(),
+
            ),
+
            Err(_) => Progress::None,
+
        };
+

+
        let context = common::ui::browse_context(context, theme, self.filter.clone(), progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListView {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::list_navigation(theme);
+
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
+

+
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
+

+
        match self.subject {
+
            Mode::Id => {
+
                let patch_browser =
+
                    ui::id_select(theme, context, self.filter.clone(), None).to_boxed();
+
                self.shortcuts = patch_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
+
            }
+
            Mode::Operation => {
+
                let patch_browser =
+
                    ui::operation_select(theme, context, self.filter.clone(), None).to_boxed();
+
                self.shortcuts = patch_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
+
            }
+
        };
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

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

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        _message: Message,
+
    ) -> Result<Option<Message>> {
+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let context_h = app
+
            .query(&Cid::List(ListCid::Context), Attribute::Height)
+
            .unwrap_or_default()
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = 1u16;
+

+
        let layout = layout::default_page(area, context_h, shortcuts_h);
+

+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
+
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

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

+
        Ok(())
+
    }
+

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

+
        Ok(())
+
    }
+
}
added bin/commands/patch/realm/select/ui.rs
@@ -0,0 +1,158 @@
+
use std::collections::HashMap;
+

+
use radicle::cob::patch::{Patch, PatchId};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::cob::PatchItem;
+
use tui::realm::ui::theme::{style, Theme};
+
use tui::realm::ui::widget::context::Shortcuts;
+
use tui::realm::ui::widget::{Widget, WidgetComponent};
+

+
use tui::realm::ui::widget::container::Tabs;
+
use tui::realm::ui::widget::label::{self};
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
+

+
use super::super::common;
+
use super::ListCid;
+

+
pub struct IdSelect {
+
    theme: Theme,
+
    browser: Widget<common::ui::PatchBrowser>,
+
}
+

+
impl IdSelect {
+
    pub fn new(theme: Theme, browser: Widget<common::ui::PatchBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

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

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            tui::realm::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::realm::ui::shortcut(&self.theme, "enter", "select"),
+
                    tui::realm::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

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

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

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

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

+
pub struct OperationSelect {
+
    theme: Theme,
+
    browser: Widget<common::ui::PatchBrowser>,
+
}
+

+
impl OperationSelect {
+
    pub fn new(theme: Theme, browser: Widget<common::ui::PatchBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

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

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            tui::realm::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::realm::ui::shortcut(&self.theme, "enter", "show"),
+
                    tui::realm::ui::shortcut(&self.theme, "c", "checkout"),
+
                    tui::realm::ui::shortcut(&self.theme, "m", "comment"),
+
                    tui::realm::ui::shortcut(&self.theme, "e", "edit"),
+
                    tui::realm::ui::shortcut(&self.theme, "d", "delete"),
+
                    tui::realm::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

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

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

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

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

+
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
+
    tui::realm::ui::tabs(
+
        theme,
+
        vec![label::reversable("Patches").style(style::cyan())],
+
    )
+
}
+

+
pub fn id_select(
+
    theme: &Theme,
+
    context: &Context,
+
    filter: Filter,
+
    selected: Option<(PatchId, Patch)>,
+
) -> Widget<IdSelect> {
+
    let browser = Widget::new(common::ui::PatchBrowser::new(
+
        theme, context, filter, selected,
+
    ));
+

+
    Widget::new(IdSelect::new(theme.clone(), browser))
+
}
+

+
pub fn operation_select(
+
    theme: &Theme,
+
    context: &Context,
+
    filter: Filter,
+
    selected: Option<(PatchId, Patch)>,
+
) -> Widget<OperationSelect> {
+
    let browser = Widget::new(common::ui::PatchBrowser::new(
+
        theme, context, filter, selected,
+
    ));
+

+
    Widget::new(OperationSelect::new(theme.clone(), browser))
+
}
added bin/commands/patch/realm/suite.rs
@@ -0,0 +1,288 @@
+
#[path = "suite/event.rs"]
+
mod event;
+
#[path = "suite/page.rs"]
+
mod page;
+
#[path = "suite/ui.rs"]
+
mod ui;
+

+
use std::hash::Hash;
+

+
use anyhow::Result;
+

+
use radicle::cob::patch::PatchId;
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::event::Key;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob;
+
use tui::common::cob::patch::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::subscription;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::{PageStack, Tui};
+
use tui::Exit;
+

+
use page::{ListView, PatchView};
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    Header,
+
    PatchBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

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

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    Patch(PatchCid),
+
    #[default]
+
    GlobalListener,
+
    Popup,
+
}
+

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

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum PopupMessage {
+
    Info(String),
+
    Warning(String),
+
    Error(String),
+
    Hide,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    Patch(PatchMessage),
+
    NavigationChanged(u16),
+
    FormSubmitted(String),
+
    Popup(PopupMessage),
+
    #[default]
+
    Tick,
+
    Quit,
+
    Batch(Vec<Message>),
+
}
+

+
#[allow(dead_code)]
+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    filter: Filter,
+
    quit: bool,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
#[allow(dead_code)]
+
impl App {
+
    pub fn new(context: Context, filter: Filter) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: Theme::default(),
+
            filter,
+
            quit: false,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::new(ListView::new(theme.clone(), self.filter.clone()));
+
        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(theme.clone(), (id, patch)));
+
            self.pages.push(view, app, &self.context, theme)?;
+

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

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = Theme::default();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Patch(PatchMessage::Show(id)) => {
+
                self.view_patch(app, id, &theme)?;
+
                Ok(None)
+
            }
+
            Message::Patch(PatchMessage::Leave) => {
+
                self.pages.pop(app)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Info(info)) => {
+
                self.show_info_popup(app, &theme, &info)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Warning(warning)) => {
+
                self.show_warning_popup(app, &theme, &warning)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Error(error)) => {
+
                self.show_error_popup(app, &theme, &error)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Hide) => {
+
                self.hide_popup(app)?;
+
                Ok(None)
+
            }
+
            Message::Quit => {
+
                self.quit = true;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+

+
    fn show_info_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::realm::ui::info(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn show_warning_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::realm::ui::warning(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn show_error_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::realm::ui::error(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn hide_popup(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.blur()?;
+
        app.umount(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+
}
+

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

+
        // Add global key listener and subscribe to key events
+
        let global = tui::realm::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(
+
                subscription::quit_clause(Key::Char('q')),
+
                SubClause::Always,
+
            )],
+
        )?;
+

+
        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);
+
        }
+

+
        if app.mounted(&Cid::Popup) {
+
            app.view(&Cid::Popup, frame, frame.size());
+
        }
+
    }
+

+
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
+
        match app.tick(PollStrategy::Once) {
+
            Ok(messages) if !messages.is_empty() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit<()>> {
+
        if self.quit {
+
            return Some(Exit { value: None });
+
        }
+
        None
+
    }
+
}
added bin/commands/patch/realm/suite/event.rs
@@ -0,0 +1,140 @@
+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
+

+
use radicle_tui::realm::ui::state::ItemState;
+
use radicle_tui::realm::ui::widget::container::{
+
    AppHeader, GlobalListener, LabeledContainer, Popup,
+
};
+
use radicle_tui::realm::ui::widget::context::{ContextBar, Shortcuts};
+
use radicle_tui::realm::ui::widget::list::PropertyList;
+
use radicle_tui::realm::ui::widget::Widget;
+

+
use super::super::common;
+
use super::{ui, Message, PatchMessage, PopupMessage};
+

+
/// 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<common::ui::PatchBrowser> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.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<ui::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<ui::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<Popup> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Popup(PopupMessage::Hide))
+
            }
+
            _ => 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 bin/commands/patch/realm/suite/page.rs
@@ -0,0 +1,346 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use radicle::cob::patch::{Patch, PatchId};
+

+
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::state::ItemState;
+
use tui::realm::ui::theme::Theme;
+
use tui::realm::ui::widget::context::{Progress, Shortcuts};
+
use tui::realm::ui::widget::Widget;
+
use tui::realm::ui::{layout, subscription};
+
use tui::realm::ViewPage;
+

+
use super::{ui, Application, Cid, ListCid, Message, PatchCid};
+

+
///
+
/// Home
+
///
+
pub struct ListView {
+
    active_component: ListCid,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
    filter: Filter,
+
}
+

+
impl ListView {
+
    pub fn new(theme: Theme, filter: Filter) -> Self {
+
        let shortcuts = Self::build_shortcuts(&theme);
+
        Self {
+
            active_component: ListCid::PatchBrowser,
+
            shortcuts,
+
            filter,
+
        }
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            tui::realm::ui::shortcuts(
+
                theme,
+
                vec![
+
                    tui::realm::ui::shortcut(theme, "tab", "section"),
+
                    tui::realm::ui::shortcut(theme, "↑/↓", "navigate"),
+
                    tui::realm::ui::shortcut(theme, "enter", "show"),
+
                    tui::realm::ui::shortcut(theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
+
        let progress = match ItemState::try_from(state) {
+
            Ok(state) => Progress::Step(
+
                state
+
                    .selected()
+
                    .map(|s| s.saturating_add(1))
+
                    .unwrap_or_default(),
+
                state.len(),
+
            ),
+
            Err(_) => Progress::None,
+
        };
+
        let context = ui::browse_context(context, theme, progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListView {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::list_navigation(theme);
+
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
+
        let patch_browser = ui::patches(theme, context, self.filter.clone(), None).to_boxed();
+

+
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
+
        app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

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

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        _message: Message,
+
    ) -> Result<Option<Message>> {
+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let context_h = app
+
            .query(&Cid::List(ListCid::Context), Attribute::Height)
+
            .unwrap_or_default()
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = 1u16;
+

+
        let layout = layout::full_page(area, context_h, shortcuts_h);
+

+
        app.view(&Cid::List(ListCid::Header), frame, layout.navigation);
+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
+
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

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

+
        Ok(())
+
    }
+

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

+
        Ok(())
+
    }
+
}
+

+
///
+
/// Patch detail page
+
///
+
pub struct PatchView {
+
    active_component: PatchCid,
+
    patch: (PatchId, Patch),
+
    shortcuts: HashMap<PatchCid, Widget<Shortcuts>>,
+
}
+

+
impl PatchView {
+
    pub fn new(theme: Theme, patch: (PatchId, Patch)) -> Self {
+
        let shortcuts = Self::build_shortcuts(&theme);
+
        PatchView {
+
            active_component: PatchCid::Activity,
+
            patch,
+
            shortcuts,
+
        }
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<PatchCid, Widget<Shortcuts>> {
+
        [
+
            (
+
                PatchCid::Activity,
+
                tui::realm::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::realm::ui::shortcut(theme, "esc", "back"),
+
                        tui::realm::ui::shortcut(theme, "tab", "section"),
+
                        tui::realm::ui::shortcut(theme, "q", "quit"),
+
                    ],
+
                ),
+
            ),
+
            (
+
                PatchCid::Files,
+
                tui::realm::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::realm::ui::shortcut(theme, "esc", "back"),
+
                        tui::realm::ui::shortcut(theme, "tab", "section"),
+
                        tui::realm::ui::shortcut(theme, "q", "quit"),
+
                    ],
+
                ),
+
            ),
+
        ]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: PatchCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::Patch(PatchCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for PatchView {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::navigation(theme);
+
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
+
        let activity = ui::activity(theme).to_boxed();
+
        let files = ui::files(theme).to_boxed();
+
        let context = ui::context(context, theme, self.patch.clone()).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.remount(Cid::Patch(PatchCid::Context), context, vec![])?;
+

+
        let active_component = Cid::Patch(self.active_component.clone());
+
        app.active(&active_component)?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+

+
        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))?;
+
        app.umount(&Cid::Patch(PatchCid::Context))?;
+
        app.umount(&Cid::Patch(PatchCid::Shortcuts))?;
+
        Ok(())
+
    }
+

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

+
            let active_component = Cid::Patch(self.active_component.clone());
+
            app.active(&active_component)?;
+
            self.update_shortcuts(app, self.active_component.clone())?;
+
        }
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let context_h = app
+
            .query(&Cid::List(ListCid::Context), Attribute::Height)
+
            .unwrap_or_default()
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = 1u16;
+

+
        let layout = layout::full_page(area, context_h, shortcuts_h);
+

+
        app.view(&Cid::Patch(PatchCid::Header), frame, layout.navigation);
+
        app.view(
+
            &Cid::Patch(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+
        app.view(&Cid::Patch(PatchCid::Context), frame, layout.context);
+
        app.view(&Cid::Patch(PatchCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

+
    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(())
+
    }
+
}
+

+
impl From<usize> for PatchCid {
+
    fn from(index: usize) -> Self {
+
        match index {
+
            0 => PatchCid::Activity,
+
            1 => PatchCid::Files,
+
            _ => PatchCid::Activity,
+
        }
+
    }
+
}
added bin/commands/patch/realm/suite/ui.rs
@@ -0,0 +1,171 @@
+
use radicle::cob::patch::{Patch, PatchId};
+
use radicle::node::AliasStore;
+

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

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::Filter;
+
use tui::common::context::Context;
+
use tui::realm::ui::cob;
+
use tui::realm::ui::layout;
+
use tui::realm::ui::theme::{style, Theme};
+
use tui::realm::ui::widget::{Widget, WidgetComponent};
+

+
use tui::realm::ui::widget::container::Tabs;
+
use tui::realm::ui::widget::context::{ContextBar, Progress};
+
use tui::realm::ui::widget::label::{self, Label};
+

+
use super::super::common;
+

+
pub struct Activity {
+
    label: Widget<Label>,
+
}
+

+
impl Activity {
+
    pub fn new(label: Widget<Label>) -> Self {
+
        Self { label }
+
    }
+
}
+

+
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();
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, area));
+
    }
+

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

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

+
pub struct Files {
+
    label: Widget<Label>,
+
}
+

+
impl Files {
+
    pub fn new(label: Widget<Label>) -> Self {
+
        Self { label }
+
    }
+
}
+

+
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();
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, area));
+
    }
+

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

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

+
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
+
    tui::realm::ui::tabs(
+
        theme,
+
        vec![label::reversable("Patches").style(style::magenta())],
+
    )
+
}
+

+
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
+
    tui::realm::ui::tabs(
+
        theme,
+
        vec![
+
            label::reversable("Activity").style(style::magenta()),
+
            label::reversable("Files").style(style::magenta()),
+
        ],
+
    )
+
}
+

+
pub fn patches(
+
    theme: &Theme,
+
    context: &Context,
+
    filter: Filter,
+
    selected: Option<(PatchId, Patch)>,
+
) -> Widget<common::ui::PatchBrowser> {
+
    Widget::new(common::ui::PatchBrowser::new(
+
        theme, context, filter, selected,
+
    ))
+
}
+

+
pub fn activity(_theme: &Theme) -> Widget<Activity> {
+
    let not_implemented = label::default("not implemented").style(style::reset());
+
    let activity = Activity::new(not_implemented);
+

+
    Widget::new(activity)
+
}
+

+
pub fn files(_theme: &Theme) -> Widget<Files> {
+
    let not_implemented = label::default("not implemented").style(style::reset());
+
    let files = Files::new(not_implemented);
+

+
    Widget::new(files)
+
}
+

+
pub fn context(context: &Context, theme: &Theme, patch: (PatchId, Patch)) -> Widget<ContextBar> {
+
    let (id, patch) = patch;
+
    let (_, rev) = patch.latest();
+
    let is_you = *patch.author().id() == context.profile().did();
+

+
    let id = cob::format::cob(&id);
+
    let title = patch.title();
+
    let author = patch.author().id();
+
    let alias = context.profile().aliases().alias(author);
+
    let author = cob::format_author(author, &alias, is_you);
+
    let comments = rev.discussion().len();
+

+
    tui::realm::ui::widget::context::bar(theme, "Patch", &id, title, &author, &comments.to_string())
+
}
+

+
pub fn browse_context(context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
+
    use radicle::cob::patch::State;
+

+
    let mut draft = 0;
+
    let mut open = 0;
+
    let mut archived = 0;
+
    let mut merged = 0;
+

+
    let patches = context.patches().as_ref().unwrap();
+
    for (_, patch) in patches {
+
        match patch.state() {
+
            State::Draft => draft += 1,
+
            State::Open { conflicts: _ } => open += 1,
+
            State::Archived => archived += 1,
+
            State::Merged {
+
                commit: _,
+
                revision: _,
+
            } => merged += 1,
+
        }
+
    }
+

+
    tui::realm::ui::widget::context::bar(
+
        theme,
+
        "Browse",
+
        "",
+
        "",
+
        &format!("{draft} draft | {open} open | {archived} archived | {merged} merged"),
+
        &progress.to_string(),
+
    )
+
}
deleted bin/commands/patch/select.rs
@@ -1,212 +0,0 @@
-
#[path = "select/event.rs"]
-
mod event;
-
#[path = "select/page.rs"]
-
mod page;
-
#[path = "select/ui.rs"]
-
mod ui;
-

-
use std::fmt::Display;
-
use std::hash::Hash;
-

-
use anyhow::Result;
-
use serde::Serialize;
-

-
use tuirealm::application::PollStrategy;
-
use tuirealm::event::Key;
-
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob::patch::Filter;
-
use tui::context::Context;
-

-
use tui::ui::subscription;
-
use tui::ui::theme::Theme;
-
use tui::{Exit, PageStack, SelectionExit, Tui};
-

-
use page::ListView;
-

-
/// The application's mode. It tells the application
-
/// which widgets to render and which output to produce.
-
/// Depends on CLI arguments given by the user.
-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Mode {
-
    #[default]
-
    Operation,
-
    Id,
-
}
-

-
/// The selected patch operation returned by the operation
-
/// selection widget.
-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub enum PatchOperation {
-
    Show,
-
    Checkout,
-
    Delete,
-
    Edit,
-
    Comment,
-
}
-

-
impl Display for PatchOperation {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            PatchOperation::Show => {
-
                write!(f, "show")
-
            }
-
            PatchOperation::Checkout => {
-
                write!(f, "checkout")
-
            }
-
            PatchOperation::Delete => {
-
                write!(f, "delete")
-
            }
-
            PatchOperation::Edit => {
-
                write!(f, "edit")
-
            }
-
            PatchOperation::Comment => {
-
                write!(f, "comment")
-
            }
-
        }
-
    }
-
}
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum ListCid {
-
    Header,
-
    PatchBrowser,
-
    Context,
-
    Shortcuts,
-
}
-

-
/// All component ids known to this application.
-
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
-
pub enum Cid {
-
    List(ListCid),
-
    #[default]
-
    GlobalListener,
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Message {
-
    #[default]
-
    Tick,
-
    Quit(Option<SelectionExit>),
-
    Batch(Vec<Message>),
-
}
-

-
pub struct App {
-
    context: Context,
-
    pages: PageStack<Cid, Message>,
-
    theme: Theme,
-
    quit: bool,
-
    mode: Mode,
-
    filter: Filter,
-
    output: Option<SelectionExit>,
-
}
-

-
/// Creates a new application using a tui-realm-application, mounts all
-
/// components and sets focus to a default one.
-
impl App {
-
    pub fn new(context: Context, mode: Mode, filter: Filter) -> Self {
-
        Self {
-
            context,
-
            pages: PageStack::default(),
-
            theme: Theme::default(),
-
            quit: false,
-
            mode,
-
            filter,
-
            output: None,
-
        }
-
    }
-

-
    fn view_list(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let home = Box::new(ListView::new(self.mode.clone(), self.filter.clone()));
-
        self.pages.push(home, app, &self.context, theme)?;
-

-
        Ok(())
-
    }
-

-
    fn process(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        let theme = Theme::default();
-
        match message {
-
            Message::Batch(messages) => {
-
                let mut results = vec![];
-
                for message in messages {
-
                    if let Some(result) = self.process(app, message)? {
-
                        results.push(result);
-
                    }
-
                }
-
                match results.len() {
-
                    0 => Ok(None),
-
                    1 => Ok(Some(results[0].to_owned())),
-
                    _ => Ok(Some(Message::Batch(results))),
-
                }
-
            }
-
            Message::Quit(output) => {
-
                self.quit = true;
-
                self.output = output;
-
                Ok(None)
-
            }
-
            _ => self
-
                .pages
-
                .peek_mut()?
-
                .update(app, &self.context, &theme, message),
-
        }
-
    }
-
}
-

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

-
        // Add global key listener and subscribe to key events
-
        let global = tui::ui::global_listener().to_boxed();
-
        app.mount(
-
            Cid::GlobalListener,
-
            global,
-
            vec![Sub::new(
-
                subscription::quit_clause(Key::Char('q')),
-
                SubClause::Always,
-
            )],
-
        )?;
-

-
        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() => {
-
                for message in messages {
-
                    let mut msg = Some(message);
-
                    while msg.is_some() {
-
                        msg = self.process(app, msg.unwrap())?;
-
                    }
-
                }
-
                Ok(true)
-
            }
-
            _ => Ok(false),
-
        }
-
    }
-

-
    fn exit(&self) -> Option<Exit<SelectionExit>> {
-
        if self.quit {
-
            return Some(Exit {
-
                value: self.output.clone(),
-
            });
-
        }
-
        None
-
    }
-
}
deleted bin/commands/patch/select/event.rs
@@ -1,186 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
-
use tuirealm::event::{Event, Key, KeyEvent};
-
use tuirealm::{MockComponent, NoUserEvent};
-

-
use radicle_tui as tui;
-

-
use tui::ui::state::ItemState;
-
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
-
use tui::ui::widget::context::{ContextBar, Shortcuts};
-
use tui::ui::widget::list::PropertyList;
-
use tui::ui::widget::Widget;
-
use tui::{Id, SelectionExit};
-

-
use super::ui::{IdSelect, OperationSelect};
-
use super::{Message, PatchOperation};
-

-
/// 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)),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => submit().map(|id| {
-
                let output = SelectionExit::default().with_id(Id::Object(id));
-
                Message::Quit(Some(output))
-
            }),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(PatchOperation::Show.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('c'),
-
                ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(PatchOperation::Checkout.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('d'),
-
                ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(PatchOperation::Delete.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('e'),
-
                ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(PatchOperation::Edit.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('m'),
-
                ..
-
            }) => submit().map(|id| {
-
                let exit = SelectionExit::default()
-
                    .with_operation(PatchOperation::Comment.to_string())
-
                    .with_id(Id::Object(id));
-
                Message::Quit(Some(exit))
-
            }),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        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 bin/commands/patch/select/page.rs
@@ -1,177 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

-
use tui::ui::state::ItemState;
-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob::patch::Filter;
-
use tui::context::Context;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::context::{Progress, Shortcuts};
-
use tui::ui::widget::Widget;
-
use tui::ui::{layout, subscription};
-
use tui::ViewPage;
-

-
use super::super::common;
-
use super::{ui, Application, Cid, ListCid, Message, Mode};
-

-
///
-
/// Home
-
///
-
pub struct ListView {
-
    active_component: ListCid,
-
    subject: Mode,
-
    filter: Filter,
-
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
-
}
-

-
impl ListView {
-
    pub fn new(subject: Mode, filter: Filter) -> Self {
-
        Self {
-
            active_component: ListCid::PatchBrowser,
-
            subject,
-
            filter,
-
            shortcuts: HashMap::default(),
-
        }
-
    }
-

-
    fn update_context(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
-
        let progress = match ItemState::try_from(state) {
-
            Ok(state) => Progress::Step(
-
                state
-
                    .selected()
-
                    .map(|s| s.saturating_add(1))
-
                    .unwrap_or_default(),
-
                state.len(),
-
            ),
-
            Err(_) => Progress::None,
-
        };
-

-
        let context = common::ui::browse_context(context, theme, self.filter.clone(), progress);
-

-
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
-

-
        Ok(())
-
    }
-

-
    fn update_shortcuts(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: ListCid,
-
    ) -> Result<()> {
-
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
-
            app.remount(
-
                Cid::List(ListCid::Shortcuts),
-
                shortcuts.clone().to_boxed(),
-
                vec![],
-
            )?;
-
        }
-

-
        Ok(())
-
    }
-
}
-

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

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

-
        match self.subject {
-
            Mode::Id => {
-
                let patch_browser =
-
                    ui::id_select(theme, context, self.filter.clone(), None).to_boxed();
-
                self.shortcuts = patch_browser.as_ref().shortcuts();
-

-
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
-
            }
-
            Mode::Operation => {
-
                let patch_browser =
-
                    ui::operation_select(theme, context, self.filter.clone(), None).to_boxed();
-
                self.shortcuts = patch_browser.as_ref().shortcuts();
-

-
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
-
            }
-
        };
-

-
        app.active(&Cid::List(self.active_component.clone()))?;
-
        self.update_shortcuts(app, self.active_component.clone())?;
-
        self.update_context(app, context, theme)?;
-

-
        Ok(())
-
    }
-

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

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        _message: Message,
-
    ) -> Result<Option<Message>> {
-
        self.update_context(app, context, theme)?;
-

-
        Ok(None)
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let context_h = app
-
            .query(&Cid::List(ListCid::Context), Attribute::Height)
-
            .unwrap_or_default()
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = 1u16;
-

-
        let layout = layout::default_page(area, context_h, shortcuts_h);
-

-
        app.view(
-
            &Cid::List(self.active_component.clone()),
-
            frame,
-
            layout.component,
-
        );
-

-
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
-
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

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

-
        Ok(())
-
    }
-

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

-
        Ok(())
-
    }
-
}
deleted bin/commands/patch/select/ui.rs
@@ -1,158 +0,0 @@
-
use std::collections::HashMap;
-

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

-
use radicle_tui as tui;
-

-
use tui::cob::patch::Filter;
-
use tui::context::Context;
-
use tui::ui::cob::PatchItem;
-
use tui::ui::theme::{style, Theme};
-
use tui::ui::widget::context::Shortcuts;
-
use tui::ui::widget::{Widget, WidgetComponent};
-

-
use tui::ui::widget::container::Tabs;
-
use tui::ui::widget::label::{self};
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use super::super::common;
-
use super::ListCid;
-

-
pub struct IdSelect {
-
    theme: Theme,
-
    browser: Widget<common::ui::PatchBrowser>,
-
}
-

-
impl IdSelect {
-
    pub fn new(theme: Theme, browser: Widget<common::ui::PatchBrowser>) -> Self {
-
        Self { theme, browser }
-
    }
-

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

-
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::PatchBrowser,
-
            tui::ui::shortcuts(
-
                &self.theme,
-
                vec![
-
                    tui::ui::shortcut(&self.theme, "enter", "select"),
-
                    tui::ui::shortcut(&self.theme, "q", "quit"),
-
                ],
-
            ),
-
        )]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-
}
-

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

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

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

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

-
pub struct OperationSelect {
-
    theme: Theme,
-
    browser: Widget<common::ui::PatchBrowser>,
-
}
-

-
impl OperationSelect {
-
    pub fn new(theme: Theme, browser: Widget<common::ui::PatchBrowser>) -> Self {
-
        Self { theme, browser }
-
    }
-

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

-
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::PatchBrowser,
-
            tui::ui::shortcuts(
-
                &self.theme,
-
                vec![
-
                    tui::ui::shortcut(&self.theme, "enter", "show"),
-
                    tui::ui::shortcut(&self.theme, "c", "checkout"),
-
                    tui::ui::shortcut(&self.theme, "m", "comment"),
-
                    tui::ui::shortcut(&self.theme, "e", "edit"),
-
                    tui::ui::shortcut(&self.theme, "d", "delete"),
-
                    tui::ui::shortcut(&self.theme, "q", "quit"),
-
                ],
-
            ),
-
        )]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-
}
-

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

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

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

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

-
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
-
    tui::ui::tabs(
-
        theme,
-
        vec![label::reversable("Patches").style(style::cyan())],
-
    )
-
}
-

-
pub fn id_select(
-
    theme: &Theme,
-
    context: &Context,
-
    filter: Filter,
-
    selected: Option<(PatchId, Patch)>,
-
) -> Widget<IdSelect> {
-
    let browser = Widget::new(common::ui::PatchBrowser::new(
-
        theme, context, filter, selected,
-
    ));
-

-
    Widget::new(IdSelect::new(theme.clone(), browser))
-
}
-

-
pub fn operation_select(
-
    theme: &Theme,
-
    context: &Context,
-
    filter: Filter,
-
    selected: Option<(PatchId, Patch)>,
-
) -> Widget<OperationSelect> {
-
    let browser = Widget::new(common::ui::PatchBrowser::new(
-
        theme, context, filter, selected,
-
    ));
-

-
    Widget::new(OperationSelect::new(theme.clone(), browser))
-
}
deleted bin/commands/patch/suite.rs
@@ -1,287 +0,0 @@
-
#[path = "suite/event.rs"]
-
mod event;
-
#[path = "suite/page.rs"]
-
mod page;
-
#[path = "suite/ui.rs"]
-
mod ui;
-

-
use std::hash::Hash;
-

-
use anyhow::Result;
-

-
use radicle::cob::patch::PatchId;
-

-
use tuirealm::application::PollStrategy;
-
use tuirealm::event::Key;
-
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob;
-
use tui::cob::patch::Filter;
-
use tui::context::Context;
-
use tui::ui::subscription;
-
use tui::ui::theme::Theme;
-
use tui::{Exit, PageStack, Tui};
-

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

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum ListCid {
-
    Header,
-
    PatchBrowser,
-
    Context,
-
    Shortcuts,
-
}
-

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

-
/// All component ids known to this application.
-
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
-
pub enum Cid {
-
    List(ListCid),
-
    Patch(PatchCid),
-
    #[default]
-
    GlobalListener,
-
    Popup,
-
}
-

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

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum PopupMessage {
-
    Info(String),
-
    Warning(String),
-
    Error(String),
-
    Hide,
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Message {
-
    Patch(PatchMessage),
-
    NavigationChanged(u16),
-
    FormSubmitted(String),
-
    Popup(PopupMessage),
-
    #[default]
-
    Tick,
-
    Quit,
-
    Batch(Vec<Message>),
-
}
-

-
#[allow(dead_code)]
-
pub struct App {
-
    context: Context,
-
    pages: PageStack<Cid, Message>,
-
    theme: Theme,
-
    filter: Filter,
-
    quit: bool,
-
}
-

-
/// Creates a new application using a tui-realm-application, mounts all
-
/// components and sets focus to a default one.
-
#[allow(dead_code)]
-
impl App {
-
    pub fn new(context: Context, filter: Filter) -> Self {
-
        Self {
-
            context,
-
            pages: PageStack::default(),
-
            theme: Theme::default(),
-
            filter,
-
            quit: false,
-
        }
-
    }
-

-
    fn view_list(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let home = Box::new(ListView::new(theme.clone(), self.filter.clone()));
-
        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(theme.clone(), (id, patch)));
-
            self.pages.push(view, app, &self.context, theme)?;
-

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

-
    fn process(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        let theme = Theme::default();
-
        match message {
-
            Message::Batch(messages) => {
-
                let mut results = vec![];
-
                for message in messages {
-
                    if let Some(result) = self.process(app, message)? {
-
                        results.push(result);
-
                    }
-
                }
-
                match results.len() {
-
                    0 => Ok(None),
-
                    1 => Ok(Some(results[0].to_owned())),
-
                    _ => Ok(Some(Message::Batch(results))),
-
                }
-
            }
-
            Message::Patch(PatchMessage::Show(id)) => {
-
                self.view_patch(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Patch(PatchMessage::Leave) => {
-
                self.pages.pop(app)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Info(info)) => {
-
                self.show_info_popup(app, &theme, &info)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Warning(warning)) => {
-
                self.show_warning_popup(app, &theme, &warning)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Error(error)) => {
-
                self.show_error_popup(app, &theme, &error)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Hide) => {
-
                self.hide_popup(app)?;
-
                Ok(None)
-
            }
-
            Message::Quit => {
-
                self.quit = true;
-
                Ok(None)
-
            }
-
            _ => self
-
                .pages
-
                .peek_mut()?
-
                .update(app, &self.context, &theme, message),
-
        }
-
    }
-

-
    fn show_info_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::ui::info(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn show_warning_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::ui::warning(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn show_error_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::ui::error(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn hide_popup(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.blur()?;
-
        app.umount(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-
}
-

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

-
        // Add global key listener and subscribe to key events
-
        let global = tui::ui::global_listener().to_boxed();
-
        app.mount(
-
            Cid::GlobalListener,
-
            global,
-
            vec![Sub::new(
-
                subscription::quit_clause(Key::Char('q')),
-
                SubClause::Always,
-
            )],
-
        )?;
-

-
        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);
-
        }
-

-
        if app.mounted(&Cid::Popup) {
-
            app.view(&Cid::Popup, frame, frame.size());
-
        }
-
    }
-

-
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
-
        match app.tick(PollStrategy::Once) {
-
            Ok(messages) if !messages.is_empty() => {
-
                for message in messages {
-
                    let mut msg = Some(message);
-
                    while msg.is_some() {
-
                        msg = self.process(app, msg.unwrap())?;
-
                    }
-
                }
-
                Ok(true)
-
            }
-
            _ => Ok(false),
-
        }
-
    }
-

-
    fn exit(&self) -> Option<Exit<()>> {
-
        if self.quit {
-
            return Some(Exit { value: None });
-
        }
-
        None
-
    }
-
}
deleted bin/commands/patch/suite/event.rs
@@ -1,139 +0,0 @@
-
use radicle_tui::ui::state::ItemState;
-
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::container::{AppHeader, GlobalListener, LabeledContainer, Popup};
-
use radicle_tui::ui::widget::context::{ContextBar, Shortcuts};
-
use radicle_tui::ui::widget::list::PropertyList;
-

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

-
use super::super::common;
-
use super::{ui, Message, PatchMessage, PopupMessage};
-

-
/// 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<common::ui::PatchBrowser> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.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<ui::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<ui::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<Popup> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Popup(PopupMessage::Hide))
-
            }
-
            _ => 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 bin/commands/patch/suite/page.rs
@@ -1,346 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

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

-
use tui::ui::state::ItemState;
-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::cob::patch::Filter;
-
use tui::context::Context;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::context::{Progress, Shortcuts};
-
use tui::ui::widget::Widget;
-
use tui::ui::{layout, subscription};
-
use tui::ViewPage;
-

-
use super::{ui, Application, Cid, ListCid, Message, PatchCid};
-

-
///
-
/// Home
-
///
-
pub struct ListView {
-
    active_component: ListCid,
-
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
-
    filter: Filter,
-
}
-

-
impl ListView {
-
    pub fn new(theme: Theme, filter: Filter) -> Self {
-
        let shortcuts = Self::build_shortcuts(&theme);
-
        Self {
-
            active_component: ListCid::PatchBrowser,
-
            shortcuts,
-
            filter,
-
        }
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::PatchBrowser,
-
            tui::ui::shortcuts(
-
                theme,
-
                vec![
-
                    tui::ui::shortcut(theme, "tab", "section"),
-
                    tui::ui::shortcut(theme, "↑/↓", "navigate"),
-
                    tui::ui::shortcut(theme, "enter", "show"),
-
                    tui::ui::shortcut(theme, "q", "quit"),
-
                ],
-
            ),
-
        )]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-

-
    fn update_context(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
-
        let progress = match ItemState::try_from(state) {
-
            Ok(state) => Progress::Step(
-
                state
-
                    .selected()
-
                    .map(|s| s.saturating_add(1))
-
                    .unwrap_or_default(),
-
                state.len(),
-
            ),
-
            Err(_) => Progress::None,
-
        };
-
        let context = ui::browse_context(context, theme, progress);
-

-
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
-

-
        Ok(())
-
    }
-

-
    fn update_shortcuts(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: ListCid,
-
    ) -> Result<()> {
-
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
-
            app.remount(
-
                Cid::List(ListCid::Shortcuts),
-
                shortcuts.clone().to_boxed(),
-
                vec![],
-
            )?;
-
        }
-
        Ok(())
-
    }
-
}
-

-
impl ViewPage<Cid, Message> for ListView {
-
    fn mount(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = ui::list_navigation(theme);
-
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
-
        let patch_browser = ui::patches(theme, context, self.filter.clone(), None).to_boxed();
-

-
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
-
        app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
-

-
        app.active(&Cid::List(self.active_component.clone()))?;
-
        self.update_shortcuts(app, self.active_component.clone())?;
-
        self.update_context(app, context, theme)?;
-

-
        Ok(())
-
    }
-

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

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        _message: Message,
-
    ) -> Result<Option<Message>> {
-
        self.update_context(app, context, theme)?;
-

-
        Ok(None)
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let context_h = app
-
            .query(&Cid::List(ListCid::Context), Attribute::Height)
-
            .unwrap_or_default()
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = 1u16;
-

-
        let layout = layout::full_page(area, context_h, shortcuts_h);
-

-
        app.view(&Cid::List(ListCid::Header), frame, layout.navigation);
-
        app.view(
-
            &Cid::List(self.active_component.clone()),
-
            frame,
-
            layout.component,
-
        );
-

-
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
-
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

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

-
        Ok(())
-
    }
-

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

-
        Ok(())
-
    }
-
}
-

-
///
-
/// Patch detail page
-
///
-
pub struct PatchView {
-
    active_component: PatchCid,
-
    patch: (PatchId, Patch),
-
    shortcuts: HashMap<PatchCid, Widget<Shortcuts>>,
-
}
-

-
impl PatchView {
-
    pub fn new(theme: Theme, patch: (PatchId, Patch)) -> Self {
-
        let shortcuts = Self::build_shortcuts(&theme);
-
        PatchView {
-
            active_component: PatchCid::Activity,
-
            patch,
-
            shortcuts,
-
        }
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<PatchCid, Widget<Shortcuts>> {
-
        [
-
            (
-
                PatchCid::Activity,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "tab", "section"),
-
                        tui::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                PatchCid::Files,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "tab", "section"),
-
                        tui::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
        ]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-

-
    fn update_shortcuts(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: PatchCid,
-
    ) -> Result<()> {
-
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
-
            app.remount(
-
                Cid::Patch(PatchCid::Shortcuts),
-
                shortcuts.clone().to_boxed(),
-
                vec![],
-
            )?;
-
        }
-
        Ok(())
-
    }
-
}
-

-
impl ViewPage<Cid, Message> for PatchView {
-
    fn mount(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = ui::navigation(theme);
-
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
-
        let activity = ui::activity(theme).to_boxed();
-
        let files = ui::files(theme).to_boxed();
-
        let context = ui::context(context, theme, self.patch.clone()).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.remount(Cid::Patch(PatchCid::Context), context, vec![])?;
-

-
        let active_component = Cid::Patch(self.active_component.clone());
-
        app.active(&active_component)?;
-
        self.update_shortcuts(app, self.active_component.clone())?;
-

-
        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))?;
-
        app.umount(&Cid::Patch(PatchCid::Context))?;
-
        app.umount(&Cid::Patch(PatchCid::Shortcuts))?;
-
        Ok(())
-
    }
-

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

-
            let active_component = Cid::Patch(self.active_component.clone());
-
            app.active(&active_component)?;
-
            self.update_shortcuts(app, self.active_component.clone())?;
-
        }
-

-
        Ok(None)
-
    }
-

-
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
-
        let area = frame.size();
-
        let context_h = app
-
            .query(&Cid::List(ListCid::Context), Attribute::Height)
-
            .unwrap_or_default()
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = 1u16;
-

-
        let layout = layout::full_page(area, context_h, shortcuts_h);
-

-
        app.view(&Cid::Patch(PatchCid::Header), frame, layout.navigation);
-
        app.view(
-
            &Cid::Patch(self.active_component.clone()),
-
            frame,
-
            layout.component,
-
        );
-
        app.view(&Cid::Patch(PatchCid::Context), frame, layout.context);
-
        app.view(&Cid::Patch(PatchCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

-
    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(())
-
    }
-
}
-

-
impl From<usize> for PatchCid {
-
    fn from(index: usize) -> Self {
-
        match index {
-
            0 => PatchCid::Activity,
-
            1 => PatchCid::Files,
-
            _ => PatchCid::Activity,
-
        }
-
    }
-
}
deleted bin/commands/patch/suite/ui.rs
@@ -1,171 +0,0 @@
-
use radicle::cob::patch::{Patch, PatchId};
-
use radicle::node::AliasStore;
-

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

-
use radicle_tui as tui;
-

-
use tui::cob::patch::Filter;
-
use tui::context::Context;
-
use tui::ui::cob;
-
use tui::ui::layout;
-
use tui::ui::theme::{style, Theme};
-
use tui::ui::widget::{Widget, WidgetComponent};
-

-
use tui::ui::widget::container::Tabs;
-
use tui::ui::widget::context::{ContextBar, Progress};
-
use tui::ui::widget::label::{self, Label};
-

-
use super::super::common;
-

-
pub struct Activity {
-
    label: Widget<Label>,
-
}
-

-
impl Activity {
-
    pub fn new(label: Widget<Label>) -> Self {
-
        Self { label }
-
    }
-
}
-

-
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();
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, area));
-
    }
-

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

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

-
pub struct Files {
-
    label: Widget<Label>,
-
}
-

-
impl Files {
-
    pub fn new(label: Widget<Label>) -> Self {
-
        Self { label }
-
    }
-
}
-

-
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();
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, area));
-
    }
-

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

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

-
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
-
    tui::ui::tabs(
-
        theme,
-
        vec![label::reversable("Patches").style(style::magenta())],
-
    )
-
}
-

-
pub fn navigation(theme: &Theme) -> Widget<Tabs> {
-
    tui::ui::tabs(
-
        theme,
-
        vec![
-
            label::reversable("Activity").style(style::magenta()),
-
            label::reversable("Files").style(style::magenta()),
-
        ],
-
    )
-
}
-

-
pub fn patches(
-
    theme: &Theme,
-
    context: &Context,
-
    filter: Filter,
-
    selected: Option<(PatchId, Patch)>,
-
) -> Widget<common::ui::PatchBrowser> {
-
    Widget::new(common::ui::PatchBrowser::new(
-
        theme, context, filter, selected,
-
    ))
-
}
-

-
pub fn activity(_theme: &Theme) -> Widget<Activity> {
-
    let not_implemented = label::default("not implemented").style(style::reset());
-
    let activity = Activity::new(not_implemented);
-

-
    Widget::new(activity)
-
}
-

-
pub fn files(_theme: &Theme) -> Widget<Files> {
-
    let not_implemented = label::default("not implemented").style(style::reset());
-
    let files = Files::new(not_implemented);
-

-
    Widget::new(files)
-
}
-

-
pub fn context(context: &Context, theme: &Theme, patch: (PatchId, Patch)) -> Widget<ContextBar> {
-
    let (id, patch) = patch;
-
    let (_, rev) = patch.latest();
-
    let is_you = *patch.author().id() == context.profile().did();
-

-
    let id = cob::format::cob(&id);
-
    let title = patch.title();
-
    let author = patch.author().id();
-
    let alias = context.profile().aliases().alias(author);
-
    let author = cob::format_author(author, &alias, is_you);
-
    let comments = rev.discussion().len();
-

-
    tui::ui::widget::context::bar(theme, "Patch", &id, title, &author, &comments.to_string())
-
}
-

-
pub fn browse_context(context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
-
    use radicle::cob::patch::State;
-

-
    let mut draft = 0;
-
    let mut open = 0;
-
    let mut archived = 0;
-
    let mut merged = 0;
-

-
    let patches = context.patches().as_ref().unwrap();
-
    for (_, patch) in patches {
-
        match patch.state() {
-
            State::Draft => draft += 1,
-
            State::Open { conflicts: _ } => open += 1,
-
            State::Archived => archived += 1,
-
            State::Merged {
-
                commit: _,
-
                revision: _,
-
            } => merged += 1,
-
        }
-
    }
-

-
    tui::ui::widget::context::bar(
-
        theme,
-
        "Browse",
-
        "",
-
        "",
-
        &format!("{draft} draft | {open} open | {archived} archived | {merged} merged"),
-
        &progress.to_string(),
-
    )
-
}
deleted src/cob.rs
@@ -1,39 +0,0 @@
-
use std::str::FromStr;
-

-
use anyhow::Result;
-

-
use radicle::cob::Label;
-
use radicle::prelude::Did;
-

-
pub mod format;
-
pub mod inbox;
-
pub mod issue;
-
pub mod patch;
-

-
pub fn parse_labels(input: String) -> Result<Vec<Label>> {
-
    let mut labels = vec![];
-
    if !input.is_empty() {
-
        for name in input.split(',') {
-
            match Label::new(name.trim()) {
-
                Ok(label) => labels.push(label),
-
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse labels.")),
-
            }
-
        }
-
    }
-

-
    Ok(labels)
-
}
-

-
pub fn parse_assignees(input: String) -> Result<Vec<Did>> {
-
    let mut assignees = vec![];
-
    if !input.is_empty() {
-
        for did in input.split(',') {
-
            match Did::from_str(&format!("did:key:{}", did)) {
-
                Ok(did) => assignees.push(did),
-
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse assignees.")),
-
            }
-
        }
-
    }
-

-
    Ok(assignees)
-
}
deleted src/cob/format.rs
@@ -1,7 +0,0 @@
-
use radicle::identity::Did;
-

-
/// Format a DID.
-
pub fn did(did: &Did) -> String {
-
    let nid = did.as_key().to_human();
-
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
-
}
deleted src/cob/inbox.rs
@@ -1,38 +0,0 @@
-
use anyhow::Result;
-

-
use radicle::node::notifications::Notification;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct Filter {}
-

-
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-
pub struct SortBy {
-
    pub reverse: bool,
-
    pub field: &'static str,
-
}
-

-
impl Default for SortBy {
-
    fn default() -> Self {
-
        Self {
-
            reverse: true,
-
            field: "timestamp",
-
        }
-
    }
-
}
-

-
pub fn all(repository: &Repository, profile: &Profile) -> Result<Vec<Notification>> {
-
    let all = profile
-
        .notifications_mut()?
-
        .by_repo(&repository.id, "timestamp")?
-
        .collect::<Vec<_>>();
-

-
    let mut notifications = vec![];
-
    for n in all {
-
        let n = n?;
-
        notifications.push(n);
-
    }
-

-
    Ok(notifications)
-
}
deleted src/cob/issue.rs
@@ -1,150 +0,0 @@
-
use std::fmt::Display;
-

-
use anyhow::Result;
-
use radicle::cob::issue::{Issue, IssueId, Issues};
-
use radicle::cob::Label;
-
use radicle::issue::CloseReason;
-
use radicle::prelude::{Did, Signer};
-
use radicle::storage::git::Repository;
-
use radicle::{issue, Profile};
-

-
use super::format;
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum State {
-
    #[default]
-
    Open,
-
    Solved,
-
    Closed,
-
}
-

-
impl Display for State {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        let state = match self {
-
            State::Open => "open",
-
            State::Solved => "solved",
-
            State::Closed => "closed",
-
        };
-
        f.write_str(state)
-
    }
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct Filter {
-
    state: Option<State>,
-
    assigned: bool,
-
    assignees: Vec<Did>,
-
}
-

-
impl Filter {
-
    pub fn with_state(mut self, state: Option<State>) -> Self {
-
        self.state = state;
-
        self
-
    }
-

-
    pub fn with_assgined(mut self, assigned: bool) -> Self {
-
        self.assigned = assigned;
-
        self
-
    }
-

-
    pub fn with_assginee(mut self, assignee: Did) -> Self {
-
        self.assignees.push(assignee);
-
        self
-
    }
-

-
    pub fn matches(&self, profile: &Profile, issue: &Issue) -> bool {
-
        let matches_state = match self.state {
-
            Some(State::Open) => matches!(issue.state(), issue::State::Open),
-
            Some(State::Solved) => matches!(
-
                issue.state(),
-
                issue::State::Closed {
-
                    reason: CloseReason::Solved
-
                }
-
            ),
-
            Some(State::Closed) => matches!(issue.state(), issue::State::Closed { .. }),
-
            None => true,
-
        };
-

-
        let matches_assgined = self
-
            .assigned
-
            .then(|| {
-
                issue
-
                    .assignees()
-
                    .collect::<Vec<_>>()
-
                    .contains(&&profile.did())
-
            })
-
            .unwrap_or(true);
-

-
        let matches_assignees = (!self.assignees.is_empty())
-
            .then(|| {
-
                self.assignees
-
                    .iter()
-
                    .any(|other| issue.assignees().collect::<Vec<_>>().contains(&other))
-
            })
-
            .unwrap_or(true);
-

-
        matches_state && matches_assgined && matches_assignees
-
    }
-
}
-

-
impl ToString for Filter {
-
    fn to_string(&self) -> String {
-
        let mut filter = String::new();
-
        filter.push(' ');
-

-
        if let Some(state) = &self.state {
-
            filter.push_str(&format!("is:{}", state));
-
            filter.push(' ');
-
        }
-
        if self.assigned {
-
            filter.push_str("is:assigned");
-
            filter.push(' ');
-
        }
-
        if !self.assignees.is_empty() {
-
            filter.push_str("assignees:");
-
            filter.push('[');
-

-
            let mut assignees = self.assignees.iter().peekable();
-
            while let Some(assignee) = assignees.next() {
-
                filter.push_str(&format::did(assignee));
-

-
                if assignees.peek().is_some() {
-
                    filter.push(',');
-
                }
-
            }
-
            filter.push(']');
-
        }
-

-
        filter
-
    }
-
}
-

-
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)?)
-
}
-

-
pub fn create<G: Signer>(
-
    repository: &Repository,
-
    signer: &G,
-
    title: String,
-
    description: String,
-
    labels: &[Label],
-
    assignees: &[Did],
-
) -> Result<IssueId> {
-
    let mut issues = Issues::open(repository)?;
-
    let issue = issues.create(title, description.trim(), labels, assignees, [], signer)?;
-

-
    Ok(*issue.id())
-
}
deleted src/cob/patch.rs
@@ -1,128 +0,0 @@
-
use std::fmt::Display;
-

-
use anyhow::Result;
-

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

-
use super::format;
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum State {
-
    Draft,
-
    #[default]
-
    Open,
-
    Merged,
-
    Archived,
-
}
-

-
impl Display for State {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        let state = match self {
-
            State::Draft => "draft",
-
            State::Open => "open",
-
            State::Merged => "merged",
-
            State::Archived => "archived",
-
        };
-
        f.write_str(state)
-
    }
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct Filter {
-
    state: Option<State>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
}
-

-
impl Filter {
-
    pub fn with_state(mut self, state: Option<State>) -> Self {
-
        self.state = state;
-
        self
-
    }
-

-
    pub fn with_authored(mut self, authored: bool) -> Self {
-
        self.authored = authored;
-
        self
-
    }
-

-
    pub fn with_author(mut self, author: Did) -> Self {
-
        self.authors.push(author);
-
        self
-
    }
-

-
    pub fn matches(&self, profile: &Profile, patch: &Patch) -> bool {
-
        let matches_state = match self.state {
-
            Some(State::Draft) => matches!(patch.state(), patch::State::Draft),
-
            Some(State::Open) => matches!(patch.state(), patch::State::Open { .. }),
-
            Some(State::Merged) => matches!(patch.state(), patch::State::Merged { .. }),
-
            Some(State::Archived) => matches!(patch.state(), patch::State::Archived),
-
            None => true,
-
        };
-

-
        let matches_authored = self
-
            .authored
-
            .then(|| *patch.author().id() == profile.did())
-
            .unwrap_or(true);
-

-
        let matches_authors = (!self.authors.is_empty())
-
            .then(|| {
-
                self.authors
-
                    .iter()
-
                    .any(|other| *patch.author().id() == *other)
-
            })
-
            .unwrap_or(true);
-

-
        matches_state && matches_authored && matches_authors
-
    }
-
}
-

-
impl ToString for Filter {
-
    fn to_string(&self) -> String {
-
        let mut filter = String::new();
-
        filter.push(' ');
-

-
        if let Some(state) = &self.state {
-
            filter.push_str(&format!("is:{}", state));
-
            filter.push(' ');
-
        }
-
        if self.authored {
-
            filter.push_str("is:authored");
-
            filter.push(' ');
-
        }
-
        if !self.authors.is_empty() {
-
            filter.push_str("authors:");
-
            filter.push('[');
-

-
            let mut authors = self.authors.iter().peekable();
-
            while let Some(author) = authors.next() {
-
                filter.push_str(&format::did(author));
-

-
                if authors.peek().is_some() {
-
                    filter.push(',');
-
                }
-
            }
-
            filter.push(']');
-
        }
-

-
        filter
-
    }
-
}
-

-
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/common.rs
@@ -0,0 +1,3 @@
+
pub mod cob;
+
pub mod context;
+
pub mod log;
added src/common/cob.rs
@@ -0,0 +1,39 @@
+
use std::str::FromStr;
+

+
use anyhow::Result;
+

+
use radicle::cob::Label;
+
use radicle::prelude::Did;
+

+
pub mod format;
+
pub mod inbox;
+
pub mod issue;
+
pub mod patch;
+

+
pub fn parse_labels(input: String) -> Result<Vec<Label>> {
+
    let mut labels = vec![];
+
    if !input.is_empty() {
+
        for name in input.split(',') {
+
            match Label::new(name.trim()) {
+
                Ok(label) => labels.push(label),
+
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse labels.")),
+
            }
+
        }
+
    }
+

+
    Ok(labels)
+
}
+

+
pub fn parse_assignees(input: String) -> Result<Vec<Did>> {
+
    let mut assignees = vec![];
+
    if !input.is_empty() {
+
        for did in input.split(',') {
+
            match Did::from_str(&format!("did:key:{}", did)) {
+
                Ok(did) => assignees.push(did),
+
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse assignees.")),
+
            }
+
        }
+
    }
+

+
    Ok(assignees)
+
}
added src/common/cob/format.rs
@@ -0,0 +1,7 @@
+
use radicle::identity::Did;
+

+
/// Format a DID.
+
pub fn did(did: &Did) -> String {
+
    let nid = did.as_key().to_human();
+
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
+
}
added src/common/cob/inbox.rs
@@ -0,0 +1,38 @@
+
use anyhow::Result;
+

+
use radicle::node::notifications::Notification;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct Filter {}
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
pub struct SortBy {
+
    pub reverse: bool,
+
    pub field: &'static str,
+
}
+

+
impl Default for SortBy {
+
    fn default() -> Self {
+
        Self {
+
            reverse: true,
+
            field: "timestamp",
+
        }
+
    }
+
}
+

+
pub fn all(repository: &Repository, profile: &Profile) -> Result<Vec<Notification>> {
+
    let all = profile
+
        .notifications_mut()?
+
        .by_repo(&repository.id, "timestamp")?
+
        .collect::<Vec<_>>();
+

+
    let mut notifications = vec![];
+
    for n in all {
+
        let n = n?;
+
        notifications.push(n);
+
    }
+

+
    Ok(notifications)
+
}
added src/common/cob/issue.rs
@@ -0,0 +1,150 @@
+
use std::fmt::Display;
+

+
use anyhow::Result;
+
use radicle::cob::issue::{Issue, IssueId, Issues};
+
use radicle::cob::Label;
+
use radicle::issue::CloseReason;
+
use radicle::prelude::{Did, Signer};
+
use radicle::storage::git::Repository;
+
use radicle::{issue, Profile};
+

+
use super::format;
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum State {
+
    #[default]
+
    Open,
+
    Solved,
+
    Closed,
+
}
+

+
impl Display for State {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        let state = match self {
+
            State::Open => "open",
+
            State::Solved => "solved",
+
            State::Closed => "closed",
+
        };
+
        f.write_str(state)
+
    }
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct Filter {
+
    state: Option<State>,
+
    assigned: bool,
+
    assignees: Vec<Did>,
+
}
+

+
impl Filter {
+
    pub fn with_state(mut self, state: Option<State>) -> Self {
+
        self.state = state;
+
        self
+
    }
+

+
    pub fn with_assgined(mut self, assigned: bool) -> Self {
+
        self.assigned = assigned;
+
        self
+
    }
+

+
    pub fn with_assginee(mut self, assignee: Did) -> Self {
+
        self.assignees.push(assignee);
+
        self
+
    }
+

+
    pub fn matches(&self, profile: &Profile, issue: &Issue) -> bool {
+
        let matches_state = match self.state {
+
            Some(State::Open) => matches!(issue.state(), issue::State::Open),
+
            Some(State::Solved) => matches!(
+
                issue.state(),
+
                issue::State::Closed {
+
                    reason: CloseReason::Solved
+
                }
+
            ),
+
            Some(State::Closed) => matches!(issue.state(), issue::State::Closed { .. }),
+
            None => true,
+
        };
+

+
        let matches_assgined = self
+
            .assigned
+
            .then(|| {
+
                issue
+
                    .assignees()
+
                    .collect::<Vec<_>>()
+
                    .contains(&&profile.did())
+
            })
+
            .unwrap_or(true);
+

+
        let matches_assignees = (!self.assignees.is_empty())
+
            .then(|| {
+
                self.assignees
+
                    .iter()
+
                    .any(|other| issue.assignees().collect::<Vec<_>>().contains(&other))
+
            })
+
            .unwrap_or(true);
+

+
        matches_state && matches_assgined && matches_assignees
+
    }
+
}
+

+
impl ToString for Filter {
+
    fn to_string(&self) -> String {
+
        let mut filter = String::new();
+
        filter.push(' ');
+

+
        if let Some(state) = &self.state {
+
            filter.push_str(&format!("is:{}", state));
+
            filter.push(' ');
+
        }
+
        if self.assigned {
+
            filter.push_str("is:assigned");
+
            filter.push(' ');
+
        }
+
        if !self.assignees.is_empty() {
+
            filter.push_str("assignees:");
+
            filter.push('[');
+

+
            let mut assignees = self.assignees.iter().peekable();
+
            while let Some(assignee) = assignees.next() {
+
                filter.push_str(&format::did(assignee));
+

+
                if assignees.peek().is_some() {
+
                    filter.push(',');
+
                }
+
            }
+
            filter.push(']');
+
        }
+

+
        filter
+
    }
+
}
+

+
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)?)
+
}
+

+
pub fn create<G: Signer>(
+
    repository: &Repository,
+
    signer: &G,
+
    title: String,
+
    description: String,
+
    labels: &[Label],
+
    assignees: &[Did],
+
) -> Result<IssueId> {
+
    let mut issues = Issues::open(repository)?;
+
    let issue = issues.create(title, description.trim(), labels, assignees, [], signer)?;
+

+
    Ok(*issue.id())
+
}
added src/common/cob/patch.rs
@@ -0,0 +1,128 @@
+
use std::fmt::Display;
+

+
use anyhow::Result;
+

+
use radicle::cob::patch::{Patch, PatchId, Patches};
+
use radicle::identity::Did;
+
use radicle::storage::git::Repository;
+
use radicle::{patch, Profile};
+

+
use super::format;
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum State {
+
    Draft,
+
    #[default]
+
    Open,
+
    Merged,
+
    Archived,
+
}
+

+
impl Display for State {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        let state = match self {
+
            State::Draft => "draft",
+
            State::Open => "open",
+
            State::Merged => "merged",
+
            State::Archived => "archived",
+
        };
+
        f.write_str(state)
+
    }
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct Filter {
+
    state: Option<State>,
+
    authored: bool,
+
    authors: Vec<Did>,
+
}
+

+
impl Filter {
+
    pub fn with_state(mut self, state: Option<State>) -> Self {
+
        self.state = state;
+
        self
+
    }
+

+
    pub fn with_authored(mut self, authored: bool) -> Self {
+
        self.authored = authored;
+
        self
+
    }
+

+
    pub fn with_author(mut self, author: Did) -> Self {
+
        self.authors.push(author);
+
        self
+
    }
+

+
    pub fn matches(&self, profile: &Profile, patch: &Patch) -> bool {
+
        let matches_state = match self.state {
+
            Some(State::Draft) => matches!(patch.state(), patch::State::Draft),
+
            Some(State::Open) => matches!(patch.state(), patch::State::Open { .. }),
+
            Some(State::Merged) => matches!(patch.state(), patch::State::Merged { .. }),
+
            Some(State::Archived) => matches!(patch.state(), patch::State::Archived),
+
            None => true,
+
        };
+

+
        let matches_authored = self
+
            .authored
+
            .then(|| *patch.author().id() == profile.did())
+
            .unwrap_or(true);
+

+
        let matches_authors = (!self.authors.is_empty())
+
            .then(|| {
+
                self.authors
+
                    .iter()
+
                    .any(|other| *patch.author().id() == *other)
+
            })
+
            .unwrap_or(true);
+

+
        matches_state && matches_authored && matches_authors
+
    }
+
}
+

+
impl ToString for Filter {
+
    fn to_string(&self) -> String {
+
        let mut filter = String::new();
+
        filter.push(' ');
+

+
        if let Some(state) = &self.state {
+
            filter.push_str(&format!("is:{}", state));
+
            filter.push(' ');
+
        }
+
        if self.authored {
+
            filter.push_str("is:authored");
+
            filter.push(' ');
+
        }
+
        if !self.authors.is_empty() {
+
            filter.push_str("authors:");
+
            filter.push('[');
+

+
            let mut authors = self.authors.iter().peekable();
+
            while let Some(author) = authors.next() {
+
                filter.push_str(&format::did(author));
+

+
                if authors.peek().is_some() {
+
                    filter.push(',');
+
                }
+
            }
+
            filter.push(']');
+
        }
+

+
        filter
+
    }
+
}
+

+
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/common/context.rs
@@ -0,0 +1,184 @@
+
use std::fmt::Display;
+

+
use radicle::cob::issue::{Issue, IssueId};
+
use radicle::cob::patch::{Patch, PatchId};
+
use radicle::crypto::ssh::keystore::{Keystore, MemorySigner};
+
use radicle::crypto::Signer;
+
use radicle::identity::{Project, RepoId};
+
use radicle::node::notifications::Notification;
+
use radicle::profile::env::RAD_PASSPHRASE;
+
use radicle::storage::git::Repository;
+
use radicle::storage::{ReadRepository, ReadStorage};
+
use radicle::Profile;
+

+
use radicle_term as term;
+
use term::{passphrase, spinner, Passphrase};
+

+
use inquire::validator;
+

+
use super::cob::inbox;
+

+
/// Git revision parameter. Supports extended SHA-1 syntax.
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Rev(String);
+

+
impl From<String> for Rev {
+
    fn from(value: String) -> Self {
+
        Rev(value)
+
    }
+
}
+

+
impl Display for Rev {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "{}", self.0)
+
    }
+
}
+

+
/// Application context that holds all the project data that are
+
/// needed to render it.
+
pub struct Context {
+
    profile: Profile,
+
    rid: RepoId,
+
    project: Project,
+
    repository: Repository,
+
    issues: Option<Vec<(IssueId, Issue)>>,
+
    patches: Option<Vec<(PatchId, Patch)>>,
+
    notifications: Vec<Notification>,
+
    signer: Option<Box<dyn Signer>>,
+
}
+

+
impl Context {
+
    pub fn new(profile: Profile, rid: RepoId) -> Result<Self, anyhow::Error> {
+
        let repository = profile.storage.repository(rid).unwrap();
+
        let project = repository.identity_doc()?.project()?;
+
        let notifications = inbox::all(&repository, &profile)?;
+

+
        let issues = None;
+
        let patches = None;
+
        let signer = None;
+

+
        Ok(Self {
+
            profile,
+
            rid,
+
            project,
+
            repository,
+
            issues,
+
            patches,
+
            notifications,
+
            signer,
+
        })
+
    }
+

+
    pub fn with_issues(mut self) -> Self {
+
        use super::cob::issue;
+
        self.issues = Some(issue::all(&self.repository).unwrap_or_default());
+
        self
+
    }
+

+
    pub fn with_patches(mut self) -> Self {
+
        use super::cob::patch;
+
        self.patches = Some(patch::all(&self.repository).unwrap_or_default());
+
        self
+
    }
+

+
    pub fn with_signer(mut self) -> Self {
+
        self.signer = signer(&self.profile).ok();
+
        self
+
    }
+

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

+
    pub fn rid(&self) -> &RepoId {
+
        &self.rid
+
    }
+

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

+
    pub fn repository(&self) -> &Repository {
+
        &self.repository
+
    }
+

+
    pub fn issues(&self) -> &Option<Vec<(IssueId, Issue)>> {
+
        &self.issues
+
    }
+

+
    pub fn patches(&self) -> &Option<Vec<(PatchId, Patch)>> {
+
        &self.patches
+
    }
+

+
    pub fn notifications(&self) -> &Vec<Notification> {
+
        &self.notifications
+
    }
+

+
    #[allow(clippy::borrowed_box)]
+
    pub fn signer(&self) -> &Option<Box<dyn Signer>> {
+
        &self.signer
+
    }
+

+
    pub fn reload_patches(&mut self) {
+
        use super::cob::patch;
+
        self.patches = Some(patch::all(&self.repository).unwrap_or_default());
+
    }
+

+
    pub fn reload_issues(&mut self) {
+
        use super::cob::issue;
+
        self.issues = Some(issue::all(&self.repository).unwrap_or_default());
+
    }
+
}
+

+
/// Validates secret key passphrases.
+
#[derive(Clone)]
+
pub struct PassphraseValidator {
+
    keystore: Keystore,
+
}
+

+
impl PassphraseValidator {
+
    /// Create a new validator.
+
    pub fn new(keystore: Keystore) -> Self {
+
        Self { keystore }
+
    }
+
}
+

+
impl inquire::validator::StringValidator for PassphraseValidator {
+
    fn validate(
+
        &self,
+
        input: &str,
+
    ) -> Result<validator::Validation, inquire::error::CustomUserError> {
+
        let passphrase = Passphrase::from(input.to_owned());
+
        if self.keystore.is_valid_passphrase(&passphrase)? {
+
            Ok(validator::Validation::Valid)
+
        } else {
+
            Ok(validator::Validation::Invalid(
+
                validator::ErrorMessage::from("Invalid passphrase, please try again"),
+
            ))
+
        }
+
    }
+
}
+

+
/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user,
+
/// if we're connected to a TTY.
+
pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
+
    if let Ok(signer) = profile.signer() {
+
        return Ok(signer);
+
    }
+
    let validator = PassphraseValidator::new(profile.keystore.clone());
+
    let passphrase = match passphrase(validator) {
+
        Ok(p) => p,
+
        Err(inquire::InquireError::NotTTY) => {
+
            return Err(anyhow::anyhow!(
+
                "running in non-interactive mode, please set `{RAD_PASSPHRASE}` to unseal your key",
+
            ));
+
        }
+
        Err(e) => return Err(e.into()),
+
    };
+
    let spinner = spinner("Unsealing key...");
+
    let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
+

+
    spinner.finish();
+

+
    Ok(signer.boxed())
+
}
added src/common/log.rs
@@ -0,0 +1,15 @@
+
use log::LevelFilter;
+

+
use radicle::profile::Profile;
+

+
pub fn enable(profile: &Profile, cmd: &str, op: &str) -> Result<(), anyhow::Error> {
+
    let logfile = format!(
+
        "{}/rad-tui-{}-{}.log",
+
        profile.home().path().to_string_lossy(),
+
        cmd,
+
        op,
+
    );
+
    simple_logging::log_to_file(logfile, LevelFilter::Info)?;
+

+
    Ok(())
+
}
deleted src/context.rs
@@ -1,184 +0,0 @@
-
use std::fmt::Display;
-

-
use radicle::cob::issue::{Issue, IssueId};
-
use radicle::cob::patch::{Patch, PatchId};
-
use radicle::crypto::ssh::keystore::{Keystore, MemorySigner};
-
use radicle::crypto::Signer;
-
use radicle::identity::{Project, RepoId};
-
use radicle::node::notifications::Notification;
-
use radicle::profile::env::RAD_PASSPHRASE;
-
use radicle::storage::git::Repository;
-
use radicle::storage::{ReadRepository, ReadStorage};
-
use radicle::Profile;
-

-
use radicle_term as term;
-
use term::{passphrase, spinner, Passphrase};
-

-
use inquire::validator;
-

-
use crate::cob::inbox;
-

-
/// Git revision parameter. Supports extended SHA-1 syntax.
-
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub struct Rev(String);
-

-
impl From<String> for Rev {
-
    fn from(value: String) -> Self {
-
        Rev(value)
-
    }
-
}
-

-
impl Display for Rev {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        write!(f, "{}", self.0)
-
    }
-
}
-

-
/// Application context that holds all the project data that are
-
/// needed to render it.
-
pub struct Context {
-
    profile: Profile,
-
    rid: RepoId,
-
    project: Project,
-
    repository: Repository,
-
    issues: Option<Vec<(IssueId, Issue)>>,
-
    patches: Option<Vec<(PatchId, Patch)>>,
-
    notifications: Vec<Notification>,
-
    signer: Option<Box<dyn Signer>>,
-
}
-

-
impl Context {
-
    pub fn new(profile: Profile, rid: RepoId) -> Result<Self, anyhow::Error> {
-
        let repository = profile.storage.repository(rid).unwrap();
-
        let project = repository.identity_doc()?.project()?;
-
        let notifications = inbox::all(&repository, &profile)?;
-

-
        let issues = None;
-
        let patches = None;
-
        let signer = None;
-

-
        Ok(Self {
-
            profile,
-
            rid,
-
            project,
-
            repository,
-
            issues,
-
            patches,
-
            notifications,
-
            signer,
-
        })
-
    }
-

-
    pub fn with_issues(mut self) -> Self {
-
        use crate::cob::issue;
-
        self.issues = Some(issue::all(&self.repository).unwrap_or_default());
-
        self
-
    }
-

-
    pub fn with_patches(mut self) -> Self {
-
        use crate::cob::patch;
-
        self.patches = Some(patch::all(&self.repository).unwrap_or_default());
-
        self
-
    }
-

-
    pub fn with_signer(mut self) -> Self {
-
        self.signer = signer(&self.profile).ok();
-
        self
-
    }
-

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

-
    pub fn rid(&self) -> &RepoId {
-
        &self.rid
-
    }
-

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

-
    pub fn repository(&self) -> &Repository {
-
        &self.repository
-
    }
-

-
    pub fn issues(&self) -> &Option<Vec<(IssueId, Issue)>> {
-
        &self.issues
-
    }
-

-
    pub fn patches(&self) -> &Option<Vec<(PatchId, Patch)>> {
-
        &self.patches
-
    }
-

-
    pub fn notifications(&self) -> &Vec<Notification> {
-
        &self.notifications
-
    }
-

-
    #[allow(clippy::borrowed_box)]
-
    pub fn signer(&self) -> &Option<Box<dyn Signer>> {
-
        &self.signer
-
    }
-

-
    pub fn reload_patches(&mut self) {
-
        use crate::cob::patch;
-
        self.patches = Some(patch::all(&self.repository).unwrap_or_default());
-
    }
-

-
    pub fn reload_issues(&mut self) {
-
        use crate::cob::issue;
-
        self.issues = Some(issue::all(&self.repository).unwrap_or_default());
-
    }
-
}
-

-
/// Validates secret key passphrases.
-
#[derive(Clone)]
-
pub struct PassphraseValidator {
-
    keystore: Keystore,
-
}
-

-
impl PassphraseValidator {
-
    /// Create a new validator.
-
    pub fn new(keystore: Keystore) -> Self {
-
        Self { keystore }
-
    }
-
}
-

-
impl inquire::validator::StringValidator for PassphraseValidator {
-
    fn validate(
-
        &self,
-
        input: &str,
-
    ) -> Result<validator::Validation, inquire::error::CustomUserError> {
-
        let passphrase = Passphrase::from(input.to_owned());
-
        if self.keystore.is_valid_passphrase(&passphrase)? {
-
            Ok(validator::Validation::Valid)
-
        } else {
-
            Ok(validator::Validation::Invalid(
-
                validator::ErrorMessage::from("Invalid passphrase, please try again"),
-
            ))
-
        }
-
    }
-
}
-

-
/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user,
-
/// if we're connected to a TTY.
-
pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
-
    if let Ok(signer) = profile.signer() {
-
        return Ok(signer);
-
    }
-
    let validator = PassphraseValidator::new(profile.keystore.clone());
-
    let passphrase = match passphrase(validator) {
-
        Ok(p) => p,
-
        Err(inquire::InquireError::NotTTY) => {
-
            return Err(anyhow::anyhow!(
-
                "running in non-interactive mode, please set `{RAD_PASSPHRASE}` to unseal your key",
-
            ));
-
        }
-
        Err(e) => return Err(e.into()),
-
    };
-
    let spinner = spinner("Unsealing key...");
-
    let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
-

-
    spinner.finish();
-

-
    Ok(signer.boxed())
-
}
added src/flux.rs
@@ -0,0 +1,4 @@
+
pub mod event;
+
pub mod store;
+
pub mod termination;
+
pub mod ui;
added src/flux/event.rs
@@ -0,0 +1,5 @@
+
#[derive(Clone, Copy)]
+
pub enum Event {
+
    Key(termion::event::Key),
+
    Resize,
+
}
added src/flux/store.rs
@@ -0,0 +1,94 @@
+
use std::fmt::Debug;
+
use std::marker::PhantomData;
+
use std::time::Duration;
+

+
use tokio::sync::broadcast;
+
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
+

+
use crate::Exit;
+

+
use super::termination::{Interrupted, Terminator};
+

+
const STORE_TICK_RATE: Duration = Duration::from_millis(1000);
+

+
pub trait State<A, P>
+
where
+
    P: Clone + Debug + Send + Sync,
+
{
+
    fn tick(&self);
+

+
    fn handle_action(&mut self, action: A) -> Option<Exit<P>>;
+
}
+

+
pub struct Store<A, S, P>
+
where
+
    S: State<A, P> + Clone + Send + Sync,
+
    P: Clone + Debug + Send + Sync,
+
{
+
    state_tx: UnboundedSender<S>,
+
    _phantom: PhantomData<(A, P)>,
+
}
+

+
impl<A, S, P> Store<A, S, P>
+
where
+
    S: State<A, P> + Clone + Send + Sync,
+
    P: Clone + Debug + Send + Sync,
+
{
+
    pub fn new() -> (Self, UnboundedReceiver<S>) {
+
        let (state_tx, state_rx) = mpsc::unbounded_channel::<S>();
+

+
        (
+
            Store {
+
                state_tx,
+
                _phantom: PhantomData,
+
            },
+
            state_rx,
+
        )
+
    }
+
}
+

+
impl<A, S, P> Store<A, S, P>
+
where
+
    S: State<A, P> + Clone + Debug + Send + Sync + 'static,
+
    P: Clone + Debug + Send + Sync + 'static,
+
{
+
    pub async fn main_loop(
+
        self,
+
        mut state: S,
+
        mut terminator: Terminator<P>,
+
        mut action_rx: UnboundedReceiver<A>,
+
        mut interrupt_rx: broadcast::Receiver<Interrupted<P>>,
+
    ) -> anyhow::Result<Interrupted<P>> {
+
        // the initial state once
+
        self.state_tx.send(state.clone())?;
+

+
        let mut ticker = tokio::time::interval(STORE_TICK_RATE);
+

+
        let result = loop {
+
            tokio::select! {
+
                // Handle the actions coming from the UI
+
                // and process them to do async operations
+
                Some(action) = action_rx.recv() => {
+
                    if let Some(exit) = state.handle_action(action) {
+
                        let interrupted = Interrupted::User { payload: exit.value };
+
                        let _ = terminator.terminate(interrupted.clone());
+

+
                        break interrupted;
+
                    }
+
                },
+
                // Tick to terminate the select every N milliseconds
+
                _ = ticker.tick() => {
+
                    state.tick();
+
                },
+
                // Catch and handle interrupt signal to gracefully shutdown
+
                Ok(interrupted) = interrupt_rx.recv() => {
+
                    break interrupted;
+
                }
+
            }
+

+
            self.state_tx.send(state.clone())?;
+
        };
+

+
        Ok(result)
+
    }
+
}
added src/flux/termination.rs
@@ -0,0 +1,66 @@
+
use std::fmt::Debug;
+

+
#[cfg(unix)]
+
use tokio::signal::unix::signal;
+
use tokio::sync::broadcast;
+

+
#[derive(Debug, Clone)]
+
pub enum Interrupted<P>
+
where
+
    P: Clone + Send + Sync + Debug,
+
{
+
    OsSignal,
+
    User { payload: Option<P> },
+
}
+

+
#[derive(Debug, Clone)]
+
pub struct Terminator<P>
+
where
+
    P: Clone + Send + Sync + Debug,
+
{
+
    interrupt_tx: broadcast::Sender<Interrupted<P>>,
+
}
+

+
impl<P> Terminator<P>
+
where
+
    P: Clone + Send + Sync + Debug + 'static,
+
{
+
    pub fn new(interrupt_tx: broadcast::Sender<Interrupted<P>>) -> Self {
+
        Self { interrupt_tx }
+
    }
+

+
    pub fn terminate(&mut self, interrupted: Interrupted<P>) -> anyhow::Result<()> {
+
        self.interrupt_tx.send(interrupted)?;
+

+
        Ok(())
+
    }
+
}
+

+
#[cfg(unix)]
+
async fn terminate_by_unix_signal<P>(mut terminator: Terminator<P>)
+
where
+
    P: Clone + Send + Sync + Debug + 'static,
+
{
+
    let mut interrupt_signal = signal(tokio::signal::unix::SignalKind::interrupt())
+
        .expect("failed to create interrupt signal stream");
+

+
    interrupt_signal.recv().await;
+

+
    terminator
+
        .terminate(Interrupted::OsSignal)
+
        .expect("failed to send interrupt signal");
+
}
+

+
// create a broadcast channel for retrieving the application kill signal
+
pub fn create_termination<P>() -> (Terminator<P>, broadcast::Receiver<Interrupted<P>>)
+
where
+
    P: Clone + Send + Sync + Debug + 'static,
+
{
+
    let (tx, rx) = broadcast::channel(1);
+
    let terminator = Terminator::new(tx);
+

+
    #[cfg(unix)]
+
    tokio::spawn(terminate_by_unix_signal(terminator.clone()));
+

+
    (terminator, rx)
+
}
added src/flux/ui.rs
@@ -0,0 +1,133 @@
+
pub mod cob;
+
pub mod ext;
+
pub mod format;
+
pub mod layout;
+
pub mod span;
+
pub mod theme;
+
pub mod widget;
+

+
use std::fmt::Debug;
+
use std::io::{self};
+
use std::thread;
+
use std::time::Duration;
+

+
// use termion::event::Event;
+
use termion::input::TermRead;
+
use termion::raw::{IntoRawMode, RawTerminal};
+

+
use ratatui::prelude::*;
+

+
use tokio::sync::broadcast;
+
use tokio::sync::mpsc::{self, UnboundedReceiver};
+

+
use super::event::Event;
+
use super::store::State;
+
use super::termination::Interrupted;
+
use super::ui::widget::{Render, Widget};
+

+
type Backend = TermionBackend<RawTerminal<io::Stdout>>;
+

+
const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
+
const INLINE_HEIGHT: u16 = 20;
+

+
pub struct Frontend<A> {
+
    action_tx: mpsc::UnboundedSender<A>,
+
}
+

+
impl<A> Frontend<A> {
+
    pub fn new() -> (Self, UnboundedReceiver<A>) {
+
        let (action_tx, action_rx) = mpsc::unbounded_channel();
+

+
        (Self { action_tx }, action_rx)
+
    }
+

+
    pub async fn main_loop<S, W, P>(
+
        self,
+
        mut state_rx: UnboundedReceiver<S>,
+
        mut interrupt_rx: broadcast::Receiver<Interrupted<P>>,
+
    ) -> anyhow::Result<Interrupted<P>>
+
    where
+
        S: State<A, P>,
+
        W: Widget<S, A> + Render<()>,
+
        P: Clone + Send + Sync + Debug,
+
    {
+
        let mut terminal = setup_terminal()?;
+
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
+
        let mut events_rx = events();
+

+
        let mut root = {
+
            let state = state_rx.recv().await.unwrap();
+

+
            W::new(&state, self.action_tx.clone())
+
        };
+

+
        let result: anyhow::Result<Interrupted<P>> = loop {
+
            tokio::select! {
+
                // Tick to terminate the select every N milliseconds
+
                _ = ticker.tick() => (),
+
                Some(event) = events_rx.recv() => match event {
+
                    Event::Key(key) => root.handle_key_event(key),
+
                    Event::Resize => (),
+
                },
+
                // Handle state updates
+
                Some(state) = state_rx.recv() => {
+
                    root = root.move_with_state(&state);
+
                },
+
                // Catch and handle interrupt signal to gracefully shutdown
+
                Ok(interrupted) = interrupt_rx.recv() => {
+
                    let size = terminal.get_frame().size();
+
                    let _ = terminal.set_cursor(size.x, size.y);
+

+
                    break Ok(interrupted);
+
                }
+
            }
+
            terminal.draw(|frame| root.render::<Backend>(frame, frame.size(), ()))?;
+
        };
+

+
        restore_terminal(&mut terminal)?;
+

+
        result
+
    }
+
}
+

+
fn setup_terminal() -> anyhow::Result<Terminal<Backend>> {
+
    let stdout = io::stdout().into_raw_mode()?;
+
    let options = TerminalOptions {
+
        viewport: Viewport::Inline(INLINE_HEIGHT),
+
    };
+

+
    Ok(Terminal::with_options(
+
        TermionBackend::new(stdout),
+
        options,
+
    )?)
+
}
+

+
fn restore_terminal(terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
+
    terminal.clear()?;
+
    Ok(())
+
}
+

+
fn events() -> mpsc::UnboundedReceiver<Event> {
+
    let (tx, rx) = mpsc::unbounded_channel();
+
    let events_tx = tx.clone();
+
    thread::spawn(move || {
+
        let stdin = io::stdin();
+
        for key in stdin.keys().flatten() {
+
            if events_tx.send(Event::Key(key)).is_err() {
+
                return;
+
            }
+
        }
+
    });
+

+
    let events_tx = tx.clone();
+
    if let Ok(mut signals) = signal_hook::iterator::Signals::new([libc::SIGWINCH]) {
+
        thread::spawn(move || {
+
            for _ in signals.forever() {
+
                if events_tx.send(Event::Resize).is_err() {
+
                    return;
+
                }
+
            }
+
        });
+
    }
+
    rx
+
}
added src/flux/ui/cob.rs
@@ -0,0 +1,477 @@
+
use radicle::crypto::PublicKey;
+
use radicle::git::Oid;
+
use radicle::identity::Did;
+
use radicle::node::{Alias, NodeId};
+
use radicle::{issue, patch, Profile};
+
use ratatui::style::{Color, Style, Stylize};
+
use ratatui::widgets::Cell;
+

+
use radicle::cob::{self, Label, ObjectId, Timestamp};
+
use radicle::issue::{Issue, IssueId, Issues};
+
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
+
use radicle::node::AliasStore;
+
use radicle::patch::{Patch, PatchId, Patches};
+
use radicle::storage::git::Repository;
+
use radicle::storage::{ReadRepository, RefUpdate};
+

+
use super::widget::ToRow;
+
use super::{format, span};
+

+
#[derive(Clone, Debug)]
+
pub struct AuthorItem {
+
    pub nid: Option<NodeId>,
+
    pub alias: Option<Alias>,
+
    pub you: bool,
+
}
+

+
impl AuthorItem {
+
    pub fn new(nid: Option<NodeId>, profile: &Profile) -> Self {
+
        let alias = match nid {
+
            Some(nid) => profile.alias(&nid),
+
            None => None,
+
        };
+
        let you = nid.map(|nid| nid == *profile.id()).unwrap_or_default();
+

+
        Self { nid, alias, you }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum NotificationKindItem {
+
    Branch {
+
        name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
    Cob {
+
        type_name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
}
+

+
impl TryFrom<(&Repository, &Notification)> for NotificationKindItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Repository, &Notification)) -> Result<Self, Self::Error> {
+
        let (repo, notification) = value;
+
        // TODO: move out of here
+
        let issues = Issues::open(repo)?;
+
        let patches = Patches::open(repo)?;
+

+
        match &notification.kind {
+
            NotificationKind::Branch { name } => {
+
                let (head, message) = if let Some(head) = notification.update.new() {
+
                    let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
+
                    (Some(head), message)
+
                } else {
+
                    (None, String::new())
+
                };
+
                let status = match notification
+
                    .update
+
                    .new()
+
                    .map(|oid| repo.is_ancestor_of(oid, head.unwrap()))
+
                    .transpose()
+
                {
+
                    Ok(Some(true)) => "merged",
+
                    Ok(Some(false)) | Ok(None) => match notification.update {
+
                        RefUpdate::Updated { .. } => "updated",
+
                        RefUpdate::Created { .. } => "created",
+
                        RefUpdate::Deleted { .. } => "deleted",
+
                        RefUpdate::Skipped { .. } => "skipped",
+
                    },
+
                    Err(e) => return Err(e.into()),
+
                }
+
                .to_owned();
+

+
                Ok(NotificationKindItem::Branch {
+
                    name: name.to_string(),
+
                    summary: message,
+
                    status: status.to_string(),
+
                    id: head.map(ObjectId::from),
+
                })
+
            }
+
            NotificationKind::Cob { type_name, id } => {
+
                let (category, summary, status) = if *type_name == *cob::issue::TYPENAME {
+
                    let Some(issue) = issues.get(id)? else {
+
                        // Issue could have been deleted after notification was created.
+
                        anyhow::bail!("Issue deleted after notification was created");
+
                    };
+
                    (
+
                        String::from("issue"),
+
                        issue.title().to_owned(),
+
                        issue.state().to_string(),
+
                    )
+
                } else if *type_name == *cob::patch::TYPENAME {
+
                    let Some(patch) = patches.get(id)? else {
+
                        // Patch could have been deleted after notification was created.
+
                        anyhow::bail!("patch deleted after notification was created");
+
                    };
+
                    (
+
                        String::from("patch"),
+
                        patch.title().to_owned(),
+
                        patch.state().to_string(),
+
                    )
+
                } else {
+
                    (type_name.to_string(), "".to_owned(), String::new())
+
                };
+

+
                Ok(NotificationKindItem::Cob {
+
                    type_name: category.to_string(),
+
                    summary: summary.to_string(),
+
                    status: status.to_string(),
+
                    id: Some(*id),
+
                })
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct NotificationItem {
+
    /// Unique notification ID.
+
    pub id: NotificationId,
+
    /// Mark this notification as seen.
+
    pub seen: bool,
+
    /// Wrapped notification kind.
+
    pub kind: NotificationKindItem,
+
    /// The author
+
    pub author: AuthorItem,
+
    /// Time the update has happened.
+
    pub timestamp: Timestamp,
+
}
+

+
impl TryFrom<(&Profile, &Repository, &Notification)> for NotificationItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Profile, &Repository, &Notification)) -> Result<Self, Self::Error> {
+
        let (profile, repo, notification) = value;
+
        let kind = NotificationKindItem::try_from((repo, notification))?;
+

+
        Ok(NotificationItem {
+
            id: notification.id,
+
            seen: notification.status.is_read(),
+
            kind,
+
            author: AuthorItem::new(notification.remote, profile),
+
            timestamp: notification.timestamp.into(),
+
        })
+
    }
+
}
+

+
impl ToRow<8> for NotificationItem {
+
    fn to_row(&self) -> [Cell; 8] {
+
        let (type_name, summary, status, kind_id) = match &self.kind {
+
            NotificationKindItem::Branch {
+
                name,
+
                summary,
+
                status,
+
                id: _,
+
            } => ("branch".to_string(), summary, status, name.to_string()),
+
            NotificationKindItem::Cob {
+
                type_name,
+
                summary,
+
                status,
+
                id,
+
            } => {
+
                let id = id.map(|id| format::cob(&id)).unwrap_or_default();
+
                (type_name.to_string(), summary, status, id.to_string())
+
            }
+
        };
+

+
        let id = span::notification_id(format!(" {:-03}", &self.id));
+
        let seen = if self.seen {
+
            span::blank()
+
        } else {
+
            span::primary(" ● ".into())
+
        };
+
        let kind_id = span::primary(kind_id);
+
        let summary = span::default(summary.to_string());
+
        let type_name = span::notification_type(type_name);
+

+
        let status = match status.as_str() {
+
            "archived" => span::default(status.to_string()).yellow(),
+
            "draft" => span::default(status.to_string()).gray().dim(),
+
            "updated" => span::primary(status.to_string()),
+
            "open" | "created" => span::positive(status.to_string()),
+
            "closed" | "merged" => span::ternary(status.to_string()),
+
            _ => span::default(status.to_string()),
+
        };
+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(format!("{} (you)", alias))
+
                } else {
+
                    span::alias(alias.to_string())
+
                }
+
            }
+
            None => match self.author.nid {
+
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
                None => span::alias("".to_string()),
+
            },
+
        };
+

+
        let timestamp = span::timestamp(format::timestamp(&self.timestamp));
+

+
        [
+
            id.into(),
+
            seen.into(),
+
            kind_id.into(),
+
            summary.into(),
+
            type_name.into(),
+
            status.into(),
+
            author.into(),
+
            timestamp.into(),
+
        ]
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct IssueItem {
+
    /// Issue OID.
+
    pub id: IssueId,
+
    /// Issue state.
+
    pub state: issue::State,
+
    /// Issue title.
+
    pub title: String,
+
    /// Issue author.
+
    pub author: AuthorItem,
+
    /// Issue labels.
+
    pub labels: Vec<Label>,
+
    /// Issue assignees.
+
    pub assignees: Vec<AuthorItem>,
+
    /// Time when issue was opened.
+
    pub timestamp: Timestamp,
+
}
+

+
impl IssueItem {
+
    pub fn new(profile: &Profile, issue: (IssueId, Issue)) -> Result<Self, anyhow::Error> {
+
        let (id, issue) = issue;
+

+
        Ok(Self {
+
            id,
+
            state: *issue.state(),
+
            title: issue.title().into(),
+
            author: AuthorItem {
+
                nid: Some(*issue.author().id),
+
                alias: profile.aliases().alias(&issue.author().id),
+
                you: *issue.author().id == *profile.did(),
+
            },
+
            labels: issue.labels().cloned().collect(),
+
            assignees: issue
+
                .assignees()
+
                .map(|did| AuthorItem {
+
                    nid: Some(**did),
+
                    alias: profile.aliases().alias(did),
+
                    you: *did == profile.did(),
+
                })
+
                .collect::<Vec<_>>(),
+
            timestamp: issue.timestamp(),
+
        })
+
    }
+
}
+

+
impl ToRow<8> for IssueItem {
+
    fn to_row(&self) -> [Cell; 8] {
+
        let (state, state_color) = format_issue_state(&self.state);
+

+
        let state = span::default(state).style(Style::default().fg(state_color));
+
        let id = span::primary(format::cob(&self.id));
+
        let title = span::default(self.title.clone());
+

+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(format!("{} (you)", alias))
+
                } else {
+
                    span::alias(alias.to_string())
+
                }
+
            }
+
            None => match self.author.nid {
+
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
                None => span::alias("".to_string()),
+
            },
+
        };
+
        let did = match self.author.nid {
+
            Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
            None => span::alias("".to_string()),
+
        };
+
        let labels = span::labels(format_labels(&self.labels));
+
        let assignees = self
+
            .assignees
+
            .iter()
+
            .map(|author| (author.nid, author.alias.clone(), author.you))
+
            .collect::<Vec<_>>();
+
        let assignees = span::alias(format_assignees(&assignees));
+
        let opened = span::timestamp(format::timestamp(&self.timestamp));
+

+
        [
+
            state.into(),
+
            id.into(),
+
            title.into(),
+
            author.into(),
+
            did.into(),
+
            labels.into(),
+
            assignees.into(),
+
            opened.into(),
+
        ]
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct PatchItem {
+
    /// Patch OID.
+
    pub id: PatchId,
+
    /// Patch state.
+
    pub state: patch::State,
+
    /// Patch title.
+
    pub title: String,
+
    /// Author of the latest revision.
+
    pub author: AuthorItem,
+
    /// Head of the latest revision.
+
    pub head: Oid,
+
    /// Lines added by the latest revision.
+
    pub added: u16,
+
    /// Lines removed by the latest revision.
+
    pub removed: u16,
+
    /// Time when patch was opened.
+
    pub timestamp: Timestamp,
+
}
+

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

+
        Ok(Self {
+
            id,
+
            state: patch.state().clone(),
+
            title: patch.title().into(),
+
            author: AuthorItem {
+
                nid: Some(*patch.author().id),
+
                alias: profile.aliases().alias(&patch.author().id),
+
                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 ToRow<9> for PatchItem {
+
    fn to_row(&self) -> [Cell; 9] {
+
        let (state, color) = format_patch_state(&self.state);
+

+
        let state = span::default(state).style(Style::default().fg(color));
+
        let id = span::primary(format::cob(&self.id));
+
        let title = span::default(self.title.clone());
+

+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(format!("{} (you)", alias))
+
                } else {
+
                    span::alias(alias.to_string())
+
                }
+
            }
+
            None => match self.author.nid {
+
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
                None => span::alias("".to_string()),
+
            },
+
        };
+
        let did = match self.author.nid {
+
            Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
            None => span::alias("".to_string()),
+
        };
+

+
        let head = span::ternary(format::oid(self.head));
+
        let added = span::positive(format!("+{}", self.added));
+
        let removed = span::negative(format!("-{}", self.removed));
+
        let updated = span::timestamp(format::timestamp(&self.timestamp));
+

+
        [
+
            state.into(),
+
            id.into(),
+
            title.into(),
+
            author.into(),
+
            did.into(),
+
            head.into(),
+
            added.into(),
+
            removed.into(),
+
            updated.into(),
+
        ]
+
    }
+
}
+

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

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

+
pub fn format_labels(labels: &[Label]) -> String {
+
    let mut output = String::new();
+
    let mut labels = labels.iter().peekable();
+

+
    while let Some(label) = labels.next() {
+
        output.push_str(&label.to_string());
+

+
        if labels.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
+

+
pub fn format_author(did: &Did, alias: &Option<Alias>, is_you: bool) -> String {
+
    let author = match alias {
+
        Some(alias) => format!("{alias}"),
+
        None => format::did(did),
+
    };
+

+
    if is_you {
+
        format!("{} (you)", author)
+
    } else {
+
        author
+
    }
+
}
+

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

+
    while let Some((assignee, alias, is_you)) = assignees.next() {
+
        if let Some(assignee) = assignee {
+
            output.push_str(&format_author(&Did::from(assignee), alias, *is_you));
+
        }
+

+
        if assignees.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
added src/flux/ui/ext.rs
@@ -0,0 +1,223 @@
+
use ratatui::buffer::Buffer;
+
use ratatui::layout::Rect;
+
use ratatui::style::Style;
+
use ratatui::symbols;
+
use ratatui::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::Rounded,
+
            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::to_border_set(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_left)
+
                    .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_top)
+
                    .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_right)
+
                    .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_bottom)
+
                    .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::line::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::line::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);
+
        }
+
    }
+
}
+

+
pub struct FooterBlock {
+
    /// 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 FooterBlock {
+
    fn default() -> Self {
+
        Self {
+
            borders: Borders::NONE,
+
            border_style: Default::default(),
+
            border_type: BorderType::Rounded,
+
            style: Default::default(),
+
        }
+
    }
+
}
+

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

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

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

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

+
impl Widget for FooterBlock {
+
    fn render(self, area: Rect, buf: &mut Buffer) {
+
        if area.area() == 0 {
+
            return;
+
        }
+
        buf.set_style(area, self.style);
+
        let symbols = BorderType::to_border_set(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_left)
+
                    .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_top)
+
                    .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_right)
+
                    .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_bottom)
+
                    .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.bottom_right)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
+
            buf.get_mut(area.right() - 1, area.top())
+
                .set_symbol(symbols::line::VERTICAL_LEFT)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
+
            buf.get_mut(area.left(), area.bottom() - 1)
+
                .set_symbol(symbols.bottom_left)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::LEFT | Borders::TOP) {
+
            buf.get_mut(area.left(), area.top())
+
                .set_symbol(symbols::line::VERTICAL_RIGHT)
+
                .set_style(self.border_style);
+
        }
+
    }
+
}
added src/flux/ui/format.rs
@@ -0,0 +1,27 @@
+
use radicle::cob::{ObjectId, Timestamp};
+
use radicle::prelude::Did;
+

+
/// Format a git Oid.
+
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
+
    format!("{:.7}", oid.into())
+
}
+

+
/// Format a COB id.
+
pub fn cob(id: &ObjectId) -> String {
+
    format!("{:.7}", id.to_string())
+
}
+

+
/// Format a DID.
+
pub fn did(did: &Did) -> String {
+
    let nid = did.as_key().to_human();
+
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
+
}
+

+
/// Format a timestamp.
+
pub fn timestamp(time: &Timestamp) -> String {
+
    let fmt = timeago::Formatter::new();
+
    let now = Timestamp::now();
+
    let duration = std::time::Duration::from_secs(now.as_secs() - time.as_secs());
+

+
    fmt.convert(duration)
+
}
added src/flux/ui/layout.rs
@@ -0,0 +1,33 @@
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};
+

+
pub struct DefaultPage {
+
    pub component: Rect,
+
    pub context: Rect,
+
    pub shortcuts: Rect,
+
}
+

+
pub fn default_page(area: Rect, context_h: u16, shortcuts_h: u16) -> DefaultPage {
+
    let margin_h = 1u16;
+
    let component_h = area
+
        .height
+
        .saturating_sub(context_h.saturating_add(shortcuts_h));
+

+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(margin_h)
+
        .constraints(
+
            [
+
                Constraint::Length(component_h),
+
                Constraint::Length(context_h),
+
                Constraint::Length(shortcuts_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area);
+

+
    DefaultPage {
+
        component: layout[0],
+
        context: layout[1],
+
        shortcuts: layout[2],
+
    }
+
}
added src/flux/ui/span.rs
@@ -0,0 +1,57 @@
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::Text;
+

+
use crate::flux::ui::theme::style;
+

+
pub fn blank() -> Text<'static> {
+
    Text::styled("", Style::default())
+
}
+

+
pub fn default(content: String) -> Text<'static> {
+
    Text::styled(content, Style::default())
+
}
+

+
pub fn primary(content: String) -> Text<'static> {
+
    default(content).style(style::cyan())
+
}
+

+
pub fn secondary(content: String) -> Text<'static> {
+
    default(content).style(style::magenta())
+
}
+

+
pub fn ternary(content: String) -> Text<'static> {
+
    default(content).style(style::blue())
+
}
+

+
pub fn positive(content: String) -> Text<'static> {
+
    default(content).style(style::green())
+
}
+

+
pub fn negative(content: String) -> Text<'static> {
+
    default(content).style(style::red())
+
}
+

+
pub fn badge(content: String) -> Text<'static> {
+
    let content = &format!(" {content} ");
+
    default(content.to_string()).magenta().reversed()
+
}
+

+
pub fn alias(content: String) -> Text<'static> {
+
    secondary(content)
+
}
+

+
pub fn labels(content: String) -> Text<'static> {
+
    ternary(content)
+
}
+

+
pub fn timestamp(content: String) -> Text<'static> {
+
    default(content).style(style::gray().dim())
+
}
+

+
pub fn notification_id(content: String) -> Text<'static> {
+
    default(content).style(style::gray().dim())
+
}
+

+
pub fn notification_type(content: String) -> Text<'static> {
+
    default(content).style(style::gray().dim())
+
}
added src/flux/ui/theme.rs
@@ -0,0 +1,55 @@
+
pub mod style {
+
    use ratatui::style::{Color, Style, Stylize};
+

+
    pub fn reset() -> Style {
+
        Style::default().fg(Color::Reset)
+
    }
+

+
    pub fn red() -> Style {
+
        Style::default().fg(Color::Red)
+
    }
+

+
    pub fn green() -> Style {
+
        Style::default().fg(Color::Green)
+
    }
+

+
    pub fn yellow() -> Style {
+
        Style::default().fg(Color::Yellow)
+
    }
+

+
    pub fn blue() -> Style {
+
        Style::default().fg(Color::Blue)
+
    }
+

+
    pub fn magenta() -> Style {
+
        Style::default().fg(Color::Magenta)
+
    }
+

+
    pub fn cyan() -> Style {
+
        Style::default().fg(Color::Cyan)
+
    }
+

+
    pub fn lightblue() -> Style {
+
        Style::default().fg(Color::LightBlue)
+
    }
+

+
    pub fn gray() -> Style {
+
        Style::default().fg(Color::Gray)
+
    }
+

+
    pub fn darkgray() -> Style {
+
        Style::default().fg(Color::DarkGray)
+
    }
+

+
    pub fn border(focus: bool) -> Style {
+
        if focus {
+
            Style::default().fg(Color::Indexed(239))
+
        } else {
+
            Style::default().fg(Color::Indexed(236))
+
        }
+
    }
+

+
    pub fn highlight() -> Style {
+
        cyan().not_dim().reversed()
+
    }
+
}
added src/flux/ui/widget.rs
@@ -0,0 +1,227 @@
+
pub mod container;
+

+
use std::fmt::Debug;
+

+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::prelude::*;
+
use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, TableState};
+

+
use super::theme::style;
+

+
pub trait Widget<S, A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized;
+

+
    fn move_with_state(self, state: &S) -> Self
+
    where
+
        Self: Sized;
+

+
    fn name(&self) -> &str;
+

+
    fn handle_key_event(&mut self, key: Key);
+
}
+

+
pub trait Render<P> {
+
    fn render<B: ratatui::backend::Backend>(&self, frame: &mut Frame, area: Rect, props: P);
+
}
+

+
pub struct Shortcut {
+
    pub short: String,
+
    pub long: String,
+
}
+

+
impl Shortcut {
+
    pub fn new(short: &str, long: &str) -> Self {
+
        Self {
+
            short: short.to_string(),
+
            long: long.to_string(),
+
        }
+
    }
+
}
+

+
pub struct ShortcutsProps {
+
    pub shortcuts: Vec<Shortcut>,
+
    pub divider: char,
+
}
+

+
pub struct Shortcuts<A> {
+
    pub action_tx: UnboundedSender<A>,
+
}
+

+
impl<S, A> Widget<S, A> for Shortcuts<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "shortcuts"
+
    }
+

+
    fn handle_key_event(&mut self, _key: termion::event::Key) {}
+
}
+

+
impl<A> Render<ShortcutsProps> for Shortcuts<A> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: ShortcutsProps) {
+
        use ratatui::widgets::Table;
+

+
        let mut shortcuts = props.shortcuts.iter().peekable();
+
        let mut row = vec![];
+

+
        while let Some(shortcut) = shortcuts.next() {
+
            let short = Text::from(shortcut.short.clone()).style(style::gray());
+
            let long = Text::from(shortcut.long.clone()).style(style::gray().dim());
+
            let spacer = Text::from(String::new());
+
            let divider = Text::from(format!(" {} ", props.divider)).style(style::gray().dim());
+

+
            row.push((shortcut.short.chars().count(), short));
+
            row.push((1, spacer));
+
            row.push((shortcut.long.chars().count(), long));
+

+
            if shortcuts.peek().is_some() {
+
                row.push((3, divider));
+
            }
+
        }
+

+
        let row_copy = row.clone();
+
        let row: Vec<Text<'_>> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(_, text)| text.clone())
+
            .collect();
+
        let widths: Vec<Constraint> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(width, _)| Constraint::Length(*width as u16))
+
            .collect();
+

+
        let table = Table::new([Row::new(row)], widths).column_spacing(0);
+
        frame.render_widget(table, area);
+
    }
+
}
+

+
pub trait ToRow<const W: usize> {
+
    fn to_row(&self) -> [Cell; W];
+
}
+

+
#[derive(Debug)]
+
pub struct TableProps<R: ToRow<W>, const W: usize> {
+
    pub items: Vec<R>,
+
    pub focus: bool,
+
    pub widths: [Constraint; W],
+
    pub has_header: bool,
+
    pub has_footer: bool,
+
    pub cutoff: usize,
+
    pub cutoff_after: usize,
+
}
+

+
pub struct Table<A> {
+
    /// Sending actions to the state store
+
    pub action_tx: UnboundedSender<A>,
+
    /// Internal selection state
+
    state: TableState,
+
}
+

+
impl<A> Table<A> {
+
    pub fn selected(&self) -> Option<usize> {
+
        self.state.selected()
+
    }
+

+
    pub fn prev(&mut self) {
+
        let selected = self.selected().map(|current| current.saturating_sub(1));
+
        self.state.select(selected);
+
    }
+

+
    pub fn next(&mut self, len: usize) {
+
        let selected = self.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                current.saturating_add(1)
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.select(selected);
+
    }
+
}
+

+
impl<S, A> Widget<S, A> for Table<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            state: TableState::default().with_selected(Some(0)),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "shortcuts"
+
    }
+

+
    fn handle_key_event(&mut self, _key: Key) {}
+
}
+

+
impl<A, R, const W: usize> Render<TableProps<R, W>> for Table<A>
+
where
+
    R: ToRow<W> + Debug,
+
{
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: TableProps<R, W>) {
+
        let widths = props.widths.to_vec();
+
        let widths = if area.width < props.cutoff as u16 {
+
            widths.iter().take(props.cutoff_after).collect::<Vec<_>>()
+
        } else {
+
            widths.iter().collect::<Vec<_>>()
+
        };
+

+
        let borders = match (props.has_header, props.has_footer) {
+
            (false, false) => Borders::ALL,
+
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
+
            (false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
+
            (true, true) => Borders::LEFT | Borders::RIGHT,
+
        };
+

+
        let rows = props
+
            .items
+
            .iter()
+
            .map(|item| Row::new(item.to_row()))
+
            .collect::<Vec<_>>();
+
        let rows = ratatui::widgets::Table::default()
+
            .rows(rows)
+
            .widths(widths)
+
            .column_spacing(1)
+
            .block(
+
                Block::default()
+
                    .border_style(style::border(props.focus))
+
                    .border_type(BorderType::Rounded)
+
                    .borders(borders),
+
            )
+
            .highlight_style(style::highlight());
+

+
        frame.render_stateful_widget(rows, area, &mut self.state.clone());
+
    }
+
}
added src/flux/ui/widget/container.rs
@@ -0,0 +1,155 @@
+
use std::fmt::Debug;
+

+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::prelude::*;
+
use ratatui::widgets::{BorderType, Borders, Row};
+

+
use crate::flux::ui::ext::{FooterBlock, HeaderBlock};
+
use crate::flux::ui::theme::style;
+

+
use super::{Render, Widget};
+

+
#[derive(Debug)]
+
pub struct FooterProps<'a, const W: usize> {
+
    pub cells: [Text<'a>; W],
+
    pub widths: [Constraint; W],
+
    pub cutoff: usize,
+
    pub cutoff_after: usize,
+
    pub focus: bool,
+
}
+

+
pub struct Footer<A> {
+
    /// Sending actions to the state store
+
    pub action_tx: UnboundedSender<A>,
+
}
+

+
impl<S, A> Widget<S, A> for Footer<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "footer"
+
    }
+

+
    fn handle_key_event(&mut self, _key: Key) {}
+
}
+

+
impl<'a, A, const W: usize> Render<FooterProps<'a, W>> for Footer<A> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: FooterProps<W>) {
+
        let widths = props.widths.to_vec();
+
        let widths = if area.width < props.cutoff as u16 {
+
            widths.iter().take(props.cutoff_after).collect::<Vec<_>>()
+
        } else {
+
            widths.iter().collect::<Vec<_>>()
+
        };
+

+
        let footer_block = FooterBlock::default()
+
            .borders(Borders::ALL)
+
            .border_style(style::border(props.focus))
+
            .border_type(BorderType::Rounded);
+

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

+
        let footer = ratatui::widgets::Table::default()
+
            .column_spacing(1)
+
            .header(Row::new(props.cells))
+
            .widths(widths);
+

+
        frame.render_widget(footer_block, area);
+
        frame.render_widget(footer, footer_layout[0]);
+
    }
+
}
+

+
#[derive(Debug)]
+
pub struct HeaderProps<'a, const W: usize> {
+
    pub cells: [Text<'a>; W],
+
    pub widths: [Constraint; W],
+
    pub cutoff: usize,
+
    pub cutoff_after: usize,
+
    pub focus: bool,
+
}
+

+
pub struct Header<A> {
+
    /// Sending actions to the state store
+
    pub action_tx: UnboundedSender<A>,
+
}
+

+
impl<S, A> Widget<S, A> for Header<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "footer"
+
    }
+

+
    fn handle_key_event(&mut self, _key: Key) {}
+
}
+

+
impl<'a, A, const W: usize> Render<HeaderProps<'a, W>> for Header<A> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: HeaderProps<W>) {
+
        let widths = props.widths.to_vec();
+
        let widths = if area.width < props.cutoff as u16 {
+
            widths.iter().take(props.cutoff_after).collect::<Vec<_>>()
+
        } else {
+
            widths.iter().collect::<Vec<_>>()
+
        };
+

+
        // Render header
+
        let block = HeaderBlock::default()
+
            .borders(Borders::ALL)
+
            .border_style(style::border(props.focus))
+
            .border_type(BorderType::Rounded);
+

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

+
        let header = Row::new(props.cells).style(style::reset().bold());
+
        let header = ratatui::widgets::Table::default()
+
            .column_spacing(1)
+
            .header(header)
+
            .widths(widths.clone());
+

+
        frame.render_widget(block, area);
+
        frame.render_widget(header, header_layout[0]);
+
    }
+
}
modified src/lib.rs
@@ -1,91 +1,42 @@
-
use std::fmt::Display;
-
use std::hash::Hash;
-
use std::time::Duration;
-

use anyhow::Result;
-
use radicle::node::notifications::NotificationId;
-
use serde::ser::{Serialize, SerializeStruct, Serializer};
-

-
use radicle::cob::ObjectId;
-

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

-
pub mod cob;
-
pub mod context;
-
pub mod log;
-
pub mod ui;
-

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

-
/// 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, Return>
-
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<()>;
+
use serde::ser::{Serialize, SerializeStruct, Serializer};

-
    /// 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>;
+
pub mod common;

-
    /// Should draw the application to a frame.
-
    fn view(&mut self, app: &mut Application<Id, Message, NoUserEvent>, frame: &mut Frame);
+
#[cfg(feature = "realm")]
+
pub mod realm;

-
    /// Should return `Some` if the application is requested to quit.
-
    fn exit(&self) -> Option<Exit<Return>>;
-
}
+
#[cfg(feature = "flux")]
+
pub mod flux;

/// An optional return value.
+
#[derive(Clone, Debug)]
pub struct Exit<T> {
    pub value: Option<T>,
}

-
/// Returned ids can be of type `ObjectId` or `NotificationId`.
-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum Id {
-
    Object(ObjectId),
-
    Notification(NotificationId),
-
}
-

-
impl Display for Id {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            Id::Object(id) => {
-
                write!(f, "{id}")
-
            }
-
            Id::Notification(id) => {
-
                write!(f, "{id}")
-
            }
-
        }
-
    }
-
}
-

/// The output that is returned by all selection interfaces.
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct SelectionExit {
-
    operation: Option<String>,
-
    ids: Vec<Id>,
-
    args: Vec<String>,
+
pub struct Selection<I>
+
where
+
    I: ToString,
+
{
+
    pub operation: Option<String>,
+
    pub ids: Vec<I>,
+
    pub args: Vec<String>,
}

-
impl SelectionExit {
+
impl<I> Selection<I>
+
where
+
    I: ToString,
+
{
    pub fn with_operation(mut self, operation: String) -> Self {
        self.operation = Some(operation);
        self
    }

-
    pub fn with_id(mut self, id: Id) -> Self {
+
    pub fn with_id(mut self, id: I) -> Self {
        self.ids.push(id);
        self
    }
@@ -96,7 +47,10 @@ impl SelectionExit {
    }
}

-
impl Serialize for SelectionExit {
+
impl<I> Serialize for Selection<I>
+
where
+
    I: ToString,
+
{
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
@@ -105,184 +59,9 @@ impl Serialize for SelectionExit {
        state.serialize_field("operation", &self.operation)?;
        state.serialize_field(
            "ids",
-
            &self
-
                .ids
-
                .iter()
-
                .map(|id| format!("{}", id))
-
                .collect::<Vec<_>>(),
+
            &self.ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
        )?;
        state.serialize_field("args", &self.args)?;
        state.end()
    }
}
-

-
/// 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 {
-
        let terminal = TerminalBridge::new().expect("Cannot create terminal bridge");
-

-
        Self { terminal }
-
    }
-

-
    /// 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, Return>(
-
        &mut self,
-
        tui: &mut T,
-
        interval: u64,
-
    ) -> Result<Option<Return>>
-
    where
-
        T: Tui<Id, Message, Return>,
-
        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.exit().is_none() {
-
            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(tui.exit().unwrap().value)
-
    }
-
}
-

-
/// `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<Id, Message>
-
where
-
    Id: Eq + PartialEq + Clone + Hash,
-
    Message: Eq,
-
{
-
    /// Will be called whenever a view page is pushed onto the page stack. Should create and mount all widgets.
-
    fn mount(
-
        &mut self,
-
        app: &mut Application<Id, 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<Id, 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<Id, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        message: Message,
-
    ) -> Result<Option<Message>>;
-

-
    /// 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<Id, 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<Id, 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<Id, Message, NoUserEvent>) -> Result<()>;
-
}
-

-
/// 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<Id, Message>
-
where
-
    Id: Eq + PartialEq + Clone + Hash,
-
    Message: Eq,
-
{
-
    pages: Vec<Box<dyn ViewPage<Id, Message>>>,
-
}
-

-
impl<Id, Message> PageStack<Id, Message>
-
where
-
    Id: Eq + PartialEq + Clone + Hash,
-
    Message: Eq,
-
{
-
    pub fn push(
-
        &mut self,
-
        mut page: Box<dyn ViewPage<Id, Message>>,
-
        app: &mut Application<Id, 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<Id, 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<Id, Message>>> {
-
        match self.pages.last_mut() {
-
            Some(page) => Ok(page),
-
            None => Err(anyhow::anyhow!(
-
                "Could not peek active page. Page stack is empty."
-
            )),
-
        }
-
    }
-
}
deleted src/log.rs
@@ -1,15 +0,0 @@
-
use log::LevelFilter;
-

-
use radicle::profile::Profile;
-

-
pub fn enable(profile: &Profile, cmd: &str, op: &str) -> Result<(), anyhow::Error> {
-
    let logfile = format!(
-
        "{}/rad-tui-{}-{}.log",
-
        profile.home().path().to_string_lossy(),
-
        cmd,
-
        op,
-
    );
-
    simple_logging::log_to_file(logfile, LevelFilter::Info)?;
-

-
    Ok(())
-
}
added src/realm.rs
@@ -0,0 +1,211 @@
+
pub mod ui;
+

+
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};
+

+
use crate::common::context::Context;
+
use crate::Exit;
+
use ui::theme::Theme;
+

+
/// 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, Return>
+
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 `Some` if the application is requested to quit.
+
    fn exit(&self) -> Option<Exit<Return>>;
+
}
+

+
/// 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 {
+
        let terminal = TerminalBridge::new().expect("Cannot create terminal bridge");
+

+
        Self { terminal }
+
    }
+

+
    /// 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, Return>(
+
        &mut self,
+
        tui: &mut T,
+
        interval: u64,
+
    ) -> Result<Option<Return>>
+
    where
+
        T: Tui<Id, Message, Return>,
+
        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.exit().is_none() {
+
            if update || resize {
+
                self.terminal
+
                    .raw_mut()
+
                    .draw(|frame| tui.view(&mut app, frame))?;
+
            }
+
            update = tui.update(&mut app)?;
+

+
            resize = size != self.terminal.raw_mut().size()?;
+
            size = self.terminal.raw_mut().size()?;
+
        }
+

+
        Ok(tui.exit().unwrap().value)
+
    }
+
}
+

+
/// `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<Id, Message>
+
where
+
    Id: Eq + PartialEq + Clone + Hash,
+
    Message: Eq,
+
{
+
    /// Will be called whenever a view page is pushed onto the page stack. Should create and mount all widgets.
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Id, 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<Id, 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<Id, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        message: Message,
+
    ) -> Result<Option<Message>>;
+

+
    /// 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<Id, 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<Id, 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<Id, Message, NoUserEvent>) -> Result<()>;
+
}
+

+
/// 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<Id, Message>
+
where
+
    Id: Eq + PartialEq + Clone + Hash,
+
    Message: Eq,
+
{
+
    pages: Vec<Box<dyn ViewPage<Id, Message>>>,
+
}
+

+
impl<Id, Message> PageStack<Id, Message>
+
where
+
    Id: Eq + PartialEq + Clone + Hash,
+
    Message: Eq,
+
{
+
    pub fn push(
+
        &mut self,
+
        mut page: Box<dyn ViewPage<Id, Message>>,
+
        app: &mut Application<Id, 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<Id, 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<Id, Message>>> {
+
        match self.pages.last_mut() {
+
            Some(page) => Ok(page),
+
            None => Err(anyhow::anyhow!(
+
                "Could not peek active page. Page stack is empty."
+
            )),
+
        }
+
    }
+
}
added src/realm/ui.rs
@@ -0,0 +1,164 @@
+
pub mod cob;
+
pub mod ext;
+
pub mod layout;
+
pub mod state;
+
pub mod subscription;
+
pub mod theme;
+
pub mod widget;
+

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

+
use widget::container::{
+
    AppHeader, AppInfo, Container, GlobalListener, Header, LabeledContainer, Popup, Tabs,
+
    VerticalLine,
+
};
+
use widget::context::{Shortcut, Shortcuts};
+
use widget::label::{self, Label, Textarea};
+
use widget::list::{ColumnWidth, Property, PropertyList, PropertyTable};
+
use widget::Widget;
+

+
use theme::{style, Theme};
+

+
use crate::common::context::Context;
+

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

+
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::header(&format!(" {title} ")));
+
    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::default(short).style(style::gray());
+
    let long = label::default(long).style(style::gray_dim());
+
    let divider = label::default(&theme.icons.whitespace.to_string());
+

+
    // 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::default(&format!(" {} ", theme.icons.shortcutbar_divider)).style(style::gray_dim());
+
    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::property(name);
+
    let divider = label::default(&format!(" {} ", theme.icons.property_divider));
+
    let value = label::default(value);
+

+
    // 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) -> Widget<AppInfo> {
+
    let project = label::default(context.project().name()).style(style::cyan());
+
    let rid = label::default(&format!(" ({})", context.rid())).style(style::yellow());
+

+
    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::default(&theme.icons.tab_overline.to_string()).style(style::magenta());
+
    let line = Widget::new(VerticalLine::new(line));
+
    let info = app_info(context);
+
    let header = AppHeader::new(nav, info, line);
+

+
    Widget::new(header)
+
}
+

+
pub fn info(theme: &Theme, message: &str) -> Widget<Popup> {
+
    let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
+
    let container = labeled_container(theme, "Info", textarea.to_boxed());
+

+
    Widget::new(Popup::new(theme.clone(), container))
+
        .width(50)
+
        .height(20)
+
}
+

+
pub fn warning(theme: &Theme, message: &str) -> Widget<Popup> {
+
    let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
+
    let container = labeled_container(theme, "Warning", textarea.to_boxed());
+

+
    Widget::new(Popup::new(theme.clone(), container))
+
        .width(50)
+
        .height(20)
+
}
+

+
pub fn error(theme: &Theme, message: &str) -> Widget<Popup> {
+
    let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
+
    let container = labeled_container(theme, "Error", textarea.to_boxed());
+

+
    Widget::new(Popup::new(theme.clone(), container))
+
        .width(50)
+
        .height(20)
+
}
added src/realm/ui/cob.rs
@@ -0,0 +1,615 @@
+
pub mod format;
+

+
use anyhow::anyhow;
+

+
use radicle_surf;
+

+
use tuirealm::props::{Color, Style};
+
use tuirealm::tui::text::Line;
+
use tuirealm::tui::widgets::Cell;
+

+
use radicle::cob::issue::{self, Issue, IssueId};
+
use radicle::cob::patch::{self, Patch, PatchId};
+
use radicle::cob::{Label, ObjectId, Timestamp};
+
use radicle::issue::Issues;
+
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
+
use radicle::node::{Alias, AliasStore};
+
use radicle::patch::Patches;
+
use radicle::prelude::Did;
+
use radicle::storage::git::Repository;
+
use radicle::storage::{Oid, ReadRepository, RefUpdate};
+
use radicle::{cob, Profile};
+

+
use super::super::ui::theme::Theme;
+
use super::super::ui::widget::list::{ListItem, TableItem};
+

+
use super::widget::label;
+

+
/// 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,
+
    /// The author's alias
+
    alias: Option<Alias>,
+
    /// 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
+
    }
+

+
    pub fn alias(&self) -> Option<Alias> {
+
        self.alias.clone()
+
    }
+
}
+

+
/// 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: patch::State,
+
    /// 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) -> &patch::State {
+
        &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 PartialEq for PatchItem {
+
    fn eq(&self, other: &Self) -> bool {
+
        self.id == other.id
+
    }
+
}
+

+
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)?;
+
        let author = patch.author().id;
+

+
        Ok(PatchItem {
+
            id,
+
            state: patch.state().clone(),
+
            title: patch.title().into(),
+
            author: AuthorItem {
+
                did: author,
+
                alias: profile.aliases().alias(&author),
+
                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, highlight: bool) -> [Cell; 8] {
+
        let (icon, color) = format_patch_state(&self.state);
+

+
        if highlight {
+
            let state = label::reversed(&icon).into();
+
            let id = label::reversed(&format::cob(&self.id)).into();
+
            let title = label::reversed(&self.title.clone()).into();
+

+
            let author = label::reversed(&format_author(
+
                &self.author.did,
+
                &self.author.alias,
+
                self.author.is_you,
+
            ))
+
            .into();
+

+
            let head = label::reversed(&format::oid(self.head)).into();
+
            let added = label::reversed(&format!("+{}", self.added)).into();
+
            let removed = label::reversed(&format!("-{}", self.removed)).into();
+
            let updated = label::reversed(&format::timestamp(&self.timestamp)).into();
+

+
            [state, id, title, author, head, added, removed, updated]
+
        } else {
+
            let state = label::default(&icon)
+
                .style(Style::default().fg(color))
+
                .into();
+
            let id = label::id(&format::cob(&self.id)).into();
+
            let title = label::default(&self.title.clone()).into();
+

+
            let author = match &self.author.alias {
+
                Some(_) => label::alias(&format_author(
+
                    &self.author.did,
+
                    &self.author.alias,
+
                    self.author.is_you,
+
                ))
+
                .into(),
+
                None => label::did(&format_author(
+
                    &self.author.did,
+
                    &self.author.alias,
+
                    self.author.is_you,
+
                ))
+
                .into(),
+
            };
+

+
            let head = label::oid(&format::oid(self.head)).into();
+
            let added = label::positive(&format!("+{}", self.added)).into();
+
            let removed = label::negative(&format!("-{}", self.removed)).into();
+
            let updated = label::timestamp(&format::timestamp(&self.timestamp)).into();
+

+
            [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: issue::State,
+
    /// Issue title.
+
    title: String,
+
    /// Issue author.
+
    author: AuthorItem,
+
    /// Issue labels.
+
    labels: Vec<Label>,
+
    /// 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) -> &issue::State {
+
        &self.state
+
    }
+

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

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

+
    pub fn labels(&self) -> &Vec<Label> {
+
        &self.labels
+
    }
+

+
    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;
+
        let author = issue.author().id;
+

+
        IssueItem {
+
            id,
+
            state: *issue.state(),
+
            title: issue.title().into(),
+
            author: AuthorItem {
+
                did: issue.author().id,
+
                alias: profile.aliases().alias(&author),
+
                is_you: *issue.author().id == *profile.did(),
+
            },
+
            labels: issue.labels().cloned().collect(),
+
            assignees: issue
+
                .assignees()
+
                .map(|did| AuthorItem {
+
                    did: *did,
+
                    alias: profile.aliases().alias(did),
+
                    is_you: *did == profile.did(),
+
                })
+
                .collect::<Vec<_>>(),
+
            timestamp: issue.timestamp(),
+
        }
+
    }
+
}
+

+
impl TableItem<7> for IssueItem {
+
    fn row(&self, _theme: &Theme, highlight: bool) -> [Cell; 7] {
+
        let (icon, color) = format_issue_state(&self.state);
+

+
        if highlight {
+
            let state = label::reversed(&icon).into();
+
            let id = label::reversed(&format::cob(&self.id)).into();
+
            let title = label::reversed(&self.title.clone()).into();
+

+
            let author = label::reversed(&format_author(
+
                &self.author.did,
+
                &self.author.alias,
+
                self.author.is_you,
+
            ))
+
            .into();
+

+
            let labels = label::reversed(&format_labels(&self.labels)).into();
+
            let assignees = self
+
                .assignees
+
                .iter()
+
                .map(|author| (author.did, author.alias.clone(), author.is_you))
+
                .collect::<Vec<_>>();
+
            let assignees = label::reversed(&format_assignees(&assignees)).into();
+
            let opened = label::reversed(&format::timestamp(&self.timestamp)).into();
+

+
            [state, id, title, author, labels, assignees, opened]
+
        } else {
+
            let state = label::default(&icon)
+
                .style(Style::default().fg(color))
+
                .into();
+
            let id = label::id(&format::cob(&self.id)).into();
+
            let title = label::default(&self.title.clone()).into();
+

+
            let author = match &self.author.alias {
+
                Some(_) => label::alias(&format_author(
+
                    &self.author.did,
+
                    &self.author.alias,
+
                    self.author.is_you,
+
                ))
+
                .into(),
+
                None => label::did(&format_author(
+
                    &self.author.did,
+
                    &self.author.alias,
+
                    self.author.is_you,
+
                ))
+
                .into(),
+
            };
+

+
            let labels = label::labels(&format_labels(&self.labels)).into();
+
            let assignees = self
+
                .assignees
+
                .iter()
+
                .map(|author| (author.did, author.alias.clone(), author.is_you))
+
                .collect::<Vec<_>>();
+
            let assignees = label::did(&format_assignees(&assignees)).into();
+
            let opened = label::timestamp(&format::timestamp(&self.timestamp)).into();
+

+
            [state, id, title, author, labels, 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![
+
            Line::from(vec![
+
                label::default(&state)
+
                    .style(Style::default().fg(state_color))
+
                    .into(),
+
                label::title(&self.title).into(),
+
            ]),
+
            Line::from(vec![
+
                label::default("   ").into(),
+
                match &self.author.alias {
+
                    Some(_) => label::alias(&format_author(
+
                        &self.author.did,
+
                        &self.author.alias,
+
                        self.author.is_you,
+
                    ))
+
                    .into(),
+
                    None => label::did(&format_author(
+
                        &self.author.did,
+
                        &self.author.alias,
+
                        self.author.is_you,
+
                    ))
+
                    .into(),
+
                },
+
                label::property_divider(&format!(" {} ", theme.icons.property_divider)).into(),
+
                label::timestamp(&format::timestamp(&self.timestamp)).into(),
+
            ]),
+
        ];
+
        tuirealm::tui::widgets::ListItem::new(lines)
+
    }
+
}
+

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

+
//////////////////////////////////////////////////////
+
#[derive(Clone)]
+
pub enum NotificationKindItem {
+
    Branch {
+
        name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
    Cob {
+
        type_name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
}
+

+
impl TryFrom<(&Repository, NotificationKind, RefUpdate)> for NotificationKindItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Repository, NotificationKind, RefUpdate)) -> Result<Self, Self::Error> {
+
        let (repo, kind, update) = value;
+
        let issues = Issues::open(repo)?;
+
        let patches = Patches::open(repo)?;
+

+
        match kind {
+
            NotificationKind::Branch { name } => {
+
                let (head, message) = if let Some(head) = update.new() {
+
                    let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
+
                    (Some(head), message)
+
                } else {
+
                    (None, String::new())
+
                };
+
                let status = match update {
+
                    RefUpdate::Updated { .. } => "updated",
+
                    RefUpdate::Created { .. } => "created",
+
                    RefUpdate::Deleted { .. } => "deleted",
+
                    RefUpdate::Skipped { .. } => "skipped",
+
                };
+

+
                Ok(NotificationKindItem::Branch {
+
                    name: name.to_string(),
+
                    summary: message,
+
                    status: status.to_string(),
+
                    id: head.map(ObjectId::from),
+
                })
+
            }
+
            NotificationKind::Cob { type_name, id } => {
+
                let (category, summary) = if type_name == *cob::issue::TYPENAME {
+
                    let issue = issues.get(&id)?.ok_or(anyhow!("missing"))?;
+
                    (String::from("issue"), issue.title().to_owned())
+
                } else if type_name == *cob::patch::TYPENAME {
+
                    let patch = patches.get(&id)?.ok_or(anyhow!("missing"))?;
+
                    (String::from("patch"), patch.title().to_owned())
+
                } else {
+
                    (type_name.to_string(), "".to_owned())
+
                };
+
                let status = match update {
+
                    RefUpdate::Updated { .. } => "updated",
+
                    RefUpdate::Created { .. } => "opened",
+
                    RefUpdate::Deleted { .. } => "deleted",
+
                    RefUpdate::Skipped { .. } => "skipped",
+
                };
+

+
                Ok(NotificationKindItem::Cob {
+
                    type_name: category.to_string(),
+
                    summary: summary.to_string(),
+
                    status: status.to_string(),
+
                    id: Some(id),
+
                })
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct NotificationItem {
+
    /// Unique notification ID.
+
    pub id: NotificationId,
+
    /// Mark this notification as seen.
+
    pub seen: bool,
+
    /// Wrapped notification kind.
+
    pub kind: NotificationKindItem,
+
    /// Time the update has happened.
+
    timestamp: Timestamp,
+
}
+

+
impl NotificationItem {
+
    pub fn id(&self) -> &NotificationId {
+
        &self.id
+
    }
+

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

+
    pub fn kind(&self) -> &NotificationKindItem {
+
        &self.kind
+
    }
+

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

+
impl TableItem<7> for NotificationItem {
+
    fn row(&self, _theme: &Theme, highlight: bool) -> [Cell; 7] {
+
        let seen = if self.seen {
+
            label::blank()
+
        } else {
+
            label::positive(" ● ")
+
        };
+

+
        let (type_name, summary, status, id) = match &self.kind() {
+
            NotificationKindItem::Branch {
+
                name,
+
                summary,
+
                status,
+
                id: _,
+
            } => ("branch".to_string(), summary, status, name.to_string()),
+
            NotificationKindItem::Cob {
+
                type_name,
+
                summary,
+
                status,
+
                id,
+
            } => {
+
                let id = id.map(|id| format::cob(&id)).unwrap_or_default();
+
                (type_name.to_string(), summary, status, id.to_string())
+
            }
+
        };
+

+
        let timestamp = if highlight {
+
            label::reversed(&format::timestamp(&self.timestamp))
+
        } else {
+
            label::timestamp(&format::timestamp(&self.timestamp))
+
        };
+

+
        [
+
            label::default(&format!(" {}", &self.id)).into(),
+
            seen.into(),
+
            label::alias(&type_name).into(),
+
            label::default(summary).into(),
+
            label::id(&id).into(),
+
            label::default(status).into(),
+
            timestamp.into(),
+
        ]
+
    }
+
}
+

+
impl TryFrom<(&Repository, Notification)> for NotificationItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Repository, Notification)) -> Result<Self, Self::Error> {
+
        let (repo, notification) = value;
+
        let kind = NotificationKindItem::try_from((repo, notification.kind, notification.update))?;
+

+
        Ok(NotificationItem {
+
            id: notification.id,
+
            seen: notification.status.is_read(),
+
            kind,
+
            timestamp: notification.timestamp.into(),
+
        })
+
    }
+
}
+

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

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

+
pub fn format_author(did: &Did, alias: &Option<Alias>, is_you: bool) -> String {
+
    let author = match alias {
+
        Some(alias) => format!("{alias}"),
+
        None => format::did(did),
+
    };
+

+
    if is_you {
+
        format!("{} (you)", author)
+
    } else {
+
        author
+
    }
+
}
+

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

+
pub fn format_labels(labels: &[Label]) -> String {
+
    let mut output = String::new();
+
    let mut labels = labels.iter().peekable();
+

+
    while let Some(label) = labels.next() {
+
        output.push_str(&label.to_string());
+

+
        if labels.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
+

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

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

+
        if assignees.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
added src/realm/ui/cob/format.rs
@@ -0,0 +1,27 @@
+
use radicle::cob::{ObjectId, Timestamp};
+
use radicle::prelude::Did;
+

+
/// Format a git Oid.
+
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
+
    format!("{:.7}", oid.into())
+
}
+

+
/// Format a COB id.
+
pub fn cob(id: &ObjectId) -> String {
+
    format!("{:.7}", id.to_string())
+
}
+

+
/// Format a DID.
+
pub fn did(did: &Did) -> String {
+
    let nid = did.as_key().to_human();
+
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
+
}
+

+
/// Format a timestamp.
+
pub fn timestamp(time: &Timestamp) -> String {
+
    let fmt = timeago::Formatter::new();
+
    let now = Timestamp::now();
+
    let duration = std::time::Duration::from_secs(now.as_secs() - time.as_secs());
+

+
    fmt.convert(duration)
+
}
added src/realm/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/realm/ui/layout.rs
@@ -0,0 +1,278 @@
+
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 FullPage {
+
    pub navigation: Rect,
+
    pub component: Rect,
+
    pub context: Rect,
+
    pub shortcuts: Rect,
+
}
+

+
pub struct DefaultPage {
+
    pub component: Rect,
+
    pub context: Rect,
+
    pub shortcuts: Rect,
+
}
+

+
pub struct IssuePage {
+
    pub header: Rect,
+
    pub left: Rect,
+
    pub right: Rect,
+
    pub context: 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)
+
        .to_vec();
+

+
    widgets.into_iter().zip(layout).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)
+
        .to_vec();
+

+
    widgets.into_iter().zip(layout).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 full_page(area: Rect, context_h: u16, shortcuts_h: u16) -> FullPage {
+
    let nav_h = 3u16;
+
    let margin_h = 1u16;
+
    let component_h = area
+
        .height
+
        .saturating_sub(nav_h.saturating_add(context_h).saturating_add(shortcuts_h));
+

+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(margin_h)
+
        .constraints(
+
            [
+
                Constraint::Length(nav_h),
+
                Constraint::Length(component_h),
+
                Constraint::Length(context_h),
+
                Constraint::Length(shortcuts_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area);
+

+
    FullPage {
+
        navigation: layout[0],
+
        component: layout[1],
+
        context: layout[2],
+
        shortcuts: layout[3],
+
    }
+
}
+

+
pub fn default_page(area: Rect, context_h: u16, shortcuts_h: u16) -> DefaultPage {
+
    let margin_h = 1u16;
+
    let component_h = area
+
        .height
+
        .saturating_sub(context_h.saturating_add(shortcuts_h));
+

+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(margin_h)
+
        .constraints(
+
            [
+
                Constraint::Length(component_h),
+
                Constraint::Length(context_h),
+
                Constraint::Length(shortcuts_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area);
+

+
    DefaultPage {
+
        component: layout[0],
+
        context: layout[1],
+
        shortcuts: layout[2],
+
    }
+
}
+

+
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)
+
        .to_vec()
+
}
+

+
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)
+
        .to_vec()
+
}
+

+
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 centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
+
    let popup_layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints(
+
            [
+
                Constraint::Percentage((100 - percent_y) / 2),
+
                Constraint::Percentage(percent_y),
+
                Constraint::Percentage((100 - percent_y) / 2),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(r);
+

+
    Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints(
+
            [
+
                Constraint::Percentage((100 - percent_x) / 2),
+
                Constraint::Percentage(percent_x),
+
                Constraint::Percentage((100 - percent_x) / 2),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(popup_layout[1])[1]
+
}
+

+
pub fn issue_page(area: Rect, shortcuts_h: u16) -> IssuePage {
+
    let header_h = 3u16;
+
    let context_h = 1u16;
+
    let margin_h = 1u16;
+
    let content_h = area
+
        .height
+
        .saturating_sub(header_h.saturating_add(context_h.saturating_add(shortcuts_h)));
+

+
    let root = Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(margin_h)
+
        .constraints(
+
            [
+
                Constraint::Length(header_h),
+
                Constraint::Length(content_h),
+
                Constraint::Length(context_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]);
+

+
    IssuePage {
+
        header: root[0],
+
        left: split[0],
+
        right: split[1],
+
        context: root[2],
+
        shortcuts: root[3],
+
    }
+
}
added src/realm/ui/state.rs
@@ -0,0 +1,163 @@
+
use anyhow::anyhow;
+

+
use tuirealm::tui::widgets::{ListState, TableState};
+
use tuirealm::{State, StateValue};
+

+
/// 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> {
+
        if !self.is_empty() {
+
            self.selected
+
        } else {
+
            None
+
        }
+
    }
+

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

+
        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
+
        }
+
    }
+

+
    pub fn len(&self) -> usize {
+
        self.len
+
    }
+

+
    pub fn is_empty(&self) -> bool {
+
        self.len == 0
+
    }
+
}
+

+
impl TryFrom<State> for ItemState {
+
    type Error = anyhow::Error;
+

+
    fn try_from(state: State) -> Result<Self, Self::Error> {
+
        match state {
+
            State::Tup2((StateValue::Usize(selected), StateValue::Usize(len))) => Ok(Self {
+
                selected: Some(selected),
+
                len,
+
            }),
+
            _ => Err(anyhow!(format!(
+
                "Cannot convert into item state: {:?}",
+
                state
+
            ))),
+
        }
+
    }
+
}
+

+
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
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct FormState {
+
    focus: Option<usize>,
+
    len: usize,
+
}
+

+
impl FormState {
+
    pub fn new(focus: Option<usize>, len: usize) -> Self {
+
        Self { focus, len }
+
    }
+

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

+
    pub fn focus_previous(&mut self) -> Option<usize> {
+
        let old_index = self.focus();
+
        let new_index = match old_index {
+
            Some(0) | None => Some(0),
+
            Some(focus) => Some(focus.saturating_sub(1)),
+
        };
+

+
        if old_index != new_index {
+
            self.focus = new_index;
+
            self.focus()
+
        } else {
+
            None
+
        }
+
    }
+

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

+
        if old_index != new_index {
+
            self.focus = new_index;
+
            self.focus()
+
        } else {
+
            None
+
        }
+
    }
+
}
added src/realm/ui/subscription.rs
@@ -0,0 +1,22 @@
+
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
+
use tuirealm::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 quit_clause<UserEvent>(key: Key) -> SubEventClause<UserEvent>
+
where
+
    UserEvent: Clone + Eq + PartialEq + PartialOrd,
+
{
+
    SubEventClause::Keyboard(KeyEvent {
+
        code: key,
+
        modifiers: KeyModifiers::NONE,
+
    })
+
}
added src/realm/ui/theme.rs
@@ -0,0 +1,147 @@
+
use tuirealm::props::BorderType;
+

+
#[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. In the future, it might be defined in a JSON
+
/// config file.
+
#[derive(Debug, Clone)]
+
pub struct Theme {
+
    pub name: String,
+
    pub icons: Icons,
+
    pub tables: Tables,
+
    pub border_type: BorderType,
+
}
+

+
impl Default for Theme {
+
    fn default() -> Theme {
+
        Theme {
+
            name: String::from("Default"),
+
            icons: Icons {
+
                property_divider: '∙',
+
                shortcutbar_divider: '∙',
+
                tab_divider: '|',
+
                tab_overline: '▔',
+
                whitespace: ' ',
+
            },
+
            tables: Tables { spacing: 2 },
+
            border_type: BorderType::Rounded,
+
        }
+
    }
+
}
+

+
pub mod style {
+
    use tuirealm::props::{Color, Style, TextModifiers};
+

+
    pub fn reset() -> Style {
+
        Style::default().fg(Color::Reset)
+
    }
+

+
    pub fn reset_dim() -> Style {
+
        Style::default()
+
            .fg(Color::Reset)
+
            .add_modifier(TextModifiers::DIM)
+
    }
+

+
    pub fn red() -> Style {
+
        Style::default().fg(Color::Red)
+
    }
+

+
    pub fn green() -> Style {
+
        Style::default().fg(Color::Green)
+
    }
+

+
    pub fn yellow() -> Style {
+
        Style::default().fg(Color::Yellow)
+
    }
+

+
    pub fn yellow_dim() -> Style {
+
        yellow().add_modifier(TextModifiers::DIM)
+
    }
+

+
    pub fn yellow_dim_reversed() -> Style {
+
        yellow_dim().add_modifier(TextModifiers::REVERSED)
+
    }
+

+
    pub fn blue() -> Style {
+
        Style::default().fg(Color::Blue)
+
    }
+

+
    pub fn magenta() -> Style {
+
        Style::default().fg(Color::Magenta)
+
    }
+

+
    pub fn magenta_dim() -> Style {
+
        Style::default()
+
            .fg(Color::Magenta)
+
            .add_modifier(TextModifiers::DIM)
+
    }
+

+
    pub fn cyan() -> Style {
+
        Style::default().fg(Color::Cyan)
+
    }
+

+
    pub fn lightblue() -> Style {
+
        Style::default().fg(Color::LightBlue)
+
    }
+

+
    pub fn gray() -> Style {
+
        Style::default().fg(Color::Gray)
+
    }
+

+
    pub fn gray_dim() -> Style {
+
        Style::default()
+
            .fg(Color::Gray)
+
            .add_modifier(TextModifiers::DIM)
+
    }
+

+
    pub fn darkgray() -> Style {
+
        Style::default().fg(Color::DarkGray)
+
    }
+

+
    pub fn reversed() -> Style {
+
        Style::default().add_modifier(TextModifiers::REVERSED)
+
    }
+

+
    pub fn default_reversed() -> Style {
+
        Style::default()
+
            .fg(Color::DarkGray)
+
            // .add_modifier(TextModifiers::DIM)
+
            .add_modifier(TextModifiers::REVERSED)
+
    }
+

+
    pub fn magenta_reversed() -> Style {
+
        Style::default()
+
            .fg(Color::Magenta)
+
            .add_modifier(TextModifiers::REVERSED)
+
    }
+

+
    pub fn yellow_reversed() -> Style {
+
        Style::default().fg(Color::DarkGray).bg(Color::Yellow)
+
    }
+

+
    pub fn border(focus: bool) -> Style {
+
        if focus {
+
            gray_dim()
+
        } else {
+
            darkgray()
+
        }
+
    }
+

+
    pub fn highlight() -> Style {
+
        Style::default()
+
            .fg(Color::Cyan)
+
            .add_modifier(TextModifiers::REVERSED)
+
    }
+
}
added src/realm/ui/widget.rs
@@ -0,0 +1,102 @@
+
pub mod container;
+
pub mod context;
+
pub mod form;
+
pub mod label;
+
pub mod list;
+
mod utils;
+

+
use std::ops::Deref;
+

+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Layout, Props, Style};
+
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 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 layout(mut self, layout: Layout) -> Self {
+
        self.attr(Attribute::Layout, AttrValue::Layout(layout));
+
        self
+
    }
+

+
    pub fn style(mut self, style: Style) -> Self {
+
        self.attr(Attribute::Style, AttrValue::Style(style));
+
        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/realm/ui/widget/common.rs
@@ -0,0 +1,186 @@
+
pub mod container;
+
pub mod context;
+
pub mod form;
+
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, Popup, VerticalLine};
+
use self::label::Textarea;
+
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)
+
        .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)
+
}
+

+
pub fn info(theme: &Theme, message: &str) -> Widget<Popup> {
+
    let textarea =
+
        Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
+
    let container = labeled_container(theme, "Info", textarea.to_boxed());
+

+
    Widget::new(Popup::new(theme.clone(), container))
+
        .width(50)
+
        .height(20)
+
}
+

+
pub fn warning(theme: &Theme, message: &str) -> Widget<Popup> {
+
    let textarea =
+
        Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
+
    let container = labeled_container(theme, "Warning", textarea.to_boxed());
+

+
    Widget::new(Popup::new(theme.clone(), container))
+
        .width(50)
+
        .height(20)
+
}
+

+
pub fn error(theme: &Theme, message: &str) -> Widget<Popup> {
+
    let textarea =
+
        Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
+
    let container = labeled_container(theme, "Error", textarea.to_boxed());
+

+
    Widget::new(Popup::new(theme.clone(), container))
+
        .width(50)
+
        .height(20)
+
}
added src/realm/ui/widget/container.rs
@@ -0,0 +1,484 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, BorderSides, Props, TextModifiers};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Margin, Rect};
+
use tuirealm::tui::widgets::{Block, Cell, Clear, Row};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use crate::realm::ui::ext::HeaderBlock;
+
use crate::realm::ui::layout;
+
use crate::realm::ui::state::TabState;
+
use crate::realm::ui::theme::{style, Theme};
+
use crate::realm::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).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();
+

+
        if display {
+
            let block = HeaderBlock::default()
+
                .borders(BorderSides::all())
+
                .border_style(style::border(focus))
+
                .border_type(self.theme.border_type);
+
            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::reset())
+
                })
+
                .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();
+

+
        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)])
+
                .split(area);
+
            // reverse draw order: child needs to be drawn first?
+
            self.component
+
                .attr(Attribute::Focus, AttrValue::Flag(focus));
+
            self.component.view(frame, layout[1]);
+

+
            let block = Block::default()
+
                .borders(BorderSides::ALL)
+
                .border_style(style::border(focus))
+
                .border_type(self.theme.border_type);
+
            frame.render_widget(block, area);
+
        }
+
    }
+

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

+
    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 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::Min(1)].as_ref())
+
                .split(area);
+

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

+
            let block = Block::default()
+
                .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
+
                .border_style(style::border(focus))
+
                .border_type(self.theme.border_type);
+
            frame.render_widget(block.clone(), layout[1]);
+

+
            self.component
+
                .attr(Attribute::Focus, AttrValue::Flag(focus));
+
            self.component.view(
+
                frame,
+
                block.inner(layout[1]).inner(&Margin {
+
                    vertical: 0,
+
                    horizontal: 1,
+
                }),
+
            );
+
        }
+
    }
+

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

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

+
pub struct Popup {
+
    component: Widget<LabeledContainer>,
+
}
+

+
impl Popup {
+
    pub fn new(_theme: Theme, component: Widget<LabeledContainer>) -> Self {
+
        Self { component }
+
    }
+
}
+

+
impl WidgetComponent for Popup {
+
    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 width = properties
+
            .get_or(Attribute::Width, AttrValue::Size(50))
+
            .unwrap_size();
+
        let height = properties
+
            .get_or(Attribute::Height, AttrValue::Size(50))
+
            .unwrap_size();
+

+
        if display {
+
            let size = frame.size();
+

+
            let area = layout::centered_rect(width, height, size);
+
            frame.render_widget(Clear, area);
+

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

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

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

+
use super::label::{self, Label, LabelGroup};
+

+
use crate::realm::ui::layout;
+
use crate::realm::ui::theme::{style, Theme};
+
use crate::realm::ui::widget::{Widget, WidgetComponent};
+

+
pub enum Progress {
+
    Percentage(usize),
+
    Step(usize, usize),
+
    None,
+
}
+

+
impl ToString for Progress {
+
    fn to_string(&self) -> std::string::String {
+
        match self {
+
            Progress::Percentage(value) => format!("{value} %"),
+
            Progress::Step(step, total) => format!("{step}/{total}"),
+
            _ => String::new(),
+
        }
+
    }
+
}
+

+
/// 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.
+
#[derive(Clone)]
+
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 {
+
    col_0: Widget<LabelGroup>,
+
    col_1: Widget<LabelGroup>,
+
    col_2: Widget<LabelGroup>,
+
    col_3: Widget<LabelGroup>,
+
    col_4: Widget<LabelGroup>,
+
}
+

+
impl ContextBar {
+
    pub const PROP_EDIT_MODE: &'static str = "edit-mode";
+

+
    pub fn new(
+
        col_0: Widget<LabelGroup>,
+
        col_1: Widget<LabelGroup>,
+
        col_2: Widget<LabelGroup>,
+
        col_3: Widget<LabelGroup>,
+
        col_4: Widget<LabelGroup>,
+
    ) -> Self {
+
        Self {
+
            col_0,
+
            col_1,
+
            col_2,
+
            col_3,
+
            col_4,
+
        }
+
    }
+
}
+

+
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 edit_mode = properties
+
            .get_or(
+
                Attribute::Custom(Self::PROP_EDIT_MODE),
+
                AttrValue::Flag(false),
+
            )
+
            .unwrap_flag();
+

+
        let col_0_w = self.col_0.query(Attribute::Width).unwrap().unwrap_size();
+
        let col_1_w = self.col_1.query(Attribute::Width).unwrap().unwrap_size();
+
        let col_3_w = self.col_3.query(Attribute::Width).unwrap().unwrap_size();
+
        let col_4_w = self.col_4.query(Attribute::Width).unwrap().unwrap_size();
+

+
        if edit_mode {
+
            self.col_0.attr(
+
                Attribute::Background,
+
                AttrValue::Color(style::yellow_reversed().bg.unwrap()),
+
            )
+
        }
+

+
        if display {
+
            let content_layout = layout::h_stack(
+
                vec![
+
                    self.col_0.clone().to_boxed(),
+
                    self.col_1.clone().to_boxed(),
+
                    self.col_2
+
                        .clone()
+
                        .width(
+
                            area.width
+
                                .saturating_sub(col_0_w + col_1_w + col_3_w + col_4_w),
+
                        )
+
                        .to_boxed(),
+
                    self.col_3.clone().to_boxed(),
+
                    self.col_4.clone().to_boxed(),
+
                ],
+
                area,
+
            );
+

+
            for (mut component, area) in content_layout {
+
                component.view(frame, area);
+
            }
+
        }
+
    }
+

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

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

+
pub fn bar(
+
    _theme: &Theme,
+
    label_0: &str,
+
    label_1: &str,
+
    label_2: &str,
+
    label_3: &str,
+
    label_4: &str,
+
) -> Widget<ContextBar> {
+
    let label_0 = label::badge(&format!(" {label_0} "));
+
    let label_1 = label::default_reversed(&format!(" {label_1} "));
+
    let label_2 = label::default_reversed(&format!(" {label_2} "));
+
    let label_3 = label::default_reversed(&format!(" {label_3} "));
+
    let label_4 = label::default_reversed(&format!(" {label_4} "));
+

+
    let label_0 = label::group(&[label_0]);
+
    let label_1 = label::group(&[label_1]);
+
    let label_2 = label::group(&[label_2]);
+
    let label_3 = label::group(&[label_3]);
+
    let label_4 = label::group(&[label_4]);
+

+
    let context_bar = ContextBar::new(label_0, label_1, label_2, label_3, label_4);
+

+
    Widget::new(context_bar).height(1)
+
}
added src/realm/ui/widget/form.rs
@@ -0,0 +1,268 @@
+
use std::collections::LinkedList;
+

+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::tui::layout::{Constraint, Direction, Margin, Rect};
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State, StateValue};
+

+
use crate::realm::ui::state::FormState;
+
use crate::realm::ui::theme::{style, Theme};
+
use crate::realm::ui::widget::{Widget, WidgetComponent};
+

+
use super::container::Container;
+
use super::label::{self, Label};
+

+
pub struct TextField {
+
    input: Widget<Container>,
+
    placeholder: Widget<Label>,
+
    show_placeholder: bool,
+
}
+

+
impl TextField {
+
    pub fn new(theme: Theme, title: &str) -> Self {
+
        // TODO: activate again
+
        // let input = tui_realm_textarea::TextArea::default()
+
        //     .wrap(false)
+
        //     .single_line(true)
+
        //     .cursor_line_style(Style::reset())
+
        //     .style(style::reset());
+
        let input = tui_realm_stdlib::Textarea::default();
+
        let container = crate::realm::ui::container(&theme, Box::new(input));
+

+
        Self {
+
            input: container,
+
            placeholder: label::default(title).style(style::gray_dim()),
+
            show_placeholder: true,
+
        }
+
    }
+
}
+

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

+
        self.input.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.input.view(frame, area);
+

+
        if self.show_placeholder {
+
            let inner = area.inner(&Margin {
+
                vertical: 1,
+
                horizontal: 2,
+
            });
+
            self.placeholder.view(frame, inner);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        if let State::Vec(values) = self.input.state() {
+
            let text = match values.get(0) {
+
                Some(StateValue::String(line)) => line.clone(),
+
                _ => String::new(),
+
            };
+

+
            State::One(StateValue::String(text))
+
        } else {
+
            State::None
+
        }
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        // TODO: activate again
+
        // use tui_realm_textarea::*;
+

+
        // let cmd = match cmd {
+
        //     Cmd::Custom(Form::CMD_PASTE) => Cmd::Custom(TEXTAREA_CMD_PASTE),
+
        //     _ => cmd,
+
        // };
+
        // let result = self.input.perform(cmd);
+

+
        // if let State::Vec(values) = self.input.state() {
+
        //     if let Some(StateValue::String(input)) = values.first() {
+
        //         self.show_placeholder = values.len() == 1 && input.is_empty();
+
        //     } else {
+
        //         self.show_placeholder = false;
+
        //     }
+
        // }
+
        // result
+
        CmdResult::None
+
    }
+
}
+

+
pub struct TextArea {
+
    input: Widget<Container>,
+
    placeholder: Widget<Label>,
+
    show_placeholder: bool,
+
}
+

+
impl TextArea {
+
    pub fn new(theme: Theme, title: &str) -> Self {
+
        // TODO: activate again
+
        // let input = tui_realm_textarea::TextArea::default()
+
        //     .wrap(true)
+
        //     .single_line(false)
+
        //     .cursor_line_style(Style::reset())
+
        //     .style(style::reset());
+
        let input = tui_realm_stdlib::Textarea::default();
+
        let container = crate::realm::ui::container(&theme, Box::new(input));
+

+
        Self {
+
            input: container,
+
            placeholder: label::default(title).style(style::gray_dim()),
+
            show_placeholder: true,
+
        }
+
    }
+
}
+

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

+
        self.input.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.input.view(frame, area);
+

+
        if self.show_placeholder {
+
            let inner = area.inner(&Margin {
+
                vertical: 1,
+
                horizontal: 2,
+
            });
+
            self.placeholder.view(frame, inner);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        // Fold each input's vector of lines into a single string.
+
        if let State::Vec(values) = self.input.state() {
+
            let mut text = String::new();
+
            let lines = values
+
                .iter()
+
                .map(|value| match value {
+
                    StateValue::String(line) => line.clone(),
+
                    _ => String::new(),
+
                })
+
                .collect::<Vec<_>>();
+

+
            let mut lines = lines.iter().peekable();
+
            while let Some(line) = lines.next() {
+
                text.push_str(line);
+
                if lines.peek().is_some() {
+
                    text.push('\n');
+
                }
+
            }
+

+
            State::One(StateValue::String(text))
+
        } else {
+
            State::None
+
        }
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        // TODO: activate again
+
        // use tui_realm_textarea::*;
+

+
        // let cmd = match cmd {
+
        //     Cmd::Custom(Form::CMD_PASTE) => Cmd::Custom(TEXTAREA_CMD_PASTE),
+
        //     Cmd::Custom(Form::CMD_NEWLINE) => Cmd::Custom(TEXTAREA_CMD_NEWLINE),
+
        //     _ => cmd,
+
        // };
+
        // let result = self.input.perform(cmd);
+

+
        // if let State::Vec(values) = self.input.state() {
+
        //     if let Some(StateValue::String(input)) = values.first() {
+
        //         self.show_placeholder = values.len() == 1 && input.is_empty();
+
        //     } else {
+
        //         self.show_placeholder = false;
+
        //     }
+
        // }
+
        // result
+
        CmdResult::None
+
    }
+
}
+

+
pub struct Form {
+
    // This form's fields: title, tags, assignees, description.
+
    inputs: Vec<Box<dyn MockComponent>>,
+
    /// State that holds the current focus etc.
+
    state: FormState,
+
}
+

+
impl Form {
+
    pub const CMD_FOCUS_PREVIOUS: &'static str = "cmd-focus-previous";
+
    pub const CMD_FOCUS_NEXT: &'static str = "cmd-focus-next";
+
    pub const CMD_NEWLINE: &'static str = "cmd-newline";
+
    pub const CMD_PASTE: &'static str = "cmd-paste";
+

+
    pub const PROP_ID: &'static str = "prop-id";
+

+
    pub fn new(_theme: Theme, inputs: Vec<Box<dyn MockComponent>>) -> Self {
+
        let state = FormState::new(Some(0), inputs.len());
+

+
        Self { inputs, state }
+
    }
+
}
+

+
impl WidgetComponent for Form {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tuirealm::props::Layout;
+
        // Clear and set current focus
+
        let focus = self.state.focus().unwrap_or(0);
+
        for input in &mut self.inputs {
+
            input.attr(Attribute::Focus, AttrValue::Flag(false));
+
        }
+
        if let Some(input) = self.inputs.get_mut(focus) {
+
            input.attr(Attribute::Focus, AttrValue::Flag(true));
+
        }
+

+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(
+
                &self
+
                    .inputs
+
                    .iter()
+
                    .map(|_| Constraint::Length(3))
+
                    .collect::<Vec<_>>(),
+
            );
+
        let layout = properties
+
            .get_or(Attribute::Layout, AttrValue::Layout(layout))
+
            .unwrap_layout();
+
        let layout = layout.chunks(area);
+

+
        for (index, area) in layout.iter().enumerate().take(self.inputs.len()) {
+
            if let Some(input) = self.inputs.get_mut(index) {
+
                input.view(frame, *area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        let states = self
+
            .inputs
+
            .iter()
+
            .map(|input| input.state())
+
            .collect::<LinkedList<_>>();
+
        State::Linked(states)
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        match cmd {
+
            Cmd::Custom(Self::CMD_FOCUS_PREVIOUS) => {
+
                self.state.focus_previous();
+
                CmdResult::None
+
            }
+
            Cmd::Custom(Self::CMD_FOCUS_NEXT) => {
+
                self.state.focus_next();
+
                CmdResult::None
+
            }
+
            Cmd::Submit => CmdResult::Submit(self.state()),
+
            _ => {
+
                let focus = self.state.focus().unwrap_or(0);
+
                if let Some(input) = self.inputs.get_mut(focus) {
+
                    return input.perform(cmd);
+
                }
+
                CmdResult::None
+
            }
+
        }
+
    }
+
}
added src/realm/ui/widget/label.rs
@@ -0,0 +1,352 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{Alignment, AttrValue, Attribute, Color, Props, Style};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
+
use tuirealm::tui::text::{Line, Span, Text};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use crate::realm::ui::layout;
+
use crate::realm::ui::theme::style;
+
use crate::realm::ui::widget::{Widget, WidgetComponent};
+

+
pub fn blank() -> Widget<Label> {
+
    default("")
+
}
+

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

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

+
pub fn reversed(content: &str) -> Widget<Label> {
+
    default(content).style(style::reversed())
+
}
+

+
pub fn default_reversed(content: &str) -> Widget<Label> {
+
    default(content).style(style::default_reversed())
+
}
+

+
pub fn group(labels: &[Widget<Label>]) -> Widget<LabelGroup> {
+
    let group = LabelGroup::new(labels);
+
    let width = labels.iter().fold(0, |total, label| {
+
        total
+
            + label
+
                .query(Attribute::Width)
+
                .unwrap_or(AttrValue::Size(0))
+
                .unwrap_size()
+
    });
+

+
    Widget::new(group).width(width)
+
}
+

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

+
    default(content)
+
}
+

+
pub fn header(content: &str) -> Widget<Label> {
+
    default(content).style(style::reset_dim())
+
}
+

+
pub fn property(content: &str) -> Widget<Label> {
+
    default(content).style(style::cyan())
+
}
+

+
pub fn property_divider(content: &str) -> Widget<Label> {
+
    default(content).style(style::gray())
+
}
+

+
pub fn badge(content: &str) -> Widget<Label> {
+
    default(content).style(style::magenta_reversed())
+
}
+

+
pub fn title(content: &str) -> Widget<Label> {
+
    default(content)
+
}
+

+
pub fn labels(content: &str) -> Widget<Label> {
+
    default(content).style(style::lightblue())
+
}
+

+
pub fn alias(content: &str) -> Widget<Label> {
+
    default(content).style(style::magenta())
+
}
+

+
pub fn did(content: &str) -> Widget<Label> {
+
    default(content).style(style::magenta_dim())
+
}
+

+
pub fn id(content: &str) -> Widget<Label> {
+
    default(content).style(style::cyan())
+
}
+

+
pub fn oid(content: &str) -> Widget<Label> {
+
    default(content).style(style::lightblue())
+
}
+

+
pub fn timestamp(content: &str) -> Widget<Label> {
+
    default(content).style(style::gray_dim())
+
}
+

+
pub fn positive(content: &str) -> Widget<Label> {
+
    default(content).style(style::green())
+
}
+

+
pub fn negative(content: &str) -> Widget<Label> {
+
    default(content).style(style::red())
+
}
+

+
/// 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 style = properties
+
            .get_or(Attribute::Style, AttrValue::Style(Style::default()))
+
            .unwrap_style();
+

+
        if display {
+
            let mut label = Label::default()
+
                .foreground(style.fg.unwrap_or(Color::Reset))
+
                .background(style.bg.unwrap_or(Color::Reset))
+
                .modifiers(style.add_modifier)
+
                .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();
+
        let style = label
+
            .query(Attribute::Style)
+
            .unwrap_or(AttrValue::Style(Style::default()))
+
            .unwrap_style();
+

+
        Span::styled(content, style)
+
    }
+
}
+

+
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();
+
        let style = label
+
            .query(Attribute::Style)
+
            .unwrap_or(AttrValue::Style(Style::default()))
+
            .unwrap_style();
+

+
        Span::styled(content, style)
+
    }
+
}
+

+
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 style = label
+
            .query(Attribute::Style)
+
            .unwrap_or(AttrValue::Style(Style::default()))
+
            .unwrap_style();
+

+
        Text::styled(content, style)
+
    }
+
}
+

+
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 style = label
+
            .query(Attribute::Style)
+
            .unwrap_or(AttrValue::Style(Style::default()))
+
            .unwrap_style();
+

+
        Text::styled(content, style)
+
    }
+
}
+

+
#[derive(Clone, Default)]
+
pub struct LabelGroup {
+
    labels: Vec<Widget<Label>>,
+
}
+

+
impl LabelGroup {
+
    pub fn new(labels: &[Widget<Label>]) -> Self {
+
        Self {
+
            labels: labels.to_vec(),
+
        }
+
    }
+
}
+

+
impl WidgetComponent for LabelGroup {
+
    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 labels: Vec<Box<dyn MockComponent>> = vec![];
+
            for label in &self.labels {
+
                labels.push(label.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
+
    }
+
}
+

+
#[derive(Default)]
+
pub struct Textarea {
+
    /// The scroll offset.
+
    offset: usize,
+
    /// The current line count.
+
    len: usize,
+
    /// The current display height.
+
    height: usize,
+
    /// The percentage scrolled.
+
    scroll_percent: usize,
+
}
+

+
impl Textarea {
+
    pub const PROP_DISPLAY_PROGRESS: &'static str = "display-progress";
+

+
    fn scroll_percent(offset: usize, len: usize, height: usize) -> usize {
+
        if height >= len {
+
            100
+
        } else {
+
            let y = offset as f64;
+
            let h = height as f64;
+
            let t = len.saturating_sub(1) as f64;
+
            let v = y / (t - h) * 100_f64;
+

+
            std::cmp::max(0, std::cmp::min(100, v as usize))
+
        }
+
    }
+
}
+

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

+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+
        let display_progress = properties
+
            .get_or(
+
                Attribute::Custom(Self::PROP_DISPLAY_PROGRESS),
+
                AttrValue::Flag(false),
+
            )
+
            .unwrap_flag();
+

+
        let content = properties
+
            .get_or(Attribute::Content, AttrValue::String(String::default()))
+
            .unwrap_string();
+

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

+
        // TODO: replace with `ratatui`'s reflow module when that becomes
+
        // public: https://github.com/tui-rs-revival/ratatui/pull/9.
+
        //
+
        // In the future, there should be highlighting for e.g. Markdown which
+
        // needs be done before wrapping. So this should rather wrap styled text
+
        // spans than plain text.
+
        let body = textwrap::wrap(&content, area.width.saturating_sub(2) as usize);
+
        self.len = body.len();
+
        self.height = (layout[0].height - 1) as usize;
+

+
        let body: String = body.iter().fold(String::new(), |mut body, line| {
+
            body.push_str(&format!("{}\n", line));
+
            body
+
        });
+

+
        let paragraph = Paragraph::new(body)
+
            .scroll((self.offset as u16, 0))
+
            .style(style::reset());
+
        frame.render_widget(paragraph, layout[0]);
+

+
        self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
+

+
        if display_progress {
+
            let progress = Line::from(vec![Span::styled(
+
                format!("{} %", self.scroll_percent),
+
                style::border(focus),
+
            )]);
+

+
            let progress = Paragraph::new(progress).alignment(Alignment::Right);
+
            frame.render_widget(progress, layout[1]);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::One(StateValue::Usize(self.scroll_percent))
+
    }
+

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

+
        match cmd {
+
            Cmd::Scroll(Direction::Up) => {
+
                self.offset = self.offset.saturating_sub(1);
+
                self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
+
                CmdResult::None
+
            }
+
            Cmd::Scroll(Direction::Down) => {
+
                if self.scroll_percent < 100 {
+
                    self.offset = self.offset.saturating_add(1);
+
                    self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
+
                }
+
                CmdResult::None
+
            }
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
added src/realm/ui/widget/list.rs
@@ -0,0 +1,375 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, BorderSides, Props};
+
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::realm::ui::layout;
+
use crate::realm::ui::state::ItemState;
+
use crate::realm::ui::theme::{style, Theme};
+
use crate::realm::ui::widget::{utils, Widget, WidgetComponent};
+

+
use super::container::Header;
+
use super::label::{self, Label};
+

+
/// 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, highlight: bool) -> [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::default("");
+
        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 + PartialEq,
+
{
+
    /// 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 + PartialEq,
+
{
+
    pub fn new(
+
        items: &[V],
+
        selected: Option<V>,
+
        header: [Widget<Label>; W],
+
        widths: [ColumnWidth; W],
+
        theme: Theme,
+
    ) -> Self {
+
        let selected = match selected {
+
            Some(item) => items.iter().position(|i| i == &item),
+
            _ => None,
+
        };
+

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

+
impl<V, const W: usize> WidgetComponent for Table<V, W>
+
where
+
    V: TableItem<W> + Clone + PartialEq,
+
{
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        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()
+
            .enumerate()
+
            .map(|(index, item)| {
+
                Row::new(item.row(
+
                    &self.theme,
+
                    match self.state.selected() {
+
                        Some(selected) => index == selected,
+
                        None => false,
+
                    },
+
                ))
+
            })
+
            .collect();
+

+
        let table = tuirealm::tui::widgets::Table::new(rows)
+
            .block(
+
                Block::default()
+
                    .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
+
                    .border_style(style::border(focus))
+
                    .border_type(self.theme.border_type),
+
            )
+
            .highlight_style(style::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 {
+
        let selected = self.state.selected().unwrap_or_default();
+
        let len = self.items.len();
+
        State::Tup2((StateValue::Usize(selected), StateValue::Usize(len)))
+
    }
+

+
    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(_) => CmdResult::Changed(self.state()),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Move(Direction::Down) => match self.state.select_next() {
+
                Some(_) => CmdResult::Changed(self.state()),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Submit => match self.state.selected() {
+
                Some(_) => CmdResult::Submit(self.state()),
+
                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,
+
        };
+

+
        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 rows: Vec<ListItem> = self
+
            .items
+
            .iter()
+
            .map(|item| item.row(&self.theme))
+
            .collect();
+
        let list = List::new(rows).highlight_style(style::highlight());
+

+
        frame.render_stateful_widget(list, area, &mut ListState::from(&self.state));
+
    }
+

+
    fn state(&self) -> State {
+
        let selected = self.state.selected().unwrap_or_default();
+
        let len = self.items.len();
+
        State::Tup2((StateValue::Usize(selected), StateValue::Usize(len)))
+
    }
+

+
    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(_) => CmdResult::Changed(self.state()),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Move(Direction::Down) => match self.state.select_next() {
+
                Some(_) => CmdResult::Changed(self.state()),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Submit => match self.state.selected() {
+
                Some(_) => CmdResult::Submit(self.state()),
+
                None => CmdResult::None,
+
            },
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
added src/realm/ui/widget/utils.rs
@@ -0,0 +1,43 @@
+
use tuirealm::tui::layout::{Constraint, Rect};
+

+
use super::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()
+
}
deleted src/ui.rs
@@ -1,164 +0,0 @@
-
pub mod cob;
-
pub mod ext;
-
pub mod layout;
-
pub mod state;
-
pub mod subscription;
-
pub mod theme;
-
pub mod widget;
-

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

-
use widget::container::{
-
    AppHeader, AppInfo, Container, GlobalListener, Header, LabeledContainer, Popup, Tabs,
-
    VerticalLine,
-
};
-
use widget::context::{Shortcut, Shortcuts};
-
use widget::label::{self, Label, Textarea};
-
use widget::list::{ColumnWidth, Property, PropertyList, PropertyTable};
-
use widget::Widget;
-

-
use theme::{style, Theme};
-

-
use super::context::Context;
-

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

-
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::header(&format!(" {title} ")));
-
    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::default(short).style(style::gray());
-
    let long = label::default(long).style(style::gray_dim());
-
    let divider = label::default(&theme.icons.whitespace.to_string());
-

-
    // 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::default(&format!(" {} ", theme.icons.shortcutbar_divider)).style(style::gray_dim());
-
    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::property(name);
-
    let divider = label::default(&format!(" {} ", theme.icons.property_divider));
-
    let value = label::default(value);
-

-
    // 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) -> Widget<AppInfo> {
-
    let project = label::default(context.project().name()).style(style::cyan());
-
    let rid = label::default(&format!(" ({})", context.rid())).style(style::yellow());
-

-
    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::default(&theme.icons.tab_overline.to_string()).style(style::magenta());
-
    let line = Widget::new(VerticalLine::new(line));
-
    let info = app_info(context);
-
    let header = AppHeader::new(nav, info, line);
-

-
    Widget::new(header)
-
}
-

-
pub fn info(theme: &Theme, message: &str) -> Widget<Popup> {
-
    let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
-
    let container = labeled_container(theme, "Info", textarea.to_boxed());
-

-
    Widget::new(Popup::new(theme.clone(), container))
-
        .width(50)
-
        .height(20)
-
}
-

-
pub fn warning(theme: &Theme, message: &str) -> Widget<Popup> {
-
    let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
-
    let container = labeled_container(theme, "Warning", textarea.to_boxed());
-

-
    Widget::new(Popup::new(theme.clone(), container))
-
        .width(50)
-
        .height(20)
-
}
-

-
pub fn error(theme: &Theme, message: &str) -> Widget<Popup> {
-
    let textarea = Widget::new(Textarea::default()).content(AttrValue::String(message.to_owned()));
-
    let container = labeled_container(theme, "Error", textarea.to_boxed());
-

-
    Widget::new(Popup::new(theme.clone(), container))
-
        .width(50)
-
        .height(20)
-
}
deleted src/ui/cob.rs
@@ -1,615 +0,0 @@
-
pub mod format;
-

-
use anyhow::anyhow;
-

-
use radicle_surf;
-

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

-
use radicle::cob::issue::{self, Issue, IssueId};
-
use radicle::cob::patch::{self, Patch, PatchId};
-
use radicle::cob::{Label, ObjectId, Timestamp};
-
use radicle::issue::Issues;
-
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
-
use radicle::node::{Alias, AliasStore};
-
use radicle::patch::Patches;
-
use radicle::prelude::Did;
-
use radicle::storage::git::Repository;
-
use radicle::storage::{Oid, ReadRepository, RefUpdate};
-
use radicle::{cob, Profile};
-

-
use crate::ui::theme::Theme;
-
use crate::ui::widget::list::{ListItem, TableItem};
-

-
use super::widget::label;
-

-
/// 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,
-
    /// The author's alias
-
    alias: Option<Alias>,
-
    /// 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
-
    }
-

-
    pub fn alias(&self) -> Option<Alias> {
-
        self.alias.clone()
-
    }
-
}
-

-
/// 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: patch::State,
-
    /// 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) -> &patch::State {
-
        &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 PartialEq for PatchItem {
-
    fn eq(&self, other: &Self) -> bool {
-
        self.id == other.id
-
    }
-
}
-

-
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)?;
-
        let author = patch.author().id;
-

-
        Ok(PatchItem {
-
            id,
-
            state: patch.state().clone(),
-
            title: patch.title().into(),
-
            author: AuthorItem {
-
                did: author,
-
                alias: profile.aliases().alias(&author),
-
                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, highlight: bool) -> [Cell; 8] {
-
        let (icon, color) = format_patch_state(&self.state);
-

-
        if highlight {
-
            let state = label::reversed(&icon).into();
-
            let id = label::reversed(&format::cob(&self.id)).into();
-
            let title = label::reversed(&self.title.clone()).into();
-

-
            let author = label::reversed(&format_author(
-
                &self.author.did,
-
                &self.author.alias,
-
                self.author.is_you,
-
            ))
-
            .into();
-

-
            let head = label::reversed(&format::oid(self.head)).into();
-
            let added = label::reversed(&format!("+{}", self.added)).into();
-
            let removed = label::reversed(&format!("-{}", self.removed)).into();
-
            let updated = label::reversed(&format::timestamp(&self.timestamp)).into();
-

-
            [state, id, title, author, head, added, removed, updated]
-
        } else {
-
            let state = label::default(&icon)
-
                .style(Style::default().fg(color))
-
                .into();
-
            let id = label::id(&format::cob(&self.id)).into();
-
            let title = label::default(&self.title.clone()).into();
-

-
            let author = match &self.author.alias {
-
                Some(_) => label::alias(&format_author(
-
                    &self.author.did,
-
                    &self.author.alias,
-
                    self.author.is_you,
-
                ))
-
                .into(),
-
                None => label::did(&format_author(
-
                    &self.author.did,
-
                    &self.author.alias,
-
                    self.author.is_you,
-
                ))
-
                .into(),
-
            };
-

-
            let head = label::oid(&format::oid(self.head)).into();
-
            let added = label::positive(&format!("+{}", self.added)).into();
-
            let removed = label::negative(&format!("-{}", self.removed)).into();
-
            let updated = label::timestamp(&format::timestamp(&self.timestamp)).into();
-

-
            [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: issue::State,
-
    /// Issue title.
-
    title: String,
-
    /// Issue author.
-
    author: AuthorItem,
-
    /// Issue labels.
-
    labels: Vec<Label>,
-
    /// 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) -> &issue::State {
-
        &self.state
-
    }
-

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

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

-
    pub fn labels(&self) -> &Vec<Label> {
-
        &self.labels
-
    }
-

-
    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;
-
        let author = issue.author().id;
-

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

-
impl TableItem<7> for IssueItem {
-
    fn row(&self, _theme: &Theme, highlight: bool) -> [Cell; 7] {
-
        let (icon, color) = format_issue_state(&self.state);
-

-
        if highlight {
-
            let state = label::reversed(&icon).into();
-
            let id = label::reversed(&format::cob(&self.id)).into();
-
            let title = label::reversed(&self.title.clone()).into();
-

-
            let author = label::reversed(&format_author(
-
                &self.author.did,
-
                &self.author.alias,
-
                self.author.is_you,
-
            ))
-
            .into();
-

-
            let labels = label::reversed(&format_labels(&self.labels)).into();
-
            let assignees = self
-
                .assignees
-
                .iter()
-
                .map(|author| (author.did, author.alias.clone(), author.is_you))
-
                .collect::<Vec<_>>();
-
            let assignees = label::reversed(&format_assignees(&assignees)).into();
-
            let opened = label::reversed(&format::timestamp(&self.timestamp)).into();
-

-
            [state, id, title, author, labels, assignees, opened]
-
        } else {
-
            let state = label::default(&icon)
-
                .style(Style::default().fg(color))
-
                .into();
-
            let id = label::id(&format::cob(&self.id)).into();
-
            let title = label::default(&self.title.clone()).into();
-

-
            let author = match &self.author.alias {
-
                Some(_) => label::alias(&format_author(
-
                    &self.author.did,
-
                    &self.author.alias,
-
                    self.author.is_you,
-
                ))
-
                .into(),
-
                None => label::did(&format_author(
-
                    &self.author.did,
-
                    &self.author.alias,
-
                    self.author.is_you,
-
                ))
-
                .into(),
-
            };
-

-
            let labels = label::labels(&format_labels(&self.labels)).into();
-
            let assignees = self
-
                .assignees
-
                .iter()
-
                .map(|author| (author.did, author.alias.clone(), author.is_you))
-
                .collect::<Vec<_>>();
-
            let assignees = label::did(&format_assignees(&assignees)).into();
-
            let opened = label::timestamp(&format::timestamp(&self.timestamp)).into();
-

-
            [state, id, title, author, labels, 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![
-
            Line::from(vec![
-
                label::default(&state)
-
                    .style(Style::default().fg(state_color))
-
                    .into(),
-
                label::title(&self.title).into(),
-
            ]),
-
            Line::from(vec![
-
                label::default("   ").into(),
-
                match &self.author.alias {
-
                    Some(_) => label::alias(&format_author(
-
                        &self.author.did,
-
                        &self.author.alias,
-
                        self.author.is_you,
-
                    ))
-
                    .into(),
-
                    None => label::did(&format_author(
-
                        &self.author.did,
-
                        &self.author.alias,
-
                        self.author.is_you,
-
                    ))
-
                    .into(),
-
                },
-
                label::property_divider(&format!(" {} ", theme.icons.property_divider)).into(),
-
                label::timestamp(&format::timestamp(&self.timestamp)).into(),
-
            ]),
-
        ];
-
        tuirealm::tui::widgets::ListItem::new(lines)
-
    }
-
}
-

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

-
//////////////////////////////////////////////////////
-
#[derive(Clone)]
-
pub enum NotificationKindItem {
-
    Branch {
-
        name: String,
-
        summary: String,
-
        status: String,
-
        id: Option<ObjectId>,
-
    },
-
    Cob {
-
        type_name: String,
-
        summary: String,
-
        status: String,
-
        id: Option<ObjectId>,
-
    },
-
}
-

-
impl TryFrom<(&Repository, NotificationKind, RefUpdate)> for NotificationKindItem {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: (&Repository, NotificationKind, RefUpdate)) -> Result<Self, Self::Error> {
-
        let (repo, kind, update) = value;
-
        let issues = Issues::open(repo)?;
-
        let patches = Patches::open(repo)?;
-

-
        match kind {
-
            NotificationKind::Branch { name } => {
-
                let (head, message) = if let Some(head) = update.new() {
-
                    let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
-
                    (Some(head), message)
-
                } else {
-
                    (None, String::new())
-
                };
-
                let status = match update {
-
                    RefUpdate::Updated { .. } => "updated",
-
                    RefUpdate::Created { .. } => "created",
-
                    RefUpdate::Deleted { .. } => "deleted",
-
                    RefUpdate::Skipped { .. } => "skipped",
-
                };
-

-
                Ok(NotificationKindItem::Branch {
-
                    name: name.to_string(),
-
                    summary: message,
-
                    status: status.to_string(),
-
                    id: head.map(ObjectId::from),
-
                })
-
            }
-
            NotificationKind::Cob { type_name, id } => {
-
                let (category, summary) = if type_name == *cob::issue::TYPENAME {
-
                    let issue = issues.get(&id)?.ok_or(anyhow!("missing"))?;
-
                    (String::from("issue"), issue.title().to_owned())
-
                } else if type_name == *cob::patch::TYPENAME {
-
                    let patch = patches.get(&id)?.ok_or(anyhow!("missing"))?;
-
                    (String::from("patch"), patch.title().to_owned())
-
                } else {
-
                    (type_name.to_string(), "".to_owned())
-
                };
-
                let status = match update {
-
                    RefUpdate::Updated { .. } => "updated",
-
                    RefUpdate::Created { .. } => "opened",
-
                    RefUpdate::Deleted { .. } => "deleted",
-
                    RefUpdate::Skipped { .. } => "skipped",
-
                };
-

-
                Ok(NotificationKindItem::Cob {
-
                    type_name: category.to_string(),
-
                    summary: summary.to_string(),
-
                    status: status.to_string(),
-
                    id: Some(id),
-
                })
-
            }
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
-
pub struct NotificationItem {
-
    /// Unique notification ID.
-
    pub id: NotificationId,
-
    /// Mark this notification as seen.
-
    pub seen: bool,
-
    /// Wrapped notification kind.
-
    pub kind: NotificationKindItem,
-
    /// Time the update has happened.
-
    timestamp: Timestamp,
-
}
-

-
impl NotificationItem {
-
    pub fn id(&self) -> &NotificationId {
-
        &self.id
-
    }
-

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

-
    pub fn kind(&self) -> &NotificationKindItem {
-
        &self.kind
-
    }
-

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

-
impl TableItem<7> for NotificationItem {
-
    fn row(&self, _theme: &Theme, highlight: bool) -> [Cell; 7] {
-
        let seen = if self.seen {
-
            label::blank()
-
        } else {
-
            label::positive(" ● ")
-
        };
-

-
        let (type_name, summary, status, id) = match &self.kind() {
-
            NotificationKindItem::Branch {
-
                name,
-
                summary,
-
                status,
-
                id: _,
-
            } => ("branch".to_string(), summary, status, name.to_string()),
-
            NotificationKindItem::Cob {
-
                type_name,
-
                summary,
-
                status,
-
                id,
-
            } => {
-
                let id = id.map(|id| format::cob(&id)).unwrap_or_default();
-
                (type_name.to_string(), summary, status, id.to_string())
-
            }
-
        };
-

-
        let timestamp = if highlight {
-
            label::reversed(&format::timestamp(&self.timestamp))
-
        } else {
-
            label::timestamp(&format::timestamp(&self.timestamp))
-
        };
-

-
        [
-
            label::default(&format!(" {}", &self.id)).into(),
-
            seen.into(),
-
            label::alias(&type_name).into(),
-
            label::default(summary).into(),
-
            label::id(&id).into(),
-
            label::default(status).into(),
-
            timestamp.into(),
-
        ]
-
    }
-
}
-

-
impl TryFrom<(&Repository, Notification)> for NotificationItem {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: (&Repository, Notification)) -> Result<Self, Self::Error> {
-
        let (repo, notification) = value;
-
        let kind = NotificationKindItem::try_from((repo, notification.kind, notification.update))?;
-

-
        Ok(NotificationItem {
-
            id: notification.id,
-
            seen: notification.status.is_read(),
-
            kind,
-
            timestamp: notification.timestamp.into(),
-
        })
-
    }
-
}
-

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

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

-
pub fn format_author(did: &Did, alias: &Option<Alias>, is_you: bool) -> String {
-
    let author = match alias {
-
        Some(alias) => format!("{alias}"),
-
        None => format::did(did),
-
    };
-

-
    if is_you {
-
        format!("{} (you)", author)
-
    } else {
-
        author
-
    }
-
}
-

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

-
pub fn format_labels(labels: &[Label]) -> String {
-
    let mut output = String::new();
-
    let mut labels = labels.iter().peekable();
-

-
    while let Some(label) = labels.next() {
-
        output.push_str(&label.to_string());
-

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

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

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

-
        if assignees.peek().is_some() {
-
            output.push(',');
-
        }
-
    }
-
    output
-
}
deleted src/ui/cob/format.rs
@@ -1,27 +0,0 @@
-
use radicle::cob::{ObjectId, Timestamp};
-
use radicle::prelude::Did;
-

-
/// Format a git Oid.
-
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
-
    format!("{:.7}", oid.into())
-
}
-

-
/// Format a COB id.
-
pub fn cob(id: &ObjectId) -> String {
-
    format!("{:.7}", id.to_string())
-
}
-

-
/// Format a DID.
-
pub fn did(did: &Did) -> String {
-
    let nid = did.as_key().to_human();
-
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
-
}
-

-
/// Format a timestamp.
-
pub fn timestamp(time: &Timestamp) -> String {
-
    let fmt = timeago::Formatter::new();
-
    let now = Timestamp::now();
-
    let duration = std::time::Duration::from_secs(now.as_secs() - time.as_secs());
-

-
    fmt.convert(duration)
-
}
deleted 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 src/ui/layout.rs
@@ -1,278 +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 FullPage {
-
    pub navigation: Rect,
-
    pub component: Rect,
-
    pub context: Rect,
-
    pub shortcuts: Rect,
-
}
-

-
pub struct DefaultPage {
-
    pub component: Rect,
-
    pub context: Rect,
-
    pub shortcuts: Rect,
-
}
-

-
pub struct IssuePage {
-
    pub header: Rect,
-
    pub left: Rect,
-
    pub right: Rect,
-
    pub context: 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)
-
        .to_vec();
-

-
    widgets.into_iter().zip(layout).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)
-
        .to_vec();
-

-
    widgets.into_iter().zip(layout).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 full_page(area: Rect, context_h: u16, shortcuts_h: u16) -> FullPage {
-
    let nav_h = 3u16;
-
    let margin_h = 1u16;
-
    let component_h = area
-
        .height
-
        .saturating_sub(nav_h.saturating_add(context_h).saturating_add(shortcuts_h));
-

-
    let layout = Layout::default()
-
        .direction(Direction::Vertical)
-
        .horizontal_margin(margin_h)
-
        .constraints(
-
            [
-
                Constraint::Length(nav_h),
-
                Constraint::Length(component_h),
-
                Constraint::Length(context_h),
-
                Constraint::Length(shortcuts_h),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(area);
-

-
    FullPage {
-
        navigation: layout[0],
-
        component: layout[1],
-
        context: layout[2],
-
        shortcuts: layout[3],
-
    }
-
}
-

-
pub fn default_page(area: Rect, context_h: u16, shortcuts_h: u16) -> DefaultPage {
-
    let margin_h = 1u16;
-
    let component_h = area
-
        .height
-
        .saturating_sub(context_h.saturating_add(shortcuts_h));
-

-
    let layout = Layout::default()
-
        .direction(Direction::Vertical)
-
        .horizontal_margin(margin_h)
-
        .constraints(
-
            [
-
                Constraint::Length(component_h),
-
                Constraint::Length(context_h),
-
                Constraint::Length(shortcuts_h),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(area);
-

-
    DefaultPage {
-
        component: layout[0],
-
        context: layout[1],
-
        shortcuts: layout[2],
-
    }
-
}
-

-
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)
-
        .to_vec()
-
}
-

-
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)
-
        .to_vec()
-
}
-

-
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 centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
-
    let popup_layout = Layout::default()
-
        .direction(Direction::Vertical)
-
        .constraints(
-
            [
-
                Constraint::Percentage((100 - percent_y) / 2),
-
                Constraint::Percentage(percent_y),
-
                Constraint::Percentage((100 - percent_y) / 2),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(r);
-

-
    Layout::default()
-
        .direction(Direction::Horizontal)
-
        .constraints(
-
            [
-
                Constraint::Percentage((100 - percent_x) / 2),
-
                Constraint::Percentage(percent_x),
-
                Constraint::Percentage((100 - percent_x) / 2),
-
            ]
-
            .as_ref(),
-
        )
-
        .split(popup_layout[1])[1]
-
}
-

-
pub fn issue_page(area: Rect, shortcuts_h: u16) -> IssuePage {
-
    let header_h = 3u16;
-
    let context_h = 1u16;
-
    let margin_h = 1u16;
-
    let content_h = area
-
        .height
-
        .saturating_sub(header_h.saturating_add(context_h.saturating_add(shortcuts_h)));
-

-
    let root = Layout::default()
-
        .direction(Direction::Vertical)
-
        .horizontal_margin(margin_h)
-
        .constraints(
-
            [
-
                Constraint::Length(header_h),
-
                Constraint::Length(content_h),
-
                Constraint::Length(context_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]);
-

-
    IssuePage {
-
        header: root[0],
-
        left: split[0],
-
        right: split[1],
-
        context: root[2],
-
        shortcuts: root[3],
-
    }
-
}
deleted src/ui/state.rs
@@ -1,163 +0,0 @@
-
use anyhow::anyhow;
-

-
use tuirealm::tui::widgets::{ListState, TableState};
-
use tuirealm::{State, StateValue};
-

-
/// 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> {
-
        if !self.is_empty() {
-
            self.selected
-
        } else {
-
            None
-
        }
-
    }
-

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

-
        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
-
        }
-
    }
-

-
    pub fn len(&self) -> usize {
-
        self.len
-
    }
-

-
    pub fn is_empty(&self) -> bool {
-
        self.len == 0
-
    }
-
}
-

-
impl TryFrom<State> for ItemState {
-
    type Error = anyhow::Error;
-

-
    fn try_from(state: State) -> Result<Self, Self::Error> {
-
        match state {
-
            State::Tup2((StateValue::Usize(selected), StateValue::Usize(len))) => Ok(Self {
-
                selected: Some(selected),
-
                len,
-
            }),
-
            _ => Err(anyhow!(format!(
-
                "Cannot convert into item state: {:?}",
-
                state
-
            ))),
-
        }
-
    }
-
}
-

-
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
-
    }
-
}
-

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

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

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

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

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

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

-
        if old_index != new_index {
-
            self.focus = new_index;
-
            self.focus()
-
        } else {
-
            None
-
        }
-
    }
-
}
deleted src/ui/subscription.rs
@@ -1,22 +0,0 @@
-
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
-
use tuirealm::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 quit_clause<UserEvent>(key: Key) -> SubEventClause<UserEvent>
-
where
-
    UserEvent: Clone + Eq + PartialEq + PartialOrd,
-
{
-
    SubEventClause::Keyboard(KeyEvent {
-
        code: key,
-
        modifiers: KeyModifiers::NONE,
-
    })
-
}
deleted src/ui/theme.rs
@@ -1,147 +0,0 @@
-
use tuirealm::props::BorderType;
-

-
#[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. In the future, it might be defined in a JSON
-
/// config file.
-
#[derive(Debug, Clone)]
-
pub struct Theme {
-
    pub name: String,
-
    pub icons: Icons,
-
    pub tables: Tables,
-
    pub border_type: BorderType,
-
}
-

-
impl Default for Theme {
-
    fn default() -> Theme {
-
        Theme {
-
            name: String::from("Default"),
-
            icons: Icons {
-
                property_divider: '∙',
-
                shortcutbar_divider: '∙',
-
                tab_divider: '|',
-
                tab_overline: '▔',
-
                whitespace: ' ',
-
            },
-
            tables: Tables { spacing: 2 },
-
            border_type: BorderType::Rounded,
-
        }
-
    }
-
}
-

-
pub mod style {
-
    use tuirealm::props::{Color, Style, TextModifiers};
-

-
    pub fn reset() -> Style {
-
        Style::default().fg(Color::Reset)
-
    }
-

-
    pub fn reset_dim() -> Style {
-
        Style::default()
-
            .fg(Color::Reset)
-
            .add_modifier(TextModifiers::DIM)
-
    }
-

-
    pub fn red() -> Style {
-
        Style::default().fg(Color::Red)
-
    }
-

-
    pub fn green() -> Style {
-
        Style::default().fg(Color::Green)
-
    }
-

-
    pub fn yellow() -> Style {
-
        Style::default().fg(Color::Yellow)
-
    }
-

-
    pub fn yellow_dim() -> Style {
-
        yellow().add_modifier(TextModifiers::DIM)
-
    }
-

-
    pub fn yellow_dim_reversed() -> Style {
-
        yellow_dim().add_modifier(TextModifiers::REVERSED)
-
    }
-

-
    pub fn blue() -> Style {
-
        Style::default().fg(Color::Blue)
-
    }
-

-
    pub fn magenta() -> Style {
-
        Style::default().fg(Color::Magenta)
-
    }
-

-
    pub fn magenta_dim() -> Style {
-
        Style::default()
-
            .fg(Color::Magenta)
-
            .add_modifier(TextModifiers::DIM)
-
    }
-

-
    pub fn cyan() -> Style {
-
        Style::default().fg(Color::Cyan)
-
    }
-

-
    pub fn lightblue() -> Style {
-
        Style::default().fg(Color::LightBlue)
-
    }
-

-
    pub fn gray() -> Style {
-
        Style::default().fg(Color::Gray)
-
    }
-

-
    pub fn gray_dim() -> Style {
-
        Style::default()
-
            .fg(Color::Gray)
-
            .add_modifier(TextModifiers::DIM)
-
    }
-

-
    pub fn darkgray() -> Style {
-
        Style::default().fg(Color::DarkGray)
-
    }
-

-
    pub fn reversed() -> Style {
-
        Style::default().add_modifier(TextModifiers::REVERSED)
-
    }
-

-
    pub fn default_reversed() -> Style {
-
        Style::default()
-
            .fg(Color::DarkGray)
-
            // .add_modifier(TextModifiers::DIM)
-
            .add_modifier(TextModifiers::REVERSED)
-
    }
-

-
    pub fn magenta_reversed() -> Style {
-
        Style::default()
-
            .fg(Color::Magenta)
-
            .add_modifier(TextModifiers::REVERSED)
-
    }
-

-
    pub fn yellow_reversed() -> Style {
-
        Style::default().fg(Color::DarkGray).bg(Color::Yellow)
-
    }
-

-
    pub fn border(focus: bool) -> Style {
-
        if focus {
-
            gray_dim()
-
        } else {
-
            darkgray()
-
        }
-
    }
-

-
    pub fn highlight() -> Style {
-
        Style::default()
-
            .fg(Color::Cyan)
-
            .add_modifier(TextModifiers::REVERSED)
-
    }
-
}
deleted src/ui/widget.rs
@@ -1,102 +0,0 @@
-
pub mod container;
-
pub mod context;
-
pub mod form;
-
pub mod label;
-
pub mod list;
-
mod utils;
-

-
use std::ops::Deref;
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, Layout, Props, Style};
-
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 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 layout(mut self, layout: Layout) -> Self {
-
        self.attr(Attribute::Layout, AttrValue::Layout(layout));
-
        self
-
    }
-

-
    pub fn style(mut self, style: Style) -> Self {
-
        self.attr(Attribute::Style, AttrValue::Style(style));
-
        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 src/ui/widget/common.rs
@@ -1,186 +0,0 @@
-
pub mod container;
-
pub mod context;
-
pub mod form;
-
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, Popup, VerticalLine};
-
use self::label::Textarea;
-
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)
-
        .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)
-
}
-

-
pub fn info(theme: &Theme, message: &str) -> Widget<Popup> {
-
    let textarea =
-
        Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
-
    let container = labeled_container(theme, "Info", textarea.to_boxed());
-

-
    Widget::new(Popup::new(theme.clone(), container))
-
        .width(50)
-
        .height(20)
-
}
-

-
pub fn warning(theme: &Theme, message: &str) -> Widget<Popup> {
-
    let textarea =
-
        Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
-
    let container = labeled_container(theme, "Warning", textarea.to_boxed());
-

-
    Widget::new(Popup::new(theme.clone(), container))
-
        .width(50)
-
        .height(20)
-
}
-

-
pub fn error(theme: &Theme, message: &str) -> Widget<Popup> {
-
    let textarea =
-
        Widget::new(Textarea::new(theme.clone())).content(AttrValue::String(message.to_owned()));
-
    let container = labeled_container(theme, "Error", textarea.to_boxed());
-

-
    Widget::new(Popup::new(theme.clone(), container))
-
        .width(50)
-
        .height(20)
-
}
deleted src/ui/widget/container.rs
@@ -1,484 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, BorderSides, Props, TextModifiers};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Margin, Rect};
-
use tuirealm::tui::widgets::{Block, Cell, Clear, 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::{style, 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).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();
-

-
        if display {
-
            let block = HeaderBlock::default()
-
                .borders(BorderSides::all())
-
                .border_style(style::border(focus))
-
                .border_type(self.theme.border_type);
-
            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::reset())
-
                })
-
                .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();
-

-
        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)])
-
                .split(area);
-
            // reverse draw order: child needs to be drawn first?
-
            self.component
-
                .attr(Attribute::Focus, AttrValue::Flag(focus));
-
            self.component.view(frame, layout[1]);
-

-
            let block = Block::default()
-
                .borders(BorderSides::ALL)
-
                .border_style(style::border(focus))
-
                .border_type(self.theme.border_type);
-
            frame.render_widget(block, area);
-
        }
-
    }
-

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

-
    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 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::Min(1)].as_ref())
-
                .split(area);
-

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

-
            let block = Block::default()
-
                .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
-
                .border_style(style::border(focus))
-
                .border_type(self.theme.border_type);
-
            frame.render_widget(block.clone(), layout[1]);
-

-
            self.component
-
                .attr(Attribute::Focus, AttrValue::Flag(focus));
-
            self.component.view(
-
                frame,
-
                block.inner(layout[1]).inner(&Margin {
-
                    vertical: 0,
-
                    horizontal: 1,
-
                }),
-
            );
-
        }
-
    }
-

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

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

-
pub struct Popup {
-
    component: Widget<LabeledContainer>,
-
}
-

-
impl Popup {
-
    pub fn new(_theme: Theme, component: Widget<LabeledContainer>) -> Self {
-
        Self { component }
-
    }
-
}
-

-
impl WidgetComponent for Popup {
-
    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 width = properties
-
            .get_or(Attribute::Width, AttrValue::Size(50))
-
            .unwrap_size();
-
        let height = properties
-
            .get_or(Attribute::Height, AttrValue::Size(50))
-
            .unwrap_size();
-

-
        if display {
-
            let size = frame.size();
-

-
            let area = layout::centered_rect(width, height, size);
-
            frame.render_widget(Clear, area);
-

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

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

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.component.perform(cmd)
-
    }
-
}
deleted src/ui/widget/context.rs
@@ -1,233 +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::{self, Label, LabelGroup};
-

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

-
pub enum Progress {
-
    Percentage(usize),
-
    Step(usize, usize),
-
    None,
-
}
-

-
impl ToString for Progress {
-
    fn to_string(&self) -> std::string::String {
-
        match self {
-
            Progress::Percentage(value) => format!("{value} %"),
-
            Progress::Step(step, total) => format!("{step}/{total}"),
-
            _ => String::new(),
-
        }
-
    }
-
}
-

-
/// 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.
-
#[derive(Clone)]
-
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 {
-
    col_0: Widget<LabelGroup>,
-
    col_1: Widget<LabelGroup>,
-
    col_2: Widget<LabelGroup>,
-
    col_3: Widget<LabelGroup>,
-
    col_4: Widget<LabelGroup>,
-
}
-

-
impl ContextBar {
-
    pub const PROP_EDIT_MODE: &'static str = "edit-mode";
-

-
    pub fn new(
-
        col_0: Widget<LabelGroup>,
-
        col_1: Widget<LabelGroup>,
-
        col_2: Widget<LabelGroup>,
-
        col_3: Widget<LabelGroup>,
-
        col_4: Widget<LabelGroup>,
-
    ) -> Self {
-
        Self {
-
            col_0,
-
            col_1,
-
            col_2,
-
            col_3,
-
            col_4,
-
        }
-
    }
-
}
-

-
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 edit_mode = properties
-
            .get_or(
-
                Attribute::Custom(Self::PROP_EDIT_MODE),
-
                AttrValue::Flag(false),
-
            )
-
            .unwrap_flag();
-

-
        let col_0_w = self.col_0.query(Attribute::Width).unwrap().unwrap_size();
-
        let col_1_w = self.col_1.query(Attribute::Width).unwrap().unwrap_size();
-
        let col_3_w = self.col_3.query(Attribute::Width).unwrap().unwrap_size();
-
        let col_4_w = self.col_4.query(Attribute::Width).unwrap().unwrap_size();
-

-
        if edit_mode {
-
            self.col_0.attr(
-
                Attribute::Background,
-
                AttrValue::Color(style::yellow_reversed().bg.unwrap()),
-
            )
-
        }
-

-
        if display {
-
            let content_layout = layout::h_stack(
-
                vec![
-
                    self.col_0.clone().to_boxed(),
-
                    self.col_1.clone().to_boxed(),
-
                    self.col_2
-
                        .clone()
-
                        .width(
-
                            area.width
-
                                .saturating_sub(col_0_w + col_1_w + col_3_w + col_4_w),
-
                        )
-
                        .to_boxed(),
-
                    self.col_3.clone().to_boxed(),
-
                    self.col_4.clone().to_boxed(),
-
                ],
-
                area,
-
            );
-

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

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

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

-
pub fn bar(
-
    _theme: &Theme,
-
    label_0: &str,
-
    label_1: &str,
-
    label_2: &str,
-
    label_3: &str,
-
    label_4: &str,
-
) -> Widget<ContextBar> {
-
    let label_0 = label::badge(&format!(" {label_0} "));
-
    let label_1 = label::default_reversed(&format!(" {label_1} "));
-
    let label_2 = label::default_reversed(&format!(" {label_2} "));
-
    let label_3 = label::default_reversed(&format!(" {label_3} "));
-
    let label_4 = label::default_reversed(&format!(" {label_4} "));
-

-
    let label_0 = label::group(&[label_0]);
-
    let label_1 = label::group(&[label_1]);
-
    let label_2 = label::group(&[label_2]);
-
    let label_3 = label::group(&[label_3]);
-
    let label_4 = label::group(&[label_4]);
-

-
    let context_bar = ContextBar::new(label_0, label_1, label_2, label_3, label_4);
-

-
    Widget::new(context_bar).height(1)
-
}
deleted src/ui/widget/form.rs
@@ -1,268 +0,0 @@
-
use std::collections::LinkedList;
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::{Constraint, Direction, Margin, Rect};
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State, StateValue};
-

-
use crate::ui::state::FormState;
-
use crate::ui::theme::{style, Theme};
-
use crate::ui::widget::{Widget, WidgetComponent};
-

-
use super::container::Container;
-
use super::label::{self, Label};
-

-
pub struct TextField {
-
    input: Widget<Container>,
-
    placeholder: Widget<Label>,
-
    show_placeholder: bool,
-
}
-

-
impl TextField {
-
    pub fn new(theme: Theme, title: &str) -> Self {
-
        // TODO: activate again
-
        // let input = tui_realm_textarea::TextArea::default()
-
        //     .wrap(false)
-
        //     .single_line(true)
-
        //     .cursor_line_style(Style::reset())
-
        //     .style(style::reset());
-
        let input = tui_realm_stdlib::Textarea::default();
-
        let container = crate::ui::container(&theme, Box::new(input));
-

-
        Self {
-
            input: container,
-
            placeholder: label::default(title).style(style::gray_dim()),
-
            show_placeholder: true,
-
        }
-
    }
-
}
-

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

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

-
        if self.show_placeholder {
-
            let inner = area.inner(&Margin {
-
                vertical: 1,
-
                horizontal: 2,
-
            });
-
            self.placeholder.view(frame, inner);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        if let State::Vec(values) = self.input.state() {
-
            let text = match values.get(0) {
-
                Some(StateValue::String(line)) => line.clone(),
-
                _ => String::new(),
-
            };
-

-
            State::One(StateValue::String(text))
-
        } else {
-
            State::None
-
        }
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        // TODO: activate again
-
        // use tui_realm_textarea::*;
-

-
        // let cmd = match cmd {
-
        //     Cmd::Custom(Form::CMD_PASTE) => Cmd::Custom(TEXTAREA_CMD_PASTE),
-
        //     _ => cmd,
-
        // };
-
        // let result = self.input.perform(cmd);
-

-
        // if let State::Vec(values) = self.input.state() {
-
        //     if let Some(StateValue::String(input)) = values.first() {
-
        //         self.show_placeholder = values.len() == 1 && input.is_empty();
-
        //     } else {
-
        //         self.show_placeholder = false;
-
        //     }
-
        // }
-
        // result
-
        CmdResult::None
-
    }
-
}
-

-
pub struct TextArea {
-
    input: Widget<Container>,
-
    placeholder: Widget<Label>,
-
    show_placeholder: bool,
-
}
-

-
impl TextArea {
-
    pub fn new(theme: Theme, title: &str) -> Self {
-
        // TODO: activate again
-
        // let input = tui_realm_textarea::TextArea::default()
-
        //     .wrap(true)
-
        //     .single_line(false)
-
        //     .cursor_line_style(Style::reset())
-
        //     .style(style::reset());
-
        let input = tui_realm_stdlib::Textarea::default();
-
        let container = crate::ui::container(&theme, Box::new(input));
-

-
        Self {
-
            input: container,
-
            placeholder: label::default(title).style(style::gray_dim()),
-
            show_placeholder: true,
-
        }
-
    }
-
}
-

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

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

-
        if self.show_placeholder {
-
            let inner = area.inner(&Margin {
-
                vertical: 1,
-
                horizontal: 2,
-
            });
-
            self.placeholder.view(frame, inner);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        // Fold each input's vector of lines into a single string.
-
        if let State::Vec(values) = self.input.state() {
-
            let mut text = String::new();
-
            let lines = values
-
                .iter()
-
                .map(|value| match value {
-
                    StateValue::String(line) => line.clone(),
-
                    _ => String::new(),
-
                })
-
                .collect::<Vec<_>>();
-

-
            let mut lines = lines.iter().peekable();
-
            while let Some(line) = lines.next() {
-
                text.push_str(line);
-
                if lines.peek().is_some() {
-
                    text.push('\n');
-
                }
-
            }
-

-
            State::One(StateValue::String(text))
-
        } else {
-
            State::None
-
        }
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        // TODO: activate again
-
        // use tui_realm_textarea::*;
-

-
        // let cmd = match cmd {
-
        //     Cmd::Custom(Form::CMD_PASTE) => Cmd::Custom(TEXTAREA_CMD_PASTE),
-
        //     Cmd::Custom(Form::CMD_NEWLINE) => Cmd::Custom(TEXTAREA_CMD_NEWLINE),
-
        //     _ => cmd,
-
        // };
-
        // let result = self.input.perform(cmd);
-

-
        // if let State::Vec(values) = self.input.state() {
-
        //     if let Some(StateValue::String(input)) = values.first() {
-
        //         self.show_placeholder = values.len() == 1 && input.is_empty();
-
        //     } else {
-
        //         self.show_placeholder = false;
-
        //     }
-
        // }
-
        // result
-
        CmdResult::None
-
    }
-
}
-

-
pub struct Form {
-
    // This form's fields: title, tags, assignees, description.
-
    inputs: Vec<Box<dyn MockComponent>>,
-
    /// State that holds the current focus etc.
-
    state: FormState,
-
}
-

-
impl Form {
-
    pub const CMD_FOCUS_PREVIOUS: &'static str = "cmd-focus-previous";
-
    pub const CMD_FOCUS_NEXT: &'static str = "cmd-focus-next";
-
    pub const CMD_NEWLINE: &'static str = "cmd-newline";
-
    pub const CMD_PASTE: &'static str = "cmd-paste";
-

-
    pub const PROP_ID: &'static str = "prop-id";
-

-
    pub fn new(_theme: Theme, inputs: Vec<Box<dyn MockComponent>>) -> Self {
-
        let state = FormState::new(Some(0), inputs.len());
-

-
        Self { inputs, state }
-
    }
-
}
-

-
impl WidgetComponent for Form {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        use tuirealm::props::Layout;
-
        // Clear and set current focus
-
        let focus = self.state.focus().unwrap_or(0);
-
        for input in &mut self.inputs {
-
            input.attr(Attribute::Focus, AttrValue::Flag(false));
-
        }
-
        if let Some(input) = self.inputs.get_mut(focus) {
-
            input.attr(Attribute::Focus, AttrValue::Flag(true));
-
        }
-

-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(
-
                &self
-
                    .inputs
-
                    .iter()
-
                    .map(|_| Constraint::Length(3))
-
                    .collect::<Vec<_>>(),
-
            );
-
        let layout = properties
-
            .get_or(Attribute::Layout, AttrValue::Layout(layout))
-
            .unwrap_layout();
-
        let layout = layout.chunks(area);
-

-
        for (index, area) in layout.iter().enumerate().take(self.inputs.len()) {
-
            if let Some(input) = self.inputs.get_mut(index) {
-
                input.view(frame, *area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        let states = self
-
            .inputs
-
            .iter()
-
            .map(|input| input.state())
-
            .collect::<LinkedList<_>>();
-
        State::Linked(states)
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        match cmd {
-
            Cmd::Custom(Self::CMD_FOCUS_PREVIOUS) => {
-
                self.state.focus_previous();
-
                CmdResult::None
-
            }
-
            Cmd::Custom(Self::CMD_FOCUS_NEXT) => {
-
                self.state.focus_next();
-
                CmdResult::None
-
            }
-
            Cmd::Submit => CmdResult::Submit(self.state()),
-
            _ => {
-
                let focus = self.state.focus().unwrap_or(0);
-
                if let Some(input) = self.inputs.get_mut(focus) {
-
                    return input.perform(cmd);
-
                }
-
                CmdResult::None
-
            }
-
        }
-
    }
-
}
deleted src/ui/widget/label.rs
@@ -1,352 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{Alignment, AttrValue, Attribute, Color, Props, Style};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::tui::text::{Line, Span, Text};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
-

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

-
pub fn blank() -> Widget<Label> {
-
    default("")
-
}
-

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

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

-
pub fn reversed(content: &str) -> Widget<Label> {
-
    default(content).style(style::reversed())
-
}
-

-
pub fn default_reversed(content: &str) -> Widget<Label> {
-
    default(content).style(style::default_reversed())
-
}
-

-
pub fn group(labels: &[Widget<Label>]) -> Widget<LabelGroup> {
-
    let group = LabelGroup::new(labels);
-
    let width = labels.iter().fold(0, |total, label| {
-
        total
-
            + label
-
                .query(Attribute::Width)
-
                .unwrap_or(AttrValue::Size(0))
-
                .unwrap_size()
-
    });
-

-
    Widget::new(group).width(width)
-
}
-

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

-
    default(content)
-
}
-

-
pub fn header(content: &str) -> Widget<Label> {
-
    default(content).style(style::reset_dim())
-
}
-

-
pub fn property(content: &str) -> Widget<Label> {
-
    default(content).style(style::cyan())
-
}
-

-
pub fn property_divider(content: &str) -> Widget<Label> {
-
    default(content).style(style::gray())
-
}
-

-
pub fn badge(content: &str) -> Widget<Label> {
-
    default(content).style(style::magenta_reversed())
-
}
-

-
pub fn title(content: &str) -> Widget<Label> {
-
    default(content)
-
}
-

-
pub fn labels(content: &str) -> Widget<Label> {
-
    default(content).style(style::lightblue())
-
}
-

-
pub fn alias(content: &str) -> Widget<Label> {
-
    default(content).style(style::magenta())
-
}
-

-
pub fn did(content: &str) -> Widget<Label> {
-
    default(content).style(style::magenta_dim())
-
}
-

-
pub fn id(content: &str) -> Widget<Label> {
-
    default(content).style(style::cyan())
-
}
-

-
pub fn oid(content: &str) -> Widget<Label> {
-
    default(content).style(style::lightblue())
-
}
-

-
pub fn timestamp(content: &str) -> Widget<Label> {
-
    default(content).style(style::gray_dim())
-
}
-

-
pub fn positive(content: &str) -> Widget<Label> {
-
    default(content).style(style::green())
-
}
-

-
pub fn negative(content: &str) -> Widget<Label> {
-
    default(content).style(style::red())
-
}
-

-
/// 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 style = properties
-
            .get_or(Attribute::Style, AttrValue::Style(Style::default()))
-
            .unwrap_style();
-

-
        if display {
-
            let mut label = Label::default()
-
                .foreground(style.fg.unwrap_or(Color::Reset))
-
                .background(style.bg.unwrap_or(Color::Reset))
-
                .modifiers(style.add_modifier)
-
                .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();
-
        let style = label
-
            .query(Attribute::Style)
-
            .unwrap_or(AttrValue::Style(Style::default()))
-
            .unwrap_style();
-

-
        Span::styled(content, style)
-
    }
-
}
-

-
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();
-
        let style = label
-
            .query(Attribute::Style)
-
            .unwrap_or(AttrValue::Style(Style::default()))
-
            .unwrap_style();
-

-
        Span::styled(content, style)
-
    }
-
}
-

-
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 style = label
-
            .query(Attribute::Style)
-
            .unwrap_or(AttrValue::Style(Style::default()))
-
            .unwrap_style();
-

-
        Text::styled(content, style)
-
    }
-
}
-

-
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 style = label
-
            .query(Attribute::Style)
-
            .unwrap_or(AttrValue::Style(Style::default()))
-
            .unwrap_style();
-

-
        Text::styled(content, style)
-
    }
-
}
-

-
#[derive(Clone, Default)]
-
pub struct LabelGroup {
-
    labels: Vec<Widget<Label>>,
-
}
-

-
impl LabelGroup {
-
    pub fn new(labels: &[Widget<Label>]) -> Self {
-
        Self {
-
            labels: labels.to_vec(),
-
        }
-
    }
-
}
-

-
impl WidgetComponent for LabelGroup {
-
    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 labels: Vec<Box<dyn MockComponent>> = vec![];
-
            for label in &self.labels {
-
                labels.push(label.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
-
    }
-
}
-

-
#[derive(Default)]
-
pub struct Textarea {
-
    /// The scroll offset.
-
    offset: usize,
-
    /// The current line count.
-
    len: usize,
-
    /// The current display height.
-
    height: usize,
-
    /// The percentage scrolled.
-
    scroll_percent: usize,
-
}
-

-
impl Textarea {
-
    pub const PROP_DISPLAY_PROGRESS: &'static str = "display-progress";
-

-
    fn scroll_percent(offset: usize, len: usize, height: usize) -> usize {
-
        if height >= len {
-
            100
-
        } else {
-
            let y = offset as f64;
-
            let h = height as f64;
-
            let t = len.saturating_sub(1) as f64;
-
            let v = y / (t - h) * 100_f64;
-

-
            std::cmp::max(0, std::cmp::min(100, v as usize))
-
        }
-
    }
-
}
-

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

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

-
        let content = properties
-
            .get_or(Attribute::Content, AttrValue::String(String::default()))
-
            .unwrap_string();
-

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

-
        // TODO: replace with `ratatui`'s reflow module when that becomes
-
        // public: https://github.com/tui-rs-revival/ratatui/pull/9.
-
        //
-
        // In the future, there should be highlighting for e.g. Markdown which
-
        // needs be done before wrapping. So this should rather wrap styled text
-
        // spans than plain text.
-
        let body = textwrap::wrap(&content, area.width.saturating_sub(2) as usize);
-
        self.len = body.len();
-
        self.height = (layout[0].height - 1) as usize;
-

-
        let body: String = body.iter().fold(String::new(), |mut body, line| {
-
            body.push_str(&format!("{}\n", line));
-
            body
-
        });
-

-
        let paragraph = Paragraph::new(body)
-
            .scroll((self.offset as u16, 0))
-
            .style(style::reset());
-
        frame.render_widget(paragraph, layout[0]);
-

-
        self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
-

-
        if display_progress {
-
            let progress = Line::from(vec![Span::styled(
-
                format!("{} %", self.scroll_percent),
-
                style::border(focus),
-
            )]);
-

-
            let progress = Paragraph::new(progress).alignment(Alignment::Right);
-
            frame.render_widget(progress, layout[1]);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::One(StateValue::Usize(self.scroll_percent))
-
    }
-

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

-
        match cmd {
-
            Cmd::Scroll(Direction::Up) => {
-
                self.offset = self.offset.saturating_sub(1);
-
                self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
-
                CmdResult::None
-
            }
-
            Cmd::Scroll(Direction::Down) => {
-
                if self.scroll_percent < 100 {
-
                    self.offset = self.offset.saturating_add(1);
-
                    self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
-
                }
-
                CmdResult::None
-
            }
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
deleted src/ui/widget/list.rs
@@ -1,375 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, BorderSides, Props};
-
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::{style, Theme};
-
use crate::ui::widget::{utils, Widget, WidgetComponent};
-

-
use super::container::Header;
-
use super::label::{self, Label};
-

-
/// 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, highlight: bool) -> [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::default("");
-
        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 + PartialEq,
-
{
-
    /// 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 + PartialEq,
-
{
-
    pub fn new(
-
        items: &[V],
-
        selected: Option<V>,
-
        header: [Widget<Label>; W],
-
        widths: [ColumnWidth; W],
-
        theme: Theme,
-
    ) -> Self {
-
        let selected = match selected {
-
            Some(item) => items.iter().position(|i| i == &item),
-
            _ => None,
-
        };
-

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

-
impl<V, const W: usize> WidgetComponent for Table<V, W>
-
where
-
    V: TableItem<W> + Clone + PartialEq,
-
{
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-

-
        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()
-
            .enumerate()
-
            .map(|(index, item)| {
-
                Row::new(item.row(
-
                    &self.theme,
-
                    match self.state.selected() {
-
                        Some(selected) => index == selected,
-
                        None => false,
-
                    },
-
                ))
-
            })
-
            .collect();
-

-
        let table = tuirealm::tui::widgets::Table::new(rows)
-
            .block(
-
                Block::default()
-
                    .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
-
                    .border_style(style::border(focus))
-
                    .border_type(self.theme.border_type),
-
            )
-
            .highlight_style(style::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 {
-
        let selected = self.state.selected().unwrap_or_default();
-
        let len = self.items.len();
-
        State::Tup2((StateValue::Usize(selected), StateValue::Usize(len)))
-
    }
-

-
    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(_) => CmdResult::Changed(self.state()),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Move(Direction::Down) => match self.state.select_next() {
-
                Some(_) => CmdResult::Changed(self.state()),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Submit => match self.state.selected() {
-
                Some(_) => CmdResult::Submit(self.state()),
-
                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,
-
        };
-

-
        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 rows: Vec<ListItem> = self
-
            .items
-
            .iter()
-
            .map(|item| item.row(&self.theme))
-
            .collect();
-
        let list = List::new(rows).highlight_style(style::highlight());
-

-
        frame.render_stateful_widget(list, area, &mut ListState::from(&self.state));
-
    }
-

-
    fn state(&self) -> State {
-
        let selected = self.state.selected().unwrap_or_default();
-
        let len = self.items.len();
-
        State::Tup2((StateValue::Usize(selected), StateValue::Usize(len)))
-
    }
-

-
    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(_) => CmdResult::Changed(self.state()),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Move(Direction::Down) => match self.state.select_next() {
-
                Some(_) => CmdResult::Changed(self.state()),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Submit => match self.state.selected() {
-
                Some(_) => CmdResult::Submit(self.state()),
-
                None => CmdResult::None,
-
            },
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
deleted src/ui/widget/utils.rs
@@ -1,43 +0,0 @@
-
use tuirealm::tui::layout::{Constraint, Rect};
-

-
use super::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()
-
}