From eaa4d07c7b487cf8865c1aa66c8100507f0b032c Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Mon, 19 Feb 2024 17:22:34 +0100 Subject: [PATCH] lib: Implement flux widget for tables --- src/flux/store.rs | 16 +-- src/flux/ui.rs | 28 +++--- src/flux/ui/ext.rs | 223 ++++++++++++++++++++++++++++++++++++++++++ src/flux/ui/format.rs | 27 +++++ src/flux/ui/span.rs | 33 +++++++ src/flux/ui/theme.rs | 54 +--------- src/flux/ui/widget.rs | 206 +++++++++++++++++++++++++++++++++++--- 7 files changed, 506 insertions(+), 81 deletions(-) create mode 100644 src/flux/ui/ext.rs create mode 100644 src/flux/ui/format.rs create mode 100644 src/flux/ui/span.rs diff --git a/src/flux/store.rs b/src/flux/store.rs index f365e2b1..86dfe2ae 100644 --- a/src/flux/store.rs +++ b/src/flux/store.rs @@ -1,3 +1,4 @@ +use std::fmt::Debug; use std::marker::PhantomData; use std::time::Duration; @@ -6,10 +7,14 @@ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use super::termination::{Interrupted, Terminator}; +const STORE_TICK_RATE: Duration = Duration::from_millis(1000); + pub trait State { + type Exit; + fn tick(&self); - fn handle_action(self, action: A) -> anyhow::Result; + fn handle_action(&mut self, action: A) -> Option; } pub struct Store @@ -39,11 +44,11 @@ where impl Store where - S: State + Clone + Send + Sync + 'static, + S: State + Clone + Send + Sync + 'static + Debug, { pub async fn main_loop( self, - state: S, + mut state: S, mut terminator: Terminator, mut action_rx: UnboundedReceiver, mut interrupt_rx: broadcast::Receiver, @@ -51,15 +56,14 @@ where // the initial state once self.state_tx.send(state.clone())?; - let mut ticker = tokio::time::interval(Duration::from_secs(1)); + let mut ticker = tokio::time::interval(STORE_TICK_RATE); let result = loop { tokio::select! { // Handle the actions coming from the UI // and process them to do async operations Some(action) = action_rx.recv() => { - let exit = state.clone().handle_action(action)?; - if exit { + if let Some(_exit) = state.handle_action(action) { let _ = terminator.terminate(Interrupted::UserInt); break Interrupted::UserInt; diff --git a/src/flux/ui.rs b/src/flux/ui.rs index 3127117b..8972b136 100644 --- a/src/flux/ui.rs +++ b/src/flux/ui.rs @@ -1,6 +1,10 @@ +pub mod cob; +pub mod ext; +pub mod format; pub mod layout; -pub mod widget; +pub mod span; pub mod theme; +pub mod widget; use std::io::{self}; use std::thread; @@ -49,15 +53,13 @@ impl Frontend { W::new(&state, self.action_tx.clone()) }; + // let mut last_frame: Option = None; let result: anyhow::Result = loop { tokio::select! { // Tick to terminate the select every N milliseconds _ = ticker.tick() => (), - Some(event) = events_rx.recv() => match event { - Event::Key(key) => { - root.handle_key_event(key) - } - _ => (), + Some(event) = events_rx.recv() => if let Event::Key(key) = event { + root.handle_key_event(key) }, // Handle state updates Some(state) = state_rx.recv() => { @@ -65,10 +67,13 @@ impl Frontend { }, // Catch and handle interrupt signal to gracefully shutdown Ok(interrupted) = interrupt_rx.recv() => { + let size = terminal.get_frame().size(); + // terminal.set_cursor(size.width, size.height + size.y)?; + terminal.set_cursor(size.x, size.y)?; + break Ok(interrupted); } } - terminal.draw(|frame| root.render::(frame, frame.size(), ()))?; }; @@ -81,7 +86,7 @@ impl Frontend { fn setup_terminal() -> anyhow::Result> { let stdout = io::stdout().into_raw_mode()?; let options = TerminalOptions { - viewport: Viewport::Inline(10), + viewport: Viewport::Inline(20), }; Ok(Terminal::with_options( @@ -90,8 +95,8 @@ fn setup_terminal() -> anyhow::Result> { )?) } -fn restore_terminal(terminal: &mut Terminal) -> anyhow::Result<()> { - Ok(terminal.show_cursor()?) +fn restore_terminal(_terminal: &mut Terminal) -> anyhow::Result<()> { + Ok(()) } fn events() -> mpsc::UnboundedReceiver { @@ -100,8 +105,7 @@ fn events() -> mpsc::UnboundedReceiver { thread::spawn(move || { let stdin = io::stdin(); for key in stdin.keys().flatten() { - if let Err(err) = keys_tx.send(Event::Key(key)) { - eprintln!("{err}"); + if keys_tx.send(Event::Key(key)).is_err() { return; } } diff --git a/src/flux/ui/ext.rs b/src/flux/ui/ext.rs new file mode 100644 index 00000000..ff51c185 --- /dev/null +++ b/src/flux/ui/ext.rs @@ -0,0 +1,223 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::symbols; +use ratatui::widgets::{BorderType, Borders, Widget}; + +pub struct HeaderBlock { + /// Visible borders + borders: Borders, + /// Border style + border_style: Style, + /// Type of the border. The default is plain lines but one can choose to have rounded corners + /// or doubled lines instead. + border_type: BorderType, + /// Widget style + style: Style, +} + +impl Default for HeaderBlock { + fn default() -> HeaderBlock { + HeaderBlock { + borders: Borders::NONE, + border_style: Default::default(), + border_type: BorderType::Rounded, + style: Default::default(), + } + } +} + +impl HeaderBlock { + pub fn border_style(mut self, style: Style) -> HeaderBlock { + self.border_style = style; + self + } + + pub fn style(mut self, style: Style) -> HeaderBlock { + self.style = style; + self + } + + pub fn borders(mut self, flag: Borders) -> HeaderBlock { + self.borders = flag; + self + } + + pub fn border_type(mut self, border_type: BorderType) -> HeaderBlock { + self.border_type = border_type; + self + } +} + +impl Widget for HeaderBlock { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.area() == 0 { + return; + } + buf.set_style(area, self.style); + let symbols = BorderType::to_border_set(self.border_type); + + // Sides + if self.borders.intersects(Borders::LEFT) { + for y in area.top()..area.bottom() { + buf.get_mut(area.left(), y) + .set_symbol(symbols.vertical_left) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::TOP) { + for x in area.left()..area.right() { + buf.get_mut(x, area.top()) + .set_symbol(symbols.horizontal_top) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::RIGHT) { + let x = area.right() - 1; + for y in area.top()..area.bottom() { + buf.get_mut(x, y) + .set_symbol(symbols.vertical_right) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::BOTTOM) { + let y = area.bottom() - 1; + for x in area.left()..area.right() { + buf.get_mut(x, y) + .set_symbol(symbols.horizontal_bottom) + .set_style(self.border_style); + } + } + + // Corners + if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) { + buf.get_mut(area.right() - 1, area.bottom() - 1) + .set_symbol(symbols::line::VERTICAL_LEFT) + .set_style(self.border_style); + } + if self.borders.contains(Borders::RIGHT | Borders::TOP) { + buf.get_mut(area.right() - 1, area.top()) + .set_symbol(symbols.top_right) + .set_style(self.border_style); + } + if self.borders.contains(Borders::LEFT | Borders::BOTTOM) { + buf.get_mut(area.left(), area.bottom() - 1) + .set_symbol(symbols::line::VERTICAL_RIGHT) + .set_style(self.border_style); + } + if self.borders.contains(Borders::LEFT | Borders::TOP) { + buf.get_mut(area.left(), area.top()) + .set_symbol(symbols.top_left) + .set_style(self.border_style); + } + } +} + +pub struct FooterBlock { + /// Visible borders + borders: Borders, + /// Border style + border_style: Style, + /// Type of the border. The default is plain lines but one can choose to have rounded corners + /// or doubled lines instead. + border_type: BorderType, + /// Widget style + style: Style, +} + +impl Default for FooterBlock { + fn default() -> Self { + Self { + borders: Borders::NONE, + border_style: Default::default(), + border_type: BorderType::Rounded, + style: Default::default(), + } + } +} + +impl FooterBlock { + pub fn border_style(mut self, style: Style) -> FooterBlock { + self.border_style = style; + self + } + + pub fn style(mut self, style: Style) -> FooterBlock { + self.style = style; + self + } + + pub fn borders(mut self, flag: Borders) -> FooterBlock { + self.borders = flag; + self + } + + pub fn border_type(mut self, border_type: BorderType) -> FooterBlock { + self.border_type = border_type; + self + } +} + +impl Widget for FooterBlock { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.area() == 0 { + return; + } + buf.set_style(area, self.style); + let symbols = BorderType::to_border_set(self.border_type); + + // Sides + if self.borders.intersects(Borders::LEFT) { + for y in area.top()..area.bottom() { + buf.get_mut(area.left(), y) + .set_symbol(symbols.vertical_left) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::TOP) { + for x in area.left()..area.right() { + buf.get_mut(x, area.top()) + .set_symbol(symbols.horizontal_top) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::RIGHT) { + let x = area.right() - 1; + for y in area.top()..area.bottom() { + buf.get_mut(x, y) + .set_symbol(symbols.vertical_right) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::BOTTOM) { + let y = area.bottom() - 1; + for x in area.left()..area.right() { + buf.get_mut(x, y) + .set_symbol(symbols.horizontal_bottom) + .set_style(self.border_style); + } + } + + // Corners + if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) { + buf.get_mut(area.right() - 1, area.bottom() - 1) + .set_symbol(symbols.bottom_right) + .set_style(self.border_style); + } + if self.borders.contains(Borders::RIGHT | Borders::TOP) { + buf.get_mut(area.right() - 1, area.top()) + .set_symbol(symbols::line::VERTICAL_LEFT) + .set_style(self.border_style); + } + if self.borders.contains(Borders::LEFT | Borders::BOTTOM) { + buf.get_mut(area.left(), area.bottom() - 1) + .set_symbol(symbols.bottom_left) + .set_style(self.border_style); + } + if self.borders.contains(Borders::LEFT | Borders::TOP) { + buf.get_mut(area.left(), area.top()) + .set_symbol(symbols::line::VERTICAL_RIGHT) + .set_style(self.border_style); + } + } +} diff --git a/src/flux/ui/format.rs b/src/flux/ui/format.rs new file mode 100644 index 00000000..42db0ff0 --- /dev/null +++ b/src/flux/ui/format.rs @@ -0,0 +1,27 @@ +use radicle::cob::{ObjectId, Timestamp}; +use radicle::prelude::Did; + +/// Format a git Oid. +pub fn oid(oid: impl Into) -> String { + format!("{:.7}", oid.into()) +} + +/// Format a COB id. +pub fn cob(id: &ObjectId) -> String { + format!("{:.7}", id.to_string()) +} + +/// Format a DID. +pub fn did(did: &Did) -> String { + let nid = did.as_key().to_human(); + format!("{}…{}", &nid[..7], &nid[nid.len() - 7..]) +} + +/// Format a timestamp. +pub fn timestamp(time: &Timestamp) -> String { + let fmt = timeago::Formatter::new(); + let now = Timestamp::now(); + let duration = std::time::Duration::from_secs(now.as_secs() - time.as_secs()); + + fmt.convert(duration) +} diff --git a/src/flux/ui/span.rs b/src/flux/ui/span.rs new file mode 100644 index 00000000..1fb3161f --- /dev/null +++ b/src/flux/ui/span.rs @@ -0,0 +1,33 @@ +use ratatui::style::{Style, Stylize}; +use ratatui::text::Text; + +use crate::flux::ui::theme::style; + +pub fn blank() -> Text<'static> { + Text::styled("", Style::default()) +} + +pub fn default(content: String) -> Text<'static> { + Text::styled(content, Style::default()) +} + +pub fn primary(content: String) -> Text<'static> { + default(content).style(style::cyan()) +} + +pub fn secondary(content: String) -> Text<'static> { + default(content).style(style::magenta()) +} + +pub fn positive(content: String) -> Text<'static> { + default(content).style(style::green()) +} + +pub fn badge(content: String) -> Text<'static> { + let content = &format!(" {content} "); + default(content.to_string()).magenta().reversed() +} + +pub fn timestamp(content: String) -> Text<'static> { + default(content).style(style::gray().dim()) +} diff --git a/src/flux/ui/theme.rs b/src/flux/ui/theme.rs index 34a59029..1c531056 100644 --- a/src/flux/ui/theme.rs +++ b/src/flux/ui/theme.rs @@ -1,16 +1,10 @@ pub mod style { - use ratatui::style::{Color, Modifier, Style}; + use ratatui::style::{Color, Style, Stylize}; pub fn reset() -> Style { Style::default().fg(Color::Reset) } - pub fn reset_dim() -> Style { - Style::default() - .fg(Color::Reset) - .add_modifier(Modifier::DIM) - } - pub fn red() -> Style { Style::default().fg(Color::Red) } @@ -23,14 +17,6 @@ pub mod style { Style::default().fg(Color::Yellow) } - pub fn yellow_dim() -> Style { - yellow().add_modifier(Modifier::DIM) - } - - pub fn yellow_dim_reversed() -> Style { - yellow_dim().add_modifier(Modifier::REVERSED) - } - pub fn blue() -> Style { Style::default().fg(Color::Blue) } @@ -39,12 +25,6 @@ pub mod style { Style::default().fg(Color::Magenta) } - pub fn magenta_dim() -> Style { - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::DIM) - } - pub fn cyan() -> Style { Style::default().fg(Color::Cyan) } @@ -57,45 +37,19 @@ pub mod style { Style::default().fg(Color::Gray) } - pub fn gray_dim() -> Style { - Style::default().fg(Color::Gray).add_modifier(Modifier::DIM) - } - pub fn darkgray() -> Style { Style::default().fg(Color::DarkGray) } - pub fn reversed() -> Style { - Style::default().add_modifier(Modifier::REVERSED) - } - - pub fn default_reversed() -> Style { - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::REVERSED) - } - - pub fn magenta_reversed() -> Style { - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::REVERSED) - } - - pub fn yellow_reversed() -> Style { - Style::default().fg(Color::DarkGray).bg(Color::Yellow) - } - pub fn border(focus: bool) -> Style { if focus { - gray_dim() + gray() } else { - darkgray() + gray().dim() } } pub fn highlight() -> Style { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::REVERSED) + cyan().not_dim().reversed() } } diff --git a/src/flux/ui/widget.rs b/src/flux/ui/widget.rs index 93acf653..b75f7a77 100644 --- a/src/flux/ui/widget.rs +++ b/src/flux/ui/widget.rs @@ -1,10 +1,13 @@ +use std::fmt::Debug; + use tokio::sync::mpsc::UnboundedSender; use termion::event::Key; use ratatui::prelude::*; -use ratatui::widgets::{Row, Table}; +use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, TableState}; +use super::ext::{FooterBlock, HeaderBlock}; use super::theme::style; pub trait Widget { @@ -22,7 +25,7 @@ pub trait Widget { } pub trait Render

{ - fn render(&self, frame: &mut Frame, area: Rect, props: P); + fn render(&mut self, frame: &mut Frame, area: Rect, props: P); } /// @@ -42,16 +45,16 @@ impl Shortcut { } } -pub struct ShortcutWidgetProps { +pub struct ShortcutsProps { pub shortcuts: Vec, pub divider: char, } -pub struct ShortcutWidget { +pub struct Shortcuts { pub action_tx: UnboundedSender, } -impl Widget for ShortcutWidget { +impl Widget for Shortcuts { fn new(state: &S, action_tx: UnboundedSender) -> Self where Self: Sized, @@ -76,26 +79,27 @@ impl Widget for ShortcutWidget { fn handle_key_event(&mut self, _key: termion::event::Key) {} } -impl Render for ShortcutWidget { +impl Render for Shortcuts { fn render( - &self, + &mut self, frame: &mut ratatui::Frame, area: Rect, - props: ShortcutWidgetProps, + props: ShortcutsProps, ) { + use ratatui::widgets::Table; + let mut shortcuts = props.shortcuts.iter().peekable(); let mut row = vec![]; while let Some(shortcut) = shortcuts.next() { let short = Text::from(shortcut.short.clone()).style(style::gray()); - let long = Text::from(shortcut.long.clone()).style(style::gray_dim()); + let long = Text::from(shortcut.long.clone()).style(style::gray().dim()); let spacer = Text::from(String::new()); - let divider = - Text::from(String::from(format!(" {} ", props.divider))).style(style::gray_dim()); + let divider = Text::from(format!(" {} ", props.divider)).style(style::gray().dim()); - row.push((1, short)); + row.push((shortcut.short.chars().count(), short)); row.push((1, spacer)); - row.push((shortcut.long.len(), long)); + row.push((shortcut.long.chars().count(), long)); if shortcuts.peek().is_some() { row.push((3, divider)); @@ -118,3 +122,179 @@ impl Render for ShortcutWidget { frame.render_widget(table, area); } } + +/// +/// +/// +pub trait ToRow { + fn to_row(&self) -> [Cell; W]; +} + +#[derive(Debug)] +pub struct FooterProps<'a> { + pub cells: Vec>, + pub widths: Vec, +} + +#[derive(Debug)] +pub struct TableProps<'a, R: ToRow, const W: usize> { + pub items: Vec, + pub focus: bool, + pub widths: [Constraint; W], + pub header: [Cell<'a>; W], + pub footer: Option>, +} + +pub struct Table { + /// Sending actions to the state store + pub action_tx: UnboundedSender, + /// Internal selection state + state: TableState, +} + +impl Table { + pub fn selected(&self) -> Option { + self.state.selected() + } + + pub fn prev(&mut self) { + let selected = self.selected().map(|current| current.saturating_sub(1)); + self.state.select(selected); + } + + pub fn next(&mut self, len: usize) { + let selected = self.selected().map(|current| { + if current < len.saturating_sub(1) { + current.saturating_add(1) + } else { + current + } + }); + self.state.select(selected); + } +} + +impl Widget for Table { + fn new(state: &S, action_tx: UnboundedSender) -> Self + where + Self: Sized, + { + Self { + action_tx: action_tx.clone(), + state: TableState::default().with_selected(Some(0)), + } + .move_with_state(state) + } + + fn move_with_state(self, _state: &S) -> Self + where + Self: Sized, + { + Self { ..self } + } + + fn name(&self) -> &str { + "shortcuts" + } + + fn handle_key_event(&mut self, _key: Key) {} +} + +impl<'a, A, R, const W: usize> Render> for Table +where + R: ToRow + Debug, +{ + fn render( + &mut self, + frame: &mut ratatui::Frame, + area: Rect, + props: TableProps, + ) { + let layout = if props.footer.is_some() { + Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Length(3), Constraint::Min(1)]) + .split(area) + }; + + // Render header + let block = HeaderBlock::default() + .borders(Borders::ALL) + .border_style(style::border(props.focus)) + .border_type(BorderType::Rounded); + + let header_layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Min(1)]) + .vertical_margin(1) + .horizontal_margin(1) + .split(layout[0]); + + let header = Row::new(props.header).style(style::reset().bold().dim()); + let header = ratatui::widgets::Table::default() + .column_spacing(1) + .header(header) + .widths(props.widths); + + frame.render_widget(block, layout[0]); + frame.render_widget(header, header_layout[0]); + + // Render content + let table_borders = if props.footer.is_some() { + Borders::LEFT | Borders::RIGHT + } else { + Borders::BOTTOM | Borders::LEFT | Borders::RIGHT + }; + + let rows = props + .items + .iter() + .map(|item| Row::new(item.to_row())) + .collect::>(); + let rows = ratatui::widgets::Table::default() + .rows(rows) + .widths(props.widths) + .column_spacing(1) + .block( + Block::default() + .border_style(style::border(props.focus)) + .border_type(BorderType::Rounded) + .borders(table_borders), + ) + .highlight_style(style::highlight()); + + frame.render_stateful_widget(rows, layout[1], &mut self.state.clone()); + + if let Some(footer) = props.footer { + // Render footer + let footer_block = FooterBlock::default() + .borders(Borders::ALL) + .border_style(style::border(props.focus)) + .border_type(BorderType::Rounded); + + let footer_layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Min(1)]) + .vertical_margin(1) + .horizontal_margin(1) + .split(layout[2]); + + let footer = ratatui::widgets::Table::default() + .column_spacing(1) + .header(Row::new(footer.cells)) + .widths(footer.widths); + + frame.render_widget(footer_block, layout[2]); + frame.render_widget(footer, footer_layout[0]); + } + } +}