mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-15 16:00:38 +08:00
patch-list: Add basic application /wo operations
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user