Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Various improvements for 0.3.0
Merged did:key:z6MkswQE...2C1V opened 1 year ago
18 files changed +186 -63 fe3580fb ff151fae
modified Cargo.toml
@@ -1,5 +1,8 @@
[package]
name = "radicle-tui"
+
description = "Radicle terminal user interfaces"
+
repository = "https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z39mP9rQAaGmERfUMPULfPUi473tY"
+
homepage = "https://radicle.xyz"
license = "MIT OR Apache-2.0"
version = "0.3.0"
authors = ["Erik Kundt <erik@zirkular.io>"]
modified README.md
@@ -1,8 +1,16 @@
# radicle-tui

-
_Radicle terminal user interfaces_
+
![alt text](demo.gif "Demo")

-
## Installation
+
`radicle-tui` provides various terminal user interfaces for interacting with the [Radicle](https://radicle.xyz) code forge and exposes the application framework they were built with.
+

+
## Binaries
+

+
This crate provides a single binary called `rad-tui`, which contains all user interfaces. Specific interfaces can be run by the appropriate command, e.g. `rad-tui patch select` shows a patch selector.
+

+
The interfaces are designed to be modular and to integrate well with the existing Radicle CLI. Right now, they are meant to be called from other programs that will collect and process their output.
+

+
### Installation

**Requirements**

@@ -10,87 +18,128 @@ _Radicle terminal user interfaces_
- Git 2.34 or later
- OpenSSH 9.1 or later with `ssh-agent`

-
### 📦 From source
+
#### From source

> Requires the Rust toolchain.

You can install the binary from source, by running the following
commands from inside this repository:

-
    cargo install --path . --force --locked
+
```
+
cargo install --path . --force --locked
+
```

Or directly from our seed node:

-
    cargo install --force --locked --git https://seed.radicle.xyz/z39mP9rQAaGmERfUMPULfPUi473tY.git
+
```
+
cargo install --force --locked --git https://seed.radicle.xyz/z39mP9rQAaGmERfUMPULfPUi473tY.git
+
```

This will install `rad-tui`. You can execute it by running `rad-tui`. All available commands can be shown by running `rad-tui --help`.

-
## Interfaces
-

-
The Radicle terminal interfaces are designed to be modular and to integrate well with the existing Radicle CLI. Right now, they are meant to be called from other programs that will collect and process their output.
-

### Usage

-
#### Patches
-

-
Select a patch and an operation:
-

-
    $ rad-tui patch select
-
    { "operation": "show", "ids": ["546443226b300484a97a2b2d7c7000af6e8169ba"], args:[] }
-

+
Select a patch, an issue or a notification and an operation:

+
```
+
$ rad-tui <patch | issue | inbox> select
+
```
Same as above:

-
    $ rad-tui patch select --mode operation
-
    { "operation": "show", "ids": ["546443226b300484a97a2b2d7c7000af6e8169ba"], args:[] }
+
```
+
$ rad-tui <patch | issue | inbox> select --mode operation
+
```

-
Select a patch only and return its id:
+
Select a patch, an issue or a notification only and return its id:

-
    $ rad-tui patch select --mode id
-
    { "operation": "null", "ids": ["546443226b300484a97a2b2d7c7000af6e8169ba"], args:[] }
+
```
+
$ rad-tui <patch | issue | inbox> select --mode id
+
```

-
#### Issues
+
### Output

-
Select an issue and an operation:
+
All interfaces return a JSON object that reflects the choices made by the user, e.g.: 

-
    $ rad-tui issue select
-
    { "operation": "show", "ids": ["12f019e3f9f52d88b470a3d7fb922452ebaca39e"], args:[] }
+
```
+
{ "operation": "show", "ids": ["546443226b300484a97a2b2d7c7000af6e8169ba"], args:[] }
+
```

+
## Framework

-
Same as above:
-

-
    $ rad-tui issue select --mode operation
-
    { "operation": "show", "ids": ["12f019e3f9f52d88b470a3d7fb922452ebaca39e"], args:[] }
+
The library portion of this crate is a framework that is the foundation for the interfaces mentioned above. The framework is built on top of [ratatui](https://ratatui.rs) and was inspired by the Flux application pattern. It took some ideas from [cursive](https://github.com/gyscos/cursive) and [rust-chat-server](https://github.com/Yengas/rust-chat-server).

+
### Example

-
Select an issue only and return its id:
+
```rust
+
use anyhow::Result;
+
use termion::event::Key;

-
    $ rad-tui issue select --mode id
-
    { "operation": "null", "ids": ["12f019e3f9f52d88b470a3d7fb922452ebaca39e"], args:[] }
+
use radicle_tui as tui;

+
use tui::store;
+
use tui::ui::widget::text::{Paragraph, ParagraphProps};
+
use tui::ui::widget::{Properties, Widget};
+
use tui::{Channel, Exit};

-
#### Inbox
+
#[derive(Clone, Debug)]
+
struct State {
+
    welcome: String,
+
}

-
Select a notification and an operation:
+
enum Message {
+
    Quit,
+
    ReverseWelcome,
+
}

-
    $ rad-tui inbox select
-
    { "operation": "show", "ids": ["1"], args:[] }
+
impl store::State<()> for State {
+
    type Message = Message;

+
    fn update(&mut self, message: Self::Message) -> Option<tui::Exit<()>> {
+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::ReverseWelcome => {
+
                self.welcome = self.welcome.chars().rev().collect::<String>();
+
                None
+
            }
+
        }
+
    }

-
Same as above:
+
    fn tick(&self) {}
+
}

-
    $ rad-tui inbox select --mode operation
-
    { "operation": "show", "ids": ["1"], args:[] }
+
#[tokio::main]
+
pub async fn main() -> Result<()> {
+
    let channel = Channel::default();
+
    let state = State {
+
        welcome: "Hello TUI".to_string(),
+
    };

+
    let welcome = Paragraph::new(&state, channel.tx.clone())
+
        .on_update(|state| {
+
            ParagraphProps::default()
+
                .text(&state.welcome.clone().into())
+
                .to_boxed()
+
        })
+
        .on_event(|paragraph, key| {
+
            paragraph
+
                .downcast_mut::<Paragraph<'_, State, Message>>()
+
                .and_then(|paragraph| match key {
+
                    Key::Char('r') => paragraph.send(Message::ReverseWelcome).ok(),
+
                    Key::Char('q') => paragraph.send(Message::Quit).ok(),
+
                    _ => None,
+
                });
+
        })
+
        .to_boxed();

-
Select a notification only and return its id:
+
    tui::run(channel, state, welcome).await?;

-
    $ rad-tui inbox select --mode id
-
    { "operation": "null", "ids": ["1"], args:[] }
+
    Ok(())
+
}
+
```


## License

-
Radicle is distributed under the terms of both the MIT license and the Apache License (Version 2.0).
+
`radicle-tui` is distributed under the terms of both the MIT license and the Apache License (Version 2.0).

See [LICENSE-APACHE](LICENSE-APACHE) and [LICENSE-MIT](LICENSE-MIT) for details.
modified bin/commands/inbox/select.rs
@@ -298,6 +298,6 @@ impl App {
                    .to_boxed()
            });

-
        tui::run(channel, state, window).await
+
        tui::run(channel, state, window.to_boxed()).await
    }
}
modified bin/commands/inbox/select/ui.rs
@@ -147,7 +147,7 @@ impl<'a: 'static> Widget for Browser<'a> {
                )
                .content(Box::<Table<State, Message, NotificationItem, 9>>::new(
                    Table::new(state, tx.clone())
-
                        .on_event(|table| {
+
                        .on_event(|table, _| {
                            table
                                .downcast_mut::<Table<State, Message, NotificationItem, 9>>()
                                .and_then(|table| {
@@ -422,7 +422,7 @@ impl Widget for Search {
            base: WidgetBase::new(tx.clone()),
            _props: SearchProps {},
            input: TextField::new(state, tx.clone())
-
                .on_event(|widget| {
+
                .on_event(|widget, _| {
                    widget
                        .downcast_mut::<TextField<State, Message>>()
                        .and_then(|field| {
@@ -536,7 +536,7 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                )
                .content(
                    Paragraph::new(state, tx.clone())
-
                        .on_event(|paragraph| {
+
                        .on_event(|paragraph, _| {
                            paragraph
                                .downcast_mut::<Paragraph<'_, State, Message>>()
                                .and_then(|paragraph| {
modified bin/commands/issue/select.rs
@@ -217,6 +217,6 @@ impl App {
                    .to_boxed()
            });

-
        tui::run(channel, state, window).await
+
        tui::run(channel, state, window.to_boxed()).await
    }
}
modified bin/commands/issue/select/ui.rs
@@ -161,7 +161,7 @@ impl<'a: 'static> Widget for Browser<'a> {
                )
                .content(Box::<Table<State, Message, IssueItem, 8>>::new(
                    Table::new(state, tx.clone())
-
                        .on_event(|table| {
+
                        .on_event(|table, _| {
                            table
                                .downcast_mut::<Table<State, Message, IssueItem, 8>>()
                                .and_then(|table| {
@@ -441,7 +441,7 @@ impl Widget for Search {
            base: WidgetBase::new(tx.clone()),
            _props: SearchProps {},
            input: TextField::new(state, tx.clone())
-
                .on_event(|widget| {
+
                .on_event(|widget, _| {
                    widget
                        .downcast_mut::<TextField<State, Message>>()
                        .and_then(|field| {
@@ -552,7 +552,7 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                )
                .content(
                    Paragraph::new(state, tx.clone())
-
                        .on_event(|paragraph| {
+
                        .on_event(|paragraph, _| {
                            paragraph
                                .downcast_mut::<Paragraph<'_, State, Message>>()
                                .and_then(|paragraph| {
modified bin/commands/patch/select.rs
@@ -218,6 +218,6 @@ impl App {
                    .to_boxed()
            });

-
        tui::run(channel, state, window).await
+
        tui::run(channel, state, window.to_boxed()).await
    }
}
modified bin/commands/patch/select/ui.rs
@@ -161,7 +161,7 @@ impl<'a: 'static> Widget for Browser<'a> {
                )
                .content(Box::<Table<State, Message, PatchItem, 9>>::new(
                    Table::new(state, tx.clone())
-
                        .on_event(|table| {
+
                        .on_event(|table, _| {
                            table
                                .downcast_mut::<Table<State, Message, PatchItem, 9>>()
                                .and_then(|table| {
@@ -464,7 +464,7 @@ impl Widget for Search {
            base: WidgetBase::new(tx.clone()),
            _props: SearchProps {},
            input: TextField::new(state, tx.clone())
-
                .on_event(|widget| {
+
                .on_event(|widget, _| {
                    widget
                        .downcast_mut::<TextField<State, Message>>()
                        .and_then(|field| {
@@ -575,7 +575,7 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                )
                .content(
                    Paragraph::new(state, tx.clone())
-
                        .on_event(|paragraph| {
+
                        .on_event(|paragraph, _| {
                            paragraph
                                .downcast_mut::<Paragraph<'_, State, Message>>()
                                .and_then(|paragraph| {
added demo.gif
added examples/hello.rs
@@ -0,0 +1,64 @@
+
use anyhow::Result;
+

+
use radicle_tui as tui;
+

+
use termion::event::Key;
+
use tui::store;
+
use tui::ui::widget::text::{Paragraph, ParagraphProps};
+
use tui::ui::widget::{Properties, Widget};
+
use tui::{Channel, Exit};
+

+
#[derive(Clone, Debug)]
+
struct State {
+
    welcome: String,
+
}
+

+
enum Message {
+
    Quit,
+
    ReverseWelcome,
+
}
+

+
impl store::State<()> for State {
+
    type Message = Message;
+

+
    fn update(&mut self, message: Self::Message) -> Option<tui::Exit<()>> {
+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::ReverseWelcome => {
+
                self.welcome = self.welcome.chars().rev().collect::<String>();
+
                None
+
            }
+
        }
+
    }
+

+
    fn tick(&self) {}
+
}
+

+
#[tokio::main]
+
pub async fn main() -> Result<()> {
+
    let channel = Channel::default();
+
    let state = State {
+
        welcome: "Hello TUI".to_string(),
+
    };
+

+
    let welcome = Paragraph::new(&state, channel.tx.clone())
+
        .on_update(|state| {
+
            ParagraphProps::default()
+
                .text(&state.welcome.clone().into())
+
                .to_boxed()
+
        })
+
        .on_event(|paragraph, key| {
+
            paragraph
+
                .downcast_mut::<Paragraph<'_, State, Message>>()
+
                .and_then(|paragraph| match key {
+
                    Key::Char('r') => paragraph.send(Message::ReverseWelcome).ok(),
+
                    Key::Char('q') => paragraph.send(Message::Quit).ok(),
+
                    _ => None,
+
                });
+
        })
+
        .to_boxed();
+

+
    tui::run(channel, state, welcome).await?;
+

+
    Ok(())
+
}
modified src/lib.rs
@@ -120,7 +120,7 @@ impl<A> Default for Channel<A> {
/// Initialize a `Store` with the `State` given and a `Frontend` with the `Widget` given,
/// and run their main loops concurrently. Connect them to the `Channel` and also to
/// an interrupt broadcast channel also initialized in this function.
-
pub async fn run<S, M, W, P>(channel: Channel<M>, state: S, root: W) -> Result<Option<P>>
+
pub async fn run<S, M, W, P>(channel: Channel<M>, state: S, root: Box<W>) -> Result<Option<P>>
where
    S: State<P, Message = M> + Clone + Debug + Send + Sync + 'static,
    W: Widget<State = S, Message = M>,
@@ -133,7 +133,7 @@ where

    tokio::try_join!(
        store.main_loop(state, terminator, channel.rx, interrupt_rx.resubscribe()),
-
        frontend.main_loop(Some(root), state_rx, interrupt_rx.resubscribe()),
+
        frontend.main_loop(Some(*root), state_rx, interrupt_rx.resubscribe()),
    )?;

    if let Ok(reason) = interrupt_rx.recv().await {
modified src/terminal.rs
@@ -95,7 +95,8 @@ impl<W: Write> ratatui::backend::Backend for TermionBackendExt<W> {
pub fn setup(height: usize) -> anyhow::Result<Terminal<Backend>> {
    let stdout = io::stdout().into_raw_mode()?;
    let options = TerminalOptions {
-
        viewport: Viewport::Inline(height as u16),
+
        // viewport: Viewport::Inline(height as u16),
+
        viewport: Viewport::Fullscreen,
    };

    Ok(Terminal::with_options(
modified src/ui/widget.rs
@@ -17,7 +17,7 @@ use ratatui::widgets::Cell;
pub type BoxedWidget<S, M> = Box<dyn Widget<State = S, Message = M>>;

pub type UpdateCallback<S> = fn(&S) -> Box<dyn Any>;
-
pub type EventCallback = fn(&mut dyn Any);
+
pub type EventCallback = fn(&mut dyn Any, Key);

/// A `WidgetBase` provides common functionality to a `Widget`. It's used to store
/// event and update callbacks as well sending messages to the UI's message channel.
modified src/ui/widget/container.rs
@@ -554,7 +554,7 @@ where
        }

        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self);
+
            (on_event)(self, key);
        }
    }

modified src/ui/widget/input.rs
@@ -172,7 +172,7 @@ where
        }

        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self);
+
            (on_event)(self, key);
        }
    }

modified src/ui/widget/list.rs
@@ -198,7 +198,7 @@ where
        }

        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self);
+
            (on_event)(self, key);
        }
    }

modified src/ui/widget/text.rs
@@ -186,7 +186,7 @@ where
        }

        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self);
+
            (on_event)(self, key);
        }
    }

modified src/ui/widget/window.rs
@@ -57,6 +57,8 @@ where
impl<'a, S, M, Id> Widget for Window<S, M, Id>
where
    'a: 'static,
+
    S: 'static,
+
    M: 'static,
    Id: Clone + Hash + Eq + PartialEq + 'static,
{
    type Message = M;
@@ -83,6 +85,10 @@ where
        if let Some(page) = page {
            page.handle_event(key);
        }
+

+
        if let Some(on_event) = self.base.on_event {
+
            (on_event)(self, key);
+
        }
    }

    fn update(&mut self, state: &S) {