Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
lib: Various improvements
Merged did:key:z6MkgFq6...nBGz opened 4 months ago
12 files changed +302 -364 9ecc9621 d99bcd24
modified bin/commands/inbox/list.rs
@@ -22,9 +22,9 @@ use radicle_tui as tui;
use tui::store;
use tui::task::{Process, Task};
use tui::ui::im;
-
use tui::ui::im::widget::{PanesState, TableState, TextEditState, TextViewState, Window};
+
use tui::ui::im::widget::{ContainerState, TableState, TextEditState, TextViewState, Window};
use tui::ui::im::{Borders, Show};
-
use tui::ui::{BufferedValue, Column};
+
use tui::ui::{BufferedValue, Column, Spacing};
use tui::{Channel, Exit};

use crate::ui::items::filter::Filter;
@@ -101,7 +101,7 @@ pub enum Change {
        page: Page,
    },
    MainGroup {
-
        state: PanesState,
+
        state: ContainerState,
    },
    Patches {
        state: TableState,
@@ -135,7 +135,7 @@ pub enum Page {
#[derive(Clone, Debug)]
pub struct AppState {
    page: Page,
-
    main_group: PanesState,
+
    main_group: ContainerState,
    patches: TableState,
    search: BufferedValue<TextEditState>,
    show_search: bool,
@@ -168,7 +168,7 @@ impl TryFrom<&Context> for App {
            notifications: Arc::new(Mutex::new(vec![])),
            state: AppState {
                page: Page::Main,
-
                main_group: PanesState::new(3, Some(0)),
+
                main_group: ContainerState::new(3, Some(0)),
                patches: TableState::new(Some(0)),
                search: BufferedValue::new(TextEditState {
                    text: search.clone(),
@@ -203,12 +203,12 @@ impl store::Update<Message> for App {
                }),
            }),
            Message::ShowSearch => {
-
                self.state.main_group = PanesState::new(3, None);
+
                self.state.main_group = ContainerState::new(3, None);
                self.state.show_search = true;
                None
            }
            Message::HideSearch { apply } => {
-
                self.state.main_group = PanesState::new(3, Some(0));
+
                self.state.main_group = ContainerState::new(3, Some(0));
                self.state.show_search = false;

                if apply {
@@ -275,13 +275,13 @@ impl Show<Message> for App {
                    let show_search = self.state.show_search;
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };

-
                    ui.panes(
+
                    ui.container(
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
                        &mut page_focus,
                        |ui| {
                            let mut group_focus = self.state.main_group.focus();

-
                            let group = ui.panes(
+
                            let group = ui.container(
                                im::Layout::Expandable3 { left_only: true },
                                &mut group_focus,
                                |ui| {
@@ -290,7 +290,7 @@ impl Show<Message> for App {
                            );
                            if group.response.changed {
                                ui.send_message(Message::Changed(Change::MainGroup {
-
                                    state: PanesState::new(3, group_focus),
+
                                    state: ContainerState::new(3, group_focus),
                                }));
                            }

@@ -301,6 +301,22 @@ impl Show<Message> for App {
                            }
                        },
                    );
+
                    if ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
+
                    }
+
                    if ui.has_input(|key| key == Key::Char('\n')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(InboxOperation::Show),
+
                        });
+
                    }
+
                    if ui.has_input(|key| key == Key::Char('c')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(InboxOperation::Clear),
+
                        });
+
                    }
+
                    if ui.has_input(|key| key == Key::Char('r')) {
+
                        ui.send_message(Message::Reload);
+
                    }
                }

                Page::Help => {
@@ -311,22 +327,22 @@ impl Show<Message> for App {
                        Constraint::Length(1),
                    ]);

-
                    ui.composite(layout, 1, |ui| {
+
                    ui.container(layout, &mut Some(1), |ui| {
                        self.show_help_text(frame, ui);
                        self.show_help_context(frame, ui);

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

-
                    if ui.input_global(|key| key == Key::Char('?')) {
+
                    if ui.has_input(|key| key == Key::Char('?')) {
                        ui.send_message(Message::Changed(Change::Page { page: Page::Main }));
                    }
-
                    if ui.input_global(|key| key == Key::Char('q')) {
-
                        ui.send_message(Message::Quit);
-
                    }
                }
            }
-
            if ui.input_global(|key| key == Key::Ctrl('c')) {
+
            if ui.has_input(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
            if ui.has_input(|key| key == Key::Ctrl('c')) {
                ui.send_message(Message::Quit);
            }
        });
@@ -359,28 +375,41 @@ impl App {
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)),
        ];

-
        let table = ui.headered_table(
-
            frame,
-
            &mut selected,
-
            &notifs,
-
            header.clone(),
-
            header,
-
            Some("No notifications found".into()),
-
        );
-
        if table.changed {
-
            ui.send_message(Message::Changed(Change::Patches {
-
                state: TableState::new(selected),
-
            }));
-
        }
+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.column_bar(
+
                    frame,
+
                    header.to_vec(),
+
                    Spacing::default(),
+
                    Some(Borders::Top),
+
                );
+

+
                let table = ui.table(
+
                    frame,
+
                    &mut selected,
+
                    &notifs,
+
                    header.to_vec(),
+
                    Some("No notifications found".into()),
+
                    Some(Borders::BottomSides),
+
                );
+
                if table.changed {
+
                    ui.send_message(Message::Changed(Change::Patches {
+
                        state: TableState::new(selected),
+
                    }));
+
                }

-
        if self.state.loading {
-
            self.show_loading_popup(frame, ui);
-
        }
+
                if self.state.loading {
+
                    self.show_loading_popup(frame, ui);
+
                }

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

    fn show_browser_footer(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
@@ -388,25 +417,6 @@ impl App {
            self.show_browser_context(frame, ui);
            self.show_browser_shortcuts(frame, ui);
        });
-
        if ui.input_global(|key| key == Key::Char('q')) {
-
            ui.send_message(Message::Quit);
-
        }
-
        if ui.input_global(|key| key == Key::Char('?')) {
-
            ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
-
        }
-
        if ui.input_global(|key| key == Key::Char('\n')) {
-
            ui.send_message(Message::Exit {
-
                operation: Some(InboxOperation::Show),
-
            });
-
        }
-
        if ui.input_global(|key| key == Key::Char('c')) {
-
            ui.send_message(Message::Exit {
-
                operation: Some(InboxOperation::Clear),
-
            });
-
        }
-
        if ui.input_global(|key| key == Key::Char('r')) {
-
            ui.send_message(Message::Reload);
-
        }
    }

    pub fn show_browser_search(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
@@ -416,11 +426,11 @@ impl App {
        );
        let mut search = self.state.search.clone();

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

@@ -432,10 +442,10 @@ impl App {
            ui.send_message(Message::Changed(Change::Search { search }));
        }

-
        if ui.input_global(|key| key == Key::Esc) {
+
        if ui.has_input(|key| key == Key::Esc) {
            ui.send_message(Message::HideSearch { apply: false });
        }
-
        if ui.input_global(|key| key == Key::Char('\n')) {
+
        if ui.has_input(|key| key == Key::Char('\n')) {
            ui.send_message(Message::HideSearch { apply: true });
        }
    }
@@ -536,7 +546,7 @@ impl App {
            }
        };

-
        ui.bar(frame, context, Some(Borders::None));
+
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
    }

    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
@@ -565,13 +575,14 @@ impl App {
                        None,
                        |ui| {
                            ui.label(frame, "");
-
                            ui.columns(
+
                            ui.column_bar(
                                frame,
                                [Column::new(
                                    Span::raw(" Loading ").magenta().slow_blink(),
                                    Constraint::Fill(1),
                                )]
                                .to_vec(),
+
                                Spacing::from(0),
                                Some(Borders::All),
                            );
                        },
@@ -583,9 +594,10 @@ impl App {
    }

    fn show_help_text(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
-
        ui.columns(
+
        ui.column_bar(
            frame,
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
            Spacing::from(0),
            Some(Borders::Top),
        );

@@ -604,7 +616,7 @@ impl App {
    }

    fn show_help_context(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
-
        ui.bar(
+
        ui.column_bar(
            frame,
            [
                Column::new(
@@ -623,6 +635,7 @@ impl App {
                ),
            ]
            .to_vec(),
+
            Spacing::from(0),
            Some(Borders::None),
        );
    }
modified bin/commands/patch/list.rs
@@ -19,11 +19,10 @@ use radicle_tui as tui;
use tui::store;
use tui::task::EmptyProcessors;
use tui::ui::im;
-
use tui::ui::im::widget::{PanesState, TableState, TextEditState, TextViewState, Window};
+
use tui::ui::im::widget::{ContainerState, TableState, TextEditState, TextViewState, Window};
use tui::ui::im::Borders;
use tui::ui::im::Show;
-
use tui::ui::BufferedValue;
-
use tui::ui::Column;
+
use tui::ui::{BufferedValue, Column, Spacing};
use tui::{Channel, Exit};

type Selection = tui::Selection<PatchId>;
@@ -90,7 +89,7 @@ pub enum Change {
        page: Page,
    },
    MainGroup {
-
        state: PanesState,
+
        state: ContainerState,
    },
    Patches {
        state: TableState,
@@ -123,7 +122,7 @@ pub enum Page {
pub struct AppState {
    mode: Mode,
    page: Page,
-
    main_group: PanesState,
+
    main_group: ContainerState,
    patches: TableState,
    search: BufferedValue<TextEditState>,
    show_search: bool,
@@ -162,7 +161,7 @@ impl TryFrom<&Context> for App {
            state: AppState {
                mode: context.mode.clone(),
                page: Page::Main,
-
                main_group: PanesState::new(3, Some(0)),
+
                main_group: ContainerState::new(3, Some(0)),
                patches: TableState::new(Some(0)),
                search: BufferedValue::new(TextEditState {
                    text: search.clone(),
@@ -204,12 +203,12 @@ impl store::Update<Message> for App {
                })
            }
            Message::ShowSearch => {
-
                self.state.main_group = PanesState::new(3, None);
+
                self.state.main_group = ContainerState::new(3, None);
                self.state.show_search = true;
                None
            }
            Message::HideSearch { apply } => {
-
                self.state.main_group = PanesState::new(3, Some(0));
+
                self.state.main_group = ContainerState::new(3, Some(0));
                self.state.show_search = false;

                if apply {
@@ -260,13 +259,13 @@ impl Show<Message> for App {
                    let show_search = self.state.show_search;
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };

-
                    ui.panes(
+
                    ui.container(
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
                        &mut page_focus,
                        |ui| {
                            let mut group_focus = self.state.main_group.focus();

-
                            let group = ui.panes(
+
                            let group = ui.container(
                                im::Layout::Expandable3 { left_only: true },
                                &mut group_focus,
                                |ui| {
@@ -275,7 +274,7 @@ impl Show<Message> for App {
                            );
                            if group.response.changed {
                                ui.send_message(Message::Changed(Change::MainGroup {
-
                                    state: PanesState::new(3, group_focus),
+
                                    state: ContainerState::new(3, group_focus),
                                }));
                            }

@@ -286,6 +285,27 @@ impl Show<Message> for App {
                            }
                        },
                    );
+
                    if ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
+
                    }
+
                    if ui.has_input(|key| key == Key::Char('\n')) {
+
                        ui.send_message(Message::ExitFromMode);
+
                    }
+
                    if ui.has_input(|key| key == Key::Char('d')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(PatchOperation::Diff),
+
                        });
+
                    }
+
                    if ui.has_input(|key| key == Key::Char('r')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(PatchOperation::Review),
+
                        });
+
                    }
+
                    if ui.has_input(|key| key == Key::Char('c')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(PatchOperation::Checkout),
+
                        });
+
                    }
                }

                Page::Help => {
@@ -296,22 +316,22 @@ impl Show<Message> for App {
                        Constraint::Length(1),
                    ]);

-
                    ui.composite(layout, 1, |ui| {
+
                    ui.container(layout, &mut Some(1), |ui| {
                        self.show_help_text(frame, ui);
                        self.show_help_context(frame, ui);

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

-
                    if ui.input_global(|key| key == Key::Char('?')) {
+
                    if ui.has_input(|key| key == Key::Char('?')) {
                        ui.send_message(Message::Changed(Change::Page { page: Page::Main }));
                    }
-
                    if ui.input_global(|key| key == Key::Char('q')) {
-
                        ui.send_message(Message::Quit);
-
                    }
                }
            }
-
            if ui.input_global(|key| key == Key::Ctrl('c')) {
+
            if ui.has_input(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
            if ui.has_input(|key| key == Key::Ctrl('c')) {
                ui.send_message(Message::Quit);
            }
        });
@@ -342,24 +362,37 @@ impl App {
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)).hide_small(),
        ];

-
        let table = ui.headered_table(
-
            frame,
-
            &mut selected,
-
            &patches,
-
            header.clone(),
-
            header,
-
            Some("No patches found".into()),
-
        );
-
        if table.changed {
-
            ui.send_message(Message::Changed(Change::Patches {
-
                state: TableState::new(selected),
-
            }));
-
        }
+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.column_bar(
+
                    frame,
+
                    header.to_vec(),
+
                    Spacing::default(),
+
                    Some(Borders::Top),
+
                );
+

+
                let table = ui.table(
+
                    frame,
+
                    &mut selected,
+
                    &patches,
+
                    header.to_vec(),
+
                    Some("No patches found".into()),
+
                    Some(Borders::BottomSides),
+
                );
+
                if table.changed {
+
                    ui.send_message(Message::Changed(Change::Patches {
+
                        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);
-
        }
+
                // TODO(erikli): Should only work if table has focus
+
                if ui.has_input(|key| key == Key::Char('/')) {
+
                    ui.send_message(Message::ShowSearch);
+
                }
+
            },
+
        );
    }

    fn show_browser_footer(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
@@ -367,30 +400,6 @@ impl App {
            self.show_browser_context(frame, ui);
            self.show_browser_shortcuts(frame, ui);
        });
-
        if ui.input_global(|key| key == Key::Char('q')) {
-
            ui.send_message(Message::Quit);
-
        }
-
        if ui.input_global(|key| key == Key::Char('?')) {
-
            ui.send_message(Message::Changed(Change::Page { 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('r')) {
-
            ui.send_message(Message::Exit {
-
                operation: Some(PatchOperation::Review),
-
            });
-
        }
-
        if ui.input_global(|key| key == Key::Char('c')) {
-
            ui.send_message(Message::Exit {
-
                operation: Some(PatchOperation::Checkout),
-
            });
-
        }
    }

    pub fn show_browser_search(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
@@ -400,11 +409,11 @@ impl App {
        );
        let mut search = self.state.search.clone();

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

@@ -416,10 +425,10 @@ impl App {
            ui.send_message(Message::Changed(Change::Search { search }));
        }

-
        if ui.input_global(|key| key == Key::Esc) {
+
        if ui.has_input(|key| key == Key::Esc) {
            ui.send_message(Message::HideSearch { apply: false });
        }
-
        if ui.input_global(|key| key == Key::Char('\n')) {
+
        if ui.has_input(|key| key == Key::Char('\n')) {
            ui.send_message(Message::HideSearch { apply: true });
        }
    }
@@ -561,7 +570,7 @@ impl App {
            }
        };

-
        ui.bar(frame, context, Some(Borders::None));
+
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
    }

    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
@@ -584,9 +593,10 @@ impl App {
    }

    fn show_help_text(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
-
        ui.columns(
+
        ui.column_bar(
            frame,
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
            Spacing::from(0),
            Some(Borders::Top),
        );

@@ -605,7 +615,7 @@ impl App {
    }

    fn show_help_context(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
-
        ui.bar(
+
        ui.column_bar(
            frame,
            [
                Column::new(
@@ -624,6 +634,7 @@ impl App {
                ),
            ]
            .to_vec(),
+
            Spacing::from(0),
            Some(Borders::None),
        );
    }
modified bin/commands/patch/review.rs
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};

use termion::event::Key;

-
use ratatui::layout::{Constraint, Position};
+
use ratatui::layout::{Constraint, Layout, Position};
use ratatui::style::{Style, Stylize};
use ratatui::text::Text;
use ratatui::{Frame, Viewport};
@@ -24,10 +24,10 @@ use radicle_tui as tui;

use tui::store;
use tui::task::EmptyProcessors;
-
use tui::ui::im::widget::{PanesState, TableState, TextViewState, Window};
+
use tui::ui::im::widget::{ContainerState, TableState, TextViewState, Window};
use tui::ui::im::{Borders, Context, Show, Ui};
use tui::ui::span;
-
use tui::ui::Column;
+
use tui::ui::{Column, Spacing};
use tui::{Channel, Exit};

use crate::git::HunkState;
@@ -130,7 +130,7 @@ impl Tui {
#[derive(Clone, Debug)]
pub enum Message {
    ShowMain,
-
    PanesChanged { state: PanesState },
+
    PanesChanged { state: ContainerState },
    HunkChanged { state: TableState },
    HunkViewChanged { state: DiffViewState },
    ShowHelp,
@@ -167,7 +167,7 @@ pub struct AppState {
    /// Current app page.
    page: AppPage,
    /// State of panes widget on the main page.
-
    panes: PanesState,
+
    panes: ContainerState,
    /// The hunks' table widget state.
    hunks: (TableState, Vec<HunkState>),
    /// Diff view states (cursor position is stored per hunk)
@@ -192,7 +192,7 @@ impl AppState {
            title,
            revision,
            page: AppPage::Main,
-
            panes: PanesState::new(2, Some(0)),
+
            panes: ContainerState::new(2, Some(0)),
            hunks: (
                TableState::new(Some(0)),
                vec![HunkState::Rejected; hunks.len()],
@@ -338,12 +338,32 @@ impl App<'_> {

        let mut selected = state.selected_hunk();

-
        let table = ui.headered_table(frame, &mut selected, &hunks, header, columns, None);
-
        if table.changed {
-
            ui.send_message(Message::HunkChanged {
-
                state: TableState::new(selected),
-
            })
-
        }
+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.column_bar(
+
                    frame,
+
                    header.to_vec(),
+
                    Spacing::default(),
+
                    Some(Borders::Top),
+
                );
+

+
                let table = ui.table(
+
                    frame,
+
                    &mut selected,
+
                    &hunks,
+
                    columns,
+
                    None,
+
                    Some(Borders::BottomSides),
+
                );
+
                if table.changed {
+
                    ui.send_message(Message::HunkChanged {
+
                        state: TableState::new(selected),
+
                    })
+
                }
+
            },
+
        );
    }

    fn show_hunk(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
@@ -359,8 +379,13 @@ impl App<'_> {
                .map(|state| state.cursor)
                .unwrap_or_default();

-
            ui.composite(layout::container(), 1, |ui| {
-
                ui.columns(frame, hunk.inner().header(), Some(Borders::Top));
+
            ui.container(layout::container(), &mut Some(1), |ui| {
+
                ui.column_bar(
+
                    frame,
+
                    hunk.inner().header(),
+
                    Spacing::from(0),
+
                    Some(Borders::Top),
+
                );

                if let Some(text) = hunk.inner().hunk_text() {
                    let diff = ui.text_view(frame, text, &mut cursor, Some(Borders::BottomSides));
@@ -409,7 +434,7 @@ impl App<'_> {
            ),
        };

-
        ui.bar(
+
        ui.column_bar(
            frame,
            [
                Column::new(
@@ -443,6 +468,7 @@ impl App<'_> {
                ),
            ]
            .to_vec(),
+
            Spacing::from(0),
            Some(Borders::None),
        );
    }
@@ -463,23 +489,23 @@ impl App<'_> {
                    '∙',
                );

-
                if ui.input_global(|key| key == Key::Char('?')) {
+
                if ui.has_input(|key| key == Key::Char('?')) {
                    ui.send_message(Message::ShowHelp);
                }
-
                if ui.input_global(|key| key == Key::Char('c')) {
+
                if ui.has_input(|key| key == Key::Char('c')) {
                    ui.send_message(Message::Comment);
                }
-
                if ui.input_global(|key| key == Key::Char('a')) {
+
                if ui.has_input(|key| key == Key::Char('a')) {
                    ui.send_message(Message::Accept);
                }
-
                if ui.input_global(|key| key == Key::Char('r')) {
+
                if ui.has_input(|key| key == Key::Char('r')) {
                    ui.send_message(Message::Reject);
                }
            }
            ReviewMode::Show => {
                ui.shortcuts(frame, &[("?", "help"), ("q", "quit")], '∙');

-
                if ui.input_global(|key| key == Key::Char('?')) {
+
                if ui.has_input(|key| key == Key::Char('?')) {
                    ui.send_message(Message::ShowHelp);
                }
            }
@@ -503,13 +529,13 @@ impl Show<Message> for App<'_> {
                    };

                    ui.layout(layout::page(), Some(0), |ui| {
-
                        let group = ui.panes(layout::list_item(), &mut focus, |ui| {
+
                        let group = ui.container(layout::list_item(), &mut focus, |ui| {
                            self.show_hunk_list(ui, frame);
                            self.show_hunk(ui, frame);
                        });
                        if group.response.changed {
                            ui.send_message(Message::PanesChanged {
-
                                state: PanesState::new(count, focus),
+
                                state: ContainerState::new(count, focus),
                            });
                        }

@@ -519,14 +545,14 @@ impl Show<Message> for App<'_> {
                }
                AppPage::Help => {
                    ui.layout(layout::page(), Some(0), |ui| {
-
                        ui.composite(layout::container(), 1, |ui| {
+
                        ui.container(layout::container(), &mut Some(1), |ui| {
                            let mut cursor = {
                                let state = self.state.lock().unwrap();
                                state.help.cursor()
                            };
                            let header = [Column::new(" Help ", Constraint::Fill(1))].to_vec();

-
                            ui.columns(frame, header, Some(Borders::Top));
+
                            ui.column_bar(frame, header, Spacing::from(0), Some(Borders::Top));
                            let help = ui.text_view(
                                frame,
                                help_text().to_string(),
@@ -545,13 +571,13 @@ impl Show<Message> for App<'_> {
                        ui.shortcuts(frame, &[("?", "close"), ("q", "quit")], '∙');
                    });

-
                    if ui.input_global(|key| key == Key::Char('?')) {
+
                    if ui.has_input(|key| key == Key::Char('?')) {
                        ui.send_message(Message::ShowMain);
                    }
                }
            }

-
            if ui.input_global(|key| key == Key::Char('q')) {
+
            if ui.has_input(|key| key == Key::Char('q')) {
                ui.send_message(Message::Quit);
            }
        });
modified bin/ui/im.rs
@@ -1,3 +1,4 @@
+
use radicle_tui::ui::Spacing;
use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
@@ -124,7 +125,12 @@ where
            ]),
            Some(1),
            |ui| {
-
                ui.columns(frame, self.header.clone().to_vec(), Some(Borders::Top));
+
                ui.column_bar(
+
                    frame,
+
                    self.header.clone().to_vec(),
+
                    Spacing::default(),
+
                    Some(Borders::Top),
+
                );

                let table = ui.table(
                    frame,
@@ -141,31 +147,36 @@ where
                response.changed |= table.changed;

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

        if !*self.show_search {
-
            if ui.input_global(|key| key == Key::Char('/')) {
+
            if ui.has_input(|key| key == Key::Char('/')) {
                *self.show_search = true;
            }
        } else {
-
            if ui.input_global(|key| key == Key::Esc) {
+
            if ui.has_input(|key| key == Key::Esc) {
                *self.show_search = false;
                self.search.reset();
            }
-
            if ui.input_global(|key| key == Key::Char('\n')) {
+
            if ui.has_input(|key| key == Key::Char('\n')) {
                *self.show_search = false;
                self.search.apply();
            }
modified examples/hello.rs
@@ -59,7 +59,7 @@ impl Show<Message> for App {
                Some(Borders::None),
            );

-
            if ui.input_global(|key| key == Key::Char('q')) {
+
            if ui.has_input(|key| key == Key::Char('q')) {
                ui.send_message(Message::Quit);
            }
        });
modified examples/selection.rs
@@ -2,6 +2,7 @@ use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Result;

+
use radicle_tui::ui::Spacing;
use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
@@ -96,7 +97,12 @@ impl Show<Message> for App {
                    .to_vec();
                    let mut selected = self.selector.selected();

-
                    ui.columns(frame, columns.clone(), Some(Borders::None));
+
                    ui.column_bar(
+
                        frame,
+
                        columns.clone(),
+
                        Spacing::default(),
+
                        Some(Borders::None),
+
                    );

                    let table = ui.table(
                        frame,
@@ -114,13 +120,13 @@ impl Show<Message> for App {

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

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

-
            if ui.input_global(|key| key == Key::Char('q')) {
+
            if ui.has_input(|key| key == Key::Char('q')) {
                ui.send_message(Message::Quit);
            }
        });
modified src/lib.rs
@@ -291,7 +291,7 @@ where
#[derive(Debug, Clone)]
pub enum Interrupted<P>
where
-
    P: Clone + Send + Sync + Debug,
+
    P: Share,
{
    OsSignal,
    User { payload: Option<P> },
@@ -301,14 +301,14 @@ where
#[derive(Debug, Clone)]
pub struct Terminator<P>
where
-
    P: Clone + Send + Sync + Debug,
+
    P: Share,
{
    interrupt_tx: broadcast::Sender<Interrupted<P>>,
}

impl<P> Terminator<P>
where
-
    P: Clone + Send + Sync + Debug + 'static,
+
    P: Share,
{
    /// Create a `Terminator` that stores the sending end of a broadcast channel.
    pub fn new(interrupt_tx: broadcast::Sender<Interrupted<P>>) -> Self {
@@ -327,7 +327,7 @@ where
#[cfg(unix)]
async fn terminate_by_unix_signal<P>(mut terminator: Terminator<P>)
where
-
    P: Clone + Send + Sync + Debug + 'static,
+
    P: Share,
{
    let mut interrupt_signal = signal(tokio::signal::unix::SignalKind::interrupt())
        .expect("failed to create interrupt signal stream");
@@ -342,7 +342,7 @@ where
/// Create a broadcast channel and spawn a task for retrieving the applications' kill signal.
pub fn create_termination<P>() -> (Terminator<P>, broadcast::Receiver<Interrupted<P>>)
where
-
    P: Clone + Send + Sync + Debug + 'static,
+
    P: Share,
{
    let (tx, rx) = broadcast::channel(1);
    let terminator = Terminator::new(tx);
modified src/terminal.rs
@@ -1,4 +1,3 @@
-
use std::fmt::Debug;
use std::io;
use std::io::Write;
use std::thread;
@@ -15,9 +14,11 @@ use termion::async_stdin;
use termion::input::TermRead;
use termion::raw::{IntoRawMode, RawTerminal};

+
use ratatui::prelude::*;
use ratatui::termion::screen::{AlternateScreen, IntoAlternateScreen};
-
use ratatui::{prelude::*, CompletedFrame};
-
use ratatui::{TerminalOptions, Viewport};
+
use ratatui::{CompletedFrame, TerminalOptions, Viewport};
+

+
use crate::Share;

use super::event::Event;
use super::Interrupted;
@@ -173,7 +174,7 @@ impl<W: Write> ratatui::backend::Backend for TermionBackendExt<W> {
pub struct StdinReader {}

impl StdinReader {
-
    pub async fn run<P: Clone + Send + Sync + Debug>(
+
    pub async fn run<P: Share>(
        self,
        event_tx: UnboundedSender<Event>,
        mut interrupt_rx: broadcast::Receiver<Interrupted<P>>,
modified src/ui.rs
@@ -95,6 +95,21 @@ impl<'a> Column<'a> {
    }
}

+
#[derive(Default)]
+
pub struct Spacing(u16);
+

+
impl From<u16> for Spacing {
+
    fn from(value: u16) -> Self {
+
        Self(value)
+
    }
+
}
+

+
impl From<Spacing> for u16 {
+
    fn from(spacing: Spacing) -> Self {
+
        spacing.0
+
    }
+
}
+

/// 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];
modified src/ui/im.rs
@@ -21,10 +21,10 @@ use crate::event::Event;
use crate::store::Update;
use crate::terminal::Terminal;
use crate::ui::theme::Theme;
-
use crate::ui::{Column, ToRow};
-
use crate::Interrupted;
+
use crate::ui::{Column, Spacing, ToRow};
+
use crate::{Interrupted, Share};

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

use self::widget::AddContentFn;

@@ -51,8 +51,8 @@ impl Frontend {
    ) -> anyhow::Result<Interrupted<R>>
    where
        S: Update<M, Return = R> + Show<M>,
-
        M: Clone,
-
        R: Clone + Send + Sync + Debug,
+
        M: Share,
+
        R: Share,
    {
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
        let mut terminal = Terminal::try_from(viewport)?;
@@ -307,15 +307,11 @@ pub struct Ui<M> {
}

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

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

-
    pub fn input_with_key(&mut self, f: impl Fn(Key) -> bool) -> Option<Key> {
+
    pub fn get_input(&mut self, f: impl Fn(Key) -> bool) -> Option<Key> {
        if self.has_focus && self.is_area_focused() {
            self.ctx.inputs.iter().find(|key| f(**key)).copied()
        } else {
@@ -479,7 +475,7 @@ impl<M> Ui<M>
where
    M: Clone,
{
-
    pub fn panes<R>(
+
    pub fn container<R>(
        &mut self,
        layout: impl Into<Layout>,
        focus: &mut Option<usize>,
@@ -497,21 +493,7 @@ where
            ..self.child_ui(area, layout)
        };

-
        widget::Panes::new(len, focus).show(&mut child_ui, add_contents)
-
    }
-

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

-
        let mut child_ui = self.child_ui(area, layout);
-
        child_ui.has_focus = area_focus;
-

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

    pub fn popup<R>(
@@ -561,21 +543,6 @@ where
        widget::Table::new(selected, items, columns, empty_message, 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>>,
-
        columns: impl IntoIterator<Item = Column<'a>>,
-
        empty_message: Option<String>,
-
    ) -> Response
-
    where
-
        R: ToRow<W> + Clone,
-
    {
-
        HeaderedTable::<R, W>::new(selected, items, header, columns, empty_message).ui(self, frame)
-
    }
-

    pub fn shortcuts(
        &mut self,
        frame: &mut Frame,
@@ -585,22 +552,14 @@ where
        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(
+
    pub fn column_bar(
        &mut self,
        frame: &mut Frame,
        columns: Vec<Column<'_>>,
+
        spacing: Spacing,
        borders: Option<Borders>,
    ) -> Response {
-
        widget::Bar::new(columns, borders).ui(self, frame)
+
        widget::ColumnBar::new(columns, spacing, borders).ui(self, frame)
    }

    pub fn text_view<'a>(
@@ -627,21 +586,14 @@ where
        frame: &mut Frame,
        text: &mut String,
        cursor: &mut usize,
+
        label: Option<impl ToString>,
        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)
+
        match label {
+
            Some(label) => widget::TextEdit::new(text, cursor, borders)
+
                .with_label(label)
+
                .ui(self, frame),
+
            _ => widget::TextEdit::new(text, cursor, borders).ui(self, frame),
+
        }
    }
}
modified src/ui/im/widget.rs
@@ -11,7 +11,7 @@ use termion::event::Key;

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

use super::{Borders, Context, InnerResponse, Response, Ui};
@@ -62,12 +62,12 @@ impl Window {
}

#[derive(Clone, Debug, Serialize, Deserialize)]
-
pub struct PanesState {
+
pub struct ContainerState {
    len: usize,
    focus: Option<usize>,
}

-
impl PanesState {
+
impl ContainerState {
    pub fn new(len: usize, focus: Option<usize>) -> Self {
        Self { len, focus }
    }
@@ -95,12 +95,12 @@ impl PanesState {
    }
}

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

-
impl<'a> Panes<'a> {
+
impl<'a> Container<'a> {
    pub fn new(len: usize, focus: &'a mut Option<usize>) -> Self {
        Self { len, focus }
    }
@@ -126,16 +126,16 @@ impl<'a> Panes<'a> {
    {
        let mut response = Response::default();

-
        let mut state = PanesState {
+
        let mut state = ContainerState {
            focus: *self.focus,
            len: self.len,
        };

-
        if ui.input_global(|key| key == Key::Char('\t')) {
+
        if ui.has_input(|key| key == Key::Char('\t')) {
            state.focus_next();
            response.changed = true;
        }
-
        if ui.input_global(|key| key == Key::BackTab) {
+
        if ui.has_input(|key| key == Key::BackTab) {
            state.focus_prev();
            response.changed = true;
        }
@@ -176,45 +176,6 @@ impl CompositeState {
    }
}

-
pub struct Composite {
-
    focus: usize,
-
}
-

-
impl Composite {
-
    pub fn new(focus: usize) -> Self {
-
        Self { 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 ui = Ui {
-
            focus_area: Some(self.focus),
-
            ..ui.clone()
-
        };
-

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

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

#[derive(Default)]
pub struct Popup {}

@@ -411,7 +372,7 @@ where

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

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

@@ -531,82 +492,23 @@ where
    }
}

-
pub struct HeaderedTable<'a, R, const W: usize> {
-
    items: &'a Vec<R>,
-
    selected: &'a mut Option<usize>,
-
    header: Vec<Column<'a>>,
+
pub struct ColumnBar<'a> {
    columns: Vec<Column<'a>>,
-
    empty_message: Option<String>,
+
    spacing: Spacing,
+
    borders: Option<Borders>,
}

-
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>>,
-
        columns: impl IntoIterator<Item = Column<'a>>,
-
        empty_message: Option<String>,
-
    ) -> Self {
+
impl<'a> ColumnBar<'a> {
+
    pub fn new(columns: Vec<Column<'a>>, spacing: Spacing, borders: Option<Borders>) -> Self {
        Self {
-
            items,
-
            selected,
-
            header: header.into_iter().collect(),
-
            columns: columns.into_iter().collect(),
-
            empty_message,
+
            columns,
+
            spacing,
+
            borders,
        }
    }
-

-
    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<R, const W: usize> Widget for HeaderedTable<'_, 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();
-

-
        ui.composite(
-
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
-
            1,
-
            |ui| {
-
                ui.columns(frame, self.header.clone().to_vec(), Some(Borders::Top));
-

-
                let table = ui.table(
-
                    frame,
-
                    self.selected,
-
                    self.items,
-
                    self.columns.to_vec(),
-
                    self.empty_message,
-
                    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 Widget for Columns<'_> {
+
impl Widget for ColumnBar<'_> {
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
    where
        M: Clone,
@@ -645,7 +547,7 @@ impl Widget for Columns<'_> {
            .collect::<Vec<_>>();

        let table = ratatui::widgets::Table::default()
-
            .column_spacing(1)
+
            .column_spacing(self.spacing.into())
            .rows([Row::new(cells)])
            .widths(widths);
        frame.render_widget(table, area);
@@ -832,7 +734,7 @@ impl Widget for TextView<'_> {

        let mut state = TextViewState::new(*self.cursor);

-
        if let Some(key) = ui.input_with_key(|_| true) {
+
        if let Some(key) = ui.get_input(|_| true) {
            let lines = self.text.lines.clone();
            let len = lines.clone().len();
            let max_line_len = lines
@@ -1095,7 +997,7 @@ impl TextEdit<'_> {
            }
        }

-
        if let Some(key) = ui.input_with_key(|_| true) {
+
        if let Some(key) = ui.get_input(|_| true) {
            match key {
                Key::Char(to_insert)
                    if (key != Key::Alt('\n'))
modified src/ui/rm.rs
@@ -1,18 +1,19 @@
pub mod widget;

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

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

+
use ratatui::Viewport;
+

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

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

@@ -49,8 +50,8 @@ impl Frontend {
    ) -> anyhow::Result<Interrupted<R>>
    where
        S: Update<M, Return = R> + 'static,
-
        M: 'static,
-
        R: Clone + Send + Sync + Debug,
+
        M: Share,
+
        R: Share,
    {
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
        let mut terminal = Terminal::try_from(viewport)?;