Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
lib: Add support for async workers
Erik Kundt committed 4 months ago
commit d962ec4b42b2467374d305ac6706fbe330918153
parent 954d7bf
19 files changed +1349 -1088
modified bin/commands/issue/list.rs
@@ -6,12 +6,12 @@ use std::str::FromStr;

use anyhow::{bail, Result};

-
use ratatui::Viewport;
use termion::event::Key;

use ratatui::layout::Constraint;
use ratatui::style::Stylize;
use ratatui::text::Text;
+
use ratatui::Viewport;

use radicle::cob::thread::CommentId;
use radicle::git::Oid;
@@ -22,12 +22,13 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::store;
+
use tui::task::EmptyProcessors;
use tui::ui::rm::widget::container::{
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
};
-
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
use tui::ui::rm::widget::list::{Tree, TreeProps};
+
use tui::ui::rm::widget::text::{TextView, TextViewProps, TextViewState};
use tui::ui::rm::widget::window::{
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
};
@@ -370,7 +371,14 @@ impl App {
                    .into()
            });

-
        tui::rm(state, window, Viewport::Inline(20), channel).await
+
        tui::rm(
+
            state,
+
            window,
+
            Viewport::Inline(20),
+
            channel,
+
            EmptyProcessors::new(),
+
        )
+
        .await
    }
}

modified bin/commands/issue/list/ui.rs
@@ -4,7 +4,7 @@ use std::vec;

use radicle::issue::{self, CloseReason};
use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
+
use tokio::sync::broadcast;

use termion::event::Key;

@@ -18,8 +18,8 @@ use tui::ui::rm::widget;
use tui::ui::rm::widget::container::{
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
-
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::text::{TextField, TextFieldProps};
use tui::ui::rm::widget::ViewProps;
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
use tui::ui::span;
@@ -119,7 +119,7 @@ pub struct Browser {
}

impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
    pub fn new(tx: broadcast::Sender<Message>) -> Self {
        Self {
            issues: Container::default()
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
modified bin/commands/patch/list.rs
@@ -7,31 +7,31 @@ use std::str::FromStr;

use anyhow::Result;

-
use ratatui::Viewport;
use termion::event::Key;

-
use radicle_tui as tui;
-

use ratatui::layout::Constraint;
use ratatui::style::Stylize;
use ratatui::text::Text;
+
use ratatui::Viewport;
+

+
use radicle::patch::PatchId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use radicle_tui as tui;

use tui::store;
+
use tui::task::EmptyProcessors;
use tui::ui::rm::widget::container::{Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::text::{TextView, TextViewProps, TextViewState};
use tui::ui::rm::widget::window::{
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
};
use tui::ui::rm::widget::{ToWidget, Widget};
use tui::ui::Column;
use tui::ui::{span, BufferedValue};
-

use tui::{BoxedAny, Channel, Exit, PageStack};

-
use radicle::patch::PatchId;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

use self::rmui::{Browser, BrowserProps};
use super::common::{Mode, PatchOperation};

@@ -65,7 +65,7 @@ impl App {
            let channel = Channel::default();
            let state = imui::App::try_from(&self.context)?;

-
            tui::im(state, viewport, channel).await
+
            tui::im(state, viewport, channel, EmptyProcessors::new()).await
        } else {
            let channel = Channel::default();
            let tx = channel.tx.clone();
@@ -81,7 +81,7 @@ impl App {
                        .into()
                });

-
            tui::rm(state, window, viewport, channel).await
+
            tui::rm(state, window, viewport, channel, EmptyProcessors::new()).await
        }
    }
}
modified bin/commands/patch/list/imui.rs
@@ -20,7 +20,8 @@ use tui::{store, Exit};

use crate::cob::patch;
use crate::tui_patch::common::{Mode, PatchOperation};
-
use crate::ui::items::{Filter, PatchItem, PatchItemFilter};
+
use crate::ui::items::filter::Filter;
+
use crate::ui::items::{PatchItem, PatchItemFilter};

use super::{Context, Selection};

modified bin/commands/patch/list/rmui.rs
@@ -3,7 +3,7 @@ use std::str::FromStr;
use std::vec;

use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
+
use tokio::sync::broadcast;

use termion::event::Key;

@@ -20,8 +20,8 @@ use tui::ui::rm::widget;
use tui::ui::rm::widget::container::{
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
-
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::text::{TextField, TextFieldProps};
use tui::ui::rm::widget::ViewProps;
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
use tui::ui::span;
@@ -120,7 +120,7 @@ pub struct Browser {
}

impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
    pub fn new(tx: broadcast::Sender<Message>) -> Self {
        Self {
            patches: Container::default()
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
modified bin/commands/patch/review.rs
@@ -23,6 +23,7 @@ use radicle::Storage;
use radicle_tui as tui;

use tui::store;
+
use tui::task::EmptyProcessors;
use tui::ui::im::widget::{PanesState, TableState, TextViewState, Window};
use tui::ui::im::{Borders, Context, Show, Ui};
use tui::ui::span;
@@ -116,7 +117,7 @@ impl Tui {
            .unwrap_or(default);

        let app = App::new(self.storage, self.review, self.hunks, state, self.mode)?;
-
        let response = tui::im(app, viewport, channel).await?;
+
        let response = tui::im(app, viewport, channel, EmptyProcessors::new()).await?;

        if let Some(response) = response.as_ref() {
            store.write(&state::to_json(&response.state)?)?;
@@ -236,7 +237,7 @@ impl AppState {
    }
}

-
#[derive(Clone)]
+
#[derive(Clone, Debug)]
pub struct App<'a> {
    /// All hunks.
    hunks: Arc<Mutex<Vec<StatefulHunkItem<'a>>>>,
modified bin/ui/rm.rs
@@ -17,7 +17,7 @@ use tui::ui::{layout, span, BufferedValue};
use super::format;
use super::items::IssueItem;

-
use crate::ui::items::Filter;
+
use crate::ui::items::filter::Filter;

/// A `BrowserState` represents the internal state of a browser widget.
/// A browser widget would consist of 2 child widgets: a list of items and a
modified examples/basic_rmui.rs
@@ -1,15 +1,16 @@
use anyhow::Result;

-
use ratatui::Viewport;
use termion::event::Key;

use ratatui::layout::Constraint;
+
use ratatui::Viewport;

use radicle_tui as tui;

use tui::store;
+
use tui::task::EmptyProcessors;
use tui::ui::rm::widget::container::{Container, Header, HeaderProps};
-
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::text::{TextView, TextViewProps, TextViewState};
use tui::ui::rm::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
use tui::ui::rm::widget::ToWidget;
use tui::ui::Column;
@@ -17,11 +18,11 @@ use tui::{BoxedAny, Channel, Exit};

const CONTENT: &str = r#"
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
-
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud 
-
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure 
+
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
+
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure
dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

-
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt 
+
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
"#;

@@ -108,7 +109,14 @@ pub async fn main() -> Result<()> {
        })
        .on_update(|_| WindowProps::default().current_page(0).to_boxed_any().into());

-
    tui::rm(app, window, Viewport::default(), channel).await?;
+
    tui::rm(
+
        app,
+
        window,
+
        Viewport::default(),
+
        channel,
+
        EmptyProcessors::new(),
+
    )
+
    .await?;

    Ok(())
}
modified examples/hello.rs
@@ -8,6 +8,7 @@ use ratatui::{Frame, Viewport};
use radicle_tui as tui;

use tui::store;
+
use tui::task::EmptyProcessors;
use tui::ui::im::widget::Window;
use tui::ui::im::Show;
use tui::ui::im::{Borders, Context};
@@ -73,7 +74,13 @@ pub async fn main() -> Result<()> {
        alien: ALIEN.to_string(),
    };

-
    tui::im(app, Viewport::default(), Channel::default()).await?;
+
    tui::im(
+
        app,
+
        Viewport::default(),
+
        Channel::default(),
+
        EmptyProcessors::new(),
+
    )
+
    .await?;

    Ok(())
}
modified examples/hello_rrmui.rs
@@ -9,22 +9,23 @@ use ratatui::text::Text;
use radicle_tui as tui;

use tui::store;
-
use tui::ui::rm::widget::input::{TextArea, TextAreaProps};
+
use tui::task::EmptyProcessors;
+
use tui::ui::rm::widget::text::{TextArea, TextAreaProps};
use tui::ui::rm::widget::ToWidget;
use tui::{BoxedAny, Channel, Exit};

const ALIEN: &str = r#"
-
     ///             ///    ,---------------------------------. 
+
     ///             ///    ,---------------------------------.
     ///             ///    | Hey there, press (q) to quit... |
-
        //         //       '---------------------------------'  
-
        //,,,///,,,//      .. 
-
     ///////////////////  .  
-
  //////@@@@@//////@@@@@///  
-
  //////@@###//////@@###///  
+
        //         //       '---------------------------------'
+
        //,,,///,,,//      ..
+
     ///////////////////  .
+
  //////@@@@@//////@@@@@///
+
  //////@@###//////@@###///
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-
     ,,,  ///   ///  ,,,     
-
     ,,,  ///   ///  ,,,     
-
          ///   ///          
+
     ,,,  ///   ///  ,,,
+
     ,,,  ///   ///  ,,,
+
          ///   ///
        /////   /////
"#;

@@ -70,7 +71,14 @@ pub async fn main() -> Result<()> {
                .into()
        });

-
    tui::rm(app, scene, Viewport::default(), channel).await?;
+
    tui::rm(
+
        app,
+
        scene,
+
        Viewport::default(),
+
        channel,
+
        EmptyProcessors::new(),
+
    )
+
    .await?;

    Ok(())
}
modified examples/selection.rs
@@ -12,6 +12,7 @@ use ratatui::{Frame, Viewport};

use radicle_tui as tui;

+
use tui::task::EmptyProcessors;
use tui::ui::im::widget::Window;
use tui::ui::im::{Borders, Context};
use tui::ui::{Column, ToRow};
@@ -144,7 +145,14 @@ pub async fn main() -> Result<()> {
        selector: TableState::new(Some(0)),
    };

-
    if let Some(exit) = tui::im(app, Viewport::Inline(12), Channel::default()).await? {
+
    if let Some(exit) = tui::im(
+
        app,
+
        Viewport::Inline(12),
+
        Channel::default(),
+
        EmptyProcessors::new(),
+
    )
+
    .await?
+
    {
        println!("{exit}");
    } else {
        anyhow::bail!("No selection");
modified src/lib.rs
@@ -7,19 +7,25 @@ pub mod ui;
use std::any::Any;
use std::fmt::Debug;

-
use ratatui::Viewport;
-
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
+
use anyhow::Result;
+

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

+
use tokio::sync::broadcast;
+
use tokio::sync::mpsc::unbounded_channel;

use serde::ser::{Serialize, SerializeStruct, Serializer};

-
use anyhow::Result;
+
use ratatui::Viewport;

use store::Update;
-
use task::Interrupted;
use ui::im;
use ui::im::Show;
use ui::rm;

+
use crate::task::Process;
+

/// An optional return value.
#[derive(Clone, Debug)]
pub struct Exit<T> {
@@ -76,6 +82,14 @@ where
    }
}

+
/// Implementors of `Share` can be used inside the multi-threaded
+
/// application environment.
+
pub trait Share: Clone + Debug + Send + Sync + 'static {}
+

+
/// Blanket implementation for all types that implement the required
+
/// traits.
+
impl<T: Clone + Debug + Send + Sync + 'static> Share for T {}
+

/// Provide implementations for conversions to and from `Box<dyn Any>`.
pub trait BoxedAny {
    fn from_boxed_any(any: Box<dyn Any>) -> Option<Self>
@@ -136,41 +150,75 @@ impl<T> PageStack<T> {
    }
}

-
/// A multi-producer, single-consumer message channel.
+
/// A multi-producer, multi-consumer message channel.
pub struct Channel<M> {
-
    pub tx: UnboundedSender<M>,
-
    pub rx: UnboundedReceiver<M>,
+
    pub tx: broadcast::Sender<M>,
+
    pub rx: broadcast::Receiver<M>,
}

-
impl<A> Default for Channel<A> {
+
impl<M: Clone> Default for Channel<M> {
    fn default() -> Self {
-
        let (tx, rx) = mpsc::unbounded_channel();
-
        Self { tx: tx.clone(), rx }
+
        let (tx, rx) = broadcast::channel(1000);
+
        Self { tx, rx }
    }
}

/// 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
+
/// and run their main loops in parallel. Connect them to the `Channel` and also to
/// an interrupt broadcast channel also initialized in this function.
-
pub async fn rm<S, M, P>(
+
/// Additionally, a list of processors can be passed. Processors will also receive all
+
/// applications messages and can emit new ones. They will be executed by an internal worker.
+
pub async fn rm<S, T, M, R>(
    state: S,
    root: rm::widget::Widget<S, M>,
    viewport: Viewport,
    channel: Channel<M>,
-
) -> Result<Option<P>>
+
    processors: Vec<T>,
+
) -> Result<Option<R>>
where
-
    S: Update<M, Return = P> + Clone + Debug + Send + Sync + 'static,
-
    M: Debug + Send + Sync + 'static,
-
    P: Clone + Debug + Send + Sync + 'static,
+
    S: Update<M, Return = R> + Share,
+
    T: Process<M> + Share,
+
    M: Share,
+
    R: Share,
{
-
    let (terminator, mut interrupt_rx) = task::create_termination();
+
    let (terminator, mut interrupt_rx) = create_termination();
+
    let (state_tx, state_rx) = unbounded_channel();
+
    let (work_tx, work_rx) = unbounded_channel();

-
    let (store, state_rx) = store::Store::<S, M, P>::new();
+
    let store = store::Store::<S, M, R>::new(state_tx.clone());
+
    let worker = task::Worker::<T, M, R>::new(work_tx.clone());
    let frontend = rm::Frontend::default();

-
    tokio::try_join!(
-
        store.run(state, terminator, channel.rx, interrupt_rx.resubscribe()),
-
        frontend.run(root, state_rx, interrupt_rx.resubscribe(), viewport),
+
    let worker_interrupt_rx = interrupt_rx.resubscribe();
+
    let store_interrupt_rx = interrupt_rx.resubscribe();
+
    let frontend_interrupt_rx = interrupt_rx.resubscribe();
+

+
    let worker_message_rx = channel.rx.resubscribe();
+
    let store_message_rx = channel.rx.resubscribe();
+

+
    // TODO(erikli): Handle errors properly
+
    let _ = tokio::try_join!(
+
        tokio::spawn(async move {
+
            worker
+
                .run(processors, worker_message_rx, worker_interrupt_rx)
+
                .await
+
        }),
+
        tokio::spawn(async move {
+
            store
+
                .run(
+
                    state,
+
                    terminator,
+
                    store_message_rx,
+
                    work_rx,
+
                    store_interrupt_rx,
+
                )
+
                .await
+
        }),
+
        tokio::spawn(async move {
+
            frontend
+
                .run(root, state_rx, frontend_interrupt_rx, viewport)
+
                .await
+
        }),
    )?;

    if let Ok(reason) = interrupt_rx.recv().await {
@@ -186,21 +234,42 @@ where
/// Initialize a `Store` with the `State` given and a `Frontend` with the `App` 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 im<S, M, P>(state: S, viewport: Viewport, channel: Channel<M>) -> Result<Option<P>>
+
/// Additionally, a list of processors can be passed. Processors will also receive all
+
/// applications messages and can emit new ones. They will be executed by an internal worker.
+
pub async fn im<S, T, M, R>(
+
    state: S,
+
    viewport: Viewport,
+
    channel: Channel<M>,
+
    processors: Vec<T>,
+
) -> Result<Option<R>>
where
-
    S: Update<M, Return = P> + Show<M> + Clone + Send + Sync + 'static,
-
    M: Clone + Debug + Send + Sync + 'static,
-
    P: Clone + Debug + Send + Sync + 'static,
+
    S: Update<M, Return = R> + Show<M> + Share,
+
    T: Process<M> + Share,
+
    M: Share,
+
    R: Share,
{
-
    let (terminator, mut interrupt_rx) = task::create_termination();
+
    let (terminator, mut interrupt_rx) = create_termination();
+
    let (state_tx, state_rx) = unbounded_channel();
+
    let (work_tx, work_rx) = unbounded_channel();

-
    let state_tx = channel.tx.clone();
-
    let (store, state_rx) = store::Store::<S, M, P>::new();
+
    let store = store::Store::<S, M, R>::new(state_tx.clone());
+
    let worker = task::Worker::<T, M, R>::new(work_tx.clone());
    let frontend = im::Frontend::default();

    tokio::try_join!(
-
        store.run(state, terminator, channel.rx, interrupt_rx.resubscribe()),
-
        frontend.run(state_tx, state_rx, interrupt_rx.resubscribe(), viewport),
+
        worker.run(
+
            processors,
+
            channel.rx.resubscribe(),
+
            interrupt_rx.resubscribe()
+
        ),
+
        store.run(
+
            state,
+
            terminator,
+
            channel.rx.resubscribe(),
+
            work_rx,
+
            interrupt_rx.resubscribe()
+
        ),
+
        frontend.run(channel.tx, state_rx, interrupt_rx.resubscribe(), viewport),
    )?;

    if let Ok(reason) = interrupt_rx.recv().await {
@@ -212,3 +281,70 @@ where
        anyhow::bail!("exited because of an unexpected error");
    }
}
+

+
/// An `Interrupt` message that is produced by either an OS signal (e.g. kill)
+
/// or the user by requesting the application to close.
+
#[derive(Debug, Clone)]
+
pub enum Interrupted<P>
+
where
+
    P: Clone + Send + Sync + Debug,
+
{
+
    OsSignal,
+
    User { payload: Option<P> },
+
}
+

+
/// The `Terminator` wraps a broadcast channel and can send an interrupt messages.
+
#[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,
+
{
+
    /// Create a `Terminator` that stores the sending end of a broadcast channel.
+
    pub fn new(interrupt_tx: broadcast::Sender<Interrupted<P>>) -> Self {
+
        Self { interrupt_tx }
+
    }
+

+
    /// Send interrupt message to the broadcast channel.
+
    pub fn terminate(&mut self, interrupted: Interrupted<P>) -> anyhow::Result<()> {
+
        self.interrupt_tx.send(interrupted)?;
+

+
        Ok(())
+
    }
+
}
+

+
/// Receive `SIGINT` and call terminator which sends the interrupt message to its broadcast channel.
+
#[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 and spawn a task for retrieving the applications' 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/store.rs
@@ -1,13 +1,12 @@
-
use std::fmt::Debug;
use std::marker::PhantomData;
use std::time::Duration;

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

-
use crate::Exit;
-

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

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

@@ -26,37 +25,32 @@ pub trait Update<M> {

/// The `Store` updates the applications' state concurrently. It handles
/// messages coming from the frontend and updates the state accordingly.
-
pub struct Store<S, M, P>
+
pub struct Store<S, M, R>
where
-
    S: Update<M, Return = P> + Clone + Send + Sync,
-
    P: Clone + Debug + Send + Sync,
+
    S: Update<M, Return = R> + Share,
{
    state_tx: UnboundedSender<S>,
-
    _phantom: PhantomData<(M, P)>,
+
    _phantom: PhantomData<(M, R)>,
}

-
impl<S, M, P> Store<S, M, P>
+
impl<S, M, R> Store<S, M, R>
where
-
    S: Update<M, Return = P> + Clone + Send + Sync,
-
    P: Clone + Debug + Send + Sync,
+
    S: Update<M, Return = R> + Share,
+
    R: Share,
{
-
    pub fn new() -> (Self, UnboundedReceiver<S>) {
-
        let (state_tx, state_rx) = mpsc::unbounded_channel::<S>();
-

-
        (
-
            Store {
-
                state_tx,
-
                _phantom: PhantomData,
-
            },
-
            state_rx,
-
        )
+
    pub fn new(tx: UnboundedSender<S>) -> Self {
+
        Self {
+
            state_tx: tx,
+
            _phantom: PhantomData,
+
        }
    }
}

-
impl<S, M, P> Store<S, M, P>
+
impl<S, M, R> Store<S, M, R>
where
-
    S: Update<M, Return = P> + Clone + Send + Sync + 'static,
-
    P: Clone + Debug + Send + Sync + 'static,
+
    S: Update<M, Return = R> + Share,
+
    M: Share,
+
    R: Share,
{
    /// By calling `main_loop`, the store will wait for new messages coming
    /// from the frontend and update the applications' state accordingly. It will
@@ -65,10 +59,11 @@ where
    pub async fn run(
        self,
        mut state: S,
-
        mut terminator: Terminator<P>,
-
        mut message_rx: UnboundedReceiver<M>,
-
        mut interrupt_rx: broadcast::Receiver<Interrupted<P>>,
-
    ) -> anyhow::Result<Interrupted<P>> {
+
        mut terminator: Terminator<R>,
+
        mut message_rx: broadcast::Receiver<M>,
+
        mut work_rx: UnboundedReceiver<M>,
+
        mut interrupt_rx: broadcast::Receiver<Interrupted<R>>,
+
    ) -> anyhow::Result<Interrupted<R>> {
        // Send the initial state once
        self.state_tx.send(state.clone())?;

@@ -78,13 +73,18 @@ where
            tokio::select! {
                // Handle the messages coming from the frontend
                // and process them to do async operations
-
                Some(message) = message_rx.recv() => {
+
                Ok(message) = message_rx.recv() => {
                    if let Some(exit) = state.update(message) {
                        let interrupted = Interrupted::User { payload: exit.value };
                        let _ = terminator.terminate(interrupted.clone());

                        break interrupted;
                    }
+
                    self.state_tx.send(state.clone())?;
+
                },
+
                Some(message) = work_rx.recv() => {
+
                    state.update(message);
+
                    self.state_tx.send(state.clone())?;
                },
                // Tick to terminate the select every N milliseconds
                _ = ticker.tick() => {
@@ -95,8 +95,6 @@ where
                    break interrupted;
                }
            }
-

-
            self.state_tx.send(state.clone())?;
        };

        Ok(result)
modified src/task.rs
@@ -1,72 +1,79 @@
-
use std::fmt::Debug;
+
use std::marker::PhantomData;
+
use std::{fmt::Debug, future::Future};

-
#[cfg(unix)]
-
use tokio::signal::unix::signal;
-
use tokio::sync::broadcast;
+
use tokio::sync::{broadcast, mpsc::UnboundedSender};

-
/// An `Interrupt` message that is produced by either an OS signal (e.g. kill)
-
/// or the user by requesting the application to close.
-
#[derive(Debug, Clone)]
-
pub enum Interrupted<P>
-
where
-
    P: Clone + Send + Sync + Debug,
-
{
-
    OsSignal,
-
    User { payload: Option<P> },
-
}
+
use super::{Interrupted, Share};

-
/// The `Terminator` wraps a broadcast channel and can send an interrupt messages.
-
#[derive(Debug, Clone)]
-
pub struct Terminator<P>
-
where
-
    P: Clone + Send + Sync + Debug,
-
{
-
    interrupt_tx: broadcast::Sender<Interrupted<P>>,
-
}
+
pub type EmptyProcessors = Vec<EmptyProcessor>;

-
impl<P> Terminator<P>
-
where
-
    P: Clone + Send + Sync + Debug + 'static,
-
{
-
    /// Create a `Terminator` that stores the sending end of a broadcast channel.
-
    pub fn new(interrupt_tx: broadcast::Sender<Interrupted<P>>) -> Self {
-
        Self { interrupt_tx }
-
    }
+
/// A task that can be run.
+
pub trait Task: Debug + Send + Sync + 'static {
+
    type Return;

-
    /// Send interrupt message to the broadcast channel.
-
    pub fn terminate(&mut self, interrupted: Interrupted<P>) -> anyhow::Result<()> {
-
        self.interrupt_tx.send(interrupted)?;
+
    fn run(&self) -> anyhow::Result<Vec<Self::Return>>;
+
}

-
        Ok(())
-
    }
+
/// A processor that can be added to the application environment.
+
/// Processors will receive application messages and can produce new ones.
+
pub trait Process<M: Share> {
+
    fn process(&mut self, _message: M) -> impl Future<Output = anyhow::Result<Vec<M>>> + Send;
}

-
/// Receive `SIGINT` and call terminator which sends the interrupt message to its broadcast channel.
-
#[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");
+
/// An empty processor that does nothing.
+
#[derive(Debug, Clone)]
+
pub struct EmptyProcessor;

-
    interrupt_signal.recv().await;
+
impl<M: Share> Process<M> for EmptyProcessor {
+
    async fn process(&mut self, _message: M) -> anyhow::Result<Vec<M>> {
+
        Ok(vec![])
+
    }
+
}

-
    terminator
-
        .terminate(Interrupted::OsSignal)
-
        .expect("failed to send interrupt signal");
+
/// A worker that is spawned by the application. Invokes
+
/// all processors and sends received application messages.
+
pub struct Worker<P, M, R> {
+
    work_tx: UnboundedSender<M>,
+
    _phantom: PhantomData<(P, M, R)>,
}

-
/// Create a broadcast channel and spawn a task for retrieving the applications' kill signal.
-
pub fn create_termination<P>() -> (Terminator<P>, broadcast::Receiver<Interrupted<P>>)
+
impl<P, M, R> Worker<P, M, R>
where
-
    P: Clone + Send + Sync + Debug + 'static,
+
    P: Process<M> + Share,
+
    M: Share,
+
    R: Share,
{
-
    let (tx, rx) = broadcast::channel(1);
-
    let terminator = Terminator::new(tx);
+
    pub fn new(tx: UnboundedSender<M>) -> Self {
+
        Self {
+
            work_tx: tx,
+
            _phantom: PhantomData,
+
        }
+
    }

-
    #[cfg(unix)]
-
    tokio::spawn(terminate_by_unix_signal(terminator.clone()));
+
    pub async fn run(
+
        &self,
+
        processors: Vec<P>,
+
        mut message_rx: broadcast::Receiver<M>,
+
        mut interrupt_rx: broadcast::Receiver<Interrupted<R>>,
+
    ) -> anyhow::Result<Interrupted<R>> {
+
        let result = loop {
+
            tokio::select! {
+
                Ok(message) = message_rx.recv() => {
+
                    for mut p in processors.clone() {
+
                        for m in p.process(message.clone()).await? {
+
                            if let Err(err) = self.work_tx.send(m) {
+
                                log::error!(target: "worker", "Unable to send message: {err}")
+
                            }
+
                        }
+
                    }
+
                },
+
                // Catch and handle interrupt signal to gracefully shutdown
+
                Ok(interrupted) = interrupt_rx.recv() => {
+
                    break interrupted;
+
                }
+
            }
+
        };

-
    (terminator, rx)
+
        Ok(result)
+
    }
}
modified src/ui/im.rs
@@ -10,7 +10,7 @@ use anyhow::Result;
use ratatui::style::Stylize;
use ratatui::text::{Span, Text};
use tokio::sync::broadcast;
-
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
+
use tokio::sync::mpsc::UnboundedReceiver;

use termion::event::Key;

@@ -19,11 +19,11 @@ use ratatui::{Frame, Viewport};

use crate::event::Event;
use crate::store::Update;
-
use crate::task::Interrupted;
use crate::terminal;
use crate::terminal::Terminal;
use crate::ui::theme::Theme;
use crate::ui::{Column, ToRow};
+
use crate::Interrupted;

use crate::ui::im::widget::{HeaderedTable, Widget};

@@ -42,17 +42,17 @@ pub trait Show<M> {
pub struct Frontend {}

impl Frontend {
-
    pub async fn run<S, M, P>(
+
    pub async fn run<S, M, R>(
        self,
-
        state_tx: UnboundedSender<M>,
+
        message_tx: broadcast::Sender<M>,
        mut state_rx: UnboundedReceiver<S>,
-
        mut interrupt_rx: broadcast::Receiver<Interrupted<P>>,
+
        mut interrupt_rx: broadcast::Receiver<Interrupted<R>>,
        viewport: Viewport,
-
    ) -> anyhow::Result<Interrupted<P>>
+
    ) -> anyhow::Result<Interrupted<R>>
    where
-
        S: Update<M, Return = P> + Show<M>,
+
        S: Update<M, Return = R> + Show<M>,
        M: Clone,
-
        P: Clone + Send + Sync + Debug,
+
        R: Clone + Send + Sync + Debug,
    {
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);

@@ -60,9 +60,9 @@ impl Frontend {
        let mut events_rx = terminal::events();

        let mut state = state_rx.recv().await.unwrap();
-
        let mut ctx = Context::default().with_sender(state_tx);
+
        let mut ctx = Context::default().with_sender(message_tx);

-
        let result: anyhow::Result<Interrupted<P>> = loop {
+
        let result: anyhow::Result<Interrupted<R>> = loop {
            tokio::select! {
                // Tick to terminate the select every N milliseconds
                _ = ticker.tick() => (),
@@ -131,7 +131,7 @@ pub struct Context<M> {
    /// Current frame of the application.
    pub(crate) frame_size: Rect,
    /// The message sender used by the `Ui` to send application messages.
-
    pub(crate) sender: Option<UnboundedSender<M>>,
+
    pub(crate) sender: Option<broadcast::Sender<M>>,
}

impl<M> Default for Context<M> {
@@ -162,7 +162,7 @@ impl<M> Context<M> {
        self
    }

-
    pub fn with_sender(mut self, sender: UnboundedSender<M>) -> Self {
+
    pub fn with_sender(mut self, sender: broadcast::Sender<M>) -> Self {
        self.sender = Some(sender);
        self
    }
modified src/ui/rm.rs
@@ -9,11 +9,11 @@ use tokio::sync::mpsc::UnboundedReceiver;

use crate::event::Event;
use crate::store::Update;
-
use crate::task::Interrupted;
use crate::terminal;
use crate::terminal::Terminal;
use crate::ui::rm::widget::RenderProps;
use crate::ui::rm::widget::Widget;
+
use crate::Interrupted;

const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);

@@ -60,6 +60,7 @@ impl Frontend {
        let mut root = {
            let state = state_rx.recv().await.unwrap();

+
            root.init();
            root.update(&state);
            root
        };
modified src/ui/rm/widget.rs
@@ -1,13 +1,13 @@
pub mod container;
-
pub mod input;
pub mod list;
+
pub mod text;
pub mod utils;
pub mod window;

use std::any::Any;
use std::rc::Rc;

-
use tokio::sync::mpsc::UnboundedSender;
+
use tokio::sync::broadcast;

use termion::event::Key;

@@ -15,13 +15,14 @@ use ratatui::prelude::*;

use self::{
    container::SectionGroupState,
-
    input::{TextAreaState, TextViewState},
+
    text::{TextAreaState, TextViewState},
};

pub type BoxedView<S, M> = Box<dyn View<State = S, Message = M>>;
pub type UpdateCallback<S> = fn(&S) -> ViewProps;
pub type EventCallback<M> = fn(Key, Option<&ViewState>, Option<&ViewProps>) -> Option<M>;
pub type RenderCallback<M> = fn(Option<&ViewProps>, &RenderProps) -> Option<M>;
+
pub type InitCallback<M> = fn() -> Option<M>;

/// `ViewProps` are properties of a `View`. They define a `View`s data, configuration etc.
/// Since the framework itself does not know the concrete type of `View`, it also does not
@@ -254,14 +255,17 @@ pub trait View {
pub struct Widget<S, M> {
    view: BoxedView<S, M>,
    props: Option<ViewProps>,
-
    sender: UnboundedSender<M>,
+
    sender: broadcast::Sender<M>,
+
    on_init: Option<InitCallback<M>>,
    on_update: Option<UpdateCallback<S>>,
    on_event: Option<EventCallback<M>>,
    on_render: Option<RenderCallback<M>>,
}

+
unsafe impl<S, M> Send for Widget<S, M> {}
+

impl<S: 'static, M: 'static> Widget<S, M> {
-
    pub fn new<V>(view: V, sender: UnboundedSender<M>) -> Self
+
    pub fn new<V>(view: V, sender: broadcast::Sender<M>) -> Self
    where
        Self: Sized,
        V: View<State = S, Message = M> + 'static,
@@ -270,6 +274,7 @@ impl<S: 'static, M: 'static> Widget<S, M> {
            view: Box::new(view),
            props: None,
            sender: sender.clone(),
+
            on_init: None,
            on_update: None,
            on_event: None,
            on_render: None,
@@ -297,6 +302,13 @@ impl<S: 'static, M: 'static> Widget<S, M> {
        }
    }

+
    /// Initializes the widget
+
    pub fn init(&mut self) {
+
        if let Some(on_init) = self.on_init {
+
            (on_init)().and_then(|message| self.sender.send(message).ok());
+
        }
+
    }
+

    /// Applications are usually defined by app-specific widgets that do know
    /// the type of `state`. These can use widgets from the library that do not know the
    /// type of `state`.
@@ -321,11 +333,11 @@ impl<S: 'static, M: 'static> Widget<S, M> {
    }

    /// Sets the optional custom event handler.
-
    pub fn on_event(mut self, callback: EventCallback<M>) -> Self
+
    pub fn on_init(mut self, callback: InitCallback<M>) -> Self
    where
        Self: Sized,
    {
-
        self.on_event = Some(callback);
+
        self.on_init = Some(callback);
        self
    }

@@ -338,6 +350,15 @@ impl<S: 'static, M: 'static> Widget<S, M> {
        self
    }

+
    /// Sets the optional custom event handler.
+
    pub fn on_event(mut self, callback: EventCallback<M>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        self.on_event = Some(callback);
+
        self
+
    }
+

    /// Sets the optional update handler.
    pub fn on_render(mut self, callback: RenderCallback<M>) -> Self
    where
@@ -351,7 +372,7 @@ impl<S: 'static, M: 'static> Widget<S, M> {
/// A `View` needs to be wrapped into a `Widget` in order to be used with the framework.
/// `ToWidget` provides a blanket implementation for all `View`s.
pub trait ToWidget<S, M> {
-
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
+
    fn to_widget(self, tx: broadcast::Sender<M>) -> Widget<S, M>
    where
        Self: Sized + 'static;
}
@@ -362,7 +383,7 @@ where
    S: 'static,
    M: 'static,
{
-
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
+
    fn to_widget(self, tx: broadcast::Sender<M>) -> Widget<S, M>
    where
        Self: Sized + 'static,
    {
deleted src/ui/rm/widget/input.rs
@@ -1,905 +0,0 @@
-
use std::marker::PhantomData;
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
-
use ratatui::style::{Style, Stylize};
-
use ratatui::text::{Line, Span, Text};
-
use ratatui::widgets::Paragraph;
-
use ratatui::Frame;
-

-
use crate::ui::theme::Theme;
-

-
use super::{utils, RenderProps, View, ViewProps, ViewState};
-

-
#[derive(Clone)]
-
pub struct TextFieldProps {
-
    /// The label of this input field.
-
    pub title: String,
-
    /// The input text.
-
    pub text: String,
-
    /// Sets if the label should be displayed inline with the input. The default is `false`.
-
    pub inline_label: bool,
-
    /// Sets if the cursor should be shown. The default is `true`.
-
    pub show_cursor: bool,
-
    /// Set to `true` if the content style should be dimmed whenever the widget
-
    /// has no focus.
-
    pub dim: bool,
-
}
-

-
impl TextFieldProps {
-
    pub fn text(mut self, new_text: &str) -> Self {
-
        if self.text != new_text {
-
            self.text = String::from(new_text);
-
        }
-
        self
-
    }
-

-
    pub fn title(mut self, title: &str) -> Self {
-
        self.title = title.to_string();
-
        self
-
    }
-

-
    pub fn inline(mut self, inline: bool) -> Self {
-
        self.inline_label = inline;
-
        self
-
    }
-

-
    pub fn dim(mut self, dim: bool) -> Self {
-
        self.dim = dim;
-
        self
-
    }
-
}
-

-
impl Default for TextFieldProps {
-
    fn default() -> Self {
-
        Self {
-
            title: String::new(),
-
            inline_label: false,
-
            show_cursor: true,
-
            text: String::new(),
-
            dim: false,
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
-
struct TextFieldState {
-
    pub text: Option<String>,
-
    pub cursor_position: usize,
-
}
-

-
pub struct TextField<S, M> {
-
    /// Internal state
-
    state: TextFieldState,
-
    /// Phantom
-
    phantom: PhantomData<(S, M)>,
-
}
-

-
impl<S, M> Default for TextField<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            state: TextFieldState {
-
                text: None,
-
                cursor_position: 0,
-
            },
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<S, M> TextField<S, M> {
-
    fn move_cursor_left(&mut self) {
-
        let cursor_moved_left = self.state.cursor_position.saturating_sub(1);
-
        self.state.cursor_position = self.clamp_cursor(cursor_moved_left);
-
    }
-

-
    fn move_cursor_right(&mut self) {
-
        let cursor_moved_right = self.state.cursor_position.saturating_add(1);
-
        self.state.cursor_position = self.clamp_cursor(cursor_moved_right);
-
    }
-

-
    fn enter_char(&mut self, new_char: char) {
-
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
-
        self.state
-
            .text
-
            .as_mut()
-
            .unwrap()
-
            .insert(self.state.cursor_position, new_char);
-
        self.move_cursor_right();
-
    }
-

-
    fn delete_char_right(&mut self) {
-
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
-

-
        // Method "remove" is not used on the saved text for deleting the selected char.
-
        // Reason: Using remove on String works on bytes instead of the chars.
-
        // Using remove would require special care because of char boundaries.
-

-
        let current_index = self.state.cursor_position;
-
        let from_left_to_current_index = current_index;
-

-
        // Getting all characters before the selected character.
-
        let before_char_to_delete = self
-
            .state
-
            .text
-
            .as_ref()
-
            .unwrap()
-
            .chars()
-
            .take(from_left_to_current_index);
-
        // Getting all characters after selected character.
-
        let after_char_to_delete = self
-
            .state
-
            .text
-
            .as_ref()
-
            .unwrap()
-
            .chars()
-
            .skip(current_index.saturating_add(1));
-

-
        // Put all characters together except the selected one.
-
        // By leaving the selected one out, it is forgotten and therefore deleted.
-
        self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
-
    }
-

-
    fn delete_char_left(&mut self) {
-
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
-

-
        let is_not_cursor_leftmost = self.state.cursor_position != 0;
-
        if is_not_cursor_leftmost {
-
            // Method "remove" is not used on the saved text for deleting the selected char.
-
            // Reason: Using remove on String works on bytes instead of the chars.
-
            // Using remove would require special care because of char boundaries.
-

-
            let current_index = self.state.cursor_position;
-
            let from_left_to_current_index = current_index - 1;
-

-
            // Getting all characters before the selected character.
-
            let before_char_to_delete = self
-
                .state
-
                .text
-
                .as_ref()
-
                .unwrap()
-
                .chars()
-
                .take(from_left_to_current_index);
-
            // Getting all characters after selected character.
-
            let after_char_to_delete = self
-
                .state
-
                .text
-
                .as_ref()
-
                .unwrap()
-
                .chars()
-
                .skip(current_index);
-

-
            // Put all characters together except the selected one.
-
            // By leaving the selected one out, it is forgotten and therefore deleted.
-
            self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
-
            self.move_cursor_left();
-
        }
-
    }
-

-
    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
-
        new_cursor_pos.clamp(0, self.state.text.clone().unwrap_or_default().len())
-
    }
-
}
-

-
impl<S, M> View for TextField<S, M>
-
where
-
    S: 'static,
-
    M: 'static,
-
{
-
    type Message = M;
-
    type State = S;
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        self.state
-
            .text
-
            .as_ref()
-
            .map(|text| ViewState::String(text.to_string()))
-
    }
-

-
    fn reset(&mut self) {
-
        self.state = TextFieldState {
-
            text: None,
-
            cursor_position: 0,
-
        };
-
    }
-

-
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        match key {
-
            Key::Char(to_insert)
-
                if (key != Key::Alt('\n'))
-
                    && (key != Key::Char('\n'))
-
                    && (key != Key::Ctrl('\n')) =>
-
            {
-
                self.enter_char(to_insert);
-
            }
-
            Key::Backspace => {
-
                self.delete_char_left();
-
            }
-
            Key::Delete => {
-
                self.delete_char_right();
-
            }
-
            Key::Left => {
-
                self.move_cursor_left();
-
            }
-
            Key::Right => {
-
                self.move_cursor_right();
-
            }
-
            _ => {}
-
        }
-

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
-
        let default = TextFieldProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextFieldProps>())
-
            .unwrap_or(&default);
-

-
        if self.state.text.is_none() {
-
            self.state.cursor_position = props.text.len().saturating_sub(1);
-
        }
-
        self.state.text = Some(props.text.clone());
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = TextFieldProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextFieldProps>())
-
            .unwrap_or(&default);
-

-
        let area = render.area;
-
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
-

-
        let text = self.state.text.clone().unwrap_or_default();
-
        let input = text.as_str();
-
        let label_content = format!(" {} ", props.title);
-
        let overline = String::from("▔").repeat(area.width as usize);
-
        let cursor_pos = self.state.cursor_position as u16;
-

-
        let (label, input, overline) = if !render.focus && props.dim {
-
            (
-
                Span::from(label_content.clone()).magenta().dim().reversed(),
-
                Span::from(input).reset().dim(),
-
                Span::raw(overline).magenta().dim(),
-
            )
-
        } else {
-
            (
-
                Span::from(label_content.clone()).magenta().reversed(),
-
                Span::from(input).reset(),
-
                Span::raw(overline).magenta(),
-
            )
-
        };
-

-
        if props.inline_label {
-
            let top_layout = Layout::horizontal([
-
                Constraint::Length(label_content.chars().count() as u16),
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
            ])
-
            .split(layout[0]);
-

-
            let overline = Line::from([overline].to_vec());
-

-
            frame.render_widget(label, top_layout[0]);
-
            frame.render_widget(input, top_layout[2]);
-
            frame.render_widget(overline, layout[1]);
-

-
            if props.show_cursor {
-
                frame.set_cursor_position(Position::new(
-
                    top_layout[2].x + cursor_pos,
-
                    top_layout[2].y,
-
                ))
-
            }
-
        } else {
-
            let top = Line::from([input].to_vec());
-
            let bottom = Line::from([label, overline].to_vec());
-

-
            frame.render_widget(top, layout[0]);
-
            frame.render_widget(bottom, layout[1]);
-

-
            if props.show_cursor {
-
                frame.set_cursor_position(Position::new(area.x + cursor_pos, area.y))
-
            }
-
        }
-
    }
-
}
-

-
/// The state of a `TextArea`.
-
#[derive(Clone, Default, Debug)]
-
pub struct TextAreaState {
-
    /// Current vertical scroll position.
-
    pub scroll: usize,
-
    /// Current cursor position.
-
    pub cursor: (usize, usize),
-
}
-

-
/// The properties of a `TextArea`.
-
#[derive(Clone)]
-
pub struct TextAreaProps<'a> {
-
    /// Content of this text area.
-
    content: Text<'a>,
-
    /// Current cursor position. Default: `(0, 0)`.
-
    cursor: (usize, usize),
-
    /// If this text area should handle events. Default: `true`.
-
    handle_keys: bool,
-
    /// If this text area is in insert mode. Default: `false`.
-
    insert_mode: bool,
-
    /// If this text area should render its scroll progress. Default: `false`.
-
    show_scroll_progress: bool,
-
    /// If this text area should render its cursor progress. Default: `false`.
-
    show_column_progress: bool,
-
    /// Set to `true` if the content style should be dimmed whenever the widget
-
    /// has no focus.
-
    dim: bool,
-
}
-

-
impl Default for TextAreaProps<'_> {
-
    fn default() -> Self {
-
        Self {
-
            content: String::new().into(),
-
            cursor: (0, 0),
-
            handle_keys: true,
-
            insert_mode: false,
-
            show_scroll_progress: false,
-
            show_column_progress: false,
-
            dim: false,
-
        }
-
    }
-
}
-

-
impl<'a> TextAreaProps<'a> {
-
    pub fn content<T>(mut self, content: T) -> Self
-
    where
-
        T: Into<Text<'a>>,
-
    {
-
        self.content = content.into();
-
        self
-
    }
-

-
    pub fn cursor(mut self, cursor: (usize, usize)) -> Self {
-
        self.cursor = cursor;
-
        self
-
    }
-

-
    pub fn show_scroll_progress(mut self, show_scroll_progress: bool) -> Self {
-
        self.show_scroll_progress = show_scroll_progress;
-
        self
-
    }
-

-
    pub fn show_column_progress(mut self, show_column_progress: bool) -> Self {
-
        self.show_column_progress = show_column_progress;
-
        self
-
    }
-

-
    pub fn handle_keys(mut self, handle_keys: bool) -> Self {
-
        self.handle_keys = handle_keys;
-
        self
-
    }
-

-
    pub fn dim(mut self, dim: bool) -> Self {
-
        self.dim = dim;
-
        self
-
    }
-
}
-

-
/// A non-editable text area that can be behave like a text editor.
-
/// It can scroll through text by moving around the cursor.
-
pub struct TextArea<'a, S, M> {
-
    phantom: PhantomData<(S, M)>,
-
    textarea: tui_textarea::TextArea<'a>,
-
    area: (u16, u16),
-
}
-

-
impl<S, M> Default for TextArea<'_, S, M> {
-
    fn default() -> Self {
-
        Self {
-
            phantom: PhantomData,
-
            textarea: tui_textarea::TextArea::default(),
-
            area: (0, 0),
-
        }
-
    }
-
}
-

-
impl<S, M> View for TextArea<'_, S, M> {
-
    type State = S;
-
    type Message = M;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        use tui_textarea::Input;
-

-
        let default = TextAreaProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextAreaProps>())
-
            .unwrap_or(&default);
-

-
        if props.handle_keys {
-
            if !props.insert_mode {
-
                match key {
-
                    Key::Left | Key::Char('h') => {
-
                        self.textarea.input(Input {
-
                            key: tui_textarea::Key::Left,
-
                            ..Default::default()
-
                        });
-
                    }
-
                    Key::Right | Key::Char('l') => {
-
                        self.textarea.input(Input {
-
                            key: tui_textarea::Key::Right,
-
                            ..Default::default()
-
                        });
-
                    }
-
                    Key::Up | Key::Char('k') => {
-
                        self.textarea.input(Input {
-
                            key: tui_textarea::Key::Up,
-
                            ..Default::default()
-
                        });
-
                    }
-
                    Key::Down | Key::Char('j') => {
-
                        self.textarea.input(Input {
-
                            key: tui_textarea::Key::Down,
-
                            ..Default::default()
-
                        });
-
                    }
-
                    _ => {}
-
                }
-
            } else {
-
                // TODO: Implement insert mode.
-
            }
-
        }
-

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
-
        let default = TextAreaProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextAreaProps>())
-
            .unwrap_or(&default);
-

-
        self.textarea = tui_textarea::TextArea::new(
-
            props
-
                .content
-
                .lines
-
                .iter()
-
                .map(|line| line.to_string())
-
                .collect::<Vec<_>>(),
-
        );
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = TextAreaProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextAreaProps>())
-
            .unwrap_or(&default);
-

-
        let [area] = Layout::default()
-
            .constraints([Constraint::Min(1)])
-
            .horizontal_margin(1)
-
            .areas(render.area);
-

-
        let [content_area, progress_area] = Layout::vertical([
-
            Constraint::Min(1),
-
            Constraint::Length(
-
                if props.show_scroll_progress || props.show_column_progress {
-
                    1
-
                } else {
-
                    0
-
                },
-
            ),
-
        ])
-
        .areas(area);
-

-
        let cursor_line_style = Style::default();
-
        let cursor_style = if render.focus {
-
            Style::default().reversed()
-
        } else {
-
            cursor_line_style
-
        };
-
        let content_style = if !render.focus && props.dim {
-
            Style::default().dim()
-
        } else {
-
            Style::default()
-
        };
-

-
        self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
-
            props.cursor.0 as u16,
-
            props.cursor.1 as u16,
-
        ));
-
        self.textarea.set_cursor_line_style(cursor_line_style);
-
        self.textarea.set_cursor_style(cursor_style);
-
        self.textarea.set_style(content_style);
-

-
        let (scroll_progress, cursor_progress) = (
-
            utils::scroll::percent_absolute(
-
                self.textarea.cursor().0,
-
                props.content.lines.len(),
-
                content_area.height.into(),
-
            ),
-
            (self.textarea.cursor().0, self.textarea.cursor().1),
-
        );
-

-
        frame.render_widget(&self.textarea, content_area);
-

-
        let mut progress_info = vec![];
-

-
        if props.show_scroll_progress {
-
            progress_info.push(Span::styled(
-
                format!("{scroll_progress}%"),
-
                Style::default().dim(),
-
            ))
-
        }
-

-
        if props.show_scroll_progress && props.show_column_progress {
-
            progress_info.push(Span::raw(" "));
-
        }
-

-
        if props.show_column_progress {
-
            progress_info.push(Span::styled(
-
                format!("[{},{}]", cursor_progress.0, cursor_progress.1),
-
                Style::default().dim(),
-
            ))
-
        }
-

-
        frame.render_widget(
-
            Line::from(progress_info).alignment(Alignment::Right),
-
            progress_area,
-
        );
-

-
        self.area = (content_area.height, content_area.width);
-
    }
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        Some(ViewState::TextArea(TextAreaState {
-
            cursor: self.textarea.cursor(),
-
            scroll: utils::scroll::percent_absolute(
-
                self.textarea.cursor().0.saturating_sub(self.area.0.into()),
-
                self.textarea.lines().len(),
-
                self.area.0.into(),
-
            ),
-
        }))
-
    }
-
}
-

-
/// State of a `TextView`.
-
#[derive(Clone, Default, Debug)]
-
pub struct TextViewState {
-
    /// Current vertical scroll position.
-
    pub scroll: usize,
-
    /// Current cursor position.
-
    pub cursor: (usize, usize),
-
    /// Content of this text view.
-
    pub content: String,
-
}
-

-
impl TextViewState {
-
    pub fn content<T>(mut self, content: T) -> Self
-
    where
-
        T: Into<String>,
-
    {
-
        self.content = content.into();
-
        self
-
    }
-

-
    pub fn cursor(mut self, cursor: (usize, usize)) -> Self {
-
        self.cursor = cursor;
-
        self
-
    }
-

-
    pub fn scroll(mut self, scroll: usize) -> Self {
-
        self.scroll = scroll;
-
        self
-
    }
-

-
    pub fn reset_cursor(&mut self) {
-
        self.cursor = (0, 0);
-
    }
-
}
-

-
/// Properties of a `TextView`.
-
#[derive(Clone)]
-
pub struct TextViewProps<'a> {
-
    /// Optional state. If set, it will override the internal view state.
-
    state: Option<TextViewState>,
-
    /// If this widget should handle events. Default: `true`.
-
    handle_keys: bool,
-
    /// If this widget should render its scroll progress. Default: `false`.
-
    show_scroll_progress: bool,
-
    /// An optional text that is rendered inside the footer bar on the bottom.
-
    footer: Option<Text<'a>>,
-
    /// The style used whenever the widget has focus.
-
    content_style: Style,
-
    /// Default scroll progress style.
-
    scroll_style: Style,
-
    /// Scroll progress style whenever the the widget has focus.
-
    focus_scroll_style: Style,
-
    /// Set to `true` if the content style should be dimmed whenever the widget
-
    /// has no focus.
-
    dim: bool,
-
}
-

-
impl<'a> TextViewProps<'a> {
-
    pub fn footer<T>(mut self, footer: Option<T>) -> Self
-
    where
-
        T: Into<Text<'a>>,
-
    {
-
        self.footer = footer.map(|f| f.into());
-
        self
-
    }
-

-
    pub fn state(mut self, state: Option<TextViewState>) -> Self {
-
        self.state = state;
-
        self
-
    }
-

-
    pub fn show_scroll_progress(mut self, show_scroll_progress: bool) -> Self {
-
        self.show_scroll_progress = show_scroll_progress;
-
        self
-
    }
-

-
    pub fn handle_keys(mut self, handle_keys: bool) -> Self {
-
        self.handle_keys = handle_keys;
-
        self
-
    }
-

-
    pub fn content_style(mut self, style: Style) -> Self {
-
        self.content_style = style;
-
        self
-
    }
-

-
    pub fn scroll_style(mut self, style: Style) -> Self {
-
        self.scroll_style = style;
-
        self
-
    }
-

-
    pub fn focus_scroll_style(mut self, style: Style) -> Self {
-
        self.focus_scroll_style = style;
-
        self
-
    }
-

-
    pub fn dim(mut self, dim: bool) -> Self {
-
        self.dim = dim;
-
        self
-
    }
-
}
-

-
impl Default for TextViewProps<'_> {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            state: None,
-
            handle_keys: true,
-
            show_scroll_progress: false,
-
            footer: None,
-
            content_style: theme.textview_style,
-
            scroll_style: theme.textview_scroll_style,
-
            focus_scroll_style: theme.textview_focus_scroll_style,
-
            dim: false,
-
        }
-
    }
-
}
-

-
/// A scrollable, non-editable text view widget. It can scroll through text by
-
/// moving around the viewport.
-
pub struct TextView<S, M> {
-
    /// Internal view state.
-
    state: TextViewState,
-
    /// Current render area.
-
    area: (u16, u16),
-
    /// Phantom.
-
    phantom: PhantomData<(S, M)>,
-
}
-

-
impl<S, M> Default for TextView<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            state: TextViewState::default(),
-
            area: (0, 0),
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<S, M> TextView<S, M> {
-
    fn scroll_up(&mut self) {
-
        self.state.cursor.0 = self.state.cursor.0.saturating_sub(1);
-
    }
-

-
    fn scroll_down(&mut self, len: usize, page_size: usize) {
-
        let end = len.saturating_sub(page_size);
-
        self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(1), end);
-
    }
-

-
    fn scroll_left(&mut self) {
-
        self.state.cursor.1 = self.state.cursor.1.saturating_sub(3);
-
    }
-

-
    fn scroll_right(&mut self, max_line_length: usize) {
-
        self.state.cursor.1 = std::cmp::min(
-
            self.state.cursor.1.saturating_add(3),
-
            max_line_length.saturating_add(3),
-
        );
-
    }
-

-
    fn prev_page(&mut self, page_size: usize) {
-
        self.state.cursor.0 = self.state.cursor.0.saturating_sub(page_size);
-
    }
-

-
    fn next_page(&mut self, len: usize, page_size: usize) {
-
        let end = len.saturating_sub(page_size);
-

-
        self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(page_size), end);
-
    }
-

-
    fn begin(&mut self) {
-
        self.state.cursor.0 = 0;
-
    }
-

-
    fn end(&mut self, len: usize, page_size: usize) {
-
        self.state.cursor.0 = len.saturating_sub(page_size);
-
    }
-

-
    fn update_area(&mut self, area: Rect) {
-
        self.area = (area.height, area.width);
-
    }
-

-
    fn render_content(&self, frame: &mut Frame, props: &TextViewProps, render: &RenderProps) {
-
        let content_style = if !render.focus && props.dim {
-
            props.content_style.dim()
-
        } else {
-
            props.content_style
-
        };
-

-
        let content = Paragraph::new(self.state.content.clone())
-
            .style(content_style)
-
            .scroll((self.state.cursor.0 as u16, self.state.cursor.1 as u16));
-

-
        frame.render_widget(content, render.area);
-
    }
-

-
    fn render_footer(
-
        &self,
-
        frame: &mut Frame,
-
        props: &TextViewProps,
-
        render: &RenderProps,
-
        content_height: u16,
-
    ) {
-
        let [text_area, scroll_area] =
-
            Layout::horizontal([Constraint::Min(1), Constraint::Length(10)]).areas(render.area);
-

-
        let scroll_style = if render.focus {
-
            props.focus_scroll_style
-
        } else {
-
            props.scroll_style
-
        };
-

-
        let mut scroll = vec![];
-
        if props.show_scroll_progress {
-
            let content_len = self.state.content.lines().count();
-
            let scroll_progress = utils::scroll::percent_absolute(
-
                self.state.cursor.0,
-
                content_len,
-
                content_height.into(),
-
            );
-
            if (content_height as usize) < content_len {
-
                // vec![Span::styled(format!("All / {}", content_len), scroll_style)]
-
                scroll = vec![Span::styled(format!("{scroll_progress}%"), scroll_style)];
-
            }
-
        }
-

-
        frame.render_widget(
-
            props
-
                .footer
-
                .as_ref()
-
                .cloned()
-
                .unwrap_or_default()
-
                .alignment(Alignment::Left)
-
                .dim(),
-
            text_area,
-
        );
-
        frame.render_widget(Line::from(scroll).alignment(Alignment::Right), scroll_area);
-
    }
-
}
-

-
impl<S, M> View for TextView<S, M>
-
where
-
    S: 'static,
-
    M: 'static,
-
{
-
    type Message = M;
-
    type State = S;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        let default = TextViewProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextViewProps>())
-
            .unwrap_or(&default);
-

-
        let lines = self.state.content.lines().clone();
-
        let len = lines.clone().count();
-
        let max_line_len = lines.map(|l| l.chars().count()).max().unwrap_or_default();
-
        let page_size = self.area.0 as usize;
-

-
        if props.handle_keys {
-
            match key {
-
                Key::Up | Key::Char('k') => {
-
                    self.scroll_up();
-
                }
-
                Key::Down | Key::Char('j') => {
-
                    self.scroll_down(len, page_size);
-
                }
-
                Key::Left | Key::Char('h') => {
-
                    self.scroll_left();
-
                }
-
                Key::Right | Key::Char('l') => {
-
                    self.scroll_right(max_line_len.saturating_sub(self.area.1.into()));
-
                }
-
                Key::PageUp => {
-
                    self.prev_page(page_size);
-
                }
-
                Key::PageDown => {
-
                    self.next_page(len, page_size);
-
                }
-
                Key::Home => {
-
                    self.begin();
-
                }
-
                Key::End => {
-
                    self.end(len, page_size);
-
                }
-
                _ => {}
-
            }
-
        }
-

-
        self.state.scroll = utils::scroll::percent_absolute(
-
            self.state.cursor.0,
-
            self.state.content.lines().count(),
-
            self.area.0.into(),
-
        );
-

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
-
        let default = TextViewProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextViewProps>())
-
            .unwrap_or(&default);
-

-
        if let Some(state) = &props.state {
-
            self.state = state.clone();
-
        }
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = TextViewProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextViewProps>())
-
            .unwrap_or(&default);
-
        let render_footer = props.show_scroll_progress || props.footer.is_some();
-

-
        let [area] = Layout::default()
-
            .constraints([Constraint::Min(1)])
-
            .horizontal_margin(1)
-
            .areas(render.area);
-

-
        if render_footer {
-
            let [content_area, footer_area] = Layout::vertical([
-
                Constraint::Min(1),
-
                Constraint::Length(if render_footer { 1 } else { 0 }),
-
            ])
-
            .areas(area);
-

-
            self.render_content(frame, props, &render.clone().area(content_area));
-
            self.render_footer(frame, props, &render.area(footer_area), content_area.height);
-
            self.update_area(content_area);
-
        } else {
-
            self.render_content(frame, props, &render.clone().area(area));
-
            self.update_area(area);
-
        }
-
    }
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        Some(ViewState::TextView(self.state.clone()))
-
    }
-
}
added src/ui/rm/widget/text.rs
@@ -0,0 +1,962 @@
+
use std::marker::PhantomData;
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::{Line, Span, Text};
+
use ratatui::widgets::Paragraph;
+
use ratatui::Frame;
+

+
use crate::ui::theme::{self, Theme};
+

+
use super::{utils, RenderProps, View, ViewProps, ViewState};
+

+
#[derive(Clone, Debug)]
+
pub struct LabelProps {
+
    pub text: String,
+
    pub style: Style,
+
}
+

+
impl LabelProps {
+
    pub fn text(mut self, text: &str) -> Self {
+
        self.text = text.to_string();
+
        self
+
    }
+

+
    pub fn style(mut self, style: Style) -> Self {
+
        self.style = style;
+
        self
+
    }
+
}
+

+
impl Default for LabelProps {
+
    fn default() -> Self {
+
        Self {
+
            text: "".to_string(),
+
            style: theme::style::reset(),
+
        }
+
    }
+
}
+

+
pub struct Label<S, M> {
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
+
}
+

+
impl<S, M> Default for Label<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M> View for Label<S, M> {
+
    type Message = M;
+
    type State = S;
+

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = LabelProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<LabelProps>())
+
            .unwrap_or(&default);
+

+
        frame.render_widget(
+
            Paragraph::new(props.text.clone()).style(props.style),
+
            render.area,
+
        );
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct TextFieldProps {
+
    /// The label of this input field.
+
    pub title: String,
+
    /// The input text.
+
    pub text: String,
+
    /// Sets if the label should be displayed inline with the input. The default is `false`.
+
    pub inline_label: bool,
+
    /// Sets if the cursor should be shown. The default is `true`.
+
    pub show_cursor: bool,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    pub dim: bool,
+
}
+

+
impl TextFieldProps {
+
    pub fn text(mut self, new_text: &str) -> Self {
+
        if self.text != new_text {
+
            self.text = String::from(new_text);
+
        }
+
        self
+
    }
+

+
    pub fn title(mut self, title: &str) -> Self {
+
        self.title = title.to_string();
+
        self
+
    }
+

+
    pub fn inline(mut self, inline: bool) -> Self {
+
        self.inline_label = inline;
+
        self
+
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
+
}
+

+
impl Default for TextFieldProps {
+
    fn default() -> Self {
+
        Self {
+
            title: String::new(),
+
            inline_label: false,
+
            show_cursor: true,
+
            text: String::new(),
+
            dim: false,
+
        }
+
    }
+
}
+

+
#[derive(Clone)]
+
struct TextFieldState {
+
    pub text: Option<String>,
+
    pub cursor_position: usize,
+
}
+

+
pub struct TextField<S, M> {
+
    /// Internal state
+
    state: TextFieldState,
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
+
}
+

+
impl<S, M> Default for TextField<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            state: TextFieldState {
+
                text: None,
+
                cursor_position: 0,
+
            },
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M> TextField<S, M> {
+
    fn move_cursor_left(&mut self) {
+
        let cursor_moved_left = self.state.cursor_position.saturating_sub(1);
+
        self.state.cursor_position = self.clamp_cursor(cursor_moved_left);
+
    }
+

+
    fn move_cursor_right(&mut self) {
+
        let cursor_moved_right = self.state.cursor_position.saturating_add(1);
+
        self.state.cursor_position = self.clamp_cursor(cursor_moved_right);
+
    }
+

+
    fn enter_char(&mut self, new_char: char) {
+
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
+
        self.state
+
            .text
+
            .as_mut()
+
            .unwrap()
+
            .insert(self.state.cursor_position, new_char);
+
        self.move_cursor_right();
+
    }
+

+
    fn delete_char_right(&mut self) {
+
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
+

+
        // Method "remove" is not used on the saved text for deleting the selected char.
+
        // Reason: Using remove on String works on bytes instead of the chars.
+
        // Using remove would require special care because of char boundaries.
+

+
        let current_index = self.state.cursor_position;
+
        let from_left_to_current_index = current_index;
+

+
        // Getting all characters before the selected character.
+
        let before_char_to_delete = self
+
            .state
+
            .text
+
            .as_ref()
+
            .unwrap()
+
            .chars()
+
            .take(from_left_to_current_index);
+
        // Getting all characters after selected character.
+
        let after_char_to_delete = self
+
            .state
+
            .text
+
            .as_ref()
+
            .unwrap()
+
            .chars()
+
            .skip(current_index.saturating_add(1));
+

+
        // Put all characters together except the selected one.
+
        // By leaving the selected one out, it is forgotten and therefore deleted.
+
        self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
+
    }
+

+
    fn delete_char_left(&mut self) {
+
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
+

+
        let is_not_cursor_leftmost = self.state.cursor_position != 0;
+
        if is_not_cursor_leftmost {
+
            // Method "remove" is not used on the saved text for deleting the selected char.
+
            // Reason: Using remove on String works on bytes instead of the chars.
+
            // Using remove would require special care because of char boundaries.
+

+
            let current_index = self.state.cursor_position;
+
            let from_left_to_current_index = current_index - 1;
+

+
            // Getting all characters before the selected character.
+
            let before_char_to_delete = self
+
                .state
+
                .text
+
                .as_ref()
+
                .unwrap()
+
                .chars()
+
                .take(from_left_to_current_index);
+
            // Getting all characters after selected character.
+
            let after_char_to_delete = self
+
                .state
+
                .text
+
                .as_ref()
+
                .unwrap()
+
                .chars()
+
                .skip(current_index);
+

+
            // Put all characters together except the selected one.
+
            // By leaving the selected one out, it is forgotten and therefore deleted.
+
            self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
+
            self.move_cursor_left();
+
        }
+
    }
+

+
    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
+
        new_cursor_pos.clamp(0, self.state.text.clone().unwrap_or_default().len())
+
    }
+
}
+

+
impl<S, M> View for TextField<S, M>
+
where
+
    S: 'static,
+
    M: 'static,
+
{
+
    type Message = M;
+
    type State = S;
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        self.state
+
            .text
+
            .as_ref()
+
            .map(|text| ViewState::String(text.to_string()))
+
    }
+

+
    fn reset(&mut self) {
+
        self.state = TextFieldState {
+
            text: None,
+
            cursor_position: 0,
+
        };
+
    }
+

+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        match key {
+
            Key::Char(to_insert)
+
                if (key != Key::Alt('\n'))
+
                    && (key != Key::Char('\n'))
+
                    && (key != Key::Ctrl('\n')) =>
+
            {
+
                self.enter_char(to_insert);
+
            }
+
            Key::Backspace => {
+
                self.delete_char_left();
+
            }
+
            Key::Delete => {
+
                self.delete_char_right();
+
            }
+
            Key::Left => {
+
                self.move_cursor_left();
+
            }
+
            Key::Right => {
+
                self.move_cursor_right();
+
            }
+
            _ => {}
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TextFieldProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextFieldProps>())
+
            .unwrap_or(&default);
+

+
        if self.state.text.is_none() {
+
            self.state.cursor_position = props.text.len().saturating_sub(1);
+
        }
+
        self.state.text = Some(props.text.clone());
+
    }
+

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = TextFieldProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextFieldProps>())
+
            .unwrap_or(&default);
+

+
        let area = render.area;
+
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
+

+
        let text = self.state.text.clone().unwrap_or_default();
+
        let input = text.as_str();
+
        let label_content = format!(" {} ", props.title);
+
        let overline = String::from("▔").repeat(area.width as usize);
+
        let cursor_pos = self.state.cursor_position as u16;
+

+
        let (label, input, overline) = if !render.focus && props.dim {
+
            (
+
                Span::from(label_content.clone()).magenta().dim().reversed(),
+
                Span::from(input).reset().dim(),
+
                Span::raw(overline).magenta().dim(),
+
            )
+
        } else {
+
            (
+
                Span::from(label_content.clone()).magenta().reversed(),
+
                Span::from(input).reset(),
+
                Span::raw(overline).magenta(),
+
            )
+
        };
+

+
        if props.inline_label {
+
            let top_layout = Layout::horizontal([
+
                Constraint::Length(label_content.chars().count() as u16),
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
            ])
+
            .split(layout[0]);
+

+
            let overline = Line::from([overline].to_vec());
+

+
            frame.render_widget(label, top_layout[0]);
+
            frame.render_widget(input, top_layout[2]);
+
            frame.render_widget(overline, layout[1]);
+

+
            if props.show_cursor {
+
                frame.set_cursor_position(Position::new(
+
                    top_layout[2].x + cursor_pos,
+
                    top_layout[2].y,
+
                ))
+
            }
+
        } else {
+
            let top = Line::from([input].to_vec());
+
            let bottom = Line::from([label, overline].to_vec());
+

+
            frame.render_widget(top, layout[0]);
+
            frame.render_widget(bottom, layout[1]);
+

+
            if props.show_cursor {
+
                frame.set_cursor_position(Position::new(area.x + cursor_pos, area.y))
+
            }
+
        }
+
    }
+
}
+

+
/// The state of a `TextArea`.
+
#[derive(Clone, Default, Debug)]
+
pub struct TextAreaState {
+
    /// Current vertical scroll position.
+
    pub scroll: usize,
+
    /// Current cursor position.
+
    pub cursor: (usize, usize),
+
}
+

+
/// The properties of a `TextArea`.
+
#[derive(Clone)]
+
pub struct TextAreaProps<'a> {
+
    /// Content of this text area.
+
    content: Text<'a>,
+
    /// Current cursor position. Default: `(0, 0)`.
+
    cursor: (usize, usize),
+
    /// If this text area should handle events. Default: `true`.
+
    handle_keys: bool,
+
    /// If this text area is in insert mode. Default: `false`.
+
    insert_mode: bool,
+
    /// If this text area should render its scroll progress. Default: `false`.
+
    show_scroll_progress: bool,
+
    /// If this text area should render its cursor progress. Default: `false`.
+
    show_column_progress: bool,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    dim: bool,
+
}
+

+
impl Default for TextAreaProps<'_> {
+
    fn default() -> Self {
+
        Self {
+
            content: String::new().into(),
+
            cursor: (0, 0),
+
            handle_keys: true,
+
            insert_mode: false,
+
            show_scroll_progress: false,
+
            show_column_progress: false,
+
            dim: false,
+
        }
+
    }
+
}
+

+
impl<'a> TextAreaProps<'a> {
+
    pub fn content<T>(mut self, content: T) -> Self
+
    where
+
        T: Into<Text<'a>>,
+
    {
+
        self.content = content.into();
+
        self
+
    }
+

+
    pub fn cursor(mut self, cursor: (usize, usize)) -> Self {
+
        self.cursor = cursor;
+
        self
+
    }
+

+
    pub fn show_scroll_progress(mut self, show_scroll_progress: bool) -> Self {
+
        self.show_scroll_progress = show_scroll_progress;
+
        self
+
    }
+

+
    pub fn show_column_progress(mut self, show_column_progress: bool) -> Self {
+
        self.show_column_progress = show_column_progress;
+
        self
+
    }
+

+
    pub fn handle_keys(mut self, handle_keys: bool) -> Self {
+
        self.handle_keys = handle_keys;
+
        self
+
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
+
}
+

+
/// A non-editable text area that can be behave like a text editor.
+
/// It can scroll through text by moving around the cursor.
+
pub struct TextArea<'a, S, M> {
+
    phantom: PhantomData<(S, M)>,
+
    textarea: tui_textarea::TextArea<'a>,
+
    area: (u16, u16),
+
}
+

+
impl<S, M> Default for TextArea<'_, S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
+
            textarea: tui_textarea::TextArea::default(),
+
            area: (0, 0),
+
        }
+
    }
+
}
+

+
impl<S, M> View for TextArea<'_, S, M> {
+
    type State = S;
+
    type Message = M;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        use tui_textarea::Input;
+

+
        let default = TextAreaProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextAreaProps>())
+
            .unwrap_or(&default);
+

+
        if props.handle_keys {
+
            if !props.insert_mode {
+
                match key {
+
                    Key::Left | Key::Char('h') => {
+
                        self.textarea.input(Input {
+
                            key: tui_textarea::Key::Left,
+
                            ..Default::default()
+
                        });
+
                    }
+
                    Key::Right | Key::Char('l') => {
+
                        self.textarea.input(Input {
+
                            key: tui_textarea::Key::Right,
+
                            ..Default::default()
+
                        });
+
                    }
+
                    Key::Up | Key::Char('k') => {
+
                        self.textarea.input(Input {
+
                            key: tui_textarea::Key::Up,
+
                            ..Default::default()
+
                        });
+
                    }
+
                    Key::Down | Key::Char('j') => {
+
                        self.textarea.input(Input {
+
                            key: tui_textarea::Key::Down,
+
                            ..Default::default()
+
                        });
+
                    }
+
                    _ => {}
+
                }
+
            } else {
+
                // TODO: Implement insert mode.
+
            }
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TextAreaProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextAreaProps>())
+
            .unwrap_or(&default);
+

+
        self.textarea = tui_textarea::TextArea::new(
+
            props
+
                .content
+
                .lines
+
                .iter()
+
                .map(|line| line.to_string())
+
                .collect::<Vec<_>>(),
+
        );
+
    }
+

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = TextAreaProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextAreaProps>())
+
            .unwrap_or(&default);
+

+
        let [area] = Layout::default()
+
            .constraints([Constraint::Min(1)])
+
            .horizontal_margin(1)
+
            .areas(render.area);
+

+
        let [content_area, progress_area] = Layout::vertical([
+
            Constraint::Min(1),
+
            Constraint::Length(
+
                if props.show_scroll_progress || props.show_column_progress {
+
                    1
+
                } else {
+
                    0
+
                },
+
            ),
+
        ])
+
        .areas(area);
+

+
        let cursor_line_style = Style::default();
+
        let cursor_style = if render.focus {
+
            Style::default().reversed()
+
        } else {
+
            cursor_line_style
+
        };
+
        let content_style = if !render.focus && props.dim {
+
            Style::default().dim()
+
        } else {
+
            Style::default()
+
        };
+

+
        self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
+
            props.cursor.0 as u16,
+
            props.cursor.1 as u16,
+
        ));
+
        self.textarea.set_cursor_line_style(cursor_line_style);
+
        self.textarea.set_cursor_style(cursor_style);
+
        self.textarea.set_style(content_style);
+

+
        let (scroll_progress, cursor_progress) = (
+
            utils::scroll::percent_absolute(
+
                self.textarea.cursor().0,
+
                props.content.lines.len(),
+
                content_area.height.into(),
+
            ),
+
            (self.textarea.cursor().0, self.textarea.cursor().1),
+
        );
+

+
        frame.render_widget(&self.textarea, content_area);
+

+
        let mut progress_info = vec![];
+

+
        if props.show_scroll_progress {
+
            progress_info.push(Span::styled(
+
                format!("{scroll_progress}%"),
+
                Style::default().dim(),
+
            ))
+
        }
+

+
        if props.show_scroll_progress && props.show_column_progress {
+
            progress_info.push(Span::raw(" "));
+
        }
+

+
        if props.show_column_progress {
+
            progress_info.push(Span::styled(
+
                format!("[{},{}]", cursor_progress.0, cursor_progress.1),
+
                Style::default().dim(),
+
            ))
+
        }
+

+
        frame.render_widget(
+
            Line::from(progress_info).alignment(Alignment::Right),
+
            progress_area,
+
        );
+

+
        self.area = (content_area.height, content_area.width);
+
    }
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        Some(ViewState::TextArea(TextAreaState {
+
            cursor: self.textarea.cursor(),
+
            scroll: utils::scroll::percent_absolute(
+
                self.textarea.cursor().0.saturating_sub(self.area.0.into()),
+
                self.textarea.lines().len(),
+
                self.area.0.into(),
+
            ),
+
        }))
+
    }
+
}
+

+
/// State of a `TextView`.
+
#[derive(Clone, Default, Debug)]
+
pub struct TextViewState {
+
    /// Current vertical scroll position.
+
    pub scroll: usize,
+
    /// Current cursor position.
+
    pub cursor: (usize, usize),
+
    /// Content of this text view.
+
    pub content: String,
+
}
+

+
impl TextViewState {
+
    pub fn content<T>(mut self, content: T) -> Self
+
    where
+
        T: Into<String>,
+
    {
+
        self.content = content.into();
+
        self
+
    }
+

+
    pub fn cursor(mut self, cursor: (usize, usize)) -> Self {
+
        self.cursor = cursor;
+
        self
+
    }
+

+
    pub fn scroll(mut self, scroll: usize) -> Self {
+
        self.scroll = scroll;
+
        self
+
    }
+

+
    pub fn reset_cursor(&mut self) {
+
        self.cursor = (0, 0);
+
    }
+
}
+

+
/// Properties of a `TextView`.
+
#[derive(Clone)]
+
pub struct TextViewProps<'a> {
+
    /// Optional state. If set, it will override the internal view state.
+
    state: Option<TextViewState>,
+
    /// If this widget should handle events. Default: `true`.
+
    handle_keys: bool,
+
    /// If this widget should render its scroll progress. Default: `false`.
+
    show_scroll_progress: bool,
+
    /// An optional text that is rendered inside the footer bar on the bottom.
+
    footer: Option<Text<'a>>,
+
    /// The style used whenever the widget has focus.
+
    content_style: Style,
+
    /// Default scroll progress style.
+
    scroll_style: Style,
+
    /// Scroll progress style whenever the the widget has focus.
+
    focus_scroll_style: Style,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    dim: bool,
+
}
+

+
impl<'a> TextViewProps<'a> {
+
    pub fn footer<T>(mut self, footer: Option<T>) -> Self
+
    where
+
        T: Into<Text<'a>>,
+
    {
+
        self.footer = footer.map(|f| f.into());
+
        self
+
    }
+

+
    pub fn state(mut self, state: Option<TextViewState>) -> Self {
+
        self.state = state;
+
        self
+
    }
+

+
    pub fn show_scroll_progress(mut self, show_scroll_progress: bool) -> Self {
+
        self.show_scroll_progress = show_scroll_progress;
+
        self
+
    }
+

+
    pub fn handle_keys(mut self, handle_keys: bool) -> Self {
+
        self.handle_keys = handle_keys;
+
        self
+
    }
+

+
    pub fn content_style(mut self, style: Style) -> Self {
+
        self.content_style = style;
+
        self
+
    }
+

+
    pub fn scroll_style(mut self, style: Style) -> Self {
+
        self.scroll_style = style;
+
        self
+
    }
+

+
    pub fn focus_scroll_style(mut self, style: Style) -> Self {
+
        self.focus_scroll_style = style;
+
        self
+
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
+
}
+

+
impl Default for TextViewProps<'_> {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            state: None,
+
            handle_keys: true,
+
            show_scroll_progress: false,
+
            footer: None,
+
            content_style: theme.textview_style,
+
            scroll_style: theme.textview_scroll_style,
+
            focus_scroll_style: theme.textview_focus_scroll_style,
+
            dim: false,
+
        }
+
    }
+
}
+

+
/// A scrollable, non-editable text view widget. It can scroll through text by
+
/// moving around the viewport.
+
pub struct TextView<S, M> {
+
    /// Internal view state.
+
    state: TextViewState,
+
    /// Current render area.
+
    area: (u16, u16),
+
    /// Phantom.
+
    phantom: PhantomData<(S, M)>,
+
}
+

+
impl<S, M> Default for TextView<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            state: TextViewState::default(),
+
            area: (0, 0),
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M> TextView<S, M> {
+
    fn scroll_up(&mut self) {
+
        self.state.cursor.0 = self.state.cursor.0.saturating_sub(1);
+
    }
+

+
    fn scroll_down(&mut self, len: usize, page_size: usize) {
+
        let end = len.saturating_sub(page_size);
+
        self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(1), end);
+
    }
+

+
    fn scroll_left(&mut self) {
+
        self.state.cursor.1 = self.state.cursor.1.saturating_sub(3);
+
    }
+

+
    fn scroll_right(&mut self, max_line_length: usize) {
+
        self.state.cursor.1 = std::cmp::min(
+
            self.state.cursor.1.saturating_add(3),
+
            max_line_length.saturating_add(3),
+
        );
+
    }
+

+
    fn prev_page(&mut self, page_size: usize) {
+
        self.state.cursor.0 = self.state.cursor.0.saturating_sub(page_size);
+
    }
+

+
    fn next_page(&mut self, len: usize, page_size: usize) {
+
        let end = len.saturating_sub(page_size);
+

+
        self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(page_size), end);
+
    }
+

+
    fn begin(&mut self) {
+
        self.state.cursor.0 = 0;
+
    }
+

+
    fn end(&mut self, len: usize, page_size: usize) {
+
        self.state.cursor.0 = len.saturating_sub(page_size);
+
    }
+

+
    fn update_area(&mut self, area: Rect) {
+
        self.area = (area.height, area.width);
+
    }
+

+
    fn render_content(&self, frame: &mut Frame, props: &TextViewProps, render: &RenderProps) {
+
        let content_style = if !render.focus && props.dim {
+
            props.content_style.dim()
+
        } else {
+
            props.content_style
+
        };
+

+
        let content = Paragraph::new(self.state.content.clone())
+
            .style(content_style)
+
            .scroll((self.state.cursor.0 as u16, self.state.cursor.1 as u16));
+

+
        frame.render_widget(content, render.area);
+
    }
+

+
    fn render_footer(
+
        &self,
+
        frame: &mut Frame,
+
        props: &TextViewProps,
+
        render: &RenderProps,
+
        content_height: u16,
+
    ) {
+
        let [text_area, scroll_area] =
+
            Layout::horizontal([Constraint::Min(1), Constraint::Length(10)]).areas(render.area);
+

+
        let scroll_style = if render.focus {
+
            props.focus_scroll_style
+
        } else {
+
            props.scroll_style
+
        };
+

+
        let mut scroll = vec![];
+
        if props.show_scroll_progress {
+
            let content_len = self.state.content.lines().count();
+
            let scroll_progress = utils::scroll::percent_absolute(
+
                self.state.cursor.0,
+
                content_len,
+
                content_height.into(),
+
            );
+
            if (content_height as usize) < content_len {
+
                // vec![Span::styled(format!("All / {}", content_len), scroll_style)]
+
                scroll = vec![Span::styled(format!("{scroll_progress}%"), scroll_style)];
+
            }
+
        }
+

+
        frame.render_widget(
+
            props
+
                .footer
+
                .as_ref()
+
                .cloned()
+
                .unwrap_or_default()
+
                .alignment(Alignment::Left)
+
                .dim(),
+
            text_area,
+
        );
+
        frame.render_widget(Line::from(scroll).alignment(Alignment::Right), scroll_area);
+
    }
+
}
+

+
impl<S, M> View for TextView<S, M>
+
where
+
    S: 'static,
+
    M: 'static,
+
{
+
    type Message = M;
+
    type State = S;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = TextViewProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextViewProps>())
+
            .unwrap_or(&default);
+

+
        let lines = self.state.content.lines().clone();
+
        let len = lines.clone().count();
+
        let max_line_len = lines.map(|l| l.chars().count()).max().unwrap_or_default();
+
        let page_size = self.area.0 as usize;
+

+
        if props.handle_keys {
+
            match key {
+
                Key::Up | Key::Char('k') => {
+
                    self.scroll_up();
+
                }
+
                Key::Down | Key::Char('j') => {
+
                    self.scroll_down(len, page_size);
+
                }
+
                Key::Left | Key::Char('h') => {
+
                    self.scroll_left();
+
                }
+
                Key::Right | Key::Char('l') => {
+
                    self.scroll_right(max_line_len.saturating_sub(self.area.1.into()));
+
                }
+
                Key::PageUp => {
+
                    self.prev_page(page_size);
+
                }
+
                Key::PageDown => {
+
                    self.next_page(len, page_size);
+
                }
+
                Key::Home => {
+
                    self.begin();
+
                }
+
                Key::End => {
+
                    self.end(len, page_size);
+
                }
+
                _ => {}
+
            }
+
        }
+

+
        self.state.scroll = utils::scroll::percent_absolute(
+
            self.state.cursor.0,
+
            self.state.content.lines().count(),
+
            self.area.0.into(),
+
        );
+

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TextViewProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextViewProps>())
+
            .unwrap_or(&default);
+

+
        if let Some(state) = &props.state {
+
            self.state = state.clone();
+
        }
+
    }
+

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = TextViewProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextViewProps>())
+
            .unwrap_or(&default);
+
        let render_footer = props.show_scroll_progress || props.footer.is_some();
+

+
        let [area] = Layout::default()
+
            .constraints([Constraint::Min(1)])
+
            .horizontal_margin(1)
+
            .areas(render.area);
+

+
        if render_footer {
+
            let [content_area, footer_area] = Layout::vertical([
+
                Constraint::Min(1),
+
                Constraint::Length(if render_footer { 1 } else { 0 }),
+
            ])
+
            .areas(area);
+

+
            self.render_content(frame, props, &render.clone().area(content_area));
+
            self.render_footer(frame, props, &render.area(footer_area), content_area.height);
+
            self.update_area(content_area);
+
        } else {
+
            self.render_content(frame, props, &render.clone().area(area));
+
            self.update_area(area);
+
        }
+
    }
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        Some(ViewState::TextView(self.state.clone()))
+
    }
+
}