зеркало из
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
Этот коммит содержится в:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
|
||||
24
Cargo.toml
24
Cargo.toml
@@ -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"]
|
||||
|
||||
|
||||
57
README.md
57
README.md
@@ -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
Обычный файл
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
Обычный файл
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());
|
||||
|
||||
221
src/cursor.rs
221
src/cursor.rs
@@ -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,
|
||||
|
||||
13
src/lib.rs
13
src/lib.rs
@@ -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
Обычный файл
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;
|
||||
|
||||
193
src/textarea.rs
193
src/textarea.rs
@@ -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 {
|
||||
|
||||
54
src/wrap.rs
54
src/wrap.rs
@@ -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:
|
||||
|
||||
Ссылка в новой задаче
Block a user