Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Introduce support for immediate mode UIs
Merged did:key:z6MkswQE...2C1V opened 1 year ago
  • add a new module im that provides an API to write immediate mode UIs on top of the application framework
  • move the existing, retained mode module to rm
  • reimplement patch select with an immediate mode UI
  • add examples for immediate mode UIs
42 files changed +7061 -4116 1114112b 1a46149e
modified README.md
@@ -5,12 +5,13 @@
`radicle-tui` provides various terminal user interfaces for interacting with the [Radicle](https://radicle.xyz) code forge. It also exposes the application framework they were built with.

# Table of Contents
+

1. [Getting Started](#getting-started)
-
    - [Installation](#installation)
-
    - [Usage](#usage)
+
   - [Installation](#installation)
+
   - [Usage](#usage)
2. [Application framework](#application-framework)
-
    - [Design](#design)
-
    - [Example](#example)
+
   - [Design](#design)
+
   - [Example](#example)
3. [Roadmap](#roadmap)
4. [Contributing](#contributing)
5. [Contact](#contact)
@@ -102,6 +103,7 @@ Soon, `rad-tui` will be integrated into [`heartwood`](https://app.radicle.xyz/no
# show an interface that let's you select a patch
./rad.sh patch show
```
+

```sh
# show an interface that let's you select a patch and an operation
./rad.sh patch --tui
@@ -116,6 +118,7 @@ Select a patch, an issue or a notification and an operation:
```
rad-tui <patch | issue | inbox> select
```
+

Same as above:

```
@@ -130,7 +133,7 @@ rad-tui <patch | issue | inbox> select --mode id

#### Output

-
All interfaces return a common JSON object on `stderr` that reflects the choices made by the user, e.g.: 
+
All interfaces return a common JSON object on `stderr` that reflects the choices made by the user, e.g.:

```
{ "operation": "show", "ids": ["546443226b300484a97a2b2d7c7000af6e8169ba"], args:[] }
@@ -138,27 +141,25 @@ All interfaces return a common JSON object on `stderr` that reflects the choices

## Application framework

-
The library portion of this crate is a framework that is the foundation for all `radicle-tui` binaries. The framework is built on top of [ratatui](https://ratatui.rs) and mostly follows the Flux application pattern. It took some ideas from [tui-realm](https://github.com/veeso/tui-realm) and [cursive](https://github.com/gyscos/cursive). The concurrency model was mostly inspired by [rust-chat-server](https://github.com/Yengas/rust-chat-server).
+
The library portion of this crate is a framework that is the foundation for all `radicle-tui` binaries. It supports building concurrent applications with an immediate mode UI. It comes with a widget library that provides low-level widgets such as lists, text fields etc. as well as higher-level application widgets such as windows, pages and various other containers.

-
> **Note**: Existing core functionalities are considered to be stable, but the API may still change at any point. New features like configurations, used-defined keybindings, themes etc. will be added soon though.
-

-
The framework comes with a widget library that provides low-level widgets such as lists, text fields etc. as well as higher-level application widgets such as windows, pages and various other containers.
-

-
> **Note:** The widget library is under heavy development and still missing most low-level widgets. These will be added where needed by the `radicle-tui` binaries.
+
> **Note:** The framework is under heavy development and still missing some required concepts / components as well as some common low-level widgets. These will be added where needed by the `radicle-tui` binaries.

### Design

-
The framework was built with a few design goals in mind:
+
The framework was built first and foremost with developer experience in mind:
+

+
- **easy-to-use**: building new or changing existing applications should be as easy as possible; ready-made widgets should come with defaults for user interactions and rendering
+
- **extensibility**: extending existing and building new widgets should be straight-forward; custom application logic components should be easy to implement
+
- **flexibility**: widgets and application logic should be easy to change and compose; it should be all about changing and composing functions and not about writing boilerplate code

-
- **async**: state updates and IO should be asynchronous and not block the UI
-
- **declarative**: developers should rather think about the *What* then the *How*
-
- **widget library**: custom widgets should be easy to build; ready-made widgets should come with defaults for user interactions and rendering
+
#### Components

The central pieces of the framework are the `Store`, the `Frontend` and a message passing system that let both communicate with each other. The `Store` handles the centralized application state and sends updates to the `Frontend`, whereas the `Frontend` handles user-interactions and sends messages to the `Store`, which updates the state accordingly.

-
On top of this, an extensible widget library was built. A widget is defined by an implementation of the `View` trait and a `Widget` it is wrapped in. A `View` handles user-interactions, updates itself whenever the application state changed and renders itself frequently. A `Widget` adds additional support for properties and event, update and render callbacks. Properties define the data, configuration etc. of a widget. They are updated by the framework taking the properties built by the `on_update` callback. The `on_event` callback is used to emit application messages whenever a widget receives an event.
+
The `Frontend` drives an _immediate mode_ `Ui`. In _immediate mode_, widgets are rendered the moment they're created and events are handled right before the actual drawing happens (in _retained mode_, you'd create stateful widgets once and later modify their properties or handle their events).

-
The main idea is to build widgets that handle their specific events already, and that are updated with the properties built by the `on_update` callback. Custom logic is added by setting the `on_event` callback. E.g. the `Table` widget handles item selection already; items are set via the `on_update` callback and application messages are emitted via the `on_event` callback.
+
> **Note:** The first versions of the framework provided a retained mode UI (rmUI) which was then replaced in favor of an immediate mode UI (imUI). The Retained mode UI is still supported, but it's recommended to use the new immediate mode UI.

### Example

@@ -167,66 +168,57 @@ use anyhow::Result;

use termion::event::Key;

-
use ratatui::text::Text;
+
use ratatui::{Frame, Viewport};

use radicle_tui as tui;

use tui::store;
-
use tui::ui::widget::text::{TextArea, TextAreaProps};
-
use tui::ui::widget::ToWidget;
-
use tui::{BoxedAny, Channel, Exit};
+
use tui::ui::im::widget::Window;
+
use tui::ui::im::Show;
+
use tui::ui::im::{Borders, Context};
+
use tui::{Channel, Exit};

-
/// Centralized application state.
#[derive(Clone, Debug)]
-
struct State {
+
struct App {
    hello: String,
}

-
/// All messages known by the application.
+
#[derive(Clone, Debug)]
enum Message {
    Quit,
}

-
/// Implementation of the app-state trait. It's updated whenever a message was received.
-
/// Applications quit whenever an `Exit` is returned by the `update` function. The `Exit`
-
/// type also provides and optional return value. 
-
impl store::State<()> for State {
-
    type Message = Message;
+
impl store::Update<Message> for App {
+
    type Return = ();

-
    fn update(&mut self, message: Self::Message) -> Option<tui::Exit<()>> {
+
    fn update(&mut self, message: Message) -> Option<tui::Exit<()>> {
        match message {
            Message::Quit => Some(Exit { value: None }),
        }
    }
}

-
/// 1. Initializes the communication channel between frontend and state store
-
/// 2. Initializes the application state
-
/// 3. Builds a textarea widget which renders a welcome message and quits the 
-
///    application when (q) is pressed
-
/// 4. Runs the TUI application
+
impl Show<Message> for App {
+
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, |ui| {
+
            ui.text_view(frame, self.hello.clone(), &mut (0, 0), Some(Borders::None));
+

+
            if ui.input_global(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

#[tokio::main]
pub async fn main() -> Result<()> {
-
    let channel = Channel::default();
-
    let sender = channel.tx.clone();
-
    let state = State {
-
        hello: "Hey there, press (q) to quit...".to_string(),
+
    let app = App {
+
        hello: "Hello World!".to_string(),
    };

-
    let textarea = TextArea::default()
-
        .to_widget(sender.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Char('q') => Some(Message::Quit),
-
            _ => None,
-
        })
-
        .on_update(|state: &State| {
-
            TextAreaProps::default()
-
                .text(&Text::raw(state.hello.clone()))
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    tui::run(channel, state, textarea).await?;
+
    tui::im(app, Viewport::default(), Channel::default()).await?;

    Ok(())
}
@@ -248,7 +240,7 @@ The project roadmap is largely defined by the requirements of the [Radicle](http
- [ ] Support user-defined keybindings
- [ ] Patch review

-
### Later   
+
### Later

- [ ] Streamline CLI integration w/ config and flags for `rad` commands (e.g. `rad patch edit --tui`)`
- [ ] Read COBs from JSON input
@@ -260,12 +252,23 @@ Contributions are what make the open source community such an amazing place to l

If you have any suggestions that would make this better, please clone the repo and open a patch. You can also simply open an issue with the label "enhancement".

+
## License
+

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

+
See [LICENSE-APACHE](https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z39mP9rQAaGmERfUMPULfPUi473tY/tree/LICENSE-APACHE) and [LICENSE-MIT](https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z39mP9rQAaGmERfUMPULfPUi473tY/tree/LICENSE-MIT) for details.
+

## Contact

Please get in touch on [Zulip](https://radicle.zulipchat.com).

-
## License
+
## Acknowledgments

-
`radicle-tui` is distributed under the terms of both the MIT license and the Apache License (Version 2.0).
+
Parts of this project rely on or were heavily inspired by some great open source projects. So we'd like to thank:

-
See [LICENSE-APACHE](https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z39mP9rQAaGmERfUMPULfPUi473tY/tree/LICENSE-APACHE) and [LICENSE-MIT](https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z39mP9rQAaGmERfUMPULfPUi473tY/tree/LICENSE-MIT) for details.
+
- [ratatui](https://ratatui.rs)
+
- [egui](https://github.com/egui)
+
- [tui-realm](https://github.com/veeso/tui-realm)
+
- [tui-textarea](https://github.com/rhysd/tui-textarea)
+
- [tui-rs-tree-widget](https://github.com/EdJoPaTo/tui-rs-tree-widget)
+
- [rust-chat-server](https://github.com/Yengas/rust-chat-server)
modified bin/commands/inbox/select.rs
@@ -5,6 +5,7 @@ use std::str::FromStr;

use anyhow::Result;

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

use ratatui::layout::Constraint;
@@ -21,14 +22,15 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::store;
-
use tui::store::StateValue;
+
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::window::{
+
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
+
};
+
use tui::ui::rm::widget::{ToWidget, Widget};
use tui::ui::span;
-
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::TextView;
-
use tui::ui::widget::input::TextViewProps;
-
use tui::ui::widget::input::TextViewState;
-
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::{ToWidget, Widget};
+
use tui::ui::BufferedValue;
+
use tui::ui::Column;
use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::inbox;
@@ -66,7 +68,7 @@ pub struct BrowserState {
    items: Vec<NotificationItem>,
    selected: Option<usize>,
    filter: NotificationItemFilter,
-
    search: store::StateValue<String>,
+
    search: BufferedValue<String>,
    show_search: bool,
}

@@ -101,7 +103,7 @@ impl TryFrom<&Context> for State {
        let doc = context.repository.identity_doc()?;
        let project = doc.project()?;

-
        let search = StateValue::new(String::new());
+
        let search = BufferedValue::new(String::new());
        let filter = NotificationItemFilter::from_str(&search.read()).unwrap_or_default();

        let mut notifications = match &context.mode.repository() {
@@ -202,6 +204,7 @@ impl TryFrom<&Context> for State {
    }
}

+
#[derive(Clone, Debug)]
pub enum Message {
    Exit { selection: Option<Selection> },
    Select { selected: Option<usize> },
@@ -214,8 +217,8 @@ pub enum Message {
    ScrollHelp { state: TextViewState },
}

-
impl store::State<Selection> for State {
-
    type Message = Message;
+
impl store::Update<Message> for State {
+
    type Return = Selection;

    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
        match message {
@@ -291,7 +294,7 @@ impl App {
                    .into()
            });

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

modified bin/commands/inbox/select/ui.rs
@@ -12,14 +12,15 @@ use ratatui::text::{Line, Text};

use radicle_tui as tui;

-
use tui::ui::span;
-
use tui::ui::widget::container::{
-
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
-
use tui::ui::widget::list::{Table, TableProps};
-
use tui::ui::widget::{self, ViewProps};
-
use tui::ui::widget::{RenderProps, ToWidget, View};
+
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
+
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::{self, ViewProps};
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+
use tui::ui::Column;

use tui::{BoxedAny, Selection};

modified bin/commands/issue/select.rs
@@ -6,6 +6,7 @@ use std::str::FromStr;

use anyhow::{bail, Result};

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

use ratatui::layout::Constraint;
@@ -21,23 +22,25 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::store;
-
use tui::store::StateValue;
-
use tui::ui::span;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::container::{
-
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
};
-
use tui::ui::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::widget::list::{Tree, TreeProps};
-
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::{PredefinedLayout, ToWidget, Widget};
+
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::list::{Tree, TreeProps};
+
use tui::ui::rm::widget::window::{
+
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
+
};
+
use tui::ui::rm::widget::{PredefinedLayout, ToWidget, Widget};
+
use tui::ui::theme::Theme;
+
use tui::ui::Column;
+
use tui::ui::{span, BufferedValue};
use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::issue;
use crate::settings::{self, ThemeBundle, ThemeMode};
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
-
use crate::ui::widget::{BrowserState, IssueDetails, IssueDetailsProps};
+
use crate::ui::rm::{BrowserState, IssueDetails, IssueDetailsProps};
use crate::ui::TerminalInfo;

use self::ui::{Browser, BrowserProps};
@@ -174,7 +177,7 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
        let settings = settings::Settings::default();

        let issues = issue::all(&context.profile, &context.repository)?;
-
        let search = StateValue::new(context.filter.to_string());
+
        let search = BufferedValue::new(context.filter.to_string());
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();

        let default_bundle = ThemeBundle::default();
@@ -231,6 +234,7 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
    }
}

+
#[derive(Clone, Debug)]
pub enum Message {
    Quit,
    Exit { operation: Option<IssueOperation> },
@@ -249,8 +253,8 @@ pub enum Message {
    ScrollHelp { state: TextViewState },
}

-
impl store::State<Selection> for State {
-
    type Message = Message;
+
impl store::Update<Message> for State {
+
    type Return = Selection;

    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
        match message {
@@ -366,7 +370,7 @@ impl App {
                    .into()
            });

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

modified bin/commands/issue/select/ui.rs
@@ -14,15 +14,16 @@ use ratatui::text::{Line, Text};

use radicle_tui as tui;

-
use tui::ui::span;
-
use tui::ui::widget;
-
use tui::ui::widget::container::{
-
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
use tui::ui::rm::widget;
+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
-
use tui::ui::widget::list::{Table, TableProps};
-
use tui::ui::widget::ViewProps;
-
use tui::ui::widget::{RenderProps, ToWidget, View};
+
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
+
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::ViewProps;
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+
use tui::ui::Column;

use tui::BoxedAny;

modified bin/commands/patch.rs
@@ -167,7 +167,7 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
                mode: opts.mode,
                filter: opts.filter.clone(),
            };
-
            let output = select::App::new(context).run().await?;
+
            let output = select::App::new(context, true).run().await?;

            let output = output
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
modified bin/commands/patch/select.rs
@@ -1,13 +1,14 @@
-
#[path = "select/ui.rs"]
-
mod ui;
+
#[path = "select/imui.rs"]
+
mod imui;
+
#[path = "select/rmui.rs"]
+
mod rmui;

use std::str::FromStr;

use anyhow::Result;

-
use radicle::patch::PatchId;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
+
use ratatui::Viewport;
+
use termion::event::Key;

use radicle_tui as tui;

@@ -15,23 +16,28 @@ use ratatui::layout::Constraint;
use ratatui::style::Stylize;
use ratatui::text::Text;

-
use termion::event::Key;
use tui::store;
-
use tui::ui::span;
-
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::{ToWidget, Widget};
+
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::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 self::ui::{Browser, BrowserProps};
+
use radicle::patch::PatchId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;

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

use crate::cob::patch;
use crate::ui::items::{PatchItem, PatchItemFilter};
-
use crate::ui::widget::BrowserState;
+
use crate::ui::rm::BrowserState;

type Selection = tui::Selection<PatchId>;

@@ -44,6 +50,40 @@ pub struct Context {

pub struct App {
    context: Context,
+
    im: bool,
+
}
+

+
impl App {
+
    pub fn new(context: Context, im: bool) -> Self {
+
        Self { context, im }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let viewport = Viewport::Inline(20);
+

+
        if self.im {
+
            let channel = Channel::default();
+
            let state = imui::App::try_from(&self.context)?;
+

+
            tui::im(state, viewport, channel).await
+
        } else {
+
            let channel = Channel::default();
+
            let tx = channel.tx.clone();
+
            let state = State::try_from(&self.context)?;
+
            let window = Window::default()
+
                .page(AppPage::Browse, browser_page(&state, &channel))
+
                .page(AppPage::Help, help_page(&state, &channel))
+
                .to_widget(tx.clone())
+
                .on_update(|state| {
+
                    WindowProps::default()
+
                        .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
+
                        .to_boxed_any()
+
                        .into()
+
                });
+

+
            tui::rm(state, window, viewport, channel).await
+
        }
+
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Hash)]
@@ -70,7 +110,7 @@ impl TryFrom<&Context> for State {

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
        let patches = patch::all(&context.profile, &context.repository)?;
-
        let search = store::StateValue::new(context.filter.to_string());
+
        let search = BufferedValue::new(context.filter.to_string());
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();

        // Convert into UI items
@@ -93,6 +133,7 @@ impl TryFrom<&Context> for State {
    }
}

+
#[derive(Clone, Debug)]
pub enum Message {
    Quit,
    Exit { operation: Option<PatchOperation> },
@@ -107,8 +148,8 @@ pub enum Message {
    ScrollHelp { state: TextViewState },
}

-
impl store::State<Selection> for State {
-
    type Message = Message;
+
impl store::Update<Message> for State {
+
    type Return = Selection;

    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
        match message {
@@ -173,31 +214,6 @@ impl store::State<Selection> for State {
    }
}

-
impl App {
-
    pub fn new(context: Context) -> Self {
-
        Self { context }
-
    }
-

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let channel = Channel::default();
-
        let state = State::try_from(&self.context)?;
-
        let tx = channel.tx.clone();
-

-
        let window = Window::default()
-
            .page(AppPage::Browse, browser_page(&state, &channel))
-
            .page(AppPage::Help, help_page(&state, &channel))
-
            .to_widget(tx.clone())
-
            .on_update(|state| {
-
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
-
                    .to_boxed_any()
-
                    .into()
-
            });
-

-
        tui::run(channel, state, window).await
-
    }
-
}
-

fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
    let tx = channel.tx.clone();

added bin/commands/patch/select/imui.rs
@@ -0,0 +1,647 @@
+
use std::str::FromStr;
+

+
use anyhow::Result;
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::Span;
+
use ratatui::Frame;
+

+
use radicle_tui as tui;
+

+
use tui::ui::im;
+
use tui::ui::im::widget::{GroupState, TableState, TextEditState, TextViewState, Window};
+
use tui::ui::im::Borders;
+
use tui::ui::im::Show;
+
use tui::ui::{BufferedValue, Column};
+
use tui::{store, Exit};
+

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

+
use super::{Context, Selection};
+

+
const HELP: &str = r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Esc`:      Quit / cancel
+

+
# Specific keybindings
+

+
`enter`:    Select patch (if --mode id)
+
`enter`:    Show patch
+
`c`:        Checkout patch
+
`d`:        Show patch diff
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
+
Example:    is:open is:authored improve"#;
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Quit,
+
    Exit {
+
        operation: Option<PatchOperation>,
+
    },
+
    ExitFromMode,
+
    PatchesChanged {
+
        state: TableState,
+
    },
+
    MainGroupChanged {
+
        state: GroupState,
+
    },
+
    PageChanged {
+
        page: Page,
+
    },
+
    HelpChanged {
+
        state: TextViewState,
+
    },
+
    ShowSearch,
+
    UpdateSearch {
+
        search: BufferedValue<TextEditState>,
+
    },
+
    HideSearch {
+
        apply: bool,
+
    },
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Page {
+
    Main,
+
    Help,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct Storage {
+
    patches: Vec<PatchItem>,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct App {
+
    storage: Storage,
+
    mode: Mode,
+
    page: Page,
+
    main_group: GroupState,
+
    patches: TableState,
+
    search: BufferedValue<TextEditState>,
+
    show_search: bool,
+
    help: TextViewState,
+
    filter: PatchItemFilter,
+
}
+

+
impl TryFrom<&Context> for App {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let patches = patch::all(&context.profile, &context.repository)?;
+
        let search = context.filter.to_string();
+
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();
+

+
        let mut items = vec![];
+
        for patch in patches {
+
            if let Ok(item) = PatchItem::new(&context.profile, &context.repository, patch.clone()) {
+
                items.push(item);
+
            }
+
        }
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+

+
        Ok(App {
+
            storage: Storage {
+
                patches: items.clone(),
+
            },
+
            mode: context.mode.clone(),
+
            page: Page::Main,
+
            main_group: GroupState::new(3, Some(0)),
+
            patches: TableState::new(Some(0)),
+
            search: BufferedValue::new(TextEditState {
+
                text: search,
+
                cursor: 0,
+
            }),
+
            show_search: false,
+
            help: TextViewState::new(HELP, (0, 0)),
+
            filter,
+
        })
+
    }
+
}
+

+
impl store::Update<Message> for App {
+
    type Return = Selection;
+

+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
+
        log::debug!("[State] Received message: {:?}", message);
+

+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => self.selected_patch().map(|issue| Exit {
+
                value: Some(Selection {
+
                    operation: operation.map(|op| op.to_string()),
+
                    ids: vec![issue.id],
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ExitFromMode => {
+
                let operation = match self.mode {
+
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
+
                    Mode::Id => None,
+
                };
+

+
                self.selected_patch().map(|issue| Exit {
+
                    value: Some(Selection {
+
                        operation,
+
                        ids: vec![issue.id],
+
                        args: vec![],
+
                    }),
+
                })
+
            }
+
            Message::PatchesChanged { state } => {
+
                self.patches = state;
+
                None
+
            }
+
            Message::MainGroupChanged { state } => {
+
                self.main_group = state;
+
                None
+
            }
+
            Message::PageChanged { page } => {
+
                self.page = page;
+
                None
+
            }
+
            Message::ShowSearch => {
+
                self.main_group = GroupState::new(3, None);
+
                self.show_search = true;
+
                None
+
            }
+
            Message::HideSearch { apply } => {
+
                self.main_group = GroupState::new(3, Some(0));
+
                self.show_search = false;
+

+
                if apply {
+
                    self.search.apply();
+
                } else {
+
                    self.search.reset();
+
                }
+

+
                self.filter =
+
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
+

+
                None
+
            }
+
            Message::UpdateSearch { search } => {
+
                self.search = search;
+
                self.filter =
+
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
+
                self.patches.select_first();
+
                None
+
            }
+
            Message::HelpChanged { state } => {
+
                self.help = state;
+
                None
+
            }
+
        }
+
    }
+
}
+

+
impl Show<Message> for App {
+
    fn show(&self, ctx: &im::Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, |ui| {
+
            match self.page {
+
                Page::Main => {
+
                    let show_search = self.show_search;
+
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
+
                    let mut group_focus = self.main_group.focus();
+

+
                    ui.group(
+
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
+
                        &mut page_focus,
+
                        |ui| {
+
                            let group = ui.group(
+
                                im::Layout::Expandable3 { left_only: true },
+
                                &mut group_focus,
+
                                |ui| {
+
                                    self.show_patches(frame, ui);
+

+
                                    ui.text_view(
+
                                        frame,
+
                                        String::new(),
+
                                        &mut (0, 0),
+
                                        Some(Borders::All),
+
                                    );
+
                                    ui.text_view(
+
                                        frame,
+
                                        String::new(),
+
                                        &mut (0, 0),
+
                                        Some(Borders::All),
+
                                    );
+
                                },
+
                            );
+
                            if group.response.changed {
+
                                ui.send_message(Message::MainGroupChanged {
+
                                    state: GroupState::new(3, group_focus),
+
                                });
+
                            }
+

+
                            if show_search {
+
                                self.show_search_text_edit(frame, ui);
+
                            } else {
+
                                ui.layout(Layout::vertical([1, 1]), |ui| {
+
                                    ui.bar(
+
                                        frame,
+
                                        match group_focus {
+
                                            Some(0) => browser_context(ui, self),
+
                                            _ => default_context(ui),
+
                                        },
+
                                        Some(Borders::None),
+
                                    );
+

+
                                    ui.shortcuts(
+
                                        frame,
+
                                        &match self.mode {
+
                                            Mode::Id => {
+
                                                [("enter", "select"), ("/", "search")].to_vec()
+
                                            }
+
                                            Mode::Operation => [
+
                                                ("enter", "show"),
+
                                                ("c", "checkout"),
+
                                                ("d", "diff"),
+
                                                ("/", "search"),
+
                                                ("?", "help"),
+
                                            ]
+
                                            .to_vec(),
+
                                        },
+
                                        '∙',
+
                                    );
+
                                });
+

+
                                if ui.input_global(|key| key == Key::Esc) {
+
                                    ui.send_message(Message::Quit);
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('?')) {
+
                                    ui.send_message(Message::PageChanged { page: Page::Help });
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('\n')) {
+
                                    ui.send_message(Message::ExitFromMode);
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('d')) {
+
                                    ui.send_message(Message::Exit {
+
                                        operation: Some(PatchOperation::Diff),
+
                                    });
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('c')) {
+
                                    ui.send_message(Message::Exit {
+
                                        operation: Some(PatchOperation::Checkout),
+
                                    });
+
                                }
+
                            }
+
                        },
+
                    );
+
                }
+

+
                Page::Help => {
+
                    let mut cursor = self.help.cursor();
+

+
                    let layout = Layout::vertical([
+
                        Constraint::Length(3),
+
                        Constraint::Fill(1),
+
                        Constraint::Length(1),
+
                        Constraint::Length(1),
+
                    ]);
+

+
                    ui.layout(layout, |ui| {
+
                        ui.set_focus(Some(0));
+
                        ui.columns(
+
                            frame,
+
                            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
                            Some(Borders::Top),
+
                        );
+

+
                        ui.set_focus(Some(1));
+
                        let text_view = ui.text_view(
+
                            frame,
+
                            self.help.text().to_string(),
+
                            &mut cursor,
+
                            Some(Borders::BottomSides),
+
                        );
+
                        if text_view.changed {
+
                            ui.send_message(Message::HelpChanged {
+
                                state: TextViewState::new(self.help.text().to_string(), cursor),
+
                            })
+
                        }
+

+
                        ui.bar(
+
                            frame,
+
                            [
+
                                Column::new(
+
                                    Span::raw(" ".to_string())
+
                                        .into_left_aligned_line()
+
                                        .style(ui.theme().bar_on_black_style),
+
                                    Constraint::Fill(1),
+
                                ),
+
                                Column::new(
+
                                    Span::raw(" ")
+
                                        .into_right_aligned_line()
+
                                        .cyan()
+
                                        .dim()
+
                                        .reversed(),
+
                                    Constraint::Length(6),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                            Some(Borders::None),
+
                        );
+

+
                        ui.shortcuts(frame, &[("?", "close")], '∙');
+
                    });
+

+
                    if ui.input_global(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::PageChanged { page: Page::Main });
+
                    }
+
                    if ui.input_global(|key| key == Key::Esc) {
+
                        ui.send_message(Message::Quit);
+
                    }
+
                }
+
            }
+
            if ui.input_global(|key| key == Key::Ctrl('c')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

+
impl App {
+
    pub fn show_patches(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let patches = self
+
            .storage
+
            .patches
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .cloned()
+
            .collect::<Vec<_>>();
+
        let mut selected = self.patches.selected();
+

+
        let header = [
+
            Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)),
+
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)),
+
            Column::new(Span::raw("Title").bold(), Constraint::Fill(1)),
+
            Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_small(),
+
            Column::new("", Constraint::Length(16)).hide_medium(),
+
            Column::new(Span::raw("Head").bold(), Constraint::Length(8)).hide_small(),
+
            Column::new(Span::raw("+").bold(), Constraint::Length(6)).hide_small(),
+
            Column::new(Span::raw("-").bold(), Constraint::Length(6)).hide_small(),
+
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)).hide_small(),
+
        ];
+

+
        let table = ui.headered_table(frame, &mut selected, &patches, header);
+
        if table.changed {
+
            ui.send_message(Message::PatchesChanged {
+
                state: TableState::new(selected),
+
            });
+
        }
+

+
        // TODO(erikli): Should only work if table has focus
+
        if ui.input_global(|key| key == Key::Char('/')) {
+
            ui.send_message(Message::ShowSearch);
+
        }
+
    }
+

+
    pub fn show_search_text_edit(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let (mut search_text, mut search_cursor) = (
+
            self.search.clone().read().text,
+
            self.search.clone().read().cursor,
+
        );
+
        let mut search = self.search.clone();
+

+
        let text_edit = ui.text_edit_labeled_singleline(
+
            frame,
+
            &mut search_text,
+
            &mut search_cursor,
+
            "Search".to_string(),
+
            Some(Borders::Spacer { top: 0, left: 0 }),
+
        );
+

+
        if text_edit.changed {
+
            search.write(TextEditState {
+
                text: search_text,
+
                cursor: search_cursor,
+
            });
+
            ui.send_message(Message::UpdateSearch { search });
+
        }
+

+
        if ui.input_global(|key| key == Key::Esc) {
+
            ui.send_message(Message::HideSearch { apply: false });
+
        }
+
        if ui.input_global(|key| key == Key::Char('\n')) {
+
            ui.send_message(Message::HideSearch { apply: true });
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn selected_patch(&self) -> Option<&PatchItem> {
+
        let patches = self
+
            .storage
+
            .patches
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .collect::<Vec<_>>();
+

+
        self.patches
+
            .selected()
+
            .and_then(|selected| patches.get(selected))
+
            .copied()
+
    }
+
}
+

+
fn browser_context<'a>(ui: &im::Ui<Message>, app: &'a App) -> Vec<Column<'a>> {
+
    let search = app.search.read().text;
+
    let total_count = app.storage.patches.len();
+
    let filtered_count = app
+
        .storage
+
        .patches
+
        .iter()
+
        .filter(|patch| app.filter.matches(patch))
+
        .collect::<Vec<_>>()
+
        .len();
+
    let experimental = false;
+

+
    if experimental {
+
        [
+
            Column::new(
+
                Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                Constraint::Length(8),
+
            ),
+
            Column::new(Span::raw("".to_string()), Constraint::Length(1)),
+
            Column::new(
+
                Span::raw(format!(" {} ", search))
+
                    .into_left_aligned_line()
+
                    .cyan()
+
                    .dim()
+
                    .reversed(),
+
                Constraint::Length((search.chars().count() + 2) as u16),
+
            ),
+
            Column::new(Span::raw("".to_string()), Constraint::Fill(1)),
+
            Column::new(
+
                Span::raw(" 0% ")
+
                    .into_right_aligned_line()
+
                    .red()
+
                    .dim()
+
                    .reversed(),
+
                Constraint::Length(6),
+
            ),
+
        ]
+
        .to_vec()
+
    } else {
+
        let filtered_counts = format!(" {filtered_count}/{total_count} ");
+
        let state_counts =
+
            app.storage
+
                .patches
+
                .iter()
+
                .fold((0, 0, 0, 0), |counts, patch| match patch.state {
+
                    radicle::patch::State::Draft => (counts.0 + 1, counts.1, counts.2, counts.3),
+
                    radicle::patch::State::Open { conflicts: _ } => {
+
                        (counts.0, counts.1 + 1, counts.2, counts.3)
+
                    }
+
                    radicle::patch::State::Archived => (counts.0, counts.1, counts.2 + 1, counts.3),
+
                    radicle::patch::State::Merged {
+
                        revision: _,
+
                        commit: _,
+
                    } => (counts.0, counts.1, counts.2, counts.3 + 1),
+
                });
+

+
        if app.filter.is_default() {
+
            let draft = format!(" {} ", state_counts.0);
+
            let open = format!(" {} ", state_counts.1);
+
            let archived = format!(" {} ", state_counts.2);
+
            let merged = format!(" {} ", state_counts.3);
+
            [
+
                Column::new(
+
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(8),
+
                ),
+
                Column::new(
+
                    Span::raw(format!(" {search} "))
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(draft.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(draft.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .green()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(open.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(open.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .yellow()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(archived.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(archived.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("✔")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .magenta()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(merged.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(merged.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw(filtered_counts.clone())
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(filtered_counts.chars().count() as u16),
+
                ),
+
            ]
+
            .to_vec()
+
        } else {
+
            [
+
                Column::new(
+
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(8),
+
                ),
+
                Column::new(
+
                    Span::raw(format!(" {search} "))
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    Span::raw(filtered_counts.clone())
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(filtered_counts.chars().count() as u16),
+
                ),
+
            ]
+
            .to_vec()
+
        }
+
    }
+
}
+

+
fn default_context<'a>(ui: &im::Ui<Message>) -> Vec<Column<'a>> {
+
    [
+
        Column::new(
+
            Span::raw(" ".to_string())
+
                .into_left_aligned_line()
+
                .style(ui.theme().bar_on_black_style),
+
            Constraint::Fill(1),
+
        ),
+
        Column::new(
+
            Span::raw(" 0% ")
+
                .into_right_aligned_line()
+
                .cyan()
+
                .dim()
+
                .reversed(),
+
            Constraint::Length(6),
+
        ),
+
    ]
+
    .to_vec()
+
}
added bin/commands/patch/select/rmui.rs
@@ -0,0 +1,330 @@
+
use std::collections::HashMap;
+
use std::str::FromStr;
+
use std::vec;
+

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

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Text};
+

+
use radicle::patch;
+
use radicle::patch::Status;
+

+
use radicle_tui as tui;
+

+
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::ViewProps;
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+
use tui::ui::Column;
+

+
use tui::BoxedAny;
+

+
use crate::ui::items::{PatchItem, PatchItemFilter};
+

+
use super::{Message, State};
+

+
type Widget = widget::Widget<State, Message>;
+

+
#[derive(Clone, Default)]
+
pub struct BrowserProps<'a> {
+
    /// Filtered patches.
+
    patches: Vec<PatchItem>,
+
    /// Patch statistics.
+
    stats: HashMap<String, usize>,
+
    /// Header columns
+
    header: Vec<Column<'a>>,
+
    /// Table columns
+
    columns: Vec<Column<'a>>,
+
    /// If search widget should be shown.
+
    show_search: bool,
+
    /// Current search string.
+
    search: String,
+
}
+

+
impl<'a> From<&State> for BrowserProps<'a> {
+
    fn from(state: &State) -> Self {
+
        let mut draft = 0;
+
        let mut open = 0;
+
        let mut archived = 0;
+
        let mut merged = 0;
+

+
        let patches = state.browser.items();
+

+
        for patch in &patches {
+
            match patch.state {
+
                patch::State::Draft => draft += 1,
+
                patch::State::Open { conflicts: _ } => open += 1,
+
                patch::State::Archived => archived += 1,
+
                patch::State::Merged {
+
                    commit: _,
+
                    revision: _,
+
                } => merged += 1,
+
            }
+
        }
+

+
        let stats = HashMap::from([
+
            ("Draft".to_string(), draft),
+
            ("Open".to_string(), open),
+
            ("Archived".to_string(), archived),
+
            ("Merged".to_string(), merged),
+
        ]);
+

+
        Self {
+
            patches,
+
            stats,
+
            header: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(1)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Head", Constraint::Length(8)).hide_small(),
+
                Column::new("+", Constraint::Length(6)).hide_small(),
+
                Column::new("-", Constraint::Length(6)).hide_small(),
+
                Column::new("Updated", Constraint::Length(16)).hide_small(),
+
            ]
+
            .to_vec(),
+
            columns: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(1)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Head", Constraint::Length(8)).hide_small(),
+
                Column::new("+", Constraint::Length(6)).hide_small(),
+
                Column::new("-", Constraint::Length(6)).hide_small(),
+
                Column::new("Updated", Constraint::Length(16)).hide_small(),
+
            ]
+
            .to_vec(),
+
            show_search: state.browser.is_search_shown(),
+
            search: state.browser.read_search(),
+
        }
+
    }
+
}
+

+
pub struct Browser {
+
    /// Patches widget
+
    patches: Widget,
+
    /// Search widget
+
    search: Widget,
+
}
+

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
        Self {
+
            patches: Container::default()
+
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+
                    HeaderProps::default()
+
                        .columns(props.header.clone())
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, PatchItem, 9>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            let (selected, _) =
+
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
+
                            Some(Message::SelectPatch {
+
                                selected: Some(selected),
+
                            })
+
                        })
+
                        .on_update(|state| {
+
                            // TODO: remove and use state directly
+
                            let props = BrowserProps::from(state);
+
                            TableProps::default()
+
                                .columns(props.columns)
+
                                .items(state.browser.items())
+
                                .selected(state.browser.selected())
+
                                .to_boxed_any()
+
                                .into()
+
                        }),
+
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browser_footer(&props))
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone())
+
                .on_update(|state| {
+
                    ContainerProps::default()
+
                        .hide_footer(BrowserProps::from(state).show_search)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
            search: TextField::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, s, _| {
+
                    Some(Message::UpdateSearch {
+
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    TextFieldProps::default()
+
                        .text(&state.browser.read_search())
+
                        .title("Search")
+
                        .inline(true)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        }
+
    }
+
}
+

+
impl View for Browser {
+
    type Message = Message;
+
    type State = State;
+

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

+
        if props.show_search {
+
            match key {
+
                Key::Esc => {
+
                    self.search.reset();
+
                    Some(Message::CloseSearch)
+
                }
+
                Key::Char('\n') => Some(Message::ApplySearch),
+
                _ => {
+
                    self.search.handle_event(key);
+
                    None
+
                }
+
            }
+
        } else {
+
            match key {
+
                Key::Char('/') => Some(Message::OpenSearch),
+
                _ => {
+
                    self.patches.handle_event(key);
+
                    None
+
                }
+
            }
+
        }
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        self.patches.update(state);
+
        self.search.update(state);
+
    }
+

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

+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
+
            let [_, search_area, _] = Layout::horizontal([
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
                Constraint::Length(1),
+
            ])
+
            .areas(search_area);
+

+
            self.patches.render(RenderProps::from(table_area), frame);
+
            self.search
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
+
        } else {
+
            self.patches.render(render, frame);
+
        }
+
    }
+
}
+

+
fn browser_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
+
    let filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();
+

+
    let search = Line::from(vec![
+
        span::default(" Search ").cyan().dim().reversed(),
+
        span::default(" "),
+
        span::default(&props.search.to_string()).gray().dim(),
+
    ]);
+

+
    let draft = Line::from(vec![
+
        span::default(&props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
+
        span::default(" Draft").dim(),
+
    ]);
+

+
    let open = Line::from(vec![
+
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
        span::default(" Open").dim(),
+
    ]);
+

+
    let merged = Line::from(vec![
+
        span::default(&props.stats.get("Merged").unwrap_or(&0).to_string())
+
            .magenta()
+
            .dim(),
+
        span::default(" Merged").dim(),
+
    ]);
+

+
    let archived = Line::from(vec![
+
        span::default(&props.stats.get("Archived").unwrap_or(&0).to_string())
+
            .yellow()
+
            .dim(),
+
        span::default(" Archived").dim(),
+
    ]);
+

+
    let sum = Line::from(vec![
+
        span::default("Σ ").dim(),
+
        span::default(&props.patches.len().to_string()).dim(),
+
    ]);
+

+
    match filter.status() {
+
        Some(state) => {
+
            let block = match state {
+
                Status::Draft => draft,
+
                Status::Open => open,
+
                Status::Merged => merged,
+
                Status::Archived => archived,
+
            };
+

+
            vec![
+
                Column::new(Text::from(search), Constraint::Fill(1)),
+
                Column::new(
+
                    Text::from(block.clone()),
+
                    Constraint::Min(block.width() as u16),
+
                ),
+
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
            ]
+
        }
+
        None => vec![
+
            Column::new(Text::from(search), Constraint::Fill(1)),
+
            Column::new(
+
                Text::from(draft.clone()),
+
                Constraint::Min(draft.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(open.clone()),
+
                Constraint::Min(open.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(merged.clone()),
+
                Constraint::Min(merged.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(archived.clone()),
+
                Constraint::Min(archived.width() as u16),
+
            ),
+
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
        ],
+
    }
+
}
deleted bin/commands/patch/select/ui.rs
@@ -1,329 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::vec;
-

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

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Text};
-

-
use radicle::patch;
-
use radicle::patch::Status;
-

-
use radicle_tui as tui;
-

-
use tui::ui::span;
-
use tui::ui::widget;
-
use tui::ui::widget::container::{
-
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
-
};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
-
use tui::ui::widget::list::{Table, TableProps};
-
use tui::ui::widget::ViewProps;
-
use tui::ui::widget::{RenderProps, ToWidget, View};
-

-
use tui::BoxedAny;
-

-
use crate::ui::items::{PatchItem, PatchItemFilter};
-

-
use super::{Message, State};
-

-
type Widget = widget::Widget<State, Message>;
-

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Filtered patches.
-
    patches: Vec<PatchItem>,
-
    /// Patch statistics.
-
    stats: HashMap<String, usize>,
-
    /// Header columns
-
    header: Vec<Column<'a>>,
-
    /// Table columns
-
    columns: Vec<Column<'a>>,
-
    /// If search widget should be shown.
-
    show_search: bool,
-
    /// Current search string.
-
    search: String,
-
}
-

-
impl<'a> From<&State> for BrowserProps<'a> {
-
    fn from(state: &State) -> Self {
-
        let mut draft = 0;
-
        let mut open = 0;
-
        let mut archived = 0;
-
        let mut merged = 0;
-

-
        let patches = state.browser.items();
-

-
        for patch in &patches {
-
            match patch.state {
-
                patch::State::Draft => draft += 1,
-
                patch::State::Open { conflicts: _ } => open += 1,
-
                patch::State::Archived => archived += 1,
-
                patch::State::Merged {
-
                    commit: _,
-
                    revision: _,
-
                } => merged += 1,
-
            }
-
        }
-

-
        let stats = HashMap::from([
-
            ("Draft".to_string(), draft),
-
            ("Open".to_string(), open),
-
            ("Archived".to_string(), archived),
-
            ("Merged".to_string(), merged),
-
        ]);
-

-
        Self {
-
            patches,
-
            stats,
-
            header: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(1)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Head", Constraint::Length(8)).hide_small(),
-
                Column::new("+", Constraint::Length(6)).hide_small(),
-
                Column::new("-", Constraint::Length(6)).hide_small(),
-
                Column::new("Updated", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            columns: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(1)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Head", Constraint::Length(8)).hide_small(),
-
                Column::new("+", Constraint::Length(6)).hide_small(),
-
                Column::new("-", Constraint::Length(6)).hide_small(),
-
                Column::new("Updated", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            show_search: state.browser.is_search_shown(),
-
            search: state.browser.read_search(),
-
        }
-
    }
-
}
-

-
pub struct Browser {
-
    /// Patches widget
-
    patches: Widget,
-
    /// Search widget
-
    search: Widget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
-
        Self {
-
            patches: Container::default()
-
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-
                    HeaderProps::default()
-
                        .columns(props.header.clone())
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, PatchItem, 9>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::SelectPatch {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            // TODO: remove and use state directly
-
                            let props = BrowserProps::from(state);
-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(state.browser.items())
-
                                .selected(state.browser.selected())
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browser_footer(&props))
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    ContainerProps::default()
-
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
            search: TextField::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    Some(Message::UpdateSearch {
-
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.read_search())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        }
-
    }
-
}
-

-
impl View for Browser {
-
    type Message = Message;
-
    type State = State;
-

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

-
        if props.show_search {
-
            match key {
-
                Key::Esc => {
-
                    self.search.reset();
-
                    Some(Message::CloseSearch)
-
                }
-
                Key::Char('\n') => Some(Message::ApplySearch),
-
                _ => {
-
                    self.search.handle_event(key);
-
                    None
-
                }
-
            }
-
        } else {
-
            match key {
-
                Key::Char('/') => Some(Message::OpenSearch),
-
                _ => {
-
                    self.patches.handle_event(key);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.patches.update(state);
-
        self.search.update(state);
-
    }
-

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

-
        if props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
-
            let [_, search_area, _] = Layout::horizontal([
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
                Constraint::Length(1),
-
            ])
-
            .areas(search_area);
-

-
            self.patches.render(RenderProps::from(table_area), frame);
-
            self.search
-
                .render(RenderProps::from(search_area).focus(render.focus), frame);
-
        } else {
-
            self.patches.render(render, frame);
-
        }
-
    }
-
}
-

-
fn browser_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
-
    let filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();
-

-
    let search = Line::from(vec![
-
        span::default(" Search ").cyan().dim().reversed(),
-
        span::default(" "),
-
        span::default(&props.search.to_string()).gray().dim(),
-
    ]);
-

-
    let draft = Line::from(vec![
-
        span::default(&props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Draft").dim(),
-
    ]);
-

-
    let open = Line::from(vec![
-
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Open").dim(),
-
    ]);
-

-
    let merged = Line::from(vec![
-
        span::default(&props.stats.get("Merged").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Merged").dim(),
-
    ]);
-

-
    let archived = Line::from(vec![
-
        span::default(&props.stats.get("Archived").unwrap_or(&0).to_string())
-
            .yellow()
-
            .dim(),
-
        span::default(" Archived").dim(),
-
    ]);
-

-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.patches.len().to_string()).dim(),
-
    ]);
-

-
    match filter.status() {
-
        Some(state) => {
-
            let block = match state {
-
                Status::Draft => draft,
-
                Status::Open => open,
-
                Status::Merged => merged,
-
                Status::Archived => archived,
-
            };
-

-
            vec![
-
                Column::new(Text::from(search), Constraint::Fill(1)),
-
                Column::new(
-
                    Text::from(block.clone()),
-
                    Constraint::Min(block.width() as u16),
-
                ),
-
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
            ]
-
        }
-
        None => vec![
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(draft.clone()),
-
                Constraint::Min(draft.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(open.clone()),
-
                Constraint::Min(open.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(merged.clone()),
-
                Constraint::Min(merged.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(archived.clone()),
-
                Constraint::Min(archived.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ],
-
    }
-
}
modified bin/ui.rs
@@ -1,6 +1,7 @@
pub mod format;
+
pub mod im;
pub mod items;
-
pub mod widget;
+
pub mod rm;

#[derive(Clone, Debug)]
pub struct TerminalInfo {
added bin/ui/im.rs
@@ -0,0 +1,190 @@
+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::Frame;
+

+
use radicle_tui as tui;
+

+
use tui::ui::im::widget::{TableState, TextEditState, Widget};
+
use tui::ui::im::{Borders, Response, Ui};
+
use tui::ui::{BufferedValue, Column, ToRow};
+

+
pub struct UiExt<'a, M>(&'a mut Ui<M>);
+

+
impl<'a, M> UiExt<'a, M> {
+
    pub fn new(ui: &'a mut Ui<M>) -> Self {
+
        Self(ui)
+
    }
+
}
+

+
impl<'a, M> From<&'a mut Ui<M>> for UiExt<'a, M> {
+
    fn from(ui: &'a mut Ui<M>) -> Self {
+
        Self::new(ui)
+
    }
+
}
+

+
#[allow(dead_code)]
+
impl<'a, M> UiExt<'a, M>
+
where
+
    M: Clone,
+
{
+
    #[allow(clippy::too_many_arguments)]
+
    pub fn browser<R, const W: usize>(
+
        &mut self,
+
        frame: &mut Frame,
+
        selected: &'a mut Option<usize>,
+
        items: &'a Vec<R>,
+
        header: impl IntoIterator<Item = Column<'a>>,
+
        footer: impl IntoIterator<Item = Column<'a>>,
+
        show_search: &'a mut bool,
+
        search: &'a mut BufferedValue<TextEditState>,
+
    ) -> Response
+
    where
+
        R: ToRow<W> + Clone,
+
    {
+
        Browser::<R, W>::new(selected, items, header, footer, show_search, search).ui(self.0, frame)
+
    }
+
}
+

+
#[allow(dead_code)]
+
#[derive(Clone, Debug)]
+
pub struct BrowserState {
+
    items: TableState,
+
    search: BufferedValue<TextEditState>,
+
    show_search: bool,
+
}
+

+
#[allow(dead_code)]
+
impl BrowserState {
+
    pub fn new(items: TableState, search: BufferedValue<TextEditState>, show_search: bool) -> Self {
+
        Self {
+
            items,
+
            search,
+
            show_search,
+
        }
+
    }
+

+
    pub fn selected(&self) -> Option<usize> {
+
        self.items.selected()
+
    }
+
}
+

+
pub struct Browser<'a, R, const W: usize> {
+
    items: &'a Vec<R>,
+
    selected: &'a mut Option<usize>,
+
    header: Vec<Column<'a>>,
+
    footer: Vec<Column<'a>>,
+
    show_search: &'a mut bool,
+
    search: &'a mut BufferedValue<TextEditState>,
+
}
+

+
#[allow(dead_code)]
+
impl<'a, R, const W: usize> Browser<'a, R, W> {
+
    pub fn new(
+
        selected: &'a mut Option<usize>,
+
        items: &'a Vec<R>,
+
        header: impl IntoIterator<Item = Column<'a>>,
+
        footer: impl IntoIterator<Item = Column<'a>>,
+
        show_search: &'a mut bool,
+
        search: &'a mut BufferedValue<TextEditState>,
+
    ) -> Self {
+
        Self {
+
            items,
+
            selected,
+
            header: header.into_iter().collect(),
+
            footer: footer.into_iter().collect(),
+
            show_search,
+
            search,
+
        }
+
    }
+

+
    pub fn items(&self) -> &Vec<R> {
+
        self.items
+
    }
+
}
+

+
/// TODO(erikli): Implement `show` that returns an `InnerResponse` such that it can
+
/// used like a group.
+
impl<'a, R, const W: usize> Widget for Browser<'a, R, W>
+
where
+
    R: ToRow<W> + Clone,
+
{
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        let mut response = Response::default();
+

+
        let (_, has_focus) = ui.current_area().unwrap_or_default();
+
        let (mut text, mut cursor) = (self.search.read().text, self.search.read().cursor);
+

+
        ui.layout(
+
            Layout::vertical([
+
                Constraint::Length(3),
+
                Constraint::Min(1),
+
                Constraint::Length(if *self.show_search { 2 } else { 3 }),
+
            ]),
+
            |ui| {
+
                // TODO(erikli): Find better solution for border focus workaround or improve
+
                // interface for manually advancing / setting the focus index.
+
                if has_focus && !*self.show_search {
+
                    ui.set_focus(Some(0));
+
                }
+
                ui.columns(frame, self.header.clone().to_vec(), Some(Borders::Top));
+

+
                if has_focus && !*self.show_search {
+
                    ui.set_focus(Some(1));
+
                }
+
                let table = ui.table(
+
                    frame,
+
                    self.selected,
+
                    self.items,
+
                    self.header.to_vec(),
+
                    if *self.show_search {
+
                        Some(Borders::BottomSides)
+
                    } else {
+
                        Some(Borders::Sides)
+
                    },
+
                );
+
                response.changed |= table.changed;
+

+
                if *self.show_search {
+
                    if has_focus {
+
                        ui.set_focus(Some(2));
+
                    }
+
                    let text_edit = ui.text_edit_labeled_singleline(
+
                        frame,
+
                        &mut text,
+
                        &mut cursor,
+
                        "Search".to_string(),
+
                        Some(Borders::Spacer { top: 0, left: 1 }),
+
                    );
+
                    self.search.write(TextEditState { text, cursor });
+
                    response.changed |= text_edit.changed;
+
                } else {
+
                    if has_focus {
+
                        ui.set_focus(Some(2));
+
                    }
+
                    ui.columns(frame, self.footer.clone().to_vec(), Some(Borders::Bottom));
+
                }
+
            },
+
        );
+

+
        if !*self.show_search {
+
            if ui.input_global(|key| key == Key::Char('/')) {
+
                *self.show_search = true;
+
            }
+
        } else {
+
            if ui.input_global(|key| key == Key::Esc) {
+
                *self.show_search = false;
+
                self.search.reset();
+
            }
+
            if ui.input_global(|key| key == Key::Char('\n')) {
+
                *self.show_search = false;
+
                self.search.apply();
+
            }
+
        }
+

+
        response
+
    }
+
}
modified bin/ui/items.rs
@@ -30,7 +30,7 @@ use radicle_tui as tui;

use tui::ui::span;
use tui::ui::theme::style;
-
use tui::ui::widget::list::{ToRow, ToTree};
+
use tui::ui::{ToRow, ToTree};

use super::super::git;
use super::format;
@@ -817,6 +817,10 @@ impl PatchItemFilter {
    pub fn status(&self) -> Option<patch::Status> {
        self.status
    }
+

+
    pub fn is_default(&self) -> bool {
+
        *self == PatchItemFilter::default()
+
    }
}

impl Filter<PatchItem> for PatchItemFilter {
@@ -899,11 +903,17 @@ impl FromStr for PatchItemFilter {
            }
        }

+
        let search = if search.is_empty() {
+
            None
+
        } else {
+
            Some(search)
+
        };
+

        Ok(Self {
            status,
            authored,
            authors,
-
            search: Some(search),
+
            search,
        })
    }
}
added bin/ui/rm.rs
@@ -0,0 +1,266 @@
+
use std::marker::PhantomData;
+
use std::str::FromStr;
+

+
use radicle::issue::{self, CloseReason};
+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Span, Text};
+
use ratatui::widgets::Row;
+
use ratatui::Frame;
+

+
use radicle_tui as tui;
+

+
use tui::ui::rm::widget::{RenderProps, View, ViewProps};
+
use tui::ui::theme::style;
+
use tui::ui::{layout, span, BufferedValue};
+

+
use super::format;
+
use super::items::IssueItem;
+

+
use crate::ui::items::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
+
/// buffered search field. The search fields value is used to build an
+
/// item filter that the item list reacts on dynamically.
+
#[derive(Clone, Debug)]
+
pub struct BrowserState<I, F> {
+
    items: Vec<I>,
+
    selected: Option<usize>,
+
    filter: F,
+
    search: BufferedValue<String>,
+
    show_search: bool,
+
}
+

+
impl<I, F> Default for BrowserState<I, F>
+
where
+
    I: Clone,
+
    F: Filter<I> + Default + FromStr,
+
{
+
    fn default() -> Self {
+
        Self {
+
            items: vec![],
+
            selected: None,
+
            filter: F::default(),
+
            search: BufferedValue::new(String::default()),
+
            show_search: false,
+
        }
+
    }
+
}
+

+
impl<I, F> BrowserState<I, F>
+
where
+
    I: Clone,
+
    F: Filter<I> + Default + FromStr,
+
{
+
    pub fn build(items: Vec<I>, filter: F, search: BufferedValue<String>) -> Self {
+
        let selected = items.first().map(|_| 0);
+

+
        Self {
+
            items,
+
            selected,
+
            filter,
+
            search,
+
            ..Default::default()
+
        }
+
    }
+

+
    pub fn items(&self) -> Vec<I> {
+
        self.items_ref().into_iter().cloned().collect()
+
    }
+

+
    pub fn items_ref(&self) -> Vec<&I> {
+
        self.items
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .collect()
+
    }
+

+
    pub fn selected(&self) -> Option<usize> {
+
        self.selected
+
    }
+

+
    pub fn selected_item(&self) -> Option<&I> {
+
        self.selected
+
            .and_then(|selected| self.items_ref().get(selected).copied())
+
    }
+

+
    pub fn select_item(&mut self, selected: Option<usize>) -> Option<&I> {
+
        self.selected = selected;
+
        self.selected_item()
+
    }
+

+
    pub fn select_first_item(&mut self) -> Option<&I> {
+
        self.selected.and_then(|selected| {
+
            if selected > self.items_ref().len() {
+
                self.selected = Some(0);
+
                self.items_ref().first().cloned()
+
            } else {
+
                self.items_ref().get(selected).cloned()
+
            }
+
        })
+
    }
+

+
    fn filter_items(&mut self) {
+
        self.filter = F::from_str(&self.search.read()).unwrap_or_default();
+
    }
+

+
    pub fn update_search(&mut self, value: String) {
+
        self.search.write(value);
+
        self.filter_items();
+
    }
+

+
    pub fn show_search(&mut self) {
+
        self.show_search = true;
+
    }
+

+
    pub fn hide_search(&mut self) {
+
        self.show_search = false;
+
    }
+

+
    pub fn apply_search(&mut self) {
+
        self.search.apply();
+
    }
+

+
    pub fn reset_search(&mut self) {
+
        self.search.reset();
+
        self.filter_items();
+
    }
+

+
    pub fn is_search_shown(&self) -> bool {
+
        self.show_search
+
    }
+

+
    pub fn read_search(&self) -> String {
+
        self.search.read()
+
    }
+
}
+

+
#[derive(Clone, Default)]
+
pub struct IssueDetailsProps {
+
    issue: Option<IssueItem>,
+
    dim: bool,
+
}
+

+
impl IssueDetailsProps {
+
    pub fn issue(mut self, issue: Option<IssueItem>) -> Self {
+
        self.issue = issue;
+
        self
+
    }
+

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

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

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

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

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

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

+
        if let Some(issue) = props.issue.as_ref() {
+
            let author = match &issue.author.alias {
+
                Some(alias) => {
+
                    if issue.author.you {
+
                        span::alias(&format!("{}", alias))
+
                    } else {
+
                        span::alias(alias)
+
                    }
+
                }
+
                None => match &issue.author.human_nid {
+
                    Some(nid) => span::alias(nid).dim(),
+
                    None => span::blank(),
+
                },
+
            };
+

+
            let did = match &issue.author.human_nid {
+
                Some(nid) => {
+
                    if issue.author.you {
+
                        span::alias("(you)").dim().italic()
+
                    } else {
+
                        span::alias(nid).dim()
+
                    }
+
                }
+
                None => span::blank(),
+
            };
+

+
            let labels = format::labels(&issue.labels);
+

+
            let status = match issue.state {
+
                issue::State::Open => Text::styled("open", style::green()),
+
                issue::State::Closed { reason } => match reason {
+
                    CloseReason::Solved => Line::from(
+
                        [
+
                            Span::styled("closed", style::red()),
+
                            Span::raw(" "),
+
                            Span::styled("(solved)", style::red().italic().dim()),
+
                        ]
+
                        .to_vec(),
+
                    )
+
                    .into(),
+
                    CloseReason::Other => Text::styled("closed", style::red()),
+
                },
+
            };
+

+
            let table = ratatui::widgets::Table::new(
+
                [
+
                    Row::new([
+
                        Text::raw("Title").cyan(),
+
                        Text::raw(issue.title.clone()).bold(),
+
                    ]),
+
                    Row::new([
+
                        Text::raw("Issue").cyan(),
+
                        Text::raw(issue.id.to_string()).bold(),
+
                    ]),
+
                    Row::new([
+
                        Text::raw("Author").cyan(),
+
                        Line::from([author, " ".into(), did].to_vec()).into(),
+
                    ]),
+
                    Row::new([Text::raw("Labels").cyan(), Text::from(labels).blue()]),
+
                    Row::new([Text::raw("Status").cyan(), status]),
+
                ],
+
                [Constraint::Length(8), Constraint::Fill(1)],
+
            );
+

+
            let table = if !render.focus && props.dim {
+
                table.dim()
+
            } else {
+
                table
+
            };
+

+
            frame.render_widget(table, area);
+
        } else {
+
            let center = layout::centered_rect(render.area, 50, 10);
+
            let hint = Text::from(span::default("No issue selected"))
+
                .centered()
+
                .light_magenta()
+
                .dim();
+

+
            frame.render_widget(hint, center);
+
        }
+
    }
+
}
deleted bin/ui/widget.rs
@@ -1,267 +0,0 @@
-
use std::marker::PhantomData;
-
use std::str::FromStr;
-

-
use radicle::issue::{self, CloseReason};
-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span, Text};
-
use ratatui::widgets::Row;
-
use ratatui::Frame;
-

-
use radicle_tui as tui;
-

-
use tui::store;
-
use tui::ui::theme::style;
-
use tui::ui::widget::{RenderProps, View, ViewProps};
-
use tui::ui::{layout, span};
-

-
use super::format;
-
use super::items::IssueItem;
-

-
use crate::ui::items::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
-
/// buffered search field. The search fields value is used to build an
-
/// item filter that the item list reacts on dynamically.
-
#[derive(Clone, Debug)]
-
pub struct BrowserState<I, F> {
-
    items: Vec<I>,
-
    selected: Option<usize>,
-
    filter: F,
-
    search: store::StateValue<String>,
-
    show_search: bool,
-
}
-

-
impl<I, F> Default for BrowserState<I, F>
-
where
-
    I: Clone,
-
    F: Filter<I> + Default + FromStr,
-
{
-
    fn default() -> Self {
-
        Self {
-
            items: vec![],
-
            selected: None,
-
            filter: F::default(),
-
            search: store::StateValue::new(String::default()),
-
            show_search: false,
-
        }
-
    }
-
}
-

-
impl<I, F> BrowserState<I, F>
-
where
-
    I: Clone,
-
    F: Filter<I> + Default + FromStr,
-
{
-
    pub fn build(items: Vec<I>, filter: F, search: store::StateValue<String>) -> Self {
-
        let selected = items.first().map(|_| 0);
-

-
        Self {
-
            items,
-
            selected,
-
            filter,
-
            search,
-
            ..Default::default()
-
        }
-
    }
-

-
    pub fn items(&self) -> Vec<I> {
-
        self.items_ref().into_iter().cloned().collect()
-
    }
-

-
    pub fn items_ref(&self) -> Vec<&I> {
-
        self.items
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .collect()
-
    }
-

-
    pub fn selected(&self) -> Option<usize> {
-
        self.selected
-
    }
-

-
    pub fn selected_item(&self) -> Option<&I> {
-
        self.selected
-
            .and_then(|selected| self.items_ref().get(selected).copied())
-
    }
-

-
    pub fn select_item(&mut self, selected: Option<usize>) -> Option<&I> {
-
        self.selected = selected;
-
        self.selected_item()
-
    }
-

-
    pub fn select_first_item(&mut self) -> Option<&I> {
-
        self.selected.and_then(|selected| {
-
            if selected > self.items_ref().len() {
-
                self.selected = Some(0);
-
                self.items_ref().first().cloned()
-
            } else {
-
                self.items_ref().get(selected).cloned()
-
            }
-
        })
-
    }
-

-
    fn filter_items(&mut self) {
-
        self.filter = F::from_str(&self.search.read()).unwrap_or_default();
-
    }
-

-
    pub fn update_search(&mut self, value: String) {
-
        self.search.write(value);
-
        self.filter_items();
-
    }
-

-
    pub fn show_search(&mut self) {
-
        self.show_search = true;
-
    }
-

-
    pub fn hide_search(&mut self) {
-
        self.show_search = false;
-
    }
-

-
    pub fn apply_search(&mut self) {
-
        self.search.apply();
-
    }
-

-
    pub fn reset_search(&mut self) {
-
        self.search.reset();
-
        self.filter_items();
-
    }
-

-
    pub fn is_search_shown(&self) -> bool {
-
        self.show_search
-
    }
-

-
    pub fn read_search(&self) -> String {
-
        self.search.read()
-
    }
-
}
-

-
#[derive(Clone, Default)]
-
pub struct IssueDetailsProps {
-
    issue: Option<IssueItem>,
-
    dim: bool,
-
}
-

-
impl IssueDetailsProps {
-
    pub fn issue(mut self, issue: Option<IssueItem>) -> Self {
-
        self.issue = issue;
-
        self
-
    }
-

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

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

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

-
impl<S, M> View for IssueDetails<S, M> {
-
    type State = S;
-
    type Message = M;
-

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

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

-
        if let Some(issue) = props.issue.as_ref() {
-
            let author = match &issue.author.alias {
-
                Some(alias) => {
-
                    if issue.author.you {
-
                        span::alias(&format!("{}", alias))
-
                    } else {
-
                        span::alias(alias)
-
                    }
-
                }
-
                None => match &issue.author.human_nid {
-
                    Some(nid) => span::alias(nid).dim(),
-
                    None => span::blank(),
-
                },
-
            };
-

-
            let did = match &issue.author.human_nid {
-
                Some(nid) => {
-
                    if issue.author.you {
-
                        span::alias("(you)").dim().italic()
-
                    } else {
-
                        span::alias(nid).dim()
-
                    }
-
                }
-
                None => span::blank(),
-
            };
-

-
            let labels = format::labels(&issue.labels);
-

-
            let status = match issue.state {
-
                issue::State::Open => Text::styled("open", style::green()),
-
                issue::State::Closed { reason } => match reason {
-
                    CloseReason::Solved => Line::from(
-
                        [
-
                            Span::styled("closed", style::red()),
-
                            Span::raw(" "),
-
                            Span::styled("(solved)", style::red().italic().dim()),
-
                        ]
-
                        .to_vec(),
-
                    )
-
                    .into(),
-
                    CloseReason::Other => Text::styled("closed", style::red()),
-
                },
-
            };
-

-
            let table = ratatui::widgets::Table::new(
-
                [
-
                    Row::new([
-
                        Text::raw("Title").cyan(),
-
                        Text::raw(issue.title.clone()).bold(),
-
                    ]),
-
                    Row::new([
-
                        Text::raw("Issue").cyan(),
-
                        Text::raw(issue.id.to_string()).bold(),
-
                    ]),
-
                    Row::new([
-
                        Text::raw("Author").cyan(),
-
                        Line::from([author, " ".into(), did].to_vec()).into(),
-
                    ]),
-
                    Row::new([Text::raw("Labels").cyan(), Text::from(labels).blue()]),
-
                    Row::new([Text::raw("Status").cyan(), status]),
-
                ],
-
                [Constraint::Length(8), Constraint::Fill(1)],
-
            );
-

-
            let table = if !render.focus && props.dim {
-
                table.dim()
-
            } else {
-
                table
-
            };
-

-
            frame.render_widget(table, area);
-
        } else {
-
            let center = layout::centered_rect(render.area, 50, 10);
-
            let hint = Text::from(span::default("No issue selected"))
-
                .centered()
-
                .light_magenta()
-
                .dim();
-

-
            frame.render_widget(hint, center);
-
        }
-
    }
-
}
deleted examples/basic.rs
@@ -1,110 +0,0 @@
-
use anyhow::Result;
-

-
use termion::event::Key;
-

-
use ratatui::layout::Constraint;
-

-
use radicle_tui as tui;
-

-
use tui::store;
-
use tui::ui::widget::container::{Column, Container, Header, HeaderProps};
-
use tui::ui::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::ToWidget;
-
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 
-
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 
-
mollit anim id est laborum.
-
"#;
-

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

-
enum Message {
-
    Quit,
-
    ReverseContent,
-
}
-

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

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

-
#[tokio::main]
-
pub async fn main() -> Result<()> {
-
    let channel = Channel::default();
-
    let sender = channel.tx.clone();
-
    let state = State {
-
        content: CONTENT.to_string(),
-
    };
-

-
    let page = Page::default()
-
        .content(
-
            Container::default()
-
                .header(Header::default().to_widget(sender.clone()).on_update(|_| {
-
                    HeaderProps::default()
-
                        .columns(vec![
-
                            Column::new("", Constraint::Length(0)),
-
                            Column::new(
-
                                "The standard Lorem Ipsum passage, used since the 1500s",
-
                                Constraint::Fill(1),
-
                            ),
-
                        ])
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(TextView::default().to_widget(sender.clone()).on_update(
-
                    |state: &State| {
-
                        let content = state.content.clone();
-
                        TextViewProps::default()
-
                            .state(Some(TextViewState::default().content(content)))
-
                            .handle_keys(false)
-
                            .to_boxed_any()
-
                            .into()
-
                    },
-
                ))
-
                .to_widget(sender.clone()),
-
        )
-
        .shortcuts(
-
            Shortcuts::default()
-
                .to_widget(sender.clone())
-
                .on_update(|_| {
-
                    ShortcutsProps::default()
-
                        .shortcuts(&[("q", "quit"), ("r", "reverse")])
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(sender.clone());
-

-
    let window = Window::default()
-
        .page(0, page)
-
        .to_widget(sender.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Char('r') => Some(Message::ReverseContent),
-
            Key::Char('q') => Some(Message::Quit),
-
            _ => None,
-
        })
-
        .on_update(|_| WindowProps::default().current_page(0).to_boxed_any().into());
-

-
    tui::run(channel, state, window).await?;
-

-
    Ok(())
-
}
added examples/basic_rmui.rs
@@ -0,0 +1,114 @@
+
use anyhow::Result;
+

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

+
use ratatui::layout::Constraint;
+

+
use radicle_tui as tui;
+

+
use tui::store;
+
use tui::ui::rm::widget::container::{Container, Header, HeaderProps};
+
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
+
use tui::ui::rm::widget::ToWidget;
+
use tui::ui::Column;
+
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 
+
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 
+
mollit anim id est laborum.
+
"#;
+

+
#[derive(Clone, Debug)]
+
struct App {
+
    content: String,
+
}
+

+
#[derive(Clone, Debug)]
+
enum Message {
+
    Quit,
+
    ReverseContent,
+
}
+

+
impl store::Update<Message> for App {
+
    type Return = ();
+

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

+
#[tokio::main]
+
pub async fn main() -> Result<()> {
+
    let channel = Channel::default();
+
    let sender = channel.tx.clone();
+
    let app = App {
+
        content: CONTENT.to_string(),
+
    };
+

+
    let page =
+
        Page::default()
+
            .content(
+
                Container::default()
+
                    .header(Header::default().to_widget(sender.clone()).on_update(|_| {
+
                        HeaderProps::default()
+
                            .columns(vec![
+
                                Column::new("", Constraint::Length(0)),
+
                                Column::new(
+
                                    "The standard Lorem Ipsum passage, used since the 1500s",
+
                                    Constraint::Fill(1),
+
                                ),
+
                            ])
+
                            .to_boxed_any()
+
                            .into()
+
                    }))
+
                    .content(TextView::default().to_widget(sender.clone()).on_update(
+
                        |app: &App| {
+
                            let content = app.content.clone();
+
                            TextViewProps::default()
+
                                .state(Some(TextViewState::default().content(content)))
+
                                .handle_keys(false)
+
                                .to_boxed_any()
+
                                .into()
+
                        },
+
                    ))
+
                    .to_widget(sender.clone()),
+
            )
+
            .shortcuts(
+
                Shortcuts::default()
+
                    .to_widget(sender.clone())
+
                    .on_update(|_| {
+
                        ShortcutsProps::default()
+
                            .shortcuts(&[("q", "quit"), ("r", "reverse")])
+
                            .to_boxed_any()
+
                            .into()
+
                    }),
+
            )
+
            .to_widget(sender.clone());
+

+
    let window = Window::default()
+
        .page(0, page)
+
        .to_widget(sender.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Char('r') => Some(Message::ReverseContent),
+
            Key::Char('q') => Some(Message::Quit),
+
            _ => None,
+
        })
+
        .on_update(|_| WindowProps::default().current_page(0).to_boxed_any().into());
+

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

+
    Ok(())
+
}
modified examples/hello.rs
@@ -1,74 +1,132 @@
+
// use anyhow::Result;
+

+
// use termion::event::Key;
+

+
// use ratatui::{Frame, Viewport};
+

+
// use radicle_tui as tui;
+

+
// use tui::store;
+
// use tui::ui::im::widget::Window;
+
// use tui::ui::im::Show;
+
// use tui::ui::im::{Borders, Context};
+
// use tui::{Channel, Exit};
+

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

+
// #[derive(Clone, Debug)]
+
// struct App {
+
//     alien: String,
+
// }
+

+
// #[derive(Clone, Debug)]
+
// enum Message {
+
//     Quit,
+
// }
+

+
// impl store::Update<Message> for App {
+
//     type Return = ();
+

+
//     fn update(&mut self, message: Message) -> Option<tui::Exit<()>> {
+
//         match message {
+
//             Message::Quit => Some(Exit { value: None }),
+
//         }
+
//     }
+
// }
+

+
// impl Show<Message> for App {
+
//     fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<()> {
+
//         Window::default().show(ctx, |ui| {
+
//             ui.text_view(frame, self.alien.clone(), &mut (0, 0), Some(Borders::None));
+

+
//             if ui.input_global(|key| key == Key::Char('q')) {
+
//                 ui.send_message(Message::Quit);
+
//             }
+
//         });
+

+
//         Ok(())
+
//     }
+
// }
+

+
// #[tokio::main]
+
// pub async fn main() -> Result<()> {
+
//     let app = App {
+
//         alien: ALIEN.to_string(),
+
//     };
+

+
//     tui::im(app, Viewport::default(), Channel::default()).await?;
+

+
//     Ok(())
+
// }
+

use anyhow::Result;

use termion::event::Key;

-
use ratatui::style::Color;
-
use ratatui::text::Text;
+
use ratatui::{Frame, Viewport};

use radicle_tui as tui;

use tui::store;
-
use tui::ui::widget::input::{TextArea, TextAreaProps};
-
use tui::ui::widget::ToWidget;
-
use tui::{BoxedAny, Channel, Exit};
-

-
const ALIEN: &str = r#"
-
     ///             ///    ,---------------------------------. 
-
     ///             ///    | Hey there, press (q) to quit... |
-
        //         //       '---------------------------------'  
-
        //,,,///,,,//      .. 
-
     ///////////////////  .  
-
  //////@@@@@//////@@@@@///  
-
  //////@@###//////@@###///  
-
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-
     ,,,  ///   ///  ,,,     
-
     ,,,  ///   ///  ,,,     
-
          ///   ///          
-
        /////   /////
-
"#;
+
use tui::ui::im::widget::Window;
+
use tui::ui::im::Show;
+
use tui::ui::im::{Borders, Context};
+
use tui::{Channel, Exit};

#[derive(Clone, Debug)]
-
struct State {
-
    alien: String,
+
struct App {
+
    hello: String,
}

+
#[derive(Clone, Debug)]
enum Message {
    Quit,
}

-
impl store::State<()> for State {
-
    type Message = Message;
+
impl store::Update<Message> for App {
+
    type Return = ();

-
    fn update(&mut self, message: Self::Message) -> Option<tui::Exit<()>> {
+
    fn update(&mut self, message: Message) -> Option<tui::Exit<()>> {
        match message {
            Message::Quit => Some(Exit { value: None }),
        }
    }
}

+
impl Show<Message> for App {
+
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, |ui| {
+
            ui.text_view(frame, self.hello.clone(), &mut (0, 0), Some(Borders::None));
+

+
            if ui.input_global(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

#[tokio::main]
pub async fn main() -> Result<()> {
-
    let channel = Channel::default();
-
    let sender = channel.tx.clone();
-
    let state = State {
-
        alien: ALIEN.to_string(),
+
    let app = App {
+
        hello: "Hello World!".to_string(),
    };

-
    let scene = TextArea::default()
-
        .to_widget(sender.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Char('q') => Some(Message::Quit),
-
            _ => None,
-
        })
-
        .on_update(|state: &State| {
-
            TextAreaProps::default()
-
                .content(Text::styled(state.alien.clone(), Color::Rgb(85, 85, 255)))
-
                .handle_keys(false)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    tui::run(channel, state, scene).await?;
+
    tui::im(app, Viewport::default(), Channel::default()).await?;

    Ok(())
}
added examples/hello_rrmui.rs
@@ -0,0 +1,76 @@
+
use anyhow::Result;
+

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

+
use ratatui::style::Color;
+
use ratatui::text::Text;
+

+
use radicle_tui as tui;
+

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

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

+
#[derive(Clone, Debug)]
+
struct App {
+
    alien: String,
+
}
+

+
#[derive(Clone, Debug)]
+
enum Message {
+
    Quit,
+
}
+

+
impl store::Update<Message> for App {
+
    type Return = ();
+

+
    fn update(&mut self, message: Message) -> Option<tui::Exit<()>> {
+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
        }
+
    }
+
}
+

+
#[tokio::main]
+
pub async fn main() -> Result<()> {
+
    let channel = Channel::default();
+
    let sender = channel.tx.clone();
+
    let app = App {
+
        alien: ALIEN.to_string(),
+
    };
+

+
    let scene = TextArea::default()
+
        .to_widget(sender.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Char('q') => Some(Message::Quit),
+
            _ => None,
+
        })
+
        .on_update(|app: &App| {
+
            TextAreaProps::default()
+
                .content(Text::styled(app.alien.clone(), Color::Rgb(85, 85, 255)))
+
                .handle_keys(false)
+
                .to_boxed_any()
+
                .into()
+
        });
+

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

+
    Ok(())
+
}
added examples/selection.rs
@@ -0,0 +1,155 @@
+
use std::time::{SystemTime, UNIX_EPOCH};
+

+
use anyhow::Result;
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::Span;
+
use ratatui::widgets::Cell;
+
use ratatui::{Frame, Viewport};
+

+
use radicle_tui as tui;
+

+
use tui::ui::im::widget::Window;
+
use tui::ui::im::{Borders, Context};
+
use tui::ui::{Column, ToRow};
+
use tui::Channel;
+
use tui::{
+
    store::Update,
+
    ui::im::{widget::TableState, Show},
+
    Exit,
+
};
+

+
#[derive(Clone, Debug)]
+
struct Item {
+
    id: usize,
+
    title: String,
+
    timestamp: usize,
+
}
+

+
impl ToRow<3> for Item {
+
    fn to_row(&self) -> [Cell; 3] {
+
        [
+
            Span::raw(self.id.to_string()).magenta().dim().into(),
+
            Span::raw(self.title.clone()).into(),
+
            Span::raw(self.timestamp.to_string())
+
                .dark_gray()
+
                .italic()
+
                .into(),
+
        ]
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
struct App {
+
    items: Vec<Item>,
+
    selector: TableState,
+
}
+

+
#[derive(Clone, Debug)]
+
enum Message {
+
    SelectionChanged { state: TableState },
+
    Return,
+
    Quit,
+
}
+

+
impl Update<Message> for App {
+
    type Return = usize;
+

+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Self::Return>> {
+
        match message {
+
            Message::SelectionChanged { state } => {
+
                self.selector = state;
+
                None
+
            }
+
            Message::Return => self
+
                .selector
+
                .selected()
+
                .and_then(|selected| self.items.get(selected))
+
                .and_then(|item| {
+
                    Some(Exit {
+
                        value: Some(item.id),
+
                    })
+
                }),
+
            Message::Quit => Some(Exit { value: None }),
+
        }
+
    }
+
}
+

+
impl Show<Message> for App {
+
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, |ui| {
+
            ui.layout(
+
                Layout::vertical([
+
                    Constraint::Length(1),
+
                    Constraint::Min(1),
+
                    Constraint::Length(1),
+
                ]),
+
                |ui| {
+
                    let columns = [
+
                        Column::new(Span::raw("Id").bold(), Constraint::Length(4)),
+
                        Column::new(Span::raw("Title").bold(), Constraint::Fill(1)),
+
                        Column::new(Span::raw("Timestamp").bold(), Constraint::Fill(1)),
+
                    ]
+
                    .to_vec();
+
                    let mut selected = self.selector.selected();
+

+
                    ui.columns(frame, columns.clone(), Some(Borders::None));
+

+
                    ui.set_focus(Some(1));
+
                    let table = ui.table(
+
                        frame,
+
                        &mut selected,
+
                        &self.items,
+
                        columns,
+
                        Some(Borders::None),
+
                    );
+
                    if table.changed {
+
                        ui.send_message(Message::SelectionChanged {
+
                            state: TableState::new(selected),
+
                        })
+
                    }
+

+
                    ui.shortcuts(frame, &[("q", "quit")], '|');
+

+
                    if ui.input_global(|key| key == Key::Char('\n')) {
+
                        ui.send_message(Message::Return);
+
                    }
+
                },
+
            );
+

+
            if ui.input_global(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

+
#[tokio::main]
+
pub async fn main() -> Result<()> {
+
    let app = App {
+
        items: (0..200)
+
            .map(|id| Item {
+
                id,
+
                title: format!("Title of item #{}", id),
+
                timestamp: SystemTime::now()
+
                    .duration_since(UNIX_EPOCH)
+
                    .expect("Could not read system time")
+
                    .as_secs() as usize,
+
            })
+
            .collect(),
+
        selector: TableState::new(Some(0)),
+
    };
+

+
    if let Some(exit) = tui::im(app, Viewport::Inline(12), Channel::default()).await? {
+
        println!("{exit}");
+
    } else {
+
        anyhow::bail!("No selection");
+
    }
+

+
    Ok(())
+
}
modified src/event.rs
@@ -1,4 +1,4 @@
-
#[derive(Clone, Copy)]
+
#[derive(Clone, Copy, Debug)]
pub enum Event {
    Key(termion::event::Key),
    Resize,
modified src/lib.rs
@@ -7,16 +7,18 @@ pub mod ui;
use std::any::Any;
use std::fmt::Debug;

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

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

use anyhow::Result;

-
use store::State;
+
use store::Update;
use task::Interrupted;
-
use ui::widget::Widget;
-
use ui::Frontend;
+
use ui::im;
+
use ui::im::Show;
+
use ui::rm;

/// An optional return value.
#[derive(Clone, Debug)]
@@ -150,20 +152,55 @@ impl<A> Default for Channel<A> {
/// Initialize a `Store` with the `State` given and a `Frontend` with the `Widget` given,
/// and run their main loops concurrently. Connect them to the `Channel` and also to
/// an interrupt broadcast channel also initialized in this function.
-
pub async fn run<S, M, P>(channel: Channel<M>, state: S, root: Widget<S, M>) -> Result<Option<P>>
+
pub async fn rm<S, M, P>(
+
    state: S,
+
    root: rm::widget::Widget<S, M>,
+
    viewport: Viewport,
+
    channel: Channel<M>,
+
) -> Result<Option<P>>
where
-
    S: State<P, Message = M> + Clone + Debug + Send + Sync + 'static,
-
    M: 'static,
+
    S: Update<M, Return = P> + Clone + Debug + Send + Sync + 'static,
+
    M: Debug + Send + Sync + 'static,
    P: Clone + Debug + Send + Sync + 'static,
{
    let (terminator, mut interrupt_rx) = task::create_termination();

    let (store, state_rx) = store::Store::<S, M, P>::new();
-
    let frontend = Frontend::default();
+
    let frontend = rm::Frontend::default();

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

+
    if let Ok(reason) = interrupt_rx.recv().await {
+
        match reason {
+
            Interrupted::User { payload } => Ok(payload),
+
            Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"),
+
        }
+
    } else {
+
        anyhow::bail!("exited because of an unexpected error");
+
    }
+
}
+

+
/// 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>>
+
where
+
    S: Update<M, Return = P> + Show<M> + Clone + Debug + Send + Sync + 'static,
+
    M: Clone + Debug + Send + Sync + 'static,
+
    P: Clone + Debug + Send + Sync + 'static,
+
{
+
    let (terminator, mut interrupt_rx) = task::create_termination();
+

+
    let state_tx = channel.tx.clone();
+
    let (store, state_rx) = store::Store::<S, M, P>::new();
+
    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),
    )?;

    if let Ok(reason) = interrupt_rx.recv().await {
modified src/store.rs
@@ -11,17 +11,14 @@ use super::task::{Interrupted, Terminator};

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

-
/// The `State` known to the application store. It handles user-defined
-
/// application messages as well as ticks.
-
pub trait State<P>
-
where
-
    P: Clone + Debug + Send + Sync,
-
{
-
    type Message;
+
/// The main state trait for the ability to update an applications' state.
+
/// Implementations should handle user-defined application messages as well as ticks.
+
pub trait Update<M> {
+
    type Return;

    /// Handle a user-defined application message and return an `Exit` object
    /// in case the received message requested the application to also quit.
-
    fn update(&mut self, message: Self::Message) -> Option<Exit<P>>;
+
    fn update(&mut self, message: M) -> Option<Exit<Self::Return>>;

    /// Handle recurring tick.
    fn tick(&mut self) {}
@@ -31,7 +28,7 @@ where
/// messages coming from the frontend and updates the state accordingly.
pub struct Store<S, M, P>
where
-
    S: State<P> + Clone + Send + Sync,
+
    S: Update<M, Return = P> + Clone + Send + Sync,
    P: Clone + Debug + Send + Sync,
{
    state_tx: UnboundedSender<S>,
@@ -40,7 +37,7 @@ where

impl<S, M, P> Store<S, M, P>
where
-
    S: State<P> + Clone + Send + Sync,
+
    S: Update<M, Return = P> + Clone + Send + Sync,
    P: Clone + Debug + Send + Sync,
{
    pub fn new() -> (Self, UnboundedReceiver<S>) {
@@ -58,14 +55,14 @@ where

impl<S, M, P> Store<S, M, P>
where
-
    S: State<P, Message = M> + Clone + Debug + Send + Sync + 'static,
+
    S: Update<M, Return = P> + Clone + Debug + Send + Sync + 'static,
    P: Clone + Debug + Send + Sync + 'static,
{
    /// By calling `main_loop`, the store will wait for new messages coming
    /// from the frontend and update the applications' state accordingly. It will
    /// also tick with the defined `STORE_TICK_RATE`.
    /// Updated states are then being send to the state message channel.
-
    pub async fn main_loop(
+
    pub async fn run(
        self,
        mut state: S,
        mut terminator: Terminator<P>,
@@ -105,105 +102,3 @@ where
        Ok(result)
    }
}
-

-
/// A `StateValue` that writes updates to an internal
-
/// buffer. This buffer can be applied or reset.
-
///
-
/// Reading from a `StateValue` will return the buffer if it's
-
/// not empty. It will return the actual value otherwise.
-
#[derive(Clone, Debug)]
-
pub struct StateValue<T>
-
where
-
    T: Clone,
-
{
-
    value: T,
-
    buffer: Option<T>,
-
}
-

-
impl<T> StateValue<T>
-
where
-
    T: Clone,
-
{
-
    pub fn new(value: T) -> Self {
-
        Self {
-
            value,
-
            buffer: None,
-
        }
-
    }
-

-
    pub fn apply(&mut self) {
-
        if let Some(buffer) = self.buffer.clone() {
-
            self.value = buffer;
-
        }
-
        self.buffer = None;
-
    }
-

-
    pub fn reset(&mut self) {
-
        self.buffer = None;
-
    }
-

-
    pub fn write(&mut self, value: T) {
-
        self.buffer = Some(value);
-
    }
-

-
    pub fn read(&self) -> T {
-
        if let Some(buffer) = self.buffer.clone() {
-
            buffer
-
        } else {
-
            self.value.clone()
-
        }
-
    }
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use super::*;
-

-
    #[test]
-
    fn state_value_read_should_succeed() {
-
        let value = StateValue::new(0);
-
        assert_eq!(value.read(), 0);
-
    }
-

-
    #[test]
-
    fn state_value_read_buffer_should_succeed() {
-
        let mut value = StateValue::new(0);
-
        value.write(1);
-

-
        assert_eq!(value.read(), 1);
-
    }
-

-
    #[test]
-
    fn state_value_apply_should_succeed() {
-
        let mut value = StateValue::new(0);
-

-
        value.write(1);
-
        assert_eq!(value.read(), 1);
-

-
        value.apply();
-
        assert_eq!(value.read(), 1);
-
    }
-

-
    #[test]
-
    fn state_value_reset_should_succeed() {
-
        let mut value = StateValue::new(0);
-

-
        value.write(1);
-
        assert_eq!(value.read(), 1);
-

-
        value.reset();
-
        assert_eq!(value.read(), 0);
-
    }
-

-
    #[test]
-
    fn state_value_reset_after_apply_should_succeed() {
-
        let mut value = StateValue::new(0);
-

-
        value.write(1);
-
        assert_eq!(value.read(), 1);
-

-
        value.apply();
-
        value.reset();
-
        assert_eq!(value.read(), 1);
-
    }
-
}
modified src/terminal.rs
@@ -93,16 +93,17 @@ impl<W: Write> ratatui::backend::Backend for TermionBackendExt<W> {
}

/// Setup a `Terminal` with inline viewport using the `termion` backend.
-
pub fn setup(height: usize) -> anyhow::Result<Terminal<Backend>> {
+
pub fn setup(viewport: Viewport) -> anyhow::Result<Terminal<Backend>> {
+
    let is_fullscreen = viewport == Viewport::Fullscreen;
    let stdout = io::stdout().into_raw_mode()?;
-
    let options = TerminalOptions {
-
        viewport: Viewport::Inline(height as u16),
-
    };
-

-
    Ok(Terminal::with_options(
-
        TermionBackendExt::new(stdout),
-
        options,
-
    )?)
+
    let options = TerminalOptions { viewport };
+
    let mut terminal = Terminal::with_options(TermionBackendExt::new(stdout), options)?;
+

+
    if is_fullscreen {
+
        terminal.clear()?;
+
    }
+

+
    Ok(terminal)
}

/// Restore the `Terminal` on quit.
modified src/ui.rs
@@ -1,103 +1,210 @@
pub mod ext;
+
pub mod im;
pub mod layout;
+
pub mod rm;
pub mod span;
pub mod theme;
-
pub mod widget;

-
use std::fmt::Debug;
-
use std::time::Duration;
+
use ratatui::layout::Constraint;
+
use ratatui::text::Text;
+
use ratatui::widgets::Cell;

-
use tokio::sync::broadcast;
-
use tokio::sync::mpsc::UnboundedReceiver;
-

-
use crate::ui::widget::RenderProps;
-

-
use self::widget::Widget;
-

-
use super::event::Event;
-
use super::store::State;
-
use super::task::Interrupted;
-
use super::terminal;
-

-
const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
-
const INLINE_HEIGHT: usize = 20;
+
use tui_tree_widget::TreeItem;

pub const RENDER_WIDTH_XSMALL: usize = 50;
pub const RENDER_WIDTH_SMALL: usize = 70;
pub const RENDER_WIDTH_MEDIUM: usize = 150;
pub const RENDER_WIDTH_LARGE: usize = usize::MAX;

-
/// The `Frontend` runs an applications' view concurrently. It handles
-
/// terminal events as well as state updates and renders the view accordingly.
+
#[derive(Clone, Debug, Default)]
+
pub struct ColumnView {
+
    small: bool,
+
    medium: bool,
+
    large: bool,
+
}
+

+
impl ColumnView {
+
    pub fn all() -> Self {
+
        Self {
+
            small: true,
+
            medium: true,
+
            large: true,
+
        }
+
    }
+

+
    pub fn small(mut self) -> Self {
+
        self.small = true;
+
        self
+
    }
+

+
    pub fn medium(mut self) -> Self {
+
        self.medium = true;
+
        self
+
    }
+

+
    pub fn large(mut self) -> Self {
+
        self.large = true;
+
        self
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct Column<'a> {
+
    pub text: Text<'a>,
+
    pub width: Constraint,
+
    pub skip: bool,
+
    pub view: ColumnView,
+
}
+

+
impl<'a> Column<'a> {
+
    pub fn new(text: impl Into<Text<'a>>, width: Constraint) -> Self {
+
        Self {
+
            text: text.into(),
+
            width,
+
            skip: false,
+
            view: ColumnView::all(),
+
        }
+
    }
+

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

+
    pub fn hide_small(mut self) -> Self {
+
        self.view = ColumnView::default().medium().large();
+
        self
+
    }
+

+
    pub fn hide_medium(mut self) -> Self {
+
        self.view = ColumnView::default().large();
+
        self
+
    }
+

+
    pub fn displayed(&self, area_width: usize) -> bool {
+
        if area_width < RENDER_WIDTH_SMALL {
+
            self.view.small
+
        } else if area_width < RENDER_WIDTH_MEDIUM {
+
            self.view.medium
+
        } else if area_width < RENDER_WIDTH_LARGE {
+
            self.view.large
+
        } else {
+
            true
+
        }
+
    }
+
}
+

+
/// Needs to be implemented for items that are supposed to be rendered in tables.
+
pub trait ToRow<const W: usize> {
+
    fn to_row(&self) -> [Cell; W];
+
}
+

+
/// Needs to be implemented for items that are supposed to be rendered in trees.
+
pub trait ToTree<Id>
+
where
+
    Id: ToString,
+
{
+
    fn rows(&self) -> Vec<TreeItem<'_, Id>>;
+
}
+

+
/// A `BufferedValue` that writes updates to an internal
+
/// buffer. This buffer can be applied or reset.
///
-
/// Once created and run with `main_loop`, the `Frontend` will wait for new messages
-
/// being sent on either the terminal event, the state or the interrupt message channel.
-
#[derive(Default)]
-
pub struct Frontend {}
-

-
impl Frontend {
-
    /// By calling `main_loop`, the `Frontend` will wait for new messages being sent
-
    /// on either the terminal event, the state or the interrupt message channel.
-
    /// After all, it will draw the (potentially) updated root widget.
-
    ///
-
    /// Terminal event messages are being sent by a thread polling `stdin` for new user input
-
    /// and another thread polling UNIX signals, e.g. `SIGWINCH` when the terminal
-
    /// window size is being changed. Terminal events are then passed to the root widget
-
    /// of the application.
-
    ///
-
    /// State messages are being sent by the applications' `Store`. Received state updates
-
    /// will be passed to the root widget as well.
-
    ///
-
    /// Interrupt messages are being sent to broadcast channel for retrieving the
-
    /// application kill signal.
-
    pub async fn main_loop<S, M, P>(
-
        self,
-
        mut root: Widget<S, M>,
-
        mut state_rx: UnboundedReceiver<S>,
-
        mut interrupt_rx: broadcast::Receiver<Interrupted<P>>,
-
    ) -> anyhow::Result<Interrupted<P>>
-
    where
-
        S: State<P> + 'static,
-
        M: 'static,
-
        P: Clone + Send + Sync + Debug,
-
    {
-
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
-

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

-
        let mut root = {
-
            let state = state_rx.recv().await.unwrap();
-

-
            root.update(&state);
-
            root
-
        };
-

-
        let result: anyhow::Result<Interrupted<P>> = loop {
-
            tokio::select! {
-
                // Tick to terminate the select every N milliseconds
-
                _ = ticker.tick() => (),
-
                Some(event) = events_rx.recv() => match event {
-
                    Event::Key(key) => root.handle_event(key),
-
                    Event::Resize => (),
-
                },
-
                // Handle state updates
-
                Some(state) = state_rx.recv() => {
-
                    root.update(&state);
-
                },
-
                // Catch and handle interrupt signal to gracefully shutdown
-
                Ok(interrupted) = interrupt_rx.recv() => {
-
                    let size = terminal.get_frame().size();
-
                    let _ = terminal.set_cursor(size.x, size.y);
-

-
                    break Ok(interrupted);
-
                }
-
            }
-
            terminal.draw(|frame| root.render(RenderProps::from(frame.size()), frame))?;
-
        };
-

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

-
        result
+
/// Reading from a `BufferedValue` will return the buffer if it's
+
/// not empty. It will return the actual value otherwise.
+
#[derive(Clone, Debug)]
+
pub struct BufferedValue<T>
+
where
+
    T: Clone,
+
{
+
    value: T,
+
    buffer: Option<T>,
+
}
+

+
impl<T> BufferedValue<T>
+
where
+
    T: Clone,
+
{
+
    pub fn new(value: T) -> Self {
+
        Self {
+
            value,
+
            buffer: None,
+
        }
+
    }
+

+
    pub fn apply(&mut self) {
+
        if let Some(buffer) = self.buffer.clone() {
+
            self.value = buffer;
+
        }
+
        self.buffer = None;
+
    }
+

+
    pub fn reset(&mut self) {
+
        self.buffer = None;
+
    }
+

+
    pub fn write(&mut self, value: T) {
+
        self.buffer = Some(value);
+
    }
+

+
    pub fn read(&self) -> T {
+
        if let Some(buffer) = self.buffer.clone() {
+
            buffer
+
        } else {
+
            self.value.clone()
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::*;
+

+
    #[test]
+
    fn state_value_read_should_succeed() {
+
        let value = BufferedValue::new(0);
+
        assert_eq!(value.read(), 0);
+
    }
+

+
    #[test]
+
    fn state_value_read_buffer_should_succeed() {
+
        let mut value = BufferedValue::new(0);
+
        value.write(1);
+

+
        assert_eq!(value.read(), 1);
+
    }
+

+
    #[test]
+
    fn state_value_apply_should_succeed() {
+
        let mut value = BufferedValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.apply();
+
        assert_eq!(value.read(), 1);
+
    }
+

+
    #[test]
+
    fn state_value_reset_should_succeed() {
+
        let mut value = BufferedValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.reset();
+
        assert_eq!(value.read(), 0);
+
    }
+

+
    #[test]
+
    fn state_value_reset_after_apply_should_succeed() {
+
        let mut value = BufferedValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.apply();
+
        value.reset();
+
        assert_eq!(value.read(), 1);
    }
}
modified src/ui/ext.rs
@@ -115,7 +115,7 @@ impl Widget for HeaderBlock {

#[derive(Clone)]
pub enum FooterBlockType {
-
    Single,
+
    Single { top: bool },
    Begin,
    End,
    Repeat,
@@ -138,8 +138,8 @@ pub struct FooterBlock {
impl Default for FooterBlock {
    fn default() -> Self {
        Self {
-
            block_type: FooterBlockType::Single,
-
            borders: Self::borders(FooterBlockType::Single),
+
            block_type: FooterBlockType::Single { top: true },
+
            borders: Self::borders(FooterBlockType::Single { top: true }),
            border_style: Default::default(),
            border_type: BorderType::Rounded,
            style: Default::default(),
@@ -171,7 +171,14 @@ impl FooterBlock {

    fn borders(block_type: FooterBlockType) -> Borders {
        match block_type {
-
            FooterBlockType::Single | FooterBlockType::Begin => Borders::ALL,
+
            FooterBlockType::Single { top } => {
+
                if top {
+
                    Borders::ALL
+
                } else {
+
                    Borders::LEFT | Borders::RIGHT | Borders::BOTTOM
+
                }
+
            }
+
            FooterBlockType::Begin => Borders::ALL,
            FooterBlockType::End | FooterBlockType::Repeat => {
                Borders::TOP | Borders::RIGHT | Borders::BOTTOM
            }
added src/ui/im.rs
@@ -0,0 +1,549 @@
+
pub mod widget;
+

+
use std::collections::VecDeque;
+
use std::fmt::Debug;
+
use std::rc::Rc;
+
use std::time::Duration;
+

+
use anyhow::Result;
+

+
use ratatui::style::Stylize;
+
use ratatui::text::{Span, Text};
+
use tokio::sync::broadcast;
+
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Rect};
+
use ratatui::{Frame, Viewport};
+

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

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

+
use self::widget::AddContentFn;
+

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

+
/// The main UI trait for the ability to render an application.
+
pub trait Show<M> {
+
    fn show(&self, ctx: &Context<M>, frame: &mut Frame) -> Result<()>;
+
}
+

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

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

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

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

+
        let result: anyhow::Result<Interrupted<P>> = loop {
+
            tokio::select! {
+
                // Tick to terminate the select every N milliseconds
+
                _ = ticker.tick() => (),
+
                Some(event) = events_rx.recv() => match event {
+
                    Event::Key(key) => ctx.store_input(key),
+
                    Event::Resize => (),
+
                },
+
                // Handle state updates
+
                Some(s) = state_rx.recv() => {
+
                    state = s;
+
                },
+
                // Catch and handle interrupt signal to gracefully shutdown
+
                Ok(interrupted) = interrupt_rx.recv() => {
+
                    let size = terminal.get_frame().size();
+
                    let _ = terminal.set_cursor(size.x, size.y);
+

+
                    break Ok(interrupted);
+
                }
+
            }
+
            terminal.draw(|frame| {
+
                let ctx = ctx.clone().with_frame_size(frame.size());
+

+
                if let Err(err) = state.show(&ctx, frame) {
+
                    log::warn!("Drawing failed: {}", err);
+
                }
+
            })?;
+

+
            ctx.clear_inputs();
+
        };
+

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

+
        result
+
    }
+
}
+

+
#[derive(Default, Debug)]
+
pub struct Response {
+
    pub changed: bool,
+
}
+

+
#[derive(Debug)]
+
pub struct InnerResponse<R> {
+
    /// What the user closure returned.
+
    pub inner: R,
+
    /// The response of the area.
+
    pub response: Response,
+
}
+

+
impl<R> InnerResponse<R> {
+
    #[inline]
+
    pub fn new(inner: R, response: Response) -> Self {
+
        Self { inner, response }
+
    }
+
}
+

+
/// A `Context` is held by the `Ui` and reflects the environment a `Ui` runs in.
+
#[derive(Clone, Debug)]
+
pub struct Context<M> {
+
    /// Currently captured user inputs. Inputs that where stored via `store_input`
+
    /// need to be cleared manually via `clear_inputs` (usually for each frame drawn).
+
    inputs: VecDeque<Key>,
+
    /// 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>>,
+
}
+

+
impl<M> Default for Context<M> {
+
    fn default() -> Self {
+
        Self {
+
            inputs: VecDeque::default(),
+
            frame_size: Rect::default(),
+
            sender: None,
+
        }
+
    }
+
}
+

+
impl<M> Context<M> {
+
    pub fn new(frame_size: Rect) -> Self {
+
        Self {
+
            frame_size,
+
            ..Default::default()
+
        }
+
    }
+

+
    pub fn with_inputs(mut self, inputs: VecDeque<Key>) -> Self {
+
        self.inputs = inputs;
+
        self
+
    }
+

+
    pub fn with_frame_size(mut self, frame_size: Rect) -> Self {
+
        self.frame_size = frame_size;
+
        self
+
    }
+

+
    pub fn with_sender(mut self, sender: UnboundedSender<M>) -> Self {
+
        self.sender = Some(sender);
+
        self
+
    }
+

+
    pub fn frame_size(&self) -> Rect {
+
        self.frame_size
+
    }
+

+
    pub fn store_input(&mut self, key: Key) {
+
        self.inputs.push_back(key);
+
    }
+

+
    pub fn clear_inputs(&mut self) {
+
        self.inputs.clear();
+
    }
+
}
+

+
/// `Borders` defines which borders should be drawn around a widget.
+
pub enum Borders {
+
    None,
+
    Spacer { top: usize, left: usize },
+
    All,
+
    Top,
+
    Sides,
+
    Bottom,
+
    BottomSides,
+
}
+

+
/// A `Layout` is used to support pre-defined layouts. It either represents
+
/// such a predefined layout or a wrapped `ratatui` layout. It's used internally
+
/// but can be build from a `ratatui` layout.
+
#[derive(Clone, Default, Debug)]
+
pub enum Layout {
+
    #[default]
+
    None,
+
    Wrapped {
+
        internal: ratatui::layout::Layout,
+
    },
+
    Expandable3 {
+
        left_only: bool,
+
    },
+
}
+

+
impl From<ratatui::layout::Layout> for Layout {
+
    fn from(layout: ratatui::layout::Layout) -> Self {
+
        Layout::Wrapped { internal: layout }
+
    }
+
}
+

+
impl Layout {
+
    pub fn len(&self) -> usize {
+
        match self {
+
            Layout::None => 0,
+
            Layout::Wrapped { internal } => internal.split(Rect::default()).len(),
+
            Layout::Expandable3 { left_only } => {
+
                if *left_only {
+
                    1
+
                } else {
+
                    3
+
                }
+
            }
+
        }
+
    }
+

+
    pub fn is_empty(&self) -> bool {
+
        self.len() == 0
+
    }
+

+
    pub fn split(&self, area: Rect) -> Rc<[Rect]> {
+
        match self {
+
            Layout::None => Rc::new([]),
+
            Layout::Wrapped { internal } => internal.split(area),
+
            Layout::Expandable3 { left_only } => {
+
                use ratatui::layout::Layout;
+

+
                if *left_only {
+
                    [area].into()
+
                } else if area.width <= 140 {
+
                    let [left, right] = Layout::horizontal([
+
                        Constraint::Percentage(50),
+
                        Constraint::Percentage(50),
+
                    ])
+
                    .areas(area);
+
                    let [right_top, right_bottom] =
+
                        Layout::vertical([Constraint::Percentage(65), Constraint::Percentage(35)])
+
                            .areas(right);
+

+
                    [left, right_top, right_bottom].into()
+
                } else {
+
                    Layout::horizontal([
+
                        Constraint::Percentage(33),
+
                        Constraint::Percentage(33),
+
                        Constraint::Percentage(33),
+
                    ])
+
                    .split(area)
+
                }
+
            }
+
        }
+
    }
+
}
+

+
/// The `Ui` is the main frontend component that provides render and user-input capture
+
/// capabilities. An application consists of at least 1 root `Ui`. An `Ui` can build child
+
/// `Ui`s that partially inherit attributes.
+
#[derive(Clone, Debug)]
+
pub struct Ui<M> {
+
    /// The context this runs in: frame sizes, captured user-inputs etc.
+
    ctx: Context<M>,
+
    /// The UI theme.
+
    theme: Theme,
+
    /// The area this can render in.
+
    area: Rect,
+
    /// The layout used to calculate the next area to draw.
+
    layout: Layout,
+
    /// Currently focused area.
+
    focus: Option<usize>,
+
    /// Current rendering counter that is increased whenever the next area to draw
+
    /// on is requested.
+
    count: usize,
+
}
+

+
impl<M> Ui<M> {
+
    pub fn input(&mut self, f: impl Fn(Key) -> bool) -> bool {
+
        self.has_focus() && self.ctx.inputs.iter().any(|key| f(*key))
+
    }
+

+
    pub fn input_global(&mut self, f: impl Fn(Key) -> bool) -> bool {
+
        self.ctx.inputs.iter().any(|key| f(*key))
+
    }
+

+
    pub fn input_with_key(&mut self, f: impl Fn(Key) -> bool) -> Option<Key> {
+
        if self.has_focus() {
+
            self.ctx.inputs.iter().find(|key| f(**key)).copied()
+
        } else {
+
            None
+
        }
+
    }
+
}
+

+
impl<M> Default for Ui<M> {
+
    fn default() -> Self {
+
        Self {
+
            theme: Theme::default(),
+
            area: Rect::default(),
+
            layout: Layout::default(),
+
            focus: None,
+
            count: 0,
+
            ctx: Context::default(),
+
        }
+
    }
+
}
+

+
impl<M> Ui<M> {
+
    pub fn new(area: Rect) -> Self {
+
        Self {
+
            area,
+
            ..Default::default()
+
        }
+
    }
+

+
    pub fn with_area(mut self, area: Rect) -> Self {
+
        self.area = area;
+
        self
+
    }
+

+
    pub fn with_layout(mut self, layout: Layout) -> Self {
+
        self.layout = layout;
+
        self
+
    }
+

+
    pub fn with_ctx(mut self, ctx: Context<M>) -> Self {
+
        self.ctx = ctx;
+
        self
+
    }
+

+
    pub fn theme(&self) -> &Theme {
+
        &self.theme
+
    }
+

+
    pub fn area(&self) -> Rect {
+
        self.area
+
    }
+

+
    pub fn next_area(&mut self) -> Option<(Rect, bool)> {
+
        let has_focus = self.focus.map(|focus| self.count == focus).unwrap_or(false);
+
        let rect = self.layout.split(self.area).get(self.count).cloned();
+

+
        self.count += 1;
+

+
        rect.map(|rect| (rect, has_focus))
+
    }
+

+
    pub fn current_area(&mut self) -> Option<(Rect, bool)> {
+
        let count = self.count.saturating_sub(1);
+

+
        let has_focus = self.focus.map(|focus| count == focus).unwrap_or(false);
+
        let rect = self.layout.split(self.area).get(self.count).cloned();
+

+
        rect.map(|rect| (rect, has_focus))
+
    }
+

+
    pub fn has_focus(&self) -> bool {
+
        let count = self.count.saturating_sub(1);
+
        self.focus.map(|focus| count == focus).unwrap_or(false)
+
    }
+

+
    pub fn count(&self) -> usize {
+
        self.count
+
    }
+

+
    pub fn set_focus(&mut self, focus: Option<usize>) {
+
        self.focus = focus;
+
    }
+

+
    pub fn focus_next(&mut self) {
+
        if self.focus.is_none() {
+
            self.focus = Some(0);
+
        } else {
+
            self.focus = Some(self.focus.unwrap().saturating_add(1));
+
        }
+
    }
+

+
    pub fn send_message(&self, message: M) {
+
        if let Some(sender) = &self.ctx.sender {
+
            let _ = sender.send(message);
+
        }
+
    }
+
}
+

+
impl<M> Ui<M>
+
where
+
    M: Clone,
+
{
+
    pub fn add(&mut self, frame: &mut Frame, widget: impl Widget) -> Response {
+
        widget.ui(self, frame)
+
    }
+

+
    pub fn child_ui(&mut self, area: Rect, layout: impl Into<Layout>) -> Self {
+
        Ui::default()
+
            .with_area(area)
+
            .with_layout(layout.into())
+
            .with_ctx(self.ctx.clone())
+
    }
+

+
    pub fn layout<R>(
+
        &mut self,
+
        layout: impl Into<Layout>,
+
        add_contents: impl FnOnce(&mut Self) -> R,
+
    ) -> InnerResponse<R> {
+
        self.layout_dyn(layout, Box::new(add_contents))
+
    }
+

+
    pub fn layout_dyn<R>(
+
        &mut self,
+
        layout: impl Into<Layout>,
+
        add_contents: Box<AddContentFn<M, R>>,
+
    ) -> InnerResponse<R> {
+
        let (area, _) = self.next_area().unwrap_or_default();
+
        let mut child_ui = self.child_ui(area, layout);
+
        let inner = add_contents(&mut child_ui);
+

+
        InnerResponse::new(inner, Response::default())
+
    }
+
}
+

+
impl<M> Ui<M>
+
where
+
    M: Clone,
+
{
+
    pub fn group<R>(
+
        &mut self,
+
        layout: impl Into<Layout>,
+
        focus: &mut Option<usize>,
+
        add_contents: impl FnOnce(&mut Ui<M>) -> R,
+
    ) -> InnerResponse<R> {
+
        let (area, _) = self.next_area().unwrap_or_default();
+

+
        let layout: Layout = layout.into();
+
        let len = layout.len();
+

+
        let mut child_ui = self.child_ui(area, layout);
+
        child_ui.set_focus(Some(0));
+

+
        widget::Group::new(len, focus).show(&mut child_ui, add_contents)
+
    }
+

+
    pub fn label<'a>(&mut self, frame: &mut Frame, content: impl Into<Text<'a>>) -> Response {
+
        widget::Label::new(content).ui(self, frame)
+
    }
+

+
    pub fn overline(&mut self, frame: &mut Frame) -> Response {
+
        let overline = String::from("▔").repeat(256);
+
        self.label(frame, Span::raw(overline).cyan())
+
    }
+

+
    pub fn separator(&mut self, frame: &mut Frame) -> Response {
+
        let overline = String::from("─").repeat(256);
+
        self.label(
+
            frame,
+
            Span::raw(overline).fg(self.theme.border_style.fg.unwrap_or_default()),
+
        )
+
    }
+

+
    pub fn table<'a, R, const W: usize>(
+
        &mut self,
+
        frame: &mut Frame,
+
        selected: &mut Option<usize>,
+
        items: &'a Vec<R>,
+
        columns: Vec<Column<'a>>,
+
        borders: Option<Borders>,
+
    ) -> Response
+
    where
+
        R: ToRow<W> + Clone,
+
    {
+
        widget::Table::new(selected, items, columns, borders).ui(self, frame)
+
    }
+

+
    pub fn headered_table<'a, R, const W: usize>(
+
        &mut self,
+
        frame: &mut Frame,
+
        selected: &'a mut Option<usize>,
+
        items: &'a Vec<R>,
+
        header: impl IntoIterator<Item = Column<'a>>,
+
    ) -> Response
+
    where
+
        R: ToRow<W> + Clone,
+
    {
+
        HeaderedTable::<R, W>::new(selected, items, header).ui(self, frame)
+
    }
+

+
    pub fn shortcuts(
+
        &mut self,
+
        frame: &mut Frame,
+
        shortcuts: &[(&str, &str)],
+
        divider: char,
+
    ) -> Response {
+
        widget::Shortcuts::new(shortcuts, divider).ui(self, frame)
+
    }
+

+
    pub fn columns(
+
        &mut self,
+
        frame: &mut Frame,
+
        columns: Vec<Column<'_>>,
+
        borders: Option<Borders>,
+
    ) -> Response {
+
        widget::Columns::new(columns, borders).ui(self, frame)
+
    }
+

+
    pub fn bar(
+
        &mut self,
+
        frame: &mut Frame,
+
        columns: Vec<Column<'_>>,
+
        borders: Option<Borders>,
+
    ) -> Response {
+
        widget::Bar::new(columns, borders).ui(self, frame)
+
    }
+

+
    pub fn text_view(
+
        &mut self,
+
        frame: &mut Frame,
+
        text: String,
+
        scroll: &mut (usize, usize),
+
        borders: Option<Borders>,
+
    ) -> Response {
+
        widget::TextView::new(text, scroll, borders).ui(self, frame)
+
    }
+

+
    pub fn text_edit_singleline(
+
        &mut self,
+
        frame: &mut Frame,
+
        text: &mut String,
+
        cursor: &mut usize,
+
        borders: Option<Borders>,
+
    ) -> Response {
+
        widget::TextEdit::new(text, cursor, borders).ui(self, frame)
+
    }
+

+
    pub fn text_edit_labeled_singleline(
+
        &mut self,
+
        frame: &mut Frame,
+
        text: &mut String,
+
        cursor: &mut usize,
+
        label: impl ToString,
+
        border: Option<Borders>,
+
    ) -> Response {
+
        widget::TextEdit::new(text, cursor, border)
+
            .with_label(label)
+
            .ui(self, frame)
+
    }
+
}
added src/ui/im/widget.rs
@@ -0,0 +1,1163 @@
+
use std::cmp;
+

+
use ratatui::layout::{Direction, Layout, Rect};
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::{Line, Span, Text};
+
use ratatui::widgets::{Block, BorderType, Row, Scrollbar, ScrollbarState};
+
use ratatui::Frame;
+
use ratatui::{layout::Constraint, widgets::Paragraph};
+
use termion::event::Key;
+

+
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
+
use crate::ui::theme::style;
+
use crate::ui::{layout, span};
+
use crate::ui::{Column, ToRow};
+

+
use super::{Borders, Context, InnerResponse, Response, Ui};
+

+
pub type AddContentFn<'a, M, R> = dyn FnOnce(&mut Ui<M>) -> R + 'a;
+

+
pub trait Widget {
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone;
+
}
+

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

+
impl Window {
+
    #[inline]
+
    pub fn show<M, R>(
+
        self,
+
        ctx: &Context<M>,
+
        add_contents: impl FnOnce(&mut Ui<M>) -> R,
+
    ) -> Option<InnerResponse<Option<R>>>
+
    where
+
        M: Clone,
+
    {
+
        self.show_dyn(ctx, Box::new(add_contents))
+
    }
+

+
    fn show_dyn<M, R>(
+
        self,
+
        ctx: &Context<M>,
+
        add_contents: Box<AddContentFn<M, R>>,
+
    ) -> Option<InnerResponse<Option<R>>>
+
    where
+
        M: Clone,
+
    {
+
        let mut ui = Ui::default()
+
            .with_area(ctx.frame_size())
+
            .with_ctx(ctx.clone())
+
            .with_layout(Layout::horizontal([Constraint::Min(1)]).into());
+

+
        let inner = add_contents(&mut ui);
+

+
        Some(InnerResponse::new(Some(inner), Response::default()))
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct GroupState {
+
    len: usize,
+
    focus: Option<usize>,
+
}
+

+
impl GroupState {
+
    pub fn new(len: usize, focus: Option<usize>) -> Self {
+
        Self { len, focus }
+
    }
+

+
    pub fn focus(&self) -> Option<usize> {
+
        self.focus
+
    }
+

+
    pub fn len(&self) -> usize {
+
        self.len
+
    }
+

+
    pub fn is_empty(&self) -> bool {
+
        self.len == 0
+
    }
+

+
    pub fn focus_next(&mut self) {
+
        self.focus = self
+
            .focus
+
            .map(|focus| cmp::min(focus.saturating_add(1), self.len.saturating_sub(1)))
+
    }
+

+
    pub fn focus_prev(&mut self) {
+
        self.focus = self.focus.map(|focus| focus.saturating_sub(1))
+
    }
+
}
+

+
pub struct Group<'a> {
+
    focus: &'a mut Option<usize>,
+
    len: usize,
+
}
+

+
impl<'a> Group<'a> {
+
    pub fn new(len: usize, focus: &'a mut Option<usize>) -> Self {
+
        Self { len, focus }
+
    }
+

+
    pub fn show<M, R>(
+
        self,
+
        ui: &mut Ui<M>,
+
        add_contents: impl FnOnce(&mut Ui<M>) -> R,
+
    ) -> InnerResponse<R>
+
    where
+
        M: Clone,
+
    {
+
        self.show_dyn(ui, Box::new(add_contents))
+
    }
+

+
    pub fn show_dyn<M, R>(
+
        self,
+
        ui: &mut Ui<M>,
+
        add_contents: Box<AddContentFn<M, R>>,
+
    ) -> InnerResponse<R>
+
    where
+
        M: Clone,
+
    {
+
        let mut response = Response::default();
+

+
        let mut state = GroupState {
+
            focus: *self.focus,
+
            len: self.len,
+
        };
+

+
        if let Some(key) = ui.input_with_key(|_| true) {
+
            match key {
+
                Key::Char('\t') => {
+
                    state.focus_next();
+
                    response.changed = true;
+
                }
+
                Key::BackTab => {
+
                    state.focus_prev();
+
                    response.changed = true;
+
                }
+
                _ => {}
+
            }
+
        }
+
        *self.focus = state.focus;
+

+
        let mut ui = Ui {
+
            focus: state.focus,
+
            ..ui.clone()
+
        };
+

+
        let inner = add_contents(&mut ui);
+

+
        InnerResponse::new(inner, response)
+
    }
+
}
+

+
pub struct Label<'a> {
+
    content: Text<'a>,
+
}
+

+
impl<'a> Label<'a> {
+
    pub fn new(content: impl Into<Text<'a>>) -> Self {
+
        Self {
+
            content: content.into(),
+
        }
+
    }
+
}
+

+
impl<'a> Widget for Label<'a> {
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response {
+
        let (area, _) = ui.next_area().unwrap_or_default();
+
        frame.render_widget(self.content, area);
+

+
        Response::default()
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct TableState {
+
    internal: ratatui::widgets::TableState,
+
}
+

+
impl TableState {
+
    pub fn new(selected: Option<usize>) -> Self {
+
        let mut internal = ratatui::widgets::TableState::default();
+
        internal.select(selected);
+

+
        Self { internal }
+
    }
+

+
    pub fn selected(&self) -> Option<usize> {
+
        self.internal.selected()
+
    }
+

+
    pub fn select_first(&mut self) {
+
        self.internal.select(Some(0));
+
    }
+
}
+

+
impl TableState {
+
    fn prev(&mut self) -> Option<usize> {
+
        let selected = self
+
            .internal
+
            .selected()
+
            .map(|current| current.saturating_sub(1));
+
        self.select(selected);
+
        selected
+
    }
+

+
    fn next(&mut self, len: usize) -> Option<usize> {
+
        let selected = self.internal.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                current.saturating_add(1)
+
            } else {
+
                current
+
            }
+
        });
+
        self.select(selected);
+
        selected
+
    }
+

+
    fn prev_page(&mut self, page_size: usize) -> Option<usize> {
+
        let selected = self
+
            .internal
+
            .selected()
+
            .map(|current| current.saturating_sub(page_size));
+
        self.select(selected);
+
        selected
+
    }
+

+
    fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
+
        let selected = self.internal.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
+
            } else {
+
                current
+
            }
+
        });
+
        self.select(selected);
+
        selected
+
    }
+

+
    fn begin(&mut self) {
+
        self.select(Some(0));
+
    }
+

+
    fn end(&mut self, len: usize) {
+
        self.select(Some(len.saturating_sub(1)));
+
    }
+

+
    fn select(&mut self, selected: Option<usize>) {
+
        self.internal.select(selected);
+
    }
+
}
+

+
pub struct Table<'a, R, const W: usize> {
+
    items: &'a Vec<R>,
+
    selected: &'a mut Option<usize>,
+
    columns: Vec<Column<'a>>,
+
    borders: Option<Borders>,
+
    show_scrollbar: bool,
+
    dim: bool,
+
}
+

+
impl<'a, R, const W: usize> Table<'a, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    pub fn new(
+
        selected: &'a mut Option<usize>,
+
        items: &'a Vec<R>,
+
        columns: Vec<Column<'a>>,
+
        borders: Option<Borders>,
+
    ) -> Self {
+
        Self {
+
            items,
+
            selected,
+
            columns,
+
            borders,
+
            show_scrollbar: true,
+
            dim: false,
+
        }
+
    }
+

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

+
impl<'a, R, const W: usize> Widget for Table<'a, R, W>
+
where
+
    R: ToRow<W> + Clone,
+
{
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        let mut response = Response::default();
+

+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let show_scrollbar = self.show_scrollbar && self.items.len() >= area.height.into();
+
        let has_items = !self.items.is_empty();
+

+
        let mut state = TableState {
+
            internal: {
+
                let mut state = ratatui::widgets::TableState::default();
+
                state.select(*self.selected);
+
                state
+
            },
+
        };
+

+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let area = render_block(frame, area, self.borders, border_style);
+

+
        if let Some(key) = ui.input_with_key(|_| true) {
+
            let len = self.items.len();
+
            let page_size = area.height as usize;
+

+
            match key {
+
                Key::Up | Key::Char('k') => {
+
                    state.prev();
+
                }
+
                Key::Down | Key::Char('j') => {
+
                    state.next(len);
+
                }
+
                Key::PageUp => {
+
                    state.prev_page(page_size);
+
                }
+
                Key::PageDown => {
+
                    state.next_page(len, page_size);
+
                }
+
                Key::Home => {
+
                    state.begin();
+
                }
+
                Key::End => {
+
                    state.end(len);
+
                }
+
                _ => {}
+
            }
+
            response.changed = true;
+
        }
+

+
        let widths: Vec<Constraint> = self
+
            .columns
+
            .iter()
+
            .filter_map(|c| {
+
                if !c.skip && c.displayed(area.width as usize) {
+
                    Some(c.width)
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect();
+

+
        if has_items {
+
            let [table_area, scroller_area] =
+
                Layout::horizontal([Constraint::Min(1), Constraint::Length(1)]).areas(area);
+

+
            let rows = self
+
                .items
+
                .iter()
+
                .map(|item| {
+
                    let mut cells = vec![];
+
                    let mut it = self.columns.iter();
+

+
                    for cell in item.to_row() {
+
                        if let Some(col) = it.next() {
+
                            if !col.skip && col.displayed(table_area.width as usize) {
+
                                cells.push(cell.clone())
+
                            }
+
                        } else {
+
                            continue;
+
                        }
+
                    }
+

+
                    Row::new(cells)
+
                })
+
                .collect::<Vec<_>>();
+

+
            let table = ratatui::widgets::Table::default()
+
                .rows(rows)
+
                .widths(widths)
+
                .column_spacing(1)
+
                .highlight_style(style::highlight(has_focus));
+

+
            let table = if !has_focus && self.dim {
+
                table.dim()
+
            } else {
+
                table
+
            };
+

+
            frame.render_stateful_widget(table, table_area, &mut state.internal);
+

+
            if show_scrollbar {
+
                let content_length = self.items.len();
+
                let scroller = Scrollbar::default()
+
                    .begin_symbol(None)
+
                    .track_symbol(None)
+
                    .end_symbol(None)
+
                    .thumb_symbol("┃")
+
                    .style(if has_focus {
+
                        Style::default()
+
                    } else {
+
                        Style::default().dim()
+
                    });
+

+
                let mut state = ScrollbarState::default()
+
                    .content_length(content_length)
+
                    .viewport_content_length(1)
+
                    .position(state.internal.offset());
+

+
                frame.render_stateful_widget(scroller, scroller_area, &mut state);
+
            }
+
        } else {
+
            let center = layout::centered_rect(area, 50, 10);
+
            let hint = Text::from(span::default("Nothing to show"))
+
                .centered()
+
                .light_magenta()
+
                .dim();
+

+
            frame.render_widget(hint, center);
+
        }
+

+
        *self.selected = state.selected();
+

+
        response
+
    }
+
}
+

+
pub struct HeaderedTable<'a, R, const W: usize> {
+
    items: &'a Vec<R>,
+
    selected: &'a mut Option<usize>,
+
    header: Vec<Column<'a>>,
+
}
+

+
impl<'a, R, const W: usize> HeaderedTable<'a, R, W> {
+
    pub fn new(
+
        selected: &'a mut Option<usize>,
+
        items: &'a Vec<R>,
+
        header: impl IntoIterator<Item = Column<'a>>,
+
    ) -> Self {
+
        Self {
+
            items,
+
            selected,
+
            header: header.into_iter().collect(),
+
        }
+
    }
+

+
    pub fn items(&self) -> &Vec<R> {
+
        self.items
+
    }
+
}
+

+
/// TODO(erikli): Implement `show` that returns an `InnerResponse` such that it can
+
/// used like a group.
+
impl<'a, R, const W: usize> Widget for HeaderedTable<'a, R, W>
+
where
+
    R: ToRow<W> + Clone,
+
{
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        let mut response = Response::default();
+

+
        let (_, has_focus) = ui.current_area().unwrap_or_default();
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            |ui| {
+
                // TODO(erikli): Find better solution for border focus workaround or improve
+
                // interface for manually advancing / setting the focus index.
+
                if has_focus {
+
                    ui.set_focus(Some(0));
+
                }
+
                ui.columns(frame, self.header.clone().to_vec(), Some(Borders::Top));
+

+
                if has_focus {
+
                    ui.set_focus(Some(1));
+
                }
+
                let table = ui.table(
+
                    frame,
+
                    self.selected,
+
                    self.items,
+
                    self.header.to_vec(),
+
                    Some(Borders::BottomSides),
+
                );
+
                response.changed |= table.changed;
+
            },
+
        );
+

+
        response
+
    }
+
}
+

+
pub struct Columns<'a> {
+
    columns: Vec<Column<'a>>,
+
    borders: Option<Borders>,
+
}
+

+
impl<'a> Columns<'a> {
+
    pub fn new(columns: Vec<Column<'a>>, borders: Option<Borders>) -> Self {
+
        Self { columns, borders }
+
    }
+
}
+

+
impl<'a> Widget for Columns<'a> {
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let area = render_block(frame, area, self.borders, border_style);
+
        let area = Rect {
+
            width: area.width.saturating_sub(1),
+
            ..area
+
        };
+

+
        let widths: Vec<Constraint> = self
+
            .columns
+
            .iter()
+
            .filter_map(|c| {
+
                if !c.skip && c.displayed(area.width as usize) {
+
                    Some(c.width)
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect();
+

+
        let cells = self
+
            .columns
+
            .iter()
+
            .filter(|c| !c.skip && c.displayed(area.width as usize))
+
            .map(|c| c.text.clone())
+
            .collect::<Vec<_>>();
+

+
        let table = ratatui::widgets::Table::default()
+
            .column_spacing(1)
+
            .rows([Row::new(cells)])
+
            .widths(widths);
+
        frame.render_widget(table, area);
+

+
        Response::default()
+
    }
+
}
+

+
pub struct Bar<'a> {
+
    columns: Vec<Column<'a>>,
+
    borders: Option<Borders>,
+
}
+

+
impl<'a> Bar<'a> {
+
    pub fn new(columns: Vec<Column<'a>>, borders: Option<Borders>) -> Self {
+
        Self { columns, borders }
+
    }
+
}
+

+
impl<'a> Widget for Bar<'a> {
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let widths = self.columns.iter().map(|c| c.width).collect::<Vec<_>>();
+
        let cells = self
+
            .columns
+
            .iter()
+
            .map(|c| c.text.clone())
+
            .collect::<Vec<_>>();
+

+
        let area = render_block(frame, area, self.borders, border_style);
+
        let table = ratatui::widgets::Table::default()
+
            .header(Row::new(cells))
+
            .widths(widths)
+
            .column_spacing(0);
+
        frame.render_widget(table, area);
+

+
        Response::default()
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct TextViewState {
+
    text: String,
+
    cursor: (usize, usize),
+
}
+

+
impl TextViewState {
+
    pub fn new(text: impl Into<String>, cursor: (usize, usize)) -> Self {
+
        Self {
+
            text: text.into(),
+
            cursor,
+
        }
+
    }
+

+
    pub fn text(&self) -> &String {
+
        &self.text
+
    }
+

+
    pub fn cursor(&self) -> (usize, usize) {
+
        self.cursor
+
    }
+
}
+

+
impl TextViewState {
+
    fn scroll_up(&mut self) {
+
        self.cursor.0 = self.cursor.0.saturating_sub(1);
+
    }
+

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

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

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

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

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

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

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

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

+
pub struct TextView<'a> {
+
    text: String,
+
    borders: Option<Borders>,
+
    cursor: &'a mut (usize, usize),
+
}
+

+
impl<'a> TextView<'a> {
+
    pub fn new(
+
        text: impl ToString,
+
        cursor: &'a mut (usize, usize),
+
        borders: Option<Borders>,
+
    ) -> Self {
+
        Self {
+
            text: text.to_string(),
+
            borders,
+
            cursor,
+
        }
+
    }
+
}
+

+
impl<'a> Widget for TextView<'a> {
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        let mut response = Response::default();
+

+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let show_scrollbar = true;
+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+
        let length = self.text.lines().count();
+
        // let virtual_length = length * ((length as f64).log2() as usize) / 100;
+
        // let content_length = area.height as usize + virtual_length;
+
        // let content_length = length;
+
        let content_length = area.height as usize;
+

+
        let area = render_block(frame, area, self.borders, border_style);
+
        let area = Rect {
+
            x: area.x.saturating_add(1),
+
            width: area.width.saturating_sub(1),
+
            ..area
+
        };
+
        let [text_area, scroller_area] = Layout::horizontal([
+
            Constraint::Min(1),
+
            if show_scrollbar {
+
                Constraint::Length(1)
+
            } else {
+
                Constraint::Length(0)
+
            },
+
        ])
+
        .areas(area);
+

+
        let scroller = Scrollbar::default()
+
            .begin_symbol(None)
+
            .track_symbol(None)
+
            .end_symbol(None)
+
            .thumb_symbol("┃")
+
            .style(if has_focus {
+
                Style::default()
+
            } else {
+
                Style::default().dim()
+
            });
+

+
        let mut scroller_state = ScrollbarState::default()
+
            .content_length(length.saturating_sub(content_length))
+
            .viewport_content_length(1)
+
            .position(self.cursor.0);
+

+
        frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
+
        frame.render_widget(
+
            Paragraph::new(self.text.clone()).scroll((self.cursor.0 as u16, self.cursor.1 as u16)),
+
            text_area,
+
        );
+

+
        let mut state = TextViewState::new(self.text.clone(), *self.cursor);
+

+
        if let Some(key) = ui.input_with_key(|_| true) {
+
            let lines = self.text.lines().clone();
+
            let len = lines.clone().count();
+
            let max_line_len = lines.map(|l| l.chars().count()).max().unwrap_or_default();
+
            let page_size = area.height as usize;
+

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

+
        response
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct TextEditState {
+
    pub text: String,
+
    pub cursor: usize,
+
}
+

+
impl TextEditState {
+
    fn move_cursor_left(&mut self) {
+
        let cursor_moved_left = self.cursor.saturating_sub(1);
+
        self.cursor = self.clamp_cursor(cursor_moved_left);
+
    }
+

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

+
    fn enter_char(&mut self, new_char: char) {
+
        self.text = self.text.clone();
+
        self.text.insert(self.cursor, new_char);
+
        self.move_cursor_right();
+
    }
+

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

+
        // 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.cursor;
+
        let from_left_to_current_index = current_index;
+

+
        // Getting all characters before the selected character.
+
        let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
+
        // Getting all characters after selected character.
+
        let after_char_to_delete = self.text.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.text = before_char_to_delete.chain(after_char_to_delete).collect();
+
    }
+

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

+
        let is_not_cursor_leftmost = self.cursor != 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.cursor;
+
            let from_left_to_current_index = current_index - 1;
+

+
            // Getting all characters before the selected character.
+
            let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
+
            // Getting all characters after selected character.
+
            let after_char_to_delete = self.text.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.text = 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.text.len())
+
    }
+
}
+

+
pub struct TextEditOutput {
+
    pub response: Response,
+
    pub state: TextEditState,
+
}
+

+
pub struct TextEdit<'a> {
+
    text: &'a mut String,
+
    cursor: &'a mut usize,
+
    borders: Option<Borders>,
+
    label: Option<String>,
+
    inline_label: bool,
+
    show_cursor: bool,
+
    dim: bool,
+
}
+

+
impl<'a> TextEdit<'a> {
+
    /// # Example
+
    ///
+
    /// ```
+
    /// let mut state = TextEditState::default();
+
    /// let output = im::widget::TextEdit::new(&mut text, &mut cursor).show(ui, frame);
+
    /// if output.response.changed {
+
    ///     state = output.state;
+
    /// }
+
    /// ```
+
    pub fn new(text: &'a mut String, cursor: &'a mut usize, borders: Option<Borders>) -> Self {
+
        Self {
+
            text,
+
            cursor,
+
            label: None,
+
            borders,
+
            inline_label: true,
+
            show_cursor: true,
+
            dim: true,
+
        }
+
    }
+

+
    pub fn with_label(mut self, label: impl ToString) -> Self {
+
        self.label = Some(label.to_string());
+
        self
+
    }
+
}
+

+
impl<'a> TextEdit<'a> {
+
    pub fn show<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> TextEditOutput
+
    where
+
        M: Clone,
+
    {
+
        let mut response = Response::default();
+

+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let area = render_block(frame, area, self.borders, border_style);
+

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

+
        let mut state = TextEditState {
+
            text: self.text.to_string(),
+
            cursor: *self.cursor,
+
        };
+

+
        let label_content = format!(" {} ", self.label.unwrap_or_default());
+
        let overline = String::from("▔").repeat(area.width as usize);
+
        let cursor_pos = *self.cursor as u16;
+

+
        let (label, input, overline) = if !has_focus && self.dim {
+
            (
+
                Span::from(label_content.clone()).magenta().dim().reversed(),
+
                Span::from(state.text.clone()).reset().dim(),
+
                Span::raw(overline).magenta().dim(),
+
            )
+
        } else {
+
            (
+
                Span::from(label_content.clone()).magenta().reversed(),
+
                Span::from(state.text.clone()).reset(),
+
                Span::raw(overline).magenta(),
+
            )
+
        };
+

+
        if self.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 self.show_cursor {
+
                frame.set_cursor(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 self.show_cursor {
+
                frame.set_cursor(area.x + cursor_pos, area.y)
+
            }
+
        }
+

+
        if let Some(key) = ui.input_with_key(|_| true) {
+
            match key {
+
                Key::Char(to_insert)
+
                    if (key != Key::Alt('\n'))
+
                        && (key != Key::Char('\n'))
+
                        && (key != Key::Ctrl('\n')) =>
+
                {
+
                    state.enter_char(to_insert);
+
                }
+
                Key::Backspace => {
+
                    state.delete_char_left();
+
                }
+
                Key::Delete => {
+
                    state.delete_char_right();
+
                }
+
                Key::Left => {
+
                    state.move_cursor_left();
+
                }
+
                Key::Right => {
+
                    state.move_cursor_right();
+
                }
+
                _ => {}
+
            }
+
            response.changed = true;
+
        }
+

+
        *self.text = state.text.clone();
+
        *self.cursor = state.cursor;
+

+
        TextEditOutput { response, state }
+
    }
+
}
+

+
impl<'a> Widget for TextEdit<'a> {
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        self.show(ui, frame).response
+
    }
+
}
+

+
pub struct Shortcuts {
+
    pub shortcuts: Vec<(String, String)>,
+
    pub divider: char,
+
}
+

+
impl Shortcuts {
+
    pub fn new(shortcuts: &[(&str, &str)], divider: char) -> Self {
+
        Self {
+
            shortcuts: shortcuts
+
                .iter()
+
                .map(|(s, a)| (s.to_string(), a.to_string()))
+
                .collect(),
+
            divider,
+
        }
+
    }
+
}
+

+
impl Widget for Shortcuts {
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
+
    where
+
        M: Clone,
+
    {
+
        use ratatui::widgets::Table;
+

+
        let (area, _) = ui.next_area().unwrap_or_default();
+

+
        let mut shortcuts = self.shortcuts.iter().peekable();
+
        let mut row = vec![];
+

+
        while let Some(shortcut) = shortcuts.next() {
+
            let short = Text::from(shortcut.0.clone()).style(ui.theme.shortcuts_keys_style);
+
            let long = Text::from(shortcut.1.clone()).style(ui.theme.shortcuts_action_style);
+
            let spacer = Text::from(String::new());
+
            let divider = Text::from(format!(" {} ", self.divider)).style(style::gray().dim());
+

+
            row.push((shortcut.0.chars().count(), short));
+
            row.push((1, spacer));
+
            row.push((shortcut.1.chars().count(), long));
+

+
            if shortcuts.peek().is_some() {
+
                row.push((3, divider));
+
            }
+
        }
+

+
        let row_copy = row.clone();
+
        let row: Vec<Text<'_>> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(_, text)| text.clone())
+
            .collect();
+
        let widths: Vec<Constraint> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(width, _)| Constraint::Length(*width as u16))
+
            .collect();
+
        let table = Table::new([Row::new(row)], widths).column_spacing(0);
+

+
        frame.render_widget(table, area);
+

+
        Response::default()
+
    }
+
}
+

+
fn render_block(frame: &mut Frame, area: Rect, borders: Option<Borders>, style: Style) -> Rect {
+
    if let Some(border) = borders {
+
        match border {
+
            Borders::None => area,
+
            Borders::Spacer { top, left } => {
+
                let areas = Layout::horizontal([Constraint::Fill(1)])
+
                    .vertical_margin(top as u16)
+
                    .horizontal_margin(left as u16)
+
                    .split(area);
+

+
                areas[0]
+
            }
+
            Borders::All => {
+
                let block = Block::default()
+
                    .border_style(style)
+
                    .border_type(BorderType::Rounded)
+
                    .borders(ratatui::widgets::Borders::ALL);
+
                frame.render_widget(block.clone(), area);
+

+
                block.inner(area)
+
            }
+
            Borders::Top => {
+
                let block = HeaderBlock::default()
+
                    .border_style(style)
+
                    .border_type(BorderType::Rounded)
+
                    .borders(ratatui::widgets::Borders::ALL);
+
                frame.render_widget(block, area);
+

+
                let areas = Layout::default()
+
                    .direction(Direction::Vertical)
+
                    .constraints(vec![Constraint::Min(1)])
+
                    .vertical_margin(1)
+
                    .horizontal_margin(1)
+
                    .split(area);
+

+
                areas[0]
+
            }
+
            Borders::Sides => {
+
                let block = Block::default()
+
                    .border_style(style)
+
                    .border_type(BorderType::Rounded)
+
                    .borders(ratatui::widgets::Borders::LEFT | ratatui::widgets::Borders::RIGHT);
+
                frame.render_widget(block.clone(), area);
+

+
                block.inner(area)
+
            }
+
            Borders::Bottom => {
+
                let areas = Layout::default()
+
                    .direction(Direction::Vertical)
+
                    .constraints(vec![Constraint::Min(1)])
+
                    .vertical_margin(1)
+
                    .horizontal_margin(1)
+
                    .split(area);
+

+
                let footer_block = FooterBlock::default()
+
                    .border_style(style)
+
                    .block_type(FooterBlockType::Single { top: true });
+
                frame.render_widget(footer_block, area);
+

+
                areas[0]
+
            }
+
            Borders::BottomSides => {
+
                let areas = Layout::default()
+
                    .direction(Direction::Vertical)
+
                    .constraints(vec![Constraint::Min(1)])
+
                    .horizontal_margin(1)
+
                    .split(area);
+

+
                let footer_block = FooterBlock::default()
+
                    .border_style(style)
+
                    .block_type(FooterBlockType::Single { top: false });
+
                frame.render_widget(footer_block, area);
+

+
                Rect {
+
                    height: areas[0].height.saturating_sub(1),
+
                    ..areas[0]
+
                }
+
            }
+
        }
+
    } else {
+
        area
+
    }
+
}
added src/ui/rm.rs
@@ -0,0 +1,93 @@
+
pub mod widget;
+

+
use std::fmt::Debug;
+
use std::time::Duration;
+

+
use ratatui::Viewport;
+
use tokio::sync::broadcast;
+
use tokio::sync::mpsc::UnboundedReceiver;
+

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

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

+
/// The `Frontend` runs an applications' view concurrently. It handles
+
/// terminal events as well as state updates and renders the view accordingly.
+
///
+
/// Once created and run with `main_loop`, the `Frontend` will wait for new messages
+
/// being sent on either the terminal event, the state or the interrupt message channel.
+
#[derive(Default)]
+
pub struct Frontend {}
+

+
impl Frontend {
+
    /// By calling `main_loop`, the `Frontend` will wait for new messages being sent
+
    /// on either the terminal event, the state or the interrupt message channel.
+
    /// After all, it will draw the (potentially) updated root widget.
+
    ///
+
    /// Terminal event messages are being sent by a thread polling `stdin` for new user input
+
    /// and another thread polling UNIX signals, e.g. `SIGWINCH` when the terminal
+
    /// window size is being changed. Terminal events are then passed to the root widget
+
    /// of the application.
+
    ///
+
    /// State messages are being sent by the applications' `Store`. Received state updates
+
    /// will be passed to the root widget as well.
+
    ///
+
    /// Interrupt messages are being sent to broadcast channel for retrieving the
+
    /// application kill signal.
+
    pub async fn run<S, M, R>(
+
        self,
+
        mut root: Widget<S, M>,
+
        mut state_rx: UnboundedReceiver<S>,
+
        mut interrupt_rx: broadcast::Receiver<Interrupted<R>>,
+
        viewport: Viewport,
+
    ) -> anyhow::Result<Interrupted<R>>
+
    where
+
        S: Update<M, Return = R> + 'static,
+
        M: 'static,
+
        R: Clone + Send + Sync + Debug,
+
    {
+
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
+

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

+
        let mut root = {
+
            let state = state_rx.recv().await.unwrap();
+

+
            root.update(&state);
+
            root
+
        };
+

+
        let result: anyhow::Result<Interrupted<R>> = loop {
+
            tokio::select! {
+
                // Tick to terminate the select every N milliseconds
+
                _ = ticker.tick() => (),
+
                Some(event) = events_rx.recv() => match event {
+
                    Event::Key(key) => root.handle_event(key),
+
                    Event::Resize => (),
+
                },
+
                // Handle state updates
+
                Some(state) = state_rx.recv() => {
+
                    root.update(&state);
+
                },
+
                // Catch and handle interrupt signal to gracefully shutdown
+
                Ok(interrupted) = interrupt_rx.recv() => {
+
                    let size = terminal.get_frame().size();
+
                    let _ = terminal.set_cursor(size.x, size.y);
+

+
                    break Ok(interrupted);
+
                }
+
            }
+
            terminal.draw(|frame| root.render(RenderProps::from(frame.size()), frame))?;
+
        };
+

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

+
        result
+
    }
+
}
added src/ui/rm/widget.rs
@@ -0,0 +1,371 @@
+
pub mod container;
+
pub mod input;
+
pub mod list;
+
pub mod utils;
+
pub mod window;
+

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

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

+
use termion::event::Key;
+

+
use ratatui::prelude::*;
+

+
use self::{
+
    container::SectionGroupState,
+
    input::{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>;
+

+
/// `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
+
/// know the concrete type of a `View`s properties.
+
/// Hence, view properties are stored inside a `Box<dyn Any>` and downcasted to the concrete
+
/// type when needed.
+
pub struct ViewProps {
+
    inner: Box<dyn Any>,
+
}
+

+
impl ViewProps {
+
    pub fn inner<T>(self) -> Option<T>
+
    where
+
        T: Default + Clone + 'static,
+
    {
+
        self.inner.downcast::<T>().ok().map(|inner| *inner)
+
    }
+

+
    pub fn inner_ref<T>(&self) -> Option<&T>
+
    where
+
        T: Default + Clone + 'static,
+
    {
+
        self.inner.downcast_ref::<T>()
+
    }
+
}
+

+
impl From<Box<dyn Any>> for ViewProps {
+
    fn from(props: Box<dyn Any>) -> Self {
+
        ViewProps { inner: props }
+
    }
+
}
+

+
impl From<&'static dyn Any> for ViewProps {
+
    fn from(inner: &'static dyn Any) -> Self {
+
        Self {
+
            inner: Box::new(inner),
+
        }
+
    }
+
}
+

+
/// A `ViewState` is the representation of a `View`s internal state. e.g. current
+
/// table selection or contents of a text field.
+
#[derive(Debug)]
+
pub enum ViewState {
+
    USize(usize),
+
    String(String),
+
    Table { selected: usize, scroll: usize },
+
    Tree(Vec<String>),
+
    TextView(TextViewState),
+
    TextArea(TextAreaState),
+
    SectionGroup(SectionGroupState),
+
}
+

+
impl ViewState {
+
    pub fn unwrap_usize(&self) -> Option<usize> {
+
        match self {
+
            ViewState::USize(value) => Some(*value),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_string(&self) -> Option<String> {
+
        match self {
+
            ViewState::String(value) => Some(value.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_table(&self) -> Option<(usize, usize)> {
+
        match self {
+
            ViewState::Table { selected, scroll } => Some((*selected, *scroll)),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_textview(&self) -> Option<TextViewState> {
+
        match self {
+
            ViewState::TextView(state) => Some(state.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_textarea(&self) -> Option<TextAreaState> {
+
        match self {
+
            ViewState::TextArea(state) => Some(state.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_section_group(&self) -> Option<SectionGroupState> {
+
        match self {
+
            ViewState::SectionGroup(state) => Some(state.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_tree(&self) -> Option<Vec<String>> {
+
        match self {
+
            ViewState::Tree(value) => Some(value.clone().to_vec()),
+
            _ => None,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Default)]
+
pub enum PredefinedLayout {
+
    #[default]
+
    None,
+
    Expandable3 {
+
        left_only: bool,
+
    },
+
}
+

+
impl PredefinedLayout {
+
    pub fn split(&self, area: Rect) -> Rc<[Rect]> {
+
        match self {
+
            Self::Expandable3 { left_only } => {
+
                if *left_only {
+
                    [area].into()
+
                } else if area.width <= 140 {
+
                    let [left, right] = Layout::horizontal([
+
                        Constraint::Percentage(50),
+
                        Constraint::Percentage(50),
+
                    ])
+
                    .areas(area);
+
                    let [right_top, right_bottom] =
+
                        Layout::vertical([Constraint::Percentage(65), Constraint::Percentage(35)])
+
                            .areas(right);
+

+
                    [left, right_top, right_bottom].into()
+
                } else {
+
                    Layout::horizontal([
+
                        Constraint::Percentage(33),
+
                        Constraint::Percentage(33),
+
                        Constraint::Percentage(33),
+
                    ])
+
                    .split(area)
+
                }
+
            }
+
            _ => Layout::default().split(area),
+
        }
+
    }
+
}
+

+
/// General properties that specify how a `View` is rendered.
+
#[derive(Clone, Default)]
+
pub struct RenderProps {
+
    /// Area of the render props.
+
    pub area: Rect,
+
    /// Layout to be rendered in.
+
    pub layout: Layout,
+
    /// Focus of the render props.
+
    pub focus: bool,
+
}
+

+
impl RenderProps {
+
    /// Sets the area to render in.
+
    pub fn area(mut self, area: Rect) -> Self {
+
        self.area = area;
+
        self
+
    }
+

+
    /// Sets the focus of these render props.
+
    pub fn focus(mut self, focus: bool) -> Self {
+
        self.focus = focus;
+
        self
+
    }
+

+
    /// Sets the layout of these render props.
+
    pub fn layout(mut self, layout: Layout) -> Self {
+
        self.layout = layout;
+
        self
+
    }
+
}
+

+
impl From<Rect> for RenderProps {
+
    fn from(area: Rect) -> Self {
+
        Self {
+
            area,
+
            layout: Layout::default(),
+
            focus: false,
+
        }
+
    }
+
}
+

+
/// Main trait defining a `View` behaviour, which needs be implemented in order to
+
/// build a custom widget. A `View` operates on an application state and can emit
+
/// application messages. It's usually is accompanied by a definition of view-specific
+
/// properties, which are being built from the application state by the framework.
+
pub trait View {
+
    type State;
+
    type Message;
+

+
    /// Should return the internal state.
+
    fn view_state(&self) -> Option<ViewState> {
+
        None
+
    }
+

+
    /// Should reset the internal state and call `reset` on all children.
+
    fn reset(&mut self) {}
+

+
    /// Should handle key events and call `handle_event` on all children.
+
    fn handle_event(&mut self, _props: Option<&ViewProps>, _key: Key) -> Option<Self::Message> {
+
        None
+
    }
+

+
    /// Should update the internal props of this and all children.
+
    fn update(&mut self, _props: Option<&ViewProps>, _state: &Self::State) {}
+

+
    /// Should render the view using the given `RenderProps`.
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame);
+
}
+

+
/// A `Widget` enhances a `View` with event and update callbacks and takes
+
/// care of calling them before / after calling into the `View`.
+
///
+
/// In _retained mode_, a widget is defined by an implementation of the `View` trait
+
/// and a `Widget` it is wrapped in. A `View` handles user-interactions, updates itself
+
/// whenever the application state changed and renders itself frequently. A `Widget` adds
+
/// additional support for properties and event, update and render callbacks. Properties
+
/// define the data, configuration etc. of a widget. They are updated by the framework
+
/// taking the properties built by the `on_update` callback. The `on_event` callback is
+
/// used to emit application messages whenever a widget receives an event.
+

+
/// The main idea is to build widgets that handle their specific events already,
+
/// and that are updated with the properties built by the `on_update` callback.
+
/// Custom logic is added by setting the `on_event` callback. E.g. the `Table` widget
+
/// handles item selection already; items are set via the `on_update` callback and
+
/// application messages are emitted via the `on_event` callback.
+
pub struct Widget<S, M> {
+
    view: BoxedView<S, M>,
+
    props: Option<ViewProps>,
+
    sender: UnboundedSender<M>,
+
    on_update: Option<UpdateCallback<S>>,
+
    on_event: Option<EventCallback<M>>,
+
    on_render: Option<RenderCallback<M>>,
+
}
+

+
impl<S: 'static, M: 'static> Widget<S, M> {
+
    pub fn new<V>(view: V, sender: UnboundedSender<M>) -> Self
+
    where
+
        Self: Sized,
+
        V: View<State = S, Message = M> + 'static,
+
    {
+
        Self {
+
            view: Box::new(view),
+
            props: None,
+
            sender: sender.clone(),
+
            on_update: None,
+
            on_event: None,
+
            on_render: None,
+
        }
+
    }
+

+
    /// Calls `reset` on the wrapped view.
+
    pub fn reset(&mut self) {
+
        self.view.reset()
+
    }
+

+
    /// Calls `handle_event` on the wrapped view as well as the `on_event` callback.
+
    /// Sends any message returned by either the view or the callback.
+
    pub fn handle_event(&mut self, key: Key) {
+
        if let Some(message) = self.view.handle_event(self.props.as_ref(), key) {
+
            let _ = self.sender.send(message);
+
        }
+

+
        if let Some(on_event) = self.on_event {
+
            if let Some(message) =
+
                (on_event)(key, self.view.view_state().as_ref(), self.props.as_ref())
+
            {
+
                let _ = self.sender.send(message);
+
            }
+
        }
+
    }
+

+
    /// 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`.
+
    ///
+
    /// If `on_update` is set, implementations of this function should call it to
+
    /// construct and update the internal props. If it is not set, app widgets can construct
+
    /// props directly via their state converters, whereas library widgets can just fallback
+
    /// to their current props.
+
    pub fn update(&mut self, state: &S) {
+
        self.props = self.on_update.map(|on_update| (on_update)(state));
+
        self.view.update(self.props.as_ref(), state);
+
    }
+

+
    /// Renders the wrapped view.
+
    pub fn render(&mut self, render: RenderProps, frame: &mut Frame) {
+
        self.view.render(self.props.as_ref(), render.clone(), frame);
+

+
        if let Some(on_render) = self.on_render {
+
            (on_render)(self.props.as_ref(), &render)
+
                .and_then(|message| self.sender.send(message).ok());
+
        }
+
    }
+

+
    /// 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_update(mut self, callback: UpdateCallback<S>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        self.on_update = Some(callback);
+
        self
+
    }
+

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

+
/// 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>
+
    where
+
        Self: Sized + 'static;
+
}
+

+
impl<T, S, M> ToWidget<S, M> for T
+
where
+
    T: View<State = S, Message = M>,
+
    S: 'static,
+
    M: 'static,
+
{
+
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
+
    where
+
        Self: Sized + 'static,
+
    {
+
        Widget::new(self, tx)
+
    }
+
}
added src/ui/rm/widget/container.rs
@@ -0,0 +1,768 @@
+
use std::fmt::Debug;
+
use std::marker::PhantomData;
+

+
use termion::event::Key;
+

+
use ratatui::prelude::*;
+
use ratatui::widgets::{Block, BorderType, Borders, Row};
+

+
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
+
use crate::ui::theme::{style, Theme};
+
use crate::ui::Column;
+

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

+
#[derive(Clone, Debug)]
+
pub struct HeaderProps<'a> {
+
    pub columns: Vec<Column<'a>>,
+
    pub cutoff: usize,
+
    pub cutoff_after: usize,
+
    pub border_style: Style,
+
    pub focus_border_style: Style,
+
}
+

+
impl<'a> HeaderProps<'a> {
+
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
+
        self.columns = columns;
+
        self
+
    }
+

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

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

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

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

+
        Self {
+
            columns: vec![],
+
            cutoff: usize::MAX,
+
            cutoff_after: usize::MAX,
+
            border_style: theme.border_style,
+
            focus_border_style: theme.focus_border_style,
+
        }
+
    }
+
}
+

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

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

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

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

+
        let width = render.area.width.saturating_sub(2);
+

+
        let widths: Vec<Constraint> = props
+
            .columns
+
            .iter()
+
            .filter_map(|c| {
+
                if !c.skip && c.displayed(width as usize) {
+
                    Some(c.width)
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect();
+

+
        let cells = props
+
            .columns
+
            .iter()
+
            .filter_map(|column| {
+
                if !column.skip && column.displayed(width as usize) {
+
                    Some(column.text.clone())
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect::<Vec<_>>();
+

+
        let border_style = if render.focus {
+
            props.focus_border_style
+
        } else {
+
            props.border_style
+
        };
+

+
        // Render header
+
        let block = HeaderBlock::default()
+
            .borders(Borders::ALL)
+
            .border_style(border_style)
+
            .border_type(BorderType::Rounded);
+

+
        let header_layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![Constraint::Min(1)])
+
            .vertical_margin(1)
+
            .horizontal_margin(1)
+
            .split(render.area);
+

+
        let header = Row::new(cells).style(style::reset().bold());
+
        let header = ratatui::widgets::Table::default()
+
            .column_spacing(1)
+
            .header(header)
+
            .widths(widths.clone());
+

+
        frame.render_widget(block, render.area);
+
        frame.render_widget(header, header_layout[0]);
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct FooterProps<'a> {
+
    pub columns: Vec<Column<'a>>,
+
    pub cutoff: usize,
+
    pub cutoff_after: usize,
+
    pub border_style: Style,
+
    pub focus_border_style: Style,
+
}
+

+
impl<'a> FooterProps<'a> {
+
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
+
        self.columns = columns;
+
        self
+
    }
+

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

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

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

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

+
        Self {
+
            columns: vec![],
+
            cutoff: usize::MAX,
+
            cutoff_after: usize::MAX,
+
            border_style: theme.border_style,
+
            focus_border_style: theme.focus_border_style,
+
        }
+
    }
+
}
+

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

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

+
impl<'a, S, M> Footer<S, M> {
+
    fn render_cell(
+
        &self,
+
        frame: &mut ratatui::Frame,
+
        border_style: Style,
+
        render: RenderProps,
+
        block_type: FooterBlockType,
+
        text: impl Into<Text<'a>>,
+
    ) {
+
        let footer_layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![Constraint::Min(1)])
+
            .vertical_margin(1)
+
            .horizontal_margin(1)
+
            .split(render.area);
+

+
        let footer_block = FooterBlock::default()
+
            .border_style(border_style)
+
            .block_type(block_type);
+
        frame.render_widget(footer_block, render.area);
+
        frame.render_widget(text.into(), footer_layout[0]);
+
    }
+
}
+

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

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

+
        let border_style = if render.focus {
+
            props.focus_border_style
+
        } else {
+
            props.border_style
+
        };
+

+
        let widths = props
+
            .columns
+
            .iter()
+
            .map(|c| match c.width {
+
                Constraint::Min(min) => Constraint::Length(min.saturating_add(3)),
+
                _ => c.width,
+
            })
+
            .collect::<Vec<_>>();
+

+
        let layout = Layout::horizontal(widths).split(render.area);
+
        let cells = props
+
            .columns
+
            .iter()
+
            .map(|c| c.text.clone())
+
            .zip(layout.iter())
+
            .collect::<Vec<_>>();
+

+
        let last = cells.len().saturating_sub(1);
+
        let len = cells.len();
+

+
        for (i, (cell, area)) in cells.into_iter().enumerate() {
+
            let block_type = match i {
+
                0 if len == 1 => FooterBlockType::Single { top: true },
+
                0 => FooterBlockType::Begin,
+
                _ if i == last => FooterBlockType::End,
+
                _ => FooterBlockType::Repeat,
+
            };
+
            self.render_cell(
+
                frame,
+
                border_style,
+
                render.clone().area(*area),
+
                block_type,
+
                cell.clone(),
+
            );
+
        }
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct ContainerProps {
+
    hide_footer: bool,
+
    border_style: Style,
+
    focus_border_style: Style,
+
}
+

+
impl Default for ContainerProps {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            hide_footer: false,
+
            border_style: theme.border_style,
+
            focus_border_style: theme.focus_border_style,
+
        }
+
    }
+
}
+

+
impl ContainerProps {
+
    pub fn hide_footer(mut self, hide: bool) -> Self {
+
        self.hide_footer = hide;
+
        self
+
    }
+

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

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

+
pub struct Container<S, M> {
+
    /// Container header
+
    header: Option<Widget<S, M>>,
+
    /// Content widget
+
    content: Option<Widget<S, M>>,
+
    /// Container footer
+
    footer: Option<Widget<S, M>>,
+
}
+

+
impl<S, M> Default for Container<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            header: None,
+
            content: None,
+
            footer: None,
+
        }
+
    }
+
}
+

+
impl<S, M> Container<S, M> {
+
    pub fn header(mut self, header: Widget<S, M>) -> Self {
+
        self.header = Some(header);
+
        self
+
    }
+

+
    pub fn content(mut self, content: Widget<S, M>) -> Self {
+
        self.content = Some(content);
+
        self
+
    }
+

+
    pub fn footer(mut self, footer: Widget<S, M>) -> Self {
+
        self.footer = Some(footer);
+
        self
+
    }
+
}
+

+
impl<S, M> View for Container<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> {
+
        if let Some(content) = &mut self.content {
+
            content.handle_event(key);
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        if let Some(header) = &mut self.header {
+
            header.update(state);
+
        }
+

+
        if let Some(content) = &mut self.content {
+
            content.update(state);
+
        }
+

+
        if let Some(footer) = &mut self.footer {
+
            footer.update(state);
+
        }
+
    }
+

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

+
        let border_style = if render.focus {
+
            props.focus_border_style
+
        } else {
+
            props.border_style
+
        };
+

+
        let header_h = if self.header.is_some() { 3 } else { 0 };
+
        let footer_h = if self.footer.is_some() && !props.hide_footer {
+
            3
+
        } else {
+
            0
+
        };
+

+
        let [header_area, content_area, footer_area] = Layout::vertical([
+
            Constraint::Length(header_h),
+
            Constraint::Min(1),
+
            Constraint::Length(footer_h),
+
        ])
+
        .areas(render.area);
+

+
        let borders = match (
+
            self.header.is_some(),
+
            (self.footer.is_some() && !props.hide_footer),
+
        ) {
+
            (false, false) => Borders::ALL,
+
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
+
            (false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
+
            (true, true) => Borders::LEFT | Borders::RIGHT,
+
        };
+

+
        let block = Block::default()
+
            .border_style(border_style)
+
            .border_type(BorderType::Rounded)
+
            .borders(borders);
+
        frame.render_widget(block.clone(), content_area);
+

+
        if let Some(header) = self.header.as_mut() {
+
            header.render(RenderProps::from(header_area).focus(render.focus), frame);
+
        }
+

+
        if let Some(content) = self.content.as_mut() {
+
            content.render(
+
                RenderProps::from(block.inner(content_area)).focus(render.focus),
+
                frame,
+
            );
+
        }
+

+
        if let Some(footer) = self.footer.as_mut() {
+
            footer.render(RenderProps::from(footer_area).focus(render.focus), frame);
+
        }
+
    }
+
}
+

+
#[derive(Clone, Default)]
+
pub enum SplitContainerFocus {
+
    #[default]
+
    Top,
+
    Bottom,
+
}
+

+
#[derive(Clone)]
+
pub struct SplitContainerProps {
+
    split_focus: SplitContainerFocus,
+
    heights: [Constraint; 2],
+
    border_style: Style,
+
    focus_border_style: Style,
+
}
+

+
impl Default for SplitContainerProps {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            split_focus: SplitContainerFocus::default(),
+
            heights: [Constraint::Percentage(50), Constraint::Percentage(50)],
+
            border_style: theme.border_style,
+
            focus_border_style: theme.focus_border_style,
+
        }
+
    }
+
}
+

+
impl SplitContainerProps {
+
    pub fn split_focus(mut self, split_focus: SplitContainerFocus) -> Self {
+
        self.split_focus = split_focus;
+
        self
+
    }
+

+
    pub fn heights(mut self, heights: [Constraint; 2]) -> Self {
+
        self.heights = heights;
+
        self
+
    }
+

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

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

+
pub struct SplitContainer<S, M> {
+
    /// Container top
+
    top: Option<Widget<S, M>>,
+
    /// Content bottom
+
    bottom: Option<Widget<S, M>>,
+
}
+

+
impl<S, M> Default for SplitContainer<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            top: None,
+
            bottom: None,
+
        }
+
    }
+
}
+

+
impl<S, M> SplitContainer<S, M> {
+
    pub fn top(mut self, top: Widget<S, M>) -> Self {
+
        self.top = Some(top);
+
        self
+
    }
+

+
    pub fn bottom(mut self, bottom: Widget<S, M>) -> Self {
+
        self.bottom = Some(bottom);
+
        self
+
    }
+
}
+

+
impl<S, M> View for SplitContainer<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 = SplitContainerProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<SplitContainerProps>())
+
            .unwrap_or(&default);
+

+
        match props.split_focus {
+
            SplitContainerFocus::Top => {
+
                if let Some(top) = self.top.as_mut() {
+
                    top.handle_event(key);
+
                }
+
            }
+
            SplitContainerFocus::Bottom => {
+
                if let Some(bottom) = self.bottom.as_mut() {
+
                    bottom.handle_event(key);
+
                }
+
            }
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        if let Some(top) = self.top.as_mut() {
+
            top.update(state);
+
        }
+

+
        if let Some(bottom) = self.bottom.as_mut() {
+
            bottom.update(state);
+
        }
+
    }
+

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

+
        let heights = props
+
            .heights
+
            .iter()
+
            .map(|c| {
+
                if let Constraint::Length(l) = c {
+
                    Constraint::Length(l + 2)
+
                } else {
+
                    *c
+
                }
+
            })
+
            .collect::<Vec<_>>();
+

+
        let border_style = if render.focus {
+
            props.focus_border_style
+
        } else {
+
            props.border_style
+
        };
+

+
        let [top_area, bottom_area] = Layout::vertical(heights).areas(render.area);
+

+
        if let Some(top) = self.top.as_mut() {
+
            let block = HeaderBlock::default()
+
                .borders(Borders::ALL)
+
                .border_style(border_style)
+
                .border_type(BorderType::Rounded);
+

+
            frame.render_widget(block, top_area);
+

+
            let [top_area] = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints(vec![Constraint::Min(1)])
+
                .vertical_margin(1)
+
                .horizontal_margin(1)
+
                .areas(top_area);
+
            top.render(RenderProps::from(top_area).focus(render.focus), frame)
+
        }
+

+
        if let Some(bottom) = self.bottom.as_mut() {
+
            let block = Block::default()
+
                .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
+
                .border_style(border_style)
+
                .border_type(BorderType::Rounded);
+

+
            frame.render_widget(block, bottom_area);
+

+
            let [bottom_area, _] = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints(vec![Constraint::Min(1), Constraint::Length(1)])
+
                .horizontal_margin(1)
+
                .areas(bottom_area);
+
            bottom.render(RenderProps::from(bottom_area).focus(render.focus), frame)
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct SectionGroupState {
+
    /// Index of currently focused section.
+
    pub focus: Option<usize>,
+
}
+

+
#[derive(Clone, Default)]
+
pub struct SectionGroupProps {
+
    /// Index of currently focused section. If set, it will override the widgets'
+
    /// internal state.
+
    focus: Option<usize>,
+
    /// If this pages' keys should be handled.
+
    handle_keys: bool,
+
    /// Section layout
+
    layout: PredefinedLayout,
+
}
+

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

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

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

+
pub struct SectionGroup<S, M> {
+
    /// All sections
+
    sections: Vec<Widget<S, M>>,
+
    /// Internal selection and offset state
+
    state: SectionGroupState,
+
}
+

+
impl<S, M> Default for SectionGroup<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            sections: vec![],
+
            state: SectionGroupState { focus: Some(0) },
+
        }
+
    }
+
}
+

+
impl<S, M> SectionGroup<S, M> {
+
    pub fn section(mut self, section: Widget<S, M>) -> Self {
+
        self.sections.push(section);
+
        self
+
    }
+

+
    fn prev(&mut self) -> Option<usize> {
+
        let focus = self.state.focus.map(|current| current.saturating_sub(1));
+
        self.state.focus = focus;
+
        focus
+
    }
+

+
    fn next(&mut self, len: usize) -> Option<usize> {
+
        let focus = self.state.focus.map(|current| {
+
            if current < len.saturating_sub(1) {
+
                current.saturating_add(1)
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.focus = focus;
+
        focus
+
    }
+
}
+

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

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

+
        if let Some(section) = self
+
            .state
+
            .focus
+
            .and_then(|focus| self.sections.get_mut(focus))
+
        {
+
            section.handle_event(key);
+
        }
+

+
        if props.handle_keys {
+
            match key {
+
                Key::BackTab => {
+
                    self.prev();
+
                }
+
                Key::Char('\t') => {
+
                    self.next(self.sections.len());
+
                }
+
                _ => {}
+
            }
+
        }
+

+
        None
+
    }
+

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

+
        for section in &mut self.sections {
+
            section.update(state);
+
        }
+

+
        if props.focus.is_some() && props.focus != self.state.focus {
+
            self.state.focus = props.focus;
+
        }
+
    }
+

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

+
        let areas = props.layout.split(render.area);
+

+
        for (index, area) in areas.iter().enumerate() {
+
            if let Some(section) = self.sections.get_mut(index) {
+
                let focus = self
+
                    .state
+
                    .focus
+
                    .map(|focus_index| index == focus_index)
+
                    .unwrap_or_default();
+

+
                section.render(RenderProps::from(*area).focus(focus), frame);
+
            }
+
        }
+
    }
+

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

+
use ratatui::widgets::Paragraph;
+
use ratatui::Frame;
+
use termion::event::Key;
+

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

+
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(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(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<'a> Default for TextAreaProps<'a> {
+
    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<'a, S, M> Default for TextArea<'a, S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
+
            textarea: tui_textarea::TextArea::default(),
+
            area: (0, 0),
+
        }
+
    }
+
}
+

+
impl<'a, S, M> View for TextArea<'a, 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.widget(), 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<'a> Default for TextViewProps<'a> {
+
    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/list.rs
@@ -0,0 +1,539 @@
+
use std::collections::HashSet;
+
use std::hash::Hash;
+
use std::marker::PhantomData;
+
use std::{cmp, vec};
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::{Style, Stylize};
+
use ratatui::symbols::border;
+
use ratatui::text::Text;
+
use ratatui::widgets::TableState;
+
use ratatui::widgets::{Block, Borders, Row, Scrollbar, ScrollbarOrientation, ScrollbarState};
+
use ratatui::Frame;
+

+
use tui_tree_widget::TreeState;
+

+
use crate::ui::theme::style;
+
use crate::ui::{layout, span};
+
use crate::ui::{Column, ToRow, ToTree};
+

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

+
#[derive(Clone, Debug)]
+
pub struct TableProps<'a, R, const W: usize>
+
where
+
    R: ToRow<W>,
+
{
+
    pub items: Vec<R>,
+
    pub selected: Option<usize>,
+
    pub columns: Vec<Column<'a>>,
+
    pub show_scrollbar: bool,
+
    pub dim: bool,
+
}
+

+
impl<'a, R, const W: usize> Default for TableProps<'a, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    fn default() -> Self {
+
        Self {
+
            items: vec![],
+
            columns: vec![],
+
            show_scrollbar: true,
+
            selected: Some(0),
+
            dim: false,
+
        }
+
    }
+
}
+

+
impl<'a, R, const W: usize> TableProps<'a, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    pub fn items(mut self, items: Vec<R>) -> Self {
+
        self.items = items;
+
        self
+
    }
+

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

+
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
+
        self.columns = columns;
+
        self
+
    }
+

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

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

+
pub struct Table<S, M, R, const W: usize>
+
where
+
    R: ToRow<W>,
+
{
+
    /// Internal selection and offset state
+
    state: (TableState, usize),
+
    /// Phantom
+
    phantom: PhantomData<(S, M, R)>,
+
    /// Current render height
+
    height: u16,
+
}
+

+
impl<S, M, R, const W: usize> Default for Table<S, M, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    fn default() -> Self {
+
        Self {
+
            state: (TableState::default().with_selected(Some(0)), 0),
+
            phantom: PhantomData,
+
            height: 1,
+
        }
+
    }
+
}
+

+
impl<S, M, R, const W: usize> Table<S, M, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    fn prev(&mut self) -> Option<usize> {
+
        let selected = self
+
            .state
+
            .0
+
            .selected()
+
            .map(|current| current.saturating_sub(1));
+
        self.state.0.select(selected);
+
        selected
+
    }
+

+
    fn next(&mut self, len: usize) -> Option<usize> {
+
        let selected = self.state.0.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                current.saturating_add(1)
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.0.select(selected);
+
        selected
+
    }
+

+
    fn prev_page(&mut self, page_size: usize) -> Option<usize> {
+
        let selected = self
+
            .state
+
            .0
+
            .selected()
+
            .map(|current| current.saturating_sub(page_size));
+
        self.state.0.select(selected);
+
        selected
+
    }
+

+
    fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
+
        let selected = self.state.0.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.0.select(selected);
+
        selected
+
    }
+

+
    fn begin(&mut self) {
+
        self.state.0.select(Some(0));
+
    }
+

+
    fn end(&mut self, len: usize) {
+
        self.state.0.select(Some(len.saturating_sub(1)));
+
    }
+
}
+

+
impl<S, M, R, const W: usize> View for Table<S, M, R, W>
+
where
+
    S: 'static,
+
    M: 'static,
+
    R: ToRow<W> + Clone + 'static,
+
{
+
    type Message = M;
+
    type State = S;
+

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

+
        let page_size = self.height;
+

+
        match key {
+
            Key::Up | Key::Char('k') => {
+
                self.prev();
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.next(props.items.len());
+
            }
+
            Key::PageUp => {
+
                self.prev_page(page_size as usize);
+
            }
+
            Key::PageDown => {
+
                self.next_page(props.items.len(), page_size as usize);
+
            }
+
            Key::Home => {
+
                self.begin();
+
            }
+
            Key::End => {
+
                self.end(props.items.len());
+
            }
+
            _ => {}
+
        }
+

+
        None
+
    }
+

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

+
        if props.selected != self.state.0.selected() {
+
            self.state.0.select(props.selected);
+
        }
+
        self.state.1 = props.items.len();
+
    }
+

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

+
        let show_scrollbar = props.show_scrollbar && props.items.len() >= self.height.into();
+
        let has_items = !props.items.is_empty();
+

+
        let widths: Vec<Constraint> = props
+
            .columns
+
            .iter()
+
            .filter_map(|c| {
+
                if !c.skip && c.displayed(render.area.width as usize) {
+
                    Some(c.width)
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect();
+

+
        if has_items {
+
            let [table_area, scroller_area] = Layout::horizontal([
+
                Constraint::Min(1),
+
                if show_scrollbar {
+
                    Constraint::Length(1)
+
                } else {
+
                    Constraint::Length(0)
+
                },
+
            ])
+
            .areas(render.area);
+

+
            let rows = props
+
                .items
+
                .iter()
+
                .map(|item| {
+
                    let mut cells = vec![];
+
                    let mut it = props.columns.iter();
+

+
                    for cell in item.to_row() {
+
                        if let Some(col) = it.next() {
+
                            if !col.skip && col.displayed(render.area.width as usize) {
+
                                cells.push(cell.clone())
+
                            }
+
                        } else {
+
                            continue;
+
                        }
+
                    }
+

+
                    Row::new(cells)
+
                })
+
                .collect::<Vec<_>>();
+

+
            let table = ratatui::widgets::Table::default()
+
                .rows(rows)
+
                .widths(widths)
+
                .column_spacing(1)
+
                .highlight_style(style::highlight(render.focus));
+

+
            let table = if !render.focus && props.dim {
+
                table.dim()
+
            } else {
+
                table
+
            };
+

+
            frame.render_stateful_widget(table, table_area, &mut self.state.0);
+

+
            let scroller = Scrollbar::default()
+
                .begin_symbol(None)
+
                .track_symbol(None)
+
                .end_symbol(None)
+
                .thumb_symbol("┃")
+
                .style(if render.focus {
+
                    Style::default()
+
                } else {
+
                    Style::default().dim()
+
                });
+
            let mut scroller_state = ScrollbarState::default()
+
                .content_length(props.items.len().saturating_sub(self.height.into()))
+
                .position(self.state.0.offset());
+
            frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
+
        } else {
+
            let center = layout::centered_rect(render.area, 50, 10);
+
            let hint = Text::from(span::default("Nothing to show"))
+
                .centered()
+
                .light_magenta()
+
                .dim();
+

+
            frame.render_widget(hint, center);
+
        }
+

+
        self.height = render.area.height;
+
    }
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        let selected = self.state.0.selected().unwrap_or_default();
+

+
        Some(ViewState::Table {
+
            selected,
+
            scroll: utils::scroll::percent_absolute(
+
                selected.saturating_sub(self.height.into()),
+
                self.state.1,
+
                self.height.into(),
+
            ),
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct TreeProps<R, Id>
+
where
+
    R: ToTree<Id> + Clone,
+
    Id: ToString,
+
{
+
    /// Root items.
+
    pub items: Vec<R>,
+
    /// Optional path to selected item, e.g. ["1.0", "1.0.1", "1.0.2"]. If not `None`,
+
    /// it will override the internal tree state.
+
    pub selected: Option<Vec<Id>>,
+
    /// If this widget should render its scrollbar. Default: `true`.
+
    pub show_scrollbar: bool,
+
    /// Optional identifier set of opened items. If not `None`,
+
    /// it will override the internal tree state.
+
    pub opened: Option<HashSet<Vec<Id>>>,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    pub dim: bool,
+
}
+

+
impl<R, Id> Default for TreeProps<R, Id>
+
where
+
    R: ToTree<Id> + Clone,
+
    Id: ToString,
+
{
+
    fn default() -> Self {
+
        Self {
+
            items: vec![],
+
            selected: None,
+
            show_scrollbar: true,
+
            opened: None,
+
            dim: false,
+
        }
+
    }
+
}
+

+
impl<R, Id> TreeProps<R, Id>
+
where
+
    R: ToTree<Id> + Clone,
+
    Id: ToString + Clone,
+
{
+
    pub fn items(mut self, items: Vec<R>) -> Self {
+
        self.items = items;
+
        self
+
    }
+

+
    pub fn selected(mut self, selected: Option<&[Id]>) -> Self {
+
        self.selected = selected.map(|s| s.to_vec());
+
        self
+
    }
+

+
    pub fn opened(mut self, opened: Option<HashSet<Vec<Id>>>) -> Self {
+
        self.opened = opened;
+
        self
+
    }
+

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

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

+
/// A `Tree` is an expandable, collapsable and scrollable tree widget, that takes
+
/// a list of root items which implement `ToTree`. It can be updated with a selection
+
/// and a set of opened items.
+
pub struct Tree<S, M, R, Id>
+
where
+
    R: ToTree<Id>,
+
    Id: ToString + Clone,
+
{
+
    /// Internal selection and offset state
+
    state: TreeState<Id>,
+
    /// Phantom
+
    phantom: PhantomData<(S, M, R, Id)>,
+
}
+

+
impl<S, M, R, Id> Default for Tree<S, M, R, Id>
+
where
+
    R: ToTree<Id>,
+
    Id: ToString + Clone + Default,
+
{
+
    fn default() -> Self {
+
        Self {
+
            state: TreeState::default(),
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M, R, Id> View for Tree<S, M, R, Id>
+
where
+
    R: ToTree<Id> + Clone + 'static,
+
    Id: ToString + Clone + Default + Eq + PartialEq + Hash + 'static,
+
{
+
    type State = S;
+
    type Message = M;
+

+
    fn reset(&mut self) {
+
        self.state = TreeState::default();
+
    }
+

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

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

+
        if let Some(opened) = &props.opened {
+
            if opened != self.state.opened() {
+
                self.state.close_all();
+
                for path in opened {
+
                    self.state.open(path.to_vec());
+
                }
+
            }
+
        }
+
    }
+

+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        match key {
+
            Key::Up | Key::Char('k') => {
+
                self.state.key_up();
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.state.key_down();
+
            }
+
            Key::Left | Key::Char('h')
+
                if !self.state.selected().is_empty() && !self.state.opened().is_empty() =>
+
            {
+
                self.state.key_left();
+
            }
+
            Key::Right | Key::Char('l') => {
+
                self.state.key_right();
+
            }
+
            _ => {}
+
        }
+

+
        None
+
    }
+

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

+
        let mut items = vec![];
+
        for item in &props.items {
+
            items.extend(item.rows());
+
        }
+

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

+
        let tree = if props.show_scrollbar {
+
            tui_tree_widget::Tree::new(&items)
+
                .expect("all item identifiers are unique")
+
                .block(
+
                    Block::default()
+
                        .borders(Borders::RIGHT)
+
                        .border_set(border::Set {
+
                            vertical_right: " ",
+
                            ..Default::default()
+
                        })
+
                        .border_style(if render.focus {
+
                            Style::default()
+
                        } else {
+
                            Style::default().dim()
+
                        }),
+
                )
+
                .experimental_scrollbar(Some(
+
                    Scrollbar::new(ScrollbarOrientation::VerticalRight)
+
                        .begin_symbol(None)
+
                        .track_symbol(None)
+
                        .end_symbol(None)
+
                        .thumb_symbol("┃"),
+
                ))
+
                .highlight_style(style::highlight(render.focus))
+
                .style(tree_style)
+
        } else {
+
            tui_tree_widget::Tree::new(&items)
+
                .expect("all item identifiers are unique")
+
                .style(tree_style)
+
                .highlight_style(style::highlight(render.focus))
+
        };
+

+
        frame.render_stateful_widget(tree, render.area, &mut self.state);
+
    }
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        Some(ViewState::Tree(
+
            self.state
+
                .selected()
+
                .to_vec()
+
                .iter()
+
                .map(|s| s.to_string())
+
                .collect(),
+
        ))
+
    }
+
}
added src/ui/rm/widget/utils.rs
@@ -0,0 +1,29 @@
+
pub mod scroll {
+
    pub fn percent_seen(selected: usize, len: usize, page_size: usize) -> usize {
+
        let step = selected;
+
        let page_size = page_size as f64;
+
        let len = len as f64;
+

+
        let lines = page_size + step.saturating_sub(page_size as usize) as f64;
+
        let progress = (lines / len * 100.0).ceil();
+

+
        if progress > 97.0 {
+
            map_range((0.0, progress), (0.0, 100.0), progress) as usize
+
        } else {
+
            progress as usize
+
        }
+
    }
+

+
    pub fn percent_absolute(offset: usize, len: usize, height: usize) -> usize {
+
        let y = offset as f64;
+
        let h = height as f64;
+
        let t = len.saturating_sub(1) as f64;
+
        let v = y / (t - h) * 100_f64;
+

+
        (v as usize).clamp(0, 100)
+
    }
+

+
    fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
+
        to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
+
    }
+
}
added src/ui/rm/widget/window.rs
@@ -0,0 +1,309 @@
+
use std::hash::Hash;
+
use std::{collections::HashMap, marker::PhantomData};
+

+
use ratatui::Frame;
+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::Text;
+
use ratatui::widgets::Row;
+

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

+
use super::{RenderProps, View, ViewProps, Widget};
+

+
#[derive(Clone)]
+
pub struct WindowProps<Id> {
+
    current_page: Option<Id>,
+
}
+

+
impl<Id> WindowProps<Id> {
+
    pub fn current_page(mut self, page: Id) -> Self {
+
        self.current_page = Some(page);
+
        self
+
    }
+
}
+

+
impl<Id> Default for WindowProps<Id> {
+
    fn default() -> Self {
+
        Self { current_page: None }
+
    }
+
}
+

+
pub struct Window<S, M, Id> {
+
    /// All pages known
+
    pages: HashMap<Id, Widget<S, M>>,
+
}
+

+
impl<S, M, Id> Default for Window<S, M, Id> {
+
    fn default() -> Self {
+
        Self {
+
            pages: HashMap::new(),
+
        }
+
    }
+
}
+

+
impl<S, M, Id> Window<S, M, Id>
+
where
+
    Id: Clone + Hash + Eq + PartialEq,
+
{
+
    pub fn page(mut self, id: Id, page: Widget<S, M>) -> Self {
+
        self.pages.insert(id, page);
+
        self
+
    }
+
}
+

+
impl<'a, S, M, Id> View for Window<S, M, Id>
+
where
+
    'a: 'static,
+
    S: 'static,
+
    M: 'static,
+
    Id: Clone + Hash + Eq + PartialEq + 'static,
+
{
+
    type Message = M;
+
    type State = S;
+

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

+
        let page = props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get_mut(id));
+

+
        if let Some(page) = page {
+
            page.handle_event(key);
+
        }
+

+
        None
+
    }
+

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

+
        let page = props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get_mut(id));
+

+
        if let Some(page) = page {
+
            page.update(state);
+
        }
+
    }
+

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

+
        let area = frame.size();
+

+
        let page = props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get_mut(id));
+

+
        if let Some(page) = page {
+
            page.render(RenderProps::from(area).focus(true), frame);
+
        }
+
    }
+
}
+

+
#[derive(Clone, Default)]
+
pub struct PageProps {
+
    /// If this view's should handle keys
+
    pub handle_keys: bool,
+
}
+

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

+
pub struct Page<S, M> {
+
    /// Content widget
+
    content: Option<Widget<S, M>>,
+
    /// Shortcut widget
+
    shortcuts: Option<Widget<S, M>>,
+
}
+

+
impl<S, M> Default for Page<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            content: None,
+
            shortcuts: None,
+
        }
+
    }
+
}
+

+
impl<S, M> Page<S, M> {
+
    pub fn content(mut self, content: Widget<S, M>) -> Self {
+
        self.content = Some(content);
+
        self
+
    }
+

+
    pub fn shortcuts(mut self, shortcuts: Widget<S, M>) -> Self {
+
        self.shortcuts = Some(shortcuts);
+
        self
+
    }
+
}
+

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

+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        if let Some(content) = self.content.as_mut() {
+
            content.handle_event(key);
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        if let Some(content) = self.content.as_mut() {
+
            content.update(state);
+
        }
+
        if let Some(shortcuts) = self.shortcuts.as_mut() {
+
            shortcuts.update(state);
+
        }
+
    }
+

+
    fn render(&mut self, _props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let [content_area, shortcuts_area] =
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(render.area);
+

+
        if let Some(content) = self.content.as_mut() {
+
            content.render(
+
                RenderProps::from(content_area)
+
                    .layout(Layout::horizontal([Constraint::Min(1)]))
+
                    .focus(true),
+
                frame,
+
            );
+
        }
+

+
        if let Some(shortcuts) = self.shortcuts.as_mut() {
+
            shortcuts.render(RenderProps::from(shortcuts_area), frame);
+
        }
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct ShortcutsProps {
+
    pub shortcuts: Vec<(String, String)>,
+
    pub divider: char,
+
    pub shortcuts_keys_style: Style,
+
    pub shortcuts_action_style: Style,
+
}
+

+
impl ShortcutsProps {
+
    pub fn divider(mut self, divider: char) -> Self {
+
        self.divider = divider;
+
        self
+
    }
+

+
    pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
+
        self.shortcuts.clear();
+
        for (short, long) in shortcuts {
+
            self.shortcuts.push((short.to_string(), long.to_string()));
+
        }
+
        self
+
    }
+

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

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

+
impl Default for ShortcutsProps {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            shortcuts: vec![],
+
            divider: '∙',
+
            shortcuts_keys_style: theme.shortcuts_keys_style,
+
            shortcuts_action_style: theme.shortcuts_action_style,
+
        }
+
    }
+
}
+

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

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

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

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        use ratatui::widgets::Table;
+

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

+
        let mut shortcuts = props.shortcuts.iter().peekable();
+
        let mut row = vec![];
+

+
        while let Some(shortcut) = shortcuts.next() {
+
            let short = Text::from(shortcut.0.clone()).style(props.shortcuts_keys_style);
+
            let long = Text::from(shortcut.1.clone()).style(props.shortcuts_action_style);
+
            let spacer = Text::from(String::new());
+
            let divider = Text::from(format!(" {} ", props.divider)).style(style::gray().dim());
+

+
            row.push((shortcut.0.chars().count(), short));
+
            row.push((1, spacer));
+
            row.push((shortcut.1.chars().count(), long));
+

+
            if shortcuts.peek().is_some() {
+
                row.push((3, divider));
+
            }
+
        }
+

+
        let row_copy = row.clone();
+
        let row: Vec<Text<'_>> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(_, text)| text.clone())
+
            .collect();
+
        let widths: Vec<Constraint> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(width, _)| Constraint::Length(*width as u16))
+
            .collect();
+

+
        let table = Table::new([Row::new(row)], widths).column_spacing(0);
+
        frame.render_widget(table, render.area);
+
    }
+
}
modified src/ui/theme.rs
@@ -9,6 +9,7 @@ pub struct Theme {
    pub textview_style: Style,
    pub textview_scroll_style: Style,
    pub textview_focus_scroll_style: Style,
+
    pub bar_on_black_style: Style,
    pub dim_no_focus: bool,
}

@@ -28,6 +29,7 @@ impl Theme {
            textview_style: style::reset(),
            textview_scroll_style: style::cyan().dim(),
            textview_focus_scroll_style: style::cyan(),
+
            bar_on_black_style: Style::default().on_gray(),
            dim_no_focus: false,
        }
    }
@@ -37,10 +39,11 @@ impl Theme {
            border_style: Style::default().fg(Color::Indexed(240)),
            focus_border_style: Style::default().fg(Color::Indexed(246)),
            shortcuts_keys_style: style::yellow().dim(),
-
            shortcuts_action_style: style::gray(),
+
            shortcuts_action_style: style::gray().dim(),
            textview_style: style::reset(),
            textview_scroll_style: style::cyan().dim(),
            textview_focus_scroll_style: style::cyan(),
+
            bar_on_black_style: Style::default().on_black(),
            dim_no_focus: false,
        }
    }
deleted src/ui/widget.rs
@@ -1,358 +0,0 @@
-
pub mod container;
-
pub mod input;
-
pub mod list;
-
pub mod utils;
-
pub mod window;
-

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

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

-
use termion::event::Key;
-

-
use ratatui::prelude::*;
-

-
use self::{
-
    container::SectionGroupState,
-
    input::{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>;
-

-
/// `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
-
/// know the concrete type of a `View`s properties.
-
/// Hence, view properties are stored inside a `Box<dyn Any>` and downcasted to the concrete
-
/// type when needed.
-
pub struct ViewProps {
-
    inner: Box<dyn Any>,
-
}
-

-
impl ViewProps {
-
    pub fn inner<T>(self) -> Option<T>
-
    where
-
        T: Default + Clone + 'static,
-
    {
-
        self.inner.downcast::<T>().ok().map(|inner| *inner)
-
    }
-

-
    pub fn inner_ref<T>(&self) -> Option<&T>
-
    where
-
        T: Default + Clone + 'static,
-
    {
-
        self.inner.downcast_ref::<T>()
-
    }
-
}
-

-
impl From<Box<dyn Any>> for ViewProps {
-
    fn from(props: Box<dyn Any>) -> Self {
-
        ViewProps { inner: props }
-
    }
-
}
-

-
impl From<&'static dyn Any> for ViewProps {
-
    fn from(inner: &'static dyn Any) -> Self {
-
        Self {
-
            inner: Box::new(inner),
-
        }
-
    }
-
}
-

-
/// A `ViewState` is the representation of a `View`s internal state. e.g. current
-
/// table selection or contents of a text field.
-
#[derive(Debug)]
-
pub enum ViewState {
-
    USize(usize),
-
    String(String),
-
    Table { selected: usize, scroll: usize },
-
    Tree(Vec<String>),
-
    TextView(TextViewState),
-
    TextArea(TextAreaState),
-
    SectionGroup(SectionGroupState),
-
}
-

-
impl ViewState {
-
    pub fn unwrap_usize(&self) -> Option<usize> {
-
        match self {
-
            ViewState::USize(value) => Some(*value),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_string(&self) -> Option<String> {
-
        match self {
-
            ViewState::String(value) => Some(value.clone()),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_table(&self) -> Option<(usize, usize)> {
-
        match self {
-
            ViewState::Table { selected, scroll } => Some((*selected, *scroll)),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_textview(&self) -> Option<TextViewState> {
-
        match self {
-
            ViewState::TextView(state) => Some(state.clone()),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_textarea(&self) -> Option<TextAreaState> {
-
        match self {
-
            ViewState::TextArea(state) => Some(state.clone()),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_section_group(&self) -> Option<SectionGroupState> {
-
        match self {
-
            ViewState::SectionGroup(state) => Some(state.clone()),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_tree(&self) -> Option<Vec<String>> {
-
        match self {
-
            ViewState::Tree(value) => Some(value.clone().to_vec()),
-
            _ => None,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Default)]
-
pub enum PredefinedLayout {
-
    #[default]
-
    None,
-
    Expandable3 {
-
        left_only: bool,
-
    },
-
}
-

-
impl PredefinedLayout {
-
    pub fn split(&self, area: Rect) -> Rc<[Rect]> {
-
        match self {
-
            Self::Expandable3 { left_only } => {
-
                if *left_only {
-
                    [area].into()
-
                } else if area.width <= 140 {
-
                    let [left, right] = Layout::horizontal([
-
                        Constraint::Percentage(50),
-
                        Constraint::Percentage(50),
-
                    ])
-
                    .areas(area);
-
                    let [right_top, right_bottom] =
-
                        Layout::vertical([Constraint::Percentage(65), Constraint::Percentage(35)])
-
                            .areas(right);
-

-
                    [left, right_top, right_bottom].into()
-
                } else {
-
                    Layout::horizontal([
-
                        Constraint::Percentage(33),
-
                        Constraint::Percentage(33),
-
                        Constraint::Percentage(33),
-
                    ])
-
                    .split(area)
-
                }
-
            }
-
            _ => Layout::default().split(area),
-
        }
-
    }
-
}
-

-
/// General properties that specify how a `View` is rendered.
-
#[derive(Clone, Default)]
-
pub struct RenderProps {
-
    /// Area of the render props.
-
    pub area: Rect,
-
    /// Layout to be rendered in.
-
    pub layout: Layout,
-
    /// Focus of the render props.
-
    pub focus: bool,
-
}
-

-
impl RenderProps {
-
    /// Sets the area to render in.
-
    pub fn area(mut self, area: Rect) -> Self {
-
        self.area = area;
-
        self
-
    }
-

-
    /// Sets the focus of these render props.
-
    pub fn focus(mut self, focus: bool) -> Self {
-
        self.focus = focus;
-
        self
-
    }
-

-
    /// Sets the layout of these render props.
-
    pub fn layout(mut self, layout: Layout) -> Self {
-
        self.layout = layout;
-
        self
-
    }
-
}
-

-
impl From<Rect> for RenderProps {
-
    fn from(area: Rect) -> Self {
-
        Self {
-
            area,
-
            layout: Layout::default(),
-
            focus: false,
-
        }
-
    }
-
}
-

-
/// Main trait defining a `View` behaviour, which needs be implemented in order to
-
/// build a custom widget. A `View` operates on an application state and can emit
-
/// application messages. It's usually is accompanied by a definition of view-specific
-
/// properties, which are being built from the application state by the framework.
-
pub trait View {
-
    type State;
-
    type Message;
-

-
    /// Should return the internal state.
-
    fn view_state(&self) -> Option<ViewState> {
-
        None
-
    }
-

-
    /// Should reset the internal state and call `reset` on all children.
-
    fn reset(&mut self) {}
-

-
    /// Should handle key events and call `handle_event` on all children.
-
    fn handle_event(&mut self, _props: Option<&ViewProps>, _key: Key) -> Option<Self::Message> {
-
        None
-
    }
-

-
    /// Should update the internal props of this and all children.
-
    fn update(&mut self, _props: Option<&ViewProps>, _state: &Self::State) {}
-

-
    /// Should render the view using the given `RenderProps`.
-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame);
-
}
-

-
/// A `View` needs to wrapped into a `Widget` before being able to use with the
-
/// framework. A `Widget` enhances a `View` with event and update callbacks and takes
-
/// care of calling them before / after calling into the `View`.
-
pub struct Widget<S, M> {
-
    view: BoxedView<S, M>,
-
    props: Option<ViewProps>,
-
    sender: UnboundedSender<M>,
-
    on_update: Option<UpdateCallback<S>>,
-
    on_event: Option<EventCallback<M>>,
-
    on_render: Option<RenderCallback<M>>,
-
}
-

-
impl<S: 'static, M: 'static> Widget<S, M> {
-
    pub fn new<V>(view: V, sender: UnboundedSender<M>) -> Self
-
    where
-
        Self: Sized,
-
        V: View<State = S, Message = M> + 'static,
-
    {
-
        Self {
-
            view: Box::new(view),
-
            props: None,
-
            sender: sender.clone(),
-
            on_update: None,
-
            on_event: None,
-
            on_render: None,
-
        }
-
    }
-

-
    /// Calls `reset` on the wrapped view.
-
    pub fn reset(&mut self) {
-
        self.view.reset()
-
    }
-

-
    /// Calls `handle_event` on the wrapped view as well as the `on_event` callback.
-
    /// Sends any message returned by either the view or the callback.
-
    pub fn handle_event(&mut self, key: Key) {
-
        if let Some(message) = self.view.handle_event(self.props.as_ref(), key) {
-
            let _ = self.sender.send(message);
-
        }
-

-
        if let Some(on_event) = self.on_event {
-
            if let Some(message) =
-
                (on_event)(key, self.view.view_state().as_ref(), self.props.as_ref())
-
            {
-
                let _ = self.sender.send(message);
-
            }
-
        }
-
    }
-

-
    /// 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`.
-
    ///
-
    /// If `on_update` is set, implementations of this function should call it to
-
    /// construct and update the internal props. If it is not set, app widgets can construct
-
    /// props directly via their state converters, whereas library widgets can just fallback
-
    /// to their current props.
-
    pub fn update(&mut self, state: &S) {
-
        self.props = self.on_update.map(|on_update| (on_update)(state));
-
        self.view.update(self.props.as_ref(), state);
-
    }
-

-
    /// Renders the wrapped view.
-
    pub fn render(&mut self, render: RenderProps, frame: &mut Frame) {
-
        self.view.render(self.props.as_ref(), render.clone(), frame);
-

-
        if let Some(on_render) = self.on_render {
-
            (on_render)(self.props.as_ref(), &render)
-
                .and_then(|message| self.sender.send(message).ok());
-
        }
-
    }
-

-
    /// 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_update(mut self, callback: UpdateCallback<S>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        self.on_update = Some(callback);
-
        self
-
    }
-

-
    /// Sets the optional update handler.
-
    pub fn on_render(mut self, callback: RenderCallback<M>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        self.on_render = Some(callback);
-
        self
-
    }
-
}
-

-
/// 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>
-
    where
-
        Self: Sized + 'static;
-
}
-

-
impl<T, S, M> ToWidget<S, M> for T
-
where
-
    T: View<State = S, Message = M>,
-
    S: 'static,
-
    M: 'static,
-
{
-
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
-
    where
-
        Self: Sized + 'static,
-
    {
-
        Widget::new(self, tx)
-
    }
-
}
deleted src/ui/widget/container.rs
@@ -1,846 +0,0 @@
-
use std::fmt::Debug;
-
use std::marker::PhantomData;
-

-
use termion::event::Key;
-

-
use ratatui::prelude::*;
-
use ratatui::widgets::{Block, BorderType, Borders, Row};
-

-
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
-
use crate::ui::theme::{style, Theme};
-
use crate::ui::{RENDER_WIDTH_LARGE, RENDER_WIDTH_MEDIUM, RENDER_WIDTH_SMALL};
-

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

-
#[derive(Clone, Debug, Default)]
-
pub struct ColumnView {
-
    small: bool,
-
    medium: bool,
-
    large: bool,
-
}
-

-
impl ColumnView {
-
    pub fn all() -> Self {
-
        Self {
-
            small: true,
-
            medium: true,
-
            large: true,
-
        }
-
    }
-

-
    pub fn small(mut self) -> Self {
-
        self.small = true;
-
        self
-
    }
-

-
    pub fn medium(mut self) -> Self {
-
        self.medium = true;
-
        self
-
    }
-

-
    pub fn large(mut self) -> Self {
-
        self.large = true;
-
        self
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct Column<'a> {
-
    pub text: Text<'a>,
-
    pub width: Constraint,
-
    pub skip: bool,
-
    pub view: ColumnView,
-
}
-

-
impl<'a> Column<'a> {
-
    pub fn new(text: impl Into<Text<'a>>, width: Constraint) -> Self {
-
        Self {
-
            text: text.into(),
-
            width,
-
            skip: false,
-
            view: ColumnView::all(),
-
        }
-
    }
-

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

-
    pub fn hide_small(mut self) -> Self {
-
        self.view = ColumnView::default().medium().large();
-
        self
-
    }
-

-
    pub fn hide_medium(mut self) -> Self {
-
        self.view = ColumnView::default().large();
-
        self
-
    }
-

-
    pub fn displayed(&self, area_width: usize) -> bool {
-
        if area_width < RENDER_WIDTH_SMALL {
-
            self.view.small
-
        } else if area_width < RENDER_WIDTH_MEDIUM {
-
            self.view.medium
-
        } else if area_width < RENDER_WIDTH_LARGE {
-
            self.view.large
-
        } else {
-
            true
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct HeaderProps<'a> {
-
    pub columns: Vec<Column<'a>>,
-
    pub cutoff: usize,
-
    pub cutoff_after: usize,
-
    pub border_style: Style,
-
    pub focus_border_style: Style,
-
}
-

-
impl<'a> HeaderProps<'a> {
-
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
-
        self.columns = columns;
-
        self
-
    }
-

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

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

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

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

-
        Self {
-
            columns: vec![],
-
            cutoff: usize::MAX,
-
            cutoff_after: usize::MAX,
-
            border_style: theme.border_style,
-
            focus_border_style: theme.focus_border_style,
-
        }
-
    }
-
}
-

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

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

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

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

-
        let width = render.area.width.saturating_sub(2);
-

-
        let widths: Vec<Constraint> = props
-
            .columns
-
            .iter()
-
            .filter_map(|c| {
-
                if !c.skip && c.displayed(width as usize) {
-
                    Some(c.width)
-
                } else {
-
                    None
-
                }
-
            })
-
            .collect();
-

-
        let cells = props
-
            .columns
-
            .iter()
-
            .filter_map(|column| {
-
                if !column.skip && column.displayed(width as usize) {
-
                    Some(column.text.clone())
-
                } else {
-
                    None
-
                }
-
            })
-
            .collect::<Vec<_>>();
-

-
        let border_style = if render.focus {
-
            props.focus_border_style
-
        } else {
-
            props.border_style
-
        };
-

-
        // Render header
-
        let block = HeaderBlock::default()
-
            .borders(Borders::ALL)
-
            .border_style(border_style)
-
            .border_type(BorderType::Rounded);
-

-
        let header_layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![Constraint::Min(1)])
-
            .vertical_margin(1)
-
            .horizontal_margin(1)
-
            .split(render.area);
-

-
        let header = Row::new(cells).style(style::reset().bold());
-
        let header = ratatui::widgets::Table::default()
-
            .column_spacing(1)
-
            .header(header)
-
            .widths(widths.clone());
-

-
        frame.render_widget(block, render.area);
-
        frame.render_widget(header, header_layout[0]);
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct FooterProps<'a> {
-
    pub columns: Vec<Column<'a>>,
-
    pub cutoff: usize,
-
    pub cutoff_after: usize,
-
    pub border_style: Style,
-
    pub focus_border_style: Style,
-
}
-

-
impl<'a> FooterProps<'a> {
-
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
-
        self.columns = columns;
-
        self
-
    }
-

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

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

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

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

-
        Self {
-
            columns: vec![],
-
            cutoff: usize::MAX,
-
            cutoff_after: usize::MAX,
-
            border_style: theme.border_style,
-
            focus_border_style: theme.focus_border_style,
-
        }
-
    }
-
}
-

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

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

-
impl<'a, S, M> Footer<S, M> {
-
    fn render_cell(
-
        &self,
-
        frame: &mut ratatui::Frame,
-
        border_style: Style,
-
        render: RenderProps,
-
        block_type: FooterBlockType,
-
        text: impl Into<Text<'a>>,
-
    ) {
-
        let footer_layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![Constraint::Min(1)])
-
            .vertical_margin(1)
-
            .horizontal_margin(1)
-
            .split(render.area);
-

-
        let footer_block = FooterBlock::default()
-
            .border_style(border_style)
-
            .block_type(block_type);
-
        frame.render_widget(footer_block, render.area);
-
        frame.render_widget(text.into(), footer_layout[0]);
-
    }
-
}
-

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

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

-
        let border_style = if render.focus {
-
            props.focus_border_style
-
        } else {
-
            props.border_style
-
        };
-

-
        let widths = props
-
            .columns
-
            .iter()
-
            .map(|c| match c.width {
-
                Constraint::Min(min) => Constraint::Length(min.saturating_add(3)),
-
                _ => c.width,
-
            })
-
            .collect::<Vec<_>>();
-

-
        let layout = Layout::horizontal(widths).split(render.area);
-
        let cells = props
-
            .columns
-
            .iter()
-
            .map(|c| c.text.clone())
-
            .zip(layout.iter())
-
            .collect::<Vec<_>>();
-

-
        let last = cells.len().saturating_sub(1);
-
        let len = cells.len();
-

-
        for (i, (cell, area)) in cells.into_iter().enumerate() {
-
            let block_type = match i {
-
                0 if len == 1 => FooterBlockType::Single,
-
                0 => FooterBlockType::Begin,
-
                _ if i == last => FooterBlockType::End,
-
                _ => FooterBlockType::Repeat,
-
            };
-
            self.render_cell(
-
                frame,
-
                border_style,
-
                render.clone().area(*area),
-
                block_type,
-
                cell.clone(),
-
            );
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
-
pub struct ContainerProps {
-
    hide_footer: bool,
-
    border_style: Style,
-
    focus_border_style: Style,
-
}
-

-
impl Default for ContainerProps {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            hide_footer: false,
-
            border_style: theme.border_style,
-
            focus_border_style: theme.focus_border_style,
-
        }
-
    }
-
}
-

-
impl ContainerProps {
-
    pub fn hide_footer(mut self, hide: bool) -> Self {
-
        self.hide_footer = hide;
-
        self
-
    }
-

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

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

-
pub struct Container<S, M> {
-
    /// Container header
-
    header: Option<Widget<S, M>>,
-
    /// Content widget
-
    content: Option<Widget<S, M>>,
-
    /// Container footer
-
    footer: Option<Widget<S, M>>,
-
}
-

-
impl<S, M> Default for Container<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            header: None,
-
            content: None,
-
            footer: None,
-
        }
-
    }
-
}
-

-
impl<S, M> Container<S, M> {
-
    pub fn header(mut self, header: Widget<S, M>) -> Self {
-
        self.header = Some(header);
-
        self
-
    }
-

-
    pub fn content(mut self, content: Widget<S, M>) -> Self {
-
        self.content = Some(content);
-
        self
-
    }
-

-
    pub fn footer(mut self, footer: Widget<S, M>) -> Self {
-
        self.footer = Some(footer);
-
        self
-
    }
-
}
-

-
impl<S, M> View for Container<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> {
-
        if let Some(content) = &mut self.content {
-
            content.handle_event(key);
-
        }
-

-
        None
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        if let Some(header) = &mut self.header {
-
            header.update(state);
-
        }
-

-
        if let Some(content) = &mut self.content {
-
            content.update(state);
-
        }
-

-
        if let Some(footer) = &mut self.footer {
-
            footer.update(state);
-
        }
-
    }
-

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

-
        let border_style = if render.focus {
-
            props.focus_border_style
-
        } else {
-
            props.border_style
-
        };
-

-
        let header_h = if self.header.is_some() { 3 } else { 0 };
-
        let footer_h = if self.footer.is_some() && !props.hide_footer {
-
            3
-
        } else {
-
            0
-
        };
-

-
        let [header_area, content_area, footer_area] = Layout::vertical([
-
            Constraint::Length(header_h),
-
            Constraint::Min(1),
-
            Constraint::Length(footer_h),
-
        ])
-
        .areas(render.area);
-

-
        let borders = match (
-
            self.header.is_some(),
-
            (self.footer.is_some() && !props.hide_footer),
-
        ) {
-
            (false, false) => Borders::ALL,
-
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
-
            (false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
-
            (true, true) => Borders::LEFT | Borders::RIGHT,
-
        };
-

-
        let block = Block::default()
-
            .border_style(border_style)
-
            .border_type(BorderType::Rounded)
-
            .borders(borders);
-
        frame.render_widget(block.clone(), content_area);
-

-
        if let Some(header) = self.header.as_mut() {
-
            header.render(RenderProps::from(header_area).focus(render.focus), frame);
-
        }
-

-
        if let Some(content) = self.content.as_mut() {
-
            content.render(
-
                RenderProps::from(block.inner(content_area)).focus(render.focus),
-
                frame,
-
            );
-
        }
-

-
        if let Some(footer) = self.footer.as_mut() {
-
            footer.render(RenderProps::from(footer_area).focus(render.focus), frame);
-
        }
-
    }
-
}
-

-
#[derive(Clone, Default)]
-
pub enum SplitContainerFocus {
-
    #[default]
-
    Top,
-
    Bottom,
-
}
-

-
#[derive(Clone)]
-
pub struct SplitContainerProps {
-
    split_focus: SplitContainerFocus,
-
    heights: [Constraint; 2],
-
    border_style: Style,
-
    focus_border_style: Style,
-
}
-

-
impl Default for SplitContainerProps {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            split_focus: SplitContainerFocus::default(),
-
            heights: [Constraint::Percentage(50), Constraint::Percentage(50)],
-
            border_style: theme.border_style,
-
            focus_border_style: theme.focus_border_style,
-
        }
-
    }
-
}
-

-
impl SplitContainerProps {
-
    pub fn split_focus(mut self, split_focus: SplitContainerFocus) -> Self {
-
        self.split_focus = split_focus;
-
        self
-
    }
-

-
    pub fn heights(mut self, heights: [Constraint; 2]) -> Self {
-
        self.heights = heights;
-
        self
-
    }
-

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

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

-
pub struct SplitContainer<S, M> {
-
    /// Container top
-
    top: Option<Widget<S, M>>,
-
    /// Content bottom
-
    bottom: Option<Widget<S, M>>,
-
}
-

-
impl<S, M> Default for SplitContainer<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            top: None,
-
            bottom: None,
-
        }
-
    }
-
}
-

-
impl<S, M> SplitContainer<S, M> {
-
    pub fn top(mut self, top: Widget<S, M>) -> Self {
-
        self.top = Some(top);
-
        self
-
    }
-

-
    pub fn bottom(mut self, bottom: Widget<S, M>) -> Self {
-
        self.bottom = Some(bottom);
-
        self
-
    }
-
}
-

-
impl<S, M> View for SplitContainer<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 = SplitContainerProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<SplitContainerProps>())
-
            .unwrap_or(&default);
-

-
        match props.split_focus {
-
            SplitContainerFocus::Top => {
-
                if let Some(top) = self.top.as_mut() {
-
                    top.handle_event(key);
-
                }
-
            }
-
            SplitContainerFocus::Bottom => {
-
                if let Some(bottom) = self.bottom.as_mut() {
-
                    bottom.handle_event(key);
-
                }
-
            }
-
        }
-

-
        None
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        if let Some(top) = self.top.as_mut() {
-
            top.update(state);
-
        }
-

-
        if let Some(bottom) = self.bottom.as_mut() {
-
            bottom.update(state);
-
        }
-
    }
-

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

-
        let heights = props
-
            .heights
-
            .iter()
-
            .map(|c| {
-
                if let Constraint::Length(l) = c {
-
                    Constraint::Length(l + 2)
-
                } else {
-
                    *c
-
                }
-
            })
-
            .collect::<Vec<_>>();
-

-
        let border_style = if render.focus {
-
            props.focus_border_style
-
        } else {
-
            props.border_style
-
        };
-

-
        let [top_area, bottom_area] = Layout::vertical(heights).areas(render.area);
-

-
        if let Some(top) = self.top.as_mut() {
-
            let block = HeaderBlock::default()
-
                .borders(Borders::ALL)
-
                .border_style(border_style)
-
                .border_type(BorderType::Rounded);
-

-
            frame.render_widget(block, top_area);
-

-
            let [top_area] = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints(vec![Constraint::Min(1)])
-
                .vertical_margin(1)
-
                .horizontal_margin(1)
-
                .areas(top_area);
-
            top.render(RenderProps::from(top_area).focus(render.focus), frame)
-
        }
-

-
        if let Some(bottom) = self.bottom.as_mut() {
-
            let block = Block::default()
-
                .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
-
                .border_style(border_style)
-
                .border_type(BorderType::Rounded);
-

-
            frame.render_widget(block, bottom_area);
-

-
            let [bottom_area, _] = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints(vec![Constraint::Min(1), Constraint::Length(1)])
-
                .horizontal_margin(1)
-
                .areas(bottom_area);
-
            bottom.render(RenderProps::from(bottom_area).focus(render.focus), frame)
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct SectionGroupState {
-
    /// Index of currently focused section.
-
    pub focus: Option<usize>,
-
}
-

-
#[derive(Clone, Default)]
-
pub struct SectionGroupProps {
-
    /// Index of currently focused section. If set, it will override the widgets'
-
    /// internal state.
-
    focus: Option<usize>,
-
    /// If this pages' keys should be handled.
-
    handle_keys: bool,
-
    /// Section layout
-
    layout: PredefinedLayout,
-
}
-

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

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

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

-
pub struct SectionGroup<S, M> {
-
    /// All sections
-
    sections: Vec<Widget<S, M>>,
-
    /// Internal selection and offset state
-
    state: SectionGroupState,
-
}
-

-
impl<S, M> Default for SectionGroup<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            sections: vec![],
-
            state: SectionGroupState { focus: Some(0) },
-
        }
-
    }
-
}
-

-
impl<S, M> SectionGroup<S, M> {
-
    pub fn section(mut self, section: Widget<S, M>) -> Self {
-
        self.sections.push(section);
-
        self
-
    }
-

-
    fn prev(&mut self) -> Option<usize> {
-
        let focus = self.state.focus.map(|current| current.saturating_sub(1));
-
        self.state.focus = focus;
-
        focus
-
    }
-

-
    fn next(&mut self, len: usize) -> Option<usize> {
-
        let focus = self.state.focus.map(|current| {
-
            if current < len.saturating_sub(1) {
-
                current.saturating_add(1)
-
            } else {
-
                current
-
            }
-
        });
-
        self.state.focus = focus;
-
        focus
-
    }
-
}
-

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

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

-
        if let Some(section) = self
-
            .state
-
            .focus
-
            .and_then(|focus| self.sections.get_mut(focus))
-
        {
-
            section.handle_event(key);
-
        }
-

-
        if props.handle_keys {
-
            match key {
-
                Key::BackTab => {
-
                    self.prev();
-
                }
-
                Key::Char('\t') => {
-
                    self.next(self.sections.len());
-
                }
-
                _ => {}
-
            }
-
        }
-

-
        None
-
    }
-

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

-
        for section in &mut self.sections {
-
            section.update(state);
-
        }
-

-
        if props.focus.is_some() && props.focus != self.state.focus {
-
            self.state.focus = props.focus;
-
        }
-
    }
-

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

-
        let areas = props.layout.split(render.area);
-

-
        for (index, area) in areas.iter().enumerate() {
-
            if let Some(section) = self.sections.get_mut(index) {
-
                let focus = self
-
                    .state
-
                    .focus
-
                    .map(|focus_index| index == focus_index)
-
                    .unwrap_or_default();
-

-
                section.render(RenderProps::from(*area).focus(focus), frame);
-
            }
-
        }
-
    }
-

-
    fn view_state(&self) -> Option<super::ViewState> {
-
        Some(ViewState::SectionGroup(self.state.clone()))
-
    }
-
}
deleted src/ui/widget/input.rs
@@ -1,902 +0,0 @@
-
use std::marker::PhantomData;
-

-
use ratatui::widgets::Paragraph;
-
use ratatui::Frame;
-
use termion::event::Key;
-

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

-
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(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(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<'a> Default for TextAreaProps<'a> {
-
    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<'a, S, M> Default for TextArea<'a, S, M> {
-
    fn default() -> Self {
-
        Self {
-
            phantom: PhantomData,
-
            textarea: tui_textarea::TextArea::default(),
-
            area: (0, 0),
-
        }
-
    }
-
}
-

-
impl<'a, S, M> View for TextArea<'a, 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.widget(), 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<'a> Default for TextViewProps<'a> {
-
    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()))
-
    }
-
}
deleted src/ui/widget/list.rs
@@ -1,553 +0,0 @@
-
use std::collections::HashSet;
-
use std::hash::Hash;
-
use std::marker::PhantomData;
-
use std::{cmp, vec};
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::{Style, Stylize};
-
use ratatui::symbols::border;
-
use ratatui::text::Text;
-
use ratatui::widgets::TableState;
-
use ratatui::widgets::{
-
    Block, Borders, Cell, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
-
};
-
use ratatui::Frame;
-

-
use tui_tree_widget::{TreeItem, TreeState};
-

-
use crate::ui::theme::style;
-
use crate::ui::{layout, span};
-

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

-
/// Needs to be implemented for items that are supposed to be rendered in tables.
-
pub trait ToRow<const W: usize> {
-
    fn to_row(&self) -> [Cell; W];
-
}
-

-
/// Needs to be implemented for items that are supposed to be rendered in trees.
-
pub trait ToTree<Id>
-
where
-
    Id: ToString,
-
{
-
    fn rows(&self) -> Vec<TreeItem<'_, Id>>;
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct TableProps<'a, R, const W: usize>
-
where
-
    R: ToRow<W>,
-
{
-
    pub items: Vec<R>,
-
    pub selected: Option<usize>,
-
    pub columns: Vec<Column<'a>>,
-
    pub show_scrollbar: bool,
-
    pub dim: bool,
-
}
-

-
impl<'a, R, const W: usize> Default for TableProps<'a, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    fn default() -> Self {
-
        Self {
-
            items: vec![],
-
            columns: vec![],
-
            show_scrollbar: true,
-
            selected: Some(0),
-
            dim: false,
-
        }
-
    }
-
}
-

-
impl<'a, R, const W: usize> TableProps<'a, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    pub fn items(mut self, items: Vec<R>) -> Self {
-
        self.items = items;
-
        self
-
    }
-

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

-
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
-
        self.columns = columns;
-
        self
-
    }
-

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

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

-
pub struct Table<S, M, R, const W: usize>
-
where
-
    R: ToRow<W>,
-
{
-
    /// Internal selection and offset state
-
    state: (TableState, usize),
-
    /// Phantom
-
    phantom: PhantomData<(S, M, R)>,
-
    /// Current render height
-
    height: u16,
-
}
-

-
impl<S, M, R, const W: usize> Default for Table<S, M, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    fn default() -> Self {
-
        Self {
-
            state: (TableState::default().with_selected(Some(0)), 0),
-
            phantom: PhantomData,
-
            height: 1,
-
        }
-
    }
-
}
-

-
impl<S, M, R, const W: usize> Table<S, M, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    fn prev(&mut self) -> Option<usize> {
-
        let selected = self
-
            .state
-
            .0
-
            .selected()
-
            .map(|current| current.saturating_sub(1));
-
        self.state.0.select(selected);
-
        selected
-
    }
-

-
    fn next(&mut self, len: usize) -> Option<usize> {
-
        let selected = self.state.0.selected().map(|current| {
-
            if current < len.saturating_sub(1) {
-
                current.saturating_add(1)
-
            } else {
-
                current
-
            }
-
        });
-
        self.state.0.select(selected);
-
        selected
-
    }
-

-
    fn prev_page(&mut self, page_size: usize) -> Option<usize> {
-
        let selected = self
-
            .state
-
            .0
-
            .selected()
-
            .map(|current| current.saturating_sub(page_size));
-
        self.state.0.select(selected);
-
        selected
-
    }
-

-
    fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
-
        let selected = self.state.0.selected().map(|current| {
-
            if current < len.saturating_sub(1) {
-
                cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
-
            } else {
-
                current
-
            }
-
        });
-
        self.state.0.select(selected);
-
        selected
-
    }
-

-
    fn begin(&mut self) {
-
        self.state.0.select(Some(0));
-
    }
-

-
    fn end(&mut self, len: usize) {
-
        self.state.0.select(Some(len.saturating_sub(1)));
-
    }
-
}
-

-
impl<S, M, R, const W: usize> View for Table<S, M, R, W>
-
where
-
    S: 'static,
-
    M: 'static,
-
    R: ToRow<W> + Clone + 'static,
-
{
-
    type Message = M;
-
    type State = S;
-

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

-
        let page_size = self.height;
-

-
        match key {
-
            Key::Up | Key::Char('k') => {
-
                self.prev();
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.next(props.items.len());
-
            }
-
            Key::PageUp => {
-
                self.prev_page(page_size as usize);
-
            }
-
            Key::PageDown => {
-
                self.next_page(props.items.len(), page_size as usize);
-
            }
-
            Key::Home => {
-
                self.begin();
-
            }
-
            Key::End => {
-
                self.end(props.items.len());
-
            }
-
            _ => {}
-
        }
-

-
        None
-
    }
-

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

-
        if props.selected != self.state.0.selected() {
-
            self.state.0.select(props.selected);
-
        }
-
        self.state.1 = props.items.len();
-
    }
-

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

-
        let show_scrollbar = props.show_scrollbar && props.items.len() >= self.height.into();
-
        let has_items = !props.items.is_empty();
-

-
        let widths: Vec<Constraint> = props
-
            .columns
-
            .iter()
-
            .filter_map(|c| {
-
                if !c.skip && c.displayed(render.area.width as usize) {
-
                    Some(c.width)
-
                } else {
-
                    None
-
                }
-
            })
-
            .collect();
-

-
        if has_items {
-
            let [table_area, scroller_area] = Layout::horizontal([
-
                Constraint::Min(1),
-
                if show_scrollbar {
-
                    Constraint::Length(1)
-
                } else {
-
                    Constraint::Length(0)
-
                },
-
            ])
-
            .areas(render.area);
-

-
            let rows = props
-
                .items
-
                .iter()
-
                .map(|item| {
-
                    let mut cells = vec![];
-
                    let mut it = props.columns.iter();
-

-
                    for cell in item.to_row() {
-
                        if let Some(col) = it.next() {
-
                            if !col.skip && col.displayed(render.area.width as usize) {
-
                                cells.push(cell.clone())
-
                            }
-
                        } else {
-
                            continue;
-
                        }
-
                    }
-

-
                    Row::new(cells)
-
                })
-
                .collect::<Vec<_>>();
-

-
            let table = ratatui::widgets::Table::default()
-
                .rows(rows)
-
                .widths(widths)
-
                .column_spacing(1)
-
                .highlight_style(style::highlight(render.focus));
-

-
            let table = if !render.focus && props.dim {
-
                table.dim()
-
            } else {
-
                table
-
            };
-

-
            frame.render_stateful_widget(table, table_area, &mut self.state.0);
-

-
            let scroller = Scrollbar::default()
-
                .begin_symbol(None)
-
                .track_symbol(None)
-
                .end_symbol(None)
-
                .thumb_symbol("┃")
-
                .style(if render.focus {
-
                    Style::default()
-
                } else {
-
                    Style::default().dim()
-
                });
-
            let mut scroller_state = ScrollbarState::default()
-
                .content_length(props.items.len().saturating_sub(self.height.into()))
-
                .position(self.state.0.offset());
-
            frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
-
        } else {
-
            let center = layout::centered_rect(render.area, 50, 10);
-
            let hint = Text::from(span::default("Nothing to show"))
-
                .centered()
-
                .light_magenta()
-
                .dim();
-

-
            frame.render_widget(hint, center);
-
        }
-

-
        self.height = render.area.height;
-
    }
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        let selected = self.state.0.selected().unwrap_or_default();
-

-
        Some(ViewState::Table {
-
            selected,
-
            scroll: utils::scroll::percent_absolute(
-
                selected.saturating_sub(self.height.into()),
-
                self.state.1,
-
                self.height.into(),
-
            ),
-
        })
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct TreeProps<R, Id>
-
where
-
    R: ToTree<Id> + Clone,
-
    Id: ToString,
-
{
-
    /// Root items.
-
    pub items: Vec<R>,
-
    /// Optional path to selected item, e.g. ["1.0", "1.0.1", "1.0.2"]. If not `None`,
-
    /// it will override the internal tree state.
-
    pub selected: Option<Vec<Id>>,
-
    /// If this widget should render its scrollbar. Default: `true`.
-
    pub show_scrollbar: bool,
-
    /// Optional identifier set of opened items. If not `None`,
-
    /// it will override the internal tree state.
-
    pub opened: Option<HashSet<Vec<Id>>>,
-
    /// Set to `true` if the content style should be dimmed whenever the widget
-
    /// has no focus.
-
    pub dim: bool,
-
}
-

-
impl<R, Id> Default for TreeProps<R, Id>
-
where
-
    R: ToTree<Id> + Clone,
-
    Id: ToString,
-
{
-
    fn default() -> Self {
-
        Self {
-
            items: vec![],
-
            selected: None,
-
            show_scrollbar: true,
-
            opened: None,
-
            dim: false,
-
        }
-
    }
-
}
-

-
impl<R, Id> TreeProps<R, Id>
-
where
-
    R: ToTree<Id> + Clone,
-
    Id: ToString + Clone,
-
{
-
    pub fn items(mut self, items: Vec<R>) -> Self {
-
        self.items = items;
-
        self
-
    }
-

-
    pub fn selected(mut self, selected: Option<&[Id]>) -> Self {
-
        self.selected = selected.map(|s| s.to_vec());
-
        self
-
    }
-

-
    pub fn opened(mut self, opened: Option<HashSet<Vec<Id>>>) -> Self {
-
        self.opened = opened;
-
        self
-
    }
-

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

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

-
/// A `Tree` is an expandable, collapsable and scrollable tree widget, that takes
-
/// a list of root items which implement `ToTree`. It can be updated with a selection
-
/// and a set of opened items.
-
pub struct Tree<S, M, R, Id>
-
where
-
    R: ToTree<Id>,
-
    Id: ToString + Clone,
-
{
-
    /// Internal selection and offset state
-
    state: TreeState<Id>,
-
    /// Phantom
-
    phantom: PhantomData<(S, M, R, Id)>,
-
}
-

-
impl<S, M, R, Id> Default for Tree<S, M, R, Id>
-
where
-
    R: ToTree<Id>,
-
    Id: ToString + Clone + Default,
-
{
-
    fn default() -> Self {
-
        Self {
-
            state: TreeState::default(),
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<S, M, R, Id> View for Tree<S, M, R, Id>
-
where
-
    R: ToTree<Id> + Clone + 'static,
-
    Id: ToString + Clone + Default + Eq + PartialEq + Hash + 'static,
-
{
-
    type State = S;
-
    type Message = M;
-

-
    fn reset(&mut self) {
-
        self.state = TreeState::default();
-
    }
-

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

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

-
        if let Some(opened) = &props.opened {
-
            if opened != self.state.opened() {
-
                self.state.close_all();
-
                for path in opened {
-
                    self.state.open(path.to_vec());
-
                }
-
            }
-
        }
-
    }
-

-
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        match key {
-
            Key::Up | Key::Char('k') => {
-
                self.state.key_up();
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.state.key_down();
-
            }
-
            Key::Left | Key::Char('h')
-
                if !self.state.selected().is_empty() && !self.state.opened().is_empty() =>
-
            {
-
                self.state.key_left();
-
            }
-
            Key::Right | Key::Char('l') => {
-
                self.state.key_right();
-
            }
-
            _ => {}
-
        }
-

-
        None
-
    }
-

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

-
        let mut items = vec![];
-
        for item in &props.items {
-
            items.extend(item.rows());
-
        }
-

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

-
        let tree = if props.show_scrollbar {
-
            tui_tree_widget::Tree::new(&items)
-
                .expect("all item identifiers are unique")
-
                .block(
-
                    Block::default()
-
                        .borders(Borders::RIGHT)
-
                        .border_set(border::Set {
-
                            vertical_right: " ",
-
                            ..Default::default()
-
                        })
-
                        .border_style(if render.focus {
-
                            Style::default()
-
                        } else {
-
                            Style::default().dim()
-
                        }),
-
                )
-
                .experimental_scrollbar(Some(
-
                    Scrollbar::new(ScrollbarOrientation::VerticalRight)
-
                        .begin_symbol(None)
-
                        .track_symbol(None)
-
                        .end_symbol(None)
-
                        .thumb_symbol("┃"),
-
                ))
-
                .highlight_style(style::highlight(render.focus))
-
                .style(tree_style)
-
        } else {
-
            tui_tree_widget::Tree::new(&items)
-
                .expect("all item identifiers are unique")
-
                .style(tree_style)
-
                .highlight_style(style::highlight(render.focus))
-
        };
-

-
        frame.render_stateful_widget(tree, render.area, &mut self.state);
-
    }
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        Some(ViewState::Tree(
-
            self.state
-
                .selected()
-
                .to_vec()
-
                .iter()
-
                .map(|s| s.to_string())
-
                .collect(),
-
        ))
-
    }
-
}
deleted src/ui/widget/utils.rs
@@ -1,29 +0,0 @@
-
pub mod scroll {
-
    pub fn percent_seen(selected: usize, len: usize, page_size: usize) -> usize {
-
        let step = selected;
-
        let page_size = page_size as f64;
-
        let len = len as f64;
-

-
        let lines = page_size + step.saturating_sub(page_size as usize) as f64;
-
        let progress = (lines / len * 100.0).ceil();
-

-
        if progress > 97.0 {
-
            map_range((0.0, progress), (0.0, 100.0), progress) as usize
-
        } else {
-
            progress as usize
-
        }
-
    }
-

-
    pub fn percent_absolute(offset: usize, len: usize, height: usize) -> usize {
-
        let y = offset as f64;
-
        let h = height as f64;
-
        let t = len.saturating_sub(1) as f64;
-
        let v = y / (t - h) * 100_f64;
-

-
        (v as usize).clamp(0, 100)
-
    }
-

-
    fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
-
        to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
-
    }
-
}
deleted src/ui/widget/window.rs
@@ -1,309 +0,0 @@
-
use std::hash::Hash;
-
use std::{collections::HashMap, marker::PhantomData};
-

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

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::{Style, Stylize};
-
use ratatui::text::Text;
-
use ratatui::widgets::Row;
-

-
use crate::ui::theme::{style, Theme};
-

-
use super::{RenderProps, View, ViewProps, Widget};
-

-
#[derive(Clone)]
-
pub struct WindowProps<Id> {
-
    current_page: Option<Id>,
-
}
-

-
impl<Id> WindowProps<Id> {
-
    pub fn current_page(mut self, page: Id) -> Self {
-
        self.current_page = Some(page);
-
        self
-
    }
-
}
-

-
impl<Id> Default for WindowProps<Id> {
-
    fn default() -> Self {
-
        Self { current_page: None }
-
    }
-
}
-

-
pub struct Window<S, M, Id> {
-
    /// All pages known
-
    pages: HashMap<Id, Widget<S, M>>,
-
}
-

-
impl<S, M, Id> Default for Window<S, M, Id> {
-
    fn default() -> Self {
-
        Self {
-
            pages: HashMap::new(),
-
        }
-
    }
-
}
-

-
impl<S, M, Id> Window<S, M, Id>
-
where
-
    Id: Clone + Hash + Eq + PartialEq,
-
{
-
    pub fn page(mut self, id: Id, page: Widget<S, M>) -> Self {
-
        self.pages.insert(id, page);
-
        self
-
    }
-
}
-

-
impl<'a, S, M, Id> View for Window<S, M, Id>
-
where
-
    'a: 'static,
-
    S: 'static,
-
    M: 'static,
-
    Id: Clone + Hash + Eq + PartialEq + 'static,
-
{
-
    type Message = M;
-
    type State = S;
-

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

-
        let page = props
-
            .current_page
-
            .as_ref()
-
            .and_then(|id| self.pages.get_mut(id));
-

-
        if let Some(page) = page {
-
            page.handle_event(key);
-
        }
-

-
        None
-
    }
-

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

-
        let page = props
-
            .current_page
-
            .as_ref()
-
            .and_then(|id| self.pages.get_mut(id));
-

-
        if let Some(page) = page {
-
            page.update(state);
-
        }
-
    }
-

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

-
        let area = frame.size();
-

-
        let page = props
-
            .current_page
-
            .as_ref()
-
            .and_then(|id| self.pages.get_mut(id));
-

-
        if let Some(page) = page {
-
            page.render(RenderProps::from(area).focus(true), frame);
-
        }
-
    }
-
}
-

-
#[derive(Clone, Default)]
-
pub struct PageProps {
-
    /// If this view's should handle keys
-
    pub handle_keys: bool,
-
}
-

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

-
pub struct Page<S, M> {
-
    /// Content widget
-
    content: Option<Widget<S, M>>,
-
    /// Shortcut widget
-
    shortcuts: Option<Widget<S, M>>,
-
}
-

-
impl<S, M> Default for Page<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            content: None,
-
            shortcuts: None,
-
        }
-
    }
-
}
-

-
impl<S, M> Page<S, M> {
-
    pub fn content(mut self, content: Widget<S, M>) -> Self {
-
        self.content = Some(content);
-
        self
-
    }
-

-
    pub fn shortcuts(mut self, shortcuts: Widget<S, M>) -> Self {
-
        self.shortcuts = Some(shortcuts);
-
        self
-
    }
-
}
-

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

-
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        if let Some(content) = self.content.as_mut() {
-
            content.handle_event(key);
-
        }
-

-
        None
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        if let Some(content) = self.content.as_mut() {
-
            content.update(state);
-
        }
-
        if let Some(shortcuts) = self.shortcuts.as_mut() {
-
            shortcuts.update(state);
-
        }
-
    }
-

-
    fn render(&mut self, _props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let [content_area, shortcuts_area] =
-
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(render.area);
-

-
        if let Some(content) = self.content.as_mut() {
-
            content.render(
-
                RenderProps::from(content_area)
-
                    .layout(Layout::horizontal([Constraint::Min(1)]))
-
                    .focus(true),
-
                frame,
-
            );
-
        }
-

-
        if let Some(shortcuts) = self.shortcuts.as_mut() {
-
            shortcuts.render(RenderProps::from(shortcuts_area), frame);
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
-
pub struct ShortcutsProps {
-
    pub shortcuts: Vec<(String, String)>,
-
    pub divider: char,
-
    pub shortcuts_keys_style: Style,
-
    pub shortcuts_action_style: Style,
-
}
-

-
impl ShortcutsProps {
-
    pub fn divider(mut self, divider: char) -> Self {
-
        self.divider = divider;
-
        self
-
    }
-

-
    pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
-
        self.shortcuts.clear();
-
        for (short, long) in shortcuts {
-
            self.shortcuts.push((short.to_string(), long.to_string()));
-
        }
-
        self
-
    }
-

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

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

-
impl Default for ShortcutsProps {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            shortcuts: vec![],
-
            divider: '∙',
-
            shortcuts_keys_style: theme.shortcuts_keys_style,
-
            shortcuts_action_style: theme.shortcuts_action_style,
-
        }
-
    }
-
}
-

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

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

-
impl<S, M> View for Shortcuts<S, M> {
-
    type Message = M;
-
    type State = S;
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        use ratatui::widgets::Table;
-

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

-
        let mut shortcuts = props.shortcuts.iter().peekable();
-
        let mut row = vec![];
-

-
        while let Some(shortcut) = shortcuts.next() {
-
            let short = Text::from(shortcut.0.clone()).style(props.shortcuts_keys_style);
-
            let long = Text::from(shortcut.1.clone()).style(props.shortcuts_action_style);
-
            let spacer = Text::from(String::new());
-
            let divider = Text::from(format!(" {} ", props.divider)).style(style::gray().dim());
-

-
            row.push((shortcut.0.chars().count(), short));
-
            row.push((1, spacer));
-
            row.push((shortcut.1.chars().count(), long));
-

-
            if shortcuts.peek().is_some() {
-
                row.push((3, divider));
-
            }
-
        }
-

-
        let row_copy = row.clone();
-
        let row: Vec<Text<'_>> = row_copy
-
            .clone()
-
            .iter()
-
            .map(|(_, text)| text.clone())
-
            .collect();
-
        let widths: Vec<Constraint> = row_copy
-
            .clone()
-
            .iter()
-
            .map(|(width, _)| Constraint::Length(*width as u16))
-
            .collect();
-

-
        let table = Table::new([Row::new(row)], widths).column_spacing(0);
-
        frame.render_widget(table, render.area);
-
    }
-
}