зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-09-03 14:26:17 +03:00
Merge branch 'search'
Этот коммит содержится в:
14
.github/workflows/ci.yml
поставляемый
14
.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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
130
README.md
130
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.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/rhysd/ss/master/tui-textarea/minimal.gif" width=539 height=172 alt="minimal example">
|
||||
|
||||
### [`editor`](./examples/editor.rs)
|
||||
|
||||
```sh
|
||||
cargo run --example editor --features search file.txt
|
||||
```
|
||||
|
||||
Simple text editor to edit multiple files.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/rhysd/ss/master/tui-textarea/editor.gif" width=539 height=172 alt="editor example">
|
||||
<img src="https://raw.githubusercontent.com/rhysd/ss/master/tui-textarea/editor.gif" width=560 height=236 alt="editor example">
|
||||
|
||||
### [`single_line`](./examples/single_line.rs)
|
||||
|
||||
```sh
|
||||
cargo run --example single_line
|
||||
```
|
||||
|
||||
Single-line input form with float number validation.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/rhysd/ss/master/tui-textarea/single_line.gif" width=539 height=92 alt="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.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/rhysd/ss/master/tui-textarea/split.gif" width=539 height=124 alt="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 `<input>` 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 `<input>` 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/
|
||||
|
||||
@@ -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<impl Display>) {
|
||||
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<Buffer<'a>>,
|
||||
term: Terminal<CrosstermBackend<io::Stdout>>,
|
||||
message: Option<Cow<'static, str>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
431
src/textarea.rs
431
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<Style> {
|
||||
match self {
|
||||
Boundary::Cursor(s) => Some(*s),
|
||||
#[cfg(feature = "search")]
|
||||
Boundary::Search(s) => Some(*s),
|
||||
Boundary::End => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A type to manage state of textarea.
|
||||
///
|
||||
/// [`TextArea::default`] creates an empty textarea. [`TextArea::new`] creates a textarea with given text lines.
|
||||
@@ -48,6 +86,10 @@ pub struct TextArea<'a> {
|
||||
scroll_top: (AtomicU16, AtomicU16),
|
||||
cursor_style: Style,
|
||||
yank: String,
|
||||
#[cfg(feature = "search")]
|
||||
search_pat: Option<Regex>,
|
||||
#[cfg(feature = "search")]
|
||||
search_style: Style,
|
||||
}
|
||||
|
||||
/// Convert any iterator whose elements can be converted into [`String`] into [`TextArea`]. Each [`String`] element is
|
||||
@@ -142,6 +184,10 @@ impl<'a> TextArea<'a> {
|
||||
scroll_top: (AtomicU16::new(0), AtomicU16::new(0)),
|
||||
cursor_style: Style::default().add_modifier(Modifier::REVERSED),
|
||||
yank: String::new(),
|
||||
#[cfg(feature = "search")]
|
||||
search_pat: None,
|
||||
#[cfg(feature = "search")]
|
||||
search_style: Style::default().bg(tui::style::Color::Blue),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,6 +982,82 @@ impl<'a> TextArea<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn line_spans<'b>(&'b self, line: &'b str, row: usize, lnum_len: u8) -> Vec<Span<'b>> {
|
||||
let mut spans = vec![];
|
||||
|
||||
if let Some(style) = self.line_number_style {
|
||||
let pad = spaces(lnum_len - num_digits(row + 1) + 1);
|
||||
spans.push(Span::styled(format!("{}{} ", pad, row + 1), style));
|
||||
}
|
||||
|
||||
let mut boundaries = vec![]; // TODO: Consider smallvec
|
||||
let mut cursor_at_end = false;
|
||||
|
||||
let style_begin = if row == self.cursor.0 {
|
||||
if let Some((start, c)) = line.char_indices().nth(self.cursor.1) {
|
||||
boundaries.push((Boundary::Cursor(self.cursor_style), start));
|
||||
boundaries.push((Boundary::End, start + c.len_utf8()));
|
||||
} else {
|
||||
cursor_at_end = true;
|
||||
}
|
||||
self.cursor_line_style
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
#[cfg(feature = "search")]
|
||||
if let Some(pat) = &self.search_pat {
|
||||
for m in pat.find_iter(line) {
|
||||
if m.start() != m.end() {
|
||||
boundaries.push((Boundary::Search(self.search_style), m.start()));
|
||||
boundaries.push((Boundary::End, m.end()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if boundaries.is_empty() {
|
||||
spans.push(Span::raw(self.replace_tabs(line)));
|
||||
if cursor_at_end {
|
||||
spans.push(Span::styled(" ", self.cursor_style));
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
boundaries.sort_unstable_by(|(l, i), (r, j)| match i.cmp(j) {
|
||||
cmp::Ordering::Equal => l.cmp(r),
|
||||
o => o,
|
||||
});
|
||||
|
||||
let mut boundaries = boundaries.into_iter();
|
||||
let mut style = style_begin;
|
||||
let mut start = 0;
|
||||
let mut stack = vec![];
|
||||
|
||||
loop {
|
||||
if let Some((next_boundary, end)) = boundaries.next() {
|
||||
if start < end {
|
||||
spans.push(Span::styled(self.replace_tabs(&line[start..end]), style));
|
||||
}
|
||||
|
||||
style = if let Some(s) = next_boundary.style() {
|
||||
stack.push(style);
|
||||
s
|
||||
} else {
|
||||
stack.pop().unwrap_or(style_begin)
|
||||
};
|
||||
start = end;
|
||||
} else {
|
||||
if start != line.len() {
|
||||
spans.push(Span::styled(self.replace_tabs(&line[start..]), style));
|
||||
}
|
||||
if cursor_at_end {
|
||||
spans.push(Span::styled(" ", self.cursor_style));
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a tui-rs widget to render the current state of the textarea. The widget instance returned from this
|
||||
/// method can be rendered with [`tui::terminal::Frame::render_widget`].
|
||||
/// ```no_run
|
||||
@@ -963,39 +1085,10 @@ impl<'a> TextArea<'a> {
|
||||
/// }
|
||||
/// ```
|
||||
pub fn widget(&'a self) -> impl Widget + 'a {
|
||||
fn num_digits(i: usize) -> u8 {
|
||||
f64::log10(i as f64) as u8 + 1
|
||||
}
|
||||
|
||||
let mut lines = Vec::with_capacity(self.lines.len());
|
||||
let line_number_len = num_digits(self.lines.len());
|
||||
let lnum_len = num_digits(self.lines.len());
|
||||
for (i, l) in self.lines.iter().map(String::as_str).enumerate() {
|
||||
let mut spans = vec![];
|
||||
|
||||
if let Some(style) = self.line_number_style {
|
||||
let pad = spaces(line_number_len - num_digits(i + 1) + 1);
|
||||
spans.push(Span::styled(format!("{}{} ", pad, i + 1), style));
|
||||
}
|
||||
|
||||
if i == self.cursor.0 {
|
||||
if let Some((i, c)) = l.char_indices().nth(self.cursor.1) {
|
||||
let j = i + c.len_utf8();
|
||||
spans.extend_from_slice(&[
|
||||
Span::styled(self.replace_tabs(&l[..i]), self.cursor_line_style),
|
||||
Span::styled(self.replace_tabs(&l[i..j]), self.cursor_style),
|
||||
Span::styled(self.replace_tabs(&l[j..]), self.cursor_line_style),
|
||||
]);
|
||||
} else {
|
||||
// When cursor is at the end of line
|
||||
spans.extend_from_slice(&[
|
||||
Span::styled(self.replace_tabs(l), self.cursor_line_style),
|
||||
Span::styled(" ", self.cursor_style),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::raw(self.replace_tabs(l)));
|
||||
}
|
||||
|
||||
let spans = self.line_spans(l, i, lnum_len);
|
||||
lines.push(Spans::from(spans));
|
||||
}
|
||||
|
||||
@@ -1326,6 +1419,282 @@ impl<'a> TextArea<'a> {
|
||||
pub fn set_yank_text(&mut self, text: impl Into<String>) {
|
||||
self.yank = text.into();
|
||||
}
|
||||
|
||||
/// Set a regular expression pattern for text search. Setting an empty string stops the text search.
|
||||
/// When a valid pattern is set, all matches will be highlighted in the textarea. Note that the cursor does not
|
||||
/// move. To move the cursor, use [`TextArea::search_forward`] and [`TextArea::search_back`].
|
||||
///
|
||||
/// Grammar of regular expression follows [regex crate](https://docs.rs/regex/latest/regex). Patterns don't match
|
||||
/// to newlines so match passes across no newline.
|
||||
///
|
||||
/// When the pattern is invalid, the search pattern will not be updated and an error will be returned.
|
||||
///
|
||||
/// ```
|
||||
/// use tui_textarea::TextArea;
|
||||
///
|
||||
/// let mut textarea = TextArea::from(["hello, world", "goodbye, world"]);
|
||||
///
|
||||
/// // Search "world"
|
||||
/// textarea.set_search_pattern("world").unwrap();
|
||||
///
|
||||
/// assert_eq!(textarea.cursor(), (0, 0));
|
||||
/// textarea.search_forward(false);
|
||||
/// assert_eq!(textarea.cursor(), (0, 7));
|
||||
/// textarea.search_forward(false);
|
||||
/// assert_eq!(textarea.cursor(), (1, 9));
|
||||
///
|
||||
/// // Stop the text search
|
||||
/// textarea.set_search_pattern("");
|
||||
///
|
||||
/// // Invalid search pattern
|
||||
/// assert!(textarea.set_search_pattern("(hello").is_err());
|
||||
/// ```
|
||||
#[cfg(feature = "search")]
|
||||
pub fn set_search_pattern(&mut self, query: impl AsRef<str>) -> Result<(), regex::Error> {
|
||||
let query = query.as_ref();
|
||||
match &self.search_pat {
|
||||
Some(r) if r.as_str() == query => {}
|
||||
_ if query.is_empty() => self.search_pat = None,
|
||||
_ => self.search_pat = Some(Regex::new(query)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a regular expression which was set by [`TextArea::set_search_pattern`]. When no text search is ongoing, this
|
||||
/// method returns `None`.
|
||||
///
|
||||
/// ```
|
||||
/// use tui_textarea::TextArea;
|
||||
///
|
||||
/// let mut textarea = TextArea::default();
|
||||
///
|
||||
/// assert!(textarea.search_pattern().is_none());
|
||||
/// textarea.set_search_pattern("hello+").unwrap();
|
||||
/// assert!(textarea.search_pattern().is_some());
|
||||
/// assert_eq!(textarea.search_pattern().unwrap().as_str(), "hello+");
|
||||
/// ```
|
||||
#[cfg(feature = "search")]
|
||||
pub fn search_pattern(&self) -> Option<&Regex> {
|
||||
self.search_pat.as_ref()
|
||||
}
|
||||
|
||||
/// Search the pattern set by [`TextArea::set_search_pattern`] forward and move the cursor to the next match
|
||||
/// position based on the current cursor position. Text search wraps around a text buffer. It returns `true` when
|
||||
/// some match was found. Otherwise it returns `false`.
|
||||
///
|
||||
/// The `match_cursor` parameter represents if the search matches to the current cursor position or not. When `true`
|
||||
/// is set and the cursor position matches to the pattern, the cursor will not move. When `false`, the cursor will
|
||||
/// move to the next match ignoring the match at the current position.
|
||||
///
|
||||
/// ```
|
||||
/// use tui_textarea::TextArea;
|
||||
///
|
||||
/// let mut textarea = TextArea::from(["hello", "helloo", "hellooo"]);
|
||||
///
|
||||
/// textarea.set_search_pattern("hello+").unwrap();
|
||||
///
|
||||
/// // Move to next position
|
||||
/// let match_found = textarea.search_forward(false);
|
||||
/// assert!(match_found);
|
||||
/// assert_eq!(textarea.cursor(), (1, 0));
|
||||
///
|
||||
/// // Since the cursor position matches to "hello+", it does not move
|
||||
/// textarea.search_forward(true);
|
||||
/// assert_eq!(textarea.cursor(), (1, 0));
|
||||
///
|
||||
/// // When `match_current` parameter is set to `false`, match at the cursor position is ignored
|
||||
/// textarea.search_forward(false);
|
||||
/// assert_eq!(textarea.cursor(), (2, 0));
|
||||
///
|
||||
/// // Text search wrap around the buffer
|
||||
/// textarea.search_forward(false);
|
||||
/// assert_eq!(textarea.cursor(), (0, 0));
|
||||
///
|
||||
/// // `false` is returned when no match was found
|
||||
/// textarea.set_search_pattern("bye+").unwrap();
|
||||
/// let match_found = textarea.search_forward(false);
|
||||
/// assert!(!match_found);
|
||||
/// ```
|
||||
#[cfg(feature = "search")]
|
||||
pub fn search_forward(&mut self, match_cursor: bool) -> bool {
|
||||
let pat = if let Some(pat) = &self.search_pat {
|
||||
pat
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let (row, col) = self.cursor;
|
||||
let current_line = &self.lines[row];
|
||||
|
||||
// Search current line after cursor
|
||||
let start_col = if match_cursor { col } else { col + 1 };
|
||||
if let Some((i, _)) = current_line.char_indices().nth(start_col) {
|
||||
if let Some(m) = pat.find_at(current_line, i) {
|
||||
let col = start_col + current_line[i..m.start()].chars().count();
|
||||
self.cursor = (row, col);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Search lines after cursor
|
||||
for (i, line) in self.lines[row + 1..].iter().enumerate() {
|
||||
if let Some(m) = pat.find(line) {
|
||||
let col = line[..m.start()].chars().count();
|
||||
self.cursor = (row + 1 + i, col);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Search lines before cursor (wrap)
|
||||
for (i, line) in self.lines[..row].iter().enumerate() {
|
||||
if let Some(m) = pat.find(line) {
|
||||
let col = line[..m.start()].chars().count();
|
||||
self.cursor = (i, col);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Search current line before cursor
|
||||
let col_idx = current_line
|
||||
.char_indices()
|
||||
.nth(col)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(current_line.len());
|
||||
if let Some(m) = pat.find(current_line) {
|
||||
let i = m.start();
|
||||
if i <= col_idx {
|
||||
let col = current_line[..i].chars().count();
|
||||
self.cursor = (row, col);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Search the pattern set by [`TextArea::set_search_pattern`] backward and move the cursor to the next match
|
||||
/// position based on the current cursor position. Text search wraps around a text buffer. It returns `true` when
|
||||
/// some match was found. Otherwise it returns `false`.
|
||||
///
|
||||
/// The `match_cursor` parameter represents if the search matches to the current cursor position or not. When `true`
|
||||
/// is set and the cursor position matches to the pattern, the cursor will not move. When `false`, the cursor will
|
||||
/// move to the next match ignoring the match at the current position.
|
||||
///
|
||||
/// ```
|
||||
/// use tui_textarea::TextArea;
|
||||
///
|
||||
/// let mut textarea = TextArea::from(["hello", "helloo", "hellooo"]);
|
||||
///
|
||||
/// textarea.set_search_pattern("hello+").unwrap();
|
||||
///
|
||||
/// // Move to next position with wrapping around the text buffer
|
||||
/// let match_found = textarea.search_back(false);
|
||||
/// assert!(match_found);
|
||||
/// assert_eq!(textarea.cursor(), (2, 0));
|
||||
///
|
||||
/// // Since the cursor position matches to "hello+", it does not move
|
||||
/// textarea.search_back(true);
|
||||
/// assert_eq!(textarea.cursor(), (2, 0));
|
||||
///
|
||||
/// // When `match_current` parameter is set to `false`, match at the cursor position is ignored
|
||||
/// textarea.search_back(false);
|
||||
/// assert_eq!(textarea.cursor(), (1, 0));
|
||||
///
|
||||
/// // `false` is returned when no match was found
|
||||
/// textarea.set_search_pattern("bye+").unwrap();
|
||||
/// let match_found = textarea.search_back(false);
|
||||
/// assert!(!match_found);
|
||||
/// ```
|
||||
#[cfg(feature = "search")]
|
||||
pub fn search_back(&mut self, match_cursor: bool) -> bool {
|
||||
let pat = if let Some(pat) = &self.search_pat {
|
||||
pat
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let (row, col) = self.cursor;
|
||||
let current_line = &self.lines[row];
|
||||
|
||||
// Search current line before cursor
|
||||
if col > 0 || match_cursor {
|
||||
let start_col = if match_cursor { col } else { col - 1 };
|
||||
if let Some((i, _)) = current_line.char_indices().nth(start_col) {
|
||||
if let Some(m) = pat
|
||||
.find_iter(current_line)
|
||||
.take_while(|m| m.start() <= i)
|
||||
.last()
|
||||
{
|
||||
let col = current_line[..m.start()].chars().count();
|
||||
self.cursor = (row, col);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search lines before cursor
|
||||
for (i, line) in self.lines[..row].iter().enumerate().rev() {
|
||||
if let Some(m) = pat.find_iter(line).last() {
|
||||
let col = line[..m.start()].chars().count();
|
||||
self.cursor = (i, col);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Search lines after cursor (wrap)
|
||||
for (i, line) in self.lines[row + 1..].iter().enumerate().rev() {
|
||||
if let Some(m) = pat.find_iter(line).last() {
|
||||
let col = line[..m.start()].chars().count();
|
||||
self.cursor = (row + 1 + i, col);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Search current line after cursor
|
||||
if let Some((i, _)) = current_line.char_indices().nth(col) {
|
||||
if let Some(m) = pat
|
||||
.find_iter(current_line)
|
||||
.skip_while(|m| m.start() < i)
|
||||
.last()
|
||||
{
|
||||
let col = col + current_line[i..m.start()].chars().count();
|
||||
self.cursor = (row, col);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Get the text style at matches of text search. The default style is colored with blue in background.
|
||||
///
|
||||
/// ```
|
||||
/// use tui::style::{Style, Color};
|
||||
/// use tui_textarea::TextArea;
|
||||
///
|
||||
/// let textarea = TextArea::default();
|
||||
///
|
||||
/// assert_eq!(textarea.search_style(), Style::default().bg(Color::Blue));
|
||||
/// ```
|
||||
#[cfg(feature = "search")]
|
||||
pub fn search_style(&self) -> Style {
|
||||
self.search_style
|
||||
}
|
||||
|
||||
/// Set the text style at matches of text search. The default style is colored with blue in background.
|
||||
/// ```
|
||||
/// use tui::style::{Style, Color};
|
||||
/// use tui_textarea::TextArea;
|
||||
///
|
||||
/// let mut textarea = TextArea::default();
|
||||
///
|
||||
/// let red_bg = Style::default().bg(Color::Red);
|
||||
/// textarea.set_search_style(red_bg);
|
||||
///
|
||||
/// assert_eq!(textarea.search_style(), red_bg);
|
||||
/// ```
|
||||
#[cfg(feature = "search")]
|
||||
pub fn set_search_style(&mut self, style: Style) {
|
||||
self.search_style = style;
|
||||
}
|
||||
}
|
||||
|
||||
struct Renderer<'a> {
|
||||
|
||||
Ссылка в новой задаче
Block a user