Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Improve text view state passing via its props
Merged did:key:z6MkgFq6...nBGz opened 1 year ago

As a consequence of this, help texts in binary need to change raw (unstyled) format.

8 files changed +179 -497 8d9bc164 171677d4
modified CHANGELOG.md
@@ -26,15 +26,16 @@

### Changed

+
**Binary features**
+

+
- Selection interfaces don't show their browser scroll progress anymore
+
- Selection interfaces show their help as unstyled markdown
+

**Library features:**

- Use container focus for table highlighting
- Default keybindings for switching sections

-
**Binary features**
-

-
- Selection interfaces don't show their browser scroll progress anymore
-

### Removed

**Library features:**
modified bin/commands/inbox/select.rs
@@ -9,8 +9,6 @@ use termion::event::Key;

use ratatui::layout::Constraint;
use ratatui::style::Stylize;
-
use ratatui::text::Line;
-
use ratatui::text::Span;
use ratatui::text::Text;

use radicle::identity::Project;
@@ -28,6 +26,7 @@ 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::{BoxedAny, Channel, Exit, PageStack};
@@ -83,8 +82,7 @@ impl BrowserState {

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

#[derive(Clone, Debug)]
@@ -198,32 +196,22 @@ impl TryFrom<&Context> for State {
                show_search: false,
            },
            help: HelpState {
-
                scroll: 0,
-
                cursor: (0, 0),
+
                text: TextViewState::default().content(help_text()),
            },
        })
    }
}

pub enum Message {
-
    Exit {
-
        selection: Option<Selection>,
-
    },
-
    Select {
-
        selected: Option<usize>,
-
    },
+
    Exit { selection: Option<Selection> },
+
    Select { selected: Option<usize> },
    OpenSearch,
-
    UpdateSearch {
-
        value: String,
-
    },
+
    UpdateSearch { value: String },
    ApplySearch,
    CloseSearch,
    OpenHelp,
    LeavePage,
-
    ScrollHelp {
-
        scroll: usize,
-
        cursor: (usize, usize),
-
    },
+
    ScrollHelp { state: TextViewState },
}

impl store::State<Selection> for State {
@@ -274,9 +262,8 @@ impl store::State<Selection> for State {
                self.pages.pop();
                None
            }
-
            Message::ScrollHelp { scroll, cursor } => {
-
                self.help.scroll = scroll;
-
                self.help.cursor = cursor;
+
            Message::ScrollHelp { state } => {
+
                self.help.text = state;
                None
            }
        }
@@ -379,18 +366,13 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
        .content(
            TextView::default()
                .to_widget(tx.clone())
-
                .on_event(|_, view_state, _| {
-
                    view_state
-
                        .and_then(|tv| tv.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp {
-
                            scroll: tvs.scroll,
-
                            cursor: tvs.cursor,
-
                        })
+
                .on_event(|_, vs, _| {
+
                    vs.and_then(|vs| vs.unwrap_textview())
+
                        .map(|tvs| Message::ScrollHelp { state: tvs })
                })
                .on_update(|state: &State| {
                    TextViewProps::default()
-
                        .content(help_text())
-
                        .cursor(state.help.cursor)
+
                        .state(Some(state.help.text.clone()))
                        .to_boxed_any()
                        .into()
                }),
@@ -404,7 +386,7 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
                            [
                                Column::new(Text::raw(""), Constraint::Fill(1)),
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.scroll)).dim(),
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
                                    Constraint::Min(4),
                                ),
                            ]
@@ -435,90 +417,28 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
}

-
fn help_text() -> Text<'static> {
-
    Text::from(
-
        [
-
            Line::from(Span::raw("Generic keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor one line up").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor one line down").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor one page up").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor one page down").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Home")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor to the first line").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "End")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor to the last line").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::from(Span::raw("Specific keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Select notification (if --mode id)").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Show notification").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "c")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Clear notifications").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "/")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Search").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "?")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Show help").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Esc")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Quit / cancel").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::from(Span::raw("Searching").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
-
                Span::raw(": "),
-
                Span::raw("is:<state> | is:patch | is:issue | <search>")
-
                    .gray()
-
                    .dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Example")).gray(),
-
                Span::raw(": "),
-
                Span::raw("is:unseen is:patch Print").gray().dim(),
-
            ]),
-
        ]
-
        .to_vec(),
-
    )
+
fn help_text() -> String {
+
    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 notification (if --mode id)
+
`enter`:    Show notification
+
`c`:        Clear notifications
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:patch | is:issue | <search>
+
Example:    is:unseen is:patch Print"#
+
        .into()
}
modified bin/commands/inbox/select/ui.rs
@@ -181,7 +181,7 @@ impl View for Browser {
    type Message = Message;
    type State = State;

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Message> {
+
    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>())
modified bin/commands/issue/select.rs
@@ -10,7 +10,7 @@ use termion::event::Key;

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

use radicle::cob::thread::CommentId;
use radicle::git::Oid;
@@ -28,7 +28,7 @@ use tui::ui::widget::container::{
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
};
-
use tui::ui::widget::input::{TextView, TextViewProps};
+
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};
@@ -150,22 +150,6 @@ impl BrowserState {
}

#[derive(Clone, Debug)]
-
pub struct CommentState {
-
    /// Current text view cursor.
-
    cursor: (usize, usize),
-
}
-

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

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

-
#[derive(Clone, Debug)]
pub struct PreviewState {
    /// If preview is visible.
    show: bool,
@@ -174,7 +158,7 @@ pub struct PreviewState {
    /// Tree selection per issue.
    selected_comments: HashMap<IssueId, Vec<CommentId>>,
    /// State of currently selected comment
-
    comment: CommentState,
+
    comment: TextViewState,
}

impl PreviewState {
@@ -222,8 +206,7 @@ impl PreviewState {

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

#[derive(Clone, Debug)]
@@ -297,12 +280,11 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
                show: true,
                issue: items.first().cloned(),
                selected_comments,
-
                comment: CommentState { cursor: (0, 0) },
+
                comment: TextViewState::default(),
            },
            section: Some(Section::Browser),
            help: HelpState {
-
                scroll: 0,
-
                cursor: (0, 0),
+
                text: TextViewState::default().content(help_text()),
            },
            theme,
        })
@@ -311,35 +293,20 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {

pub enum Message {
    Quit,
-
    Exit {
-
        operation: Option<IssueOperation>,
-
    },
+
    Exit { operation: Option<IssueOperation> },
    ExitFromMode,
-
    SelectIssue {
-
        selected: Option<usize>,
-
    },
+
    SelectIssue { selected: Option<usize> },
    OpenSearch,
-
    UpdateSearch {
-
        value: String,
-
    },
+
    UpdateSearch { value: String },
    ApplySearch,
    CloseSearch,
    TogglePreview,
-
    FocusSection {
-
        section: Option<Section>,
-
    },
-
    SelectComment {
-
        selected: Option<Vec<CommentId>>,
-
    },
-
    ScrollComment {
-
        cursor: (usize, usize),
-
    },
+
    FocusSection { section: Option<Section> },
+
    SelectComment { selected: Option<Vec<CommentId>> },
+
    ScrollComment { state: TextViewState },
    OpenHelp,
    LeavePage,
-
    ScrollHelp {
-
        scroll: usize,
-
        cursor: (usize, usize),
-
    },
+
    ScrollHelp { state: TextViewState },
}

impl store::State<Selection> for State {
@@ -393,8 +360,8 @@ impl store::State<Selection> for State {
                self.preview.comment.reset_cursor();
                None
            }
-
            Message::ScrollComment { cursor } => {
-
                self.preview.comment.update_cursor(cursor);
+
            Message::ScrollComment { state } => {
+
                self.preview.comment = state;
                None
            }
            Message::OpenSearch => {
@@ -439,9 +406,8 @@ impl store::State<Selection> for State {
                self.pages.pop();
                None
            }
-
            Message::ScrollHelp { scroll, cursor } => {
-
                self.help.scroll = scroll;
-
                self.help.cursor = cursor;
+
            Message::ScrollHelp { state } => {
+
                self.help.text = state;
                None
            }
        }
@@ -637,10 +603,8 @@ fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
            TextView::default()
                .to_widget(tx.clone())
                .on_event(|_, vs, _| {
-
                    let textview = vs.and_then(|p| p.unwrap_textview()).unwrap_or_default();
-
                    Some(Message::ScrollComment {
-
                        cursor: textview.cursor,
-
                    })
+
                    let state = vs.and_then(|p| p.unwrap_textview()).unwrap_or_default();
+
                    Some(Message::ScrollComment { state })
                })
                .on_update(|state: &State| {
                    let comment = state.preview.selected_comment();
@@ -664,9 +628,8 @@ fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
                        .unwrap_or_default();

                    TextViewProps::default()
-
                        .content(body)
+
                        .state(Some(state.preview.comment.clone().content(body)))
                        .footer(Some(reactions))
-
                        .cursor(state.preview.comment.cursor)
                        .show_scroll_progress(true)
                        .dim(state.theme.dim_no_focus)
                        .to_boxed_any()
@@ -699,15 +662,11 @@ fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
                .on_event(|_, view_state, _| {
                    view_state
                        .and_then(|tv| tv.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp {
-
                            scroll: tvs.scroll,
-
                            cursor: tvs.cursor,
-
                        })
+
                        .map(|tvs| Message::ScrollHelp { state: tvs })
                })
                .on_update(|state: &State| {
                    TextViewProps::default()
-
                        .content(help_text())
-
                        .cursor(state.help.cursor)
+
                        .state(Some(state.help.text.clone()))
                        .dim(state.theme.dim_no_focus)
                        .to_boxed_any()
                        .into()
@@ -722,7 +681,7 @@ fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
                            [
                                Column::new(Text::raw(""), Constraint::Fill(1)),
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.scroll)).dim(),
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
                                    Constraint::Min(4),
                                ),
                            ]
@@ -760,158 +719,33 @@ fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
}

-
fn help_text() -> Text<'static> {
-
    Text::from(
-
        [
-
            Line::from(Span::raw("Generic keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("move cursor one line up").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("move cursor one line down").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("move cursor one page up").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("move cursor one page down").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Home")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("move cursor to the first line").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "End")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("move cursor to the last line").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::raw(""),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Tab")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("focus next section").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Backtab")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("focus previous section").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::raw(""),
-
            Line::from(Span::raw("Specific keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("Select issue (if --mode id)").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("Show issue").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "e")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("Edit issue").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "p")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("Toggle issue preview").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "/")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("Search").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "?")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("Show help").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Esc")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("Quit / cancel").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::raw(""),
-
            Line::from(Span::raw("Searching").cyan()),
-
            Line::raw(""),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>")
-
                        .gray()
-
                        .dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Example")).gray(),
-
                    Span::raw(": "),
-
                    Span::raw("is:solved is:authored alias").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
        ]
-
        .to_vec())
+
fn help_text() -> String {
+
    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
+
`Tab`:      focus next section
+
`BackTab`:  focus previous section
+
`Esc`:      Quit / cancel
+

+
# Specific keybindings
+

+
`Enter`:    Select issue (if --mode id)
+
`Enter`:    Show issue
+
`e`:        Edit issue
+
`p`:        Toggle issue preview
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>
+
Example:    is:solved is:authored alias"#
+
        .into()
}

fn append_opened(all: &mut HashSet<Vec<String>>, path: Vec<String>, comment: CommentItem) {
modified bin/commands/patch/select.rs
@@ -13,13 +13,13 @@ use radicle_tui as tui;

use ratatui::layout::Constraint;
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span, Text};
+
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};
+
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};

@@ -72,8 +72,7 @@ impl BrowserState {

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

#[derive(Clone, Debug)]
@@ -112,32 +111,22 @@ impl TryFrom<&Context> for State {
                show_search: false,
            },
            help: HelpState {
-
                scroll: 0,
-
                cursor: (0, 0),
+
                text: TextViewState::default().content(help_text()),
            },
        })
    }
}

pub enum Message {
-
    Exit {
-
        selection: Option<Selection>,
-
    },
-
    Select {
-
        selected: Option<usize>,
-
    },
+
    Exit { selection: Option<Selection> },
+
    Select { selected: Option<usize> },
    OpenSearch,
-
    UpdateSearch {
-
        value: String,
-
    },
+
    UpdateSearch { value: String },
    ApplySearch,
    CloseSearch,
    OpenHelp,
    LeavePage,
-
    ScrollHelp {
-
        scroll: usize,
-
        cursor: (usize, usize),
-
    },
+
    ScrollHelp { state: TextViewState },
}

impl store::State<Selection> for State {
@@ -181,16 +170,17 @@ impl store::State<Selection> for State {
                None
            }
            Message::OpenHelp => {
+
                log::warn!("OpenHelp");
                self.pages.push(AppPage::Help);
                None
            }
            Message::LeavePage => {
+
                log::warn!("LeavePage");
                self.pages.pop();
                None
            }
-
            Message::ScrollHelp { scroll, cursor } => {
-
                self.help.scroll = scroll;
-
                self.help.cursor = cursor;
+
            Message::ScrollHelp { state } => {
+
                self.help.text = state;
                None
            }
        }
@@ -297,15 +287,11 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
                .on_event(|_, view_state, _| {
                    view_state
                        .and_then(|tv| tv.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp {
-
                            scroll: tvs.scroll,
-
                            cursor: tvs.cursor,
-
                        })
+
                        .map(|tvs| Message::ScrollHelp { state: tvs })
                })
                .on_update(|state: &State| {
                    TextViewProps::default()
-
                        .content(help_text())
-
                        .cursor(state.help.cursor)
+
                        .state(Some(state.help.text.clone()))
                        .to_boxed_any()
                        .into()
                }),
@@ -319,7 +305,7 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
                            [
                                Column::new(Text::raw(""), Constraint::Fill(1)),
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.scroll)).dim(),
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
                                    Constraint::Min(4),
                                ),
                            ]
@@ -350,95 +336,29 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
}

-
fn help_text() -> Text<'static> {
-
    Text::from(
-
        [
-
            Line::from(Span::raw("Generic keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor one line up").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor one line down").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor one page up").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor one page down").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Home")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor to the first line").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "End")).gray(),
-
                Span::raw(": "),
-
                Span::raw("move cursor to the last line").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::from(Span::raw("Specific keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Select patch (if --mode id)").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Show patch").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "c")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Checkout patch").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "d")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Show patch diff").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "/")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Search").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "?")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Show help").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Esc")).gray(),
-
                Span::raw(": "),
-
                Span::raw("Quit / cancel").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::from(Span::raw("Searching").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
-
                Span::raw(": "),
-
                Span::raw("is:<state> | is:authored | authors:[<did>, <did>] | <search>")
-
                    .gray()
-
                    .dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Example")).gray(),
-
                Span::raw(": "),
-
                Span::raw("is:open is:authored improve").gray().dim(),
-
            ]),
-
        ]
-
        .to_vec(),
-
    )
+
fn help_text() -> String {
+
    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"#
+
        .into()
}
modified bin/commands/patch/select/ui.rs
@@ -219,7 +219,6 @@ impl View for Browser {
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
-
                Key::Char('?') => Some(Message::OpenHelp),
                Key::Char('/') => Some(Message::OpenSearch),
                Key::Char('\n') => {
                    let operation = match props.mode {
modified examples/basic.rs
@@ -8,7 +8,7 @@ use radicle_tui as tui;

use tui::store;
use tui::ui::widget::container::{Column, Container, Header, HeaderProps};
-
use tui::ui::widget::input::{TextView, TextViewProps};
+
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};
@@ -72,8 +72,9 @@ pub async fn main() -> Result<()> {
                }))
                .content(TextView::default().to_widget(sender.clone()).on_update(
                    |state: &State| {
+
                        let content = state.content.clone();
                        TextViewProps::default()
-
                            .content(state.content.clone())
+
                            .state(Some(TextViewState::default().content(content)))
                            .handle_keys(false)
                            .to_boxed_any()
                            .into()
modified src/ui/widget/input.rs
@@ -565,15 +565,39 @@ pub struct TextViewState {
    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> {
-
    /// Content of this text view.
-
    content: Text<'a>,
-
    /// Current cursor position. Default: `(0, 0)`.
-
    cursor: (usize, usize),
+
    /// 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`.
@@ -592,14 +616,6 @@ pub struct TextViewProps<'a> {
}

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

    pub fn footer<T>(mut self, footer: Option<T>) -> Self
    where
        T: Into<Text<'a>>,
@@ -608,8 +624,8 @@ impl<'a> TextViewProps<'a> {
        self
    }

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

@@ -649,8 +665,7 @@ impl<'a> Default for TextViewProps<'a> {
        let theme = Theme::default();

        Self {
-
            content: String::new().into(),
-
            cursor: (0, 0),
+
            state: None,
            handle_keys: true,
            show_scroll_progress: false,
            footer: None,
@@ -676,10 +691,7 @@ pub struct TextView<S, M> {
impl<S, M> Default for TextView<S, M> {
    fn default() -> Self {
        Self {
-
            state: TextViewState {
-
                scroll: 0,
-
                cursor: (0, 0),
-
            },
+
            state: TextViewState::default(),
            area: (0, 0),
            phantom: PhantomData,
        }
@@ -736,7 +748,7 @@ impl<S, M> TextView<S, M> {
            props.content_style
        };

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

@@ -761,7 +773,7 @@ impl<S, M> TextView<S, M> {

        let mut scroll = vec![];
        if props.show_scroll_progress {
-
            let content_len = props.content.lines.len();
+
            let content_len = self.state.content.lines().count();
            let scroll_progress = utils::scroll::percent_absolute(
                self.state.cursor.0,
                content_len,
@@ -801,14 +813,9 @@ where
            .and_then(|props| props.inner_ref::<TextViewProps>())
            .unwrap_or(&default);

-
        let len = props.content.lines.len();
-
        let max_line_len = props
-
            .content
-
            .lines
-
            .iter()
-
            .map(|l| l.width())
-
            .max()
-
            .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 {
@@ -843,7 +850,7 @@ where

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

@@ -856,8 +863,8 @@ where
            .and_then(|props| props.inner_ref::<TextViewProps>())
            .unwrap_or(&default);

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

@@ -884,8 +891,8 @@ where
            self.render_footer(frame, props, &render.area(footer_area), content_area.height);
            self.update_area(content_area);
        } else {
-
            self.render_content(frame, props, &render);
-
            self.update_area(render.area);
+
            self.render_content(frame, props, &render.clone().area(area));
+
            self.update_area(area);
        }
    }