mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-02 18:00:33 +08:00
feat: Carve out patch tui
This commit is contained in:
@@ -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,
|
||||
|
||||
+109
@@ -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."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-139
@@ -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,
|
||||
|
||||
+6
-256
@@ -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, .. }) => {
|
||||
|
||||
+54
-610
@@ -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"),
|
||||
],
|
||||
),
|
||||
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"),
|
||||
],
|
||||
),
|
||||
(
|
||||
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"),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
)]
|
||||
.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::List(ListCid::Header), header, vec![])?;
|
||||
app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, 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![])?;
|
||||
|
||||
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(),
|
||||
)?;
|
||||
|
||||
@@ -298,363 +163,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
|
||||
///
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user