Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
refactor: Simplify library ui and widget modules
Erik Kundt committed 2 years ago
commit f4eaf5c17eb2edf1dc10e0a726be22b19acb55a5
parent e47a0401fabf75752b3d79e95094953e462a0cda
22 files changed +1937 -1754
modified src/issue/app.rs
@@ -9,16 +9,17 @@ use radicle::identity::{Id, Project};
use radicle::prelude::Signer;
use radicle::profile::Profile;

-
use radicle_tui::ui::subscription;
-
use radicle_tui::ui::widget;
use tuirealm::application::PollStrategy;
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};

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

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

use page::{IssuePage, ListPage};

@@ -235,7 +236,7 @@ impl App {
        theme: &Theme,
        message: &str,
    ) -> Result<()> {
-
        let popup = widget::common::info(theme, message);
+
        let popup = tui::ui::info(theme, message);
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
        app.active(&Cid::Popup)?;

@@ -248,7 +249,7 @@ impl App {
        theme: &Theme,
        message: &str,
    ) -> Result<()> {
-
        let popup = widget::common::warning(theme, message);
+
        let popup = tui::ui::warning(theme, message);
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
        app.active(&Cid::Popup)?;

@@ -261,7 +262,7 @@ impl App {
        theme: &Theme,
        message: &str,
    ) -> Result<()> {
-
        let popup = widget::common::error(theme, message);
+
        let popup = tui::ui::error(theme, message);
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
        app.active(&Cid::Popup)?;

@@ -304,7 +305,7 @@ impl Tui<Cid, Message> for App {
        self.view_list(app, &self.theme.clone())?;

        // Add global key listener and subscribe to key events
-
        let global = widget::common::global_listener().to_boxed();
+
        let global = tui::ui::global_listener().to_boxed();
        app.mount(
            Cid::GlobalListener,
            global,
modified src/issue/app/event.rs
@@ -3,14 +3,14 @@ use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection, Position};
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};

-
use radicle_tui::ui::widget::common::container::{
-
    AppHeader, GlobalListener, LabeledContainer, Popup,
-
};
-
use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts};
-
use radicle_tui::ui::widget::common::form::Form;
-
use radicle_tui::ui::widget::common::list::PropertyList;
+
use radicle_tui as tui;

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

+
use tui::ui::widget::Widget;

use super::ui;
use super::{IssueCid, IssueMessage, Message, PopupMessage};
modified src/issue/app/page.rs
@@ -3,15 +3,18 @@ use std::collections::HashMap;
use anyhow::Result;

use radicle::cob::issue::{Issue, IssueId};
-
use radicle_tui::cob;
-
use radicle_tui::ui::widget::common::context::{Progress, Shortcuts};
+

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

-
use radicle_tui::ui::context::Context;
-
use radicle_tui::ui::layout;
-
use radicle_tui::ui::theme::Theme;
-
use radicle_tui::ui::widget::{self, Widget};
-
use radicle_tui::ViewPage;
+
use radicle_tui as tui;
+

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

use super::{
    Application, Cid, IssueCid, IssueCobMessage, IssueMessage, ListCid, Message, PopupMessage,
@@ -53,14 +56,14 @@ impl ListPage {
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
        [(
            ListCid::IssueBrowser,
-
            widget::common::shortcuts(
+
            tui::ui::shortcuts(
                theme,
                vec![
-
                    widget::common::shortcut(theme, "tab", "section"),
-
                    widget::common::shortcut(theme, "↑/↓", "navigate"),
-
                    widget::common::shortcut(theme, "enter", "show"),
-
                    widget::common::shortcut(theme, "o", "open"),
-
                    widget::common::shortcut(theme, "q", "quit"),
+
                    tui::ui::shortcut(theme, "tab", "section"),
+
                    tui::ui::shortcut(theme, "↑/↓", "navigate"),
+
                    tui::ui::shortcut(theme, "enter", "show"),
+
                    tui::ui::shortcut(theme, "o", "open"),
+
                    tui::ui::shortcut(theme, "q", "quit"),
                ],
            ),
        )]
@@ -112,7 +115,7 @@ impl ViewPage<Cid, Message> for ListPage {
        theme: &Theme,
    ) -> Result<()> {
        let navigation = ui::list_navigation(theme);
-
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
+
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
        let issue_browser = ui::issues(context, theme, None).to_boxed();

        app.remount(Cid::List(ListCid::Header), header, vec![])?;
@@ -229,36 +232,36 @@ impl IssuePage {
        [
            (
                IssueCid::List,
-
                widget::common::shortcuts(
+
                tui::ui::shortcuts(
                    theme,
                    vec![
-
                        widget::common::shortcut(theme, "esc", "back"),
-
                        widget::common::shortcut(theme, "↑/↓", "navigate"),
-
                        widget::common::shortcut(theme, "enter", "show"),
-
                        widget::common::shortcut(theme, "o", "open"),
-
                        widget::common::shortcut(theme, "q", "quit"),
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "↑/↓", "navigate"),
+
                        tui::ui::shortcut(theme, "enter", "show"),
+
                        tui::ui::shortcut(theme, "o", "open"),
+
                        tui::ui::shortcut(theme, "q", "quit"),
                    ],
                ),
            ),
            (
                IssueCid::Details,
-
                widget::common::shortcuts(
+
                tui::ui::shortcuts(
                    theme,
                    vec![
-
                        widget::common::shortcut(theme, "esc", "back"),
-
                        widget::common::shortcut(theme, "↑/↓", "scroll"),
-
                        widget::common::shortcut(theme, "q", "quit"),
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "↑/↓", "scroll"),
+
                        tui::ui::shortcut(theme, "q", "quit"),
                    ],
                ),
            ),
            (
                IssueCid::Form,
-
                widget::common::shortcuts(
+
                tui::ui::shortcuts(
                    theme,
                    vec![
-
                        widget::common::shortcut(theme, "esc", "back"),
-
                        widget::common::shortcut(theme, "shift + tab / tab", "navigate"),
-
                        widget::common::shortcut(theme, "ctrl + s", "submit"),
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "shift + tab / tab", "navigate"),
+
                        tui::ui::shortcut(theme, "ctrl + s", "submit"),
                    ],
                ),
            ),
@@ -334,7 +337,7 @@ impl ViewPage<Cid, Message> for IssuePage {
        theme: &Theme,
    ) -> Result<()> {
        let navigation = ui::list_navigation(theme);
-
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
+
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
        let list = ui::list(context, theme, self.issue.clone()).to_boxed();

        app.remount(Cid::Issue(IssueCid::Header), header, vec![])?;
modified src/issue/app/ui.rs
@@ -8,18 +8,19 @@ use tuirealm::command::{Cmd, CmdResult};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};

-
use radicle_tui::ui::cob;
-
use radicle_tui::ui::cob::IssueItem;
-
use radicle_tui::ui::context::Context;
-
use radicle_tui::ui::theme::Theme;
-
use radicle_tui::ui::widget::common;
-
use radicle_tui::ui::widget::{Widget, WidgetComponent};
-

-
use common::container::{Container, Tabs};
-
use common::context::{ContextBar, Progress};
-
use common::form::{Form, TextArea, TextField};
-
use common::label::Textarea;
-
use common::list::{ColumnWidth, List, Property, Table};
+
use radicle_tui as tui;
+

+
use tui::ui::cob;
+
use tui::ui::cob::IssueItem;
+
use tui::ui::context::Context;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::{Widget, WidgetComponent};
+

+
use tui::ui::widget::container::{Container, Tabs};
+
use tui::ui::widget::context::{ContextBar, Progress};
+
use tui::ui::widget::form::{Form, TextArea, TextField};
+
use tui::ui::widget::label::Textarea;
+
use tui::ui::widget::list::{ColumnWidth, List, Property, Table};

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

@@ -31,13 +32,13 @@ pub struct IssueBrowser {
impl IssueBrowser {
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
        let header = [
-
            common::label(" ● "),
-
            common::label("ID"),
-
            common::label("Title"),
-
            common::label("Author"),
-
            common::label("Tags"),
-
            common::label("Assignees"),
-
            common::label("Opened"),
+
            tui::ui::label(" ● "),
+
            tui::ui::label("ID"),
+
            tui::ui::label("Title"),
+
            tui::ui::label("Author"),
+
            tui::ui::label("Tags"),
+
            tui::ui::label("Assignees"),
+
            tui::ui::label("Opened"),
        ];

        let widths = [
@@ -121,7 +122,7 @@ impl LargeList {
        let list = Widget::new(List::new(&items, selected, theme.clone()))
            .highlight(theme.colors.item_list_highlighted_bg);

-
        let container = common::container(theme, list.to_boxed());
+
        let container = tui::ui::container(theme, list.to_boxed());

        Self {
            items,
@@ -166,30 +167,30 @@ impl IssueHeader {
        let item = IssueItem::from((context.profile(), repo, id, issue.clone()));

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

        let author = Property::new(
-
            common::label("Author").foreground(theme.colors.property_name_fg),
-
            common::label(&cob::format_author(issue.author().id(), by_you))
+
            tui::ui::label("Author").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&cob::format_author(issue.author().id(), by_you))
                .foreground(theme.colors.browser_list_author),
        );

        let issue_id = Property::new(
-
            common::label("Issue").foreground(theme.colors.property_name_fg),
-
            common::label(&id.to_string()).foreground(theme.colors.browser_list_description),
+
            tui::ui::label("Issue").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&id.to_string()).foreground(theme.colors.browser_list_description),
        );

        let labels = Property::new(
-
            common::label("Labels").foreground(theme.colors.property_name_fg),
-
            common::label(&cob::format_labels(item.labels()))
+
            tui::ui::label("Labels").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&cob::format_labels(item.labels()))
                .foreground(theme.colors.browser_list_labels),
        );

        let assignees = Property::new(
-
            common::label("Assignees").foreground(theme.colors.property_name_fg),
-
            common::label(&cob::format_assignees(
+
            tui::ui::label("Assignees").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&cob::format_assignees(
                &item
                    .assignees()
                    .iter()
@@ -200,11 +201,11 @@ impl IssueHeader {
        );

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

-
        let table = common::property_table(
+
        let table = tui::ui::property_table(
            theme,
            vec![
                Widget::new(title),
@@ -215,7 +216,7 @@ impl IssueHeader {
                Widget::new(state),
            ],
        );
-
        let container = common::container(theme, table.to_boxed());
+
        let container = tui::ui::container(theme, table.to_boxed());

        Self { container }
    }
@@ -294,7 +295,7 @@ impl CommentBody {
            .content(AttrValue::String(content))
            .foreground(theme.colors.default_fg);

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

        Self { textarea }
    }
@@ -320,9 +321,9 @@ impl WidgetComponent for CommentBody {
}

pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
-
    common::tabs(
+
    tui::ui::tabs(
        theme,
-
        vec![common::reversable_label("Issues").foreground(theme.colors.tabs_highlighted_fg)],
+
        vec![tui::ui::reversable_label("Issues").foreground(theme.colors.tabs_highlighted_fg)],
    )
}

@@ -403,7 +404,7 @@ pub fn browse_context(context: &Context, theme: &Theme, progress: Progress) -> W
        .collect::<Vec<_>>()
        .len();

-
    common::context::bar(
+
    tui::ui::widget::context::bar(
        theme,
        "Browse",
        "",
@@ -418,11 +419,11 @@ pub fn description_context(
    theme: &Theme,
    progress: Progress,
) -> Widget<ContextBar> {
-
    common::context::bar(theme, "Show", "", "", "", &progress.to_string())
+
    tui::ui::widget::context::bar(theme, "Show", "", "", "", &progress.to_string())
}

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

modified src/patch/app.rs
@@ -11,10 +11,10 @@ use radicle::identity::{Id, Project};
use radicle::prelude::Signer;
use radicle::profile::Profile;

-
use radicle_tui::ui::widget;
use tuirealm::application::PollStrategy;
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};

+
use radicle_tui as tui;
use radicle_tui::cob;
use radicle_tui::ui::context::Context;
use radicle_tui::ui::subscription;
@@ -189,7 +189,7 @@ impl App {
        theme: &Theme,
        message: &str,
    ) -> Result<()> {
-
        let popup = widget::common::info(theme, message);
+
        let popup = tui::ui::info(theme, message);
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
        app.active(&Cid::Popup)?;

@@ -202,7 +202,7 @@ impl App {
        theme: &Theme,
        message: &str,
    ) -> Result<()> {
-
        let popup = widget::common::warning(theme, message);
+
        let popup = tui::ui::warning(theme, message);
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
        app.active(&Cid::Popup)?;

@@ -215,7 +215,7 @@ impl App {
        theme: &Theme,
        message: &str,
    ) -> Result<()> {
-
        let popup = widget::common::error(theme, message);
+
        let popup = tui::ui::error(theme, message);
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
        app.active(&Cid::Popup)?;

@@ -235,7 +235,7 @@ impl Tui<Cid, Message> for App {
        self.view_list(app, &self.theme.clone())?;

        // Add global key listener and subscribe to key events
-
        let global = widget::common::global_listener().to_boxed();
+
        let global = tui::ui::global_listener().to_boxed();
        app.mount(
            Cid::GlobalListener,
            global,
modified src/patch/app/event.rs
@@ -2,11 +2,9 @@ use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};

-
use radicle_tui::ui::widget::common::container::{
-
    AppHeader, GlobalListener, LabeledContainer, Popup,
-
};
-
use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts};
-
use radicle_tui::ui::widget::common::list::PropertyList;
+
use radicle_tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer, Popup};
+
use radicle_tui::ui::widget::context::{ContextBar, Shortcuts};
+
use radicle_tui::ui::widget::list::PropertyList;

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

modified src/patch/app/page.rs
@@ -4,15 +4,16 @@ use anyhow::Result;

use radicle::cob::patch::{Patch, PatchId};

-
use radicle_tui::ui::widget::common::context::{Progress, Shortcuts};
use tuirealm::{Frame, NoUserEvent, State, StateValue, Sub, SubClause};

-
use radicle_tui::ui::context::Context;
-
use radicle_tui::ui::layout;
-
use radicle_tui::ui::subscription;
-
use radicle_tui::ui::theme::Theme;
-
use radicle_tui::ui::widget::{self, Widget};
-
use radicle_tui::ViewPage;
+
use radicle_tui as tui;
+

+
use tui::ui::context::Context;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::context::{Progress, Shortcuts};
+
use tui::ui::widget::Widget;
+
use tui::ui::{layout, subscription};
+
use tui::ViewPage;

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

@@ -36,13 +37,13 @@ impl ListView {
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
        [(
            ListCid::PatchBrowser,
-
            widget::common::shortcuts(
+
            tui::ui::shortcuts(
                theme,
                vec![
-
                    widget::common::shortcut(theme, "tab", "section"),
-
                    widget::common::shortcut(theme, "↑/↓", "navigate"),
-
                    widget::common::shortcut(theme, "enter", "show"),
-
                    widget::common::shortcut(theme, "q", "quit"),
+
                    tui::ui::shortcut(theme, "tab", "section"),
+
                    tui::ui::shortcut(theme, "↑/↓", "navigate"),
+
                    tui::ui::shortcut(theme, "enter", "show"),
+
                    tui::ui::shortcut(theme, "q", "quit"),
                ],
            ),
        )]
@@ -95,7 +96,7 @@ impl ViewPage<Cid, Message> for ListView {
        theme: &Theme,
    ) -> Result<()> {
        let navigation = ui::list_navigation(theme);
-
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
+
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
        let patch_browser = ui::patches(context, theme, None).to_boxed();

        app.remount(Cid::List(ListCid::Header), header, vec![])?;
@@ -186,23 +187,23 @@ impl PatchView {
        [
            (
                PatchCid::Activity,
-
                widget::common::shortcuts(
+
                tui::ui::shortcuts(
                    theme,
                    vec![
-
                        widget::common::shortcut(theme, "esc", "back"),
-
                        widget::common::shortcut(theme, "tab", "section"),
-
                        widget::common::shortcut(theme, "q", "quit"),
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "tab", "section"),
+
                        tui::ui::shortcut(theme, "q", "quit"),
                    ],
                ),
            ),
            (
                PatchCid::Files,
-
                widget::common::shortcuts(
+
                tui::ui::shortcuts(
                    theme,
                    vec![
-
                        widget::common::shortcut(theme, "esc", "back"),
-
                        widget::common::shortcut(theme, "tab", "section"),
-
                        widget::common::shortcut(theme, "q", "quit"),
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "tab", "section"),
+
                        tui::ui::shortcut(theme, "q", "quit"),
                    ],
                ),
            ),
@@ -236,7 +237,7 @@ impl ViewPage<Cid, Message> for PatchView {
        theme: &Theme,
    ) -> Result<()> {
        let navigation = ui::navigation(theme);
-
        let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed();
+
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
        let activity = ui::activity(theme).to_boxed();
        let files = ui::files(theme).to_boxed();
        let context = ui::context(context, theme, self.patch.clone()).to_boxed();
modified src/patch/app/ui.rs
@@ -4,18 +4,19 @@ use tuirealm::command::{Cmd, CmdResult};
use tuirealm::tui::layout::Rect;
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};

-
use radicle_tui::ui::cob;
-
use radicle_tui::ui::cob::PatchItem;
-
use radicle_tui::ui::context::Context;
-
use radicle_tui::ui::layout;
-
use radicle_tui::ui::theme::Theme;
-
use radicle_tui::ui::widget::common;
-
use radicle_tui::ui::widget::{Widget, WidgetComponent};
-

-
use common::container::Tabs;
-
use common::context::{ContextBar, Progress};
-
use common::label::Label;
-
use common::list::{ColumnWidth, Table};
+
use radicle_tui as tui;
+

+
use tui::ui::cob;
+
use tui::ui::cob::PatchItem;
+
use tui::ui::context::Context;
+
use tui::ui::layout;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::{Widget, WidgetComponent};
+

+
use tui::ui::widget::container::Tabs;
+
use tui::ui::widget::context::{ContextBar, Progress};
+
use tui::ui::widget::label::Label;
+
use tui::ui::widget::list::{ColumnWidth, Table};

pub struct PatchBrowser {
    items: Vec<PatchItem>,
@@ -25,14 +26,14 @@ pub struct PatchBrowser {
impl PatchBrowser {
    pub fn new(context: &Context, theme: &Theme, selected: Option<(PatchId, Patch)>) -> Self {
        let header = [
-
            common::label(" ● "),
-
            common::label("ID"),
-
            common::label("Title"),
-
            common::label("Author"),
-
            common::label("Head"),
-
            common::label("+"),
-
            common::label("-"),
-
            common::label("Updated"),
+
            tui::ui::label(" ● "),
+
            tui::ui::label("ID"),
+
            tui::ui::label("Title"),
+
            tui::ui::label("Author"),
+
            tui::ui::label("Head"),
+
            tui::ui::label("+"),
+
            tui::ui::label("-"),
+
            tui::ui::label("Updated"),
        ];

        let widths = [
@@ -158,18 +159,18 @@ impl WidgetComponent for Files {
}

pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
-
    common::tabs(
+
    tui::ui::tabs(
        theme,
-
        vec![common::reversable_label("Patches").foreground(theme.colors.tabs_highlighted_fg)],
+
        vec![tui::ui::reversable_label("Patches").foreground(theme.colors.tabs_highlighted_fg)],
    )
}

pub fn navigation(theme: &Theme) -> Widget<Tabs> {
-
    common::tabs(
+
    tui::ui::tabs(
        theme,
        vec![
-
            common::reversable_label("Activity").foreground(theme.colors.tabs_highlighted_fg),
-
            common::reversable_label("Files").foreground(theme.colors.tabs_highlighted_fg),
+
            tui::ui::reversable_label("Activity").foreground(theme.colors.tabs_highlighted_fg),
+
            tui::ui::reversable_label("Files").foreground(theme.colors.tabs_highlighted_fg),
        ],
    )
}
@@ -183,14 +184,14 @@ pub fn patches(
}

pub fn activity(theme: &Theme) -> Widget<Activity> {
-
    let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
+
    let not_implemented = tui::ui::label("not implemented").foreground(theme.colors.default_fg);
    let activity = Activity::new(not_implemented);

    Widget::new(activity)
}

pub fn files(theme: &Theme) -> Widget<Files> {
-
    let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
+
    let not_implemented = tui::ui::label("not implemented").foreground(theme.colors.default_fg);
    let files = Files::new(not_implemented);

    Widget::new(files)
@@ -206,7 +207,7 @@ pub fn context(context: &Context, theme: &Theme, patch: (PatchId, Patch)) -> Wid
    let author = cob::format_author(patch.author().id(), is_you);
    let comments = rev.discussion().len();

-
    common::context::bar(theme, "Patch", &id, title, &author, &comments.to_string())
+
    tui::ui::widget::context::bar(theme, "Patch", &id, title, &author, &comments.to_string())
}

pub fn browse_context(context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
@@ -230,7 +231,7 @@ pub fn browse_context(context: &Context, theme: &Theme, progress: Progress) -> W
        }
    }

-
    common::context::bar(
+
    tui::ui::widget::context::bar(
        theme,
        "Browse",
        "",
modified src/ui.rs
@@ -6,3 +6,183 @@ 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::{Label, Textarea};
+
use widget::list::{ColumnWidth, Property, PropertyList, PropertyTable};
+

+
use widget::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)
+
}
modified src/ui/cob.rs
@@ -2,8 +2,9 @@ pub mod format;

use radicle_surf;

-
// use cli::terminal::format;
-
// use radicle_cli as cli;
+
use tuirealm::props::{Color, Style};
+
use tuirealm::tui::text::{Span, Spans};
+
use tuirealm::tui::widgets::Cell;

use radicle::prelude::Did;
use radicle::storage::git::Repository;
@@ -14,14 +15,8 @@ use radicle::cob::issue::{Issue, IssueId, State as IssueState};
use radicle::cob::patch::{Patch, PatchId, State as PatchState};
use radicle::cob::{Label, Timestamp};

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

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

-
use super::widget::common::list::ListItem;
+
use crate::ui::widget::list::{ListItem, TableItem};

/// An author item that can be used in tables, list or trees.
///
modified src/ui/widget.rs
@@ -1,4 +1,8 @@
-
pub mod common;
+
pub mod container;
+
pub mod context;
+
pub mod form;
+
pub mod label;
+
pub mod list;
mod utils;

use std::ops::Deref;
deleted src/ui/widget/common/container.rs
@@ -1,504 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Props, Style, TextModifiers};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Margin, Rect};
-
use tuirealm::tui::widgets::{Block, Cell, Clear, Row};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-
    fn state(&self) -> State {
-
        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 color = if focus {
-
            self.theme.colors.container_border_focus_fg
-
        } else {
-
            self.theme.colors.container_border_fg
-
        };
-

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

-
        if display {
-
            let layout = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints([Constraint::Length(header_height), Constraint::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::default().fg(color))
-
                .border_type(BorderType::Rounded);
-
            frame.render_widget(block.clone(), layout[1]);
-

-
            self.component
-
                .attr(Attribute::Focus, AttrValue::Flag(focus));
-
            self.component.view(
-
                frame,
-
                block.inner(layout[1]).inner(&Margin {
-
                    vertical: 0,
-
                    horizontal: 1,
-
                }),
-
            );
-
        }
-
    }
-

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

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

-
pub struct Popup {
-
    component: Widget<LabeledContainer>,
-
}
-

-
impl Popup {
-
    pub fn new(_theme: Theme, component: Widget<LabeledContainer>) -> Self {
-
        Self { component }
-
    }
-
}
-

-
impl WidgetComponent for Popup {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, _area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-
        let width = properties
-
            .get_or(Attribute::Width, AttrValue::Size(50))
-
            .unwrap_size();
-
        let height = properties
-
            .get_or(Attribute::Height, AttrValue::Size(50))
-
            .unwrap_size();
-

-
        if display {
-
            let size = frame.size();
-

-
            let area = layout::centered_rect(width, height, size);
-
            frame.render_widget(Clear, area);
-

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

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

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

-
use super::label::Label;
-

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

-
pub enum Progress {
-
    Percentage(usize),
-
    Step(usize, usize),
-
    None,
-
}
-

-
impl ToString for Progress {
-
    fn to_string(&self) -> std::string::String {
-
        match self {
-
            Progress::Percentage(value) => format!("{value} %"),
-
            Progress::Step(step, total) => format!("{step}/{total}"),
-
            _ => String::new(),
-
        }
-
    }
-
}
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-
pub struct ContextBar {
-
    label_0: Widget<Label>,
-
    label_1: Widget<Label>,
-
    label_2: Widget<Label>,
-
    label_3: Widget<Label>,
-
    label_4: Widget<Label>,
-
    theme: Theme,
-
}
-

-
impl ContextBar {
-
    pub const PROP_EDIT_MODE: &str = "edit-mode";
-

-
    pub fn new(
-
        theme: Theme,
-
        label_0: Widget<Label>,
-
        label_1: Widget<Label>,
-
        label_2: Widget<Label>,
-
        label_3: Widget<Label>,
-
        label_4: Widget<Label>,
-
    ) -> Self {
-
        Self {
-
            theme,
-
            label_0,
-
            label_1,
-
            label_2,
-
            label_3,
-
            label_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 label_0_w = self.label_0.query(Attribute::Width).unwrap().unwrap_size();
-
        let label_1_w = self.label_1.query(Attribute::Width).unwrap().unwrap_size();
-
        let label_2_w = self.label_2.query(Attribute::Width).unwrap().unwrap_size();
-
        let label_4_w = self.label_4.query(Attribute::Width).unwrap().unwrap_size();
-

-
        if edit_mode {
-
            self.label_0.attr(
-
                Attribute::Background,
-
                AttrValue::Color(self.theme.colors.context_badge_edit_bg),
-
            )
-
        }
-

-
        if display {
-
            let layout = layout::h_stack(
-
                vec![
-
                    self.label_0.clone().to_boxed(),
-
                    self.label_1.clone().to_boxed(),
-
                    self.label_3
-
                        .clone()
-
                        .width(
-
                            area.width
-
                                .saturating_sub(label_0_w + label_1_w + label_2_w + label_4_w),
-
                        )
-
                        .to_boxed(),
-
                    self.label_2.clone().to_boxed(),
-
                    self.label_4.clone().to_boxed(),
-
                ],
-
                area,
-
            );
-

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

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

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

-
pub fn bar(
-
    theme: &Theme,
-
    label_0: &str,
-
    label_1: &str,
-
    label_2: &str,
-
    label_3: &str,
-
    label_4: &str,
-
) -> Widget<ContextBar> {
-
    let context = super::label(&format!(" {label_0} "))
-
        .foreground(theme.colors.context_bg)
-
        .background(theme.colors.context_badge_bg);
-
    let id = super::label(&format!(" {label_1} "))
-
        .foreground(theme.colors.context_color_fg)
-
        .background(theme.colors.context_bg);
-
    let title = super::label(&format!(" {label_2} "))
-
        .foreground(theme.colors.default_fg)
-
        .background(theme.colors.context_bg);
-
    let author = super::label(&format!(" {label_3} "))
-
        .foreground(theme.colors.context_light)
-
        .background(theme.colors.context_bg);
-
    let comments = super::label(&format!(" {label_4} "))
-
        .foreground(theme.colors.context_light)
-
        .background(theme.colors.context_bg);
-

-
    let context_bar = ContextBar::new(theme.clone(), context, id, author, title, comments);
-

-
    Widget::new(context_bar).height(1)
-
}
deleted src/ui/widget/common/form.rs
@@ -1,261 +0,0 @@
-
use std::collections::LinkedList;
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::Style;
-
use tuirealm::tui::layout::{Constraint, Direction, Margin, Rect};
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State, StateValue};
-

-
use crate::ui::state::FormState;
-
use crate::ui::theme::Theme;
-
use crate::ui::widget::{Widget, WidgetComponent};
-

-
use super::container::Container;
-
use super::label::Label;
-

-
pub struct TextField {
-
    input: Widget<Container>,
-
    placeholder: Widget<Label>,
-
    show_placeholder: bool,
-
}
-

-
impl TextField {
-
    pub fn new(theme: Theme, title: &str) -> Self {
-
        let input = tui_realm_textarea::TextArea::default()
-
            .wrap(false)
-
            .single_line(true)
-
            .cursor_line_style(Style::reset())
-
            .style(Style::default().fg(theme.colors.default_fg));
-
        let container = super::container(&theme, Box::new(input));
-

-
        Self {
-
            input: container,
-
            placeholder: super::label(title).foreground(theme.colors.input_placeholder_fg),
-
            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 {
-
        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
-
    }
-
}
-

-
pub struct TextArea {
-
    input: Widget<Container>,
-
    placeholder: Widget<Label>,
-
    show_placeholder: bool,
-
}
-

-
impl TextArea {
-
    pub fn new(theme: Theme, title: &str) -> Self {
-
        let input = tui_realm_textarea::TextArea::default()
-
            .wrap(true)
-
            .single_line(false)
-
            .cursor_line_style(Style::reset())
-
            .style(Style::default().fg(theme.colors.default_fg));
-
        let container = super::container(&theme, Box::new(input));
-

-
        Self {
-
            input: container,
-
            placeholder: super::label(title).foreground(theme.colors.input_placeholder_fg),
-
            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 {
-
        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
-
    }
-
}
-

-
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: &str = "cmd-focus-previous";
-
    pub const CMD_FOCUS_NEXT: &str = "cmd-focus-next";
-
    pub const CMD_NEWLINE: &str = "cmd-newline";
-
    pub const CMD_PASTE: &str = "cmd-paste";
-

-
    pub const PROP_ID: &str = "prop-id";
-

-
    pub fn new(_theme: Theme, inputs: Vec<Box<dyn MockComponent>>) -> Self {
-
        let state = FormState::new(Some(0), inputs.len());
-

-
        Self { inputs, state }
-
    }
-
}
-

-
impl WidgetComponent for Form {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        use tuirealm::props::Layout;
-
        // Clear and set current focus
-
        let focus = self.state.focus().unwrap_or(0);
-
        for input in &mut self.inputs {
-
            input.attr(Attribute::Focus, AttrValue::Flag(false));
-
        }
-
        if let Some(input) = self.inputs.get_mut(focus) {
-
            input.attr(Attribute::Focus, AttrValue::Flag(true));
-
        }
-

-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(
-
                &self
-
                    .inputs
-
                    .iter()
-
                    .map(|_| Constraint::Length(3))
-
                    .collect::<Vec<_>>(),
-
            );
-
        let layout = properties
-
            .get_or(Attribute::Layout, AttrValue::Layout(layout))
-
            .unwrap_layout();
-
        let layout = layout.chunks(area);
-

-
        for (index, area) in layout.iter().enumerate().take(self.inputs.len()) {
-
            if let Some(input) = self.inputs.get_mut(index) {
-
                input.view(frame, *area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        let states = self
-
            .inputs
-
            .iter()
-
            .map(|input| input.state())
-
            .collect::<LinkedList<_>>();
-
        State::Linked(states)
-
    }
-

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        match cmd {
-
            Cmd::Custom(Self::CMD_FOCUS_PREVIOUS) => {
-
                self.state.focus_previous();
-
                CmdResult::None
-
            }
-
            Cmd::Custom(Self::CMD_FOCUS_NEXT) => {
-
                self.state.focus_next();
-
                CmdResult::None
-
            }
-
            Cmd::Submit => CmdResult::Submit(self.state()),
-
            _ => {
-
                let focus = self.state.focus().unwrap_or(0);
-
                if let Some(input) = self.inputs.get_mut(focus) {
-
                    return input.perform(cmd);
-
                }
-
                CmdResult::None
-
            }
-
        }
-
    }
-
}
deleted src/ui/widget/common/label.rs
@@ -1,209 +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::{Span, Spans, Text};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
-

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

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

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

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

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

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

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

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

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

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

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

-
        Text::styled(content, Style::default().fg(foreground))
-
    }
-
}
-

-
pub struct Textarea {
-
    /// The current theme.
-
    theme: Theme,
-
    /// 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: &str = "display-progress";
-

-
    pub fn new(theme: Theme) -> Self {
-
        Self {
-
            theme,
-
            offset: 0,
-
            len: 0,
-
            height: 0,
-
            scroll_percent: 0,
-
        }
-
    }
-

-
    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 fg = properties
-
            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-
        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);
-

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

-
        // 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().map(|line| format!("{}\n", line)).collect();
-

-
        let paragraph = Paragraph::new(body)
-
            .scroll((self.offset as u16, 0))
-
            .style(Style::default().fg(fg));
-
        frame.render_widget(paragraph, layout[0]);
-

-
        self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
-

-
        if display_progress {
-
            let progress = Spans::from(vec![Span::styled(
-
                format!("{} %", self.scroll_percent),
-
                Style::default().fg(highlight_color),
-
            )]);
-

-
            let progress = Paragraph::new(progress).alignment(Alignment::Right);
-
            frame.render_widget(progress, layout[1]);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::One(StateValue::Usize(self.scroll_percent))
-
    }
-

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

-
        match cmd {
-
            Cmd::Scroll(Direction::Up) => {
-
                self.offset = self.offset.saturating_sub(1);
-
                self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
-
                CmdResult::None
-
            }
-
            Cmd::Scroll(Direction::Down) => {
-
                if self.scroll_percent < 100 {
-
                    self.offset = self.offset.saturating_add(1);
-
                    self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
-
                }
-
                CmdResult::None
-
            }
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
deleted src/ui/widget/common/list.rs
@@ -1,381 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::tui::widgets::{Block, Cell, ListState, Row, TableState};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-
/// A table component that can display a list of [`TableItem`]s.
-
pub struct Table<V, const W: usize>
-
where
-
    V: TableItem<W> + Clone + 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 highlight = properties
-
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-

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

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

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

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

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

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

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

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

-
    fn state(&self) -> State {
-
        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(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Move(Direction::Down) => match self.state.select_next() {
-
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Submit => match self.state.selected() {
-
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
-

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

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

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

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

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

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

-
        frame.render_stateful_widget(list, 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(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Move(Direction::Down) => match self.state.select_next() {
-
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            Cmd::Submit => match self.state.selected() {
-
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
-
                None => CmdResult::None,
-
            },
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
added src/ui/widget/container.rs
@@ -0,0 +1,504 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Props, Style, TextModifiers};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Margin, Rect};
+
use tuirealm::tui::widgets::{Block, Cell, Clear, Row};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

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

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

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

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

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

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

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

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

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

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

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

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

+
////////////////////////////////////////////////
+

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
    fn state(&self) -> State {
+
        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 color = if focus {
+
            self.theme.colors.container_border_focus_fg
+
        } else {
+
            self.theme.colors.container_border_fg
+
        };
+

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

+
        if display {
+
            let layout = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints([Constraint::Length(header_height), Constraint::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::default().fg(color))
+
                .border_type(BorderType::Rounded);
+
            frame.render_widget(block.clone(), layout[1]);
+

+
            self.component
+
                .attr(Attribute::Focus, AttrValue::Flag(focus));
+
            self.component.view(
+
                frame,
+
                block.inner(layout[1]).inner(&Margin {
+
                    vertical: 0,
+
                    horizontal: 1,
+
                }),
+
            );
+
        }
+
    }
+

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

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

+
pub struct Popup {
+
    component: Widget<LabeledContainer>,
+
}
+

+
impl Popup {
+
    pub fn new(_theme: Theme, component: Widget<LabeledContainer>) -> Self {
+
        Self { component }
+
    }
+
}
+

+
impl WidgetComponent for Popup {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, _area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+
        let width = properties
+
            .get_or(Attribute::Width, AttrValue::Size(50))
+
            .unwrap_size();
+
        let height = properties
+
            .get_or(Attribute::Height, AttrValue::Size(50))
+
            .unwrap_size();
+

+
        if display {
+
            let size = frame.size();
+

+
            let area = layout::centered_rect(width, height, size);
+
            frame.render_widget(Clear, area);
+

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

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

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

+
use super::label::Label;
+

+
use crate::ui::layout;
+
use crate::ui::theme::Theme;
+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
pub enum Progress {
+
    Percentage(usize),
+
    Step(usize, usize),
+
    None,
+
}
+

+
impl ToString for Progress {
+
    fn to_string(&self) -> std::string::String {
+
        match self {
+
            Progress::Percentage(value) => format!("{value} %"),
+
            Progress::Step(step, total) => format!("{step}/{total}"),
+
            _ => String::new(),
+
        }
+
    }
+
}
+

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

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

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

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

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

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

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

+
/// A shortcut bar that displays multiple shortcuts and separates them with a
+
/// divider.
+
#[derive(Clone)]
+
pub struct Shortcuts {
+
    shortcuts: Vec<Widget<Shortcut>>,
+
    divider: Widget<Label>,
+
}
+

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

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

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

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

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

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

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

+
pub struct ContextBar {
+
    label_0: Widget<Label>,
+
    label_1: Widget<Label>,
+
    label_2: Widget<Label>,
+
    label_3: Widget<Label>,
+
    label_4: Widget<Label>,
+
    theme: Theme,
+
}
+

+
impl ContextBar {
+
    pub const PROP_EDIT_MODE: &str = "edit-mode";
+

+
    pub fn new(
+
        theme: Theme,
+
        label_0: Widget<Label>,
+
        label_1: Widget<Label>,
+
        label_2: Widget<Label>,
+
        label_3: Widget<Label>,
+
        label_4: Widget<Label>,
+
    ) -> Self {
+
        Self {
+
            theme,
+
            label_0,
+
            label_1,
+
            label_2,
+
            label_3,
+
            label_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 label_0_w = self.label_0.query(Attribute::Width).unwrap().unwrap_size();
+
        let label_1_w = self.label_1.query(Attribute::Width).unwrap().unwrap_size();
+
        let label_2_w = self.label_2.query(Attribute::Width).unwrap().unwrap_size();
+
        let label_4_w = self.label_4.query(Attribute::Width).unwrap().unwrap_size();
+

+
        if edit_mode {
+
            self.label_0.attr(
+
                Attribute::Background,
+
                AttrValue::Color(self.theme.colors.context_badge_edit_bg),
+
            )
+
        }
+

+
        if display {
+
            let layout = layout::h_stack(
+
                vec![
+
                    self.label_0.clone().to_boxed(),
+
                    self.label_1.clone().to_boxed(),
+
                    self.label_3
+
                        .clone()
+
                        .width(
+
                            area.width
+
                                .saturating_sub(label_0_w + label_1_w + label_2_w + label_4_w),
+
                        )
+
                        .to_boxed(),
+
                    self.label_2.clone().to_boxed(),
+
                    self.label_4.clone().to_boxed(),
+
                ],
+
                area,
+
            );
+

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

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

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

+
pub fn bar(
+
    theme: &Theme,
+
    label_0: &str,
+
    label_1: &str,
+
    label_2: &str,
+
    label_3: &str,
+
    label_4: &str,
+
) -> Widget<ContextBar> {
+
    let context = crate::ui::label(&format!(" {label_0} "))
+
        .foreground(theme.colors.context_bg)
+
        .background(theme.colors.context_badge_bg);
+
    let id = crate::ui::label(&format!(" {label_1} "))
+
        .foreground(theme.colors.context_color_fg)
+
        .background(theme.colors.context_bg);
+
    let title = crate::ui::label(&format!(" {label_2} "))
+
        .foreground(theme.colors.default_fg)
+
        .background(theme.colors.context_bg);
+
    let author = crate::ui::label(&format!(" {label_3} "))
+
        .foreground(theme.colors.context_light)
+
        .background(theme.colors.context_bg);
+
    let comments = crate::ui::label(&format!(" {label_4} "))
+
        .foreground(theme.colors.context_light)
+
        .background(theme.colors.context_bg);
+

+
    let context_bar = ContextBar::new(theme.clone(), context, id, author, title, comments);
+

+
    Widget::new(context_bar).height(1)
+
}
added src/ui/widget/form.rs
@@ -0,0 +1,261 @@
+
use std::collections::LinkedList;
+

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

+
use crate::ui::state::FormState;
+
use crate::ui::theme::Theme;
+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
use super::container::Container;
+
use super::label::Label;
+

+
pub struct TextField {
+
    input: Widget<Container>,
+
    placeholder: Widget<Label>,
+
    show_placeholder: bool,
+
}
+

+
impl TextField {
+
    pub fn new(theme: Theme, title: &str) -> Self {
+
        let input = tui_realm_textarea::TextArea::default()
+
            .wrap(false)
+
            .single_line(true)
+
            .cursor_line_style(Style::reset())
+
            .style(Style::default().fg(theme.colors.default_fg));
+
        let container = crate::ui::container(&theme, Box::new(input));
+

+
        Self {
+
            input: container,
+
            placeholder: crate::ui::label(title).foreground(theme.colors.input_placeholder_fg),
+
            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 {
+
        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
+
    }
+
}
+

+
pub struct TextArea {
+
    input: Widget<Container>,
+
    placeholder: Widget<Label>,
+
    show_placeholder: bool,
+
}
+

+
impl TextArea {
+
    pub fn new(theme: Theme, title: &str) -> Self {
+
        let input = tui_realm_textarea::TextArea::default()
+
            .wrap(true)
+
            .single_line(false)
+
            .cursor_line_style(Style::reset())
+
            .style(Style::default().fg(theme.colors.default_fg));
+
        let container = crate::ui::container(&theme, Box::new(input));
+

+
        Self {
+
            input: container,
+
            placeholder: crate::ui::label(title).foreground(theme.colors.input_placeholder_fg),
+
            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 {
+
        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
+
    }
+
}
+

+
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: &str = "cmd-focus-previous";
+
    pub const CMD_FOCUS_NEXT: &str = "cmd-focus-next";
+
    pub const CMD_NEWLINE: &str = "cmd-newline";
+
    pub const CMD_PASTE: &str = "cmd-paste";
+

+
    pub const PROP_ID: &str = "prop-id";
+

+
    pub fn new(_theme: Theme, inputs: Vec<Box<dyn MockComponent>>) -> Self {
+
        let state = FormState::new(Some(0), inputs.len());
+

+
        Self { inputs, state }
+
    }
+
}
+

+
impl WidgetComponent for Form {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tuirealm::props::Layout;
+
        // Clear and set current focus
+
        let focus = self.state.focus().unwrap_or(0);
+
        for input in &mut self.inputs {
+
            input.attr(Attribute::Focus, AttrValue::Flag(false));
+
        }
+
        if let Some(input) = self.inputs.get_mut(focus) {
+
            input.attr(Attribute::Focus, AttrValue::Flag(true));
+
        }
+

+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(
+
                &self
+
                    .inputs
+
                    .iter()
+
                    .map(|_| Constraint::Length(3))
+
                    .collect::<Vec<_>>(),
+
            );
+
        let layout = properties
+
            .get_or(Attribute::Layout, AttrValue::Layout(layout))
+
            .unwrap_layout();
+
        let layout = layout.chunks(area);
+

+
        for (index, area) in layout.iter().enumerate().take(self.inputs.len()) {
+
            if let Some(input) = self.inputs.get_mut(index) {
+
                input.view(frame, *area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        let states = self
+
            .inputs
+
            .iter()
+
            .map(|input| input.state())
+
            .collect::<LinkedList<_>>();
+
        State::Linked(states)
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        match cmd {
+
            Cmd::Custom(Self::CMD_FOCUS_PREVIOUS) => {
+
                self.state.focus_previous();
+
                CmdResult::None
+
            }
+
            Cmd::Custom(Self::CMD_FOCUS_NEXT) => {
+
                self.state.focus_next();
+
                CmdResult::None
+
            }
+
            Cmd::Submit => CmdResult::Submit(self.state()),
+
            _ => {
+
                let focus = self.state.focus().unwrap_or(0);
+
                if let Some(input) = self.inputs.get_mut(focus) {
+
                    return input.perform(cmd);
+
                }
+
                CmdResult::None
+
            }
+
        }
+
    }
+
}
added src/ui/widget/label.rs
@@ -0,0 +1,209 @@
+
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::{Span, Spans, Text};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use crate::ui::theme::Theme;
+
use crate::ui::widget::{Widget, WidgetComponent};
+

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

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

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

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

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

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

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

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

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

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

+
        Text::styled(content, Style::default().fg(foreground))
+
    }
+
}
+

+
pub struct Textarea {
+
    /// The current theme.
+
    theme: Theme,
+
    /// 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: &str = "display-progress";
+

+
    pub fn new(theme: Theme) -> Self {
+
        Self {
+
            theme,
+
            offset: 0,
+
            len: 0,
+
            height: 0,
+
            scroll_percent: 0,
+
        }
+
    }
+

+
    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 fg = properties
+
            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+
        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);
+

+
        let highlight_color = if focus {
+
            self.theme.colors.container_border_focus_fg
+
        } else {
+
            self.theme.colors.container_border_fg
+
        };
+

+
        // 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().map(|line| format!("{}\n", line)).collect();
+

+
        let paragraph = Paragraph::new(body)
+
            .scroll((self.offset as u16, 0))
+
            .style(Style::default().fg(fg));
+
        frame.render_widget(paragraph, layout[0]);
+

+
        self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
+

+
        if display_progress {
+
            let progress = Spans::from(vec![Span::styled(
+
                format!("{} %", self.scroll_percent),
+
                Style::default().fg(highlight_color),
+
            )]);
+

+
            let progress = Paragraph::new(progress).alignment(Alignment::Right);
+
            frame.render_widget(progress, layout[1]);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::One(StateValue::Usize(self.scroll_percent))
+
    }
+

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

+
        match cmd {
+
            Cmd::Scroll(Direction::Up) => {
+
                self.offset = self.offset.saturating_sub(1);
+
                self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
+
                CmdResult::None
+
            }
+
            Cmd::Scroll(Direction::Down) => {
+
                if self.scroll_percent < 100 {
+
                    self.offset = self.offset.saturating_add(1);
+
                    self.scroll_percent = Self::scroll_percent(self.offset, self.len, self.height);
+
                }
+
                CmdResult::None
+
            }
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
added src/ui/widget/list.rs
@@ -0,0 +1,380 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
+
use tuirealm::tui::widgets::{Block, Cell, ListState, Row, TableState};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

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

+
use super::container::Header;
+
use super::label::Label;
+

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
/// A table component that can display a list of [`TableItem`]s.
+
pub struct Table<V, const W: usize>
+
where
+
    V: TableItem<W> + Clone + 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 highlight = properties
+
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

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

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

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

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

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

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

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

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

+
    fn state(&self) -> State {
+
        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(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Move(Direction::Down) => match self.state.select_next() {
+
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Submit => match self.state.selected() {
+
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
+

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

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

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

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

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

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

+
        frame.render_stateful_widget(list, 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(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Move(Direction::Down) => match self.state.select_next() {
+
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Submit => match self.state.selected() {
+
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
modified src/ui/widget/utils.rs
@@ -1,6 +1,6 @@
use tuirealm::tui::layout::{Constraint, Rect};

-
use super::common::list::ColumnWidth;
+
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`.