Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Implement page stack and page state
Erik Kundt committed 3 years ago
commit 46959969c6a8cd1d77bd13fbfc8194b33e76905a
parent 11bcf343d7d94aa19f56891a359804f0033a799f
4 files changed +262 -93
modified radicle-tui/src/app.rs
@@ -4,7 +4,6 @@ pub mod subscription;

use anyhow::Result;

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

@@ -17,6 +16,10 @@ use radicle_tui::ui;
use radicle_tui::ui::theme::{self, Theme};
use radicle_tui::Tui;

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

+
use self::page::PageStack;
+

#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum HomeCid {
    Navigation,
@@ -43,12 +46,12 @@ pub enum Cid {
/// Messages handled by this application.
#[derive(Debug, Eq, PartialEq)]
pub enum HomeMessage {
-
    Show,
+
    PatchChanged(usize),
}

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

@@ -65,14 +68,12 @@ pub struct Context {
    profile: Profile,
    id: Id,
    project: Project,
-
    selected_patch: usize,
-
    patches: Vec<(PatchId, Patch)>,
}

#[allow(dead_code)]
pub struct App {
    context: Context,
-
    active_page: Box<dyn page::ViewPage>,
+
    pages: PageStack,
    theme: Theme,
    quit: bool,
}
@@ -81,49 +82,63 @@ pub struct App {
/// components and sets focus to a default one.
impl App {
    pub fn new(profile: Profile, id: Id, project: Project) -> Self {
-
        let patches = patch::load_all(&profile, id);
        Self {
            context: Context {
                id,
                profile,
                project,
-
                selected_patch: 0,
-
                patches,
            },
+
            pages: PageStack::default(),
            theme: theme::default_dark(),
-
            active_page: Box::<page::Home>::default(),
            quit: false,
        }
    }

-
    fn mount_home_view(
+
    fn view_home(
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
        theme: &Theme,
    ) -> Result<()> {
-
        self.active_page = Box::<page::Home>::default();
-
        self.active_page.mount(app, &self.context, theme)?;
-
        self.active_page.activate(app)?;
+
        let patches = patch::load_all(&self.context.profile, self.context.id);
+
        let home = Box::new(HomeView::new(patches));
+
        self.pages.push(home, app, &self.context, theme)?;

        Ok(())
    }

-
    fn mount_patch_view(
+
    fn view_patch(
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
        theme: &Theme,
    ) -> Result<()> {
-
        self.active_page = Box::<page::PatchView>::default();
-
        self.active_page.mount(app, &self.context, theme)?;
-
        self.active_page.activate(app)?;
-

-
        Ok(())
+
        let page = self.pages.peek_mut()?;
+
        let state = page.state().unwrap_map();
+
        let patches = state
+
            .and_then(|mut values| values.remove("patches"))
+
            .and_then(|value| value.unwrap_patches());
+

+
        match patches {
+
            Some((patches, selection)) => match patches.get(selection) {
+
                Some((id, patch)) => {
+
                    let view = Box::new(PatchView::new((*id, patch.clone())));
+
                    self.pages.push(view, app, &self.context, theme)?;
+

+
                    Ok(())
+
                }
+
                None => Err(anyhow::anyhow!(
+
                    "Could not mount 'page::PatchView'. Patch not found."
+
                )),
+
            },
+
            None => Err(anyhow::anyhow!(
+
                "Could not mount 'page::PatchView'. No state value for 'patches' found."
+
            )),
+
        }
    }
}

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

        // Add global key listener and subscribe to key events
        let global = ui::widget::common::global_listener().to_boxed();
@@ -133,7 +148,9 @@ impl Tui<Cid, Message> for App {
    }

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

    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
@@ -142,20 +159,15 @@ impl Tui<Cid, Message> for App {
                let theme = theme::default_dark();
                for message in messages {
                    match message {
-
                        Message::Home(HomeMessage::Show) => {
-
                            self.mount_home_view(app, &theme)?;
-
                        }
-
                        Message::Patch(PatchMessage::Show(index)) => {
-
                            self.context.selected_patch = index;
-
                            self.mount_patch_view(app, &theme)?;
+
                        Message::Patch(PatchMessage::Show) => {
+
                            self.view_patch(app, &theme)?;
                        }
                        Message::Patch(PatchMessage::Leave) => {
-
                            self.mount_home_view(app, &theme)?;
+
                            self.pages.pop(app)?;
                        }
                        Message::Quit => self.quit = true,
                        _ => {
-
                            self.active_page.update(message);
-
                            self.active_page.activate(app)?;
+
                            self.pages.peek_mut()?.update(app, message)?;
                        }
                    }
                }
modified radicle-tui/src/app/event.rs
@@ -13,7 +13,7 @@ use radicle_tui::ui::components::patch;

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

-
use super::{Message, PatchMessage};
+
use super::{HomeMessage, Message, PatchMessage};

/// Since the framework does not know the type of messages that are being
/// passed around in the app, the following handlers need to be implemented for
@@ -51,23 +51,24 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<Browser<(PatchId, Patc
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        match event {
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
+
                match self.perform(Cmd::Move(MoveDirection::Up)) {
+
                    CmdResult::Changed(State::One(StateValue::Usize(index))) => {
+
                        Some(Message::Home(HomeMessage::PatchChanged(index)))
+
                    }
+
                    _ => Some(Message::Tick),
+
                }
            }
            Event::Keyboard(KeyEvent {
                code: Key::Down, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(State::One(StateValue::Usize(index))) => {
-
                    Some(Message::Patch(PatchMessage::Show(index)))
+
            }) => match self.perform(Cmd::Move(MoveDirection::Down)) {
+
                CmdResult::Changed(State::One(StateValue::Usize(index))) => {
+
                    Some(Message::Home(HomeMessage::PatchChanged(index)))
                }
-
                _ => None,
+
                _ => Some(Message::Tick),
            },
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => Some(Message::Patch(PatchMessage::Show)),
            _ => None,
        }
    }
modified radicle-tui/src/app/page.rs
@@ -1,8 +1,15 @@
+
use std::collections::HashMap;
+

use anyhow::Result;
-
use radicle_tui::ui::{layout, theme::Theme, widget};
+

+
use radicle::cob::patch::{Patch, PatchId};
use tuirealm::{Frame, NoUserEvent};

-
use super::{subscription, Application, Cid, Context, HomeCid, Message, PatchCid};
+
use radicle_tui::ui::layout;
+
use radicle_tui::ui::theme::Theme;
+
use radicle_tui::ui::widget;
+

+
use super::{subscription, Application, Cid, Context, HomeCid, HomeMessage, Message, PatchCid};

/// `tuirealm`'s event and prop system is designed to work with flat component hierarchies.
/// Building deep nested component hierarchies would need a lot more additional effort to
@@ -12,6 +19,7 @@ use super::{subscription, Application, Cid, Context, HomeCid, Message, PatchCid}
/// 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>,
@@ -19,41 +27,54 @@ pub trait ViewPage {
        theme: &Theme,
    ) -> Result<()>;

-
    fn update(&mut self, message: Message);
+
    /// 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>,
+
        message: Message,
+
    ) -> Result<()>;

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

-
    fn activate(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()>;
+
    /// Can be used to retrieve a view page's internal state in a unified form.
+
    fn state(&self) -> PageState;
}

///
/// Home
///
-
pub struct Home {
+
pub struct HomeView {
    active_component: Cid,
+
    patches: (Vec<(PatchId, Patch)>, usize),
}

-
impl Default for Home {
-
    fn default() -> Self {
-
        Home {
+
impl HomeView {
+
    pub fn new(patches: Vec<(PatchId, Patch)>) -> Self {
+
        HomeView {
            active_component: Cid::Home(HomeCid::Dashboard),
+
            patches: (patches, 0),
        }
    }
}

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

        let dashboard = widget::home::dashboard(theme, &context.id, &context.project).to_boxed();
        let issue_browser = widget::home::issues(theme).to_boxed();
-
        let patch_browser =
-
            widget::home::patches(theme, &context.patches, &context.profile).to_boxed();
+
        let patch_browser = widget::home::patches(theme, patches, &context.profile).to_boxed();

        app.remount(
            Cid::Home(HomeCid::Navigation),
@@ -65,18 +86,36 @@ impl ViewPage for Home {
        app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?;
        app.remount(Cid::Home(HomeCid::PatchBrowser), patch_browser, vec![])?;

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

        Ok(())
    }

-
    fn update(&mut self, message: Message) {
-
        if let Message::NavigationChanged(index) = message {
-
            self.active_component = match index {
-
                0 => Cid::Home(HomeCid::Dashboard),
-
                1 => Cid::Home(HomeCid::IssueBrowser),
-
                2 => Cid::Home(HomeCid::PatchBrowser),
-
                _ => Cid::Home(HomeCid::Dashboard),
-
            };
+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::Home(HomeCid::Navigation))?;
+
        app.umount(&Cid::Home(HomeCid::Dashboard))?;
+
        app.umount(&Cid::Home(HomeCid::IssueBrowser))?;
+
        app.umount(&Cid::Home(HomeCid::PatchBrowser))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<()> {
+
        match message {
+
            Message::NavigationChanged(index) => {
+
                self.active_component = Cid::Home(HomeCid::from(index as usize));
+
            }
+
            Message::Home(HomeMessage::PatchChanged(index)) => {
+
                self.patches.1 = index;
+
            }
+
            _ => {}
        }
+
        app.active(&self.active_component)?;
+

+
        Ok(())
    }

    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
@@ -87,9 +126,17 @@ impl ViewPage for Home {
        app.view(&self.active_component, frame, layout[1]);
    }

-
    fn activate(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.active(&self.active_component)?;
-
        Ok(())
+
    fn state(&self) -> PageState {
+
        let (patches, selected) = &self.patches;
+
        PageState::Map(
+
            [(
+
                "patches".to_string(),
+
                PageStateValue::Patches(patches.clone(), *selected),
+
            )]
+
            .iter()
+
            .cloned()
+
            .collect(),
+
        )
    }
}

@@ -98,12 +145,14 @@ impl ViewPage for Home {
///
pub struct PatchView {
    active_component: Cid,
+
    patch: (PatchId, Patch),
}

-
impl Default for PatchView {
-
    fn default() -> Self {
+
impl PatchView {
+
    pub fn new(patch: (PatchId, Patch)) -> Self {
        PatchView {
            active_component: Cid::Patch(PatchCid::Activity),
+
            patch,
        }
    }
}
@@ -115,31 +164,42 @@ impl ViewPage for PatchView {
        context: &Context,
        theme: &Theme,
    ) -> Result<()> {
-
        if let Some((id, patch)) = context.patches.get(context.selected_patch) {
-
            let navigation = widget::patch::navigation(theme).to_boxed();
-
            let activity =
-
                widget::patch::activity(theme, (*id, patch), &context.profile).to_boxed();
-
            let files = widget::patch::files(theme, (*id, patch), &context.profile).to_boxed();
-

-
            app.remount(
-
                Cid::Patch(PatchCid::Navigation),
-
                navigation,
-
                subscription::navigation(),
-
            )?;
-
            app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
-
            app.remount(Cid::Patch(PatchCid::Files), files, vec![])?;
-
        }
+
        let (id, patch) = &self.patch;
+
        let navigation = widget::patch::navigation(theme).to_boxed();
+
        let activity = widget::patch::activity(theme, (*id, patch), &context.profile).to_boxed();
+
        let files = widget::patch::files(theme, (*id, patch), &context.profile).to_boxed();
+

+
        app.remount(
+
            Cid::Patch(PatchCid::Navigation),
+
            navigation,
+
            subscription::navigation(),
+
        )?;
+
        app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
+
        app.remount(Cid::Patch(PatchCid::Files), files, vec![])?;
+

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

        Ok(())
    }

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

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<()> {
        if let Message::NavigationChanged(index) = message {
-
            self.active_component = match index {
-
                0 => Cid::Patch(PatchCid::Activity),
-
                1 => Cid::Patch(PatchCid::Files),
-
                _ => Cid::Patch(PatchCid::Activity),
-
            };
+
            self.active_component = Cid::Patch(PatchCid::from(index as usize));
        }
+
        app.active(&self.active_component)?;
+

+
        Ok(())
    }

    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
@@ -150,8 +210,104 @@ impl ViewPage for PatchView {
        app.view(&self.active_component, frame, layout[1]);
    }

-
    fn activate(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.active(&self.active_component)?;
+
    fn state(&self) -> PageState {
+
        PageState::None
+
    }
+
}
+

+
/// Represents a state value that can be retrieved from a view page.
+
#[derive(Clone)]
+
pub enum PageStateValue {
+
    /// List of patches and its selected patch
+
    Patches(Vec<(PatchId, Patch)>, usize),
+
}
+

+
impl PageStateValue {
+
    pub fn unwrap_patches(self) -> Option<(Vec<(PatchId, Patch)>, usize)> {
+
        match self {
+
            PageStateValue::Patches(patches, selection) => Some((patches, selection)),
+
        }
+
    }
+
}
+

+
/// View pages provide a way to retrieve their state in a unified manner
+
/// in case that state needs to be passed to other pages.
+
#[derive(Clone)]
+
pub enum PageState {
+
    None,
+
    Map(HashMap<String, PageStateValue>),
+
}
+

+
impl PageState {
+
    pub fn unwrap_map(self) -> Option<HashMap<String, PageStateValue>> {
+
        match self {
+
            PageState::Map(map) => Some(map),
+
            _ => None,
+
        }
+
    }
+
}
+

+
/// 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<()> {
+
        page.mount(app, context, theme)?;
+
        self.pages.push(page);
+

        Ok(())
    }
+

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

+
        Ok(())
+
    }
+

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

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

+
impl From<usize> for PatchCid {
+
    fn from(index: usize) -> Self {
+
        match index {
+
            0 => PatchCid::Activity,
+
            1 => PatchCid::Files,
+
            _ => PatchCid::Activity,
+
        }
+
    }
}
modified radicle-tui/src/lib.rs
@@ -80,9 +80,9 @@ impl Window {

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