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

feat(textarea): add native cursor render mode (#26)

* feat(textarea): add native cursor render mode

- Add CursorRenderMode::{Cell, Hidden} and TextArea accessors for render mode plus rendered_cursor_position().
- Store the terminal-relative cursor position from a shared render plan that accounts for blocks, line numbers, scroll, wrapping, tabs, and wide Unicode.
- Let hidden mode suppress only the textarea-owned cursor cell while preserving default Cell rendering and set_cursor_style behavior.
- Document native cursor integration and add focused tests for default rendering, hidden rendering, placeholders, and cursor-position edge cases.

* chore: prepare 0.12.1 release
Этот коммит содержится в:
srothgan
2026-07-10 12:14:27 +02:00
коммит произвёл GitHub
родитель d6335585bc
Коммит 475f0beb82
9 изменённых файлов: 449 добавлений и 60 удалений

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

@@ -1,5 +1,11 @@
# Changelog
## [0.12.1] - 2026-07-10 [Changes][v0.12.1]
### Features
- **Native cursor integration** (#26, @srothgan): Add opt-in `CursorRenderMode::Hidden` and `TextArea::rendered_cursor_position()` for placing backend-owned terminal cursors while preserving the default buffer-drawn cursor behavior.
## [0.12.0] - 2026-06-30 [Changes][v0.12.0]
### Features
@@ -491,6 +497,7 @@ First release :tada:
- docs.rs: https://docs.rs/tui-textarea/latest/tui_textarea/
[v0.12.1]: https://github.com/srothgan/tui-textarea/compare/v0.12.0...v0.12.1
[v0.12.0]: https://github.com/srothgan/tui-textarea/compare/v0.11.0...v0.12.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

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

@@ -3,7 +3,7 @@ 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.12.0"
version = "0.12.1"
edition = "2024"
rust-version = "1.88.0" # for Ratatui 0.30 split crates
authors = ["Simon Peter Rothgang <simonrothgang@icloud.com>", "rhysd <lin90162@yahoo.co.jp>"]

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

@@ -20,6 +20,7 @@ Since this fork was created, it has added or integrated:
- Row measurement via `TextAreaMeasure`, `TextArea::measure()`, `set_min_rows()`, and `set_max_rows()`
- Bulk whole-buffer replacement via `TextArea::set_lines()`
- Opt-in atomic ranges for caller-owned placeholders, mentions, or other indivisible spans
- Opt-in native terminal cursor integration via `CursorRenderMode::Hidden` and `rendered_cursor_position()`
**Features:**
@@ -36,6 +37,7 @@ Since this fork was created, it has added or integrated:
- Custom highlighted ranges
- Opt-in atomic ranges for indivisible editing spans
- Placeholder and masking support
- Optional native terminal cursor placement while keeping backend-specific cursor shape control outside the widget
- Mouse scrolling
- Yank support. Paste text deleted with `C-k`, `C-j`, ...
- Backend agnostic. [crossterm][], [termion][], [termwiz][], and your own backend are all supported
@@ -387,6 +389,26 @@ use ratatui::style::{Style, Modifier};
textarea.set_cursor_line_style(Style::default());
```
### Use a native terminal cursor
By default, `TextArea` draws its cursor as a styled cell in the Ratatui buffer. This keeps existing rendering behavior
unchanged. Applications that want a native terminal cursor, such as a blinking bar, can hide the drawn cursor and place
the terminal cursor after rendering.
```rust,ignore
use tui_textarea::{CursorRenderMode, TextArea};
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
frame.render_widget(&textarea, area);
if let Some(position) = textarea.rendered_cursor_position() {
frame.set_cursor_position(position);
}
```
`tui-textarea-2` does not set backend-specific cursor shapes. Configure those in the application, for example with
`crossterm::cursor::SetCursorStyle::BlinkingBar` during terminal setup and reset the shape during teardown.
### Configure tab width
The default tab width is 4. To change it, use `TextArea::set_tab_length()` method. The following sets 2 to tab width.
@@ -648,6 +670,9 @@ Useful state/configuration helpers:
| `textarea.remove_block()` | Remove block chrome |
| `textarea.set_line_number_style(style)` | Enable or restyle line numbers |
| `textarea.remove_line_number()` | Disable line numbers |
| `textarea.set_cursor_render_mode(mode)` | Draw or hide the textarea-owned cursor cell |
| `textarea.cursor_render_mode()` | Read the current cursor render mode |
| `textarea.rendered_cursor_position()` | Get the last rendered terminal cursor position |
| `textarea.set_placeholder_text(text)` | Set or disable placeholder text |
| `textarea.set_placeholder_style(style)` | Change placeholder style |
| `textarea.set_mask_char(ch)` | Enable character masking |

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

@@ -41,6 +41,18 @@ impl Boundary {
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum CursorCellRender {
Draw(Style),
Hidden,
}
impl From<Style> for CursorCellRender {
fn from(style: Style) -> Self {
Self::Draw(style)
}
}
struct DisplayTextBuilder {
tab_len: u8,
width: usize,
@@ -98,7 +110,7 @@ pub struct LineHighlighter<'a> {
boundaries: Vec<(Boundary, usize)>,
style_begin: Style,
cursor_at_end: bool,
cursor_style: Style,
cursor_cell: CursorCellRender,
tab_len: u8,
mask: Option<char>,
select_at_end: bool,
@@ -108,7 +120,7 @@ pub struct LineHighlighter<'a> {
impl<'a> LineHighlighter<'a> {
pub fn new(
line: &'a str,
cursor_style: Style,
cursor_cell: impl Into<CursorCellRender>,
tab_len: u8,
mask: Option<char>,
select_style: Style,
@@ -119,7 +131,7 @@ impl<'a> LineHighlighter<'a> {
boundaries: vec![],
style_begin: Style::default(),
cursor_at_end: false,
cursor_style,
cursor_cell: cursor_cell.into(),
tab_len,
mask,
select_at_end: false,
@@ -138,12 +150,14 @@ impl<'a> LineHighlighter<'a> {
}
pub fn cursor_line(&mut self, cursor_col: usize, style: Style) {
if let Some((start, c)) = self.line.char_indices().nth(cursor_col) {
self.boundaries
.push((Boundary::Cursor(self.cursor_style), start));
self.boundaries.push((Boundary::End, start + c.len_utf8()));
} else {
self.cursor_at_end = true;
if let CursorCellRender::Draw(cursor_style) = self.cursor_cell {
if let Some((start, c)) = self.line.char_indices().nth(cursor_col) {
self.boundaries
.push((Boundary::Cursor(cursor_style), start));
self.boundaries.push((Boundary::End, start + c.len_utf8()));
} else {
self.cursor_at_end = true;
}
}
self.style_begin = style;
}
@@ -247,7 +261,7 @@ impl<'a> LineHighlighter<'a> {
mut boundaries,
tab_len,
style_begin,
cursor_style,
cursor_cell,
cursor_at_end,
mask,
select_at_end,
@@ -260,7 +274,7 @@ impl<'a> LineHighlighter<'a> {
if !built.is_empty() {
spans.push(Span::styled(built, style_begin));
}
if cursor_at_end {
if let (true, CursorCellRender::Draw(cursor_style)) = (cursor_at_end, cursor_cell) {
spans.push(Span::styled(" ", cursor_style));
} else if select_at_end {
spans.push(Span::styled(" ", select_style));
@@ -295,7 +309,7 @@ impl<'a> LineHighlighter<'a> {
spans.push(Span::styled(builder.build(&line[start..]), style));
}
if cursor_at_end {
if let (true, CursorCellRender::Draw(cursor_style)) = (cursor_at_end, cursor_cell) {
spans.push(Span::styled(" ", cursor_style));
} else if select_at_end {
spans.push(Span::styled(" ", select_style));

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

@@ -44,6 +44,6 @@ pub use input::{Input, Key};
pub use scroll::Scrolling;
pub use textarea::{
AtomicCursorBias, AtomicDeleteDirection, AtomicRange, AtomicRangeError,
AtomicRangeRejectReason, RejectedAtomicRange, TextArea, TextAreaMeasure,
AtomicRangeRejectReason, CursorRenderMode, RejectedAtomicRange, TextArea, TextAreaMeasure,
};
pub use wrap::WrapMode;

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

@@ -51,11 +51,11 @@ impl Search {
// Search current line after cursor
let start_col = if match_cursor { col } else { col + 1 };
if let Some((i, _)) = current_line.char_indices().nth(start_col) {
if let Some(m) = pat.find_at(current_line, i) {
let col = start_col + current_line[i..m.start()].chars().count();
return Some((row, col));
}
if let Some((i, _)) = current_line.char_indices().nth(start_col)
&& let Some(m) = pat.find_at(current_line, i)
{
let col = start_col + current_line[i..m.start()].chars().count();
return Some((row, col));
}
// Search lines after cursor
@@ -108,15 +108,14 @@ impl Search {
// Search current line before cursor
if col > 0 || match_cursor {
let start_col = if match_cursor { col } else { col - 1 };
if let Some((i, _)) = current_line.char_indices().nth(start_col) {
if let Some(m) = pat
if let Some((i, _)) = current_line.char_indices().nth(start_col)
&& let Some(m) = pat
.find_iter(current_line)
.take_while(|m| m.start() <= i)
.last()
{
let col = current_line[..m.start()].chars().count();
return Some((row, col));
}
{
let col = current_line[..m.start()].chars().count();
return Some((row, col));
}
}
@@ -137,15 +136,14 @@ impl Search {
}
// Search current line after cursor
if let Some((i, _)) = current_line.char_indices().nth(col) {
if let Some(m) = pat
if let Some((i, _)) = current_line.char_indices().nth(col)
&& let Some(m) = pat
.find_iter(current_line)
.skip_while(|m| m.start() < i)
.last()
{
let col = col + current_line[i..m.start()].chars().count();
return Some((row, col));
}
{
let col = col + current_line[i..m.start()].chars().count();
return Some((row, col));
}
None

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

@@ -1,8 +1,8 @@
use crate::cursor::{CursorMove, DataCursor, ScreenCursor};
use crate::highlight::LineHighlighter;
use crate::highlight::{CursorCellRender, LineHighlighter};
use crate::history::{Edit, EditKind, History};
use crate::input::{Input, Key};
use crate::ratatui::layout::{Alignment, Rect};
use crate::ratatui::layout::{Alignment, Position, Rect};
use crate::ratatui::style::{Color, Modifier, Style};
use crate::ratatui::text::Line;
use crate::ratatui::widgets::{Block, Widget};
@@ -135,6 +135,20 @@ pub struct TextAreaMeasure {
pub max_rows: u16,
}
/// Controls whether [`TextArea`] draws its own visual cursor into the rendered buffer.
///
/// This does not control terminal cursor shape or blinking. Backend-specific cursor
/// shape setup, such as crossterm cursor styles, remains caller-owned.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum CursorRenderMode {
/// Draw the textarea cursor as a styled cell in the Ratatui buffer.
#[default]
Cell,
/// Do not draw a textarea-owned cursor cell.
Hidden,
}
/// A type to manage state of textarea. These are some important methods:
///
/// - [`TextArea::default`] creates an empty textarea.
@@ -196,6 +210,7 @@ pub struct TextArea<'a> {
line_number_style: Option<Style>,
pub(crate) viewport: Viewport,
pub(crate) cursor_style: Style,
cursor_render_mode: CursorRenderMode,
yank: YankText,
#[cfg(feature = "search")]
search: Search,
@@ -214,6 +229,7 @@ pub struct TextArea<'a> {
pub(crate) screen_lines: RefCell<Vec<ScreenLine>>,
pub(crate) data_pointers: RefCell<Vec<DataLine>>,
pub(crate) area: Cell<Rect>,
pub(crate) rendered_cursor_position: Cell<Option<Position>>,
}
/// Convert any iterator whose elements can be converted into [`String`] into [`TextArea`]. Each [`String`] element is
@@ -314,6 +330,7 @@ impl<'a> TextArea<'a> {
line_number_style: None,
viewport: Viewport::default(),
cursor_style: Style::default().add_modifier(Modifier::REVERSED),
cursor_render_mode: CursorRenderMode::Cell,
yank: YankText::default(),
#[cfg(feature = "search")]
search: Search::default(),
@@ -332,6 +349,7 @@ impl<'a> TextArea<'a> {
screen_lines: RefCell::new(Vec::new()),
data_pointers: RefCell::new(Vec::new()),
area: Cell::new(Rect::default()),
rendered_cursor_position: Cell::new(None),
};
textarea.screen_map_load();
textarea
@@ -2032,9 +2050,13 @@ impl<'a> TextArea<'a> {
lnum_len: u8,
) -> Line<'b> {
let fragment = &line[wrapped.start_byte..wrapped.end_byte];
let cursor_cell = match self.cursor_render_mode {
CursorRenderMode::Cell => CursorCellRender::Draw(self.cursor_style),
CursorRenderMode::Hidden => CursorCellRender::Hidden,
};
let mut hl = LineHighlighter::new(
fragment,
self.cursor_style,
cursor_cell,
self.tab_len,
self.mask,
self.select_style,
@@ -2497,8 +2519,11 @@ impl<'a> TextArea<'a> {
self.mask
}
/// Set the style of cursor. By default, a cursor is rendered in the reversed color. Setting the same style as
/// cursor line hides a cursor.
/// Set the style of the buffer-drawn cursor cell.
///
/// By default, a cursor is rendered in the reversed color. This style is visible
/// when [`CursorRenderMode::Cell`] is active. It is stored but has no visual
/// effect while [`CursorRenderMode::Hidden`] is active.
/// ```
/// use ratatui::style::{Style, Color};
/// use tui_textarea::TextArea;
@@ -2518,6 +2543,46 @@ impl<'a> TextArea<'a> {
self.cursor_style
}
/// Set how the textarea-owned cursor is rendered into the Ratatui buffer.
///
/// [`CursorRenderMode::Cell`] is the default and preserves the historical
/// behavior. [`CursorRenderMode::Hidden`] suppresses only the textarea-owned
/// cursor cell so applications can place a native terminal cursor with
/// [`TextArea::rendered_cursor_position`].
pub fn set_cursor_render_mode(&mut self, mode: CursorRenderMode) {
self.cursor_render_mode = mode;
}
/// Get the current cursor render mode.
pub fn cursor_render_mode(&self) -> CursorRenderMode {
self.cursor_render_mode
}
/// Get the cursor position calculated during the most recent render.
///
/// This returns terminal-relative coordinates suitable for
/// `ratatui_core::terminal::Frame::set_cursor_position` after rendering this
/// widget. It returns `None` before the first render or when the cursor cannot be
/// represented inside the most recent render area. Cursor shape and blinking are
/// backend concerns and are not controlled by `tui-textarea-2`.
///
/// ```ignore
/// use tui_textarea::{CursorRenderMode, TextArea};
///
/// let mut textarea = TextArea::default();
/// textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
///
/// # let area = ratatui::layout::Rect::default();
/// # let mut frame: ratatui::Frame = unimplemented!();
/// frame.render_widget(&textarea, area);
/// if let Some(position) = textarea.rendered_cursor_position() {
/// frame.set_cursor_position(position);
/// }
/// ```
pub fn rendered_cursor_position(&self) -> Option<Position> {
self.rendered_cursor_position.get()
}
/// Get slice of line texts. This method borrows the content, but not moves. Note that the returned slice will
/// never be empty because an empty text means a slice containing one empty line. This is correct since any text
/// file must end with a newline.
@@ -2747,10 +2812,10 @@ impl<'a> TextArea<'a> {
/// assert_eq!(m.preferred_rows, 2);
/// ```
pub fn measure(&mut self, width_cols: u16) -> TextAreaMeasure {
if let Some((cached_width, cached_result)) = self.measure_cache {
if cached_width == width_cols {
return cached_result;
}
if let Some((cached_width, cached_result)) = self.measure_cache
&& cached_width == width_cols
{
return cached_result;
}
let area = Rect {

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

@@ -1,8 +1,8 @@
use crate::ratatui::buffer::Buffer;
use crate::ratatui::layout::Rect;
use crate::ratatui::layout::{Position, Rect};
use crate::ratatui::text::{Line, Span, Text};
use crate::ratatui::widgets::{Paragraph, Widget};
use crate::textarea::TextArea;
use crate::textarea::{CursorRenderMode, TextArea};
use crate::util::num_digits;
use crate::wrap::WrapMode;
use portable_atomic::{AtomicU64, Ordering};
@@ -88,6 +88,14 @@ fn next_scroll_top(prev_top: u16, cursor: u16, len: u16) -> u16 {
}
}
struct RenderPlan {
inner_area: Rect,
top_row: u16,
top_col: u16,
rendered_cursor_position: Option<Position>,
placeholder_active: bool,
}
impl<'a> TextArea<'a> {
fn text_widget(&'a self, top_row: usize, height: usize) -> Text<'a> {
let lnum_len = num_digits(self.lines().len());
@@ -102,9 +110,13 @@ impl<'a> TextArea<'a> {
}
fn placeholder_widget(&'a self) -> Text<'a> {
let cursor = Span::styled(" ", self.cursor_style);
let text = Span::raw(self.placeholder.as_str());
Text::from(Line::from(vec![cursor, text]))
if self.cursor_render_mode() == CursorRenderMode::Cell {
let cursor = Span::styled(" ", self.cursor_style);
Text::from(Line::from(vec![cursor, text]))
} else {
Text::from(Line::from(vec![text]))
}
}
fn scroll_top_row(&self, prev_top: u16, height: u16) -> u16 {
@@ -125,24 +137,25 @@ impl<'a> TextArea<'a> {
}
next_scroll_top(prev_top, cursor, width)
}
}
impl Widget for &TextArea<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
fn render_plan(&self, area: Rect) -> RenderPlan {
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 Rect { width, height, .. } = inner_area;
let placeholder_active = !self.placeholder.is_empty() && self.is_empty();
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)
let (top_row, top_col) = if placeholder_active {
(0, 0)
} else {
let top_row = self.scroll_top_row(prev_top_row, height);
let top_col = if self.wrap_mode() == WrapMode::None {
@@ -150,30 +163,98 @@ impl Widget for &TextArea<'_> {
} else {
0
};
(top_row, top_col)
};
let rendered_cursor_position =
self.rendered_position_in(inner_area, top_row, top_col, placeholder_active);
RenderPlan {
inner_area,
top_row,
top_col,
rendered_cursor_position,
placeholder_active,
}
}
fn rendered_position_in(
&self,
inner_area: Rect,
top_row: u16,
top_col: u16,
placeholder_active: bool,
) -> Option<Position> {
if inner_area.width == 0 || inner_area.height == 0 {
return None;
}
if placeholder_active {
return Some(Position {
x: inner_area.x,
y: inner_area.y,
});
}
let cursor = self.screen_cursor();
let cursor_row = cursor.row;
let top_row = top_row as usize;
let height = inner_area.height as usize;
if cursor_row < top_row || top_row.saturating_add(height) <= cursor_row {
return None;
}
let mut cursor_col = cursor.col;
if self.line_number_style().is_some() {
cursor_col = cursor_col.saturating_add(num_digits(self.lines().len()) as usize + 2);
}
let top_col = top_col as usize;
let width = inner_area.width as usize;
if cursor_col < top_col || top_col.saturating_add(width) <= cursor_col {
return None;
}
Some(Position {
x: inner_area.x + (cursor_col - top_col) as u16,
y: inner_area.y + (cursor_row - top_row) as u16,
})
}
}
impl Widget for &TextArea<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let plan = self.render_plan(area);
let Rect { width, height, .. } = plan.inner_area;
let (text, style) = if plan.placeholder_active {
(self.placeholder_widget(), self.placeholder_style)
} else {
(
self.text_widget(top_row as _, height as _),
self.text_widget(plan.top_row as _, height as _),
self.style(),
top_row,
top_col,
)
};
// To get fine control over the text color and the surrrounding block they have to be rendered separately
// see https://github.com/ratatui/ratatui/issues/144
let mut text_area = area;
let mut text_area = plan.inner_area;
let mut inner = Paragraph::new(text)
.style(style)
.alignment(self.alignment());
if let Some(b) = self.block() {
text_area = b.inner(area);
text_area = plan.inner_area;
b.render(area, buf)
}
if top_col != 0 {
inner = inner.scroll((0, top_col));
if plan.top_col != 0 {
inner = inner.scroll((0, plan.top_col));
}
// Store scroll top position for rendering on the next tick
self.viewport.store(top_row, top_col, width, height);
self.viewport
.store(plan.top_row, plan.top_col, width, height);
self.rendered_cursor_position
.set(plan.rendered_cursor_position);
inner.render(text_area, buf);
}

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

@@ -1,9 +1,11 @@
use ratatui::layout::Alignment;
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Widget as _;
use ratatui::widgets::{Block, Borders};
use std::cmp;
use std::fmt::Debug;
use tui_textarea::{CursorMove, TextArea};
use tui_textarea::{CursorMove, CursorRenderMode, TextArea, WrapMode};
fn assert_undo_redo<T: Debug>(
before_pos: (usize, usize),
@@ -34,6 +36,12 @@ fn assert_no_undo_redo<T: Debug>(t: &mut TextArea<'_>, context: T) {
assert_eq!(t.cursor(), pos, "pos after redo: {context:?}");
}
fn render_textarea(textarea: &TextArea<'_>, area: Rect) -> Buffer {
let mut buf = Buffer::empty(area);
textarea.render(area, &mut buf);
buf
}
#[test]
fn test_insert_soft_tab() {
for test in [
@@ -1621,6 +1629,7 @@ fn test_set_lines_preserves_non_content_configuration() {
t.set_block(Block::default().borders(Borders::ALL).title("Input"));
t.set_style(base_style);
t.set_cursor_style(cursor_style);
t.set_cursor_render_mode(CursorRenderMode::Hidden);
t.set_cursor_line_style(cursor_line_style);
t.set_selection_style(selection_style);
t.set_tab_length(8);
@@ -1642,6 +1651,7 @@ fn test_set_lines_preserves_non_content_configuration() {
assert!(t.block().is_some());
assert_eq!(t.style(), base_style);
assert_eq!(t.cursor_style(), cursor_style);
assert_eq!(t.cursor_render_mode(), CursorRenderMode::Hidden);
assert_eq!(t.cursor_line_style(), cursor_line_style);
assert_eq!(t.selection_style(), selection_style);
assert_eq!(t.tab_length(), 8);
@@ -1656,6 +1666,195 @@ fn test_set_lines_preserves_non_content_configuration() {
assert_eq!(t.max_histories(), 3);
}
#[test]
fn cursor_render_mode_defaults_to_cell() {
assert_eq!(
TextArea::default().cursor_render_mode(),
CursorRenderMode::Cell
);
}
#[test]
fn set_cursor_render_mode_round_trips() {
let mut textarea = TextArea::default();
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
assert_eq!(textarea.cursor_render_mode(), CursorRenderMode::Hidden);
textarea.set_cursor_render_mode(CursorRenderMode::Cell);
assert_eq!(textarea.cursor_render_mode(), CursorRenderMode::Cell);
}
#[test]
fn cell_mode_keeps_existing_cursor_rendering() {
let mut textarea = TextArea::from(["abc"]);
let cursor_style = Style::default().bg(Color::Red);
textarea.set_cursor_style(cursor_style);
textarea.set_cursor_line_style(Style::default());
textarea.move_cursor(CursorMove::Jump(0, 1));
let buf = render_textarea(&textarea, Rect::new(0, 0, 5, 1));
assert_eq!(buf[(1, 0)].symbol(), "b");
assert_eq!(buf[(1, 0)].style().bg, cursor_style.bg);
}
#[test]
fn hidden_mode_does_not_draw_cursor_cell_inside_text() {
let mut textarea = TextArea::from(["abc"]);
textarea.set_cursor_style(Style::default().bg(Color::Red));
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
textarea.move_cursor(CursorMove::Jump(0, 1));
let buf = render_textarea(&textarea, Rect::new(0, 0, 5, 1));
assert_eq!(buf[(1, 0)].symbol(), "b");
assert_ne!(buf[(1, 0)].style().bg, Some(Color::Red));
}
#[test]
fn hidden_mode_does_not_append_cursor_space_at_line_end() {
let mut textarea = TextArea::from(["abc"]);
let cursor_style = Style::default().bg(Color::Red);
textarea.set_cursor_style(cursor_style);
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
textarea.move_cursor(CursorMove::End);
let buf = render_textarea(&textarea, Rect::new(0, 0, 5, 1));
assert_eq!(buf[(0, 0)].symbol(), "a");
assert_eq!(buf[(1, 0)].symbol(), "b");
assert_eq!(buf[(2, 0)].symbol(), "c");
assert_eq!(buf[(3, 0)].symbol(), " ");
assert_ne!(buf[(3, 0)].style().bg, cursor_style.bg);
}
#[test]
fn hidden_mode_placeholder_does_not_draw_cursor_cell() {
let mut textarea = TextArea::default();
textarea.set_placeholder_text("Type here");
textarea.set_cursor_style(Style::default().bg(Color::Red));
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
let buf = render_textarea(&textarea, Rect::new(0, 0, 12, 1));
assert_eq!(buf[(0, 0)].symbol(), "T");
assert_eq!(
textarea.rendered_cursor_position(),
Some(Position { x: 0, y: 0 })
);
}
#[test]
fn rendered_cursor_position_returns_none_before_first_render() {
let textarea = TextArea::default();
assert_eq!(textarea.rendered_cursor_position(), None);
}
#[test]
fn rendered_cursor_position_after_default_render() {
let mut textarea = TextArea::from(["abc"]);
textarea.move_cursor(CursorMove::Jump(0, 2));
render_textarea(&textarea, Rect::new(10, 5, 20, 3));
assert_eq!(
textarea.rendered_cursor_position(),
Some(Position { x: 12, y: 5 })
);
}
#[test]
fn rendered_cursor_position_accounts_for_block() {
let mut textarea = TextArea::from(["abc"]);
textarea.set_block(Block::default().borders(Borders::ALL));
textarea.move_cursor(CursorMove::Jump(0, 2));
render_textarea(&textarea, Rect::new(10, 5, 20, 5));
assert_eq!(
textarea.rendered_cursor_position(),
Some(Position { x: 13, y: 6 })
);
}
#[test]
fn rendered_cursor_position_accounts_for_line_numbers() {
let mut textarea = TextArea::from(["abc"]);
textarea.set_line_number_style(Style::default());
textarea.move_cursor(CursorMove::Jump(0, 1));
render_textarea(&textarea, Rect::new(10, 5, 20, 3));
assert_eq!(
textarea.rendered_cursor_position(),
Some(Position { x: 14, y: 5 })
);
}
#[test]
fn rendered_cursor_position_accounts_for_horizontal_scroll() {
let mut textarea = TextArea::from(["abcdefghi"]);
textarea.move_cursor(CursorMove::End);
render_textarea(&textarea, Rect::new(0, 0, 5, 1));
assert_eq!(
textarea.rendered_cursor_position(),
Some(Position { x: 4, y: 0 })
);
}
#[test]
fn rendered_cursor_position_accounts_for_wrap() {
let mut textarea = TextArea::from(["abcdefghij"]);
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
textarea.move_cursor(CursorMove::Jump(0, 7));
render_textarea(&textarea, Rect::new(4, 3, 5, 3));
assert_eq!(
textarea.rendered_cursor_position(),
Some(Position { x: 6, y: 4 })
);
}
#[test]
fn rendered_cursor_position_returns_none_for_empty_area() {
let textarea = TextArea::default();
render_textarea(&textarea, Rect::new(0, 0, 0, 1));
assert_eq!(textarea.rendered_cursor_position(), None);
}
#[test]
fn rendered_cursor_position_handles_wide_unicode() {
let mut textarea = TextArea::from(["aあb"]);
textarea.move_cursor(CursorMove::Jump(0, 2));
render_textarea(&textarea, Rect::new(0, 0, 10, 1));
assert_eq!(
textarea.rendered_cursor_position(),
Some(Position { x: 3, y: 0 })
);
}
#[test]
fn rendered_cursor_position_handles_tabs() {
let mut textarea = TextArea::from(["a\tb"]);
textarea.move_cursor(CursorMove::Jump(0, 2));
render_textarea(&textarea, Rect::new(0, 0, 10, 1));
assert_eq!(
textarea.rendered_cursor_position(),
Some(Position { x: 4, y: 0 })
);
}
#[test]
fn test_set_lines_clamps_cursor_and_clears_selection_and_history() {
let mut t = TextArea::from(["hello", "world"]);