Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
feat: Carve out patch tui
Erik Kundt committed 2 years ago
commit 21241e64b6323268e41efe6b593dfc5782dd67a7
parent 40389dc30fcf99bbbdc43fe6d74fb84b98acf500
9 files changed +363 -1028
modified src/issue/app/page.rs
@@ -160,7 +160,7 @@ impl HomeView {
                    }
                    _ => Progress::None,
                };
-
                let context = widget::patch::browse_context(context, theme, progress);
+
                let context = widget::issue::browse_context(context, theme, progress);
                Some(context)
            }
            _ => None,
modified src/lib.rs
@@ -11,6 +11,9 @@ use tuirealm::{Application, EventListenerCfg, NoUserEvent};
pub mod cob;
pub mod ui;

+
use ui::context::Context;
+
use ui::theme::Theme;
+

/// Trait that must be implemented by client applications in order to be run
/// as tui-application using tui-realm. Implementors act as models to the
/// tui-realm application that can be polled for new messages, updated
@@ -96,3 +99,109 @@ impl Window {
        Ok(())
    }
}
+

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

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

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

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

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

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

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

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

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

+
        self.pages.push(page);
+

+
        Ok(())
+
    }
+

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

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

+
        Ok(())
+
    }
+

+
    pub fn peek_mut(&mut self) -> Result<&mut Box<dyn ViewPage<Id, Message>>> {
+
        match self.pages.last_mut() {
+
            Some(page) => Ok(page),
+
            None => Err(anyhow::anyhow!(
+
                "Could not peek active page. Page stack is empty."
+
            )),
+
        }
+
    }
+
}
modified src/patch/app.rs
@@ -1,10 +1,11 @@
mod event;
mod page;
-
mod subscription;
+
mod ui;
+

+
use std::hash::Hash;

use anyhow::Result;

-
use radicle::cob::issue::IssueId;
use radicle::cob::patch::PatchId;
use radicle::identity::{Id, Project};
use radicle::prelude::Signer;
@@ -14,20 +15,18 @@ 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::subscription;
use radicle_tui::ui::theme::{self, Theme};
+
use radicle_tui::PageStack;
use radicle_tui::Tui;
-
use radicle_tui::{cob, ui};
-

-
use page::{HomeView, PatchView};

-
use self::page::{IssuePage, PageStack};
+
use page::{ListView, PatchView};

#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum HomeCid {
+
pub enum ListCid {
    Header,
-
    Dashboard,
-
    IssueBrowser,
    PatchBrowser,
    Context,
    Shortcuts,
@@ -42,54 +41,16 @@ pub enum PatchCid {
    Shortcuts,
}

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

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

-
/// Messages handled by this application.
-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum HomeMessage {
-
    RefreshIssues(Option<IssueId>),
-
}
-

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

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

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PatchMessage {
    Show(PatchId),
@@ -104,14 +65,13 @@ pub enum PopupMessage {
    Hide,
}

-
#[derive(Clone, Debug, Eq, PartialEq)]
+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub enum Message {
-
    Home(HomeMessage),
-
    Issue(IssueMessage),
    Patch(PatchMessage),
    NavigationChanged(u16),
    FormSubmitted(String),
    Popup(PopupMessage),
+
    #[default]
    Tick,
    Quit,
    Batch(Vec<Message>),
@@ -120,7 +80,7 @@ pub enum Message {
#[allow(dead_code)]
pub struct App {
    context: Context,
-
    pages: PageStack,
+
    pages: PageStack<Cid, Message>,
    theme: Theme,
    quit: bool,
}
@@ -137,12 +97,12 @@ impl App {
        }
    }

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

        Ok(())
@@ -168,35 +128,6 @@ impl App {
        }
    }

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

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

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

    fn process(
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
@@ -217,35 +148,6 @@ impl App {
                    _ => Ok(Some(Message::Batch(results))),
                }
            }
-
            Message::Issue(IssueMessage::Cob(IssueCobMessage::Create {
-
                title,
-
                tags,
-
                assignees,
-
                description,
-
            })) => match self.create_issue(title, description, tags, assignees) {
-
                Ok(id) => {
-
                    self.context.reload();
-

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

-
                    Ok(None)
-
                }
-
            },
-
            Message::Issue(IssueMessage::Show(id)) => {
-
                self.view_issue(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Issue(IssueMessage::Leave(id)) => {
-
                self.pages.pop(app)?;
-
                Ok(Some(Message::Home(HomeMessage::RefreshIssues(id))))
-
            }
            Message::Patch(PatchMessage::Show(id)) => {
                self.view_patch(app, id, &theme)?;
                Ok(None)
@@ -326,37 +228,14 @@ impl App {

        Ok(())
    }
-

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

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

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

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

        // Add global key listener and subscribe to key events
-
        let global = ui::widget::common::global_listener().to_boxed();
+
        let global = widget::common::global_listener().to_boxed();
        app.mount(
            Cid::GlobalListener,
            global,
modified src/patch/app/event.rs
@@ -1,20 +1,17 @@
-
use radicle::cob::issue::IssueId;
-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection, Position};
-
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
+
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::form::Form;
use radicle_tui::ui::widget::common::list::PropertyList;
-
use radicle_tui::ui::widget::home::{Dashboard, IssueBrowser, PatchBrowser};
-
use radicle_tui::ui::widget::{issue, patch};
+
use radicle_tui::ui::widget::home::PatchBrowser;

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

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

/// Since the framework does not know the type of messages that are being
/// passed around in the app, the following handlers need to be implemented for
@@ -49,190 +46,6 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
    }
}

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

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

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

impl tuirealm::Component<Message, NoUserEvent> for Widget<PatchBrowser> {
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        match event {
@@ -271,70 +84,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<PatchBrowser> {
    }
}

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

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

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

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Activity> {
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::Activity> {
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        match event {
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
@@ -345,7 +95,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Activity> {
    }
}

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<patch::Files> {
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::Files> {
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        match event {
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
modified src/patch/app/page.rs
@@ -2,132 +2,50 @@ use std::collections::HashMap;

use anyhow::Result;

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

-
use radicle_tui::cob;
use radicle_tui::ui::widget::common::context::{Progress, Shortcuts};
-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, State, StateValue, Sub, SubClause};
+
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 super::{
-
    subscription, Application, Cid, HomeCid, HomeMessage, IssueCid, IssueCobMessage, IssueMessage,
-
    Message, PatchCid, PopupMessage,
-
};
-

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

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

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

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

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

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

///
/// Home
///
-
pub struct HomeView {
-
    active_component: HomeCid,
-
    shortcuts: HashMap<HomeCid, Widget<Shortcuts>>,
+
pub struct ListView {
+
    active_component: ListCid,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
}

-
impl HomeView {
+
impl ListView {
    pub fn new(theme: Theme) -> Self {
        let shortcuts = Self::build_shortcuts(&theme);
-
        HomeView {
-
            active_component: HomeCid::Dashboard,
+
        Self {
+
            active_component: ListCid::PatchBrowser,
            shortcuts,
        }
    }

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

-
        Ok(())
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<HomeCid, Widget<Shortcuts>> {
-
        [
-
            (
-
                HomeCid::Dashboard,
-
                widget::common::shortcuts(
-
                    theme,
-
                    vec![
-
                        widget::common::shortcut(theme, "tab", "section"),
-
                        widget::common::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                HomeCid::IssueBrowser,
-
                widget::common::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"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                HomeCid::PatchBrowser,
-
                widget::common::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"),
-
                    ],
-
                ),
+
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            widget::common::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"),
+
                ],
            ),
-
        ]
+
        )]
        .iter()
        .cloned()
        .collect()
@@ -138,37 +56,17 @@ impl HomeView {
        app: &mut Application<Cid, Message, NoUserEvent>,
        context: &Context,
        theme: &Theme,
-
        cid: HomeCid,
    ) -> Result<()> {
-
        let context = match cid {
-
            HomeCid::IssueBrowser => {
-
                let state = app.state(&Cid::Home(HomeCid::IssueBrowser))?;
-
                let progress = match state {
-
                    State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => {
-
                        Progress::Step(step.saturating_add(1), total)
-
                    }
-
                    _ => Progress::None,
-
                };
-
                let context = widget::issue::browse_context(context, theme, progress);
-
                Some(context)
+
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
+
        let progress = match state {
+
            State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => {
+
                Progress::Step(step.saturating_add(1), total)
            }
-
            HomeCid::PatchBrowser => {
-
                let state = app.state(&Cid::Home(HomeCid::PatchBrowser))?;
-
                let progress = match state {
-
                    State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => {
-
                        Progress::Step(step.saturating_add(1), total)
-
                    }
-
                    _ => Progress::None,
-
                };
-
                let context = widget::patch::browse_context(context, theme, progress);
-
                Some(context)
-
            }
-
            _ => None,
+
            _ => Progress::None,
        };
+
        let context = ui::browse_context(context, theme, progress);

-
        if let Some(context) = context {
-
            app.remount(Cid::Home(HomeCid::Context), context.to_boxed(), vec![])?;
-
        }
+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;

        Ok(())
    }
@@ -176,11 +74,11 @@ impl HomeView {
    fn update_shortcuts(
        &self,
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: HomeCid,
+
        cid: ListCid,
    ) -> Result<()> {
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
            app.remount(
-
                Cid::Home(HomeCid::Shortcuts),
+
                Cid::List(ListCid::Shortcuts),
                shortcuts.clone().to_boxed(),
                vec![],
            )?;
@@ -189,41 +87,32 @@ impl HomeView {
    }
}

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

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

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

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

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

        Ok(())
    }

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

@@ -232,30 +121,9 @@ impl ViewPage for HomeView {
        app: &mut Application<Cid, Message, NoUserEvent>,
        context: &Context,
        theme: &Theme,
-
        message: Message,
+
        _message: Message,
    ) -> Result<Option<Message>> {
-
        match message {
-
            Message::NavigationChanged(index) => {
-
                self.activate(app, HomeCid::from(index as usize))?;
-
                self.update_shortcuts(app, self.active_component.clone())?;
-
            }
-
            Message::Home(HomeMessage::RefreshIssues(id)) => {
-
                let selected = match id {
-
                    Some(id) => {
-
                        cob::issue::find(context.repository(), &id)?.map(|issue| (id, issue))
-
                    }
-
                    _ => None,
-
                };
-

-
                let issue_browser = widget::home::issues(context, theme, selected).to_boxed();
-
                app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?;
-

-
                self.activate(app, HomeCid::IssueBrowser)?;
-
            }
-
            _ => {}
-
        }
-

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

        Ok(None)
    }
@@ -265,23 +133,20 @@ impl ViewPage for HomeView {
        let shortcuts_h = 1u16;
        let layout = layout::default_page(area, shortcuts_h);

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

-
        if self.active_component != HomeCid::Dashboard {
-
            app.view(&Cid::Home(HomeCid::Context), frame, layout.context);
-
        }
-

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

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

@@ -290,7 +155,7 @@ impl ViewPage for HomeView {

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

@@ -299,363 +164,6 @@ impl ViewPage for HomeView {
}

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

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

-
        Self {
-
            issue,
-
            active_component,
-
            shortcuts,
-
        }
-
    }
-

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

-
        Ok(())
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<IssueCid, Widget<Shortcuts>> {
-
        [
-
            (
-
                IssueCid::List,
-
                widget::common::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"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                IssueCid::Details,
-
                widget::common::shortcuts(
-
                    theme,
-
                    vec![
-
                        widget::common::shortcut(theme, "esc", "back"),
-
                        widget::common::shortcut(theme, "↑/↓", "scroll"),
-
                        widget::common::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                IssueCid::Form,
-
                widget::common::shortcuts(
-
                    theme,
-
                    vec![
-
                        widget::common::shortcut(theme, "esc", "back"),
-
                        widget::common::shortcut(theme, "shift + tab / tab", "navigate"),
-
                        widget::common::shortcut(theme, "ctrl + s", "submit"),
-
                    ],
-
                ),
-
            ),
-
        ]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-

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

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

-
        Ok(())
-
    }
-

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

-
impl ViewPage for IssuePage {
-
    fn mount(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let header = widget::common::app_header(context, theme, None).to_boxed();
-
        let list = widget::issue::list(context, theme, self.issue.clone()).to_boxed();
-

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

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

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

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

-
        Ok(())
-
    }
-

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

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

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

-
        Ok(())
-
    }
-

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

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

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

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

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

-
                app.unsubscribe(&Cid::GlobalListener, subscription::global_clause())?;
-

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

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

-
                app.subscribe(
-
                    &Cid::GlobalListener,
-
                    Sub::new(subscription::global_clause(), SubClause::Always),
-
                )?;
-

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

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

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

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

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

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

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

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

-
        Ok(None)
-
    }
-

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

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

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

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

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

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

-
///
/// Patch detail page
///
pub struct PatchView {
@@ -720,18 +228,18 @@ impl PatchView {
    }
}

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

        app.remount(Cid::Patch(PatchCid::Header), header, vec![])?;
        app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
@@ -806,70 +314,6 @@ impl ViewPage for PatchView {
    }
}

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

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

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

-
        self.pages.push(page);
-

-
        Ok(())
-
    }
-

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

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

-
        Ok(())
-
    }
-

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

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

impl From<usize> for PatchCid {
    fn from(index: usize) -> Self {
        match index {
deleted src/patch/app/subscription.rs
@@ -1,22 +0,0 @@
-
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
-
use tuirealm::SubEventClause;
-

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

-
pub fn global_clause<UserEvent>() -> SubEventClause<UserEvent>
-
where
-
    UserEvent: Clone + Eq + PartialEq + PartialOrd,
-
{
-
    SubEventClause::Keyboard(KeyEvent {
-
        code: Key::Char('q'),
-
        modifiers: KeyModifiers::NONE,
-
    })
-
}
added src/patch/app/ui.rs
@@ -0,0 +1,152 @@
+
use radicle::cob::patch::{Patch, PatchId};
+

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

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

+
use common::container::Tabs;
+
use common::context::{ContextBar, Progress};
+
use common::label::Label;
+

+
pub struct Activity {
+
    label: Widget<Label>,
+
}
+

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

+
impl WidgetComponent for Activity {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, area));
+
    }
+

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

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

+
pub struct Files {
+
    label: Widget<Label>,
+
}
+

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

+
impl WidgetComponent for Files {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, area));
+
    }
+

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

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

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

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

+
pub fn activity(theme: &Theme) -> Widget<Activity> {
+
    let not_implemented = common::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 files = Files::new(not_implemented);
+

+
    Widget::new(files)
+
}
+

+
pub fn context(context: &Context, theme: &Theme, patch: (PatchId, Patch)) -> Widget<ContextBar> {
+
    let (id, patch) = patch;
+
    let (_, rev) = patch.latest();
+
    let is_you = *patch.author().id() == context.profile().did();
+

+
    let id = cob::format::cob(&id);
+
    let title = patch.title();
+
    let author = cob::format_author(patch.author().id(), is_you);
+
    let comments = rev.discussion().len();
+

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

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

+
    let patches = context.patches();
+
    let mut draft = 0;
+
    let mut open = 0;
+
    let mut archived = 0;
+
    let mut merged = 0;
+

+
    for (_, patch) in patches {
+
        match patch.state() {
+
            State::Draft => draft += 1,
+
            State::Open { conflicts: _ } => open += 1,
+
            State::Archived => archived += 1,
+
            State::Merged {
+
                commit: _,
+
                revision: _,
+
            } => merged += 1,
+
        }
+
    }
+

+
    common::context::bar(
+
        theme,
+
        "Browse",
+
        "",
+
        "",
+
        &format!("{draft} draft | {open} open | {archived} archived | {merged} merged"),
+
        &progress.to_string(),
+
    )
+
}
modified src/ui.rs
@@ -3,5 +3,6 @@ pub mod context;
pub mod ext;
pub mod layout;
pub mod state;
+
pub mod subscription;
pub mod theme;
pub mod widget;
added src/ui/subscription.rs
@@ -0,0 +1,22 @@
+
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
+
use tuirealm::SubEventClause;
+

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

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