diff --git a/README.md b/README.md index 357fd1a..2a722dd 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ text editor can be easily put as part of your TUI application. - Line number - Cursor line highlight - Search with regular expressions +- Mouse scrolling - Yank support. Paste text deleted with `C-k`, `C-j`, ... - Backend agnostic. [crossterm][], [termion][], and your own backend are all supported - Multiple textarea widgets in the same screen @@ -410,9 +411,11 @@ notify how to move the cursor. | `textarea.move_cursor(CursorMove::Top)` | Move cursor to top of lines | | `textarea.move_cursor(CursorMove::Bottom)` | Move cursor to bottom of lines | | `textarea.move_cursor(CursorMove::Jump(row, col))` | Move cursor to (row, col) position | +| `textarea.move_cursor(CursorMove::InViewport)` | Move cursor to stay in the viewport | | `textarea.set_search_pattern(pattern)` | Set a pattern for text search | | `textarea.search_forward(match_cursor)` | Move cursor to next match of text search | | `textarea.search_back(match_cursor)` | Move cursor to previous match of text search | +| `textarea.scroll(rows, cols)` | Scroll the viewport | To define your own key mappings, simply call the above methods in your code instead of `TextArea::input()` method. The following example defines modal key mappings like Vim. @@ -465,6 +468,8 @@ loop { Input { key: Key::Esc, .. } => textarea.set_search_pattern("").unwrap(), Input { key: Key::Char('n'), .. } => textarea.search_forward(), Input { key: Key::Char('N'), .. } => textarea.search_back(), + Input { key: Key::Char('e'), ctrl: true .. } => textarea.scroll(1, 0), + Input { key: Key::Char('y'), ctrl: true .. } => textarea.scroll(-1, 0), _ => {}, }, Mode::Insert => match read()?.into() { diff --git a/examples/termion.rs b/examples/termion.rs index 9a1ebea..3d7e2b4 100644 --- a/examples/termion.rs +++ b/examples/termion.rs @@ -3,17 +3,17 @@ use std::io; use std::sync::mpsc; use std::thread; use std::time::Duration; -use termion::event::Key; +use termion::event::Event as TermEvent; use termion::input::{MouseTerminal, TermRead}; use termion::raw::IntoRawMode; use termion::screen::AlternateScreen; use tui::backend::TermionBackend; use tui::widgets::{Block, Borders}; use tui::Terminal; -use tui_textarea::TextArea; +use tui_textarea::{Input, Key, TextArea}; enum Event { - Key(Key), + Term(TermEvent), Tick, } @@ -25,12 +25,12 @@ fn main() -> Result<(), Box> { let mut term = Terminal::new(backend)?; let events = { - let keys = io::stdin().keys(); + let events = io::stdin().events(); let (tx, rx) = mpsc::channel(); let keys_tx = tx.clone(); thread::spawn(move || { - for key in keys.flatten() { - keys_tx.send(Event::Key(key)).unwrap(); + for event in events.flatten() { + keys_tx.send(Event::Term(event)).unwrap(); } }); thread::spawn(move || loop { @@ -49,10 +49,12 @@ fn main() -> Result<(), Box> { loop { match events.recv()? { - Event::Key(Key::Esc) => break, - Event::Key(key) => { - textarea.input(key); - } + Event::Term(event) => match event.into() { + Input { key: Key::Esc, .. } => break, + input => { + textarea.input(input); + } + }, Event::Tick => {} } term.draw(|f| { diff --git a/src/cursor.rs b/src/cursor.rs index 260a949..6c52530 100644 --- a/src/cursor.rs +++ b/src/cursor.rs @@ -1,3 +1,4 @@ +use crate::widget::Viewport; use crate::word::{find_word_start_backward, find_word_start_forward}; #[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; @@ -187,6 +188,38 @@ pub enum CursorMove { /// assert_eq!(textarea.cursor(), (2, 4)); /// ``` Jump(u16, u16), + /// Move cursor to keep it within the viewport. For example, when a viewport displays line 8 to line 16: + /// + /// - cursor at line 4 is moved to line 8 + /// - cursor at line 20 is moved to line 16 + /// - cursor at line 12 is not moved + /// + /// This is useful when you moved a cursor but you don't want to move the viewport. + /// ``` + /// # use tui::buffer::Buffer; + /// # use tui::layout::Rect; + /// # use tui::widgets::Widget; + /// use tui_textarea::{TextArea, CursorMove}; + /// + /// // Let's say terminal height is 8. + /// + /// // Create textarea with 20 lines "0", "1", "2", "3", ... + /// // The viewport is displaying from line 1 to line 8. + /// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect(); + /// # // Call `render` at least once to populate terminal size + /// # let r = Rect { x: 0, y: 0, width: 24, height: 8 }; + /// # let mut b = Buffer::empty(r.clone()); + /// # textarea.widget().render(r, &mut b); + /// + /// // Move cursor to the end of lines (line 20). It is outside the viewport (line 1 to line 8) + /// textarea.move_cursor(CursorMove::Bottom); + /// assert_eq!(textarea.cursor(), (19, 0)); + /// + /// // Cursor is moved to line 8 to enter the viewport + /// textarea.move_cursor(CursorMove::InViewport); + /// assert_eq!(textarea.cursor(), (7, 0)); + /// ``` + InViewport, } impl CursorMove { @@ -194,6 +227,7 @@ impl CursorMove { &self, (row, col): (usize, usize), lines: &[String], + viewport: &Viewport, ) -> Option<(usize, usize)> { use CursorMove::*; @@ -271,6 +305,16 @@ impl CursorMove { let col = fit_col(*col as usize, &lines[row]); Some((row, col)) } + InViewport => { + let (row_top, col_top, row_bottom, col_bottom) = viewport.position(); + + let row = row.clamp(row_top as usize, row_bottom as usize); + let row = cmp::min(row, lines.len() - 1); + let col = col.clamp(col_top as usize, col_bottom as usize); + let col = fit_col(col, &lines[row]); + + Some((row, col)) + } } } } diff --git a/src/input.rs b/src/input.rs index 60b9c7b..5eccc21 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,9 +1,12 @@ #[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; #[cfg(feature = "crossterm")] -use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{ + Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent as CrosstermMouseEvent, + MouseEventKind as CrosstermMouseEventKind, +}; #[cfg(feature = "termion")] -use termion::event::{Event as TerimonEvent, Key as TermionKey}; +use termion::event::{Event as TermionEvent, Key as TermionKey, MouseEvent as TermionMouseEvent}; /// Backend-agnostic key input kind. #[non_exhaustive] @@ -27,6 +30,10 @@ pub enum Key { PageUp, PageDown, Esc, + /// Virtual key to scroll down by mouse + MouseScrollDown, + /// Virtual key to scroll up by mouse + MouseScrollUp, /// An invalid key input (this key is always ignored by [`TextArea`](crate::TextArea)). Null, } @@ -90,10 +97,10 @@ impl Default for Input { impl From for Input { /// Convert [`crossterm::event::Event`] to [`Input`]. fn from(event: CrosstermEvent) -> Self { - if let CrosstermEvent::Key(key) = event { - Self::from(key) - } else { - Self::default() + match event { + CrosstermEvent::Key(key) => Self::from(key), + CrosstermEvent::Mouse(mouse) => Self::from(mouse), + _ => Self::default(), } } } @@ -126,14 +133,29 @@ impl From for Input { } } +#[cfg(feature = "crossterm")] +impl From for Input { + /// Convert [`crossterm::event::MouseEvent`] to [`Input`]. + fn from(mouse: CrosstermMouseEvent) -> Self { + let key = match mouse.kind { + CrosstermMouseEventKind::ScrollDown => Key::MouseScrollDown, + CrosstermMouseEventKind::ScrollUp => Key::MouseScrollUp, + _ => return Self::default(), + }; + let ctrl = mouse.modifiers.contains(KeyModifiers::CONTROL); + let alt = mouse.modifiers.contains(KeyModifiers::ALT); + Self { key, ctrl, alt } + } +} + #[cfg(feature = "termion")] -impl From for Input { +impl From for Input { /// Convert [`termion::event::Event`] to [`Input`]. - fn from(event: TerimonEvent) -> Self { - if let TerimonEvent::Key(key) = event { - Self::from(key) - } else { - Self::default() + fn from(event: TermionEvent) -> Self { + match event { + TermionEvent::Key(key) => Self::from(key), + TermionEvent::Mouse(mouse) => Self::from(mouse), + _ => Self::default(), } } } @@ -176,3 +198,21 @@ impl From for Input { Input { key, ctrl, alt } } } + +#[cfg(feature = "termion")] +impl From for Input { + /// Convert [`termion::event::MouseEvent`] to [`Input`]. + fn from(mouse: TermionMouseEvent) -> Self { + use termion::event::MouseButton; + let key = match mouse { + TermionMouseEvent::Press(MouseButton::WheelUp, ..) => Key::MouseScrollUp, + TermionMouseEvent::Press(MouseButton::WheelDown, ..) => Key::MouseScrollDown, + _ => return Self::default(), + }; + Self { + key, + ctrl: false, + alt: false, + } + } +} diff --git a/src/textarea.rs b/src/textarea.rs index 91a3801..0152301 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -5,7 +5,7 @@ use crate::input::{Input, Key}; #[cfg(feature = "search")] use crate::search::Search; use crate::util::spaces; -use crate::widget::{Renderer, ScrollTop}; +use crate::widget::{Renderer, Viewport}; use crate::word::{find_word_end_forward, find_word_start_backward}; use tui::style::{Modifier, Style}; use tui::text::Spans; @@ -42,7 +42,7 @@ pub struct TextArea<'a> { history: History, cursor_line_style: Style, line_number_style: Option