├── .gitignore ├── .rustfmt.toml ├── fuzz ├── .gitignore ├── Cargo.toml └── fuzz_targets │ ├── insert_delete.rs │ └── edit.rs ├── .typos.toml ├── .codecov.yaml ├── tests ├── history.rs ├── serde.rs ├── input.rs ├── search.rs └── cursor.rs ├── .cargo └── config.toml ├── bench ├── Cargo.toml ├── README.md ├── benches │ ├── delete.rs │ ├── search.rs │ ├── insert.rs │ └── cursor.rs └── src │ └── lib.rs ├── src ├── util.rs ├── lib.rs ├── word.rs ├── input │ ├── mod.rs │ ├── crossterm.rs │ ├── termion.rs │ └── termwiz.rs ├── search.rs ├── widget.rs ├── scroll.rs ├── cursor.rs └── history.rs ├── LICENSE.txt ├── examples ├── minimal.rs ├── tuirs_minimal.rs ├── termwiz.rs ├── popup_placeholder.rs ├── variable.rs ├── termion.rs ├── password.rs ├── tuirs_termion.rs ├── single_line.rs ├── split.rs ├── editor.rs ├── tuirs_editor.rs └── vim.rs ├── CONTRIBUTING.md ├── Cargo.toml └── .github └── workflows └── ci.yml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | corpus 3 | artifacts 4 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | ratatui = "ratatui" 3 | -------------------------------------------------------------------------------- /.codecov.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.codecov.com/docs/commit-status#disabling-a-status 2 | coverage: 3 | status: 4 | project: off 5 | patch: off 6 | 7 | comment: false 8 | -------------------------------------------------------------------------------- /tests/history.rs: -------------------------------------------------------------------------------- 1 | use tui_textarea::TextArea; 2 | 3 | // Regression test for #4 4 | #[test] 5 | fn disable_history() { 6 | let mut t = TextArea::default(); 7 | t.set_max_histories(0); 8 | assert!(t.insert_str("hello")); 9 | assert_eq!(t.lines(), ["hello"]); 10 | } 11 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | watch-check = ["watch", "--why", "-x", "clippy --features=search,termwiz --examples --tests", "-x", "clippy --features tuirs-crossterm,search --no-default-features --examples --tests"] 3 | watch-test = ["watch", "--why", "-x", "test --features=search,termwiz", "-w", "src", "-w", "tests"] 4 | -------------------------------------------------------------------------------- /bench/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tui-textarea-bench" 3 | version = "0.0.0" 4 | edition = "2021" 5 | publish = false 6 | license = "MIT" 7 | 8 | [lib] 9 | bench = false 10 | 11 | [dependencies] 12 | tui-textarea = { path = "..", features = ["no-backend", "search"] } 13 | ratatui = { version = "0.29.0", default-features = false } 14 | 15 | [dev-dependencies] 16 | criterion = "0.5" 17 | rand = { version = "0.8.5", features = ["small_rng"] } 18 | 19 | [[bench]] 20 | name = "insert" 21 | harness = false 22 | 23 | [[bench]] 24 | name = "search" 25 | harness = false 26 | 27 | [[bench]] 28 | name = "cursor" 29 | harness = false 30 | 31 | [[bench]] 32 | name = "delete" 33 | harness = false 34 | -------------------------------------------------------------------------------- /bench/README.md: -------------------------------------------------------------------------------- 1 | Benchmarks for tui-textarea using [Criterion.rs][criterion]. 2 | 3 | To run all benchmarks: 4 | 5 | ```sh 6 | cargo bench --benches 7 | ``` 8 | 9 | To run specific benchmark suite: 10 | 11 | ```sh 12 | cargo bench --bench insert 13 | ``` 14 | 15 | To filter benchmarks: 16 | 17 | ```sh 18 | cargo bench append::1_lorem 19 | ``` 20 | 21 | To compare benchmark results with [critcmp][]: 22 | 23 | ```sh 24 | git checkout main 25 | cargo bench -- --save-baseline base 26 | 27 | git checkout your-feature 28 | cargo bench -- --save-baseline change 29 | 30 | critcmp base change 31 | ``` 32 | 33 | [criterion]: https://github.com/bheisler/criterion.rs 34 | [critcmp]: https://github.com/BurntSushi/critcmp 35 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | pub fn spaces(size: u8) -> &'static str { 2 | const SPACES: &str = " "; 3 | &SPACES[..size as usize] 4 | } 5 | 6 | pub fn num_digits(i: usize) -> u8 { 7 | f64::log10(i as f64) as u8 + 1 8 | } 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct Pos { 12 | pub row: usize, 13 | pub col: usize, 14 | pub offset: usize, 15 | } 16 | 17 | impl Pos { 18 | pub fn new(row: usize, col: usize, offset: usize) -> Self { 19 | Self { row, col, offset } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tui-textarea-fuzz" 3 | version = "0.0.0" 4 | authors = ["Automatically generated"] 5 | publish = false 6 | edition = "2021" 7 | 8 | [package.metadata] 9 | cargo-fuzz = true 10 | 11 | [dependencies] 12 | libfuzzer-sys = "0.4" 13 | arbitrary = { version = "1", features = ["derive"] } 14 | tui-textarea = { path = "..", features = ["search", "arbitrary"] } 15 | tui-textarea-bench = { path = "../bench" } 16 | 17 | # Prevent this from interfering with workspaces 18 | [workspace] 19 | members = ["."] 20 | 21 | [[bin]] 22 | name = "edit" 23 | path = "fuzz_targets/edit.rs" 24 | test = false 25 | doc = false 26 | 27 | [[bin]] 28 | name = "insert_delete" 29 | path = "fuzz_targets/insert_delete.rs" 30 | test = false 31 | doc = false 32 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/insert_delete.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use arbitrary::{Arbitrary as _, Result, Unstructured}; 4 | use libfuzzer_sys::fuzz_target; 5 | use tui_textarea::{CursorMove, TextArea}; 6 | use tui_textarea_bench::{dummy_terminal, TerminalExt}; 7 | 8 | fn fuzz(data: &[u8]) -> Result<()> { 9 | let mut term = dummy_terminal(); 10 | let mut textarea = TextArea::default(); 11 | let mut data = Unstructured::new(data); 12 | for i in 0..100 { 13 | textarea.move_cursor(CursorMove::arbitrary(&mut data)?); 14 | if i % 2 == 0 { 15 | textarea.insert_str(String::arbitrary(&mut data)?); 16 | } else { 17 | textarea.delete_str(usize::arbitrary(&mut data)?); 18 | } 19 | term.draw_textarea(&textarea); 20 | } 21 | Ok(()) 22 | } 23 | 24 | fuzz_target!(|data: &[u8]| { 25 | fuzz(data).unwrap(); 26 | }); 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | the MIT License 2 | 3 | Copyright (c) 2022 rhysd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 17 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR 20 | THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/edit.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use arbitrary::{Arbitrary, Result, Unstructured}; 4 | use libfuzzer_sys::fuzz_target; 5 | use std::str; 6 | use tui_textarea::{CursorMove, Input, TextArea}; 7 | use tui_textarea_bench::{dummy_terminal, TerminalExt}; 8 | 9 | #[derive(Arbitrary)] 10 | enum RandomInput { 11 | Input(Input), 12 | Cursor(CursorMove), 13 | } 14 | 15 | impl RandomInput { 16 | fn apply(self, t: &mut TextArea<'_>) { 17 | match self { 18 | Self::Input(input) => { 19 | t.input(input); 20 | } 21 | Self::Cursor(m) => t.move_cursor(m), 22 | } 23 | } 24 | } 25 | 26 | fn fuzz(data: &[u8]) -> Result<()> { 27 | let mut term = dummy_terminal(); 28 | let mut data = Unstructured::new(data); 29 | let text = <&str>::arbitrary(&mut data)?; 30 | let mut textarea = TextArea::from(text.lines()); 31 | for _ in 0..100 { 32 | let input = RandomInput::arbitrary(&mut data)?; 33 | input.apply(&mut textarea); 34 | term.draw_textarea(&textarea); 35 | } 36 | Ok(()) 37 | } 38 | 39 | fuzz_target!(|data: &[u8]| { 40 | let _ = fuzz(data); 41 | }); 42 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![allow(clippy::needless_range_loop)] 3 | #![warn(clippy::dbg_macro, clippy::print_stdout)] 4 | #![cfg_attr(docsrs, feature(doc_cfg))] 5 | #![doc = include_str!("../README.md")] 6 | 7 | #[cfg(all(feature = "ratatui", feature = "tuirs"))] 8 | compile_error!("ratatui support and tui-rs support are exclusive. only one of them can be enabled at the same time. see https://github.com/rhysd/tui-textarea#installation"); 9 | 10 | mod cursor; 11 | mod highlight; 12 | mod history; 13 | mod input; 14 | mod scroll; 15 | #[cfg(feature = "search")] 16 | mod search; 17 | mod textarea; 18 | mod util; 19 | mod widget; 20 | mod word; 21 | 22 | #[cfg(feature = "ratatui")] 23 | #[allow(clippy::single_component_path_imports)] 24 | use ratatui; 25 | #[cfg(feature = "tuirs")] 26 | use tui as ratatui; 27 | 28 | #[cfg(feature = "crossterm")] 29 | #[allow(clippy::single_component_path_imports)] 30 | use crossterm; 31 | #[cfg(feature = "tuirs-crossterm")] 32 | use crossterm_025 as crossterm; 33 | 34 | #[cfg(feature = "termion")] 35 | #[allow(clippy::single_component_path_imports)] 36 | use termion; 37 | #[cfg(feature = "tuirs-termion")] 38 | use termion_15 as termion; 39 | 40 | pub use cursor::CursorMove; 41 | pub use input::{Input, Key}; 42 | pub use scroll::Scrolling; 43 | pub use textarea::TextArea; 44 | -------------------------------------------------------------------------------- /tests/serde.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "serde")] 2 | 3 | use tui_textarea::{CursorMove, Input, Key, Scrolling}; 4 | 5 | #[test] 6 | fn test_serde_key() { 7 | let k = Key::Char('a'); 8 | let s = serde_json::to_string(&k).unwrap(); 9 | assert_eq!(s, r#"{"Char":"a"}"#); 10 | let d: Key = serde_json::from_str(&s).unwrap(); 11 | assert_eq!(d, k); 12 | } 13 | 14 | #[test] 15 | fn test_serde_input() { 16 | let i = Input { 17 | key: Key::Char('a'), 18 | ctrl: true, 19 | alt: false, 20 | shift: true, 21 | }; 22 | let s = serde_json::to_string(&i).unwrap(); 23 | assert_eq!( 24 | s, 25 | r#"{"key":{"Char":"a"},"ctrl":true,"alt":false,"shift":true}"#, 26 | ); 27 | let d: Input = serde_json::from_str(&s).unwrap(); 28 | assert_eq!(d, i); 29 | } 30 | 31 | #[test] 32 | fn test_serde_scrolling() { 33 | let scroll = Scrolling::Delta { rows: 1, cols: 2 }; 34 | let s = serde_json::to_string(&scroll).unwrap(); 35 | assert_eq!(s, r#"{"Delta":{"rows":1,"cols":2}}"#); 36 | let d: Scrolling = serde_json::from_str(&s).unwrap(); 37 | assert_eq!(d, scroll); 38 | } 39 | 40 | #[test] 41 | fn test_serde_cursor_move() { 42 | let c = CursorMove::Forward; 43 | let s = serde_json::to_string(&c).unwrap(); 44 | assert_eq!(s, r#""Forward""#); 45 | let d: CursorMove = serde_json::from_str(&s).unwrap(); 46 | assert_eq!(d, c); 47 | } 48 | -------------------------------------------------------------------------------- /bench/benches/delete.rs: -------------------------------------------------------------------------------- 1 | use criterion::{criterion_group, criterion_main, Criterion}; 2 | use tui_textarea::{CursorMove, TextArea}; 3 | use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; 4 | 5 | #[derive(Clone, Copy)] 6 | enum Kind { 7 | Char, 8 | Word, 9 | Line, 10 | } 11 | 12 | #[inline] 13 | fn run(textarea: &TextArea<'_>, kind: Kind) { 14 | let mut term = dummy_terminal(); 15 | let mut t = textarea.clone(); 16 | t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); 17 | for _ in 0..100 { 18 | let modified = match kind { 19 | Kind::Char => t.delete_char(), 20 | Kind::Word => t.delete_word(), 21 | Kind::Line => t.delete_line_by_head(), 22 | }; 23 | if !modified { 24 | t = textarea.clone(); 25 | t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); 26 | } 27 | term.draw_textarea(&t); 28 | } 29 | } 30 | 31 | fn bench(c: &mut Criterion) { 32 | let mut lines = vec![]; 33 | for _ in 0..10 { 34 | lines.extend(LOREM.iter().map(|s| s.to_string())); 35 | } 36 | let textarea = TextArea::new(lines); 37 | 38 | c.bench_function("delete::char", |b| b.iter(|| run(&textarea, Kind::Char))); 39 | c.bench_function("delete::word", |b| b.iter(|| run(&textarea, Kind::Word))); 40 | c.bench_function("delete::line", |b| b.iter(|| run(&textarea, Kind::Line))); 41 | } 42 | 43 | criterion_group!(delete, bench); 44 | criterion_main!(delete); 45 | -------------------------------------------------------------------------------- /examples/minimal.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::terminal::{ 3 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 4 | }; 5 | use ratatui::backend::CrosstermBackend; 6 | use ratatui::widgets::{Block, Borders}; 7 | use ratatui::Terminal; 8 | use std::io; 9 | use tui_textarea::{Input, Key, TextArea}; 10 | 11 | fn main() -> io::Result<()> { 12 | let stdout = io::stdout(); 13 | let mut stdout = stdout.lock(); 14 | 15 | enable_raw_mode()?; 16 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 17 | let backend = CrosstermBackend::new(stdout); 18 | let mut term = Terminal::new(backend)?; 19 | 20 | let mut textarea = TextArea::default(); 21 | textarea.set_block( 22 | Block::default() 23 | .borders(Borders::ALL) 24 | .title("Crossterm Minimal Example"), 25 | ); 26 | 27 | loop { 28 | term.draw(|f| { 29 | f.render_widget(&textarea, f.area()); 30 | })?; 31 | match crossterm::event::read()?.into() { 32 | Input { key: Key::Esc, .. } => break, 33 | input => { 34 | textarea.input(input); 35 | } 36 | } 37 | } 38 | 39 | disable_raw_mode()?; 40 | crossterm::execute!( 41 | term.backend_mut(), 42 | LeaveAlternateScreen, 43 | DisableMouseCapture 44 | )?; 45 | term.show_cursor()?; 46 | 47 | println!("Lines: {:?}", textarea.lines()); 48 | Ok(()) 49 | } 50 | -------------------------------------------------------------------------------- /examples/tuirs_minimal.rs: -------------------------------------------------------------------------------- 1 | // Use `crossterm` v0.25 for `tui` backend. 2 | use crossterm_025 as crossterm; 3 | 4 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 5 | use crossterm::terminal::{ 6 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 7 | }; 8 | use std::io; 9 | use tui::backend::CrosstermBackend; 10 | use tui::widgets::{Block, Borders}; 11 | use tui::Terminal; 12 | use tui_textarea::{Input, Key, TextArea}; 13 | 14 | fn main() -> io::Result<()> { 15 | let stdout = io::stdout(); 16 | let mut stdout = stdout.lock(); 17 | 18 | enable_raw_mode()?; 19 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 20 | let backend = CrosstermBackend::new(stdout); 21 | let mut term = Terminal::new(backend)?; 22 | 23 | let mut textarea = TextArea::default(); 24 | textarea.set_block( 25 | Block::default() 26 | .borders(Borders::ALL) 27 | .title("Crossterm Minimal Example"), 28 | ); 29 | 30 | loop { 31 | term.draw(|f| { 32 | f.render_widget(&textarea, f.size()); 33 | })?; 34 | match crossterm::event::read()?.into() { 35 | Input { key: Key::Esc, .. } => break, 36 | input => { 37 | textarea.input(input); 38 | } 39 | } 40 | } 41 | 42 | disable_raw_mode()?; 43 | crossterm::execute!( 44 | term.backend_mut(), 45 | LeaveAlternateScreen, 46 | DisableMouseCapture 47 | )?; 48 | term.show_cursor()?; 49 | 50 | println!("Lines: {:?}", textarea.lines()); 51 | Ok(()) 52 | } 53 | -------------------------------------------------------------------------------- /bench/benches/search.rs: -------------------------------------------------------------------------------- 1 | use criterion::{criterion_group, criterion_main, Criterion}; 2 | use tui_textarea::TextArea; 3 | use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; 4 | 5 | #[inline] 6 | fn run(pat: &str, mut textarea: TextArea<'_>, forward: bool) { 7 | let mut term = dummy_terminal(); 8 | textarea.set_search_pattern(pat).unwrap(); 9 | term.draw_textarea(&textarea); 10 | for _ in 0..100 { 11 | if forward { 12 | textarea.search_forward(false); 13 | } else { 14 | textarea.search_back(false); 15 | } 16 | term.draw_textarea(&textarea); 17 | } 18 | textarea.set_search_pattern(r"").unwrap(); 19 | term.draw_textarea(&textarea); 20 | } 21 | 22 | fn short(c: &mut Criterion) { 23 | let textarea = TextArea::from(LOREM.iter().map(|s| s.to_string())); 24 | c.bench_function("search::forward_short", |b| { 25 | b.iter(|| run(r"\w*i\w*", textarea.clone(), true)) 26 | }); 27 | c.bench_function("search::back_short", |b| { 28 | b.iter(|| run(r"\w*i\w*", textarea.clone(), false)) 29 | }); 30 | } 31 | 32 | fn long(c: &mut Criterion) { 33 | let mut lines = vec![]; 34 | for _ in 0..10 { 35 | lines.extend(LOREM.iter().map(|s| s.to_string())); 36 | } 37 | let textarea = TextArea::new(lines); 38 | c.bench_function("search::forward_long", |b| { 39 | b.iter(|| run(r"[A-Z]\w*", textarea.clone(), true)) 40 | }); 41 | c.bench_function("search::back_long", |b| { 42 | b.iter(|| run(r"[A-Z]\w*", textarea.clone(), false)) 43 | }); 44 | } 45 | 46 | criterion_group!(search, short, long); 47 | criterion_main!(search); 48 | -------------------------------------------------------------------------------- /examples/termwiz.rs: -------------------------------------------------------------------------------- 1 | use ratatui::backend::TermwizBackend; 2 | use ratatui::widgets::{Block, Borders}; 3 | use ratatui::Terminal; 4 | use std::error::Error; 5 | use std::time::Duration; 6 | use termwiz::input::InputEvent; 7 | use termwiz::terminal::Terminal as _; 8 | use tui_textarea::{Input, Key, TextArea}; 9 | 10 | fn main() -> Result<(), Box> { 11 | let backend = TermwizBackend::new()?; 12 | let mut term = Terminal::new(backend)?; 13 | term.hide_cursor()?; 14 | 15 | let mut textarea = TextArea::default(); 16 | textarea.set_block( 17 | Block::default() 18 | .borders(Borders::ALL) 19 | .title("Termwiz Minimal Example"), 20 | ); 21 | 22 | // The event loop 23 | loop { 24 | term.draw(|f| { 25 | f.render_widget(&textarea, f.area()); 26 | })?; 27 | 28 | if let Some(input) = term 29 | .backend_mut() 30 | .buffered_terminal_mut() 31 | .terminal() 32 | .poll_input(Some(Duration::from_millis(100)))? 33 | { 34 | if let InputEvent::Resized { cols, rows } = input { 35 | term.backend_mut() 36 | .buffered_terminal_mut() 37 | .resize(cols, rows); 38 | } else { 39 | match input.into() { 40 | Input { key: Key::Esc, .. } => break, 41 | input => { 42 | textarea.input(input); 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | term.show_cursor()?; 50 | term.flush()?; 51 | drop(term); // Leave terminal raw mode to print the following line 52 | 53 | println!("Lines: {:?}", textarea.lines()); 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /examples/popup_placeholder.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::terminal::{ 3 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 4 | }; 5 | use ratatui::backend::CrosstermBackend; 6 | use ratatui::layout::Rect; 7 | use ratatui::style::{Color, Style}; 8 | use ratatui::widgets::{Block, Borders}; 9 | use ratatui::Terminal; 10 | use std::io; 11 | use tui_textarea::{Input, Key, TextArea}; 12 | 13 | fn main() -> io::Result<()> { 14 | let stdout = io::stdout(); 15 | let mut stdout = stdout.lock(); 16 | 17 | enable_raw_mode()?; 18 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 19 | let backend = CrosstermBackend::new(stdout); 20 | let mut term = Terminal::new(backend)?; 21 | 22 | let mut textarea = TextArea::default(); 23 | textarea.set_block( 24 | Block::default() 25 | .borders(Borders::ALL) 26 | .border_style(Style::default().fg(Color::LightBlue)) 27 | .title("Crossterm Popup Example"), 28 | ); 29 | 30 | let area = Rect { 31 | width: 40, 32 | height: 5, 33 | x: 5, 34 | y: 5, 35 | }; 36 | textarea.set_style(Style::default().fg(Color::Yellow)); 37 | textarea.set_placeholder_style(Style::default()); 38 | textarea.set_placeholder_text("prompt message"); 39 | loop { 40 | term.draw(|f| { 41 | f.render_widget(&textarea, area); 42 | })?; 43 | match crossterm::event::read()?.into() { 44 | Input { key: Key::Esc, .. } => break, 45 | input => { 46 | textarea.input(input); 47 | } 48 | } 49 | } 50 | 51 | disable_raw_mode()?; 52 | crossterm::execute!( 53 | term.backend_mut(), 54 | LeaveAlternateScreen, 55 | DisableMouseCapture 56 | )?; 57 | term.show_cursor()?; 58 | 59 | println!("Lines: {:?}", textarea.lines()); 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /examples/variable.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::terminal::{ 3 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 4 | }; 5 | use ratatui::backend::CrosstermBackend; 6 | use ratatui::layout::{Constraint, Direction, Layout}; 7 | use ratatui::widgets::{Block, Borders}; 8 | use ratatui::Terminal; 9 | use std::cmp; 10 | use std::io; 11 | use tui_textarea::{Input, Key, TextArea}; 12 | 13 | fn main() -> io::Result<()> { 14 | let stdout = io::stdout(); 15 | let mut stdout = stdout.lock(); 16 | 17 | enable_raw_mode()?; 18 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 19 | let backend = CrosstermBackend::new(stdout); 20 | let mut term = Terminal::new(backend)?; 21 | 22 | let mut textarea = TextArea::default(); 23 | textarea.set_block( 24 | Block::default() 25 | .borders(Borders::ALL) 26 | .title("Textarea with Variable Height"), 27 | ); 28 | 29 | loop { 30 | term.draw(|f| { 31 | const MIN_HEIGHT: usize = 3; 32 | let height = cmp::max(textarea.lines().len(), MIN_HEIGHT) as u16 + 2; // + 2 for borders 33 | let chunks = Layout::default() 34 | .direction(Direction::Vertical) 35 | .constraints([Constraint::Length(height), Constraint::Min(0)]) 36 | .split(f.area()); 37 | f.render_widget(&textarea, chunks[0]); 38 | })?; 39 | match crossterm::event::read()?.into() { 40 | Input { key: Key::Esc, .. } => break, 41 | input => { 42 | textarea.input(input); 43 | } 44 | } 45 | } 46 | 47 | disable_raw_mode()?; 48 | crossterm::execute!( 49 | term.backend_mut(), 50 | LeaveAlternateScreen, 51 | DisableMouseCapture 52 | )?; 53 | term.show_cursor()?; 54 | 55 | println!("Lines: {:?}", textarea.lines()); 56 | Ok(()) 57 | } 58 | -------------------------------------------------------------------------------- /examples/termion.rs: -------------------------------------------------------------------------------- 1 | use ratatui::backend::TermionBackend; 2 | use ratatui::widgets::{Block, Borders}; 3 | use ratatui::Terminal; 4 | use std::error::Error; 5 | use std::io; 6 | use std::sync::mpsc; 7 | use std::thread; 8 | use std::time::Duration; 9 | use termion::event::Event as TermEvent; 10 | use termion::input::{MouseTerminal, TermRead}; 11 | use termion::raw::IntoRawMode; 12 | use termion::screen::IntoAlternateScreen; 13 | use tui_textarea::{Input, Key, TextArea}; 14 | 15 | enum Event { 16 | Term(TermEvent), 17 | Tick, 18 | } 19 | 20 | fn main() -> Result<(), Box> { 21 | let stdout = io::stdout().into_raw_mode()?.into_alternate_screen()?; 22 | let stdout = MouseTerminal::from(stdout); 23 | let backend = TermionBackend::new(stdout); 24 | let mut term = Terminal::new(backend)?; 25 | 26 | let events = { 27 | let events = io::stdin().events(); 28 | let (tx, rx) = mpsc::channel(); 29 | let keys_tx = tx.clone(); 30 | thread::spawn(move || { 31 | for event in events.flatten() { 32 | keys_tx.send(Event::Term(event)).unwrap(); 33 | } 34 | }); 35 | thread::spawn(move || loop { 36 | tx.send(Event::Tick).unwrap(); 37 | thread::sleep(Duration::from_millis(100)); 38 | }); 39 | rx 40 | }; 41 | 42 | let mut textarea = TextArea::default(); 43 | textarea.set_block( 44 | Block::default() 45 | .borders(Borders::ALL) 46 | .title("Termion Minimal Example"), 47 | ); 48 | 49 | loop { 50 | match events.recv()? { 51 | Event::Term(event) => match event.into() { 52 | Input { key: Key::Esc, .. } => break, 53 | input => { 54 | textarea.input(input); 55 | } 56 | }, 57 | Event::Tick => {} 58 | } 59 | term.draw(|f| { 60 | f.render_widget(&textarea, f.area()); 61 | })?; 62 | } 63 | 64 | drop(term); // Leave terminal raw mode to print the following line 65 | println!("Lines: {:?}", textarea.lines()); 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /examples/password.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::terminal::{ 3 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 4 | }; 5 | use ratatui::backend::CrosstermBackend; 6 | use ratatui::layout::{Constraint, Layout}; 7 | use ratatui::style::{Color, Style}; 8 | use ratatui::widgets::{Block, Borders}; 9 | use ratatui::Terminal; 10 | use std::io; 11 | use tui_textarea::{Input, Key, TextArea}; 12 | 13 | fn main() -> io::Result<()> { 14 | let stdout = io::stdout(); 15 | let mut stdout = stdout.lock(); 16 | 17 | enable_raw_mode()?; 18 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 19 | let backend = CrosstermBackend::new(stdout); 20 | let mut term = Terminal::new(backend)?; 21 | 22 | let mut textarea = TextArea::default(); 23 | textarea.set_cursor_line_style(Style::default()); 24 | textarea.set_mask_char('\u{2022}'); //U+2022 BULLET (•) 25 | textarea.set_placeholder_text("Please enter your password"); 26 | let constraints = [Constraint::Length(3), Constraint::Min(1)]; 27 | let layout = Layout::default().constraints(constraints); 28 | textarea.set_style(Style::default().fg(Color::LightGreen)); 29 | textarea.set_block(Block::default().borders(Borders::ALL).title("Password")); 30 | 31 | loop { 32 | term.draw(|f| { 33 | let chunks = layout.split(f.area()); 34 | f.render_widget(&textarea, chunks[0]); 35 | })?; 36 | 37 | match crossterm::event::read()?.into() { 38 | Input { 39 | key: Key::Esc | Key::Enter, 40 | .. 41 | } => break, 42 | input => { 43 | if textarea.input(input) { 44 | // When the input modified its text, validate the text content 45 | } 46 | } 47 | } 48 | } 49 | 50 | disable_raw_mode()?; 51 | crossterm::execute!( 52 | term.backend_mut(), 53 | LeaveAlternateScreen, 54 | DisableMouseCapture, 55 | )?; 56 | term.show_cursor()?; 57 | 58 | println!("Input: {:?}", textarea.lines()[0]); 59 | Ok(()) 60 | } 61 | -------------------------------------------------------------------------------- /tests/input.rs: -------------------------------------------------------------------------------- 1 | use tui_textarea::{Input, Key, TextArea}; 2 | 3 | // Sanity test for checking textarea does not crash against all combination of inputs 4 | #[test] 5 | fn test_input_all_combinations_sanity() { 6 | use Key::*; 7 | 8 | fn push_all_modifiers_combination(inputs: &mut Vec, key: Key) { 9 | for ctrl in [true, false] { 10 | for alt in [true, false] { 11 | for shift in [true, false] { 12 | inputs.push(Input { 13 | key, 14 | ctrl, 15 | alt, 16 | shift, 17 | }); 18 | } 19 | } 20 | } 21 | } 22 | 23 | let mut inputs = vec![]; 24 | 25 | for c in ' '..='~' { 26 | push_all_modifiers_combination(&mut inputs, Char(c)); 27 | } 28 | for i in 0..=15 { 29 | push_all_modifiers_combination(&mut inputs, F(i)); 30 | } 31 | for k in [ 32 | Null, 33 | Char('あ'), 34 | Char('🐶'), 35 | Backspace, 36 | Enter, 37 | Left, 38 | Right, 39 | Up, 40 | Down, 41 | Tab, 42 | Delete, 43 | Home, 44 | End, 45 | PageUp, 46 | PageDown, 47 | Esc, 48 | MouseScrollDown, 49 | MouseScrollUp, 50 | Copy, 51 | Cut, 52 | Paste, 53 | ] { 54 | push_all_modifiers_combination(&mut inputs, k); 55 | } 56 | 57 | let mut t = TextArea::from(["abc", "def", "ghi", "jkl", "mno", "pqr"]); 58 | 59 | for input in inputs { 60 | t.input(input.clone()); 61 | t.undo(); 62 | t.redo(); 63 | t.input_without_shortcuts(input); 64 | t.undo(); 65 | t.redo(); 66 | } 67 | } 68 | 69 | #[test] 70 | fn test_insert_multi_code_unit_emoji() { 71 | let mut t = TextArea::default(); 72 | for c in "👨‍👩‍👧‍👦".chars() { 73 | let input = Input { 74 | key: Key::Char(c), 75 | ctrl: false, 76 | alt: false, 77 | shift: false, 78 | }; 79 | assert!(t.input(input), "{c:?}"); 80 | } 81 | assert_eq!(t.lines(), ["👨‍👩‍👧‍👦"]); 82 | } 83 | -------------------------------------------------------------------------------- /examples/tuirs_termion.rs: -------------------------------------------------------------------------------- 1 | // Use `termion` v1.5 for `tui` backend. 2 | use termion_15 as termion; 3 | 4 | use std::error::Error; 5 | use std::io; 6 | use std::sync::mpsc; 7 | use std::thread; 8 | use std::time::Duration; 9 | use termion::event::Event as TermEvent; 10 | use termion::input::{MouseTerminal, TermRead}; 11 | use termion::raw::IntoRawMode; 12 | use termion::screen::AlternateScreen; 13 | use tui::backend::TermionBackend; 14 | use tui::widgets::{Block, Borders}; 15 | use tui::Terminal; 16 | use tui_textarea::{Input, Key, TextArea}; 17 | 18 | enum Event { 19 | Term(TermEvent), 20 | Tick, 21 | } 22 | 23 | fn main() -> Result<(), Box> { 24 | let stdout = io::stdout().into_raw_mode()?; 25 | let stdout = MouseTerminal::from(stdout); 26 | let stdout = AlternateScreen::from(stdout); 27 | let backend = TermionBackend::new(stdout); 28 | let mut term = Terminal::new(backend)?; 29 | 30 | let events = { 31 | let events = io::stdin().events(); 32 | let (tx, rx) = mpsc::channel(); 33 | let keys_tx = tx.clone(); 34 | thread::spawn(move || { 35 | for event in events.flatten() { 36 | keys_tx.send(Event::Term(event)).unwrap(); 37 | } 38 | }); 39 | thread::spawn(move || loop { 40 | tx.send(Event::Tick).unwrap(); 41 | thread::sleep(Duration::from_millis(100)); 42 | }); 43 | rx 44 | }; 45 | 46 | let mut textarea = TextArea::default(); 47 | textarea.set_block( 48 | Block::default() 49 | .borders(Borders::ALL) 50 | .title("Termion Minimal Example"), 51 | ); 52 | 53 | loop { 54 | match events.recv()? { 55 | Event::Term(event) => match event.into() { 56 | Input { key: Key::Esc, .. } => break, 57 | input => { 58 | textarea.input(input); 59 | } 60 | }, 61 | Event::Tick => {} 62 | } 63 | term.draw(|f| { 64 | f.render_widget(&textarea, f.size()); 65 | })?; 66 | } 67 | 68 | drop(term); // Leave terminal raw mode to print the following line 69 | println!("Lines: {:?}", textarea.lines()); 70 | Ok(()) 71 | } 72 | -------------------------------------------------------------------------------- /src/word.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq, Eq, Clone, Copy)] 2 | enum CharKind { 3 | Space, 4 | Punct, 5 | Other, 6 | } 7 | 8 | impl CharKind { 9 | fn new(c: char) -> Self { 10 | if c.is_whitespace() { 11 | Self::Space 12 | } else if c.is_ascii_punctuation() { 13 | Self::Punct 14 | } else { 15 | Self::Other 16 | } 17 | } 18 | } 19 | 20 | pub fn find_word_start_forward(line: &str, start_col: usize) -> Option { 21 | let mut it = line.chars().enumerate().skip(start_col); 22 | let mut prev = CharKind::new(it.next()?.1); 23 | for (col, c) in it { 24 | let cur = CharKind::new(c); 25 | if cur != CharKind::Space && prev != cur { 26 | return Some(col); 27 | } 28 | prev = cur; 29 | } 30 | None 31 | } 32 | 33 | pub fn find_word_exclusive_end_forward(line: &str, start_col: usize) -> Option { 34 | let mut it = line.chars().enumerate().skip(start_col); 35 | let mut prev = CharKind::new(it.next()?.1); 36 | for (col, c) in it { 37 | let cur = CharKind::new(c); 38 | if prev != CharKind::Space && prev != cur { 39 | return Some(col); 40 | } 41 | prev = cur; 42 | } 43 | None 44 | } 45 | 46 | pub fn find_word_inclusive_end_forward(line: &str, start_col: usize) -> Option { 47 | let mut it = line.chars().enumerate().skip(start_col); 48 | let (mut last_col, c) = it.next()?; 49 | let mut prev = CharKind::new(c); 50 | for (col, c) in it { 51 | let cur = CharKind::new(c); 52 | if prev != CharKind::Space && cur != prev { 53 | return Some(col.saturating_sub(1)); 54 | } 55 | prev = cur; 56 | last_col = col; 57 | } 58 | if prev != CharKind::Space { 59 | Some(last_col) 60 | } else { 61 | None 62 | } 63 | } 64 | 65 | pub fn find_word_start_backward(line: &str, start_col: usize) -> Option { 66 | let idx = line 67 | .char_indices() 68 | .nth(start_col) 69 | .map(|(i, _)| i) 70 | .unwrap_or(line.len()); 71 | let mut it = line[..idx].chars().rev().enumerate(); 72 | let mut cur = CharKind::new(it.next()?.1); 73 | for (i, c) in it { 74 | let next = CharKind::new(c); 75 | if cur != CharKind::Space && next != cur { 76 | return Some(start_col - i); 77 | } 78 | cur = next; 79 | } 80 | (cur != CharKind::Space).then(|| 0) 81 | } 82 | -------------------------------------------------------------------------------- /examples/single_line.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::terminal::{ 3 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 4 | }; 5 | use ratatui::backend::CrosstermBackend; 6 | use ratatui::layout::{Constraint, Layout}; 7 | use ratatui::style::{Color, Style}; 8 | use ratatui::widgets::{Block, Borders}; 9 | use ratatui::Terminal; 10 | use std::io; 11 | use tui_textarea::{Input, Key, TextArea}; 12 | 13 | fn validate(textarea: &mut TextArea) -> bool { 14 | if let Err(err) = textarea.lines()[0].parse::() { 15 | textarea.set_style(Style::default().fg(Color::LightRed)); 16 | textarea.set_block( 17 | Block::default() 18 | .borders(Borders::ALL) 19 | .border_style(Color::LightRed) 20 | .title(format!("ERROR: {}", err)), 21 | ); 22 | false 23 | } else { 24 | textarea.set_style(Style::default().fg(Color::LightGreen)); 25 | textarea.set_block( 26 | Block::default() 27 | .border_style(Color::LightGreen) 28 | .borders(Borders::ALL) 29 | .title("OK"), 30 | ); 31 | true 32 | } 33 | } 34 | 35 | fn main() -> io::Result<()> { 36 | let stdout = io::stdout(); 37 | let mut stdout = stdout.lock(); 38 | 39 | enable_raw_mode()?; 40 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 41 | let backend = CrosstermBackend::new(stdout); 42 | let mut term = Terminal::new(backend)?; 43 | 44 | let mut textarea = TextArea::default(); 45 | textarea.set_cursor_line_style(Style::default()); 46 | textarea.set_placeholder_text("Enter a valid float (e.g. 1.56)"); 47 | let layout = Layout::default().constraints([Constraint::Length(3), Constraint::Min(1)]); 48 | let mut is_valid = validate(&mut textarea); 49 | 50 | loop { 51 | term.draw(|f| { 52 | let chunks = layout.split(f.area()); 53 | f.render_widget(&textarea, chunks[0]); 54 | })?; 55 | 56 | match crossterm::event::read()?.into() { 57 | Input { key: Key::Esc, .. } => break, 58 | Input { 59 | key: Key::Enter, .. 60 | } if is_valid => break, 61 | Input { 62 | key: Key::Char('m'), 63 | ctrl: true, 64 | .. 65 | } 66 | | Input { 67 | key: Key::Enter, .. 68 | } => {} 69 | input => { 70 | // TextArea::input returns if the input modified its text 71 | if textarea.input(input) { 72 | is_valid = validate(&mut textarea); 73 | } 74 | } 75 | } 76 | } 77 | 78 | disable_raw_mode()?; 79 | crossterm::execute!( 80 | term.backend_mut(), 81 | LeaveAlternateScreen, 82 | DisableMouseCapture 83 | )?; 84 | term.show_cursor()?; 85 | 86 | println!("Input: {:?}", textarea.lines()[0]); 87 | Ok(()) 88 | } 89 | -------------------------------------------------------------------------------- /examples/split.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::terminal::{ 3 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 4 | }; 5 | use ratatui::backend::CrosstermBackend; 6 | use ratatui::layout::{Constraint, Direction, Layout}; 7 | use ratatui::style::{Color, Modifier, Style}; 8 | use ratatui::widgets::{Block, Borders}; 9 | use ratatui::Terminal; 10 | use std::io; 11 | use tui_textarea::{Input, Key, TextArea}; 12 | 13 | fn inactivate(textarea: &mut TextArea<'_>) { 14 | textarea.set_cursor_line_style(Style::default()); 15 | textarea.set_cursor_style(Style::default()); 16 | textarea.set_block( 17 | Block::default() 18 | .borders(Borders::ALL) 19 | .style(Style::default().fg(Color::DarkGray)) 20 | .title(" Inactive (^X to switch) "), 21 | ); 22 | } 23 | 24 | fn activate(textarea: &mut TextArea<'_>) { 25 | textarea.set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED)); 26 | textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); 27 | textarea.set_block( 28 | Block::default() 29 | .borders(Borders::ALL) 30 | .style(Style::default()) 31 | .title(" Active "), 32 | ); 33 | } 34 | 35 | fn main() -> io::Result<()> { 36 | let stdout = io::stdout(); 37 | let mut stdout = stdout.lock(); 38 | enable_raw_mode()?; 39 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 40 | let backend = CrosstermBackend::new(stdout); 41 | let mut term = Terminal::new(backend)?; 42 | 43 | let mut textarea = [TextArea::default(), TextArea::default()]; 44 | 45 | let layout = Layout::default() 46 | .direction(Direction::Horizontal) 47 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()); 48 | 49 | let mut which = 0; 50 | activate(&mut textarea[0]); 51 | inactivate(&mut textarea[1]); 52 | 53 | loop { 54 | term.draw(|f| { 55 | let chunks = layout.split(f.area()); 56 | for (textarea, chunk) in textarea.iter().zip(chunks.iter()) { 57 | f.render_widget(textarea, *chunk); 58 | } 59 | })?; 60 | match crossterm::event::read()?.into() { 61 | Input { key: Key::Esc, .. } => break, 62 | Input { 63 | key: Key::Char('x'), 64 | ctrl: true, 65 | .. 66 | } => { 67 | inactivate(&mut textarea[which]); 68 | which = (which + 1) % 2; 69 | activate(&mut textarea[which]); 70 | } 71 | input => { 72 | textarea[which].input(input); 73 | } 74 | } 75 | } 76 | 77 | disable_raw_mode()?; 78 | crossterm::execute!( 79 | term.backend_mut(), 80 | LeaveAlternateScreen, 81 | DisableMouseCapture 82 | )?; 83 | term.show_cursor()?; 84 | 85 | println!("Left textarea: {:?}", textarea[0].lines()); 86 | println!("Right textarea: {:?}", textarea[1].lines()); 87 | Ok(()) 88 | } 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Reporting an issue 2 | 3 | For reporting a bug, please make sure your report includes the following points. 4 | 5 | - How to reproduce it 6 | - What text the textarea contained as pre-condition 7 | - What operations you did 8 | - What was the expected behavior 9 | - What was the actual behavior 10 | - Environment 11 | - Your terminal 12 | - Rust version 13 | - `ratatui` or `tui` crate version 14 | - Enabled features of `tui-textarea` crate 15 | 16 | An example of bug report: https://github.com/rhysd/tui-textarea/issues/1 17 | 18 | ## Submitting a pull request 19 | 20 | Please ensure that all tests and linter checks passed on your branch before creating a PR which modifies some source files. 21 | 22 | To run tests: 23 | 24 | ```sh 25 | cargo test --features=search 26 | ``` 27 | 28 | To run linters: 29 | 30 | ```sh 31 | cargo clippy --features=search,termwiz,termion --tests --examples 32 | cargo clippy --features=tuirs-crossterm,tuirs-termion,search --no-default-features --tests --examples 33 | cargo fmt -- --check 34 | ``` 35 | 36 | Note: On Windows, remove `termion` and `tuirs-termion` features from `--features` argument since termion doesn't support Windows. 37 | 38 | If you use [cargo-watch][], `cargo watch-check` and `cargo watch-test` aliases are useful to run checks/tests automatically 39 | on files being changed. 40 | 41 | ### Code coverage 42 | 43 | Code coverage is monitored on [codecov][]. 44 | 45 | https://app.codecov.io/gh/rhysd/tui-textarea 46 | 47 | When you implement some new feature, consider to add new unit tests which cover your implementation. 48 | 49 | ## Print debugging 50 | 51 | Since this crate uses stdout, `println!` is not available for debugging. Instead, stderr through [`eprintln!`][eprintln] 52 | or [`dbg!`][dbg] are useful. 53 | 54 | At first, add prints where you want to debug: 55 | 56 | ```rust 57 | eprintln!("some value is {:?}", some_value); 58 | dbg!(&some_value); 59 | ``` 60 | 61 | Then redirect stderr to some file: 62 | 63 | ```sh 64 | cargo run --example minimal 2>debug.txt 65 | ``` 66 | 67 | Then the debug prints are output to the `debug.txt` file. If timing is important or you want to see the output in real-time, 68 | it would be useful to monitor the file content with `tail` command in another terminal window. 69 | 70 | ```sh 71 | # In a terminal, reproduce the issue 72 | cargo run --example minimal 2>debug.txt 73 | 74 | # In another terminal, run `tail` command to monitor the content 75 | tail -F debug.txt 76 | ``` 77 | 78 | ## Running a fuzzer 79 | 80 | To run fuzzing tests, [cargo-fuzz][] and Rust nightly toolchain are necessary. 81 | 82 | ```sh 83 | # Show list of fuzzing targets 84 | cargo +nightly fuzz list 85 | 86 | # Run 'edit' fuzzing test case 87 | cargo +nightly fuzz run edit 88 | ``` 89 | 90 | ## Running benchmark suites 91 | 92 | Benchmarks are available using [Criterion.rs][criterion]. 93 | 94 | To separate `criterion` crate dependency, benchmark suites are separated as another crate in [bench/](./bench). 95 | 96 | To run benchmarks: 97 | 98 | ```sh 99 | cd ./bench 100 | cargo bench --benches 101 | ``` 102 | 103 | See [README in bench/](./bench/README.md) for more details. 104 | 105 | [cargo-watch]: https://crates.io/crates/cargo-watch 106 | [cargo-fuzz]: https://github.com/rust-fuzz/cargo-fuzz 107 | [criterion]: https://github.com/bheisler/criterion.rs 108 | [eprintln]: https://doc.rust-lang.org/std/macro.eprintln.html 109 | [dbg]: https://doc.rust-lang.org/std/macro.dbg.html 110 | [codecov]: https://about.codecov.io/ 111 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tui-textarea" 3 | version = "0.7.0" 4 | edition = "2021" 5 | rust-version = "1.56.1" # for `tui` crate support 6 | authors = ["rhysd "] 7 | description = """ 8 | tui-textarea is a simple yet powerful text editor widget for ratatui and tui-rs. Multi-line 9 | text editor can be easily put as part of your TUI application. 10 | """ 11 | license = "MIT" 12 | homepage = "https://github.com/rhysd/tui-textarea#readme" 13 | repository = "https://github.com/rhysd/tui-textarea" 14 | readme = "README.md" 15 | categories = ["text-editors", "text-processing"] 16 | keywords = ["tui", "textarea", "editor", "input", "ratatui"] 17 | include = ["/src", "/examples", "/tests", "/README.md", "/LICENSE.txt"] 18 | 19 | [features] 20 | default = ["crossterm"] 21 | # Features to use ratatui 22 | ratatui = ["dep:ratatui"] 23 | crossterm = ["ratatui", "dep:crossterm", "ratatui/crossterm"] 24 | termion = ["ratatui", "dep:termion", "ratatui/termion"] 25 | termwiz = ["ratatui", "dep:termwiz", "ratatui/termwiz"] 26 | no-backend = ["ratatui"] 27 | # Features to use tui-rs 28 | tuirs = ["dep:tui"] 29 | tuirs-crossterm = ["tuirs", "dep:crossterm-025", "tui/crossterm"] 30 | tuirs-termion = ["tuirs", "dep:termion-15", "tui/termion"] 31 | tuirs-no-backend = ["tuirs"] 32 | # Other optional features 33 | search = ["dep:regex"] 34 | serde = ["dep:serde"] 35 | arbitrary = ["dep:arbitrary"] 36 | 37 | [dependencies] 38 | arbitrary = { version = "1", features = ["derive"], optional = true } 39 | crossterm = { package = "crossterm", version = "0.28", optional = true } 40 | crossterm-025 = { package = "crossterm", version = "0.25", optional = true } 41 | ratatui = { version = "0.29.0", default-features = false, optional = true } 42 | regex = { version = "1", optional = true } 43 | termion = { version = "4.0", optional = true } 44 | termion-15 = { package = "termion", version = "1.5", optional = true } 45 | termwiz = { version = "0.22.0", optional = true } 46 | tui = { version = "0.19", default-features = false, optional = true } 47 | unicode-width = "0.2.0" 48 | serde = { version = "1", optional = true , features = ["derive"] } 49 | 50 | [[example]] 51 | name = "minimal" 52 | required-features = ["crossterm"] 53 | 54 | [[example]] 55 | name = "editor" 56 | required-features = ["crossterm", "search"] 57 | 58 | [[example]] 59 | name = "split" 60 | required-features = ["crossterm"] 61 | 62 | [[example]] 63 | name = "single_line" 64 | required-features = ["crossterm"] 65 | 66 | [[example]] 67 | name = "variable" 68 | required-features = ["crossterm"] 69 | 70 | [[example]] 71 | name = "vim" 72 | required-features = ["crossterm"] 73 | 74 | [[example]] 75 | name = "password" 76 | required-features = ["crossterm"] 77 | 78 | [[example]] 79 | name = "popup_placeholder" 80 | required-features = ["crossterm"] 81 | 82 | [[example]] 83 | name = "termwiz" 84 | required-features = ["termwiz"] 85 | 86 | [[example]] 87 | name = "termion" 88 | required-features = ["termion"] 89 | 90 | [[example]] 91 | name = "tuirs_minimal" 92 | required-features = ["tuirs-crossterm"] 93 | 94 | [[example]] 95 | name = "tuirs_editor" 96 | required-features = ["tuirs-crossterm", "search"] 97 | 98 | [[example]] 99 | name = "tuirs_termion" 100 | required-features = ["tuirs-termion"] 101 | 102 | [workspace] 103 | members = ["bench"] 104 | 105 | [profile.bench] 106 | lto = "thin" 107 | 108 | [dev-dependencies] 109 | serde_json = "1.0.120" 110 | 111 | [package.metadata.docs.rs] 112 | targets = ["x86_64-unknown-linux-gnu"] 113 | features = ["search", "crossterm", "termwiz", "termion", "serde"] 114 | rustdoc-args = ["--cfg", "docsrs"] 115 | -------------------------------------------------------------------------------- /bench/src/lib.rs: -------------------------------------------------------------------------------- 1 | // We use empty backend for our benchmark instead of tui::backend::TestBackend to make impact of benchmark from tui-rs 2 | // as small as possible. 3 | 4 | use ratatui::backend::{Backend, WindowSize}; 5 | use ratatui::buffer::Cell; 6 | use ratatui::layout::{Position, Size}; 7 | use ratatui::Terminal; 8 | use std::io; 9 | use tui_textarea::TextArea; 10 | 11 | pub const LOREM: &[&str] = &[ 12 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do", 13 | "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim", 14 | "ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut", 15 | "aliquip ex ea commodo consequat. Duis aute irure dolor in", 16 | "reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla", 17 | "pariatur. Excepteur sint occaecat cupidatat non proident, sunt in", 18 | "culpa qui officia deserunt mollit anim id est laborum.", 19 | ]; 20 | pub const SEED: [u8; 32] = [ 21 | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 22 | 27, 28, 29, 30, 31, 32, 23 | ]; 24 | 25 | pub struct DummyBackend { 26 | width: u16, 27 | height: u16, 28 | cursor: (u16, u16), 29 | } 30 | 31 | impl Default for DummyBackend { 32 | #[inline] 33 | fn default() -> Self { 34 | Self { 35 | width: 40, 36 | height: 12, 37 | cursor: (0, 0), 38 | } 39 | } 40 | } 41 | 42 | impl Backend for DummyBackend { 43 | #[inline] 44 | fn draw<'a, I>(&mut self, _content: I) -> io::Result<()> 45 | where 46 | I: Iterator, 47 | { 48 | Ok(()) 49 | } 50 | 51 | #[inline] 52 | fn hide_cursor(&mut self) -> io::Result<()> { 53 | Ok(()) 54 | } 55 | 56 | #[inline] 57 | fn show_cursor(&mut self) -> io::Result<()> { 58 | Ok(()) 59 | } 60 | 61 | #[inline] 62 | fn get_cursor(&mut self) -> io::Result<(u16, u16)> { 63 | Ok(self.cursor) 64 | } 65 | 66 | #[inline] 67 | fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { 68 | self.cursor = (x, y); 69 | Ok(()) 70 | } 71 | 72 | #[inline] 73 | fn clear(&mut self) -> io::Result<()> { 74 | Ok(()) 75 | } 76 | 77 | #[inline] 78 | fn size(&self) -> io::Result { 79 | Ok(Size { 80 | width: self.width, 81 | height: self.height, 82 | }) 83 | } 84 | 85 | #[inline] 86 | fn window_size(&mut self) -> io::Result { 87 | Ok(WindowSize { 88 | columns_rows: Size { 89 | width: self.width, 90 | height: self.height, 91 | }, 92 | pixels: Size { 93 | width: self.width * 6, 94 | height: self.height * 12, 95 | }, 96 | }) 97 | } 98 | 99 | #[inline] 100 | fn flush(&mut self) -> io::Result<()> { 101 | Ok(()) 102 | } 103 | 104 | #[inline] 105 | fn get_cursor_position(&mut self) -> io::Result { 106 | let (x, y) = self.cursor; 107 | Ok(Position { x, y }) 108 | } 109 | 110 | #[inline] 111 | fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { 112 | let Position { x, y } = position.into(); 113 | self.cursor = (x, y); 114 | Ok(()) 115 | } 116 | } 117 | 118 | #[inline] 119 | pub fn dummy_terminal() -> Terminal { 120 | Terminal::new(DummyBackend::default()).unwrap() 121 | } 122 | 123 | pub trait TerminalExt { 124 | fn draw_textarea(&mut self, textarea: &TextArea<'_>); 125 | } 126 | 127 | impl TerminalExt for Terminal { 128 | #[inline] 129 | fn draw_textarea(&mut self, textarea: &TextArea<'_>) { 130 | self.draw(|f| f.render_widget(textarea, f.area())).unwrap(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | env: 4 | CARGO_TERM_COLOR: always 5 | 6 | jobs: 7 | unit-test: 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | fail-fast: true 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: dtolnay/rust-toolchain@stable 16 | - uses: Swatinem/rust-cache@v2 17 | - name: Install cargo-llvm-cov 18 | uses: taiki-e/install-action@cargo-llvm-cov 19 | - name: Run tests on Linux or macOS 20 | run: | 21 | cargo llvm-cov --color always --lcov --output-path lcov.info --features=search,termwiz,termion,serde,arbitrary 22 | cargo llvm-cov --color always --no-run 23 | if: ${{ matrix.os != 'windows-latest' }} 24 | - name: Run tests on Windows 25 | run: | 26 | cargo llvm-cov --color always --lcov --output-path lcov.info --features=search,termwiz,serde,arbitrary 27 | cargo llvm-cov --color always --no-run 28 | if: ${{ matrix.os == 'windows-latest' }} 29 | - run: cargo test --no-default-features --features=tuirs-crossterm,search -- --skip .rs 30 | - run: cargo test --no-default-features --features=tuirs-termion,search -- --skip .rs 31 | if: ${{ matrix.os != 'windows-latest' }} 32 | - run: cargo test --no-default-features --features=no-backend,search -- --skip .rs 33 | - run: cargo test --no-default-features --features=tuirs-no-backend,search -- --skip .rs 34 | - uses: codecov/codecov-action@v4 35 | with: 36 | files: lcov.info 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | lint: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: dtolnay/rust-toolchain@stable 43 | with: 44 | components: clippy,rustfmt 45 | - uses: Swatinem/rust-cache@v2 46 | - run: cargo fmt -- --check 47 | - run: cargo clippy --examples --tests -- -D warnings 48 | - run: cargo clippy --examples --tests --features search,serde -- -D warnings 49 | - run: cargo clippy --examples --tests --no-default-features --features termion -- -D warnings 50 | - run: cargo clippy --examples --tests --no-default-features --features termion,search -- -D warnings 51 | - run: cargo clippy --examples --tests --no-default-features --features termwiz -- -D warnings 52 | - run: cargo clippy --examples --tests --no-default-features --features termwiz,search -- -D warnings 53 | - run: cargo clippy --examples --tests --no-default-features --features no-backend -- -D warnings 54 | - run: cargo clippy --examples --tests --no-default-features --features no-backend,search -- -D warnings 55 | - run: cargo clippy --examples --tests --no-default-features --features tuirs-crossterm -- -D warnings 56 | - run: cargo clippy --examples --tests --no-default-features --features tuirs-crossterm,search -- -D warnings 57 | - run: cargo clippy --examples --tests --no-default-features --features tuirs-termion -- -D warnings 58 | - run: cargo clippy --examples --tests --no-default-features --features tuirs-termion,search -- -D warnings 59 | - run: cargo clippy --examples --tests --no-default-features --features tuirs-no-backend -- -D warnings 60 | - run: cargo clippy --examples --tests --no-default-features --features tuirs-no-backend,search -- -D warnings 61 | - run: cargo rustdoc --features=search,termwiz,termion,serde -p tui-textarea -- -D warnings 62 | cargo-doc: 63 | runs-on: ubuntu-latest 64 | steps: 65 | - uses: actions/checkout@v4 66 | - uses: dtolnay/rust-toolchain@nightly 67 | - name: Run `cargo rustdoc` with same configuration as docs.rs 68 | run: | 69 | set -e 70 | md="$(cargo metadata --format-version=1 | jq '.packages[] | select(.name=="tui-textarea") | .metadata.docs.rs')" 71 | rustdoc_args="$(echo "$md" | jq -r '.["rustdoc-args"] | join(" ")') -D warnings" 72 | features="$(echo "$md" | jq -r '.features | join(",")')" 73 | 74 | set -x 75 | for target in $(echo "$md" | jq -r '.targets | join(" ")') 76 | do 77 | rustup target add "$target" 78 | cargo rustdoc -p tui-textarea "--features=$features" "--target=$target" -- $rustdoc_args 79 | done 80 | -------------------------------------------------------------------------------- /bench/benches/insert.rs: -------------------------------------------------------------------------------- 1 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 2 | use rand::rngs::SmallRng; 3 | use rand::{Rng, SeedableRng}; 4 | use tui_textarea::{CursorMove, Input, Key, TextArea}; 5 | use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM, SEED}; 6 | 7 | #[inline] 8 | fn append_lorem(repeat: usize) -> usize { 9 | let mut textarea = TextArea::default(); 10 | let mut term = dummy_terminal(); 11 | for _ in 0..repeat { 12 | for line in LOREM { 13 | for c in line.chars() { 14 | textarea.input(Input { 15 | key: Key::Char(c), 16 | ctrl: false, 17 | alt: false, 18 | shift: false, 19 | }); 20 | term.draw_textarea(&textarea); 21 | } 22 | } 23 | textarea.input(Input { 24 | key: Key::Enter, 25 | ctrl: false, 26 | alt: false, 27 | shift: false, 28 | }); 29 | term.draw_textarea(&textarea); 30 | } 31 | textarea.lines().len() 32 | } 33 | 34 | #[inline] 35 | fn random_lorem(repeat: usize) -> usize { 36 | let mut rng = SmallRng::from_seed(SEED); 37 | let mut textarea = TextArea::default(); 38 | let mut term = dummy_terminal(); 39 | 40 | for _ in 0..repeat { 41 | for line in LOREM { 42 | let row = rng.gen_range(0..textarea.lines().len() as u16); 43 | textarea.move_cursor(CursorMove::Jump(row, 0)); 44 | textarea.move_cursor(CursorMove::End); 45 | 46 | textarea.input(Input { 47 | key: Key::Enter, 48 | ctrl: false, 49 | alt: false, 50 | shift: false, 51 | }); 52 | term.draw_textarea(&textarea); 53 | 54 | for c in line.chars() { 55 | textarea.input(Input { 56 | key: Key::Char(c), 57 | ctrl: false, 58 | alt: false, 59 | shift: false, 60 | }); 61 | term.draw_textarea(&textarea); 62 | } 63 | } 64 | } 65 | 66 | textarea.lines().len() 67 | } 68 | 69 | #[inline] 70 | fn append_long_lorem(repeat: usize) -> usize { 71 | let mut textarea = TextArea::default(); 72 | let mut term = dummy_terminal(); 73 | 74 | for _ in 0..repeat { 75 | for line in LOREM { 76 | for c in line.chars() { 77 | textarea.input(Input { 78 | key: Key::Char(c), 79 | ctrl: false, 80 | alt: false, 81 | shift: false, 82 | }); 83 | term.draw_textarea(&textarea); 84 | } 85 | } 86 | } 87 | 88 | textarea.lines().len() 89 | } 90 | 91 | fn append(c: &mut Criterion) { 92 | c.bench_function("insert::append::1_lorem", |b| { 93 | b.iter(|| black_box(append_lorem(1))) 94 | }); 95 | c.bench_function("insert::append::10_lorem", |b| { 96 | b.iter(|| black_box(append_lorem(10))) 97 | }); 98 | c.bench_function("insert::append::50_lorem", |b| { 99 | b.iter(|| black_box(append_lorem(50))) 100 | }); 101 | } 102 | 103 | fn random(c: &mut Criterion) { 104 | c.bench_function("insert::random::1_lorem", |b| { 105 | b.iter(|| black_box(random_lorem(1))) 106 | }); 107 | c.bench_function("insert::random::10_lorem", |b| { 108 | b.iter(|| black_box(random_lorem(10))) 109 | }); 110 | c.bench_function("insert::random::50_lorem", |b| { 111 | b.iter(|| black_box(random_lorem(50))) 112 | }); 113 | } 114 | 115 | // Inserting a long line is slower than multiple short lines into `TextArea` 116 | fn long(c: &mut Criterion) { 117 | c.bench_function("insert::long::1_lorem", |b| { 118 | b.iter(|| black_box(append_long_lorem(1))) 119 | }); 120 | c.bench_function("insert::long::5_lorem", |b| { 121 | b.iter(|| black_box(append_long_lorem(5))) 122 | }); 123 | c.bench_function("insert::long::10_lorem", |b| { 124 | b.iter(|| black_box(append_long_lorem(10))) 125 | }); 126 | } 127 | 128 | criterion_group!(insert, append, random, long); 129 | criterion_main!(insert); 130 | -------------------------------------------------------------------------------- /src/input/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(any(feature = "crossterm", feature = "tuirs-crossterm"))] 2 | mod crossterm; 3 | #[cfg(any(feature = "termion", feature = "tuirs-termion"))] 4 | mod termion; 5 | #[cfg(feature = "termwiz")] 6 | mod termwiz; 7 | 8 | #[cfg(feature = "arbitrary")] 9 | use arbitrary::Arbitrary; 10 | #[cfg(feature = "serde")] 11 | use serde::{Deserialize, Serialize}; 12 | 13 | /// Backend-agnostic key input kind. 14 | /// 15 | /// This type is marked as `#[non_exhaustive]` since more keys may be supported in the future. 16 | #[non_exhaustive] 17 | #[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)] 18 | #[cfg_attr(feature = "arbitrary", derive(Arbitrary))] 19 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 20 | pub enum Key { 21 | /// Normal letter key input 22 | Char(char), 23 | /// F1, F2, F3, ... keys 24 | F(u8), 25 | /// Backspace key 26 | Backspace, 27 | /// Enter or return key 28 | Enter, 29 | /// Left arrow key 30 | Left, 31 | /// Right arrow key 32 | Right, 33 | /// Up arrow key 34 | Up, 35 | /// Down arrow key 36 | Down, 37 | /// Tab key 38 | Tab, 39 | /// Delete key 40 | Delete, 41 | /// Home key 42 | Home, 43 | /// End key 44 | End, 45 | /// Page up key 46 | PageUp, 47 | /// Page down key 48 | PageDown, 49 | /// Escape key 50 | Esc, 51 | /// Copy key. This key is supported by termwiz only 52 | Copy, 53 | /// Cut key. This key is supported by termwiz only 54 | Cut, 55 | /// Paste key. This key is supported by termwiz only 56 | Paste, 57 | /// Virtual key to scroll down by mouse 58 | MouseScrollDown, 59 | /// Virtual key to scroll up by mouse 60 | MouseScrollUp, 61 | /// An invalid key input (this key is always ignored by [`TextArea`](crate::TextArea)) 62 | Null, 63 | } 64 | 65 | impl Default for Key { 66 | fn default() -> Self { 67 | Key::Null 68 | } 69 | } 70 | 71 | /// Backend-agnostic key input type. 72 | /// 73 | /// When `crossterm`, `termion`, `termwiz` features are enabled, converting respective key input types into this 74 | /// `Input` type is defined. 75 | /// ```no_run 76 | /// use tui_textarea::{TextArea, Input, Key}; 77 | /// use crossterm::event::{Event, read}; 78 | /// 79 | /// let event = read().unwrap(); 80 | /// 81 | /// // `Input::from` can convert backend-native event into `Input` 82 | /// let input = Input::from(event.clone()); 83 | /// // or `Into::into` 84 | /// let input: Input = event.clone().into(); 85 | /// // Conversion from `KeyEvent` value is also available 86 | /// if let Event::Key(key) = event { 87 | /// let input = Input::from(key); 88 | /// } 89 | /// ``` 90 | /// 91 | /// Creating `Input` instance directly can cause backend-agnostic input as follows. 92 | /// 93 | /// ``` 94 | /// use tui_textarea::{TextArea, Input, Key}; 95 | /// 96 | /// let mut textarea = TextArea::default(); 97 | /// 98 | /// // Input Ctrl+A 99 | /// textarea.input(Input { 100 | /// key: Key::Char('a'), 101 | /// ctrl: true, 102 | /// alt: false, 103 | /// shift: false, 104 | /// }); 105 | /// ``` 106 | #[derive(Debug, Clone, Default, PartialEq, Hash, Eq)] 107 | #[cfg_attr(feature = "arbitrary", derive(Arbitrary))] 108 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 109 | pub struct Input { 110 | /// Typed key. 111 | pub key: Key, 112 | /// Ctrl modifier key. `true` means Ctrl key was pressed. 113 | pub ctrl: bool, 114 | /// Alt modifier key. `true` means Alt key was pressed. 115 | pub alt: bool, 116 | /// Shift modifier key. `true` means Shift key was pressed. 117 | pub shift: bool, 118 | } 119 | 120 | #[cfg(test)] 121 | mod tests { 122 | use super::*; 123 | 124 | #[allow(dead_code)] 125 | pub(crate) fn input(key: Key, ctrl: bool, alt: bool, shift: bool) -> Input { 126 | Input { 127 | key, 128 | ctrl, 129 | alt, 130 | shift, 131 | } 132 | } 133 | 134 | #[test] 135 | #[cfg(feature = "arbitrary")] 136 | fn arbitrary_input() { 137 | let mut u = arbitrary::Unstructured::new(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); 138 | Input::arbitrary(&mut u).unwrap(); 139 | } 140 | 141 | #[test] 142 | #[cfg(feature = "arbitrary")] 143 | fn arbitrary_key() { 144 | let mut u = arbitrary::Unstructured::new(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); 145 | Key::arbitrary(&mut u).unwrap(); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/search.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "search")] 2 | 3 | use tui_textarea::{CursorMove, TextArea}; 4 | 5 | #[test] 6 | fn search_lines_forward() { 7 | #[rustfmt::skip] 8 | let mut textarea = TextArea::from([ 9 | "fooo foo", 10 | "foo fo foo fooo", 11 | "foooo", 12 | ]); 13 | 14 | // Move to 'f' on 'fo' at line 2 15 | textarea.move_cursor(CursorMove::Jump(1, 4)); 16 | 17 | textarea.set_search_pattern("fo+").unwrap(); 18 | 19 | let expected = [(1, 7), (1, 11), (2, 0), (0, 0), (0, 5), (1, 0), (1, 4)]; 20 | for (i, pos) in expected.into_iter().enumerate() { 21 | let moved = textarea.search_forward(false); 22 | let cursor = textarea.cursor(); 23 | assert!(moved, "{}th move didn't happen: {:?}", i + 1, cursor); 24 | assert_eq!(pos, cursor, "{}th position is unexpected", i + 1); 25 | } 26 | } 27 | 28 | #[test] 29 | fn search_lines_backward() { 30 | #[rustfmt::skip] 31 | let mut textarea = TextArea::from([ 32 | "fooo foo", 33 | "foo fo foo fooo", 34 | "foooo", 35 | ]); 36 | 37 | // Move to 'f' on 'fo' at line 2 38 | textarea.move_cursor(CursorMove::Jump(1, 4)); 39 | 40 | textarea.set_search_pattern("fo+").unwrap(); 41 | 42 | let expected = [(1, 0), (0, 5), (0, 0), (2, 0), (1, 11), (1, 7), (1, 4)]; 43 | for (i, pos) in expected.into_iter().enumerate() { 44 | let moved = textarea.search_back(false); 45 | let cursor = textarea.cursor(); 46 | assert!(moved, "{}th move didn't happen: {:?}", i + 1, cursor); 47 | assert_eq!(pos, cursor, "{}th position is unexpected", i + 1); 48 | } 49 | } 50 | 51 | #[test] 52 | fn search_forward_within_line() { 53 | let mut textarea = TextArea::from(["foo fo foo fooo"]); 54 | 55 | // Move to 'f' on 'fo' 56 | textarea.move_cursor(CursorMove::Jump(0, 4)); 57 | 58 | textarea.set_search_pattern("fo+").unwrap(); 59 | 60 | let expected = [(0, 7), (0, 11), (0, 0), (0, 4)]; 61 | for (i, pos) in expected.into_iter().enumerate() { 62 | let moved = textarea.search_forward(false); 63 | let cursor = textarea.cursor(); 64 | assert!(moved, "{}th move didn't happen: {:?}", i + 1, cursor); 65 | assert_eq!(pos, cursor, "{}th position is unexpected", i + 1); 66 | } 67 | } 68 | 69 | #[test] 70 | fn search_backward_within_line() { 71 | let mut textarea = TextArea::from(["foo fo foo fooo"]); 72 | 73 | // Move to 'f' on 'fo' 74 | textarea.move_cursor(CursorMove::Jump(0, 4)); 75 | 76 | textarea.set_search_pattern("fo+").unwrap(); 77 | 78 | let expected = [(0, 0), (0, 11), (0, 7), (0, 4)]; 79 | for (i, pos) in expected.into_iter().enumerate() { 80 | let moved = textarea.search_back(false); 81 | let cursor = textarea.cursor(); 82 | assert!(moved, "{}th move didn't happen: {:?}", i + 1, cursor); 83 | assert_eq!(pos, cursor, "{}th position is unexpected", i + 1); 84 | } 85 | } 86 | 87 | #[test] 88 | fn search_not_found() { 89 | let mut textarea = TextArea::from(["fo fo fo fo"]); 90 | textarea.set_search_pattern("foo+").unwrap(); 91 | 92 | assert!(!textarea.search_forward(false)); 93 | assert!(!textarea.search_back(false)); 94 | } 95 | 96 | #[test] 97 | fn accept_cursor_position() { 98 | let mut textarea = TextArea::from(["foooo fooooooo"]); 99 | textarea.set_search_pattern("foo+").unwrap(); 100 | 101 | let cursor = textarea.cursor(); 102 | assert!(textarea.search_forward(true)); 103 | assert_eq!(textarea.cursor(), cursor); 104 | assert!(textarea.search_back(true)); 105 | assert_eq!(textarea.cursor(), cursor); 106 | } 107 | 108 | #[test] 109 | fn set_search_pattern() { 110 | let mut textarea = TextArea::from(["foo"]); 111 | 112 | assert!(textarea.search_pattern().is_none()); 113 | assert!(!textarea.search_forward(true)); 114 | assert!(!textarea.search_forward(false)); 115 | assert!(!textarea.search_back(true)); 116 | assert!(!textarea.search_back(false)); 117 | 118 | textarea.set_search_pattern("(foo").unwrap_err(); 119 | assert!(textarea.search_pattern().is_none()); 120 | 121 | textarea.set_search_pattern("(fo+)ba+r").unwrap(); 122 | let pat = textarea.search_pattern().unwrap(); 123 | assert_eq!(pat.as_str(), "(fo+)ba+r"); 124 | 125 | textarea.set_search_pattern("fo+").unwrap(); 126 | textarea.set_search_pattern("").unwrap(); 127 | assert!(textarea.search_pattern().is_none()); 128 | assert!(!textarea.search_forward(true)); 129 | assert!(!textarea.search_forward(false)); 130 | assert!(!textarea.search_back(true)); 131 | assert!(!textarea.search_back(false)); 132 | } 133 | -------------------------------------------------------------------------------- /bench/benches/cursor.rs: -------------------------------------------------------------------------------- 1 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 2 | use tui_textarea::{CursorMove, TextArea}; 3 | use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; 4 | 5 | #[derive(Clone, Copy)] 6 | enum Restore { 7 | TopLeft, 8 | BottomLeft, 9 | BottomRight, 10 | None, 11 | } 12 | 13 | impl Restore { 14 | fn cursor_move(self) -> Option { 15 | match self { 16 | Self::TopLeft => Some(CursorMove::Jump(0, 0)), 17 | Self::BottomLeft => Some(CursorMove::Jump(u16::MAX, 0)), 18 | Self::BottomRight => Some(CursorMove::Jump(u16::MAX, u16::MAX)), 19 | Self::None => None, 20 | } 21 | } 22 | } 23 | 24 | fn prepare_textarea() -> TextArea<'static> { 25 | let mut lines = Vec::with_capacity(LOREM.len() * 2 + 1); 26 | lines.extend(LOREM.iter().map(|s| s.to_string())); 27 | lines.push("".to_string()); 28 | lines.extend(LOREM.iter().map(|s| s.to_string())); 29 | TextArea::new(lines) 30 | } 31 | 32 | fn run( 33 | mut textarea: TextArea<'_>, 34 | moves: &[CursorMove], 35 | restore: Restore, 36 | repeat: usize, 37 | ) -> (usize, usize) { 38 | let mut term = dummy_terminal(); 39 | 40 | let mut prev = textarea.cursor(); 41 | for _ in 0..repeat { 42 | for m in moves { 43 | textarea.move_cursor(*m); 44 | term.draw_textarea(&textarea); 45 | } 46 | if let Some(m) = restore.cursor_move() { 47 | if textarea.cursor() == prev { 48 | textarea.move_cursor(m); 49 | prev = textarea.cursor(); 50 | } 51 | } 52 | } 53 | 54 | textarea.cursor() 55 | } 56 | 57 | fn move_char(c: &mut Criterion) { 58 | let textarea = prepare_textarea(); 59 | c.bench_function("cursor::char::forward", |b| { 60 | b.iter(|| { 61 | black_box(run( 62 | textarea.clone(), 63 | &[CursorMove::Forward], 64 | Restore::TopLeft, 65 | 1000, 66 | )) 67 | }) 68 | }); 69 | c.bench_function("cursor::char::back", |b| { 70 | b.iter(|| { 71 | black_box(run( 72 | textarea.clone(), 73 | &[CursorMove::Back], 74 | Restore::BottomRight, 75 | 1000, 76 | )) 77 | }) 78 | }); 79 | c.bench_function("cursor::char::down", |b| { 80 | b.iter(|| { 81 | black_box(run( 82 | textarea.clone(), 83 | &[CursorMove::Down], 84 | Restore::TopLeft, 85 | 1000, 86 | )) 87 | }) 88 | }); 89 | c.bench_function("cursor::char::up", |b| { 90 | b.iter(|| { 91 | black_box(run( 92 | textarea.clone(), 93 | &[CursorMove::Up], 94 | Restore::BottomLeft, 95 | 1000, 96 | )) 97 | }) 98 | }); 99 | } 100 | fn move_word(c: &mut Criterion) { 101 | let textarea = prepare_textarea(); 102 | c.bench_function("cursor::word::forward", |b| { 103 | b.iter(|| { 104 | black_box(run( 105 | textarea.clone(), 106 | &[CursorMove::WordForward], 107 | Restore::TopLeft, 108 | 1000, 109 | )) 110 | }) 111 | }); 112 | c.bench_function("cursor::word::back", |b| { 113 | b.iter(|| { 114 | black_box(run( 115 | textarea.clone(), 116 | &[CursorMove::WordBack], 117 | Restore::BottomRight, 118 | 1000, 119 | )) 120 | }) 121 | }); 122 | } 123 | fn move_paragraph(c: &mut Criterion) { 124 | let textarea = prepare_textarea(); 125 | c.bench_function("cursor::paragraph::down", |b| { 126 | b.iter(|| { 127 | black_box(run( 128 | textarea.clone(), 129 | &[CursorMove::ParagraphForward], 130 | Restore::TopLeft, 131 | 1000, 132 | )) 133 | }) 134 | }); 135 | c.bench_function("cursor::paragraph::up", |b| { 136 | b.iter(|| { 137 | black_box(run( 138 | textarea.clone(), 139 | &[CursorMove::ParagraphBack], 140 | Restore::BottomLeft, 141 | 1000, 142 | )) 143 | }) 144 | }); 145 | } 146 | fn move_edge(c: &mut Criterion) { 147 | let textarea = prepare_textarea(); 148 | c.bench_function("cursor::edge::head_end", |b| { 149 | b.iter(|| { 150 | black_box(run( 151 | textarea.clone(), 152 | &[CursorMove::End, CursorMove::Head], 153 | Restore::None, 154 | 500, 155 | )) 156 | }) 157 | }); 158 | c.bench_function("cursor::edge::top_bottom", |b| { 159 | b.iter(|| { 160 | black_box(run( 161 | textarea.clone(), 162 | &[CursorMove::Bottom, CursorMove::Top], 163 | Restore::None, 164 | 500, 165 | )) 166 | }) 167 | }); 168 | } 169 | 170 | criterion_group!(cursor, move_char, move_word, move_paragraph, move_edge); 171 | criterion_main!(cursor); 172 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | use crate::ratatui::style::{Color, Style}; 2 | use regex::Regex; 3 | 4 | #[derive(Clone, Debug)] 5 | pub struct Search { 6 | pub pat: Option, 7 | pub style: Style, 8 | } 9 | 10 | impl Default for Search { 11 | fn default() -> Self { 12 | Self { 13 | pat: None, 14 | style: Style::default().bg(Color::Blue), 15 | } 16 | } 17 | } 18 | 19 | impl Search { 20 | pub fn matches<'a>( 21 | &'a self, 22 | line: &'a str, 23 | ) -> Option + 'a> { 24 | let pat = self.pat.as_ref()?; 25 | let matches = pat.find_iter(line).map(|m| (m.start(), m.end())); 26 | Some(matches) 27 | } 28 | 29 | pub fn set_pattern(&mut self, query: &str) -> Result<(), regex::Error> { 30 | match &self.pat { 31 | Some(r) if r.as_str() == query => {} 32 | _ if query.is_empty() => self.pat = None, 33 | _ => self.pat = Some(Regex::new(query)?), 34 | } 35 | Ok(()) 36 | } 37 | 38 | pub fn forward( 39 | &mut self, 40 | lines: &[String], 41 | cursor: (usize, usize), 42 | match_cursor: bool, 43 | ) -> Option<(usize, usize)> { 44 | let pat = if let Some(pat) = &self.pat { 45 | pat 46 | } else { 47 | return None; 48 | }; 49 | let (row, col) = cursor; 50 | let current_line = &lines[row]; 51 | 52 | // Search current line after cursor 53 | let start_col = if match_cursor { col } else { col + 1 }; 54 | if let Some((i, _)) = current_line.char_indices().nth(start_col) { 55 | if let Some(m) = pat.find_at(current_line, i) { 56 | let col = start_col + current_line[i..m.start()].chars().count(); 57 | return Some((row, col)); 58 | } 59 | } 60 | 61 | // Search lines after cursor 62 | for (i, line) in lines[row + 1..].iter().enumerate() { 63 | if let Some(m) = pat.find(line) { 64 | let col = line[..m.start()].chars().count(); 65 | return Some((row + 1 + i, col)); 66 | } 67 | } 68 | 69 | // Search lines before cursor (wrap) 70 | for (i, line) in lines[..row].iter().enumerate() { 71 | if let Some(m) = pat.find(line) { 72 | let col = line[..m.start()].chars().count(); 73 | return Some((i, col)); 74 | } 75 | } 76 | 77 | // Search current line before cursor 78 | let col_idx = current_line 79 | .char_indices() 80 | .nth(col) 81 | .map(|(i, _)| i) 82 | .unwrap_or(current_line.len()); 83 | if let Some(m) = pat.find(current_line) { 84 | let i = m.start(); 85 | if i <= col_idx { 86 | let col = current_line[..i].chars().count(); 87 | return Some((row, col)); 88 | } 89 | } 90 | 91 | None 92 | } 93 | 94 | pub fn back( 95 | &mut self, 96 | lines: &[String], 97 | cursor: (usize, usize), 98 | match_cursor: bool, 99 | ) -> Option<(usize, usize)> { 100 | let pat = if let Some(pat) = &self.pat { 101 | pat 102 | } else { 103 | return None; 104 | }; 105 | let (row, col) = cursor; 106 | let current_line = &lines[row]; 107 | 108 | // Search current line before cursor 109 | if col > 0 || match_cursor { 110 | let start_col = if match_cursor { col } else { col - 1 }; 111 | if let Some((i, _)) = current_line.char_indices().nth(start_col) { 112 | if let Some(m) = pat 113 | .find_iter(current_line) 114 | .take_while(|m| m.start() <= i) 115 | .last() 116 | { 117 | let col = current_line[..m.start()].chars().count(); 118 | return Some((row, col)); 119 | } 120 | } 121 | } 122 | 123 | // Search lines before cursor 124 | for (i, line) in lines[..row].iter().enumerate().rev() { 125 | if let Some(m) = pat.find_iter(line).last() { 126 | let col = line[..m.start()].chars().count(); 127 | return Some((i, col)); 128 | } 129 | } 130 | 131 | // Search lines after cursor (wrap) 132 | for (i, line) in lines[row + 1..].iter().enumerate().rev() { 133 | if let Some(m) = pat.find_iter(line).last() { 134 | let col = line[..m.start()].chars().count(); 135 | return Some((row + 1 + i, col)); 136 | } 137 | } 138 | 139 | // Search current line after cursor 140 | if let Some((i, _)) = current_line.char_indices().nth(col) { 141 | if let Some(m) = pat 142 | .find_iter(current_line) 143 | .skip_while(|m| m.start() < i) 144 | .last() 145 | { 146 | let col = col + current_line[i..m.start()].chars().count(); 147 | return Some((row, col)); 148 | } 149 | } 150 | 151 | None 152 | } 153 | } 154 | 155 | #[cfg(test)] 156 | mod tests { 157 | use super::*; 158 | 159 | #[test] 160 | fn matches() { 161 | let mut s = Search::default(); 162 | s.set_pattern("fo+").unwrap(); 163 | 164 | let m: Vec<_> = s.matches("fo foo bar fooo").unwrap().collect(); 165 | assert_eq!(m, [(0, 2), (3, 6), (11, 15)]); 166 | 167 | s.set_pattern("").unwrap(); 168 | assert!(s.matches("fo foo bar fooo").is_none()); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/widget.rs: -------------------------------------------------------------------------------- 1 | use crate::ratatui::buffer::Buffer; 2 | use crate::ratatui::layout::Rect; 3 | use crate::ratatui::text::{Span, Text}; 4 | use crate::ratatui::widgets::{Paragraph, Widget}; 5 | use crate::textarea::TextArea; 6 | use crate::util::num_digits; 7 | #[cfg(feature = "ratatui")] 8 | use ratatui::text::Line; 9 | use std::cmp; 10 | use std::sync::atomic::{AtomicU64, Ordering}; 11 | #[cfg(feature = "tuirs")] 12 | use tui::text::Spans as Line; 13 | 14 | // &mut 'a (u16, u16, u16, u16) is not available since `render` method takes immutable reference of TextArea 15 | // instance. In the case, the TextArea instance cannot be accessed from any other objects since it is mutablly 16 | // borrowed. 17 | // 18 | // `ratatui::Frame::render_stateful_widget` would be an assumed way to render a stateful widget. But at this 19 | // point we stick with using `ratatui::Frame::render_widget` because it is simpler API. Users don't need to 20 | // manage states of textarea instances separately. 21 | // https://docs.rs/ratatui/latest/ratatui/terminal/struct.Frame.html#method.render_stateful_widget 22 | #[derive(Default, Debug)] 23 | pub struct Viewport(AtomicU64); 24 | 25 | impl Clone for Viewport { 26 | fn clone(&self) -> Self { 27 | let u = self.0.load(Ordering::Relaxed); 28 | Viewport(AtomicU64::new(u)) 29 | } 30 | } 31 | 32 | impl Viewport { 33 | pub fn scroll_top(&self) -> (u16, u16) { 34 | let u = self.0.load(Ordering::Relaxed); 35 | ((u >> 16) as u16, u as u16) 36 | } 37 | 38 | pub fn rect(&self) -> (u16, u16, u16, u16) { 39 | let u = self.0.load(Ordering::Relaxed); 40 | let width = (u >> 48) as u16; 41 | let height = (u >> 32) as u16; 42 | let row = (u >> 16) as u16; 43 | let col = u as u16; 44 | (row, col, width, height) 45 | } 46 | 47 | pub fn position(&self) -> (u16, u16, u16, u16) { 48 | let (row_top, col_top, width, height) = self.rect(); 49 | let row_bottom = row_top.saturating_add(height).saturating_sub(1); 50 | let col_bottom = col_top.saturating_add(width).saturating_sub(1); 51 | 52 | ( 53 | row_top, 54 | col_top, 55 | cmp::max(row_top, row_bottom), 56 | cmp::max(col_top, col_bottom), 57 | ) 58 | } 59 | 60 | fn store(&self, row: u16, col: u16, width: u16, height: u16) { 61 | // Pack four u16 values into one u64 value 62 | let u = 63 | ((width as u64) << 48) | ((height as u64) << 32) | ((row as u64) << 16) | col as u64; 64 | self.0.store(u, Ordering::Relaxed); 65 | } 66 | 67 | pub fn scroll(&mut self, rows: i16, cols: i16) { 68 | fn apply_scroll(pos: u16, delta: i16) -> u16 { 69 | if delta >= 0 { 70 | pos.saturating_add(delta as u16) 71 | } else { 72 | pos.saturating_sub(-delta as u16) 73 | } 74 | } 75 | 76 | let u = self.0.get_mut(); 77 | let row = apply_scroll((*u >> 16) as u16, rows); 78 | let col = apply_scroll(*u as u16, cols); 79 | *u = (*u & 0xffff_ffff_0000_0000) | ((row as u64) << 16) | (col as u64); 80 | } 81 | } 82 | 83 | #[inline] 84 | fn next_scroll_top(prev_top: u16, cursor: u16, len: u16) -> u16 { 85 | if cursor < prev_top { 86 | cursor 87 | } else if prev_top + len <= cursor { 88 | cursor + 1 - len 89 | } else { 90 | prev_top 91 | } 92 | } 93 | 94 | impl<'a> TextArea<'a> { 95 | fn text_widget(&'a self, top_row: usize, height: usize) -> Text<'a> { 96 | let lines_len = self.lines().len(); 97 | let lnum_len = num_digits(lines_len); 98 | let bottom_row = cmp::min(top_row + height, lines_len); 99 | let mut lines = Vec::with_capacity(bottom_row - top_row); 100 | for (i, line) in self.lines()[top_row..bottom_row].iter().enumerate() { 101 | lines.push(self.line_spans(line.as_str(), top_row + i, lnum_len)); 102 | } 103 | Text::from(lines) 104 | } 105 | 106 | fn placeholder_widget(&'a self) -> Text<'a> { 107 | let cursor = Span::styled(" ", self.cursor_style); 108 | let text = Span::raw(self.placeholder.as_str()); 109 | Text::from(Line::from(vec![cursor, text])) 110 | } 111 | 112 | fn scroll_top_row(&self, prev_top: u16, height: u16) -> u16 { 113 | next_scroll_top(prev_top, self.cursor().0 as u16, height) 114 | } 115 | 116 | fn scroll_top_col(&self, prev_top: u16, width: u16) -> u16 { 117 | let mut cursor = self.cursor().1 as u16; 118 | // Adjust the cursor position due to the width of line number. 119 | if self.line_number_style().is_some() { 120 | let lnum = num_digits(self.lines().len()) as u16 + 2; // `+ 2` for margins 121 | if cursor <= lnum { 122 | cursor *= 2; // Smoothly slide the line number into the screen on scrolling left 123 | } else { 124 | cursor += lnum; // The cursor position is shifted by the line number part 125 | }; 126 | } 127 | next_scroll_top(prev_top, cursor, width) 128 | } 129 | } 130 | 131 | impl Widget for &TextArea<'_> { 132 | fn render(self, area: Rect, buf: &mut Buffer) { 133 | let Rect { width, height, .. } = if let Some(b) = self.block() { 134 | b.inner(area) 135 | } else { 136 | area 137 | }; 138 | 139 | let (top_row, top_col) = self.viewport.scroll_top(); 140 | let top_row = self.scroll_top_row(top_row, height); 141 | let top_col = self.scroll_top_col(top_col, width); 142 | 143 | let (text, style) = if !self.placeholder.is_empty() && self.is_empty() { 144 | (self.placeholder_widget(), self.placeholder_style) 145 | } else { 146 | (self.text_widget(top_row as _, height as _), self.style()) 147 | }; 148 | 149 | // To get fine control over the text color and the surrrounding block they have to be rendered separately 150 | // see https://github.com/ratatui/ratatui/issues/144 151 | let mut text_area = area; 152 | let mut inner = Paragraph::new(text) 153 | .style(style) 154 | .alignment(self.alignment()); 155 | if let Some(b) = self.block() { 156 | text_area = b.inner(area); 157 | // ratatui does not need `clone()` call because `Block` implements `WidgetRef` and `&T` implements `Widget` 158 | // where `T: WidgetRef`. So `b.render` internally calls `b.render_ref` and it doesn't move out `self`. 159 | #[cfg(feature = "tuirs")] 160 | let b = b.clone(); 161 | b.render(area, buf) 162 | } 163 | if top_col != 0 { 164 | inner = inner.scroll((0, top_col)); 165 | } 166 | 167 | // Store scroll top position for rendering on the next tick 168 | self.viewport.store(top_row, top_col, width, height); 169 | 170 | inner.render(text_area, buf); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/input/crossterm.rs: -------------------------------------------------------------------------------- 1 | use super::{Input, Key}; 2 | use crate::crossterm::event::{ 3 | Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, 4 | }; 5 | 6 | impl From for Input { 7 | /// Convert [`crossterm::event::Event`] into [`Input`]. 8 | fn from(event: Event) -> Self { 9 | match event { 10 | Event::Key(key) => Self::from(key), 11 | Event::Mouse(mouse) => Self::from(mouse), 12 | _ => Self::default(), 13 | } 14 | } 15 | } 16 | 17 | impl From for Key { 18 | /// Convert [`crossterm::event::KeyCode`] into [`Key`]. 19 | fn from(code: KeyCode) -> Self { 20 | match code { 21 | KeyCode::Char(c) => Key::Char(c), 22 | KeyCode::Backspace => Key::Backspace, 23 | KeyCode::Enter => Key::Enter, 24 | KeyCode::Left => Key::Left, 25 | KeyCode::Right => Key::Right, 26 | KeyCode::Up => Key::Up, 27 | KeyCode::Down => Key::Down, 28 | KeyCode::Tab => Key::Tab, 29 | KeyCode::Delete => Key::Delete, 30 | KeyCode::Home => Key::Home, 31 | KeyCode::End => Key::End, 32 | KeyCode::PageUp => Key::PageUp, 33 | KeyCode::PageDown => Key::PageDown, 34 | KeyCode::Esc => Key::Esc, 35 | KeyCode::F(x) => Key::F(x), 36 | _ => Key::Null, 37 | } 38 | } 39 | } 40 | 41 | impl From for Input { 42 | /// Convert [`crossterm::event::KeyEvent`] into [`Input`]. 43 | fn from(key: KeyEvent) -> Self { 44 | if key.kind == KeyEventKind::Release { 45 | // On Windows or when `crossterm::event::PushKeyboardEnhancementFlags` is set, 46 | // key release event can be reported. Ignore it. (#14) 47 | return Self::default(); 48 | } 49 | 50 | let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); 51 | let alt = key.modifiers.contains(KeyModifiers::ALT); 52 | let shift = key.modifiers.contains(KeyModifiers::SHIFT); 53 | let key = Key::from(key.code); 54 | 55 | Self { 56 | key, 57 | ctrl, 58 | alt, 59 | shift, 60 | } 61 | } 62 | } 63 | 64 | impl From for Key { 65 | /// Convert [`crossterm::event::MouseEventKind`] into [`Key`]. 66 | fn from(kind: MouseEventKind) -> Self { 67 | match kind { 68 | MouseEventKind::ScrollDown => Key::MouseScrollDown, 69 | MouseEventKind::ScrollUp => Key::MouseScrollUp, 70 | _ => Key::Null, 71 | } 72 | } 73 | } 74 | 75 | impl From for Input { 76 | /// Convert [`crossterm::event::MouseEvent`] into [`Input`]. 77 | fn from(mouse: MouseEvent) -> Self { 78 | let key = Key::from(mouse.kind); 79 | let ctrl = mouse.modifiers.contains(KeyModifiers::CONTROL); 80 | let alt = mouse.modifiers.contains(KeyModifiers::ALT); 81 | let shift = mouse.modifiers.contains(KeyModifiers::SHIFT); 82 | Self { 83 | key, 84 | ctrl, 85 | alt, 86 | shift, 87 | } 88 | } 89 | } 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | use super::*; 94 | use crate::crossterm::event::KeyEventState; 95 | use crate::input::tests::input; 96 | 97 | fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { 98 | KeyEvent { 99 | code, 100 | modifiers, 101 | kind: KeyEventKind::Press, 102 | state: KeyEventState::empty(), 103 | } 104 | } 105 | 106 | fn mouse_event(kind: MouseEventKind, modifiers: KeyModifiers) -> MouseEvent { 107 | MouseEvent { 108 | kind, 109 | column: 1, 110 | row: 1, 111 | modifiers, 112 | } 113 | } 114 | 115 | #[test] 116 | fn key_to_input() { 117 | for (from, to) in [ 118 | ( 119 | key_event(KeyCode::Char('a'), KeyModifiers::empty()), 120 | input(Key::Char('a'), false, false, false), 121 | ), 122 | ( 123 | key_event(KeyCode::Enter, KeyModifiers::empty()), 124 | input(Key::Enter, false, false, false), 125 | ), 126 | ( 127 | key_event(KeyCode::Left, KeyModifiers::CONTROL), 128 | input(Key::Left, true, false, false), 129 | ), 130 | ( 131 | key_event(KeyCode::Right, KeyModifiers::SHIFT), 132 | input(Key::Right, false, false, true), 133 | ), 134 | ( 135 | key_event(KeyCode::Home, KeyModifiers::ALT), 136 | input(Key::Home, false, true, false), 137 | ), 138 | ( 139 | key_event( 140 | KeyCode::F(1), 141 | KeyModifiers::ALT | KeyModifiers::CONTROL | KeyModifiers::SHIFT, 142 | ), 143 | input(Key::F(1), true, true, true), 144 | ), 145 | ( 146 | key_event(KeyCode::NumLock, KeyModifiers::CONTROL), 147 | input(Key::Null, true, false, false), 148 | ), 149 | ] { 150 | assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to); 151 | } 152 | } 153 | 154 | #[test] 155 | fn mouse_to_input() { 156 | for (from, to) in [ 157 | ( 158 | mouse_event(MouseEventKind::ScrollDown, KeyModifiers::empty()), 159 | input(Key::MouseScrollDown, false, false, false), 160 | ), 161 | ( 162 | mouse_event(MouseEventKind::ScrollUp, KeyModifiers::CONTROL), 163 | input(Key::MouseScrollUp, true, false, false), 164 | ), 165 | ( 166 | mouse_event(MouseEventKind::ScrollUp, KeyModifiers::SHIFT), 167 | input(Key::MouseScrollUp, false, false, true), 168 | ), 169 | ( 170 | mouse_event(MouseEventKind::ScrollDown, KeyModifiers::ALT), 171 | input(Key::MouseScrollDown, false, true, false), 172 | ), 173 | ( 174 | mouse_event( 175 | MouseEventKind::ScrollUp, 176 | KeyModifiers::CONTROL | KeyModifiers::ALT, 177 | ), 178 | input(Key::MouseScrollUp, true, true, false), 179 | ), 180 | ( 181 | mouse_event(MouseEventKind::Moved, KeyModifiers::CONTROL), 182 | input(Key::Null, true, false, false), 183 | ), 184 | ] { 185 | assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to); 186 | } 187 | } 188 | 189 | #[test] 190 | fn event_to_input() { 191 | for (from, to) in [ 192 | ( 193 | Event::Key(key_event(KeyCode::Char('a'), KeyModifiers::empty())), 194 | input(Key::Char('a'), false, false, false), 195 | ), 196 | ( 197 | Event::Mouse(mouse_event( 198 | MouseEventKind::ScrollDown, 199 | KeyModifiers::empty(), 200 | )), 201 | input(Key::MouseScrollDown, false, false, false), 202 | ), 203 | (Event::FocusGained, input(Key::Null, false, false, false)), 204 | ] { 205 | assert_eq!(Input::from(from.clone()), to, "{:?} -> {:?}", from, to); 206 | } 207 | } 208 | 209 | // Regression for https://github.com/rhysd/tui-textarea/issues/14 210 | #[test] 211 | fn ignore_key_release_event() { 212 | let mut from = key_event(KeyCode::Char('a'), KeyModifiers::empty()); 213 | from.kind = KeyEventKind::Release; 214 | let to = input(Key::Null, false, false, false); 215 | assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/input/termion.rs: -------------------------------------------------------------------------------- 1 | use super::{Input, Key}; 2 | use crate::termion::event::{Event, Key as KeyEvent, MouseButton, MouseEvent}; 3 | 4 | impl From for Input { 5 | /// Convert [`termion::event::Event`] into [`Input`]. 6 | fn from(event: Event) -> Self { 7 | match event { 8 | Event::Key(key) => Self::from(key), 9 | Event::Mouse(mouse) => Self::from(mouse), 10 | _ => Self::default(), 11 | } 12 | } 13 | } 14 | 15 | impl From for Input { 16 | /// Convert [`termion::event::Key`] into [`Input`]. 17 | /// 18 | /// termion does not provide a way to get Shift key's state. Instead termion passes key inputs as-is. For example, 19 | /// when 'Shift + A' is pressed with US keyboard, termion passes `termion::event::Key::Char('A')`. We cannot know 20 | /// how the 'A' character was input. 21 | /// 22 | /// So the `shift` field of the returned `Input` instance is always `false` except for combinations with arrow keys. 23 | /// For example, `termion::event::Key::Char('A')` is converted to `Input { key: Key::Char('A'), shift: false, .. }`. 24 | fn from(key: KeyEvent) -> Self { 25 | #[cfg(feature = "termion")] 26 | let (ctrl, alt, shift) = match key { 27 | KeyEvent::Ctrl(_) 28 | | KeyEvent::CtrlUp 29 | | KeyEvent::CtrlRight 30 | | KeyEvent::CtrlDown 31 | | KeyEvent::CtrlLeft 32 | | KeyEvent::CtrlHome 33 | | KeyEvent::CtrlEnd => (true, false, false), 34 | KeyEvent::Alt(_) 35 | | KeyEvent::AltUp 36 | | KeyEvent::AltRight 37 | | KeyEvent::AltDown 38 | | KeyEvent::AltLeft => (false, true, false), 39 | KeyEvent::ShiftUp 40 | | KeyEvent::ShiftRight 41 | | KeyEvent::ShiftDown 42 | | KeyEvent::ShiftLeft => (false, false, true), 43 | _ => (false, false, false), 44 | }; 45 | 46 | #[cfg(feature = "tuirs-termion")] 47 | let (ctrl, alt, shift) = match key { 48 | KeyEvent::Ctrl(_) => (true, false, false), 49 | KeyEvent::Alt(_) => (false, true, false), 50 | _ => (false, false, false), 51 | }; 52 | 53 | #[cfg(feature = "termion")] 54 | let key = match key { 55 | KeyEvent::Char('\n' | '\r') => Key::Enter, 56 | KeyEvent::Char(c) | KeyEvent::Ctrl(c) | KeyEvent::Alt(c) => Key::Char(c), 57 | KeyEvent::Backspace => Key::Backspace, 58 | KeyEvent::Left | KeyEvent::CtrlLeft | KeyEvent::AltLeft | KeyEvent::ShiftLeft => { 59 | Key::Left 60 | } 61 | KeyEvent::Right | KeyEvent::CtrlRight | KeyEvent::AltRight | KeyEvent::ShiftRight => { 62 | Key::Right 63 | } 64 | KeyEvent::Up | KeyEvent::CtrlUp | KeyEvent::AltUp | KeyEvent::ShiftUp => Key::Up, 65 | KeyEvent::Down | KeyEvent::CtrlDown | KeyEvent::AltDown | KeyEvent::ShiftDown => { 66 | Key::Down 67 | } 68 | KeyEvent::Home | KeyEvent::CtrlHome => Key::Home, 69 | KeyEvent::End | KeyEvent::CtrlEnd => Key::End, 70 | KeyEvent::PageUp => Key::PageUp, 71 | KeyEvent::PageDown => Key::PageDown, 72 | KeyEvent::BackTab => Key::Tab, 73 | KeyEvent::Delete => Key::Delete, 74 | KeyEvent::Esc => Key::Esc, 75 | KeyEvent::F(x) => Key::F(x), 76 | _ => Key::Null, 77 | }; 78 | 79 | #[cfg(feature = "tuirs-termion")] 80 | let key = match key { 81 | KeyEvent::Char('\n' | '\r') => Key::Enter, 82 | KeyEvent::Char(c) | KeyEvent::Ctrl(c) | KeyEvent::Alt(c) => Key::Char(c), 83 | KeyEvent::Backspace => Key::Backspace, 84 | KeyEvent::Left => Key::Left, 85 | KeyEvent::Right => Key::Right, 86 | KeyEvent::Up => Key::Up, 87 | KeyEvent::Down => Key::Down, 88 | KeyEvent::Home => Key::Home, 89 | KeyEvent::End => Key::End, 90 | KeyEvent::PageUp => Key::PageUp, 91 | KeyEvent::PageDown => Key::PageDown, 92 | KeyEvent::BackTab => Key::Tab, 93 | KeyEvent::Delete => Key::Delete, 94 | KeyEvent::Esc => Key::Esc, 95 | KeyEvent::F(x) => Key::F(x), 96 | _ => Key::Null, 97 | }; 98 | 99 | Input { 100 | key, 101 | ctrl, 102 | alt, 103 | shift, 104 | } 105 | } 106 | } 107 | 108 | impl From for Key { 109 | /// Convert [`termion::event::MouseButton`] into [`Key`]. 110 | fn from(button: MouseButton) -> Self { 111 | match button { 112 | MouseButton::WheelUp => Key::MouseScrollUp, 113 | MouseButton::WheelDown => Key::MouseScrollDown, 114 | _ => Key::Null, 115 | } 116 | } 117 | } 118 | 119 | impl From for Input { 120 | /// Convert [`termion::event::MouseEvent`] into [`Input`]. 121 | fn from(mouse: MouseEvent) -> Self { 122 | let key = if let MouseEvent::Press(button, ..) = mouse { 123 | Key::from(button) 124 | } else { 125 | Key::Null 126 | }; 127 | Self { 128 | key, 129 | ctrl: false, 130 | alt: false, 131 | shift: false, 132 | } 133 | } 134 | } 135 | 136 | #[cfg(test)] 137 | mod tests { 138 | use super::*; 139 | use crate::input::tests::input; 140 | 141 | #[test] 142 | fn key_to_input() { 143 | for (from, to) in [ 144 | ( 145 | KeyEvent::Char('a'), 146 | input(Key::Char('a'), false, false, false), 147 | ), 148 | ( 149 | KeyEvent::Ctrl('a'), 150 | input(Key::Char('a'), true, false, false), 151 | ), 152 | ( 153 | KeyEvent::Alt('a'), 154 | input(Key::Char('a'), false, true, false), 155 | ), 156 | (KeyEvent::Char('\n'), input(Key::Enter, false, false, false)), 157 | (KeyEvent::Char('\r'), input(Key::Enter, false, false, false)), 158 | (KeyEvent::F(1), input(Key::F(1), false, false, false)), 159 | (KeyEvent::BackTab, input(Key::Tab, false, false, false)), 160 | (KeyEvent::Null, input(Key::Null, false, false, false)), 161 | #[cfg(feature = "termion")] 162 | (KeyEvent::ShiftDown, input(Key::Down, false, false, true)), 163 | #[cfg(feature = "termion")] 164 | (KeyEvent::AltUp, input(Key::Up, false, true, false)), 165 | #[cfg(feature = "termion")] 166 | (KeyEvent::CtrlLeft, input(Key::Left, true, false, false)), 167 | #[cfg(feature = "termion")] 168 | (KeyEvent::CtrlHome, input(Key::Home, true, false, false)), 169 | ] { 170 | assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to); 171 | } 172 | } 173 | 174 | #[test] 175 | fn mouse_to_input() { 176 | for (from, to) in [ 177 | ( 178 | MouseEvent::Press(MouseButton::WheelDown, 1, 1), 179 | input(Key::MouseScrollDown, false, false, false), 180 | ), 181 | ( 182 | MouseEvent::Press(MouseButton::WheelUp, 1, 1), 183 | input(Key::MouseScrollUp, false, false, false), 184 | ), 185 | ( 186 | MouseEvent::Press(MouseButton::Left, 1, 1), 187 | input(Key::Null, false, false, false), 188 | ), 189 | ( 190 | MouseEvent::Release(1, 1), 191 | input(Key::Null, false, false, false), 192 | ), 193 | ( 194 | MouseEvent::Hold(1, 1), 195 | input(Key::Null, false, false, false), 196 | ), 197 | ] { 198 | assert_eq!(Input::from(from), to, "{:?} -> {:?}", from, to); 199 | } 200 | } 201 | 202 | #[test] 203 | fn event_to_input() { 204 | for (from, to) in [ 205 | ( 206 | Event::Key(KeyEvent::Char('a')), 207 | input(Key::Char('a'), false, false, false), 208 | ), 209 | ( 210 | Event::Mouse(MouseEvent::Press(MouseButton::WheelDown, 1, 1)), 211 | input(Key::MouseScrollDown, false, false, false), 212 | ), 213 | ( 214 | Event::Unsupported(vec![]), 215 | input(Key::Null, false, false, false), 216 | ), 217 | ] { 218 | assert_eq!(Input::from(from.clone()), to, "{:?} -> {:?}", from, to); 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/scroll.rs: -------------------------------------------------------------------------------- 1 | use crate::widget::Viewport; 2 | #[cfg(feature = "serde")] 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Specify how to scroll the textarea. 6 | /// 7 | /// This type is marked as `#[non_exhaustive]` since more variations may be supported in the future. Note that the cursor will 8 | /// not move until it goes out the viewport. See also: [`TextArea::scroll`] 9 | /// 10 | /// [`TextArea::scroll`]: https://docs.rs/tui-textarea/latest/tui_textarea/struct.TextArea.html#method.scroll 11 | #[non_exhaustive] 12 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 13 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 14 | pub enum Scrolling { 15 | /// Scroll the textarea by rows (vertically) and columns (horizontally). Passing positive scroll amounts to `rows` and `cols` 16 | /// scolls it to down and right. Negative integers means the opposite directions. `(i16, i16)` pair can be converted into 17 | /// `Scrolling::Delta` where 1st element means rows and 2nd means columns. 18 | /// 19 | /// ``` 20 | /// # use ratatui::buffer::Buffer; 21 | /// # use ratatui::layout::Rect; 22 | /// # use ratatui::widgets::Widget as _; 23 | /// use tui_textarea::{TextArea, Scrolling}; 24 | /// 25 | /// // Let's say terminal height is 8. 26 | /// 27 | /// // Create textarea with 20 lines "0", "1", "2", "3", ... 28 | /// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect(); 29 | /// # // Call `render` at least once to populate terminal size 30 | /// # let r = Rect { x: 0, y: 0, width: 24, height: 8 }; 31 | /// # let mut b = Buffer::empty(r.clone()); 32 | /// # textarea.render(r, &mut b); 33 | /// 34 | /// // Scroll down by 2 lines. 35 | /// textarea.scroll(Scrolling::Delta{rows: 2, cols: 0}); 36 | /// assert_eq!(textarea.cursor(), (2, 0)); 37 | /// 38 | /// // (1, 0) is converted into Scrolling::Delta{rows: 1, cols: 0} 39 | /// textarea.scroll((1, 0)); 40 | /// assert_eq!(textarea.cursor(), (3, 0)); 41 | /// ``` 42 | Delta { rows: i16, cols: i16 }, 43 | /// Scroll down the textarea by one page. 44 | /// 45 | /// ``` 46 | /// # use ratatui::buffer::Buffer; 47 | /// # use ratatui::layout::Rect; 48 | /// # use ratatui::widgets::Widget as _; 49 | /// use tui_textarea::{TextArea, Scrolling}; 50 | /// 51 | /// // Let's say terminal height is 8. 52 | /// 53 | /// // Create textarea with 20 lines "0", "1", "2", "3", ... 54 | /// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect(); 55 | /// # // Call `render` at least once to populate terminal size 56 | /// # let r = Rect { x: 0, y: 0, width: 24, height: 8 }; 57 | /// # let mut b = Buffer::empty(r.clone()); 58 | /// # textarea.render(r, &mut b); 59 | /// 60 | /// // Scroll down by one page (8 lines) 61 | /// textarea.scroll(Scrolling::PageDown); 62 | /// assert_eq!(textarea.cursor(), (8, 0)); 63 | /// textarea.scroll(Scrolling::PageDown); 64 | /// assert_eq!(textarea.cursor(), (16, 0)); 65 | /// textarea.scroll(Scrolling::PageDown); 66 | /// assert_eq!(textarea.cursor(), (19, 0)); // Reached bottom of the textarea 67 | /// ``` 68 | PageDown, 69 | /// Scroll up the textarea by one page. 70 | /// 71 | /// ``` 72 | /// # use ratatui::buffer::Buffer; 73 | /// # use ratatui::layout::Rect; 74 | /// # use ratatui::widgets::Widget as _; 75 | /// use tui_textarea::{TextArea, Scrolling, CursorMove}; 76 | /// 77 | /// // Let's say terminal height is 8. 78 | /// 79 | /// // Create textarea with 20 lines "0", "1", "2", "3", ... 80 | /// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect(); 81 | /// # // Call `render` at least once to populate terminal size 82 | /// # let r = Rect { x: 0, y: 0, width: 24, height: 8 }; 83 | /// # let mut b = Buffer::empty(r.clone()); 84 | /// # textarea.render(r.clone(), &mut b); 85 | /// 86 | /// // Go to the last line at first 87 | /// textarea.move_cursor(CursorMove::Bottom); 88 | /// assert_eq!(textarea.cursor(), (19, 0)); 89 | /// # // Call `render` to populate terminal size 90 | /// # textarea.render(r.clone(), &mut b); 91 | /// 92 | /// // Scroll up by one page (8 lines) 93 | /// textarea.scroll(Scrolling::PageUp); 94 | /// assert_eq!(textarea.cursor(), (11, 0)); 95 | /// textarea.scroll(Scrolling::PageUp); 96 | /// assert_eq!(textarea.cursor(), (7, 0)); // Reached top of the textarea 97 | /// ``` 98 | PageUp, 99 | /// Scroll down the textarea by half of the page. 100 | /// 101 | /// ``` 102 | /// # use ratatui::buffer::Buffer; 103 | /// # use ratatui::layout::Rect; 104 | /// # use ratatui::widgets::Widget as _; 105 | /// use tui_textarea::{TextArea, Scrolling}; 106 | /// 107 | /// // Let's say terminal height is 8. 108 | /// 109 | /// // Create textarea with 10 lines "0", "1", "2", "3", ... 110 | /// let mut textarea: TextArea = (0..10).into_iter().map(|i| i.to_string()).collect(); 111 | /// # // Call `render` at least once to populate terminal size 112 | /// # let r = Rect { x: 0, y: 0, width: 24, height: 8 }; 113 | /// # let mut b = Buffer::empty(r.clone()); 114 | /// # textarea.render(r, &mut b); 115 | /// 116 | /// // Scroll down by half-page (4 lines) 117 | /// textarea.scroll(Scrolling::HalfPageDown); 118 | /// assert_eq!(textarea.cursor(), (4, 0)); 119 | /// textarea.scroll(Scrolling::HalfPageDown); 120 | /// assert_eq!(textarea.cursor(), (8, 0)); 121 | /// textarea.scroll(Scrolling::HalfPageDown); 122 | /// assert_eq!(textarea.cursor(), (9, 0)); // Reached bottom of the textarea 123 | /// ``` 124 | HalfPageDown, 125 | /// Scroll up the textarea by half of the page. 126 | /// 127 | /// ``` 128 | /// # use ratatui::buffer::Buffer; 129 | /// # use ratatui::layout::Rect; 130 | /// # use ratatui::widgets::Widget as _; 131 | /// use tui_textarea::{TextArea, Scrolling, CursorMove}; 132 | /// 133 | /// // Let's say terminal height is 8. 134 | /// 135 | /// // Create textarea with 20 lines "0", "1", "2", "3", ... 136 | /// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect(); 137 | /// # // Call `render` at least once to populate terminal size 138 | /// # let r = Rect { x: 0, y: 0, width: 24, height: 8 }; 139 | /// # let mut b = Buffer::empty(r.clone()); 140 | /// # textarea.render(r.clone(), &mut b); 141 | /// 142 | /// // Go to the last line at first 143 | /// textarea.move_cursor(CursorMove::Bottom); 144 | /// assert_eq!(textarea.cursor(), (19, 0)); 145 | /// # // Call `render` to populate terminal size 146 | /// # textarea.render(r.clone(), &mut b); 147 | /// 148 | /// // Scroll up by half-page (4 lines) 149 | /// textarea.scroll(Scrolling::HalfPageUp); 150 | /// assert_eq!(textarea.cursor(), (15, 0)); 151 | /// textarea.scroll(Scrolling::HalfPageUp); 152 | /// assert_eq!(textarea.cursor(), (11, 0)); 153 | /// ``` 154 | HalfPageUp, 155 | } 156 | 157 | impl Scrolling { 158 | pub(crate) fn scroll(self, viewport: &mut Viewport) { 159 | let (rows, cols) = match self { 160 | Self::Delta { rows, cols } => (rows, cols), 161 | Self::PageDown => { 162 | let (_, _, _, height) = viewport.rect(); 163 | (height as i16, 0) 164 | } 165 | Self::PageUp => { 166 | let (_, _, _, height) = viewport.rect(); 167 | (-(height as i16), 0) 168 | } 169 | Self::HalfPageDown => { 170 | let (_, _, _, height) = viewport.rect(); 171 | ((height as i16) / 2, 0) 172 | } 173 | Self::HalfPageUp => { 174 | let (_, _, _, height) = viewport.rect(); 175 | (-(height as i16) / 2, 0) 176 | } 177 | }; 178 | viewport.scroll(rows, cols); 179 | } 180 | } 181 | 182 | impl From<(i16, i16)> for Scrolling { 183 | fn from((rows, cols): (i16, i16)) -> Self { 184 | Self::Delta { rows, cols } 185 | } 186 | } 187 | 188 | #[cfg(test)] 189 | mod tests { 190 | use super::*; 191 | 192 | // Separate tests for tui-rs support 193 | #[test] 194 | fn delta() { 195 | use crate::ratatui::buffer::Buffer; 196 | use crate::ratatui::layout::Rect; 197 | use crate::ratatui::widgets::Widget as _; 198 | use crate::TextArea; 199 | 200 | let mut textarea: TextArea = (0..20).map(|i| i.to_string()).collect(); 201 | let r = Rect { 202 | x: 0, 203 | y: 0, 204 | width: 24, 205 | height: 8, 206 | }; 207 | let mut b = Buffer::empty(r); 208 | textarea.render(r, &mut b); 209 | 210 | textarea.scroll(Scrolling::Delta { rows: 2, cols: 0 }); 211 | assert_eq!(textarea.cursor(), (2, 0)); 212 | 213 | textarea.scroll((1, 0)); 214 | assert_eq!(textarea.cursor(), (3, 0)); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/input/termwiz.rs: -------------------------------------------------------------------------------- 1 | use super::{Input, Key}; 2 | use termwiz::input::{ 3 | InputEvent, KeyCode, KeyEvent, Modifiers, MouseButtons, MouseEvent, PixelMouseEvent, 4 | }; 5 | 6 | impl From for Input { 7 | /// Convert [`termwiz::input::InputEvent`] into [`Input`]. 8 | fn from(input: InputEvent) -> Self { 9 | match input { 10 | InputEvent::Key(key) => Self::from(key), 11 | InputEvent::Mouse(mouse) => Self::from(mouse), 12 | InputEvent::PixelMouse(mouse) => Self::from(mouse), 13 | _ => Self::default(), 14 | } 15 | } 16 | } 17 | 18 | impl From for Key { 19 | /// Convert [`termwiz::input::KeyCode`] into [`Key`]. 20 | fn from(key: KeyCode) -> Self { 21 | match key { 22 | KeyCode::Char(c) => Key::Char(c), 23 | KeyCode::Backspace => Key::Backspace, 24 | KeyCode::Tab => Key::Tab, 25 | KeyCode::Enter => Key::Enter, 26 | KeyCode::Escape => Key::Esc, 27 | KeyCode::PageUp => Key::PageUp, 28 | KeyCode::PageDown => Key::PageDown, 29 | KeyCode::End => Key::End, 30 | KeyCode::Home => Key::Home, 31 | KeyCode::LeftArrow => Key::Left, 32 | KeyCode::RightArrow => Key::Right, 33 | KeyCode::UpArrow => Key::Up, 34 | KeyCode::DownArrow => Key::Down, 35 | KeyCode::Delete => Key::Delete, 36 | KeyCode::Function(x) => Key::F(x), 37 | KeyCode::Copy => Key::Copy, 38 | KeyCode::Cut => Key::Cut, 39 | KeyCode::Paste => Key::Paste, 40 | _ => Key::Null, 41 | } 42 | } 43 | } 44 | 45 | impl From for Input { 46 | /// Convert [`termwiz::input::KeyEvent`] into [`Input`]. 47 | fn from(key: KeyEvent) -> Self { 48 | let KeyEvent { key, modifiers } = key; 49 | let key = Key::from(key); 50 | let ctrl = modifiers.contains(Modifiers::CTRL); 51 | let alt = modifiers.contains(Modifiers::ALT); 52 | let shift = modifiers.contains(Modifiers::SHIFT); 53 | 54 | Self { 55 | key, 56 | ctrl, 57 | alt, 58 | shift, 59 | } 60 | } 61 | } 62 | 63 | impl From for Key { 64 | /// Convert [`termwiz::input::MouseButtons`] into [`Key`]. 65 | fn from(buttons: MouseButtons) -> Self { 66 | if buttons.contains(MouseButtons::VERT_WHEEL) { 67 | if buttons.contains(MouseButtons::WHEEL_POSITIVE) { 68 | Key::MouseScrollUp 69 | } else { 70 | Key::MouseScrollDown 71 | } 72 | } else { 73 | Key::Null 74 | } 75 | } 76 | } 77 | 78 | impl From for Input { 79 | /// Convert [`termwiz::input::MouseEvent`] into [`Input`]. 80 | fn from(mouse: MouseEvent) -> Self { 81 | let MouseEvent { 82 | mouse_buttons, 83 | modifiers, 84 | .. 85 | } = mouse; 86 | let key = Key::from(mouse_buttons); 87 | let ctrl = modifiers.contains(Modifiers::CTRL); 88 | let alt = modifiers.contains(Modifiers::ALT); 89 | let shift = modifiers.contains(Modifiers::SHIFT); 90 | 91 | Self { 92 | key, 93 | ctrl, 94 | alt, 95 | shift, 96 | } 97 | } 98 | } 99 | 100 | impl From for Input { 101 | /// Convert [`termwiz::input::PixelMouseEvent`] into [`Input`]. 102 | fn from(mouse: PixelMouseEvent) -> Self { 103 | let PixelMouseEvent { 104 | mouse_buttons, 105 | modifiers, 106 | .. 107 | } = mouse; 108 | 109 | let key = Key::from(mouse_buttons); 110 | let ctrl = modifiers.contains(Modifiers::CTRL); 111 | let alt = modifiers.contains(Modifiers::ALT); 112 | let shift = modifiers.contains(Modifiers::SHIFT); 113 | 114 | Self { 115 | key, 116 | ctrl, 117 | alt, 118 | shift, 119 | } 120 | } 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::*; 126 | use crate::input::tests::input; 127 | 128 | fn key_event(key: KeyCode, modifiers: Modifiers) -> KeyEvent { 129 | KeyEvent { key, modifiers } 130 | } 131 | 132 | fn mouse_event(mouse_buttons: MouseButtons, modifiers: Modifiers) -> MouseEvent { 133 | MouseEvent { 134 | mouse_buttons, 135 | modifiers, 136 | x: 1, 137 | y: 1, 138 | } 139 | } 140 | 141 | fn pixel_mouse_event(mouse_buttons: MouseButtons, modifiers: Modifiers) -> PixelMouseEvent { 142 | PixelMouseEvent { 143 | mouse_buttons, 144 | modifiers, 145 | x_pixels: 1, 146 | y_pixels: 1, 147 | } 148 | } 149 | 150 | #[test] 151 | fn key_to_input() { 152 | for (from, to) in [ 153 | ( 154 | key_event(KeyCode::Char('a'), Modifiers::empty()), 155 | input(Key::Char('a'), false, false, false), 156 | ), 157 | ( 158 | key_event(KeyCode::Enter, Modifiers::empty()), 159 | input(Key::Enter, false, false, false), 160 | ), 161 | ( 162 | key_event(KeyCode::LeftArrow, Modifiers::CTRL), 163 | input(Key::Left, true, false, false), 164 | ), 165 | ( 166 | key_event(KeyCode::RightArrow, Modifiers::SHIFT), 167 | input(Key::Right, false, false, true), 168 | ), 169 | ( 170 | key_event(KeyCode::Home, Modifiers::ALT), 171 | input(Key::Home, false, true, false), 172 | ), 173 | ( 174 | key_event( 175 | KeyCode::Function(1), 176 | Modifiers::ALT | Modifiers::CTRL | Modifiers::SHIFT, 177 | ), 178 | input(Key::F(1), true, true, true), 179 | ), 180 | ( 181 | key_event(KeyCode::NumLock, Modifiers::CTRL), 182 | input(Key::Null, true, false, false), 183 | ), 184 | ] { 185 | assert_eq!(Input::from(from.clone()), to, "{:?} -> {:?}", from, to); 186 | } 187 | } 188 | 189 | #[test] 190 | fn mouse_to_input() { 191 | for (from, to) in [ 192 | ( 193 | mouse_event(MouseButtons::VERT_WHEEL, Modifiers::empty()), 194 | input(Key::MouseScrollDown, false, false, false), 195 | ), 196 | ( 197 | mouse_event( 198 | MouseButtons::VERT_WHEEL | MouseButtons::WHEEL_POSITIVE, 199 | Modifiers::empty(), 200 | ), 201 | input(Key::MouseScrollUp, false, false, false), 202 | ), 203 | ( 204 | mouse_event(MouseButtons::VERT_WHEEL, Modifiers::CTRL), 205 | input(Key::MouseScrollDown, true, false, false), 206 | ), 207 | ( 208 | mouse_event(MouseButtons::VERT_WHEEL, Modifiers::SHIFT), 209 | input(Key::MouseScrollDown, false, false, true), 210 | ), 211 | ( 212 | mouse_event(MouseButtons::VERT_WHEEL, Modifiers::ALT), 213 | input(Key::MouseScrollDown, false, true, false), 214 | ), 215 | ( 216 | mouse_event( 217 | MouseButtons::VERT_WHEEL, 218 | Modifiers::CTRL | Modifiers::ALT | Modifiers::SHIFT, 219 | ), 220 | input(Key::MouseScrollDown, true, true, true), 221 | ), 222 | ( 223 | mouse_event(MouseButtons::LEFT, Modifiers::empty()), 224 | input(Key::Null, false, false, false), 225 | ), 226 | ] { 227 | assert_eq!(Input::from(from.clone()), to, "{:?} -> {:?}", from, to); 228 | 229 | let from = pixel_mouse_event(from.mouse_buttons, from.modifiers); 230 | assert_eq!(Input::from(from.clone()), to, "{:?} -> {:?}", from, to); 231 | } 232 | } 233 | 234 | #[test] 235 | fn event_to_input() { 236 | for (from, to) in [ 237 | ( 238 | InputEvent::Key(key_event(KeyCode::Char('a'), Modifiers::empty())), 239 | input(Key::Char('a'), false, false, false), 240 | ), 241 | ( 242 | InputEvent::Mouse(mouse_event(MouseButtons::VERT_WHEEL, Modifiers::empty())), 243 | input(Key::MouseScrollDown, false, false, false), 244 | ), 245 | ( 246 | InputEvent::PixelMouse(pixel_mouse_event( 247 | MouseButtons::VERT_WHEEL, 248 | Modifiers::empty(), 249 | )), 250 | input(Key::MouseScrollDown, false, false, false), 251 | ), 252 | ( 253 | InputEvent::Paste("x".into()), 254 | input(Key::Null, false, false, false), 255 | ), 256 | ] { 257 | assert_eq!(Input::from(from.clone()), to, "{:?} -> {:?}", from, to); 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /tests/cursor.rs: -------------------------------------------------------------------------------- 1 | use tui_textarea::{CursorMove, TextArea}; 2 | 3 | const BOTTOM_RIGHT: CursorMove = CursorMove::Jump(u16::MAX, u16::MAX); 4 | 5 | #[test] 6 | fn empty_textarea() { 7 | use CursorMove::*; 8 | 9 | let mut t = TextArea::default(); 10 | for m in [ 11 | Forward, 12 | Back, 13 | Up, 14 | Down, 15 | Head, 16 | End, 17 | Top, 18 | Bottom, 19 | WordForward, 20 | WordEnd, 21 | WordBack, 22 | ParagraphForward, 23 | ParagraphBack, 24 | Jump(0, 0), 25 | Jump(u16::MAX, u16::MAX), 26 | ] { 27 | t.move_cursor(m); 28 | assert_eq!(t.cursor(), (0, 0), "{:?}", m); 29 | } 30 | } 31 | 32 | #[test] 33 | fn forward() { 34 | for (text, positions) in [ 35 | ( 36 | ["abc", "def"], 37 | [ 38 | (0, 1), 39 | (0, 2), 40 | (0, 3), 41 | (1, 0), 42 | (1, 1), 43 | (1, 2), 44 | (1, 3), 45 | (1, 3), 46 | ], 47 | ), 48 | ( 49 | ["あいう", "🐶🐱👪"], 50 | [ 51 | (0, 1), 52 | (0, 2), 53 | (0, 3), 54 | (1, 0), 55 | (1, 1), 56 | (1, 2), 57 | (1, 3), 58 | (1, 3), 59 | ], 60 | ), 61 | ] { 62 | let mut t = TextArea::from(text); 63 | 64 | for pos in positions { 65 | t.move_cursor(CursorMove::Forward); 66 | assert_eq!(t.cursor(), pos, "{:?}", t.lines()); 67 | } 68 | } 69 | } 70 | 71 | #[test] 72 | fn back() { 73 | for (text, positions) in [ 74 | ( 75 | ["abc", "def"], 76 | [ 77 | (1, 2), 78 | (1, 1), 79 | (1, 0), 80 | (0, 3), 81 | (0, 2), 82 | (0, 1), 83 | (0, 0), 84 | (0, 0), 85 | ], 86 | ), 87 | ( 88 | ["あいう", "🐶🐱👪"], 89 | [ 90 | (1, 2), 91 | (1, 1), 92 | (1, 0), 93 | (0, 3), 94 | (0, 2), 95 | (0, 1), 96 | (0, 0), 97 | (0, 0), 98 | ], 99 | ), 100 | ] { 101 | let mut t = TextArea::from(text); 102 | t.move_cursor(BOTTOM_RIGHT); 103 | 104 | for pos in positions { 105 | t.move_cursor(CursorMove::Back); 106 | assert_eq!(t.cursor(), pos, "{:?}", t.lines()); 107 | } 108 | } 109 | } 110 | 111 | #[test] 112 | fn up() { 113 | for text in [["abc", "def", "ghi"], ["あいう", "🐶🐱🐰", "👪🤟🏿👩🏻‍❤️‍💋‍👨🏾"]] 114 | { 115 | let mut t = TextArea::from(text); 116 | 117 | for col in 0..=3 { 118 | let mut row = 2; 119 | 120 | t.move_cursor(CursorMove::Jump(2, col as u16)); 121 | assert_eq!(t.cursor(), (row, col), "{:?}", t.lines()); 122 | 123 | while row > 0 { 124 | t.move_cursor(CursorMove::Up); 125 | row -= 1; 126 | assert_eq!(t.cursor(), (row, col), "{:?}", t.lines()); 127 | } 128 | } 129 | } 130 | } 131 | 132 | #[test] 133 | fn up_trim() { 134 | for text in [["", "a", "bcd", "efgh"], ["", "👪", "🐶!🐱", "あ?い!"]] { 135 | let mut t = TextArea::from(text); 136 | t.move_cursor(CursorMove::Jump(3, 3)); 137 | 138 | for expected in [(2, 3), (1, 1), (0, 0)] { 139 | t.move_cursor(CursorMove::Up); 140 | assert_eq!(t.cursor(), expected, "{:?}", t.lines()); 141 | } 142 | } 143 | } 144 | 145 | #[test] 146 | fn down() { 147 | for text in [["abc", "def", "ghi"], ["あいう", "🐶🐱🐰", "👪🤟🏿👩🏻‍❤️‍💋‍👨🏾"]] 148 | { 149 | let mut t = TextArea::from(text); 150 | 151 | for col in 0..=3 { 152 | let mut row = 0; 153 | 154 | t.move_cursor(CursorMove::Jump(0, col as u16)); 155 | assert_eq!(t.cursor(), (row, col), "{:?}", t.lines()); 156 | 157 | while row < 2 { 158 | t.move_cursor(CursorMove::Down); 159 | row += 1; 160 | assert_eq!(t.cursor(), (row, col), "{:?}", t.lines()); 161 | } 162 | } 163 | } 164 | } 165 | 166 | #[test] 167 | fn down_trim() { 168 | for text in [["abcd", "efg", "h", ""], ["あ?い!", "🐶!🐱", "👪", ""]] { 169 | let mut t = TextArea::from(text); 170 | t.move_cursor(CursorMove::Jump(0, 3)); 171 | 172 | for expected in [(1, 3), (2, 1), (3, 0)] { 173 | t.move_cursor(CursorMove::Down); 174 | assert_eq!(t.cursor(), expected, "{:?}", t.lines()); 175 | } 176 | } 177 | } 178 | 179 | #[test] 180 | fn head() { 181 | for text in [["efg", "h", ""], ["あいう", "👪", ""]] { 182 | let mut t = TextArea::from(text); 183 | for row in 0..t.lines().len() { 184 | let len = t.lines()[row].len(); 185 | for col in [0, len / 2, len] { 186 | t.move_cursor(CursorMove::Jump(row as u16, col as u16)); 187 | t.move_cursor(CursorMove::Head); 188 | assert_eq!(t.cursor(), (row, 0), "{:?}", t.lines()); 189 | } 190 | } 191 | } 192 | } 193 | 194 | #[test] 195 | fn end() { 196 | for text in [["efg", "h", ""], ["あいう", "👪", ""]] { 197 | let mut t = TextArea::from(text); 198 | for row in 0..t.lines().len() { 199 | let len = match row { 200 | 0 => 3, 201 | 1 => 1, 202 | 2 => 0, 203 | _ => unreachable!(), 204 | }; 205 | for col in [0, len / 2, len] { 206 | t.move_cursor(CursorMove::Jump(row as u16, col as u16)); 207 | t.move_cursor(CursorMove::End); 208 | assert_eq!(t.cursor(), (row, len), "{:?}", t.lines()); 209 | } 210 | } 211 | } 212 | } 213 | 214 | #[test] 215 | fn top() { 216 | for text in [["abc", "def", "ghi"], ["あいう", "🐶🐱🐰", "👪🤟🏿👩🏻‍❤️‍💋‍👨🏾"]] 217 | { 218 | let mut t = TextArea::from(text); 219 | for row in 0..=2 { 220 | for col in 0..=3 { 221 | t.move_cursor(CursorMove::Jump(row, col)); 222 | t.move_cursor(CursorMove::Top); 223 | assert_eq!(t.cursor(), (0, col as usize), "{:?}", t.lines()); 224 | } 225 | } 226 | } 227 | } 228 | 229 | #[test] 230 | fn top_trim() { 231 | for lines in [ 232 | &["a", "bc"][..], 233 | &["あ", "🐶🐱"][..], 234 | &["a", "bcd", "ef"][..], 235 | &["", "犬"][..], 236 | ] { 237 | let mut t: TextArea = lines.iter().cloned().collect(); 238 | t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); 239 | t.move_cursor(CursorMove::Top); 240 | let col = t.lines()[0].chars().count(); 241 | assert_eq!(t.cursor(), (0, col), "{:?}", t.lines()); 242 | } 243 | } 244 | 245 | #[test] 246 | fn bottom() { 247 | for text in [["abc", "def", "ghi"], ["あいう", "🐶🐱🐰", "👪🤟🏿👩🏻‍❤️‍💋‍👨🏾"]] 248 | { 249 | let mut t = TextArea::from(text); 250 | for row in 0..=2 { 251 | for col in 0..=3 { 252 | t.move_cursor(CursorMove::Jump(row, col)); 253 | t.move_cursor(CursorMove::Bottom); 254 | assert_eq!(t.cursor(), (2, col as usize), "{:?}", t.lines()); 255 | } 256 | } 257 | } 258 | } 259 | 260 | #[test] 261 | fn bottom_trim() { 262 | for lines in [ 263 | &["bc", "a"][..], 264 | &["🐶🐱", "🐰"][..], 265 | &["ef", "bcd", "a"][..], 266 | &["犬", ""][..], 267 | ] { 268 | let mut t: TextArea = lines.iter().cloned().collect(); 269 | t.move_cursor(CursorMove::Jump(0, u16::MAX)); 270 | t.move_cursor(CursorMove::Bottom); 271 | let col = t.lines().last().unwrap().chars().count(); 272 | assert_eq!(t.cursor(), (t.lines().len() - 1, col), "{:?}", t.lines()); 273 | } 274 | } 275 | 276 | #[test] 277 | fn word_end() { 278 | for (lines, positions) in [ 279 | ( 280 | &[ 281 | "aaa !!! bbb", // Consecutive punctuations are a word 282 | ][..], 283 | &[(0, 2), (0, 6), (0, 10)][..], 284 | ), 285 | ( 286 | &[ 287 | "aaa!!!bbb", // Word boundaries without spaces 288 | ][..], 289 | &[(0, 2), (0, 5), (0, 8)][..], 290 | ), 291 | ( 292 | &[ 293 | "aaa", "", "", "bbb", // Go across multiple empty lines (regression of #75) 294 | ][..], 295 | &[(0, 2), (3, 2)][..], 296 | ), 297 | ( 298 | &[ 299 | "aaa", " ", " ", "bbb", // Go across multiple blank lines 300 | ][..], 301 | &[(0, 2), (3, 2)][..], 302 | ), 303 | ( 304 | &[ 305 | " aaa", " bbb", // Ignore the spaces at the head of line 306 | ][..], 307 | &[(0, 5), (1, 5)][..], 308 | ), 309 | ( 310 | &[ 311 | "aaa ", "bbb ", // Ignore the spaces at the end of line 312 | ][..], 313 | &[(0, 2), (1, 2), (1, 6)][..], 314 | ), 315 | ( 316 | &[ 317 | "a aa", "b!!!", // Accept the head of line (regression of #75) 318 | ][..], 319 | &[(0, 3), (1, 0), (1, 3)][..], 320 | ), 321 | ] { 322 | let mut t: TextArea = lines.iter().cloned().collect(); 323 | for pos in positions { 324 | t.move_cursor(CursorMove::WordEnd); 325 | assert_eq!(t.cursor(), *pos, "{:?}", t.lines()); 326 | } 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /examples/editor.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::terminal::{ 3 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 4 | }; 5 | use ratatui::backend::CrosstermBackend; 6 | use ratatui::layout::{Constraint, Direction, Layout}; 7 | use ratatui::style::{Color, Modifier, Style}; 8 | use ratatui::text::{Line, Span}; 9 | use ratatui::widgets::{Block, Borders, Paragraph}; 10 | use ratatui::Terminal; 11 | use std::borrow::Cow; 12 | use std::env; 13 | use std::fmt::Display; 14 | use std::fs; 15 | use std::io; 16 | use std::io::{BufRead, Write}; 17 | use std::path::PathBuf; 18 | use tui_textarea::{CursorMove, Input, Key, TextArea}; 19 | 20 | macro_rules! error { 21 | ($fmt: expr $(, $args:tt)*) => {{ 22 | Err(io::Error::new(io::ErrorKind::Other, format!($fmt $(, $args)*))) 23 | }}; 24 | } 25 | 26 | struct SearchBox<'a> { 27 | textarea: TextArea<'a>, 28 | open: bool, 29 | } 30 | 31 | impl Default for SearchBox<'_> { 32 | fn default() -> Self { 33 | let mut textarea = TextArea::default(); 34 | textarea.set_block(Block::default().borders(Borders::ALL).title("Search")); 35 | Self { 36 | textarea, 37 | open: false, 38 | } 39 | } 40 | } 41 | 42 | impl SearchBox<'_> { 43 | fn open(&mut self) { 44 | self.open = true; 45 | } 46 | 47 | fn close(&mut self) { 48 | self.open = false; 49 | // Remove input for next search. Do not recreate `self.textarea` instance to keep undo history so that users can 50 | // restore previous input easily. 51 | self.textarea.move_cursor(CursorMove::End); 52 | self.textarea.delete_line_by_head(); 53 | } 54 | 55 | fn height(&self) -> u16 { 56 | if self.open { 57 | 3 58 | } else { 59 | 0 60 | } 61 | } 62 | 63 | fn input(&mut self, input: Input) -> Option<&'_ str> { 64 | match input { 65 | Input { 66 | key: Key::Enter, .. 67 | } 68 | | Input { 69 | key: Key::Char('m'), 70 | ctrl: true, 71 | .. 72 | } => None, // Disable shortcuts which inserts a newline. See `single_line` example 73 | input => { 74 | let modified = self.textarea.input(input); 75 | modified.then(|| self.textarea.lines()[0].as_str()) 76 | } 77 | } 78 | } 79 | 80 | fn set_error(&mut self, err: Option) { 81 | let b = if let Some(err) = err { 82 | Block::default() 83 | .borders(Borders::ALL) 84 | .title(format!("Search: {}", err)) 85 | .style(Style::default().fg(Color::Red)) 86 | } else { 87 | Block::default().borders(Borders::ALL).title("Search") 88 | }; 89 | self.textarea.set_block(b); 90 | } 91 | } 92 | 93 | struct Buffer<'a> { 94 | textarea: TextArea<'a>, 95 | path: PathBuf, 96 | modified: bool, 97 | } 98 | 99 | impl Buffer<'_> { 100 | fn new(path: PathBuf) -> io::Result { 101 | let mut textarea = if let Ok(md) = path.metadata() { 102 | if md.is_file() { 103 | let mut textarea: TextArea = io::BufReader::new(fs::File::open(&path)?) 104 | .lines() 105 | .collect::>()?; 106 | if textarea.lines().iter().any(|l| l.starts_with('\t')) { 107 | textarea.set_hard_tab_indent(true); 108 | } 109 | textarea 110 | } else { 111 | return error!("{:?} is not a file", path); 112 | } 113 | } else { 114 | TextArea::default() // File does not exist 115 | }; 116 | textarea.set_line_number_style(Style::default().fg(Color::DarkGray)); 117 | Ok(Self { 118 | textarea, 119 | path, 120 | modified: false, 121 | }) 122 | } 123 | 124 | fn save(&mut self) -> io::Result<()> { 125 | if !self.modified { 126 | return Ok(()); 127 | } 128 | let mut f = io::BufWriter::new(fs::File::create(&self.path)?); 129 | for line in self.textarea.lines() { 130 | f.write_all(line.as_bytes())?; 131 | f.write_all(b"\n")?; 132 | } 133 | self.modified = false; 134 | Ok(()) 135 | } 136 | } 137 | 138 | struct Editor<'a> { 139 | current: usize, 140 | buffers: Vec>, 141 | term: Terminal>, 142 | message: Option>, 143 | search: SearchBox<'a>, 144 | } 145 | 146 | impl Editor<'_> { 147 | fn new(paths: I) -> io::Result 148 | where 149 | I: Iterator, 150 | I::Item: Into, 151 | { 152 | let buffers = paths 153 | .map(|p| Buffer::new(p.into())) 154 | .collect::>>()?; 155 | if buffers.is_empty() { 156 | return error!("USAGE: cargo run --example editor FILE1 [FILE2...]"); 157 | } 158 | let mut stdout = io::stdout(); 159 | enable_raw_mode()?; 160 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 161 | let backend = CrosstermBackend::new(stdout); 162 | let term = Terminal::new(backend)?; 163 | Ok(Self { 164 | current: 0, 165 | buffers, 166 | term, 167 | message: None, 168 | search: SearchBox::default(), 169 | }) 170 | } 171 | 172 | fn run(&mut self) -> io::Result<()> { 173 | loop { 174 | let search_height = self.search.height(); 175 | let layout = Layout::default() 176 | .direction(Direction::Vertical) 177 | .constraints( 178 | [ 179 | Constraint::Length(search_height), 180 | Constraint::Min(1), 181 | Constraint::Length(1), 182 | Constraint::Length(1), 183 | ] 184 | .as_ref(), 185 | ); 186 | 187 | self.term.draw(|f| { 188 | let chunks = layout.split(f.area()); 189 | 190 | if search_height > 0 { 191 | f.render_widget(&self.search.textarea, chunks[0]); 192 | } 193 | 194 | let buffer = &self.buffers[self.current]; 195 | let textarea = &buffer.textarea; 196 | f.render_widget(textarea, chunks[1]); 197 | 198 | // Render status line 199 | let modified = if buffer.modified { " [modified]" } else { "" }; 200 | let slot = format!("[{}/{}]", self.current + 1, self.buffers.len()); 201 | let path = format!(" {}{} ", buffer.path.display(), modified); 202 | let (row, col) = textarea.cursor(); 203 | let cursor = format!("({},{})", row + 1, col + 1); 204 | let status_chunks = Layout::default() 205 | .direction(Direction::Horizontal) 206 | .constraints( 207 | [ 208 | Constraint::Length(slot.len() as u16), 209 | Constraint::Min(1), 210 | Constraint::Length(cursor.len() as u16), 211 | ] 212 | .as_ref(), 213 | ) 214 | .split(chunks[2]); 215 | let status_style = Style::default().add_modifier(Modifier::REVERSED); 216 | f.render_widget(Paragraph::new(slot).style(status_style), status_chunks[0]); 217 | f.render_widget(Paragraph::new(path).style(status_style), status_chunks[1]); 218 | f.render_widget(Paragraph::new(cursor).style(status_style), status_chunks[2]); 219 | 220 | // Render message at bottom 221 | let message = if let Some(message) = self.message.take() { 222 | Line::from(Span::raw(message)) 223 | } else if search_height > 0 { 224 | Line::from(vec![ 225 | Span::raw("Press "), 226 | Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), 227 | Span::raw(" to jump to first match and close, "), 228 | Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), 229 | Span::raw(" to close, "), 230 | Span::styled( 231 | "^G or ↓ or ^N", 232 | Style::default().add_modifier(Modifier::BOLD), 233 | ), 234 | Span::raw(" to search next, "), 235 | Span::styled( 236 | "M-G or ↑ or ^P", 237 | Style::default().add_modifier(Modifier::BOLD), 238 | ), 239 | Span::raw(" to search previous"), 240 | ]) 241 | } else { 242 | Line::from(vec![ 243 | Span::raw("Press "), 244 | Span::styled("^Q", Style::default().add_modifier(Modifier::BOLD)), 245 | Span::raw(" to quit, "), 246 | Span::styled("^S", Style::default().add_modifier(Modifier::BOLD)), 247 | Span::raw(" to save, "), 248 | Span::styled("^G", Style::default().add_modifier(Modifier::BOLD)), 249 | Span::raw(" to search, "), 250 | Span::styled("^T", Style::default().add_modifier(Modifier::BOLD)), 251 | Span::raw(" to switch buffer"), 252 | ]) 253 | }; 254 | f.render_widget(Paragraph::new(message), chunks[3]); 255 | })?; 256 | 257 | if search_height > 0 { 258 | let textarea = &mut self.buffers[self.current].textarea; 259 | match crossterm::event::read()?.into() { 260 | Input { 261 | key: Key::Char('g' | 'n'), 262 | ctrl: true, 263 | alt: false, 264 | .. 265 | } 266 | | Input { key: Key::Down, .. } => { 267 | if !textarea.search_forward(false) { 268 | self.search.set_error(Some("Pattern not found")); 269 | } 270 | } 271 | Input { 272 | key: Key::Char('g'), 273 | ctrl: false, 274 | alt: true, 275 | .. 276 | } 277 | | Input { 278 | key: Key::Char('p'), 279 | ctrl: true, 280 | alt: false, 281 | .. 282 | } 283 | | Input { key: Key::Up, .. } => { 284 | if !textarea.search_back(false) { 285 | self.search.set_error(Some("Pattern not found")); 286 | } 287 | } 288 | Input { 289 | key: Key::Enter, .. 290 | } => { 291 | if !textarea.search_forward(true) { 292 | self.message = Some("Pattern not found".into()); 293 | } 294 | self.search.close(); 295 | textarea.set_search_pattern("").unwrap(); 296 | } 297 | Input { key: Key::Esc, .. } => { 298 | self.search.close(); 299 | textarea.set_search_pattern("").unwrap(); 300 | } 301 | input => { 302 | if let Some(query) = self.search.input(input) { 303 | let maybe_err = textarea.set_search_pattern(query).err(); 304 | self.search.set_error(maybe_err); 305 | } 306 | } 307 | } 308 | } else { 309 | match crossterm::event::read()?.into() { 310 | Input { 311 | key: Key::Char('q'), 312 | ctrl: true, 313 | .. 314 | } => break, 315 | Input { 316 | key: Key::Char('t'), 317 | ctrl: true, 318 | .. 319 | } => { 320 | self.current = (self.current + 1) % self.buffers.len(); 321 | self.message = 322 | Some(format!("Switched to buffer #{}", self.current + 1).into()); 323 | } 324 | Input { 325 | key: Key::Char('s'), 326 | ctrl: true, 327 | .. 328 | } => { 329 | self.buffers[self.current].save()?; 330 | self.message = Some("Saved!".into()); 331 | } 332 | Input { 333 | key: Key::Char('g'), 334 | ctrl: true, 335 | .. 336 | } => { 337 | self.search.open(); 338 | } 339 | input => { 340 | let buffer = &mut self.buffers[self.current]; 341 | buffer.modified = buffer.textarea.input(input); 342 | } 343 | } 344 | } 345 | } 346 | 347 | Ok(()) 348 | } 349 | } 350 | 351 | impl Drop for Editor<'_> { 352 | fn drop(&mut self) { 353 | self.term.show_cursor().unwrap(); 354 | disable_raw_mode().unwrap(); 355 | crossterm::execute!( 356 | self.term.backend_mut(), 357 | LeaveAlternateScreen, 358 | DisableMouseCapture 359 | ) 360 | .unwrap(); 361 | } 362 | } 363 | 364 | fn main() -> io::Result<()> { 365 | Editor::new(env::args_os().skip(1))?.run() 366 | } 367 | -------------------------------------------------------------------------------- /examples/tuirs_editor.rs: -------------------------------------------------------------------------------- 1 | // Use `crossterm` v0.25 for `tui` backend. 2 | use crossterm_025 as crossterm; 3 | 4 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 5 | use crossterm::terminal::{ 6 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 7 | }; 8 | use std::borrow::Cow; 9 | use std::env; 10 | use std::fmt::Display; 11 | use std::fs; 12 | use std::io; 13 | use std::io::{BufRead, Write}; 14 | use std::path::PathBuf; 15 | use tui::backend::CrosstermBackend; 16 | use tui::layout::{Constraint, Direction, Layout}; 17 | use tui::style::{Color, Modifier, Style}; 18 | use tui::text::{Span, Spans}; 19 | use tui::widgets::{Block, Borders, Paragraph}; 20 | use tui::Terminal; 21 | use tui_textarea::{CursorMove, Input, Key, TextArea}; 22 | 23 | macro_rules! error { 24 | ($fmt: expr $(, $args:tt)*) => {{ 25 | Err(io::Error::new(io::ErrorKind::Other, format!($fmt $(, $args)*))) 26 | }}; 27 | } 28 | 29 | struct SearchBox<'a> { 30 | textarea: TextArea<'a>, 31 | open: bool, 32 | } 33 | 34 | impl Default for SearchBox<'_> { 35 | fn default() -> Self { 36 | let mut textarea = TextArea::default(); 37 | textarea.set_block(Block::default().borders(Borders::ALL).title("Search")); 38 | Self { 39 | textarea, 40 | open: false, 41 | } 42 | } 43 | } 44 | 45 | impl SearchBox<'_> { 46 | fn open(&mut self) { 47 | self.open = true; 48 | } 49 | 50 | fn close(&mut self) { 51 | self.open = false; 52 | // Remove input for next search. Do not recreate `self.textarea` instance to keep undo history so that users can 53 | // restore previous input easily. 54 | self.textarea.move_cursor(CursorMove::End); 55 | self.textarea.delete_line_by_head(); 56 | } 57 | 58 | fn height(&self) -> u16 { 59 | if self.open { 60 | 3 61 | } else { 62 | 0 63 | } 64 | } 65 | 66 | fn input(&mut self, input: Input) -> Option<&'_ str> { 67 | match input { 68 | Input { 69 | key: Key::Enter, .. 70 | } 71 | | Input { 72 | key: Key::Char('m'), 73 | ctrl: true, 74 | .. 75 | } => None, // Disable shortcuts which inserts a newline. See `single_line` example 76 | input => { 77 | let modified = self.textarea.input(input); 78 | modified.then(|| self.textarea.lines()[0].as_str()) 79 | } 80 | } 81 | } 82 | 83 | fn set_error(&mut self, err: Option) { 84 | let b = if let Some(err) = err { 85 | Block::default() 86 | .borders(Borders::ALL) 87 | .title(format!("Search: {}", err)) 88 | .style(Style::default().fg(Color::Red)) 89 | } else { 90 | Block::default().borders(Borders::ALL).title("Search") 91 | }; 92 | self.textarea.set_block(b); 93 | } 94 | } 95 | 96 | struct Buffer<'a> { 97 | textarea: TextArea<'a>, 98 | path: PathBuf, 99 | modified: bool, 100 | } 101 | 102 | impl Buffer<'_> { 103 | fn new(path: PathBuf) -> io::Result { 104 | let mut textarea = if let Ok(md) = path.metadata() { 105 | if md.is_file() { 106 | let mut textarea: TextArea = io::BufReader::new(fs::File::open(&path)?) 107 | .lines() 108 | .collect::>()?; 109 | if textarea.lines().iter().any(|l| l.starts_with('\t')) { 110 | textarea.set_hard_tab_indent(true); 111 | } 112 | textarea 113 | } else { 114 | return error!("{:?} is not a file", path); 115 | } 116 | } else { 117 | TextArea::default() // File does not exist 118 | }; 119 | textarea.set_line_number_style(Style::default().fg(Color::DarkGray)); 120 | Ok(Self { 121 | textarea, 122 | path, 123 | modified: false, 124 | }) 125 | } 126 | 127 | fn save(&mut self) -> io::Result<()> { 128 | if !self.modified { 129 | return Ok(()); 130 | } 131 | let mut f = io::BufWriter::new(fs::File::create(&self.path)?); 132 | for line in self.textarea.lines() { 133 | f.write_all(line.as_bytes())?; 134 | f.write_all(b"\n")?; 135 | } 136 | self.modified = false; 137 | Ok(()) 138 | } 139 | } 140 | 141 | struct Editor<'a> { 142 | current: usize, 143 | buffers: Vec>, 144 | term: Terminal>, 145 | message: Option>, 146 | search: SearchBox<'a>, 147 | } 148 | 149 | impl Editor<'_> { 150 | fn new(paths: I) -> io::Result 151 | where 152 | I: Iterator, 153 | I::Item: Into, 154 | { 155 | let buffers = paths 156 | .map(|p| Buffer::new(p.into())) 157 | .collect::>>()?; 158 | if buffers.is_empty() { 159 | return error!("USAGE: cargo run --example editor FILE1 [FILE2...]"); 160 | } 161 | let mut stdout = io::stdout(); 162 | enable_raw_mode()?; 163 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 164 | let backend = CrosstermBackend::new(stdout); 165 | let term = Terminal::new(backend)?; 166 | Ok(Self { 167 | current: 0, 168 | buffers, 169 | term, 170 | message: None, 171 | search: SearchBox::default(), 172 | }) 173 | } 174 | 175 | fn run(&mut self) -> io::Result<()> { 176 | loop { 177 | let search_height = self.search.height(); 178 | let layout = Layout::default() 179 | .direction(Direction::Vertical) 180 | .constraints( 181 | [ 182 | Constraint::Length(search_height), 183 | Constraint::Min(1), 184 | Constraint::Length(1), 185 | Constraint::Length(1), 186 | ] 187 | .as_ref(), 188 | ); 189 | 190 | self.term.draw(|f| { 191 | let chunks = layout.split(f.size()); 192 | 193 | if search_height > 0 { 194 | f.render_widget(&self.search.textarea, chunks[0]); 195 | } 196 | 197 | let buffer = &self.buffers[self.current]; 198 | let textarea = &buffer.textarea; 199 | f.render_widget(textarea, chunks[1]); 200 | 201 | // Render status line 202 | let modified = if buffer.modified { " [modified]" } else { "" }; 203 | let slot = format!("[{}/{}]", self.current + 1, self.buffers.len()); 204 | let path = format!(" {}{} ", buffer.path.display(), modified); 205 | let (row, col) = textarea.cursor(); 206 | let cursor = format!("({},{})", row + 1, col + 1); 207 | let status_chunks = Layout::default() 208 | .direction(Direction::Horizontal) 209 | .constraints( 210 | [ 211 | Constraint::Length(slot.len() as u16), 212 | Constraint::Min(1), 213 | Constraint::Length(cursor.len() as u16), 214 | ] 215 | .as_ref(), 216 | ) 217 | .split(chunks[2]); 218 | let status_style = Style::default().add_modifier(Modifier::REVERSED); 219 | f.render_widget(Paragraph::new(slot).style(status_style), status_chunks[0]); 220 | f.render_widget(Paragraph::new(path).style(status_style), status_chunks[1]); 221 | f.render_widget(Paragraph::new(cursor).style(status_style), status_chunks[2]); 222 | 223 | // Render message at bottom 224 | let message = if let Some(message) = self.message.take() { 225 | Spans::from(Span::raw(message)) 226 | } else if search_height > 0 { 227 | Spans::from(vec![ 228 | Span::raw("Press "), 229 | Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), 230 | Span::raw(" to jump to first match and close, "), 231 | Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), 232 | Span::raw(" to close, "), 233 | Span::styled( 234 | "^G or ↓ or ^N", 235 | Style::default().add_modifier(Modifier::BOLD), 236 | ), 237 | Span::raw(" to search next, "), 238 | Span::styled( 239 | "M-G or ↑ or ^P", 240 | Style::default().add_modifier(Modifier::BOLD), 241 | ), 242 | Span::raw(" to search previous"), 243 | ]) 244 | } else { 245 | Spans::from(vec![ 246 | Span::raw("Press "), 247 | Span::styled("^Q", Style::default().add_modifier(Modifier::BOLD)), 248 | Span::raw(" to quit, "), 249 | Span::styled("^S", Style::default().add_modifier(Modifier::BOLD)), 250 | Span::raw(" to save, "), 251 | Span::styled("^G", Style::default().add_modifier(Modifier::BOLD)), 252 | Span::raw(" to search, "), 253 | Span::styled("^T", Style::default().add_modifier(Modifier::BOLD)), 254 | Span::raw(" to switch buffer"), 255 | ]) 256 | }; 257 | f.render_widget(Paragraph::new(message), chunks[3]); 258 | })?; 259 | 260 | if search_height > 0 { 261 | let textarea = &mut self.buffers[self.current].textarea; 262 | match crossterm::event::read()?.into() { 263 | Input { 264 | key: Key::Char('g' | 'n'), 265 | ctrl: true, 266 | alt: false, 267 | .. 268 | } 269 | | Input { key: Key::Down, .. } => { 270 | if !textarea.search_forward(false) { 271 | self.search.set_error(Some("Pattern not found")); 272 | } 273 | } 274 | Input { 275 | key: Key::Char('g'), 276 | ctrl: false, 277 | alt: true, 278 | .. 279 | } 280 | | Input { 281 | key: Key::Char('p'), 282 | ctrl: true, 283 | alt: false, 284 | .. 285 | } 286 | | Input { key: Key::Up, .. } => { 287 | if !textarea.search_back(false) { 288 | self.search.set_error(Some("Pattern not found")); 289 | } 290 | } 291 | Input { 292 | key: Key::Enter, .. 293 | } => { 294 | if !textarea.search_forward(true) { 295 | self.message = Some("Pattern not found".into()); 296 | } 297 | self.search.close(); 298 | textarea.set_search_pattern("").unwrap(); 299 | } 300 | Input { key: Key::Esc, .. } => { 301 | self.search.close(); 302 | textarea.set_search_pattern("").unwrap(); 303 | } 304 | input => { 305 | if let Some(query) = self.search.input(input) { 306 | let maybe_err = textarea.set_search_pattern(query).err(); 307 | self.search.set_error(maybe_err); 308 | } 309 | } 310 | } 311 | } else { 312 | match crossterm::event::read()?.into() { 313 | Input { 314 | key: Key::Char('q'), 315 | ctrl: true, 316 | .. 317 | } => break, 318 | Input { 319 | key: Key::Char('t'), 320 | ctrl: true, 321 | .. 322 | } => { 323 | self.current = (self.current + 1) % self.buffers.len(); 324 | self.message = 325 | Some(format!("Switched to buffer #{}", self.current + 1).into()); 326 | } 327 | Input { 328 | key: Key::Char('s'), 329 | ctrl: true, 330 | .. 331 | } => { 332 | self.buffers[self.current].save()?; 333 | self.message = Some("Saved!".into()); 334 | } 335 | Input { 336 | key: Key::Char('g'), 337 | ctrl: true, 338 | .. 339 | } => { 340 | self.search.open(); 341 | } 342 | input => { 343 | let buffer = &mut self.buffers[self.current]; 344 | buffer.modified = buffer.textarea.input(input); 345 | } 346 | } 347 | } 348 | } 349 | 350 | Ok(()) 351 | } 352 | } 353 | 354 | impl Drop for Editor<'_> { 355 | fn drop(&mut self) { 356 | self.term.show_cursor().unwrap(); 357 | disable_raw_mode().unwrap(); 358 | crossterm::execute!( 359 | self.term.backend_mut(), 360 | LeaveAlternateScreen, 361 | DisableMouseCapture 362 | ) 363 | .unwrap(); 364 | } 365 | } 366 | 367 | fn main() -> io::Result<()> { 368 | Editor::new(env::args_os().skip(1))?.run() 369 | } 370 | -------------------------------------------------------------------------------- /src/cursor.rs: -------------------------------------------------------------------------------- 1 | use crate::widget::Viewport; 2 | use crate::word::{ 3 | find_word_inclusive_end_forward, find_word_start_backward, find_word_start_forward, 4 | }; 5 | #[cfg(feature = "arbitrary")] 6 | use arbitrary::Arbitrary; 7 | #[cfg(feature = "serde")] 8 | use serde::{Deserialize, Serialize}; 9 | use std::cmp; 10 | 11 | /// Specify how to move the cursor. 12 | /// 13 | /// This type is marked as `#[non_exhaustive]` since more variations may be supported in the future. 14 | #[non_exhaustive] 15 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 16 | #[cfg_attr(feature = "arbitrary", derive(Arbitrary))] 17 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 18 | pub enum CursorMove { 19 | /// Move cursor forward by one character. When the cursor is at the end of line, it moves to the head of next line. 20 | /// ``` 21 | /// use tui_textarea::{TextArea, CursorMove}; 22 | /// 23 | /// let mut textarea = TextArea::from(["abc"]); 24 | /// 25 | /// textarea.move_cursor(CursorMove::Forward); 26 | /// assert_eq!(textarea.cursor(), (0, 1)); 27 | /// textarea.move_cursor(CursorMove::Forward); 28 | /// assert_eq!(textarea.cursor(), (0, 2)); 29 | /// ``` 30 | Forward, 31 | /// Move cursor backward by one character. When the cursor is at the head of line, it moves to the end of previous 32 | /// line. 33 | /// ``` 34 | /// use tui_textarea::{TextArea, CursorMove}; 35 | /// 36 | /// let mut textarea = TextArea::from(["abc"]); 37 | /// 38 | /// textarea.move_cursor(CursorMove::Forward); 39 | /// textarea.move_cursor(CursorMove::Forward); 40 | /// textarea.move_cursor(CursorMove::Back); 41 | /// assert_eq!(textarea.cursor(), (0, 1)); 42 | /// ``` 43 | Back, 44 | /// Move cursor up by one line. 45 | /// ``` 46 | /// use tui_textarea::{TextArea, CursorMove}; 47 | /// 48 | /// let mut textarea = TextArea::from(["a", "b", "c"]); 49 | /// 50 | /// textarea.move_cursor(CursorMove::Down); 51 | /// textarea.move_cursor(CursorMove::Down); 52 | /// textarea.move_cursor(CursorMove::Up); 53 | /// assert_eq!(textarea.cursor(), (1, 0)); 54 | /// ``` 55 | Up, 56 | /// Move cursor down by one line. 57 | /// ``` 58 | /// use tui_textarea::{TextArea, CursorMove}; 59 | /// 60 | /// let mut textarea = TextArea::from(["a", "b", "c"]); 61 | /// 62 | /// textarea.move_cursor(CursorMove::Down); 63 | /// assert_eq!(textarea.cursor(), (1, 0)); 64 | /// textarea.move_cursor(CursorMove::Down); 65 | /// assert_eq!(textarea.cursor(), (2, 0)); 66 | /// ``` 67 | Down, 68 | /// Move cursor to the head of line. When the cursor is at the head of line, it moves to the end of previous line. 69 | /// ``` 70 | /// use tui_textarea::{TextArea, CursorMove}; 71 | /// 72 | /// let mut textarea = TextArea::from(["abc"]); 73 | /// 74 | /// textarea.move_cursor(CursorMove::Forward); 75 | /// textarea.move_cursor(CursorMove::Forward); 76 | /// textarea.move_cursor(CursorMove::Head); 77 | /// assert_eq!(textarea.cursor(), (0, 0)); 78 | /// ``` 79 | Head, 80 | /// Move cursor to the end of line. When the cursor is at the end of line, it moves to the head of next line. 81 | /// ``` 82 | /// use tui_textarea::{TextArea, CursorMove}; 83 | /// 84 | /// let mut textarea = TextArea::from(["abc"]); 85 | /// 86 | /// textarea.move_cursor(CursorMove::End); 87 | /// assert_eq!(textarea.cursor(), (0, 3)); 88 | /// ``` 89 | End, 90 | /// Move cursor to the top of lines. 91 | /// ``` 92 | /// use tui_textarea::{TextArea, CursorMove}; 93 | /// 94 | /// let mut textarea = TextArea::from(["a", "b", "c"]); 95 | /// 96 | /// textarea.move_cursor(CursorMove::Down); 97 | /// textarea.move_cursor(CursorMove::Down); 98 | /// textarea.move_cursor(CursorMove::Top); 99 | /// assert_eq!(textarea.cursor(), (0, 0)); 100 | /// ``` 101 | Top, 102 | /// Move cursor to the bottom of lines. 103 | /// ``` 104 | /// use tui_textarea::{TextArea, CursorMove}; 105 | /// 106 | /// let mut textarea = TextArea::from(["a", "b", "c"]); 107 | /// 108 | /// textarea.move_cursor(CursorMove::Bottom); 109 | /// assert_eq!(textarea.cursor(), (2, 0)); 110 | /// ``` 111 | Bottom, 112 | /// Move cursor forward by one word. Word boundary appears at spaces, punctuations, and others. For example 113 | /// `fn foo(a)` consists of words `fn`, `foo`, `(`, `a`, `)`. When the cursor is at the end of line, it moves to the 114 | /// head of next line. 115 | /// ``` 116 | /// use tui_textarea::{TextArea, CursorMove}; 117 | /// 118 | /// let mut textarea = TextArea::from(["aaa bbb ccc"]); 119 | /// 120 | /// textarea.move_cursor(CursorMove::WordForward); 121 | /// assert_eq!(textarea.cursor(), (0, 4)); 122 | /// textarea.move_cursor(CursorMove::WordForward); 123 | /// assert_eq!(textarea.cursor(), (0, 8)); 124 | /// ``` 125 | WordForward, 126 | /// Move cursor forward to the next end of word. Word boundary appears at spaces, punctuations, and others. For example 127 | /// `fn foo(a)` consists of words `fn`, `foo`, `(`, `a`, `)`. When the cursor is at the end of line, it moves to the 128 | /// end of the first word of the next line. This is similar to the 'e' mapping of Vim in normal mode. 129 | /// ``` 130 | /// use tui_textarea::{TextArea, CursorMove}; 131 | /// 132 | /// let mut textarea = TextArea::from([ 133 | /// "aaa bbb [[[ccc]]]", 134 | /// "", 135 | /// " ddd", 136 | /// ]); 137 | /// 138 | /// 139 | /// textarea.move_cursor(CursorMove::WordEnd); 140 | /// assert_eq!(textarea.cursor(), (0, 2)); // At the end of 'aaa' 141 | /// textarea.move_cursor(CursorMove::WordEnd); 142 | /// assert_eq!(textarea.cursor(), (0, 6)); // At the end of 'bbb' 143 | /// textarea.move_cursor(CursorMove::WordEnd); 144 | /// assert_eq!(textarea.cursor(), (0, 10)); // At the end of '[[[' 145 | /// textarea.move_cursor(CursorMove::WordEnd); 146 | /// assert_eq!(textarea.cursor(), (0, 13)); // At the end of 'ccc' 147 | /// textarea.move_cursor(CursorMove::WordEnd); 148 | /// assert_eq!(textarea.cursor(), (0, 16)); // At the end of ']]]' 149 | /// textarea.move_cursor(CursorMove::WordEnd); 150 | /// assert_eq!(textarea.cursor(), (2, 3)); // At the end of 'ddd' 151 | /// ``` 152 | WordEnd, 153 | /// Move cursor backward by one word. Word boundary appears at spaces, punctuations, and others. For example 154 | /// `fn foo(a)` consists of words `fn`, `foo`, `(`, `a`, `)`.When the cursor is at the head of line, it moves to 155 | /// the end of previous line. 156 | /// ``` 157 | /// use tui_textarea::{TextArea, CursorMove}; 158 | /// 159 | /// let mut textarea = TextArea::from(["aaa bbb ccc"]); 160 | /// 161 | /// textarea.move_cursor(CursorMove::End); 162 | /// textarea.move_cursor(CursorMove::WordBack); 163 | /// assert_eq!(textarea.cursor(), (0, 8)); 164 | /// textarea.move_cursor(CursorMove::WordBack); 165 | /// assert_eq!(textarea.cursor(), (0, 4)); 166 | /// textarea.move_cursor(CursorMove::WordBack); 167 | /// assert_eq!(textarea.cursor(), (0, 0)); 168 | /// ``` 169 | WordBack, 170 | /// Move cursor down by one paragraph. Paragraph is a chunk of non-empty lines. Cursor moves to the first line of paragraph. 171 | /// ``` 172 | /// use tui_textarea::{TextArea, CursorMove}; 173 | /// 174 | /// // aaa 175 | /// // 176 | /// // bbb 177 | /// // 178 | /// // ccc 179 | /// // ddd 180 | /// let mut textarea = TextArea::from(["aaa", "", "bbb", "", "ccc", "ddd"]); 181 | /// 182 | /// textarea.move_cursor(CursorMove::ParagraphForward); 183 | /// assert_eq!(textarea.cursor(), (2, 0)); 184 | /// textarea.move_cursor(CursorMove::ParagraphForward); 185 | /// assert_eq!(textarea.cursor(), (4, 0)); 186 | /// ``` 187 | ParagraphForward, 188 | /// Move cursor up by one paragraph. Paragraph is a chunk of non-empty lines. Cursor moves to the first line of paragraph. 189 | /// ``` 190 | /// use tui_textarea::{TextArea, CursorMove}; 191 | /// 192 | /// // aaa 193 | /// // 194 | /// // bbb 195 | /// // 196 | /// // ccc 197 | /// // ddd 198 | /// let mut textarea = TextArea::from(["aaa", "", "bbb", "", "ccc", "ddd"]); 199 | /// 200 | /// textarea.move_cursor(CursorMove::Bottom); 201 | /// textarea.move_cursor(CursorMove::ParagraphBack); 202 | /// assert_eq!(textarea.cursor(), (4, 0)); 203 | /// textarea.move_cursor(CursorMove::ParagraphBack); 204 | /// assert_eq!(textarea.cursor(), (2, 0)); 205 | /// textarea.move_cursor(CursorMove::ParagraphBack); 206 | /// assert_eq!(textarea.cursor(), (0, 0)); 207 | /// ``` 208 | ParagraphBack, 209 | /// Move cursor to (row, col) position. When the position points outside the text, the cursor position is made fit 210 | /// within the text. Note that row and col are 0-based. (0, 0) means the first character of the first line. 211 | /// 212 | /// When there are 10 lines, jumping to row 15 moves the cursor to the last line (row is 9 in the case). When there 213 | /// are 10 characters in the line, jumping to col 15 moves the cursor to end of the line (col is 10 in the case). 214 | /// ``` 215 | /// use tui_textarea::{TextArea, CursorMove}; 216 | /// 217 | /// let mut textarea = TextArea::from(["aaaa", "bbbb", "cccc"]); 218 | /// 219 | /// textarea.move_cursor(CursorMove::Jump(1, 2)); 220 | /// assert_eq!(textarea.cursor(), (1, 2)); 221 | /// 222 | /// textarea.move_cursor(CursorMove::Jump(10, 10)); 223 | /// assert_eq!(textarea.cursor(), (2, 4)); 224 | /// ``` 225 | Jump(u16, u16), 226 | /// Move cursor to keep it within the viewport. For example, when a viewport displays line 8 to line 16: 227 | /// 228 | /// - cursor at line 4 is moved to line 8 229 | /// - cursor at line 20 is moved to line 16 230 | /// - cursor at line 12 is not moved 231 | /// 232 | /// This is useful when you moved a cursor but you don't want to move the viewport. 233 | /// ``` 234 | /// # use ratatui::buffer::Buffer; 235 | /// # use ratatui::layout::Rect; 236 | /// # use ratatui::widgets::Widget as _; 237 | /// use tui_textarea::{TextArea, CursorMove}; 238 | /// 239 | /// // Let's say terminal height is 8. 240 | /// 241 | /// // Create textarea with 20 lines "0", "1", "2", "3", ... 242 | /// // The viewport is displaying from line 1 to line 8. 243 | /// let mut textarea: TextArea = (0..20).into_iter().map(|i| i.to_string()).collect(); 244 | /// # // Call `render` at least once to populate terminal size 245 | /// # let r = Rect { x: 0, y: 0, width: 24, height: 8 }; 246 | /// # let mut b = Buffer::empty(r.clone()); 247 | /// # textarea.render(r, &mut b); 248 | /// 249 | /// // Move cursor to the end of lines (line 20). It is outside the viewport (line 1 to line 8) 250 | /// textarea.move_cursor(CursorMove::Bottom); 251 | /// assert_eq!(textarea.cursor(), (19, 0)); 252 | /// 253 | /// // Cursor is moved to line 8 to enter the viewport 254 | /// textarea.move_cursor(CursorMove::InViewport); 255 | /// assert_eq!(textarea.cursor(), (7, 0)); 256 | /// ``` 257 | InViewport, 258 | } 259 | 260 | impl CursorMove { 261 | pub(crate) fn next_cursor( 262 | &self, 263 | (row, col): (usize, usize), 264 | lines: &[String], 265 | viewport: &Viewport, 266 | ) -> Option<(usize, usize)> { 267 | use CursorMove::*; 268 | 269 | fn fit_col(col: usize, line: &str) -> usize { 270 | cmp::min(col, line.chars().count()) 271 | } 272 | 273 | match self { 274 | Forward if col >= lines[row].chars().count() => { 275 | (row + 1 < lines.len()).then(|| (row + 1, 0)) 276 | } 277 | Forward => Some((row, col + 1)), 278 | Back if col == 0 => { 279 | let row = row.checked_sub(1)?; 280 | Some((row, lines[row].chars().count())) 281 | } 282 | Back => Some((row, col - 1)), 283 | Up => { 284 | let row = row.checked_sub(1)?; 285 | Some((row, fit_col(col, &lines[row]))) 286 | } 287 | Down => Some((row + 1, fit_col(col, lines.get(row + 1)?))), 288 | Head => Some((row, 0)), 289 | End => Some((row, lines[row].chars().count())), 290 | Top => Some((0, fit_col(col, &lines[0]))), 291 | Bottom => { 292 | let row = lines.len() - 1; 293 | Some((row, fit_col(col, &lines[row]))) 294 | } 295 | WordEnd => { 296 | // `+ 1` for not accepting the current cursor position 297 | if let Some(col) = find_word_inclusive_end_forward(&lines[row], col + 1) { 298 | Some((row, col)) 299 | } else { 300 | let mut row = row; 301 | loop { 302 | if row == lines.len() - 1 { 303 | break Some((row, lines[row].chars().count())); 304 | } 305 | row += 1; 306 | if let Some(col) = find_word_inclusive_end_forward(&lines[row], 0) { 307 | break Some((row, col)); 308 | } 309 | } 310 | } 311 | } 312 | WordForward => { 313 | if let Some(col) = find_word_start_forward(&lines[row], col) { 314 | Some((row, col)) 315 | } else if row + 1 < lines.len() { 316 | Some((row + 1, 0)) 317 | } else { 318 | Some((row, lines[row].chars().count())) 319 | } 320 | } 321 | WordBack => { 322 | if let Some(col) = find_word_start_backward(&lines[row], col) { 323 | Some((row, col)) 324 | } else if row > 0 { 325 | Some((row - 1, lines[row - 1].chars().count())) 326 | } else { 327 | Some((row, 0)) 328 | } 329 | } 330 | ParagraphForward => { 331 | let mut prev_is_empty = lines[row].is_empty(); 332 | for row in row + 1..lines.len() { 333 | let line = &lines[row]; 334 | let is_empty = line.is_empty(); 335 | if !is_empty && prev_is_empty { 336 | return Some((row, fit_col(col, line))); 337 | } 338 | prev_is_empty = is_empty; 339 | } 340 | let row = lines.len() - 1; 341 | Some((row, fit_col(col, &lines[row]))) 342 | } 343 | ParagraphBack => { 344 | let row = row.checked_sub(1)?; 345 | let mut prev_is_empty = lines[row].is_empty(); 346 | for row in (0..row).rev() { 347 | let is_empty = lines[row].is_empty(); 348 | if is_empty && !prev_is_empty { 349 | return Some((row + 1, fit_col(col, &lines[row + 1]))); 350 | } 351 | prev_is_empty = is_empty; 352 | } 353 | Some((0, fit_col(col, &lines[0]))) 354 | } 355 | Jump(row, col) => { 356 | let row = cmp::min(*row as usize, lines.len() - 1); 357 | let col = fit_col(*col as usize, &lines[row]); 358 | Some((row, col)) 359 | } 360 | InViewport => { 361 | let (row_top, col_top, row_bottom, col_bottom) = viewport.position(); 362 | 363 | let row = row.clamp(row_top as usize, row_bottom as usize); 364 | let row = cmp::min(row, lines.len() - 1); 365 | let col = col.clamp(col_top as usize, col_bottom as usize); 366 | let col = fit_col(col, &lines[row]); 367 | 368 | Some((row, col)) 369 | } 370 | } 371 | } 372 | } 373 | 374 | #[cfg(test)] 375 | mod tests { 376 | // Separate tests for tui-rs support 377 | #[test] 378 | fn in_viewport() { 379 | use crate::ratatui::buffer::Buffer; 380 | use crate::ratatui::layout::Rect; 381 | use crate::ratatui::widgets::Widget as _; 382 | use crate::{CursorMove, TextArea}; 383 | 384 | let mut textarea: TextArea = (0..20).map(|i| i.to_string()).collect(); 385 | let r = Rect { 386 | x: 0, 387 | y: 0, 388 | width: 24, 389 | height: 8, 390 | }; 391 | let mut b = Buffer::empty(r); 392 | textarea.render(r, &mut b); 393 | 394 | textarea.move_cursor(CursorMove::Bottom); 395 | assert_eq!(textarea.cursor(), (19, 0)); 396 | 397 | textarea.move_cursor(CursorMove::InViewport); 398 | assert_eq!(textarea.cursor(), (7, 0)); 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /examples/vim.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::terminal::{ 3 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 4 | }; 5 | use ratatui::backend::CrosstermBackend; 6 | use ratatui::style::{Color, Modifier, Style}; 7 | use ratatui::widgets::{Block, Borders}; 8 | use ratatui::Terminal; 9 | use std::env; 10 | use std::fmt; 11 | use std::fs; 12 | use std::io; 13 | use std::io::BufRead; 14 | use tui_textarea::{CursorMove, Input, Key, Scrolling, TextArea}; 15 | 16 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 17 | enum Mode { 18 | Normal, 19 | Insert, 20 | Visual, 21 | Operator(char), 22 | } 23 | 24 | impl Mode { 25 | fn block<'a>(&self) -> Block<'a> { 26 | let help = match self { 27 | Self::Normal => "type q to quit, type i to enter insert mode", 28 | Self::Insert => "type Esc to back to normal mode", 29 | Self::Visual => "type y to yank, type d to delete, type Esc to back to normal mode", 30 | Self::Operator(_) => "move cursor to apply operator", 31 | }; 32 | let title = format!("{} MODE ({})", self, help); 33 | Block::default().borders(Borders::ALL).title(title) 34 | } 35 | 36 | fn cursor_style(&self) -> Style { 37 | let color = match self { 38 | Self::Normal => Color::Reset, 39 | Self::Insert => Color::LightBlue, 40 | Self::Visual => Color::LightYellow, 41 | Self::Operator(_) => Color::LightGreen, 42 | }; 43 | Style::default().fg(color).add_modifier(Modifier::REVERSED) 44 | } 45 | } 46 | 47 | impl fmt::Display for Mode { 48 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 49 | match self { 50 | Self::Normal => write!(f, "NORMAL"), 51 | Self::Insert => write!(f, "INSERT"), 52 | Self::Visual => write!(f, "VISUAL"), 53 | Self::Operator(c) => write!(f, "OPERATOR({})", c), 54 | } 55 | } 56 | } 57 | 58 | // How the Vim emulation state transitions 59 | enum Transition { 60 | Nop, 61 | Mode(Mode), 62 | Pending(Input), 63 | Quit, 64 | } 65 | 66 | // State of Vim emulation 67 | struct Vim { 68 | mode: Mode, 69 | pending: Input, // Pending input to handle a sequence with two keys like gg 70 | } 71 | 72 | impl Vim { 73 | fn new(mode: Mode) -> Self { 74 | Self { 75 | mode, 76 | pending: Input::default(), 77 | } 78 | } 79 | 80 | fn with_pending(self, pending: Input) -> Self { 81 | Self { 82 | mode: self.mode, 83 | pending, 84 | } 85 | } 86 | 87 | fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition { 88 | if input.key == Key::Null { 89 | return Transition::Nop; 90 | } 91 | 92 | match self.mode { 93 | Mode::Normal | Mode::Visual | Mode::Operator(_) => { 94 | match input { 95 | Input { 96 | key: Key::Char('h'), 97 | .. 98 | } => textarea.move_cursor(CursorMove::Back), 99 | Input { 100 | key: Key::Char('j'), 101 | .. 102 | } => textarea.move_cursor(CursorMove::Down), 103 | Input { 104 | key: Key::Char('k'), 105 | .. 106 | } => textarea.move_cursor(CursorMove::Up), 107 | Input { 108 | key: Key::Char('l'), 109 | .. 110 | } => textarea.move_cursor(CursorMove::Forward), 111 | Input { 112 | key: Key::Char('w'), 113 | .. 114 | } => textarea.move_cursor(CursorMove::WordForward), 115 | Input { 116 | key: Key::Char('e'), 117 | ctrl: false, 118 | .. 119 | } => { 120 | textarea.move_cursor(CursorMove::WordEnd); 121 | if matches!(self.mode, Mode::Operator(_)) { 122 | textarea.move_cursor(CursorMove::Forward); // Include the text under the cursor 123 | } 124 | } 125 | Input { 126 | key: Key::Char('b'), 127 | ctrl: false, 128 | .. 129 | } => textarea.move_cursor(CursorMove::WordBack), 130 | Input { 131 | key: Key::Char('^'), 132 | .. 133 | } => textarea.move_cursor(CursorMove::Head), 134 | Input { 135 | key: Key::Char('$'), 136 | .. 137 | } => textarea.move_cursor(CursorMove::End), 138 | Input { 139 | key: Key::Char('D'), 140 | .. 141 | } => { 142 | textarea.delete_line_by_end(); 143 | return Transition::Mode(Mode::Normal); 144 | } 145 | Input { 146 | key: Key::Char('C'), 147 | .. 148 | } => { 149 | textarea.delete_line_by_end(); 150 | textarea.cancel_selection(); 151 | return Transition::Mode(Mode::Insert); 152 | } 153 | Input { 154 | key: Key::Char('p'), 155 | .. 156 | } => { 157 | textarea.paste(); 158 | return Transition::Mode(Mode::Normal); 159 | } 160 | Input { 161 | key: Key::Char('u'), 162 | ctrl: false, 163 | .. 164 | } => { 165 | textarea.undo(); 166 | return Transition::Mode(Mode::Normal); 167 | } 168 | Input { 169 | key: Key::Char('r'), 170 | ctrl: true, 171 | .. 172 | } => { 173 | textarea.redo(); 174 | return Transition::Mode(Mode::Normal); 175 | } 176 | Input { 177 | key: Key::Char('x'), 178 | .. 179 | } => { 180 | textarea.delete_next_char(); 181 | return Transition::Mode(Mode::Normal); 182 | } 183 | Input { 184 | key: Key::Char('i'), 185 | .. 186 | } => { 187 | textarea.cancel_selection(); 188 | return Transition::Mode(Mode::Insert); 189 | } 190 | Input { 191 | key: Key::Char('a'), 192 | .. 193 | } => { 194 | textarea.cancel_selection(); 195 | textarea.move_cursor(CursorMove::Forward); 196 | return Transition::Mode(Mode::Insert); 197 | } 198 | Input { 199 | key: Key::Char('A'), 200 | .. 201 | } => { 202 | textarea.cancel_selection(); 203 | textarea.move_cursor(CursorMove::End); 204 | return Transition::Mode(Mode::Insert); 205 | } 206 | Input { 207 | key: Key::Char('o'), 208 | .. 209 | } => { 210 | textarea.move_cursor(CursorMove::End); 211 | textarea.insert_newline(); 212 | return Transition::Mode(Mode::Insert); 213 | } 214 | Input { 215 | key: Key::Char('O'), 216 | .. 217 | } => { 218 | textarea.move_cursor(CursorMove::Head); 219 | textarea.insert_newline(); 220 | textarea.move_cursor(CursorMove::Up); 221 | return Transition::Mode(Mode::Insert); 222 | } 223 | Input { 224 | key: Key::Char('I'), 225 | .. 226 | } => { 227 | textarea.cancel_selection(); 228 | textarea.move_cursor(CursorMove::Head); 229 | return Transition::Mode(Mode::Insert); 230 | } 231 | Input { 232 | key: Key::Char('q'), 233 | .. 234 | } => return Transition::Quit, 235 | Input { 236 | key: Key::Char('e'), 237 | ctrl: true, 238 | .. 239 | } => textarea.scroll((1, 0)), 240 | Input { 241 | key: Key::Char('y'), 242 | ctrl: true, 243 | .. 244 | } => textarea.scroll((-1, 0)), 245 | Input { 246 | key: Key::Char('d'), 247 | ctrl: true, 248 | .. 249 | } => textarea.scroll(Scrolling::HalfPageDown), 250 | Input { 251 | key: Key::Char('u'), 252 | ctrl: true, 253 | .. 254 | } => textarea.scroll(Scrolling::HalfPageUp), 255 | Input { 256 | key: Key::Char('f'), 257 | ctrl: true, 258 | .. 259 | } => textarea.scroll(Scrolling::PageDown), 260 | Input { 261 | key: Key::Char('b'), 262 | ctrl: true, 263 | .. 264 | } => textarea.scroll(Scrolling::PageUp), 265 | Input { 266 | key: Key::Char('v'), 267 | ctrl: false, 268 | .. 269 | } if self.mode == Mode::Normal => { 270 | textarea.start_selection(); 271 | return Transition::Mode(Mode::Visual); 272 | } 273 | Input { 274 | key: Key::Char('V'), 275 | ctrl: false, 276 | .. 277 | } if self.mode == Mode::Normal => { 278 | textarea.move_cursor(CursorMove::Head); 279 | textarea.start_selection(); 280 | textarea.move_cursor(CursorMove::End); 281 | return Transition::Mode(Mode::Visual); 282 | } 283 | Input { key: Key::Esc, .. } 284 | | Input { 285 | key: Key::Char('v'), 286 | ctrl: false, 287 | .. 288 | } if self.mode == Mode::Visual => { 289 | textarea.cancel_selection(); 290 | return Transition::Mode(Mode::Normal); 291 | } 292 | Input { 293 | key: Key::Char('g'), 294 | ctrl: false, 295 | .. 296 | } if matches!( 297 | self.pending, 298 | Input { 299 | key: Key::Char('g'), 300 | ctrl: false, 301 | .. 302 | } 303 | ) => 304 | { 305 | textarea.move_cursor(CursorMove::Top) 306 | } 307 | Input { 308 | key: Key::Char('G'), 309 | ctrl: false, 310 | .. 311 | } => textarea.move_cursor(CursorMove::Bottom), 312 | Input { 313 | key: Key::Char(c), 314 | ctrl: false, 315 | .. 316 | } if self.mode == Mode::Operator(c) => { 317 | // Handle yy, dd, cc. (This is not strictly the same behavior as Vim) 318 | textarea.move_cursor(CursorMove::Head); 319 | textarea.start_selection(); 320 | let cursor = textarea.cursor(); 321 | textarea.move_cursor(CursorMove::Down); 322 | if cursor == textarea.cursor() { 323 | textarea.move_cursor(CursorMove::End); // At the last line, move to end of the line instead 324 | } 325 | } 326 | Input { 327 | key: Key::Char(op @ ('y' | 'd' | 'c')), 328 | ctrl: false, 329 | .. 330 | } if self.mode == Mode::Normal => { 331 | textarea.start_selection(); 332 | return Transition::Mode(Mode::Operator(op)); 333 | } 334 | Input { 335 | key: Key::Char('y'), 336 | ctrl: false, 337 | .. 338 | } if self.mode == Mode::Visual => { 339 | textarea.move_cursor(CursorMove::Forward); // Vim's text selection is inclusive 340 | textarea.copy(); 341 | return Transition::Mode(Mode::Normal); 342 | } 343 | Input { 344 | key: Key::Char('d'), 345 | ctrl: false, 346 | .. 347 | } if self.mode == Mode::Visual => { 348 | textarea.move_cursor(CursorMove::Forward); // Vim's text selection is inclusive 349 | textarea.cut(); 350 | return Transition::Mode(Mode::Normal); 351 | } 352 | Input { 353 | key: Key::Char('c'), 354 | ctrl: false, 355 | .. 356 | } if self.mode == Mode::Visual => { 357 | textarea.move_cursor(CursorMove::Forward); // Vim's text selection is inclusive 358 | textarea.cut(); 359 | return Transition::Mode(Mode::Insert); 360 | } 361 | input => return Transition::Pending(input), 362 | } 363 | 364 | // Handle the pending operator 365 | match self.mode { 366 | Mode::Operator('y') => { 367 | textarea.copy(); 368 | Transition::Mode(Mode::Normal) 369 | } 370 | Mode::Operator('d') => { 371 | textarea.cut(); 372 | Transition::Mode(Mode::Normal) 373 | } 374 | Mode::Operator('c') => { 375 | textarea.cut(); 376 | Transition::Mode(Mode::Insert) 377 | } 378 | _ => Transition::Nop, 379 | } 380 | } 381 | Mode::Insert => match input { 382 | Input { key: Key::Esc, .. } 383 | | Input { 384 | key: Key::Char('c'), 385 | ctrl: true, 386 | .. 387 | } => Transition::Mode(Mode::Normal), 388 | input => { 389 | textarea.input(input); // Use default key mappings in insert mode 390 | Transition::Mode(Mode::Insert) 391 | } 392 | }, 393 | } 394 | } 395 | } 396 | 397 | fn main() -> io::Result<()> { 398 | let stdout = io::stdout(); 399 | let mut stdout = stdout.lock(); 400 | 401 | enable_raw_mode()?; 402 | crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 403 | let backend = CrosstermBackend::new(stdout); 404 | let mut term = Terminal::new(backend)?; 405 | 406 | let mut textarea = if let Some(path) = env::args().nth(1) { 407 | let file = fs::File::open(path)?; 408 | io::BufReader::new(file) 409 | .lines() 410 | .collect::>()? 411 | } else { 412 | TextArea::default() 413 | }; 414 | 415 | textarea.set_block(Mode::Normal.block()); 416 | textarea.set_cursor_style(Mode::Normal.cursor_style()); 417 | let mut vim = Vim::new(Mode::Normal); 418 | 419 | loop { 420 | term.draw(|f| f.render_widget(&textarea, f.area()))?; 421 | 422 | vim = match vim.transition(crossterm::event::read()?.into(), &mut textarea) { 423 | Transition::Mode(mode) if vim.mode != mode => { 424 | textarea.set_block(mode.block()); 425 | textarea.set_cursor_style(mode.cursor_style()); 426 | Vim::new(mode) 427 | } 428 | Transition::Nop | Transition::Mode(_) => vim, 429 | Transition::Pending(input) => vim.with_pending(input), 430 | Transition::Quit => break, 431 | } 432 | } 433 | 434 | disable_raw_mode()?; 435 | crossterm::execute!( 436 | term.backend_mut(), 437 | LeaveAlternateScreen, 438 | DisableMouseCapture 439 | )?; 440 | term.show_cursor()?; 441 | 442 | println!("Lines: {:?}", textarea.lines()); 443 | 444 | Ok(()) 445 | } 446 | -------------------------------------------------------------------------------- /src/history.rs: -------------------------------------------------------------------------------- 1 | use crate::util::Pos; 2 | use std::collections::VecDeque; 3 | 4 | #[derive(Clone, Debug)] 5 | pub enum EditKind { 6 | InsertChar(char), 7 | DeleteChar(char), 8 | InsertNewline, 9 | DeleteNewline, 10 | InsertStr(String), 11 | DeleteStr(String), 12 | InsertChunk(Vec), 13 | DeleteChunk(Vec), 14 | } 15 | 16 | impl EditKind { 17 | pub(crate) fn apply(&self, lines: &mut Vec, before: &Pos, after: &Pos) { 18 | match self { 19 | EditKind::InsertChar(c) => { 20 | lines[before.row].insert(before.offset, *c); 21 | } 22 | EditKind::DeleteChar(_) => { 23 | lines[before.row].remove(after.offset); 24 | } 25 | EditKind::InsertNewline => { 26 | let line = &mut lines[before.row]; 27 | let next_line = line[before.offset..].to_string(); 28 | line.truncate(before.offset); 29 | lines.insert(before.row + 1, next_line); 30 | } 31 | EditKind::DeleteNewline => { 32 | debug_assert!(before.row > 0, "invalid pos: {:?}", before); 33 | let line = lines.remove(before.row); 34 | lines[before.row - 1].push_str(&line); 35 | } 36 | EditKind::InsertStr(s) => { 37 | lines[before.row].insert_str(before.offset, s.as_str()); 38 | } 39 | EditKind::DeleteStr(s) => { 40 | lines[after.row].drain(after.offset..after.offset + s.len()); 41 | } 42 | EditKind::InsertChunk(c) => { 43 | debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c); 44 | 45 | // Handle first line of chunk 46 | let first_line = &mut lines[before.row]; 47 | let mut last_line = first_line.drain(before.offset..).as_str().to_string(); 48 | first_line.push_str(&c[0]); 49 | 50 | // Handle last line of chunk 51 | let next_row = before.row + 1; 52 | last_line.insert_str(0, c.last().unwrap()); 53 | lines.insert(next_row, last_line); 54 | 55 | // Handle middle lines of chunk 56 | lines.splice(next_row..next_row, c[1..c.len() - 1].iter().cloned()); 57 | } 58 | EditKind::DeleteChunk(c) => { 59 | debug_assert!(c.len() > 1, "Chunk size must be > 1: {:?}", c); 60 | 61 | // Remove middle lines of chunk 62 | let mut last_line = lines 63 | .drain(after.row + 1..after.row + c.len()) 64 | .last() 65 | .unwrap(); 66 | // Remove last line of chunk 67 | last_line.drain(..c[c.len() - 1].len()); 68 | 69 | // Remove first line of chunk and concat remaining 70 | let first_line = &mut lines[after.row]; 71 | first_line.truncate(after.offset); 72 | first_line.push_str(&last_line); 73 | } 74 | } 75 | } 76 | 77 | fn invert(&self) -> Self { 78 | use EditKind::*; 79 | match self.clone() { 80 | InsertChar(c) => DeleteChar(c), 81 | DeleteChar(c) => InsertChar(c), 82 | InsertNewline => DeleteNewline, 83 | DeleteNewline => InsertNewline, 84 | InsertStr(s) => DeleteStr(s), 85 | DeleteStr(s) => InsertStr(s), 86 | InsertChunk(c) => DeleteChunk(c), 87 | DeleteChunk(c) => InsertChunk(c), 88 | } 89 | } 90 | } 91 | 92 | #[derive(Clone, Debug)] 93 | pub struct Edit { 94 | kind: EditKind, 95 | before: Pos, 96 | after: Pos, 97 | } 98 | 99 | impl Edit { 100 | pub fn new(kind: EditKind, before: Pos, after: Pos) -> Self { 101 | Self { 102 | kind, 103 | before, 104 | after, 105 | } 106 | } 107 | 108 | pub fn redo(&self, lines: &mut Vec) { 109 | self.kind.apply(lines, &self.before, &self.after); 110 | } 111 | 112 | pub fn undo(&self, lines: &mut Vec) { 113 | self.kind.invert().apply(lines, &self.after, &self.before); // Undo is redo of inverted edit 114 | } 115 | 116 | pub fn cursor_before(&self) -> (usize, usize) { 117 | (self.before.row, self.before.col) 118 | } 119 | 120 | pub fn cursor_after(&self) -> (usize, usize) { 121 | (self.after.row, self.after.col) 122 | } 123 | } 124 | 125 | #[derive(Clone, Debug)] 126 | pub struct History { 127 | index: usize, 128 | max_items: usize, 129 | edits: VecDeque, 130 | } 131 | 132 | impl History { 133 | pub fn new(max_items: usize) -> Self { 134 | Self { 135 | index: 0, 136 | max_items, 137 | edits: VecDeque::new(), 138 | } 139 | } 140 | 141 | pub fn push(&mut self, edit: Edit) { 142 | if self.max_items == 0 { 143 | return; 144 | } 145 | 146 | if self.edits.len() == self.max_items { 147 | self.edits.pop_front(); 148 | self.index = self.index.saturating_sub(1); 149 | } 150 | 151 | if self.index < self.edits.len() { 152 | self.edits.truncate(self.index); 153 | } 154 | 155 | self.index += 1; 156 | self.edits.push_back(edit); 157 | } 158 | 159 | pub fn redo(&mut self, lines: &mut Vec) -> Option<(usize, usize)> { 160 | if self.index == self.edits.len() { 161 | return None; 162 | } 163 | let edit = &self.edits[self.index]; 164 | edit.redo(lines); 165 | self.index += 1; 166 | Some(edit.cursor_after()) 167 | } 168 | 169 | pub fn undo(&mut self, lines: &mut Vec) -> Option<(usize, usize)> { 170 | self.index = self.index.checked_sub(1)?; 171 | let edit = &self.edits[self.index]; 172 | edit.undo(lines); 173 | Some(edit.cursor_before()) 174 | } 175 | 176 | pub fn max_items(&self) -> usize { 177 | self.max_items 178 | } 179 | } 180 | 181 | #[cfg(test)] 182 | mod tests { 183 | use super::*; 184 | 185 | #[test] 186 | fn insert_delete_chunk() { 187 | #[rustfmt::skip] 188 | let tests = [ 189 | // Positions 190 | ( 191 | // Text before edit 192 | &[ 193 | "ab", 194 | "cd", 195 | "ef", 196 | ][..], 197 | // (row, col) position before edit 198 | (0, 0), 199 | // Chunk to be inserted 200 | &[ 201 | "x", "y", 202 | ][..], 203 | // Text after edit 204 | &[ 205 | "x", 206 | "yab", 207 | "cd", 208 | "ef", 209 | ][..], 210 | ), 211 | ( 212 | &[ 213 | "ab", 214 | "cd", 215 | "ef", 216 | ][..], 217 | (0, 1), 218 | &[ 219 | "x", "y", 220 | ][..], 221 | &[ 222 | "ax", 223 | "yb", 224 | "cd", 225 | "ef", 226 | ][..], 227 | ), 228 | ( 229 | &[ 230 | "ab", 231 | "cd", 232 | "ef", 233 | ][..], 234 | (0, 2), 235 | &[ 236 | "x", "y", 237 | ][..], 238 | &[ 239 | "abx", 240 | "y", 241 | "cd", 242 | "ef", 243 | ][..], 244 | ), 245 | ( 246 | &[ 247 | "ab", 248 | "cd", 249 | "ef", 250 | ][..], 251 | (1, 0), 252 | &[ 253 | "x", "y", 254 | ][..], 255 | &[ 256 | "ab", 257 | "x", 258 | "ycd", 259 | "ef", 260 | ][..], 261 | ), 262 | ( 263 | &[ 264 | "ab", 265 | "cd", 266 | "ef", 267 | ][..], 268 | (1, 1), 269 | &[ 270 | "x", "y", 271 | ][..], 272 | &[ 273 | "ab", 274 | "cx", 275 | "yd", 276 | "ef", 277 | ][..], 278 | ), 279 | ( 280 | &[ 281 | "ab", 282 | "cd", 283 | "ef", 284 | ][..], 285 | (1, 2), 286 | &[ 287 | "x", "y", 288 | ][..], 289 | &[ 290 | "ab", 291 | "cdx", 292 | "y", 293 | "ef", 294 | ][..], 295 | ), 296 | ( 297 | &[ 298 | "ab", 299 | "cd", 300 | "ef", 301 | ][..], 302 | (2, 0), 303 | &[ 304 | "x", "y", 305 | ][..], 306 | &[ 307 | "ab", 308 | "cd", 309 | "x", 310 | "yef", 311 | ][..], 312 | ), 313 | ( 314 | &[ 315 | "ab", 316 | "cd", 317 | "ef", 318 | ][..], 319 | (2, 1), 320 | &[ 321 | "x", "y", 322 | ][..], 323 | &[ 324 | "ab", 325 | "cd", 326 | "ex", 327 | "yf", 328 | ][..], 329 | ), 330 | ( 331 | &[ 332 | "ab", 333 | "cd", 334 | "ef", 335 | ][..], 336 | (2, 2), 337 | &[ 338 | "x", "y", 339 | ][..], 340 | &[ 341 | "ab", 342 | "cd", 343 | "efx", 344 | "y", 345 | ][..], 346 | ), 347 | // More than 2 lines 348 | ( 349 | &[ 350 | "ab", 351 | "cd", 352 | "ef", 353 | ][..], 354 | (1, 1), 355 | &[ 356 | "x", "y", "z", "w" 357 | ][..], 358 | &[ 359 | "ab", 360 | "cx", 361 | "y", 362 | "z", 363 | "wd", 364 | "ef", 365 | ][..], 366 | ), 367 | // Empty lines 368 | ( 369 | &[ 370 | "", 371 | "", 372 | "", 373 | ][..], 374 | (0, 0), 375 | &[ 376 | "x", "y", "z" 377 | ][..], 378 | &[ 379 | "x", 380 | "y", 381 | "z", 382 | "", 383 | "", 384 | ][..], 385 | ), 386 | ( 387 | &[ 388 | "", 389 | "", 390 | "", 391 | ][..], 392 | (1, 0), 393 | &[ 394 | "x", "y", "z" 395 | ][..], 396 | &[ 397 | "", 398 | "x", 399 | "y", 400 | "z", 401 | "", 402 | ][..], 403 | ), 404 | ( 405 | &[ 406 | "", 407 | "", 408 | "", 409 | ][..], 410 | (2, 0), 411 | &[ 412 | "x", "y", "z" 413 | ][..], 414 | &[ 415 | "", 416 | "", 417 | "x", 418 | "y", 419 | "z", 420 | ][..], 421 | ), 422 | // Empty buffer 423 | ( 424 | &[ 425 | "", 426 | ][..], 427 | (0, 0), 428 | &[ 429 | "x", "y", "z" 430 | ][..], 431 | &[ 432 | "x", 433 | "y", 434 | "z", 435 | ][..], 436 | ), 437 | // Insert empty lines 438 | ( 439 | &[ 440 | "ab", 441 | "cd", 442 | "ef", 443 | ][..], 444 | (0, 0), 445 | &[ 446 | "", "", "", 447 | ][..], 448 | &[ 449 | "", 450 | "", 451 | "ab", 452 | "cd", 453 | "ef", 454 | ][..], 455 | ), 456 | ( 457 | &[ 458 | "ab", 459 | "cd", 460 | "ef", 461 | ][..], 462 | (1, 0), 463 | &[ 464 | "", "", "", 465 | ][..], 466 | &[ 467 | "ab", 468 | "", 469 | "", 470 | "cd", 471 | "ef", 472 | ][..], 473 | ), 474 | ( 475 | &[ 476 | "ab", 477 | "cd", 478 | "ef", 479 | ][..], 480 | (1, 1), 481 | &[ 482 | "", "", "", 483 | ][..], 484 | &[ 485 | "ab", 486 | "c", 487 | "", 488 | "d", 489 | "ef", 490 | ][..], 491 | ), 492 | ( 493 | &[ 494 | "ab", 495 | "cd", 496 | "ef", 497 | ][..], 498 | (1, 2), 499 | &[ 500 | "", "", "", 501 | ][..], 502 | &[ 503 | "ab", 504 | "cd", 505 | "", 506 | "", 507 | "ef", 508 | ][..], 509 | ), 510 | ( 511 | &[ 512 | "ab", 513 | "cd", 514 | "ef", 515 | ][..], 516 | (2, 2), 517 | &[ 518 | "", "", "", 519 | ][..], 520 | &[ 521 | "ab", 522 | "cd", 523 | "ef", 524 | "", 525 | "", 526 | ][..], 527 | ), 528 | // Multi-byte characters 529 | ( 530 | &[ 531 | "🐶🐱", 532 | "🐮🐰", 533 | "🐧🐭", 534 | ][..], 535 | (0, 0), 536 | &[ 537 | "🐷", "🐼", "🐴", 538 | ][..], 539 | &[ 540 | "🐷", 541 | "🐼", 542 | "🐴🐶🐱", 543 | "🐮🐰", 544 | "🐧🐭", 545 | ][..], 546 | ), 547 | ( 548 | &[ 549 | "🐶🐱", 550 | "🐮🐰", 551 | "🐧🐭", 552 | ][..], 553 | (0, 2), 554 | &[ 555 | "🐷", "🐼", "🐴", 556 | ][..], 557 | &[ 558 | "🐶🐱🐷", 559 | "🐼", 560 | "🐴", 561 | "🐮🐰", 562 | "🐧🐭", 563 | ][..], 564 | ), 565 | ( 566 | &[ 567 | "🐶🐱", 568 | "🐮🐰", 569 | "🐧🐭", 570 | ][..], 571 | (1, 0), 572 | &[ 573 | "🐷", "🐼", "🐴", 574 | ][..], 575 | &[ 576 | "🐶🐱", 577 | "🐷", 578 | "🐼", 579 | "🐴🐮🐰", 580 | "🐧🐭", 581 | ][..], 582 | ), 583 | ( 584 | &[ 585 | "🐶🐱", 586 | "🐮🐰", 587 | "🐧🐭", 588 | ][..], 589 | (1, 1), 590 | &[ 591 | "🐷", "🐼", "🐴", 592 | ][..], 593 | &[ 594 | "🐶🐱", 595 | "🐮🐷", 596 | "🐼", 597 | "🐴🐰", 598 | "🐧🐭", 599 | ][..], 600 | ), 601 | ( 602 | &[ 603 | "🐶🐱", 604 | "🐮🐰", 605 | "🐧🐭", 606 | ][..], 607 | (2, 2), 608 | &[ 609 | "🐷", "🐼", "🐴", 610 | ][..], 611 | &[ 612 | "🐶🐱", 613 | "🐮🐰", 614 | "🐧🐭🐷", 615 | "🐼", 616 | "🐴", 617 | ][..], 618 | ), 619 | ]; 620 | 621 | for test in tests { 622 | let (before, pos, input, expected) = test; 623 | let (row, col) = pos; 624 | let before_pos = { 625 | let offset = before[row] 626 | .char_indices() 627 | .map(|(i, _)| i) 628 | .nth(col) 629 | .unwrap_or(before[row].len()); 630 | Pos::new(row, col, offset) 631 | }; 632 | let mut lines: Vec<_> = before.iter().map(|s| s.to_string()).collect(); 633 | let chunk: Vec<_> = input.iter().map(|s| s.to_string()).collect(); 634 | let after_pos = { 635 | let row = row + input.len() - 1; 636 | let last = input.last().unwrap(); 637 | let col = last.chars().count(); 638 | Pos::new(row, col, last.len()) 639 | }; 640 | 641 | let edit = EditKind::InsertChunk(chunk.clone()); 642 | edit.apply(&mut lines, &before_pos, &after_pos); 643 | assert_eq!(&lines, expected, "{test:?}"); 644 | 645 | let edit = EditKind::DeleteChunk(chunk); 646 | edit.apply(&mut lines, &after_pos, &before_pos); 647 | assert_eq!(&lines, &before, "{test:?}"); 648 | } 649 | } 650 | } 651 | --------------------------------------------------------------------------------