Skip to main content

oxicode_textarea/
textarea.rs

1use crate::editor::{
2    ApplyEditPlanError, EditBuffer, EditCommand, EditCommandCategory, EditOutcome, EditPlan,
3    WordStyle, classify_key_event,
4};
5use crossterm::event::KeyCode;
6use crossterm::event::KeyEvent;
7use crossterm::event::KeyModifiers;
8use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
9use ratatui::buffer::Buffer;
10use ratatui::layout::Rect;
11use ratatui::style::Color;
12use ratatui::style::Style;
13use ratatui::text::Line;
14use ratatui::widgets::StatefulWidgetRef;
15use ratatui::widgets::WidgetRef;
16use ratatui_core::buffer::Buffer as CoreBuffer;
17use ratatui_core::layout::Rect as CoreRect;
18use ratatui_core::widgets::Widget as _;
19use std::cell::Ref;
20use std::cell::RefCell;
21use std::ops::Range;
22use std::time::Instant;
23use textwrap::Options;
24use tui_scrollbar::{ScrollBar, ScrollLengths};
25use unicode_segmentation::UnicodeSegmentation;
26use unicode_width::UnicodeWidthStr;
27
28/// Stable, unique identifier for a text element. Monotonically increasing, never reused.
29///
30/// The host app can use this as a key into its own metadata store
31/// (e.g. `HashMap<ElementId, PasteMetadata>`).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub struct ElementId(u64);
34
35impl ElementId {
36    /// Construct an `ElementId` from a raw `u64` value.
37    ///
38    /// Primarily useful for tests and serialization; normal code should use
39    /// the IDs returned by [`TextArea::insert_element`].
40    pub fn from_raw(raw: u64) -> Self {
41        Self(raw)
42    }
43}
44
45/// Opaque element kind tag. The textarea does not interpret this value;
46/// the host app defines constants like `ElementKind(1)` for pastes,
47/// `ElementKind(2)` for file references, etc.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub struct ElementKind(pub u16);
50
51// ── Clipboard ──
52
53/// Trait for clipboard access. The textarea calls this on copy/cut/paste.
54///
55/// The default implementation ([`InternalClipboard`]) stores text in memory.
56/// Host apps can provide a system clipboard backend (e.g. `arboard`) via
57/// [`TextArea::set_clipboard_provider`].
58pub trait ClipboardProvider: std::fmt::Debug + Send {
59    /// Read the current clipboard contents (for paste).
60    fn get(&mut self) -> Option<String>;
61    /// Write text to the clipboard (on copy/cut).
62    fn set(&mut self, text: &str);
63}
64
65/// In-memory clipboard — the default provider.
66#[derive(Debug, Default)]
67pub struct InternalClipboard {
68    contents: Option<String>,
69}
70
71impl ClipboardProvider for InternalClipboard {
72    fn get(&mut self) -> Option<String> {
73        self.contents.clone()
74    }
75
76    fn set(&mut self, text: &str) {
77        self.contents = Some(text.to_string());
78    }
79}
80
81// ── Text element events ──
82
83/// An interaction with a [`TextElement`], returned by [`TextArea::poll_element_event`].
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct TextElementEvent {
86    /// The element that was interacted with.
87    pub id: ElementId,
88    /// What kind of interaction occurred.
89    pub kind: TextElementEventKind,
90}
91
92/// The kind of element interaction.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum TextElementEventKind {
95    /// The element was clicked (single click).
96    Click,
97    /// The mouse entered the element (was outside or on a different element).
98    HoverEnter,
99    /// The mouse left the element (moved to plain text or a different element).
100    HoverLeave,
101}
102
103/// An atomic text element embedded in the buffer.
104///
105/// Elements are indivisible units for navigation and editing. The cursor
106/// cannot be placed inside an element; it jumps from the start boundary
107/// to the end boundary atomically.
108#[derive(Debug, Clone)]
109pub struct TextElement {
110    /// Stable identifier, unique across the lifetime of the `TextArea`.
111    pub id: ElementId,
112    /// Byte range in the underlying text buffer.
113    pub range: Range<usize>,
114    /// Host-defined kind tag.
115    pub kind: ElementKind,
116    /// Custom display text and styling. When `Some`, this `Line` is rendered
117    /// instead of the raw buffer text. When `None`, the buffer text is rendered
118    /// with a default element style (cyan).
119    pub display: Option<Line<'static>>,
120}
121
122// ── Selection ──
123
124/// A byte-range selection in the buffer, created by mouse drag.
125#[derive(Debug, Clone, Copy)]
126pub struct Selection {
127    /// Buffer position where the selection started (fixed anchor).
128    pub anchor: usize,
129    /// Buffer position where the selection currently extends to (moves with drag).
130    pub head: usize,
131}
132
133// ── Mouse ──
134
135/// Result of processing a mouse event in the textarea.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum MouseAction {
138    /// Nothing interesting happened.
139    Nothing,
140    /// Cursor was placed at a position (single click on plain text).
141    CursorPlaced,
142    /// Selection was updated (drag in progress, or double/triple click).
143    SelectionUpdated,
144    /// Selection was finalized — text copied to clipboard.
145    /// Host should call `take_clipboard()` to retrieve it.
146    SelectionFinished,
147    /// Content was scrolled (mouse wheel).
148    Scrolled,
149}
150
151/// Tracks consecutive clicks at the same screen position to detect
152/// double-click (word select) and triple-click (line select).
153#[derive(Debug)]
154struct ClickTracker {
155    last_time: Instant,
156    last_pos: (u16, u16),
157    count: u8,
158}
159
160impl Default for ClickTracker {
161    fn default() -> Self {
162        Self {
163            last_time: Instant::now(),
164            last_pos: (u16::MAX, u16::MAX),
165            count: 0,
166        }
167    }
168}
169
170impl ClickTracker {
171    /// Maximum time between clicks to count as multi-click (ms).
172    const MULTI_CLICK_MS: u128 = 500;
173
174    /// Register a click at `(col, row)`. Returns the click count (1, 2, or 3).
175    fn register(&mut self, col: u16, row: u16) -> u8 {
176        let now = Instant::now();
177        let elapsed = now.duration_since(self.last_time).as_millis();
178        if elapsed < Self::MULTI_CLICK_MS && self.last_pos == (col, row) && self.count < 3 {
179            self.count += 1;
180        } else {
181            self.count = 1;
182        }
183        self.last_time = now;
184        self.last_pos = (col, row);
185        self.count
186    }
187}
188
189#[derive(Debug)]
190pub struct TextArea {
191    text: EditBuffer,
192    wrap_cache: RefCell<Option<WrapCache>>,
193    preferred_col: Option<usize>,
194    elements: Vec<TextElement>,
195    next_element_id: u64,
196    kill_buffer: String,
197    undo: UndoState,
198    /// Active selection (mouse drag). `None` when no selection.
199    selection: Option<Selection>,
200    /// Clipboard provider — defaults to [`InternalClipboard`].
201    /// Swap with [`set_clipboard_provider`](Self::set_clipboard_provider)
202    clipboard_provider: Box<dyn ClipboardProvider + Send>,
203    /// Last copied text — set on copy/cut, cleared by `take_clipboard()`.
204    /// This is the "notification" channel: the host calls `take_clipboard()`
205    /// to detect that something was just copied.
206    clipboard: Option<String>,
207    /// Whether to keep the selection visible after mouse-up.
208    /// When `false`, selection clears immediately on mouse-up (fully transient).
209    pub keep_selection_after_mouseup: bool,
210    /// Style applied to selected text.  Defaults to a tokyonight-inspired
211    /// blue background (`rgb(49, 62, 115)`) with an explicit light foreground
212    /// (`rgb(192, 202, 245)`) so the selection is legible regardless of the
213    /// host terminal's colour scheme.
214    ///
215    /// Override to match your own theme, e.g.:
216    /// ```ignore
217    /// textarea.selection_style = Style::default().bg(Color::Rgb(60, 60, 60));
218    /// ```
219    pub selection_style: Style,
220    /// Screen position of the last mouse-down (for distinguishing click vs drag).
221    mouse_down_pos: Option<(u16, u16)>,
222    /// Buffer byte position of the mouse-down anchor (for drag selection).
223    drag_anchor: Option<usize>,
224    /// Whether a drag is currently in progress.
225    drag_active: bool,
226    /// Last time drag-scroll was applied (throttle).
227    last_drag_scroll: Option<Instant>,
228    /// Number of drag-scroll steps taken so far (for acceleration).
229    drag_scroll_steps: u32,
230    /// Stored drag event for continuous drag-scroll (re-triggered on timer).
231    /// Set when a drag moves outside the textarea area; cleared on mouse-up.
232    pending_drag_scroll: Option<MouseEvent>,
233    /// Tracks multi-click (double/triple) at the same position.
234    click_tracker: ClickTracker,
235    /// Internal scroll offset set by mousewheel events.  When `Some`, this
236    /// overrides the external `TextAreaState.scroll` so the viewport scrolls
237    /// independently of the cursor.  Cleared whenever the cursor moves
238    /// (typing, navigation, click) so the viewport snaps back to follow it.
239    scroll_override: Option<u16>,
240    /// Whether to show a scrollbar on the right edge when content overflows.
241    /// When enabled, the rightmost column is reserved for the scrollbar track
242    /// and the text area wraps at `width - 1`. Defaults to `true`.
243    pub show_scrollbar: bool,
244    /// Style for the scrollbar track (empty space).  Defaults to a dark
245    /// tokyonight-inspired background.  Override to match your theme's
246    /// background when embedding the textarea in a non-default-bg context.
247    pub scrollbar_track_style: Style,
248    /// Style for the scrollbar thumb (draggable indicator).  Defaults to a
249    /// slightly lighter tokyonight shade.  Override to match your theme.
250    pub scrollbar_thumb_style: Style,
251    /// Padding (in columns) between the text content and the scrollbar track.
252    /// Only applies when the scrollbar is visible.  Defaults to `0`.
253    pub scrollbar_padding: u16,
254    /// Whether the user is currently dragging the scrollbar thumb.
255    scrollbar_dragging: bool,
256    /// Currently hovered element (for enter/leave detection).
257    hovered_element: Option<ElementId>,
258    /// Pending element event — consumed by [`poll_element_event`](Self::poll_element_event).
259    pending_element_event: Option<TextElementEvent>,
260    /// Columns per tab character for display width and tab→space expansion on
261    /// insert. `0` leaves tabs as-is (unicode-width treats them as 0-width).
262    /// Defaults to `4`, matching scrollback `appearance::tab_width`.
263    tab_width: u8,
264}
265
266#[derive(Debug, Clone)]
267struct WrapCache {
268    width: u16,
269    lines: Vec<Range<usize>>,
270}
271
272#[derive(Debug, Default, Clone, Copy)]
273pub struct TextAreaState {
274    /// Index into wrapped lines of the first visible line.
275    pub scroll: u16,
276}
277
278// ── Undo/Redo ──
279
280/// A snapshot of the textarea state for undo/redo.
281#[derive(Debug, Clone)]
282struct UndoEntry {
283    text: String,
284    cursor: usize,
285    elements: Vec<TextElement>,
286}
287
288/// What kind of mutation is being performed. Used for batching consecutive
289/// same-kind operations into a single undo step.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291enum MutationKind {
292    /// Character-by-character typing, `insert_str`, `yank`.
293    Insert,
294    /// Backspace, delete forward.
295    Delete,
296    /// Ctrl+K, Ctrl+U, word-delete — always a discrete undo step.
297    Kill,
298    /// `insert_element`, `replace_range_with_element` — always discrete.
299    Element,
300    /// `set_text`, `replace_range` (host-driven) — always discrete.
301    Replace,
302}
303
304/// Manages the undo/redo stacks.
305#[derive(Debug)]
306struct UndoState {
307    stack: Vec<UndoEntry>,
308    redo: Vec<UndoEntry>,
309    max_depth: usize,
310    /// The kind of the last mutation that was checkpointed.
311    last_kind: Option<MutationKind>,
312    /// Cursor position *after* the last mutation completed.
313    /// Used to detect cursor jumps (arrows between inserts → new undo group).
314    last_cursor: usize,
315    /// Whether the last inserted character was whitespace.
316    /// Used to break insert batches at word boundaries (ws↔non-ws transitions).
317    last_insert_ws: bool,
318    /// Nesting depth for undo groups. When > 0, `pre_mutate` is suppressed.
319    group_depth: usize,
320    /// Snapshot taken when the outermost `begin_undo_group()` was called.
321    /// Used by `end_undo_group` to push the checkpoint, or by
322    /// `cancel_undo_group` to restore the pre-group state.
323    group_checkpoint: Option<UndoEntry>,
324}
325
326impl Default for UndoState {
327    fn default() -> Self {
328        Self {
329            stack: Vec::new(),
330            redo: Vec::new(),
331            max_depth: 100,
332            last_kind: None,
333            last_cursor: 0,
334            last_insert_ws: false,
335            group_depth: 0,
336            group_checkpoint: None,
337        }
338    }
339}
340
341/// Whether `key` is the undo chord [`TextArea::input`] binds: lowercase
342/// 'z' with Ctrl or Cmd. Uppercase 'Z' (redo) is intentionally excluded,
343/// which keeps this guard disjoint from the redo arm regardless of order.
344///
345/// Single source for the binding: `input()`'s undo arm consumes this
346/// predicate, and hosts that react to undo (e.g. retiring an undo hint)
347/// call it too, so the chord and its observers cannot drift.
348pub fn is_undo_input(key: &KeyEvent) -> bool {
349    matches!(key.code, KeyCode::Char('z'))
350        && (key.modifiers.contains(KeyModifiers::CONTROL)
351            || key.modifiers.contains(KeyModifiers::SUPER))
352}
353
354impl TextArea {
355    /// Compute the number of lines to scroll per mouse wheel tick based on
356    /// the viewport height.  Small viewports scroll slowly (1 line), large
357    /// viewports scroll faster (up to 3 lines).
358    fn scroll_lines_for_height(height: u16) -> u16 {
359        match height {
360            0..=5 => 1,
361            6..=15 => 2,
362            _ => 3,
363        }
364    }
365
366    /// Drag-scroll throttle intervals (ms): ramps up from slow to fast.
367    /// After the last entry, the final value repeats.
368    const DRAG_SCROLL_RAMP_MS: &[u128] = &[80, 60, 40];
369
370    /// Compute the drag-scroll interval for the given step count.
371    fn drag_scroll_interval(step: u32) -> u128 {
372        let ramp = Self::DRAG_SCROLL_RAMP_MS;
373        ramp[ramp.len().min(step as usize + 1) - 1]
374    }
375
376    /// How many extra lines to scroll based on distance from area edge.
377    /// Returns 1 for 1-2 rows outside, 2 for 3-4 rows, 3 for 5-8, etc.
378    fn drag_scroll_lines_for_distance(distance: u16) -> usize {
379        match distance {
380            0..=2 => 1,
381            3..=5 => 2,
382            6..=10 => 3,
383            _ => 5,
384        }
385    }
386
387    /// Clamp a buffer position so it stays within a wrapped line's range
388    /// `[line_start, line_end)`.  Without this, `display_col_to_buffer_pos`
389    /// can return `line_end` when the column exceeds the line's display
390    /// width — and `line_end` equals the *next* wrapped line's start,
391    /// which confuses `effective_scroll` into thinking the cursor hasn't
392    /// actually moved to the target line.
393    ///
394    /// Uses `self.text` to find the last valid char boundary inside the line
395    /// so we never land in the middle of a multi-byte character.
396    fn clamp_to_line(&self, pos: usize, line_start: usize, line_end: usize) -> usize {
397        if line_end > line_start {
398            // Find the start of the last character in the line.
399            let last_char_start = self.text[line_start..line_end]
400                .char_indices()
401                .next_back()
402                .map(|(i, _)| line_start + i)
403                .unwrap_or(line_start);
404            pos.min(last_char_start)
405        } else {
406            line_start
407        }
408    }
409
410    pub fn new() -> Self {
411        Self {
412            text: EditBuffer::new(),
413            wrap_cache: RefCell::new(None),
414            preferred_col: None,
415            elements: Vec::new(),
416            next_element_id: 0,
417            kill_buffer: String::new(),
418            undo: UndoState::default(),
419            selection: None,
420            clipboard_provider: Box::new(InternalClipboard::default()),
421            clipboard: None,
422            keep_selection_after_mouseup: true,
423            selection_style: Style::default()
424                .bg(Color::Rgb(49, 62, 115))
425                .fg(Color::Rgb(192, 202, 245)),
426            mouse_down_pos: None,
427            drag_anchor: None,
428            drag_active: false,
429            last_drag_scroll: None,
430            drag_scroll_steps: 0,
431            pending_drag_scroll: None,
432            click_tracker: ClickTracker::default(),
433            scroll_override: None,
434            show_scrollbar: true,
435            scrollbar_track_style: Style::default().bg(Color::Rgb(32, 35, 53)),
436            scrollbar_thumb_style: Style::default()
437                .fg(Color::Rgb(42, 46, 65))
438                .bg(Color::Rgb(32, 35, 53)),
439            scrollbar_padding: 0,
440            scrollbar_dragging: false,
441            hovered_element: None,
442            pending_element_event: None,
443            tab_width: 4,
444        }
445    }
446
447    /// Columns per tab for display width and tab→space expansion (`0` = passthrough).
448    pub fn tab_width(&self) -> u8 {
449        self.tab_width
450    }
451
452    /// Set columns per tab. Also controls expansion on insert/`set_text`/`replace_range`.
453    pub fn set_tab_width(&mut self, tab_width: u8) {
454        if self.tab_width != tab_width {
455            self.tab_width = tab_width;
456            self.wrap_cache.replace(None);
457        }
458    }
459
460    /// Expand `\t` to `tab_width` spaces (scrollback-compatible fixed width).
461    /// `tab_width == 0` or no tabs → borrowed input.
462    ///
463    /// Public because it is the exact transform every insert path applies
464    /// (see [`insert_str`](Self::insert_str) /
465    /// [`insert_element`](Self::insert_element)), letting hosts canonicalize
466    /// external text before comparing it against buffer content.
467    pub fn expand_tabs<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
468        expand_tabs_with_width(text, self.tab_width)
469    }
470
471    /// Display width of plain buffer text, treating tabs as `tab_width` columns.
472    fn plain_display_width(&self, text: &str) -> usize {
473        plain_display_width_with_tab(text, self.tab_width)
474    }
475
476    /// Display width of a single grapheme cluster (tab uses `tab_width`).
477    fn grapheme_display_width(&self, grapheme: &str) -> usize {
478        grapheme_display_width_with_tab(grapheme, self.tab_width)
479    }
480
481    fn element_ranges(&self) -> Vec<Range<usize>> {
482        self.elements
483            .iter()
484            .map(|element| element.range.clone())
485            .collect()
486    }
487
488    fn adjust_position_after_edit(
489        position: usize,
490        replaced: &Range<usize>,
491        inserted_len: usize,
492    ) -> usize {
493        if position < replaced.start {
494            position
495        } else if position <= replaced.end {
496            replaced.start + inserted_len
497        } else {
498            position - replaced.len() + inserted_len
499        }
500    }
501
502    fn is_semantic_edit(plan: &EditPlan) -> bool {
503        plan.removed_text() != plan.replacement() || !plan.replaced_byte_range().is_empty()
504    }
505
506    fn assert_valid_edit_plan(&self, plan: &EditPlan) {
507        if let Err(error) = self.text.validate_plan(plan) {
508            panic!("textarea edit invariant failed: {error:?}");
509        }
510    }
511
512    fn apply_validated_edit_plan(
513        &mut self,
514        plan: EditPlan,
515        mutation_kind: Option<MutationKind>,
516    ) -> EditOutcome {
517        let semantic_edit = Self::is_semantic_edit(&plan);
518        let replaced = plan.replaced_byte_range();
519        let inserted_len = plan.replacement().len();
520        let outcome = self.text.apply_validated_plan(&plan);
521        if semantic_edit {
522            self.update_elements_after_replace(replaced.start, replaced.end, inserted_len);
523            if let Some(selection) = &mut self.selection {
524                selection.anchor =
525                    Self::adjust_position_after_edit(selection.anchor, &replaced, inserted_len);
526                selection.head =
527                    Self::adjust_position_after_edit(selection.head, &replaced, inserted_len);
528            }
529            if self
530                .selection
531                .is_some_and(|selection| selection.anchor == selection.head)
532            {
533                self.selection = None;
534            }
535            self.wrap_cache.replace(None);
536            if mutation_kind == Some(MutationKind::Kill) {
537                self.kill_buffer = plan.into_removed_text();
538            }
539        }
540        if semantic_edit || !matches!(outcome, EditOutcome::Unchanged) {
541            self.preferred_col = None;
542            self.scroll_override = None;
543        }
544        outcome
545    }
546
547    fn try_apply_edit_plan(
548        &mut self,
549        plan: EditPlan,
550        mutation_kind: Option<MutationKind>,
551    ) -> Result<EditOutcome, ApplyEditPlanError> {
552        self.text.validate_plan(&plan)?;
553        let semantic_edit = Self::is_semantic_edit(&plan);
554        if semantic_edit && let Some(kind) = mutation_kind {
555            self.pre_mutate(kind);
556        }
557        let outcome = self.apply_validated_edit_plan(plan, mutation_kind);
558        if semantic_edit && mutation_kind.is_some() {
559            self.post_mutate();
560        }
561        Ok(outcome)
562    }
563
564    fn apply_edit_plan(
565        &mut self,
566        plan: EditPlan,
567        mutation_kind: Option<MutationKind>,
568    ) -> EditOutcome {
569        match self.try_apply_edit_plan(plan, mutation_kind) {
570            Ok(outcome) => outcome,
571            Err(error) => panic!("textarea edit invariant failed: {error:?}"),
572        }
573    }
574
575    fn apply_edit_command(
576        &mut self,
577        command: EditCommand,
578        mutation_kind: Option<MutationKind>,
579    ) -> EditOutcome {
580        let category = command.category();
581        let ranges = self.element_ranges();
582        let plan = self.text.plan_command(command, &ranges);
583        let outcome = self.apply_edit_plan(plan, mutation_kind);
584        if category == EditCommandCategory::Navigation {
585            self.preferred_col = None;
586            self.scroll_override = None;
587        }
588        outcome
589    }
590
591    fn plan_edit_replacement(&self, range: Range<usize>, replacement: &str) -> EditPlan {
592        let replacement = self.expand_tabs(replacement).into_owned();
593        let ranges = self.element_ranges();
594        self.text
595            .plan_replace_byte_range(range, &replacement, &ranges)
596    }
597
598    fn apply_edit_replacement(
599        &mut self,
600        range: Range<usize>,
601        replacement: &str,
602        mutation_kind: Option<MutationKind>,
603    ) {
604        let plan = self.plan_edit_replacement(range, replacement);
605        self.apply_edit_plan(plan, mutation_kind);
606    }
607
608    pub fn set_text(&mut self, text: &str) {
609        let cursor = self.cursor();
610        let plan = self.plan_edit_replacement(0..self.text.len(), text);
611        self.assert_valid_edit_plan(&plan);
612        self.pre_mutate(MutationKind::Replace);
613        let _ = self.text.apply_validated_plan(&plan);
614        self.elements.clear();
615        let len = self.text.len();
616        self.set_cursor_inner(cursor.min(len));
617        self.wrap_cache.replace(None);
618        self.preferred_col = None;
619        // Kill buffer intentionally survives: yank is independent of buffer
620        // content, so a cut can be pasted into a fresh prompt after send.
621        self.selection = None;
622        self.mouse_down_pos = None;
623        self.drag_anchor = None;
624        self.drag_active = false;
625        self.last_drag_scroll = None;
626        self.drag_scroll_steps = 0;
627        self.pending_drag_scroll = None;
628        self.click_tracker = ClickTracker::default();
629        self.scroll_override = None;
630        self.scrollbar_dragging = false;
631        self.hovered_element = None;
632        self.pending_element_event = None;
633        self.post_mutate();
634    }
635
636    pub fn text(&self) -> &str {
637        self.text.text()
638    }
639
640    pub fn insert_str(&mut self, text: &str) {
641        if text.is_empty() {
642            return;
643        }
644        self.scroll_override = None;
645        // Word boundary: break the insert batch when char class changes (ws↔non-ws).
646        if let Some(first) = text.chars().next() {
647            let first_ws = first.is_whitespace();
648            if self.undo.last_kind == Some(MutationKind::Insert)
649                && self.undo.last_insert_ws != first_ws
650            {
651                // Force pre_mutate to see a "kind change" so it pushes a checkpoint.
652                self.undo.last_kind = None;
653            }
654        }
655        self.apply_edit_replacement(
656            self.cursor()..self.cursor(),
657            text,
658            Some(MutationKind::Insert),
659        );
660        if let Some(last) = text.chars().last() {
661            self.undo.last_insert_ws = last.is_whitespace();
662        }
663    }
664
665    pub fn insert_str_at(&mut self, pos: usize, text: &str) {
666        if text.is_empty() {
667            return;
668        }
669        self.apply_edit_replacement(pos..pos, text, Some(MutationKind::Insert));
670        if let Some(last) = text.chars().last() {
671            self.undo.last_insert_ws = last.is_whitespace();
672        }
673    }
674
675    pub fn replace_range(&mut self, range: std::ops::Range<usize>, text: &str) {
676        self.apply_edit_replacement(range, text, Some(MutationKind::Replace));
677    }
678
679    pub fn cursor(&self) -> usize {
680        self.text.cursor_byte()
681    }
682
683    pub fn set_cursor(&mut self, pos: usize) {
684        let pos = pos.clamp(0, self.text.len());
685        let pos = self.clamp_pos_to_nearest_boundary(pos);
686        self.set_cursor_inner(pos);
687        self.preferred_col = None;
688        self.scroll_override = None;
689    }
690
691    fn set_cursor_inner(&mut self, pos: usize) {
692        let _ = self.text.set_cursor_byte(pos);
693    }
694
695    /// Override the scroll position, bypassing cursor-follow logic.
696    ///
697    /// When set to `Some(offset)`, `effective_scroll` will use this offset
698    /// instead of ensuring the cursor is visible. Useful for forcing a
699    /// specific viewport (e.g., scroll-to-top when the textarea is collapsed
700    /// and unfocused). Set to `None` to restore normal cursor-following.
701    ///
702    /// Note: unlike the internal scroll_override set by mousewheel events,
703    /// this is NOT cleared by cursor movement — it persists until explicitly
704    /// cleared by the caller.
705    pub fn set_scroll_override(&mut self, scroll: Option<u16>) {
706        self.scroll_override = scroll;
707    }
708
709    /// Current scroll override value (if any).
710    pub fn scroll_override(&self) -> Option<u16> {
711        self.scroll_override
712    }
713
714    pub fn desired_height(&self, width: u16) -> u16 {
715        self.wrapped_lines(width).len() as u16
716    }
717
718    #[cfg_attr(not(test), allow(dead_code))]
719    pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
720        self.cursor_pos_with_state(area, TextAreaState::default())
721    }
722
723    /// Compute the on-screen cursor position taking scrolling into account.
724    ///
725    /// Returns `None` if the cursor is not visible in the current viewport
726    /// (e.g. the user scrolled the viewport away from the cursor via mousewheel).
727    ///
728    /// Unlike [`Self::screen_position_of`], this applies a wrap-boundary adjustment:
729    /// when the cursor sits at the exact wrap boundary (col == content width),
730    /// it is shown at the start of the next visual line instead of on the
731    /// invisible right border.
732    pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> {
733        let tw = self.text_width(area);
734        let lines = self.wrapped_lines(tw);
735        let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
736        let mut i = Self::wrapped_line_index_by_start(&lines, self.cursor())?;
737        let ls = &lines[i];
738        let mut col = self.display_width_of_range(ls.start, self.cursor()) as u16;
739
740        // If the cursor sits at the exact wrap boundary (col == content width),
741        // show it at the start of the next visual line instead of on the
742        // invisible right border.  When the cursor is at text.len() and the
743        // last line is exactly full, there is no next wrapped line — but we
744        // still want the cursor on a new row at column 0.
745        if col >= tw {
746            i += 1;
747            col = 0;
748        }
749
750        // If the cursor's visual line is outside the visible viewport, hide it.
751        let scroll = effective_scroll as usize;
752        if i < scroll || i >= scroll + area.height as usize {
753            return None;
754        }
755
756        let screen_row = (i - scroll) as u16;
757        Some((area.x + col, area.y + screen_row))
758    }
759
760    /// Compute the on-screen position of an arbitrary buffer byte offset.
761    ///
762    /// Returns `None` if the position is outside the visible viewport.
763    /// Does not apply cursor-specific wrap-boundary adjustments — see
764    /// [`Self::cursor_pos_with_state`] for cursor positioning.
765    pub fn screen_position_of(
766        &self,
767        pos: usize,
768        area: Rect,
769        state: TextAreaState,
770    ) -> Option<(u16, u16)> {
771        let tw = self.text_width(area);
772        let lines = self.wrapped_lines(tw);
773        let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
774        let i = Self::wrapped_line_index_by_start(&lines, pos)?;
775        let ls = &lines[i];
776        let col = self.display_width_of_range(ls.start, pos) as u16;
777
778        let scroll = effective_scroll as usize;
779        if i < scroll || i >= scroll + area.height as usize {
780            return None;
781        }
782
783        let screen_row = (i - scroll) as u16;
784        Some((area.x + col, area.y + screen_row))
785    }
786
787    /// Compute the on-screen cells covered by a buffer byte range.
788    ///
789    /// A soft-wrapped range can cross visual rows, so unlike
790    /// [`Self::screen_position_of`] this returns one height-1 [`Rect`] per visual
791    /// row the range intersects, top to bottom, clamped to the content
792    /// region (`text_width` columns — excludes any scrollbar column). Rows
793    /// scrolled outside the viewport are skipped, so a partially visible
794    /// range yields only its visible rows. Bytes belonging to no row (a
795    /// `\n`, or whitespace dropped at a wrap boundary) are not covered;
796    /// trailing spaces kept on a row are. Ranges that are empty, extend
797    /// past the text, or have non-char-boundary endpoints yield no spans.
798    pub fn screen_spans_of_range(
799        &self,
800        range: Range<usize>,
801        area: Rect,
802        state: TextAreaState,
803    ) -> Vec<Rect> {
804        let mut spans = Vec::new();
805        if range.start >= range.end
806            || range.end > self.text.len()
807            || !self.text.is_char_boundary(range.start)
808            || !self.text.is_char_boundary(range.end)
809        {
810            return spans;
811        }
812        let tw = self.text_width(area);
813        let lines = self.wrapped_lines(tw);
814        let scroll = self.effective_scroll(area.height, &lines, state.scroll) as usize;
815        // Rows before the one containing `range.start` cannot intersect;
816        // `None` (start ahead of the first row) falls back to scanning all.
817        let first = Self::wrapped_line_index_by_start(&lines, range.start).unwrap_or(0);
818        // Rendered content stops at `tw` columns; a row's trailing wrap
819        // spaces can measure wider, so clamp to the content edge, not the
820        // full area (whose last column may hold the scrollbar).
821        let right_edge = area.x.saturating_add(tw);
822        for (i, ls) in lines.iter().enumerate().skip(first) {
823            if ls.start >= range.end {
824                break;
825            }
826            if i < scroll {
827                continue;
828            }
829            if i >= scroll + area.height as usize {
830                break;
831            }
832            let seg_start = range.start.max(ls.start);
833            let seg_end = range.end.min(ls.end);
834            if seg_start >= seg_end {
835                continue;
836            }
837            let start_x = area
838                .x
839                .saturating_add(self.display_width_of_range(ls.start, seg_start) as u16)
840                .min(right_edge);
841            let end_x = area
842                .x
843                .saturating_add(self.display_width_of_range(ls.start, seg_end) as u16)
844                .min(right_edge);
845            if start_x < end_x {
846                spans.push(Rect {
847                    x: start_x,
848                    y: area.y + (i - scroll) as u16,
849                    width: end_x - start_x,
850                    height: 1,
851                });
852            }
853        }
854        spans
855    }
856
857    /// Map screen coordinates `(col, row)` to a buffer byte position.
858    ///
859    /// Returns `None` if `(col, row)` is outside the textarea `area`.
860    ///
861    /// Edge cases:
862    /// - Click past end of a wrapped line → snaps to line end.
863    /// - Click below all text → snaps to `text.len()`.
864    /// - Click on an element → snaps to nearest element boundary (start or end).
865    pub fn buffer_pos_at_screen(
866        &self,
867        col: u16,
868        row: u16,
869        area: Rect,
870        state: TextAreaState,
871    ) -> Option<usize> {
872        // Outside the textarea area → None.
873        if col < area.x || col >= area.x + area.width || row < area.y || row >= area.y + area.height
874        {
875            return None;
876        }
877
878        let tw = self.text_width(area);
879        let lines = self.wrapped_lines(tw);
880        let scroll = self.effective_scroll(area.height, &lines, state.scroll);
881
882        let visual_row = (row - area.y) as usize + scroll as usize;
883
884        // Below all text → end of text.
885        if visual_row >= lines.len() {
886            return Some(self.text.len());
887        }
888
889        let line = &lines[visual_row];
890        let target_col = (col - area.x) as usize;
891        // Clamp line.end to text length (safety measure for edge cases).
892        let line_end = line.end.min(self.text.len());
893        Some(
894            self.display_col_to_buffer_pos(line.start, line_end, target_col)
895                .0,
896        )
897    }
898
899    /// Like `buffer_pos_at_screen` but also indicates whether the column
900    /// fell on an element's display region.
901    fn buffer_pos_at_screen_ex(
902        &self,
903        col: u16,
904        row: u16,
905        area: Rect,
906        state: TextAreaState,
907    ) -> Option<(usize, bool)> {
908        if col < area.x || row < area.y {
909            return None;
910        }
911
912        let tw = self.text_width(area);
913        let lines = self.wrapped_lines(tw);
914        let scroll = self.effective_scroll(area.height, &lines, state.scroll);
915
916        let visual_row = (row - area.y) as usize + scroll as usize;
917
918        if visual_row >= lines.len() {
919            return Some((self.text.len(), false));
920        }
921
922        let line = &lines[visual_row];
923        let target_col = (col - area.x) as usize;
924        let line_end = line.end.min(self.text.len());
925        Some(self.display_col_to_buffer_pos(line.start, line_end, target_col))
926    }
927
928    /// Return the element at screen coordinates, if any.
929    ///
930    /// Uses `buffer_pos_at_screen` to find the buffer position, then checks
931    /// whether that position falls inside an element.
932    pub fn element_at_screen(
933        &self,
934        col: u16,
935        row: u16,
936        area: Rect,
937        state: TextAreaState,
938    ) -> Option<&TextElement> {
939        let (pos, hit_element) = self.buffer_pos_at_screen_ex(col, row, area, state)?;
940        if hit_element {
941            // hit_element means the column fell on an element's display.
942            // pos may be elem start or elem end — match either.
943            self.elements
944                .iter()
945                .find(|e| pos >= e.range.start && pos <= e.range.end && !e.range.is_empty())
946        } else {
947            self.elements
948                .iter()
949                .find(|e| pos >= e.range.start && pos < e.range.end)
950        }
951    }
952
953    // ── Selection API ──
954
955    /// Normalized selection range, expanded to element boundaries.
956    ///
957    /// Returns `None` if no selection is active or anchor == head (empty).
958    pub fn selection_range(&self) -> Option<Range<usize>> {
959        let sel = self.selection?;
960        if sel.anchor == sel.head {
961            return None;
962        }
963        let start = sel.anchor.min(sel.head);
964        let end = sel.anchor.max(sel.head);
965        let expanded = self.expand_range_to_element_boundaries(start..end);
966        let clamped_start = expanded.start.min(self.text.len());
967        let clamped_end = expanded.end.min(self.text.len());
968        if clamped_start >= clamped_end {
969            None
970        } else {
971            Some(clamped_start..clamped_end)
972        }
973    }
974
975    /// Text within the current selection (buffer text, not display text).
976    pub fn selected_text(&self) -> Option<String> {
977        let range = self.selection_range()?;
978        Some(self.text[range].to_string())
979    }
980
981    /// Clear the selection without affecting the clipboard.
982    pub fn clear_selection(&mut self) {
983        self.selection = None;
984    }
985
986    /// Delete the selected range (if any). Returns `true` if text was deleted.
987    ///
988    /// This is a single undo step. After deletion, the cursor is placed at
989    /// the start of the deleted range and the selection is cleared.
990    pub fn delete_selection(&mut self) -> bool {
991        let Some(range) = self.selection_range() else {
992            return false;
993        };
994        let start = range.start;
995        self.apply_edit_replacement(range, "", Some(MutationKind::Replace));
996        self.set_cursor_inner(start.min(self.text.len()));
997        self.post_mutate();
998        self.selection = None;
999        true
1000    }
1001
1002    /// Set the selection programmatically.
1003    pub fn set_selection(&mut self, anchor: usize, head: usize) {
1004        self.selection = Some(Selection { anchor, head });
1005    }
1006
1007    /// Take the clipboard contents (returns `None` if empty).
1008    ///
1009    /// This is the primary way for the host app to retrieve text
1010    /// that was selected by mouse drag / double-click / triple-click.
1011    pub fn take_clipboard(&mut self) -> Option<String> {
1012        self.clipboard.take()
1013    }
1014
1015    /// Peek at the current clipboard content without consuming it.
1016    pub fn clipboard(&self) -> Option<&str> {
1017        self.clipboard.as_deref()
1018    }
1019
1020    /// Replace the clipboard provider. The default is [`InternalClipboard`]
1021    /// (in-memory only). Pass an `arboard`-backed implementation to sync
1022    /// copy/cut/paste with the system clipboard.
1023    pub fn set_clipboard_provider(&mut self, provider: Box<dyn ClipboardProvider + Send>) {
1024        self.clipboard_provider = provider;
1025    }
1026
1027    // ── Element events ──
1028
1029    /// Take the pending [`TextElementEvent`], if any.
1030    ///
1031    /// Call this after [`handle_mouse`](Self::handle_mouse) to check whether
1032    /// an element was clicked or hover-entered/left.
1033    pub fn poll_element_event(&mut self) -> Option<TextElementEvent> {
1034        self.pending_element_event.take()
1035    }
1036
1037    /// Internal: set clipboard text via the provider AND the notification field.
1038    fn set_clipboard_text(&mut self, text: String) {
1039        if !text.is_empty() {
1040            self.clipboard_provider.set(&text);
1041            self.clipboard = Some(text);
1042        }
1043    }
1044
1045    // ── Timers / tick ──
1046
1047    /// Recommended poll timeout for the host event loop.
1048    ///
1049    /// When the textarea has pending timer-driven work (e.g. continuous
1050    /// drag-scrolling while the mouse is held outside the area), this
1051    /// returns `Some(ms)`.  The host should use this as the
1052    /// `event::poll` timeout.  When the poll times out without an event,
1053    /// call [`tick`](Self::tick).
1054    ///
1055    /// Returns `None` when no timer work is pending — the host can use
1056    /// its own default timeout.
1057    pub fn poll_timeout_ms(&self) -> Option<u64> {
1058        // Drag-scroll is the only timer-driven feature for now.
1059        self.pending_drag_scroll.as_ref()?;
1060        let interval = Self::drag_scroll_interval(self.drag_scroll_steps);
1061        Some(interval as u64)
1062    }
1063
1064    /// Advance timer-driven work (called by the host when `poll` times
1065    /// out).  Returns a `MouseAction` describing what changed (typically
1066    /// `SelectionUpdated` for drag-scroll, or `Nothing`).
1067    pub fn tick(&mut self, area: Rect, state: TextAreaState) -> MouseAction {
1068        // Drag-scroll continuation.
1069        if let Some(event) = self.pending_drag_scroll {
1070            return self.handle_mouse(event, area, state);
1071        }
1072        MouseAction::Nothing
1073    }
1074
1075    // ── Mouse ──
1076
1077    /// Shared single/double-click treatment of a click that landed on an
1078    /// element display (`hit_element`): snap the cursor to the element
1079    /// start, anchor drags there, and emit [`TextElementEventKind::Click`].
1080    ///
1081    /// Returns `None` when the click was not on an element.
1082    fn element_click_snap(&mut self, pos: usize, hit_element: bool) -> Option<MouseAction> {
1083        if !hit_element {
1084            return None;
1085        }
1086        let elem = self
1087            .elements
1088            .iter()
1089            .find(|e| pos >= e.range.start && pos <= e.range.end && !e.range.is_empty())?;
1090        let id = elem.id;
1091        let start = elem.range.start;
1092        self.set_cursor_inner(start);
1093        self.preferred_col = None;
1094        self.drag_anchor = Some(start);
1095        self.pending_element_event = Some(TextElementEvent {
1096            id,
1097            kind: TextElementEventKind::Click,
1098        });
1099        Some(MouseAction::CursorPlaced)
1100    }
1101
1102    /// Process a crossterm `MouseEvent` and return what happened.
1103    ///
1104    /// The host app is expected to call this from its event loop for
1105    /// every `Event::Mouse(mouse)` and pass the textarea's render `area`
1106    /// plus the current `TextAreaState` (for scroll info).
1107    pub fn handle_mouse(
1108        &mut self,
1109        event: MouseEvent,
1110        area: Rect,
1111        state: TextAreaState,
1112    ) -> MouseAction {
1113        // ── Scrollbar interaction ──
1114        // When scrollbar is shown, clicks/drags on the rightmost column
1115        // control the scroll position instead of placing the cursor.
1116        let tw = self.text_width(area);
1117        let has_scrollbar = self.show_scrollbar && tw < area.width;
1118        let on_scrollbar = has_scrollbar && event.column == area.x + area.width - 1;
1119
1120        // Handle scrollbar drag continuation (even if pointer moved off the column).
1121        if self.scrollbar_dragging {
1122            match event.kind {
1123                MouseEventKind::Drag(MouseButton::Left)
1124                | MouseEventKind::Down(MouseButton::Left) => {
1125                    return self.handle_scrollbar_click(event.row, area, tw);
1126                }
1127                MouseEventKind::Up(MouseButton::Left) => {
1128                    self.scrollbar_dragging = false;
1129                    return MouseAction::Scrolled;
1130                }
1131                _ => {}
1132            }
1133        }
1134
1135        if on_scrollbar && let MouseEventKind::Down(MouseButton::Left) = event.kind {
1136            self.scrollbar_dragging = true;
1137            // If the click is on the thumb, don't jump — just start the drag
1138            // from the current position.  Only jump when clicking the track.
1139            if self.is_scrollbar_thumb_at(event.row, area, tw) {
1140                return MouseAction::Scrolled;
1141            }
1142            return self.handle_scrollbar_click(event.row, area, tw);
1143        }
1144
1145        match event.kind {
1146            MouseEventKind::Down(MouseButton::Left) => {
1147                // Some terminals re-emit Down(Left) after a scroll event
1148                // even though the button was held the whole time.  When a
1149                // drag is already active, treat this as a drag continuation
1150                // so the selection anchor is preserved.
1151                if self.drag_active {
1152                    return self.handle_mouse(
1153                        MouseEvent {
1154                            kind: MouseEventKind::Drag(MouseButton::Left),
1155                            ..event
1156                        },
1157                        area,
1158                        state,
1159                    );
1160                }
1161
1162                let col = event.column;
1163                let row = event.row;
1164
1165                // Track multi-click (double/triple).
1166                let click_count = self.click_tracker.register(col, row);
1167
1168                // Record the mouse-down position (for drag detection).
1169                self.mouse_down_pos = Some((col, row));
1170                self.drag_active = false;
1171                self.last_drag_scroll = None;
1172                self.drag_scroll_steps = 0;
1173                self.pending_drag_scroll = None;
1174
1175                // Clear any existing selection.
1176                self.clear_selection();
1177
1178                // Map screen coordinates to buffer position.
1179                // IMPORTANT: this must happen BEFORE clearing scroll_override
1180                // so that effective_scroll uses the current viewport, not the
1181                // cursor-following fallback.
1182                let Some((pos, hit_element)) = self.buffer_pos_at_screen_ex(col, row, area, state)
1183                else {
1184                    self.scroll_override = None;
1185                    self.drag_anchor = None;
1186                    return MouseAction::Nothing;
1187                };
1188
1189                // Now that we have the correct buffer position, clear the
1190                // scroll override so the viewport follows the cursor again.
1191                self.scroll_override = None;
1192
1193                match click_count {
1194                    2 => {
1195                        // Double-click on an element display: snap like a
1196                        // single click (cursor to element start + Click
1197                        // event). Word-selecting would select and copy the
1198                        // element's hidden buffer text to the clipboard;
1199                        // the host decides what a chip double-click means.
1200                        // Triple-click line-select below intentionally keeps
1201                        // buffer-text semantics, element content included —
1202                        // a copy gesture, like drag-select across a chip.
1203                        if let Some(action) = self.element_click_snap(pos, hit_element) {
1204                            return action;
1205                        }
1206                        // Double-click: select word under cursor.
1207                        // Whitespace clicks just place the cursor (no selection).
1208                        let is_ws = pos < self.text.len()
1209                            && self.text[pos..]
1210                                .chars()
1211                                .next()
1212                                .is_none_or(|ch| ch.is_whitespace());
1213                        let start = self.word_start_at(pos);
1214                        let end = self.word_end_at(pos);
1215                        if !is_ws && start < end {
1216                            self.selection = Some(Selection {
1217                                anchor: start,
1218                                head: end,
1219                            });
1220                            // Place cursor on the last character of the
1221                            // selection (neovim style), not one past the end.
1222                            let cursor = self.text[start..end]
1223                                .char_indices()
1224                                .next_back()
1225                                .map(|(i, _)| start + i)
1226                                .unwrap_or(start);
1227                            self.set_cursor_inner(cursor);
1228                            self.preferred_col = None;
1229                            if let Some(text) = self.selected_text() {
1230                                self.set_clipboard_text(text);
1231                            }
1232                            return MouseAction::SelectionFinished;
1233                        }
1234                        // Clicked on whitespace — just place cursor.
1235                        self.set_cursor_inner(pos);
1236                        self.preferred_col = None;
1237                        MouseAction::CursorPlaced
1238                    }
1239                    3 => {
1240                        // Triple-click: select entire source line (\n-delimited).
1241                        let line_start = self.beginning_of_line(pos);
1242                        // Include the trailing \n if present.
1243                        let line_end_excl = self.end_of_line(pos);
1244                        let line_end = if line_end_excl < self.text.len() {
1245                            line_end_excl + 1 // include \n
1246                        } else {
1247                            line_end_excl
1248                        };
1249                        self.selection = Some(Selection {
1250                            anchor: line_start,
1251                            head: line_end,
1252                        });
1253                        // Keep cursor at the click position (like neovim),
1254                        // not at the end of the selection.
1255                        self.set_cursor_inner(pos);
1256                        self.preferred_col = None;
1257                        if let Some(text) = self.selected_text() {
1258                            self.set_clipboard_text(text);
1259                        }
1260                        MouseAction::SelectionFinished
1261                    }
1262                    _ => {
1263                        // Single click: place cursor.
1264                        //
1265                        // If click landed on an element display, snap cursor
1266                        // to elem start. `hit_element` is reliable because
1267                        // display_col_to_buffer_pos sets it when the column
1268                        // falls within an element's visual width.
1269                        if let Some(action) = self.element_click_snap(pos, hit_element) {
1270                            return action;
1271                        }
1272
1273                        self.drag_anchor = Some(pos);
1274                        self.set_cursor_inner(pos);
1275                        self.preferred_col = None;
1276
1277                        MouseAction::CursorPlaced
1278                    }
1279                }
1280            }
1281            MouseEventKind::Drag(MouseButton::Left) => {
1282                let Some(anchor) = self.drag_anchor else {
1283                    return MouseAction::Nothing;
1284                };
1285
1286                // Compute the buffer position for the drag endpoint.
1287                // We need to scope the `lines` borrow so it's dropped before
1288                // we mutate self.
1289
1290                // Throttle drag-scroll (above/below area) to avoid
1291                // lightning-fast scrolling at mouse-report rate.
1292                // Acceleration: first step waits 80ms, then 60ms, then 40ms.
1293                let outside_area = event.row < area.y || event.row >= area.y + area.height;
1294                if outside_area {
1295                    // Store event for continuous drag-scroll re-triggering.
1296                    self.pending_drag_scroll = Some(event);
1297
1298                    let now = Instant::now();
1299                    let interval = Self::drag_scroll_interval(self.drag_scroll_steps);
1300                    if let Some(last) = self.last_drag_scroll
1301                        && now.duration_since(last).as_millis() < interval
1302                    {
1303                        return MouseAction::Nothing;
1304                    }
1305                    self.last_drag_scroll = Some(now);
1306                    self.drag_scroll_steps = self.drag_scroll_steps.saturating_add(1);
1307                } else {
1308                    // Back inside area — cancel continuous drag-scroll.
1309                    self.pending_drag_scroll = None;
1310                }
1311
1312                let (head, new_scroll) = {
1313                    let tw = self.text_width(area);
1314                    let lines = self.wrapped_lines(tw);
1315                    let scroll = self.effective_scroll(area.height, &lines, state.scroll) as usize;
1316                    let visible_end = scroll + area.height as usize;
1317
1318                    if event.row < area.y {
1319                        // ── Dragging above the area → scroll up ──
1320                        let dist = area.y - event.row;
1321                        let n = Self::drag_scroll_lines_for_distance(dist);
1322                        let target_line = scroll.saturating_sub(n);
1323                        let pos = if target_line < lines.len() {
1324                            let col = event.column.saturating_sub(area.x) as usize;
1325                            let line = &lines[target_line];
1326                            let line_end = line.end.min(self.text.len());
1327                            let p = self.display_col_to_buffer_pos(line.start, line_end, col).0;
1328                            self.clamp_to_line(p, line.start, line_end)
1329                        } else {
1330                            0
1331                        };
1332                        (pos, Some(target_line as u16))
1333                    } else if event.row >= area.y + area.height {
1334                        // ── Dragging below the area → scroll down ──
1335                        let dist = event.row - (area.y + area.height) + 1;
1336                        let n = Self::drag_scroll_lines_for_distance(dist);
1337                        let target_line = (visible_end + n - 1).min(lines.len().saturating_sub(1));
1338                        let max_scroll = lines.len().saturating_sub(area.height as usize);
1339                        let new_scroll = (target_line + 1)
1340                            .saturating_sub(area.height as usize)
1341                            .min(max_scroll);
1342                        let pos = if target_line < lines.len() {
1343                            let col = event.column.saturating_sub(area.x) as usize;
1344                            let line = &lines[target_line];
1345                            let line_end = line.end.min(self.text.len());
1346                            let pos = self.display_col_to_buffer_pos(line.start, line_end, col).0;
1347                            self.clamp_to_line(pos, line.start, line_end)
1348                        } else {
1349                            self.text.len()
1350                        };
1351                        (pos, Some(new_scroll as u16))
1352                    } else {
1353                        // ── Within the area → normal drag ──
1354                        let col = event.column.clamp(area.x, area.x + tw.saturating_sub(1));
1355                        let row = event.row;
1356                        drop(lines); // release borrow for buffer_pos_at_screen
1357                        match self.buffer_pos_at_screen(col, row, area, state) {
1358                            Some(pos) => (pos, None),
1359                            None => return MouseAction::Nothing,
1360                        }
1361                    }
1362                };
1363
1364                if let Some(s) = new_scroll {
1365                    self.scroll_override = Some(s);
1366                }
1367                if head == anchor {
1368                    self.drag_active = false;
1369                    self.selection = None;
1370                } else {
1371                    self.drag_active = true;
1372                    self.selection = Some(Selection { anchor, head });
1373                }
1374                self.set_cursor_inner(head);
1375                self.preferred_col = None;
1376
1377                if self.selection.is_some() {
1378                    MouseAction::SelectionUpdated
1379                } else {
1380                    MouseAction::CursorPlaced
1381                }
1382            }
1383            MouseEventKind::Up(MouseButton::Left) => {
1384                self.mouse_down_pos = None;
1385                let was_drag = self.drag_active;
1386                self.drag_active = false;
1387                self.scrollbar_dragging = false;
1388                self.pending_drag_scroll = None;
1389                self.drag_anchor = None;
1390
1391                if was_drag {
1392                    // Discard zero-width selections (anchor == head) that arise
1393                    // from mouse jitter — they look like an active selection to
1394                    // the keyboard handler and silently swallow Backspace/Delete.
1395                    if self.selection_range().is_none() {
1396                        self.selection = None;
1397                        MouseAction::CursorPlaced
1398                    } else {
1399                        // Finalize selection: copy to clipboard.
1400                        if let Some(text) = self.selected_text()
1401                            && !text.is_empty()
1402                        {
1403                            self.set_clipboard_text(text);
1404                        }
1405
1406                        if !self.keep_selection_after_mouseup {
1407                            self.selection = None;
1408                        }
1409
1410                        MouseAction::SelectionFinished
1411                    }
1412                } else {
1413                    MouseAction::Nothing
1414                }
1415            }
1416            MouseEventKind::ScrollDown => {
1417                let tw = self.text_width(area);
1418                let lines = self.wrapped_lines(tw);
1419                let total = lines.len();
1420                if total <= area.height as usize {
1421                    return MouseAction::Nothing;
1422                }
1423                let max_scroll = total.saturating_sub(area.height as usize) as u16;
1424                let current = self
1425                    .scroll_override
1426                    .unwrap_or_else(|| self.effective_scroll(area.height, &lines, state.scroll));
1427                let scroll_lines = Self::scroll_lines_for_height(area.height);
1428                let new_scroll = (current + scroll_lines).min(max_scroll);
1429                if new_scroll == current {
1430                    return MouseAction::Nothing;
1431                }
1432                // If dragging, extend the selection head to follow the scroll.
1433                let drag_new_pos = if self.drag_active {
1434                    let target_line =
1435                        (new_scroll as usize + area.height as usize - 1).min(lines.len() - 1);
1436                    Some(lines[target_line].start)
1437                } else {
1438                    None
1439                };
1440                drop(lines);
1441                self.scroll_override = Some(new_scroll);
1442                if let Some(new_pos) = drag_new_pos {
1443                    if let Some(sel) = &mut self.selection {
1444                        sel.head = new_pos;
1445                    }
1446                    self.set_cursor_inner(new_pos);
1447                }
1448                MouseAction::Scrolled
1449            }
1450            MouseEventKind::ScrollUp => {
1451                let tw = self.text_width(area);
1452                let lines = self.wrapped_lines(tw);
1453                let total = lines.len();
1454                if total <= area.height as usize {
1455                    return MouseAction::Nothing;
1456                }
1457                let current = self
1458                    .scroll_override
1459                    .unwrap_or_else(|| self.effective_scroll(area.height, &lines, state.scroll));
1460                let scroll_lines = Self::scroll_lines_for_height(area.height);
1461                let new_scroll = current.saturating_sub(scroll_lines);
1462                if new_scroll == current {
1463                    return MouseAction::Nothing;
1464                }
1465                // If dragging, extend the selection head to follow the scroll.
1466                let drag_new_pos = if self.drag_active {
1467                    let target_line = new_scroll as usize;
1468                    Some(if target_line < lines.len() {
1469                        lines[target_line].start
1470                    } else {
1471                        0
1472                    })
1473                } else {
1474                    None
1475                };
1476                drop(lines);
1477                self.scroll_override = Some(new_scroll);
1478                if let Some(new_pos) = drag_new_pos {
1479                    if let Some(sel) = &mut self.selection {
1480                        sel.head = new_pos;
1481                    }
1482                    self.set_cursor_inner(new_pos);
1483                }
1484                MouseAction::Scrolled
1485            }
1486            MouseEventKind::Moved => {
1487                // Hover detection: hit-test elements under the cursor.
1488                let hovered_id = self
1489                    .element_at_screen(event.column, event.row, area, state)
1490                    .map(|e| e.id);
1491
1492                let prev = self.hovered_element;
1493                if hovered_id != prev {
1494                    // Emit leave for the old element first, then enter for the new one.
1495                    // We only store the last event; if both happen, prefer enter
1496                    // (the caller already knows about the old element from a prior enter).
1497                    if let Some(old_id) = prev {
1498                        self.pending_element_event = Some(TextElementEvent {
1499                            id: old_id,
1500                            kind: TextElementEventKind::HoverLeave,
1501                        });
1502                    }
1503                    if let Some(new_id) = hovered_id {
1504                        self.pending_element_event = Some(TextElementEvent {
1505                            id: new_id,
1506                            kind: TextElementEventKind::HoverEnter,
1507                        });
1508                    }
1509                    self.hovered_element = hovered_id;
1510                }
1511                MouseAction::Nothing
1512            }
1513            _ => MouseAction::Nothing,
1514        }
1515    }
1516
1517    /// Handle a click or drag on the scrollbar track.
1518    ///
1519    /// Maps the row position proportionally to a scroll offset:
1520    /// clicking at the top of the track scrolls to the start, at the
1521    /// bottom scrolls to the end.
1522    fn handle_scrollbar_click(&mut self, row: u16, area: Rect, tw: u16) -> MouseAction {
1523        if area.height == 0 {
1524            return MouseAction::Nothing;
1525        }
1526        let total = {
1527            let lines = self.wrapped_lines(tw);
1528            lines.len()
1529        };
1530        if total <= area.height as usize {
1531            return MouseAction::Nothing;
1532        }
1533        let max_scroll = total.saturating_sub(area.height as usize) as u16;
1534        let rel_row = row.saturating_sub(area.y);
1535        // Map relative row to a scroll offset proportionally.
1536        let scroll = if area.height <= 1 {
1537            0
1538        } else {
1539            ((rel_row as u32 * max_scroll as u32) / (area.height.saturating_sub(1)) as u32) as u16
1540        };
1541        self.scroll_override = Some(scroll.min(max_scroll));
1542        MouseAction::Scrolled
1543    }
1544
1545    /// Check whether the given screen row falls on the scrollbar thumb.
1546    ///
1547    /// Renders the scrollbar into a scratch buffer and checks whether the
1548    /// cell at `row` is a non-space character (thumb glyph) or a space (track).
1549    fn is_scrollbar_thumb_at(&self, row: u16, area: Rect, tw: u16) -> bool {
1550        if area.height == 0 {
1551            return false;
1552        }
1553        let total = {
1554            let lines = self.wrapped_lines(tw);
1555            lines.len()
1556        };
1557        if total <= area.height as usize {
1558            return false;
1559        }
1560        let current_scroll = self.scroll_override.unwrap_or(0);
1561
1562        let lengths = ScrollLengths {
1563            content_len: total,
1564            viewport_len: area.height as usize,
1565        };
1566        let scrollbar = ScrollBar::vertical(lengths).offset(current_scroll as usize);
1567        let sb_x = area.right().saturating_sub(1);
1568        let core_area = CoreRect {
1569            x: sb_x,
1570            y: area.y,
1571            width: 1,
1572            height: area.height,
1573        };
1574        let mut scratch = CoreBuffer::empty(core_area);
1575        (&scrollbar).render(core_area, &mut scratch);
1576
1577        if row < area.y || row >= area.y + area.height {
1578            return false;
1579        }
1580        scratch[(sb_x, row)].symbol() != " "
1581    }
1582
1583    pub fn is_empty(&self) -> bool {
1584        self.text.is_empty()
1585    }
1586
1587    fn is_word_char(ch: char) -> bool {
1588        ch.is_alphanumeric() || ch == '_'
1589    }
1590
1591    /// Classify a character into a word-class for double-click selection.
1592    ///
1593    /// Three classes (matching vim/neovim `w` word definition):
1594    /// - `0`: whitespace
1595    /// - `1`: word chars (alphanumeric + underscore)
1596    /// - `2`: punctuation / everything else
1597    fn char_class(ch: char) -> u8 {
1598        if ch.is_whitespace() {
1599            0
1600        } else if Self::is_word_char(ch) {
1601            1
1602        } else {
1603            2
1604        }
1605    }
1606
1607    /// Find the start of the word containing `pos` (for double-click selection).
1608    ///
1609    /// Uses vim-style word classes: word chars (alphanumeric + `_`), punctuation,
1610    /// and whitespace are three distinct groups.  Scans backward until the class
1611    /// changes.
1612    ///
1613    /// If `pos` is inside an element, returns the element start.
1614    fn word_start_at(&self, pos: usize) -> usize {
1615        // If inside an element, return element start.
1616        if let Some(elem) = self
1617            .elements
1618            .iter()
1619            .find(|e| pos >= e.range.start && pos < e.range.end)
1620        {
1621            return elem.range.start;
1622        }
1623
1624        // Determine the class of the character at `pos` (or just before if at end).
1625        let target_class = if pos < self.text.len() {
1626            Self::char_class(self.text[pos..].chars().next().unwrap())
1627        } else if pos > 0 {
1628            let ch = self.text[..pos].chars().next_back().unwrap();
1629            Self::char_class(ch)
1630        } else {
1631            return 0;
1632        };
1633
1634        let before = &self.text[..pos];
1635        let word_start = before
1636            .char_indices()
1637            .rev()
1638            .find(|&(_, ch)| Self::char_class(ch) != target_class)
1639            .map(|(idx, ch)| idx + ch.len_utf8())
1640            .unwrap_or(0);
1641        self.adjust_pos_out_of_elements(word_start, true)
1642    }
1643
1644    /// Find the end of the word containing `pos` (for double-click selection).
1645    ///
1646    /// Uses vim-style word classes (see [`Self::char_class`]).
1647    ///
1648    /// If `pos` is inside an element, returns the element end.
1649    fn word_end_at(&self, pos: usize) -> usize {
1650        // If inside an element, return element end.
1651        if let Some(elem) = self
1652            .elements
1653            .iter()
1654            .find(|e| pos >= e.range.start && pos < e.range.end)
1655        {
1656            return elem.range.end;
1657        }
1658
1659        // Determine the class of the character at `pos`.
1660        let target_class = if pos < self.text.len() {
1661            Self::char_class(self.text[pos..].chars().next().unwrap())
1662        } else {
1663            return self.text.len();
1664        };
1665
1666        let after = &self.text[pos..];
1667        let word_end = after
1668            .char_indices()
1669            .find(|&(_, ch)| Self::char_class(ch) != target_class)
1670            .map(|(rel_idx, _)| pos + rel_idx)
1671            .unwrap_or(self.text.len());
1672        self.adjust_pos_out_of_elements(word_end, false)
1673    }
1674
1675    fn current_display_col(&self) -> usize {
1676        let bol = self.beginning_of_current_line();
1677        self.display_width_of_range(bol, self.cursor())
1678    }
1679
1680    /// Compute the display width of the buffer range `[from..to)`.
1681    ///
1682    /// Plain runs use tab-aware width (`tab_width` columns per `\t`, or
1683    /// unicode-width when `tab_width == 0`). Element ranges with a custom
1684    /// `display` use the element's display width instead of the buffer text
1685    /// width. This is the core of the display projection system.
1686    fn display_width_of_range(&self, from: usize, to: usize) -> usize {
1687        if from >= to {
1688            return 0;
1689        }
1690        let mut width = 0usize;
1691        let mut pos = from;
1692
1693        for elem in &self.elements {
1694            if elem.range.start >= to {
1695                break; // elements are sorted, no more overlap possible
1696            }
1697            if elem.range.end <= pos {
1698                continue; // element is entirely before our current position
1699            }
1700
1701            // Plain text before this element
1702            if pos < elem.range.start {
1703                let plain_end = elem.range.start.min(to);
1704                width += self.plain_display_width(&self.text[pos..plain_end]);
1705                pos = plain_end;
1706            }
1707            if pos >= to {
1708                break;
1709            }
1710
1711            // Element region
1712            let elem_start_in_range = elem.range.start.max(pos);
1713            let elem_end_in_range = elem.range.end.min(to);
1714            if elem_start_in_range < elem_end_in_range {
1715                if let Some(display) = &elem.display {
1716                    // If the range covers the entire element (or starts at element start),
1717                    // use the full display width. If it covers only a partial overlap
1718                    // (cursor inside element — shouldn't happen normally), fall back to
1719                    // buffer text width.
1720                    if elem_start_in_range == elem.range.start {
1721                        let display_w: usize = display
1722                            .spans
1723                            .iter()
1724                            .map(|s| s.content.as_ref().width())
1725                            .sum();
1726                        width += display_w;
1727                    } else {
1728                        width += self.plain_display_width(
1729                            &self.text[elem_start_in_range..elem_end_in_range],
1730                        );
1731                    }
1732                } else {
1733                    width += self
1734                        .plain_display_width(&self.text[elem_start_in_range..elem_end_in_range]);
1735                }
1736                pos = elem_end_in_range;
1737            }
1738        }
1739
1740        // Remaining plain text after all elements
1741        if pos < to {
1742            width += self.plain_display_width(&self.text[pos..to]);
1743        }
1744
1745        width
1746    }
1747
1748    fn wrapped_line_index_by_start(lines: &[Range<usize>], pos: usize) -> Option<usize> {
1749        // partition_point returns the index of the first element for which
1750        // the predicate is false, i.e. the count of elements with start <= pos.
1751        let idx = lines.partition_point(|r| r.start <= pos);
1752        if idx == 0 { None } else { Some(idx - 1) }
1753    }
1754
1755    /// Map a display column to a buffer byte position on a given wrapped line.
1756    ///
1757    /// Pure query — does not mutate any state. Handles elements (snapping to
1758    /// nearest element boundary) and wide unicode graphemes.
1759    /// If `target_col` is past the line's display width, returns `line_end`
1760    /// (clamped to the nearest element boundary).
1761    ///
1762    /// Returns `(byte_pos, hit_element)` where `hit_element` is `true` when
1763    /// the column fell on an element's display region.
1764    fn display_col_to_buffer_pos(
1765        &self,
1766        line_start: usize,
1767        line_end: usize,
1768        target_col: usize,
1769    ) -> (usize, bool) {
1770        let mut width_so_far = 0usize;
1771        let mut pos = line_start;
1772
1773        while pos < line_end {
1774            // Check if pos is at or inside an element
1775            if let Some(elem_idx) = self
1776                .elements
1777                .iter()
1778                .position(|e| pos >= e.range.start && pos < e.range.end)
1779            {
1780                let elem = &self.elements[elem_idx];
1781                let elem_start = elem.range.start;
1782                let elem_buf_end = elem.range.end;
1783                // The visible portion of the element on this line
1784                let elem_line_end = elem_buf_end.min(line_end);
1785
1786                if pos == elem_start {
1787                    // We're at the start of an element — treat it as a whole unit.
1788                    let elem_display_w = if let Some(display) = &elem.display {
1789                        display
1790                            .spans
1791                            .iter()
1792                            .map(|s| s.content.as_ref().width())
1793                            .sum()
1794                    } else {
1795                        self.plain_display_width(&self.text[elem_start..elem_line_end])
1796                    };
1797
1798                    if width_so_far + elem_display_w > target_col {
1799                        // Click landed on this element display — snap to the
1800                        // nearer boundary (start vs end of the underlying
1801                        // buffer text) so that drag-selection works naturally.
1802                        let dist_start = target_col.saturating_sub(width_so_far);
1803                        let dist_end = elem_display_w.saturating_sub(dist_start);
1804                        if dist_start <= dist_end {
1805                            return (elem_start, true);
1806                        } else {
1807                            return (elem_buf_end, true);
1808                        }
1809                    }
1810                    width_so_far += elem_display_w;
1811                    pos = elem_buf_end.min(line_end); // move past element (or to line end)
1812                } else {
1813                    // We're in the middle of an element (e.g. a wrapped line starts
1814                    // mid-element). Skip past the rest of the element on this line.
1815                    let partial_w = self.plain_display_width(&self.text[pos..elem_line_end]);
1816                    if width_so_far + partial_w > target_col {
1817                        // Snap to element's actual end boundary
1818                        return (elem_buf_end, true);
1819                    }
1820                    width_so_far += partial_w;
1821                    pos = elem_buf_end.min(line_end); // move past element (or to line end)
1822                }
1823                continue;
1824            }
1825
1826            // Plain text grapheme
1827            let slice = &self.text[pos..line_end];
1828            if let Some(grapheme) = slice.graphemes(true).next() {
1829                let grapheme_width = self.grapheme_display_width(grapheme);
1830                width_so_far += grapheme_width;
1831                if width_so_far > target_col {
1832                    return (self.clamp_pos_to_nearest_boundary(pos), false);
1833                }
1834                pos += grapheme.len();
1835            } else {
1836                break;
1837            }
1838        }
1839
1840        (self.clamp_pos_to_nearest_boundary(line_end), false)
1841    }
1842
1843    fn move_to_display_col_on_line(
1844        &mut self,
1845        line_start: usize,
1846        line_end: usize,
1847        target_col: usize,
1848    ) {
1849        let cursor = self
1850            .display_col_to_buffer_pos(line_start, line_end, target_col)
1851            .0;
1852        self.set_cursor_inner(cursor);
1853    }
1854
1855    fn beginning_of_line(&self, pos: usize) -> usize {
1856        // Scan backward for '\n' that is NOT inside an element.
1857        // Newlines inside elements (e.g. multi-line paste) are not line boundaries.
1858        for i in (0..pos).rev() {
1859            if self.text.as_bytes()[i] == b'\n' && !self.is_inside_element(i) {
1860                return i + 1;
1861            }
1862        }
1863        0
1864    }
1865    fn beginning_of_current_line(&self) -> usize {
1866        self.beginning_of_line(self.cursor())
1867    }
1868
1869    fn end_of_line(&self, pos: usize) -> usize {
1870        // Scan forward for '\n' that is NOT inside an element.
1871        for i in pos..self.text.len() {
1872            if self.text.as_bytes()[i] == b'\n' && !self.is_inside_element(i) {
1873                return i;
1874            }
1875        }
1876        self.text.len()
1877    }
1878    fn end_of_current_line(&self) -> usize {
1879        self.end_of_line(self.cursor())
1880    }
1881
1882    /// Check if a byte position is inside (strictly within) an element.
1883    fn is_inside_element(&self, pos: usize) -> bool {
1884        self.elements
1885            .iter()
1886            .any(|e| pos >= e.range.start && pos < e.range.end)
1887    }
1888
1889    fn apply_classified_command(&mut self, command: EditCommand) {
1890        if let EditCommand::Insert(character) = command {
1891            self.insert_str(&character.to_string());
1892            return;
1893        }
1894        let mutation_kind = match command.category() {
1895            EditCommandCategory::Insert => unreachable!("insert commands return above"),
1896            EditCommandCategory::Navigation => None,
1897            EditCommandCategory::Delete => Some(MutationKind::Delete),
1898            EditCommandCategory::Kill => Some(MutationKind::Kill),
1899        };
1900        self.apply_edit_command(command, mutation_kind);
1901    }
1902
1903    pub fn input(&mut self, event: KeyEvent) {
1904        // ── Selection-aware interception ──
1905        // When a selection is active, certain keys interact with the selected
1906        // range rather than performing their normal single-char action.
1907        if self.selection.is_some() {
1908            if let Some(EditCommand::Insert(character)) = classify_key_event(&event) {
1909                self.begin_undo_group();
1910                if !self.delete_selection() {
1911                    self.clear_selection();
1912                }
1913                self.insert_str(&character.to_string());
1914                self.end_undo_group();
1915                return;
1916            }
1917            match event {
1918                // Enter / Ctrl-J/M → replace selection with newline.
1919                KeyEvent {
1920                    code: KeyCode::Char('j' | 'm'),
1921                    modifiers: KeyModifiers::CONTROL,
1922                    ..
1923                }
1924                | KeyEvent {
1925                    code: KeyCode::Enter,
1926                    ..
1927                } => {
1928                    self.begin_undo_group();
1929                    if !self.delete_selection() {
1930                        self.clear_selection();
1931                    }
1932                    self.insert_str("\n");
1933                    self.end_undo_group();
1934                    return;
1935                }
1936                // Backspace / Delete → delete the selection only (no extra char).
1937                // If the selection is zero-width (anchor == head), delete_selection()
1938                // returns false — clear the stale selection and fall through to the
1939                // normal single-char delete so Backspace/Delete aren't silently swallowed.
1940                KeyEvent {
1941                    code: KeyCode::Backspace | KeyCode::Delete | KeyCode::Char('\x08' | '\x7f'),
1942                    ..
1943                }
1944                | KeyEvent {
1945                    code: KeyCode::Char('h'),
1946                    modifiers: KeyModifiers::CONTROL,
1947                    ..
1948                }
1949                | KeyEvent {
1950                    code: KeyCode::Char('d'),
1951                    modifiers: KeyModifiers::CONTROL,
1952                    ..
1953                } => {
1954                    if self.delete_selection() {
1955                        return;
1956                    }
1957                    // Zero-width selection — clear and fall through.
1958                    self.clear_selection();
1959                }
1960                // Ctrl-X → cut selection (copy to clipboard + delete).
1961                KeyEvent {
1962                    code: KeyCode::Char('x'),
1963                    modifiers: KeyModifiers::CONTROL,
1964                    ..
1965                } => {
1966                    if let Some(text) = self.selected_text() {
1967                        self.set_clipboard_text(text);
1968                    }
1969                    if self.delete_selection() {
1970                        return;
1971                    }
1972                    // Zero-width selection — clear and fall through.
1973                    self.clear_selection();
1974                }
1975                // All other keys → clear selection, fall through to normal handling.
1976                _ => {
1977                    self.clear_selection();
1978                }
1979            }
1980        }
1981
1982        if let Some(command) = classify_key_event(&event) {
1983            self.apply_classified_command(command);
1984            return;
1985        }
1986
1987        match event {
1988            KeyEvent {
1989                code: KeyCode::Char('j' | 'm'),
1990                modifiers: KeyModifiers::CONTROL,
1991                ..
1992            }
1993            | KeyEvent {
1994                code: KeyCode::Enter,
1995                ..
1996            } => self.insert_str("\n"),
1997            KeyEvent {
1998                code: KeyCode::Char('y'),
1999                modifiers: KeyModifiers::CONTROL,
2000                ..
2001            } => {
2002                self.yank();
2003            }
2004
2005            // Undo / Redo (Ctrl or Cmd)
2006            KeyEvent {
2007                code: KeyCode::Char('Z'),
2008                modifiers,
2009                ..
2010            } if modifiers.contains(KeyModifiers::CONTROL)
2011                || modifiers.contains(KeyModifiers::SUPER) =>
2012            {
2013                // Ctrl/Cmd-Shift-Z → redo (terminals that report uppercase Z + Shift)
2014                self.redo();
2015            }
2016            k if is_undo_input(&k) => {
2017                self.undo();
2018            }
2019            KeyEvent {
2020                code: KeyCode::Char('r'),
2021                modifiers: KeyModifiers::CONTROL,
2022                ..
2023            } => {
2024                self.redo();
2025            }
2026
2027            // Ctrl-V → paste from clipboard provider.
2028            KeyEvent {
2029                code: KeyCode::Char('v'),
2030                modifiers: KeyModifiers::CONTROL,
2031                ..
2032            } => {
2033                if let Some(text) = self.clipboard_provider.get() {
2034                    self.insert_str(&text);
2035                }
2036            }
2037
2038            // Cmd+Left / Cmd+Right (macOS): terminals using the Kitty keyboard
2039            // protocol (Ghostty, Kitty, WezTerm) send these as Super+Arrow.
2040            KeyEvent {
2041                code: KeyCode::Left,
2042                modifiers: KeyModifiers::SUPER,
2043                ..
2044            } => {
2045                self.move_cursor_to_beginning_of_line(false);
2046            }
2047            KeyEvent {
2048                code: KeyCode::Right,
2049                modifiers: KeyModifiers::SUPER,
2050                ..
2051            } => {
2052                self.move_cursor_to_end_of_line(false);
2053            }
2054            KeyEvent {
2055                code: KeyCode::Up, ..
2056            }
2057            | KeyEvent {
2058                code: KeyCode::Char('p'),
2059                modifiers: KeyModifiers::CONTROL,
2060                ..
2061            } => {
2062                self.move_cursor_up();
2063            }
2064            KeyEvent {
2065                code: KeyCode::Down,
2066                ..
2067            }
2068            | KeyEvent {
2069                code: KeyCode::Char('n'),
2070                modifiers: KeyModifiers::CONTROL,
2071                ..
2072            } => {
2073                self.move_cursor_down();
2074            }
2075            // Home/End → logical line (full left/right even when soft-wrapped).
2076            // Super+Left/Right stay on the visual wrap row; Ctrl+A/E chain
2077            // across logical lines when already at BOL/EOL.
2078            KeyEvent {
2079                code: KeyCode::Home,
2080                ..
2081            } => {
2082                self.set_cursor(self.beginning_of_current_line());
2083            }
2084
2085            KeyEvent {
2086                code: KeyCode::End, ..
2087            } => {
2088                self.set_cursor(self.end_of_current_line());
2089            }
2090            _o => {
2091                #[cfg(feature = "debug-logs")]
2092                tracing::debug!("Unhandled key event in TextArea: {:?}", _o);
2093            }
2094        }
2095    }
2096
2097    // ── Undo/Redo ──
2098
2099    /// Create a snapshot of the current textarea state.
2100    fn snapshot(&self) -> UndoEntry {
2101        UndoEntry {
2102            text: self.text().to_owned(),
2103            cursor: self.cursor(),
2104            elements: self.elements.clone(),
2105        }
2106    }
2107
2108    /// Restore the textarea state from a snapshot.
2109    fn restore(&mut self, entry: UndoEntry) {
2110        self.text = EditBuffer::from_parts(entry.text, entry.cursor);
2111        self.elements = entry.elements;
2112        self.wrap_cache.replace(None);
2113        self.preferred_col = None;
2114        // Note: next_element_id is intentionally NOT restored — it only increases.
2115        // Note: kill_buffer is intentionally NOT restored — yank is separate from undo.
2116    }
2117
2118    /// Called before a mutation to decide whether to push a new undo checkpoint.
2119    ///
2120    /// Batching rules:
2121    /// - Inside an undo group (`group_depth > 0`) → skip entirely.
2122    /// - First mutation ever → always checkpoint.
2123    /// - Kind changed from last → checkpoint.
2124    /// - Cursor moved since last mutation (arrows, clicks) → checkpoint.
2125    /// - Kill / Element / Replace → always checkpoint (discrete actions).
2126    /// - Same Insert or Delete with consecutive cursor → extend batch (no checkpoint).
2127    /// - Word boundary (ws↔non-ws transition) → checkpoint (handled by callers
2128    ///   resetting `last_kind` before calling this method).
2129    fn pre_mutate(&mut self, kind: MutationKind) {
2130        // Inside an undo group — the group handles its own checkpoint.
2131        if self.undo.group_depth > 0 {
2132            return;
2133        }
2134
2135        let should_push = match self.undo.last_kind {
2136            None => true,
2137            Some(prev) => {
2138                prev != kind
2139                    || self.cursor() != self.undo.last_cursor
2140                    || matches!(
2141                        kind,
2142                        MutationKind::Kill | MutationKind::Element | MutationKind::Replace
2143                    )
2144            }
2145        };
2146
2147        if should_push {
2148            let entry = self.snapshot();
2149            self.undo.stack.push(entry);
2150            if self.undo.stack.len() > self.undo.max_depth {
2151                self.undo.stack.remove(0);
2152            }
2153        }
2154        self.undo.redo.clear();
2155        self.undo.last_kind = Some(kind);
2156    }
2157
2158    /// Update `last_cursor` after a mutation completes so the next `pre_mutate`
2159    /// can detect cursor jumps.
2160    fn post_mutate(&mut self) {
2161        self.undo.last_cursor = self.cursor();
2162    }
2163
2164    /// Clear the undo/redo history, leaving the current text and cursor
2165    /// untouched.
2166    ///
2167    /// Use this when a buffer is reset to represent a *new logical
2168    /// context* — e.g. a shared input widget that is reused for a
2169    /// different target — so that a later `undo` can't resurrect text
2170    /// that belonged to the previous context. `set_text` deliberately
2171    /// records a checkpoint (so an accidental replace is undoable), so
2172    /// callers that want a hard reset must follow it with this.
2173    pub fn clear_history(&mut self) {
2174        self.undo.stack.clear();
2175        self.undo.redo.clear();
2176        self.undo.last_kind = None;
2177        self.undo.last_cursor = self.cursor();
2178    }
2179
2180    /// Undo the last mutation. Returns `true` if there was something to undo.
2181    pub fn undo(&mut self) -> bool {
2182        if let Some(entry) = self.undo.stack.pop() {
2183            self.scroll_override = None;
2184            let current = self.snapshot();
2185            self.undo.redo.push(current);
2186            self.restore(entry);
2187            // Reset batching — next mutation starts a fresh group.
2188            self.undo.last_kind = None;
2189            self.undo.last_cursor = self.cursor();
2190            true
2191        } else {
2192            false
2193        }
2194    }
2195
2196    /// Redo the last undone mutation. Returns `true` if there was something to redo.
2197    pub fn redo(&mut self) -> bool {
2198        if let Some(entry) = self.undo.redo.pop() {
2199            self.scroll_override = None;
2200            let current = self.snapshot();
2201            self.undo.stack.push(current);
2202            self.restore(entry);
2203            // Reset batching — next mutation starts a fresh group.
2204            self.undo.last_kind = None;
2205            self.undo.last_cursor = self.cursor();
2206            true
2207        } else {
2208            false
2209        }
2210    }
2211
2212    pub fn can_undo(&self) -> bool {
2213        !self.undo.stack.is_empty()
2214    }
2215
2216    pub fn can_redo(&self) -> bool {
2217        !self.undo.redo.is_empty()
2218    }
2219
2220    /// Begin an undo group. All mutations between `begin_undo_group()` and
2221    /// `end_undo_group()` are collapsed into a single undo step.
2222    ///
2223    /// Groups can be nested: only the outermost `end_undo_group()` pushes
2224    /// the checkpoint. Inner begin/end pairs are reference-counted.
2225    ///
2226    /// Use cases:
2227    /// - Autocomplete: `replace_range_with_element` + `insert_str(" ")` = 1 undo step
2228    /// - Line-select: enter → N live-updates → confirm = 1 undo step
2229    pub fn begin_undo_group(&mut self) {
2230        if self.undo.group_depth == 0 {
2231            // Outermost group — take the snapshot.
2232            self.undo.group_checkpoint = Some(self.snapshot());
2233        }
2234        self.undo.group_depth += 1;
2235    }
2236
2237    /// End an undo group. If this closes the outermost group and the state
2238    /// actually changed, a single undo entry is pushed.
2239    pub fn end_undo_group(&mut self) {
2240        if self.undo.group_depth == 0 {
2241            return; // Unbalanced call — ignore.
2242        }
2243        self.undo.group_depth -= 1;
2244        if self.undo.group_depth == 0 {
2245            if let Some(checkpoint) = self.undo.group_checkpoint.take() {
2246                // Only push if state actually changed.
2247                let changed = checkpoint.text.as_str() != self.text()
2248                    || checkpoint.cursor != self.cursor()
2249                    || checkpoint.elements.len() != self.elements.len();
2250                if changed {
2251                    self.undo.stack.push(checkpoint);
2252                    if self.undo.stack.len() > self.undo.max_depth {
2253                        self.undo.stack.remove(0);
2254                    }
2255                    self.undo.redo.clear();
2256                }
2257            }
2258            // Reset batching state so the next mutation starts fresh.
2259            self.undo.last_kind = None;
2260            self.undo.last_cursor = self.cursor();
2261        }
2262    }
2263
2264    /// Cancel an undo group. Restores the textarea to the state it was in
2265    /// when `begin_undo_group()` was called — no undo entry is created.
2266    ///
2267    /// Use case: line-select cancel → revert all live-updates, leave no trace.
2268    pub fn cancel_undo_group(&mut self) {
2269        if self.undo.group_depth == 0 {
2270            return; // Unbalanced call — ignore.
2271        }
2272        // Always restore to the outermost checkpoint, regardless of nesting.
2273        self.undo.group_depth = 0;
2274        if let Some(checkpoint) = self.undo.group_checkpoint.take() {
2275            self.restore(checkpoint);
2276        }
2277        // Reset batching state.
2278        self.undo.last_kind = None;
2279        self.undo.last_cursor = self.cursor();
2280    }
2281
2282    // ####### Input Functions #######
2283    pub fn delete_backward(&mut self, n: usize) {
2284        if n == 0 {
2285            return;
2286        }
2287        if n == 1 {
2288            self.apply_edit_command(
2289                EditCommand::DeleteGraphemeBackward,
2290                Some(MutationKind::Delete),
2291            );
2292            return;
2293        }
2294        self.begin_undo_group();
2295        for _ in 0..n {
2296            if matches!(
2297                self.apply_edit_command(
2298                    EditCommand::DeleteGraphemeBackward,
2299                    Some(MutationKind::Delete),
2300                ),
2301                EditOutcome::Unchanged
2302            ) {
2303                break;
2304            }
2305        }
2306        self.end_undo_group();
2307    }
2308
2309    pub fn delete_forward(&mut self, n: usize) {
2310        if n == 0 {
2311            return;
2312        }
2313        if n == 1 {
2314            self.apply_edit_command(
2315                EditCommand::DeleteGraphemeForward,
2316                Some(MutationKind::Delete),
2317            );
2318            return;
2319        }
2320        self.begin_undo_group();
2321        for _ in 0..n {
2322            if matches!(
2323                self.apply_edit_command(
2324                    EditCommand::DeleteGraphemeForward,
2325                    Some(MutationKind::Delete),
2326                ),
2327                EditOutcome::Unchanged
2328            ) {
2329                break;
2330            }
2331        }
2332        self.end_undo_group();
2333    }
2334
2335    pub fn delete_backward_word(&mut self) {
2336        self.apply_edit_command(
2337            EditCommand::DeleteWordBackward(WordStyle::Small),
2338            Some(MutationKind::Kill),
2339        );
2340    }
2341
2342    /// readline `unix-word-rubout` (whitespace-delimited), vs
2343    /// [`Self::delete_backward_word`]'s punctuation-chunked M-DEL semantics.
2344    pub fn delete_backward_unix_word(&mut self) {
2345        self.apply_edit_command(
2346            EditCommand::DeleteWordBackward(WordStyle::WhitespaceDelimited),
2347            Some(MutationKind::Kill),
2348        );
2349    }
2350
2351    /// Delete text to the right of the cursor using readline-style word semantics.
2352    ///
2353    /// Deletes from the current cursor position through the end of the next word as determined
2354    /// by `end_of_next_word()`. Any delimiters between the cursor and that word
2355    /// (whitespace, punctuation, newlines) are included in the deletion.
2356    pub fn delete_forward_word(&mut self) {
2357        self.apply_edit_command(
2358            EditCommand::DeleteWordForward(WordStyle::Small),
2359            Some(MutationKind::Kill),
2360        );
2361    }
2362
2363    pub fn kill_to_end_of_line(&mut self) {
2364        self.apply_edit_command(EditCommand::DeleteToLineEnd, Some(MutationKind::Kill));
2365    }
2366
2367    pub fn kill_to_beginning_of_line(&mut self) {
2368        self.apply_edit_command(EditCommand::DeleteToLineStart, Some(MutationKind::Kill));
2369    }
2370
2371    /// Kill the entire current line (BOL to EOL), regardless of cursor position.
2372    /// If the line is already empty, consumes the preceding newline to join lines.
2373    pub fn kill_current_line(&mut self) {
2374        let bol = self.beginning_of_current_line();
2375        let eol = self.end_of_current_line();
2376
2377        let range = if bol == eol {
2378            if bol > 0 { Some(bol - 1..bol) } else { None }
2379        } else {
2380            Some(bol..eol)
2381        };
2382
2383        if let Some(range) = range {
2384            self.apply_edit_replacement(range, "", Some(MutationKind::Kill));
2385        }
2386    }
2387
2388    pub fn yank(&mut self) {
2389        if self.kill_buffer.is_empty() {
2390            return;
2391        }
2392        let text = self.kill_buffer.clone();
2393        self.apply_edit_replacement(
2394            self.cursor()..self.cursor(),
2395            &text,
2396            Some(MutationKind::Insert),
2397        );
2398        if let Some(last) = text.chars().last() {
2399            self.undo.last_insert_ws = last.is_whitespace();
2400        }
2401    }
2402
2403    /// Move the cursor left by a single grapheme cluster.
2404    pub fn move_cursor_left(&mut self) {
2405        self.apply_edit_command(EditCommand::MoveGraphemeLeft, None);
2406    }
2407
2408    /// Move the cursor right by a single grapheme cluster.
2409    pub fn move_cursor_right(&mut self) {
2410        self.apply_edit_command(EditCommand::MoveGraphemeRight, None);
2411    }
2412
2413    pub fn move_cursor_up(&mut self) {
2414        self.scroll_override = None;
2415        // If we have a wrapping cache, prefer navigating across wrapped (visual) lines.
2416        if let Some((target_col, maybe_line)) = {
2417            let cache_ref = self.wrap_cache.borrow();
2418            if let Some(cache) = cache_ref.as_ref() {
2419                let lines = &cache.lines;
2420                if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor()) {
2421                    let cur_range = &lines[idx];
2422                    let target_col = self.preferred_col.unwrap_or_else(|| {
2423                        self.display_width_of_range(cur_range.start, self.cursor())
2424                    });
2425                    if idx > 0 {
2426                        let prev = &lines[idx - 1];
2427                        let line_start = prev.start;
2428                        let line_end = prev.end;
2429                        Some((target_col, Some((line_start, line_end))))
2430                    } else {
2431                        Some((target_col, None))
2432                    }
2433                } else {
2434                    None
2435                }
2436            } else {
2437                None
2438            }
2439        } {
2440            // We had wrapping info. Apply movement accordingly.
2441            match maybe_line {
2442                Some((line_start, line_end)) => {
2443                    if self.preferred_col.is_none() {
2444                        self.preferred_col = Some(target_col);
2445                    }
2446                    self.move_to_display_col_on_line(line_start, line_end, target_col);
2447                    return;
2448                }
2449                None => {
2450                    // Already at first visual line -> move to start
2451                    self.set_cursor_inner(0);
2452                    self.preferred_col = None;
2453                    return;
2454                }
2455            }
2456        }
2457
2458        // Fallback to logical line navigation if we don't have wrapping info yet.
2459        if let Some(prev_nl) = self.text[..self.cursor()].rfind('\n') {
2460            let target_col = match self.preferred_col {
2461                Some(c) => c,
2462                None => {
2463                    let c = self.current_display_col();
2464                    self.preferred_col = Some(c);
2465                    c
2466                }
2467            };
2468            let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
2469            let prev_line_end = prev_nl;
2470            self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
2471        } else {
2472            self.set_cursor_inner(0);
2473            self.preferred_col = None;
2474        }
2475    }
2476
2477    pub fn move_cursor_down(&mut self) {
2478        self.scroll_override = None;
2479        // If we have a wrapping cache, prefer navigating across wrapped (visual) lines.
2480        if let Some((target_col, move_to_last)) = {
2481            let cache_ref = self.wrap_cache.borrow();
2482            if let Some(cache) = cache_ref.as_ref() {
2483                let lines = &cache.lines;
2484                if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor()) {
2485                    let cur_range = &lines[idx];
2486                    let target_col = self.preferred_col.unwrap_or_else(|| {
2487                        self.display_width_of_range(cur_range.start, self.cursor())
2488                    });
2489                    if idx + 1 < lines.len() {
2490                        let next = &lines[idx + 1];
2491                        let line_start = next.start;
2492                        let line_end = next.end;
2493                        Some((target_col, Some((line_start, line_end))))
2494                    } else {
2495                        Some((target_col, None))
2496                    }
2497                } else {
2498                    None
2499                }
2500            } else {
2501                None
2502            }
2503        } {
2504            match move_to_last {
2505                Some((line_start, line_end)) => {
2506                    if self.preferred_col.is_none() {
2507                        self.preferred_col = Some(target_col);
2508                    }
2509                    self.move_to_display_col_on_line(line_start, line_end, target_col);
2510                    return;
2511                }
2512                None => {
2513                    // Already on last visual line -> move to end
2514                    self.set_cursor_inner(self.text.len());
2515                    self.preferred_col = None;
2516                    return;
2517                }
2518            }
2519        }
2520
2521        // Fallback to logical line navigation if we don't have wrapping info yet.
2522        let target_col = match self.preferred_col {
2523            Some(c) => c,
2524            None => {
2525                let c = self.current_display_col();
2526                self.preferred_col = Some(c);
2527                c
2528            }
2529        };
2530        if let Some(next_nl) = self.text[self.cursor()..]
2531            .find('\n')
2532            .map(|i| i + self.cursor())
2533        {
2534            let next_line_start = next_nl + 1;
2535            let next_line_end = self.text[next_line_start..]
2536                .find('\n')
2537                .map(|i| i + next_line_start)
2538                .unwrap_or(self.text.len());
2539            self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
2540        } else {
2541            self.set_cursor_inner(self.text.len());
2542            self.preferred_col = None;
2543        }
2544    }
2545
2546    /// Home / Super+Left when `move_up_at_bol` is false (visual row if wrapped);
2547    /// Ctrl+A when true (logical line; already-at-BOL chains to previous line).
2548    pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) {
2549        if move_up_at_bol {
2550            self.apply_edit_command(EditCommand::MoveLogicalLineStart, None);
2551            return;
2552        }
2553        if let Some(bol) = self.beginning_of_current_visual_line() {
2554            self.set_cursor(bol);
2555            return;
2556        }
2557
2558        let bol = self.beginning_of_current_line();
2559        self.set_cursor(bol);
2560    }
2561
2562    /// End / Super+Right when `move_down_at_eol` is false (visual row if wrapped);
2563    /// Ctrl+E when true (logical line; already-at-EOL chains to next line).
2564    pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) {
2565        if move_down_at_eol {
2566            self.apply_edit_command(EditCommand::MoveLogicalLineEnd, None);
2567            return;
2568        }
2569        if let Some(eol) = self.end_of_current_visual_line() {
2570            self.set_cursor(eol);
2571            return;
2572        }
2573
2574        let eol = self.end_of_current_line();
2575        self.set_cursor(eol);
2576    }
2577
2578    fn beginning_of_current_visual_line(&self) -> Option<usize> {
2579        let cache = self.wrap_cache.borrow();
2580        let cache = cache.as_ref()?;
2581        let idx = Self::wrapped_line_index_by_start(&cache.lines, self.cursor())?;
2582        Some(cache.lines[idx].start)
2583    }
2584
2585    /// Soft-continued visual rows land on the last char (exclusive end is the
2586    /// next row's start). Final segment of a logical line uses exclusive end.
2587    fn end_of_current_visual_line(&self) -> Option<usize> {
2588        let cache = self.wrap_cache.borrow();
2589        let cache = cache.as_ref()?;
2590        let idx = Self::wrapped_line_index_by_start(&cache.lines, self.cursor())?;
2591        let line = &cache.lines[idx];
2592        let end = line.end.min(self.text.len());
2593        let soft_continued = cache
2594            .lines
2595            .get(idx + 1)
2596            .is_some_and(|next| next.start == end);
2597        if soft_continued && end > line.start {
2598            Some(self.clamp_to_line(end, line.start, end))
2599        } else {
2600            Some(end)
2601        }
2602    }
2603
2604    // ===== Text elements support =====
2605
2606    /// Insert an atomic text element at the current cursor position.
2607    ///
2608    /// The `text` is inserted into the buffer and registered as an element.
2609    /// The `kind` tag is opaque to the textarea (host-defined).
2610    /// The `display` optionally overrides how the element is rendered.
2611    ///
2612    /// Returns the assigned [`ElementId`] so the host can store associated metadata.
2613    pub fn insert_element(
2614        &mut self,
2615        text: &str,
2616        kind: ElementKind,
2617        display: Option<Line<'static>>,
2618    ) -> ElementId {
2619        let plan = self.plan_edit_replacement(self.cursor()..self.cursor(), text);
2620        self.apply_element_transaction(plan, kind, display)
2621    }
2622
2623    /// Replace a range of buffer text with an atomic element.
2624    ///
2625    /// This is the "confirm autocomplete" operation: the trigger text (e.g. `@foo`)
2626    /// is deleted and replaced with element text (e.g. `@src/foo.rs`) in a single
2627    /// atomic operation. The cursor is placed at the end of the new element.
2628    ///
2629    /// Returns the assigned [`ElementId`].
2630    pub fn replace_range_with_element(
2631        &mut self,
2632        range: Range<usize>,
2633        text: &str,
2634        kind: ElementKind,
2635        display: Option<Line<'static>>,
2636    ) -> ElementId {
2637        let plan = self.plan_edit_replacement(range, text);
2638        self.apply_element_transaction(plan, kind, display)
2639    }
2640
2641    fn apply_element_transaction(
2642        &mut self,
2643        plan: EditPlan,
2644        kind: ElementKind,
2645        display: Option<Line<'static>>,
2646    ) -> ElementId {
2647        let start = plan.replaced_byte_range().start;
2648        let inserted_len = plan.replacement().len();
2649        self.assert_valid_edit_plan(&plan);
2650        self.pre_mutate(MutationKind::Element);
2651        self.apply_validated_edit_plan(plan, Some(MutationKind::Element));
2652        let end = start + inserted_len;
2653        let id = self.add_element(start..end, kind, display);
2654        self.set_cursor(end);
2655        self.post_mutate();
2656        id
2657    }
2658
2659    fn add_element(
2660        &mut self,
2661        range: Range<usize>,
2662        kind: ElementKind,
2663        display: Option<Line<'static>>,
2664    ) -> ElementId {
2665        let id = ElementId(self.next_element_id);
2666        self.next_element_id += 1;
2667        let elem = TextElement {
2668            id,
2669            range,
2670            kind,
2671            display,
2672        };
2673        self.elements.push(elem);
2674        self.elements.sort_by_key(|e| e.range.start);
2675        self.wrap_cache.replace(None);
2676        id
2677    }
2678
2679    /// Returns the element at the current cursor position, if any.
2680    ///
2681    /// If the cursor is at an element's start boundary, that element is returned.
2682    /// If the cursor is strictly inside an element (shouldn't happen in normal
2683    /// operation), the containing element is returned.
2684    pub fn element_at_cursor(&self) -> Option<&TextElement> {
2685        self.elements
2686            .iter()
2687            .find(|e| self.cursor() >= e.range.start && self.cursor() < e.range.end)
2688    }
2689
2690    /// Returns the underlying buffer text for the element with the given id.
2691    pub fn element_text(&self, id: ElementId) -> Option<&str> {
2692        self.elements
2693            .iter()
2694            .find(|e| e.id == id)
2695            .map(|e| &self.text[e.range.clone()])
2696    }
2697
2698    /// Update the display for an existing element. Invalidates the wrap cache.
2699    pub fn set_element_display(&mut self, id: ElementId, display: Option<Line<'static>>) {
2700        if let Some(e) = self.elements.iter_mut().find(|e| e.id == id) {
2701            e.display = display;
2702            self.wrap_cache.replace(None);
2703        }
2704    }
2705
2706    /// Returns a slice of all elements, sorted by buffer position.
2707    pub fn elements(&self) -> &[TextElement] {
2708        &self.elements
2709    }
2710
2711    /// Re-register elements after a [`Self::set_text`] call that placed their
2712    /// buffer text back verbatim. Each `(range, kind, display)` tuple
2713    /// describes one element whose text already occupies `range` in the
2714    /// buffer. No text is inserted — this only recreates the element
2715    /// metadata so the textarea renders chips instead of raw text.
2716    pub fn restore_elements(
2717        &mut self,
2718        elems: impl IntoIterator<Item = (Range<usize>, ElementKind, Option<Line<'static>>)>,
2719    ) {
2720        for (range, kind, display) in elems {
2721            self.add_element(range, kind, display);
2722        }
2723        self.wrap_cache.replace(None);
2724    }
2725
2726    /// Inline an element: remove it from the element list so its buffer text
2727    /// becomes plain editable characters. The text content is unchanged.
2728    ///
2729    /// The cursor is placed at the end of the inlined region.
2730    /// This operation is a single undoable step.
2731    ///
2732    /// Returns `true` if the element was found and inlined, `false` otherwise.
2733    pub fn inline_element(&mut self, id: ElementId) -> bool {
2734        let Some(idx) = self.elements.iter().position(|e| e.id == id) else {
2735            return false;
2736        };
2737        let end = self.elements[idx].range.end;
2738
2739        // Snapshot for undo before removing the element.
2740        self.pre_mutate(MutationKind::Element);
2741
2742        self.elements.remove(idx);
2743        self.set_cursor_inner(end);
2744        self.preferred_col = None;
2745        self.wrap_cache.replace(None);
2746        self.undo.last_kind = None; // always discrete
2747
2748        true
2749    }
2750
2751    /// Get the contiguous non-whitespace "word" that the cursor is inside or at the start of.
2752    ///
2753    /// Returns `(byte_range, text)` where `byte_range` is the range in the buffer.
2754    /// Returns `None` if the cursor is on whitespace or the buffer is empty.
2755    ///
2756    /// This is useful for trigger-character detection (e.g. finding `@foo` under the cursor
2757    /// for autocomplete). The host can then check `text.starts_with('@')` etc.
2758    pub fn word_at_cursor(&self) -> Option<(Range<usize>, &str)> {
2759        if self.text.is_empty() {
2760            return None;
2761        }
2762        let pos = self.cursor().min(self.text.len());
2763
2764        // Find word start: scan backward from cursor to find whitespace boundary
2765        let start = self.text[..pos]
2766            .rfind(|c: char| c.is_whitespace())
2767            .map(|i| {
2768                i + self.text[i..]
2769                    .chars()
2770                    .next()
2771                    .map(|c| c.len_utf8())
2772                    .unwrap_or(1)
2773            })
2774            .unwrap_or(0);
2775
2776        // Find word end: scan forward from cursor to find whitespace boundary
2777        let end = self.text[pos..]
2778            .find(|c: char| c.is_whitespace())
2779            .map(|i| i + pos)
2780            .unwrap_or(self.text.len());
2781
2782        // Also extend backward from start in case cursor is at word boundary
2783        // Actually, we also need to handle cursor being between words.
2784        // If cursor is at whitespace, return None.
2785        if start >= end {
2786            return None;
2787        }
2788
2789        // If cursor is beyond the word end (cursor at whitespace after word), return None
2790        // Unless cursor is exactly at start position of the word
2791        let word = &self.text[start..end];
2792        if word.chars().all(|c| c.is_whitespace()) {
2793            return None;
2794        }
2795
2796        Some((start..end, word))
2797    }
2798
2799    fn find_element_containing(&self, pos: usize) -> Option<usize> {
2800        self.elements
2801            .iter()
2802            .position(|e| pos > e.range.start && pos < e.range.end)
2803    }
2804
2805    fn clamp_pos_to_nearest_boundary(&self, mut pos: usize) -> usize {
2806        if pos > self.text.len() {
2807            pos = self.text.len();
2808        }
2809        if let Some(idx) = self.find_element_containing(pos) {
2810            let e = &self.elements[idx];
2811            let dist_start = pos.saturating_sub(e.range.start);
2812            let dist_end = e.range.end.saturating_sub(pos);
2813            if dist_start <= dist_end {
2814                e.range.start
2815            } else {
2816                e.range.end
2817            }
2818        } else {
2819            pos
2820        }
2821    }
2822
2823    fn expand_range_to_element_boundaries(&self, mut range: Range<usize>) -> Range<usize> {
2824        // Expand to include any intersecting elements fully
2825        loop {
2826            let mut changed = false;
2827            for e in &self.elements {
2828                if e.range.start < range.end && e.range.end > range.start {
2829                    let new_start = range.start.min(e.range.start);
2830                    let new_end = range.end.max(e.range.end);
2831                    if new_start != range.start || new_end != range.end {
2832                        range.start = new_start;
2833                        range.end = new_end;
2834                        changed = true;
2835                    }
2836                }
2837            }
2838            if !changed {
2839                break;
2840            }
2841        }
2842        range
2843    }
2844
2845    fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) {
2846        // Generic shift: for pure insert, removed = 0; for delete, inserted = 0.
2847        let end = at + removed;
2848        let diff = inserted as isize - removed as isize;
2849        // Remove elements fully deleted by the operation and shift the rest
2850        self.elements
2851            .retain(|e| !(e.range.start >= at && e.range.end <= end));
2852        for e in &mut self.elements {
2853            if e.range.end <= at {
2854                // before edit
2855            } else if e.range.start >= end {
2856                // after edit
2857                e.range.start = ((e.range.start as isize) + diff) as usize;
2858                e.range.end = ((e.range.end as isize) + diff) as usize;
2859            } else {
2860                // Overlap with element but not fully contained (shouldn't happen when using
2861                // element-aware replace, but degrade gracefully by snapping element to new bounds)
2862                let new_start = at.min(e.range.start);
2863                let new_end = at + inserted.max(e.range.end.saturating_sub(end));
2864                e.range.start = new_start;
2865                e.range.end = new_end;
2866            }
2867        }
2868    }
2869
2870    fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) {
2871        self.shift_elements(start, end.saturating_sub(start), inserted_len);
2872    }
2873
2874    /// Move to the beginning of the previous navigable chunk.
2875    ///
2876    /// Word characters are alphanumeric plus `_`. Punctuation runs (such as
2877    /// `-`) are their own chunk, so moving left across `aa-bb` stops at the
2878    /// right side of `-`, then the left side of `-`, then the start of `aa`.
2879    /// Whitespace is skipped over. Elements remain atomic units.
2880    pub fn beginning_of_previous_word(&self) -> usize {
2881        let ranges = self.element_ranges();
2882        self.text
2883            .plan_command(EditCommand::MoveWordLeft(WordStyle::Small), &ranges)
2884            .cursor_byte()
2885    }
2886
2887    /// Start of the previous whitespace-delimited WORD; elements count as
2888    /// non-whitespace.
2889    pub fn beginning_of_previous_unix_word(&self) -> usize {
2890        let ranges = self.element_ranges();
2891        self.text
2892            .plan_command(
2893                EditCommand::MoveWordLeft(WordStyle::WhitespaceDelimited),
2894                &ranges,
2895            )
2896            .cursor_byte()
2897    }
2898
2899    /// Move to the end of the next navigable chunk.
2900    ///
2901    /// Word characters are alphanumeric plus `_`. Punctuation runs (such as
2902    /// `-`) are their own chunk, so moving right across `aa-bb` stops at the
2903    /// left side of `-`, then the right side of `-`, then the end of `bb`.
2904    /// Whitespace is skipped over. Elements remain atomic units.
2905    pub fn end_of_next_word(&self) -> usize {
2906        let ranges = self.element_ranges();
2907        self.text
2908            .plan_command(EditCommand::MoveWordRight(WordStyle::Small), &ranges)
2909            .cursor_byte()
2910    }
2911
2912    fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize {
2913        if let Some(idx) = self.find_element_containing(pos) {
2914            let e = &self.elements[idx];
2915            if prefer_start {
2916                e.range.start
2917            } else {
2918                e.range.end
2919            }
2920        } else {
2921            pos
2922        }
2923    }
2924
2925    #[expect(clippy::unwrap_used)]
2926    fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec<Range<usize>>> {
2927        // A zero-width terminal must not reach textwrap — it can produce
2928        // borrowed empty slices that don't point into the input buffer,
2929        // causing out-of-bounds panics in wrap_ranges pointer arithmetic.
2930        let width = width.max(1);
2931        // Ensure cache is ready (potentially mutably borrow, then drop)
2932        {
2933            let mut cache = self.wrap_cache.borrow_mut();
2934            let needs_recalc = match cache.as_ref() {
2935                Some(c) => c.width != width,
2936                None => true,
2937            };
2938            if needs_recalc {
2939                let lines = if self.elements.iter().any(|e| e.display.is_some()) {
2940                    self.element_aware_wrap_ranges(width as usize)
2941                } else {
2942                    crate::wrapping::wrap_ranges(
2943                        &self.text,
2944                        Options::new(width as usize)
2945                            .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
2946                    )
2947                };
2948                *cache = Some(WrapCache { width, lines });
2949            }
2950        }
2951
2952        let cache = self.wrap_cache.borrow();
2953        Ref::map(cache, |c| &c.as_ref().unwrap().lines)
2954    }
2955
2956    /// Element-display-aware greedy wrapping.
2957    ///
2958    /// Produces wrap ranges where each range is `start..end` with `end` being
2959    /// the exclusive byte position of the content (including any trailing
2960    /// spaces that belong to this visual line).
2961    ///
2962    /// Elements are treated as atomic units for wrapping: if an element's
2963    /// display width doesn't fit on the current line, the element is moved
2964    /// to a new line (like word-wrap). If it doesn't fit on *any* line
2965    /// (wider than terminal), it gets its own line and rendering truncates it.
2966    fn element_aware_wrap_ranges(&self, width: usize) -> Vec<Range<usize>> {
2967        let width = width.max(1);
2968        let mut result = Vec::new();
2969
2970        // Process each logical line (split by \n), but skip \n inside elements.
2971        // Newlines inside elements are internal to the element and must not create
2972        // visual line breaks — the element's display is a single-line chip.
2973        let mut seg_start = 0;
2974        loop {
2975            let seg_end = self.next_logical_newline(seg_start);
2976
2977            self.greedy_wrap_segment(seg_start, seg_end, width, &mut result);
2978
2979            if seg_end >= self.text.len() {
2980                break;
2981            }
2982            seg_start = seg_end + 1; // skip \n
2983        }
2984
2985        if result.is_empty() {
2986            result.push(0..0);
2987        }
2988
2989        result
2990    }
2991
2992    /// Find the next `\n` at or after `from` that is NOT inside an element.
2993    ///
2994    /// Returns `self.text.len()` if no such newline exists.
2995    fn next_logical_newline(&self, from: usize) -> usize {
2996        let mut pos = from;
2997        while pos < self.text.len() {
2998            // If pos is inside an element, skip past the entire element.
2999            if let Some(elem) = self
3000                .elements
3001                .iter()
3002                .find(|e| pos >= e.range.start && pos < e.range.end)
3003            {
3004                pos = elem.range.end;
3005                continue;
3006            }
3007            if self.text.as_bytes()[pos] == b'\n' {
3008                return pos;
3009            }
3010            pos += 1;
3011        }
3012        self.text.len()
3013    }
3014
3015    /// Greedy-wrap a single logical line (no \n inside `start..end`).
3016    fn greedy_wrap_segment(
3017        &self,
3018        start: usize,
3019        end: usize,
3020        width: usize,
3021        result: &mut Vec<Range<usize>>,
3022    ) {
3023        if start >= end {
3024            // Empty logical line
3025            result.push(start..end);
3026            return;
3027        }
3028
3029        let mut line_start = start;
3030        let mut pos = start;
3031        let mut display_w: usize = 0;
3032        // Position right after the last break opportunity (start of the next word/element).
3033        let mut last_break_pos: Option<usize> = None;
3034
3035        while pos < end {
3036            // Check if pos is at the start of an element
3037            if let Some(elem) = self
3038                .elements
3039                .iter()
3040                .find(|e| pos == e.range.start && e.range.start < e.range.end)
3041            {
3042                let elem_end = elem.range.end.min(end);
3043                let elem_dw: usize = if let Some(display) = &elem.display {
3044                    display
3045                        .spans
3046                        .iter()
3047                        .map(|s| s.content.as_ref().width())
3048                        .sum()
3049                } else {
3050                    self.plain_display_width(&self.text[elem.range.start..elem_end])
3051                };
3052
3053                if display_w > 0 && display_w + elem_dw > width {
3054                    // Element doesn't fit on current line — break before it.
3055                    let break_at = last_break_pos.unwrap_or(pos);
3056                    result.push(line_start..break_at);
3057                    line_start = break_at;
3058                    // Skip leading spaces/tabs on the new line
3059                    while line_start < end
3060                        && line_start < pos
3061                        && matches!(self.text.as_bytes().get(line_start), Some(b' ' | b'\t'))
3062                    {
3063                        line_start += 1;
3064                    }
3065                    pos = line_start;
3066                    display_w = 0;
3067                    last_break_pos = None;
3068                    continue;
3069                }
3070
3071                display_w += elem_dw;
3072                pos = elem_end;
3073                // After element is a break opportunity
3074                last_break_pos = Some(pos);
3075                continue;
3076            }
3077
3078            // Plain text grapheme cluster
3079            let slice = &self.text[pos..end];
3080            let Some(grapheme) = slice.graphemes(true).next() else {
3081                break;
3082            };
3083            let grapheme_width = self.grapheme_display_width(grapheme);
3084
3085            if display_w + grapheme_width > width && display_w > 0 {
3086                // Need to wrap
3087                let break_at = last_break_pos.unwrap_or(pos);
3088                result.push(line_start..break_at);
3089                line_start = break_at;
3090                // Skip leading spaces/tabs on the new line
3091                while line_start < end
3092                    && line_start < pos
3093                    && matches!(self.text.as_bytes().get(line_start), Some(b' ' | b'\t'))
3094                {
3095                    line_start += 1;
3096                }
3097                display_w = self.display_width_of_range(line_start, pos);
3098                last_break_pos = None;
3099                if line_start == pos {
3100                    // No break opportunity found; break at current position (break_words).
3101                    display_w = grapheme_width;
3102                    pos += grapheme.len();
3103                }
3104                continue;
3105            }
3106
3107            if grapheme == " " || grapheme == "\t" {
3108                // Space/tab is a break opportunity; break point is after it.
3109                last_break_pos = Some(pos + grapheme.len());
3110            }
3111
3112            display_w += grapheme_width;
3113            pos += grapheme.len();
3114        }
3115
3116        // Final visual line of this logical line
3117        result.push(line_start..end);
3118    }
3119
3120    /// Calculate the scroll offset that should be used to satisfy the
3121    /// invariants given the current area size and wrapped lines.
3122    ///
3123    /// - Cursor is always on screen.
3124    /// - No scrolling if content fits in the area.
3125    fn effective_scroll(
3126        &self,
3127        area_height: u16,
3128        lines: &[Range<usize>],
3129        current_scroll: u16,
3130    ) -> u16 {
3131        let total_lines = lines.len() as u16;
3132        if area_height >= total_lines {
3133            return 0;
3134        }
3135
3136        let max_scroll = total_lines.saturating_sub(area_height);
3137
3138        // If we have an internal scroll override (from mousewheel), use it
3139        // — but still clamp to valid range.
3140        if let Some(ovr) = self.scroll_override {
3141            return ovr.min(max_scroll);
3142        }
3143
3144        // Where is the cursor within wrapped lines? Prefer assigning boundary positions
3145        // (where pos equals the start of a wrapped line) to that later line.
3146        let cursor_line_idx =
3147            Self::wrapped_line_index_by_start(lines, self.cursor()).unwrap_or(0) as u16;
3148
3149        let mut scroll = current_scroll.min(max_scroll);
3150
3151        // Ensure cursor is visible within [scroll, scroll + area_height)
3152        if cursor_line_idx < scroll {
3153            scroll = cursor_line_idx;
3154        } else if cursor_line_idx >= scroll + area_height {
3155            scroll = cursor_line_idx + 1 - area_height;
3156        }
3157        scroll
3158    }
3159
3160    /// Compute the effective content width for text wrapping, accounting for
3161    /// the scrollbar column.  Uses a 2-shot approach:
3162    ///
3163    /// 1. Wrap at full `area_width` to get line count.
3164    /// 2. If scrollbar needed (lines > height) and `show_scrollbar`, reduce
3165    ///    width by 1 for the scrollbar track.
3166    ///
3167    /// Returns `(content_width, needs_scrollbar)`.
3168    fn content_width(&self, area_width: u16, area_height: u16) -> (u16, bool) {
3169        if !self.show_scrollbar || area_width <= 1 {
3170            return (area_width, false);
3171        }
3172        // First shot — wrap at full width to check if content overflows.
3173        let lines = self.wrapped_lines(area_width);
3174        let needs = lines.len() as u16 > area_height;
3175        if needs {
3176            // 1 for scrollbar track + padding gap
3177            let reserved = 1 + self.scrollbar_padding;
3178            (area_width.saturating_sub(reserved), true)
3179        } else {
3180            (area_width, false)
3181        }
3182    }
3183
3184    /// Convenience: content width for wrapping (area width minus scrollbar if needed).
3185    fn text_width(&self, area: Rect) -> u16 {
3186        self.content_width(area.width, area.height).0
3187    }
3188}
3189
3190impl WidgetRef for &TextArea {
3191    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
3192        let (cw, needs_sb) = self.content_width(area.width, area.height);
3193        let content_area = Rect { width: cw, ..area };
3194        let lines = self.wrapped_lines(cw);
3195        self.render_lines(content_area, buf, &lines, 0..lines.len());
3196        if needs_sb {
3197            self.render_scrollbar(area, buf, lines.len() as u16, area.height, 0);
3198        }
3199    }
3200}
3201
3202impl StatefulWidgetRef for &TextArea {
3203    type State = TextAreaState;
3204
3205    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
3206        let (cw, needs_sb) = self.content_width(area.width, area.height);
3207        let content_area = Rect { width: cw, ..area };
3208        let lines = self.wrapped_lines(cw);
3209        let scroll = self.effective_scroll(area.height, &lines, state.scroll);
3210        state.scroll = scroll;
3211
3212        let start = scroll as usize;
3213        let end = (scroll + area.height).min(lines.len() as u16) as usize;
3214        self.render_lines(content_area, buf, &lines, start..end);
3215        if needs_sb {
3216            self.render_scrollbar(area, buf, lines.len() as u16, area.height, scroll);
3217        }
3218    }
3219}
3220
3221impl TextArea {
3222    /// Render a scrollbar in the rightmost column of `area`.
3223    ///
3224    /// Uses `tui_scrollbar::ScrollBar` rendered into a scratch ratatui-core
3225    /// buffer, then copies cells into the main buffer with muted styling.
3226    fn render_scrollbar(
3227        &self,
3228        area: Rect,
3229        buf: &mut Buffer,
3230        total_lines: u16,
3231        viewport_lines: u16,
3232        offset: u16,
3233    ) {
3234        if total_lines <= viewport_lines || area.width == 0 || area.height == 0 {
3235            return;
3236        }
3237
3238        let sb_area = Rect {
3239            x: area.right().saturating_sub(1),
3240            y: area.y,
3241            width: 1,
3242            height: area.height,
3243        };
3244
3245        let lengths = ScrollLengths {
3246            content_len: total_lines as usize,
3247            viewport_len: viewport_lines as usize,
3248        };
3249        let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
3250
3251        // Render into ratatui-core scratch buffer then copy with styling.
3252        let core_area = CoreRect {
3253            x: sb_area.x,
3254            y: sb_area.y,
3255            width: sb_area.width,
3256            height: sb_area.height,
3257        };
3258        let mut scratch = CoreBuffer::empty(core_area);
3259        (&scrollbar).render(core_area, &mut scratch);
3260
3261        let track_style = self.scrollbar_track_style;
3262        let thumb_style = self.scrollbar_thumb_style;
3263
3264        for row in 0..sb_area.height {
3265            let x = sb_area.x;
3266            let y = sb_area.y + row;
3267            let src = &scratch[(x, y)];
3268            let dst = &mut buf[(x, y)];
3269            let symbol = src.symbol();
3270            dst.set_symbol(symbol);
3271            if symbol == " " {
3272                dst.set_style(track_style);
3273            } else {
3274                dst.set_style(thumb_style);
3275            }
3276        }
3277    }
3278
3279    fn render_lines(
3280        &self,
3281        area: Rect,
3282        buf: &mut Buffer,
3283        lines: &[Range<usize>],
3284        range: std::ops::Range<usize>,
3285    ) {
3286        let area_right = area.x + area.width; // exclusive right boundary
3287        let sel_range = self.selection_range();
3288
3289        for (row, idx) in range.enumerate() {
3290            let r = &lines[idx];
3291            let y = area.y + row as u16;
3292            let line_range = r.start..r.end;
3293
3294            // Render the line segment-by-segment (plain text → element → plain text → …)
3295            // using display-aware x positioning. This ensures that when an element's
3296            // display text is wider (or narrower) than its buffer text, all subsequent
3297            // content is positioned correctly.
3298            let mut display_x: u16 = 0; // current display column
3299            let mut buf_pos = line_range.start; // current position in the buffer
3300
3301            // Collect elements that overlap this visual line, in order.
3302            let overlapping: Vec<&TextElement> = self
3303                .elements
3304                .iter()
3305                .filter(|e| {
3306                    let os = e.range.start.max(line_range.start);
3307                    let oe = e.range.end.min(line_range.end);
3308                    os < oe
3309                })
3310                .collect();
3311
3312            for elem in &overlapping {
3313                let overlap_start = elem.range.start.max(line_range.start);
3314                let overlap_end = elem.range.end.min(line_range.end);
3315
3316                // 1. Render plain text before this element (buf_pos..overlap_start)
3317                if buf_pos < overlap_start && display_x < area.width {
3318                    let plain = &self.text[buf_pos..overlap_start];
3319                    let avail = (area.width - display_x) as usize;
3320                    let (paint, paint_w) = paint_plain_for_display(plain, avail, self.tab_width);
3321                    buf.set_string(area.x + display_x, y, paint.as_ref(), Style::default());
3322                    display_x += paint_w as u16;
3323                }
3324
3325                // 2. Render the element
3326                if display_x >= area.width {
3327                    buf_pos = overlap_end;
3328                    continue;
3329                }
3330
3331                let avail = (area.width - display_x) as usize;
3332
3333                if let Some(display) = &elem.display {
3334                    if overlap_start == elem.range.start {
3335                        // First visual line of the element — render display text.
3336                        let display = truncate_line_display(display, avail);
3337                        for span in &display.spans {
3338                            let content = span.content.as_ref();
3339                            let w = content.width() as u16;
3340                            if display_x >= area.width {
3341                                break;
3342                            }
3343                            buf.set_string(area.x + display_x, y, content, span.style);
3344                            display_x += w;
3345                        }
3346                    }
3347                    // If element spans multiple visual lines but has a display,
3348                    // subsequent lines show nothing for this element region (blank).
3349                    // display_x doesn't advance (already blank in the buffer).
3350                } else {
3351                    // No custom display: render buffer text with default element style.
3352                    let styled = &self.text[overlap_start..overlap_end];
3353                    let style = Style::default().fg(Color::Cyan);
3354                    let (paint, paint_w) = paint_plain_for_display(styled, avail, self.tab_width);
3355                    buf.set_string(area.x + display_x, y, paint.as_ref(), style);
3356                    display_x += paint_w as u16;
3357                }
3358
3359                buf_pos = overlap_end;
3360            }
3361
3362            // 3. Render any remaining plain text after the last element
3363            if buf_pos < line_range.end && display_x < area.width {
3364                let plain = &self.text[buf_pos..line_range.end];
3365                let avail = (area.width - display_x) as usize;
3366                let (paint, paint_w) = paint_plain_for_display(plain, avail, self.tab_width);
3367                buf.set_string(area.x + display_x, y, paint.as_ref(), Style::default());
3368                // Keep display_x consistent with earlier segments (selection uses
3369                // display_width_of_range on a second pass).
3370                let _painted_end = display_x.saturating_add(paint_w as u16);
3371                let _ = _painted_end;
3372            }
3373
3374            // 4. Apply selection highlight (second pass over cells)
3375            if let Some(sel_range) = &sel_range {
3376                // Intersect the selection with this visual line's buffer range.
3377                let line_sel_start = sel_range.start.max(line_range.start);
3378                let line_sel_end = sel_range.end.min(line_range.end);
3379                if line_sel_start < line_sel_end {
3380                    // Compute display column range for the selected portion.
3381                    let col_start =
3382                        self.display_width_of_range(line_range.start, line_sel_start) as u16;
3383                    let col_end =
3384                        self.display_width_of_range(line_range.start, line_sel_end) as u16;
3385                    let col_start = col_start.min(area.width);
3386                    let col_end = col_end.min(area.width);
3387                    for cx in col_start..col_end {
3388                        let cell = &mut buf[(area.x + cx, y)];
3389                        cell.set_style(self.selection_style);
3390                    }
3391                }
3392            }
3393
3394            let _ = area_right; // suppress unused warning (used for documentation)
3395        }
3396    }
3397}
3398
3399/// Expand `\t` to a fixed number of spaces (`tab_width`), matching scrollback.
3400fn expand_tabs_with_width(text: &str, tab_width: u8) -> std::borrow::Cow<'_, str> {
3401    if tab_width == 0 || !text.contains('\t') {
3402        return std::borrow::Cow::Borrowed(text);
3403    }
3404    std::borrow::Cow::Owned(text.replace('\t', &" ".repeat(tab_width as usize)))
3405}
3406
3407fn grapheme_display_width_with_tab(grapheme: &str, tab_width: u8) -> usize {
3408    if grapheme == "\t" {
3409        if tab_width == 0 {
3410            0
3411        } else {
3412            tab_width as usize
3413        }
3414    } else {
3415        grapheme.width()
3416    }
3417}
3418
3419fn plain_display_width_with_tab(text: &str, tab_width: u8) -> usize {
3420    if tab_width == 0 || !text.contains('\t') {
3421        return text.width();
3422    }
3423    text.graphemes(true)
3424        .map(|g| grapheme_display_width_with_tab(g, tab_width))
3425        .sum()
3426}
3427
3428/// Clip a string to fit within `max_width` display columns (tabs = 0 width).
3429/// Returns a substring that is at most `max_width` columns wide.
3430fn clip_str_to_display_width(s: &str, max_width: usize) -> &str {
3431    clip_str_to_display_width_with_tab(s, max_width, 0)
3432}
3433
3434/// Clip considering tabs as `tab_width` columns (byte index into original `s`).
3435fn clip_str_to_display_width_with_tab(s: &str, max_width: usize, tab_width: u8) -> &str {
3436    let mut width = 0;
3437    for (i, grapheme) in s.grapheme_indices(true) {
3438        let grapheme_width = grapheme_display_width_with_tab(grapheme, tab_width);
3439        if width + grapheme_width > max_width {
3440            return &s[..i];
3441        }
3442        width += grapheme_width;
3443    }
3444    s
3445}
3446
3447/// Clip and expand tabs so paint width matches cursor/display-width math.
3448/// Returns (paint string, display columns used). Borrows when no expansion needed.
3449fn paint_plain_for_display(
3450    s: &str,
3451    max_width: usize,
3452    tab_width: u8,
3453) -> (std::borrow::Cow<'_, str>, usize) {
3454    let clipped = clip_str_to_display_width_with_tab(s, max_width, tab_width);
3455    let paint = expand_tabs_with_width(clipped, tab_width);
3456    let w = plain_display_width_with_tab(clipped, tab_width);
3457    (paint, w)
3458}
3459
3460/// Truncate a display `Line` to fit within `max_width` columns.
3461///
3462/// If the line fits, it is returned as-is (cloned). If it overflows:
3463/// - Reserve 1 column for `…`.
3464/// - **Bracket-preservation heuristic:** if the display text ends with a closing
3465///   bracket (`]`, `)`, `}`, `>`), preserve it so e.g. `[Pasted ~10 lines]`
3466///   becomes `[Pasted ~1…]` rather than `[Pasted ~10…`.
3467/// - Otherwise, truncate and append `…`.
3468fn truncate_line_display(line: &Line<'static>, max_width: usize) -> Line<'static> {
3469    use ratatui::text::Span;
3470
3471    let total_width: usize = line.spans.iter().map(|s| s.content.as_ref().width()).sum();
3472    if total_width <= max_width {
3473        return line.clone();
3474    }
3475    if max_width == 0 {
3476        return Line::default();
3477    }
3478
3479    // Determine if we should preserve a closing bracket.
3480    let last_char = line
3481        .spans
3482        .iter()
3483        .rev()
3484        .find_map(|s| s.content.as_ref().chars().last());
3485    let (preserve_bracket, bracket_char, bracket_style) = match last_char {
3486        Some(ch @ (']' | ')' | '}' | '>')) => {
3487            // Find the style of the last span containing this char.
3488            let style = line.spans.last().map(|s| s.style).unwrap_or_default();
3489            (true, Some(ch), style)
3490        }
3491        _ => (false, None, Style::default()),
3492    };
3493
3494    // Budget: max_width minus 1 for '…', minus 1 for bracket if preserving.
3495    // If max_width is too small for both ellipsis and bracket, skip bracket.
3496    let preserve_bracket = preserve_bracket && max_width >= 3;
3497    let content_budget = if preserve_bracket {
3498        max_width.saturating_sub(2) // 1 for …, 1 for bracket
3499    } else {
3500        max_width.saturating_sub(1) // 1 for …
3501    };
3502
3503    let mut new_spans: Vec<Span<'static>> = Vec::new();
3504    let mut used = 0usize;
3505
3506    for span in &line.spans {
3507        let content = span.content.as_ref();
3508        let sw = content.width();
3509        if used + sw <= content_budget {
3510            new_spans.push(span.clone());
3511            used += sw;
3512        } else {
3513            // Partially include this span without splitting a grapheme cluster.
3514            let remaining = content_budget - used;
3515            if remaining > 0 {
3516                let partial = clip_str_to_display_width(content, remaining);
3517                if !partial.is_empty() {
3518                    new_spans.push(Span::styled(partial.to_string(), span.style));
3519                }
3520            }
3521            break;
3522        }
3523    }
3524
3525    // Append ellipsis (inherits style of last content span, or default).
3526    let ellipsis_style = new_spans.last().map(|s| s.style).unwrap_or_default();
3527    new_spans.push(Span::styled("…", ellipsis_style));
3528
3529    // Append preserved bracket if applicable.
3530    if preserve_bracket && let Some(ch) = bracket_char {
3531        new_spans.push(Span::styled(ch.to_string(), bracket_style));
3532    }
3533
3534    Line::from(new_spans)
3535}
3536
3537#[cfg(test)]
3538#[path = "textarea_tests.rs"]
3539mod tests;