1
0
зеркало из https://github.com/glebtv/tui-textarea.git synced 2026-09-03 14:26:17 +03:00
Этот коммит содержится в:
rhysd
2022-07-08 17:44:38 +09:00
родитель b5a475ba65 fee107847e
Коммит 259a626c97
6 изменённых файлов: 707 добавлений и 111 удалений

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
Просмотреть файл

@@ -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);
}
}
}
}

Просмотреть файл

@@ -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> {