lib: Introduce tui-textarea

This commit is contained in:
Erik Kundt
2024-06-20 14:42:39 +02:00
parent 6484ede291
commit cbb33f68c1
7 changed files with 310 additions and 200 deletions
Generated
+52 -2
View File
@@ -478,6 +478,31 @@ dependencies = [
"cfg-if",
]
[[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"
@@ -1168,6 +1193,7 @@ 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",
]
@@ -1838,6 +1864,7 @@ dependencies = [
"timeago",
"tokio",
"tokio-stream",
"tui-textarea",
]
[[package]]
@@ -1878,6 +1905,7 @@ dependencies = [
"bitflags 2.4.1",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"itertools",
"lru",
@@ -2109,6 +2137,17 @@ dependencies = [
"signal-hook-registry",
]
[[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"
@@ -2717,6 +2756,17 @@ dependencies = [
"tree-sitter",
]
[[package]]
name = "tui-textarea"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e"
dependencies = [
"crossterm",
"ratatui",
"unicode-width",
]
[[package]]
name = "typenum"
version = "1.17.0"
@@ -2767,9 +2817,9 @@ checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
[[package]]
name = "unicode-width"
version = "0.1.10"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
[[package]]
name = "universal-hash"
+1
View File
@@ -44,3 +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"
+2 -2
View File
@@ -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::text::{TextArea, TextAreaProps};
use tui::ui::widget::input::{TextArea, TextAreaProps};
use tui::ui::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
use tui::ui::widget::ToWidget;
use tui::{BoxedAny, Channel, Exit};
@@ -73,7 +73,7 @@ pub async fn main() -> Result<()> {
.content(TextArea::default().to_widget(sender.clone()).on_update(
|state: &State| {
TextAreaProps::default()
.text(&state.content.clone().into())
.content(state.content.clone())
.can_scroll(false)
.to_boxed_any()
.into()
+3 -2
View File
@@ -8,7 +8,7 @@ use ratatui::text::Text;
use radicle_tui as tui;
use tui::store;
use tui::ui::widget::text::{TextArea, TextAreaProps};
use tui::ui::widget::input::{TextArea, TextAreaProps};
use tui::ui::widget::ToWidget;
use tui::{BoxedAny, Channel, Exit};
@@ -62,7 +62,8 @@ pub async fn main() -> Result<()> {
})
.on_update(|state: &State| {
TextAreaProps::default()
.text(&Text::styled(state.alien.clone(), Color::Rgb(85, 85, 255)))
.content(Text::styled(state.alien.clone(), Color::Rgb(85, 85, 255)))
.can_scroll(false)
.to_boxed_any()
.into()
});
+15 -2
View File
@@ -1,7 +1,6 @@
pub mod container;
pub mod input;
pub mod list;
pub mod text;
pub mod utils;
pub mod window;
@@ -63,7 +62,14 @@ impl From<&'static dyn Any> for ViewProps {
pub enum ViewState {
USize(usize),
String(String),
Table { selected: usize, scroll: usize },
Table {
selected: usize,
scroll: usize,
},
TextArea {
scroll: usize,
cursor: (usize, usize),
},
}
impl ViewState {
@@ -87,6 +93,13 @@ impl ViewState {
_ => None,
}
}
pub fn unwrap_textarea(&self) -> Option<(usize, (usize, usize))> {
match self {
ViewState::TextArea { scroll, cursor } => Some((*scroll, *cursor)),
_ => None,
}
}
}
#[derive(Clone, Default)]
+237 -4
View File
@@ -3,11 +3,11 @@ use std::marker::PhantomData;
use ratatui::Frame;
use termion::event::Key;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span};
use ratatui::layout::{Alignment, Constraint, Layout};
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span, Text};
use super::{RenderProps, View, ViewProps, ViewState};
use super::{utils, RenderProps, View, ViewProps, ViewState};
#[derive(Clone)]
pub struct TextFieldProps {
@@ -281,3 +281,236 @@ 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
}
}
#[derive(Clone)]
pub struct TextAreaProps<'a> {
content: Text<'a>,
cursor: (usize, usize),
can_scroll: bool,
progress_info: TextAreaProgressInfo,
}
impl<'a> Default for TextAreaProps<'a> {
fn default() -> Self {
Self {
content: String::new().into(),
cursor: (0, 0),
can_scroll: true,
progress_info: TextAreaProgressInfo::default(),
}
}
}
impl<'a> TextAreaProps<'a> {
pub fn content<T>(mut self, content: T) -> Self
where
T: Into<Text<'a>>,
{
self.content = content.into();
self
}
pub fn cursor(mut self, cursor: (usize, usize)) -> Self {
self.cursor = cursor;
self
}
pub fn progress_info(mut self, progress_info: TextAreaProgressInfo) -> Self {
self.progress_info = progress_info;
self
}
pub fn can_scroll(mut self, can_scroll: bool) -> Self {
self.can_scroll = can_scroll;
self
}
}
pub struct TextArea<'a, S, M> {
phantom: PhantomData<(S, M)>,
textarea: tui_textarea::TextArea<'a>,
height: u16,
}
impl<'a, S, M> Default for TextArea<'a, S, M> {
fn default() -> Self {
Self {
phantom: PhantomData,
textarea: tui_textarea::TextArea::default(),
height: 0,
}
}
}
impl<'a, S, M> View for TextArea<'a, S, M> {
type State = S;
type Message = M;
fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
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()
});
}
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()
});
}
_ => {}
}
}
None
}
fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
let default = TextAreaProps::default();
let props = props
.and_then(|props| props.inner_ref::<TextAreaProps>())
.unwrap_or(&default);
self.textarea = tui_textarea::TextArea::new(
props
.content
.lines
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>(),
);
}
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
let default = TextAreaProps::default();
let props = props
.and_then(|props| props.inner_ref::<TextAreaProps>())
.unwrap_or(&default);
let [area] = Layout::default()
.constraints([Constraint::Min(1)])
.horizontal_margin(1)
.areas(render.area);
let 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,
));
self.textarea.set_cursor_line_style(cursor_line_style);
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(),
))
}
if props.progress_info.scroll && props.progress_info.cursor {
progress_info.push(Span::raw(" "));
}
if props.progress_info.cursor {
progress_info.push(Span::styled(
format!(
"[{},{}]",
self.textarea.cursor().0,
self.textarea.cursor().1
),
Style::default().dim(),
))
}
let line = Line::from(progress_info).alignment(Alignment::Right);
frame.render_widget(self.textarea.widget(), content_area);
frame.render_widget(line, progress_area);
} else {
frame.render_widget(self.textarea.widget(), area);
}
}
fn view_state(&self) -> Option<ViewState> {
Some(ViewState::TextArea {
scroll: utils::scroll::percent_absolute(
self.textarea.cursor().0.saturating_sub(self.height.into()),
self.textarea.lines().len(),
self.height.into(),
),
cursor: self.textarea.cursor(),
})
}
}
-188
View File
@@ -1,188 +0,0 @@
use std::marker::PhantomData;
use termion::event::Key;
use ratatui::layout::{Constraint, Layout};
use ratatui::text::Text;
use ratatui::Frame;
use super::utils;
use super::{RenderProps, View, ViewProps, ViewState};
#[derive(Clone)]
pub struct TextAreaProps<'a> {
pub content: Text<'a>,
pub has_header: bool,
pub has_footer: bool,
pub progress: usize,
pub can_scroll: bool,
}
impl<'a> TextAreaProps<'a> {
pub fn text(mut self, text: &Text<'a>) -> Self {
self.content = text.clone();
self
}
pub fn can_scroll(mut self, can_scroll: bool) -> Self {
self.can_scroll = can_scroll;
self
}
}
impl<'a> Default for TextAreaProps<'a> {
fn default() -> Self {
Self {
content: Text::raw(""),
has_header: false,
has_footer: false,
progress: 0,
can_scroll: true,
}
}
}
#[derive(Clone)]
struct TextAreaState {
/// Internal offset
pub offset: usize,
/// Internal progress
pub progress: usize,
}
pub struct TextArea<S, M> {
/// Internal state
state: TextAreaState,
/// Phantom
phantom: PhantomData<(S, M)>,
/// Current render height
height: u16,
}
impl<S, M> Default for TextArea<S, M> {
fn default() -> Self {
Self {
state: TextAreaState {
offset: 0,
progress: 0,
},
phantom: PhantomData,
height: 1,
}
}
}
impl<S, M> TextArea<S, M> {
fn scroll(&self) -> (u16, u16) {
(self.state.offset as u16, 0)
}
fn prev(&mut self, len: usize, page_size: usize) -> (u16, u16) {
self.state.offset = self.state.offset.saturating_sub(1);
self.state.progress = utils::scroll::percent_absolute(self.state.offset, len, page_size);
self.scroll()
}
fn next(&mut self, len: usize, page_size: usize) -> (u16, u16) {
if self.state.progress < 100 {
self.state.offset = self.state.offset.saturating_add(1);
self.state.progress =
utils::scroll::percent_absolute(self.state.offset, len, page_size);
}
self.scroll()
}
fn prev_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
self.state.offset = self.state.offset.saturating_sub(page_size);
self.state.progress = utils::scroll::percent_absolute(self.state.offset, len, page_size);
self.scroll()
}
fn next_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
let end = len.saturating_sub(page_size);
self.state.offset = std::cmp::min(self.state.offset.saturating_add(page_size), end);
self.state.progress = utils::scroll::percent_absolute(self.state.offset, len, page_size);
self.scroll()
}
fn begin(&mut self, len: usize, page_size: usize) -> (u16, u16) {
self.state.offset = 0;
self.state.progress = utils::scroll::percent_absolute(self.state.offset, len, page_size);
self.scroll()
}
fn end(&mut self, len: usize, page_size: usize) -> (u16, u16) {
self.state.offset = len.saturating_sub(page_size);
self.state.progress = utils::scroll::percent_absolute(self.state.offset, len, page_size);
self.scroll()
}
}
impl<S, M> View for TextArea<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 = TextAreaProps::default();
let props = props
.and_then(|props| props.inner_ref::<TextAreaProps>())
.unwrap_or(&default);
let len = props.content.lines.len() + 1;
let page_size = self.height as usize;
if props.can_scroll {
match key {
Key::Up | Key::Char('k') => {
self.prev(len, page_size);
}
Key::Down | Key::Char('j') => {
self.next(len, page_size);
}
Key::PageUp => {
self.prev_page(len, page_size);
}
Key::PageDown => {
self.next_page(len, page_size);
}
Key::Home => {
self.begin(len, page_size);
}
Key::End => {
self.end(len, page_size);
}
_ => {}
}
}
None
}
fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
self.height = render.area.height;
let default = TextAreaProps::default();
let props = props
.and_then(|props| props.inner_ref::<TextAreaProps>())
.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.offset as u16, 0));
frame.render_widget(content, content_area);
}
fn view_state(&self) -> Option<ViewState> {
Some(ViewState::USize(self.state.progress))
}
}