tui: Add basic framework

Signed-off-by: Erik Kundt <erik@zirkular.io>
This commit is contained in:
Erik Kundt 2022-07-04 12:44:06 +02:00 committed by Alexis Sellier
parent 5ccf38079c
commit 62867d26bd
No known key found for this signature in database
10 changed files with 699 additions and 0 deletions

141
Cargo.lock generated
View File

@ -29,6 +29,26 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
dependencies = [
"getrandom 0.2.8",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
dependencies = [
"memchr",
]
[[package]]
name = "amplify"
version = "4.0.0-beta.17"
@ -299,6 +319,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cc"
version = "1.0.78"
@ -987,6 +1013,9 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "headers"
@ -1261,6 +1290,29 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "lazy-regex"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a505da2f89befd87ab425d252795f0f285e100b43e7d22d29528df3d9a576793"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8edfc11b8f56ce85e207e62ea21557cfa09bb24a8f6b04ae181b086ff8611c22"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@ -2075,6 +2127,17 @@ dependencies = [
"radicle-cli",
]
[[package]]
name = "radicle-tui"
version = "0.1.0"
dependencies = [
"anyhow",
"lexopt",
"radicle-term",
"tui-realm-stdlib",
"tuirealm",
]
[[package]]
name = "rand"
version = "0.7.3"
@ -2170,6 +2233,8 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
@ -2458,6 +2523,12 @@ version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "smawk"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043"
[[package]]
name = "snapbox"
version = "0.4.4"
@ -2668,6 +2739,17 @@ dependencies = [
"redox_termios",
]
[[package]]
name = "textwrap"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
]
[[package]]
name = "thiserror"
version = "1.0.38"
@ -2928,6 +3010,55 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
[[package]]
name = "tui"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1"
dependencies = [
"bitflags",
"cassowary",
"termion 1.5.6",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "tui-realm-stdlib"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66f252bf8b07c6fd708ddd6349b5f044ae5b488b26929c745728d9c7e2cebfa6"
dependencies = [
"textwrap",
"tuirealm",
"unicode-width",
]
[[package]]
name = "tuirealm"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "265411b5606f400459af94fbc5aae6a7bc0e98094d08cb5868390c932be88e26"
dependencies = [
"bitflags",
"lazy-regex",
"termion 1.5.6",
"thiserror",
"tui",
"tuirealm_derive",
]
[[package]]
name = "tuirealm_derive"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0adcdaf59881626555558eae08f8a53003c8a1961723b4d7a10c51599abbc81"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.16.0"
@ -2946,6 +3077,16 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc"
[[package]]
name = "unicode-linebreak"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137"
dependencies = [
"hashbrown",
"regex",
]
[[package]]
name = "unicode-normalization"
version = "0.1.22"

View File

@ -12,6 +12,7 @@ members = [
"radicle-remote-helper",
"radicle-ssh",
"radicle-tools",
"radicle-tui",
]
default-members = [
"radicle",

21
radicle-tui/Cargo.toml Normal file
View File

@ -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"

88
radicle-tui/src/lib.rs Normal file
View File

@ -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(())
}
}

58
radicle-tui/src/main.rs Normal file
View File

@ -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);
}
}

46
radicle-tui/src/ui.rs Normal file
View File

@ -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())
}

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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: '',
},
}
}

View File

@ -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)
}
}