patch-list: Add basic application /wo operations

This commit is contained in:
Erik Kundt
2024-01-15 16:39:21 +01:00
parent eb3ac81ad2
commit 9253736a52
7 changed files with 528 additions and 3 deletions
+18
View File
@@ -1,5 +1,7 @@
#[path = "patch/common.rs"]
mod common;
#[path = "patch/list.rs"]
mod list;
#[path = "patch/select.rs"]
mod select;
#[path = "patch/suite.rs"]
@@ -23,6 +25,7 @@ pub const HELP: Help = Help {
Usage
rad-tui patch select
rad-tui patch list
General options
@@ -35,11 +38,13 @@ pub struct Options {
}
pub enum Operation {
List,
Select,
}
#[derive(PartialEq, Eq)]
pub enum OperationName {
List,
Select,
}
@@ -57,6 +62,7 @@ impl Args for Options {
return Err(Error::Help.into());
}
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
"list" => op = Some(OperationName::List),
"select" => op = Some(OperationName::Select),
unknown => anyhow::bail!("unknown operation '{}'", unknown),
},
@@ -65,6 +71,7 @@ impl Args for Options {
}
let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
OperationName::List => Operation::List,
OperationName::Select => Operation::Select,
};
Ok((Options { op }, vec![]))
@@ -76,6 +83,17 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>
.map_err(|_| anyhow!("this command must be run in the context of a project"))?;
match options.op {
Operation::List => {
let context = context::Context::new(id)?.with_patches();
log::enable(context.profile(), "patch", "list")?;
let patch_id = Window::default()
.run(&mut list::App::new(context), 1000 / FPS)?
.unwrap_or_default();
eprint!("{patch_id}");
}
Operation::Select => {
let context = context::Context::new(id)?.with_patches();
+163
View File
@@ -0,0 +1,163 @@
#[path = "list/event.rs"]
mod event;
#[path = "list/page.rs"]
mod page;
#[path = "list/ui.rs"]
mod ui;
use std::hash::Hash;
use anyhow::Result;
use radicle::cob::patch::PatchId;
use tuirealm::application::PollStrategy;
use tuirealm::event::Key;
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
use radicle_tui as tui;
use tui::context::Context;
use tui::ui::subscription;
use tui::ui::theme::Theme;
use tui::{Exit, PageStack, Tui};
use page::ListView;
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum ListCid {
Header,
PatchBrowser,
Context,
Shortcuts,
}
/// All component ids known to this application.
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
pub enum Cid {
List(ListCid),
#[default]
GlobalListener,
}
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub enum Message {
#[default]
Tick,
Quit(Option<PatchId>),
Batch(Vec<Message>),
}
pub struct App {
context: Context,
pages: PageStack<Cid, Message>,
theme: Theme,
quit: bool,
result: Option<PatchId>,
}
/// Creates a new application using a tui-realm-application, mounts all
/// components and sets focus to a default one.
impl App {
pub fn new(context: Context) -> Self {
Self {
context,
pages: PageStack::default(),
theme: Theme::default(),
quit: false,
result: None,
}
}
fn view_list(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
theme: &Theme,
) -> Result<()> {
let home = Box::new(ListView::new(theme.clone()));
self.pages.push(home, app, &self.context, theme)?;
Ok(())
}
fn process(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
message: Message,
) -> Result<Option<Message>> {
let theme = Theme::default();
match message {
Message::Batch(messages) => {
let mut results = vec![];
for message in messages {
if let Some(result) = self.process(app, message)? {
results.push(result);
}
}
match results.len() {
0 => Ok(None),
1 => Ok(Some(results[0].to_owned())),
_ => Ok(Some(Message::Batch(results))),
}
}
Message::Quit(id) => {
self.quit = true;
self.result = id;
Ok(None)
}
_ => self
.pages
.peek_mut()?
.update(app, &self.context, &theme, message),
}
}
}
impl Tui<Cid, Message> for App {
fn init(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
self.view_list(app, &self.theme.clone())?;
// Add global key listener and subscribe to key events
let global = tui::ui::global_listener().to_boxed();
app.mount(
Cid::GlobalListener,
global,
vec![Sub::new(
subscription::quit_clause(Key::Char('q')),
SubClause::Always,
)],
)?;
Ok(())
}
fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut 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> {
match app.tick(PollStrategy::Once) {
Ok(messages) if !messages.is_empty() => {
for message in messages {
let mut msg = Some(message);
while msg.is_some() {
msg = self.process(app, msg.unwrap())?;
}
}
Ok(true)
}
_ => Ok(false),
}
}
fn exit(&self) -> Option<Exit> {
if self.quit {
return Some(Exit {
value: self.result.map(|id| format!("{id}")),
});
}
None
}
}
+87
View File
@@ -0,0 +1,87 @@
use tuirealm::command::{Cmd, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent};
use radicle_tui as tui;
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
use tui::ui::widget::context::{ContextBar, Shortcuts};
use tui::ui::widget::list::PropertyList;
use tui::ui::widget::Widget;
use super::super::common;
use super::Message;
/// 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
/// each component used.
///
/// TODO: should handle `Event::WindowResize`, which is not emitted by `termion`.
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event {
Event::Keyboard(KeyEvent {
code: Key::Char('q'),
..
}) => Some(Message::Quit(None)),
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<common::ui::PatchBrowser> {
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::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)
}
_ => None,
}
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<LabeledContainer> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<PropertyList> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<ContextBar> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
impl tuirealm::Component<Message, NoUserEvent> for Widget<Shortcuts> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}
+160
View File
@@ -0,0 +1,160 @@
use std::collections::HashMap;
use anyhow::Result;
use tuirealm::{Frame, NoUserEvent, State, StateValue, Sub, SubClause};
use radicle_tui as tui;
use tui::context::Context;
use tui::ui::theme::Theme;
use tui::ui::widget::context::{Progress, Shortcuts};
use tui::ui::widget::Widget;
use tui::ui::{layout, subscription};
use tui::ViewPage;
use super::{ui, Application, Cid, ListCid, Message};
///
/// Home
///
pub struct ListView {
active_component: ListCid,
shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
}
impl ListView {
pub fn new(theme: Theme) -> Self {
let shortcuts = Self::build_shortcuts(&theme);
Self {
active_component: ListCid::PatchBrowser,
shortcuts,
}
}
fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
[(
ListCid::PatchBrowser,
tui::ui::shortcuts(
theme,
vec![
tui::ui::shortcut(theme, "↑/↓", "navigate"),
tui::ui::shortcut(theme, "q", "quit"),
],
),
)]
.iter()
.cloned()
.collect()
}
fn update_context(
&self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
) -> Result<()> {
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)
}
_ => Progress::None,
};
let context = ui::browse_context(context, theme, progress);
app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
Ok(())
}
fn update_shortcuts(
&self,
app: &mut Application<Cid, Message, NoUserEvent>,
cid: ListCid,
) -> Result<()> {
if let Some(shortcuts) = self.shortcuts.get(&cid) {
app.remount(
Cid::List(ListCid::Shortcuts),
shortcuts.clone().to_boxed(),
vec![],
)?;
}
Ok(())
}
}
impl ViewPage<Cid, Message> for ListView {
fn mount(
&self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
) -> Result<()> {
let navigation = ui::list_navigation(theme);
let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
let patch_browser = ui::patches(context, theme, None).to_boxed();
app.remount(Cid::List(ListCid::Header), header, vec![])?;
app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
app.active(&Cid::List(self.active_component.clone()))?;
self.update_shortcuts(app, self.active_component.clone())?;
self.update_context(app, context, theme)?;
Ok(())
}
fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
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(())
}
fn update(
&mut self,
app: &mut Application<Cid, Message, NoUserEvent>,
context: &Context,
theme: &Theme,
_message: Message,
) -> Result<Option<Message>> {
self.update_context(app, context, theme)?;
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::default_page(area, shortcuts_h);
app.view(
&Cid::List(self.active_component.clone()),
frame,
layout.component,
);
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::List(ListCid::Header),
Sub::new(subscription::navigation_clause(), SubClause::Always),
)?;
Ok(())
}
fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
app.unsubscribe(
&Cid::List(ListCid::Header),
subscription::navigation_clause(),
)?;
Ok(())
}
}
+91
View File
@@ -0,0 +1,91 @@
use radicle::cob::patch::{Patch, PatchId};
use radicle_tui as tui;
use tui::context::Context;
use tui::ui::theme::{style, Theme};
use tui::ui::widget::Widget;
use tui::ui::widget::container::Tabs;
use tui::ui::widget::context::{ContextBar, Progress};
use tui::ui::widget::label::{self};
use super::super::common;
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
tui::ui::tabs(
theme,
vec![label::reversable("Patches").style(style::cyan())],
)
}
pub fn patches(
context: &Context,
theme: &Theme,
selected: Option<(PatchId, Patch)>,
) -> Widget<common::ui::PatchBrowser> {
Widget::new(common::ui::PatchBrowser::new(context, theme, selected))
}
pub fn browse_context(context: &Context, _theme: &Theme, progress: Progress) -> Widget<ContextBar> {
use radicle::cob::patch::State;
let mut draft = 0;
let mut open = 0;
let mut archived = 0;
let mut merged = 0;
let patches = context.patches().as_ref().unwrap();
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,
}
}
let context = label::default("");
let divider = label::default(" | ");
let draft_n = label::default(&format!("{draft}")).style(style::gray_dim());
let draft = label::default(" Draft");
let open_n = label::default(&format!("{open}")).style(style::green());
let open = label::default(" Open");
let archived_n = label::default(&format!("{archived}")).style(style::yellow());
let archived = label::default(" Archived");
let merged_n = label::default(&format!("{merged}")).style(style::cyan());
let merged = label::default(" Merged ");
let progress =
label::default(&format!(" {} ", progress.to_string())).style(style::magenta_reversed());
let spacer = label::default("");
let context_bar = ContextBar::new(
label::group(&[context]),
label::group(&[spacer.clone()]),
label::group(&[spacer]),
label::group(&[
draft_n,
draft,
divider.clone(),
open_n,
open,
divider.clone(),
archived_n,
archived,
divider,
merged_n,
merged,
]),
label::group(&[progress]),
);
Widget::new(context_bar).height(1)
}
+2 -1
View File
@@ -129,7 +129,8 @@ impl ViewPage<Cid, Message> for ListView {
fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
let area = frame.size();
let layout = layout::default_page(area);
let shortcuts_h = 0u16;
let layout = layout::default_page(area, shortcuts_h);
app.view(
&Cid::List(self.active_component.clone()),
+7 -2
View File
@@ -18,6 +18,7 @@ pub struct FullPage {
pub struct DefaultPage {
pub component: Rect,
pub context: Rect,
pub shortcuts: Rect,
}
pub struct IssuePage {
@@ -126,10 +127,12 @@ pub fn full_page(area: Rect, shortcuts_h: u16) -> FullPage {
}
}
pub fn default_page(area: Rect) -> DefaultPage {
pub fn default_page(area: Rect, shortcuts_h: u16) -> DefaultPage {
let context_h = 1u16;
let margin_h = 1u16;
let component_h = area.height.saturating_sub(context_h);
let component_h = area
.height
.saturating_sub(context_h.saturating_add(shortcuts_h));
let layout = Layout::default()
.direction(Direction::Vertical)
@@ -138,6 +141,7 @@ pub fn default_page(area: Rect) -> DefaultPage {
[
Constraint::Length(component_h),
Constraint::Length(context_h),
Constraint::Length(shortcuts_h),
]
.as_ref(),
)
@@ -146,6 +150,7 @@ pub fn default_page(area: Rect) -> DefaultPage {
DefaultPage {
component: layout[0],
context: layout[1],
shortcuts: layout[2],
}
}