diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d38f15..e63c1df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,9 @@ jobs: toolchain: stable override: true - uses: Swatinem/rust-cache@v1 - - run: cargo test -- --skip src/lib.rs + - run: cargo test --all-features -- --skip src/lib.rs if: ${{ matrix.os != 'windows-latest' }} - - run: cargo test -- --skip src\lib.rs + - run: cargo test --features=search -- --skip src\lib.rs if: ${{ matrix.os == 'windows-latest' }} lint: runs-on: ubuntu-latest @@ -35,9 +35,7 @@ jobs: components: rustfmt, clippy override: true - uses: Swatinem/rust-cache@v1 - - name: rustfmt - run: cargo fmt -- --check - - name: clippy - run: cargo clippy --examples --all-features -- -D warnings - - name: clippy with --no-default-features - run: cargo clippy --no-default-features -- -D warnings + - run: cargo fmt -- --check + - run: cargo clippy --examples --all-features -- -D warnings + - run: cargo clippy --no-default-features -- -D warnings + - run: cargo clippy --no-default-features --features termion -- -D warnings diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a120f6f..9067922 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Please ensure that all tests and linter checks passed on your branch before crea To run tests: ```sh -cargo test -- --skip src/lib.rs +cargo test --features=search -- --skip src/lib.rs ``` `--skip` is necessary since `cargo test` tries to run code blocks in [README file](./README.md). diff --git a/Cargo.toml b/Cargo.toml index fbb1d83..38f78be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,9 +24,11 @@ include = [ default = ["crossterm"] crossterm = ["dep:crossterm", "tui/crossterm"] termion = ["dep:termion", "tui/termion"] +search = ["dep:regex"] [dependencies] crossterm = { version = "0.23", optional = true } +regex = { version = "1", optional = true } termion = { version = "1.5", optional = true } tui = { version = "0.18", default-features = false } @@ -40,7 +42,7 @@ required-features = ["termion"] [[example]] name = "editor" -required-features = ["crossterm"] +required-features = ["crossterm", "search"] [[example]] name = "split" diff --git a/README.md b/README.md index eb974bb..3aea05b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ text editor can be easily put as part of your TUI application. - Undo/Redo - Line number - Cursor line highlight +- Search with regular expressions - Yank support. Paste text deleted with `C-k`, `C-j`, ... - Backend agnostic. [crossterm][], [termion][], and your own backend are all supported - Multiple textarea widgets in the same screen @@ -24,40 +25,60 @@ text editor can be easily put as part of your TUI application. Running `cargo run --example` in this repository can demonstrate usage of tui-textarea. +### [`minimal`](./examples/minimal.rs) + ```sh cargo run --example minimal ``` -### [`minimal`](./examples/minimal.rs) - Minimal usage with [crossterm][] support. minimal example ### [`editor`](./examples/editor.rs) +```sh +cargo run --example editor --features search file.txt +``` + Simple text editor to edit multiple files. -editor example +editor example ### [`single_line`](./examples/single_line.rs) +```sh +cargo run --example single_line +``` + Single-line input form with float number validation. single line example ### [`split`](./examples/split.rs) +```sh +cargo run --example split +``` + Two split textareas in a screen and switch them. An example for multiple textarea instances. multiple textareas example ### [`termion`](./examples/termion.rs) -Minimal usage with [termion][] support. To run this example, `termion` feature needs to be enabled. +```sh +cargo run --example termion --features=termion +``` + +Minimal usage with [termion][] support. ### [`variable`](./examples/variable.rs) +```sh +cargo run --example variable +``` + Simple textarea with variable height following the number of lines. ## Installation @@ -70,6 +91,15 @@ tui = "*" tui-textarea = "*" ``` +If you need text search with regular expressions, enable `search` feature. It adds [regex crate][regex] crate as +dependency. + +```toml +[dependencies] +tui = "*" +tui-textarea = { version = "*", features = ["search"] } +``` + If you're using tui-rs with [termion][], enable `termion` feature instead of `crossterm` feature. ```toml @@ -278,6 +308,44 @@ Setting 0 disables undo/redo. textarea.set_max_histories(0); ``` +### Text search with regular expressions + +To search text in textarea, set a regular expression pattern with `TextArea::set_search_pattern()` and move cursor with +`TextArea::search_forward()` for forward search or `TextArea::search_back()` backward search. The regular expression is +handled by [`regex` crate][regex]. + +Text search wraps around the textarea. When searching forward and no match found until the end of textarea, it searches +the pattern from start of the file. + +Matches are highlighted in textarea. The text style to highlight matches can be changed with +`TextArea::set_search_style()`. Setting an empty string to `TextArea::set_search_pattern()` stops the text search. + +```rust +// Start text search matching to "hello" or "hi". This highlights matches in textarea but does not move cursor. +// `regex::Error` is returned on invalid pattern. +textarea.set_search_pattern("(hello|hi)").unwrap(); + +textarea.search_forward(false); // Move cursor to the next match +textarea.search_back(false); // Move cursor to the previous match + +// Setting empty string stops the search +textarea.set_search_pattern("").unwrap(); +``` + +No UI is provided for text search. You need to provide your own UI to input search query. It is recommended to use +another `TextArea` for search form. To build a single-line input form, see 'Single-line input like `` in HTML' in +'Advanced Usage' section below. + +[`editor` example](./examples/editor.rs) implements a text search with search form built on `TextArea`. See the +implementation for working example. + +To use text search, `search` feature needs to be enabled in your `Cargo.toml`. It is disabled by default to avoid +depending on `regex` crate until it is necessary. + +```toml +tui-textarea = { version = "*", features = ["search"] } +``` + ## Advanced Usage ### Single-line input like `` in HTML @@ -317,31 +385,34 @@ See [`single_line` example](./examples/single_line.rs) for working example. All editor operations are defined as public methods of `TextArea`. To move cursor, use `tui_textarea::CursorMove` to notify how to move the cursor. -| Method | Operation | -|------------------------------------------------------|-------------------------------------------| -| `textarea.delete_char()` | Delete one character before cursor | -| `textarea.delete_next_char()` | Delete one character next to cursor | -| `textarea.insert_newline()` | Insert newline | -| `textarea.delete_line_by_end()` | Delete from cursor until the end of line | -| `textarea.delete_line_by_head()` | Delete from cursor until the head of line | -| `textarea.delete_word()` | Delete one word before cursor | -| `textarea.delete_next_word()` | Delete one word next to cursor | -| `textarea.undo()` | Undo | -| `textarea.redo()` | Redo | -| `textarea.paste()` | Paste yanked text | -| `textarea.move_cursor(CursorMove::Forward)` | Move cursor forward by one character | -| `textarea.move_cursor(CursorMove::Back)` | Move cursor backward by one character | -| `textarea.move_cursor(CursorMove::Up)` | Move cursor up by one line | -| `textarea.move_cursor(CursorMove::Down)` | Move cursor down by one line | -| `textarea.move_cursor(CursorMove::WordForward)` | Move cursor forward by word | -| `textarea.move_cursor(CursorMove::WordBack)` | Move cursor backward by word | -| `textarea.move_cursor(CursorMove::ParagraphForward)` | Move cursor up by paragraph | -| `textarea.move_cursor(CursorMove::ParagraphBack)` | Move cursor down by paragraph | -| `textarea.move_cursor(CursorMove::End)` | Move cursor to the end of line | -| `textarea.move_cursor(CursorMove::Head)` | Move cursor to the head of line | -| `textarea.move_cursor(CursorMove::Top)` | Move cursor to top of lines | -| `textarea.move_cursor(CursorMove::Bottom)` | Move cursor to bottom of lines | -| `textarea.move_cursor(CursorMove::Jump(row, col))` | Move cursor to (row, col) position | +| Method | Operation | +|------------------------------------------------------|----------------------------------------------| +| `textarea.delete_char()` | Delete one character before cursor | +| `textarea.delete_next_char()` | Delete one character next to cursor | +| `textarea.insert_newline()` | Insert newline | +| `textarea.delete_line_by_end()` | Delete from cursor until the end of line | +| `textarea.delete_line_by_head()` | Delete from cursor until the head of line | +| `textarea.delete_word()` | Delete one word before cursor | +| `textarea.delete_next_word()` | Delete one word next to cursor | +| `textarea.undo()` | Undo | +| `textarea.redo()` | Redo | +| `textarea.paste()` | Paste yanked text | +| `textarea.move_cursor(CursorMove::Forward)` | Move cursor forward by one character | +| `textarea.move_cursor(CursorMove::Back)` | Move cursor backward by one character | +| `textarea.move_cursor(CursorMove::Up)` | Move cursor up by one line | +| `textarea.move_cursor(CursorMove::Down)` | Move cursor down by one line | +| `textarea.move_cursor(CursorMove::WordForward)` | Move cursor forward by word | +| `textarea.move_cursor(CursorMove::WordBack)` | Move cursor backward by word | +| `textarea.move_cursor(CursorMove::ParagraphForward)` | Move cursor up by paragraph | +| `textarea.move_cursor(CursorMove::ParagraphBack)` | Move cursor down by paragraph | +| `textarea.move_cursor(CursorMove::End)` | Move cursor to the end of line | +| `textarea.move_cursor(CursorMove::Head)` | Move cursor to the head of line | +| `textarea.move_cursor(CursorMove::Top)` | Move cursor to top of lines | +| `textarea.move_cursor(CursorMove::Bottom)` | Move cursor to bottom of lines | +| `textarea.move_cursor(CursorMove::Jump(row, col))` | Move cursor to (row, col) position | +| `textarea.set_search_pattern(pattern)` | Set a pattern for text search | +| `textarea.search_forward(match_cursor)` | Move cursor to next match of text search | +| `textarea.search_back(match_cursor)` | Move cursor to previous match of text search | To define your own key mappings, simply call the above methods in your code instead of `TextArea::input()` method. The following example defines modal key mappings like Vim. @@ -549,3 +620,4 @@ tui-textarea is distributed under [The MIT License](./LICENSE.txt). [repo]: https://github.com/rhysd/tui-textarea [new-issue]: https://github.com/rhysd/tui-textarea/issues/new [pulls]: https://github.com/rhysd/tui-textarea/pulls +[regex]: https://docs.rs/regex/latest/regex/ diff --git a/examples/editor.rs b/examples/editor.rs index b8cdcd8..7c7dac4 100644 --- a/examples/editor.rs +++ b/examples/editor.rs @@ -5,6 +5,7 @@ use crossterm::terminal::{ }; use std::borrow::Cow; use std::env; +use std::fmt::Display; use std::fs; use std::io; use std::io::{BufRead, Write}; @@ -13,9 +14,9 @@ use tui::backend::CrosstermBackend; use tui::layout::{Constraint, Direction, Layout}; use tui::style::{Color, Modifier, Style}; use tui::text::{Span, Spans}; -use tui::widgets::Paragraph; +use tui::widgets::{Block, Borders, Paragraph}; use tui::Terminal; -use tui_textarea::{Input, Key, TextArea}; +use tui_textarea::{CursorMove, Input, Key, TextArea}; macro_rules! error { ($fmt: expr $(, $args:tt)*) => {{ @@ -23,6 +24,73 @@ macro_rules! error { }}; } +struct SearchBox<'a> { + textarea: TextArea<'a>, + open: bool, +} + +impl<'a> Default for SearchBox<'a> { + fn default() -> Self { + let mut textarea = TextArea::default(); + textarea.set_block(Block::default().borders(Borders::ALL).title("Search")); + Self { + textarea, + open: false, + } + } +} + +impl<'a> SearchBox<'a> { + fn open(&mut self) { + self.open = true; + } + + fn close(&mut self) { + self.open = false; + // Remove input for next search. Do not recreate `self.textarea` instance to keep undo history so that users can + // restore previous input easily. + self.textarea.move_cursor(CursorMove::End); + self.textarea.delete_line_by_head(); + } + + fn height(&self) -> u16 { + if self.open { + 3 + } else { + 0 + } + } + + fn input(&mut self, input: Input) -> Option<&'_ str> { + match input { + Input { + key: Key::Enter, .. + } + | Input { + key: Key::Char('m'), + ctrl: true, + .. + } => None, // Disable shortcuts which inserts a newline. See `single_line` example + input => { + let modified = self.textarea.input(input); + modified.then(|| self.textarea.lines()[0].as_str()) + } + } + } + + fn set_error(&mut self, err: Option) { + let b = if let Some(err) = err { + Block::default() + .borders(Borders::ALL) + .title(format!("Search: {}", err)) + .style(Style::default().fg(Color::Red)) + } else { + Block::default().borders(Borders::ALL).title("Search") + }; + self.textarea.set_block(b); + } +} + struct Buffer<'a> { textarea: TextArea<'a>, path: PathBuf, @@ -73,6 +141,7 @@ struct Editor<'a> { buffers: Vec>, term: Terminal>, message: Option>, + search: SearchBox<'a>, } impl<'a> Editor<'a> { @@ -99,28 +168,36 @@ impl<'a> Editor<'a> { buffers, term, message: None, + search: SearchBox::default(), }) } fn run(&mut self) -> io::Result<()> { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Min(1), - Constraint::Length(1), - Constraint::Length(1), - ] - .as_ref(), - ); - loop { + let search_height = self.search.height(); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(search_height), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ] + .as_ref(), + ); + self.term.draw(|f| { + let chunks = layout.split(f.size()); + + if search_height > 0 { + f.render_widget(self.search.textarea.widget(), chunks[0]); + } + let buffer = &self.buffers[self.current]; let textarea = &buffer.textarea; - let chunks = layout.split(f.size()); let widget = textarea.widget(); - f.render_widget(widget, chunks[0]); + f.render_widget(widget, chunks[1]); // Render status line let modified = if buffer.modified { " [modified]" } else { "" }; @@ -138,7 +215,7 @@ impl<'a> Editor<'a> { ] .as_ref(), ) - .split(chunks[1]); + .split(chunks[2]); let status_style = Style::default().add_modifier(Modifier::REVERSED); f.render_widget(Paragraph::new(slot).style(status_style), status_chunks[0]); f.render_widget(Paragraph::new(path).style(status_style), status_chunks[1]); @@ -147,6 +224,24 @@ impl<'a> Editor<'a> { // Render message at bottom let message = if let Some(message) = self.message.take() { Spans::from(Span::raw(message)) + } else if search_height > 0 { + Spans::from(vec![ + Span::raw("Press "), + Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to jump to first match and close, "), + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to close, "), + Span::styled( + "^G or ↓ or ^N", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" to search next, "), + Span::styled( + "M-G or ↑ or ^P", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" to search previous"), + ]) } else { Spans::from(vec![ Span::raw("Press "), @@ -154,38 +249,98 @@ impl<'a> Editor<'a> { Span::raw(" to quit, "), Span::styled("^S", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" to save, "), + Span::styled("^G", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to search, "), Span::styled("^X", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" to switch buffer"), ]) }; - f.render_widget(Paragraph::new(message), chunks[2]); + f.render_widget(Paragraph::new(message), chunks[3]); })?; - match Input::from(crossterm::event::read()?) { - Input { - key: Key::Char('q'), - ctrl: true, - .. - } => break, - Input { - key: Key::Char('x'), - ctrl: true, - .. - } => { - self.current = (self.current + 1) % self.buffers.len(); - self.message = Some(format!("Switched to buffer #{}", self.current + 1).into()); + if search_height > 0 { + let textarea = &mut self.buffers[self.current].textarea; + match Input::from(crossterm::event::read()?) { + Input { + key: Key::Char('g' | 'n'), + ctrl: true, + alt: false, + } + | Input { key: Key::Down, .. } => { + if !textarea.search_forward(false) { + self.search.set_error(Some("Pattern not found")); + } + } + Input { + key: Key::Char('g'), + ctrl: false, + alt: true, + } + | Input { + key: Key::Char('p'), + ctrl: true, + alt: false, + } + | Input { key: Key::Up, .. } => { + if !textarea.search_back(false) { + self.search.set_error(Some("Pattern not found")); + } + } + Input { + key: Key::Enter, .. + } => { + if !textarea.search_forward(true) { + self.message = Some("Pattern not found".into()); + } + self.search.close(); + textarea.set_search_pattern("").unwrap(); + } + Input { key: Key::Esc, .. } => { + self.search.close(); + textarea.set_search_pattern("").unwrap(); + } + input => { + if let Some(query) = self.search.input(input) { + let maybe_err = textarea.set_search_pattern(query).err(); + self.search.set_error(maybe_err); + } + } } - Input { - key: Key::Char('s'), - ctrl: true, - .. - } => { - self.buffers[self.current].save()?; - self.message = Some("Saved!".into()); - } - input => { - let buffer = &mut self.buffers[self.current]; - buffer.modified = buffer.textarea.input(input); + } else { + match Input::from(crossterm::event::read()?) { + Input { + key: Key::Char('q'), + ctrl: true, + .. + } => break, + Input { + key: Key::Char('x'), + ctrl: true, + .. + } => { + self.current = (self.current + 1) % self.buffers.len(); + self.message = + Some(format!("Switched to buffer #{}", self.current + 1).into()); + } + Input { + key: Key::Char('s'), + ctrl: true, + .. + } => { + self.buffers[self.current].save()?; + self.message = Some("Saved!".into()); + } + Input { + key: Key::Char('g'), + ctrl: true, + .. + } => { + self.search.open(); + } + input => { + let buffer = &mut self.buffers[self.current]; + buffer.modified = buffer.textarea.input(input); + } } } } diff --git a/src/textarea.rs b/src/textarea.rs index 3a7c10a..601e0d1 100644 --- a/src/textarea.rs +++ b/src/textarea.rs @@ -2,7 +2,10 @@ use crate::cursor::CursorMove; use crate::history::{Edit, EditKind, History}; use crate::input::{Input, Key}; use crate::word::{find_word_end_forward, find_word_start_backward}; +#[cfg(feature = "search")] +use regex::Regex; use std::borrow::Cow; +use std::cmp; use std::sync::atomic::{AtomicU16, Ordering}; use tui::buffer::Buffer; use tui::layout::Rect; @@ -15,6 +18,41 @@ fn spaces(size: u8) -> &'static str { &SPACES[..size as usize] } +fn num_digits(i: usize) -> u8 { + f64::log10(i as f64) as u8 + 1 +} + +// Highlight boundary in line +enum Boundary { + Cursor(Style), + #[cfg(feature = "search")] + Search(Style), + End, +} + +impl Boundary { + fn cmp(&self, other: &Boundary) -> cmp::Ordering { + fn rank(b: &Boundary) -> u8 { + match b { + Boundary::Cursor(_) => 2, + #[cfg(feature = "search")] + Boundary::Search(_) => 1, + Boundary::End => 0, + } + } + rank(self).cmp(&rank(other)) + } + + fn style(&self) -> Option