diff --git a/Cargo.lock b/Cargo.lock index a9ca8bcd..b5e6ea47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index f3a8d84a..bd555857 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" \ No newline at end of file diff --git a/examples/basic.rs b/examples/basic.rs index e6f1d7b6..7fc53fb7 100644 --- a/examples/basic.rs +++ b/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::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() diff --git a/examples/hello.rs b/examples/hello.rs index 2f5741cc..0c17c457 100644 --- a/examples/hello.rs +++ b/examples/hello.rs @@ -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() }); diff --git a/src/ui/widget.rs b/src/ui/widget.rs index 94cc84e1..207387ee 100644 --- a/src/ui/widget.rs +++ b/src/ui/widget.rs @@ -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)] diff --git a/src/ui/widget/input.rs b/src/ui/widget/input.rs index 4e978391..90a686b0 100644 --- a/src/ui/widget/input.rs +++ b/src/ui/widget/input.rs @@ -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(mut self, content: T) -> Self + where + T: Into>, + { + 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 { + let default = TextAreaProps::default(); + let props = props + .and_then(|props| props.inner_ref::()) + .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::()) + .unwrap_or(&default); + + self.textarea = tui_textarea::TextArea::new( + props + .content + .lines + .iter() + .map(|line| line.to_string()) + .collect::>(), + ); + } + + 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::()) + .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 { + 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(), + }) + } +} diff --git a/src/ui/widget/text.rs b/src/ui/widget/text.rs deleted file mode 100644 index cf4bb543..00000000 --- a/src/ui/widget/text.rs +++ /dev/null @@ -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 { - /// Internal state - state: TextAreaState, - /// Phantom - phantom: PhantomData<(S, M)>, - /// Current render height - height: u16, -} - -impl Default for TextArea { - fn default() -> Self { - Self { - state: TextAreaState { - offset: 0, - progress: 0, - }, - phantom: PhantomData, - height: 1, - } - } -} - -impl TextArea { - 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 View for TextArea -where - S: 'static, - M: 'static, -{ - type Message = M; - type State = S; - - fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option { - let default = TextAreaProps::default(); - let props = props - .and_then(|props| props.inner_ref::()) - .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::()) - .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 { - Some(ViewState::USize(self.state.progress)) - } -}