Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
9711 lines
359 KiB
Rust
9711 lines
359 KiB
Rust
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Color;
|
||
use ratatui::style::Style;
|
||
use ratatui::text::Line;
|
||
use ratatui::widgets::StatefulWidgetRef;
|
||
use ratatui::widgets::WidgetRef;
|
||
use ratatui_core::buffer::Buffer as CoreBuffer;
|
||
use ratatui_core::layout::Rect as CoreRect;
|
||
use ratatui_core::widgets::Widget as _;
|
||
use std::cell::Ref;
|
||
use std::cell::RefCell;
|
||
use std::ops::Range;
|
||
use std::time::Instant;
|
||
use textwrap::Options;
|
||
use tui_scrollbar::{ScrollBar, ScrollLengths};
|
||
use unicode_segmentation::UnicodeSegmentation;
|
||
use unicode_width::UnicodeWidthStr;
|
||
|
||
/// Stable, unique identifier for a text element. Monotonically increasing, never reused.
|
||
///
|
||
/// The host app can use this as a key into its own metadata store
|
||
/// (e.g. `HashMap<ElementId, PasteMetadata>`).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct ElementId(u64);
|
||
|
||
impl ElementId {
|
||
/// Construct an `ElementId` from a raw `u64` value.
|
||
///
|
||
/// Primarily useful for tests and serialization; normal code should use
|
||
/// the IDs returned by [`TextArea::insert_element`].
|
||
pub fn from_raw(raw: u64) -> Self {
|
||
Self(raw)
|
||
}
|
||
}
|
||
|
||
/// Opaque element kind tag. The textarea does not interpret this value;
|
||
/// the host app defines constants like `ElementKind(1)` for pastes,
|
||
/// `ElementKind(2)` for file references, etc.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct ElementKind(pub u16);
|
||
|
||
// ── Clipboard ──
|
||
|
||
/// Trait for clipboard access. The textarea calls this on copy/cut/paste.
|
||
///
|
||
/// The default implementation ([`InternalClipboard`]) stores text in memory.
|
||
/// Host apps can provide a system clipboard backend (e.g. `arboard`) via
|
||
/// [`TextArea::set_clipboard_provider`].
|
||
pub trait ClipboardProvider: std::fmt::Debug {
|
||
/// Read the current clipboard contents (for paste).
|
||
fn get(&mut self) -> Option<String>;
|
||
/// Write text to the clipboard (on copy/cut).
|
||
fn set(&mut self, text: &str);
|
||
}
|
||
|
||
/// In-memory clipboard — the default provider.
|
||
#[derive(Debug, Default)]
|
||
pub struct InternalClipboard {
|
||
contents: Option<String>,
|
||
}
|
||
|
||
impl ClipboardProvider for InternalClipboard {
|
||
fn get(&mut self) -> Option<String> {
|
||
self.contents.clone()
|
||
}
|
||
|
||
fn set(&mut self, text: &str) {
|
||
self.contents = Some(text.to_string());
|
||
}
|
||
}
|
||
|
||
// ── Text element events ──
|
||
|
||
/// An interaction with a [`TextElement`], returned by [`TextArea::poll_element_event`].
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct TextElementEvent {
|
||
/// The element that was interacted with.
|
||
pub id: ElementId,
|
||
/// What kind of interaction occurred.
|
||
pub kind: TextElementEventKind,
|
||
}
|
||
|
||
/// The kind of element interaction.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum TextElementEventKind {
|
||
/// The element was clicked (single click).
|
||
Click,
|
||
/// The mouse entered the element (was outside or on a different element).
|
||
HoverEnter,
|
||
/// The mouse left the element (moved to plain text or a different element).
|
||
HoverLeave,
|
||
}
|
||
|
||
/// An atomic text element embedded in the buffer.
|
||
///
|
||
/// Elements are indivisible units for navigation and editing. The cursor
|
||
/// cannot be placed inside an element; it jumps from the start boundary
|
||
/// to the end boundary atomically.
|
||
#[derive(Debug, Clone)]
|
||
pub struct TextElement {
|
||
/// Stable identifier, unique across the lifetime of the `TextArea`.
|
||
pub id: ElementId,
|
||
/// Byte range in the underlying text buffer.
|
||
pub range: Range<usize>,
|
||
/// Host-defined kind tag.
|
||
pub kind: ElementKind,
|
||
/// Custom display text and styling. When `Some`, this `Line` is rendered
|
||
/// instead of the raw buffer text. When `None`, the buffer text is rendered
|
||
/// with a default element style (cyan).
|
||
pub display: Option<Line<'static>>,
|
||
}
|
||
|
||
// ── Selection ──
|
||
|
||
/// A byte-range selection in the buffer, created by mouse drag.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct Selection {
|
||
/// Buffer position where the selection started (fixed anchor).
|
||
pub anchor: usize,
|
||
/// Buffer position where the selection currently extends to (moves with drag).
|
||
pub head: usize,
|
||
}
|
||
|
||
// ── Mouse ──
|
||
|
||
/// Result of processing a mouse event in the textarea.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum MouseAction {
|
||
/// Nothing interesting happened.
|
||
Nothing,
|
||
/// Cursor was placed at a position (single click on plain text).
|
||
CursorPlaced,
|
||
/// Selection was updated (drag in progress, or double/triple click).
|
||
SelectionUpdated,
|
||
/// Selection was finalized — text copied to clipboard.
|
||
/// Host should call `take_clipboard()` to retrieve it.
|
||
SelectionFinished,
|
||
/// Content was scrolled (mouse wheel).
|
||
Scrolled,
|
||
}
|
||
|
||
/// Tracks consecutive clicks at the same screen position to detect
|
||
/// double-click (word select) and triple-click (line select).
|
||
#[derive(Debug)]
|
||
struct ClickTracker {
|
||
last_time: Instant,
|
||
last_pos: (u16, u16),
|
||
count: u8,
|
||
}
|
||
|
||
impl Default for ClickTracker {
|
||
fn default() -> Self {
|
||
Self {
|
||
last_time: Instant::now(),
|
||
last_pos: (u16::MAX, u16::MAX),
|
||
count: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ClickTracker {
|
||
/// Maximum time between clicks to count as multi-click (ms).
|
||
const MULTI_CLICK_MS: u128 = 500;
|
||
|
||
/// Register a click at `(col, row)`. Returns the click count (1, 2, or 3).
|
||
fn register(&mut self, col: u16, row: u16) -> u8 {
|
||
let now = Instant::now();
|
||
let elapsed = now.duration_since(self.last_time).as_millis();
|
||
if elapsed < Self::MULTI_CLICK_MS && self.last_pos == (col, row) && self.count < 3 {
|
||
self.count += 1;
|
||
} else {
|
||
self.count = 1;
|
||
}
|
||
self.last_time = now;
|
||
self.last_pos = (col, row);
|
||
self.count
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct TextArea {
|
||
text: String,
|
||
cursor_pos: usize,
|
||
wrap_cache: RefCell<Option<WrapCache>>,
|
||
preferred_col: Option<usize>,
|
||
elements: Vec<TextElement>,
|
||
next_element_id: u64,
|
||
kill_buffer: String,
|
||
undo: UndoState,
|
||
/// Active selection (mouse drag). `None` when no selection.
|
||
selection: Option<Selection>,
|
||
/// Clipboard provider — defaults to [`InternalClipboard`].
|
||
/// Swap with [`set_clipboard_provider`](Self::set_clipboard_provider)
|
||
/// for system clipboard support.
|
||
clipboard_provider: Box<dyn ClipboardProvider>,
|
||
/// Last copied text — set on copy/cut, cleared by `take_clipboard()`.
|
||
/// This is the "notification" channel: the host calls `take_clipboard()`
|
||
/// to detect that something was just copied.
|
||
clipboard: Option<String>,
|
||
/// Whether to keep the selection visible after mouse-up.
|
||
/// When `false`, selection clears immediately on mouse-up (fully transient).
|
||
pub keep_selection_after_mouseup: bool,
|
||
/// Style applied to selected text. Defaults to a tokyonight-inspired
|
||
/// blue background (`rgb(49, 62, 115)`) with an explicit light foreground
|
||
/// (`rgb(192, 202, 245)`) so the selection is legible regardless of the
|
||
/// host terminal's colour scheme.
|
||
///
|
||
/// Override to match your own theme, e.g.:
|
||
/// ```ignore
|
||
/// textarea.selection_style = Style::default().bg(Color::Rgb(60, 60, 60));
|
||
/// ```
|
||
pub selection_style: Style,
|
||
/// Screen position of the last mouse-down (for distinguishing click vs drag).
|
||
mouse_down_pos: Option<(u16, u16)>,
|
||
/// Buffer byte position of the mouse-down anchor (for drag selection).
|
||
drag_anchor: Option<usize>,
|
||
/// Whether a drag is currently in progress.
|
||
drag_active: bool,
|
||
/// Last time drag-scroll was applied (throttle).
|
||
last_drag_scroll: Option<Instant>,
|
||
/// Number of drag-scroll steps taken so far (for acceleration).
|
||
drag_scroll_steps: u32,
|
||
/// Stored drag event for continuous drag-scroll (re-triggered on timer).
|
||
/// Set when a drag moves outside the textarea area; cleared on mouse-up.
|
||
pending_drag_scroll: Option<MouseEvent>,
|
||
/// Tracks multi-click (double/triple) at the same position.
|
||
click_tracker: ClickTracker,
|
||
/// Internal scroll offset set by mousewheel events. When `Some`, this
|
||
/// overrides the external `TextAreaState.scroll` so the viewport scrolls
|
||
/// independently of the cursor. Cleared whenever the cursor moves
|
||
/// (typing, navigation, click) so the viewport snaps back to follow it.
|
||
scroll_override: Option<u16>,
|
||
/// Whether to show a scrollbar on the right edge when content overflows.
|
||
/// When enabled, the rightmost column is reserved for the scrollbar track
|
||
/// and the text area wraps at `width - 1`. Defaults to `true`.
|
||
pub show_scrollbar: bool,
|
||
/// Style for the scrollbar track (empty space). Defaults to a dark
|
||
/// tokyonight-inspired background. Override to match your theme's
|
||
/// background when embedding the textarea in a non-default-bg context.
|
||
pub scrollbar_track_style: Style,
|
||
/// Style for the scrollbar thumb (draggable indicator). Defaults to a
|
||
/// slightly lighter tokyonight shade. Override to match your theme.
|
||
pub scrollbar_thumb_style: Style,
|
||
/// Padding (in columns) between the text content and the scrollbar track.
|
||
/// Only applies when the scrollbar is visible. Defaults to `0`.
|
||
pub scrollbar_padding: u16,
|
||
/// Whether the user is currently dragging the scrollbar thumb.
|
||
scrollbar_dragging: bool,
|
||
/// Currently hovered element (for enter/leave detection).
|
||
hovered_element: Option<ElementId>,
|
||
/// Pending element event — consumed by [`poll_element_event`](Self::poll_element_event).
|
||
pending_element_event: Option<TextElementEvent>,
|
||
/// Columns per tab character for display width and tab→space expansion on
|
||
/// insert. `0` leaves tabs as-is (unicode-width treats them as 0-width).
|
||
/// Defaults to `4`, matching scrollback `appearance::tab_width`.
|
||
tab_width: u8,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct WrapCache {
|
||
width: u16,
|
||
lines: Vec<Range<usize>>,
|
||
}
|
||
|
||
#[derive(Debug, Default, Clone, Copy)]
|
||
pub struct TextAreaState {
|
||
/// Index into wrapped lines of the first visible line.
|
||
pub scroll: u16,
|
||
}
|
||
|
||
// ── Undo/Redo ──
|
||
|
||
/// A snapshot of the textarea state for undo/redo.
|
||
#[derive(Debug, Clone)]
|
||
struct UndoEntry {
|
||
text: String,
|
||
cursor: usize,
|
||
elements: Vec<TextElement>,
|
||
}
|
||
|
||
/// What kind of mutation is being performed. Used for batching consecutive
|
||
/// same-kind operations into a single undo step.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum MutationKind {
|
||
/// Character-by-character typing, `insert_str`, `yank`.
|
||
Insert,
|
||
/// Backspace, delete forward.
|
||
Delete,
|
||
/// Ctrl+K, Ctrl+U, word-delete — always a discrete undo step.
|
||
Kill,
|
||
/// `insert_element`, `replace_range_with_element` — always discrete.
|
||
Element,
|
||
/// `set_text`, `replace_range` (host-driven) — always discrete.
|
||
Replace,
|
||
}
|
||
|
||
/// Manages the undo/redo stacks.
|
||
#[derive(Debug)]
|
||
struct UndoState {
|
||
stack: Vec<UndoEntry>,
|
||
redo: Vec<UndoEntry>,
|
||
max_depth: usize,
|
||
/// The kind of the last mutation that was checkpointed.
|
||
last_kind: Option<MutationKind>,
|
||
/// Cursor position *after* the last mutation completed.
|
||
/// Used to detect cursor jumps (arrows between inserts → new undo group).
|
||
last_cursor: usize,
|
||
/// Whether the last inserted character was whitespace.
|
||
/// Used to break insert batches at word boundaries (ws↔non-ws transitions).
|
||
last_insert_ws: bool,
|
||
/// Nesting depth for undo groups. When > 0, `pre_mutate` is suppressed.
|
||
group_depth: usize,
|
||
/// Snapshot taken when the outermost `begin_undo_group()` was called.
|
||
/// Used by `end_undo_group` to push the checkpoint, or by
|
||
/// `cancel_undo_group` to restore the pre-group state.
|
||
group_checkpoint: Option<UndoEntry>,
|
||
}
|
||
|
||
impl Default for UndoState {
|
||
fn default() -> Self {
|
||
Self {
|
||
stack: Vec::new(),
|
||
redo: Vec::new(),
|
||
max_depth: 100,
|
||
last_kind: None,
|
||
last_cursor: 0,
|
||
last_insert_ws: false,
|
||
group_depth: 0,
|
||
group_checkpoint: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether `key` is the undo chord [`TextArea::input`] binds: lowercase
|
||
/// 'z' with Ctrl or Cmd. Uppercase 'Z' (redo) is intentionally excluded,
|
||
/// which keeps this guard disjoint from the redo arm regardless of order.
|
||
///
|
||
/// Single source for the binding: `input()`'s undo arm consumes this
|
||
/// predicate, and hosts that react to undo (e.g. retiring an undo hint)
|
||
/// call it too, so the chord and its observers cannot drift.
|
||
pub fn is_undo_input(key: &KeyEvent) -> bool {
|
||
matches!(key.code, KeyCode::Char('z'))
|
||
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|
||
|| key.modifiers.contains(KeyModifiers::SUPER))
|
||
}
|
||
|
||
impl TextArea {
|
||
/// Compute the number of lines to scroll per mouse wheel tick based on
|
||
/// the viewport height. Small viewports scroll slowly (1 line), large
|
||
/// viewports scroll faster (up to 3 lines).
|
||
fn scroll_lines_for_height(height: u16) -> u16 {
|
||
match height {
|
||
0..=5 => 1,
|
||
6..=15 => 2,
|
||
_ => 3,
|
||
}
|
||
}
|
||
|
||
/// Drag-scroll throttle intervals (ms): ramps up from slow to fast.
|
||
/// After the last entry, the final value repeats.
|
||
const DRAG_SCROLL_RAMP_MS: &[u128] = &[80, 60, 40];
|
||
|
||
/// Compute the drag-scroll interval for the given step count.
|
||
fn drag_scroll_interval(step: u32) -> u128 {
|
||
let ramp = Self::DRAG_SCROLL_RAMP_MS;
|
||
ramp[ramp.len().min(step as usize + 1) - 1]
|
||
}
|
||
|
||
/// How many extra lines to scroll based on distance from area edge.
|
||
/// Returns 1 for 1-2 rows outside, 2 for 3-4 rows, 3 for 5-8, etc.
|
||
fn drag_scroll_lines_for_distance(distance: u16) -> usize {
|
||
match distance {
|
||
0..=2 => 1,
|
||
3..=5 => 2,
|
||
6..=10 => 3,
|
||
_ => 5,
|
||
}
|
||
}
|
||
|
||
/// Clamp a buffer position so it stays within a wrapped line's range
|
||
/// `[line_start, line_end)`. Without this, `display_col_to_buffer_pos`
|
||
/// can return `line_end` when the column exceeds the line's display
|
||
/// width — and `line_end` equals the *next* wrapped line's start,
|
||
/// which confuses `effective_scroll` into thinking the cursor hasn't
|
||
/// actually moved to the target line.
|
||
///
|
||
/// Uses `self.text` to find the last valid char boundary inside the line
|
||
/// so we never land in the middle of a multi-byte character.
|
||
fn clamp_to_line(&self, pos: usize, line_start: usize, line_end: usize) -> usize {
|
||
if line_end > line_start {
|
||
// Find the start of the last character in the line.
|
||
let last_char_start = self.text[line_start..line_end]
|
||
.char_indices()
|
||
.next_back()
|
||
.map(|(i, _)| line_start + i)
|
||
.unwrap_or(line_start);
|
||
pos.min(last_char_start)
|
||
} else {
|
||
line_start
|
||
}
|
||
}
|
||
|
||
pub fn new() -> Self {
|
||
Self {
|
||
text: String::new(),
|
||
cursor_pos: 0,
|
||
wrap_cache: RefCell::new(None),
|
||
preferred_col: None,
|
||
elements: Vec::new(),
|
||
next_element_id: 0,
|
||
kill_buffer: String::new(),
|
||
undo: UndoState::default(),
|
||
selection: None,
|
||
clipboard_provider: Box::new(InternalClipboard::default()),
|
||
clipboard: None,
|
||
keep_selection_after_mouseup: true,
|
||
selection_style: Style::default()
|
||
.bg(Color::Rgb(49, 62, 115))
|
||
.fg(Color::Rgb(192, 202, 245)),
|
||
mouse_down_pos: None,
|
||
drag_anchor: None,
|
||
drag_active: false,
|
||
last_drag_scroll: None,
|
||
drag_scroll_steps: 0,
|
||
pending_drag_scroll: None,
|
||
click_tracker: ClickTracker::default(),
|
||
scroll_override: None,
|
||
show_scrollbar: true,
|
||
scrollbar_track_style: Style::default().bg(Color::Rgb(32, 35, 53)),
|
||
scrollbar_thumb_style: Style::default()
|
||
.fg(Color::Rgb(42, 46, 65))
|
||
.bg(Color::Rgb(32, 35, 53)),
|
||
scrollbar_padding: 0,
|
||
scrollbar_dragging: false,
|
||
hovered_element: None,
|
||
pending_element_event: None,
|
||
tab_width: 4,
|
||
}
|
||
}
|
||
|
||
/// Columns per tab for display width and tab→space expansion (`0` = passthrough).
|
||
pub fn tab_width(&self) -> u8 {
|
||
self.tab_width
|
||
}
|
||
|
||
/// Set columns per tab. Also controls expansion on insert/`set_text`/`replace_range`.
|
||
pub fn set_tab_width(&mut self, tab_width: u8) {
|
||
if self.tab_width != tab_width {
|
||
self.tab_width = tab_width;
|
||
self.wrap_cache.replace(None);
|
||
}
|
||
}
|
||
|
||
/// Expand `\t` to `tab_width` spaces (scrollback-compatible fixed width).
|
||
/// `tab_width == 0` or no tabs → borrowed input.
|
||
///
|
||
/// Public because it is the exact transform every insert path applies
|
||
/// (see [`insert_str`](Self::insert_str) /
|
||
/// [`insert_element`](Self::insert_element)), letting hosts canonicalize
|
||
/// external text before comparing it against buffer content.
|
||
pub fn expand_tabs<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
|
||
expand_tabs_with_width(text, self.tab_width)
|
||
}
|
||
|
||
/// Display width of plain buffer text, treating tabs as `tab_width` columns.
|
||
fn plain_display_width(&self, text: &str) -> usize {
|
||
plain_display_width_with_tab(text, self.tab_width)
|
||
}
|
||
|
||
/// Display width of a single grapheme cluster (tab uses `tab_width`).
|
||
fn grapheme_display_width(&self, grapheme: &str) -> usize {
|
||
grapheme_display_width_with_tab(grapheme, self.tab_width)
|
||
}
|
||
|
||
pub fn set_text(&mut self, text: &str) {
|
||
self.pre_mutate(MutationKind::Replace);
|
||
self.text = self.expand_tabs(text).into_owned();
|
||
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
|
||
self.wrap_cache.replace(None);
|
||
self.preferred_col = None;
|
||
self.elements.clear();
|
||
// Kill buffer intentionally survives: yank is independent of buffer
|
||
// content, so a cut can be pasted into a fresh prompt after send.
|
||
self.selection = None;
|
||
self.mouse_down_pos = None;
|
||
self.drag_anchor = None;
|
||
self.drag_active = false;
|
||
self.last_drag_scroll = None;
|
||
self.drag_scroll_steps = 0;
|
||
self.pending_drag_scroll = None;
|
||
self.click_tracker = ClickTracker::default();
|
||
self.scroll_override = None;
|
||
self.scrollbar_dragging = false;
|
||
self.hovered_element = None;
|
||
self.pending_element_event = None;
|
||
self.post_mutate();
|
||
}
|
||
|
||
pub fn text(&self) -> &str {
|
||
&self.text
|
||
}
|
||
|
||
pub fn insert_str(&mut self, text: &str) {
|
||
if text.is_empty() {
|
||
return;
|
||
}
|
||
self.scroll_override = None;
|
||
// Word boundary: break the insert batch when char class changes (ws↔non-ws).
|
||
if let Some(first) = text.chars().next() {
|
||
let first_ws = first.is_whitespace();
|
||
if self.undo.last_kind == Some(MutationKind::Insert)
|
||
&& self.undo.last_insert_ws != first_ws
|
||
{
|
||
// Force pre_mutate to see a "kind change" so it pushes a checkpoint.
|
||
self.undo.last_kind = None;
|
||
}
|
||
}
|
||
self.pre_mutate(MutationKind::Insert);
|
||
self.insert_str_inner(self.cursor_pos, text);
|
||
if let Some(last) = text.chars().last() {
|
||
self.undo.last_insert_ws = last.is_whitespace();
|
||
}
|
||
self.post_mutate();
|
||
}
|
||
|
||
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
|
||
if text.is_empty() {
|
||
return;
|
||
}
|
||
self.pre_mutate(MutationKind::Insert);
|
||
self.insert_str_inner(pos, text);
|
||
if let Some(last) = text.chars().last() {
|
||
self.undo.last_insert_ws = last.is_whitespace();
|
||
}
|
||
self.post_mutate();
|
||
}
|
||
|
||
/// Raw text insertion — no undo tracking. Called by all insert paths.
|
||
/// Returns the number of bytes actually inserted (post tab expansion).
|
||
fn insert_str_inner(&mut self, pos: usize, text: &str) -> usize {
|
||
if text.is_empty() {
|
||
return 0;
|
||
}
|
||
let pos = self.clamp_pos_for_insertion(pos);
|
||
let text = self.expand_tabs(text);
|
||
let inserted_len = text.len();
|
||
self.text.insert_str(pos, &text);
|
||
self.wrap_cache.replace(None);
|
||
if pos <= self.cursor_pos {
|
||
self.cursor_pos += inserted_len;
|
||
}
|
||
self.shift_elements(pos, 0, inserted_len);
|
||
self.preferred_col = None;
|
||
inserted_len
|
||
}
|
||
|
||
pub fn replace_range(&mut self, range: std::ops::Range<usize>, text: &str) {
|
||
self.pre_mutate(MutationKind::Replace);
|
||
let range = self.expand_range_to_element_boundaries(range);
|
||
self.replace_range_raw(range, text);
|
||
self.post_mutate();
|
||
}
|
||
|
||
/// Returns the number of bytes inserted (post tab expansion).
|
||
fn replace_range_raw(&mut self, range: std::ops::Range<usize>, text: &str) -> usize {
|
||
assert!(range.start <= range.end);
|
||
let start = range.start.clamp(0, self.text.len());
|
||
let end = range.end.clamp(0, self.text.len());
|
||
let text = self.expand_tabs(text);
|
||
let removed_len = end - start;
|
||
let inserted_len = text.len();
|
||
if removed_len == 0 && inserted_len == 0 {
|
||
return 0;
|
||
}
|
||
// Any text mutation should snap the viewport back to follow the cursor.
|
||
self.scroll_override = None;
|
||
let diff = inserted_len as isize - removed_len as isize;
|
||
|
||
self.text.replace_range(start..end, &text);
|
||
self.wrap_cache.replace(None);
|
||
self.preferred_col = None;
|
||
self.update_elements_after_replace(start, end, inserted_len);
|
||
|
||
// Update the cursor position to account for the edit.
|
||
self.cursor_pos = if self.cursor_pos < start {
|
||
// Cursor was before the edited range – no shift.
|
||
self.cursor_pos
|
||
} else if self.cursor_pos <= end {
|
||
// Cursor was inside the replaced range – move to end of the new text.
|
||
start + inserted_len
|
||
} else {
|
||
// Cursor was after the replaced range – shift by the length diff.
|
||
((self.cursor_pos as isize) + diff) as usize
|
||
}
|
||
.min(self.text.len());
|
||
|
||
// Ensure cursor is not inside an element
|
||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||
inserted_len
|
||
}
|
||
|
||
pub fn cursor(&self) -> usize {
|
||
self.cursor_pos
|
||
}
|
||
|
||
pub fn set_cursor(&mut self, pos: usize) {
|
||
self.cursor_pos = pos.clamp(0, self.text.len());
|
||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||
self.preferred_col = None;
|
||
self.scroll_override = None;
|
||
}
|
||
|
||
/// Override the scroll position, bypassing cursor-follow logic.
|
||
///
|
||
/// When set to `Some(offset)`, `effective_scroll` will use this offset
|
||
/// instead of ensuring the cursor is visible. Useful for forcing a
|
||
/// specific viewport (e.g., scroll-to-top when the textarea is collapsed
|
||
/// and unfocused). Set to `None` to restore normal cursor-following.
|
||
///
|
||
/// Note: unlike the internal scroll_override set by mousewheel events,
|
||
/// this is NOT cleared by cursor movement — it persists until explicitly
|
||
/// cleared by the caller.
|
||
pub fn set_scroll_override(&mut self, scroll: Option<u16>) {
|
||
self.scroll_override = scroll;
|
||
}
|
||
|
||
/// Current scroll override value (if any).
|
||
pub fn scroll_override(&self) -> Option<u16> {
|
||
self.scroll_override
|
||
}
|
||
|
||
pub fn desired_height(&self, width: u16) -> u16 {
|
||
self.wrapped_lines(width).len() as u16
|
||
}
|
||
|
||
#[cfg_attr(not(test), allow(dead_code))]
|
||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||
self.cursor_pos_with_state(area, TextAreaState::default())
|
||
}
|
||
|
||
/// Compute the on-screen cursor position taking scrolling into account.
|
||
///
|
||
/// Returns `None` if the cursor is not visible in the current viewport
|
||
/// (e.g. the user scrolled the viewport away from the cursor via mousewheel).
|
||
///
|
||
/// Unlike [`screen_position_of`], this applies a wrap-boundary adjustment:
|
||
/// when the cursor sits at the exact wrap boundary (col == content width),
|
||
/// it is shown at the start of the next visual line instead of on the
|
||
/// invisible right border.
|
||
pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> {
|
||
let tw = self.text_width(area);
|
||
let lines = self.wrapped_lines(tw);
|
||
let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||
let mut i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?;
|
||
let ls = &lines[i];
|
||
let mut col = self.display_width_of_range(ls.start, self.cursor_pos) as u16;
|
||
|
||
// If the cursor sits at the exact wrap boundary (col == content width),
|
||
// show it at the start of the next visual line instead of on the
|
||
// invisible right border. When the cursor is at text.len() and the
|
||
// last line is exactly full, there is no next wrapped line — but we
|
||
// still want the cursor on a new row at column 0.
|
||
if col >= tw {
|
||
i += 1;
|
||
col = 0;
|
||
}
|
||
|
||
// If the cursor's visual line is outside the visible viewport, hide it.
|
||
let scroll = effective_scroll as usize;
|
||
if i < scroll || i >= scroll + area.height as usize {
|
||
return None;
|
||
}
|
||
|
||
let screen_row = (i - scroll) as u16;
|
||
Some((area.x + col, area.y + screen_row))
|
||
}
|
||
|
||
/// Compute the on-screen position of an arbitrary buffer byte offset.
|
||
///
|
||
/// Returns `None` if the position is outside the visible viewport.
|
||
/// Does not apply cursor-specific wrap-boundary adjustments — see
|
||
/// [`cursor_pos_with_state`] for cursor positioning.
|
||
pub fn screen_position_of(
|
||
&self,
|
||
pos: usize,
|
||
area: Rect,
|
||
state: TextAreaState,
|
||
) -> Option<(u16, u16)> {
|
||
let tw = self.text_width(area);
|
||
let lines = self.wrapped_lines(tw);
|
||
let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||
let i = Self::wrapped_line_index_by_start(&lines, pos)?;
|
||
let ls = &lines[i];
|
||
let col = self.display_width_of_range(ls.start, pos) as u16;
|
||
|
||
let scroll = effective_scroll as usize;
|
||
if i < scroll || i >= scroll + area.height as usize {
|
||
return None;
|
||
}
|
||
|
||
let screen_row = (i - scroll) as u16;
|
||
Some((area.x + col, area.y + screen_row))
|
||
}
|
||
|
||
/// Compute the on-screen cells covered by a buffer byte range.
|
||
///
|
||
/// A soft-wrapped range can cross visual rows, so unlike
|
||
/// [`screen_position_of`] this returns one height-1 [`Rect`] per visual
|
||
/// row the range intersects, top to bottom, clamped to the content
|
||
/// region (`text_width` columns — excludes any scrollbar column). Rows
|
||
/// scrolled outside the viewport are skipped, so a partially visible
|
||
/// range yields only its visible rows. Bytes belonging to no row (a
|
||
/// `\n`, or whitespace dropped at a wrap boundary) are not covered;
|
||
/// trailing spaces kept on a row are. Ranges that are empty, extend
|
||
/// past the text, or have non-char-boundary endpoints yield no spans.
|
||
pub fn screen_spans_of_range(
|
||
&self,
|
||
range: Range<usize>,
|
||
area: Rect,
|
||
state: TextAreaState,
|
||
) -> Vec<Rect> {
|
||
let mut spans = Vec::new();
|
||
if range.start >= range.end
|
||
|| range.end > self.text.len()
|
||
|| !self.text.is_char_boundary(range.start)
|
||
|| !self.text.is_char_boundary(range.end)
|
||
{
|
||
return spans;
|
||
}
|
||
let tw = self.text_width(area);
|
||
let lines = self.wrapped_lines(tw);
|
||
let scroll = self.effective_scroll(area.height, &lines, state.scroll) as usize;
|
||
// Rows before the one containing `range.start` cannot intersect;
|
||
// `None` (start ahead of the first row) falls back to scanning all.
|
||
let first = Self::wrapped_line_index_by_start(&lines, range.start).unwrap_or(0);
|
||
// Rendered content stops at `tw` columns; a row's trailing wrap
|
||
// spaces can measure wider, so clamp to the content edge, not the
|
||
// full area (whose last column may hold the scrollbar).
|
||
let right_edge = area.x.saturating_add(tw);
|
||
for (i, ls) in lines.iter().enumerate().skip(first) {
|
||
if ls.start >= range.end {
|
||
break;
|
||
}
|
||
if i < scroll {
|
||
continue;
|
||
}
|
||
if i >= scroll + area.height as usize {
|
||
break;
|
||
}
|
||
let seg_start = range.start.max(ls.start);
|
||
let seg_end = range.end.min(ls.end);
|
||
if seg_start >= seg_end {
|
||
continue;
|
||
}
|
||
let start_x = area
|
||
.x
|
||
.saturating_add(self.display_width_of_range(ls.start, seg_start) as u16)
|
||
.min(right_edge);
|
||
let end_x = area
|
||
.x
|
||
.saturating_add(self.display_width_of_range(ls.start, seg_end) as u16)
|
||
.min(right_edge);
|
||
if start_x < end_x {
|
||
spans.push(Rect {
|
||
x: start_x,
|
||
y: area.y + (i - scroll) as u16,
|
||
width: end_x - start_x,
|
||
height: 1,
|
||
});
|
||
}
|
||
}
|
||
spans
|
||
}
|
||
|
||
/// Map screen coordinates `(col, row)` to a buffer byte position.
|
||
///
|
||
/// Returns `None` if `(col, row)` is outside the textarea `area`.
|
||
///
|
||
/// Edge cases:
|
||
/// - Click past end of a wrapped line → snaps to line end.
|
||
/// - Click below all text → snaps to `text.len()`.
|
||
/// - Click on an element → snaps to nearest element boundary (start or end).
|
||
pub fn buffer_pos_at_screen(
|
||
&self,
|
||
col: u16,
|
||
row: u16,
|
||
area: Rect,
|
||
state: TextAreaState,
|
||
) -> Option<usize> {
|
||
// Outside the textarea area → None.
|
||
if col < area.x || col >= area.x + area.width || row < area.y || row >= area.y + area.height
|
||
{
|
||
return None;
|
||
}
|
||
|
||
let tw = self.text_width(area);
|
||
let lines = self.wrapped_lines(tw);
|
||
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||
|
||
let visual_row = (row - area.y) as usize + scroll as usize;
|
||
|
||
// Below all text → end of text.
|
||
if visual_row >= lines.len() {
|
||
return Some(self.text.len());
|
||
}
|
||
|
||
let line = &lines[visual_row];
|
||
let target_col = (col - area.x) as usize;
|
||
// Clamp line.end to text length (safety measure for edge cases).
|
||
let line_end = line.end.min(self.text.len());
|
||
Some(
|
||
self.display_col_to_buffer_pos(line.start, line_end, target_col)
|
||
.0,
|
||
)
|
||
}
|
||
|
||
/// Like `buffer_pos_at_screen` but also indicates whether the column
|
||
/// fell on an element's display region.
|
||
fn buffer_pos_at_screen_ex(
|
||
&self,
|
||
col: u16,
|
||
row: u16,
|
||
area: Rect,
|
||
state: TextAreaState,
|
||
) -> Option<(usize, bool)> {
|
||
if col < area.x || row < area.y {
|
||
return None;
|
||
}
|
||
|
||
let tw = self.text_width(area);
|
||
let lines = self.wrapped_lines(tw);
|
||
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||
|
||
let visual_row = (row - area.y) as usize + scroll as usize;
|
||
|
||
if visual_row >= lines.len() {
|
||
return Some((self.text.len(), false));
|
||
}
|
||
|
||
let line = &lines[visual_row];
|
||
let target_col = (col - area.x) as usize;
|
||
let line_end = line.end.min(self.text.len());
|
||
Some(self.display_col_to_buffer_pos(line.start, line_end, target_col))
|
||
}
|
||
|
||
/// Return the element at screen coordinates, if any.
|
||
///
|
||
/// Uses `buffer_pos_at_screen` to find the buffer position, then checks
|
||
/// whether that position falls inside an element.
|
||
pub fn element_at_screen(
|
||
&self,
|
||
col: u16,
|
||
row: u16,
|
||
area: Rect,
|
||
state: TextAreaState,
|
||
) -> Option<&TextElement> {
|
||
let (pos, hit_element) = self.buffer_pos_at_screen_ex(col, row, area, state)?;
|
||
if hit_element {
|
||
// hit_element means the column fell on an element's display.
|
||
// pos may be elem start or elem end — match either.
|
||
self.elements
|
||
.iter()
|
||
.find(|e| pos >= e.range.start && pos <= e.range.end && !e.range.is_empty())
|
||
} else {
|
||
self.elements
|
||
.iter()
|
||
.find(|e| pos >= e.range.start && pos < e.range.end)
|
||
}
|
||
}
|
||
|
||
// ── Selection API ──
|
||
|
||
/// Normalized selection range, expanded to element boundaries.
|
||
///
|
||
/// Returns `None` if no selection is active or anchor == head (empty).
|
||
pub fn selection_range(&self) -> Option<Range<usize>> {
|
||
let sel = self.selection?;
|
||
if sel.anchor == sel.head {
|
||
return None;
|
||
}
|
||
let start = sel.anchor.min(sel.head);
|
||
let end = sel.anchor.max(sel.head);
|
||
let expanded = self.expand_range_to_element_boundaries(start..end);
|
||
let clamped_start = expanded.start.min(self.text.len());
|
||
let clamped_end = expanded.end.min(self.text.len());
|
||
if clamped_start >= clamped_end {
|
||
None
|
||
} else {
|
||
Some(clamped_start..clamped_end)
|
||
}
|
||
}
|
||
|
||
/// Text within the current selection (buffer text, not display text).
|
||
pub fn selected_text(&self) -> Option<String> {
|
||
let range = self.selection_range()?;
|
||
Some(self.text[range].to_string())
|
||
}
|
||
|
||
/// Clear the selection without affecting the clipboard.
|
||
pub fn clear_selection(&mut self) {
|
||
self.selection = None;
|
||
}
|
||
|
||
/// Delete the selected range (if any). Returns `true` if text was deleted.
|
||
///
|
||
/// This is a single undo step. After deletion, the cursor is placed at
|
||
/// the start of the deleted range and the selection is cleared.
|
||
pub fn delete_selection(&mut self) -> bool {
|
||
let Some(range) = self.selection_range() else {
|
||
return false;
|
||
};
|
||
let start = range.start;
|
||
self.pre_mutate(MutationKind::Replace);
|
||
self.replace_range_raw(range, "");
|
||
self.cursor_pos = start.min(self.text.len());
|
||
self.post_mutate();
|
||
self.selection = None;
|
||
true
|
||
}
|
||
|
||
/// Set the selection programmatically.
|
||
pub fn set_selection(&mut self, anchor: usize, head: usize) {
|
||
self.selection = Some(Selection { anchor, head });
|
||
}
|
||
|
||
/// Take the clipboard contents (returns `None` if empty).
|
||
///
|
||
/// This is the primary way for the host app to retrieve text
|
||
/// that was selected by mouse drag / double-click / triple-click.
|
||
pub fn take_clipboard(&mut self) -> Option<String> {
|
||
self.clipboard.take()
|
||
}
|
||
|
||
/// Peek at the current clipboard content without consuming it.
|
||
pub fn clipboard(&self) -> Option<&str> {
|
||
self.clipboard.as_deref()
|
||
}
|
||
|
||
/// Replace the clipboard provider. The default is [`InternalClipboard`]
|
||
/// (in-memory only). Pass an `arboard`-backed implementation to sync
|
||
/// copy/cut/paste with the system clipboard.
|
||
pub fn set_clipboard_provider(&mut self, provider: Box<dyn ClipboardProvider>) {
|
||
self.clipboard_provider = provider;
|
||
}
|
||
|
||
// ── Element events ──
|
||
|
||
/// Take the pending [`TextElementEvent`], if any.
|
||
///
|
||
/// Call this after [`handle_mouse`](Self::handle_mouse) to check whether
|
||
/// an element was clicked or hover-entered/left.
|
||
pub fn poll_element_event(&mut self) -> Option<TextElementEvent> {
|
||
self.pending_element_event.take()
|
||
}
|
||
|
||
/// Internal: set clipboard text via the provider AND the notification field.
|
||
fn set_clipboard_text(&mut self, text: String) {
|
||
if !text.is_empty() {
|
||
self.clipboard_provider.set(&text);
|
||
self.clipboard = Some(text);
|
||
}
|
||
}
|
||
|
||
// ── Timers / tick ──
|
||
|
||
/// Recommended poll timeout for the host event loop.
|
||
///
|
||
/// When the textarea has pending timer-driven work (e.g. continuous
|
||
/// drag-scrolling while the mouse is held outside the area), this
|
||
/// returns `Some(ms)`. The host should use this as the
|
||
/// `event::poll` timeout. When the poll times out without an event,
|
||
/// call [`tick`](Self::tick).
|
||
///
|
||
/// Returns `None` when no timer work is pending — the host can use
|
||
/// its own default timeout.
|
||
pub fn poll_timeout_ms(&self) -> Option<u64> {
|
||
// Drag-scroll is the only timer-driven feature for now.
|
||
self.pending_drag_scroll.as_ref()?;
|
||
let interval = Self::drag_scroll_interval(self.drag_scroll_steps);
|
||
Some(interval as u64)
|
||
}
|
||
|
||
/// Advance timer-driven work (called by the host when `poll` times
|
||
/// out). Returns a `MouseAction` describing what changed (typically
|
||
/// `SelectionUpdated` for drag-scroll, or `Nothing`).
|
||
pub fn tick(&mut self, area: Rect, state: TextAreaState) -> MouseAction {
|
||
// Drag-scroll continuation.
|
||
if let Some(event) = self.pending_drag_scroll {
|
||
return self.handle_mouse(event, area, state);
|
||
}
|
||
MouseAction::Nothing
|
||
}
|
||
|
||
// ── Mouse ──
|
||
|
||
/// Shared single/double-click treatment of a click that landed on an
|
||
/// element display (`hit_element`): snap the cursor to the element
|
||
/// start, anchor drags there, and emit [`TextElementEventKind::Click`].
|
||
///
|
||
/// Returns `None` when the click was not on an element.
|
||
fn element_click_snap(&mut self, pos: usize, hit_element: bool) -> Option<MouseAction> {
|
||
if !hit_element {
|
||
return None;
|
||
}
|
||
let elem = self
|
||
.elements
|
||
.iter()
|
||
.find(|e| pos >= e.range.start && pos <= e.range.end && !e.range.is_empty())?;
|
||
let id = elem.id;
|
||
let start = elem.range.start;
|
||
self.cursor_pos = start;
|
||
self.preferred_col = None;
|
||
self.drag_anchor = Some(start);
|
||
self.pending_element_event = Some(TextElementEvent {
|
||
id,
|
||
kind: TextElementEventKind::Click,
|
||
});
|
||
Some(MouseAction::CursorPlaced)
|
||
}
|
||
|
||
/// Process a crossterm `MouseEvent` and return what happened.
|
||
///
|
||
/// The host app is expected to call this from its event loop for
|
||
/// every `Event::Mouse(mouse)` and pass the textarea's render `area`
|
||
/// plus the current `TextAreaState` (for scroll info).
|
||
pub fn handle_mouse(
|
||
&mut self,
|
||
event: MouseEvent,
|
||
area: Rect,
|
||
state: TextAreaState,
|
||
) -> MouseAction {
|
||
// ── Scrollbar interaction ──
|
||
// When scrollbar is shown, clicks/drags on the rightmost column
|
||
// control the scroll position instead of placing the cursor.
|
||
let tw = self.text_width(area);
|
||
let has_scrollbar = self.show_scrollbar && tw < area.width;
|
||
let on_scrollbar = has_scrollbar && event.column == area.x + area.width - 1;
|
||
|
||
// Handle scrollbar drag continuation (even if pointer moved off the column).
|
||
if self.scrollbar_dragging {
|
||
match event.kind {
|
||
MouseEventKind::Drag(MouseButton::Left)
|
||
| MouseEventKind::Down(MouseButton::Left) => {
|
||
return self.handle_scrollbar_click(event.row, area, tw);
|
||
}
|
||
MouseEventKind::Up(MouseButton::Left) => {
|
||
self.scrollbar_dragging = false;
|
||
return MouseAction::Scrolled;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
if on_scrollbar && let MouseEventKind::Down(MouseButton::Left) = event.kind {
|
||
self.scrollbar_dragging = true;
|
||
// If the click is on the thumb, don't jump — just start the drag
|
||
// from the current position. Only jump when clicking the track.
|
||
if self.is_scrollbar_thumb_at(event.row, area, tw) {
|
||
return MouseAction::Scrolled;
|
||
}
|
||
return self.handle_scrollbar_click(event.row, area, tw);
|
||
}
|
||
|
||
match event.kind {
|
||
MouseEventKind::Down(MouseButton::Left) => {
|
||
// Some terminals re-emit Down(Left) after a scroll event
|
||
// even though the button was held the whole time. When a
|
||
// drag is already active, treat this as a drag continuation
|
||
// so the selection anchor is preserved.
|
||
if self.drag_active {
|
||
return self.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
..event
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
}
|
||
|
||
let col = event.column;
|
||
let row = event.row;
|
||
|
||
// Track multi-click (double/triple).
|
||
let click_count = self.click_tracker.register(col, row);
|
||
|
||
// Record the mouse-down position (for drag detection).
|
||
self.mouse_down_pos = Some((col, row));
|
||
self.drag_active = false;
|
||
self.last_drag_scroll = None;
|
||
self.drag_scroll_steps = 0;
|
||
self.pending_drag_scroll = None;
|
||
|
||
// Clear any existing selection.
|
||
self.clear_selection();
|
||
|
||
// Map screen coordinates to buffer position.
|
||
// IMPORTANT: this must happen BEFORE clearing scroll_override
|
||
// so that effective_scroll uses the current viewport, not the
|
||
// cursor-following fallback.
|
||
let Some((pos, hit_element)) = self.buffer_pos_at_screen_ex(col, row, area, state)
|
||
else {
|
||
self.scroll_override = None;
|
||
self.drag_anchor = None;
|
||
return MouseAction::Nothing;
|
||
};
|
||
|
||
// Now that we have the correct buffer position, clear the
|
||
// scroll override so the viewport follows the cursor again.
|
||
self.scroll_override = None;
|
||
|
||
match click_count {
|
||
2 => {
|
||
// Double-click on an element display: snap like a
|
||
// single click (cursor to element start + Click
|
||
// event). Word-selecting would select and copy the
|
||
// element's hidden buffer text to the clipboard;
|
||
// the host decides what a chip double-click means.
|
||
// Triple-click line-select below intentionally keeps
|
||
// buffer-text semantics, element content included —
|
||
// a copy gesture, like drag-select across a chip.
|
||
if let Some(action) = self.element_click_snap(pos, hit_element) {
|
||
return action;
|
||
}
|
||
// Double-click: select word under cursor.
|
||
// Whitespace clicks just place the cursor (no selection).
|
||
let is_ws = pos < self.text.len()
|
||
&& self.text[pos..]
|
||
.chars()
|
||
.next()
|
||
.is_none_or(|ch| ch.is_whitespace());
|
||
let start = self.word_start_at(pos);
|
||
let end = self.word_end_at(pos);
|
||
if !is_ws && start < end {
|
||
self.selection = Some(Selection {
|
||
anchor: start,
|
||
head: end,
|
||
});
|
||
// Place cursor on the last character of the
|
||
// selection (neovim style), not one past the end.
|
||
self.cursor_pos = self.text[start..end]
|
||
.char_indices()
|
||
.next_back()
|
||
.map(|(i, _)| start + i)
|
||
.unwrap_or(start);
|
||
self.preferred_col = None;
|
||
if let Some(text) = self.selected_text() {
|
||
self.set_clipboard_text(text);
|
||
}
|
||
return MouseAction::SelectionFinished;
|
||
}
|
||
// Clicked on whitespace — just place cursor.
|
||
self.cursor_pos = pos;
|
||
self.preferred_col = None;
|
||
MouseAction::CursorPlaced
|
||
}
|
||
3 => {
|
||
// Triple-click: select entire source line (\n-delimited).
|
||
let line_start = self.beginning_of_line(pos);
|
||
// Include the trailing \n if present.
|
||
let line_end_excl = self.end_of_line(pos);
|
||
let line_end = if line_end_excl < self.text.len() {
|
||
line_end_excl + 1 // include \n
|
||
} else {
|
||
line_end_excl
|
||
};
|
||
self.selection = Some(Selection {
|
||
anchor: line_start,
|
||
head: line_end,
|
||
});
|
||
// Keep cursor at the click position (like neovim),
|
||
// not at the end of the selection.
|
||
self.cursor_pos = pos;
|
||
self.preferred_col = None;
|
||
if let Some(text) = self.selected_text() {
|
||
self.set_clipboard_text(text);
|
||
}
|
||
MouseAction::SelectionFinished
|
||
}
|
||
_ => {
|
||
// Single click: place cursor.
|
||
//
|
||
// If click landed on an element display, snap cursor
|
||
// to elem start. `hit_element` is reliable because
|
||
// display_col_to_buffer_pos sets it when the column
|
||
// falls within an element's visual width.
|
||
if let Some(action) = self.element_click_snap(pos, hit_element) {
|
||
return action;
|
||
}
|
||
|
||
self.drag_anchor = Some(pos);
|
||
self.cursor_pos = pos;
|
||
self.preferred_col = None;
|
||
|
||
MouseAction::CursorPlaced
|
||
}
|
||
}
|
||
}
|
||
MouseEventKind::Drag(MouseButton::Left) => {
|
||
let Some(anchor) = self.drag_anchor else {
|
||
return MouseAction::Nothing;
|
||
};
|
||
|
||
// Compute the buffer position for the drag endpoint.
|
||
// We need to scope the `lines` borrow so it's dropped before
|
||
// we mutate self.
|
||
|
||
// Throttle drag-scroll (above/below area) to avoid
|
||
// lightning-fast scrolling at mouse-report rate.
|
||
// Acceleration: first step waits 80ms, then 60ms, then 40ms.
|
||
let outside_area = event.row < area.y || event.row >= area.y + area.height;
|
||
if outside_area {
|
||
// Store event for continuous drag-scroll re-triggering.
|
||
self.pending_drag_scroll = Some(event);
|
||
|
||
let now = Instant::now();
|
||
let interval = Self::drag_scroll_interval(self.drag_scroll_steps);
|
||
if let Some(last) = self.last_drag_scroll
|
||
&& now.duration_since(last).as_millis() < interval
|
||
{
|
||
return MouseAction::Nothing;
|
||
}
|
||
self.last_drag_scroll = Some(now);
|
||
self.drag_scroll_steps = self.drag_scroll_steps.saturating_add(1);
|
||
} else {
|
||
// Back inside area — cancel continuous drag-scroll.
|
||
self.pending_drag_scroll = None;
|
||
}
|
||
|
||
let (head, new_scroll) = {
|
||
let tw = self.text_width(area);
|
||
let lines = self.wrapped_lines(tw);
|
||
let scroll = self.effective_scroll(area.height, &lines, state.scroll) as usize;
|
||
let visible_end = scroll + area.height as usize;
|
||
|
||
if event.row < area.y {
|
||
// ── Dragging above the area → scroll up ──
|
||
let dist = area.y - event.row;
|
||
let n = Self::drag_scroll_lines_for_distance(dist);
|
||
let target_line = scroll.saturating_sub(n);
|
||
let pos = if target_line < lines.len() {
|
||
let col = event.column.saturating_sub(area.x) as usize;
|
||
let line = &lines[target_line];
|
||
let line_end = line.end.min(self.text.len());
|
||
let p = self.display_col_to_buffer_pos(line.start, line_end, col).0;
|
||
self.clamp_to_line(p, line.start, line_end)
|
||
} else {
|
||
0
|
||
};
|
||
(pos, Some(target_line as u16))
|
||
} else if event.row >= area.y + area.height {
|
||
// ── Dragging below the area → scroll down ──
|
||
let dist = event.row - (area.y + area.height) + 1;
|
||
let n = Self::drag_scroll_lines_for_distance(dist);
|
||
let target_line = (visible_end + n - 1).min(lines.len().saturating_sub(1));
|
||
let max_scroll = lines.len().saturating_sub(area.height as usize);
|
||
let new_scroll = (target_line + 1)
|
||
.saturating_sub(area.height as usize)
|
||
.min(max_scroll);
|
||
let pos = if target_line < lines.len() {
|
||
let col = event.column.saturating_sub(area.x) as usize;
|
||
let line = &lines[target_line];
|
||
let line_end = line.end.min(self.text.len());
|
||
let pos = self.display_col_to_buffer_pos(line.start, line_end, col).0;
|
||
self.clamp_to_line(pos, line.start, line_end)
|
||
} else {
|
||
self.text.len()
|
||
};
|
||
(pos, Some(new_scroll as u16))
|
||
} else {
|
||
// ── Within the area → normal drag ──
|
||
let col = event.column.clamp(area.x, area.x + tw.saturating_sub(1));
|
||
let row = event.row;
|
||
drop(lines); // release borrow for buffer_pos_at_screen
|
||
match self.buffer_pos_at_screen(col, row, area, state) {
|
||
Some(pos) => (pos, None),
|
||
None => return MouseAction::Nothing,
|
||
}
|
||
}
|
||
};
|
||
|
||
if let Some(s) = new_scroll {
|
||
self.scroll_override = Some(s);
|
||
}
|
||
if head == anchor {
|
||
self.drag_active = false;
|
||
self.selection = None;
|
||
} else {
|
||
self.drag_active = true;
|
||
self.selection = Some(Selection { anchor, head });
|
||
}
|
||
self.cursor_pos = head;
|
||
self.preferred_col = None;
|
||
|
||
if self.selection.is_some() {
|
||
MouseAction::SelectionUpdated
|
||
} else {
|
||
MouseAction::CursorPlaced
|
||
}
|
||
}
|
||
MouseEventKind::Up(MouseButton::Left) => {
|
||
self.mouse_down_pos = None;
|
||
let was_drag = self.drag_active;
|
||
self.drag_active = false;
|
||
self.scrollbar_dragging = false;
|
||
self.pending_drag_scroll = None;
|
||
self.drag_anchor = None;
|
||
|
||
if was_drag {
|
||
// Discard zero-width selections (anchor == head) that arise
|
||
// from mouse jitter — they look like an active selection to
|
||
// the keyboard handler and silently swallow Backspace/Delete.
|
||
if self.selection_range().is_none() {
|
||
self.selection = None;
|
||
MouseAction::CursorPlaced
|
||
} else {
|
||
// Finalize selection: copy to clipboard.
|
||
if let Some(text) = self.selected_text()
|
||
&& !text.is_empty()
|
||
{
|
||
self.set_clipboard_text(text);
|
||
}
|
||
|
||
if !self.keep_selection_after_mouseup {
|
||
self.selection = None;
|
||
}
|
||
|
||
MouseAction::SelectionFinished
|
||
}
|
||
} else {
|
||
MouseAction::Nothing
|
||
}
|
||
}
|
||
MouseEventKind::ScrollDown => {
|
||
let tw = self.text_width(area);
|
||
let lines = self.wrapped_lines(tw);
|
||
let total = lines.len();
|
||
if total <= area.height as usize {
|
||
return MouseAction::Nothing;
|
||
}
|
||
let max_scroll = total.saturating_sub(area.height as usize) as u16;
|
||
let current = self
|
||
.scroll_override
|
||
.unwrap_or_else(|| self.effective_scroll(area.height, &lines, state.scroll));
|
||
let scroll_lines = Self::scroll_lines_for_height(area.height);
|
||
let new_scroll = (current + scroll_lines).min(max_scroll);
|
||
if new_scroll == current {
|
||
return MouseAction::Nothing;
|
||
}
|
||
// If dragging, extend the selection head to follow the scroll.
|
||
let drag_new_pos = if self.drag_active {
|
||
let target_line =
|
||
(new_scroll as usize + area.height as usize - 1).min(lines.len() - 1);
|
||
Some(lines[target_line].start)
|
||
} else {
|
||
None
|
||
};
|
||
drop(lines);
|
||
self.scroll_override = Some(new_scroll);
|
||
if let Some(new_pos) = drag_new_pos {
|
||
if let Some(sel) = &mut self.selection {
|
||
sel.head = new_pos;
|
||
}
|
||
self.cursor_pos = new_pos;
|
||
}
|
||
MouseAction::Scrolled
|
||
}
|
||
MouseEventKind::ScrollUp => {
|
||
let tw = self.text_width(area);
|
||
let lines = self.wrapped_lines(tw);
|
||
let total = lines.len();
|
||
if total <= area.height as usize {
|
||
return MouseAction::Nothing;
|
||
}
|
||
let current = self
|
||
.scroll_override
|
||
.unwrap_or_else(|| self.effective_scroll(area.height, &lines, state.scroll));
|
||
let scroll_lines = Self::scroll_lines_for_height(area.height);
|
||
let new_scroll = current.saturating_sub(scroll_lines);
|
||
if new_scroll == current {
|
||
return MouseAction::Nothing;
|
||
}
|
||
// If dragging, extend the selection head to follow the scroll.
|
||
let drag_new_pos = if self.drag_active {
|
||
let target_line = new_scroll as usize;
|
||
Some(if target_line < lines.len() {
|
||
lines[target_line].start
|
||
} else {
|
||
0
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
drop(lines);
|
||
self.scroll_override = Some(new_scroll);
|
||
if let Some(new_pos) = drag_new_pos {
|
||
if let Some(sel) = &mut self.selection {
|
||
sel.head = new_pos;
|
||
}
|
||
self.cursor_pos = new_pos;
|
||
}
|
||
MouseAction::Scrolled
|
||
}
|
||
MouseEventKind::Moved => {
|
||
// Hover detection: hit-test elements under the cursor.
|
||
let hovered_id = self
|
||
.element_at_screen(event.column, event.row, area, state)
|
||
.map(|e| e.id);
|
||
|
||
let prev = self.hovered_element;
|
||
if hovered_id != prev {
|
||
// Emit leave for the old element first, then enter for the new one.
|
||
// We only store the last event; if both happen, prefer enter
|
||
// (the caller already knows about the old element from a prior enter).
|
||
if let Some(old_id) = prev {
|
||
self.pending_element_event = Some(TextElementEvent {
|
||
id: old_id,
|
||
kind: TextElementEventKind::HoverLeave,
|
||
});
|
||
}
|
||
if let Some(new_id) = hovered_id {
|
||
self.pending_element_event = Some(TextElementEvent {
|
||
id: new_id,
|
||
kind: TextElementEventKind::HoverEnter,
|
||
});
|
||
}
|
||
self.hovered_element = hovered_id;
|
||
}
|
||
MouseAction::Nothing
|
||
}
|
||
_ => MouseAction::Nothing,
|
||
}
|
||
}
|
||
|
||
/// Handle a click or drag on the scrollbar track.
|
||
///
|
||
/// Maps the row position proportionally to a scroll offset:
|
||
/// clicking at the top of the track scrolls to the start, at the
|
||
/// bottom scrolls to the end.
|
||
fn handle_scrollbar_click(&mut self, row: u16, area: Rect, tw: u16) -> MouseAction {
|
||
if area.height == 0 {
|
||
return MouseAction::Nothing;
|
||
}
|
||
let total = {
|
||
let lines = self.wrapped_lines(tw);
|
||
lines.len()
|
||
};
|
||
if total <= area.height as usize {
|
||
return MouseAction::Nothing;
|
||
}
|
||
let max_scroll = total.saturating_sub(area.height as usize) as u16;
|
||
let rel_row = row.saturating_sub(area.y);
|
||
// Map relative row to a scroll offset proportionally.
|
||
let scroll = if area.height <= 1 {
|
||
0
|
||
} else {
|
||
((rel_row as u32 * max_scroll as u32) / (area.height.saturating_sub(1)) as u32) as u16
|
||
};
|
||
self.scroll_override = Some(scroll.min(max_scroll));
|
||
MouseAction::Scrolled
|
||
}
|
||
|
||
/// Check whether the given screen row falls on the scrollbar thumb.
|
||
///
|
||
/// Renders the scrollbar into a scratch buffer and checks whether the
|
||
/// cell at `row` is a non-space character (thumb glyph) or a space (track).
|
||
fn is_scrollbar_thumb_at(&self, row: u16, area: Rect, tw: u16) -> bool {
|
||
if area.height == 0 {
|
||
return false;
|
||
}
|
||
let total = {
|
||
let lines = self.wrapped_lines(tw);
|
||
lines.len()
|
||
};
|
||
if total <= area.height as usize {
|
||
return false;
|
||
}
|
||
let current_scroll = self.scroll_override.unwrap_or(0);
|
||
|
||
let lengths = ScrollLengths {
|
||
content_len: total,
|
||
viewport_len: area.height as usize,
|
||
};
|
||
let scrollbar = ScrollBar::vertical(lengths).offset(current_scroll as usize);
|
||
let sb_x = area.right().saturating_sub(1);
|
||
let core_area = CoreRect {
|
||
x: sb_x,
|
||
y: area.y,
|
||
width: 1,
|
||
height: area.height,
|
||
};
|
||
let mut scratch = CoreBuffer::empty(core_area);
|
||
(&scrollbar).render(core_area, &mut scratch);
|
||
|
||
if row < area.y || row >= area.y + area.height {
|
||
return false;
|
||
}
|
||
scratch[(sb_x, row)].symbol() != " "
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.text.is_empty()
|
||
}
|
||
|
||
fn is_word_char(ch: char) -> bool {
|
||
ch.is_alphanumeric() || ch == '_'
|
||
}
|
||
|
||
/// Classify a character into a word-class for double-click selection.
|
||
///
|
||
/// Three classes (matching vim/neovim `w` word definition):
|
||
/// - `0`: whitespace
|
||
/// - `1`: word chars (alphanumeric + underscore)
|
||
/// - `2`: punctuation / everything else
|
||
fn char_class(ch: char) -> u8 {
|
||
if ch.is_whitespace() {
|
||
0
|
||
} else if Self::is_word_char(ch) {
|
||
1
|
||
} else {
|
||
2
|
||
}
|
||
}
|
||
|
||
/// Classify the atomic unit in `start..end` for word navigation.
|
||
///
|
||
/// Elements are treated as their own navigable unit. Plain-text units use
|
||
/// the same word classes as double-click selection.
|
||
fn atomic_unit_class(&self, start: usize, end: usize) -> Option<u8> {
|
||
if start >= end {
|
||
return None;
|
||
}
|
||
|
||
if self
|
||
.elements
|
||
.iter()
|
||
.any(|e| start >= e.range.start && end <= e.range.end)
|
||
{
|
||
return Some(3);
|
||
}
|
||
|
||
self.text[start..end].chars().next().map(Self::char_class)
|
||
}
|
||
|
||
/// Find the start of the word containing `pos` (for double-click selection).
|
||
///
|
||
/// Uses vim-style word classes: word chars (alphanumeric + `_`), punctuation,
|
||
/// and whitespace are three distinct groups. Scans backward until the class
|
||
/// changes.
|
||
///
|
||
/// If `pos` is inside an element, returns the element start.
|
||
fn word_start_at(&self, pos: usize) -> usize {
|
||
// If inside an element, return element start.
|
||
if let Some(elem) = self
|
||
.elements
|
||
.iter()
|
||
.find(|e| pos >= e.range.start && pos < e.range.end)
|
||
{
|
||
return elem.range.start;
|
||
}
|
||
|
||
// Determine the class of the character at `pos` (or just before if at end).
|
||
let target_class = if pos < self.text.len() {
|
||
Self::char_class(self.text[pos..].chars().next().unwrap())
|
||
} else if pos > 0 {
|
||
let ch = self.text[..pos].chars().next_back().unwrap();
|
||
Self::char_class(ch)
|
||
} else {
|
||
return 0;
|
||
};
|
||
|
||
let before = &self.text[..pos];
|
||
let word_start = before
|
||
.char_indices()
|
||
.rev()
|
||
.find(|&(_, ch)| Self::char_class(ch) != target_class)
|
||
.map(|(idx, ch)| idx + ch.len_utf8())
|
||
.unwrap_or(0);
|
||
self.adjust_pos_out_of_elements(word_start, true)
|
||
}
|
||
|
||
/// Find the end of the word containing `pos` (for double-click selection).
|
||
///
|
||
/// Uses vim-style word classes (see [`Self::char_class`]).
|
||
///
|
||
/// If `pos` is inside an element, returns the element end.
|
||
fn word_end_at(&self, pos: usize) -> usize {
|
||
// If inside an element, return element end.
|
||
if let Some(elem) = self
|
||
.elements
|
||
.iter()
|
||
.find(|e| pos >= e.range.start && pos < e.range.end)
|
||
{
|
||
return elem.range.end;
|
||
}
|
||
|
||
// Determine the class of the character at `pos`.
|
||
let target_class = if pos < self.text.len() {
|
||
Self::char_class(self.text[pos..].chars().next().unwrap())
|
||
} else {
|
||
return self.text.len();
|
||
};
|
||
|
||
let after = &self.text[pos..];
|
||
let word_end = after
|
||
.char_indices()
|
||
.find(|&(_, ch)| Self::char_class(ch) != target_class)
|
||
.map(|(rel_idx, _)| pos + rel_idx)
|
||
.unwrap_or(self.text.len());
|
||
self.adjust_pos_out_of_elements(word_end, false)
|
||
}
|
||
|
||
fn current_display_col(&self) -> usize {
|
||
let bol = self.beginning_of_current_line();
|
||
self.display_width_of_range(bol, self.cursor_pos)
|
||
}
|
||
|
||
/// Compute the display width of the buffer range `[from..to)`.
|
||
///
|
||
/// Plain runs use tab-aware width (`tab_width` columns per `\t`, or
|
||
/// unicode-width when `tab_width == 0`). Element ranges with a custom
|
||
/// `display` use the element's display width instead of the buffer text
|
||
/// width. This is the core of the display projection system.
|
||
fn display_width_of_range(&self, from: usize, to: usize) -> usize {
|
||
if from >= to {
|
||
return 0;
|
||
}
|
||
let mut width = 0usize;
|
||
let mut pos = from;
|
||
|
||
for elem in &self.elements {
|
||
if elem.range.start >= to {
|
||
break; // elements are sorted, no more overlap possible
|
||
}
|
||
if elem.range.end <= pos {
|
||
continue; // element is entirely before our current position
|
||
}
|
||
|
||
// Plain text before this element
|
||
if pos < elem.range.start {
|
||
let plain_end = elem.range.start.min(to);
|
||
width += self.plain_display_width(&self.text[pos..plain_end]);
|
||
pos = plain_end;
|
||
}
|
||
if pos >= to {
|
||
break;
|
||
}
|
||
|
||
// Element region
|
||
let elem_start_in_range = elem.range.start.max(pos);
|
||
let elem_end_in_range = elem.range.end.min(to);
|
||
if elem_start_in_range < elem_end_in_range {
|
||
if let Some(display) = &elem.display {
|
||
// If the range covers the entire element (or starts at element start),
|
||
// use the full display width. If it covers only a partial overlap
|
||
// (cursor inside element — shouldn't happen normally), fall back to
|
||
// buffer text width.
|
||
if elem_start_in_range == elem.range.start {
|
||
let display_w: usize = display
|
||
.spans
|
||
.iter()
|
||
.map(|s| s.content.as_ref().width())
|
||
.sum();
|
||
width += display_w;
|
||
} else {
|
||
width += self.plain_display_width(
|
||
&self.text[elem_start_in_range..elem_end_in_range],
|
||
);
|
||
}
|
||
} else {
|
||
width += self
|
||
.plain_display_width(&self.text[elem_start_in_range..elem_end_in_range]);
|
||
}
|
||
pos = elem_end_in_range;
|
||
}
|
||
}
|
||
|
||
// Remaining plain text after all elements
|
||
if pos < to {
|
||
width += self.plain_display_width(&self.text[pos..to]);
|
||
}
|
||
|
||
width
|
||
}
|
||
|
||
fn wrapped_line_index_by_start(lines: &[Range<usize>], pos: usize) -> Option<usize> {
|
||
// partition_point returns the index of the first element for which
|
||
// the predicate is false, i.e. the count of elements with start <= pos.
|
||
let idx = lines.partition_point(|r| r.start <= pos);
|
||
if idx == 0 { None } else { Some(idx - 1) }
|
||
}
|
||
|
||
/// Map a display column to a buffer byte position on a given wrapped line.
|
||
///
|
||
/// Pure query — does not mutate any state. Handles elements (snapping to
|
||
/// nearest element boundary) and wide unicode graphemes.
|
||
/// If `target_col` is past the line's display width, returns `line_end`
|
||
/// (clamped to the nearest element boundary).
|
||
///
|
||
/// Returns `(byte_pos, hit_element)` where `hit_element` is `true` when
|
||
/// the column fell on an element's display region.
|
||
fn display_col_to_buffer_pos(
|
||
&self,
|
||
line_start: usize,
|
||
line_end: usize,
|
||
target_col: usize,
|
||
) -> (usize, bool) {
|
||
let mut width_so_far = 0usize;
|
||
let mut pos = line_start;
|
||
|
||
while pos < line_end {
|
||
// Check if pos is at or inside an element
|
||
if let Some(elem_idx) = self
|
||
.elements
|
||
.iter()
|
||
.position(|e| pos >= e.range.start && pos < e.range.end)
|
||
{
|
||
let elem = &self.elements[elem_idx];
|
||
let elem_start = elem.range.start;
|
||
let elem_buf_end = elem.range.end;
|
||
// The visible portion of the element on this line
|
||
let elem_line_end = elem_buf_end.min(line_end);
|
||
|
||
if pos == elem_start {
|
||
// We're at the start of an element — treat it as a whole unit.
|
||
let elem_display_w = if let Some(display) = &elem.display {
|
||
display
|
||
.spans
|
||
.iter()
|
||
.map(|s| s.content.as_ref().width())
|
||
.sum()
|
||
} else {
|
||
self.plain_display_width(&self.text[elem_start..elem_line_end])
|
||
};
|
||
|
||
if width_so_far + elem_display_w > target_col {
|
||
// Click landed on this element display — snap to the
|
||
// nearer boundary (start vs end of the underlying
|
||
// buffer text) so that drag-selection works naturally.
|
||
let dist_start = target_col.saturating_sub(width_so_far);
|
||
let dist_end = elem_display_w.saturating_sub(dist_start);
|
||
if dist_start <= dist_end {
|
||
return (elem_start, true);
|
||
} else {
|
||
return (elem_buf_end, true);
|
||
}
|
||
}
|
||
width_so_far += elem_display_w;
|
||
pos = elem_buf_end.min(line_end); // move past element (or to line end)
|
||
} else {
|
||
// We're in the middle of an element (e.g. a wrapped line starts
|
||
// mid-element). Skip past the rest of the element on this line.
|
||
let partial_w = self.plain_display_width(&self.text[pos..elem_line_end]);
|
||
if width_so_far + partial_w > target_col {
|
||
// Snap to element's actual end boundary
|
||
return (elem_buf_end, true);
|
||
}
|
||
width_so_far += partial_w;
|
||
pos = elem_buf_end.min(line_end); // move past element (or to line end)
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Plain text grapheme
|
||
let slice = &self.text[pos..line_end];
|
||
if let Some(grapheme) = slice.graphemes(true).next() {
|
||
let grapheme_width = self.grapheme_display_width(grapheme);
|
||
width_so_far += grapheme_width;
|
||
if width_so_far > target_col {
|
||
return (self.clamp_pos_to_nearest_boundary(pos), false);
|
||
}
|
||
pos += grapheme.len();
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
(self.clamp_pos_to_nearest_boundary(line_end), false)
|
||
}
|
||
|
||
fn move_to_display_col_on_line(
|
||
&mut self,
|
||
line_start: usize,
|
||
line_end: usize,
|
||
target_col: usize,
|
||
) {
|
||
self.cursor_pos = self
|
||
.display_col_to_buffer_pos(line_start, line_end, target_col)
|
||
.0;
|
||
}
|
||
|
||
fn beginning_of_line(&self, pos: usize) -> usize {
|
||
// Scan backward for '\n' that is NOT inside an element.
|
||
// Newlines inside elements (e.g. multi-line paste) are not line boundaries.
|
||
for i in (0..pos).rev() {
|
||
if self.text.as_bytes()[i] == b'\n' && !self.is_inside_element(i) {
|
||
return i + 1;
|
||
}
|
||
}
|
||
0
|
||
}
|
||
fn beginning_of_current_line(&self) -> usize {
|
||
self.beginning_of_line(self.cursor_pos)
|
||
}
|
||
|
||
fn end_of_line(&self, pos: usize) -> usize {
|
||
// Scan forward for '\n' that is NOT inside an element.
|
||
for i in pos..self.text.len() {
|
||
if self.text.as_bytes()[i] == b'\n' && !self.is_inside_element(i) {
|
||
return i;
|
||
}
|
||
}
|
||
self.text.len()
|
||
}
|
||
fn end_of_current_line(&self) -> usize {
|
||
self.end_of_line(self.cursor_pos)
|
||
}
|
||
|
||
/// Check if a byte position is inside (strictly within) an element.
|
||
fn is_inside_element(&self, pos: usize) -> bool {
|
||
self.elements
|
||
.iter()
|
||
.any(|e| pos >= e.range.start && pos < e.range.end)
|
||
}
|
||
|
||
/// Map a base US-keyboard character to its Shift-modified form.
|
||
///
|
||
/// With the kitty keyboard protocol (`DISAMBIGUATE_ESCAPE_CODES`), some
|
||
/// terminals (notably VS Code's xterm.js) report shifted characters using
|
||
/// the **base** key codepoint with the SHIFT modifier instead of the
|
||
/// already-shifted codepoint. For example, Shift+2 may arrive as
|
||
/// `Char('2') + SHIFT` rather than `Char('@') + SHIFT`. This function
|
||
/// translates the base character so the correct symbol is inserted.
|
||
///
|
||
/// Characters that are already shifted (e.g. `@`, `#`) or not in the US
|
||
/// layout map are returned unchanged.
|
||
fn apply_shift(c: char) -> char {
|
||
if c.is_ascii_lowercase() {
|
||
c.to_ascii_uppercase()
|
||
} else {
|
||
c
|
||
}
|
||
}
|
||
|
||
pub fn input(&mut self, event: KeyEvent) {
|
||
// ── Selection-aware interception ──
|
||
// When a selection is active, certain keys interact with the selected
|
||
// range rather than performing their normal single-char action.
|
||
if self.selection.is_some() {
|
||
match event {
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||
..
|
||
} if c != '\x08' && c != '\x7f' => {
|
||
let c = if event.modifiers.contains(KeyModifiers::SHIFT) {
|
||
Self::apply_shift(c)
|
||
} else {
|
||
c
|
||
};
|
||
self.begin_undo_group();
|
||
if !self.delete_selection() {
|
||
self.clear_selection();
|
||
}
|
||
self.insert_str(&c.to_string());
|
||
self.end_undo_group();
|
||
return;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers,
|
||
..
|
||
} if crate::is_altgr(modifiers) => {
|
||
self.begin_undo_group();
|
||
if !self.delete_selection() {
|
||
self.clear_selection();
|
||
}
|
||
self.insert_str(&c.to_string());
|
||
self.end_undo_group();
|
||
return;
|
||
}
|
||
// Enter / Ctrl-J/M → replace selection with newline.
|
||
KeyEvent {
|
||
code: KeyCode::Char('j' | 'm'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Enter,
|
||
..
|
||
} => {
|
||
self.begin_undo_group();
|
||
if !self.delete_selection() {
|
||
self.clear_selection();
|
||
}
|
||
self.insert_str("\n");
|
||
self.end_undo_group();
|
||
return;
|
||
}
|
||
// Backspace / Delete → delete the selection only (no extra char).
|
||
// If the selection is zero-width (anchor == head), delete_selection()
|
||
// returns false — clear the stale selection and fall through to the
|
||
// normal single-char delete so Backspace/Delete aren't silently swallowed.
|
||
KeyEvent {
|
||
code: KeyCode::Backspace | KeyCode::Delete | KeyCode::Char('\x08' | '\x7f'),
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('h'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('d'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
if self.delete_selection() {
|
||
return;
|
||
}
|
||
// Zero-width selection — clear and fall through.
|
||
self.clear_selection();
|
||
}
|
||
// Ctrl-X → cut selection (copy to clipboard + delete).
|
||
KeyEvent {
|
||
code: KeyCode::Char('x'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
if let Some(text) = self.selected_text() {
|
||
self.set_clipboard_text(text);
|
||
}
|
||
if self.delete_selection() {
|
||
return;
|
||
}
|
||
// Zero-width selection — clear and fall through.
|
||
self.clear_selection();
|
||
}
|
||
// All other keys → clear selection, fall through to normal handling.
|
||
_ => {
|
||
self.clear_selection();
|
||
}
|
||
}
|
||
}
|
||
|
||
match event {
|
||
// Some terminals (or configurations) send Control key chords as
|
||
// C0 control characters without reporting the CONTROL modifier.
|
||
// Handle common fallbacks for Ctrl-B/Ctrl-F here so they don't get
|
||
// inserted as literal control bytes.
|
||
KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => {
|
||
self.move_cursor_left();
|
||
}
|
||
KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => {
|
||
self.move_cursor_right();
|
||
}
|
||
// When the Kitty keyboard protocol gets silently popped (e.g. by a
|
||
// terminal emulator losing track of enhancement state after heavy
|
||
// output or focus changes), Backspace can arrive as raw DEL (0x7F)
|
||
// or BS (0x08) characters instead of KeyCode::Backspace. Intercept
|
||
// them here so they don't get inserted as invisible garbage.
|
||
KeyEvent { code: KeyCode::Char('\u{007f}'), .. } /* DEL */ |
|
||
KeyEvent { code: KeyCode::Char('\u{0008}'), modifiers: KeyModifiers::NONE, .. } /* BS */ => {
|
||
self.delete_backward(1);
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
|
||
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
|
||
// for word navigation. Those are handled explicitly below.
|
||
// Also exclude \x08 (BS) and \x7f (DEL) which are handled as backspace/delete.
|
||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||
..
|
||
} if c != '\x08' && c != '\x7f' => {
|
||
let c = if event.modifiers.contains(KeyModifiers::SHIFT) {
|
||
Self::apply_shift(c)
|
||
} else {
|
||
c
|
||
};
|
||
self.insert_str(&c.to_string());
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('j' | 'm'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Enter,
|
||
..
|
||
} => self.insert_str("\n"),
|
||
KeyEvent {
|
||
code: KeyCode::Char('h'),
|
||
modifiers,
|
||
..
|
||
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||
self.delete_backward_word()
|
||
}
|
||
// Must come after explicit Ctrl+Alt bindings so they aren't shadowed.
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers,
|
||
..
|
||
} if crate::is_altgr(modifiers) => {
|
||
self.insert_str(&c.to_string());
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Backspace,
|
||
modifiers: KeyModifiers::ALT,
|
||
..
|
||
} => self.delete_backward_word(),
|
||
// Cmd+Backspace (macOS): delete from cursor to beginning of line.
|
||
KeyEvent {
|
||
code: KeyCode::Backspace,
|
||
modifiers: KeyModifiers::SUPER,
|
||
..
|
||
} => {
|
||
self.kill_to_beginning_of_line();
|
||
}
|
||
// Ctrl+Backspace: delete previous word (common on Linux/Windows).
|
||
KeyEvent {
|
||
code: KeyCode::Backspace,
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => self.delete_backward_word(),
|
||
KeyEvent {
|
||
code: KeyCode::Backspace | KeyCode::Char('\x08'),
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('h'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => self.delete_backward(1),
|
||
// Delete forward word: Alt+Delete, Alt+D (Emacs Meta-d),
|
||
// Cmd+D (macOS Kitty), Ctrl+Delete (Linux/Windows; mirrors Ctrl+Backspace).
|
||
KeyEvent {
|
||
code: KeyCode::Delete,
|
||
modifiers: KeyModifiers::ALT,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Delete,
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('d'),
|
||
modifiers: KeyModifiers::ALT,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('d'),
|
||
modifiers: KeyModifiers::SUPER,
|
||
..
|
||
} => self.delete_forward_word(),
|
||
KeyEvent {
|
||
code: KeyCode::Delete,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('d'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => self.delete_forward(1),
|
||
|
||
// Readline parity: C-w is unix-word-rubout; M-DEL/C-Backspace
|
||
// above intentionally keep chunked delete_backward_word.
|
||
KeyEvent {
|
||
code: KeyCode::Char('w'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.delete_backward_unix_word();
|
||
}
|
||
// Meta-b -> move to beginning of previous word
|
||
// Meta-f -> move to end of next word
|
||
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
|
||
KeyEvent {
|
||
code: KeyCode::Char('b'),
|
||
modifiers: KeyModifiers::ALT,
|
||
..
|
||
} => {
|
||
self.set_cursor(self.beginning_of_previous_word());
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('f'),
|
||
modifiers: KeyModifiers::ALT,
|
||
..
|
||
} => {
|
||
self.set_cursor(self.end_of_next_word());
|
||
}
|
||
// Ctrl+U / terminal-translated Cmd+Backspace (^U): kill cursor → BOL.
|
||
// Many terminals map Cmd+Backspace to ^U; kill_current_line would wipe
|
||
// text after the cursor.
|
||
KeyEvent {
|
||
code: KeyCode::Char('u'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.kill_to_beginning_of_line();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('k'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.kill_to_end_of_line();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('y'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.yank();
|
||
}
|
||
|
||
// Undo / Redo (Ctrl or Cmd)
|
||
KeyEvent {
|
||
code: KeyCode::Char('Z'),
|
||
modifiers,
|
||
..
|
||
} if modifiers.contains(KeyModifiers::CONTROL)
|
||
|| modifiers.contains(KeyModifiers::SUPER) =>
|
||
{
|
||
// Ctrl/Cmd-Shift-Z → redo (terminals that report uppercase Z + Shift)
|
||
self.redo();
|
||
}
|
||
k if is_undo_input(&k) => {
|
||
self.undo();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('r'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.redo();
|
||
}
|
||
|
||
// Ctrl-V → paste from clipboard provider.
|
||
KeyEvent {
|
||
code: KeyCode::Char('v'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
if let Some(text) = self.clipboard_provider.get() {
|
||
self.insert_str(&text);
|
||
}
|
||
}
|
||
|
||
// Cursor movement
|
||
KeyEvent {
|
||
code: KeyCode::Left,
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
self.move_cursor_left();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Right,
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
self.move_cursor_right();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('b'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.move_cursor_left();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('f'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.move_cursor_right();
|
||
}
|
||
// Some terminals send Alt+Arrow for word-wise movement:
|
||
// Option/Left -> Alt+Left (previous word start)
|
||
// Option/Right -> Alt+Right (next word end)
|
||
KeyEvent {
|
||
code: KeyCode::Left,
|
||
modifiers: KeyModifiers::ALT,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Left,
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.set_cursor(self.beginning_of_previous_word());
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Right,
|
||
modifiers: KeyModifiers::ALT,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Right,
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.set_cursor(self.end_of_next_word());
|
||
}
|
||
// Cmd+Left / Cmd+Right (macOS): terminals using the Kitty keyboard
|
||
// protocol (Ghostty, Kitty, WezTerm) send these as Super+Arrow.
|
||
KeyEvent {
|
||
code: KeyCode::Left,
|
||
modifiers: KeyModifiers::SUPER,
|
||
..
|
||
} => {
|
||
self.move_cursor_to_beginning_of_line(false);
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Right,
|
||
modifiers: KeyModifiers::SUPER,
|
||
..
|
||
} => {
|
||
self.move_cursor_to_end_of_line(false);
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Up, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('p'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.move_cursor_up();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Down,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('n'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.move_cursor_down();
|
||
}
|
||
// Home/End → visual row; Ctrl+A/E → logical line (emacs).
|
||
KeyEvent {
|
||
code: KeyCode::Home,
|
||
..
|
||
} => {
|
||
self.move_cursor_to_beginning_of_line(false);
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('a'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.move_cursor_to_beginning_of_line(true);
|
||
}
|
||
|
||
KeyEvent {
|
||
code: KeyCode::End, ..
|
||
} => {
|
||
self.move_cursor_to_end_of_line(false);
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('e'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.move_cursor_to_end_of_line(true);
|
||
}
|
||
_o => {
|
||
#[cfg(feature = "debug-logs")]
|
||
tracing::debug!("Unhandled key event in TextArea: {:?}", _o);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Undo/Redo ──
|
||
|
||
/// Create a snapshot of the current textarea state.
|
||
fn snapshot(&self) -> UndoEntry {
|
||
UndoEntry {
|
||
text: self.text.clone(),
|
||
cursor: self.cursor_pos,
|
||
elements: self.elements.clone(),
|
||
}
|
||
}
|
||
|
||
/// Restore the textarea state from a snapshot.
|
||
fn restore(&mut self, entry: UndoEntry) {
|
||
self.text = entry.text;
|
||
self.cursor_pos = entry.cursor;
|
||
self.elements = entry.elements;
|
||
self.wrap_cache.replace(None);
|
||
self.preferred_col = None;
|
||
// Note: next_element_id is intentionally NOT restored — it only increases.
|
||
// Note: kill_buffer is intentionally NOT restored — yank is separate from undo.
|
||
}
|
||
|
||
/// Called before a mutation to decide whether to push a new undo checkpoint.
|
||
///
|
||
/// Batching rules:
|
||
/// - Inside an undo group (`group_depth > 0`) → skip entirely.
|
||
/// - First mutation ever → always checkpoint.
|
||
/// - Kind changed from last → checkpoint.
|
||
/// - Cursor moved since last mutation (arrows, clicks) → checkpoint.
|
||
/// - Kill / Element / Replace → always checkpoint (discrete actions).
|
||
/// - Same Insert or Delete with consecutive cursor → extend batch (no checkpoint).
|
||
/// - Word boundary (ws↔non-ws transition) → checkpoint (handled by callers
|
||
/// resetting `last_kind` before calling this method).
|
||
fn pre_mutate(&mut self, kind: MutationKind) {
|
||
// Inside an undo group — the group handles its own checkpoint.
|
||
if self.undo.group_depth > 0 {
|
||
return;
|
||
}
|
||
|
||
let should_push = match self.undo.last_kind {
|
||
None => true,
|
||
Some(prev) => {
|
||
prev != kind
|
||
|| self.cursor_pos != self.undo.last_cursor
|
||
|| matches!(
|
||
kind,
|
||
MutationKind::Kill | MutationKind::Element | MutationKind::Replace
|
||
)
|
||
}
|
||
};
|
||
|
||
if should_push {
|
||
let entry = self.snapshot();
|
||
self.undo.stack.push(entry);
|
||
if self.undo.stack.len() > self.undo.max_depth {
|
||
self.undo.stack.remove(0);
|
||
}
|
||
}
|
||
self.undo.redo.clear();
|
||
self.undo.last_kind = Some(kind);
|
||
}
|
||
|
||
/// Update `last_cursor` after a mutation completes so the next `pre_mutate`
|
||
/// can detect cursor jumps.
|
||
fn post_mutate(&mut self) {
|
||
self.undo.last_cursor = self.cursor_pos;
|
||
}
|
||
|
||
/// Clear the undo/redo history, leaving the current text and cursor
|
||
/// untouched.
|
||
///
|
||
/// Use this when a buffer is reset to represent a *new logical
|
||
/// context* — e.g. a shared input widget that is reused for a
|
||
/// different target — so that a later `undo` can't resurrect text
|
||
/// that belonged to the previous context. `set_text` deliberately
|
||
/// records a checkpoint (so an accidental replace is undoable), so
|
||
/// callers that want a hard reset must follow it with this.
|
||
pub fn clear_history(&mut self) {
|
||
self.undo.stack.clear();
|
||
self.undo.redo.clear();
|
||
self.undo.last_kind = None;
|
||
self.undo.last_cursor = self.cursor_pos;
|
||
}
|
||
|
||
/// Undo the last mutation. Returns `true` if there was something to undo.
|
||
pub fn undo(&mut self) -> bool {
|
||
if let Some(entry) = self.undo.stack.pop() {
|
||
self.scroll_override = None;
|
||
let current = self.snapshot();
|
||
self.undo.redo.push(current);
|
||
self.restore(entry);
|
||
// Reset batching — next mutation starts a fresh group.
|
||
self.undo.last_kind = None;
|
||
self.undo.last_cursor = self.cursor_pos;
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
/// Redo the last undone mutation. Returns `true` if there was something to redo.
|
||
pub fn redo(&mut self) -> bool {
|
||
if let Some(entry) = self.undo.redo.pop() {
|
||
self.scroll_override = None;
|
||
let current = self.snapshot();
|
||
self.undo.stack.push(current);
|
||
self.restore(entry);
|
||
// Reset batching — next mutation starts a fresh group.
|
||
self.undo.last_kind = None;
|
||
self.undo.last_cursor = self.cursor_pos;
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
pub fn can_undo(&self) -> bool {
|
||
!self.undo.stack.is_empty()
|
||
}
|
||
|
||
pub fn can_redo(&self) -> bool {
|
||
!self.undo.redo.is_empty()
|
||
}
|
||
|
||
/// Begin an undo group. All mutations between `begin_undo_group()` and
|
||
/// `end_undo_group()` are collapsed into a single undo step.
|
||
///
|
||
/// Groups can be nested: only the outermost `end_undo_group()` pushes
|
||
/// the checkpoint. Inner begin/end pairs are reference-counted.
|
||
///
|
||
/// Use cases:
|
||
/// - Autocomplete: `replace_range_with_element` + `insert_str(" ")` = 1 undo step
|
||
/// - Line-select: enter → N live-updates → confirm = 1 undo step
|
||
pub fn begin_undo_group(&mut self) {
|
||
if self.undo.group_depth == 0 {
|
||
// Outermost group — take the snapshot.
|
||
self.undo.group_checkpoint = Some(self.snapshot());
|
||
}
|
||
self.undo.group_depth += 1;
|
||
}
|
||
|
||
/// End an undo group. If this closes the outermost group and the state
|
||
/// actually changed, a single undo entry is pushed.
|
||
pub fn end_undo_group(&mut self) {
|
||
if self.undo.group_depth == 0 {
|
||
return; // Unbalanced call — ignore.
|
||
}
|
||
self.undo.group_depth -= 1;
|
||
if self.undo.group_depth == 0 {
|
||
if let Some(checkpoint) = self.undo.group_checkpoint.take() {
|
||
// Only push if state actually changed.
|
||
let changed = checkpoint.text != self.text
|
||
|| checkpoint.cursor != self.cursor_pos
|
||
|| checkpoint.elements.len() != self.elements.len();
|
||
if changed {
|
||
self.undo.stack.push(checkpoint);
|
||
if self.undo.stack.len() > self.undo.max_depth {
|
||
self.undo.stack.remove(0);
|
||
}
|
||
self.undo.redo.clear();
|
||
}
|
||
}
|
||
// Reset batching state so the next mutation starts fresh.
|
||
self.undo.last_kind = None;
|
||
self.undo.last_cursor = self.cursor_pos;
|
||
}
|
||
}
|
||
|
||
/// Cancel an undo group. Restores the textarea to the state it was in
|
||
/// when `begin_undo_group()` was called — no undo entry is created.
|
||
///
|
||
/// Use case: line-select cancel → revert all live-updates, leave no trace.
|
||
pub fn cancel_undo_group(&mut self) {
|
||
if self.undo.group_depth == 0 {
|
||
return; // Unbalanced call — ignore.
|
||
}
|
||
// Always restore to the outermost checkpoint, regardless of nesting.
|
||
self.undo.group_depth = 0;
|
||
if let Some(checkpoint) = self.undo.group_checkpoint.take() {
|
||
self.restore(checkpoint);
|
||
}
|
||
// Reset batching state.
|
||
self.undo.last_kind = None;
|
||
self.undo.last_cursor = self.cursor_pos;
|
||
}
|
||
|
||
// ####### Input Functions #######
|
||
pub fn delete_backward(&mut self, n: usize) {
|
||
if n == 0 || self.cursor_pos == 0 {
|
||
return;
|
||
}
|
||
self.pre_mutate(MutationKind::Delete);
|
||
let mut target = self.cursor_pos;
|
||
for _ in 0..n {
|
||
target = self.prev_atomic_boundary(target);
|
||
if target == 0 {
|
||
break;
|
||
}
|
||
}
|
||
let range = self.expand_range_to_element_boundaries(target..self.cursor_pos);
|
||
self.replace_range_raw(range, "");
|
||
self.post_mutate();
|
||
}
|
||
|
||
pub fn delete_forward(&mut self, n: usize) {
|
||
if n == 0 || self.cursor_pos >= self.text.len() {
|
||
return;
|
||
}
|
||
self.pre_mutate(MutationKind::Delete);
|
||
let mut target = self.cursor_pos;
|
||
for _ in 0..n {
|
||
target = self.next_atomic_boundary(target);
|
||
if target >= self.text.len() {
|
||
break;
|
||
}
|
||
}
|
||
let range = self.expand_range_to_element_boundaries(self.cursor_pos..target);
|
||
self.replace_range_raw(range, "");
|
||
self.post_mutate();
|
||
}
|
||
|
||
pub fn delete_backward_word(&mut self) {
|
||
let start = self.beginning_of_previous_word();
|
||
if start >= self.cursor_pos {
|
||
return;
|
||
}
|
||
self.pre_mutate(MutationKind::Kill);
|
||
self.kill_range(start..self.cursor_pos);
|
||
self.post_mutate();
|
||
}
|
||
|
||
/// readline `unix-word-rubout` (whitespace-delimited), vs
|
||
/// [`Self::delete_backward_word`]'s punctuation-chunked M-DEL semantics.
|
||
pub fn delete_backward_unix_word(&mut self) {
|
||
let start = self.beginning_of_previous_unix_word();
|
||
if start >= self.cursor_pos {
|
||
return;
|
||
}
|
||
self.pre_mutate(MutationKind::Kill);
|
||
self.kill_range(start..self.cursor_pos);
|
||
self.post_mutate();
|
||
}
|
||
|
||
/// Delete text to the right of the cursor using readline-style word semantics.
|
||
///
|
||
/// Deletes from the current cursor position through the end of the next word as determined
|
||
/// by `end_of_next_word()`. Any delimiters between the cursor and that word
|
||
/// (whitespace, punctuation, newlines) are included in the deletion.
|
||
pub fn delete_forward_word(&mut self) {
|
||
let end = self.end_of_next_word();
|
||
if end > self.cursor_pos {
|
||
self.pre_mutate(MutationKind::Kill);
|
||
self.kill_range(self.cursor_pos..end);
|
||
self.post_mutate();
|
||
}
|
||
}
|
||
|
||
pub fn kill_to_end_of_line(&mut self) {
|
||
let eol = self.end_of_current_line();
|
||
let range = if self.cursor_pos == eol {
|
||
if eol < self.text.len() {
|
||
Some(self.cursor_pos..eol + 1)
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
Some(self.cursor_pos..eol)
|
||
};
|
||
|
||
if let Some(range) = range {
|
||
self.pre_mutate(MutationKind::Kill);
|
||
self.kill_range(range);
|
||
self.post_mutate();
|
||
}
|
||
}
|
||
|
||
pub fn kill_to_beginning_of_line(&mut self) {
|
||
let bol = self.beginning_of_current_line();
|
||
let range = if self.cursor_pos == bol {
|
||
if bol > 0 { Some(bol - 1..bol) } else { None }
|
||
} else {
|
||
Some(bol..self.cursor_pos)
|
||
};
|
||
|
||
if let Some(range) = range {
|
||
self.pre_mutate(MutationKind::Kill);
|
||
self.kill_range(range);
|
||
self.post_mutate();
|
||
}
|
||
}
|
||
|
||
/// Kill the entire current line (BOL to EOL), regardless of cursor position.
|
||
/// If the line is already empty, consumes the preceding newline to join lines.
|
||
pub fn kill_current_line(&mut self) {
|
||
let bol = self.beginning_of_current_line();
|
||
let eol = self.end_of_current_line();
|
||
|
||
let range = if bol == eol {
|
||
if bol > 0 { Some(bol - 1..bol) } else { None }
|
||
} else {
|
||
Some(bol..eol)
|
||
};
|
||
|
||
if let Some(range) = range {
|
||
self.pre_mutate(MutationKind::Kill);
|
||
self.kill_range(range);
|
||
self.post_mutate();
|
||
}
|
||
}
|
||
|
||
pub fn yank(&mut self) {
|
||
if self.kill_buffer.is_empty() {
|
||
return;
|
||
}
|
||
self.pre_mutate(MutationKind::Insert);
|
||
let text = self.kill_buffer.clone();
|
||
self.insert_str_inner(self.cursor_pos, &text);
|
||
if let Some(last) = text.chars().last() {
|
||
self.undo.last_insert_ws = last.is_whitespace();
|
||
}
|
||
self.post_mutate();
|
||
}
|
||
|
||
fn kill_range(&mut self, range: Range<usize>) {
|
||
let range = self.expand_range_to_element_boundaries(range);
|
||
let range = range.start.min(self.text.len())..range.end.min(self.text.len());
|
||
if range.start >= range.end {
|
||
return;
|
||
}
|
||
|
||
let removed = self.text[range.clone()].to_string();
|
||
if removed.is_empty() {
|
||
return;
|
||
}
|
||
|
||
self.kill_buffer = removed;
|
||
self.replace_range_raw(range, "");
|
||
}
|
||
|
||
/// Move the cursor left by a single grapheme cluster.
|
||
pub fn move_cursor_left(&mut self) {
|
||
self.scroll_override = None;
|
||
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
|
||
self.preferred_col = None;
|
||
}
|
||
|
||
/// Move the cursor right by a single grapheme cluster.
|
||
pub fn move_cursor_right(&mut self) {
|
||
self.scroll_override = None;
|
||
self.cursor_pos = self.next_atomic_boundary(self.cursor_pos);
|
||
self.preferred_col = None;
|
||
}
|
||
|
||
pub fn move_cursor_up(&mut self) {
|
||
self.scroll_override = None;
|
||
// If we have a wrapping cache, prefer navigating across wrapped (visual) lines.
|
||
if let Some((target_col, maybe_line)) = {
|
||
let cache_ref = self.wrap_cache.borrow();
|
||
if let Some(cache) = cache_ref.as_ref() {
|
||
let lines = &cache.lines;
|
||
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
|
||
let cur_range = &lines[idx];
|
||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||
self.display_width_of_range(cur_range.start, self.cursor_pos)
|
||
});
|
||
if idx > 0 {
|
||
let prev = &lines[idx - 1];
|
||
let line_start = prev.start;
|
||
let line_end = prev.end;
|
||
Some((target_col, Some((line_start, line_end))))
|
||
} else {
|
||
Some((target_col, None))
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
} {
|
||
// We had wrapping info. Apply movement accordingly.
|
||
match maybe_line {
|
||
Some((line_start, line_end)) => {
|
||
if self.preferred_col.is_none() {
|
||
self.preferred_col = Some(target_col);
|
||
}
|
||
self.move_to_display_col_on_line(line_start, line_end, target_col);
|
||
return;
|
||
}
|
||
None => {
|
||
// Already at first visual line -> move to start
|
||
self.cursor_pos = 0;
|
||
self.preferred_col = None;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback to logical line navigation if we don't have wrapping info yet.
|
||
if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') {
|
||
let target_col = match self.preferred_col {
|
||
Some(c) => c,
|
||
None => {
|
||
let c = self.current_display_col();
|
||
self.preferred_col = Some(c);
|
||
c
|
||
}
|
||
};
|
||
let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
|
||
let prev_line_end = prev_nl;
|
||
self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
|
||
} else {
|
||
self.cursor_pos = 0;
|
||
self.preferred_col = None;
|
||
}
|
||
}
|
||
|
||
pub fn move_cursor_down(&mut self) {
|
||
self.scroll_override = None;
|
||
// If we have a wrapping cache, prefer navigating across wrapped (visual) lines.
|
||
if let Some((target_col, move_to_last)) = {
|
||
let cache_ref = self.wrap_cache.borrow();
|
||
if let Some(cache) = cache_ref.as_ref() {
|
||
let lines = &cache.lines;
|
||
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
|
||
let cur_range = &lines[idx];
|
||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||
self.display_width_of_range(cur_range.start, self.cursor_pos)
|
||
});
|
||
if idx + 1 < lines.len() {
|
||
let next = &lines[idx + 1];
|
||
let line_start = next.start;
|
||
let line_end = next.end;
|
||
Some((target_col, Some((line_start, line_end))))
|
||
} else {
|
||
Some((target_col, None))
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
} {
|
||
match move_to_last {
|
||
Some((line_start, line_end)) => {
|
||
if self.preferred_col.is_none() {
|
||
self.preferred_col = Some(target_col);
|
||
}
|
||
self.move_to_display_col_on_line(line_start, line_end, target_col);
|
||
return;
|
||
}
|
||
None => {
|
||
// Already on last visual line -> move to end
|
||
self.cursor_pos = self.text.len();
|
||
self.preferred_col = None;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback to logical line navigation if we don't have wrapping info yet.
|
||
let target_col = match self.preferred_col {
|
||
Some(c) => c,
|
||
None => {
|
||
let c = self.current_display_col();
|
||
self.preferred_col = Some(c);
|
||
c
|
||
}
|
||
};
|
||
if let Some(next_nl) = self.text[self.cursor_pos..]
|
||
.find('\n')
|
||
.map(|i| i + self.cursor_pos)
|
||
{
|
||
let next_line_start = next_nl + 1;
|
||
let next_line_end = self.text[next_line_start..]
|
||
.find('\n')
|
||
.map(|i| i + next_line_start)
|
||
.unwrap_or(self.text.len());
|
||
self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
|
||
} else {
|
||
self.cursor_pos = self.text.len();
|
||
self.preferred_col = None;
|
||
}
|
||
}
|
||
|
||
/// Home / Super+Left when `move_up_at_bol` is false (visual row if wrapped);
|
||
/// Ctrl+A when true (logical line; already-at-BOL chains to previous line).
|
||
pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) {
|
||
if !move_up_at_bol && let Some(bol) = self.beginning_of_current_visual_line() {
|
||
self.set_cursor(bol);
|
||
return;
|
||
}
|
||
|
||
let bol = self.beginning_of_current_line();
|
||
if move_up_at_bol && self.cursor_pos == bol {
|
||
self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1)));
|
||
} else {
|
||
self.set_cursor(bol);
|
||
}
|
||
}
|
||
|
||
/// End / Super+Right when `move_down_at_eol` is false (visual row if wrapped);
|
||
/// Ctrl+E when true (logical line; already-at-EOL chains to next line).
|
||
pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) {
|
||
if !move_down_at_eol && let Some(eol) = self.end_of_current_visual_line() {
|
||
self.set_cursor(eol);
|
||
return;
|
||
}
|
||
|
||
let eol = self.end_of_current_line();
|
||
if move_down_at_eol && self.cursor_pos == eol {
|
||
let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len());
|
||
self.set_cursor(self.end_of_line(next_pos));
|
||
} else {
|
||
self.set_cursor(eol);
|
||
}
|
||
}
|
||
|
||
fn beginning_of_current_visual_line(&self) -> Option<usize> {
|
||
let cache = self.wrap_cache.borrow();
|
||
let cache = cache.as_ref()?;
|
||
let idx = Self::wrapped_line_index_by_start(&cache.lines, self.cursor_pos)?;
|
||
Some(cache.lines[idx].start)
|
||
}
|
||
|
||
/// Soft-continued visual rows land on the last char (exclusive end is the
|
||
/// next row's start). Final segment of a logical line uses exclusive end.
|
||
fn end_of_current_visual_line(&self) -> Option<usize> {
|
||
let cache = self.wrap_cache.borrow();
|
||
let cache = cache.as_ref()?;
|
||
let idx = Self::wrapped_line_index_by_start(&cache.lines, self.cursor_pos)?;
|
||
let line = &cache.lines[idx];
|
||
let end = line.end.min(self.text.len());
|
||
let soft_continued = cache
|
||
.lines
|
||
.get(idx + 1)
|
||
.is_some_and(|next| next.start == end);
|
||
if soft_continued && end > line.start {
|
||
Some(self.clamp_to_line(end, line.start, end))
|
||
} else {
|
||
Some(end)
|
||
}
|
||
}
|
||
|
||
// ===== Text elements support =====
|
||
|
||
/// Insert an atomic text element at the current cursor position.
|
||
///
|
||
/// The `text` is inserted into the buffer and registered as an element.
|
||
/// The `kind` tag is opaque to the textarea (host-defined).
|
||
/// The `display` optionally overrides how the element is rendered.
|
||
///
|
||
/// Returns the assigned [`ElementId`] so the host can store associated metadata.
|
||
pub fn insert_element(
|
||
&mut self,
|
||
text: &str,
|
||
kind: ElementKind,
|
||
display: Option<Line<'static>>,
|
||
) -> ElementId {
|
||
self.pre_mutate(MutationKind::Element);
|
||
let start = self.clamp_pos_for_insertion(self.cursor_pos);
|
||
let inserted_len = self.insert_str_inner(start, text);
|
||
let end = start + inserted_len;
|
||
let id = self.add_element(start..end, kind, display);
|
||
// Place cursor at end of inserted element
|
||
self.set_cursor(end);
|
||
self.post_mutate();
|
||
id
|
||
}
|
||
|
||
/// Replace a range of buffer text with an atomic element.
|
||
///
|
||
/// This is the "confirm autocomplete" operation: the trigger text (e.g. `@foo`)
|
||
/// is deleted and replaced with element text (e.g. `@src/foo.rs`) in a single
|
||
/// atomic operation. The cursor is placed at the end of the new element.
|
||
///
|
||
/// Returns the assigned [`ElementId`].
|
||
pub fn replace_range_with_element(
|
||
&mut self,
|
||
range: Range<usize>,
|
||
text: &str,
|
||
kind: ElementKind,
|
||
display: Option<Line<'static>>,
|
||
) -> ElementId {
|
||
self.pre_mutate(MutationKind::Element);
|
||
// First, remove any elements that overlap the replacement range
|
||
self.elements
|
||
.retain(|e| !(e.range.start < range.end && e.range.end > range.start));
|
||
let start = range.start.clamp(0, self.text.len());
|
||
let inserted_len = self.replace_range_raw(range, text);
|
||
let end = start + inserted_len;
|
||
let id = self.add_element(start..end, kind, display);
|
||
// Place cursor at end of new element
|
||
self.set_cursor(end);
|
||
self.post_mutate();
|
||
id
|
||
}
|
||
|
||
fn add_element(
|
||
&mut self,
|
||
range: Range<usize>,
|
||
kind: ElementKind,
|
||
display: Option<Line<'static>>,
|
||
) -> ElementId {
|
||
let id = ElementId(self.next_element_id);
|
||
self.next_element_id += 1;
|
||
let elem = TextElement {
|
||
id,
|
||
range,
|
||
kind,
|
||
display,
|
||
};
|
||
self.elements.push(elem);
|
||
self.elements.sort_by_key(|e| e.range.start);
|
||
id
|
||
}
|
||
|
||
/// Returns the element at the current cursor position, if any.
|
||
///
|
||
/// If the cursor is at an element's start boundary, that element is returned.
|
||
/// If the cursor is strictly inside an element (shouldn't happen in normal
|
||
/// operation), the containing element is returned.
|
||
pub fn element_at_cursor(&self) -> Option<&TextElement> {
|
||
self.elements
|
||
.iter()
|
||
.find(|e| self.cursor_pos >= e.range.start && self.cursor_pos < e.range.end)
|
||
}
|
||
|
||
/// Returns the underlying buffer text for the element with the given id.
|
||
pub fn element_text(&self, id: ElementId) -> Option<&str> {
|
||
self.elements
|
||
.iter()
|
||
.find(|e| e.id == id)
|
||
.map(|e| &self.text[e.range.clone()])
|
||
}
|
||
|
||
/// Update the display for an existing element. Invalidates the wrap cache.
|
||
pub fn set_element_display(&mut self, id: ElementId, display: Option<Line<'static>>) {
|
||
if let Some(e) = self.elements.iter_mut().find(|e| e.id == id) {
|
||
e.display = display;
|
||
self.wrap_cache.replace(None);
|
||
}
|
||
}
|
||
|
||
/// Returns a slice of all elements, sorted by buffer position.
|
||
pub fn elements(&self) -> &[TextElement] {
|
||
&self.elements
|
||
}
|
||
|
||
/// Re-register elements after a [`set_text`] call that placed their
|
||
/// buffer text back verbatim. Each `(range, kind, display)` tuple
|
||
/// describes one element whose text already occupies `range` in the
|
||
/// buffer. No text is inserted — this only recreates the element
|
||
/// metadata so the textarea renders chips instead of raw text.
|
||
pub fn restore_elements(
|
||
&mut self,
|
||
elems: impl IntoIterator<Item = (Range<usize>, ElementKind, Option<Line<'static>>)>,
|
||
) {
|
||
for (range, kind, display) in elems {
|
||
self.add_element(range, kind, display);
|
||
}
|
||
self.wrap_cache.replace(None);
|
||
}
|
||
|
||
/// Inline an element: remove it from the element list so its buffer text
|
||
/// becomes plain editable characters. The text content is unchanged.
|
||
///
|
||
/// The cursor is placed at the end of the inlined region.
|
||
/// This operation is a single undoable step.
|
||
///
|
||
/// Returns `true` if the element was found and inlined, `false` otherwise.
|
||
pub fn inline_element(&mut self, id: ElementId) -> bool {
|
||
let Some(idx) = self.elements.iter().position(|e| e.id == id) else {
|
||
return false;
|
||
};
|
||
let end = self.elements[idx].range.end;
|
||
|
||
// Snapshot for undo before removing the element.
|
||
self.pre_mutate(MutationKind::Element);
|
||
|
||
self.elements.remove(idx);
|
||
self.cursor_pos = end;
|
||
self.preferred_col = None;
|
||
self.wrap_cache.replace(None);
|
||
self.undo.last_kind = None; // always discrete
|
||
|
||
true
|
||
}
|
||
|
||
/// Get the contiguous non-whitespace "word" that the cursor is inside or at the start of.
|
||
///
|
||
/// Returns `(byte_range, text)` where `byte_range` is the range in the buffer.
|
||
/// Returns `None` if the cursor is on whitespace or the buffer is empty.
|
||
///
|
||
/// This is useful for trigger-character detection (e.g. finding `@foo` under the cursor
|
||
/// for autocomplete). The host can then check `text.starts_with('@')` etc.
|
||
pub fn word_at_cursor(&self) -> Option<(Range<usize>, &str)> {
|
||
if self.text.is_empty() {
|
||
return None;
|
||
}
|
||
let pos = self.cursor_pos.min(self.text.len());
|
||
|
||
// Find word start: scan backward from cursor to find whitespace boundary
|
||
let start = self.text[..pos]
|
||
.rfind(|c: char| c.is_whitespace())
|
||
.map(|i| {
|
||
i + self.text[i..]
|
||
.chars()
|
||
.next()
|
||
.map(|c| c.len_utf8())
|
||
.unwrap_or(1)
|
||
})
|
||
.unwrap_or(0);
|
||
|
||
// Find word end: scan forward from cursor to find whitespace boundary
|
||
let end = self.text[pos..]
|
||
.find(|c: char| c.is_whitespace())
|
||
.map(|i| i + pos)
|
||
.unwrap_or(self.text.len());
|
||
|
||
// Also extend backward from start in case cursor is at word boundary
|
||
// Actually, we also need to handle cursor being between words.
|
||
// If cursor is at whitespace, return None.
|
||
if start >= end {
|
||
return None;
|
||
}
|
||
|
||
// If cursor is beyond the word end (cursor at whitespace after word), return None
|
||
// Unless cursor is exactly at start position of the word
|
||
let word = &self.text[start..end];
|
||
if word.chars().all(|c| c.is_whitespace()) {
|
||
return None;
|
||
}
|
||
|
||
Some((start..end, word))
|
||
}
|
||
|
||
fn find_element_containing(&self, pos: usize) -> Option<usize> {
|
||
self.elements
|
||
.iter()
|
||
.position(|e| pos > e.range.start && pos < e.range.end)
|
||
}
|
||
|
||
fn clamp_pos_to_nearest_boundary(&self, mut pos: usize) -> usize {
|
||
if pos > self.text.len() {
|
||
pos = self.text.len();
|
||
}
|
||
if let Some(idx) = self.find_element_containing(pos) {
|
||
let e = &self.elements[idx];
|
||
let dist_start = pos.saturating_sub(e.range.start);
|
||
let dist_end = e.range.end.saturating_sub(pos);
|
||
if dist_start <= dist_end {
|
||
e.range.start
|
||
} else {
|
||
e.range.end
|
||
}
|
||
} else {
|
||
pos
|
||
}
|
||
}
|
||
|
||
fn clamp_pos_for_insertion(&self, pos: usize) -> usize {
|
||
// Do not allow inserting into the middle of an element
|
||
if let Some(idx) = self.find_element_containing(pos) {
|
||
let e = &self.elements[idx];
|
||
// Choose closest edge for insertion
|
||
let dist_start = pos.saturating_sub(e.range.start);
|
||
let dist_end = e.range.end.saturating_sub(pos);
|
||
if dist_start <= dist_end {
|
||
e.range.start
|
||
} else {
|
||
e.range.end
|
||
}
|
||
} else {
|
||
pos
|
||
}
|
||
}
|
||
|
||
fn expand_range_to_element_boundaries(&self, mut range: Range<usize>) -> Range<usize> {
|
||
// Expand to include any intersecting elements fully
|
||
loop {
|
||
let mut changed = false;
|
||
for e in &self.elements {
|
||
if e.range.start < range.end && e.range.end > range.start {
|
||
let new_start = range.start.min(e.range.start);
|
||
let new_end = range.end.max(e.range.end);
|
||
if new_start != range.start || new_end != range.end {
|
||
range.start = new_start;
|
||
range.end = new_end;
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
if !changed {
|
||
break;
|
||
}
|
||
}
|
||
range
|
||
}
|
||
|
||
fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) {
|
||
// Generic shift: for pure insert, removed = 0; for delete, inserted = 0.
|
||
let end = at + removed;
|
||
let diff = inserted as isize - removed as isize;
|
||
// Remove elements fully deleted by the operation and shift the rest
|
||
self.elements
|
||
.retain(|e| !(e.range.start >= at && e.range.end <= end));
|
||
for e in &mut self.elements {
|
||
if e.range.end <= at {
|
||
// before edit
|
||
} else if e.range.start >= end {
|
||
// after edit
|
||
e.range.start = ((e.range.start as isize) + diff) as usize;
|
||
e.range.end = ((e.range.end as isize) + diff) as usize;
|
||
} else {
|
||
// Overlap with element but not fully contained (shouldn't happen when using
|
||
// element-aware replace, but degrade gracefully by snapping element to new bounds)
|
||
let new_start = at.min(e.range.start);
|
||
let new_end = at + inserted.max(e.range.end.saturating_sub(end));
|
||
e.range.start = new_start;
|
||
e.range.end = new_end;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) {
|
||
self.shift_elements(start, end.saturating_sub(start), inserted_len);
|
||
}
|
||
|
||
fn prev_atomic_boundary(&self, pos: usize) -> usize {
|
||
if pos == 0 {
|
||
return 0;
|
||
}
|
||
// If currently at an element end or inside, jump to start of that element.
|
||
if let Some(idx) = self
|
||
.elements
|
||
.iter()
|
||
.position(|e| pos > e.range.start && pos <= e.range.end)
|
||
{
|
||
return self.elements[idx].range.start;
|
||
}
|
||
let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false);
|
||
match gc.prev_boundary(&self.text, 0) {
|
||
Ok(Some(b)) => {
|
||
if let Some(idx) = self.find_element_containing(b) {
|
||
self.elements[idx].range.start
|
||
} else {
|
||
b
|
||
}
|
||
}
|
||
Ok(None) => 0,
|
||
Err(_) => pos.saturating_sub(1),
|
||
}
|
||
}
|
||
|
||
fn next_atomic_boundary(&self, pos: usize) -> usize {
|
||
if pos >= self.text.len() {
|
||
return self.text.len();
|
||
}
|
||
// If currently at an element start or inside, jump to end of that element.
|
||
if let Some(idx) = self
|
||
.elements
|
||
.iter()
|
||
.position(|e| pos >= e.range.start && pos < e.range.end)
|
||
{
|
||
return self.elements[idx].range.end;
|
||
}
|
||
let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false);
|
||
match gc.next_boundary(&self.text, 0) {
|
||
Ok(Some(b)) => {
|
||
if let Some(idx) = self.find_element_containing(b) {
|
||
self.elements[idx].range.end
|
||
} else {
|
||
b
|
||
}
|
||
}
|
||
Ok(None) => self.text.len(),
|
||
Err(_) => pos.saturating_add(1),
|
||
}
|
||
}
|
||
|
||
/// Move to the beginning of the previous navigable chunk.
|
||
///
|
||
/// Word characters are alphanumeric plus `_`. Punctuation runs (such as
|
||
/// `-`) are their own chunk, so moving left across `aa-bb` stops at the
|
||
/// right side of `-`, then the left side of `-`, then the start of `aa`.
|
||
/// Whitespace is skipped over. Elements remain atomic units.
|
||
pub fn beginning_of_previous_word(&self) -> usize {
|
||
let mut pos = self.cursor_pos.min(self.text.len());
|
||
|
||
while pos > 0 {
|
||
let prev = self.prev_atomic_boundary(pos);
|
||
match self.atomic_unit_class(prev, pos) {
|
||
Some(0) => pos = prev,
|
||
Some(_) => break,
|
||
None => return 0,
|
||
}
|
||
}
|
||
|
||
if pos == 0 {
|
||
return 0;
|
||
}
|
||
|
||
let target_class = self.atomic_unit_class(self.prev_atomic_boundary(pos), pos);
|
||
while pos > 0 {
|
||
let prev = self.prev_atomic_boundary(pos);
|
||
if self.atomic_unit_class(prev, pos) == target_class {
|
||
pos = prev;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
pos
|
||
}
|
||
|
||
/// Start of the previous whitespace-delimited WORD; elements count as
|
||
/// non-whitespace.
|
||
pub fn beginning_of_previous_unix_word(&self) -> usize {
|
||
let mut pos = self.cursor_pos.min(self.text.len());
|
||
|
||
// Skip the whitespace run (class 0) left of the cursor.
|
||
while pos > 0 {
|
||
let prev = self.prev_atomic_boundary(pos);
|
||
match self.atomic_unit_class(prev, pos) {
|
||
Some(0) => pos = prev,
|
||
Some(_) => break,
|
||
None => return 0,
|
||
}
|
||
}
|
||
|
||
// Consume non-whitespace units (word chars, punctuation, elements).
|
||
while pos > 0 {
|
||
let prev = self.prev_atomic_boundary(pos);
|
||
match self.atomic_unit_class(prev, pos) {
|
||
Some(0) | None => break,
|
||
Some(_) => pos = prev,
|
||
}
|
||
}
|
||
|
||
pos
|
||
}
|
||
|
||
/// Move to the end of the next navigable chunk.
|
||
///
|
||
/// Word characters are alphanumeric plus `_`. Punctuation runs (such as
|
||
/// `-`) are their own chunk, so moving right across `aa-bb` stops at the
|
||
/// left side of `-`, then the right side of `-`, then the end of `bb`.
|
||
/// Whitespace is skipped over. Elements remain atomic units.
|
||
pub fn end_of_next_word(&self) -> usize {
|
||
let mut pos = self.cursor_pos.min(self.text.len());
|
||
|
||
while pos < self.text.len() {
|
||
let next = self.next_atomic_boundary(pos);
|
||
match self.atomic_unit_class(pos, next) {
|
||
Some(0) => pos = next,
|
||
Some(_) => break,
|
||
None => return self.text.len(),
|
||
}
|
||
}
|
||
|
||
if pos >= self.text.len() {
|
||
return self.text.len();
|
||
}
|
||
|
||
let target_class = self.atomic_unit_class(pos, self.next_atomic_boundary(pos));
|
||
while pos < self.text.len() {
|
||
let next = self.next_atomic_boundary(pos);
|
||
if self.atomic_unit_class(pos, next) == target_class {
|
||
pos = next;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
pos
|
||
}
|
||
|
||
fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize {
|
||
if let Some(idx) = self.find_element_containing(pos) {
|
||
let e = &self.elements[idx];
|
||
if prefer_start {
|
||
e.range.start
|
||
} else {
|
||
e.range.end
|
||
}
|
||
} else {
|
||
pos
|
||
}
|
||
}
|
||
|
||
#[expect(clippy::unwrap_used)]
|
||
fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec<Range<usize>>> {
|
||
// A zero-width terminal must not reach textwrap — it can produce
|
||
// borrowed empty slices that don't point into the input buffer,
|
||
// causing out-of-bounds panics in wrap_ranges pointer arithmetic.
|
||
let width = width.max(1);
|
||
// Ensure cache is ready (potentially mutably borrow, then drop)
|
||
{
|
||
let mut cache = self.wrap_cache.borrow_mut();
|
||
let needs_recalc = match cache.as_ref() {
|
||
Some(c) => c.width != width,
|
||
None => true,
|
||
};
|
||
if needs_recalc {
|
||
let lines = if self.elements.iter().any(|e| e.display.is_some()) {
|
||
self.element_aware_wrap_ranges(width as usize)
|
||
} else {
|
||
crate::wrapping::wrap_ranges(
|
||
&self.text,
|
||
Options::new(width as usize)
|
||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||
)
|
||
};
|
||
*cache = Some(WrapCache { width, lines });
|
||
}
|
||
}
|
||
|
||
let cache = self.wrap_cache.borrow();
|
||
Ref::map(cache, |c| &c.as_ref().unwrap().lines)
|
||
}
|
||
|
||
/// Element-display-aware greedy wrapping.
|
||
///
|
||
/// Produces wrap ranges where each range is `start..end` with `end` being
|
||
/// the exclusive byte position of the content (including any trailing
|
||
/// spaces that belong to this visual line).
|
||
///
|
||
/// Elements are treated as atomic units for wrapping: if an element's
|
||
/// display width doesn't fit on the current line, the element is moved
|
||
/// to a new line (like word-wrap). If it doesn't fit on *any* line
|
||
/// (wider than terminal), it gets its own line and rendering truncates it.
|
||
fn element_aware_wrap_ranges(&self, width: usize) -> Vec<Range<usize>> {
|
||
let width = width.max(1);
|
||
let mut result = Vec::new();
|
||
|
||
// Process each logical line (split by \n), but skip \n inside elements.
|
||
// Newlines inside elements are internal to the element and must not create
|
||
// visual line breaks — the element's display is a single-line chip.
|
||
let mut seg_start = 0;
|
||
loop {
|
||
let seg_end = self.next_logical_newline(seg_start);
|
||
|
||
self.greedy_wrap_segment(seg_start, seg_end, width, &mut result);
|
||
|
||
if seg_end >= self.text.len() {
|
||
break;
|
||
}
|
||
seg_start = seg_end + 1; // skip \n
|
||
}
|
||
|
||
if result.is_empty() {
|
||
result.push(0..0);
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// Find the next `\n` at or after `from` that is NOT inside an element.
|
||
///
|
||
/// Returns `self.text.len()` if no such newline exists.
|
||
fn next_logical_newline(&self, from: usize) -> usize {
|
||
let mut pos = from;
|
||
while pos < self.text.len() {
|
||
// If pos is inside an element, skip past the entire element.
|
||
if let Some(elem) = self
|
||
.elements
|
||
.iter()
|
||
.find(|e| pos >= e.range.start && pos < e.range.end)
|
||
{
|
||
pos = elem.range.end;
|
||
continue;
|
||
}
|
||
if self.text.as_bytes()[pos] == b'\n' {
|
||
return pos;
|
||
}
|
||
pos += 1;
|
||
}
|
||
self.text.len()
|
||
}
|
||
|
||
/// Greedy-wrap a single logical line (no \n inside `start..end`).
|
||
fn greedy_wrap_segment(
|
||
&self,
|
||
start: usize,
|
||
end: usize,
|
||
width: usize,
|
||
result: &mut Vec<Range<usize>>,
|
||
) {
|
||
if start >= end {
|
||
// Empty logical line
|
||
result.push(start..end);
|
||
return;
|
||
}
|
||
|
||
let mut line_start = start;
|
||
let mut pos = start;
|
||
let mut display_w: usize = 0;
|
||
// Position right after the last break opportunity (start of the next word/element).
|
||
let mut last_break_pos: Option<usize> = None;
|
||
|
||
while pos < end {
|
||
// Check if pos is at the start of an element
|
||
if let Some(elem) = self
|
||
.elements
|
||
.iter()
|
||
.find(|e| pos == e.range.start && e.range.start < e.range.end)
|
||
{
|
||
let elem_end = elem.range.end.min(end);
|
||
let elem_dw: usize = if let Some(display) = &elem.display {
|
||
display
|
||
.spans
|
||
.iter()
|
||
.map(|s| s.content.as_ref().width())
|
||
.sum()
|
||
} else {
|
||
self.plain_display_width(&self.text[elem.range.start..elem_end])
|
||
};
|
||
|
||
if display_w > 0 && display_w + elem_dw > width {
|
||
// Element doesn't fit on current line — break before it.
|
||
let break_at = last_break_pos.unwrap_or(pos);
|
||
result.push(line_start..break_at);
|
||
line_start = break_at;
|
||
// Skip leading spaces/tabs on the new line
|
||
while line_start < end
|
||
&& line_start < pos
|
||
&& matches!(self.text.as_bytes().get(line_start), Some(b' ' | b'\t'))
|
||
{
|
||
line_start += 1;
|
||
}
|
||
pos = line_start;
|
||
display_w = 0;
|
||
last_break_pos = None;
|
||
continue;
|
||
}
|
||
|
||
display_w += elem_dw;
|
||
pos = elem_end;
|
||
// After element is a break opportunity
|
||
last_break_pos = Some(pos);
|
||
continue;
|
||
}
|
||
|
||
// Plain text grapheme cluster
|
||
let slice = &self.text[pos..end];
|
||
let Some(grapheme) = slice.graphemes(true).next() else {
|
||
break;
|
||
};
|
||
let grapheme_width = self.grapheme_display_width(grapheme);
|
||
|
||
if display_w + grapheme_width > width && display_w > 0 {
|
||
// Need to wrap
|
||
let break_at = last_break_pos.unwrap_or(pos);
|
||
result.push(line_start..break_at);
|
||
line_start = break_at;
|
||
// Skip leading spaces/tabs on the new line
|
||
while line_start < end
|
||
&& line_start < pos
|
||
&& matches!(self.text.as_bytes().get(line_start), Some(b' ' | b'\t'))
|
||
{
|
||
line_start += 1;
|
||
}
|
||
display_w = self.display_width_of_range(line_start, pos);
|
||
last_break_pos = None;
|
||
if line_start == pos {
|
||
// No break opportunity found; break at current position (break_words).
|
||
display_w = grapheme_width;
|
||
pos += grapheme.len();
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if grapheme == " " || grapheme == "\t" {
|
||
// Space/tab is a break opportunity; break point is after it.
|
||
last_break_pos = Some(pos + grapheme.len());
|
||
}
|
||
|
||
display_w += grapheme_width;
|
||
pos += grapheme.len();
|
||
}
|
||
|
||
// Final visual line of this logical line
|
||
result.push(line_start..end);
|
||
}
|
||
|
||
/// Calculate the scroll offset that should be used to satisfy the
|
||
/// invariants given the current area size and wrapped lines.
|
||
///
|
||
/// - Cursor is always on screen.
|
||
/// - No scrolling if content fits in the area.
|
||
fn effective_scroll(
|
||
&self,
|
||
area_height: u16,
|
||
lines: &[Range<usize>],
|
||
current_scroll: u16,
|
||
) -> u16 {
|
||
let total_lines = lines.len() as u16;
|
||
if area_height >= total_lines {
|
||
return 0;
|
||
}
|
||
|
||
let max_scroll = total_lines.saturating_sub(area_height);
|
||
|
||
// If we have an internal scroll override (from mousewheel), use it
|
||
// — but still clamp to valid range.
|
||
if let Some(ovr) = self.scroll_override {
|
||
return ovr.min(max_scroll);
|
||
}
|
||
|
||
// Where is the cursor within wrapped lines? Prefer assigning boundary positions
|
||
// (where pos equals the start of a wrapped line) to that later line.
|
||
let cursor_line_idx =
|
||
Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16;
|
||
|
||
let mut scroll = current_scroll.min(max_scroll);
|
||
|
||
// Ensure cursor is visible within [scroll, scroll + area_height)
|
||
if cursor_line_idx < scroll {
|
||
scroll = cursor_line_idx;
|
||
} else if cursor_line_idx >= scroll + area_height {
|
||
scroll = cursor_line_idx + 1 - area_height;
|
||
}
|
||
scroll
|
||
}
|
||
|
||
/// Compute the effective content width for text wrapping, accounting for
|
||
/// the scrollbar column. Uses a 2-shot approach:
|
||
///
|
||
/// 1. Wrap at full `area_width` to get line count.
|
||
/// 2. If scrollbar needed (lines > height) and `show_scrollbar`, reduce
|
||
/// width by 1 for the scrollbar track.
|
||
///
|
||
/// Returns `(content_width, needs_scrollbar)`.
|
||
fn content_width(&self, area_width: u16, area_height: u16) -> (u16, bool) {
|
||
if !self.show_scrollbar || area_width <= 1 {
|
||
return (area_width, false);
|
||
}
|
||
// First shot — wrap at full width to check if content overflows.
|
||
let lines = self.wrapped_lines(area_width);
|
||
let needs = lines.len() as u16 > area_height;
|
||
if needs {
|
||
// 1 for scrollbar track + padding gap
|
||
let reserved = 1 + self.scrollbar_padding;
|
||
(area_width.saturating_sub(reserved), true)
|
||
} else {
|
||
(area_width, false)
|
||
}
|
||
}
|
||
|
||
/// Convenience: content width for wrapping (area width minus scrollbar if needed).
|
||
fn text_width(&self, area: Rect) -> u16 {
|
||
self.content_width(area.width, area.height).0
|
||
}
|
||
}
|
||
|
||
impl WidgetRef for &TextArea {
|
||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||
let (cw, needs_sb) = self.content_width(area.width, area.height);
|
||
let content_area = Rect { width: cw, ..area };
|
||
let lines = self.wrapped_lines(cw);
|
||
self.render_lines(content_area, buf, &lines, 0..lines.len());
|
||
if needs_sb {
|
||
self.render_scrollbar(area, buf, lines.len() as u16, area.height, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
impl StatefulWidgetRef for &TextArea {
|
||
type State = TextAreaState;
|
||
|
||
fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||
let (cw, needs_sb) = self.content_width(area.width, area.height);
|
||
let content_area = Rect { width: cw, ..area };
|
||
let lines = self.wrapped_lines(cw);
|
||
let scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||
state.scroll = scroll;
|
||
|
||
let start = scroll as usize;
|
||
let end = (scroll + area.height).min(lines.len() as u16) as usize;
|
||
self.render_lines(content_area, buf, &lines, start..end);
|
||
if needs_sb {
|
||
self.render_scrollbar(area, buf, lines.len() as u16, area.height, scroll);
|
||
}
|
||
}
|
||
}
|
||
|
||
impl TextArea {
|
||
/// Render a scrollbar in the rightmost column of `area`.
|
||
///
|
||
/// Uses `tui_scrollbar::ScrollBar` rendered into a scratch ratatui-core
|
||
/// buffer, then copies cells into the main buffer with muted styling.
|
||
fn render_scrollbar(
|
||
&self,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
total_lines: u16,
|
||
viewport_lines: u16,
|
||
offset: u16,
|
||
) {
|
||
if total_lines <= viewport_lines || area.width == 0 || area.height == 0 {
|
||
return;
|
||
}
|
||
|
||
let sb_area = Rect {
|
||
x: area.right().saturating_sub(1),
|
||
y: area.y,
|
||
width: 1,
|
||
height: area.height,
|
||
};
|
||
|
||
let lengths = ScrollLengths {
|
||
content_len: total_lines as usize,
|
||
viewport_len: viewport_lines as usize,
|
||
};
|
||
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
|
||
|
||
// Render into ratatui-core scratch buffer then copy with styling.
|
||
let core_area = CoreRect {
|
||
x: sb_area.x,
|
||
y: sb_area.y,
|
||
width: sb_area.width,
|
||
height: sb_area.height,
|
||
};
|
||
let mut scratch = CoreBuffer::empty(core_area);
|
||
(&scrollbar).render(core_area, &mut scratch);
|
||
|
||
let track_style = self.scrollbar_track_style;
|
||
let thumb_style = self.scrollbar_thumb_style;
|
||
|
||
for row in 0..sb_area.height {
|
||
let x = sb_area.x;
|
||
let y = sb_area.y + row;
|
||
let src = &scratch[(x, y)];
|
||
let dst = &mut buf[(x, y)];
|
||
let symbol = src.symbol();
|
||
dst.set_symbol(symbol);
|
||
if symbol == " " {
|
||
dst.set_style(track_style);
|
||
} else {
|
||
dst.set_style(thumb_style);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_lines(
|
||
&self,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
lines: &[Range<usize>],
|
||
range: std::ops::Range<usize>,
|
||
) {
|
||
let area_right = area.x + area.width; // exclusive right boundary
|
||
let sel_range = self.selection_range();
|
||
|
||
for (row, idx) in range.enumerate() {
|
||
let r = &lines[idx];
|
||
let y = area.y + row as u16;
|
||
let line_range = r.start..r.end;
|
||
|
||
// Render the line segment-by-segment (plain text → element → plain text → …)
|
||
// using display-aware x positioning. This ensures that when an element's
|
||
// display text is wider (or narrower) than its buffer text, all subsequent
|
||
// content is positioned correctly.
|
||
let mut display_x: u16 = 0; // current display column
|
||
let mut buf_pos = line_range.start; // current position in the buffer
|
||
|
||
// Collect elements that overlap this visual line, in order.
|
||
let overlapping: Vec<&TextElement> = self
|
||
.elements
|
||
.iter()
|
||
.filter(|e| {
|
||
let os = e.range.start.max(line_range.start);
|
||
let oe = e.range.end.min(line_range.end);
|
||
os < oe
|
||
})
|
||
.collect();
|
||
|
||
for elem in &overlapping {
|
||
let overlap_start = elem.range.start.max(line_range.start);
|
||
let overlap_end = elem.range.end.min(line_range.end);
|
||
|
||
// 1. Render plain text before this element (buf_pos..overlap_start)
|
||
if buf_pos < overlap_start && display_x < area.width {
|
||
let plain = &self.text[buf_pos..overlap_start];
|
||
let avail = (area.width - display_x) as usize;
|
||
let (paint, paint_w) = paint_plain_for_display(plain, avail, self.tab_width);
|
||
buf.set_string(area.x + display_x, y, paint.as_ref(), Style::default());
|
||
display_x += paint_w as u16;
|
||
}
|
||
|
||
// 2. Render the element
|
||
if display_x >= area.width {
|
||
buf_pos = overlap_end;
|
||
continue;
|
||
}
|
||
|
||
let avail = (area.width - display_x) as usize;
|
||
|
||
if let Some(display) = &elem.display {
|
||
if overlap_start == elem.range.start {
|
||
// First visual line of the element — render display text.
|
||
let display = truncate_line_display(display, avail);
|
||
for span in &display.spans {
|
||
let content = span.content.as_ref();
|
||
let w = content.width() as u16;
|
||
if display_x >= area.width {
|
||
break;
|
||
}
|
||
buf.set_string(area.x + display_x, y, content, span.style);
|
||
display_x += w;
|
||
}
|
||
}
|
||
// If element spans multiple visual lines but has a display,
|
||
// subsequent lines show nothing for this element region (blank).
|
||
// display_x doesn't advance (already blank in the buffer).
|
||
} else {
|
||
// No custom display: render buffer text with default element style.
|
||
let styled = &self.text[overlap_start..overlap_end];
|
||
let style = Style::default().fg(Color::Cyan);
|
||
let (paint, paint_w) = paint_plain_for_display(styled, avail, self.tab_width);
|
||
buf.set_string(area.x + display_x, y, paint.as_ref(), style);
|
||
display_x += paint_w as u16;
|
||
}
|
||
|
||
buf_pos = overlap_end;
|
||
}
|
||
|
||
// 3. Render any remaining plain text after the last element
|
||
if buf_pos < line_range.end && display_x < area.width {
|
||
let plain = &self.text[buf_pos..line_range.end];
|
||
let avail = (area.width - display_x) as usize;
|
||
let (paint, paint_w) = paint_plain_for_display(plain, avail, self.tab_width);
|
||
buf.set_string(area.x + display_x, y, paint.as_ref(), Style::default());
|
||
// Keep display_x consistent with earlier segments (selection uses
|
||
// display_width_of_range on a second pass).
|
||
let _painted_end = display_x.saturating_add(paint_w as u16);
|
||
let _ = _painted_end;
|
||
}
|
||
|
||
// 4. Apply selection highlight (second pass over cells)
|
||
if let Some(sel_range) = &sel_range {
|
||
// Intersect the selection with this visual line's buffer range.
|
||
let line_sel_start = sel_range.start.max(line_range.start);
|
||
let line_sel_end = sel_range.end.min(line_range.end);
|
||
if line_sel_start < line_sel_end {
|
||
// Compute display column range for the selected portion.
|
||
let col_start =
|
||
self.display_width_of_range(line_range.start, line_sel_start) as u16;
|
||
let col_end =
|
||
self.display_width_of_range(line_range.start, line_sel_end) as u16;
|
||
let col_start = col_start.min(area.width);
|
||
let col_end = col_end.min(area.width);
|
||
for cx in col_start..col_end {
|
||
let cell = &mut buf[(area.x + cx, y)];
|
||
cell.set_style(self.selection_style);
|
||
}
|
||
}
|
||
}
|
||
|
||
let _ = area_right; // suppress unused warning (used for documentation)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Expand `\t` to a fixed number of spaces (`tab_width`), matching scrollback.
|
||
fn expand_tabs_with_width(text: &str, tab_width: u8) -> std::borrow::Cow<'_, str> {
|
||
if tab_width == 0 || !text.contains('\t') {
|
||
return std::borrow::Cow::Borrowed(text);
|
||
}
|
||
std::borrow::Cow::Owned(text.replace('\t', &" ".repeat(tab_width as usize)))
|
||
}
|
||
|
||
fn grapheme_display_width_with_tab(grapheme: &str, tab_width: u8) -> usize {
|
||
if grapheme == "\t" {
|
||
if tab_width == 0 {
|
||
0
|
||
} else {
|
||
tab_width as usize
|
||
}
|
||
} else {
|
||
grapheme.width()
|
||
}
|
||
}
|
||
|
||
fn plain_display_width_with_tab(text: &str, tab_width: u8) -> usize {
|
||
if tab_width == 0 || !text.contains('\t') {
|
||
return text.width();
|
||
}
|
||
text.graphemes(true)
|
||
.map(|g| grapheme_display_width_with_tab(g, tab_width))
|
||
.sum()
|
||
}
|
||
|
||
/// Clip a string to fit within `max_width` display columns (tabs = 0 width).
|
||
/// Returns a substring that is at most `max_width` columns wide.
|
||
fn clip_str_to_display_width(s: &str, max_width: usize) -> &str {
|
||
clip_str_to_display_width_with_tab(s, max_width, 0)
|
||
}
|
||
|
||
/// Clip considering tabs as `tab_width` columns (byte index into original `s`).
|
||
fn clip_str_to_display_width_with_tab(s: &str, max_width: usize, tab_width: u8) -> &str {
|
||
let mut width = 0;
|
||
for (i, grapheme) in s.grapheme_indices(true) {
|
||
let grapheme_width = grapheme_display_width_with_tab(grapheme, tab_width);
|
||
if width + grapheme_width > max_width {
|
||
return &s[..i];
|
||
}
|
||
width += grapheme_width;
|
||
}
|
||
s
|
||
}
|
||
|
||
/// Clip and expand tabs so paint width matches cursor/display-width math.
|
||
/// Returns (paint string, display columns used). Borrows when no expansion needed.
|
||
fn paint_plain_for_display(
|
||
s: &str,
|
||
max_width: usize,
|
||
tab_width: u8,
|
||
) -> (std::borrow::Cow<'_, str>, usize) {
|
||
let clipped = clip_str_to_display_width_with_tab(s, max_width, tab_width);
|
||
let paint = expand_tabs_with_width(clipped, tab_width);
|
||
let w = plain_display_width_with_tab(clipped, tab_width);
|
||
(paint, w)
|
||
}
|
||
|
||
/// Truncate a display `Line` to fit within `max_width` columns.
|
||
///
|
||
/// If the line fits, it is returned as-is (cloned). If it overflows:
|
||
/// - Reserve 1 column for `…`.
|
||
/// - **Bracket-preservation heuristic:** if the display text ends with a closing
|
||
/// bracket (`]`, `)`, `}`, `>`), preserve it so e.g. `[Pasted ~10 lines]`
|
||
/// becomes `[Pasted ~1…]` rather than `[Pasted ~10…`.
|
||
/// - Otherwise, truncate and append `…`.
|
||
fn truncate_line_display(line: &Line<'static>, max_width: usize) -> Line<'static> {
|
||
use ratatui::text::Span;
|
||
|
||
let total_width: usize = line.spans.iter().map(|s| s.content.as_ref().width()).sum();
|
||
if total_width <= max_width {
|
||
return line.clone();
|
||
}
|
||
if max_width == 0 {
|
||
return Line::default();
|
||
}
|
||
|
||
// Determine if we should preserve a closing bracket.
|
||
let last_char = line
|
||
.spans
|
||
.iter()
|
||
.rev()
|
||
.find_map(|s| s.content.as_ref().chars().last());
|
||
let (preserve_bracket, bracket_char, bracket_style) = match last_char {
|
||
Some(ch @ (']' | ')' | '}' | '>')) => {
|
||
// Find the style of the last span containing this char.
|
||
let style = line.spans.last().map(|s| s.style).unwrap_or_default();
|
||
(true, Some(ch), style)
|
||
}
|
||
_ => (false, None, Style::default()),
|
||
};
|
||
|
||
// Budget: max_width minus 1 for '…', minus 1 for bracket if preserving.
|
||
// If max_width is too small for both ellipsis and bracket, skip bracket.
|
||
let preserve_bracket = preserve_bracket && max_width >= 3;
|
||
let content_budget = if preserve_bracket {
|
||
max_width.saturating_sub(2) // 1 for …, 1 for bracket
|
||
} else {
|
||
max_width.saturating_sub(1) // 1 for …
|
||
};
|
||
|
||
let mut new_spans: Vec<Span<'static>> = Vec::new();
|
||
let mut used = 0usize;
|
||
|
||
for span in &line.spans {
|
||
let content = span.content.as_ref();
|
||
let sw = content.width();
|
||
if used + sw <= content_budget {
|
||
new_spans.push(span.clone());
|
||
used += sw;
|
||
} else {
|
||
// Partially include this span without splitting a grapheme cluster.
|
||
let remaining = content_budget - used;
|
||
if remaining > 0 {
|
||
let partial = clip_str_to_display_width(content, remaining);
|
||
if !partial.is_empty() {
|
||
new_spans.push(Span::styled(partial.to_string(), span.style));
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Append ellipsis (inherits style of last content span, or default).
|
||
let ellipsis_style = new_spans.last().map(|s| s.style).unwrap_or_default();
|
||
new_spans.push(Span::styled("…", ellipsis_style));
|
||
|
||
// Append preserved bracket if applicable.
|
||
if preserve_bracket && let Some(ch) = bracket_char {
|
||
new_spans.push(Span::styled(ch.to_string(), bracket_style));
|
||
}
|
||
|
||
Line::from(new_spans)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
// crossterm types are intentionally not imported here to avoid unused warnings
|
||
use rand::prelude::*;
|
||
|
||
fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String {
|
||
let r: u8 = rng.random_range(0..100);
|
||
match r {
|
||
0..=4 => "\n".to_string(),
|
||
5..=12 => " ".to_string(),
|
||
13..=35 => (rng.random_range(b'a'..=b'z') as char).to_string(),
|
||
36..=45 => (rng.random_range(b'A'..=b'Z') as char).to_string(),
|
||
46..=52 => (rng.random_range(b'0'..=b'9') as char).to_string(),
|
||
53..=65 => {
|
||
// Some emoji (wide graphemes)
|
||
let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"];
|
||
choices[rng.random_range(0..choices.len())].to_string()
|
||
}
|
||
66..=75 => {
|
||
// CJK wide characters
|
||
let choices = ["漢", "字", "測", "試", "你", "好", "界", "编", "码"];
|
||
choices[rng.random_range(0..choices.len())].to_string()
|
||
}
|
||
76..=85 => {
|
||
// Combining mark sequences
|
||
let base = ["e", "a", "o", "n", "u"][rng.random_range(0..5)];
|
||
let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"];
|
||
format!("{base}{}", marks[rng.random_range(0..marks.len())])
|
||
}
|
||
86..=92 => {
|
||
// Some non-latin single codepoints (Greek, Cyrillic, Hebrew)
|
||
let choices = ["Ω", "β", "Ж", "ю", "ש", "م", "ह"];
|
||
choices[rng.random_range(0..choices.len())].to_string()
|
||
}
|
||
_ => {
|
||
// ZWJ sequences (single graphemes but multi-codepoint)
|
||
let choices = [
|
||
"👩\u{200D}💻", // woman technologist
|
||
"👨\u{200D}💻", // man technologist
|
||
"🏳️\u{200D}🌈", // rainbow flag
|
||
];
|
||
choices[rng.random_range(0..choices.len())].to_string()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn ta_with(text: &str) -> TextArea {
|
||
let mut t = TextArea::new();
|
||
t.insert_str(text);
|
||
t
|
||
}
|
||
|
||
#[test]
|
||
fn is_undo_input_accepts_ctrl_and_cmd_z() {
|
||
assert!(is_undo_input(&KeyEvent::new(
|
||
KeyCode::Char('z'),
|
||
KeyModifiers::CONTROL
|
||
)));
|
||
assert!(is_undo_input(&KeyEvent::new(
|
||
KeyCode::Char('z'),
|
||
KeyModifiers::SUPER
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn is_undo_input_rejects_redo_and_plain_z() {
|
||
// Uppercase 'Z' (redo) stays excluded so the guard is disjoint from
|
||
// the redo arm regardless of match order.
|
||
assert!(!is_undo_input(&KeyEvent::new(
|
||
KeyCode::Char('Z'),
|
||
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
||
)));
|
||
// A bare 'z' (no chord modifier) is plain typing, not undo.
|
||
assert!(!is_undo_input(&KeyEvent::new(
|
||
KeyCode::Char('z'),
|
||
KeyModifiers::NONE
|
||
)));
|
||
assert!(!is_undo_input(&KeyEvent::new(
|
||
KeyCode::Char('z'),
|
||
KeyModifiers::SHIFT
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn insert_and_replace_update_cursor_and_text() {
|
||
// insert helpers
|
||
let mut t = ta_with("hello");
|
||
t.set_cursor(5);
|
||
t.insert_str("!");
|
||
assert_eq!(t.text(), "hello!");
|
||
assert_eq!(t.cursor(), 6);
|
||
|
||
t.insert_str_at(0, "X");
|
||
assert_eq!(t.text(), "Xhello!");
|
||
assert_eq!(t.cursor(), 7);
|
||
|
||
// Insert after the cursor should not move it
|
||
t.set_cursor(1);
|
||
let end = t.text().len();
|
||
t.insert_str_at(end, "Y");
|
||
assert_eq!(t.text(), "Xhello!Y");
|
||
assert_eq!(t.cursor(), 1);
|
||
|
||
// replace_range cases
|
||
// 1) cursor before range
|
||
let mut t = ta_with("abcd");
|
||
t.set_cursor(1);
|
||
t.replace_range(2..3, "Z");
|
||
assert_eq!(t.text(), "abZd");
|
||
assert_eq!(t.cursor(), 1);
|
||
|
||
// 2) cursor inside range
|
||
let mut t = ta_with("abcd");
|
||
t.set_cursor(2);
|
||
t.replace_range(1..3, "Q");
|
||
assert_eq!(t.text(), "aQd");
|
||
assert_eq!(t.cursor(), 2);
|
||
|
||
// 3) cursor after range with shifted by diff
|
||
let mut t = ta_with("abcd");
|
||
t.set_cursor(4);
|
||
t.replace_range(0..1, "AA");
|
||
assert_eq!(t.text(), "AAbcd");
|
||
assert_eq!(t.cursor(), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn delete_backward_and_forward_edges() {
|
||
let mut t = ta_with("abc");
|
||
t.set_cursor(1);
|
||
t.delete_backward(1);
|
||
assert_eq!(t.text(), "bc");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// deleting backward at start is a no-op
|
||
t.set_cursor(0);
|
||
t.delete_backward(1);
|
||
assert_eq!(t.text(), "bc");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// forward delete removes next grapheme
|
||
t.set_cursor(1);
|
||
t.delete_forward(1);
|
||
assert_eq!(t.text(), "b");
|
||
assert_eq!(t.cursor(), 1);
|
||
|
||
// forward delete at end is a no-op
|
||
t.set_cursor(t.text().len());
|
||
t.delete_forward(1);
|
||
assert_eq!(t.text(), "b");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_backward_word_and_kill_line_variants() {
|
||
// delete backward word at end removes the whole previous word
|
||
let mut t = ta_with("hello world ");
|
||
t.set_cursor(t.text().len());
|
||
t.delete_backward_word();
|
||
assert_eq!(t.text(), "hello ");
|
||
assert_eq!(t.cursor(), 8);
|
||
|
||
// From inside a word, delete from word start to cursor
|
||
let mut t = ta_with("foo bar");
|
||
t.set_cursor(6); // inside "bar" (after 'a')
|
||
t.delete_backward_word();
|
||
assert_eq!(t.text(), "foo r");
|
||
assert_eq!(t.cursor(), 4);
|
||
|
||
// From end, delete the last word only
|
||
let mut t = ta_with("foo bar");
|
||
t.set_cursor(t.text().len());
|
||
t.delete_backward_word();
|
||
assert_eq!(t.text(), "foo ");
|
||
assert_eq!(t.cursor(), 4);
|
||
|
||
let mut t = ta_with("hello-world");
|
||
t.set_cursor(t.text().len());
|
||
t.delete_backward_word();
|
||
assert_eq!(t.text(), "hello-");
|
||
assert_eq!(t.cursor(), "hello-".len());
|
||
|
||
// kill_to_end_of_line when not at EOL
|
||
let mut t = ta_with("abc\ndef");
|
||
t.set_cursor(1); // on first line, middle
|
||
t.kill_to_end_of_line();
|
||
assert_eq!(t.text(), "a\ndef");
|
||
assert_eq!(t.cursor(), 1);
|
||
|
||
// kill_to_end_of_line when at EOL deletes newline
|
||
let mut t = ta_with("abc\ndef");
|
||
t.set_cursor(3); // EOL of first line
|
||
t.kill_to_end_of_line();
|
||
assert_eq!(t.text(), "abcdef");
|
||
assert_eq!(t.cursor(), 3);
|
||
|
||
// kill_to_beginning_of_line from middle of line
|
||
let mut t = ta_with("abc\ndef");
|
||
t.set_cursor(5); // on second line, after 'e'
|
||
t.kill_to_beginning_of_line();
|
||
assert_eq!(t.text(), "abc\nef");
|
||
|
||
// kill_to_beginning_of_line at beginning of non-first line removes the previous newline
|
||
let mut t = ta_with("abc\ndef");
|
||
t.set_cursor(4); // beginning of second line
|
||
t.kill_to_beginning_of_line();
|
||
assert_eq!(t.text(), "abcdef");
|
||
assert_eq!(t.cursor(), 3);
|
||
|
||
// kill_current_line from middle of single line
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(5);
|
||
t.kill_current_line();
|
||
assert_eq!(t.text(), "");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// kill_current_line from middle of multiline
|
||
let mut t = ta_with("abc\ndef\nghi");
|
||
t.set_cursor(5);
|
||
t.kill_current_line();
|
||
assert_eq!(t.text(), "abc\n\nghi");
|
||
assert_eq!(t.cursor(), 4);
|
||
|
||
// kill_current_line on empty line joins with previous
|
||
let mut t = ta_with("abc\n\nghi");
|
||
t.set_cursor(4);
|
||
t.kill_current_line();
|
||
assert_eq!(t.text(), "abc\nghi");
|
||
assert_eq!(t.cursor(), 3);
|
||
|
||
// kill_current_line at beginning of only line
|
||
let mut t = ta_with("hello");
|
||
t.set_cursor(0);
|
||
t.kill_current_line();
|
||
assert_eq!(t.text(), "");
|
||
assert_eq!(t.cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn delete_forward_word_variants() {
|
||
let mut t = ta_with("hello world ");
|
||
t.set_cursor(0);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), " world ");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
let mut t = ta_with("hello world ");
|
||
t.set_cursor(1);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), "h world ");
|
||
assert_eq!(t.cursor(), 1);
|
||
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(t.text().len());
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), "hello world");
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
|
||
let mut t = ta_with("foo \nbar");
|
||
t.set_cursor(3);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), "foo");
|
||
assert_eq!(t.cursor(), 3);
|
||
|
||
let mut t = ta_with("foo\nbar");
|
||
t.set_cursor(3);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), "foo");
|
||
assert_eq!(t.cursor(), 3);
|
||
|
||
let mut t = ta_with("hello-world");
|
||
t.set_cursor(0);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), "-world");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
let mut t = ta_with("hello world ");
|
||
t.set_cursor(t.text().len() + 10);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), "hello world ");
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn super_right_moves_to_end_of_line() {
|
||
let mut t = ta_with("hello world\nsecond line");
|
||
t.set_cursor(3); // middle of "hello world"
|
||
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::SUPER));
|
||
assert_eq!(t.cursor(), 11); // end of "hello world" (before \n)
|
||
|
||
// Already at end of line → stays there
|
||
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::SUPER));
|
||
assert_eq!(t.cursor(), 11);
|
||
}
|
||
|
||
#[test]
|
||
fn super_left_moves_to_beginning_of_line() {
|
||
let mut t = ta_with("hello world\nsecond line");
|
||
let second_line_start = t.text().find("second").unwrap();
|
||
t.set_cursor(second_line_start + 4); // middle of "second line"
|
||
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::SUPER));
|
||
assert_eq!(t.cursor(), second_line_start);
|
||
|
||
// Already at beginning of line → stays there
|
||
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::SUPER));
|
||
assert_eq!(t.cursor(), second_line_start);
|
||
}
|
||
|
||
#[test]
|
||
fn super_backspace_kills_to_beginning_of_line() {
|
||
let mut t = ta_with("hello world\nsecond line");
|
||
let second_line_start = t.text().find("second").unwrap();
|
||
t.set_cursor(second_line_start + 7); // after "second "
|
||
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER));
|
||
assert_eq!(t.text(), "hello world\nline");
|
||
assert_eq!(t.cursor(), second_line_start);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_u_kills_to_beginning_of_line_keeps_text_after_cursor() {
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(5);
|
||
t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), " world");
|
||
assert_eq!(t.cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn delete_forward_word_handles_atomic_elements() {
|
||
let kind = ElementKind(0);
|
||
|
||
let mut t = TextArea::new();
|
||
t.insert_element("<element>", kind, None);
|
||
t.insert_str(" tail");
|
||
|
||
t.set_cursor(0);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), " tail");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
let mut t = TextArea::new();
|
||
t.insert_str(" ");
|
||
t.insert_element("<element>", kind, None);
|
||
t.insert_str(" tail");
|
||
|
||
t.set_cursor(0);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), " tail");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
let mut t = TextArea::new();
|
||
t.insert_str("prefix ");
|
||
t.insert_element("<element>", kind, None);
|
||
t.insert_str(" tail");
|
||
|
||
// cursor in the middle of the element, delete_forward_word deletes the element
|
||
let elem_range = t.elements()[0].range.clone();
|
||
t.cursor_pos = elem_range.start + (elem_range.len() / 2);
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), "prefix tail");
|
||
assert_eq!(t.cursor(), elem_range.start);
|
||
}
|
||
|
||
// ===== Phase 1: Typed element tests =====
|
||
|
||
#[test]
|
||
fn element_id_is_unique_and_stable() {
|
||
let mut t = TextArea::new();
|
||
let kind = ElementKind(1);
|
||
|
||
let id1 = t.insert_element("aaa", kind, None);
|
||
let id2 = t.insert_element("bbb", kind, None);
|
||
assert_ne!(id1, id2);
|
||
|
||
// ids survive after deletion of the first element
|
||
t.set_cursor(0);
|
||
t.delete_forward(1); // deletes "aaa" atomically
|
||
assert_eq!(t.elements().len(), 1);
|
||
assert_eq!(t.elements()[0].id, id2);
|
||
}
|
||
|
||
#[test]
|
||
fn element_kind_preserved() {
|
||
let mut t = TextArea::new();
|
||
let kind_paste = ElementKind(1);
|
||
let kind_file = ElementKind(2);
|
||
|
||
t.insert_element("paste", kind_paste, None);
|
||
t.insert_element("file", kind_file, None);
|
||
|
||
assert_eq!(t.elements()[0].kind, kind_paste);
|
||
assert_eq!(t.elements()[1].kind, kind_file);
|
||
}
|
||
|
||
#[test]
|
||
fn element_at_cursor_returns_element() {
|
||
let mut t = TextArea::new();
|
||
let kind = ElementKind(0);
|
||
|
||
t.insert_str("before ");
|
||
let id = t.insert_element("[paste]", kind, None);
|
||
t.insert_str(" after");
|
||
|
||
// Cursor is at end of element after insert_element
|
||
// Move to start of element
|
||
t.set_cursor(7); // "before " is 7 bytes, element starts at 7
|
||
let elem = t.element_at_cursor().expect("should find element");
|
||
assert_eq!(elem.id, id);
|
||
assert_eq!(elem.kind, kind);
|
||
|
||
// Cursor before element
|
||
t.set_cursor(0);
|
||
assert!(t.element_at_cursor().is_none());
|
||
|
||
// Cursor after element
|
||
t.set_cursor(t.text().len());
|
||
assert!(t.element_at_cursor().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn element_text_returns_buffer_text() {
|
||
let mut t = TextArea::new();
|
||
let id = t.insert_element("raw buffer content", ElementKind(0), None);
|
||
assert_eq!(t.element_text(id), Some("raw buffer content"));
|
||
|
||
// Non-existent id returns None
|
||
let fake_id = ElementId(9999);
|
||
assert_eq!(t.element_text(fake_id), None);
|
||
}
|
||
|
||
#[test]
|
||
fn element_display_can_be_set_and_updated() {
|
||
let mut t = TextArea::new();
|
||
let display = Line::from("[Pasted 5 lines]");
|
||
let id = t.insert_element("lots of raw text here", ElementKind(1), Some(display));
|
||
|
||
// Verify display is set
|
||
let elem = &t.elements()[0];
|
||
assert!(elem.display.is_some());
|
||
assert_eq!(
|
||
elem.display.as_ref().unwrap().to_string(),
|
||
"[Pasted 5 lines]"
|
||
);
|
||
|
||
// Update display
|
||
let new_display = Line::from("[Pasted 5 lines, 200 chars]");
|
||
t.set_element_display(id, Some(new_display));
|
||
let elem = &t.elements()[0];
|
||
assert_eq!(
|
||
elem.display.as_ref().unwrap().to_string(),
|
||
"[Pasted 5 lines, 200 chars]"
|
||
);
|
||
|
||
// Clear display
|
||
t.set_element_display(id, None);
|
||
assert!(t.elements()[0].display.is_none());
|
||
|
||
// Buffer text is unchanged
|
||
assert_eq!(t.element_text(id), Some("lots of raw text here"));
|
||
}
|
||
|
||
#[test]
|
||
fn insert_element_returns_id_for_metadata_tracking() {
|
||
let mut t = TextArea::new();
|
||
let mut metadata: std::collections::HashMap<ElementId, String> =
|
||
std::collections::HashMap::new();
|
||
|
||
let id1 = t.insert_element("paste1", ElementKind(1), None);
|
||
metadata.insert(id1, "First paste".to_string());
|
||
|
||
let id2 = t.insert_element("paste2", ElementKind(1), None);
|
||
metadata.insert(id2, "Second paste".to_string());
|
||
|
||
// Verify we can look up metadata by id
|
||
assert_eq!(metadata.get(&id1), Some(&"First paste".to_string()));
|
||
assert_eq!(metadata.get(&id2), Some(&"Second paste".to_string()));
|
||
|
||
// Delete first element
|
||
t.set_cursor(0);
|
||
t.delete_forward(1);
|
||
|
||
// id2 still valid in our metadata map
|
||
let remaining = &t.elements()[0];
|
||
assert_eq!(remaining.id, id2);
|
||
assert_eq!(
|
||
metadata.get(&remaining.id),
|
||
Some(&"Second paste".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn elements_returns_sorted_slice() {
|
||
let mut t = TextArea::new();
|
||
let kind = ElementKind(0);
|
||
|
||
t.insert_str("aaa ");
|
||
t.insert_element("BBB", kind, None);
|
||
t.insert_str(" ccc ");
|
||
t.insert_element("DDD", kind, None);
|
||
|
||
let elems = t.elements();
|
||
assert_eq!(elems.len(), 2);
|
||
assert!(elems[0].range.start < elems[1].range.start);
|
||
assert_eq!(&t.text()[elems[0].range.clone()], "BBB");
|
||
assert_eq!(&t.text()[elems[1].range.clone()], "DDD");
|
||
}
|
||
|
||
// ===== Phase 2: Display rendering & truncation tests =====
|
||
|
||
#[test]
|
||
fn render_element_with_display_shows_display_text() {
|
||
use ratatui::style::Stylize;
|
||
|
||
let mut t = TextArea::new();
|
||
let display = Line::from("[Pasted]".cyan());
|
||
t.insert_element("raw content here", ElementKind(0), Some(display));
|
||
|
||
let area = Rect::new(0, 0, 20, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
// The rendered buffer should show "[Pasted]" not "raw content here"
|
||
let rendered: String = (0..area.width)
|
||
.map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
|
||
.collect::<String>();
|
||
let rendered = rendered.trim_end();
|
||
assert_eq!(rendered, "[Pasted]");
|
||
}
|
||
|
||
#[test]
|
||
fn render_element_without_display_shows_buffer_text_cyan() {
|
||
let mut t = TextArea::new();
|
||
t.insert_element("hello", ElementKind(0), None);
|
||
|
||
let area = Rect::new(0, 0, 20, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
// Should show "hello" with cyan foreground
|
||
let cell = buf.cell((0, 0)).unwrap();
|
||
assert_eq!(cell.symbol(), "h");
|
||
assert_eq!(cell.fg, Color::Cyan);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_line_display_no_truncation_needed() {
|
||
let line = Line::from("[Short]");
|
||
let result = truncate_line_display(&line, 20);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
assert_eq!(text, "[Short]");
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_line_display_with_bracket_preservation() {
|
||
let line: Line<'static> = Line::from("[Pasted ~100 lines]");
|
||
// Width 12: budget = 12 - 2 (ellipsis + bracket) = 10 chars content
|
||
let result = truncate_line_display(&line, 12);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
assert!(text.ends_with(']'), "should preserve ]: got {text:?}");
|
||
assert!(text.contains('…'), "should contain ellipsis: got {text:?}");
|
||
assert_eq!(text, "[Pasted ~1…]");
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_line_display_without_bracket() {
|
||
let line: Line<'static> = Line::from("very long display text");
|
||
let result = truncate_line_display(&line, 10);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
assert!(text.contains('…'));
|
||
assert!(!text.ends_with(']'));
|
||
// 9 chars content + 1 ellipsis = 10
|
||
assert_eq!(text, "very long…");
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_line_display_zero_width() {
|
||
let line: Line<'static> = Line::from("[Pasted]");
|
||
let result = truncate_line_display(&line, 0);
|
||
assert!(result.spans.is_empty() || result.width() == 0);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_line_display_width_1() {
|
||
let line: Line<'static> = Line::from("[Pasted]");
|
||
let result = truncate_line_display(&line, 1);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
// Only room for "…" (the bracket can't fit with content)
|
||
assert_eq!(text, "…");
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_preserves_multi_span_styles() {
|
||
use ratatui::text::Span;
|
||
|
||
let line: Line<'static> = Line::from(vec![
|
||
Span::styled("[", Style::default().fg(Color::Yellow)),
|
||
Span::styled("Pasted ~100 lines", Style::default().fg(Color::Cyan)),
|
||
Span::styled("]", Style::default().fg(Color::Yellow)),
|
||
]);
|
||
let result = truncate_line_display(&line, 10);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
assert!(text.ends_with(']'));
|
||
assert!(text.contains('…'));
|
||
// "[" (1) + content budget (10-2=8) + "…" (1) + "]" (1) = 11? No...
|
||
// Budget: 10 - 2 = 8 for content. "[" is 1, so 7 more chars of "Pasted ~"
|
||
// Result: "[Pasted …]" which is 10 wide
|
||
assert!(
|
||
result.width() <= 10,
|
||
"width should be <= 10, got {}",
|
||
result.width()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_element_with_prefix_text() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("hi ");
|
||
let display = Line::from("[P]");
|
||
t.insert_element("raw", ElementKind(0), Some(display));
|
||
|
||
let area = Rect::new(0, 0, 20, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
let rendered: String = (0..area.width)
|
||
.map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
|
||
.collect::<String>();
|
||
let rendered = rendered.trim_end();
|
||
assert_eq!(rendered, "hi [P]");
|
||
}
|
||
|
||
#[test]
|
||
fn render_text_after_element_uses_display_width() {
|
||
// User scenario: "foo " + element("Clean build", display="[📎 Pasted 1 line, 11 chars]") + " abcde"
|
||
// Display: "[📎 Pasted 1 line, 11 chars]" = 1+2+1+23+1 = 28 display cols
|
||
// Buffer: "Clean build" = 11 bytes
|
||
// Without fix, text after element renders at buffer x, overlapping with element display.
|
||
let mut t = TextArea::new();
|
||
t.insert_str("foo ");
|
||
let display = Line::from(vec![
|
||
ratatui::text::Span::raw("["),
|
||
ratatui::text::Span::raw("📎 "),
|
||
ratatui::text::Span::raw("Pasted 1 line, 11 chars"),
|
||
ratatui::text::Span::raw("]"),
|
||
]);
|
||
t.insert_element("Clean build", ElementKind(0), Some(display));
|
||
t.insert_str(" abcde");
|
||
|
||
// Area wide enough to fit everything
|
||
let area = Rect::new(0, 0, 80, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
// Verify key cells: text before element, element display, text after element.
|
||
// "foo " occupies cols 0-3
|
||
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "f");
|
||
assert_eq!(buf.cell((3, 0)).unwrap().symbol(), " ");
|
||
|
||
// Element display starts at col 4: "[📎 Pasted 1 line, 11 chars]"
|
||
assert_eq!(buf.cell((4, 0)).unwrap().symbol(), "[");
|
||
assert_eq!(buf.cell((5, 0)).unwrap().symbol(), "📎");
|
||
// col 6 is the wide-char continuation cell
|
||
assert_eq!(buf.cell((7, 0)).unwrap().symbol(), " ");
|
||
assert_eq!(buf.cell((8, 0)).unwrap().symbol(), "P");
|
||
|
||
// Element display ends at col 31 ("]")
|
||
assert_eq!(buf.cell((31, 0)).unwrap().symbol(), "]");
|
||
|
||
// Text after element: " abcde" starting at col 32
|
||
assert_eq!(buf.cell((32, 0)).unwrap().symbol(), " ");
|
||
assert_eq!(buf.cell((33, 0)).unwrap().symbol(), "a");
|
||
assert_eq!(buf.cell((34, 0)).unwrap().symbol(), "b");
|
||
assert_eq!(buf.cell((35, 0)).unwrap().symbol(), "c");
|
||
assert_eq!(buf.cell((36, 0)).unwrap().symbol(), "d");
|
||
assert_eq!(buf.cell((37, 0)).unwrap().symbol(), "e");
|
||
}
|
||
|
||
#[test]
|
||
fn render_text_after_wider_display_element_simple() {
|
||
// Simpler case: element buffer text "x" (1 byte), display "[LONG]" (6 cols)
|
||
// Suffix text "!" should render at column 6, not column 1.
|
||
let mut t = TextArea::new();
|
||
let display = Line::from("[LONG]");
|
||
t.insert_element("x", ElementKind(0), Some(display));
|
||
t.insert_str("!");
|
||
|
||
let area = Rect::new(0, 0, 20, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
let rendered: String = (0..area.width)
|
||
.map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
|
||
.collect::<String>();
|
||
let rendered = rendered.trim_end();
|
||
assert_eq!(rendered, "[LONG]!");
|
||
}
|
||
|
||
// ===== Phase 3: Display projection tests =====
|
||
|
||
#[test]
|
||
fn display_width_of_range_plain_text() {
|
||
let t = ta_with("hello world");
|
||
assert_eq!(t.display_width_of_range(0, 5), 5); // "hello"
|
||
assert_eq!(t.display_width_of_range(0, 11), 11); // "hello world"
|
||
assert_eq!(t.display_width_of_range(6, 11), 5); // "world"
|
||
}
|
||
|
||
#[test]
|
||
fn insert_expands_tabs_to_spaces() {
|
||
let mut t = TextArea::new();
|
||
assert_eq!(t.tab_width(), 4);
|
||
t.insert_str("a\tb");
|
||
assert_eq!(t.text(), "a b");
|
||
assert_eq!(t.cursor(), 6);
|
||
assert_eq!(t.display_width_of_range(0, t.text().len()), 6);
|
||
|
||
let mut t2 = TextArea::new();
|
||
t2.set_tab_width(8);
|
||
t2.insert_str("x\ty");
|
||
assert_eq!(t2.text(), "x y");
|
||
assert_eq!(t2.cursor(), 10);
|
||
assert_eq!(t2.display_width_of_range(0, t2.text().len()), 10);
|
||
|
||
let mut t3 = TextArea::new();
|
||
t3.insert_str("\ta");
|
||
assert_eq!(t3.text(), " a");
|
||
t3.set_text("");
|
||
t3.insert_str("a\t");
|
||
assert_eq!(t3.text(), "a ");
|
||
assert_eq!(t3.cursor(), 5);
|
||
t3.set_text("");
|
||
t3.insert_str("\t\t");
|
||
assert_eq!(t3.text(), " ");
|
||
assert_eq!(t3.display_width_of_range(0, 8), 8);
|
||
t3.insert_str("");
|
||
assert_eq!(t3.text(), " ");
|
||
t3.insert_str_at(0, "z\t");
|
||
assert_eq!(t3.text(), "z ");
|
||
}
|
||
|
||
#[test]
|
||
fn set_text_and_replace_expand_tabs() {
|
||
let mut t = TextArea::new();
|
||
t.set_text("col1\tcol2");
|
||
assert_eq!(t.text(), "col1 col2");
|
||
|
||
t.replace_range(4..8, "\t");
|
||
assert_eq!(t.text(), "col1 col2");
|
||
|
||
t.replace_range(4..4, "\tx");
|
||
assert_eq!(t.text(), "col1 x col2");
|
||
// Insert-only replace places cursor at end of inserted expansion (4 spaces + 'x').
|
||
assert_eq!(&t.text()[4..9], " x");
|
||
|
||
let mut t0 = TextArea::new();
|
||
t0.set_tab_width(0);
|
||
t0.insert_str("a\tb");
|
||
assert_eq!(t0.text(), "a\tb");
|
||
t0.set_text("x\ty");
|
||
assert_eq!(t0.text(), "x\ty");
|
||
// Passthrough: display width matches unicode-width (no expansion).
|
||
assert_eq!(
|
||
t0.display_width_of_range(0, t0.text().len()),
|
||
"x\ty".width()
|
||
);
|
||
t0.set_cursor(t0.text().len());
|
||
let area = Rect::new(0, 0, 80, 1);
|
||
let (x, _y) = t0.cursor_pos(area).unwrap();
|
||
assert_eq!(x as usize, "x\ty".width());
|
||
}
|
||
|
||
#[test]
|
||
fn remaining_tabs_count_in_display_width_and_cursor() {
|
||
// Simulate leftover tabs without going through expand (tab_width set after set_text
|
||
// would still expand on set_text; inject via tab_width=0 then enable display tabs).
|
||
let mut t = TextArea::new();
|
||
t.set_tab_width(0);
|
||
t.set_text("a\tb\tc");
|
||
assert!(t.text().contains('\t'));
|
||
t.set_tab_width(4);
|
||
// "a" + 4 + "b" + 4 + "c" = 11
|
||
assert_eq!(t.display_width_of_range(0, t.text().len()), 11);
|
||
t.set_cursor(t.text().len());
|
||
let area = Rect::new(0, 0, 80, 1);
|
||
let (x, _y) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(x, 11);
|
||
}
|
||
|
||
#[test]
|
||
fn set_tab_width_does_not_rewrite_existing_spaces() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("a\tb");
|
||
assert_eq!(t.text(), "a b");
|
||
t.set_tab_width(8);
|
||
assert_eq!(t.text(), "a b");
|
||
t.insert_str("\tc");
|
||
assert_eq!(t.text(), "a b c");
|
||
}
|
||
|
||
#[test]
|
||
fn multi_column_paste_tabs_readable() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("Name\tAge\tCity\nAda\t36\tLondon");
|
||
assert_eq!(t.text(), "Name Age City\nAda 36 London");
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
let end = t.text().len();
|
||
let area = Rect::new(0, 0, 80, 3);
|
||
let (x, _y) = t.cursor_pos(area).unwrap();
|
||
// Ada(3) + 4 + 36(2) + 4 + London(6) = 19
|
||
assert_eq!(x, 19);
|
||
let bol = t.text().rfind('\n').map(|i| i + 1).unwrap_or(0);
|
||
assert_eq!(x as usize, t.display_width_of_range(bol, end));
|
||
let last_line = &t.text()[bol..];
|
||
let (paint, paint_w) = paint_plain_for_display(last_line, 80, 4);
|
||
assert_eq!(paint.as_ref(), last_line);
|
||
assert_eq!(paint_w, 19);
|
||
}
|
||
|
||
#[test]
|
||
fn insert_element_expands_tabs_and_covers_full_range() {
|
||
let mut t = TextArea::new();
|
||
t.insert_element("a\tb", ElementKind(0), None);
|
||
assert_eq!(t.text(), "a b");
|
||
assert_eq!(t.elements().len(), 1);
|
||
assert_eq!(t.elements()[0].range, 0..6);
|
||
assert_eq!(t.cursor(), 6);
|
||
|
||
let mut t2 = TextArea::new();
|
||
t2.insert_element("a\tb\nc\td", ElementKind(1), Some(Line::from("[P]")));
|
||
assert_eq!(t2.text(), "a b\nc d");
|
||
assert_eq!(t2.elements()[0].range, 0..t2.text().len());
|
||
assert_eq!(t2.cursor(), t2.text().len());
|
||
assert!(!t2.text().contains('\t'));
|
||
}
|
||
|
||
#[test]
|
||
fn replace_range_with_element_expands_tabs() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("xx");
|
||
t.replace_range_with_element(0..2, "a\tb", ElementKind(0), None);
|
||
assert_eq!(t.text(), "a b");
|
||
assert_eq!(t.elements()[0].range, 0..6);
|
||
assert_eq!(t.cursor(), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn unicode_plus_tabs_expansion_and_residual() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("名\tAge");
|
||
// 名 is typically width 2; plus 4 spaces + Age
|
||
assert_eq!(t.text(), "名 Age");
|
||
assert_eq!(
|
||
t.display_width_of_range(0, t.text().len()),
|
||
"名".width() + 4 + 3
|
||
);
|
||
t.set_cursor(t.text().len());
|
||
let area = Rect::new(0, 0, 80, 1);
|
||
let (x, _) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(x as usize, "名".width() + 4 + 3);
|
||
|
||
let mut t2 = TextArea::new();
|
||
t2.set_tab_width(0);
|
||
t2.set_text("😀\tb");
|
||
t2.set_tab_width(4);
|
||
let expected = "😀".width() + 4 + 1;
|
||
assert_eq!(t2.display_width_of_range(0, t2.text().len()), expected);
|
||
t2.set_cursor(t2.text().len());
|
||
let (x, _) = t2.cursor_pos(area).unwrap();
|
||
assert_eq!(x as usize, expected);
|
||
}
|
||
|
||
#[test]
|
||
fn tab_helpers_clip_and_paint() {
|
||
assert_eq!(expand_tabs_with_width("a\tb", 4).as_ref(), "a b");
|
||
assert!(matches!(
|
||
expand_tabs_with_width("a\tb", 0),
|
||
std::borrow::Cow::Borrowed("a\tb")
|
||
));
|
||
assert!(matches!(
|
||
expand_tabs_with_width("ab", 4),
|
||
std::borrow::Cow::Borrowed("ab")
|
||
));
|
||
assert_eq!(plain_display_width_with_tab("a\tb\tc", 4), 11);
|
||
assert_eq!(
|
||
plain_display_width_with_tab("a\tb\tc", 0),
|
||
"a\tb\tc".width()
|
||
);
|
||
assert_eq!(clip_str_to_display_width_with_tab("a\tb", 3, 4), "a");
|
||
let (paint, w) = paint_plain_for_display("a\tb", 80, 4);
|
||
assert_eq!(paint.as_ref(), "a b");
|
||
assert_eq!(w, 6);
|
||
let (paint2, w2) = paint_plain_for_display("a\tb", 3, 4);
|
||
assert_eq!(paint2.as_ref(), "a");
|
||
assert_eq!(w2, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn display_width_of_range_with_display_element() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("ab");
|
||
// Element has 100 bytes of buffer text but displays as "[P]" (3 chars)
|
||
let buffer_text = "x".repeat(100);
|
||
let display = Line::from("[P]");
|
||
t.insert_element(&buffer_text, ElementKind(0), Some(display));
|
||
t.insert_str("cd");
|
||
|
||
// Range covering just "ab" = 2
|
||
assert_eq!(t.display_width_of_range(0, 2), 2);
|
||
// Range covering "ab" + element = 2 + 3 = 5
|
||
assert_eq!(t.display_width_of_range(0, 102), 5);
|
||
// Range covering "ab" + element + "cd" = 2 + 3 + 2 = 7
|
||
assert_eq!(t.display_width_of_range(0, 104), 7);
|
||
// Range covering just the element = 3
|
||
assert_eq!(t.display_width_of_range(2, 102), 3);
|
||
// Range covering just "cd" = 2
|
||
assert_eq!(t.display_width_of_range(102, 104), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_pos_uses_display_width() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("ab");
|
||
let buffer_text = "x".repeat(50);
|
||
let display = Line::from("[P]");
|
||
t.insert_element(&buffer_text, ElementKind(0), Some(display));
|
||
t.insert_str("cd");
|
||
|
||
// Cursor at end of element (buffer pos 52)
|
||
t.set_cursor(52);
|
||
let area = Rect::new(0, 0, 80, 1);
|
||
let (x, _y) = t.cursor_pos(area).unwrap();
|
||
// Expected: "ab" (2) + "[P]" (3) = column 5
|
||
assert_eq!(x, 5);
|
||
|
||
// Cursor at end of text (buffer pos 54)
|
||
t.set_cursor(54);
|
||
let (x, _y) = t.cursor_pos(area).unwrap();
|
||
// Expected: "ab" (2) + "[P]" (3) + "cd" (2) = column 7
|
||
assert_eq!(x, 7);
|
||
|
||
// Cursor at start
|
||
t.set_cursor(0);
|
||
let (x, _y) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(x, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn display_width_no_elements() {
|
||
let t = ta_with("abc");
|
||
assert_eq!(t.display_width_of_range(0, 3), 3);
|
||
assert_eq!(t.display_width_of_range(1, 2), 1);
|
||
assert_eq!(t.display_width_of_range(3, 3), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn display_width_element_without_display() {
|
||
let mut t = TextArea::new();
|
||
t.insert_element("elem", ElementKind(0), None);
|
||
// No display override — width should equal buffer text width
|
||
assert_eq!(t.display_width_of_range(0, 4), 4);
|
||
}
|
||
|
||
// ===== Wide unicode in display text =====
|
||
|
||
#[test]
|
||
fn display_width_with_wide_unicode_display() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("ab");
|
||
// Display text has emoji (each width 2) and CJK
|
||
let display = Line::from("📎漢字"); // 2 + 2 + 2 = 6 display columns
|
||
t.insert_element("raw", ElementKind(0), Some(display));
|
||
t.insert_str("cd");
|
||
|
||
// "ab" = 2, element display = 6, "cd" = 2 → total 10
|
||
assert_eq!(t.display_width_of_range(0, 2), 2);
|
||
assert_eq!(t.display_width_of_range(2, 5), 6); // element "raw" = 3 bytes
|
||
assert_eq!(t.display_width_of_range(0, 7), 10); // "ab" + elem + "cd"
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_pos_with_wide_unicode_display() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("a");
|
||
// Element display is "🚀" (width 2), buffer text is "xyz" (3 bytes)
|
||
let display = Line::from("🚀");
|
||
t.insert_element("xyz", ElementKind(0), Some(display));
|
||
t.insert_str("b");
|
||
|
||
let area = Rect::new(0, 0, 40, 1);
|
||
|
||
// Cursor after "a" (at element start → element_at_cursor returns it)
|
||
t.set_cursor(1);
|
||
let (x, _) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(x, 1); // "a" = 1 col
|
||
|
||
// Cursor after element (buffer pos 4 = 1 + 3)
|
||
t.set_cursor(4);
|
||
let (x, _) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(x, 3); // "a" (1) + "🚀" (2) = 3
|
||
|
||
// Cursor at end "b" (buffer pos 5)
|
||
t.set_cursor(5);
|
||
let (x, _) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(x, 4); // "a" (1) + "🚀" (2) + "b" (1) = 4
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_display_with_wide_unicode() {
|
||
// Display: "📎paste" = 2+5 = 7 cols, truncate to 5
|
||
let line: Line<'static> = Line::from("📎paste");
|
||
let result = truncate_line_display(&line, 5);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
// Budget = 5 - 1 (ellipsis) = 4 content cols → "📎pa" (2+1+1=4)
|
||
assert!(text.contains('…'));
|
||
assert!(
|
||
result.width() <= 5,
|
||
"width should be <= 5, got {}",
|
||
result.width()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clip_str_to_display_width_preserves_zwj_graphemes() {
|
||
let s = "👩\u{200D}💻a";
|
||
|
||
assert_eq!(clip_str_to_display_width(s, 0), "");
|
||
assert_eq!(clip_str_to_display_width(s, 1), "");
|
||
assert_eq!(clip_str_to_display_width(s, 2), "👩\u{200D}💻");
|
||
assert_eq!(clip_str_to_display_width(s, 3), s);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_display_preserves_zwj_graphemes() {
|
||
let line: Line<'static> = Line::from("👩\u{200D}💻abc");
|
||
let result = truncate_line_display(&line, 3);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
|
||
assert_eq!(text, "👩\u{200D}💻…");
|
||
assert_eq!(result.width(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_display_wide_char_at_boundary() {
|
||
// Display: "ab🚀cd" = 2+2+2 = 6 cols, truncate to 4
|
||
// Budget = 4 - 1 = 3 content cols. "ab" = 2, "🚀" = 2 → doesn't fit → "ab…"
|
||
let line: Line<'static> = Line::from("ab🚀cd");
|
||
let result = truncate_line_display(&line, 4);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
assert_eq!(text, "ab…");
|
||
assert!(result.width() <= 4);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_display_bracket_with_wide_chars() {
|
||
// "[📎 pasted]" = 1+2+1+6+1 = 11 cols, truncate to 7
|
||
// Budget = 7 - 2 (ellipsis + bracket) = 5 content cols → "[📎 p…]"
|
||
let line: Line<'static> = Line::from("[📎 pasted]");
|
||
let result = truncate_line_display(&line, 7);
|
||
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
assert!(text.ends_with(']'), "should preserve ]: got {text:?}");
|
||
assert!(text.contains('…'));
|
||
assert!(result.width() <= 7, "got {}", result.width());
|
||
}
|
||
|
||
#[test]
|
||
fn render_element_with_wide_unicode_display() {
|
||
let mut t = TextArea::new();
|
||
let display = Line::from("📎漢");
|
||
t.insert_element("hidden text", ElementKind(0), Some(display));
|
||
|
||
let area = Rect::new(0, 0, 20, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
// Should show "📎漢" (4 display cols: 2+2)
|
||
let cell0 = buf.cell((0, 0)).unwrap();
|
||
assert_eq!(cell0.symbol(), "📎");
|
||
let cell2 = buf.cell((2, 0)).unwrap();
|
||
assert_eq!(cell2.symbol(), "漢");
|
||
}
|
||
|
||
// ===== Element-aware editing behavior (explicit tests) =====
|
||
|
||
#[test]
|
||
fn backspace_at_element_end_deletes_entire_element() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("before ");
|
||
t.insert_element("[paste]", ElementKind(0), None);
|
||
// Cursor is now at end of element
|
||
assert_eq!(t.cursor(), 14); // "before " (7) + "[paste]" (7)
|
||
|
||
t.delete_backward(1);
|
||
assert_eq!(t.text(), "before ");
|
||
assert_eq!(t.cursor(), 7);
|
||
assert!(t.elements().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn delete_at_element_start_deletes_entire_element() {
|
||
let mut t = TextArea::new();
|
||
t.insert_element("[paste]", ElementKind(0), None);
|
||
t.insert_str(" after");
|
||
t.set_cursor(0);
|
||
|
||
t.delete_forward(1);
|
||
assert_eq!(t.text(), " after");
|
||
assert_eq!(t.cursor(), 0);
|
||
assert!(t.elements().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn left_right_navigation_jumps_over_element() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("a");
|
||
t.insert_element("[elem]", ElementKind(0), None);
|
||
t.insert_str("b");
|
||
// text = "a[elem]b", element at 1..7
|
||
|
||
// Start at end, move left: should jump from 8 → 7 (before 'b'),
|
||
// then 7 → 1 (before element, atomic jump), then 1 → 0
|
||
t.set_cursor(8);
|
||
t.move_cursor_left(); // 8 → 7
|
||
assert_eq!(t.cursor(), 7);
|
||
t.move_cursor_left(); // 7 → 1 (atomic jump over "[elem]")
|
||
assert_eq!(t.cursor(), 1);
|
||
t.move_cursor_left(); // 1 → 0
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// Now right: 0 → 1, then 1 → 7 (atomic jump), then 7 → 8
|
||
t.move_cursor_right(); // 0 → 1
|
||
assert_eq!(t.cursor(), 1);
|
||
t.move_cursor_right(); // 1 → 7 (atomic jump over "[elem]")
|
||
assert_eq!(t.cursor(), 7);
|
||
t.move_cursor_right(); // 7 → 8
|
||
assert_eq!(t.cursor(), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn word_delete_backward_removes_element_atomically() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("prefix ");
|
||
t.insert_element("[pasted content]", ElementKind(0), None);
|
||
// Cursor at end of element
|
||
assert_eq!(t.cursor(), 23); // 7 + 16
|
||
|
||
t.delete_backward_word();
|
||
// Should remove the entire element (it's one "word" unit)
|
||
assert_eq!(t.text(), "prefix ");
|
||
assert!(t.elements().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn word_delete_forward_removes_element_atomically() {
|
||
let mut t = TextArea::new();
|
||
t.insert_element("[element]", ElementKind(0), None);
|
||
t.insert_str(" suffix");
|
||
t.set_cursor(0);
|
||
|
||
t.delete_forward_word();
|
||
assert_eq!(t.text(), " suffix");
|
||
assert!(t.elements().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn kill_to_eol_removes_element_in_range() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("start ");
|
||
t.insert_element("[elem]", ElementKind(0), None);
|
||
t.insert_str(" end");
|
||
t.set_cursor(6); // right after "start "
|
||
|
||
t.kill_to_end_of_line();
|
||
assert_eq!(t.text(), "start ");
|
||
assert!(t.elements().is_empty());
|
||
}
|
||
|
||
// ===== Element newline skipping in BOL/EOL =====
|
||
//
|
||
// Elements with multi-line buffer text (e.g. paste blocks) should be
|
||
// treated as atomic for line navigation. Newlines inside elements are
|
||
// NOT line boundaries.
|
||
|
||
#[test]
|
||
fn ctrl_e_skips_newline_inside_element() {
|
||
// "foo <element:line1\nline2> bar"
|
||
// Ctrl-E from start should go to end of the whole line, not stop
|
||
// at the \n inside the element.
|
||
let mut t = TextArea::new();
|
||
t.insert_str("foo ");
|
||
t.insert_element("line1\nline2", ElementKind(1), None);
|
||
t.insert_str(" bar");
|
||
// buffer = "foo line1\nline2 bar" (19 bytes), element at 4..15
|
||
|
||
t.set_cursor(0);
|
||
t.move_cursor_to_end_of_line(false);
|
||
assert_eq!(t.cursor(), t.text().len()); // should reach end of "foo ... bar"
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_a_skips_newline_inside_element() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("foo ");
|
||
t.insert_element("line1\nline2", ElementKind(1), None);
|
||
t.insert_str(" bar");
|
||
// buffer = "foo line1\nline2 bar" (19 bytes)
|
||
|
||
// Set cursor to end
|
||
t.set_cursor(t.text().len());
|
||
t.move_cursor_to_beginning_of_line(false);
|
||
assert_eq!(t.cursor(), 0); // should reach beginning, not stop inside element
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_e_from_element_boundary_skips_to_real_eol() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("foo ");
|
||
t.insert_element("a\nb\nc", ElementKind(1), None);
|
||
t.insert_str(" bar");
|
||
// element at 4..9
|
||
|
||
// Place cursor at element start boundary
|
||
t.set_cursor(4);
|
||
t.move_cursor_to_end_of_line(false);
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_a_from_after_element_skips_to_real_bol() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("foo ");
|
||
t.insert_element("a\nb\nc", ElementKind(1), None);
|
||
t.insert_str(" bar");
|
||
// element at 4..9, " bar" at 9..13
|
||
|
||
// Place cursor on " bar"
|
||
t.set_cursor(10);
|
||
t.move_cursor_to_beginning_of_line(false);
|
||
assert_eq!(t.cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn kill_to_eol_with_multiline_element() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("foo ");
|
||
t.insert_element("x\ny\nz", ElementKind(1), None);
|
||
t.insert_str(" bar");
|
||
// buffer = "foo x\ny\nz bar", element at 4..9
|
||
|
||
t.set_cursor(0);
|
||
t.kill_to_end_of_line();
|
||
// Should kill everything on the line: "foo " + element + " bar"
|
||
assert_eq!(t.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn kill_to_bol_with_multiline_element() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("foo ");
|
||
t.insert_element("x\ny\nz", ElementKind(1), None);
|
||
t.insert_str(" bar");
|
||
|
||
t.set_cursor(t.text().len()); // end of " bar"
|
||
t.kill_to_beginning_of_line();
|
||
assert_eq!(t.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn bol_eol_with_real_newline_and_element() {
|
||
// "hello\nfoo <element:a\nb> bar"
|
||
// Two real lines. Element is on the second line.
|
||
let mut t = TextArea::new();
|
||
t.insert_str("hello\nfoo ");
|
||
t.insert_element("a\nb", ElementKind(1), None);
|
||
t.insert_str(" bar");
|
||
// buffer = "hello\nfoo a\nb bar"
|
||
// Real newline at 5. Element at 10..13. Element's \n at 11 should be skipped.
|
||
|
||
// From start of second line (pos 6), Ctrl-E should reach end
|
||
t.set_cursor(6);
|
||
t.move_cursor_to_end_of_line(false);
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
|
||
// From end, Ctrl-A should go back to pos 6
|
||
t.move_cursor_to_beginning_of_line(false);
|
||
assert_eq!(t.cursor(), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn bol_eol_no_element_unchanged() {
|
||
// Verify the fix doesn't break normal (no-element) behavior.
|
||
let mut t = TextArea::new();
|
||
t.insert_str("line1\nline2\nline3");
|
||
|
||
t.set_cursor(6); // start of "line2"
|
||
t.move_cursor_to_end_of_line(false);
|
||
assert_eq!(t.cursor(), 11); // end of "line2" (before \n)
|
||
|
||
t.move_cursor_to_beginning_of_line(false);
|
||
assert_eq!(t.cursor(), 6);
|
||
}
|
||
|
||
// ===== Element-display-aware wrapping =====
|
||
|
||
#[test]
|
||
fn wrapping_uses_element_display_width() {
|
||
// Scenario: "foo bar " + element("Clean build", display 28 cols) at width 20.
|
||
// Buffer text: "foo bar Clean build" = 19 buffer cols → textwrap says it fits on one line.
|
||
// Display text: "foo bar [📎 Pasted 1 line, 11 chars]" = 8 + 28 = 36 display cols → should wrap.
|
||
let mut t = TextArea::new();
|
||
t.insert_str("foo bar ");
|
||
let display = Line::from("[📎 Pasted 1 line, 11 chars]"); // 28 display cols
|
||
t.insert_element("Clean build", ElementKind(0), Some(display));
|
||
// buffer = "foo bar Clean build" (19 bytes)
|
||
|
||
let lines = t.wrapped_lines(20);
|
||
// The element display (28 cols) doesn't fit after "foo bar " (8 cols) on a 20-col line.
|
||
// But it DOES fit on a fresh 20-col line (28 > 20, so it overflows but gets its own line).
|
||
// Expected: line 1 = "foo bar ", line 2 = element.
|
||
assert!(
|
||
lines.len() >= 2,
|
||
"Expected wrapping to produce at least 2 lines, got {} lines. \
|
||
Line ranges: {:?}",
|
||
lines.len(),
|
||
*lines,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wrapping_element_fits_on_next_line() {
|
||
// Element display (10 cols) doesn't fit after "hello " (6 cols) on 12-col line,
|
||
// but fits on a fresh line.
|
||
let mut t = TextArea::new();
|
||
t.insert_str("hello ");
|
||
let display = Line::from("[Pasted!]"); // 9 display cols
|
||
t.insert_element("xy", ElementKind(0), Some(display));
|
||
t.insert_str(" z");
|
||
// buffer: "hello xy z" (10 bytes)
|
||
// display: "hello [Pasted!] z" = 6 + 9 + 2 = 17 display cols
|
||
|
||
let lines = t.wrapped_lines(12);
|
||
// Line 1: "hello " (6 cols, fits)
|
||
// Line 2: "[Pasted!] z" (9 + 2 = 11 cols, fits in 12)
|
||
assert_eq!(
|
||
lines.len(),
|
||
2,
|
||
"Expected 2 wrapped lines, got {}. Ranges: {:?}",
|
||
lines.len(),
|
||
*lines,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wrapping_element_without_display_uses_buffer_width() {
|
||
// Element without display override: wrapping should use buffer text width (unchanged behavior).
|
||
let mut t = TextArea::new();
|
||
t.insert_str("hello ");
|
||
t.insert_element("xy", ElementKind(0), None);
|
||
t.insert_str(" z");
|
||
// buffer: "hello xy z" (10 bytes), no display override
|
||
// display = buffer = "hello xy z" = 10 cols
|
||
|
||
let lines = t.wrapped_lines(12);
|
||
// 10 cols fits on 12-col line → 1 line
|
||
assert_eq!(lines.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn wrapping_element_display_renders_on_correct_lines() {
|
||
// End-to-end: wrapping + rendering with display element.
|
||
// "abc " (4) + element("xy", display="[ELEM]" = 6 cols) + " d" (2)
|
||
// At width 8: "abc " (4) + "[ELEM]" (6) = 10 > 8 → wrap before element
|
||
// Line 1: "abc " (4 cols), Line 2: "[ELEM] d" (8 cols)
|
||
let mut t = TextArea::new();
|
||
t.insert_str("abc ");
|
||
let display = Line::from("[ELEM]");
|
||
t.insert_element("xy", ElementKind(0), Some(display));
|
||
t.insert_str(" d");
|
||
// buffer: "abc xy d" (8 bytes)
|
||
|
||
// Check wrapping (drop the Ref before rendering)
|
||
{
|
||
let lines = t.wrapped_lines(8);
|
||
assert_eq!(lines.len(), 2, "Should wrap into 2 lines, got {:?}", *lines);
|
||
}
|
||
|
||
// Render and verify
|
||
let area = Rect::new(0, 0, 8, 2);
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
// Line 1 (y=0): "abc " padded to 8
|
||
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "a");
|
||
assert_eq!(buf.cell((1, 0)).unwrap().symbol(), "b");
|
||
assert_eq!(buf.cell((2, 0)).unwrap().symbol(), "c");
|
||
assert_eq!(buf.cell((3, 0)).unwrap().symbol(), " ");
|
||
|
||
// Line 2 (y=1): "[ELEM] d"
|
||
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "[");
|
||
assert_eq!(buf.cell((1, 1)).unwrap().symbol(), "E");
|
||
assert_eq!(buf.cell((5, 1)).unwrap().symbol(), "]");
|
||
assert_eq!(buf.cell((6, 1)).unwrap().symbol(), " ");
|
||
assert_eq!(buf.cell((7, 1)).unwrap().symbol(), "d");
|
||
}
|
||
|
||
#[test]
|
||
fn wrapping_element_with_newlines_stays_single_line() {
|
||
// When an element's buffer text contains \n, wrapping must NOT split at those
|
||
// newlines. The element's display is a single-line chip; the \n is internal.
|
||
// Scenario: "hello " + element("line1\nline2\nline3", display="[paste]") + " world"
|
||
// Buffer: "hello line1\nline2\nline3 world" (contains \n inside element)
|
||
// Display: "hello [paste] world" = 6 + 7 + 6 = 19 cols
|
||
// At width 40: should be 1 visual line.
|
||
let mut t = TextArea::new();
|
||
t.insert_str("hello ");
|
||
let display = Line::from("[paste]"); // 7 display cols
|
||
t.insert_element("line1\nline2\nline3", ElementKind(0), Some(display));
|
||
t.insert_str(" world");
|
||
// buffer: "hello line1\nline2\nline3 world"
|
||
|
||
let lines = t.wrapped_lines(40);
|
||
assert_eq!(
|
||
lines.len(),
|
||
1,
|
||
"Element with internal \\n should NOT create extra visual lines. \
|
||
Got {} lines: {:?}",
|
||
lines.len(),
|
||
*lines,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_pos_after_multiline_element() {
|
||
// After inserting text after a multiline element, the cursor should be on the
|
||
// same visual line as the element chip, not bumped down by internal newlines.
|
||
let mut t = TextArea::new();
|
||
t.insert_str("hello ");
|
||
let display = Line::from("[paste]"); // 7 display cols
|
||
t.insert_element("line1\nline2", ElementKind(0), Some(display));
|
||
t.insert_str(" world");
|
||
|
||
let area = Rect::new(0, 0, 80, 10);
|
||
let pos = t.cursor_pos(area);
|
||
assert_eq!(
|
||
pos,
|
||
Some((19, 0)), // 6 + 7 + 6 = 19, row 0
|
||
"Cursor should be at col 19, row 0 after multiline element. \
|
||
Got {:?}. Buffer: {:?}, cursor byte: {}",
|
||
pos,
|
||
t.text(),
|
||
t.cursor(),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn yank_restores_last_kill() {
|
||
let mut t = ta_with("hello");
|
||
t.set_cursor(0);
|
||
t.kill_to_end_of_line();
|
||
assert_eq!(t.text(), "");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
t.yank();
|
||
assert_eq!(t.text(), "hello");
|
||
assert_eq!(t.cursor(), 5);
|
||
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(t.text().len());
|
||
t.delete_backward_word();
|
||
assert_eq!(t.text(), "hello ");
|
||
assert_eq!(t.cursor(), 6);
|
||
|
||
t.yank();
|
||
assert_eq!(t.text(), "hello world");
|
||
assert_eq!(t.cursor(), 11);
|
||
|
||
let mut t = ta_with("hello");
|
||
t.set_cursor(5);
|
||
t.kill_to_beginning_of_line();
|
||
assert_eq!(t.text(), "");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
t.yank();
|
||
assert_eq!(t.text(), "hello");
|
||
assert_eq!(t.cursor(), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn kill_buffer_survives_set_text() {
|
||
// A cut must outlive the buffer reset that send does via set_text("").
|
||
let mut t = ta_with("hello");
|
||
t.set_cursor(0);
|
||
t.kill_to_end_of_line();
|
||
assert_eq!(t.text(), "");
|
||
|
||
t.set_text(""); // send resets the prompt
|
||
assert_eq!(t.text(), "");
|
||
|
||
t.yank();
|
||
assert_eq!(t.text(), "hello");
|
||
assert_eq!(t.cursor(), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_left_and_right_handle_graphemes() {
|
||
let mut t = ta_with("a👍b");
|
||
t.set_cursor(t.text().len());
|
||
|
||
t.move_cursor_left(); // before 'b'
|
||
let after_first_left = t.cursor();
|
||
t.move_cursor_left(); // before '👍'
|
||
let after_second_left = t.cursor();
|
||
t.move_cursor_left(); // before 'a'
|
||
let after_third_left = t.cursor();
|
||
|
||
assert!(after_first_left < t.text().len());
|
||
assert!(after_second_left < after_first_left);
|
||
assert!(after_third_left < after_second_left);
|
||
|
||
// Move right back to end safely
|
||
t.move_cursor_right();
|
||
t.move_cursor_right();
|
||
t.move_cursor_right();
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn control_b_and_f_move_cursor() {
|
||
let mut t = ta_with("abcd");
|
||
t.set_cursor(1);
|
||
|
||
t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.cursor(), 2);
|
||
|
||
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.cursor(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn control_b_f_fallback_control_chars_move_cursor() {
|
||
let mut t = ta_with("abcd");
|
||
t.set_cursor(2);
|
||
|
||
// Simulate terminals that send C0 control chars without CONTROL modifier.
|
||
// ^B (U+0002) should move left
|
||
t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE));
|
||
assert_eq!(t.cursor(), 1);
|
||
|
||
// ^F (U+0006) should move right
|
||
t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE));
|
||
assert_eq!(t.cursor(), 2);
|
||
}
|
||
|
||
/// Regression (user report): Ctrl+W must rubout to whitespace, not stop
|
||
/// at punctuation.
|
||
#[test]
|
||
fn ctrl_w_unix_word_rubout_deletes_to_whitespace() {
|
||
let mut t = ta_with("git commit -m hello-world");
|
||
t.set_cursor(t.text().len());
|
||
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "git commit -m ");
|
||
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "git commit ");
|
||
}
|
||
|
||
#[test]
|
||
fn unix_word_rubout_whitespace_runs_paths_and_edges() {
|
||
let mut t = ta_with("cat path/to/file.rs ");
|
||
t.set_cursor(t.text().len());
|
||
t.delete_backward_unix_word();
|
||
assert_eq!(t.text(), "cat ");
|
||
assert_eq!(t.cursor(), 4);
|
||
|
||
let mut t = ta_with("foo bar-baz");
|
||
t.set_cursor(7); // foo bar|-baz
|
||
t.delete_backward_unix_word();
|
||
assert_eq!(t.text(), "foo -baz");
|
||
assert_eq!(t.cursor(), 4);
|
||
|
||
// Newlines are whitespace: rubout crosses line boundaries.
|
||
let mut t = ta_with("line1\nword ");
|
||
t.set_cursor(t.text().len());
|
||
t.delete_backward_unix_word();
|
||
assert_eq!(t.text(), "line1\n");
|
||
|
||
let mut t = ta_with("");
|
||
t.delete_backward_unix_word();
|
||
assert_eq!(t.text(), "");
|
||
let mut t = ta_with(" ");
|
||
t.set_cursor(3);
|
||
t.delete_backward_unix_word();
|
||
assert_eq!(t.text(), "");
|
||
}
|
||
|
||
/// Readline parity: only C-w is whitespace-delimited; M-DEL/C-Backspace
|
||
/// stay chunked.
|
||
#[test]
|
||
fn alt_backspace_keeps_word_chunk_semantics() {
|
||
let mut t = ta_with("hello-world");
|
||
t.set_cursor(t.text().len());
|
||
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
|
||
assert_eq!(t.text(), "hello-");
|
||
|
||
let mut t = ta_with("hello-world");
|
||
t.set_cursor(t.text().len());
|
||
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "hello-");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_backward_word_alt_keys() {
|
||
// Test the custom Alt+Ctrl+h binding
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(t.text().len()); // cursor at the end
|
||
t.input(KeyEvent::new(
|
||
KeyCode::Char('h'),
|
||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||
));
|
||
assert_eq!(t.text(), "hello ");
|
||
assert_eq!(t.cursor(), 6);
|
||
|
||
// Test the standard Alt+Backspace binding
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(t.text().len()); // cursor at the end
|
||
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
|
||
assert_eq!(t.text(), "hello ");
|
||
assert_eq!(t.cursor(), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_backspace_deletes_backward_word() {
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(t.text().len());
|
||
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "hello ");
|
||
assert_eq!(t.cursor(), 6);
|
||
|
||
// From end of middle word: deletes "bar", leaves surrounding spaces
|
||
let mut t = ta_with("foo bar baz");
|
||
t.set_cursor(7); // after "bar"
|
||
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "foo baz");
|
||
assert_eq!(t.cursor(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_delete_deletes_forward_word() {
|
||
// Mirror of ctrl_backspace_deletes_backward_word.
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), " world");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// From start of middle word: deletes "bar", leaves surrounding spaces
|
||
let mut t = ta_with("foo bar baz");
|
||
t.set_cursor(4); // before "bar"
|
||
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "foo baz");
|
||
assert_eq!(t.cursor(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn delete_backward_word_handles_narrow_no_break_space() {
|
||
let mut t = ta_with("32\u{202F}AM");
|
||
t.set_cursor(t.text().len());
|
||
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
|
||
pretty_assertions::assert_eq!(t.text(), "32\u{202F}");
|
||
pretty_assertions::assert_eq!(t.cursor(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn delete_forward_word_with_without_alt_modifier() {
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT));
|
||
assert_eq!(t.text(), " world");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
let mut t = ta_with("hello");
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||
assert_eq!(t.text(), "ello");
|
||
assert_eq!(t.cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn alt_d_deletes_forward_word() {
|
||
// Alt+D (Meta-d, Emacs) → delete forward word
|
||
let mut t = ta_with("hello world foo");
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT));
|
||
assert_eq!(t.text(), " world foo");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// Alt+D at a word boundary
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(5); // cursor right after "hello"
|
||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT));
|
||
assert_eq!(t.text(), "hello");
|
||
assert_eq!(t.cursor(), 5);
|
||
|
||
// Super+D (Cmd+D on macOS with Kitty protocol) also works
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::SUPER));
|
||
assert_eq!(t.text(), " world");
|
||
assert_eq!(t.cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_p_moves_cursor_up() {
|
||
let mut t = ta_with("first\nsecond\nthird");
|
||
let second_line_start = 6; // after "first\n"
|
||
t.set_cursor(second_line_start + 2); // middle of "second"
|
||
t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
|
||
// Should be on first line now
|
||
assert!(t.cursor() < second_line_start);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_n_moves_cursor_down() {
|
||
let mut t = ta_with("first\nsecond\nthird");
|
||
t.set_cursor(2); // middle of "first"
|
||
t.input(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
|
||
let second_line_start = 6;
|
||
// Should be on second line now
|
||
assert!(t.cursor() >= second_line_start);
|
||
}
|
||
|
||
#[test]
|
||
fn control_h_backspace() {
|
||
// Test Ctrl+H as backspace
|
||
let mut t = ta_with("12345");
|
||
t.set_cursor(3); // cursor after '3'
|
||
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "1245");
|
||
assert_eq!(t.cursor(), 2);
|
||
|
||
// Test Ctrl+H at beginning (should be no-op)
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "1245");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// Test Ctrl+H at end
|
||
t.set_cursor(t.text().len());
|
||
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.text(), "124");
|
||
assert_eq!(t.cursor(), 3);
|
||
}
|
||
#[test]
|
||
fn char_bs_backspace() {
|
||
// Test Char('\x08') (BS) as backspace
|
||
let mut t = ta_with("12345");
|
||
t.set_cursor(3); // cursor after '3'
|
||
t.input(KeyEvent::new(KeyCode::Char('\x08'), KeyModifiers::NONE));
|
||
assert_eq!(t.text(), "1245");
|
||
assert_eq!(t.cursor(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn char_del_deletes_backward() {
|
||
// Char('\x7f') (DEL) should delete backward — on Unix terminals,
|
||
// Backspace sends 0x7F in legacy mode (no Kitty protocol).
|
||
let mut t = ta_with("12345");
|
||
t.set_cursor(2); // cursor after '2'
|
||
t.input(KeyEvent::new(KeyCode::Char('\x7f'), KeyModifiers::NONE));
|
||
assert_eq!(t.text(), "1345");
|
||
assert_eq!(t.cursor(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn del_char_treated_as_backspace() {
|
||
// When Kitty keyboard protocol gets silently popped, Backspace can
|
||
// arrive as raw DEL (0x7F) instead of KeyCode::Backspace. Ensure it
|
||
// deletes backward instead of inserting an invisible character.
|
||
let mut t = ta_with("hello");
|
||
t.set_cursor(3);
|
||
t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE));
|
||
assert_eq!(t.text(), "helo");
|
||
assert_eq!(t.cursor(), 2);
|
||
|
||
// At beginning: no-op
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE));
|
||
assert_eq!(t.text(), "helo");
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// With modifiers (e.g. ALT): still treated as backspace
|
||
t.set_cursor(t.text().len());
|
||
t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::ALT));
|
||
assert_eq!(t.text(), "hel");
|
||
assert_eq!(t.cursor(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn bs_char_treated_as_backspace() {
|
||
// BS (0x08) arriving as Char without CONTROL modifier should also
|
||
// delete backward (Ctrl-H without the modifier flag).
|
||
let mut t = ta_with("abcde");
|
||
t.set_cursor(4);
|
||
t.input(KeyEvent::new(KeyCode::Char('\u{0008}'), KeyModifiers::NONE));
|
||
assert_eq!(t.text(), "abce");
|
||
assert_eq!(t.cursor(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn del_char_with_selection_deletes_selection() {
|
||
// DEL (0x7F) arriving as Char with an active selection should delete
|
||
// the selection cleanly, not insert an invisible control character.
|
||
let mut t = ta_with("hello world");
|
||
t.set_selection(0, 5);
|
||
t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE));
|
||
assert_eq!(t.text(), " world");
|
||
assert_eq!(t.cursor(), 0);
|
||
assert!(t.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_vertical_movement_across_lines_and_bounds() {
|
||
let mut t = ta_with("short\nloooooooooong\nmid");
|
||
// Place cursor on second line, column 5
|
||
let second_line_start = 6; // after first '\n'
|
||
t.set_cursor(second_line_start + 5);
|
||
|
||
// Move up: target column preserved, clamped by line length
|
||
t.move_cursor_up();
|
||
assert_eq!(t.cursor(), 5); // first line has len 5
|
||
|
||
// Move up again goes to start of text
|
||
t.move_cursor_up();
|
||
assert_eq!(t.cursor(), 0);
|
||
|
||
// Move down: from start to target col tracked
|
||
t.move_cursor_down();
|
||
// On first move down, we should land on second line, at col 0 (target col remembered as 0)
|
||
let pos_after_down = t.cursor();
|
||
assert!(pos_after_down >= second_line_start);
|
||
|
||
// Move down again to third line; clamp to its length
|
||
t.move_cursor_down();
|
||
let third_line_start = t.text().find("mid").unwrap();
|
||
let third_line_end = third_line_start + 3;
|
||
assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end);
|
||
|
||
// Moving down at last line jumps to end
|
||
t.move_cursor_down();
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn home_end_and_emacs_style_home_end() {
|
||
let mut t = ta_with("one\ntwo\nthree");
|
||
// Position at middle of second line
|
||
let second_line_start = t.text().find("two").unwrap();
|
||
t.set_cursor(second_line_start + 1);
|
||
|
||
t.move_cursor_to_beginning_of_line(false);
|
||
assert_eq!(t.cursor(), second_line_start);
|
||
|
||
// Ctrl-A behavior: if at BOL, go to beginning of previous line
|
||
t.move_cursor_to_beginning_of_line(true);
|
||
assert_eq!(t.cursor(), 0); // beginning of first line
|
||
|
||
// Move to EOL of first line
|
||
t.move_cursor_to_end_of_line(false);
|
||
assert_eq!(t.cursor(), 3);
|
||
|
||
// Ctrl-E: if at EOL, go to end of next line
|
||
t.move_cursor_to_end_of_line(true);
|
||
// end of second line ("two") is right before its '\n'
|
||
let end_second_nl = t.text().find("\nthree").unwrap();
|
||
assert_eq!(t.cursor(), end_second_nl);
|
||
}
|
||
|
||
#[test]
|
||
fn home_end_use_visual_row_when_soft_wrapped() {
|
||
// width 4 → "abcd" | "efgh" | "ij"
|
||
let mut t = ta_with("abcdefghij");
|
||
let _ = t.desired_height(4);
|
||
t.set_cursor(6); // mid second visual row
|
||
|
||
t.move_cursor_to_beginning_of_line(false);
|
||
assert_eq!(t.cursor(), 4);
|
||
t.move_cursor_to_end_of_line(false);
|
||
assert_eq!(t.cursor(), 7);
|
||
|
||
t.input(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
||
assert_eq!(t.cursor(), 4);
|
||
t.input(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
|
||
assert_eq!(t.cursor(), 7);
|
||
|
||
// Last visual row: End → text end; Home → row start.
|
||
t.set_cursor(8);
|
||
t.move_cursor_to_end_of_line(false);
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
t.move_cursor_to_beginning_of_line(false);
|
||
assert_eq!(t.cursor(), 8);
|
||
|
||
// Ctrl+A/E stay logical.
|
||
t.set_cursor(6);
|
||
t.move_cursor_to_beginning_of_line(true);
|
||
assert_eq!(t.cursor(), 0);
|
||
t.set_cursor(6);
|
||
t.move_cursor_to_end_of_line(true);
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn end_of_line_or_down_at_end_of_text() {
|
||
let mut t = ta_with("one\ntwo");
|
||
// Place cursor at absolute end of the text
|
||
t.set_cursor(t.text().len());
|
||
// Should remain at end without panicking
|
||
t.move_cursor_to_end_of_line(true);
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
|
||
// Also verify behavior when at EOL of a non-final line:
|
||
let eol_first_line = 3; // index of '\n' in "one\ntwo"
|
||
t.set_cursor(eol_first_line);
|
||
t.move_cursor_to_end_of_line(true);
|
||
assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line
|
||
}
|
||
|
||
#[test]
|
||
fn word_navigation_helpers() {
|
||
let t = ta_with(" alpha beta gamma");
|
||
let mut t = t; // make mutable for set_cursor
|
||
// Put cursor after "alpha"
|
||
let after_alpha = t.text().find("alpha").unwrap() + "alpha".len();
|
||
t.set_cursor(after_alpha);
|
||
assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces
|
||
|
||
// Put cursor at start of beta
|
||
let beta_start = t.text().find("beta").unwrap();
|
||
t.set_cursor(beta_start);
|
||
assert_eq!(t.end_of_next_word(), beta_start + "beta".len());
|
||
|
||
// If at end, end_of_next_word returns len
|
||
t.set_cursor(t.text().len());
|
||
assert_eq!(t.end_of_next_word(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn word_navigation_splits_on_hyphen() {
|
||
let mut t = ta_with("hello-world");
|
||
let hyphen = t.text().find('-').unwrap();
|
||
let after_hyphen = hyphen + 1;
|
||
|
||
t.set_cursor(t.text().len());
|
||
assert_eq!(t.beginning_of_previous_word(), after_hyphen);
|
||
|
||
t.set_cursor(after_hyphen);
|
||
assert_eq!(t.beginning_of_previous_word(), hyphen);
|
||
|
||
t.set_cursor(hyphen);
|
||
assert_eq!(t.beginning_of_previous_word(), 0);
|
||
|
||
t.set_cursor(0);
|
||
assert_eq!(t.end_of_next_word(), hyphen);
|
||
|
||
t.set_cursor(hyphen);
|
||
assert_eq!(t.end_of_next_word(), after_hyphen);
|
||
|
||
t.set_cursor(after_hyphen);
|
||
assert_eq!(t.end_of_next_word(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn alt_arrow_navigation_splits_on_hyphen() {
|
||
let mut t = ta_with("hello-world");
|
||
let hyphen = t.text().find('-').unwrap();
|
||
let after_hyphen = hyphen + 1;
|
||
let end = t.text().len();
|
||
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));
|
||
assert_eq!(t.cursor(), hyphen);
|
||
|
||
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));
|
||
assert_eq!(t.cursor(), after_hyphen);
|
||
|
||
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));
|
||
assert_eq!(t.cursor(), end);
|
||
|
||
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT));
|
||
assert_eq!(t.cursor(), after_hyphen);
|
||
|
||
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT));
|
||
assert_eq!(t.cursor(), hyphen);
|
||
|
||
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT));
|
||
assert_eq!(t.cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_at_wrap_boundary_shows_on_next_line() {
|
||
// When typing fills an entire line, the cursor sits at the exact wrap
|
||
// boundary. It should be reported on the *next* visual line at col 0,
|
||
// not at col == width (which is the invisible right border).
|
||
|
||
// Case 1: text exactly fills one line — cursor at text.len()
|
||
let mut t = ta_with("abcde");
|
||
let area = Rect::new(0, 0, 5, 3); // width 5
|
||
t.set_cursor(5); // cursor right after 'e'
|
||
|
||
let (x, y) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(x, 0, "cursor x should be 0 (start of virtual next line)");
|
||
assert_eq!(y, 1, "cursor y should be 1 (next line)");
|
||
|
||
// Case 2: text wraps — cursor at the boundary between two wrapped lines
|
||
let mut t = ta_with("abcdefgh");
|
||
let area = Rect::new(0, 0, 5, 3); // width 5, wraps after 'e'
|
||
// cursor at position 5 = start of "fgh" = should be col 0, row 1
|
||
t.set_cursor(5);
|
||
|
||
let (x, y) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(x, 0, "cursor at wrap point should be col 0 of next line");
|
||
assert_eq!(y, 1, "cursor at wrap point should be on second visual line");
|
||
}
|
||
|
||
#[test]
|
||
fn wrapping_and_cursor_positions() {
|
||
let mut t = ta_with("hello world here");
|
||
let area = Rect::new(0, 0, 6, 10); // width 6 -> wraps words
|
||
// desired height counts wrapped lines
|
||
assert!(t.desired_height(area.width) >= 3);
|
||
|
||
// Place cursor in "world"
|
||
let world_start = t.text().find("world").unwrap();
|
||
t.set_cursor(world_start + 3);
|
||
let (_x, y) = t.cursor_pos(area).unwrap();
|
||
assert_eq!(y, 1); // world should be on second wrapped line
|
||
|
||
// With state and small height, cursor is mapped onto visible row
|
||
let mut state = TextAreaState::default();
|
||
let small_area = Rect::new(0, 0, 6, 1);
|
||
// First call: cursor not visible -> effective scroll ensures it is
|
||
let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
|
||
assert_eq!(y, 0);
|
||
|
||
// Render with state to update actual scroll value
|
||
let mut buf = Buffer::empty(small_area);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state);
|
||
// After render, state.scroll should be adjusted so cursor row fits
|
||
let effective_lines = t.desired_height(small_area.width);
|
||
assert!(state.scroll < effective_lines);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
|
||
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(3);
|
||
let area = Rect::new(2, 5, 20, 3);
|
||
// Even if an absurd scroll is provided, when content fits the area the
|
||
// effective scroll is 0 and the cursor position matches cursor_pos.
|
||
let bad_state = TextAreaState { scroll: 999 };
|
||
let (x1, y1) = t.cursor_pos(area).unwrap();
|
||
let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap();
|
||
assert_eq!((x2, y2), (x1, y1));
|
||
|
||
// Case 2: Cursor below the current window — y should be clamped to the
|
||
// bottom row (area.height - 1) after adjusting effective scroll.
|
||
let mut t = ta_with("one two three four five six");
|
||
// Force wrapping to many visual lines.
|
||
let wrap_width = 4;
|
||
let _ = t.desired_height(wrap_width);
|
||
// Put cursor somewhere near the end so it's definitely below the first window.
|
||
t.set_cursor(t.text().len().saturating_sub(2));
|
||
let small_area = Rect::new(0, 0, wrap_width, 2);
|
||
let state = TextAreaState { scroll: 0 };
|
||
let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
|
||
assert_eq!(y, small_area.y + small_area.height - 1);
|
||
|
||
// Case 3: Cursor above the current window — y should be top row (0)
|
||
// when the provided scroll is too large.
|
||
let mut t = ta_with("alpha beta gamma delta epsilon zeta");
|
||
let wrap_width = 5;
|
||
let lines = t.desired_height(wrap_width);
|
||
// Place cursor near start so an excessive scroll moves it to top row.
|
||
t.set_cursor(1);
|
||
let area = Rect::new(0, 0, wrap_width, 3);
|
||
let state = TextAreaState {
|
||
scroll: lines.saturating_mul(2),
|
||
};
|
||
let (_x, y) = t.cursor_pos_with_state(area, state).unwrap();
|
||
assert_eq!(y, area.y);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_spans_of_range_single_row() {
|
||
let t = ta_with("xy /model tail");
|
||
let area = Rect::new(2, 1, 40, 3);
|
||
let state = TextAreaState::default();
|
||
|
||
// "/model" = bytes 3..9, all on the first visual row.
|
||
let spans = t.screen_spans_of_range(3..9, area, state);
|
||
assert_eq!(spans, vec![Rect::new(5, 1, 6, 1)]);
|
||
|
||
// Degenerate ranges yield no spans.
|
||
assert!(t.screen_spans_of_range(4..4, area, state).is_empty());
|
||
assert!(t.screen_spans_of_range(4..999, area, state).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn screen_spans_of_range_rejects_non_char_boundaries() {
|
||
// 'é' spans bytes 1..3; an endpoint inside it must yield no spans
|
||
// (tolerated like the other invalid-range shapes, never a panic).
|
||
let t = ta_with("héllo");
|
||
let area = Rect::new(0, 0, 10, 2);
|
||
let state = TextAreaState::default();
|
||
|
||
assert!(t.screen_spans_of_range(2..5, area, state).is_empty());
|
||
assert!(t.screen_spans_of_range(0..2, area, state).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn screen_spans_of_range_covers_wrapped_rows() {
|
||
// A token wider than the wrap width must split at the line end and
|
||
// report one span per visual row it lands on.
|
||
let mut t = ta_with("aa /pr-workflow");
|
||
t.show_scrollbar = false;
|
||
let area = Rect::new(0, 0, 8, 4);
|
||
let state = TextAreaState::default();
|
||
|
||
// "/pr-workflow" = bytes 3..15, display width 12 > wrap width 8.
|
||
let spans = t.screen_spans_of_range(3..15, area, state);
|
||
assert!(
|
||
spans.len() >= 2,
|
||
"token must cover multiple rows: {spans:?}"
|
||
);
|
||
assert!(spans.iter().all(|r| r.height == 1));
|
||
for pair in spans.windows(2) {
|
||
assert_eq!(pair[1].y, pair[0].y + 1, "rows must be consecutive");
|
||
}
|
||
for r in &spans[1..] {
|
||
assert_eq!(r.x, area.x, "continuation rows start at the left edge");
|
||
assert!(r.right() <= area.x + area.width);
|
||
}
|
||
// Tokens contain no whitespace, so no cell is lost at wrap boundaries:
|
||
// the summed span widths equal the token's display width.
|
||
let total: u16 = spans.iter().map(|r| r.width).sum();
|
||
assert_eq!(total, 12);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_spans_of_range_skips_offscreen_rows() {
|
||
// Cursor at the end scrolls the viewport to the tail: the token's
|
||
// first row is above the viewport, but its visible tail must still
|
||
// be reported (screen_position_of on the start would return None).
|
||
let mut t = ta_with("/pr-workflow abc");
|
||
t.show_scrollbar = false;
|
||
let area = Rect::new(0, 0, 8, 2);
|
||
let state = TextAreaState::default();
|
||
|
||
let spans = t.screen_spans_of_range(0..12, area, state);
|
||
assert!(!spans.is_empty(), "visible token tail must be reported");
|
||
for r in &spans {
|
||
assert!((area.y..area.y + area.height).contains(&r.y));
|
||
assert!(r.width > 0 && r.right() <= area.x + area.width);
|
||
}
|
||
let total: u16 = spans.iter().map(|r| r.width).sum();
|
||
assert!(
|
||
total < 12,
|
||
"off-screen head must not be reported: {spans:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_spans_of_range_uses_display_width() {
|
||
// 2-cell CJK chars: 日本語 (9 bytes, display width 6) at wrap width 4
|
||
// renders as 日本 / 語.
|
||
let mut t = ta_with("日本語");
|
||
t.show_scrollbar = false;
|
||
let area = Rect::new(1, 0, 4, 3);
|
||
let state = TextAreaState::default();
|
||
|
||
let spans = t.screen_spans_of_range(0..9, area, state);
|
||
assert_eq!(spans, vec![Rect::new(1, 0, 4, 1), Rect::new(1, 1, 2, 1)]);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_spans_of_range_clamps_to_content_edge() {
|
||
// Overflowing content puts the scrollbar up, so content is only
|
||
// `tw = width - 1` columns. Row 0's byte range keeps its trailing
|
||
// wrap spaces ("ab " measures 5), but the reported span must stop
|
||
// at the content edge (4), never reaching the scrollbar column.
|
||
let mut t = ta_with("ab cd ef gh");
|
||
t.set_cursor(0);
|
||
let area = Rect::new(0, 0, 5, 2);
|
||
let state = TextAreaState::default();
|
||
|
||
let spans = t.screen_spans_of_range(0..5, area, state);
|
||
assert_eq!(spans, vec![Rect::new(0, 0, 4, 1)]);
|
||
}
|
||
|
||
#[test]
|
||
fn wrapped_navigation_across_visual_lines() {
|
||
let mut t = ta_with("abcdefghij");
|
||
t.show_scrollbar = false;
|
||
// Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"]
|
||
let _ = t.desired_height(4);
|
||
|
||
// From the very start, moving down should go to the start of the next wrapped line (index 4)
|
||
t.set_cursor(0);
|
||
t.move_cursor_down();
|
||
assert_eq!(t.cursor(), 4);
|
||
|
||
// Cursor at boundary index 4 should be displayed at start of second wrapped line
|
||
t.set_cursor(4);
|
||
let area = Rect::new(0, 0, 4, 10);
|
||
let (x, y) = t.cursor_pos(area).unwrap();
|
||
assert_eq!((x, y), (0, 1));
|
||
|
||
// With state and small height, cursor should be visible at row 0, col 0
|
||
let small_area = Rect::new(0, 0, 4, 1);
|
||
let state = TextAreaState::default();
|
||
let (x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
|
||
assert_eq!((x, y), (0, 0));
|
||
|
||
// Place cursor in the middle of the second wrapped line ("efgh"), at 'g'
|
||
t.set_cursor(6);
|
||
// Move up should go to same column on previous wrapped line -> index 2 ('c')
|
||
t.move_cursor_up();
|
||
assert_eq!(t.cursor(), 2);
|
||
|
||
// Move down should return to same position on the next wrapped line -> back to index 6 ('g')
|
||
t.move_cursor_down();
|
||
assert_eq!(t.cursor(), 6);
|
||
|
||
// Move down again should go to third wrapped line. Target col is 2, but the line has len 2 -> clamp to end
|
||
t.move_cursor_down();
|
||
assert_eq!(t.cursor(), t.text().len());
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_pos_with_state_after_movements() {
|
||
let mut t = ta_with("abcdefghij");
|
||
// Wrap width 4 -> visual lines: abcd | efgh | ij
|
||
let _ = t.desired_height(4);
|
||
let area = Rect::new(0, 0, 4, 2);
|
||
let mut state = TextAreaState::default();
|
||
let mut buf = Buffer::empty(area);
|
||
|
||
// Start at beginning
|
||
t.set_cursor(0);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
|
||
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
|
||
assert_eq!((x, y), (0, 0));
|
||
|
||
// Move down to second visual line; should be at bottom row (row 1) within 2-line viewport
|
||
t.move_cursor_down();
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
|
||
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
|
||
assert_eq!((x, y), (0, 1));
|
||
|
||
// Move down to third visual line; viewport scrolls and keeps cursor on bottom row
|
||
t.move_cursor_down();
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
|
||
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
|
||
assert_eq!((x, y), (0, 1));
|
||
|
||
// Move up to second visual line; with current scroll, it appears on top row
|
||
t.move_cursor_up();
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
|
||
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
|
||
assert_eq!((x, y), (0, 0));
|
||
|
||
// Column preservation across moves: set to col 2 on first line, move down
|
||
t.set_cursor(2);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
|
||
let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap();
|
||
assert_eq!((x0, y0), (2, 0));
|
||
t.move_cursor_down();
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
|
||
let (x1, y1) = t.cursor_pos_with_state(area, state).unwrap();
|
||
assert_eq!((x1, y1), (2, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn wrapped_navigation_with_newlines_and_spaces() {
|
||
// Include spaces and an explicit newline to exercise boundaries
|
||
let mut t = ta_with("word1 word2\nword3");
|
||
// Width 6 will wrap "word1 " and then "word2" before the newline
|
||
let _ = t.desired_height(6);
|
||
|
||
// Put cursor on the second wrapped line before the newline, at column 1 of "word2"
|
||
let start_word2 = t.text().find("word2").unwrap();
|
||
t.set_cursor(start_word2 + 1);
|
||
|
||
// Up should go to first wrapped line, column 1 -> index 1
|
||
t.move_cursor_up();
|
||
assert_eq!(t.cursor(), 1);
|
||
|
||
// Down should return to the same visual column on "word2"
|
||
t.move_cursor_down();
|
||
assert_eq!(t.cursor(), start_word2 + 1);
|
||
|
||
// Down again should cross the logical newline to the next visual line ("word3"), clamped to its length if needed
|
||
t.move_cursor_down();
|
||
let start_word3 = t.text().find("word3").unwrap();
|
||
assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len());
|
||
}
|
||
|
||
#[test]
|
||
fn wrapped_navigation_with_wide_graphemes() {
|
||
// Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries
|
||
let mut t = ta_with("👍👍👍👍");
|
||
let _ = t.desired_height(3);
|
||
|
||
// Put cursor after the second emoji (which should be on first wrapped line)
|
||
t.set_cursor("👍👍".len());
|
||
|
||
// Move down should go to the start of the next wrapped line (same column preserved but clamped)
|
||
t.move_cursor_down();
|
||
// We expect to land somewhere within the third emoji or at the start of it
|
||
let pos_after_down = t.cursor();
|
||
assert!(pos_after_down >= "👍👍".len());
|
||
|
||
// Moving up should take us back to the original position
|
||
t.move_cursor_up();
|
||
assert_eq!(t.cursor(), "👍👍".len());
|
||
}
|
||
|
||
#[test]
|
||
fn wrapped_navigation_with_zwj_graphemes() {
|
||
let grapheme = "👩\u{200D}💻";
|
||
let mut t = ta_with(&format!("{grapheme}{grapheme}{grapheme}"));
|
||
let _ = t.desired_height(4);
|
||
|
||
t.set_cursor(grapheme.len() * 2);
|
||
|
||
t.move_cursor_down();
|
||
let pos_after_down = t.cursor();
|
||
assert!(pos_after_down >= grapheme.len() * 2);
|
||
|
||
t.move_cursor_up();
|
||
assert_eq!(t.cursor(), grapheme.len() * 2);
|
||
}
|
||
|
||
#[test]
|
||
fn element_aware_wrap_ranges_preserve_zwj_graphemes() {
|
||
let grapheme = "👩\u{200D}💻";
|
||
let mut t = TextArea::new();
|
||
t.insert_str(&format!("{grapheme}{grapheme}"));
|
||
t.insert_element("raw", ElementKind(0), Some(Line::from("[P]")));
|
||
|
||
let ranges = {
|
||
let lines = t.wrapped_lines(2);
|
||
lines.iter().cloned().collect::<Vec<_>>()
|
||
};
|
||
|
||
assert_eq!(ranges.len(), 3);
|
||
assert_eq!(&t.text()[ranges[0].clone()], grapheme);
|
||
assert_eq!(&t.text()[ranges[1].clone()], grapheme);
|
||
}
|
||
|
||
#[test]
|
||
fn fuzz_textarea_randomized() {
|
||
// Deterministic seed for reproducibility
|
||
// Seed the RNG based on the current day in Pacific Time (PST/PDT). This
|
||
// keeps the fuzz test deterministic within a day while still varying
|
||
// day-to-day to improve coverage.
|
||
let pst_today_seed: u64 = (chrono::Utc::now() - chrono::Duration::hours(8))
|
||
.date_naive()
|
||
.and_hms_opt(0, 0, 0)
|
||
.unwrap()
|
||
.and_utc()
|
||
.timestamp() as u64;
|
||
let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed);
|
||
|
||
for _case in 0..500 {
|
||
let mut ta = TextArea::new();
|
||
let mut state = TextAreaState::default();
|
||
// Track element payloads we insert. Payloads use characters '[' and ']' which
|
||
// are not produced by rand_grapheme(), avoiding accidental collisions.
|
||
let mut elem_texts: Vec<String> = Vec::new();
|
||
let mut next_elem_id: usize = 0;
|
||
// Start with a random base string
|
||
let base_len = rng.random_range(0..30);
|
||
let mut base = String::new();
|
||
for _ in 0..base_len {
|
||
base.push_str(&rand_grapheme(&mut rng));
|
||
}
|
||
ta.set_text(&base);
|
||
// Choose a valid char boundary for initial cursor
|
||
let mut boundaries: Vec<usize> = vec![0];
|
||
boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1));
|
||
boundaries.push(ta.text().len());
|
||
let init = boundaries[rng.random_range(0..boundaries.len())];
|
||
ta.set_cursor(init);
|
||
|
||
let mut width: u16 = rng.random_range(1..=12);
|
||
let mut height: u16 = rng.random_range(1..=4);
|
||
|
||
for _step in 0..60 {
|
||
// Mostly stable width/height, occasionally change
|
||
if rng.random_bool(0.1) {
|
||
width = rng.random_range(1..=12);
|
||
}
|
||
if rng.random_bool(0.1) {
|
||
height = rng.random_range(1..=4);
|
||
}
|
||
|
||
// Pick an operation
|
||
match rng.random_range(0..18) {
|
||
0 => {
|
||
// insert small random string at cursor
|
||
let len = rng.random_range(0..6);
|
||
let mut s = String::new();
|
||
for _ in 0..len {
|
||
s.push_str(&rand_grapheme(&mut rng));
|
||
}
|
||
ta.insert_str(&s);
|
||
}
|
||
1 => {
|
||
// replace_range with small random slice
|
||
let mut b: Vec<usize> = vec![0];
|
||
b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1));
|
||
b.push(ta.text().len());
|
||
let i1 = rng.random_range(0..b.len());
|
||
let i2 = rng.random_range(0..b.len());
|
||
let (start, end) = if b[i1] <= b[i2] {
|
||
(b[i1], b[i2])
|
||
} else {
|
||
(b[i2], b[i1])
|
||
};
|
||
let insert_len = rng.random_range(0..=4);
|
||
let mut s = String::new();
|
||
for _ in 0..insert_len {
|
||
s.push_str(&rand_grapheme(&mut rng));
|
||
}
|
||
let before = ta.text().len();
|
||
// If the chosen range intersects an element, replace_range will expand to
|
||
// element boundaries, so the naive size delta assertion does not hold.
|
||
let intersects_element = elem_texts.iter().any(|payload| {
|
||
if let Some(pstart) = ta.text().find(payload) {
|
||
let pend = pstart + payload.len();
|
||
pstart < end && pend > start
|
||
} else {
|
||
false
|
||
}
|
||
});
|
||
ta.replace_range(start..end, &s);
|
||
if !intersects_element {
|
||
let after = ta.text().len();
|
||
assert_eq!(
|
||
after as isize,
|
||
before as isize + (s.len() as isize) - ((end - start) as isize)
|
||
);
|
||
}
|
||
}
|
||
2 => ta.delete_backward(rng.random_range(0..=3)),
|
||
3 => ta.delete_forward(rng.random_range(0..=3)),
|
||
4 => ta.delete_backward_word(),
|
||
5 => ta.kill_to_beginning_of_line(),
|
||
6 => ta.kill_to_end_of_line(),
|
||
7 => ta.move_cursor_left(),
|
||
8 => ta.move_cursor_right(),
|
||
9 => ta.move_cursor_up(),
|
||
10 => ta.move_cursor_down(),
|
||
11 => ta.move_cursor_to_beginning_of_line(true),
|
||
12 => ta.move_cursor_to_end_of_line(true),
|
||
13 => {
|
||
// Insert an element with a unique sentinel payload
|
||
let payload =
|
||
format!("[[EL#{}:{}]]", next_elem_id, rng.random_range(1000..9999));
|
||
next_elem_id += 1;
|
||
ta.insert_element(&payload, ElementKind(0), None);
|
||
elem_texts.push(payload);
|
||
}
|
||
14 => {
|
||
// Try inserting inside an existing element (should clamp to boundary)
|
||
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
|
||
&& let Some(start) = ta.text().find(&payload)
|
||
{
|
||
let end = start + payload.len();
|
||
if end - start > 2 {
|
||
let pos = rng.random_range(start + 1..end - 1);
|
||
let ins = rand_grapheme(&mut rng);
|
||
ta.insert_str_at(pos, &ins);
|
||
}
|
||
}
|
||
}
|
||
15 => {
|
||
// Replace a range that intersects an element -> whole element should be replaced
|
||
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
|
||
&& let Some(start) = ta.text().find(&payload)
|
||
{
|
||
let end = start + payload.len();
|
||
// Create an intersecting range [start-δ, end-δ2)
|
||
let mut s = start.saturating_sub(rng.random_range(0..=2));
|
||
let mut e = (end + rng.random_range(0..=2)).min(ta.text().len());
|
||
// Align to char boundaries to satisfy String::replace_range contract
|
||
let txt = ta.text();
|
||
while s > 0 && !txt.is_char_boundary(s) {
|
||
s -= 1;
|
||
}
|
||
while e < txt.len() && !txt.is_char_boundary(e) {
|
||
e += 1;
|
||
}
|
||
if s < e {
|
||
// Small replacement text
|
||
let mut srep = String::new();
|
||
for _ in 0..rng.random_range(0..=2) {
|
||
srep.push_str(&rand_grapheme(&mut rng));
|
||
}
|
||
ta.replace_range(s..e, &srep);
|
||
}
|
||
}
|
||
}
|
||
16 => {
|
||
// Try setting the cursor to a position inside an element; it should clamp out
|
||
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
|
||
&& let Some(start) = ta.text().find(&payload)
|
||
{
|
||
let end = start + payload.len();
|
||
if end - start > 2 {
|
||
let pos = rng.random_range(start + 1..end - 1);
|
||
ta.set_cursor(pos);
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
// Jump to word boundaries
|
||
if rng.random_bool(0.5) {
|
||
let p = ta.beginning_of_previous_word();
|
||
ta.set_cursor(p);
|
||
} else {
|
||
let p = ta.end_of_next_word();
|
||
ta.set_cursor(p);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sanity invariants
|
||
assert!(ta.cursor() <= ta.text().len());
|
||
|
||
// Element invariants
|
||
for payload in &elem_texts {
|
||
if let Some(start) = ta.text().find(payload) {
|
||
let end = start + payload.len();
|
||
// 1) Text inside elements matches the initially set payload
|
||
assert_eq!(&ta.text()[start..end], payload);
|
||
// 2) Cursor is never strictly inside an element
|
||
let c = ta.cursor();
|
||
assert!(
|
||
c <= start || c >= end,
|
||
"cursor inside element: {start}..{end} at {c}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Render and compute cursor positions; ensure they are in-bounds and do not panic
|
||
let area = Rect::new(0, 0, width, height);
|
||
// Stateless render into an area tall enough for all wrapped lines
|
||
let total_lines = ta.desired_height(width);
|
||
let full_area = Rect::new(0, 0, width, total_lines.max(1));
|
||
let mut buf = Buffer::empty(full_area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf);
|
||
|
||
// cursor_pos: x must be within width when present
|
||
let _ = ta.cursor_pos(area);
|
||
|
||
// cursor_pos_with_state: always within viewport rows
|
||
let (_x, _y) = ta
|
||
.cursor_pos_with_state(area, state)
|
||
.unwrap_or((area.x, area.y));
|
||
|
||
// Stateful render should not panic, and updates scroll
|
||
let mut sbuf = Buffer::empty(area);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(
|
||
&(&ta),
|
||
area,
|
||
&mut sbuf,
|
||
&mut state,
|
||
);
|
||
|
||
// After wrapping, desired height equals the number of lines we would render without scroll
|
||
let total_lines = total_lines as usize;
|
||
// state.scroll must not exceed total_lines when content fits within area height
|
||
if (height as usize) >= total_lines {
|
||
assert_eq!(state.scroll, 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Mouse M1: Screen→Buffer mapping tests ──
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_plain_text_start() {
|
||
let t = ta_with("hello");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click at column 0 → pos 0
|
||
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_plain_text_middle() {
|
||
let t = ta_with("hello");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click at column 3 → pos 3
|
||
assert_eq!(t.buffer_pos_at_screen(3, 0, area, state), Some(3));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_past_end_of_line() {
|
||
let t = ta_with("hello");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click at column 10, line only has 5 chars → snap to end of text
|
||
assert_eq!(t.buffer_pos_at_screen(10, 0, area, state), Some(5));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_below_text() {
|
||
let t = ta_with("hello");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click on row 3, text only occupies row 0 → end of text
|
||
assert_eq!(t.buffer_pos_at_screen(0, 3, area, state), Some(5));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_outside_area() {
|
||
let t = ta_with("hello");
|
||
let area = Rect::new(5, 5, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click at (0, 0) which is outside area starting at (5, 5)
|
||
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), None);
|
||
// Click at (4, 5) — just left of area
|
||
assert_eq!(t.buffer_pos_at_screen(4, 5, area, state), None);
|
||
// Click at (5, 4) — just above area
|
||
assert_eq!(t.buffer_pos_at_screen(5, 4, area, state), None);
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_with_area_offset() {
|
||
let t = ta_with("hello");
|
||
let area = Rect::new(10, 5, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click at screen (13, 5) = column 3 within the area → pos 3
|
||
assert_eq!(t.buffer_pos_at_screen(13, 5, area, state), Some(3));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_multiline() {
|
||
let t = ta_with("hello\nworld");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click on row 0, col 2 → "hello" pos 2
|
||
assert_eq!(t.buffer_pos_at_screen(2, 0, area, state), Some(2));
|
||
// Click on row 1, col 1 → "world" pos 6+1 = 7
|
||
assert_eq!(t.buffer_pos_at_screen(1, 1, area, state), Some(7));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_wrapped_text() {
|
||
// "abcdefghij" at width 5 wraps into "abcde" (0..5) and "fghij" (5..10)
|
||
let t = ta_with("abcdefghij");
|
||
let area = Rect::new(0, 0, 5, 5);
|
||
let state = TextAreaState::default();
|
||
// Click on row 0, col 2 → pos 2
|
||
assert_eq!(t.buffer_pos_at_screen(2, 0, area, state), Some(2));
|
||
// Click on row 1, col 0 → pos 5 (start of second wrapped line)
|
||
assert_eq!(t.buffer_pos_at_screen(0, 1, area, state), Some(5));
|
||
// Click on row 1, col 3 → pos 8
|
||
assert_eq!(t.buffer_pos_at_screen(3, 1, area, state), Some(8));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_scrolled() {
|
||
// 3 lines, area height 2 → first line scrolled off when cursor is at end
|
||
let mut t = ta_with("aaa\nbbb\nccc");
|
||
t.set_cursor(t.text().len()); // cursor at end → scroll to show last lines
|
||
let area = Rect::new(0, 0, 20, 2);
|
||
let mut state = TextAreaState::default();
|
||
// Render to compute scroll
|
||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
|
||
// state.scroll should be 1 (skipping "aaa")
|
||
assert_eq!(state.scroll, 1);
|
||
// Click row 0 = visual row 0 = wrapped line 1 ("bbb"), col 1 → pos 5
|
||
assert_eq!(t.buffer_pos_at_screen(1, 0, area, state), Some(5));
|
||
// Click row 1 = visual row 1 = wrapped line 2 ("ccc"), col 2 → pos 10
|
||
assert_eq!(t.buffer_pos_at_screen(2, 1, area, state), Some(10));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_wide_unicode() {
|
||
// "a🦀b" — 🦀 is 2 columns wide (4 bytes)
|
||
let t = ta_with("a🦀b");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// col 0 → 'a' at pos 0
|
||
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), Some(0));
|
||
// col 1 → first column of 🦀 → pos 1
|
||
assert_eq!(t.buffer_pos_at_screen(1, 0, area, state), Some(1));
|
||
// col 2 → second column of 🦀 → still pos 1 (within the 2-wide grapheme;
|
||
// display_col_to_buffer_pos snaps to start of grapheme since target_col < width_so_far)
|
||
// Actually: width_so_far after 'a' is 1, then 🦀 adds 2 → width_so_far=3 > target_col=2
|
||
// → returns pos 1 (start of 🦀)
|
||
assert_eq!(t.buffer_pos_at_screen(2, 0, area, state), Some(1));
|
||
// col 3 → 'b' at pos 5 (1 + 4 bytes for 🦀)
|
||
assert_eq!(t.buffer_pos_at_screen(3, 0, area, state), Some(5));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_element_with_display() {
|
||
// "ab" + element(buffer="raw_text", display="[X]") + "cd"
|
||
// Display: "ab[X]cd" — element is at display cols 2..5
|
||
let mut t = TextArea::new();
|
||
t.insert_str("ab");
|
||
let display = Line::from("[X]");
|
||
t.insert_element("raw_text", ElementKind(0), Some(display));
|
||
t.insert_str("cd");
|
||
// Buffer: "abraw_textcd", element range 2..10
|
||
assert_eq!(t.text(), "abraw_textcd");
|
||
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// col 0 → 'a' at pos 0
|
||
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), Some(0));
|
||
// col 1 → 'b' at pos 1
|
||
assert_eq!(t.buffer_pos_at_screen(1, 0, area, state), Some(1));
|
||
// col 2 → start of element display "[X]" → snap to element start (pos 2)
|
||
assert_eq!(t.buffer_pos_at_screen(2, 0, area, state), Some(2));
|
||
// col 3 → middle of element display → snap to nearest boundary
|
||
// display width = 3, dist_start = 1, dist_end = 2 → snap to start (pos 2)
|
||
assert_eq!(t.buffer_pos_at_screen(3, 0, area, state), Some(2));
|
||
// col 4 → near end of element display → snap to end (pos 10)
|
||
// dist_start = 2, dist_end = 1 → snap to end
|
||
assert_eq!(t.buffer_pos_at_screen(4, 0, area, state), Some(10));
|
||
// col 5 → 'c' at pos 10
|
||
assert_eq!(t.buffer_pos_at_screen(5, 0, area, state), Some(10));
|
||
// col 6 → 'd' at pos 11
|
||
assert_eq!(t.buffer_pos_at_screen(6, 0, area, state), Some(11));
|
||
}
|
||
|
||
#[test]
|
||
fn element_at_screen_hit_and_miss() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("ab");
|
||
let display = Line::from("[File]");
|
||
let id = t.insert_element("file.rs", ElementKind(1), Some(display));
|
||
t.insert_str("cd");
|
||
// Display: "ab[File]cd"
|
||
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click on 'a' (col 0) → no element
|
||
assert!(t.element_at_screen(0, 0, area, state).is_none());
|
||
// Click on 'b' (col 1) → no element
|
||
assert!(t.element_at_screen(1, 0, area, state).is_none());
|
||
// Click on element display (col 2) → element (snaps to start, pos 2 = element start)
|
||
let elem = t.element_at_screen(2, 0, area, state);
|
||
assert!(elem.is_some());
|
||
assert_eq!(elem.unwrap().id, id);
|
||
// Click on element display (col 3) → still the element
|
||
assert_eq!(t.element_at_screen(3, 0, area, state).unwrap().id, id);
|
||
// Click past element (col 8) → 'c' or 'd', no element
|
||
assert!(t.element_at_screen(8, 0, area, state).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_pos_at_screen_empty_textarea() {
|
||
let t = TextArea::new();
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click on empty textarea → pos 0
|
||
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), Some(0));
|
||
// Click at col 5 → still pos 0 (end of empty text)
|
||
assert_eq!(t.buffer_pos_at_screen(5, 0, area, state), Some(0));
|
||
}
|
||
|
||
// ── Mouse M2: Selection state + rendering tests ──
|
||
|
||
#[test]
|
||
fn selection_range_normalizes_anchor_head() {
|
||
let mut t = ta_with("hello world");
|
||
// anchor > head → range should be normalized to start..end
|
||
t.set_selection(8, 3);
|
||
let range = t.selection_range().unwrap();
|
||
assert_eq!(range, 3..8);
|
||
}
|
||
|
||
#[test]
|
||
fn selection_range_anchor_equals_head_is_none() {
|
||
let mut t = ta_with("hello");
|
||
t.set_selection(3, 3);
|
||
assert!(t.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn selected_text_returns_buffer_substring() {
|
||
let mut t = ta_with("hello world");
|
||
t.set_selection(6, 11);
|
||
assert_eq!(t.selected_text().unwrap(), "world");
|
||
}
|
||
|
||
#[test]
|
||
fn selection_expands_to_element_boundaries() {
|
||
let mut t = TextArea::new();
|
||
t.insert_str("ab");
|
||
t.insert_element("element_text", ElementKind(0), None);
|
||
t.insert_str("cd");
|
||
// Buffer: "abelement_textcd", element range 2..14
|
||
assert_eq!(t.text(), "abelement_textcd");
|
||
|
||
// Select only part of the element (bytes 5..10) → should expand to 2..14
|
||
t.set_selection(5, 10);
|
||
let range = t.selection_range().unwrap();
|
||
assert_eq!(range.start, 2); // expanded to element start
|
||
assert_eq!(range.end, 14); // expanded to element end
|
||
assert_eq!(t.selected_text().unwrap(), "element_text");
|
||
}
|
||
|
||
#[test]
|
||
fn clear_selection_clears() {
|
||
let mut t = ta_with("hello");
|
||
t.set_selection(0, 3);
|
||
assert!(t.selection_range().is_some());
|
||
t.clear_selection();
|
||
assert!(t.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn take_clipboard_returns_and_clears() {
|
||
let mut t = TextArea::new();
|
||
t.set_clipboard_text("copied text".to_string());
|
||
let text = t.take_clipboard();
|
||
assert_eq!(text, Some("copied text".to_string()));
|
||
// Second take returns None
|
||
assert_eq!(t.take_clipboard(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn no_selection_returns_none() {
|
||
let t = ta_with("hello");
|
||
assert!(t.selection_range().is_none());
|
||
assert!(t.selected_text().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn selection_rendering_applies_default_selection_style() {
|
||
let mut t = ta_with("hello");
|
||
t.set_selection(1, 4); // select "ell"
|
||
|
||
let area = Rect::new(0, 0, 10, 1);
|
||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
let default_bg = Color::Rgb(49, 62, 115);
|
||
let default_fg = Color::Rgb(192, 202, 245);
|
||
// Cells 1, 2, 3 should have the default selection bg + fg
|
||
for col in 1..4u16 {
|
||
let cell = &buf[(col, 0)];
|
||
assert_eq!(
|
||
cell.bg, default_bg,
|
||
"cell at col {col} should have default selection bg"
|
||
);
|
||
assert_eq!(
|
||
cell.fg, default_fg,
|
||
"cell at col {col} should have default selection fg"
|
||
);
|
||
}
|
||
// Cell 0 ('h') and cell 4 ('o') should NOT have selection bg
|
||
assert_ne!(buf[(0, 0)].bg, default_bg);
|
||
assert_ne!(buf[(4, 0)].bg, default_bg);
|
||
}
|
||
|
||
// ── Phase 1: Undo/Redo plumbing tests ──
|
||
|
||
#[test]
|
||
fn undo_insert_chars_one_at_a_time() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("a");
|
||
ta.insert_str("b");
|
||
ta.insert_str("c");
|
||
assert_eq!(ta.text(), "abc");
|
||
assert_eq!(ta.cursor(), 3);
|
||
|
||
// Phase 2: consecutive single-char inserts are batched into 1 undo step.
|
||
assert!(ta.undo());
|
||
assert_eq!(ta.text(), "");
|
||
assert_eq!(ta.cursor(), 0);
|
||
|
||
// Nothing left to undo.
|
||
assert!(!ta.undo());
|
||
}
|
||
|
||
#[test]
|
||
fn redo_after_undo_restores() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
ta.insert_str(" ");
|
||
ta.insert_str("world");
|
||
assert_eq!(ta.text(), "hello world");
|
||
|
||
// With word boundary batching: "hello" / " " / "world" = 3 steps.
|
||
ta.undo(); // undo "world"
|
||
assert_eq!(ta.text(), "hello ");
|
||
ta.undo(); // undo " "
|
||
assert_eq!(ta.text(), "hello");
|
||
ta.undo(); // undo "hello"
|
||
assert_eq!(ta.text(), "");
|
||
|
||
// Redo walks forward.
|
||
ta.redo();
|
||
assert_eq!(ta.text(), "hello");
|
||
ta.redo();
|
||
assert_eq!(ta.text(), "hello ");
|
||
ta.redo();
|
||
assert_eq!(ta.text(), "hello world");
|
||
assert_eq!(ta.cursor(), 11);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_via_super_modifier() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
assert_eq!(ta.text(), "hello");
|
||
|
||
// Cmd+Z (SUPER) triggers undo.
|
||
ta.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::SUPER));
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn redo_via_super_modifier() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "");
|
||
|
||
// Cmd+Shift+Z (SUPER, reported as uppercase Z) triggers redo.
|
||
ta.input(KeyEvent::new(
|
||
KeyCode::Char('Z'),
|
||
KeyModifiers::SUPER | KeyModifiers::SHIFT,
|
||
));
|
||
assert_eq!(ta.text(), "hello");
|
||
}
|
||
|
||
#[test]
|
||
fn redo_cleared_by_new_mutation() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("abc");
|
||
|
||
ta.undo(); // undo "abc" → ""
|
||
assert_eq!(ta.text(), "");
|
||
assert!(ta.can_redo());
|
||
|
||
ta.insert_str("x"); // new mutation clears redo
|
||
assert!(!ta.can_redo());
|
||
assert_eq!(ta.text(), "x");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_delete_backward_restores_char() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
ta.delete_backward(1); // "hell"
|
||
assert_eq!(ta.text(), "hell");
|
||
|
||
ta.undo(); // undo delete → "hello"
|
||
assert_eq!(ta.text(), "hello");
|
||
assert_eq!(ta.cursor(), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_redo_preserves_cursor() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("abc");
|
||
// cursor is at 3
|
||
ta.set_cursor(1);
|
||
ta.insert_str("X"); // "aXbc", cursor at 2
|
||
assert_eq!(ta.text(), "aXbc");
|
||
assert_eq!(ta.cursor(), 2);
|
||
|
||
ta.undo(); // undo insert "X" → "abc", cursor at 1
|
||
assert_eq!(ta.text(), "abc");
|
||
assert_eq!(ta.cursor(), 1);
|
||
|
||
ta.redo(); // redo → "aXbc", cursor at 2
|
||
assert_eq!(ta.text(), "aXbc");
|
||
assert_eq!(ta.cursor(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn can_undo_can_redo_reflect_state() {
|
||
let mut ta = TextArea::new();
|
||
assert!(!ta.can_undo());
|
||
assert!(!ta.can_redo());
|
||
|
||
ta.insert_str("a");
|
||
assert!(ta.can_undo());
|
||
assert!(!ta.can_redo());
|
||
|
||
ta.undo();
|
||
assert!(!ta.can_undo());
|
||
assert!(ta.can_redo());
|
||
|
||
ta.redo();
|
||
assert!(ta.can_undo());
|
||
assert!(!ta.can_redo());
|
||
}
|
||
|
||
#[test]
|
||
fn undo_stack_depth_capped() {
|
||
let mut ta = TextArea::new();
|
||
// Override max_depth for testing.
|
||
ta.undo.max_depth = 5;
|
||
|
||
// Use set_text (Replace — always discrete) to force separate undo steps.
|
||
for i in 0..10 {
|
||
ta.set_text(&format!("v{i}"));
|
||
}
|
||
assert_eq!(ta.text(), "v9");
|
||
// Stack should be capped at 5.
|
||
assert_eq!(ta.undo.stack.len(), 5);
|
||
|
||
// We can undo at most 5 times.
|
||
let mut count = 0;
|
||
while ta.undo() {
|
||
count += 1;
|
||
}
|
||
assert_eq!(count, 5);
|
||
// We've undone 5 set_text calls, landing on the 5th oldest state.
|
||
assert_eq!(ta.text(), "v4");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_set_text_restores_previous() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
ta.set_text("new");
|
||
assert_eq!(ta.text(), "new");
|
||
|
||
ta.undo(); // undo set_text → "hello"
|
||
assert_eq!(ta.text(), "hello");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_redo_multiple_round_trips() {
|
||
let mut ta = TextArea::new();
|
||
// Use separate insert kinds so they don't batch together.
|
||
ta.insert_str("hello");
|
||
ta.delete_backward(2); // "hel" — kind changes Insert→Delete, new undo step
|
||
assert_eq!(ta.text(), "hel");
|
||
|
||
ta.undo(); // undo delete → "hello"
|
||
assert_eq!(ta.text(), "hello");
|
||
|
||
ta.undo(); // undo insert → ""
|
||
assert_eq!(ta.text(), "");
|
||
|
||
ta.redo(); // redo insert → "hello"
|
||
assert_eq!(ta.text(), "hello");
|
||
|
||
ta.redo(); // redo delete → "hel"
|
||
assert_eq!(ta.text(), "hel");
|
||
|
||
// undo one, insert new → redo cleared
|
||
ta.undo(); // undo delete → "hello"
|
||
assert_eq!(ta.text(), "hello");
|
||
ta.insert_str("z"); // "helloz" — new branch
|
||
assert_eq!(ta.text(), "helloz");
|
||
assert!(!ta.can_redo());
|
||
|
||
// undo "z" — but wait, "z" extends the "hello" batch (same kind, consecutive cursor)?
|
||
// No: undo reset last_kind=None, so "z" is a fresh group.
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "hello");
|
||
}
|
||
|
||
// ── Phase 2: Batching tests ──
|
||
|
||
#[test]
|
||
fn batch_consecutive_inserts_into_one_undo_step() {
|
||
// Typing "hello" char by char → batched into 1 undo step.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("h");
|
||
ta.insert_str("e");
|
||
ta.insert_str("l");
|
||
ta.insert_str("l");
|
||
ta.insert_str("o");
|
||
assert_eq!(ta.text(), "hello");
|
||
assert_eq!(ta.undo.stack.len(), 1); // single checkpoint
|
||
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "");
|
||
assert!(!ta.undo());
|
||
}
|
||
|
||
#[test]
|
||
fn batch_consecutive_deletes_into_one_undo_step() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
// 5 backspaces — all Delete kind, consecutive cursor
|
||
ta.delete_backward(1); // o
|
||
ta.delete_backward(1); // l
|
||
ta.delete_backward(1); // l
|
||
ta.delete_backward(1); // e
|
||
ta.delete_backward(1); // h
|
||
assert_eq!(ta.text(), "");
|
||
|
||
// 2 undo steps: 1 for insert batch, 1 for delete batch
|
||
ta.undo(); // undo all deletes
|
||
assert_eq!(ta.text(), "hello");
|
||
|
||
ta.undo(); // undo insert
|
||
assert_eq!(ta.text(), "");
|
||
assert!(!ta.undo());
|
||
}
|
||
|
||
#[test]
|
||
fn kind_change_breaks_batch() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
ta.delete_backward(1); // "hell" — kind changes → new step
|
||
assert_eq!(ta.text(), "hell");
|
||
|
||
// 2 undo steps
|
||
ta.undo(); // undo delete
|
||
assert_eq!(ta.text(), "hello");
|
||
ta.undo(); // undo insert
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_jump_breaks_insert_batch() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("he"); // cursor at 2
|
||
ta.set_cursor(0); // move cursor to 0 (no mutation, just movement)
|
||
ta.insert_str("X"); // cursor was at 0, last_cursor was 2 → jump → new step
|
||
assert_eq!(ta.text(), "Xhe");
|
||
|
||
// 2 undo steps
|
||
ta.undo(); // undo "X"
|
||
assert_eq!(ta.text(), "he");
|
||
ta.undo(); // undo "he"
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn kill_always_discrete() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello world");
|
||
ta.set_cursor(5);
|
||
ta.kill_to_end_of_line(); // kills " world"
|
||
assert_eq!(ta.text(), "hello");
|
||
ta.kill_to_end_of_line(); // kills nothing (already at EOL with no newline... wait)
|
||
|
||
// Second kill at EOL does nothing (text.len() == cursor_pos).
|
||
// So only 1 kill undo step.
|
||
ta.undo(); // undo kill
|
||
assert_eq!(ta.text(), "hello world");
|
||
}
|
||
|
||
#[test]
|
||
fn kill_consecutive_each_own_step() {
|
||
// Two kill operations back-to-back should be separate undo steps.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("aaa bbb ccc");
|
||
ta.set_cursor(7); // after "aaa bbb"
|
||
ta.kill_to_end_of_line(); // kills " ccc" → "aaa bbb"
|
||
assert_eq!(ta.text(), "aaa bbb");
|
||
ta.set_cursor(3);
|
||
ta.kill_to_end_of_line(); // kills " bbb" → "aaa"
|
||
assert_eq!(ta.text(), "aaa");
|
||
|
||
ta.undo(); // undo second kill
|
||
assert_eq!(ta.text(), "aaa bbb");
|
||
ta.undo(); // undo first kill
|
||
assert_eq!(ta.text(), "aaa bbb ccc");
|
||
}
|
||
|
||
#[test]
|
||
fn insert_str_multi_char_is_one_step() {
|
||
// A single insert_str("hello world") call is 1 undo step.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello world");
|
||
assert_eq!(ta.undo.stack.len(), 1);
|
||
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn set_text_always_discrete() {
|
||
let mut ta = TextArea::new();
|
||
ta.set_text("first");
|
||
ta.set_text("second");
|
||
assert_eq!(ta.text(), "second");
|
||
|
||
// Each set_text is its own undo step (Replace is always discrete)
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "first");
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn insert_then_undo_then_insert_fresh_batch() {
|
||
// After undo, last_kind is reset, so new inserts start a fresh batch.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("ab");
|
||
ta.undo(); // → ""
|
||
ta.insert_str("cd");
|
||
ta.insert_str("ef"); // should batch with "cd"
|
||
assert_eq!(ta.text(), "cdef");
|
||
|
||
ta.undo(); // undo "cdef" batch
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_forward_batches() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("abcde");
|
||
ta.set_cursor(0);
|
||
ta.delete_forward(1); // "bcde"
|
||
ta.delete_forward(1); // "cde"
|
||
ta.delete_forward(1); // "de"
|
||
assert_eq!(ta.text(), "de");
|
||
|
||
// All delete_forward calls batch into 1 step
|
||
ta.undo(); // undo all deletes
|
||
assert_eq!(ta.text(), "abcde");
|
||
}
|
||
|
||
#[test]
|
||
fn word_boundary_breaks_insert_batch() {
|
||
// Typing "foo bar" char by char: ws↔non-ws transitions create checkpoints.
|
||
let mut ta = TextArea::new();
|
||
// "foo" — all non-ws, batches into 1 step
|
||
ta.insert_str("f");
|
||
ta.insert_str("o");
|
||
ta.insert_str("o");
|
||
// " " — whitespace, class change → new step
|
||
ta.insert_str(" ");
|
||
// "bar" — non-ws, class change → new step
|
||
ta.insert_str("b");
|
||
ta.insert_str("a");
|
||
ta.insert_str("r");
|
||
assert_eq!(ta.text(), "foo bar");
|
||
|
||
ta.undo(); // undo "bar"
|
||
assert_eq!(ta.text(), "foo ");
|
||
ta.undo(); // undo " "
|
||
assert_eq!(ta.text(), "foo");
|
||
ta.undo(); // undo "foo"
|
||
assert_eq!(ta.text(), "");
|
||
assert!(!ta.undo());
|
||
}
|
||
|
||
#[test]
|
||
fn word_boundary_whitespace_runs_batch_together() {
|
||
// Multiple consecutive whitespace chars batch into one step.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("a");
|
||
ta.insert_str(" ");
|
||
ta.insert_str(" ");
|
||
ta.insert_str(" ");
|
||
ta.insert_str("b");
|
||
assert_eq!(ta.text(), "a b");
|
||
|
||
ta.undo(); // undo "b"
|
||
assert_eq!(ta.text(), "a ");
|
||
ta.undo(); // undo " "
|
||
assert_eq!(ta.text(), "a");
|
||
ta.undo(); // undo "a"
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn word_boundary_newlines_are_whitespace() {
|
||
// Newlines are whitespace — they batch with spaces, break from words.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("foo");
|
||
ta.insert_str("\n");
|
||
ta.insert_str("\n");
|
||
ta.insert_str(" ");
|
||
ta.insert_str(" ");
|
||
ta.insert_str("bar");
|
||
assert_eq!(ta.text(), "foo\n\n bar");
|
||
|
||
ta.undo(); // undo "bar"
|
||
assert_eq!(ta.text(), "foo\n\n ");
|
||
ta.undo(); // undo "\n\n " (all whitespace batched)
|
||
assert_eq!(ta.text(), "foo");
|
||
ta.undo(); // undo "foo"
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn word_boundary_multi_char_insert_str_is_one_step() {
|
||
// A single insert_str("hello world") call is still 1 undo step,
|
||
// even though it contains a space. Boundary check only applies
|
||
// between separate insert_str calls.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello world");
|
||
assert_eq!(ta.undo.stack.len(), 1);
|
||
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn word_boundary_after_undo_starts_fresh() {
|
||
// After undo, last_kind is reset — no stale boundary check.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("abc");
|
||
ta.insert_str(" ");
|
||
ta.undo(); // undo " "
|
||
assert_eq!(ta.text(), "abc");
|
||
// Now insert non-ws — should start a fresh batch, no boundary check against stale state.
|
||
ta.insert_str("d");
|
||
ta.insert_str("e");
|
||
assert_eq!(ta.text(), "abcde");
|
||
|
||
ta.undo(); // undo "de"
|
||
assert_eq!(ta.text(), "abc");
|
||
}
|
||
|
||
#[test]
|
||
fn element_insert_always_discrete() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hi ");
|
||
ta.insert_element("@file.rs", ElementKind(0), None);
|
||
// Element should be its own undo step, not batched with the insert.
|
||
assert_eq!(ta.text(), "hi @file.rs");
|
||
|
||
ta.undo(); // undo element
|
||
assert_eq!(ta.text(), "hi ");
|
||
assert!(ta.elements().is_empty());
|
||
|
||
ta.undo(); // undo "hi "
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
// ── Phase 3: Element undo/redo tests ──
|
||
|
||
#[test]
|
||
fn undo_insert_element_redo_preserves_element_id() {
|
||
let mut ta = TextArea::new();
|
||
let id = ta.insert_element("@foo", ElementKind(1), None);
|
||
assert_eq!(ta.elements().len(), 1);
|
||
assert_eq!(ta.elements()[0].id, id);
|
||
|
||
ta.undo(); // remove element
|
||
assert!(ta.elements().is_empty());
|
||
assert_eq!(ta.text(), "");
|
||
|
||
ta.redo(); // restore element — same ElementId
|
||
assert_eq!(ta.elements().len(), 1);
|
||
assert_eq!(ta.elements()[0].id, id);
|
||
assert_eq!(ta.text(), "@foo");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_replace_range_with_element_restores_original() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello @foo world");
|
||
// Replace "@foo" (6..10) with an element
|
||
let id = ta.replace_range_with_element(6..10, "@bar.rs", ElementKind(2), None);
|
||
assert_eq!(ta.text(), "hello @bar.rs world");
|
||
assert_eq!(ta.elements().len(), 1);
|
||
assert_eq!(ta.elements()[0].id, id);
|
||
|
||
ta.undo(); // undo replace → original text, no elements
|
||
assert_eq!(ta.text(), "hello @foo world");
|
||
assert!(ta.elements().is_empty());
|
||
|
||
ta.redo(); // redo → element back
|
||
assert_eq!(ta.text(), "hello @bar.rs world");
|
||
assert_eq!(ta.elements().len(), 1);
|
||
assert_eq!(ta.elements()[0].id, id);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_element_display_preserved() {
|
||
let mut ta = TextArea::new();
|
||
let display = Line::from(vec![
|
||
ratatui::text::Span::styled("[", Style::default().fg(Color::Green)),
|
||
ratatui::text::Span::raw("file.rs"),
|
||
ratatui::text::Span::styled("]", Style::default().fg(Color::Green)),
|
||
]);
|
||
let id = ta.insert_element("@file.rs", ElementKind(0), Some(display));
|
||
assert!(ta.elements()[0].display.is_some());
|
||
|
||
ta.undo();
|
||
assert!(ta.elements().is_empty());
|
||
|
||
ta.redo();
|
||
assert_eq!(ta.elements().len(), 1);
|
||
assert_eq!(ta.elements()[0].id, id);
|
||
// Display should be restored from the snapshot clone
|
||
let restored = ta.elements()[0].display.as_ref().unwrap();
|
||
assert_eq!(restored.spans.len(), 3);
|
||
let text: String = restored.spans.iter().map(|s| s.content.as_ref()).collect();
|
||
assert_eq!(text, "[file.rs]");
|
||
}
|
||
|
||
#[test]
|
||
fn next_element_id_never_decreases_after_undo() {
|
||
let mut ta = TextArea::new();
|
||
let id1 = ta.insert_element("a", ElementKind(0), None);
|
||
let id2 = ta.insert_element("b", ElementKind(0), None);
|
||
|
||
ta.undo(); // undo element "b"
|
||
ta.undo(); // undo element "a"
|
||
assert!(ta.elements().is_empty());
|
||
|
||
// New element after undo should get a fresh ID, never reuse id1 or id2.
|
||
let id3 = ta.insert_element("c", ElementKind(0), None);
|
||
assert_ne!(id3, id1);
|
||
assert_ne!(id3, id2);
|
||
// IDs are monotonically increasing
|
||
assert!(id3.0 > id2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_on_element_undo_restores_element() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("before ");
|
||
let id = ta.insert_element("[paste]", ElementKind(0), None);
|
||
assert_eq!(ta.text(), "before [paste]");
|
||
assert_eq!(ta.cursor(), 14);
|
||
|
||
// Backspace at element end → deletes entire element atomically
|
||
ta.delete_backward(1);
|
||
assert_eq!(ta.text(), "before ");
|
||
assert!(ta.elements().is_empty());
|
||
|
||
// Undo → element restored with same ID
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "before [paste]");
|
||
assert_eq!(ta.elements().len(), 1);
|
||
assert_eq!(ta.elements()[0].id, id);
|
||
assert_eq!(ta.elements()[0].range, 7..14);
|
||
}
|
||
|
||
// ── Phase 4: Undo group tests ──
|
||
|
||
#[test]
|
||
fn undo_group_collapses_multiple_mutations() {
|
||
// Autocomplete scenario: replace trigger + insert trailing space = 1 undo step.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello @fo");
|
||
assert_eq!(ta.text(), "hello @fo");
|
||
|
||
ta.begin_undo_group();
|
||
ta.replace_range_with_element(6..9, "@foo.rs", ElementKind(1), None);
|
||
ta.insert_str(" "); // trailing space after element
|
||
ta.end_undo_group();
|
||
|
||
assert_eq!(ta.text(), "hello @foo.rs ");
|
||
assert_eq!(ta.elements().len(), 1);
|
||
|
||
// Single undo undoes the entire autocomplete operation.
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "hello @fo");
|
||
assert!(ta.elements().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn cancel_undo_group_restores_original() {
|
||
// Line-select cancel: enter → N live-updates → cancel = 0 undo entries.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("original");
|
||
let stack_before = ta.undo.stack.len();
|
||
|
||
ta.begin_undo_group();
|
||
ta.set_text("modified once");
|
||
ta.set_text("modified twice");
|
||
ta.cancel_undo_group();
|
||
|
||
// State restored to before the group.
|
||
assert_eq!(ta.text(), "original");
|
||
// No new undo entries created by the group.
|
||
assert_eq!(ta.undo.stack.len(), stack_before);
|
||
}
|
||
|
||
#[test]
|
||
fn nested_groups_only_outermost_pushes() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("start");
|
||
|
||
ta.begin_undo_group(); // depth 1
|
||
ta.insert_str(" A");
|
||
ta.begin_undo_group(); // depth 2
|
||
ta.insert_str(" B");
|
||
ta.end_undo_group(); // depth 1 (inner end — no push)
|
||
assert_eq!(ta.text(), "start A B");
|
||
ta.insert_str(" C");
|
||
ta.end_undo_group(); // depth 0 (outermost end — push)
|
||
|
||
assert_eq!(ta.text(), "start A B C");
|
||
|
||
// Single undo undoes everything in the group.
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "start");
|
||
}
|
||
|
||
#[test]
|
||
fn group_with_no_mutations_creates_no_entry() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
let stack_len = ta.undo.stack.len();
|
||
|
||
ta.begin_undo_group();
|
||
// No mutations inside the group.
|
||
ta.end_undo_group();
|
||
|
||
// Stack unchanged — no empty undo entry created.
|
||
assert_eq!(ta.undo.stack.len(), stack_len);
|
||
}
|
||
|
||
#[test]
|
||
fn redo_cleared_by_end_undo_group() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
ta.undo(); // undo → ""
|
||
assert!(ta.can_redo());
|
||
|
||
ta.begin_undo_group();
|
||
ta.insert_str("world");
|
||
ta.end_undo_group();
|
||
|
||
// Redo from the previous undo should be cleared.
|
||
assert!(!ta.can_redo());
|
||
assert_eq!(ta.text(), "world");
|
||
}
|
||
|
||
#[test]
|
||
fn cancel_nested_group_restores_outermost() {
|
||
// Even if deeply nested, cancel restores to the outermost group snapshot.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("original");
|
||
|
||
ta.begin_undo_group();
|
||
ta.insert_str(" X");
|
||
ta.begin_undo_group();
|
||
ta.insert_str(" Y");
|
||
// Cancel from inner level — should still restore to outermost snapshot.
|
||
ta.cancel_undo_group();
|
||
|
||
assert_eq!(ta.text(), "original");
|
||
assert_eq!(ta.undo.group_depth, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn mutations_after_group_work_normally() {
|
||
// After a group ends, normal batching resumes.
|
||
let mut ta = TextArea::new();
|
||
|
||
ta.begin_undo_group();
|
||
ta.insert_str("grouped");
|
||
ta.end_undo_group();
|
||
|
||
// Normal insert after group — should be its own batch.
|
||
ta.insert_str("X");
|
||
ta.insert_str("Y"); // batches with X
|
||
|
||
ta.undo(); // undo "XY"
|
||
assert_eq!(ta.text(), "grouped");
|
||
|
||
ta.undo(); // undo group
|
||
assert_eq!(ta.text(), "");
|
||
}
|
||
|
||
// ── M3: Click-to-place cursor tests ──
|
||
|
||
/// Helper to create a MouseEvent for testing.
|
||
fn mouse_down(col: u16, row: u16) -> MouseEvent {
|
||
MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: col,
|
||
row,
|
||
modifiers: KeyModifiers::NONE,
|
||
}
|
||
}
|
||
|
||
fn mouse_up(col: u16, row: u16) -> MouseEvent {
|
||
MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: col,
|
||
row,
|
||
modifiers: KeyModifiers::NONE,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn click_places_cursor_at_correct_position() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click at column 3 → cursor at byte 3
|
||
let action = ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert_eq!(ta.cursor(), 3);
|
||
|
||
// Click at column 0 → cursor at byte 0
|
||
let action = ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert_eq!(ta.cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_element_returns_clicked_element() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hi ");
|
||
let id = ta.insert_element("elem", ElementKind(0), None);
|
||
ta.insert_str(" bye");
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Element occupies cols 3..7, click at col 4
|
||
let action = ta.handle_mouse(mouse_down(4, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
let ev = ta.poll_element_event().expect("should emit element click");
|
||
assert_eq!(ev.id, id);
|
||
assert_eq!(ev.kind, TextElementEventKind::Click);
|
||
}
|
||
|
||
#[test]
|
||
fn click_past_end_of_line_snaps_to_line_end() {
|
||
let mut ta = ta_with("hi");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click far past end of "hi" (col 20)
|
||
let action = ta.handle_mouse(mouse_down(20, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert_eq!(ta.cursor(), 2); // end of "hi"
|
||
}
|
||
|
||
#[test]
|
||
fn click_below_text_snaps_to_text_end() {
|
||
let mut ta = ta_with("hello");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click on row 3 (only 1 row of text)
|
||
let action = ta.handle_mouse(mouse_down(0, 3), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert_eq!(ta.cursor(), 5); // text.len()
|
||
}
|
||
|
||
#[test]
|
||
fn click_clears_existing_selection() {
|
||
let mut ta = ta_with("hello world");
|
||
ta.set_selection(0, 5);
|
||
assert!(ta.selection_range().is_some());
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
ta.handle_mouse(mouse_down(8, 0), area, state);
|
||
assert!(ta.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn click_outside_area_returns_nothing() {
|
||
let mut ta = ta_with("hello");
|
||
let area = Rect::new(5, 5, 20, 3);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click outside the area
|
||
let action = ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
assert_eq!(action, MouseAction::Nothing);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_up_clears_down_pos() {
|
||
let mut ta = ta_with("hello");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
assert!(ta.mouse_down_pos.is_some());
|
||
|
||
ta.handle_mouse(mouse_up(2, 0), area, state);
|
||
assert!(ta.mouse_down_pos.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_second_line_multiline_text() {
|
||
let mut ta = ta_with("hello\nworld");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click on row 1, col 2 → "world" starts at byte 6, so byte 8 = 'r'
|
||
let action = ta.handle_mouse(mouse_down(2, 1), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert_eq!(ta.cursor(), 8); // "hello\nwo" = 8 bytes → cursor at 'r'
|
||
}
|
||
|
||
// ── M4: Drag selection tests ──
|
||
|
||
fn mouse_drag(col: u16, row: u16) -> MouseEvent {
|
||
MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: col,
|
||
row,
|
||
modifiers: KeyModifiers::NONE,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drag_selects_text() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Mouse down at col 0, drag to col 5
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_drag(5, 0), area, state);
|
||
assert_eq!(action, MouseAction::SelectionUpdated);
|
||
assert_eq!(ta.selection_range(), Some(0..5));
|
||
assert_eq!(ta.selected_text(), Some("hello".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn drag_across_element_expands_to_element_boundaries() {
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("ab");
|
||
ta.insert_element("ELEM", ElementKind(0), None);
|
||
ta.insert_str("cd");
|
||
// buffer: "abELEMcd"
|
||
// element range: 2..6
|
||
// display cols: a(0) b(1) E(2) L(3) E(4) M(5) c(6) d(7)
|
||
|
||
// Drag from col 1 ("b") to col 7 ("d") — fully crosses the element.
|
||
// Raw selection: anchor=1, head=7. Element at 2..6 is fully inside.
|
||
ta.handle_mouse(mouse_down(1, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(7, 0), area, state);
|
||
let range = ta.selection_range().unwrap();
|
||
assert_eq!(range, 1..7);
|
||
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("ab");
|
||
ta.insert_element("ELEM", ElementKind(0), None);
|
||
ta.insert_str("cd");
|
||
|
||
// Now test partial overlap: drag from col 0 to col 3 (into the element).
|
||
// display_col_to_buffer_pos snaps col 3 to element start (2) since dist
|
||
// to start (1) < dist to end (3). Raw selection 0..2 → but element at
|
||
// 2..6 is NOT overlapped, so no expansion.
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(3, 0), area, state);
|
||
let range = ta.selection_range().unwrap();
|
||
assert_eq!(range, 0..2);
|
||
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("ab");
|
||
ta.insert_element("ELEM", ElementKind(0), None);
|
||
ta.insert_str("cd");
|
||
|
||
// Drag from col 0 to col 5 — past element midpoint, so snaps to end (6).
|
||
// Raw selection 0..6 → element fully covered.
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(5, 0), area, state);
|
||
let range = ta.selection_range().unwrap();
|
||
assert_eq!(range, 0..6);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_up_after_drag_copies_to_clipboard() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
ta.handle_mouse(mouse_down(6, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(11, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_up(11, 0), area, state);
|
||
assert_eq!(action, MouseAction::SelectionFinished);
|
||
|
||
// Clipboard should contain "world"
|
||
assert_eq!(ta.take_clipboard(), Some("world".to_string()));
|
||
// take_clipboard clears it
|
||
assert_eq!(ta.take_clipboard(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn selection_persists_after_mouseup_by_default() {
|
||
let mut ta = ta_with("hello");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(3, 0), area, state);
|
||
ta.handle_mouse(mouse_up(3, 0), area, state);
|
||
|
||
// Default: keep_selection_after_mouseup = true
|
||
assert!(ta.selection_range().is_some());
|
||
assert_eq!(ta.selected_text(), Some("hel".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn selection_clears_after_mouseup_when_configured() {
|
||
let mut ta = ta_with("hello");
|
||
ta.keep_selection_after_mouseup = false;
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(3, 0), area, state);
|
||
ta.handle_mouse(mouse_up(3, 0), area, state);
|
||
|
||
// Clipboard was still set
|
||
assert_eq!(ta.take_clipboard(), Some("hel".to_string()));
|
||
// But selection is cleared
|
||
assert!(ta.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_deletes_selection_only() {
|
||
let mut ta = ta_with("hello world");
|
||
ta.set_selection(0, 5);
|
||
assert_eq!(ta.selected_text(), Some("hello".to_string()));
|
||
|
||
// Backspace should delete "hello", not an extra char.
|
||
ta.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
assert_eq!(ta.text(), " world");
|
||
assert_eq!(ta.cursor(), 0);
|
||
assert!(ta.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn typing_replaces_selection() {
|
||
let mut ta = ta_with("hello world");
|
||
ta.set_selection(0, 5);
|
||
|
||
// Typing 'X' should replace "hello" with "X".
|
||
ta.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
|
||
assert_eq!(ta.text(), "X world");
|
||
assert_eq!(ta.cursor(), 1);
|
||
assert!(ta.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn arrow_clears_selection() {
|
||
let mut ta = ta_with("hello world");
|
||
ta.set_selection(0, 5);
|
||
assert!(ta.selection_range().is_some());
|
||
|
||
ta.input(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||
assert!(ta.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn undo_after_delete_selection_restores() {
|
||
let mut ta = ta_with("hello world");
|
||
ta.set_selection(0, 5);
|
||
|
||
ta.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
assert_eq!(ta.text(), " world");
|
||
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "hello world");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_after_type_replace_selection_restores() {
|
||
let mut ta = ta_with("hello world");
|
||
ta.set_selection(0, 5);
|
||
|
||
ta.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
|
||
assert_eq!(ta.text(), "X world");
|
||
|
||
// Single undo should restore to pre-replacement state (undo group).
|
||
ta.undo();
|
||
assert_eq!(ta.text(), "hello world");
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_works_with_zero_width_selection() {
|
||
// Regression: a zero-width selection (anchor == head, from mouse
|
||
// jitter) caused Backspace/Delete to be silently swallowed because
|
||
// delete_selection() returned false but input() still returned early.
|
||
let mut ta = ta_with("hello");
|
||
ta.set_cursor(5);
|
||
// Simulate a zero-width selection (anchor == head at cursor).
|
||
ta.set_selection(5, 5);
|
||
assert!(ta.selection_range().is_none()); // zero-width → no range
|
||
|
||
// Backspace must still delete the char before cursor.
|
||
ta.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
assert_eq!(ta.text(), "hell");
|
||
assert_eq!(ta.cursor(), 4);
|
||
// Selection should be cleared.
|
||
assert!(ta.selection.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn delete_forward_works_with_zero_width_selection() {
|
||
let mut ta = ta_with("hello");
|
||
ta.set_cursor(2);
|
||
ta.set_selection(2, 2);
|
||
|
||
ta.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||
assert_eq!(ta.text(), "helo");
|
||
assert_eq!(ta.cursor(), 2);
|
||
assert!(ta.selection.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_x_with_zero_width_selection_falls_through() {
|
||
let mut ta = ta_with("hello");
|
||
ta.set_cursor(5);
|
||
ta.set_selection(5, 5);
|
||
|
||
// Ctrl-X on zero-width selection shouldn't eat the key.
|
||
// It should clear selection and fall through to normal handling
|
||
// (which for Ctrl-X without selection is a no-op, but the selection
|
||
// must be cleared).
|
||
ta.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
||
assert!(ta.selection.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_up_discards_zero_width_drag_selection() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click at col 3 then drag to same position (zero distance).
|
||
ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_drag(3, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
let action = ta.handle_mouse(mouse_up(3, 0), area, state);
|
||
assert_eq!(action, MouseAction::Nothing);
|
||
|
||
// Zero-width drag should not leave a selection behind.
|
||
assert!(ta.selection.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn drag_backward_selects_correctly() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click at col 8, drag back to col 3 (backward selection)
|
||
ta.handle_mouse(mouse_down(8, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(3, 0), area, state);
|
||
// selection_range() normalizes anchor/head
|
||
assert_eq!(ta.selection_range(), Some(3..8));
|
||
assert_eq!(ta.selected_text(), Some("lo wo".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn click_after_drag_clears_selection() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Drag to select
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(5, 0), area, state);
|
||
ta.handle_mouse(mouse_up(5, 0), area, state);
|
||
assert!(ta.selection_range().is_some());
|
||
|
||
// New click clears the old selection
|
||
ta.handle_mouse(mouse_down(8, 0), area, state);
|
||
assert!(ta.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn set_text_clears_selection_and_drag_state() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(5, 0), area, state);
|
||
assert!(ta.selection_range().is_some());
|
||
assert!(ta.drag_anchor.is_some());
|
||
assert!(ta.drag_active);
|
||
assert!(ta.mouse_down_pos.is_some());
|
||
|
||
ta.set_text("reset");
|
||
|
||
assert!(ta.selection.is_none());
|
||
assert!(ta.selection_range().is_none());
|
||
assert!(ta.drag_anchor.is_none());
|
||
assert!(!ta.drag_active);
|
||
assert!(ta.mouse_down_pos.is_none());
|
||
assert!(ta.pending_drag_scroll.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn same_cell_drag_does_not_create_selection() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_drag(3, 0), area, state);
|
||
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert!(ta.selection.is_none());
|
||
assert!(ta.selection_range().is_none());
|
||
assert!(!ta.drag_active);
|
||
}
|
||
|
||
#[test]
|
||
fn typing_with_zero_width_selection_inserts_character() {
|
||
let mut ta = ta_with("hello");
|
||
ta.set_cursor(5);
|
||
ta.set_selection(5, 5);
|
||
|
||
ta.input(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT));
|
||
|
||
assert_eq!(ta.text(), "hello!");
|
||
assert_eq!(ta.cursor(), 6);
|
||
assert!(ta.selection.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_up_after_drag_clears_drag_anchor() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(5, 0), area, state);
|
||
assert!(ta.drag_anchor.is_some());
|
||
|
||
ta.handle_mouse(mouse_up(5, 0), area, state);
|
||
|
||
assert!(ta.drag_anchor.is_none());
|
||
assert!(!ta.drag_active);
|
||
}
|
||
|
||
// ── M5: Double/triple click tests ──
|
||
|
||
#[test]
|
||
fn double_click_selects_word() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Double-click on "hello" (col 2)
|
||
ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
assert_eq!(action, MouseAction::SelectionFinished);
|
||
assert_eq!(ta.selection_range(), Some(0..5));
|
||
assert_eq!(ta.selected_text(), Some("hello".to_string()));
|
||
assert_eq!(ta.take_clipboard(), Some("hello".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn double_click_cursor_on_last_char() {
|
||
// Neovim places cursor on the last character of the selection,
|
||
// not one past the end.
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Double-click on "hello"
|
||
ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
|
||
assert_eq!(ta.selection_range(), Some(0..5));
|
||
// Cursor should be on 'o' (byte 4), not on ' ' (byte 5)
|
||
assert_eq!(
|
||
ta.cursor(),
|
||
4,
|
||
"double-click cursor should be on last char 'o' (byte 4), got {}",
|
||
ta.cursor()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn double_click_cursor_on_last_char_unicode() {
|
||
// Test with multi-byte characters — cursor must land on a valid char boundary
|
||
let mut ta = ta_with("café bar");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Double-click on "café" (col 1 = 'a')
|
||
ta.handle_mouse(mouse_down(1, 0), area, state);
|
||
ta.handle_mouse(mouse_down(1, 0), area, state);
|
||
|
||
assert_eq!(ta.selected_text(), Some("café".to_string()));
|
||
// 'é' is 2 bytes (0xC3 0xA9), so "café" = [c(0), a(1), f(2), é(3,4)]
|
||
// Last char 'é' starts at byte 3
|
||
assert_eq!(
|
||
ta.cursor(),
|
||
3,
|
||
"cursor should be on 'é' (byte 3), got {}",
|
||
ta.cursor()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn double_click_on_second_word() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Double-click on "world" (col 8)
|
||
ta.handle_mouse(mouse_down(8, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_down(8, 0), area, state);
|
||
assert_eq!(action, MouseAction::SelectionFinished);
|
||
assert_eq!(ta.selection_range(), Some(6..11));
|
||
assert_eq!(ta.selected_text(), Some("world".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn double_click_stops_at_punctuation() {
|
||
// "hello, world," — double-click on 'h' should select "hello", not "hello,"
|
||
let mut ta = ta_with("hello, world,");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Double-click on "hello" (col 2 = 'l')
|
||
ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
assert_eq!(action, MouseAction::SelectionFinished);
|
||
assert_eq!(
|
||
ta.selected_text(),
|
||
Some("hello".to_string()),
|
||
"should select only 'hello', not include trailing comma"
|
||
);
|
||
assert_eq!(ta.selection_range(), Some(0..5));
|
||
}
|
||
|
||
#[test]
|
||
fn double_click_on_punctuation_selects_punctuation_run() {
|
||
// Double-click on punctuation selects the contiguous punctuation
|
||
let mut ta = ta_with("hello... world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Double-click on "..." (col 6 = first '.')
|
||
ta.handle_mouse(mouse_down(6, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_down(6, 0), area, state);
|
||
assert_eq!(action, MouseAction::SelectionFinished);
|
||
assert_eq!(
|
||
ta.selected_text(),
|
||
Some("...".to_string()),
|
||
"double-click on punctuation should select the punctuation run"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn double_click_word_with_underscore() {
|
||
// Underscores are part of a word (like vim's iskeyword)
|
||
let mut ta = ta_with("hello_world foo");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Double-click on "hello_world" (col 3)
|
||
ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
assert_eq!(action, MouseAction::SelectionFinished);
|
||
assert_eq!(
|
||
ta.selected_text(),
|
||
Some("hello_world".to_string()),
|
||
"underscore should be part of the word"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn double_click_on_element_snaps_like_single_click() {
|
||
// Word-selecting an element would copy its hidden buffer text to
|
||
// the clipboard; a double-click must instead snap to the element
|
||
// start and re-emit Click so the host decides what it means.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hi ");
|
||
let display = Line::from("[chip]");
|
||
let id = ta.insert_element("hidden\ntext", ElementKind(0), Some(display));
|
||
ta.insert_str(" bye");
|
||
// Buffer: "hi hidden\ntext bye", element at 3..14, display "[chip]".
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// First click on the display (col 4) emits its own Click event.
|
||
ta.handle_mouse(mouse_down(4, 0), area, state);
|
||
assert!(ta.poll_element_event().is_some());
|
||
|
||
let action = ta.handle_mouse(mouse_down(4, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert_eq!(ta.cursor(), 3); // element start
|
||
assert!(ta.selection_range().is_none());
|
||
assert_eq!(ta.take_clipboard(), None);
|
||
let ev = ta
|
||
.poll_element_event()
|
||
.expect("double-click re-emits Click");
|
||
assert_eq!(ev.id, id);
|
||
assert_eq!(ev.kind, TextElementEventKind::Click);
|
||
}
|
||
|
||
#[test]
|
||
fn triple_click_selects_line() {
|
||
let mut ta = ta_with("hello world\nsecond line\nthird");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Triple-click on first line (col 3)
|
||
ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
assert_eq!(action, MouseAction::SelectionFinished);
|
||
// Should select "hello world\n" (including the newline)
|
||
assert_eq!(ta.selection_range(), Some(0..12));
|
||
assert_eq!(ta.selected_text(), Some("hello world\n".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn triple_click_on_last_line_selects_to_end() {
|
||
let mut ta = ta_with("hello\nworld");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Triple-click on "world" (row 1, col 2)
|
||
ta.handle_mouse(mouse_down(2, 1), area, state);
|
||
ta.handle_mouse(mouse_down(2, 1), area, state);
|
||
let action = ta.handle_mouse(mouse_down(2, 1), area, state);
|
||
assert_eq!(action, MouseAction::SelectionFinished);
|
||
// Last line has no trailing \n — selects to text.len()
|
||
assert_eq!(ta.selection_range(), Some(6..11));
|
||
assert_eq!(ta.selected_text(), Some("world".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn triple_click_cursor_stays_at_click_pos() {
|
||
// Triple-click should select the whole line but keep the cursor
|
||
// at the click position, not at the end of the selection.
|
||
let mut ta = ta_with("hello world\nsecond line\nthird");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Triple-click on first line at col 3 (byte 3 = 'l')
|
||
ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
ta.handle_mouse(mouse_down(3, 0), area, state);
|
||
|
||
// Selection covers the full line "hello world\n"
|
||
assert_eq!(ta.selection_range(), Some(0..12));
|
||
|
||
// Cursor should be at the click position (byte 3), not at line_end (12)
|
||
assert_eq!(
|
||
ta.cursor(),
|
||
3,
|
||
"triple-click cursor should stay at click pos (3), got {}",
|
||
ta.cursor()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn selection_uses_custom_style_override() {
|
||
let mut t = ta_with("hello");
|
||
t.selection_style = Style::default().bg(Color::Blue);
|
||
t.set_selection(1, 4); // select "ell"
|
||
|
||
let area = Rect::new(0, 0, 10, 1);
|
||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
|
||
|
||
// Cells 1, 2, 3 should have Blue background (custom selection style)
|
||
for col in 1..4u16 {
|
||
let cell = &buf[(col, 0)];
|
||
assert_eq!(
|
||
cell.bg,
|
||
Color::Blue,
|
||
"cell at col {col} should have Blue bg"
|
||
);
|
||
}
|
||
// Cell 0 ('h') and cell 4 ('o') should NOT have Blue bg
|
||
assert_ne!(buf[(0, 0)].bg, Color::Blue);
|
||
assert_ne!(buf[(4, 0)].bg, Color::Blue);
|
||
}
|
||
|
||
#[test]
|
||
fn double_click_on_whitespace_places_cursor() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Double-click on whitespace (col 6)
|
||
ta.handle_mouse(mouse_down(6, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_down(6, 0), area, state);
|
||
// Whitespace has no word → just places cursor
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert!(ta.selection_range().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn click_tracker_resets_on_position_change() {
|
||
let mut ta = ta_with("hello world");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click at col 2, then at col 8 → not a double-click
|
||
ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
let action = ta.handle_mouse(mouse_down(8, 0), area, state);
|
||
// Should be a single click, not a double-click
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert!(ta.selection_range().is_none());
|
||
}
|
||
|
||
// ── Drag-to-scroll tests ──
|
||
|
||
#[test]
|
||
fn drag_below_area_scrolls_down_and_extends_selection() {
|
||
// Text with 5 lines, visible area is only 3 rows tall.
|
||
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee");
|
||
// Place cursor at start so scroll=0.
|
||
ta.set_cursor(0);
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click on first line.
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
assert_eq!(ta.cursor(), 0);
|
||
|
||
// Drag below the visible area (row 5, past area.height=3).
|
||
let action = ta.handle_mouse(mouse_drag(0, 5), area, state);
|
||
assert_eq!(action, MouseAction::SelectionUpdated);
|
||
|
||
// Cursor should have moved past the visible area.
|
||
// With scroll=0 and height=3, visible lines are 0,1,2 (aaa,bbb,ccc).
|
||
// Dragging below → target_line = visible_end = 3 → "ddd" starts at byte 12.
|
||
// At col 0, cursor should be at byte 12 (start of "ddd").
|
||
assert!(ta.cursor() >= 12);
|
||
|
||
// Selection should extend from anchor (0) to the new cursor position.
|
||
let range = ta.selection_range().unwrap();
|
||
assert_eq!(range.start, 0);
|
||
assert!(range.end >= 12);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_above_area_scrolls_up_and_extends_selection() {
|
||
// Text with 5 lines, start with cursor on the last line.
|
||
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee");
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
|
||
// Place cursor at the end first (to ensure scroll is at the bottom).
|
||
ta.set_cursor(ta.text().len());
|
||
let state = TextAreaState { scroll: 2 };
|
||
|
||
// Click on bottom visible line (row 2).
|
||
ta.handle_mouse(mouse_down(1, 2), area, state);
|
||
|
||
// Drag above the visible area (row is before area.y).
|
||
// Since area.y = 0, dragging to row=0 when scroll=2 means the row
|
||
// is at the top edge. We need a row *above* the area. With area.y=0,
|
||
// we can't go negative, but we can use an area with area.y > 0.
|
||
let area2 = Rect::new(0, 5, 40, 3); // area starts at row 5
|
||
ta.handle_mouse(mouse_down(1, 7), area2, state); // click at row 7 (visible)
|
||
|
||
// Drag above: row 3 (above area2.y=5)
|
||
let action = ta.handle_mouse(mouse_drag(0, 3), area2, state);
|
||
assert_eq!(action, MouseAction::SelectionUpdated);
|
||
|
||
// Cursor should have moved to a line above the visible region.
|
||
// The exact position depends on how many lines we scroll per drag.
|
||
let range = ta.selection_range().unwrap();
|
||
assert!(range.start < range.end);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_below_area_moves_cursor_past_last_visible_line() {
|
||
// 10 short lines, area shows only 2.
|
||
let text = "L0\nL1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9";
|
||
let mut ta = ta_with(text);
|
||
// Place cursor at start so scroll=0.
|
||
ta.set_cursor(0);
|
||
let area = Rect::new(0, 0, 40, 2);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click at start.
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
assert_eq!(ta.cursor(), 0);
|
||
|
||
// Drag below area (row 10, way below the 2-row area).
|
||
let action = ta.handle_mouse(mouse_drag(1, 10), area, state);
|
||
assert_eq!(action, MouseAction::SelectionUpdated);
|
||
|
||
// With scroll=0 and height=2, visible lines are 0,1 (L0,L1).
|
||
// Dragging below → target_line = 2 → "L2" starts at byte 6.
|
||
// Cursor should be at col 1 of L2 → byte 7.
|
||
assert!(ta.cursor() >= 6, "cursor={} should be >= 6", ta.cursor());
|
||
}
|
||
|
||
#[test]
|
||
fn drag_above_wide_column_still_scrolls_up() {
|
||
// Bug: when dragging above the area with a column wider than the
|
||
// target line, display_col_to_buffer_pos returns line_end which
|
||
// equals the *next* line's start. wrapped_line_index_by_start
|
||
// then resolves to the next line, so effective_scroll sees the
|
||
// cursor as still within the viewport and doesn't scroll.
|
||
//
|
||
// Scenario: 10 short lines ("ab"), area is 3 rows tall with
|
||
// area.y = 2 (so we can drag above). Scroll starts at line 5.
|
||
// We drag to row 1 (above area.y = 2) at column 50 (way past
|
||
// each 3-byte line). The cursor must land ON the target line
|
||
// (line 4), not spill over to line 5.
|
||
let text = "ab\nab\nab\nab\nab\nab\nab\nab\nab\nab";
|
||
let mut ta = ta_with(text);
|
||
let area = Rect::new(0, 2, 40, 3); // area starts at row 2
|
||
let state = TextAreaState { scroll: 5 };
|
||
|
||
// Place cursor on wrapped line 6 (within viewport at scroll=5).
|
||
// "ab\n" is 3 bytes per line, so line 6 starts at byte 18.
|
||
ta.set_cursor(18);
|
||
|
||
// Click inside the area (row 3 = area.y + 1).
|
||
ta.handle_mouse(mouse_down(1, 3), area, state);
|
||
|
||
// Drag above the area: row 1 (< area.y=2), column 50 (far right).
|
||
let action = ta.handle_mouse(mouse_drag(50, 1), area, state);
|
||
assert_eq!(action, MouseAction::SelectionUpdated);
|
||
|
||
// target_line = scroll(5) - 1 = 4. Line 4 spans bytes 12..15.
|
||
// The cursor MUST be within line 4's range [12, 14], NOT at 15
|
||
// (which is line 5's start).
|
||
let cursor = ta.cursor();
|
||
assert!(
|
||
(12..15).contains(&cursor),
|
||
"cursor={cursor} should be in [12, 15) (on line 4), \
|
||
not at 15 (line 5 start)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_below_wide_column_still_scrolls_down() {
|
||
// Same bug but for scrolling down: dragging below the area with
|
||
// a wide column should place the cursor on the target line, not
|
||
// spill over to the next line.
|
||
let text = "ab\nab\nab\nab\nab\nab\nab\nab\nab\nab";
|
||
let mut ta = ta_with(text);
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
let state = TextAreaState::default(); // scroll=0
|
||
|
||
// Place cursor on first line.
|
||
ta.set_cursor(0);
|
||
|
||
// Click inside the area.
|
||
ta.handle_mouse(mouse_down(1, 0), area, state);
|
||
|
||
// Drag below the area: row 5 (>= area.y + height=3), column 50.
|
||
let action = ta.handle_mouse(mouse_drag(50, 5), area, state);
|
||
assert_eq!(action, MouseAction::SelectionUpdated);
|
||
|
||
// visible_end = 0 + 3 = 3. dist = 5 - 3 + 1 = 3.
|
||
// n = drag_scroll_lines_for_distance(3) = 2.
|
||
// target_line = (3 + 2 - 1) = 4. Line 4 spans bytes 12..15.
|
||
// Cursor must be within [12, 14], not at 15.
|
||
let cursor = ta.cursor();
|
||
assert!(
|
||
(12..15).contains(&cursor),
|
||
"cursor={cursor} should be in [12, 15) (on line 4), \
|
||
not at 15 (line 5 start)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_above_with_multibyte_line_end_does_not_panic() {
|
||
// Regression: clamp_to_line used `line_end - 1` which can land inside
|
||
// a multi-byte character (e.g. '│' = 3 bytes).
|
||
let text = "aaa│\nbbb│\nccc│\nddd│\neee│\nfff│\nggg│";
|
||
let mut ta = ta_with(text);
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
// Start scrolled down so we can drag above.
|
||
let state = TextAreaState { scroll: 3 };
|
||
ta.set_cursor(20); // somewhere in the middle
|
||
|
||
// Click inside the area.
|
||
ta.handle_mouse(mouse_down(1, 1), area, state);
|
||
|
||
// Drag above the area at a wide column (beyond line width).
|
||
let action = ta.handle_mouse(mouse_drag(50, 0), area, state);
|
||
// Should not panic — cursor should be on a valid char boundary.
|
||
assert!(
|
||
matches!(action, MouseAction::SelectionUpdated),
|
||
"drag above should create selection, got {action:?}"
|
||
);
|
||
// Verify cursor is at a valid char boundary by reading from it.
|
||
let cursor = ta.cursor();
|
||
assert!(
|
||
ta.text().is_char_boundary(cursor),
|
||
"cursor at byte {cursor} is not a char boundary"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn selection_across_element_with_multibyte_chars_does_not_panic() {
|
||
// Regression: display_col_to_buffer_pos used `line_end + 1` to skip
|
||
// past elements, but `line_end + 1` can land inside a multi-byte
|
||
// character (e.g. '│' = 3 bytes).
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("before ");
|
||
// Create an element whose backing text contains multi-byte '│' chars
|
||
// across multiple lines — this triggers wrapping mid-element.
|
||
let backing = "│ Ctrl+Shift+Z/Y redo │\n│ Ctrl+C clear │";
|
||
ta.insert_element(backing, ElementKind(0), None);
|
||
ta.insert_str(" after");
|
||
|
||
let area = Rect::new(0, 0, 30, 5); // narrow so wrapping is forced
|
||
|
||
// Select across the entire text (from start to end).
|
||
ta.set_selection(0, ta.text().len());
|
||
|
||
// Render should not panic.
|
||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&ta), area, &mut buf);
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_text_with_multibyte_chars_does_not_panic() {
|
||
// Plain text with '│' — clicking anywhere should not panic.
|
||
let text = "│ Ctrl+Shift+Z/Y redo │\n│ Ctrl+C clear │";
|
||
let mut ta = ta_with(text);
|
||
let area = Rect::new(0, 0, 30, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click at various columns — should not panic.
|
||
for col in 0..25u16 {
|
||
ta.handle_mouse(mouse_down(col, 0), area, state);
|
||
}
|
||
// Double-click should also be safe.
|
||
ta.handle_mouse(mouse_down(5, 0), area, state);
|
||
ta.handle_mouse(mouse_down(5, 0), area, state);
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_wrapped_line_ending_with_multibyte_char_does_not_panic() {
|
||
// Regression: when a line wraps and '│' (3-byte char) ends up right
|
||
// at the wrap boundary, the wrapping code (or rendering) can produce
|
||
// a byte position inside the multi-byte character.
|
||
//
|
||
// Reproduce: enough spaces so '│' is pushed to the next wrapped line.
|
||
let text = format!("{}│", " ".repeat(29)); // 29 spaces + '│' = 30 display cols
|
||
let mut ta = ta_with(&text);
|
||
let area = Rect::new(0, 0, 30, 5); // width 30 → '│' wraps to next line
|
||
let _state = TextAreaState::default();
|
||
|
||
// Select across the wrap boundary.
|
||
ta.set_selection(0, ta.text().len());
|
||
|
||
// Render should not panic.
|
||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||
ratatui::widgets::WidgetRef::render_ref(&(&ta), area, &mut buf);
|
||
}
|
||
|
||
#[test]
|
||
fn clicking_on_wrapped_multibyte_line_does_not_panic() {
|
||
// Same as above but triggered via click/drag rather than render.
|
||
for extra_spaces in 28..33 {
|
||
let text = format!("{}│end", " ".repeat(extra_spaces));
|
||
let mut ta = ta_with(&text);
|
||
let area = Rect::new(0, 0, 30, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click on every column of both rows.
|
||
for row in 0..2u16 {
|
||
for col in 0..30u16 {
|
||
ta.handle_mouse(mouse_down(col, row), area, state);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Inline element tests ──
|
||
|
||
#[test]
|
||
fn inline_element_replaces_element_with_text() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("before ");
|
||
let id = ta.insert_element("pasted\ncontent\nhere", ElementKind(1), None);
|
||
ta.insert_str(" after");
|
||
// Buffer: "before pasted\ncontent\nhere after"
|
||
// Element at "pasted\ncontent\nhere" (bytes 7..26)
|
||
|
||
let inlined = ta.inline_element(id);
|
||
assert!(inlined);
|
||
|
||
// Text should remain the same (the element's buffer text is kept).
|
||
assert_eq!(ta.text(), "before pasted\ncontent\nhere after");
|
||
// But the element should be gone.
|
||
assert!(ta.elements().is_empty());
|
||
// Cursor should be at the end of the inlined text.
|
||
assert_eq!(ta.cursor(), 26); // end of "pasted\ncontent\nhere"
|
||
}
|
||
|
||
#[test]
|
||
fn inline_element_is_undoable() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("A ");
|
||
let id = ta.insert_element("multi\nline", ElementKind(1), None);
|
||
ta.insert_str(" B");
|
||
// Buffer: "A multi\nline B", element at 2..12
|
||
|
||
assert_eq!(ta.elements().len(), 1);
|
||
|
||
ta.inline_element(id);
|
||
assert!(ta.elements().is_empty());
|
||
assert_eq!(ta.text(), "A multi\nline B");
|
||
|
||
// Undo should restore the element.
|
||
assert!(ta.undo());
|
||
assert_eq!(ta.elements().len(), 1);
|
||
assert_eq!(ta.element_text(id), Some("multi\nline"));
|
||
}
|
||
|
||
#[test]
|
||
fn inline_nonexistent_element_returns_false() {
|
||
let mut ta = ta_with("hello");
|
||
let fake_id = ElementId(9999);
|
||
assert!(!ta.inline_element(fake_id));
|
||
}
|
||
|
||
#[test]
|
||
fn inline_element_cursor_at_element_start() {
|
||
let mut ta = TextArea::new();
|
||
let id = ta.insert_element("elem", ElementKind(0), None);
|
||
ta.insert_str(" tail");
|
||
// Cursor is after " tail" → at end.
|
||
// Move cursor to element start.
|
||
ta.set_cursor(0);
|
||
|
||
ta.inline_element(id);
|
||
// Element removed, text unchanged.
|
||
assert!(ta.elements().is_empty());
|
||
// Cursor at end of inlined region.
|
||
assert_eq!(ta.cursor(), 4);
|
||
}
|
||
|
||
// ── Click-on-element edge cases ──
|
||
|
||
#[test]
|
||
fn click_on_element_second_half_snaps_to_start() {
|
||
// Element with a wide display: clicking on the right half should still
|
||
// snap cursor to element start and emit a Click element event.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("ab");
|
||
// Element display is "ELEM" (4 chars). Buffer text is "xy".
|
||
let display = Line::from("ELEM");
|
||
let id = ta.insert_element("xy", ElementKind(0), Some(display));
|
||
ta.insert_str("cd");
|
||
// Buffer: "abxycd", element at 2..4, display "ELEM" (4 wide)
|
||
// Visual: a b E L E M c d
|
||
// 0 1 2 3 4 5 6 7
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
ta.set_cursor(0);
|
||
|
||
// Click on col 5 → second half of "ELEM" display.
|
||
// display_col_to_buffer_pos should return elem_end=4 (closer to end).
|
||
// handle_mouse should detect this as on-element and snap to start.
|
||
let action = ta.handle_mouse(mouse_down(5, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
let ev = ta.poll_element_event().expect("should emit element click");
|
||
assert_eq!(ev.id, id);
|
||
assert_eq!(ev.kind, TextElementEventKind::Click);
|
||
assert_eq!(ta.cursor(), 2); // element start
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_element_first_half_snaps_to_start() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("ab");
|
||
let display = Line::from("ELEM");
|
||
let id = ta.insert_element("xy", ElementKind(0), Some(display));
|
||
ta.insert_str("cd");
|
||
ta.set_cursor(0);
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click on col 2 → first half of "ELEM" display.
|
||
let action = ta.handle_mouse(mouse_down(2, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
let ev = ta.poll_element_event().expect("should emit element click");
|
||
assert_eq!(ev.id, id);
|
||
assert_eq!(ev.kind, TextElementEventKind::Click);
|
||
assert_eq!(ta.cursor(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn click_after_element_places_cursor_not_element() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("ab");
|
||
let display = Line::from("EL");
|
||
ta.insert_element("xy", ElementKind(0), Some(display));
|
||
ta.insert_str("cd");
|
||
ta.set_cursor(0);
|
||
// Visual: a b E L c d
|
||
// 0 1 2 3 4 5
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click on col 4 → 'c' (after element).
|
||
let action = ta.handle_mouse(mouse_down(4, 0), area, state);
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert_eq!(ta.cursor(), 4); // byte 4 = 'c'
|
||
}
|
||
|
||
// ── Mouse wheel tests ──
|
||
|
||
fn mouse_scroll_down(col: u16, row: u16) -> MouseEvent {
|
||
MouseEvent {
|
||
kind: MouseEventKind::ScrollDown,
|
||
column: col,
|
||
row,
|
||
modifiers: KeyModifiers::NONE,
|
||
}
|
||
}
|
||
|
||
fn mouse_scroll_up(col: u16, row: u16) -> MouseEvent {
|
||
MouseEvent {
|
||
kind: MouseEventKind::ScrollUp,
|
||
column: col,
|
||
row,
|
||
modifiers: KeyModifiers::NONE,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_down_returns_scrolled() {
|
||
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee");
|
||
ta.set_cursor(0);
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
let state = TextAreaState::default();
|
||
|
||
let action = ta.handle_mouse(mouse_scroll_down(5, 1), area, state);
|
||
assert_eq!(action, MouseAction::Scrolled);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_up_returns_scrolled() {
|
||
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee");
|
||
ta.set_cursor(ta.text().len());
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
let state = TextAreaState { scroll: 2 };
|
||
|
||
let action = ta.handle_mouse(mouse_scroll_up(5, 1), area, state);
|
||
assert_eq!(action, MouseAction::Scrolled);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_down_when_content_fits_returns_nothing() {
|
||
let mut ta = ta_with("short");
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
let action = ta.handle_mouse(mouse_scroll_down(5, 1), area, state);
|
||
assert_eq!(action, MouseAction::Nothing);
|
||
}
|
||
|
||
#[test]
|
||
fn mousewheel_scrolls_viewport_not_cursor() {
|
||
// Mousewheel should scroll the viewport without moving the cursor.
|
||
// The cursor should stay at its current buffer position.
|
||
let text = (0..20)
|
||
.map(|i| format!("line {i}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut ta = ta_with(&text);
|
||
let area = Rect::new(0, 0, 40, 5); // only 5 lines visible
|
||
let state = TextAreaState::default();
|
||
|
||
// Place cursor on "line 2"
|
||
let line2_start = text.find("line 2").unwrap();
|
||
ta.set_cursor(line2_start);
|
||
let cursor_before = ta.cursor();
|
||
|
||
// Scroll down
|
||
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
|
||
|
||
// Cursor must NOT have moved
|
||
assert_eq!(
|
||
ta.cursor(),
|
||
cursor_before,
|
||
"mousewheel should not move cursor"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn click_after_scroll_places_cursor_at_clicked_line() {
|
||
// After scrolling the viewport away from the cursor via mousewheel,
|
||
// clicking on a visible line should place the cursor on THAT line —
|
||
// not jump to some other position based on the old cursor location.
|
||
let text = (0..40)
|
||
.map(|i| format!("line {:02}", i))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut ta = ta_with(&text);
|
||
// height=20 → scroll_lines_for_height returns 3 lines/tick
|
||
let area = Rect::new(0, 0, 40, 20);
|
||
let mut state = TextAreaState::default();
|
||
|
||
// Cursor starts at line 0.
|
||
ta.set_cursor(0);
|
||
|
||
// Render to initialize state.scroll.
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
|
||
|
||
// Scroll down 3 ticks (3 lines × 3 = 9 lines).
|
||
for _ in 0..3 {
|
||
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
|
||
}
|
||
|
||
// Viewport should now start around line 9.
|
||
assert!(
|
||
state.scroll >= 9,
|
||
"viewport should have scrolled; scroll={}",
|
||
state.scroll
|
||
);
|
||
|
||
// Click on visual row 0 (which is now "line 09" or similar).
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
|
||
// The cursor should now be on a line that was VISIBLE.
|
||
let cursor = ta.cursor();
|
||
let lines = ta.wrapped_lines(area.width);
|
||
let cursor_line = TextArea::wrapped_line_index_by_start(&lines, cursor).unwrap();
|
||
assert!(
|
||
cursor_line >= state.scroll as usize
|
||
&& cursor_line < (state.scroll + area.height) as usize,
|
||
"click on visible row 0 should place cursor on a visible line; \
|
||
cursor_line={cursor_line}, scroll={}, visible=[{}..{})",
|
||
state.scroll,
|
||
state.scroll,
|
||
state.scroll as usize + area.height as usize,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_select_after_scroll_selects_visible_text() {
|
||
// After scrolling, drag-selecting should work on the visible text,
|
||
// not jump the viewport back.
|
||
let text = (0..20)
|
||
.map(|i| format!("line {:02}", i))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut ta = ta_with(&text);
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let mut state = TextAreaState::default();
|
||
|
||
ta.set_cursor(0);
|
||
|
||
// Render + scroll down.
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
|
||
for _ in 0..3 {
|
||
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
|
||
}
|
||
let scroll_after = state.scroll;
|
||
|
||
// Click-down on row 1, then drag to row 3.
|
||
ta.handle_mouse(mouse_down(0, 1), area, state);
|
||
// Re-render so state.scroll updates after the click.
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
|
||
ta.handle_mouse(mouse_drag(5, 3), area, state);
|
||
|
||
// Selection should exist.
|
||
let sel = ta.selection_range().expect("drag should create selection");
|
||
|
||
// The selected region should be within the visible range, not at line 0.
|
||
let lines = ta.wrapped_lines(area.width);
|
||
let sel_start_line = TextArea::wrapped_line_index_by_start(&lines, sel.start).unwrap();
|
||
assert!(
|
||
sel_start_line >= scroll_after as usize,
|
||
"selection start should be in scrolled region; \
|
||
sel_start_line={sel_start_line}, scroll={scroll_after}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_outside_after_mousewheel_still_scrolls() {
|
||
// After mousewheel scrolling during a drag, dragging outside the
|
||
// textarea area should continue to auto-scroll the viewport.
|
||
let text = (0..30)
|
||
.map(|i| format!("line {:02}", i))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut ta = ta_with(&text);
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let mut state = TextAreaState::default();
|
||
|
||
ta.set_cursor(0);
|
||
|
||
// Render to initialize state.
|
||
let mut buf = Buffer::empty(area);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
|
||
|
||
// Start drag at row 0.
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(0, 1), area, state);
|
||
assert!(ta.selection_range().is_some());
|
||
|
||
// Mousewheel scroll down during drag.
|
||
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
|
||
let scroll_after_wheel = state.scroll;
|
||
|
||
// Now drag below the area (row = area.y + area.height = 5).
|
||
// This should auto-scroll the viewport further down.
|
||
// We need to bypass throttle, so reset the timer.
|
||
ta.last_drag_scroll = None;
|
||
ta.drag_scroll_steps = 0;
|
||
ta.handle_mouse(mouse_drag(0, area.y + area.height), area, state);
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
|
||
|
||
assert!(
|
||
state.scroll > scroll_after_wheel,
|
||
"drag-below after mousewheel should continue scrolling; \
|
||
scroll={}, expected > {scroll_after_wheel}",
|
||
state.scroll
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_during_drag_preserves_selection_anchor() {
|
||
// Start drag at "bbb", scroll down — selection should extend,
|
||
// anchor stays at original position.
|
||
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee\nfff\nggg");
|
||
ta.set_cursor(0); // put cursor at start so scroll=0 is consistent
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click-down on "bbb" (row 1, col 1 → byte 5 = second 'b')
|
||
ta.handle_mouse(mouse_down(1, 1), area, state);
|
||
let anchor = ta.cursor();
|
||
assert_eq!(
|
||
anchor, 5,
|
||
"click on bbb col 1 should place cursor at byte 5"
|
||
);
|
||
|
||
// Start drag → creates selection
|
||
ta.handle_mouse(mouse_drag(2, 1), area, state);
|
||
assert!(
|
||
ta.selection_range().is_some(),
|
||
"drag should create selection"
|
||
);
|
||
|
||
// Now scroll down while dragging
|
||
let action = ta.handle_mouse(mouse_scroll_down(1, 1), area, state);
|
||
assert_eq!(action, MouseAction::Scrolled);
|
||
|
||
// Selection should still exist and anchor should not have moved
|
||
let sel = ta
|
||
.selection_range()
|
||
.expect("selection should survive scroll");
|
||
assert!(
|
||
sel.contains(&anchor),
|
||
"anchor byte {anchor} should still be inside selection {sel:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_during_drag_extends_selection_head() {
|
||
// Scroll down during drag should move the selection head forward.
|
||
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee\nfff\nggg");
|
||
ta.set_cursor(0);
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click-down on "aaa" (row 0, col 1)
|
||
ta.handle_mouse(mouse_down(1, 0), area, state);
|
||
// Start drag
|
||
ta.handle_mouse(mouse_drag(2, 0), area, state);
|
||
let sel_before = ta.selection_range().unwrap();
|
||
|
||
// Scroll down
|
||
ta.handle_mouse(mouse_scroll_down(1, 1), area, state);
|
||
let sel_after = ta.selection_range().unwrap();
|
||
|
||
// Selection should have grown (head moved forward)
|
||
assert!(
|
||
sel_after.end > sel_before.end,
|
||
"scroll-down during drag should extend selection: before={sel_before:?} after={sel_after:?}"
|
||
);
|
||
// Anchor should not have moved
|
||
assert_eq!(sel_after.start, sel_before.start);
|
||
}
|
||
|
||
#[test]
|
||
fn down_during_active_drag_does_not_reset_anchor() {
|
||
// Some terminals re-emit Down(Left) after a scroll event even though
|
||
// the button was held the whole time. When `drag_active` is true,
|
||
// a Down should be treated as a drag continuation, not a new click.
|
||
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee\nfff\nggg");
|
||
ta.set_cursor(0);
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click on "aaa" (row 0, col 1), then drag to start selection.
|
||
ta.handle_mouse(mouse_down(1, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(2, 0), area, state);
|
||
let anchor_before = ta.selection_range().unwrap().start;
|
||
|
||
// Scroll down while dragging.
|
||
ta.handle_mouse(mouse_scroll_down(1, 1), area, state);
|
||
assert!(
|
||
ta.selection_range().is_some(),
|
||
"selection must survive scroll"
|
||
);
|
||
|
||
// Simulate terminal re-emitting Down(Left) at a different row.
|
||
ta.handle_mouse(mouse_down(1, 2), area, state);
|
||
|
||
// Selection should still exist and anchor should NOT have moved.
|
||
let sel = ta
|
||
.selection_range()
|
||
.expect("Down during drag must not kill selection");
|
||
assert_eq!(
|
||
sel.start, anchor_before,
|
||
"anchor must not reset: expected {anchor_before}, got {}",
|
||
sel.start
|
||
);
|
||
}
|
||
|
||
// ── Drag-scroll acceleration / distance helpers ──
|
||
|
||
#[test]
|
||
fn drag_scroll_interval_ramps_up() {
|
||
// Step 0 → 80ms, step 1 → 60ms, step 2+ → 40ms.
|
||
assert_eq!(TextArea::drag_scroll_interval(0), 80);
|
||
assert_eq!(TextArea::drag_scroll_interval(1), 60);
|
||
assert_eq!(TextArea::drag_scroll_interval(2), 40);
|
||
assert_eq!(TextArea::drag_scroll_interval(100), 40);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_scroll_lines_for_distance_tiers() {
|
||
// Close: 1 line, farther: more lines.
|
||
assert_eq!(TextArea::drag_scroll_lines_for_distance(1), 1);
|
||
assert_eq!(TextArea::drag_scroll_lines_for_distance(2), 1);
|
||
assert_eq!(TextArea::drag_scroll_lines_for_distance(3), 2);
|
||
assert_eq!(TextArea::drag_scroll_lines_for_distance(5), 2);
|
||
assert_eq!(TextArea::drag_scroll_lines_for_distance(6), 3);
|
||
assert_eq!(TextArea::drag_scroll_lines_for_distance(10), 3);
|
||
assert_eq!(TextArea::drag_scroll_lines_for_distance(20), 5);
|
||
}
|
||
|
||
// ── Scrollbar tests ──
|
||
|
||
#[test]
|
||
fn scrollbar_not_shown_when_content_fits() {
|
||
// 3 lines of text in a 5-row viewport → no scrollbar needed.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("aaa\nbbb\nccc");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let (cw, needs) = ta.content_width(area.width, area.height);
|
||
assert!(!needs, "should not need scrollbar when content fits");
|
||
assert_eq!(cw, 20, "full width when no scrollbar");
|
||
}
|
||
|
||
#[test]
|
||
fn scrollbar_shown_when_content_overflows() {
|
||
// 10 lines of text in a 5-row viewport → scrollbar needed.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let (cw, needs) = ta.content_width(area.width, area.height);
|
||
assert!(needs, "should need scrollbar when content overflows");
|
||
assert_eq!(cw, 19, "width reduced by 1 for scrollbar");
|
||
}
|
||
|
||
#[test]
|
||
fn scrollbar_respects_show_scrollbar_false() {
|
||
let mut ta = TextArea::new();
|
||
ta.show_scrollbar = false;
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let (cw, needs) = ta.content_width(area.width, area.height);
|
||
assert!(!needs, "scrollbar disabled");
|
||
assert_eq!(cw, 20, "full width when scrollbar disabled");
|
||
}
|
||
|
||
#[test]
|
||
fn scrollbar_wrapping_uses_narrower_width() {
|
||
// A line that fits in 20 cols but not in 19 should wrap differently
|
||
// when scrollbar is present.
|
||
let mut ta = TextArea::new();
|
||
// 19 'a's → fits in 19 cols (no wrap).
|
||
// Then enough other lines to overflow the viewport.
|
||
ta.insert_str(&format!("{}\n2\n3\n4\n5\n6", "a".repeat(19)));
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let (cw, needs) = ta.content_width(area.width, area.height);
|
||
assert!(needs, "overflows");
|
||
assert_eq!(cw, 19);
|
||
// The 19-char line should NOT wrap at width 19 — it fits exactly.
|
||
let lines = ta.wrapped_lines(cw);
|
||
// First wrapped line should contain all 19 chars.
|
||
assert_eq!(&ta.text()[lines[0].clone()], &"a".repeat(19));
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_scrollbar_column_scrolls() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
// Move cursor to start so we can verify it doesn't move.
|
||
ta.cursor_pos = 0;
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click on the scrollbar column (rightmost column = 19),
|
||
// at the bottom row of the viewport → should scroll to end.
|
||
let action = ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 19,
|
||
row: 4,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
assert_eq!(action, MouseAction::Scrolled);
|
||
// Cursor should not have moved — scrollbar click doesn't place cursor.
|
||
assert_eq!(ta.cursor_pos, 0);
|
||
// scroll_override should be set.
|
||
assert!(ta.scroll_override.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_scrollbar_top_scrolls_to_top() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Scroll to the bottom first so the top of the track is NOT the thumb.
|
||
ta.scroll_override = Some(5);
|
||
// Click at top of scrollbar (row 0) — should be track, jump to top.
|
||
ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 19,
|
||
row: 0,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
assert_eq!(ta.scroll_override, Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_text_area_does_not_trigger_scrollbar() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Click on column 18 (text area, not scrollbar column 19).
|
||
let action = ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 18,
|
||
row: 0,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
// Should place cursor, not scroll.
|
||
assert_eq!(action, MouseAction::CursorPlaced);
|
||
assert!(!ta.scrollbar_dragging);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_on_scrollbar_scrolls_proportionally() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
// Scroll to bottom so the thumb is at the bottom, then click
|
||
// on the track at row 0 to start a track-based drag.
|
||
ta.scroll_override = Some(5);
|
||
ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 19,
|
||
row: 0,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
assert!(ta.scrollbar_dragging);
|
||
let scroll_at_top = ta.scroll_override.unwrap();
|
||
assert_eq!(scroll_at_top, 0, "track click at top should jump to 0");
|
||
// Drag to middle of scrollbar track.
|
||
ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: 19,
|
||
row: 2,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
let scroll_at_mid = ta.scroll_override.unwrap();
|
||
assert!(
|
||
scroll_at_mid > scroll_at_top,
|
||
"dragging down should scroll further"
|
||
);
|
||
// Drag to bottom.
|
||
ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||
column: 19,
|
||
row: 4,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
let scroll_at_bottom = ta.scroll_override.unwrap();
|
||
assert!(
|
||
scroll_at_bottom > scroll_at_mid,
|
||
"dragging to bottom should scroll to max"
|
||
);
|
||
// Mouse up ends drag.
|
||
ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Up(MouseButton::Left),
|
||
column: 19,
|
||
row: 4,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
assert!(!ta.scrollbar_dragging);
|
||
}
|
||
|
||
#[test]
|
||
fn scrollbar_render_produces_track_and_thumb() {
|
||
// Render a textarea with overflow and verify the scrollbar column
|
||
// has non-default styled cells.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = TextAreaState::default();
|
||
StatefulWidgetRef::render_ref(&&ta, area, &mut buf, &mut state);
|
||
|
||
let sb_col = 19u16;
|
||
// All cells in the scrollbar column should have the track bg color.
|
||
let mut has_thumb = false;
|
||
for row in 0..5u16 {
|
||
let cell = &buf[(sb_col, row)];
|
||
// Track bg is Rgb(45,45,55); check bg is set.
|
||
assert!(cell.style().bg.is_some(), "scrollbar cell should have bg");
|
||
if cell.symbol() != " " {
|
||
has_thumb = true;
|
||
}
|
||
}
|
||
assert!(has_thumb, "should have at least one thumb cell");
|
||
}
|
||
|
||
#[test]
|
||
fn no_scrollbar_column_when_content_fits() {
|
||
// When content fits, the rightmost column should not have
|
||
// scrollbar styling.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = TextAreaState::default();
|
||
StatefulWidgetRef::render_ref(&&ta, area, &mut buf, &mut state);
|
||
|
||
let last_col = 19u16;
|
||
let cell = &buf[(last_col, 0u16)];
|
||
// Should be default (empty space), not scrollbar styled.
|
||
assert!(
|
||
cell.style().bg.is_none() || !matches!(cell.style().bg, Some(Color::Rgb(32, 35, 53))),
|
||
"should not have scrollbar bg when content fits"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_pos_accounts_for_scrollbar_width() {
|
||
// When scrollbar is shown, cursor position should use content width,
|
||
// not full area width.
|
||
let mut ta = TextArea::new();
|
||
// Fill 18 chars + enough lines to overflow.
|
||
ta.insert_str(&format!("{}\n2\n3\n4\n5\n6", "x".repeat(18)));
|
||
ta.cursor_pos = 0; // at start
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
let pos = ta.cursor_pos_with_state(area, state);
|
||
// Cursor at pos 0 should be at (0, 0).
|
||
assert_eq!(pos, Some((0, 0)));
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_scrollbar_thumb_does_not_jump() {
|
||
// With 10 lines in a 5-row viewport, the thumb is near the top
|
||
// when scroll is at 0. Clicking on the thumb should NOT jump —
|
||
// it should just start a drag from the current position.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Scroll is at 0 — thumb should be at the top of the track.
|
||
// Click on row 0 (top of track = on the thumb).
|
||
let action = ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 19,
|
||
row: 0,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
assert_eq!(action, MouseAction::Scrolled);
|
||
assert!(ta.scrollbar_dragging);
|
||
// The scroll should NOT have changed — thumb click = no jump.
|
||
assert!(
|
||
ta.scroll_override.is_none() || ta.scroll_override == Some(0),
|
||
"thumb click should not jump: {:?}",
|
||
ta.scroll_override,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_scrollbar_track_jumps() {
|
||
// Clicking on the track (outside the thumb) should jump.
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
|
||
let area = Rect::new(0, 0, 20, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Scroll at 0, thumb near top. Click at bottom of track (row 4)
|
||
// which should be on the track, not the thumb.
|
||
let action = ta.handle_mouse(
|
||
MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 19,
|
||
row: 4,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
area,
|
||
state,
|
||
);
|
||
assert_eq!(action, MouseAction::Scrolled);
|
||
// Should have jumped to a non-zero scroll position.
|
||
assert!(
|
||
ta.scroll_override.unwrap_or(0) > 0,
|
||
"track click should jump"
|
||
);
|
||
}
|
||
|
||
// ── Clipboard provider tests ──
|
||
|
||
#[test]
|
||
fn default_clipboard_provider_round_trips() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hello world");
|
||
// Select all via set_selection and cut
|
||
ta.set_selection(0, 5);
|
||
ta.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
||
// take_clipboard returns the cut text
|
||
assert_eq!(ta.take_clipboard(), Some("hello".to_string()));
|
||
// Ctrl-V pastes it back
|
||
ta.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
|
||
assert_eq!(ta.text(), "hello world");
|
||
}
|
||
|
||
#[test]
|
||
fn custom_clipboard_provider() {
|
||
#[derive(Debug)]
|
||
struct TestClip {
|
||
stored: Option<String>,
|
||
}
|
||
impl ClipboardProvider for TestClip {
|
||
fn get(&mut self) -> Option<String> {
|
||
self.stored.clone()
|
||
}
|
||
fn set(&mut self, text: &str) {
|
||
self.stored = Some(format!("CUSTOM:{text}"));
|
||
}
|
||
}
|
||
|
||
let mut ta = TextArea::new();
|
||
ta.set_clipboard_provider(Box::new(TestClip { stored: None }));
|
||
ta.insert_str("abc");
|
||
ta.set_selection(0, 3);
|
||
// Ctrl-X should call provider.set with "abc"
|
||
ta.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
||
// Ctrl-V should paste from provider.get → "CUSTOM:abc"
|
||
ta.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
|
||
assert_eq!(ta.text(), "CUSTOM:abc");
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_v_pastes_from_provider() {
|
||
#[derive(Debug)]
|
||
struct PreloadedClip;
|
||
impl ClipboardProvider for PreloadedClip {
|
||
fn get(&mut self) -> Option<String> {
|
||
Some("pasted!".to_string())
|
||
}
|
||
fn set(&mut self, _text: &str) {}
|
||
}
|
||
|
||
let mut ta = TextArea::new();
|
||
ta.set_clipboard_provider(Box::new(PreloadedClip));
|
||
ta.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
|
||
assert_eq!(ta.text(), "pasted!");
|
||
}
|
||
|
||
#[test]
|
||
fn copy_on_selection_finalized_sets_provider() {
|
||
// Drag-select → mouse up should call provider.set
|
||
#[derive(Debug)]
|
||
struct RecordingClip {
|
||
last_set: Option<String>,
|
||
}
|
||
impl ClipboardProvider for RecordingClip {
|
||
fn get(&mut self) -> Option<String> {
|
||
self.last_set.clone()
|
||
}
|
||
fn set(&mut self, text: &str) {
|
||
self.last_set = Some(text.to_string());
|
||
}
|
||
}
|
||
|
||
let mut ta = TextArea::new();
|
||
ta.set_clipboard_provider(Box::new(RecordingClip { last_set: None }));
|
||
ta.insert_str("hello");
|
||
ta.set_cursor(0);
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Click at 0, drag to 5, release
|
||
ta.handle_mouse(mouse_down(0, 0), area, state);
|
||
ta.handle_mouse(mouse_drag(5, 0), area, state);
|
||
ta.handle_mouse(mouse_up(5, 0), area, state);
|
||
|
||
// Now Ctrl-V should paste "hello" (from provider)
|
||
ta.set_cursor(5);
|
||
ta.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
|
||
assert_eq!(ta.text(), "hellohello");
|
||
}
|
||
|
||
// ── Hover / element event tests ──
|
||
|
||
fn mouse_moved(col: u16, row: u16) -> MouseEvent {
|
||
MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: col,
|
||
row,
|
||
modifiers: KeyModifiers::NONE,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hover_enter_on_element() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hi ");
|
||
let id = ta.insert_element("elem", ElementKind(0), None);
|
||
ta.insert_str(" bye");
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Move over plain text — no event
|
||
ta.handle_mouse(mouse_moved(0, 0), area, state);
|
||
assert!(ta.poll_element_event().is_none());
|
||
|
||
// Move over element (col 3)
|
||
ta.handle_mouse(mouse_moved(3, 0), area, state);
|
||
let ev = ta.poll_element_event().expect("should emit HoverEnter");
|
||
assert_eq!(ev.id, id);
|
||
assert_eq!(ev.kind, TextElementEventKind::HoverEnter);
|
||
}
|
||
|
||
#[test]
|
||
fn hover_leave_on_element() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hi ");
|
||
let id = ta.insert_element("elem", ElementKind(0), None);
|
||
ta.insert_str(" bye");
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Enter the element
|
||
ta.handle_mouse(mouse_moved(3, 0), area, state);
|
||
ta.poll_element_event(); // consume
|
||
|
||
// Leave the element
|
||
ta.handle_mouse(mouse_moved(0, 0), area, state);
|
||
let ev = ta.poll_element_event().expect("should emit HoverLeave");
|
||
assert_eq!(ev.id, id);
|
||
assert_eq!(ev.kind, TextElementEventKind::HoverLeave);
|
||
}
|
||
|
||
#[test]
|
||
fn hover_stays_on_same_element_no_event() {
|
||
let mut ta = TextArea::new();
|
||
ta.insert_str("hi ");
|
||
ta.insert_element("elem", ElementKind(0), None);
|
||
ta.insert_str(" bye");
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Enter the element (col 3)
|
||
ta.handle_mouse(mouse_moved(3, 0), area, state);
|
||
ta.poll_element_event(); // consume enter
|
||
|
||
// Move within the element (col 4) — no new event
|
||
ta.handle_mouse(mouse_moved(4, 0), area, state);
|
||
assert!(ta.poll_element_event().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn hover_between_two_elements() {
|
||
let mut ta = TextArea::new();
|
||
let id1 = ta.insert_element("AA", ElementKind(0), None);
|
||
ta.insert_str(" ");
|
||
let id2 = ta.insert_element("BB", ElementKind(0), None);
|
||
// Visual: A A B B
|
||
// 0 1 2 3 4
|
||
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let state = TextAreaState::default();
|
||
|
||
// Hover element 1
|
||
ta.handle_mouse(mouse_moved(0, 0), area, state);
|
||
let ev = ta.poll_element_event().unwrap();
|
||
assert_eq!(ev.id, id1);
|
||
assert_eq!(ev.kind, TextElementEventKind::HoverEnter);
|
||
|
||
// Move to element 2 — should emit enter for id2
|
||
// (HoverLeave for id1 gets overwritten by HoverEnter for id2)
|
||
ta.handle_mouse(mouse_moved(3, 0), area, state);
|
||
let ev = ta.poll_element_event().unwrap();
|
||
assert_eq!(ev.id, id2);
|
||
assert_eq!(ev.kind, TextElementEventKind::HoverEnter);
|
||
}
|
||
|
||
// ── set_scroll_override / scroll_override tests ────────────────────
|
||
|
||
#[test]
|
||
fn scroll_override_getter_setter() {
|
||
let mut ta = TextArea::new();
|
||
assert_eq!(ta.scroll_override(), None);
|
||
ta.set_scroll_override(Some(5));
|
||
assert_eq!(ta.scroll_override(), Some(5));
|
||
ta.set_scroll_override(None);
|
||
assert_eq!(ta.scroll_override(), None);
|
||
}
|
||
|
||
/// Helper: stateful render (saves typing the full trait path).
|
||
fn render_stateful(ta: &TextArea, area: Rect, buf: &mut Buffer, state: &mut TextAreaState) {
|
||
ratatui::widgets::StatefulWidgetRef::render_ref(&ta, area, buf, state);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_override_forces_viewport_ignoring_cursor() {
|
||
// With 20 lines, cursor at end, and viewport of 5 rows,
|
||
// effective_scroll normally follows the cursor to the bottom.
|
||
// set_scroll_override(Some(0)) should force viewport to top.
|
||
let text = (0..20)
|
||
.map(|i| format!("line {i}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut ta = ta_with(&text);
|
||
ta.set_cursor(ta.text().len()); // cursor at end
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let mut state = TextAreaState::default();
|
||
let mut buf = Buffer::empty(area);
|
||
|
||
// Render without override: cursor-follow scrolls to bottom.
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert!(state.scroll > 0, "should scroll to show cursor at end");
|
||
let normal_scroll = state.scroll;
|
||
|
||
// Set override to 0 and render: viewport at top despite cursor at end.
|
||
ta.set_scroll_override(Some(0));
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(state.scroll, 0, "override should force scroll to 0");
|
||
|
||
// Clear override: cursor-follow resumes.
|
||
ta.set_scroll_override(None);
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(
|
||
state.scroll, normal_scroll,
|
||
"clearing override should resume cursor-follow"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_override_clamped_to_max() {
|
||
// Override value larger than max_scroll should be clamped.
|
||
let text = "line 0\nline 1\nline 2"; // 3 lines
|
||
let mut ta = ta_with(text);
|
||
ta.set_cursor(0);
|
||
let area = Rect::new(0, 0, 40, 2); // 2 rows visible, max_scroll = 1
|
||
let mut state = TextAreaState::default();
|
||
let mut buf = Buffer::empty(area);
|
||
|
||
ta.set_scroll_override(Some(999));
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(state.scroll, 1, "override should be clamped to max_scroll");
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_override_survives_render_cycles() {
|
||
// The override should persist across multiple renders (unlike
|
||
// mousewheel override which clears on cursor movement).
|
||
let text = (0..20)
|
||
.map(|i| format!("line {i}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut ta = ta_with(&text);
|
||
ta.set_cursor(ta.text().len());
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let mut state = TextAreaState::default();
|
||
let mut buf = Buffer::empty(area);
|
||
|
||
ta.set_scroll_override(Some(3));
|
||
for _ in 0..5 {
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(state.scroll, 3, "override should persist across renders");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_override_save_restore_round_trip() {
|
||
// Simulates the collapsed-prompt pattern:
|
||
// 1. Render normally (cursor-follow)
|
||
// 2. Save state.scroll + scroll_override
|
||
// 3. Override to 0, render collapsed
|
||
// 4. Restore both → next render shows original viewport
|
||
let text = (0..30)
|
||
.map(|i| format!("line {i}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut ta = ta_with(&text);
|
||
ta.set_cursor(ta.text().len()); // cursor at end
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let mut state = TextAreaState::default();
|
||
let mut buf = Buffer::empty(area);
|
||
|
||
// 1. Initial render — cursor-follow scrolls to bottom.
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
let original_scroll = state.scroll;
|
||
let original_override = ta.scroll_override();
|
||
assert!(original_scroll > 0);
|
||
assert_eq!(original_override, None);
|
||
|
||
// 2. "Collapse": override to 0, render a few frames.
|
||
ta.set_scroll_override(Some(0));
|
||
for _ in 0..3 {
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(state.scroll, 0);
|
||
}
|
||
|
||
// 3. Restore both.
|
||
ta.set_scroll_override(original_override);
|
||
state.scroll = original_scroll;
|
||
|
||
// 4. Render "uncollapsed" — should show original position.
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(
|
||
state.scroll, original_scroll,
|
||
"restored scroll should match original"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_override_save_restore_with_mousewheel() {
|
||
// Same as above but the user had mousewheel-scrolled away from cursor
|
||
// before collapse. Both state.scroll and scroll_override must be
|
||
// saved/restored for the viewport to return to its pre-collapse position.
|
||
let text = (0..30)
|
||
.map(|i| format!("line {i}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let mut ta = ta_with(&text);
|
||
ta.set_cursor(0); // cursor at start
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
let mut state = TextAreaState::default();
|
||
let mut buf = Buffer::empty(area);
|
||
|
||
// Render at start, then mousewheel to scroll viewport away from cursor.
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(state.scroll, 0);
|
||
for _ in 0..5 {
|
||
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
}
|
||
let mousewheel_scroll = state.scroll;
|
||
let mousewheel_override = ta.scroll_override();
|
||
assert!(mousewheel_scroll > 0, "should have scrolled away");
|
||
assert!(mousewheel_override.is_some(), "mousewheel sets override");
|
||
|
||
// "Collapse": save both, override to 0.
|
||
let saved_scroll = state.scroll;
|
||
let saved_override = ta.scroll_override();
|
||
ta.set_scroll_override(Some(0));
|
||
for _ in 0..3 {
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(state.scroll, 0);
|
||
}
|
||
|
||
// Restore both.
|
||
ta.set_scroll_override(saved_override);
|
||
state.scroll = saved_scroll;
|
||
|
||
// Render "uncollapsed" — viewport should be at the mousewheel position,
|
||
// NOT snapped to cursor (which is at line 0).
|
||
render_stateful(&ta, area, &mut buf, &mut state);
|
||
assert_eq!(
|
||
state.scroll, mousewheel_scroll,
|
||
"viewport should restore to mousewheel position, not snap to cursor"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn apply_shift_only_uppercases_letters() {
|
||
assert_eq!(TextArea::apply_shift('a'), 'A');
|
||
assert_eq!(TextArea::apply_shift('z'), 'Z');
|
||
assert_eq!(TextArea::apply_shift('A'), 'A');
|
||
// Non-letters pass through unchanged (trust the terminal's layout mapping).
|
||
assert_eq!(TextArea::apply_shift('7'), '7');
|
||
assert_eq!(TextArea::apply_shift('/'), '/');
|
||
assert_eq!(TextArea::apply_shift(';'), ';');
|
||
}
|
||
|
||
#[test]
|
||
fn modifier_keys_do_not_insert_text() {
|
||
let mut t = ta_with("hello");
|
||
let len = t.text().len();
|
||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||
assert_eq!(t.text().len(), len);
|
||
}
|
||
|
||
#[test]
|
||
fn alt_word_nav_preserved() {
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(t.text().len());
|
||
let text = t.text().to_owned();
|
||
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT));
|
||
assert_eq!(t.text(), text);
|
||
assert!(t.cursor() < text.len());
|
||
|
||
t.set_cursor(0);
|
||
t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT));
|
||
assert_eq!(t.text(), text);
|
||
assert!(t.cursor() > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_alt_h_deletes_word() {
|
||
let mut t = ta_with("hello world");
|
||
t.set_cursor(t.text().len());
|
||
t.input(KeyEvent::new(
|
||
KeyCode::Char('h'),
|
||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||
));
|
||
assert_eq!(t.text(), "hello ");
|
||
}
|
||
|
||
#[test]
|
||
fn plain_and_shifted_chars_insert() {
|
||
let mut t = TextArea::new();
|
||
for c in ['a', 'z', '1', '/', '@', '{', '!', '~'] {
|
||
t.input(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
||
}
|
||
assert_eq!(t.text(), "az1/@{!~");
|
||
|
||
let mut t = TextArea::new();
|
||
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT));
|
||
t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::SHIFT));
|
||
assert_eq!(t.text(), "AZ");
|
||
}
|
||
|
||
#[test]
|
||
fn altgr_char_insertion_platform_dependent() {
|
||
let mut t = TextArea::new();
|
||
t.input(KeyEvent::new(
|
||
KeyCode::Char('@'),
|
||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||
));
|
||
if cfg!(target_os = "windows") {
|
||
assert_eq!(t.text(), "@");
|
||
} else {
|
||
assert_eq!(t.text(), "");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shift_number_trusts_terminal_character() {
|
||
// QWERTZ: terminal sends Char('/') + SHIFT for Shift+7.
|
||
let mut t = TextArea::new();
|
||
t.input(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::SHIFT));
|
||
assert_eq!(t.text(), "/");
|
||
}
|
||
}
|