Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
lib: Remove legacy tui-realm framework
Merged did:key:z6MkgFq6...nBGz opened 2 years ago
75 files changed +3429 -7405 74b576ca 96a86880
modified Cargo.lock
@@ -65,15 +65,6 @@ dependencies = [
]

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

-
[[package]]
name = "allocator-api2"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -916,15 +907,6 @@ dependencies = [

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

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

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

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

-
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1655,7 +1614,7 @@ dependencies = [
 "radicle",
 "radicle-surf",
 "radicle-term",
-
 "ratatui 0.26.1",
+
 "ratatui",
 "serde",
 "serde_json",
 "signal-hook",
@@ -1666,8 +1625,6 @@ dependencies = [
 "timeago",
 "tokio",
 "tokio-stream",
-
 "tui-realm-stdlib",
-
 "tuirealm",
]

[[package]]
@@ -1701,23 +1658,6 @@ dependencies = [

[[package]]
name = "ratatui"
-
version = "0.23.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "2e2e4cd95294a85c3b4446e63ef054eea43e0205b1fd60120c16b74ff7ff96ad"
-
dependencies = [
-
 "bitflags 2.4.1",
-
 "cassowary",
-
 "indoc",
-
 "itertools 0.11.0",
-
 "paste",
-
 "strum 0.25.0",
-
 "termion 2.0.1",
-
 "unicode-segmentation",
-
 "unicode-width",
-
]
-

-
[[package]]
-
name = "ratatui"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcb12f8fbf6c62614b0d56eb352af54f6a22410c3b079eb53ee93c7b97dd31d8"
@@ -1726,11 +1666,11 @@ dependencies = [
 "cassowary",
 "compact_str",
 "indoc",
-
 "itertools 0.12.1",
+
 "itertools",
 "lru",
 "paste",
 "stability",
-
 "strum 0.26.1",
+
 "strum",
 "termion 3.0.0",
 "time 0.3.34",
 "unicode-segmentation",
@@ -1777,35 +1717,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb"

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

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

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

-
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2143,33 +2054,11 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"

[[package]]
name = "strum"
-
version = "0.25.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
-
dependencies = [
-
 "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]]
-
name = "strum_macros"
-
version = "0.25.3"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
-
dependencies = [
-
 "heck",
-
 "proc-macro2",
-
 "quote",
-
 "rustversion",
-
 "syn 2.0.48",
+
 "strum_macros",
]

[[package]]
@@ -2251,18 +2140,6 @@ dependencies = [

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

-
[[package]]
-
name = "termion"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417813675a504dfbbf21bfde32c03e5bf9f2413999962b479023c02848c1c7a5"
@@ -2424,56 +2301,6 @@ dependencies = [
]

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

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

-
[[package]]
-
name = "tuirealm"
-
version = "1.9.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "412447298ad477c25ff50c4a894ff5077b6ee3e25b913d42db30021d81b1af53"
-
dependencies = [
-
 "bitflags 2.4.1",
-
 "lazy-regex",
-
 "ratatui 0.23.0",
-
 "termion 2.0.1",
-
 "thiserror",
-
 "tui",
-
 "tuirealm_derive",
-
]
-

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

-
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -10,11 +10,6 @@ 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"] }
@@ -35,7 +30,5 @@ timeago = { version = "0.4.1" }
termion = { version = "3" }
textwrap = { version = "0.16.0" }
thiserror = { version = "1" }
-
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 }
+
tokio = { version = "1.32.0", features = ["full"] }
+
tokio-stream = { version = "0.1.14" }
modified bin/commands/inbox.rs
@@ -9,7 +9,7 @@ use anyhow::anyhow;

use radicle_tui as tui;

-
use tui::common::cob::inbox::{self};
+
use tui::cob::inbox::{self};

use crate::terminal;
use crate::terminal::args::{Args, Error, Help};
@@ -141,7 +141,7 @@ impl Args for Options {
#[tokio::main]
pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
    use radicle::storage::ReadStorage;
-
    use tui::common::log;
+
    use tui::log;

    let (_, rid) = radicle::rad::cwd()
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
modified bin/commands/inbox/select.rs
@@ -12,12 +12,12 @@ use radicle::storage::git::Repository;
use radicle::Profile;
use radicle_tui as tui;

-
use tui::common::cob::inbox::{self};
-
use tui::flux::store;
-
use tui::flux::store::StateValue;
-
use tui::flux::task::{self, Interrupted};
-
use tui::flux::ui::items::NotificationItem;
-
use tui::flux::ui::Frontend;
+
use tui::cob::inbox::{self};
+
use tui::store;
+
use tui::store::StateValue;
+
use tui::task::{self, Interrupted};
+
use tui::ui::items::NotificationItem;
+
use tui::ui::Frontend;
use tui::Exit;

use ui::ListPage;
modified bin/commands/inbox/select/ui.rs
@@ -14,14 +14,12 @@ use radicle::identity::Project;

use radicle_tui as tui;

-
use tui::flux::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
-
use tui::flux::ui::span;
-
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
-
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
-
use tui::flux::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::flux::ui::widget::{
-
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
-
};
+
use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
+
use tui::ui::span;
+
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::widget::input::{TextField, TextFieldProps};
+
use tui::ui::widget::text::{Paragraph, ParagraphProps};
+
use tui::ui::widget::{Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
use tui::Selection;

use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
@@ -122,7 +120,7 @@ impl<'a> Widget<State, Action> for ListPage<'a> {
impl<'a> Render<()> for ListPage<'a> {
    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 layout = tui::ui::layout::default_page(area, 0u16, 1u16);

        let shortcuts = if self.props.show_search {
            vec![
modified bin/commands/issue.rs
@@ -12,8 +12,8 @@ use radicle::issue;
use radicle::identity::RepoId;
use radicle_tui as tui;

-
use tui::common::cob;
-
use tui::common::log;
+
use tui::cob;
+
use tui::log;

use crate::terminal;
use crate::terminal::args::{Args, Error, Help};
modified bin/commands/issue/select.rs
@@ -9,11 +9,11 @@ use radicle::Profile;

use radicle_tui as tui;

-
use tui::common::cob::issue::{self, Filter};
-
use tui::flux::store::{self, StateValue};
-
use tui::flux::task::{self, Interrupted};
-
use tui::flux::ui::items::IssueItem;
-
use tui::flux::ui::Frontend;
+
use tui::cob::issue::{self, Filter};
+
use tui::store::{self, StateValue};
+
use tui::task::{self, Interrupted};
+
use tui::ui::items::IssueItem;
+
use tui::ui::Frontend;
use tui::Exit;

use ui::ListPage;
modified bin/commands/issue/select/ui.rs
@@ -14,14 +14,12 @@ use ratatui::text::{Line, Span, Text};

use radicle_tui as tui;

-
use tui::flux::ui::items::{IssueItem, IssueItemFilter};
-
use tui::flux::ui::span;
-
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
-
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
-
use tui::flux::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::flux::ui::widget::{
-
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
-
};
+
use tui::ui::items::{IssueItem, IssueItemFilter};
+
use tui::ui::span;
+
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::widget::input::{TextField, TextFieldProps};
+
use tui::ui::widget::text::{Paragraph, ParagraphProps};
+
use tui::ui::widget::{Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
use tui::Selection;

use crate::tui_issue::common::IssueOperation;
@@ -120,7 +118,7 @@ impl<'a> Widget<State, Action> for ListPage<'a> {
impl<'a> Render<()> for ListPage<'a> {
    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 layout = tui::ui::layout::default_page(area, 0u16, 1u16);

        let shortcuts = if self.props.show_search {
            vec![
modified bin/commands/patch.rs
@@ -11,8 +11,8 @@ use radicle::identity::RepoId;
use radicle::patch::Status;
use radicle_tui as tui;

-
use tui::common::cob::patch::{self, Filter};
-
use tui::common::log;
+
use tui::cob::patch::{self, Filter};
+
use tui::log;

use crate::terminal;
use crate::terminal::args::{Args, Error, Help};
modified bin/commands/patch/select.rs
@@ -9,11 +9,11 @@ use radicle::Profile;

use radicle_tui as tui;

-
use tui::common::cob::patch::{self, Filter};
-
use tui::flux::store;
-
use tui::flux::task::{self, Interrupted};
-
use tui::flux::ui::items::PatchItem;
-
use tui::flux::ui::Frontend;
+
use tui::cob::patch::{self, Filter};
+
use tui::store;
+
use tui::task::{self, Interrupted};
+
use tui::ui::items::PatchItem;
+
use tui::ui::Frontend;
use tui::Exit;

use ui::ListPage;
modified bin/commands/patch/select/ui.rs
@@ -15,14 +15,12 @@ use radicle::patch::{self, Status};

use radicle_tui as tui;

-
use tui::flux::ui::items::{PatchItem, PatchItemFilter};
-
use tui::flux::ui::span;
-
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
-
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
-
use tui::flux::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::flux::ui::widget::{
-
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
-
};
+
use tui::ui::items::{PatchItem, PatchItemFilter};
+
use tui::ui::span;
+
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::widget::input::{TextField, TextFieldProps};
+
use tui::ui::widget::text::{Paragraph, ParagraphProps};
+
use tui::ui::widget::{Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
use tui::Selection;

use crate::tui_patch::common::Mode;
@@ -122,7 +120,7 @@ impl<'a> Widget<State, Action> for ListPage<'a> {
impl<'a> Render<()> for ListPage<'a> {
    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 layout = tui::ui::layout::default_page(area, 0u16, 1u16);

        let shortcuts = if self.props.show_search {
            vec![
added src/cob.rs
@@ -0,0 +1,38 @@
+
use std::str::FromStr;
+

+
use anyhow::Result;
+

+
use radicle::cob::Label;
+
use radicle::prelude::Did;
+

+
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/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/cob/issue.rs
@@ -0,0 +1,149 @@
+
use anyhow::Result;
+

+
use radicle::cob::issue::{Issue, IssueId};
+
use radicle::cob::Label;
+
use radicle::issue::cache::Issues;
+
use radicle::issue::State;
+
use radicle::prelude::{Did, Signer};
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct Filter {
+
    state: Option<State>,
+
    assigned: bool,
+
    assignees: Vec<Did>,
+
}
+

+
impl Default for Filter {
+
    fn default() -> Self {
+
        Self {
+
            state: Some(State::default()),
+
            assigned: false,
+
            assignees: vec![],
+
        }
+
    }
+
}
+

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

+
impl ToString for Filter {
+
    fn to_string(&self) -> String {
+
        let mut filter = String::new();
+

+
        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(&assignee.encode());
+

+
                if assignees.peek().is_some() {
+
                    filter.push(',');
+
                }
+
            }
+
            filter.push(']');
+
        }
+

+
        filter
+
    }
+
}
+

+
pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
+
    let cache = profile.issues(repository)?;
+
    let issues = cache.list()?;
+

+
    Ok(issues.flatten().collect())
+
}
+

+
pub fn find(profile: &Profile, repository: &Repository, id: &IssueId) -> Result<Option<Issue>> {
+
    let cache = profile.issues(repository)?;
+
    Ok(cache.get(id)?)
+
}
+

+
pub fn create<G: Signer>(
+
    profile: &Profile,
+
    repository: &Repository,
+
    signer: &G,
+
    title: String,
+
    description: String,
+
    labels: &[Label],
+
    assignees: &[Did],
+
) -> Result<IssueId> {
+
    let mut issues = profile.issues_mut(repository)?;
+
    let issue = issues.create(title, description.trim(), labels, assignees, [], signer)?;
+

+
    Ok(*issue.id())
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use std::str::FromStr;
+

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

+
    use super::*;
+

+
    #[test]
+
    fn issue_filter_display_with_state_should_succeed() -> Result<()> {
+
        let actual = Filter::default().with_state(Some(issue::State::Open));
+

+
        assert_eq!(String::from("is:open "), actual.to_string());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn issue_filter_display_with_state_and_assigned_should_succeed() -> Result<()> {
+
        let actual = Filter::default()
+
            .with_state(Some(issue::State::Open))
+
            .with_assgined(true);
+

+
        assert_eq!(String::from("is:open is:assigned "), actual.to_string());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn issue_filter_display_with_status_and_author_should_succeed() -> Result<()> {
+
        let actual = Filter::default()
+
            .with_state(Some(issue::State::Open))
+
            .with_assginee(Did::from_str(
+
                "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
+
            )?);
+

+
        assert_eq!(
+
            String::from(
+
                "is:open assignees:[did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V]"
+
            ),
+
            actual.to_string()
+
        );
+

+
        Ok(())
+
    }
+
}
added src/cob/patch.rs
@@ -0,0 +1,133 @@
+
use anyhow::Result;
+

+
use radicle::cob::patch::{Patch, PatchId};
+
use radicle::identity::Did;
+
use radicle::patch::cache::Patches;
+
use radicle::patch::Status;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct Filter {
+
    status: Option<Status>,
+
    authored: bool,
+
    authors: Vec<Did>,
+
}
+

+
impl Default for Filter {
+
    fn default() -> Self {
+
        Self {
+
            status: Some(Status::default()),
+
            authored: false,
+
            authors: vec![],
+
        }
+
    }
+
}
+

+
impl Filter {
+
    pub fn with_status(mut self, status: Option<Status>) -> Self {
+
        self.status = status;
+
        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
+
    }
+
}
+

+
impl ToString for Filter {
+
    fn to_string(&self) -> String {
+
        let mut filter = String::new();
+

+
        if let Some(state) = &self.status {
+
            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(&author.encode());
+

+
                if authors.peek().is_some() {
+
                    filter.push(',');
+
                }
+
            }
+
            filter.push(']');
+
        }
+

+
        filter
+
    }
+
}
+

+
pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(PatchId, Patch)>> {
+
    let cache = profile.patches(repository)?;
+
    let patches = cache.list()?;
+

+
    Ok(patches.flatten().collect())
+
}
+

+
pub fn find(profile: &Profile, repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
+
    let cache = profile.patches(repository)?;
+
    Ok(cache.get(id)?)
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use std::str::FromStr;
+

+
    use anyhow::Result;
+
    use radicle::patch;
+

+
    use super::*;
+

+
    #[test]
+
    fn patch_filter_display_with_status_should_succeed() -> Result<()> {
+
        let actual = Filter::default().with_status(Some(patch::Status::Open));
+

+
        assert_eq!(String::from("is:open "), actual.to_string());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn patch_filter_display_with_status_and_authored_should_succeed() -> Result<()> {
+
        let actual = Filter::default()
+
            .with_status(Some(patch::Status::Open))
+
            .with_authored(true);
+

+
        assert_eq!(String::from("is:open is:authored "), actual.to_string());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn patch_filter_display_with_status_and_author_should_succeed() -> Result<()> {
+
        let actual = Filter::default()
+
            .with_status(Some(patch::Status::Open))
+
            .with_author(Did::from_str(
+
                "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
+
            )?);
+

+
        assert_eq!(
+
            String::from(
+
                "is:open authors:[did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V]"
+
            ),
+
            actual.to_string()
+
        );
+

+
        Ok(())
+
    }
+
}
deleted src/common.rs
@@ -1,3 +0,0 @@
-
pub mod cob;
-
pub mod context;
-
pub mod log;
deleted src/common/cob.rs
@@ -1,38 +0,0 @@
-
use std::str::FromStr;
-

-
use anyhow::Result;
-

-
use radicle::cob::Label;
-
use radicle::prelude::Did;
-

-
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/common/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/common/cob/issue.rs
@@ -1,149 +0,0 @@
-
use anyhow::Result;
-

-
use radicle::cob::issue::{Issue, IssueId};
-
use radicle::cob::Label;
-
use radicle::issue::cache::Issues;
-
use radicle::issue::State;
-
use radicle::prelude::{Did, Signer};
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub struct Filter {
-
    state: Option<State>,
-
    assigned: bool,
-
    assignees: Vec<Did>,
-
}
-

-
impl Default for Filter {
-
    fn default() -> Self {
-
        Self {
-
            state: Some(State::default()),
-
            assigned: false,
-
            assignees: vec![],
-
        }
-
    }
-
}
-

-
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
-
    }
-
}
-

-
impl ToString for Filter {
-
    fn to_string(&self) -> String {
-
        let mut filter = String::new();
-

-
        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(&assignee.encode());
-

-
                if assignees.peek().is_some() {
-
                    filter.push(',');
-
                }
-
            }
-
            filter.push(']');
-
        }
-

-
        filter
-
    }
-
}
-

-
pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
-
    let cache = profile.issues(repository)?;
-
    let issues = cache.list()?;
-

-
    Ok(issues.flatten().collect())
-
}
-

-
pub fn find(profile: &Profile, repository: &Repository, id: &IssueId) -> Result<Option<Issue>> {
-
    let cache = profile.issues(repository)?;
-
    Ok(cache.get(id)?)
-
}
-

-
pub fn create<G: Signer>(
-
    profile: &Profile,
-
    repository: &Repository,
-
    signer: &G,
-
    title: String,
-
    description: String,
-
    labels: &[Label],
-
    assignees: &[Did],
-
) -> Result<IssueId> {
-
    let mut issues = profile.issues_mut(repository)?;
-
    let issue = issues.create(title, description.trim(), labels, assignees, [], signer)?;
-

-
    Ok(*issue.id())
-
}
-

-
#[cfg(test)]
-
mod tests {
-
    use std::str::FromStr;
-

-
    use anyhow::Result;
-
    use radicle::issue;
-

-
    use super::*;
-

-
    #[test]
-
    fn issue_filter_display_with_state_should_succeed() -> Result<()> {
-
        let actual = Filter::default().with_state(Some(issue::State::Open));
-

-
        assert_eq!(String::from("is:open "), actual.to_string());
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn issue_filter_display_with_state_and_assigned_should_succeed() -> Result<()> {
-
        let actual = Filter::default()
-
            .with_state(Some(issue::State::Open))
-
            .with_assgined(true);
-

-
        assert_eq!(String::from("is:open is:assigned "), actual.to_string());
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn issue_filter_display_with_status_and_author_should_succeed() -> Result<()> {
-
        let actual = Filter::default()
-
            .with_state(Some(issue::State::Open))
-
            .with_assginee(Did::from_str(
-
                "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
-
            )?);
-

-
        assert_eq!(
-
            String::from(
-
                "is:open assignees:[did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V]"
-
            ),
-
            actual.to_string()
-
        );
-

-
        Ok(())
-
    }
-
}
deleted src/common/cob/patch.rs
@@ -1,133 +0,0 @@
-
use anyhow::Result;
-

-
use radicle::cob::patch::{Patch, PatchId};
-
use radicle::identity::Did;
-
use radicle::patch::cache::Patches;
-
use radicle::patch::Status;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub struct Filter {
-
    status: Option<Status>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
}
-

-
impl Default for Filter {
-
    fn default() -> Self {
-
        Self {
-
            status: Some(Status::default()),
-
            authored: false,
-
            authors: vec![],
-
        }
-
    }
-
}
-

-
impl Filter {
-
    pub fn with_status(mut self, status: Option<Status>) -> Self {
-
        self.status = status;
-
        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
-
    }
-
}
-

-
impl ToString for Filter {
-
    fn to_string(&self) -> String {
-
        let mut filter = String::new();
-

-
        if let Some(state) = &self.status {
-
            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(&author.encode());
-

-
                if authors.peek().is_some() {
-
                    filter.push(',');
-
                }
-
            }
-
            filter.push(']');
-
        }
-

-
        filter
-
    }
-
}
-

-
pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(PatchId, Patch)>> {
-
    let cache = profile.patches(repository)?;
-
    let patches = cache.list()?;
-

-
    Ok(patches.flatten().collect())
-
}
-

-
pub fn find(profile: &Profile, repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
-
    let cache = profile.patches(repository)?;
-
    Ok(cache.get(id)?)
-
}
-

-
#[cfg(test)]
-
mod tests {
-
    use std::str::FromStr;
-

-
    use anyhow::Result;
-
    use radicle::patch;
-

-
    use super::*;
-

-
    #[test]
-
    fn patch_filter_display_with_status_should_succeed() -> Result<()> {
-
        let actual = Filter::default().with_status(Some(patch::Status::Open));
-

-
        assert_eq!(String::from("is:open "), actual.to_string());
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn patch_filter_display_with_status_and_authored_should_succeed() -> Result<()> {
-
        let actual = Filter::default()
-
            .with_status(Some(patch::Status::Open))
-
            .with_authored(true);
-

-
        assert_eq!(String::from("is:open is:authored "), actual.to_string());
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn patch_filter_display_with_status_and_author_should_succeed() -> Result<()> {
-
        let actual = Filter::default()
-
            .with_status(Some(patch::Status::Open))
-
            .with_author(Did::from_str(
-
                "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
-
            )?);
-

-
        assert_eq!(
-
            String::from(
-
                "is:open authors:[did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V]"
-
            ),
-
            actual.to_string()
-
        );
-

-
        Ok(())
-
    }
-
}
deleted src/common/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 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.profile, &self.repository).unwrap_or_default());
-
        self
-
    }
-

-
    pub fn with_patches(mut self) -> Self {
-
        use super::cob::patch;
-
        self.patches = Some(patch::all(&self.profile, &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.profile, &self.repository).unwrap_or_default());
-
    }
-

-
    pub fn reload_issues(&mut self) {
-
        use super::cob::issue;
-
        self.issues = Some(issue::all(&self.profile, &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())
-
}
deleted src/common/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/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.profile, &self.repository).unwrap_or_default());
+
        self
+
    }
+

+
    pub fn with_patches(mut self) -> Self {
+
        use super::cob::patch;
+
        self.patches = Some(patch::all(&self.profile, &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.profile, &self.repository).unwrap_or_default());
+
    }
+

+
    pub fn reload_issues(&mut self) {
+
        use super::cob::issue;
+
        self.issues = Some(issue::all(&self.profile, &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/event.rs
@@ -0,0 +1,5 @@
+
#[derive(Clone, Copy)]
+
pub enum Event {
+
    Key(termion::event::Key),
+
    Resize,
+
}
deleted src/flux.rs
@@ -1,6 +0,0 @@
-
pub mod event;
-
pub mod git;
-
pub mod store;
-
pub mod task;
-
pub mod terminal;
-
pub mod ui;
deleted src/flux/event.rs
@@ -1,5 +0,0 @@
-
#[derive(Clone, Copy)]
-
pub enum Event {
-
    Key(termion::event::Key),
-
    Resize,
-
}
deleted src/flux/git.rs
@@ -1,20 +0,0 @@
-
use radicle::git;
-
use radicle::git::Oid;
-

-
/// Get the diff stats between two commits.
-
/// Should match the default output of `git diff <old> <new> --stat` exactly.
-
pub fn diff_stats(
-
    repo: &git::raw::Repository,
-
    old: &Oid,
-
    new: &Oid,
-
) -> Result<git::raw::DiffStats, git::raw::Error> {
-
    let old = repo.find_commit(**old)?;
-
    let new = repo.find_commit(**new)?;
-
    let old_tree = old.tree()?;
-
    let new_tree = new.tree()?;
-
    let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
-
    let mut find_opts = git::raw::DiffFindOptions::new();
-

-
    diff.find_similar(Some(&mut find_opts))?;
-
    diff.stats()
-
}
deleted src/flux/store.rs
@@ -1,196 +0,0 @@
-
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::task::{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)
-
    }
-
}
-

-
/// A `StateValue` that writes updates to an internal
-
/// buffer. This buffer can be applied or reset.
-
///
-
/// Reading from a `StateValue` will return the buffer if it's
-
/// not empty. It will return the actual value otherwise.
-
#[derive(Clone, Debug)]
-
pub struct StateValue<T>
-
where
-
    T: Clone,
-
{
-
    value: T,
-
    buffer: Option<T>,
-
}
-

-
impl<T> StateValue<T>
-
where
-
    T: Clone,
-
{
-
    pub fn new(value: T) -> Self {
-
        Self {
-
            value,
-
            buffer: None,
-
        }
-
    }
-

-
    pub fn apply(&mut self) {
-
        if let Some(buffer) = self.buffer.clone() {
-
            self.value = buffer;
-
        }
-
        self.buffer = None;
-
    }
-

-
    pub fn reset(&mut self) {
-
        self.buffer = None;
-
    }
-

-
    pub fn write(&mut self, value: T) {
-
        self.buffer = Some(value);
-
    }
-

-
    pub fn read(&self) -> T {
-
        if let Some(buffer) = self.buffer.clone() {
-
            buffer
-
        } else {
-
            self.value.clone()
-
        }
-
    }
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use super::*;
-

-
    #[test]
-
    fn state_value_read_should_succeed() {
-
        let value = StateValue::new(0);
-
        assert_eq!(value.read(), 0);
-
    }
-

-
    #[test]
-
    fn state_value_read_buffer_should_succeed() {
-
        let mut value = StateValue::new(0);
-
        value.write(1);
-

-
        assert_eq!(value.read(), 1);
-
    }
-

-
    #[test]
-
    fn state_value_apply_should_succeed() {
-
        let mut value = StateValue::new(0);
-

-
        value.write(1);
-
        assert_eq!(value.read(), 1);
-

-
        value.apply();
-
        assert_eq!(value.read(), 1);
-
    }
-

-
    #[test]
-
    fn state_value_reset_should_succeed() {
-
        let mut value = StateValue::new(0);
-

-
        value.write(1);
-
        assert_eq!(value.read(), 1);
-

-
        value.reset();
-
        assert_eq!(value.read(), 0);
-
    }
-

-
    #[test]
-
    fn state_value_reset_after_apply_should_succeed() {
-
        let mut value = StateValue::new(0);
-

-
        value.write(1);
-
        assert_eq!(value.read(), 1);
-

-
        value.apply();
-
        value.reset();
-
        assert_eq!(value.read(), 1);
-
    }
-
}
deleted src/flux/task.rs
@@ -1,66 +0,0 @@
-
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)
-
}
deleted src/flux/terminal.rs
@@ -1,134 +0,0 @@
-
use std::io::{self, Write};
-
use std::thread;
-

-
use termion::input::TermRead;
-
use termion::raw::{IntoRawMode, RawTerminal};
-

-
use ratatui::prelude::*;
-

-
use tokio::sync::mpsc::{self};
-

-
use super::event::Event;
-

-
type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;
-

-
/// FIXME Remove workaround after a new `ratatui` version with
-
/// https://github.com/ratatui-org/ratatui/pull/981/ included was released.
-
pub struct TermionBackendExt<W>
-
where
-
    W: Write,
-
{
-
    cursor: Option<(u16, u16)>,
-
    inner: TermionBackend<W>,
-
}
-

-
impl<W> TermionBackendExt<W>
-
where
-
    W: Write,
-
{
-
    pub fn new(writer: W) -> Self {
-
        Self {
-
            cursor: None,
-
            inner: TermionBackend::new(writer),
-
        }
-
    }
-
}
-

-
impl<W: Write> ratatui::backend::Backend for TermionBackendExt<W> {
-
    fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
-
    where
-
        I: Iterator<Item = (u16, u16, &'a buffer::Cell)>,
-
    {
-
        self.inner.draw(content)
-
    }
-

-
    fn append_lines(&mut self, n: u16) -> io::Result<()> {
-
        self.inner.append_lines(n)
-
    }
-

-
    fn hide_cursor(&mut self) -> io::Result<()> {
-
        self.inner.hide_cursor()
-
    }
-

-
    fn show_cursor(&mut self) -> io::Result<()> {
-
        self.inner.show_cursor()
-
    }
-

-
    fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
-
        match self.inner.get_cursor() {
-
            Ok((x, y)) => {
-
                let cursor = (x.saturating_sub(0), y.saturating_sub(0));
-
                self.cursor = Some(cursor);
-
                Ok(cursor)
-
            }
-
            Err(_) => Ok(self.cursor.unwrap_or((0, 0))),
-
        }
-
    }
-

-
    fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
-
        self.cursor = Some((x, y));
-
        self.inner.set_cursor(x, y)
-
    }
-

-
    fn clear(&mut self) -> io::Result<()> {
-
        self.inner.clear()
-
    }
-

-
    fn clear_region(&mut self, clear_type: backend::ClearType) -> io::Result<()> {
-
        self.inner.clear_region(clear_type)
-
    }
-

-
    fn size(&self) -> io::Result<Rect> {
-
        self.inner.size()
-
    }
-

-
    fn window_size(&mut self) -> io::Result<backend::WindowSize> {
-
        self.inner.window_size()
-
    }
-

-
    fn flush(&mut self) -> io::Result<()> {
-
        ratatui::backend::Backend::flush(&mut self.inner)
-
    }
-
}
-

-
pub fn setup(height: usize) -> anyhow::Result<Terminal<Backend>> {
-
    let stdout = io::stdout().into_raw_mode()?;
-
    let options = TerminalOptions {
-
        viewport: Viewport::Inline(height as u16),
-
    };
-

-
    Ok(Terminal::with_options(
-
        TermionBackendExt::new(stdout),
-
        options,
-
    )?)
-
}
-

-
pub fn restore(terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
-
    terminal.clear()?;
-
    Ok(())
-
}
-

-
pub 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
-
}
deleted src/flux/ui.rs
@@ -1,89 +0,0 @@
-
pub mod ext;
-
pub mod format;
-
pub mod items;
-
pub mod layout;
-
pub mod span;
-
pub mod theme;
-
pub mod widget;
-

-
use std::fmt::Debug;
-
use std::io::{self};
-
use std::time::Duration;
-

-
use termion::raw::RawTerminal;
-

-
use tokio::sync::broadcast;
-
use tokio::sync::mpsc::{self, UnboundedReceiver};
-

-
use super::event::Event;
-
use super::store::State;
-
use super::task::Interrupted;
-
use super::terminal;
-
use super::terminal::TermionBackendExt;
-
use super::ui::widget::{Render, Widget};
-

-
type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;
-

-
const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
-
const INLINE_HEIGHT: usize = 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 ticker = tokio::time::interval(RENDERING_TICK_RATE);
-

-
        let mut terminal = terminal::setup(INLINE_HEIGHT)?;
-
        let mut events_rx = terminal::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(), ()))?;
-
        };
-

-
        terminal::restore(&mut terminal)?;
-

-
        result
-
    }
-
}
deleted src/flux/ui/ext.rs
@@ -1,252 +0,0 @@
-
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);
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
-
pub enum FooterBlockType {
-
    Single,
-
    Begin,
-
    End,
-
    Repeat,
-
}
-

-
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,
-
    ///
-
    block_type: FooterBlockType,
-
    /// Widget style
-
    style: Style,
-
}
-

-
impl Default for FooterBlock {
-
    fn default() -> Self {
-
        Self {
-
            block_type: FooterBlockType::Single,
-
            borders: Self::borders(FooterBlockType::Single),
-
            border_style: Default::default(),
-
            border_type: BorderType::Rounded,
-
            style: Default::default(),
-
        }
-
    }
-
}
-

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

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

-
    pub fn block_type(mut self, block_type: FooterBlockType) -> Self {
-
        self.block_type = block_type.clone();
-
        self.borders = Self::borders(block_type);
-
        self
-
    }
-

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

-
    fn borders(block_type: FooterBlockType) -> Borders {
-
        match block_type {
-
            FooterBlockType::Single | FooterBlockType::Begin => Borders::ALL,
-
            FooterBlockType::End | FooterBlockType::Repeat => {
-
                Borders::TOP | Borders::RIGHT | Borders::BOTTOM
-
            }
-
        }
-
    }
-
}
-

-
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) {
-
            let symbol = match self.block_type {
-
                FooterBlockType::Begin | FooterBlockType::Repeat => symbols::line::HORIZONTAL_UP,
-
                _ => symbols.bottom_right,
-
            };
-
            buf.get_mut(area.right() - 1, area.bottom() - 1)
-
                .set_symbol(symbol)
-
                .set_style(self.border_style);
-
        }
-
        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
-
            let symbol = match self.block_type {
-
                FooterBlockType::Begin | FooterBlockType::Repeat => symbols::line::HORIZONTAL_DOWN,
-
                _ => symbols::line::VERTICAL_LEFT,
-
            };
-
            buf.get_mut(area.right() - 1, area.top())
-
                .set_symbol(symbol)
-
                .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);
-
        }
-
    }
-
}
deleted src/flux/ui/format.rs
@@ -1,95 +0,0 @@
-
use radicle::cob::Label;
-
use radicle::cob::{ObjectId, Timestamp};
-
use radicle::crypto::PublicKey;
-
use radicle::issue;
-
use radicle::node::Alias;
-
use radicle::patch;
-
use radicle::prelude::Did;
-
use ratatui::style::Color;
-

-
/// 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)
-
}
-

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

-
pub fn 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 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 author(did: &Did, alias: &Option<Alias>, is_you: bool) -> String {
-
    let author = match alias {
-
        Some(alias) => format!("{alias}"),
-
        None => self::did(did),
-
    };
-

-
    if is_you {
-
        format!("{} (you)", author)
-
    } else {
-
        author
-
    }
-
}
-

-
pub fn 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(&self::author(&Did::from(assignee), alias, *is_you));
-
        }
-

-
        if assignees.peek().is_some() {
-
            output.push(',');
-
        }
-
    }
-
    output
-
}
deleted src/flux/ui/items.rs
@@ -1,966 +0,0 @@
-
use std::str::FromStr;
-

-
use nom::bytes::complete::{tag, take};
-
use nom::multi::separated_list0;
-
use nom::sequence::{delimited, preceded};
-
use nom::{IResult, Parser};
-

-
use radicle::cob::{Label, ObjectId, Timestamp, TypedId};
-
use radicle::git::Oid;
-
use radicle::identity::{Did, Identity};
-
use radicle::issue::{self, CloseReason, Issue, IssueId, Issues};
-
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
-
use radicle::node::{Alias, AliasStore, NodeId};
-
use radicle::patch;
-
use radicle::patch::{Patch, PatchId, Patches};
-
use radicle::storage::git::Repository;
-
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository};
-
use radicle::Profile;
-

-
use ratatui::style::{Style, Stylize};
-
use ratatui::widgets::Cell;
-

-
use super::super::git;
-
use super::theme::style;
-
use super::widget::ToRow;
-
use super::{format, span};
-

-
#[derive(Clone, Debug, PartialEq, Eq)]
-
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>,
-
    },
-
    Unknown {
-
        refname: String,
-
    },
-
}
-

-
impl NotificationKindItem {
-
    pub fn new(
-
        repo: &Repository,
-
        notification: &Notification,
-
    ) -> Result<Option<Self>, anyhow::Error> {
-
        // 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(Some(NotificationKindItem::Branch {
-
                    name: name.to_string(),
-
                    summary: message,
-
                    status: status.to_string(),
-
                    id: head.map(ObjectId::from),
-
                }))
-
            }
-
            NotificationKind::Cob { typed_id } => {
-
                let TypedId { id, .. } = typed_id;
-
                let (category, summary, state) = if typed_id.is_issue() {
-
                    let Some(issue) = issues.get(id)? else {
-
                        // Issue could have been deleted after notification was created.
-
                        return Ok(None);
-
                    };
-
                    (
-
                        String::from("issue"),
-
                        issue.title().to_owned(),
-
                        issue.state().to_string(),
-
                    )
-
                } else if typed_id.is_patch() {
-
                    let Some(patch) = patches.get(id)? else {
-
                        // Patch could have been deleted after notification was created.
-
                        return Ok(None);
-
                    };
-
                    (
-
                        String::from("patch"),
-
                        patch.title().to_owned(),
-
                        patch.state().to_string(),
-
                    )
-
                } else if typed_id.is_identity() {
-
                    let Ok(identity) = Identity::get(id, repo) else {
-
                        log::error!(
-
                            target: "cli",
-
                            "Error retrieving identity {id} for notification {}", notification.id
-
                        );
-
                        return Ok(None);
-
                    };
-
                    let Some(rev) = notification
-
                        .update
-
                        .new()
-
                        .and_then(|id| identity.revision(&id))
-
                    else {
-
                        log::error!(
-
                            target: "cli",
-
                            "Error retrieving identity revision for notification {}", notification.id
-
                        );
-
                        return Ok(None);
-
                    };
-
                    (String::from("id"), rev.title.clone(), rev.state.to_string())
-
                } else {
-
                    (typed_id.type_name.to_string(), "".to_owned(), String::new())
-
                };
-

-
                Ok(Some(NotificationKindItem::Cob {
-
                    type_name: category.to_string(),
-
                    summary: summary.to_string(),
-
                    status: state.to_string(),
-
                    id: Some(*id),
-
                }))
-
            }
-
            NotificationKind::Unknown { refname } => Ok(Some(NotificationKindItem::Unknown {
-
                refname: refname.to_string(),
-
            })),
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct NotificationItem {
-
    /// Unique notification ID.
-
    pub id: NotificationId,
-
    /// The project this belongs to.
-
    pub project: String,
-
    /// 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 NotificationItem {
-
    pub fn new(
-
        profile: &Profile,
-
        repo: &Repository,
-
        notification: &Notification,
-
    ) -> Result<Option<Self>, anyhow::Error> {
-
        let project = profile
-
            .storage
-
            .repository(repo.id)?
-
            .identity_doc()?
-
            .project()?;
-
        let name = project.name().to_string();
-
        let kind = NotificationKindItem::new(repo, notification)?;
-

-
        if kind.is_none() {
-
            return Ok(None);
-
        }
-

-
        Ok(Some(NotificationItem {
-
            id: notification.id,
-
            project: name,
-
            seen: notification.status.is_read(),
-
            kind: kind.unwrap(),
-
            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.clone(),
-
                status.clone(),
-
                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.clone(),
-
                    status.clone(),
-
                    id.to_string(),
-
                )
-
            }
-
            NotificationKindItem::Unknown { refname } => (
-
                refname.to_string(),
-
                String::new(),
-
                String::new(),
-
                String::new(),
-
            ),
-
        };
-

-
        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(),
-
        ]
-
    }
-
}
-

-
impl ToRow<9> for NotificationItem {
-
    fn to_row(&self) -> [Cell; 9] {
-
        let row: [Cell; 8] = self.to_row();
-
        let name = span::default(self.project.clone()).style(style::gray().dim());
-

-
        [
-
            row[0].clone(),
-
            row[1].clone(),
-
            name.into(),
-
            row[2].clone(),
-
            row[3].clone(),
-
            row[4].clone(),
-
            row[5].clone(),
-
            row[6].clone(),
-
            row[7].clone(),
-
        ]
-
    }
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum NotificationType {
-
    Patch,
-
    Issue,
-
    Branch,
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum NotificationState {
-
    Seen,
-
    Unseen,
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct NotificationItemFilter {
-
    state: Option<NotificationState>,
-
    type_name: Option<NotificationType>,
-
    authors: Vec<Did>,
-
    search: Option<String>,
-
}
-

-
impl NotificationItemFilter {
-
    pub fn state(&self) -> Option<NotificationState> {
-
        self.state.clone()
-
    }
-

-
    pub fn matches(&self, notif: &NotificationItem) -> bool {
-
        use fuzzy_matcher::skim::SkimMatcherV2;
-
        use fuzzy_matcher::FuzzyMatcher;
-

-
        let matcher = SkimMatcherV2::default();
-

-
        let matches_state = match self.state {
-
            Some(NotificationState::Seen) => notif.seen,
-
            Some(NotificationState::Unseen) => !notif.seen,
-
            None => true,
-
        };
-

-
        let matches_type = match self.type_name {
-
            Some(NotificationType::Patch) => matches!(&notif.kind, NotificationKindItem::Cob {
-
                type_name,
-
                summary: _,
-
                status: _,
-
                id: _,
-
            } if type_name == "patch"),
-
            Some(NotificationType::Issue) => matches!(&notif.kind, NotificationKindItem::Cob {
-
                    type_name,
-
                    summary: _,
-
                    status: _,
-
                    id: _,
-
                } if type_name == "issue"),
-
            Some(NotificationType::Branch) => {
-
                matches!(notif.kind, NotificationKindItem::Branch { .. })
-
            }
-
            None => true,
-
        };
-

-
        let matches_authors = (!self.authors.is_empty())
-
            .then(|| {
-
                self.authors
-
                    .iter()
-
                    .any(|other| notif.author.nid == Some(**other))
-
            })
-
            .unwrap_or(true);
-

-
        let matches_search = match &self.search {
-
            Some(search) => {
-
                let summary = match &notif.kind {
-
                    NotificationKindItem::Cob {
-
                        type_name: _,
-
                        summary,
-
                        status: _,
-
                        id: _,
-
                    } => summary,
-
                    NotificationKindItem::Branch {
-
                        name: _,
-
                        summary,
-
                        status: _,
-
                        id: _,
-
                    } => summary,
-
                    NotificationKindItem::Unknown { refname: _ } => "",
-
                };
-
                match matcher.fuzzy_match(summary, search) {
-
                    Some(score) => score == 0 || score > 60,
-
                    _ => false,
-
                }
-
            }
-
            None => true,
-
        };
-

-
        matches_state && matches_type && matches_authors && matches_search
-
    }
-
}
-

-
impl FromStr for NotificationItemFilter {
-
    type Err = anyhow::Error;
-

-
    fn from_str(value: &str) -> Result<Self, Self::Err> {
-
        let mut state = None;
-
        let mut type_name = None;
-
        let mut search = String::new();
-
        let mut authors = vec![];
-

-
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("authors:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )(input)
-
        };
-

-
        let parts = value.split(' ');
-
        for part in parts {
-
            match part {
-
                "is:seen" => state = Some(NotificationState::Seen),
-
                "is:unseen" => state = Some(NotificationState::Unseen),
-
                "is:patch" => type_name = Some(NotificationType::Patch),
-
                "is:issue" => type_name = Some(NotificationType::Issue),
-
                "is:branch" => type_name = Some(NotificationType::Branch),
-
                other => {
-
                    if let Ok((_, dids)) = authors_parser.parse(other) {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
-
                        }
-
                    } else {
-
                        search.push_str(other);
-
                    }
-
                }
-
            }
-
        }
-

-
        Ok(Self {
-
            state,
-
            type_name,
-
            authors,
-
            search: Some(search),
-
        })
-
    }
-
}
-

-
#[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, Default, Debug, Eq, PartialEq)]
-
pub struct IssueItemFilter {
-
    state: Option<issue::State>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
    assigned: bool,
-
    assignees: Vec<Did>,
-
    search: Option<String>,
-
}
-

-
impl IssueItemFilter {
-
    pub fn state(&self) -> Option<issue::State> {
-
        self.state
-
    }
-

-
    pub fn matches(&self, issue: &IssueItem) -> bool {
-
        use fuzzy_matcher::skim::SkimMatcherV2;
-
        use fuzzy_matcher::FuzzyMatcher;
-

-
        let matcher = SkimMatcherV2::default();
-

-
        let matches_state = match self.state {
-
            Some(issue::State::Closed {
-
                reason: CloseReason::Other,
-
            }) => matches!(issue.state, issue::State::Closed { .. }),
-
            Some(state) => issue.state == state,
-
            None => true,
-
        };
-

-
        let matches_authored = if self.authored {
-
            issue.author.you
-
        } else {
-
            true
-
        };
-

-
        let matches_authors = (!self.authors.is_empty())
-
            .then(|| {
-
                self.authors
-
                    .iter()
-
                    .any(|other| issue.author.nid == Some(**other))
-
            })
-
            .unwrap_or(true);
-

-
        let matches_assigned = self
-
            .assigned
-
            .then(|| issue.assignees.iter().any(|assignee| assignee.you))
-
            .unwrap_or(true);
-

-
        let matches_assignees = (!self.assignees.is_empty())
-
            .then(|| {
-
                self.assignees.iter().any(|other| {
-
                    issue
-
                        .assignees
-
                        .iter()
-
                        .filter_map(|author| author.nid)
-
                        .collect::<Vec<_>>()
-
                        .contains(other)
-
                })
-
            })
-
            .unwrap_or(true);
-

-
        let matches_search = match &self.search {
-
            Some(search) => match matcher.fuzzy_match(&issue.title, search) {
-
                Some(score) => score == 0 || score > 60,
-
                _ => false,
-
            },
-
            None => true,
-
        };
-

-
        matches_state
-
            && matches_authored
-
            && matches_authors
-
            && matches_assigned
-
            && matches_assignees
-
            && matches_search
-
    }
-
}
-

-
impl FromStr for IssueItemFilter {
-
    type Err = anyhow::Error;
-

-
    fn from_str(value: &str) -> Result<Self, Self::Err> {
-
        let mut state = None;
-
        let mut search = String::new();
-
        let mut authored = false;
-
        let mut authors = vec![];
-
        let mut assigned = false;
-
        let mut assignees = vec![];
-

-
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("authors:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )(input)
-
        };
-

-
        let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("assignees:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )(input)
-
        };
-

-
        let parts = value.split(' ');
-
        for part in parts {
-
            match part {
-
                "is:open" => state = Some(issue::State::Open),
-
                "is:closed" => {
-
                    state = Some(issue::State::Closed {
-
                        reason: issue::CloseReason::Other,
-
                    })
-
                }
-
                "is:solved" => {
-
                    state = Some(issue::State::Closed {
-
                        reason: issue::CloseReason::Solved,
-
                    })
-
                }
-
                "is:authored" => authored = true,
-
                "is:assigned" => assigned = true,
-
                other => {
-
                    if let Ok((_, dids)) = assignees_parser.parse(other) {
-
                        for did in dids {
-
                            assignees.push(Did::from_str(did)?);
-
                        }
-
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
-
                        }
-
                    } else {
-
                        search.push_str(other);
-
                    }
-
                }
-
            }
-
        }
-

-
        Ok(Self {
-
            state,
-
            authored,
-
            authors,
-
            assigned,
-
            assignees,
-
            search: Some(search),
-
        })
-
    }
-
}
-

-
#[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 (_, revision) = patch.latest();
-
        let (from, to) = revision.range();
-
        let stats = git::diff_stats(repository.raw(), &from, &to)?;
-

-
        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: revision.head(),
-
            added: stats.insertions() as u16,
-
            removed: stats.deletions() as u16,
-
            timestamp: patch.updated_at(),
-
        })
-
    }
-
}
-

-
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(),
-
        ]
-
    }
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct PatchItemFilter {
-
    status: Option<patch::Status>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
    search: Option<String>,
-
}
-

-
impl PatchItemFilter {
-
    pub fn status(&self) -> Option<patch::Status> {
-
        self.status
-
    }
-

-
    pub fn matches(&self, patch: &PatchItem) -> bool {
-
        use fuzzy_matcher::skim::SkimMatcherV2;
-
        use fuzzy_matcher::FuzzyMatcher;
-

-
        let matcher = SkimMatcherV2::default();
-

-
        let matches_state = match self.status {
-
            Some(patch::Status::Draft) => matches!(patch.state, patch::State::Draft),
-
            Some(patch::Status::Open) => matches!(patch.state, patch::State::Open { .. }),
-
            Some(patch::Status::Merged) => matches!(patch.state, patch::State::Merged { .. }),
-
            Some(patch::Status::Archived) => matches!(patch.state, patch::State::Archived),
-
            None => true,
-
        };
-

-
        let matches_authored = if self.authored {
-
            patch.author.you
-
        } else {
-
            true
-
        };
-

-
        let matches_authors = (!self.authors.is_empty())
-
            .then(|| {
-
                self.authors
-
                    .iter()
-
                    .any(|other| patch.author.nid == Some(**other))
-
            })
-
            .unwrap_or(true);
-

-
        let matches_search = match &self.search {
-
            Some(search) => match matcher.fuzzy_match(&patch.title, search) {
-
                Some(score) => score == 0 || score > 60,
-
                _ => false,
-
            },
-
            None => true,
-
        };
-

-
        matches_state && matches_authored && matches_authors && matches_search
-
    }
-
}
-

-
impl FromStr for PatchItemFilter {
-
    type Err = anyhow::Error;
-

-
    fn from_str(value: &str) -> Result<Self, Self::Err> {
-
        let mut status = None;
-
        let mut search = String::new();
-
        let mut authored = false;
-
        let mut authors = vec![];
-

-
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("authors:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )(input)
-
        };
-

-
        let parts = value.split(' ');
-
        for part in parts {
-
            match part {
-
                "is:open" => status = Some(patch::Status::Open),
-
                "is:merged" => status = Some(patch::Status::Merged),
-
                "is:archived" => status = Some(patch::Status::Archived),
-
                "is:draft" => status = Some(patch::Status::Draft),
-
                "is:authored" => authored = true,
-
                other => match authors_parser.parse(other) {
-
                    Ok((_, dids)) => {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
-
                        }
-
                    }
-
                    _ => search.push_str(other),
-
                },
-
            }
-
        }
-

-
        Ok(Self {
-
            status,
-
            authored,
-
            authors,
-
            search: Some(search),
-
        })
-
    }
-
}
-

-
#[cfg(test)]
-
mod tests {
-
    use anyhow::Result;
-

-
    use super::*;
-

-
    #[test]
-
    fn patch_item_filter_from_str_should_succeed() -> Result<()> {
-
        let search = r#"is:open is:authored authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
-
        let actual = PatchItemFilter::from_str(search)?;
-

-
        let expected = PatchItemFilter {
-
            status: Some(patch::Status::Open),
-
            authored: true,
-
            authors: vec![
-
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
-
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
-
            ],
-
            search: Some("cli".to_string()),
-
        };
-

-
        assert_eq!(expected, actual);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn issue_item_filter_from_str_should_succeed() -> Result<()> {
-
        let search = r#"is:open is:assigned assignees:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
-
        let actual = IssueItemFilter::from_str(search)?;
-

-
        let expected = IssueItemFilter {
-
            state: Some(issue::State::Open),
-
            authors: vec![Did::from_str(
-
                "did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
-
            )?],
-
            authored: true,
-
            assigned: true,
-
            assignees: vec![
-
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
-
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
-
            ],
-
            search: Some("cli".to_string()),
-
        };
-

-
        assert_eq!(expected, actual);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn notification_item_filter_from_str_should_succeed() -> Result<()> {
-
        let search = r#"is:seen is:patch authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
-
        let actual = NotificationItemFilter::from_str(search)?;
-

-
        let expected = NotificationItemFilter {
-
            state: Some(NotificationState::Seen),
-
            type_name: Some(NotificationType::Patch),
-
            authors: vec![
-
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
-
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
-
            ],
-
            search: Some("cli".to_string()),
-
        };
-

-
        assert_eq!(expected, actual);
-

-
        Ok(())
-
    }
-
}
deleted src/flux/ui/layout.rs
@@ -1,53 +0,0 @@
-
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],
-
    }
-
}
-

-
pub fn centered_rect(r: Rect, percent_x: u16, percent_y: u16) -> Rect {
-
    let layout = Layout::default()
-
        .direction(Direction::Vertical)
-
        .constraints([
-
            Constraint::Percentage((100 - percent_y) / 2),
-
            Constraint::Percentage(percent_y),
-
            Constraint::Percentage((100 - percent_y) / 2),
-
        ])
-
        .split(r);
-

-
    Layout::default()
-
        .direction(Direction::Horizontal)
-
        .constraints([
-
            Constraint::Percentage((100 - percent_x) / 2),
-
            Constraint::Percentage(percent_x),
-
            Constraint::Percentage((100 - percent_x) / 2),
-
        ])
-
        .split(layout[1])[1]
-
}
deleted src/flux/ui/span.rs
@@ -1,81 +0,0 @@
-
use ratatui::style::{Style, Stylize};
-
use ratatui::text::Span;
-

-
use crate::flux::ui::theme::style;
-

-
pub fn blank() -> Span<'static> {
-
    Span::styled("", Style::default())
-
}
-

-
pub fn default(content: String) -> Span<'static> {
-
    Span::styled(content, Style::default())
-
}
-

-
pub fn primary(content: String) -> Span<'static> {
-
    default(content).style(style::cyan())
-
}
-

-
pub fn secondary(content: String) -> Span<'static> {
-
    default(content).style(style::magenta())
-
}
-

-
pub fn ternary(content: String) -> Span<'static> {
-
    default(content).style(style::blue())
-
}
-

-
pub fn positive(content: String) -> Span<'static> {
-
    default(content).style(style::green())
-
}
-

-
pub fn negative(content: String) -> Span<'static> {
-
    default(content).style(style::red())
-
}
-

-
pub fn badge(content: String) -> Span<'static> {
-
    let content = &format!(" {content} ");
-
    default(content.to_string()).magenta().reversed()
-
}
-

-
pub fn alias(content: String) -> Span<'static> {
-
    secondary(content)
-
}
-

-
pub fn labels(content: String) -> Span<'static> {
-
    ternary(content)
-
}
-

-
pub fn timestamp(content: String) -> Span<'static> {
-
    default(content).style(style::gray().dim())
-
}
-

-
pub fn notification_id(content: String) -> Span<'static> {
-
    default(content).style(style::gray().dim())
-
}
-

-
pub fn notification_type(content: String) -> Span<'static> {
-
    default(content).style(style::gray().dim())
-
}
-

-
pub fn step(step: usize, len: usize, fill_zeros: bool) -> Span<'static> {
-
    if fill_zeros {
-
        if len > 10 {
-
            badge(format!("{:-02}/{:-02}", step, len))
-
        } else if len > 100 {
-
            badge(format!("{:-03}/{:-03}", step, len))
-
        } else if len > 1000 {
-
            badge(format!("{:-04}/{:-04}", step, len))
-
        } else if len > 10000 {
-
            badge(format!("{:-05}/{:-05}", step, len))
-
        } else {
-
            badge(format!("{}/{}", step, len))
-
        }
-
    } else {
-
        badge(format!("{}/{}", step, len))
-
    }
-
}
-

-
pub fn progress(step: usize, len: usize) -> Span<'static> {
-
    let progress = step as f32 / len as f32 * 100_f32;
-
    let progress = progress as usize;
-
    default(format!("{}%", progress)).dim()
-
}
deleted src/flux/ui/theme.rs
@@ -1,55 +0,0 @@
-
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()
-
    }
-
}
deleted src/flux/ui/widget.rs
@@ -1,308 +0,0 @@
-
pub mod container;
-
pub mod input;
-
pub mod text;
-

-
use std::cmp;
-
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;
-
use super::{layout, span};
-

-
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) -> Option<usize> {
-
        let selected = self.selected().map(|current| current.saturating_sub(1));
-
        self.state.select(selected);
-
        selected
-
    }
-

-
    pub fn next(&mut self, len: usize) -> Option<usize> {
-
        let selected = self.selected().map(|current| {
-
            if current < len.saturating_sub(1) {
-
                current.saturating_add(1)
-
            } else {
-
                current
-
            }
-
        });
-
        self.state.select(selected);
-
        selected
-
    }
-

-
    pub fn prev_page(&mut self, page_size: usize) -> Option<usize> {
-
        let selected = self
-
            .selected()
-
            .map(|current| current.saturating_sub(page_size));
-
        self.state.select(selected);
-
        selected
-
    }
-

-
    pub fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
-
        let selected = self.selected().map(|current| {
-
            if current < len.saturating_sub(1) {
-
                cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
-
            } else {
-
                current
-
            }
-
        });
-
        self.state.select(selected);
-
        selected
-
    }
-

-
    pub fn begin(&mut self) -> Option<usize> {
-
        self.state.select(Some(0));
-
        self.state.selected()
-
    }
-

-
    pub fn end(&mut self, len: usize) -> Option<usize> {
-
        self.state.select(Some(len.saturating_sub(1)));
-
        self.state.selected()
-
    }
-

-
    pub fn progress(&self, len: usize) -> (usize, usize) {
-
        let step = self
-
            .selected()
-
            .map(|selected| selected.saturating_add(1))
-
            .unwrap_or_default();
-

-
        (cmp::min(step, len), len)
-
    }
-

-
    pub fn progress_percentage(&self, len: usize, page_size: usize) -> usize {
-
        let step = self.selected().unwrap_or_default();
-
        let page_size = page_size as f64;
-
        let len = len as f64;
-

-
        let lines = page_size + step.saturating_sub(page_size as usize) as f64;
-
        let progress = (lines / len * 100.0).ceil();
-

-
        if progress > 97.0 {
-
            Self::map_range((0.0, progress), (0.0, 100.0), progress) as usize
-
        } else {
-
            progress as usize
-
        }
-
    }
-

-
    fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
-
        to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
-
    }
-
}
-

-
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,
-
        };
-

-
        if !props.items.is_empty() {
-
            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());
-
        } else {
-
            let block = Block::default()
-
                .border_style(style::border(props.focus))
-
                .border_type(BorderType::Rounded)
-
                .borders(borders);
-

-
            frame.render_widget(block, area);
-

-
            let center = layout::centered_rect(area, 50, 10);
-
            let hint = Text::from(span::default("Nothing to show".to_string()))
-
                .centered()
-
                .light_magenta()
-
                .dim();
-

-
            frame.render_widget(hint, center);
-
        }
-
    }
-
}
deleted src/flux/ui/widget/container.rs
@@ -1,177 +0,0 @@
-
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, FooterBlockType, 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> Footer<A> {
-
    fn render_cell<'a>(
-
        &self,
-
        frame: &mut ratatui::Frame,
-
        area: Rect,
-
        block_type: FooterBlockType,
-
        text: impl Into<Text<'a>>,
-
        focus: bool,
-
    ) {
-
        let footer_layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![Constraint::Min(1)])
-
            .vertical_margin(1)
-
            .horizontal_margin(1)
-
            .split(area);
-

-
        let footer_block = FooterBlock::default()
-
            .border_style(style::border(focus))
-
            .block_type(block_type);
-
        frame.render_widget(footer_block, area);
-
        frame.render_widget(text.into(), footer_layout[0]);
-
    }
-
}
-

-
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
-
            .into_iter()
-
            .map(|c| match c {
-
                Constraint::Min(min) => Constraint::Length(min.saturating_add(3)),
-
                _ => c,
-
            })
-
            .collect::<Vec<_>>();
-

-
        let layout = Layout::horizontal(widths).split(area);
-
        let cells = props.cells.iter().zip(layout.iter()).collect::<Vec<_>>();
-

-
        let last = cells.len().saturating_sub(1);
-
        let len = cells.len();
-

-
        for (i, (cell, area)) in cells.into_iter().enumerate() {
-
            let block_type = match i {
-
                0 if len == 1 => FooterBlockType::Single,
-
                0 => FooterBlockType::Begin,
-
                _ if i == last => FooterBlockType::End,
-
                _ => FooterBlockType::Repeat,
-
            };
-
            self.render_cell(frame, *area, block_type, cell.clone(), props.focus);
-
        }
-
    }
-
}
-

-
#[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]);
-
    }
-
}
deleted src/flux/ui/widget/input.rs
@@ -1,179 +0,0 @@
-
use termion::event::Key;
-

-
use tokio::sync::mpsc::UnboundedSender;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::prelude::{Backend, Rect};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span};
-

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

-
pub struct TextField {
-
    /// Current value of the input box
-
    text: String,
-
    /// Position of cursor in the editor area.
-
    cursor_position: usize,
-
}
-

-
impl TextField {
-
    pub fn text(&self) -> &str {
-
        &self.text
-
    }
-

-
    pub fn set_text(&mut self, new_text: &str) {
-
        if self.text != new_text {
-
            self.text = String::from(new_text);
-
            self.cursor_position = self.text.len();
-
        }
-
    }
-

-
    pub fn reset(&mut self) {
-
        self.cursor_position = 0;
-
        self.text.clear();
-
    }
-

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

-
    fn move_cursor_left(&mut self) {
-
        let cursor_moved_left = self.cursor_position.saturating_sub(1);
-
        self.cursor_position = self.clamp_cursor(cursor_moved_left);
-
    }
-

-
    fn move_cursor_right(&mut self) {
-
        let cursor_moved_right = self.cursor_position.saturating_add(1);
-
        self.cursor_position = self.clamp_cursor(cursor_moved_right);
-
    }
-

-
    fn enter_char(&mut self, new_char: char) {
-
        self.text.insert(self.cursor_position, new_char);
-

-
        self.move_cursor_right();
-
    }
-

-
    fn delete_char(&mut self) {
-
        let is_not_cursor_leftmost = self.cursor_position != 0;
-
        if is_not_cursor_leftmost {
-
            // Method "remove" is not used on the saved text for deleting the selected char.
-
            // Reason: Using remove on String works on bytes instead of the chars.
-
            // Using remove would require special care because of char boundaries.
-

-
            let current_index = self.cursor_position;
-
            let from_left_to_current_index = current_index - 1;
-

-
            // Getting all characters before the selected character.
-
            let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
-
            // Getting all characters after selected character.
-
            let after_char_to_delete = self.text.chars().skip(current_index);
-

-
            // Put all characters together except the selected one.
-
            // By leaving the selected one out, it is forgotten and therefore deleted.
-
            self.text = before_char_to_delete.chain(after_char_to_delete).collect();
-
            self.move_cursor_left();
-
        }
-
    }
-

-
    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
-
        new_cursor_pos.clamp(0, self.text.len())
-
    }
-
}
-

-
impl<S, A> Widget<S, A> for TextField {
-
    fn new(_state: &S, _action_tx: UnboundedSender<A>) -> Self {
-
        Self {
-
            //
-
            text: String::new(),
-
            cursor_position: 0,
-
        }
-
    }
-

-
    fn move_with_state(self, _state: &S) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self { ..self }
-
    }
-

-
    fn name(&self) -> &str {
-
        "Input Box"
-
    }
-

-
    fn handle_key_event(&mut self, key: Key) {
-
        match key {
-
            Key::Char(to_insert)
-
                if (key != Key::Alt('\n'))
-
                    && (key != Key::Char('\n'))
-
                    && (key != Key::Ctrl('\n')) =>
-
            {
-
                self.enter_char(to_insert);
-
            }
-
            Key::Backspace => {
-
                self.delete_char();
-
            }
-
            Key::Left => {
-
                self.move_cursor_left();
-
            }
-
            Key::Right => {
-
                self.move_cursor_right();
-
            }
-
            _ => {}
-
        }
-
    }
-
}
-

-
pub struct TextFieldProps {
-
    pub titles: (String, String),
-
    pub inline_label: bool,
-
    pub show_cursor: bool,
-
}
-

-
impl Render<TextFieldProps> for TextField {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: TextFieldProps) {
-
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
-

-
        let input = self.text.as_str();
-
        let label = format!(" {} ", props.titles.0);
-
        let overline = String::from("▔").repeat(area.width as usize);
-
        let cursor_pos = self.cursor_position as u16;
-

-
        if props.inline_label {
-
            let top_layout = Layout::horizontal([
-
                Constraint::Length(label.chars().count() as u16),
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
            ])
-
            .split(layout[0]);
-

-
            let label = Span::from(label.clone()).magenta().dim().reversed();
-
            let input = Span::from(input).reset();
-

-
            let overline = Line::from([Span::raw(overline).magenta().dim()].to_vec());
-

-
            frame.render_widget(label, top_layout[0]);
-
            frame.render_widget(input, top_layout[2]);
-
            frame.render_widget(overline, layout[1]);
-

-
            if props.show_cursor {
-
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
-
            }
-
        } else {
-
            let top = Line::from([Span::from(input).reset()].to_vec());
-
            let bottom = Line::from(
-
                [
-
                    Span::from(label).magenta().dim().reversed(),
-
                    Span::raw(overline).magenta().dim(),
-
                ]
-
                .to_vec(),
-
            );
-

-
            frame.render_widget(top, layout[0]);
-
            frame.render_widget(bottom, layout[1]);
-

-
            if props.show_cursor {
-
                frame.set_cursor(area.x + cursor_pos, area.y)
-
            }
-
        }
-
    }
-
}
deleted src/flux/ui/widget/text.rs
@@ -1,137 +0,0 @@
-
use tokio::sync::mpsc::UnboundedSender;
-

-
use termion::event::Key;
-

-
use ratatui::backend::Backend;
-
use ratatui::layout::{Constraint, Layout, Rect};
-
use ratatui::text::Text;
-
use ratatui::widgets::{Block, BorderType, Borders};
-

-
use crate::flux::ui::theme::style;
-

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

-
pub struct ParagraphProps<'a> {
-
    pub content: Text<'a>,
-
    pub focus: bool,
-
    pub has_header: bool,
-
    pub has_footer: bool,
-
}
-

-
pub struct Paragraph<A> {
-
    /// Sending actions to the state store
-
    pub action_tx: UnboundedSender<A>,
-
    /// Internal offset
-
    offset: usize,
-
    /// Internal progress
-
    progress: usize,
-
}
-

-
impl<A> Paragraph<A> {
-
    pub fn scroll(&self) -> (u16, u16) {
-
        (self.offset as u16, 0)
-
    }
-

-
    pub fn prev(&mut self, len: usize, page_size: usize) -> (u16, u16) {
-
        self.offset = self.offset.saturating_sub(1);
-
        self.progress = Self::scroll_percent(self.offset, len, page_size);
-
        self.scroll()
-
    }
-

-
    pub fn next(&mut self, len: usize, page_size: usize) -> (u16, u16) {
-
        if self.progress < 100 {
-
            self.offset = self.offset.saturating_add(1);
-
            self.progress = Self::scroll_percent(self.offset, len, page_size);
-
        }
-

-
        self.scroll()
-
    }
-

-
    pub fn prev_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
-
        self.offset = self.offset.saturating_sub(page_size);
-
        self.progress = Self::scroll_percent(self.offset, len, page_size);
-
        self.scroll()
-
    }
-

-
    pub fn next_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
-
        let end = len.saturating_sub(page_size);
-

-
        self.offset = std::cmp::min(self.offset.saturating_add(page_size), end);
-
        self.progress = Self::scroll_percent(self.offset, len, page_size);
-
        self.scroll()
-
    }
-

-
    pub fn begin(&mut self, len: usize, page_size: usize) -> (u16, u16) {
-
        self.offset = 0;
-
        self.progress = Self::scroll_percent(self.offset, len, page_size);
-
        self.scroll()
-
    }
-

-
    pub fn end(&mut self, len: usize, page_size: usize) -> (u16, u16) {
-
        self.offset = len.saturating_sub(page_size);
-
        self.progress = Self::scroll_percent(self.offset, len, page_size);
-
        self.scroll()
-
    }
-

-
    pub fn progress(&self) -> usize {
-
        self.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<S, A> Widget<S, A> for Paragraph<A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            action_tx: action_tx.clone(),
-
            offset: 0,
-
            progress: 0,
-
        }
-
        .move_with_state(state)
-
    }
-

-
    fn move_with_state(self, _state: &S) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self { ..self }
-
    }
-

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

-
    fn handle_key_event(&mut self, _key: Key) {}
-
}
-

-
impl<'a, A> Render<ParagraphProps<'a>> for Paragraph<A> {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: ParagraphProps) {
-
        let block = Block::default()
-
            .borders(Borders::LEFT | Borders::RIGHT)
-
            .border_type(BorderType::Rounded)
-
            .border_style(style::border(props.focus));
-
        frame.render_widget(block, area);
-

-
        let [content_area] = Layout::horizontal([Constraint::Min(1)])
-
            .horizontal_margin(2)
-
            .areas(area);
-
        let content =
-
            ratatui::widgets::Paragraph::new(props.content.clone()).scroll((self.offset as u16, 0));
-

-
        frame.render_widget(content, content_area);
-
    }
-
}
added src/git.rs
@@ -0,0 +1,20 @@
+
use radicle::git;
+
use radicle::git::Oid;
+

+
/// Get the diff stats between two commits.
+
/// Should match the default output of `git diff <old> <new> --stat` exactly.
+
pub fn diff_stats(
+
    repo: &git::raw::Repository,
+
    old: &Oid,
+
    new: &Oid,
+
) -> Result<git::raw::DiffStats, git::raw::Error> {
+
    let old = repo.find_commit(**old)?;
+
    let new = repo.find_commit(**new)?;
+
    let old_tree = old.tree()?;
+
    let new_tree = new.tree()?;
+
    let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
+
    let mut find_opts = git::raw::DiffFindOptions::new();
+

+
    diff.find_similar(Some(&mut find_opts))?;
+
    diff.stats()
+
}
modified src/lib.rs
@@ -1,15 +1,17 @@
+
pub mod cob;
+
pub mod context;
+
pub mod event;
+
pub mod git;
+
pub mod log;
+
pub mod store;
+
pub mod task;
+
pub mod terminal;
+
pub mod ui;
+

use anyhow::Result;

use serde::ser::{Serialize, SerializeStruct, Serializer};

-
pub mod common;
-

-
#[cfg(feature = "realm")]
-
pub mod realm;
-

-
#[cfg(feature = "flux")]
-
pub mod flux;
-

/// An optional return value.
#[derive(Clone, Debug)]
pub struct Exit<T> {
added src/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/realm.rs
@@ -1,211 +0,0 @@
-
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."
-
            )),
-
        }
-
    }
-
}
deleted src/realm/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 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)
-
}
deleted src/realm/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 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
-
}
deleted src/realm/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/realm/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/realm/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/realm/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/realm/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/realm/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/realm/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/realm/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/realm/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::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)
-
    }
-
}
deleted src/realm/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::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)
-
}
deleted src/realm/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::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
-
            }
-
        }
-
    }
-
}
deleted src/realm/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::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,
-
        }
-
    }
-
}
deleted src/realm/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::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,
-
        }
-
    }
-
}
deleted src/realm/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()
-
}
added src/store.rs
@@ -0,0 +1,196 @@
+
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::task::{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)
+
    }
+
}
+

+
/// A `StateValue` that writes updates to an internal
+
/// buffer. This buffer can be applied or reset.
+
///
+
/// Reading from a `StateValue` will return the buffer if it's
+
/// not empty. It will return the actual value otherwise.
+
#[derive(Clone, Debug)]
+
pub struct StateValue<T>
+
where
+
    T: Clone,
+
{
+
    value: T,
+
    buffer: Option<T>,
+
}
+

+
impl<T> StateValue<T>
+
where
+
    T: Clone,
+
{
+
    pub fn new(value: T) -> Self {
+
        Self {
+
            value,
+
            buffer: None,
+
        }
+
    }
+

+
    pub fn apply(&mut self) {
+
        if let Some(buffer) = self.buffer.clone() {
+
            self.value = buffer;
+
        }
+
        self.buffer = None;
+
    }
+

+
    pub fn reset(&mut self) {
+
        self.buffer = None;
+
    }
+

+
    pub fn write(&mut self, value: T) {
+
        self.buffer = Some(value);
+
    }
+

+
    pub fn read(&self) -> T {
+
        if let Some(buffer) = self.buffer.clone() {
+
            buffer
+
        } else {
+
            self.value.clone()
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::*;
+

+
    #[test]
+
    fn state_value_read_should_succeed() {
+
        let value = StateValue::new(0);
+
        assert_eq!(value.read(), 0);
+
    }
+

+
    #[test]
+
    fn state_value_read_buffer_should_succeed() {
+
        let mut value = StateValue::new(0);
+
        value.write(1);
+

+
        assert_eq!(value.read(), 1);
+
    }
+

+
    #[test]
+
    fn state_value_apply_should_succeed() {
+
        let mut value = StateValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.apply();
+
        assert_eq!(value.read(), 1);
+
    }
+

+
    #[test]
+
    fn state_value_reset_should_succeed() {
+
        let mut value = StateValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.reset();
+
        assert_eq!(value.read(), 0);
+
    }
+

+
    #[test]
+
    fn state_value_reset_after_apply_should_succeed() {
+
        let mut value = StateValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.apply();
+
        value.reset();
+
        assert_eq!(value.read(), 1);
+
    }
+
}
added src/task.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/terminal.rs
@@ -0,0 +1,134 @@
+
use std::io::{self, Write};
+
use std::thread;
+

+
use termion::input::TermRead;
+
use termion::raw::{IntoRawMode, RawTerminal};
+

+
use ratatui::prelude::*;
+

+
use tokio::sync::mpsc::{self};
+

+
use super::event::Event;
+

+
type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;
+

+
/// FIXME Remove workaround after a new `ratatui` version with
+
/// https://github.com/ratatui-org/ratatui/pull/981/ included was released.
+
pub struct TermionBackendExt<W>
+
where
+
    W: Write,
+
{
+
    cursor: Option<(u16, u16)>,
+
    inner: TermionBackend<W>,
+
}
+

+
impl<W> TermionBackendExt<W>
+
where
+
    W: Write,
+
{
+
    pub fn new(writer: W) -> Self {
+
        Self {
+
            cursor: None,
+
            inner: TermionBackend::new(writer),
+
        }
+
    }
+
}
+

+
impl<W: Write> ratatui::backend::Backend for TermionBackendExt<W> {
+
    fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
+
    where
+
        I: Iterator<Item = (u16, u16, &'a buffer::Cell)>,
+
    {
+
        self.inner.draw(content)
+
    }
+

+
    fn append_lines(&mut self, n: u16) -> io::Result<()> {
+
        self.inner.append_lines(n)
+
    }
+

+
    fn hide_cursor(&mut self) -> io::Result<()> {
+
        self.inner.hide_cursor()
+
    }
+

+
    fn show_cursor(&mut self) -> io::Result<()> {
+
        self.inner.show_cursor()
+
    }
+

+
    fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
+
        match self.inner.get_cursor() {
+
            Ok((x, y)) => {
+
                let cursor = (x.saturating_sub(0), y.saturating_sub(0));
+
                self.cursor = Some(cursor);
+
                Ok(cursor)
+
            }
+
            Err(_) => Ok(self.cursor.unwrap_or((0, 0))),
+
        }
+
    }
+

+
    fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
+
        self.cursor = Some((x, y));
+
        self.inner.set_cursor(x, y)
+
    }
+

+
    fn clear(&mut self) -> io::Result<()> {
+
        self.inner.clear()
+
    }
+

+
    fn clear_region(&mut self, clear_type: backend::ClearType) -> io::Result<()> {
+
        self.inner.clear_region(clear_type)
+
    }
+

+
    fn size(&self) -> io::Result<Rect> {
+
        self.inner.size()
+
    }
+

+
    fn window_size(&mut self) -> io::Result<backend::WindowSize> {
+
        self.inner.window_size()
+
    }
+

+
    fn flush(&mut self) -> io::Result<()> {
+
        ratatui::backend::Backend::flush(&mut self.inner)
+
    }
+
}
+

+
pub fn setup(height: usize) -> anyhow::Result<Terminal<Backend>> {
+
    let stdout = io::stdout().into_raw_mode()?;
+
    let options = TerminalOptions {
+
        viewport: Viewport::Inline(height as u16),
+
    };
+

+
    Ok(Terminal::with_options(
+
        TermionBackendExt::new(stdout),
+
        options,
+
    )?)
+
}
+

+
pub fn restore(terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
+
    terminal.clear()?;
+
    Ok(())
+
}
+

+
pub 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/ui.rs
@@ -0,0 +1,89 @@
+
pub mod ext;
+
pub mod format;
+
pub mod items;
+
pub mod layout;
+
pub mod span;
+
pub mod theme;
+
pub mod widget;
+

+
use std::fmt::Debug;
+
use std::io::{self};
+
use std::time::Duration;
+

+
use termion::raw::RawTerminal;
+

+
use tokio::sync::broadcast;
+
use tokio::sync::mpsc::{self, UnboundedReceiver};
+

+
use super::event::Event;
+
use super::store::State;
+
use super::task::Interrupted;
+
use super::terminal;
+
use super::terminal::TermionBackendExt;
+
use super::ui::widget::{Render, Widget};
+

+
type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;
+

+
const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
+
const INLINE_HEIGHT: usize = 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 ticker = tokio::time::interval(RENDERING_TICK_RATE);
+

+
        let mut terminal = terminal::setup(INLINE_HEIGHT)?;
+
        let mut events_rx = terminal::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(), ()))?;
+
        };
+

+
        terminal::restore(&mut terminal)?;
+

+
        result
+
    }
+
}
added src/ui/ext.rs
@@ -0,0 +1,252 @@
+
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);
+
        }
+
    }
+
}
+

+
#[derive(Clone)]
+
pub enum FooterBlockType {
+
    Single,
+
    Begin,
+
    End,
+
    Repeat,
+
}
+

+
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,
+
    ///
+
    block_type: FooterBlockType,
+
    /// Widget style
+
    style: Style,
+
}
+

+
impl Default for FooterBlock {
+
    fn default() -> Self {
+
        Self {
+
            block_type: FooterBlockType::Single,
+
            borders: Self::borders(FooterBlockType::Single),
+
            border_style: Default::default(),
+
            border_type: BorderType::Rounded,
+
            style: Default::default(),
+
        }
+
    }
+
}
+

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

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

+
    pub fn block_type(mut self, block_type: FooterBlockType) -> Self {
+
        self.block_type = block_type.clone();
+
        self.borders = Self::borders(block_type);
+
        self
+
    }
+

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

+
    fn borders(block_type: FooterBlockType) -> Borders {
+
        match block_type {
+
            FooterBlockType::Single | FooterBlockType::Begin => Borders::ALL,
+
            FooterBlockType::End | FooterBlockType::Repeat => {
+
                Borders::TOP | Borders::RIGHT | Borders::BOTTOM
+
            }
+
        }
+
    }
+
}
+

+
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) {
+
            let symbol = match self.block_type {
+
                FooterBlockType::Begin | FooterBlockType::Repeat => symbols::line::HORIZONTAL_UP,
+
                _ => symbols.bottom_right,
+
            };
+
            buf.get_mut(area.right() - 1, area.bottom() - 1)
+
                .set_symbol(symbol)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
+
            let symbol = match self.block_type {
+
                FooterBlockType::Begin | FooterBlockType::Repeat => symbols::line::HORIZONTAL_DOWN,
+
                _ => symbols::line::VERTICAL_LEFT,
+
            };
+
            buf.get_mut(area.right() - 1, area.top())
+
                .set_symbol(symbol)
+
                .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/ui/format.rs
@@ -0,0 +1,95 @@
+
use radicle::cob::Label;
+
use radicle::cob::{ObjectId, Timestamp};
+
use radicle::crypto::PublicKey;
+
use radicle::issue;
+
use radicle::node::Alias;
+
use radicle::patch;
+
use radicle::prelude::Did;
+
use ratatui::style::Color;
+

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

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

+
pub fn 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 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 author(did: &Did, alias: &Option<Alias>, is_you: bool) -> String {
+
    let author = match alias {
+
        Some(alias) => format!("{alias}"),
+
        None => self::did(did),
+
    };
+

+
    if is_you {
+
        format!("{} (you)", author)
+
    } else {
+
        author
+
    }
+
}
+

+
pub fn 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(&self::author(&Did::from(assignee), alias, *is_you));
+
        }
+

+
        if assignees.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
added src/ui/items.rs
@@ -0,0 +1,966 @@
+
use std::str::FromStr;
+

+
use nom::bytes::complete::{tag, take};
+
use nom::multi::separated_list0;
+
use nom::sequence::{delimited, preceded};
+
use nom::{IResult, Parser};
+

+
use radicle::cob::{Label, ObjectId, Timestamp, TypedId};
+
use radicle::git::Oid;
+
use radicle::identity::{Did, Identity};
+
use radicle::issue::{self, CloseReason, Issue, IssueId, Issues};
+
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
+
use radicle::node::{Alias, AliasStore, NodeId};
+
use radicle::patch;
+
use radicle::patch::{Patch, PatchId, Patches};
+
use radicle::storage::git::Repository;
+
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository};
+
use radicle::Profile;
+

+
use ratatui::style::{Style, Stylize};
+
use ratatui::widgets::Cell;
+

+
use super::super::git;
+
use super::theme::style;
+
use super::widget::ToRow;
+
use super::{format, span};
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
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>,
+
    },
+
    Unknown {
+
        refname: String,
+
    },
+
}
+

+
impl NotificationKindItem {
+
    pub fn new(
+
        repo: &Repository,
+
        notification: &Notification,
+
    ) -> Result<Option<Self>, anyhow::Error> {
+
        // 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(Some(NotificationKindItem::Branch {
+
                    name: name.to_string(),
+
                    summary: message,
+
                    status: status.to_string(),
+
                    id: head.map(ObjectId::from),
+
                }))
+
            }
+
            NotificationKind::Cob { typed_id } => {
+
                let TypedId { id, .. } = typed_id;
+
                let (category, summary, state) = if typed_id.is_issue() {
+
                    let Some(issue) = issues.get(id)? else {
+
                        // Issue could have been deleted after notification was created.
+
                        return Ok(None);
+
                    };
+
                    (
+
                        String::from("issue"),
+
                        issue.title().to_owned(),
+
                        issue.state().to_string(),
+
                    )
+
                } else if typed_id.is_patch() {
+
                    let Some(patch) = patches.get(id)? else {
+
                        // Patch could have been deleted after notification was created.
+
                        return Ok(None);
+
                    };
+
                    (
+
                        String::from("patch"),
+
                        patch.title().to_owned(),
+
                        patch.state().to_string(),
+
                    )
+
                } else if typed_id.is_identity() {
+
                    let Ok(identity) = Identity::get(id, repo) else {
+
                        log::error!(
+
                            target: "cli",
+
                            "Error retrieving identity {id} for notification {}", notification.id
+
                        );
+
                        return Ok(None);
+
                    };
+
                    let Some(rev) = notification
+
                        .update
+
                        .new()
+
                        .and_then(|id| identity.revision(&id))
+
                    else {
+
                        log::error!(
+
                            target: "cli",
+
                            "Error retrieving identity revision for notification {}", notification.id
+
                        );
+
                        return Ok(None);
+
                    };
+
                    (String::from("id"), rev.title.clone(), rev.state.to_string())
+
                } else {
+
                    (typed_id.type_name.to_string(), "".to_owned(), String::new())
+
                };
+

+
                Ok(Some(NotificationKindItem::Cob {
+
                    type_name: category.to_string(),
+
                    summary: summary.to_string(),
+
                    status: state.to_string(),
+
                    id: Some(*id),
+
                }))
+
            }
+
            NotificationKind::Unknown { refname } => Ok(Some(NotificationKindItem::Unknown {
+
                refname: refname.to_string(),
+
            })),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct NotificationItem {
+
    /// Unique notification ID.
+
    pub id: NotificationId,
+
    /// The project this belongs to.
+
    pub project: String,
+
    /// 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 NotificationItem {
+
    pub fn new(
+
        profile: &Profile,
+
        repo: &Repository,
+
        notification: &Notification,
+
    ) -> Result<Option<Self>, anyhow::Error> {
+
        let project = profile
+
            .storage
+
            .repository(repo.id)?
+
            .identity_doc()?
+
            .project()?;
+
        let name = project.name().to_string();
+
        let kind = NotificationKindItem::new(repo, notification)?;
+

+
        if kind.is_none() {
+
            return Ok(None);
+
        }
+

+
        Ok(Some(NotificationItem {
+
            id: notification.id,
+
            project: name,
+
            seen: notification.status.is_read(),
+
            kind: kind.unwrap(),
+
            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.clone(),
+
                status.clone(),
+
                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.clone(),
+
                    status.clone(),
+
                    id.to_string(),
+
                )
+
            }
+
            NotificationKindItem::Unknown { refname } => (
+
                refname.to_string(),
+
                String::new(),
+
                String::new(),
+
                String::new(),
+
            ),
+
        };
+

+
        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(),
+
        ]
+
    }
+
}
+

+
impl ToRow<9> for NotificationItem {
+
    fn to_row(&self) -> [Cell; 9] {
+
        let row: [Cell; 8] = self.to_row();
+
        let name = span::default(self.project.clone()).style(style::gray().dim());
+

+
        [
+
            row[0].clone(),
+
            row[1].clone(),
+
            name.into(),
+
            row[2].clone(),
+
            row[3].clone(),
+
            row[4].clone(),
+
            row[5].clone(),
+
            row[6].clone(),
+
            row[7].clone(),
+
        ]
+
    }
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum NotificationType {
+
    Patch,
+
    Issue,
+
    Branch,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum NotificationState {
+
    Seen,
+
    Unseen,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct NotificationItemFilter {
+
    state: Option<NotificationState>,
+
    type_name: Option<NotificationType>,
+
    authors: Vec<Did>,
+
    search: Option<String>,
+
}
+

+
impl NotificationItemFilter {
+
    pub fn state(&self) -> Option<NotificationState> {
+
        self.state.clone()
+
    }
+

+
    pub fn matches(&self, notif: &NotificationItem) -> bool {
+
        use fuzzy_matcher::skim::SkimMatcherV2;
+
        use fuzzy_matcher::FuzzyMatcher;
+

+
        let matcher = SkimMatcherV2::default();
+

+
        let matches_state = match self.state {
+
            Some(NotificationState::Seen) => notif.seen,
+
            Some(NotificationState::Unseen) => !notif.seen,
+
            None => true,
+
        };
+

+
        let matches_type = match self.type_name {
+
            Some(NotificationType::Patch) => matches!(&notif.kind, NotificationKindItem::Cob {
+
                type_name,
+
                summary: _,
+
                status: _,
+
                id: _,
+
            } if type_name == "patch"),
+
            Some(NotificationType::Issue) => matches!(&notif.kind, NotificationKindItem::Cob {
+
                    type_name,
+
                    summary: _,
+
                    status: _,
+
                    id: _,
+
                } if type_name == "issue"),
+
            Some(NotificationType::Branch) => {
+
                matches!(notif.kind, NotificationKindItem::Branch { .. })
+
            }
+
            None => true,
+
        };
+

+
        let matches_authors = (!self.authors.is_empty())
+
            .then(|| {
+
                self.authors
+
                    .iter()
+
                    .any(|other| notif.author.nid == Some(**other))
+
            })
+
            .unwrap_or(true);
+

+
        let matches_search = match &self.search {
+
            Some(search) => {
+
                let summary = match &notif.kind {
+
                    NotificationKindItem::Cob {
+
                        type_name: _,
+
                        summary,
+
                        status: _,
+
                        id: _,
+
                    } => summary,
+
                    NotificationKindItem::Branch {
+
                        name: _,
+
                        summary,
+
                        status: _,
+
                        id: _,
+
                    } => summary,
+
                    NotificationKindItem::Unknown { refname: _ } => "",
+
                };
+
                match matcher.fuzzy_match(summary, search) {
+
                    Some(score) => score == 0 || score > 60,
+
                    _ => false,
+
                }
+
            }
+
            None => true,
+
        };
+

+
        matches_state && matches_type && matches_authors && matches_search
+
    }
+
}
+

+
impl FromStr for NotificationItemFilter {
+
    type Err = anyhow::Error;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        let mut state = None;
+
        let mut type_name = None;
+
        let mut search = String::new();
+
        let mut authors = vec![];
+

+
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("authors:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )(input)
+
        };
+

+
        let parts = value.split(' ');
+
        for part in parts {
+
            match part {
+
                "is:seen" => state = Some(NotificationState::Seen),
+
                "is:unseen" => state = Some(NotificationState::Unseen),
+
                "is:patch" => type_name = Some(NotificationType::Patch),
+
                "is:issue" => type_name = Some(NotificationType::Issue),
+
                "is:branch" => type_name = Some(NotificationType::Branch),
+
                other => {
+
                    if let Ok((_, dids)) = authors_parser.parse(other) {
+
                        for did in dids {
+
                            authors.push(Did::from_str(did)?);
+
                        }
+
                    } else {
+
                        search.push_str(other);
+
                    }
+
                }
+
            }
+
        }
+

+
        Ok(Self {
+
            state,
+
            type_name,
+
            authors,
+
            search: Some(search),
+
        })
+
    }
+
}
+

+
#[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, Default, Debug, Eq, PartialEq)]
+
pub struct IssueItemFilter {
+
    state: Option<issue::State>,
+
    authored: bool,
+
    authors: Vec<Did>,
+
    assigned: bool,
+
    assignees: Vec<Did>,
+
    search: Option<String>,
+
}
+

+
impl IssueItemFilter {
+
    pub fn state(&self) -> Option<issue::State> {
+
        self.state
+
    }
+

+
    pub fn matches(&self, issue: &IssueItem) -> bool {
+
        use fuzzy_matcher::skim::SkimMatcherV2;
+
        use fuzzy_matcher::FuzzyMatcher;
+

+
        let matcher = SkimMatcherV2::default();
+

+
        let matches_state = match self.state {
+
            Some(issue::State::Closed {
+
                reason: CloseReason::Other,
+
            }) => matches!(issue.state, issue::State::Closed { .. }),
+
            Some(state) => issue.state == state,
+
            None => true,
+
        };
+

+
        let matches_authored = if self.authored {
+
            issue.author.you
+
        } else {
+
            true
+
        };
+

+
        let matches_authors = (!self.authors.is_empty())
+
            .then(|| {
+
                self.authors
+
                    .iter()
+
                    .any(|other| issue.author.nid == Some(**other))
+
            })
+
            .unwrap_or(true);
+

+
        let matches_assigned = self
+
            .assigned
+
            .then(|| issue.assignees.iter().any(|assignee| assignee.you))
+
            .unwrap_or(true);
+

+
        let matches_assignees = (!self.assignees.is_empty())
+
            .then(|| {
+
                self.assignees.iter().any(|other| {
+
                    issue
+
                        .assignees
+
                        .iter()
+
                        .filter_map(|author| author.nid)
+
                        .collect::<Vec<_>>()
+
                        .contains(other)
+
                })
+
            })
+
            .unwrap_or(true);
+

+
        let matches_search = match &self.search {
+
            Some(search) => match matcher.fuzzy_match(&issue.title, search) {
+
                Some(score) => score == 0 || score > 60,
+
                _ => false,
+
            },
+
            None => true,
+
        };
+

+
        matches_state
+
            && matches_authored
+
            && matches_authors
+
            && matches_assigned
+
            && matches_assignees
+
            && matches_search
+
    }
+
}
+

+
impl FromStr for IssueItemFilter {
+
    type Err = anyhow::Error;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        let mut state = None;
+
        let mut search = String::new();
+
        let mut authored = false;
+
        let mut authors = vec![];
+
        let mut assigned = false;
+
        let mut assignees = vec![];
+

+
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("authors:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )(input)
+
        };
+

+
        let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("assignees:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )(input)
+
        };
+

+
        let parts = value.split(' ');
+
        for part in parts {
+
            match part {
+
                "is:open" => state = Some(issue::State::Open),
+
                "is:closed" => {
+
                    state = Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Other,
+
                    })
+
                }
+
                "is:solved" => {
+
                    state = Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Solved,
+
                    })
+
                }
+
                "is:authored" => authored = true,
+
                "is:assigned" => assigned = true,
+
                other => {
+
                    if let Ok((_, dids)) = assignees_parser.parse(other) {
+
                        for did in dids {
+
                            assignees.push(Did::from_str(did)?);
+
                        }
+
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
+
                        for did in dids {
+
                            authors.push(Did::from_str(did)?);
+
                        }
+
                    } else {
+
                        search.push_str(other);
+
                    }
+
                }
+
            }
+
        }
+

+
        Ok(Self {
+
            state,
+
            authored,
+
            authors,
+
            assigned,
+
            assignees,
+
            search: Some(search),
+
        })
+
    }
+
}
+

+
#[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 (_, revision) = patch.latest();
+
        let (from, to) = revision.range();
+
        let stats = git::diff_stats(repository.raw(), &from, &to)?;
+

+
        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: revision.head(),
+
            added: stats.insertions() as u16,
+
            removed: stats.deletions() as u16,
+
            timestamp: patch.updated_at(),
+
        })
+
    }
+
}
+

+
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(),
+
        ]
+
    }
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct PatchItemFilter {
+
    status: Option<patch::Status>,
+
    authored: bool,
+
    authors: Vec<Did>,
+
    search: Option<String>,
+
}
+

+
impl PatchItemFilter {
+
    pub fn status(&self) -> Option<patch::Status> {
+
        self.status
+
    }
+

+
    pub fn matches(&self, patch: &PatchItem) -> bool {
+
        use fuzzy_matcher::skim::SkimMatcherV2;
+
        use fuzzy_matcher::FuzzyMatcher;
+

+
        let matcher = SkimMatcherV2::default();
+

+
        let matches_state = match self.status {
+
            Some(patch::Status::Draft) => matches!(patch.state, patch::State::Draft),
+
            Some(patch::Status::Open) => matches!(patch.state, patch::State::Open { .. }),
+
            Some(patch::Status::Merged) => matches!(patch.state, patch::State::Merged { .. }),
+
            Some(patch::Status::Archived) => matches!(patch.state, patch::State::Archived),
+
            None => true,
+
        };
+

+
        let matches_authored = if self.authored {
+
            patch.author.you
+
        } else {
+
            true
+
        };
+

+
        let matches_authors = (!self.authors.is_empty())
+
            .then(|| {
+
                self.authors
+
                    .iter()
+
                    .any(|other| patch.author.nid == Some(**other))
+
            })
+
            .unwrap_or(true);
+

+
        let matches_search = match &self.search {
+
            Some(search) => match matcher.fuzzy_match(&patch.title, search) {
+
                Some(score) => score == 0 || score > 60,
+
                _ => false,
+
            },
+
            None => true,
+
        };
+

+
        matches_state && matches_authored && matches_authors && matches_search
+
    }
+
}
+

+
impl FromStr for PatchItemFilter {
+
    type Err = anyhow::Error;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        let mut status = None;
+
        let mut search = String::new();
+
        let mut authored = false;
+
        let mut authors = vec![];
+

+
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("authors:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )(input)
+
        };
+

+
        let parts = value.split(' ');
+
        for part in parts {
+
            match part {
+
                "is:open" => status = Some(patch::Status::Open),
+
                "is:merged" => status = Some(patch::Status::Merged),
+
                "is:archived" => status = Some(patch::Status::Archived),
+
                "is:draft" => status = Some(patch::Status::Draft),
+
                "is:authored" => authored = true,
+
                other => match authors_parser.parse(other) {
+
                    Ok((_, dids)) => {
+
                        for did in dids {
+
                            authors.push(Did::from_str(did)?);
+
                        }
+
                    }
+
                    _ => search.push_str(other),
+
                },
+
            }
+
        }
+

+
        Ok(Self {
+
            status,
+
            authored,
+
            authors,
+
            search: Some(search),
+
        })
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use anyhow::Result;
+

+
    use super::*;
+

+
    #[test]
+
    fn patch_item_filter_from_str_should_succeed() -> Result<()> {
+
        let search = r#"is:open is:authored authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
+
        let actual = PatchItemFilter::from_str(search)?;
+

+
        let expected = PatchItemFilter {
+
            status: Some(patch::Status::Open),
+
            authored: true,
+
            authors: vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ],
+
            search: Some("cli".to_string()),
+
        };
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn issue_item_filter_from_str_should_succeed() -> Result<()> {
+
        let search = r#"is:open is:assigned assignees:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
+
        let actual = IssueItemFilter::from_str(search)?;
+

+
        let expected = IssueItemFilter {
+
            state: Some(issue::State::Open),
+
            authors: vec![Did::from_str(
+
                "did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
+
            )?],
+
            authored: true,
+
            assigned: true,
+
            assignees: vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ],
+
            search: Some("cli".to_string()),
+
        };
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn notification_item_filter_from_str_should_succeed() -> Result<()> {
+
        let search = r#"is:seen is:patch authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
+
        let actual = NotificationItemFilter::from_str(search)?;
+

+
        let expected = NotificationItemFilter {
+
            state: Some(NotificationState::Seen),
+
            type_name: Some(NotificationType::Patch),
+
            authors: vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ],
+
            search: Some("cli".to_string()),
+
        };
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+
}
added src/ui/layout.rs
@@ -0,0 +1,53 @@
+
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],
+
    }
+
}
+

+
pub fn centered_rect(r: Rect, percent_x: u16, percent_y: u16) -> Rect {
+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints([
+
            Constraint::Percentage((100 - percent_y) / 2),
+
            Constraint::Percentage(percent_y),
+
            Constraint::Percentage((100 - percent_y) / 2),
+
        ])
+
        .split(r);
+

+
    Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints([
+
            Constraint::Percentage((100 - percent_x) / 2),
+
            Constraint::Percentage(percent_x),
+
            Constraint::Percentage((100 - percent_x) / 2),
+
        ])
+
        .split(layout[1])[1]
+
}
added src/ui/span.rs
@@ -0,0 +1,81 @@
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::Span;
+

+
use crate::ui::theme::style;
+

+
pub fn blank() -> Span<'static> {
+
    Span::styled("", Style::default())
+
}
+

+
pub fn default(content: String) -> Span<'static> {
+
    Span::styled(content, Style::default())
+
}
+

+
pub fn primary(content: String) -> Span<'static> {
+
    default(content).style(style::cyan())
+
}
+

+
pub fn secondary(content: String) -> Span<'static> {
+
    default(content).style(style::magenta())
+
}
+

+
pub fn ternary(content: String) -> Span<'static> {
+
    default(content).style(style::blue())
+
}
+

+
pub fn positive(content: String) -> Span<'static> {
+
    default(content).style(style::green())
+
}
+

+
pub fn negative(content: String) -> Span<'static> {
+
    default(content).style(style::red())
+
}
+

+
pub fn badge(content: String) -> Span<'static> {
+
    let content = &format!(" {content} ");
+
    default(content.to_string()).magenta().reversed()
+
}
+

+
pub fn alias(content: String) -> Span<'static> {
+
    secondary(content)
+
}
+

+
pub fn labels(content: String) -> Span<'static> {
+
    ternary(content)
+
}
+

+
pub fn timestamp(content: String) -> Span<'static> {
+
    default(content).style(style::gray().dim())
+
}
+

+
pub fn notification_id(content: String) -> Span<'static> {
+
    default(content).style(style::gray().dim())
+
}
+

+
pub fn notification_type(content: String) -> Span<'static> {
+
    default(content).style(style::gray().dim())
+
}
+

+
pub fn step(step: usize, len: usize, fill_zeros: bool) -> Span<'static> {
+
    if fill_zeros {
+
        if len > 10 {
+
            badge(format!("{:-02}/{:-02}", step, len))
+
        } else if len > 100 {
+
            badge(format!("{:-03}/{:-03}", step, len))
+
        } else if len > 1000 {
+
            badge(format!("{:-04}/{:-04}", step, len))
+
        } else if len > 10000 {
+
            badge(format!("{:-05}/{:-05}", step, len))
+
        } else {
+
            badge(format!("{}/{}", step, len))
+
        }
+
    } else {
+
        badge(format!("{}/{}", step, len))
+
    }
+
}
+

+
pub fn progress(step: usize, len: usize) -> Span<'static> {
+
    let progress = step as f32 / len as f32 * 100_f32;
+
    let progress = progress as usize;
+
    default(format!("{}%", progress)).dim()
+
}
added src/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/ui/widget.rs
@@ -0,0 +1,308 @@
+
pub mod container;
+
pub mod input;
+
pub mod text;
+

+
use std::cmp;
+
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;
+
use super::{layout, span};
+

+
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) -> Option<usize> {
+
        let selected = self.selected().map(|current| current.saturating_sub(1));
+
        self.state.select(selected);
+
        selected
+
    }
+

+
    pub fn next(&mut self, len: usize) -> Option<usize> {
+
        let selected = self.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                current.saturating_add(1)
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.select(selected);
+
        selected
+
    }
+

+
    pub fn prev_page(&mut self, page_size: usize) -> Option<usize> {
+
        let selected = self
+
            .selected()
+
            .map(|current| current.saturating_sub(page_size));
+
        self.state.select(selected);
+
        selected
+
    }
+

+
    pub fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
+
        let selected = self.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.select(selected);
+
        selected
+
    }
+

+
    pub fn begin(&mut self) -> Option<usize> {
+
        self.state.select(Some(0));
+
        self.state.selected()
+
    }
+

+
    pub fn end(&mut self, len: usize) -> Option<usize> {
+
        self.state.select(Some(len.saturating_sub(1)));
+
        self.state.selected()
+
    }
+

+
    pub fn progress(&self, len: usize) -> (usize, usize) {
+
        let step = self
+
            .selected()
+
            .map(|selected| selected.saturating_add(1))
+
            .unwrap_or_default();
+

+
        (cmp::min(step, len), len)
+
    }
+

+
    pub fn progress_percentage(&self, len: usize, page_size: usize) -> usize {
+
        let step = self.selected().unwrap_or_default();
+
        let page_size = page_size as f64;
+
        let len = len as f64;
+

+
        let lines = page_size + step.saturating_sub(page_size as usize) as f64;
+
        let progress = (lines / len * 100.0).ceil();
+

+
        if progress > 97.0 {
+
            Self::map_range((0.0, progress), (0.0, 100.0), progress) as usize
+
        } else {
+
            progress as usize
+
        }
+
    }
+

+
    fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
+
        to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
+
    }
+
}
+

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

+
        if !props.items.is_empty() {
+
            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());
+
        } else {
+
            let block = Block::default()
+
                .border_style(style::border(props.focus))
+
                .border_type(BorderType::Rounded)
+
                .borders(borders);
+

+
            frame.render_widget(block, area);
+

+
            let center = layout::centered_rect(area, 50, 10);
+
            let hint = Text::from(span::default("Nothing to show".to_string()))
+
                .centered()
+
                .light_magenta()
+
                .dim();
+

+
            frame.render_widget(hint, center);
+
        }
+
    }
+
}
added src/ui/widget/container.rs
@@ -0,0 +1,177 @@
+
use std::fmt::Debug;
+

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

+
use termion::event::Key;
+

+
use ratatui::prelude::*;
+
use ratatui::widgets::{BorderType, Borders, Row};
+

+
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
+
use crate::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> Footer<A> {
+
    fn render_cell<'a>(
+
        &self,
+
        frame: &mut ratatui::Frame,
+
        area: Rect,
+
        block_type: FooterBlockType,
+
        text: impl Into<Text<'a>>,
+
        focus: bool,
+
    ) {
+
        let footer_layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![Constraint::Min(1)])
+
            .vertical_margin(1)
+
            .horizontal_margin(1)
+
            .split(area);
+

+
        let footer_block = FooterBlock::default()
+
            .border_style(style::border(focus))
+
            .block_type(block_type);
+
        frame.render_widget(footer_block, area);
+
        frame.render_widget(text.into(), footer_layout[0]);
+
    }
+
}
+

+
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
+
            .into_iter()
+
            .map(|c| match c {
+
                Constraint::Min(min) => Constraint::Length(min.saturating_add(3)),
+
                _ => c,
+
            })
+
            .collect::<Vec<_>>();
+

+
        let layout = Layout::horizontal(widths).split(area);
+
        let cells = props.cells.iter().zip(layout.iter()).collect::<Vec<_>>();
+

+
        let last = cells.len().saturating_sub(1);
+
        let len = cells.len();
+

+
        for (i, (cell, area)) in cells.into_iter().enumerate() {
+
            let block_type = match i {
+
                0 if len == 1 => FooterBlockType::Single,
+
                0 => FooterBlockType::Begin,
+
                _ if i == last => FooterBlockType::End,
+
                _ => FooterBlockType::Repeat,
+
            };
+
            self.render_cell(frame, *area, block_type, cell.clone(), props.focus);
+
        }
+
    }
+
}
+

+
#[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]);
+
    }
+
}
added src/ui/widget/input.rs
@@ -0,0 +1,179 @@
+
use termion::event::Key;
+

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

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::prelude::{Backend, Rect};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Span};
+

+
use super::{Render, Widget};
+

+
pub struct TextField {
+
    /// Current value of the input box
+
    text: String,
+
    /// Position of cursor in the editor area.
+
    cursor_position: usize,
+
}
+

+
impl TextField {
+
    pub fn text(&self) -> &str {
+
        &self.text
+
    }
+

+
    pub fn set_text(&mut self, new_text: &str) {
+
        if self.text != new_text {
+
            self.text = String::from(new_text);
+
            self.cursor_position = self.text.len();
+
        }
+
    }
+

+
    pub fn reset(&mut self) {
+
        self.cursor_position = 0;
+
        self.text.clear();
+
    }
+

+
    pub fn is_empty(&self) -> bool {
+
        self.text.is_empty()
+
    }
+

+
    fn move_cursor_left(&mut self) {
+
        let cursor_moved_left = self.cursor_position.saturating_sub(1);
+
        self.cursor_position = self.clamp_cursor(cursor_moved_left);
+
    }
+

+
    fn move_cursor_right(&mut self) {
+
        let cursor_moved_right = self.cursor_position.saturating_add(1);
+
        self.cursor_position = self.clamp_cursor(cursor_moved_right);
+
    }
+

+
    fn enter_char(&mut self, new_char: char) {
+
        self.text.insert(self.cursor_position, new_char);
+

+
        self.move_cursor_right();
+
    }
+

+
    fn delete_char(&mut self) {
+
        let is_not_cursor_leftmost = self.cursor_position != 0;
+
        if is_not_cursor_leftmost {
+
            // Method "remove" is not used on the saved text for deleting the selected char.
+
            // Reason: Using remove on String works on bytes instead of the chars.
+
            // Using remove would require special care because of char boundaries.
+

+
            let current_index = self.cursor_position;
+
            let from_left_to_current_index = current_index - 1;
+

+
            // Getting all characters before the selected character.
+
            let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
+
            // Getting all characters after selected character.
+
            let after_char_to_delete = self.text.chars().skip(current_index);
+

+
            // Put all characters together except the selected one.
+
            // By leaving the selected one out, it is forgotten and therefore deleted.
+
            self.text = before_char_to_delete.chain(after_char_to_delete).collect();
+
            self.move_cursor_left();
+
        }
+
    }
+

+
    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
+
        new_cursor_pos.clamp(0, self.text.len())
+
    }
+
}
+

+
impl<S, A> Widget<S, A> for TextField {
+
    fn new(_state: &S, _action_tx: UnboundedSender<A>) -> Self {
+
        Self {
+
            //
+
            text: String::new(),
+
            cursor_position: 0,
+
        }
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

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

+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Char(to_insert)
+
                if (key != Key::Alt('\n'))
+
                    && (key != Key::Char('\n'))
+
                    && (key != Key::Ctrl('\n')) =>
+
            {
+
                self.enter_char(to_insert);
+
            }
+
            Key::Backspace => {
+
                self.delete_char();
+
            }
+
            Key::Left => {
+
                self.move_cursor_left();
+
            }
+
            Key::Right => {
+
                self.move_cursor_right();
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
pub struct TextFieldProps {
+
    pub titles: (String, String),
+
    pub inline_label: bool,
+
    pub show_cursor: bool,
+
}
+

+
impl Render<TextFieldProps> for TextField {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: TextFieldProps) {
+
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
+

+
        let input = self.text.as_str();
+
        let label = format!(" {} ", props.titles.0);
+
        let overline = String::from("▔").repeat(area.width as usize);
+
        let cursor_pos = self.cursor_position as u16;
+

+
        if props.inline_label {
+
            let top_layout = Layout::horizontal([
+
                Constraint::Length(label.chars().count() as u16),
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
            ])
+
            .split(layout[0]);
+

+
            let label = Span::from(label.clone()).magenta().dim().reversed();
+
            let input = Span::from(input).reset();
+

+
            let overline = Line::from([Span::raw(overline).magenta().dim()].to_vec());
+

+
            frame.render_widget(label, top_layout[0]);
+
            frame.render_widget(input, top_layout[2]);
+
            frame.render_widget(overline, layout[1]);
+

+
            if props.show_cursor {
+
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
+
            }
+
        } else {
+
            let top = Line::from([Span::from(input).reset()].to_vec());
+
            let bottom = Line::from(
+
                [
+
                    Span::from(label).magenta().dim().reversed(),
+
                    Span::raw(overline).magenta().dim(),
+
                ]
+
                .to_vec(),
+
            );
+

+
            frame.render_widget(top, layout[0]);
+
            frame.render_widget(bottom, layout[1]);
+

+
            if props.show_cursor {
+
                frame.set_cursor(area.x + cursor_pos, area.y)
+
            }
+
        }
+
    }
+
}
added src/ui/widget/text.rs
@@ -0,0 +1,137 @@
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::backend::Backend;
+
use ratatui::layout::{Constraint, Layout, Rect};
+
use ratatui::text::Text;
+
use ratatui::widgets::{Block, BorderType, Borders};
+

+
use crate::ui::theme::style;
+

+
use super::{Render, Widget};
+

+
pub struct ParagraphProps<'a> {
+
    pub content: Text<'a>,
+
    pub focus: bool,
+
    pub has_header: bool,
+
    pub has_footer: bool,
+
}
+

+
pub struct Paragraph<A> {
+
    /// Sending actions to the state store
+
    pub action_tx: UnboundedSender<A>,
+
    /// Internal offset
+
    offset: usize,
+
    /// Internal progress
+
    progress: usize,
+
}
+

+
impl<A> Paragraph<A> {
+
    pub fn scroll(&self) -> (u16, u16) {
+
        (self.offset as u16, 0)
+
    }
+

+
    pub fn prev(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        self.offset = self.offset.saturating_sub(1);
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn next(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        if self.progress < 100 {
+
            self.offset = self.offset.saturating_add(1);
+
            self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        }
+

+
        self.scroll()
+
    }
+

+
    pub fn prev_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        self.offset = self.offset.saturating_sub(page_size);
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn next_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        let end = len.saturating_sub(page_size);
+

+
        self.offset = std::cmp::min(self.offset.saturating_add(page_size), end);
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn begin(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        self.offset = 0;
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn end(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        self.offset = len.saturating_sub(page_size);
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn progress(&self) -> usize {
+
        self.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<S, A> Widget<S, A> for Paragraph<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            offset: 0,
+
            progress: 0,
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

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

+
    fn handle_key_event(&mut self, _key: Key) {}
+
}
+

+
impl<'a, A> Render<ParagraphProps<'a>> for Paragraph<A> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: ParagraphProps) {
+
        let block = Block::default()
+
            .borders(Borders::LEFT | Borders::RIGHT)
+
            .border_type(BorderType::Rounded)
+
            .border_style(style::border(props.focus));
+
        frame.render_widget(block, area);
+

+
        let [content_area] = Layout::horizontal([Constraint::Min(1)])
+
            .horizontal_margin(2)
+
            .areas(area);
+
        let content =
+
            ratatui::widgets::Paragraph::new(props.content.clone()).scroll((self.offset as u16, 0));
+

+
        frame.render_widget(content, content_area);
+
    }
+
}