Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Introduce text area and text view widget
Merged did:key:z6MkswQE...2C1V opened 1 year ago
10 files changed +383 -178 7ce183e9 087e7bff
modified CHANGELOG.md
@@ -11,6 +11,8 @@
- Per-column visibility for tables depending on their render width
- Vertically split container
- Predefined layouts for section groups
+
- `TextView`: Scrollable text viewer widget
+
- `TextArea`: Non-editable text area widget

### Changed

@@ -24,6 +26,7 @@
**Library features:**

- Widgets are not immutable anymore in their render function
+
- Ability to send messages through widgets
- All Radicle-dependent code (moved to `bin/`)
- Page size attribute from scrollable widgets
- Cutoff attributes from table properties
modified Cargo.lock
@@ -479,31 +479,6 @@ dependencies = [
]

[[package]]
-
name = "crossterm"
-
version = "0.27.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
-
dependencies = [
-
 "bitflags 2.4.1",
-
 "crossterm_winapi",
-
 "libc",
-
 "mio",
-
 "parking_lot",
-
 "signal-hook",
-
 "signal-hook-mio",
-
 "winapi",
-
]
-

-
[[package]]
-
name = "crossterm_winapi"
-
version = "0.9.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
-
dependencies = [
-
 "winapi",
-
]
-

-
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1193,7 +1168,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
dependencies = [
 "libc",
-
 "log",
 "wasi 0.11.0+wasi-snapshot-preview1",
 "windows-sys 0.48.0",
]
@@ -1905,7 +1879,6 @@ dependencies = [
 "bitflags 2.4.1",
 "cassowary",
 "compact_str",
-
 "crossterm",
 "indoc",
 "itertools",
 "lru",
@@ -2138,17 +2111,6 @@ dependencies = [
]

[[package]]
-
name = "signal-hook-mio"
-
version = "0.2.3"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
-
dependencies = [
-
 "libc",
-
 "mio",
-
 "signal-hook",
-
]
-

-
[[package]]
name = "signal-hook-registry"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2762,8 +2724,8 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e"
dependencies = [
-
 "crossterm",
 "ratatui",
+
 "termion 2.0.3",
 "unicode-width",
]

modified Cargo.toml
@@ -44,4 +44,4 @@ textwrap = { version = "0.16.0" }
thiserror = { version = "1" }
tokio = { version = "1.32.0", features = ["full"] }
tokio-stream = { version = "0.1.14" }
-
tui-textarea = "0.4.0"

\ No newline at end of file
+
tui-textarea = { version = "0.4.0", default-features = false, features = ["termion"] }

\ No newline at end of file
modified bin/commands/inbox/select.rs
@@ -26,7 +26,8 @@ use tui::store;
use tui::store::StateValue;
use tui::ui::span;
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::{TextArea, TextAreaProps};
+
use tui::ui::widget::input::TextView;
+
use tui::ui::widget::input::TextViewProps;
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
use tui::ui::widget::{ToWidget, Widget};
use tui::{BoxedAny, Channel, Exit, PageStack};
@@ -381,14 +382,18 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
                .into()
        }))
        .content(
-
            TextArea::default()
+
            TextView::default()
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    let (scroll, cursor) = s.and_then(|p| p.unwrap_textarea()).unwrap_or_default();
-
                    Some(Message::ScrollHelp { scroll, cursor })
+
                .on_event(|_, view_state, _| {
+
                    view_state
+
                        .and_then(|tv| tv.unwrap_textview())
+
                        .map(|tvs| Message::ScrollHelp {
+
                            scroll: tvs.scroll,
+
                            cursor: tvs.cursor,
+
                        })
                })
                .on_update(|state: &State| {
-
                    TextAreaProps::default()
+
                    TextViewProps::default()
                        .content(help_text())
                        .cursor(state.help.cursor)
                        .to_boxed_any()
modified bin/commands/issue/select.rs
@@ -21,7 +21,7 @@ use tui::store;
use tui::store::StateValue;
use tui::ui::span;
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::{TextArea, TextAreaProps};
+
use tui::ui::widget::input::{TextView, TextViewProps};
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
use tui::ui::widget::{ToWidget, Widget};

@@ -300,16 +300,22 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
                .into()
        }))
        .content(
-
            TextArea::default()
+
            TextView::default()
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    let (scroll, cursor) = s.and_then(|p| p.unwrap_textarea()).unwrap_or_default();
-
                    Some(Message::ScrollHelp { scroll, cursor })
+
                .on_event(|_, view_state, _| {
+
                    view_state
+
                        .and_then(|tv| tv.unwrap_textview())
+
                        .map(|tvs| Message::ScrollHelp {
+
                            scroll: tvs.scroll,
+
                            cursor: tvs.cursor,
+
                        })
                })
                .on_update(|state: &State| {
-
                    TextAreaProps::default()
+
                    TextViewProps::default()
                        .content(help_text())
                        .cursor(state.help.cursor)
+
                        .show_scroll_progress(true)
+
                        .show_column_progress(true)
                        .to_boxed_any()
                        .into()
                }),
modified bin/commands/patch/select.rs
@@ -300,9 +300,13 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
        .content(
            TextArea::default()
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    let (scroll, cursor) = s.and_then(|p| p.unwrap_textarea()).unwrap_or_default();
-
                    Some(Message::ScrollHelp { scroll, cursor })
+
                .on_event(|_, view_state, _| {
+
                    view_state
+
                        .and_then(|tv| tv.unwrap_textarea())
+
                        .map(|tas| Message::ScrollHelp {
+
                            scroll: tas.scroll,
+
                            cursor: tas.cursor,
+
                        })
                })
                .on_update(|state: &State| {
                    TextAreaProps::default()
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::{TextArea, TextAreaProps};
+
use tui::ui::widget::input::{TextView, TextViewProps};
use tui::ui::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
use tui::ui::widget::ToWidget;
use tui::{BoxedAny, Channel, Exit};
@@ -70,11 +70,11 @@ pub async fn main() -> Result<()> {
                        .to_boxed_any()
                        .into()
                }))
-
                .content(TextArea::default().to_widget(sender.clone()).on_update(
+
                .content(TextView::default().to_widget(sender.clone()).on_update(
                    |state: &State| {
-
                        TextAreaProps::default()
+
                        TextViewProps::default()
                            .content(state.content.clone())
-
                            .can_scroll(false)
+
                            .handle_keys(false)
                            .to_boxed_any()
                            .into()
                    },
modified examples/hello.rs
@@ -63,7 +63,7 @@ pub async fn main() -> Result<()> {
        .on_update(|state: &State| {
            TextAreaProps::default()
                .content(Text::styled(state.alien.clone(), Color::Rgb(85, 85, 255)))
-
                .can_scroll(false)
+
                .handle_keys(false)
                .to_boxed_any()
                .into()
        });
modified src/ui/widget.rs
@@ -13,6 +13,8 @@ use termion::event::Key;

use ratatui::prelude::*;

+
use self::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>;
@@ -62,14 +64,9 @@ impl From<&'static dyn Any> for ViewProps {
pub enum ViewState {
    USize(usize),
    String(String),
-
    Table {
-
        selected: usize,
-
        scroll: usize,
-
    },
-
    TextArea {
-
        scroll: usize,
-
        cursor: (usize, usize),
-
    },
+
    Table { selected: usize, scroll: usize },
+
    TextView(TextViewState),
+
    TextArea(TextAreaState),
}

impl ViewState {
@@ -94,9 +91,16 @@ impl ViewState {
        }
    }

-
    pub fn unwrap_textarea(&self) -> Option<(usize, (usize, usize))> {
+
    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 { scroll, cursor } => Some((*scroll, *cursor)),
+
            ViewState::TextArea(state) => Some(state.clone()),
            _ => None,
        }
    }
@@ -299,11 +303,6 @@ impl<S: 'static, M: 'static> Widget<S, M> {
        self.on_render = Some(callback);
        self
    }
-

-
    /// Sends a message to the widgets' message channel.
-
    pub fn send(&self, message: M) {
-
        let _ = self.sender.send(message);
-
    }
}

/// A `View` needs to be wrapped into a `Widget` in order to be used with the framework.
modified src/ui/widget/input.rs
@@ -282,35 +282,30 @@ where
    }
}

-
/// Configuration of a `TextArea`'s internal progress display.
-
#[derive(Default, Clone)]
-
pub struct TextAreaProgressInfo {
-
    scroll: bool,
-
    cursor: bool,
-
}
-

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

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

-
    pub fn is_rendered(&self) -> bool {
-
        self.scroll || self.cursor
-
    }
+
/// The state of a `TextArea`.
+
#[derive(Clone, Default)]
+
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),
-
    can_scroll: bool,
-
    progress_info: TextAreaProgressInfo,
+
    /// 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,
}

impl<'a> Default for TextAreaProps<'a> {
@@ -318,8 +313,10 @@ impl<'a> Default for TextAreaProps<'a> {
        Self {
            content: String::new().into(),
            cursor: (0, 0),
-
            can_scroll: true,
-
            progress_info: TextAreaProgressInfo::default(),
+
            handle_keys: true,
+
            insert_mode: false,
+
            show_scroll_progress: false,
+
            show_column_progress: false,
        }
    }
}
@@ -338,21 +335,28 @@ impl<'a> TextAreaProps<'a> {
        self
    }

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

-
    pub fn can_scroll(mut self, can_scroll: bool) -> Self {
-
        self.can_scroll = can_scroll;
+
    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
    }
}

+
/// 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>,
-
    height: u16,
+
    area: (u16, u16),
}

impl<'a, S, M> Default for TextArea<'a, S, M> {
@@ -360,7 +364,7 @@ impl<'a, S, M> Default for TextArea<'a, S, M> {
        Self {
            phantom: PhantomData,
            textarea: tui_textarea::TextArea::default(),
-
            height: 0,
+
            area: (0, 0),
        }
    }
}
@@ -370,38 +374,44 @@ impl<'a, S, M> View for TextArea<'a, S, M> {
    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.can_scroll {
-
            match key {
-
                Key::Left => {
-
                    self.textarea.input(tui_textarea::Input {
-
                        key: tui_textarea::Key::Left,
-
                        ..Default::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()
+
                        });
+
                    }
+
                    _ => {}
                }
-
                Key::Right => {
-
                    self.textarea.input(tui_textarea::Input {
-
                        key: tui_textarea::Key::Right,
-
                        ..Default::default()
-
                    });
-
                }
-
                Key::Up => {
-
                    self.textarea.input(tui_textarea::Input {
-
                        key: tui_textarea::Key::Up,
-
                        ..Default::default()
-
                    });
-
                }
-
                Key::Down => {
-
                    self.textarea.input(tui_textarea::Input {
-
                        key: tui_textarea::Key::Down,
-
                        ..Default::default()
-
                    });
-
                }
-
                _ => {}
+
            } else {
+
                // TODO: Implement insert mode.
            }
        }

@@ -435,22 +445,27 @@ impl<'a, S, M> View for TextArea<'a, S, M> {
            .horizontal_margin(1)
            .areas(render.area);

-
        let cursor_line_style = Style::default();
+
        let progress_height = if props.show_scroll_progress || props.show_column_progress {
+
            1
+
        } else {
+
            0
+
        };

+
        let [content_area, progress_area] =
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(progress_height)]).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 {
            Style::default()
        } else {
            Style::default().dim()
        };

-
        self.height = render.area.height;
-

        self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
            props.cursor.0 as u16,
            props.cursor.1 as u16,
@@ -459,58 +474,269 @@ impl<'a, S, M> View for TextArea<'a, S, M> {
        self.textarea.set_cursor_style(cursor_style);
        self.textarea.set_style(content_style);

-
        if props.progress_info.is_rendered() {
-
            let [content_area, progress_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);
-

-
            let mut progress_info = vec![];
-

-
            if props.progress_info.scroll {
-
                progress_info.push(Span::styled(
-
                    format!(
-
                        "{}%",
-
                        utils::scroll::percent_absolute(
-
                            self.textarea.cursor().0,
-
                            self.textarea.lines().len(),
-
                            content_area.height.into()
-
                        )
-
                    ),
-
                    Style::default().dim(),
-
                ))
-
            }
+
        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),
+
        );

-
            if props.progress_info.scroll && props.progress_info.cursor {
-
                progress_info.push(Span::raw(" "));
-
            }
+
        frame.render_widget(self.textarea.widget(), content_area);

-
            if props.progress_info.cursor {
-
                progress_info.push(Span::styled(
-
                    format!(
-
                        "[{},{}]",
-
                        self.textarea.cursor().0,
-
                        self.textarea.cursor().1
-
                    ),
-
                    Style::default().dim(),
-
                ))
-
            }
+
        let mut progress_info = vec![];

-
            let line = Line::from(progress_info).alignment(Alignment::Right);
+
        if props.show_scroll_progress {
+
            progress_info.push(Span::styled(
+
                format!("{}%", scroll_progress),
+
                Style::default().dim(),
+
            ))
+
        }

-
            frame.render_widget(self.textarea.widget(), content_area);
-
            frame.render_widget(line, progress_area);
-
        } else {
-
            frame.render_widget(self.textarea.widget(), area);
+
        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 {
+
        Some(ViewState::TextArea(TextAreaState {
+
            cursor: self.textarea.cursor(),
            scroll: utils::scroll::percent_absolute(
-
                self.textarea.cursor().0.saturating_sub(self.height.into()),
+
                self.textarea.cursor().0.saturating_sub(self.area.0.into()),
                self.textarea.lines().len(),
-
                self.height.into(),
+
                self.area.0.into(),
            ),
-
            cursor: self.textarea.cursor(),
-
        })
+
        }))
+
    }
+
}
+

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

+
/// 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),
+
    /// 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,
+
    /// If this widget should render its cursor progress. Default: `false`.
+
    show_column_progress: bool,
+
}
+

+
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 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
+
    }
+
}
+

+
impl<'a> Default for TextViewProps<'a> {
+
    fn default() -> Self {
+
        Self {
+
            content: String::new().into(),
+
            cursor: (0, 0),
+
            handle_keys: true,
+
            show_scroll_progress: false,
+
            show_column_progress: 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 {
+
                scroll: 0,
+
                cursor: (0, 0),
+
            },
+
            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);
+
    }
+
}
+

+
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 len = props.content.lines.len();
+
        let max_line_len = props
+
            .content
+
            .lines
+
            .iter()
+
            .map(|l| l.width())
+
            .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,
+
            props.content.lines.len(),
+
            self.area.0.into(),
+
        );
+

+
        None
+
    }
+

+
    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 [content_area] = Layout::horizontal([Constraint::Min(1)])
+
            .horizontal_margin(1)
+
            .areas(render.area);
+
        let content = ratatui::widgets::Paragraph::new(props.content.clone())
+
            .style(props.content.style)
+
            .scroll((self.state.cursor.0 as u16, self.state.cursor.1 as u16));
+

+
        frame.render_widget(content, content_area);
+

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

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