diff --git a/bin/commands/patch.rs b/bin/commands/patch.rs index 798debb4..56c86f96 100644 --- a/bin/commands/patch.rs +++ b/bin/commands/patch.rs @@ -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(); diff --git a/bin/commands/patch/list.rs b/bin/commands/patch/list.rs new file mode 100644 index 00000000..8b2841b7 --- /dev/null +++ b/bin/commands/patch/list.rs @@ -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), + Batch(Vec), +} + +pub struct App { + context: Context, + pages: PageStack, + theme: Theme, + quit: bool, + result: Option, +} + +/// 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, + 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, + message: Message, + ) -> Result> { + 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 for App { + fn init(&mut self, app: &mut Application) -> 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, frame: &mut Frame) { + if let Ok(page) = self.pages.peek_mut() { + page.view(app, frame); + } + } + + fn update(&mut self, app: &mut Application) -> Result { + 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 { + if self.quit { + return Some(Exit { + value: self.result.map(|id| format!("{id}")), + }); + } + None + } +} diff --git a/bin/commands/patch/list/event.rs b/bin/commands/patch/list/event.rs new file mode 100644 index 00000000..bf304622 --- /dev/null +++ b/bin/commands/patch/list/event.rs @@ -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 for Widget { + fn on(&mut self, event: Event) -> Option { + match event { + Event::Keyboard(KeyEvent { + code: Key::Char('q'), + .. + }) => Some(Message::Quit(None)), + _ => None, + } + } +} + +impl tuirealm::Component for Widget { + fn on(&mut self, event: Event) -> Option { + 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 for Widget { + fn on(&mut self, _event: Event) -> Option { + None + } +} + +impl tuirealm::Component for Widget { + fn on(&mut self, _event: Event) -> Option { + None + } +} + +impl tuirealm::Component for Widget { + fn on(&mut self, _event: Event) -> Option { + None + } +} + +impl tuirealm::Component for Widget { + fn on(&mut self, _event: Event) -> Option { + None + } +} + +impl tuirealm::Component for Widget { + fn on(&mut self, _event: Event) -> Option { + None + } +} diff --git a/bin/commands/patch/list/page.rs b/bin/commands/patch/list/page.rs new file mode 100644 index 00000000..af2b0dc2 --- /dev/null +++ b/bin/commands/patch/list/page.rs @@ -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>, +} + +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::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, + 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: ListCid, + ) -> Result<()> { + if let Some(shortcuts) = self.shortcuts.get(&cid) { + app.remount( + Cid::List(ListCid::Shortcuts), + shortcuts.clone().to_boxed(), + vec![], + )?; + } + Ok(()) + } +} + +impl ViewPage for ListView { + fn mount( + &self, + app: &mut Application, + 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) -> 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, + context: &Context, + theme: &Theme, + _message: Message, + ) -> Result> { + self.update_context(app, context, theme)?; + + Ok(None) + } + + fn view(&mut self, app: &mut Application, 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) -> Result<()> { + app.subscribe( + &Cid::List(ListCid::Header), + Sub::new(subscription::navigation_clause(), SubClause::Always), + )?; + + Ok(()) + } + + fn unsubscribe(&self, app: &mut Application) -> Result<()> { + app.unsubscribe( + &Cid::List(ListCid::Header), + subscription::navigation_clause(), + )?; + + Ok(()) + } +} diff --git a/bin/commands/patch/list/ui.rs b/bin/commands/patch/list/ui.rs new file mode 100644 index 00000000..6da37fbb --- /dev/null +++ b/bin/commands/patch/list/ui.rs @@ -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 { + tui::ui::tabs( + theme, + vec![label::reversable("Patches").style(style::cyan())], + ) +} + +pub fn patches( + context: &Context, + theme: &Theme, + selected: Option<(PatchId, Patch)>, +) -> Widget { + Widget::new(common::ui::PatchBrowser::new(context, theme, selected)) +} + +pub fn browse_context(context: &Context, _theme: &Theme, progress: Progress) -> Widget { + 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) +} diff --git a/bin/commands/patch/select/page.rs b/bin/commands/patch/select/page.rs index a8a3beec..f97857d4 100644 --- a/bin/commands/patch/select/page.rs +++ b/bin/commands/patch/select/page.rs @@ -129,7 +129,8 @@ impl ViewPage for ListView { fn view(&mut self, app: &mut Application, 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()), diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 6c7a096f..d0ce5994 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -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], } }