Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Add basic framework
Erik Kundt committed 3 years ago
commit 89ad69e46c11f4c1b76a5183bb15cf99a9fe3c93
8 files changed +557 -0
added radicle-tui/Cargo.toml
@@ -0,0 +1,21 @@
+
[package]
+
name = "radicle-tui"
+
license = "MIT OR Apache-2.0"
+
version = "0.1.0"
+
authors = ["Erik Kundt <erik@zirkular.io>"]
+
edition = "2021"
+
build = "../build.rs"
+

+
[[bin]]
+
name = "radicle-tui"
+
path = "src/main.rs"
+

+
[dependencies]
+
anyhow = { version = "1" }
+
lexopt = { version = "0.2" }
+
tuirealm = { version = "1.8.0", default-features = false, features = [ "with-termion" ] }
+
tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-termion" ] }
+

+
[dependencies.radicle-term]
+
version = "0"
+
path = "../radicle-term"
added radicle-tui/src/lib.rs
@@ -0,0 +1,88 @@
+
use std::hash::Hash;
+
use std::time::Duration;
+

+
use anyhow::Result;
+

+
use tuirealm::terminal::TerminalBridge;
+
use tuirealm::Frame;
+
use tuirealm::{Application, EventListenerCfg, NoUserEvent};
+

+
mod ui;
+

+
/// Trait that must be implemented by client applications in order to be run
+
/// as tui-application using tui-realm. Implementors act as models to the
+
/// tui-realm application that can be polled for new messages, updated
+
/// accordingly and rendered with new state.
+
///
+
/// Please see `examples/` for further information on how to use it.
+
pub trait Tui<Id, Message>
+
where
+
    Id: Eq + PartialEq + Clone + Hash,
+
    Message: Eq,
+
{
+
    /// Should initialize an application by mounting and activating components.
+
    fn init(&mut self, app: &mut Application<Id, Message, NoUserEvent>) -> Result<()>;
+

+
    /// Should update the current state by handling a message from the view.
+
    fn update(&mut self, app: &mut Application<Id, Message, NoUserEvent>, interval: u64);
+

+
    /// Should draw the application to a frame.
+
    fn view(&mut self, app: &mut Application<Id, Message, NoUserEvent>, frame: &mut Frame);
+

+
    /// Should return true if the application is requested to quit.
+
    fn quit(&self) -> bool;
+
}
+

+
/// A tui-window using the cross-platform Terminal helper provided
+
/// by tui-realm.
+
pub struct Window {
+
    /// Helper around `Terminal` to quickly setup and perform on terminal.
+
    pub terminal: TerminalBridge,
+
}
+

+
impl Default for Window {
+
    fn default() -> Self {
+
        Self::new()
+
    }
+
}
+

+
/// Provides a way to create and run a new tui-application.
+
impl Window {
+
    /// Creates a tui-window using the default cross-platform Terminal
+
    /// helper and panics if its creation fails.
+
    pub fn new() -> Self {
+
        Self {
+
            terminal: TerminalBridge::new().expect("Cannot create terminal bridge"),
+
        }
+
    }
+

+
    /// Runs this tui-window with the tui-application given and performs the
+
    /// following steps:
+
    /// 1. Enter alternative terminal screen
+
    /// 2. Run main loop until application should quit and with each iteration
+
    ///    - poll new events (tick or user event)
+
    ///    - update application state
+
    ///    - redraw view
+
    /// 3. Leave alternative terminal screen
+
    pub fn run<T, Id, Message>(&mut self, tui: &mut T, interval: u64) -> Result<()>
+
    where
+
        T: Tui<Id, Message>,
+
        Id: Eq + PartialEq + Clone + Hash,
+
        Message: Eq,
+
    {
+
        let mut app = Application::init(
+
            EventListenerCfg::default().default_input_listener(Duration::from_millis(interval)),
+
        );
+
        tui.init(&mut app)?;
+

+
        while !tui.quit() {
+
            tui.update(&mut app, interval);
+

+
            self.terminal.raw_mut().draw(|frame| {
+
                tui.view(&mut app, frame);
+
            })?;
+
        }
+

+
        Ok(())
+
    }
+
}
added radicle-tui/src/main.rs
@@ -0,0 +1,58 @@
+
use std::process;
+

+
use radicle_term as term;
+

+
pub const NAME: &str = "radicle-tui";
+
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+
pub const GIT_HEAD: &str = env!("GIT_HEAD");
+
pub const FPS: u64 = 60;
+

+
pub const HELP: &str = r#"
+
Usage
+

+
    radicle-tui [<option>...]
+

+
Options
+

+
    --version       Print version
+
    --help          Print help
+

+
"#;
+

+
struct Options;
+

+
impl Options {
+
    fn from_env() -> Result<Self, anyhow::Error> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_env();
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("version") => {
+
                    println!("{NAME} {VERSION}+{GIT_HEAD}");
+
                    process::exit(0);
+
                }
+
                Long("help") => {
+
                    println!("{HELP}");
+
                    process::exit(0);
+
                }
+
                _ => anyhow::bail!(arg.unexpected()),
+
            }
+
        }
+

+
        Ok(Self {})
+
    }
+
}
+

+
fn execute() -> anyhow::Result<()> {
+
    let _ = Options::from_env()?;
+
    Ok(())
+
}
+

+
fn main() {
+
    if let Err(err) = execute() {
+
        term::error(format!("Error: rad-tui: {err}"));
+
        process::exit(1);
+
    }
+
}
added radicle-tui/src/ui.rs
@@ -0,0 +1,46 @@
+
pub mod components;
+
pub mod layout;
+
pub mod theme;
+
pub mod widget;
+

+
use tuirealm::props::Attribute;
+
use tuirealm::{MockComponent, StateValue};
+

+
use components::{GlobalListener, Label, Shortcut, ShortcutBar};
+
use widget::Widget;
+

+
pub fn label(content: &str) -> Widget<Label> {
+
    // TODO: Remove when size constraints are implemented
+
    let width = content.chars().count() as u16;
+
    let label = Label::new(StateValue::String(content.to_owned()));
+

+
    Widget::new(label).height(1).width(width)
+
}
+

+
pub fn shortcut(theme: &theme::Theme, short: &str, long: &str) -> Widget<Shortcut> {
+
    let short = label(short).foreground(theme.colors.shortcut_short_fg);
+
    let divider = label(&theme.icons.whitespace.to_string());
+
    let long = label(long).foreground(theme.colors.shortcut_long_fg);
+

+
    // TODO: Remove when size constraints are implemented
+
    let short_w = short.query(Attribute::Width).unwrap().unwrap_size();
+
    let divider_w = divider.query(Attribute::Width).unwrap().unwrap_size();
+
    let long_w = long.query(Attribute::Width).unwrap().unwrap_size();
+
    let width = short_w.saturating_add(divider_w).saturating_add(long_w);
+

+
    let shortcut = Shortcut::new(short, divider, long);
+

+
    Widget::new(shortcut).height(1).width(width)
+
}
+

+
pub fn shortcut_bar(theme: &theme::Theme, shortcuts: Vec<Widget<Shortcut>>) -> Widget<ShortcutBar> {
+
    let divider = label(&format!(" {} ", theme.icons.shortcutbar_divider))
+
        .foreground(theme.colors.shortcutbar_divider_fg);
+
    let shortcut_bar = ShortcutBar::new(shortcuts, divider);
+

+
    Widget::new(shortcut_bar).height(1)
+
}
+

+
pub fn global_listener() -> Widget<GlobalListener> {
+
    Widget::new(GlobalListener::default())
+
}
added radicle-tui/src/ui/components.rs
@@ -0,0 +1,166 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Color, Props};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use super::layout;
+
use super::widget::{Widget, WidgetComponent};
+

+
#[derive(Default)]
+
pub struct GlobalListener {}
+

+
impl WidgetComponent for GlobalListener {
+
    fn view(&mut self, _properties: &Props, _frame: &mut Frame, _area: Rect) {}
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A label that can be styled using a foreground color and text modifiers.
+
/// Its height is fixed, its width depends on the length of the text it displays.
+
#[derive(Clone)]
+
pub struct Label {
+
    content: StateValue,
+
}
+

+
impl Label {
+
    pub fn new(content: StateValue) -> Self {
+
        Self { content }
+
    }
+
}
+

+
impl WidgetComponent for Label {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tui_realm_stdlib::Label;
+

+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let foreground = properties
+
            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

+
        if display {
+
            let mut label = match properties.get(Attribute::TextProps) {
+
                Some(modifiers) => Label::default()
+
                    .foreground(foreground)
+
                    .modifiers(modifiers.unwrap_text_modifiers())
+
                    .text(self.content.clone().unwrap_string()),
+
                None => Label::default()
+
                    .foreground(foreground)
+
                    .text(self.content.clone().unwrap_string()),
+
            };
+

+
            label.view(frame, area);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::One(self.content.clone())
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A shortcut that consists of a label displaying the "hotkey", a label that displays
+
/// the action and a spacer between them.
+
#[derive(Clone)]
+
pub struct Shortcut {
+
    short: Widget<Label>,
+
    divider: Widget<Label>,
+
    long: Widget<Label>,
+
}
+

+
impl Shortcut {
+
    pub fn new(short: Widget<Label>, divider: Widget<Label>, long: Widget<Label>) -> Self {
+
        Self {
+
            short,
+
            divider,
+
            long,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Shortcut {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let labels: Vec<Box<dyn MockComponent>> = vec![
+
                self.short.clone().to_boxed(),
+
                self.divider.clone().to_boxed(),
+
                self.long.clone().to_boxed(),
+
            ];
+

+
            let layout = layout::h_stack(labels, area);
+
            for (mut shortcut, area) in layout {
+
                shortcut.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A shortcut bar that displays multiple shortcuts and separates them with a
+
/// divider.
+
pub struct ShortcutBar {
+
    shortcuts: Vec<Widget<Shortcut>>,
+
    divider: Widget<Label>,
+
}
+

+
impl ShortcutBar {
+
    pub fn new(shortcuts: Vec<Widget<Shortcut>>, divider: Widget<Label>) -> Self {
+
        Self { shortcuts, divider }
+
    }
+
}
+

+
impl WidgetComponent for ShortcutBar {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let mut widgets: Vec<Box<dyn MockComponent>> = vec![];
+
            let mut shortcuts = self.shortcuts.iter_mut().peekable();
+

+
            while let Some(shortcut) = shortcuts.next() {
+
                if shortcuts.peek().is_some() {
+
                    widgets.push(shortcut.clone().to_boxed());
+
                    widgets.push(self.divider.clone().to_boxed())
+
                } else {
+
                    widgets.push(shortcut.clone().to_boxed());
+
                }
+
            }
+

+
            let layout = layout::h_stack(widgets, area);
+
            for (mut widget, area) in layout {
+
                widget.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
added radicle-tui/src/ui/layout.rs
@@ -0,0 +1,49 @@
+
use tuirealm::props::{AttrValue, Attribute};
+
use tuirealm::tui::layout::Rect;
+

+
use tuirealm::tui::layout::{Constraint, Direction, Layout};
+
use tuirealm::MockComponent;
+

+
pub fn v_stack(
+
    widgets: Vec<Box<dyn MockComponent>>,
+
    area: Rect,
+
) -> Vec<(Box<dyn MockComponent>, Rect)> {
+
    let constraints = widgets
+
        .iter()
+
        .map(|w| {
+
            Constraint::Length(
+
                w.query(Attribute::Height)
+
                    .unwrap_or(AttrValue::Size(0))
+
                    .unwrap_size(),
+
            )
+
        })
+
        .collect::<Vec<_>>();
+
    let layout = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints(constraints)
+
        .split(area);
+

+
    widgets.into_iter().zip(layout.into_iter()).collect()
+
}
+

+
pub fn h_stack(
+
    widgets: Vec<Box<dyn MockComponent>>,
+
    area: Rect,
+
) -> Vec<(Box<dyn MockComponent>, Rect)> {
+
    let constraints = widgets
+
        .iter()
+
        .map(|w| {
+
            Constraint::Length(
+
                w.query(Attribute::Width)
+
                    .unwrap_or(AttrValue::Size(0))
+
                    .unwrap_size(),
+
            )
+
        })
+
        .collect::<Vec<_>>();
+
    let layout = Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints(constraints)
+
        .split(area);
+

+
    widgets.into_iter().zip(layout.into_iter()).collect()
+
}
added radicle-tui/src/ui/theme.rs
@@ -0,0 +1,49 @@
+
use tuirealm::props::Color;
+

+
#[derive(Debug)]
+
pub struct Colors {
+
    pub shortcut_short_fg: Color,
+
    pub shortcut_long_fg: Color,
+
    pub shortcutbar_divider_fg: Color,
+
}
+

+
#[derive(Debug)]
+
pub struct Icons {
+
    pub whitespace: char,
+
    pub shortcutbar_divider: char,
+
}
+

+
/// The Radicle TUI theme. Can be defined in a JSON config file. e.g.:
+
///
+
/// {
+
///     "name": "Radicle Dark",
+
///     "colors": {
+
///         "foreground": "#ffffff",
+
///         "highlightedBackground": "#000000",
+
///     },
+
///     "icons": {
+
///         "workspaces.divider": "|",
+
///         "shortcuts.divider: "∙",
+
///     }
+
/// }
+
#[derive(Debug)]
+
pub struct Theme {
+
    pub name: String,
+
    pub colors: Colors,
+
    pub icons: Icons,
+
}
+

+
pub fn default_dark() -> Theme {
+
    Theme {
+
        name: String::from("Radicle Dark"),
+
        colors: Colors {
+
            shortcut_short_fg: Color::Rgb(100, 100, 100),
+
            shortcut_long_fg: Color::Rgb(70, 70, 70),
+
            shortcutbar_divider_fg: Color::Rgb(70, 70, 70),
+
        },
+
        icons: Icons {
+
            whitespace: ' ',
+
            shortcutbar_divider: '∙',
+
        },
+
    }
+
}
added radicle-tui/src/ui/widget.rs
@@ -0,0 +1,80 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Color, Props};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{Frame, MockComponent, State};
+

+
pub type BoxedWidget<T> = Box<Widget<T>>;
+

+
pub trait WidgetComponent {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect);
+

+
    fn state(&self) -> State;
+

+
    fn perform(&mut self, cmd: Cmd) -> CmdResult;
+
}
+

+
#[derive(Clone)]
+
pub struct Widget<T: WidgetComponent> {
+
    component: T,
+
    properties: Props,
+
}
+

+
impl<T: WidgetComponent> Widget<T> {
+
    pub fn new(component: T) -> Self {
+
        Widget {
+
            component,
+
            properties: Props::default(),
+
        }
+
    }
+

+
    pub fn foreground(mut self, fg: Color) -> Self {
+
        self.attr(Attribute::Foreground, AttrValue::Color(fg));
+
        self
+
    }
+

+
    pub fn highlight(mut self, fg: Color) -> Self {
+
        self.attr(Attribute::HighlightedColor, AttrValue::Color(fg));
+
        self
+
    }
+

+
    pub fn background(mut self, bg: Color) -> Self {
+
        self.attr(Attribute::Background, AttrValue::Color(bg));
+
        self
+
    }
+

+
    pub fn height(mut self, h: u16) -> Self {
+
        self.attr(Attribute::Height, AttrValue::Size(h));
+
        self
+
    }
+

+
    pub fn width(mut self, w: u16) -> Self {
+
        self.attr(Attribute::Width, AttrValue::Size(w));
+
        self
+
    }
+

+
    pub fn to_boxed(self) -> Box<Self> {
+
        Box::new(self)
+
    }
+
}
+

+
impl<T: WidgetComponent> MockComponent for Widget<T> {
+
    fn view(&mut self, frame: &mut Frame, area: Rect) {
+
        self.component.view(&self.properties, frame, area)
+
    }
+

+
    fn query(&self, attr: Attribute) -> Option<AttrValue> {
+
        self.properties.get(attr)
+
    }
+

+
    fn attr(&mut self, attr: Attribute, value: AttrValue) {
+
        self.properties.set(attr, value)
+
    }
+

+
    fn state(&self) -> State {
+
        self.component.state()
+
    }
+

+
    fn perform(&mut self, cmd: Cmd) -> CmdResult {
+
        self.component.perform(cmd)
+
    }
+
}