1
0
зеркало из https://github.com/glebtv/tui-textarea.git synced 2026-08-28 11:36:17 +03:00

feat: add screen/data cursor maps for wrapped movement (#17)

* feat!: remove tui-rs support and map screen cursors

* docs(examples): add fixed-size recording mode

* chore(release): bump to v0.11.0
Этот коммит содержится в:
srothgan
2026-05-03 08:48:05 +02:00
коммит произвёл GitHub
родитель f20df6b965
Коммит 8f708cfdc7
28 изменённых файлов: 782 добавлений и 925 удалений

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

@@ -1,3 +1,14 @@
<a id="v0.11.0"></a>
# [v0.11.0](https://github.com/srothgan/tui-textarea/releases/tag/v0.11.0) - 2026-05-03
- **BREAKING:** Remove legacy `tui-rs` feature support and the `tuirs-*` examples; this fork now supports ratatui integrations only.
- Add a screen/data cursor mapping layer to preserve visual columns correctly across wrapped rows containing tabs or wide Unicode characters.
- Rework wrapped cursor movement and rendering to use the cached screen map shared by the widget and cursor logic.
- Add regression tests for wrapped cursor movement across mixed-width text.
- Add an opt-in fixed-size recording mode for crossterm examples via `TUI_TEXTAREA_RECORDING` and `TUI_TEXTAREA_RECORDING_SIZE`.
[Changes][v0.11.0]
<a id="v0.10.2"></a>
# [v0.10.2](https://github.com/srothgan/tui-textarea/releases/tag/v0.10.2) - 2026-03-13
@@ -526,6 +537,7 @@ First release :tada:
[Changes][v0.1.0]
[v0.11.0]: https://github.com/srothgan/tui-textarea/compare/v0.10.2...v0.11.0
[v0.10.2]: https://github.com/srothgan/tui-textarea/compare/v0.10.1...v0.10.2
[v0.10.1]: https://github.com/srothgan/tui-textarea/compare/v0.10.0...v0.10.1
[v0.10.0]: https://github.com/srothgan/tui-textarea/compare/v0.9.2...v0.10.0

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

@@ -3,12 +3,12 @@ name = "tui-textarea-2"
homepage = "https://github.com/srothgan/tui-textarea#readme"
repository = "https://github.com/srothgan/tui-textarea"
documentation = "https://docs.rs/tui-textarea-2/latest/tui_textarea/"
version = "0.10.2"
version = "0.11.0"
edition = "2024"
rust-version = "1.85.0" # for Rust 2024 edition support
authors = ["Simon Peter Rothgang <simonrothgang@icloud.com>", "rhysd <lin90162@yahoo.co.jp>"]
description = """
tui-textarea is a simple yet powerful text editor widget for ratatui and tui-rs. Multi-line
tui-textarea is a simple yet powerful text editor widget for ratatui. Multi-line
text editor can be easily put as part of your TUI application.
"""
license = "MIT"
@@ -29,11 +29,6 @@ crossterm_0_28 = ["ratatui", "dep:crossterm-028"]
termion = ["ratatui", "dep:termion"]
termwiz = ["ratatui", "dep:termwiz"]
no-backend = ["ratatui"]
# Features to use tui-rs
tuirs = ["dep:tui"]
tuirs-crossterm = ["tuirs", "dep:crossterm-025", "tui/crossterm"]
tuirs-termion = ["tuirs", "dep:termion-15", "tui/termion"]
tuirs-no-backend = ["tuirs"]
# Other optional features
search = ["dep:regex"]
serde = ["dep:serde"]
@@ -43,15 +38,12 @@ arbitrary = ["dep:arbitrary"]
arbitrary = { version = "1", features = ["derive"], optional = true }
crossterm = { package = "crossterm", version = "0.29", optional = true }
crossterm-028 = { package = "crossterm", version = "0.28", optional = true }
crossterm-025 = { package = "crossterm", version = "0.25", optional = true }
ratatui-core = { version = "0.1.0", default-features = false, optional = true }
ratatui-widgets = { version = "0.3.0", default-features = false, optional = true }
portable-atomic = "1.11.1"
regex = { version = "1", optional = true }
termion = { version = "4.0", optional = true }
termion-15 = { package = "termion", version = "1.5", optional = true }
termwiz = { version = "0.23.0", optional = true }
tui = { version = "0.19", default-features = false, optional = true }
unicode-width = "0.2.0"
unicode-segmentation = "1.10.1"
serde = { version = "1", optional = true , features = ["derive"] }
@@ -96,18 +88,6 @@ required-features = ["termwiz"]
name = "termion"
required-features = ["termion"]
[[example]]
name = "tuirs_minimal"
required-features = ["tuirs-crossterm"]
[[example]]
name = "tuirs_editor"
required-features = ["tuirs-crossterm", "search"]
[[example]]
name = "tuirs_termion"
required-features = ["tuirs-termion"]
[workspace]
members = ["bench"]

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

@@ -6,7 +6,7 @@ tui-textarea-2
[![Rust Clippy][clippy-badge]][clippy]
[![coverage][codecov-badge]][codecov]
[tui-textarea-2][crate] is a simple yet powerful text editor widget like <textarea> in HTML for [ratatui][] and [tui-rs][].
[tui-textarea-2][crate] is a simple yet powerful text editor widget like <textarea> in HTML for [ratatui][].
Multi-line text editor can be easily put as part of your TUI application.
> Maintained fork notice: this repository is maintained under `srothgan/tui-textarea` to keep compatibility updates moving (including ratatui 0.30+ support and maintenance fixes).
@@ -38,7 +38,7 @@ Since this fork was created, it has added or integrated:
- Yank support. Paste text deleted with `C-k`, `C-j`, ...
- Backend agnostic. [crossterm][], [termion][], [termwiz][], and your own backend are all supported
- Multiple textarea widgets in the same screen
- Support both [ratatui][] (the fork by community) and [tui-rs][] (the original)
- Support [ratatui][]
[Documentation][doc]
@@ -46,6 +46,10 @@ Since this fork was created, it has added or integrated:
Running `cargo run --example` in this repository can demonstrate usage of tui-textarea.
For consistent GIF capture across the crossterm examples, set `TUI_TEXTAREA_RECORDING=1` for the default `120x30`
terminal size or override it with `TUI_TEXTAREA_RECORDING_SIZE=<cols>x<rows>`. See
[`examples/RECORDING.md`](./examples/RECORDING.md) for the recording workflow.
### [`minimal`](./examples/minimal.rs)
```sh
@@ -142,22 +146,6 @@ cargo run --example termwiz --no-default-features --features=termwiz
Minimal usage with [termwiz][] support.
### Examples for [tui-rs][] support
All above examples use [ratatui][], but some examples provide tui-rs version. Try `tuirs_` prefix. In these cases, you
need to specify features to use tui-rs and `--no-default-features` flag explicitly.
```sh
# tui-rs version of `minimal` example
cargo run --example tuirs_minimal --no-default-features --features=tuirs-crossterm
# tui-rs version of `editor` example
cargo run --example tuirs_editor --no-default-features --features=tuirs-crossterm,search file.txt
# tui-rs version of `termion` example
cargo run --example tuirs_termion --no-default-features --features=tuirs-termion
```
## Installation
Add `tui-textarea` crate to dependencies in your `Cargo.toml`. This enables crossterm backend support by default.
@@ -191,30 +179,14 @@ ratatui = { version = "*", default-features = false, features = ["termwiz"] }
tui-textarea = { package = "tui-textarea-2", version = "*", default-features = false, features = ["termwiz"] }
```
If you're using [tui-rs][] instead of [ratatui][], you need to enable features for using tui-rs crate and to disable
default features. The following table shows feature names corresponding to the dependencies.
The following table shows feature names corresponding to the supported ratatui backend integrations.
| | crossterm | termion | termwiz | Your own backend |
|---------|----------------------------------|-----------------|-----------|--------------------|
| ratatui | `crossterm` (enabled by default) | `termion` | `termwiz` | `no-backend` |
| tui-rs | `tuirs-crossterm` | `tuirs-termion` | N/A | `tuirs-no-backend` |
For example, when you want to use the combination of [tui-rs][] and [crossterm][],
```toml
[dependencies]
tui = "*"
tui-textarea = { package = "tui-textarea-2", version = "*", features = ["tuirs-crossterm"], default-features = false }
```
Note that [ratatui][] support and [tui-rs][] support are exclusive. When you use [tui-rs][] support, you must disable
[ratatui][] support by `default-features = false`.
In addition to above dependencies, you also need to install [crossterm][] or [termion][] or [termwiz][] to initialize
your application and to receive key inputs. Note that the dependency versions of [crossterm][] crate and [termion][]
crate differ between [ratatui][] and [tui-rs][]. Please make sure to use the same version that matches the package you
are using. For example, [tui-rs][] depends on [crossterm][] v0.2.5 or [termion][] v1.5 where both crates are older than
[ratatui][]'s dependencies.
your application and to receive key inputs.
## Minimal Usage
@@ -665,16 +637,13 @@ match read()?.into() {
### Use your own backend
ratatui and tui-rs allows to make your own backend by implementing [`ratatui::backend::Backend`][ratatui-backend] trait.
tui-textarea supports it as well. Please use `no-backend` feature for [ratatui][] or `tuirs-no-backend` feature for
[tui-rs][]. They avoid adding backend crates (crossterm, termion, or termwiz) since you're using your own backend.
ratatui allows to make your own backend by implementing [`ratatui::backend::Backend`][ratatui-backend] trait.
tui-textarea supports it as well. Please use the `no-backend` feature. It avoids adding backend crates (crossterm,
termion, or termwiz) since you're using your own backend.
```toml
[dependencies]
# For ratatui
tui-textarea = { package = "tui-textarea-2", version = "*", default-features = false, features = ["no-backend"] }
# For tui-rs
tui-textarea = { package = "tui-textarea-2", version = "*", default-features = false, features = ["tuirs-no-backend"] }
```
`tui_textarea::Input` is a type for backend-agnostic key input. What you need to do is converting key event in your own
@@ -820,8 +789,7 @@ println!("{input:?}");
MSRV of this crate is Rust 1.85.0 because the crate uses Rust 2024 edition.
If you enable older `tui-rs` integration features, you still need dependency versions compatible with that backend stack,
but the crate itself now targets Rust 1.85.0.
This crate targets Rust 1.85.0 regardless of which supported ratatui backend feature you enable.
## Versioning
@@ -855,7 +823,6 @@ tui-textarea-2 is distributed under [The MIT License](./LICENSE).
[clippy]: https://github.com/srothgan/tui-textarea/actions/workflows/clippy.yml
[codecov-badge]: https://codecov.io/gh/srothgan/tui-textarea/graph/badge.svg
[codecov]: https://codecov.io/gh/srothgan/tui-textarea
[tui-rs]: https://github.com/fdehau/tui-rs
[ratatui]: https://github.com/ratatui/ratatui
[crossterm]: https://docs.rs/crossterm/latest/crossterm/
[termion]: https://docs.rs/termion/latest/termion/

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

@@ -1,5 +1,4 @@
// We use empty backend for our benchmark instead of tui::backend::TestBackend to make impact of benchmark from tui-rs
// as small as possible.
// We use an empty backend for benchmarks instead of a full test backend to keep backend overhead out of the results.
use ratatui::backend::{Backend, WindowSize};
use ratatui::buffer::Cell;

53
examples/RECORDING.md Обычный файл
Просмотреть файл

@@ -0,0 +1,53 @@
# Example Recording
This directory supports an opt-in recording mode for the crossterm examples so GIF captures can use the same terminal size and aspect ratio.
## Default Size
Set `TUI_TEXTAREA_RECORDING=1` to force the default recording size:
- `120x30`
- aspect ratio: `4:1`
PowerShell:
```powershell
$env:TUI_TEXTAREA_RECORDING=1
cargo run --example minimal
```
## Custom Size
Set `TUI_TEXTAREA_RECORDING_SIZE=<cols>x<rows>` to override the default:
```powershell
$env:TUI_TEXTAREA_RECORDING_SIZE="140x35"
cargo run --example split
```
Valid format:
- `120x30`
- `140x35`
- `160x40`
## Covered Examples
The shared recording-size helper is enabled for these crossterm examples:
- `minimal`
- `editor`
- `single_line`
- `split`
- `variable`
- `vim`
- `popup_placeholder`
- `password`
`termion` and `termwiz` are not wired through this helper.
## Notes
- The helper resizes the terminal on startup and restores the previous size on exit.
- Use one fixed size for a whole GIF set if you want every capture to align cleanly in the README.
- If you only care about consistent aspect ratio, choose any size with the same width-to-height ratio.

65
examples/common/mod.rs Обычный файл
Просмотреть файл

@@ -0,0 +1,65 @@
use crossterm::terminal::{SetSize, size};
use std::env;
use std::io::{self, Write};
const DEFAULT_RECORDING_WIDTH: u16 = 120;
const DEFAULT_RECORDING_HEIGHT: u16 = 30;
const RECORDING_FLAG: &str = "TUI_TEXTAREA_RECORDING";
const RECORDING_SIZE_VAR: &str = "TUI_TEXTAREA_RECORDING_SIZE";
#[derive(Default)]
pub struct RecordingSizeGuard {
original_size: Option<(u16, u16)>,
}
impl RecordingSizeGuard {
pub fn restore<W: Write>(mut self, writer: &mut W) -> io::Result<()> {
if let Some((cols, rows)) = self.original_size.take() {
crossterm::execute!(writer, SetSize(cols, rows))?;
}
Ok(())
}
}
pub fn maybe_force_recording_size<W: Write>(writer: &mut W) -> io::Result<RecordingSizeGuard> {
let Some((cols, rows)) = configured_recording_size()? else {
return Ok(RecordingSizeGuard::default());
};
let original_size = size().ok();
crossterm::execute!(writer, SetSize(cols, rows))?;
Ok(RecordingSizeGuard { original_size })
}
fn configured_recording_size() -> io::Result<Option<(u16, u16)>> {
if let Some(raw) = env::var_os(RECORDING_SIZE_VAR) {
let raw = raw.into_string().map_err(|_| invalid_size())?;
return parse_size(&raw).map(Some);
}
if env::var_os(RECORDING_FLAG).is_some() {
return Ok(Some((DEFAULT_RECORDING_WIDTH, DEFAULT_RECORDING_HEIGHT)));
}
Ok(None)
}
fn parse_size(raw: &str) -> io::Result<(u16, u16)> {
let (width, height) = raw.split_once('x').ok_or_else(invalid_size)?;
let width = width.trim().parse().map_err(|_| invalid_size())?;
let height = height.trim().parse().map_err(|_| invalid_size())?;
if width == 0 || height == 0 {
return Err(invalid_size());
}
Ok((width, height))
}
fn invalid_size() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{RECORDING_SIZE_VAR} must be set as <cols>x<rows>, for example 120x30"),
)
}

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

@@ -1,3 +1,5 @@
mod common;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -14,6 +16,7 @@ use std::fmt::Display;
use std::fs;
use std::io;
use std::io::{BufRead, Write};
use std::mem;
use std::path::PathBuf;
use tui_textarea::{CursorMove, Input, Key, TextArea};
@@ -135,6 +138,7 @@ struct Editor<'a> {
current: usize,
buffers: Vec<Buffer<'a>>,
term: Terminal<CrosstermBackend<io::Stdout>>,
recording: common::RecordingSizeGuard,
message: Option<Cow<'static, str>>,
search: SearchBox<'a>,
}
@@ -154,12 +158,14 @@ impl Editor<'_> {
let mut stdout = io::stdout();
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let recording = common::maybe_force_recording_size(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let term = Terminal::new(backend)?;
Ok(Self {
current: 0,
buffers,
term,
recording,
message: None,
search: SearchBox::default(),
})
@@ -348,12 +354,14 @@ impl Drop for Editor<'_> {
fn drop(&mut self) {
self.term.show_cursor().unwrap();
disable_raw_mode().unwrap();
let recording = mem::take(&mut self.recording);
crossterm::execute!(
self.term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.unwrap();
recording.restore(self.term.backend_mut()).unwrap();
}
}

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

@@ -1,3 +1,5 @@
mod common;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -14,6 +16,7 @@ fn main() -> io::Result<()> {
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let recording = common::maybe_force_recording_size(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
@@ -42,6 +45,7 @@ fn main() -> io::Result<()> {
LeaveAlternateScreen,
DisableMouseCapture
)?;
recording.restore(term.backend_mut())?;
term.show_cursor()?;
println!("Lines: {:?}", textarea.lines());

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

@@ -1,3 +1,5 @@
mod common;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -16,6 +18,7 @@ fn main() -> io::Result<()> {
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let recording = common::maybe_force_recording_size(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
@@ -53,6 +56,7 @@ fn main() -> io::Result<()> {
LeaveAlternateScreen,
DisableMouseCapture,
)?;
recording.restore(term.backend_mut())?;
term.show_cursor()?;
println!("Input: {:?}", textarea.lines()[0]);

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

@@ -1,3 +1,5 @@
mod common;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -16,6 +18,7 @@ fn main() -> io::Result<()> {
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let recording = common::maybe_force_recording_size(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
@@ -54,6 +57,7 @@ fn main() -> io::Result<()> {
LeaveAlternateScreen,
DisableMouseCapture
)?;
recording.restore(term.backend_mut())?;
term.show_cursor()?;
println!("Lines: {:?}", textarea.lines());

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

@@ -1,3 +1,5 @@
mod common;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -38,6 +40,7 @@ fn main() -> io::Result<()> {
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let recording = common::maybe_force_recording_size(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
@@ -81,6 +84,7 @@ fn main() -> io::Result<()> {
LeaveAlternateScreen,
DisableMouseCapture
)?;
recording.restore(term.backend_mut())?;
term.show_cursor()?;
println!("Input: {:?}", textarea.lines()[0]);

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

@@ -1,3 +1,5 @@
mod common;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -37,6 +39,7 @@ fn main() -> io::Result<()> {
let mut stdout = stdout.lock();
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let recording = common::maybe_force_recording_size(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
@@ -80,6 +83,7 @@ fn main() -> io::Result<()> {
LeaveAlternateScreen,
DisableMouseCapture
)?;
recording.restore(term.backend_mut())?;
term.show_cursor()?;
println!("Left textarea: {:?}", textarea[0].lines());

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

@@ -1,365 +0,0 @@
// Use `crossterm` v0.25 for `tui` backend.
use crossterm_025 as crossterm;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use std::borrow::Cow;
use std::env;
use std::fmt::Display;
use std::fs;
use std::io;
use std::io::{BufRead, Write};
use std::path::PathBuf;
use tui::Terminal;
use tui::backend::CrosstermBackend;
use tui::layout::{Constraint, Direction, Layout};
use tui::style::{Color, Modifier, Style};
use tui::text::{Span, Spans};
use tui::widgets::{Block, Borders, Paragraph};
use tui_textarea::{CursorMove, Input, Key, TextArea};
macro_rules! error {
($fmt: expr $(, $args:tt)*) => {{
Err(io::Error::new(io::ErrorKind::Other, format!($fmt $(, $args)*)))
}};
}
struct SearchBox<'a> {
textarea: TextArea<'a>,
open: bool,
}
impl Default for SearchBox<'_> {
fn default() -> Self {
let mut textarea = TextArea::default();
textarea.set_block(Block::default().borders(Borders::ALL).title("Search"));
Self {
textarea,
open: false,
}
}
}
impl SearchBox<'_> {
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,
modified: bool,
}
impl Buffer<'_> {
fn new(path: PathBuf) -> io::Result<Self> {
let mut textarea = if let Ok(md) = path.metadata() {
if md.is_file() {
let mut textarea: TextArea = io::BufReader::new(fs::File::open(&path)?)
.lines()
.collect::<io::Result<_>>()?;
if textarea.lines().iter().any(|l| l.starts_with('\t')) {
textarea.set_hard_tab_indent(true);
}
textarea
} else {
return error!("{:?} is not a file", path);
}
} else {
TextArea::default() // File does not exist
};
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
Ok(Self {
textarea,
path,
modified: false,
})
}
fn save(&mut self) -> io::Result<()> {
if !self.modified {
return Ok(());
}
let mut f = io::BufWriter::new(fs::File::create(&self.path)?);
for line in self.textarea.lines() {
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
}
self.modified = false;
Ok(())
}
}
struct Editor<'a> {
current: usize,
buffers: Vec<Buffer<'a>>,
term: Terminal<CrosstermBackend<io::Stdout>>,
message: Option<Cow<'static, str>>,
search: SearchBox<'a>,
}
impl Editor<'_> {
fn new<I>(paths: I) -> io::Result<Self>
where
I: Iterator,
I::Item: Into<PathBuf>,
{
let buffers = paths
.map(|p| Buffer::new(p.into()))
.collect::<io::Result<Vec<_>>>()?;
if buffers.is_empty() {
return error!("USAGE: cargo run --example editor FILE1 [FILE2...]");
}
let mut stdout = io::stdout();
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let term = Terminal::new(backend)?;
Ok(Self {
current: 0,
buffers,
term,
message: None,
search: SearchBox::default(),
})
}
fn run(&mut self) -> io::Result<()> {
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, chunks[0]);
}
let buffer = &self.buffers[self.current];
let textarea = &buffer.textarea;
f.render_widget(textarea, chunks[1]);
// Render status line
let modified = if buffer.modified { " [modified]" } else { "" };
let slot = format!("[{}/{}]", self.current + 1, self.buffers.len());
let path = format!(" {}{} ", buffer.path.display(), modified);
let (row, col) = textarea.cursor();
let cursor = format!("({},{})", row + 1, col + 1);
let status_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Length(slot.len() as u16),
Constraint::Min(1),
Constraint::Length(cursor.len() as u16),
]
.as_ref(),
)
.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]);
f.render_widget(Paragraph::new(cursor).style(status_style), status_chunks[2]);
// 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 "),
Span::styled("^Q", Style::default().add_modifier(Modifier::BOLD)),
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("^T", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to switch buffer"),
])
};
f.render_widget(Paragraph::new(message), chunks[3]);
})?;
if search_height > 0 {
let textarea = &mut self.buffers[self.current].textarea;
match crossterm::event::read()?.into() {
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);
}
}
}
} else {
match crossterm::event::read()?.into() {
Input {
key: Key::Char('q'),
ctrl: true,
..
} => break,
Input {
key: Key::Char('t'),
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);
}
}
}
}
Ok(())
}
}
impl Drop for Editor<'_> {
fn drop(&mut self) {
self.term.show_cursor().unwrap();
disable_raw_mode().unwrap();
crossterm::execute!(
self.term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.unwrap();
}
}
fn main() -> io::Result<()> {
Editor::new(env::args_os().skip(1))?.run()
}

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

@@ -1,52 +0,0 @@
// Use `crossterm` v0.25 for `tui` backend.
use crossterm_025 as crossterm;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use std::io;
use tui::Terminal;
use tui::backend::CrosstermBackend;
use tui::widgets::{Block, Borders};
use tui_textarea::{Input, Key, TextArea};
fn main() -> io::Result<()> {
let stdout = io::stdout();
let mut stdout = stdout.lock();
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
let mut textarea = TextArea::default();
textarea.set_block(
Block::default()
.borders(Borders::ALL)
.title("Crossterm Minimal Example"),
);
loop {
term.draw(|f| {
f.render_widget(&textarea, f.size());
})?;
match crossterm::event::read()?.into() {
Input { key: Key::Esc, .. } => break,
input => {
textarea.input(input);
}
}
}
disable_raw_mode()?;
crossterm::execute!(
term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
term.show_cursor()?;
println!("Lines: {:?}", textarea.lines());
Ok(())
}

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

@@ -1,73 +0,0 @@
// Use `termion` v1.5 for `tui` backend.
use termion_15 as termion;
use std::error::Error;
use std::io;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use termion::event::Event as TermEvent;
use termion::input::{MouseTerminal, TermRead};
use termion::raw::IntoRawMode;
use termion::screen::AlternateScreen;
use tui::Terminal;
use tui::backend::TermionBackend;
use tui::widgets::{Block, Borders};
use tui_textarea::{Input, Key, TextArea};
enum Event {
Term(TermEvent),
Tick,
}
fn main() -> Result<(), Box<dyn Error>> {
let stdout = io::stdout().into_raw_mode()?;
let stdout = MouseTerminal::from(stdout);
let stdout = AlternateScreen::from(stdout);
let backend = TermionBackend::new(stdout);
let mut term = Terminal::new(backend)?;
let events = {
let events = io::stdin().events();
let (tx, rx) = mpsc::channel();
let keys_tx = tx.clone();
thread::spawn(move || {
for event in events.flatten() {
keys_tx.send(Event::Term(event)).unwrap();
}
});
thread::spawn(move || {
loop {
tx.send(Event::Tick).unwrap();
thread::sleep(Duration::from_millis(100));
}
});
rx
};
let mut textarea = TextArea::default();
textarea.set_block(
Block::default()
.borders(Borders::ALL)
.title("Termion Minimal Example"),
);
loop {
match events.recv()? {
Event::Term(event) => match event.into() {
Input { key: Key::Esc, .. } => break,
input => {
textarea.input(input);
}
},
Event::Tick => {}
}
term.draw(|f| {
f.render_widget(&textarea, f.size());
})?;
}
drop(term); // Leave terminal raw mode to print the following line
println!("Lines: {:?}", textarea.lines());
Ok(())
}

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

@@ -1,3 +1,5 @@
mod common;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -16,6 +18,7 @@ fn main() -> io::Result<()> {
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let recording = common::maybe_force_recording_size(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
@@ -51,6 +54,7 @@ fn main() -> io::Result<()> {
LeaveAlternateScreen,
DisableMouseCapture
)?;
recording.restore(term.backend_mut())?;
term.show_cursor()?;
println!("Lines: {:?}", textarea.lines());

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

@@ -1,3 +1,5 @@
mod common;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -400,6 +402,7 @@ fn main() -> io::Result<()> {
enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let recording = common::maybe_force_recording_size(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut term = Terminal::new(backend)?;
@@ -437,6 +440,7 @@ fn main() -> io::Result<()> {
LeaveAlternateScreen,
DisableMouseCapture
)?;
recording.restore(term.backend_mut())?;
term.show_cursor()?;
println!("Lines: {:?}", textarea.lines());

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

@@ -1,14 +1,59 @@
use crate::textarea::TextArea;
use crate::widget::Viewport;
use crate::word::{
find_word_inclusive_end_forward, find_word_start_backward, find_word_start_forward,
};
use crate::wrap::{WrappedLine, cursor_at_visual_row, cursor_visual_row};
#[cfg(feature = "arbitrary")]
use arbitrary::Arbitrary;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use std::cmp;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub(crate) struct DataCursor(pub usize, pub usize);
impl DataCursor {
pub(crate) fn to_screen_cursor(self, ta: &TextArea<'_>) -> ScreenCursor {
ta.data_to_screen_cursor(self)
}
}
impl PartialEq<(usize, usize)> for DataCursor {
fn eq(&self, other: &(usize, usize)) -> bool {
self.0 == other.0 && self.1 == other.1
}
}
impl From<(usize, usize)> for DataCursor {
fn from((row, col): (usize, usize)) -> Self {
Self(row, col)
}
}
impl From<DataCursor> for (usize, usize) {
fn from(cursor: DataCursor) -> Self {
(cursor.0, cursor.1)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub(crate) struct ScreenCursor {
pub row: usize,
pub col: usize,
pub char: Option<char>,
pub dc: Option<DataCursor>,
}
impl ScreenCursor {
pub(crate) fn to_data_cursor(self, ta: &TextArea<'_>) -> DataCursor {
ta.screen_to_data_cursor(self)
}
}
/// Specify how to move the cursor.
///
/// This type is marked as `#[non_exhaustive]` since more variations may be supported in the future.
@@ -261,129 +306,151 @@ pub enum CursorMove {
impl CursorMove {
pub(crate) fn next_cursor(
&self,
(row, col): (usize, usize),
lines: &[String],
cursor: ScreenCursor,
ta: &TextArea<'_>,
viewport: &Viewport,
wrapped: Option<&[WrappedLine]>,
) -> Option<(usize, usize)> {
) -> Option<ScreenCursor> {
use CursorMove::*;
fn fit_col(col: usize, line: &str) -> usize {
cmp::min(col, line.chars().count())
}
let row = cursor.row;
let col = cursor.col;
let dc = cursor.dc.unwrap_or_else(|| cursor.to_data_cursor(ta));
match self {
Forward if col >= lines[row].chars().count() => {
(row + 1 < lines.len()).then(|| (row + 1, 0))
}
Forward => Some((row, col + 1)),
Forward if col >= ta.screen_line_width(row) => (row + 1 < ta.screen_lines_count())
.then(|| ScreenCursor {
row: row + 1,
col: 0,
char: None,
dc: None,
}),
Forward => Some(ta.increment_screen_cursor(cursor)),
Back if col == 0 => {
let row = row.checked_sub(1)?;
Some((row, lines[row].chars().count()))
}
Back => Some((row, col - 1)),
Up if wrapped.is_some() => {
let rows = wrapped.unwrap();
let visual = cursor_visual_row(rows, (row, col));
if visual == 0 {
return None;
}
Some(cursor_at_visual_row(lines, rows, (row, col), visual - 1))
Some(ScreenCursor {
row,
col: ta.screen_line_max_cursor_col(row),
char: None,
dc: None,
})
}
Back => Some(ta.decrement_screen_cursor(cursor)),
Up => {
let row = row.checked_sub(1)?;
Some((row, fit_col(col, &lines[row])))
Some(ScreenCursor {
row,
col: cmp::min(col, ta.screen_line_max_cursor_col(row)),
char: None,
dc: None,
})
}
Down if wrapped.is_some() => {
let rows = wrapped.unwrap();
let visual = cursor_visual_row(rows, (row, col));
if visual >= rows.len() - 1 {
return None;
Down => {
if row + 1 >= ta.screen_lines_count() {
None
} else {
Some(ScreenCursor {
row: row + 1,
col: cmp::min(col, ta.screen_line_max_cursor_col(row + 1)),
char: None,
dc: None,
})
}
Some(cursor_at_visual_row(lines, rows, (row, col), visual + 1))
}
Down => Some((row + 1, fit_col(col, lines.get(row + 1)?))),
Head => Some((row, 0)),
End => Some((row, lines[row].chars().count())),
Top => Some((0, fit_col(col, &lines[0]))),
Head => Some(DataCursor(dc.0, 0).to_screen_cursor(ta)),
End => Some(DataCursor(dc.0, ta.lines[dc.0].chars().count()).to_screen_cursor(ta)),
Top => {
let col = cmp::min(dc.1, ta.lines[0].chars().count());
Some(DataCursor(0, col).to_screen_cursor(ta))
}
Bottom => {
let row = lines.len() - 1;
Some((row, fit_col(col, &lines[row])))
let row = ta.lines.len() - 1;
let col = cmp::min(dc.1, ta.lines[row].chars().count());
Some(DataCursor(row, col).to_screen_cursor(ta))
}
WordEnd => {
// `+ 1` for not accepting the current cursor position
if let Some(col) = find_word_inclusive_end_forward(&lines[row], col + 1) {
Some((row, col))
if let Some(col) = find_word_inclusive_end_forward(&ta.lines[dc.0], dc.1 + 1) {
Some(DataCursor(dc.0, col).to_screen_cursor(ta))
} else {
let mut row = row;
let mut row = dc.0;
loop {
if row == lines.len() - 1 {
break Some((row, lines[row].chars().count()));
if row == ta.lines.len() - 1 {
break Some(
DataCursor(row, ta.lines[row].chars().count()).to_screen_cursor(ta),
);
}
row += 1;
if let Some(col) = find_word_inclusive_end_forward(&lines[row], 0) {
break Some((row, col));
if let Some(col) = find_word_inclusive_end_forward(&ta.lines[row], 0) {
break Some(DataCursor(row, col).to_screen_cursor(ta));
}
}
}
}
WordForward => {
if let Some(col) = find_word_start_forward(&lines[row], col) {
Some((row, col))
} else if row + 1 < lines.len() {
Some((row + 1, 0))
if let Some(col) = find_word_start_forward(&ta.lines[dc.0], dc.1) {
Some(DataCursor(dc.0, col).to_screen_cursor(ta))
} else if dc.0 + 1 < ta.lines.len() {
Some(DataCursor(dc.0 + 1, 0).to_screen_cursor(ta))
} else {
Some((row, lines[row].chars().count()))
Some(DataCursor(dc.0, ta.lines[dc.0].chars().count()).to_screen_cursor(ta))
}
}
WordBack => {
if let Some(col) = find_word_start_backward(&lines[row], col) {
Some((row, col))
} else if row > 0 {
Some((row - 1, lines[row - 1].chars().count()))
if let Some(col) = find_word_start_backward(&ta.lines[dc.0], dc.1) {
Some(DataCursor(dc.0, col).to_screen_cursor(ta))
} else if dc.0 > 0 {
let row = dc.0 - 1;
Some(DataCursor(row, ta.lines[row].chars().count()).to_screen_cursor(ta))
} else {
Some((row, 0))
Some(DataCursor(dc.0, 0).to_screen_cursor(ta))
}
}
ParagraphForward => {
let mut prev_is_empty = lines[row].is_empty();
for row in row + 1..lines.len() {
let line = &lines[row];
let mut prev_is_empty = ta.lines[dc.0].is_empty();
for row in dc.0 + 1..ta.lines.len() {
let line = &ta.lines[row];
let is_empty = line.is_empty();
if !is_empty && prev_is_empty {
return Some((row, fit_col(col, line)));
let col = cmp::min(dc.1, line.chars().count());
return Some(DataCursor(row, col).to_screen_cursor(ta));
}
prev_is_empty = is_empty;
}
let row = lines.len() - 1;
Some((row, fit_col(col, &lines[row])))
let row = ta.lines.len() - 1;
let col = cmp::min(dc.1, ta.lines[row].chars().count());
Some(DataCursor(row, col).to_screen_cursor(ta))
}
ParagraphBack => {
let row = row.checked_sub(1)?;
let mut prev_is_empty = lines[row].is_empty();
let row = dc.0.checked_sub(1)?;
let mut prev_is_empty = ta.lines[row].is_empty();
for row in (0..row).rev() {
let is_empty = lines[row].is_empty();
let is_empty = ta.lines[row].is_empty();
if is_empty && !prev_is_empty {
return Some((row + 1, fit_col(col, &lines[row + 1])));
let target = row + 1;
let col = cmp::min(dc.1, ta.lines[target].chars().count());
return Some(DataCursor(target, col).to_screen_cursor(ta));
}
prev_is_empty = is_empty;
}
Some((0, fit_col(col, &lines[0])))
let col = cmp::min(dc.1, ta.lines[0].chars().count());
Some(DataCursor(0, col).to_screen_cursor(ta))
}
Jump(row, col) => {
let row = cmp::min(*row as usize, lines.len() - 1);
let col = fit_col(*col as usize, &lines[row]);
Some((row, col))
let row = cmp::min(*row as usize, ta.lines.len() - 1);
let col = cmp::min(*col as usize, ta.lines[row].chars().count());
Some(DataCursor(row, col).to_screen_cursor(ta))
}
InViewport => {
let (row_top, col_top, row_bottom, col_bottom) = viewport.position();
let row = row.clamp(row_top as usize, row_bottom as usize);
let row = cmp::min(row, lines.len() - 1);
let row = cmp::min(row, ta.screen_lines_count() - 1);
let col = col.clamp(col_top as usize, col_bottom as usize);
let col = fit_col(col, &lines[row]);
Some((row, col))
let col = cmp::min(col, ta.screen_line_max_cursor_col(row));
Some(ScreenCursor {
row,
col,
char: None,
dc: None,
})
}
}
}
@@ -391,7 +458,15 @@ impl CursorMove {
#[cfg(test)]
mod tests {
// Separate tests for tui-rs support
use super::DataCursor;
#[test]
fn data_cursor_round_trips_with_tuple() {
let dc = DataCursor::from((2, 7));
let tuple: (usize, usize) = dc.into();
assert_eq!(tuple, (2, 7));
}
#[test]
fn in_viewport() {
use crate::ratatui::buffer::Buffer;

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

@@ -1,12 +1,8 @@
use crate::ratatui::style::Style;
use crate::ratatui::text::Span;
use crate::ratatui::text::{Line, Span};
use crate::util::{num_digits, spaces};
#[cfg(feature = "ratatui")]
use ratatui_core::text::Line;
use std::borrow::Cow;
use std::cmp::Ordering;
#[cfg(feature = "tuirs")]
use tui::text::Spans as Line;
use unicode_width::UnicodeWidthChar as _;
enum Boundary {
@@ -309,8 +305,7 @@ impl<'a> LineHighlighter<'a> {
}
}
// Tests for spans don't work with tui-rs
#[cfg(all(test, feature = "ratatui"))]
#[cfg(test)]
mod tests {
use super::*;
use crate::ratatui::style::Color;

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

@@ -1,10 +1,6 @@
#[cfg(any(
feature = "crossterm",
feature = "crossterm_0_28",
feature = "tuirs-crossterm"
))]
#[cfg(any(feature = "crossterm", feature = "crossterm_0_28"))]
mod crossterm;
#[cfg(any(feature = "termion", feature = "tuirs-termion"))]
#[cfg(feature = "termion")]
mod termion;
#[cfg(feature = "termwiz")]
mod termwiz;
@@ -120,7 +116,12 @@ pub struct Input {
mod tests {
use super::*;
#[allow(dead_code)]
#[cfg(any(
feature = "crossterm",
feature = "crossterm_0_28",
feature = "termion",
feature = "termwiz"
))]
pub(crate) fn input(key: Key, ctrl: bool, alt: bool, shift: bool) -> Input {
Input {
key,

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

@@ -22,7 +22,6 @@ impl From<KeyEvent> for Input {
/// So the `shift` field of the returned `Input` instance is always `false` except for combinations with arrow keys.
/// For example, `termion::event::Key::Char('A')` is converted to `Input { key: Key::Char('A'), shift: false, .. }`.
fn from(key: KeyEvent) -> Self {
#[cfg(feature = "termion")]
let (ctrl, alt, shift) = match key {
KeyEvent::Ctrl(_)
| KeyEvent::CtrlUp
@@ -43,14 +42,6 @@ impl From<KeyEvent> for Input {
_ => (false, false, false),
};
#[cfg(feature = "tuirs-termion")]
let (ctrl, alt, shift) = match key {
KeyEvent::Ctrl(_) => (true, false, false),
KeyEvent::Alt(_) => (false, true, false),
_ => (false, false, false),
};
#[cfg(feature = "termion")]
let key = match key {
KeyEvent::Char('\n' | '\r') => Key::Enter,
KeyEvent::Char(c) | KeyEvent::Ctrl(c) | KeyEvent::Alt(c) => Key::Char(c),
@@ -76,26 +67,6 @@ impl From<KeyEvent> for Input {
_ => Key::Null,
};
#[cfg(feature = "tuirs-termion")]
let key = match key {
KeyEvent::Char('\n' | '\r') => Key::Enter,
KeyEvent::Char(c) | KeyEvent::Ctrl(c) | KeyEvent::Alt(c) => Key::Char(c),
KeyEvent::Backspace => Key::Backspace,
KeyEvent::Left => Key::Left,
KeyEvent::Right => Key::Right,
KeyEvent::Up => Key::Up,
KeyEvent::Down => Key::Down,
KeyEvent::Home => Key::Home,
KeyEvent::End => Key::End,
KeyEvent::PageUp => Key::PageUp,
KeyEvent::PageDown => Key::PageDown,
KeyEvent::BackTab => Key::Tab,
KeyEvent::Delete => Key::Delete,
KeyEvent::Esc => Key::Esc,
KeyEvent::F(x) => Key::F(x),
_ => Key::Null,
};
Input {
key,
ctrl,

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

@@ -4,15 +4,11 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
#![doc = include_str!("../README.md")]
#[cfg(all(feature = "ratatui", feature = "tuirs"))]
compile_error!(
"ratatui support and tui-rs support are exclusive. only one of them can be enabled at the same time. see https://github.com/rhysd/tui-textarea#installation"
);
mod cursor;
mod highlight;
mod history;
mod input;
mod screen_map;
mod scroll;
#[cfg(feature = "search")]
mod search;
@@ -32,14 +28,9 @@ mod ratatui {
pub use ratatui_widgets::{block::Block, paragraph::Paragraph};
}
}
#[cfg(feature = "tuirs")]
use tui as ratatui;
#[cfg(all(feature = "crossterm", not(feature = "crossterm_0_28")))]
#[allow(clippy::single_component_path_imports)]
use crossterm;
#[cfg(feature = "tuirs-crossterm")]
use crossterm_025 as crossterm;
#[cfg(feature = "crossterm_0_28")]
#[allow(clippy::single_component_path_imports)]
use crossterm_028 as crossterm;
@@ -47,8 +38,6 @@ use crossterm_028 as crossterm;
#[cfg(feature = "termion")]
#[allow(clippy::single_component_path_imports)]
use termion;
#[cfg(feature = "tuirs-termion")]
use termion_15 as termion;
pub use cursor::CursorMove;
pub use input::{Input, Key};

285
src/screen_map.rs Обычный файл
Просмотреть файл

@@ -0,0 +1,285 @@
use crate::cursor::{DataCursor, ScreenCursor};
use crate::textarea::TextArea;
use crate::util::num_digits;
use crate::wrap::{WrapMode, WrappedLine, effective_wrap_width, wrapped_rows};
use unicode_width::UnicodeWidthChar;
#[derive(Debug, Clone, Copy)]
pub(crate) struct ScreenLine {
pub wrapped: WrappedLine,
pub screen_width: usize,
pub cursor_max_col: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DataLine {
pub first_screen_line: usize,
pub screen_line_count: usize,
pub pure_ascii: bool,
}
fn is_pure_ascii(line: &str) -> bool {
line.is_ascii() && !line.contains('\t')
}
fn char_display_width(c: char, col: usize, tab_len: u8) -> usize {
if c == '\t' {
let tab = tab_len.max(1) as usize;
let pad = tab - (col % tab);
pad.max(1)
} else {
c.width().unwrap_or(0)
}
}
fn display_width(text: &str, tab_len: u8) -> usize {
let mut col = 0usize;
for c in text.chars() {
col += char_display_width(c, col, tab_len);
}
col
}
fn screen_col_for_char_offset(text: &str, char_offset: usize, tab_len: u8) -> usize {
let mut col = 0usize;
for c in text.chars().take(char_offset) {
col += char_display_width(c, col, tab_len);
}
col
}
fn char_offset_for_screen_col(text: &str, screen_col: usize, tab_len: u8) -> usize {
let mut col = 0usize;
let mut chars = 0usize;
for c in text.chars() {
if col >= screen_col {
break;
}
let width = char_display_width(c, col, tab_len);
if col + width > screen_col {
break;
}
col += width;
chars += 1;
}
chars
}
impl TextArea<'_> {
fn fallback_rows(&self) -> Vec<WrappedLine> {
self.lines
.iter()
.enumerate()
.map(|(row, line)| WrappedLine {
row,
start_byte: 0,
end_byte: line.len(),
start_col: 0,
end_col: line.chars().count(),
first_in_row: true,
last_in_row: true,
})
.collect()
}
pub(crate) fn screen_map_load(&self) {
let wrap_width = if self.wrap_mode() != WrapMode::None {
let width = self.area.get().width;
if width > 0 {
let line_number_len = self
.line_number_style()
.map(|_| num_digits(self.lines.len()));
Some(effective_wrap_width(width, line_number_len))
} else {
None
}
} else {
None
};
let rows = match wrap_width {
Some(width) => wrapped_rows(&self.lines, self.wrap_mode(), width, self.tab_length()),
None => self.fallback_rows(),
};
let mut screen_lines = Vec::with_capacity(rows.len());
let mut data_pointers = vec![DataLine::default(); self.lines.len()];
for (screen_idx, wrapped) in rows.into_iter().enumerate() {
if data_pointers[wrapped.row].screen_line_count == 0 {
data_pointers[wrapped.row].first_screen_line = screen_idx;
data_pointers[wrapped.row].pure_ascii = is_pure_ascii(&self.lines[wrapped.row]);
}
data_pointers[wrapped.row].screen_line_count += 1;
let fragment = &self.lines[wrapped.row][wrapped.start_byte..wrapped.end_byte];
let char_count = wrapped.end_col.saturating_sub(wrapped.start_col);
let cursor_max_col = if wrapped.last_in_row {
display_width(fragment, self.tab_length())
} else {
screen_col_for_char_offset(
fragment,
char_count.saturating_sub(1),
self.tab_length(),
)
};
screen_lines.push(ScreenLine {
wrapped,
screen_width: display_width(fragment, self.tab_length()),
cursor_max_col,
});
}
*self.screen_lines.borrow_mut() = screen_lines;
*self.data_pointers.borrow_mut() = data_pointers;
}
pub(crate) fn screen_lines_count(&self) -> usize {
self.screen_lines.borrow().len()
}
pub(crate) fn screen_line_width(&self, row: usize) -> usize {
self.screen_lines.borrow()[row].screen_width
}
pub(crate) fn screen_line_max_cursor_col(&self, row: usize) -> usize {
self.screen_lines.borrow()[row].cursor_max_col
}
pub(crate) fn screen_line(&self, row: usize) -> ScreenLine {
self.screen_lines.borrow()[row]
}
pub(crate) fn screen_to_data_cursor(&self, screen: ScreenCursor) -> DataCursor {
if let Some(dc) = screen.dc {
return dc;
}
let line = self.screen_line(screen.row);
let fragment =
&self.lines[line.wrapped.row][line.wrapped.start_byte..line.wrapped.end_byte];
let char_offset = char_offset_for_screen_col(fragment, screen.col, self.tab_length());
DataCursor(line.wrapped.row, line.wrapped.start_col + char_offset)
}
pub(crate) fn data_to_screen_cursor(&self, data: DataCursor) -> ScreenCursor {
let data_line = &self.data_pointers.borrow()[data.0];
let screen_lines = self.screen_lines.borrow();
let mut screen_idx = data_line.first_screen_line;
for idx in
data_line.first_screen_line..data_line.first_screen_line + data_line.screen_line_count
{
let line = screen_lines[idx];
let contains = if line.wrapped.last_in_row {
line.wrapped.start_col <= data.1 && data.1 <= line.wrapped.end_col
} else {
line.wrapped.start_col <= data.1 && data.1 < line.wrapped.end_col
};
if contains {
screen_idx = idx;
break;
}
}
let line = screen_lines[screen_idx];
let fragment = &self.lines[data.0][line.wrapped.start_byte..line.wrapped.end_byte];
let char_offset = data.1.saturating_sub(line.wrapped.start_col);
let col = screen_col_for_char_offset(fragment, char_offset, self.tab_length());
ScreenCursor {
row: screen_idx,
col,
char: self.lines[data.0].chars().nth(data.1),
dc: Some(data),
}
}
pub(crate) fn increment_screen_cursor(&self, screen: ScreenCursor) -> ScreenCursor {
let dc = screen.to_data_cursor(self);
self.data_to_screen_cursor(DataCursor(dc.0, dc.1 + 1))
}
pub(crate) fn decrement_screen_cursor(&self, screen: ScreenCursor) -> ScreenCursor {
let dc = screen.to_data_cursor(self);
self.data_to_screen_cursor(DataCursor(dc.0, dc.1 - 1))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ratatui::layout::Rect;
fn make_textarea(lines: &[&str], wrap_mode: WrapMode, width: u16) -> TextArea<'static> {
let mut textarea = TextArea::from(lines.iter().copied());
textarea.set_wrap_mode(wrap_mode);
textarea.area.set(Rect {
x: 0,
y: 0,
width,
height: 40,
});
textarea.screen_map_load();
textarea
}
fn assert_round_trips(textarea: &TextArea<'_>) {
for (row, line) in textarea.lines.iter().enumerate() {
for col in 0..=line.chars().count() {
let dc = DataCursor(row, col);
let sc = textarea.data_to_screen_cursor(dc);
assert_eq!(textarea.screen_to_data_cursor(sc), dc);
let detached = ScreenCursor { dc: None, ..sc };
assert_eq!(textarea.screen_to_data_cursor(detached), dc);
assert_eq!(
textarea.data_to_screen_cursor(textarea.screen_to_data_cursor(detached)),
sc
);
}
}
}
#[test]
fn screen_map_round_trips_ascii_word_wrap() {
let textarea = make_textarea(
&[
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
],
WrapMode::Word,
24,
);
assert!(textarea.screen_lines_count() > textarea.lines.len());
assert_round_trips(&textarea);
}
#[test]
fn screen_map_round_trips_cjk_glyph_wrap() {
let textarea = make_textarea(
&["混合中文字符和ASCIIabc来验证屏幕坐标与数据坐标的往返转换。"],
WrapMode::Glyph,
12,
);
assert!(textarea.screen_lines_count() > textarea.lines.len());
assert_round_trips(&textarea);
}
#[test]
fn screen_map_round_trips_multilingual_word_or_glyph_wrap() {
let textarea = make_textarea(
&[
"ASCII and русский текст can share one buffer.",
"第二行 mixed-width 内容 with English words.",
],
WrapMode::WordOrGlyph,
18,
);
assert!(textarea.screen_lines_count() > textarea.lines.len());
assert_round_trips(&textarea);
}
}

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

@@ -189,7 +189,6 @@ impl From<(i16, i16)> for Scrolling {
mod tests {
use super::*;
// Separate tests for tui-rs support
#[test]
fn delta() {
use crate::TextArea;

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

@@ -1,26 +1,22 @@
use crate::cursor::CursorMove;
use crate::cursor::{CursorMove, DataCursor, ScreenCursor};
use crate::highlight::LineHighlighter;
use crate::history::{Edit, EditKind, History};
use crate::input::{Input, Key};
use crate::ratatui::layout::{Alignment, Rect};
use crate::ratatui::style::{Color, Modifier, Style};
use crate::ratatui::text::Line;
use crate::ratatui::widgets::{Block, Widget};
use crate::screen_map::{DataLine, ScreenLine};
use crate::scroll::Scrolling;
#[cfg(feature = "search")]
use crate::search::Search;
use crate::util::{Pos, num_digits, spaces};
use crate::widget::Viewport;
use crate::word::{find_word_exclusive_end_forward, find_word_start_backward};
use crate::wrap::{
WrapMode, WrappedLine, cursor_at_visual_row, cursor_visual_row, effective_wrap_width,
wrapped_rows,
};
#[cfg(feature = "ratatui")]
use ratatui_core::text::Line;
use crate::wrap::{WrapMode, WrappedLine, effective_wrap_width, wrapped_rows};
use std::cell::{Cell, RefCell};
use std::cmp::{self, Ordering};
use std::fmt;
#[cfg(feature = "tuirs")]
use tui::text::Spans as Line;
use unicode_width::UnicodeWidthChar as _;
#[derive(Debug, Clone)]
@@ -129,10 +125,10 @@ pub struct TextAreaMeasure {
/// ```
#[derive(Clone, Debug)]
pub struct TextArea<'a> {
lines: Vec<String>,
pub(crate) lines: Vec<String>,
block: Option<Block<'a>>,
style: Style,
cursor: (usize, usize), // 0-base
cursor: DataCursor, // 0-base
tab_len: u8,
hard_tab_indent: bool,
history: History,
@@ -150,10 +146,13 @@ pub struct TextArea<'a> {
pub(crate) placeholder: String,
pub(crate) placeholder_style: Style,
mask: Option<char>,
selection_start: Option<(usize, usize)>,
selection_start: Option<DataCursor>,
select_style: Style,
custom_highlights: Vec<CustomHighlight>,
measure_cache: Option<(u16, TextAreaMeasure)>,
pub(crate) screen_lines: RefCell<Vec<ScreenLine>>,
pub(crate) data_pointers: RefCell<Vec<DataLine>>,
pub(crate) area: Cell<Rect>,
}
/// Convert any iterator whose elements can be converted into [`String`] into [`TextArea`]. Each [`String`] element is
@@ -224,6 +223,10 @@ impl Default for TextArea<'_> {
}
impl<'a> TextArea<'a> {
fn refresh_screen_map(&self) {
self.screen_map_load();
}
/// Create [`TextArea`] instance with given lines. If you have value other than `Vec<String>`, [`TextArea::from`]
/// may be more useful.
/// ```
@@ -238,11 +241,11 @@ impl<'a> TextArea<'a> {
lines.push(String::new());
}
Self {
let textarea = Self {
lines,
block: None,
style: Style::default(),
cursor: (0, 0),
cursor: DataCursor(0, 0),
tab_len: 4,
hard_tab_indent: false,
history: History::new(50),
@@ -264,7 +267,12 @@ impl<'a> TextArea<'a> {
select_style: Style::default().bg(Color::LightBlue),
custom_highlights: Default::default(),
measure_cache: None,
}
screen_lines: RefCell::new(Vec::new()),
data_pointers: RefCell::new(Vec::new()),
area: Cell::new(Rect::default()),
};
textarea.screen_map_load();
textarea
}
/// Handle a key input with default key mappings. For default key mappings, see the table in
@@ -694,7 +702,7 @@ impl<'a> TextArea<'a> {
// Check invariants
debug_assert!(!self.lines.is_empty(), "no line after {:?}", input);
let (r, c) = self.cursor;
let DataCursor(r, c) = self.cursor;
debug_assert!(
self.lines.len() > r,
"cursor {:?} exceeds max lines {} after {:?}",
@@ -775,10 +783,11 @@ impl<'a> TextArea<'a> {
}
fn push_history(&mut self, kind: EditKind, before: Pos, after_offset: usize) {
let (row, col) = self.cursor;
let DataCursor(row, col) = self.cursor;
let after = Pos::new(row, col, after_offset);
let edit = Edit::new(kind, before, after);
self.history.push(edit);
self.refresh_screen_map();
self.reset_measure_cache();
}
@@ -798,7 +807,7 @@ impl<'a> TextArea<'a> {
}
self.delete_selection(false);
let (row, col) = self.cursor;
let DataCursor(row, col) = self.cursor;
let line = &mut self.lines[row];
let i = line
.char_indices()
@@ -844,7 +853,7 @@ impl<'a> TextArea<'a> {
fn insert_chunk(&mut self, chunk: Vec<String>) -> bool {
debug_assert!(chunk.len() > 1, "Chunk size must be > 1: {:?}", chunk);
let (row, col) = self.cursor;
let DataCursor(row, col) = self.cursor;
let line = &mut self.lines[row];
let i = line
.char_indices()
@@ -857,7 +866,7 @@ impl<'a> TextArea<'a> {
row + chunk.len() - 1,
chunk[chunk.len() - 1].chars().count(),
);
self.cursor = (row, col);
self.cursor = DataCursor(row, col);
let end_offset = chunk.last().unwrap().len();
@@ -873,7 +882,7 @@ impl<'a> TextArea<'a> {
return false;
}
let (row, col) = self.cursor;
let DataCursor(row, col) = self.cursor;
let line = &mut self.lines[row];
debug_assert!(
!s.contains('\n'),
@@ -895,7 +904,7 @@ impl<'a> TextArea<'a> {
}
fn delete_range(&mut self, start: Pos, end: Pos, should_yank: bool) {
self.cursor = (start.row, start.col);
self.cursor = DataCursor(start.row, start.col);
if start.row == end.row {
let removed = self.lines[start.row]
@@ -961,7 +970,7 @@ impl<'a> TextArea<'a> {
return false;
}
let (start_row, start_col) = self.cursor;
let DataCursor(start_row, start_col) = self.cursor;
let mut remaining = chars;
let mut find_end = move |line: &str| {
@@ -1052,13 +1061,13 @@ impl<'a> TextArea<'a> {
(s.len(), last_col + 1)
}
let (row, _) = self.cursor;
let DataCursor(row, _) = self.cursor;
let line = &mut self.lines[row];
if let Some((i, _)) = line.char_indices().nth(col) {
let (bytes, chars) = bytes_and_chars(chars, &line[i..]);
let removed = line.drain(i..i + bytes).as_str().to_string();
self.cursor = (row, col);
self.cursor = DataCursor(row, col);
self.push_history(
EditKind::DeleteStr(removed.clone()),
Pos::new(row, col + chars, i + bytes),
@@ -1096,7 +1105,7 @@ impl<'a> TextArea<'a> {
return true;
}
let (row, col) = self.cursor;
let DataCursor(row, col) = self.cursor;
let width: usize = self.lines[row]
.chars()
.take(col)
@@ -1119,7 +1128,7 @@ impl<'a> TextArea<'a> {
pub fn insert_newline(&mut self) {
self.delete_selection(false);
let (row, col) = self.cursor;
let DataCursor(row, col) = self.cursor;
let line = &mut self.lines[row];
let offset = line
.char_indices()
@@ -1130,7 +1139,7 @@ impl<'a> TextArea<'a> {
line.truncate(offset);
self.lines.insert(row + 1, next_line);
self.cursor = (row + 1, 0);
self.cursor = DataCursor(row + 1, 0);
self.push_history(EditKind::InsertNewline, Pos::new(row, col, offset), 0);
}
@@ -1150,7 +1159,7 @@ impl<'a> TextArea<'a> {
return true;
}
let (row, _) = self.cursor;
let DataCursor(row, _) = self.cursor;
if row == 0 {
return false;
}
@@ -1159,7 +1168,7 @@ impl<'a> TextArea<'a> {
let prev_line = &mut self.lines[row - 1];
let prev_line_end = prev_line.len();
self.cursor = (row - 1, prev_line.chars().count());
self.cursor = DataCursor(row - 1, prev_line.chars().count());
prev_line.push_str(&line);
self.push_history(EditKind::DeleteNewline, Pos::new(row, 0, 0), prev_line_end);
true
@@ -1182,7 +1191,7 @@ impl<'a> TextArea<'a> {
return true;
}
let (row, col) = self.cursor;
let DataCursor(row, col) = self.cursor;
if col == 0 {
return self.delete_newline();
}
@@ -1297,7 +1306,7 @@ impl<'a> TextArea<'a> {
if self.delete_selection(false) {
return true;
}
let (r, c) = self.cursor;
let DataCursor(r, c) = self.cursor;
if let Some(col) = find_word_start_backward(&self.lines[r], c) {
self.delete_piece(col, c - col)
} else if c > 0 {
@@ -1327,7 +1336,7 @@ impl<'a> TextArea<'a> {
if self.delete_selection(false) {
return true;
}
let (r, c) = self.cursor;
let DataCursor(r, c) = self.cursor;
let line = &self.lines[r];
if let Some(col) = find_word_exclusive_end_forward(line, c) {
self.delete_piece(c, col - c)
@@ -1336,7 +1345,7 @@ impl<'a> TextArea<'a> {
if c < end_col {
self.delete_piece(c, end_col - c)
} else if r + 1 < self.lines.len() {
self.cursor = (r + 1, 0);
self.cursor = DataCursor(r + 1, 0);
self.delete_newline()
} else {
false
@@ -1465,7 +1474,7 @@ impl<'a> TextArea<'a> {
/// ```
pub fn select_all(&mut self) {
self.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX));
self.selection_start = Some((0, 0));
self.selection_start = Some(DataCursor(0, 0));
}
/// Return if text selection is ongoing or not.
@@ -1495,16 +1504,16 @@ impl<'a> TextArea<'a> {
.unwrap_or(line.len())
}
fn clamp_cursor_to_buffer(&mut self, cursor: (usize, usize)) -> (usize, usize) {
fn clamp_cursor_to_buffer(&mut self, cursor: (usize, usize)) -> DataCursor {
if self.lines.is_empty() {
self.lines.push(String::new());
return (0, 0);
return DataCursor(0, 0);
}
let (row, col) = cursor;
let row = row.min(self.lines.len() - 1);
let col = col.min(self.lines[row].chars().count());
(row, col)
DataCursor(row, col)
}
fn reset_measure_cache(&mut self) {
@@ -1540,8 +1549,8 @@ impl<'a> TextArea<'a> {
}
fn selection_positions(&self) -> Option<(Pos, Pos)> {
let (sr, sc) = self.selection_start?;
let (er, ec) = self.cursor;
let DataCursor(sr, sc) = self.selection_start?;
let DataCursor(er, ec) = self.cursor;
let (so, eo) = (self.line_offset(sr, sc), self.line_offset(er, ec));
let s = Pos::new(sr, sc, so);
let e = Pos::new(er, ec, eo);
@@ -1639,29 +1648,7 @@ impl<'a> TextArea<'a> {
}
fn move_cursor_with_shift(&mut self, m: CursorMove, shift: bool) {
let wrapped_storage;
let wrapped_ref = if self.wrap_mode != WrapMode::None {
let (_, _, width, _) = self.viewport.rect();
if width > 0 {
let line_number_len = self.line_number_style.map(|_| num_digits(self.lines.len()));
let wrap_width = effective_wrap_width(width, line_number_len);
wrapped_storage =
wrapped_rows(&self.lines, self.wrap_mode, wrap_width, self.tab_len);
Some(wrapped_storage.as_slice())
} else {
None
}
} else {
None
};
let next = if m == CursorMove::InViewport && self.wrap_mode != WrapMode::None {
self.cursor_in_wrapped_viewport()
} else {
m.next_cursor(self.cursor, &self.lines, &self.viewport, wrapped_ref)
};
if let Some(cursor) = next {
if let Some(cursor) = m.next_cursor(self.screen_cursor(), self, &self.viewport) {
if shift {
if self.selection_start.is_none() {
self.start_selection();
@@ -1669,36 +1656,10 @@ impl<'a> TextArea<'a> {
} else {
self.cancel_selection();
}
self.cursor = cursor;
self.cursor = self.screen_to_data_cursor(cursor);
}
}
fn cursor_in_wrapped_viewport(&self) -> Option<(usize, usize)> {
let (row_top, _, width, height) = self.viewport.rect();
if height == 0 {
return Some(self.cursor);
}
let line_number_len = self.line_number_style.map(|_| num_digits(self.lines.len()));
let wrap_width = effective_wrap_width(width, line_number_len);
let rows = wrapped_rows(&self.lines, self.wrap_mode, wrap_width, self.tab_len);
if rows.is_empty() {
return Some(self.cursor);
}
let cursor_visual = cursor_visual_row(&rows, self.cursor);
let row_top = row_top as usize;
let row_bottom = row_top + height.saturating_sub(1) as usize;
let target_visual = cursor_visual.clamp(row_top, row_bottom);
Some(cursor_at_visual_row(
&self.lines,
&rows,
self.cursor,
target_visual,
))
}
/// Undo the last modification. This method returns if the undo modified text contents or not in the textarea.
/// ```
/// use tui_textarea::{TextArea, CursorMove};
@@ -1714,6 +1675,7 @@ impl<'a> TextArea<'a> {
if let Some(cursor) = self.history.undo(&mut self.lines) {
self.cancel_selection();
self.cursor = self.clamp_cursor_to_buffer(cursor);
self.refresh_screen_map();
self.reset_measure_cache();
true
} else {
@@ -1738,6 +1700,7 @@ impl<'a> TextArea<'a> {
if let Some(cursor) = self.history.redo(&mut self.lines) {
self.cancel_selection();
self.cursor = self.clamp_cursor_to_buffer(cursor);
self.refresh_screen_map();
self.reset_measure_cache();
true
} else {
@@ -1745,19 +1708,6 @@ impl<'a> TextArea<'a> {
}
}
pub(crate) fn line_spans<'b>(&'b self, line: &'b str, row: usize, lnum_len: u8) -> Line<'b> {
let wrapped = WrappedLine {
row,
start_byte: 0,
end_byte: line.len(),
start_col: 0,
end_col: line.chars().count(),
first_in_row: true,
last_in_row: true,
};
self.line_spans_segment(line, &wrapped, lnum_len)
}
pub(crate) fn line_spans_segment<'b>(
&'b self,
line: &'b str,
@@ -1870,7 +1820,7 @@ impl<'a> TextArea<'a> {
hl.into_spans()
}
/// Build a ratatui (or tui-rs) widget to render the current state of the textarea. The widget instance returned
/// Build a ratatui widget to render the current state of the textarea. The widget instance returned
/// from this method can be rendered with [`ratatui_core::terminal::Frame::render_widget`].
///
/// This method was deprecated at v0.5.3 and is no longer necessary. Instead you can directly pass `&TextArea`
@@ -1940,6 +1890,7 @@ impl<'a> TextArea<'a> {
/// ```
pub fn set_block(&mut self, block: Block<'a>) {
self.block = Some(block);
self.refresh_screen_map();
self.reset_measure_cache();
}
@@ -1956,6 +1907,7 @@ impl<'a> TextArea<'a> {
/// ```
pub fn remove_block(&mut self) {
self.block = None;
self.refresh_screen_map();
self.reset_measure_cache();
}
@@ -1981,6 +1933,7 @@ impl<'a> TextArea<'a> {
/// ```
pub fn set_tab_length(&mut self, len: u8) {
self.tab_len = len;
self.refresh_screen_map();
self.reset_measure_cache();
}
@@ -2088,6 +2041,7 @@ impl<'a> TextArea<'a> {
/// ```
pub fn set_line_number_style(&mut self, style: Style) {
self.line_number_style = Some(style);
self.refresh_screen_map();
self.reset_measure_cache();
}
@@ -2105,6 +2059,7 @@ impl<'a> TextArea<'a> {
/// ```
pub fn remove_line_number(&mut self) {
self.line_number_style = None;
self.refresh_screen_map();
self.reset_measure_cache();
}
@@ -2284,6 +2239,7 @@ impl<'a> TextArea<'a> {
self.selection_start = None;
self.custom_highlights.clear();
self.viewport = Viewport::default();
self.refresh_screen_map();
self.reset_measure_cache();
}
@@ -2317,7 +2273,11 @@ impl<'a> TextArea<'a> {
/// assert_eq!(textarea.cursor(), (1, 1));
/// ```
pub fn cursor(&self) -> (usize, usize) {
self.cursor
self.cursor.into()
}
pub(crate) fn screen_cursor(&self) -> ScreenCursor {
self.cursor.to_screen_cursor(self)
}
/// Get the current selection range as a pair of the start position and the end position. The range is bounded
@@ -2357,9 +2317,9 @@ impl<'a> TextArea<'a> {
pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
self.selection_start.map(|pos| {
if pos > self.cursor {
(self.cursor, pos)
(self.cursor.into(), pos.into())
} else {
(pos, self.cursor)
(pos.into(), self.cursor.into())
}
})
}
@@ -2378,6 +2338,7 @@ impl<'a> TextArea<'a> {
pub fn set_alignment(&mut self, alignment: Alignment) {
if let Alignment::Center | Alignment::Right = alignment {
self.line_number_style = None;
self.refresh_screen_map();
self.reset_measure_cache();
}
self.alignment = alignment;
@@ -2407,6 +2368,7 @@ impl<'a> TextArea<'a> {
/// ```
pub fn set_wrap_mode(&mut self, mode: WrapMode) {
self.wrap_mode = mode;
self.refresh_screen_map();
self.reset_measure_cache();
}
@@ -2667,8 +2629,11 @@ impl<'a> TextArea<'a> {
#[cfg(feature = "search")]
#[cfg_attr(docsrs, doc(cfg(feature = "search")))]
pub fn search_forward(&mut self, match_cursor: bool) -> bool {
if let Some(cursor) = self.search.forward(&self.lines, self.cursor, match_cursor) {
self.cursor = cursor;
if let Some(cursor) = self
.search
.forward(&self.lines, self.cursor.into(), match_cursor)
{
self.cursor = cursor.into();
true
} else {
false
@@ -2711,8 +2676,11 @@ impl<'a> TextArea<'a> {
#[cfg(feature = "search")]
#[cfg_attr(docsrs, doc(cfg(feature = "search")))]
pub fn search_back(&mut self, match_cursor: bool) -> bool {
if let Some(cursor) = self.search.back(&self.lines, self.cursor, match_cursor) {
self.cursor = cursor;
if let Some(cursor) = self
.search
.back(&self.lines, self.cursor.into(), match_cursor)
{
self.cursor = cursor.into();
true
} else {
false
@@ -2808,9 +2776,8 @@ impl<'a> TextArea<'a> {
#[cfg(test)]
mod tests {
use super::*;
use ratatui::widgets::Borders;
use ratatui_widgets::borders::Borders;
// Separate tests for tui-rs support
#[test]
fn scroll() {
use crate::ratatui::buffer::Buffer;

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

@@ -1,17 +1,12 @@
use crate::ratatui::buffer::Buffer;
use crate::ratatui::layout::Rect;
use crate::ratatui::text::{Span, Text};
use crate::ratatui::text::{Line, Span, Text};
use crate::ratatui::widgets::{Paragraph, Widget};
use crate::textarea::TextArea;
use crate::util::num_digits;
use crate::wrap::{WrapMode, cursor_visual_row, effective_wrap_width, wrapped_rows};
use crate::wrap::WrapMode;
use portable_atomic::{AtomicU64, Ordering};
#[cfg(feature = "ratatui")]
use ratatui_core::text::Line;
use std::cmp;
#[cfg(feature = "tuirs")]
use tui::text::Spans as Line;
use unicode_width::UnicodeWidthChar;
// &mut 'a (u16, u16, u16, u16) is not available since `render` method takes immutable reference of TextArea
// instance. In the case, the TextArea instance cannot be accessed from any other objects since it is mutablly
@@ -95,12 +90,13 @@ fn next_scroll_top(prev_top: u16, cursor: u16, len: u16) -> u16 {
impl<'a> TextArea<'a> {
fn text_widget(&'a self, top_row: usize, height: usize) -> Text<'a> {
let lines_len = self.lines().len();
let lnum_len = num_digits(lines_len);
let bottom_row = cmp::min(top_row + height, lines_len);
let lnum_len = num_digits(self.lines().len());
let screen_lines = self.screen_lines.borrow();
let bottom_row = cmp::min(top_row + height, screen_lines.len());
let mut lines = Vec::with_capacity(bottom_row - top_row);
for (i, line) in self.lines()[top_row..bottom_row].iter().enumerate() {
lines.push(self.line_spans(line.as_str(), top_row + i, lnum_len));
for row in &screen_lines[top_row..bottom_row] {
let line = &self.lines()[row.wrapped.row];
lines.push(self.line_spans_segment(line, &row.wrapped, lnum_len));
}
Text::from(lines)
}
@@ -111,57 +107,12 @@ impl<'a> TextArea<'a> {
Text::from(Line::from(vec![cursor, text]))
}
fn wrapped_text_widget(
&'a self,
prev_top_row: u16,
width: u16,
height: u16,
) -> (Text<'a>, u16) {
if height == 0 {
return (Text::default(), prev_top_row);
}
let lines_len = self.lines().len();
let lnum_len = num_digits(lines_len);
let line_number_len = self.line_number_style().map(|_| lnum_len);
let wrap_width = effective_wrap_width(width, line_number_len);
let wrapped = wrapped_rows(
self.lines(),
self.wrap_mode(),
wrap_width,
self.tab_length(),
);
if wrapped.is_empty() {
return (Text::default(), 0);
}
let cursor_visual = cursor_visual_row(&wrapped, self.cursor());
let top_row = next_scroll_top(prev_top_row, cursor_visual as u16, height);
let top = top_row as usize;
let bottom = cmp::min(top + height as usize, wrapped.len());
let mut lines = Vec::with_capacity(bottom.saturating_sub(top));
for row in &wrapped[top..bottom] {
let line = &self.lines()[row.row];
lines.push(self.line_spans_segment(line, row, lnum_len));
}
(Text::from(lines), top_row)
}
fn scroll_top_row(&self, prev_top: u16, height: u16) -> u16 {
next_scroll_top(prev_top, self.cursor().0 as u16, height)
next_scroll_top(prev_top, self.screen_cursor().row as u16, height)
}
fn scroll_top_col(&self, prev_top: u16, width: u16) -> u16 {
let (row, col) = self.cursor();
// Adujst the cursor position due to the width of non-latine characters.
let mut cursor = self.lines()[row]
.chars()
.take(col)
.map(|c| c.width().unwrap_or(0))
.sum::<usize>() as u16;
let mut cursor = self.screen_cursor().col as u16;
// Adjust the cursor position due to the width of line number.
if self.line_number_style().is_some() {
@@ -178,27 +129,33 @@ impl<'a> TextArea<'a> {
impl Widget for &TextArea<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let Rect { width, height, .. } = if let Some(b) = self.block() {
let inner_area = if let Some(b) = self.block() {
b.inner(area)
} else {
area
};
if self.area.get() != inner_area {
self.area.set(inner_area);
self.screen_map_load();
}
let Rect { width, height, .. } = inner_area;
let (prev_top_row, prev_top_col) = self.viewport.scroll_top();
let (text, style, top_row, top_col) = if !self.placeholder.is_empty() && self.is_empty() {
(self.placeholder_widget(), self.placeholder_style, 0, 0)
} else if self.wrap_mode() == WrapMode::None {
} else {
let top_row = self.scroll_top_row(prev_top_row, height);
let top_col = self.scroll_top_col(prev_top_col, width);
let top_col = if self.wrap_mode() == WrapMode::None {
self.scroll_top_col(prev_top_col, width)
} else {
0
};
(
self.text_widget(top_row as _, height as _),
self.style(),
top_row,
top_col,
)
} else {
let (text, top_row) = self.wrapped_text_widget(prev_top_row, width, height);
(text, self.style(), top_row, 0)
};
// To get fine control over the text color and the surrrounding block they have to be rendered separately
@@ -209,10 +166,6 @@ impl Widget for &TextArea<'_> {
.alignment(self.alignment());
if let Some(b) = self.block() {
text_area = b.inner(area);
// ratatui does not need `clone()` call because `Block` implements `WidgetRef` and `&T` implements `Widget`
// where `T: WidgetRef`. So `b.render` internally calls `b.render_ref` and it doesn't move out `self`.
#[cfg(feature = "tuirs")]
let b = b.clone();
b.render(area, buf)
}
if top_col != 0 {

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

@@ -76,60 +76,6 @@ pub(crate) fn wrapped_rows(
rows
}
pub(crate) fn cursor_visual_row(rows: &[WrappedLine], cursor: (usize, usize)) -> usize {
let (cursor_row, cursor_col) = cursor;
let mut fallback = 0usize;
for (vrow, wrapped) in rows.iter().copied().enumerate() {
if wrapped.row != cursor_row {
continue;
}
fallback = vrow;
let contains = if wrapped.last_in_row {
wrapped.start_col <= cursor_col && cursor_col <= wrapped.end_col
} else {
wrapped.start_col <= cursor_col && cursor_col < wrapped.end_col
};
if contains {
return vrow;
}
}
fallback
}
pub(crate) fn cursor_at_visual_row(
lines: &[String],
rows: &[WrappedLine],
cursor: (usize, usize),
visual_row: usize,
) -> (usize, usize) {
if rows.is_empty() {
return cursor;
}
// Compute the visual column offset within the current visual row so we can
// preserve it when moving to the target row (straight up/down movement).
let current_visual = cursor_visual_row(rows, cursor);
let current_wrapped = rows[current_visual.min(rows.len() - 1)];
let visual_col_offset = cursor.1.saturating_sub(current_wrapped.start_col);
let target = rows[visual_row.min(rows.len() - 1)];
let line_len = lines[target.row].chars().count();
// Apply the visual offset to the target row's start column.
let target_col = target.start_col + visual_col_offset;
// For non-last wrapped segments the end boundary is exclusive (end_col is the
// start_col of the next visual line), so clamp to end_col - 1 to stay on this
// visual row.
let max_col = if target.last_in_row {
target.end_col
} else {
target.end_col.saturating_sub(1)
};
let col = target_col.min(line_len).clamp(target.start_col, max_col);
(target.row, col)
}
pub(crate) fn line_ranges(
line: &str,
mode: WrapMode,

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

@@ -4,6 +4,10 @@ use ratatui::style::{Color, Style};
use ratatui::widgets::Widget as _;
use tui_textarea::{CursorMove, TextArea, WrapMode};
fn cell_symbol(cell: &ratatui::buffer::Cell) -> &str {
cell.symbol()
}
fn render_lines(textarea: &TextArea<'_>, width: u16, height: u16) -> Vec<String> {
let area = Rect {
x: 0,
@@ -18,7 +22,7 @@ fn render_lines(textarea: &TextArea<'_>, width: u16, height: u16) -> Vec<String>
.map(|y| {
let mut line = String::new();
for x in 0..width {
line.push_str(buf[(x, y)].symbol());
line.push_str(cell_symbol(&buf[(x, y)]));
}
line
})
@@ -409,6 +413,56 @@ fn wrapped_cursor_column_clamped_to_shorter_visual_line() {
assert_eq!(textarea.cursor(), (0, 8));
}
#[test]
fn wrapped_cursor_down_preserves_visual_column_for_mixed_width_same_logical_line() {
let mut textarea = TextArea::from(["a中bcde"]);
textarea.set_wrap_mode(WrapMode::Glyph);
render(&textarea, 4, 4);
// After `a中`, the data column is 2 but the visual column is 3.
textarea.move_cursor(CursorMove::Jump(0, 2));
assert_eq!(textarea.cursor(), (0, 2));
textarea.move_cursor(CursorMove::Down);
assert_eq!(textarea.cursor(), (0, 6));
}
#[test]
fn wrapped_cursor_up_preserves_visual_column_for_mixed_width_same_logical_line() {
let mut textarea = TextArea::from(["a中bcde"]);
textarea.set_wrap_mode(WrapMode::Glyph);
render(&textarea, 4, 4);
textarea.move_cursor(CursorMove::Jump(0, 6));
assert_eq!(textarea.cursor(), (0, 6));
textarea.move_cursor(CursorMove::Up);
assert_eq!(textarea.cursor(), (0, 2));
}
#[test]
fn wrapped_cursor_down_preserves_visual_column_when_crossing_into_mixed_width_line() {
let mut textarea = TextArea::from(["abcd", "a中bcde"]);
textarea.set_wrap_mode(WrapMode::Glyph);
render(&textarea, 4, 4);
// Visual column 3 in ASCII should land at visual column 3 in the mixed-width row.
textarea.move_cursor(CursorMove::Jump(0, 3));
assert_eq!(textarea.cursor(), (0, 3));
textarea.move_cursor(CursorMove::Down);
assert_eq!(textarea.cursor(), (1, 2));
}
#[test]
fn wrapped_cursor_up_preserves_visual_column_when_crossing_out_of_mixed_width_line() {
let mut textarea = TextArea::from(["abcd", "a中bcde"]);
textarea.set_wrap_mode(WrapMode::Glyph);
render(&textarea, 4, 4);
textarea.move_cursor(CursorMove::Jump(1, 2));
assert_eq!(textarea.cursor(), (1, 2));
textarea.move_cursor(CursorMove::Up);
assert_eq!(textarea.cursor(), (0, 3));
}
#[test]
fn wrapped_cursor_down_up_with_word_wrap_mode() {
// "hello world" at width=6 with Word wrap: