lib: Implement flux widget for tables

This commit is contained in:
Erik Kundt
2024-02-19 23:14:31 +01:00
parent 98132c68e0
commit eaa4d07c7b
7 changed files with 506 additions and 81 deletions
+10 -6
View File
@@ -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<A> {
type Exit;
fn tick(&self);
fn handle_action(self, action: A) -> anyhow::Result<bool>;
fn handle_action(&mut self, action: A) -> Option<Self::Exit>;
}
pub struct Store<A, S>
@@ -39,11 +44,11 @@ where
impl<A, S> Store<A, S>
where
S: State<A> + Clone + Send + Sync + 'static,
S: State<A> + Clone + Send + Sync + 'static + Debug,
{
pub async fn main_loop(
self,
state: S,
mut state: S,
mut terminator: Terminator,
mut action_rx: UnboundedReceiver<A>,
mut interrupt_rx: broadcast::Receiver<Interrupted>,
@@ -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;
+16 -12
View File
@@ -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<A> Frontend<A> {
W::new(&state, self.action_tx.clone())
};
// let mut last_frame: Option<CompletedFrame> = None;
let result: anyhow::Result<Interrupted> = 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<A> Frontend<A> {
},
// 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::<Backend>(frame, frame.size(), ()))?;
};
@@ -81,7 +86,7 @@ impl<A> Frontend<A> {
fn setup_terminal() -> anyhow::Result<Terminal<Backend>> {
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<Terminal<Backend>> {
)?)
}
fn restore_terminal(terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
Ok(terminal.show_cursor()?)
fn restore_terminal(_terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
Ok(())
}
fn events() -> mpsc::UnboundedReceiver<Event> {
@@ -100,8 +105,7 @@ fn events() -> mpsc::UnboundedReceiver<Event> {
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;
}
}
+223
View File
@@ -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);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
use radicle::cob::{ObjectId, Timestamp};
use radicle::prelude::Did;
/// Format a git Oid.
pub fn oid(oid: impl Into<radicle::git::Oid>) -> 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)
}
+33
View File
@@ -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())
}
+4 -50
View File
@@ -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()
}
}
+193 -13
View File
@@ -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<S, A> {
@@ -22,7 +25,7 @@ pub trait Widget<S, A> {
}
pub trait Render<P> {
fn render<B: ratatui::backend::Backend>(&self, frame: &mut Frame, area: Rect, props: P);
fn render<B: ratatui::backend::Backend>(&mut self, frame: &mut Frame, area: Rect, props: P);
}
///
@@ -42,16 +45,16 @@ impl Shortcut {
}
}
pub struct ShortcutWidgetProps {
pub struct ShortcutsProps {
pub shortcuts: Vec<Shortcut>,
pub divider: char,
}
pub struct ShortcutWidget<A> {
pub struct Shortcuts<A> {
pub action_tx: UnboundedSender<A>,
}
impl<S, A> Widget<S, A> for ShortcutWidget<A> {
impl<S, A> Widget<S, A> for Shortcuts<A> {
fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
where
Self: Sized,
@@ -76,26 +79,27 @@ impl<S, A> Widget<S, A> for ShortcutWidget<A> {
fn handle_key_event(&mut self, _key: termion::event::Key) {}
}
impl<A> Render<ShortcutWidgetProps> for ShortcutWidget<A> {
impl<A> Render<ShortcutsProps> for Shortcuts<A> {
fn render<B: Backend>(
&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<A> Render<ShortcutWidgetProps> for ShortcutWidget<A> {
frame.render_widget(table, area);
}
}
///
///
///
pub trait ToRow<const W: usize> {
fn to_row(&self) -> [Cell; W];
}
#[derive(Debug)]
pub struct FooterProps<'a> {
pub cells: Vec<Text<'a>>,
pub widths: Vec<Constraint>,
}
#[derive(Debug)]
pub struct TableProps<'a, R: ToRow<W>, const W: usize> {
pub items: Vec<R>,
pub focus: bool,
pub widths: [Constraint; W],
pub header: [Cell<'a>; W],
pub footer: Option<FooterProps<'a>>,
}
pub struct Table<A> {
/// Sending actions to the state store
pub action_tx: UnboundedSender<A>,
/// Internal selection state
state: TableState,
}
impl<A> Table<A> {
pub fn selected(&self) -> Option<usize> {
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<S, A> Widget<S, A> for Table<A> {
fn new(state: &S, action_tx: UnboundedSender<A>) -> 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<TableProps<'a, R, W>> for Table<A>
where
R: ToRow<W> + Debug,
{
fn render<B: Backend>(
&mut self,
frame: &mut ratatui::Frame,
area: Rect,
props: TableProps<R, W>,
) {
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::<Vec<_>>();
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]);
}
}
}