tui: Implement page stack and page state

This commit is contained in:
Erik Kundt 2023-04-26 13:08:55 +02:00 committed by Alexis Sellier
parent e7cc32a278
commit 4142d85178
No known key found for this signature in database
4 changed files with 261 additions and 92 deletions

View File

@ -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)?;
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());
Ok(())
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)?;
}
}
}

View File

@ -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)
}
}) => match self.perform(Cmd::Move(MoveDirection::Down)) {
CmdResult::Changed(State::One(StateValue::Usize(index))) => {
Some(Message::Home(HomeMessage::PatchChanged(index)))
}
_ => 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)))
}
_ => None,
},
}) => Some(Message::Patch(PatchMessage::Show)),
_ => None,
}
}

View File

@ -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();
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)?;
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![])?;
}
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)?;
Ok(())
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,
}
}
}

View File

@ -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)?;
}