Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Add local workaround for cursor bug in `ratatui`
Merged did:key:z6MkswQE...2C1V opened 2 years ago
  • introduces new terminal and task modules
  • adjust bins to the above
  • implement extension for termion backend with cursor bug workaround
  • switch to ratatui dep from crates.io
11 files changed +222 -130 8d9962c8 53cca887
modified Cargo.lock
@@ -1686,7 +1686,8 @@ dependencies = [
[[package]]
name = "ratatui"
version = "0.26.1"
-
source = "git+https://github.com/erak/ratatui#2d097acf380ac583bf014ac28f75750b497b7454"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bcb12f8fbf6c62614b0d56eb352af54f6a22410c3b079eb53ee93c7b97dd31d8"
dependencies = [
 "bitflags 2.4.1",
 "cassowary",
modified Cargo.toml
@@ -24,7 +24,7 @@ log = { version = "0.4.19" }
radicle = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5" }
radicle-term = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5", package = "radicle-term" }
radicle-surf = { version = "0.18.0" }
-
ratatui = { git = "https://github.com/erak/ratatui", default-features = false, features = ["all-widgets", "termion"] }
+
ratatui = { version = "0.26.1", default-features = false, features = ["all-widgets", "termion"] }
simple-logging = { version = "2.0.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
modified bin/commands/inbox/flux/select.rs
@@ -14,7 +14,7 @@ use radicle_tui as tui;

use tui::common::cob::inbox::{self};
use tui::flux::store::{State, Store};
-
use tui::flux::termination::{self, Interrupted};
+
use tui::flux::task::{self, Interrupted};
use tui::flux::ui::cob::NotificationItem;
use tui::flux::ui::Frontend;
use tui::Exit;
@@ -186,7 +186,7 @@ impl App {
    }

    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (terminator, mut interrupt_rx) = task::create_termination();
        let (store, state_rx) = Store::<Action, InboxState, Selection>::new();
        let (frontend, action_rx) = Frontend::<Action>::new();
        let state = InboxState::try_from(&self.context)?;
modified bin/commands/issue/flux/select.rs
@@ -11,7 +11,7 @@ use radicle_tui as tui;

use tui::common::cob::issue::{self, Filter};
use tui::flux::store::{State, Store};
-
use tui::flux::termination::{self, Interrupted};
+
use tui::flux::task::{self, Interrupted};
use tui::flux::ui::cob::IssueItem;
use tui::flux::ui::Frontend;
use tui::Exit;
@@ -115,7 +115,7 @@ impl App {
    }

    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (terminator, mut interrupt_rx) = task::create_termination();
        let (store, state_rx) = Store::<Action, IssuesState, Selection>::new();
        let (frontend, action_rx) = Frontend::<Action>::new();
        let state = IssuesState::try_from(&self.context)?;
modified bin/commands/patch/flux/select.rs
@@ -11,7 +11,7 @@ use radicle_tui as tui;

use tui::common::cob::patch::{self, Filter};
use tui::flux::store::{State, Store};
-
use tui::flux::termination::{self, Interrupted};
+
use tui::flux::task::{self, Interrupted};
use tui::flux::ui::cob::PatchItem;
use tui::flux::ui::Frontend;
use tui::Exit;
@@ -116,7 +116,7 @@ impl App {
    }

    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (terminator, mut interrupt_rx) = task::create_termination();
        let (store, state_rx) = Store::<Action, PatchesState, Selection>::new();
        let (frontend, action_rx) = Frontend::<Action>::new();
        let state = PatchesState::try_from(&self.context)?;
modified src/flux.rs
@@ -1,4 +1,5 @@
pub mod event;
pub mod store;
-
pub mod termination;
+
pub mod task;
+
pub mod terminal;
pub mod ui;
modified src/flux/store.rs
@@ -7,7 +7,7 @@ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};

use crate::Exit;

-
use super::termination::{Interrupted, Terminator};
+
use super::task::{Interrupted, Terminator};

const STORE_TICK_RATE: Duration = Duration::from_millis(1000);

added src/flux/task.rs
@@ -0,0 +1,66 @@
+
use std::fmt::Debug;
+

+
#[cfg(unix)]
+
use tokio::signal::unix::signal;
+
use tokio::sync::broadcast;
+

+
#[derive(Debug, Clone)]
+
pub enum Interrupted<P>
+
where
+
    P: Clone + Send + Sync + Debug,
+
{
+
    OsSignal,
+
    User { payload: Option<P> },
+
}
+

+
#[derive(Debug, Clone)]
+
pub struct Terminator<P>
+
where
+
    P: Clone + Send + Sync + Debug,
+
{
+
    interrupt_tx: broadcast::Sender<Interrupted<P>>,
+
}
+

+
impl<P> Terminator<P>
+
where
+
    P: Clone + Send + Sync + Debug + 'static,
+
{
+
    pub fn new(interrupt_tx: broadcast::Sender<Interrupted<P>>) -> Self {
+
        Self { interrupt_tx }
+
    }
+

+
    pub fn terminate(&mut self, interrupted: Interrupted<P>) -> anyhow::Result<()> {
+
        self.interrupt_tx.send(interrupted)?;
+

+
        Ok(())
+
    }
+
}
+

+
#[cfg(unix)]
+
async fn terminate_by_unix_signal<P>(mut terminator: Terminator<P>)
+
where
+
    P: Clone + Send + Sync + Debug + 'static,
+
{
+
    let mut interrupt_signal = signal(tokio::signal::unix::SignalKind::interrupt())
+
        .expect("failed to create interrupt signal stream");
+

+
    interrupt_signal.recv().await;
+

+
    terminator
+
        .terminate(Interrupted::OsSignal)
+
        .expect("failed to send interrupt signal");
+
}
+

+
// create a broadcast channel for retrieving the application kill signal
+
pub fn create_termination<P>() -> (Terminator<P>, broadcast::Receiver<Interrupted<P>>)
+
where
+
    P: Clone + Send + Sync + Debug + 'static,
+
{
+
    let (tx, rx) = broadcast::channel(1);
+
    let terminator = Terminator::new(tx);
+

+
    #[cfg(unix)]
+
    tokio::spawn(terminate_by_unix_signal(terminator.clone()));
+

+
    (terminator, rx)
+
}
added src/flux/terminal.rs
@@ -0,0 +1,134 @@
+
use std::io::{self, Write};
+
use std::thread;
+

+
use termion::input::TermRead;
+
use termion::raw::{IntoRawMode, RawTerminal};
+

+
use ratatui::prelude::*;
+

+
use tokio::sync::mpsc::{self};
+

+
use super::event::Event;
+

+
type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;
+

+
/// FIXME Remove workaround after a new `ratatui` version with
+
/// https://github.com/ratatui-org/ratatui/pull/981/ included was released.
+
pub struct TermionBackendExt<W>
+
where
+
    W: Write,
+
{
+
    cursor: Option<(u16, u16)>,
+
    inner: TermionBackend<W>,
+
}
+

+
impl<W> TermionBackendExt<W>
+
where
+
    W: Write,
+
{
+
    pub fn new(writer: W) -> Self {
+
        Self {
+
            cursor: None,
+
            inner: TermionBackend::new(writer),
+
        }
+
    }
+
}
+

+
impl<W: Write> ratatui::backend::Backend for TermionBackendExt<W> {
+
    fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
+
    where
+
        I: Iterator<Item = (u16, u16, &'a buffer::Cell)>,
+
    {
+
        self.inner.draw(content)
+
    }
+

+
    fn append_lines(&mut self, n: u16) -> io::Result<()> {
+
        self.inner.append_lines(n)
+
    }
+

+
    fn hide_cursor(&mut self) -> io::Result<()> {
+
        self.inner.hide_cursor()
+
    }
+

+
    fn show_cursor(&mut self) -> io::Result<()> {
+
        self.inner.show_cursor()
+
    }
+

+
    fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
+
        match self.inner.get_cursor() {
+
            Ok((x, y)) => {
+
                let cursor = (x.saturating_sub(1), y.saturating_sub(1));
+
                self.cursor = Some(cursor);
+
                Ok(cursor)
+
            }
+
            Err(_) => Ok(self.cursor.unwrap_or((0, 0))),
+
        }
+
    }
+

+
    fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
+
        self.cursor = Some((x, y));
+
        self.inner.set_cursor(x, y)
+
    }
+

+
    fn clear(&mut self) -> io::Result<()> {
+
        self.inner.clear()
+
    }
+

+
    fn clear_region(&mut self, clear_type: backend::ClearType) -> io::Result<()> {
+
        self.inner.clear_region(clear_type)
+
    }
+

+
    fn size(&self) -> io::Result<Rect> {
+
        self.inner.size()
+
    }
+

+
    fn window_size(&mut self) -> io::Result<backend::WindowSize> {
+
        self.inner.window_size()
+
    }
+

+
    fn flush(&mut self) -> io::Result<()> {
+
        ratatui::backend::Backend::flush(&mut self.inner)
+
    }
+
}
+

+
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),
+
    };
+

+
    Ok(Terminal::with_options(
+
        TermionBackendExt::new(stdout),
+
        options,
+
    )?)
+
}
+

+
pub fn restore(terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
+
    terminal.clear()?;
+
    Ok(())
+
}
+

+
pub fn events() -> mpsc::UnboundedReceiver<Event> {
+
    let (tx, rx) = mpsc::unbounded_channel();
+
    let events_tx = tx.clone();
+
    thread::spawn(move || {
+
        let stdin = io::stdin();
+
        for key in stdin.keys().flatten() {
+
            if events_tx.send(Event::Key(key)).is_err() {
+
                return;
+
            }
+
        }
+
    });
+

+
    let events_tx = tx.clone();
+
    if let Ok(mut signals) = signal_hook::iterator::Signals::new([libc::SIGWINCH]) {
+
        thread::spawn(move || {
+
            for _ in signals.forever() {
+
                if events_tx.send(Event::Resize).is_err() {
+
                    return;
+
                }
+
            }
+
        });
+
    }
+
    rx
+
}
deleted src/flux/termination.rs
@@ -1,66 +0,0 @@
-
use std::fmt::Debug;
-

-
#[cfg(unix)]
-
use tokio::signal::unix::signal;
-
use tokio::sync::broadcast;
-

-
#[derive(Debug, Clone)]
-
pub enum Interrupted<P>
-
where
-
    P: Clone + Send + Sync + Debug,
-
{
-
    OsSignal,
-
    User { payload: Option<P> },
-
}
-

-
#[derive(Debug, Clone)]
-
pub struct Terminator<P>
-
where
-
    P: Clone + Send + Sync + Debug,
-
{
-
    interrupt_tx: broadcast::Sender<Interrupted<P>>,
-
}
-

-
impl<P> Terminator<P>
-
where
-
    P: Clone + Send + Sync + Debug + 'static,
-
{
-
    pub fn new(interrupt_tx: broadcast::Sender<Interrupted<P>>) -> Self {
-
        Self { interrupt_tx }
-
    }
-

-
    pub fn terminate(&mut self, interrupted: Interrupted<P>) -> anyhow::Result<()> {
-
        self.interrupt_tx.send(interrupted)?;
-

-
        Ok(())
-
    }
-
}
-

-
#[cfg(unix)]
-
async fn terminate_by_unix_signal<P>(mut terminator: Terminator<P>)
-
where
-
    P: Clone + Send + Sync + Debug + 'static,
-
{
-
    let mut interrupt_signal = signal(tokio::signal::unix::SignalKind::interrupt())
-
        .expect("failed to create interrupt signal stream");
-

-
    interrupt_signal.recv().await;
-

-
    terminator
-
        .terminate(Interrupted::OsSignal)
-
        .expect("failed to send interrupt signal");
-
}
-

-
// create a broadcast channel for retrieving the application kill signal
-
pub fn create_termination<P>() -> (Terminator<P>, broadcast::Receiver<Interrupted<P>>)
-
where
-
    P: Clone + Send + Sync + Debug + 'static,
-
{
-
    let (tx, rx) = broadcast::channel(1);
-
    let terminator = Terminator::new(tx);
-

-
    #[cfg(unix)]
-
    tokio::spawn(terminate_by_unix_signal(terminator.clone()));
-

-
    (terminator, rx)
-
}
modified src/flux/ui.rs
@@ -8,27 +8,24 @@ pub mod widget;

use std::fmt::Debug;
use std::io::{self};
-
use std::thread;
use std::time::Duration;

-
// use termion::event::Event;
-
use termion::input::TermRead;
-
use termion::raw::{IntoRawMode, RawTerminal};
-

-
use ratatui::prelude::*;
+
use termion::raw::RawTerminal;

use tokio::sync::broadcast;
use tokio::sync::mpsc::{self, UnboundedReceiver};

use super::event::Event;
use super::store::State;
-
use super::termination::Interrupted;
+
use super::task::Interrupted;
+
use super::terminal;
+
use super::terminal::TermionBackendExt;
use super::ui::widget::{Render, Widget};

-
type Backend = TermionBackend<RawTerminal<io::Stdout>>;
+
type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;

const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
-
const INLINE_HEIGHT: u16 = 20;
+
const INLINE_HEIGHT: usize = 20;

pub struct Frontend<A> {
    action_tx: mpsc::UnboundedSender<A>,
@@ -51,9 +48,10 @@ impl<A> Frontend<A> {
        W: Widget<S, A> + Render<()>,
        P: Clone + Send + Sync + Debug,
    {
-
        let mut terminal = setup_terminal()?;
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
-
        let mut events_rx = events();
+

+
        let mut terminal = terminal::setup(INLINE_HEIGHT)?;
+
        let mut events_rx = terminal::events();

        let mut root = {
            let state = state_rx.recv().await.unwrap();
@@ -84,50 +82,8 @@ impl<A> Frontend<A> {
            terminal.draw(|frame| root.render::<Backend>(frame, frame.size(), ()))?;
        };

-
        restore_terminal(&mut terminal)?;
+
        terminal::restore(&mut terminal)?;

        result
    }
}
-

-
fn setup_terminal() -> anyhow::Result<Terminal<Backend>> {
-
    let stdout = io::stdout().into_raw_mode()?;
-
    let options = TerminalOptions {
-
        viewport: Viewport::Inline(INLINE_HEIGHT),
-
    };
-

-
    Ok(Terminal::with_options(
-
        TermionBackend::new(stdout),
-
        options,
-
    )?)
-
}
-

-
fn restore_terminal(terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
-
    terminal.clear()?;
-
    Ok(())
-
}
-

-
fn events() -> mpsc::UnboundedReceiver<Event> {
-
    let (tx, rx) = mpsc::unbounded_channel();
-
    let events_tx = tx.clone();
-
    thread::spawn(move || {
-
        let stdin = io::stdin();
-
        for key in stdin.keys().flatten() {
-
            if events_tx.send(Event::Key(key)).is_err() {
-
                return;
-
            }
-
        }
-
    });
-

-
    let events_tx = tx.clone();
-
    if let Ok(mut signals) = signal_hook::iterator::Signals::new([libc::SIGWINCH]) {
-
        thread::spawn(move || {
-
            for _ in signals.forever() {
-
                if events_tx.send(Event::Resize).is_err() {
-
                    return;
-
                }
-
            }
-
        });
-
    }
-
    rx
-
}