зеркало из
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
Этот коммит содержится в:
@@ -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>"]
|
||||
|
||||
25
README.md
25
README.md
@@ -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,13 +150,15 @@ impl<'a> LineHighlighter<'a> {
|
||||
}
|
||||
|
||||
pub fn cursor_line(&mut self, cursor_col: usize, style: Style) {
|
||||
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(self.cursor_style), start));
|
||||
.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,12 +51,12 @@ 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) {
|
||||
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
|
||||
for (i, line) in lines[row + 1..].iter().enumerate() {
|
||||
@@ -108,8 +108,8 @@ 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()
|
||||
@@ -118,7 +118,6 @@ impl Search {
|
||||
return Some((row, col));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search lines before cursor
|
||||
for (i, line) in lines[..row].iter().enumerate().rev() {
|
||||
@@ -137,8 +136,8 @@ 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()
|
||||
@@ -146,7 +145,6 @@ impl Search {
|
||||
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,11 +2812,11 @@ 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 {
|
||||
if let Some((cached_width, cached_result)) = self.measure_cache
|
||||
&& cached_width == width_cols
|
||||
{
|
||||
return cached_result;
|
||||
}
|
||||
}
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
|
||||
115
src/widget.rs
115
src/widget.rs
@@ -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());
|
||||
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
|
||||
};
|
||||
(
|
||||
self.text_widget(top_row as _, height as _),
|
||||
self.style(),
|
||||
(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(plan.top_row as _, height as _),
|
||||
self.style(),
|
||||
)
|
||||
};
|
||||
|
||||
// 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"]);
|
||||
|
||||
Ссылка в новой задаче
Block a user