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.
### [`editor`](./examples/editor.rs)
+```sh
+cargo run --example editor --features search file.txt
+```
+
Simple text editor to edit multiple files.
-
+
### [`single_line`](./examples/single_line.rs)
+```sh
+cargo run --example single_line
+```
+
Single-line input form with float number validation.
### [`split`](./examples/split.rs)
+```sh
+cargo run --example split
+```
+
Two split textareas in a screen and switch them. An example for multiple textarea instances.
### [`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