// Foundation work for #1116/#1175 — see the equivalent header note in
// composer/key_hint.rs for the full rationale. The blanket allow goes away
// when PR 2 wires up the consumers (the chat_composer.rs swap from
// ratatui-textarea to composer::textarea + the popup widgets that orbit it).

//! # Provenance
//!
//! Ported from `codex-rs/tui/src/bottom_pane/textarea.rs` at upstream
//! commit `87d2235b5470d8969b51d80278a46aa30703eeab` (codex `main` as
//! of 2026-05-06).
//!
//! ## Vendor-sync skips
//!
//! The vendor-sync workflow reads `skip <SHA>` lines below and excludes
//! those upstream commits from drift reports. Each entry must justify
//! WHY koda intentionally does not port the commit. To unskip, delete
//! the line and re-port the commit.
//!
//! - skip 48402be6fa: feat(tui): improve TUI keymap coverage (#20798) —
//!   2/3 of this commit (new `kill_whole_line`/`fast_mode` keymap actions,
//!   `/keymap` picker entries) depends on infrastructure koda doesn't have
//!   (`/keymap` slash command + `codex-config` crate integration). The
//!   third part (C0 control-char normalization) is entangled with the
//!   feature additions; cherry-picking it cleanly isn't worth the diff.
//!   Re-evaluate if koda adds a `/keymap` UI.
//!
//! Original work: Copyright (c) OpenAI / codex contributors,
//! licensed under the Apache License, Version 2.0.
//! See `LICENSES/codex-APACHE-2.0` (vendored at the workspace root) for
//! the full license text.
//!
//! # Adaptations for koda
//!
//! This is the HEAD-aligned re-port (Stage 5d of #1178). Compared to
//! the upstream file at `codex-rs/tui/src/bottom_pane/textarea.rs`:
//!
//! ## Module-path swaps (we vendor into `composer/` not `tui/`)
//!
//! - `use crate::key_hint::*`           → `use super::key_hint::*`
//!   (our verbatim vendor at `composer/key_hint.rs`)
//! - `use crate::keymap::{EditorKeymap, RuntimeKeymap, VimNormalKeymap,
//!    VimOperatorKeymap}` → `use super::keymap::*`
//!   (our koda-OWNED policy layer at `composer/keymap.rs`; same
//!   struct/field shape, no config integration)
//! - `use codex_protocol::user_input::{ByteRange, TextElement}`
//!   → `use super::text_element::*` (our reduced port at
//!   `composer/text_element.rs`; same data types, no protocol
//!   serialization derives)
//! - `crate::wrapping::wrap_ranges` → `super::wrapping::wrap_ranges`
//!   (our reduced port at `composer/wrapping.rs`; same 2 helpers,
//!   none of the URL-aware adaptive wrapping)
//!
//! ## ratatui 0.30 API drift (codex is on ratatui 0.29)
//!
//! - `WidgetRef` (unstable in 0.26-0.29) was removed in ratatui 0.30.
//!   Migrated to `Widget` implemented for `&TextArea` (the modern
//!   pattern; same call-site semantics).
//! - `StatefulWidgetRef` → `StatefulWidget` for `&TextArea` (same
//!   story).
//! - Method signature `fn render_ref(&self, ...)` → `fn render(self, ...)`.
//!   Inside the body `self` is `&TextArea` (the impl is on the
//!   reference type), so semantics are identical.
//! - All test-code `ratatui::widgets::StatefulWidgetRef::render_ref`
//!   call sites updated to `ratatui::widgets::StatefulWidget::render`,
//!   ditto `WidgetRef::render_ref` → `Widget::render`.
//!
//! ## Test-suite gating
//!
//! Codex's `fuzz_textarea_randomized` property test uses `rand` +
//! `chrono` to drive thousands of randomized edits. Adding both as
//! dev-deps for one test isn't worth it; the deterministic test suite
//! covers all the explicit behaviors. The fuzz test is preserved
//! verbatim under `#[cfg(any())]` (always-false gate) along with the
//! `rand_grapheme` helper and `use rand::prelude::*;`. Reviving is a
//! one-line cfg swap once we add the dev-deps.
//!
//! ## What's NOT adapted
//!
//! Everything else — the editor logic, kill-buffer semantics, vim
//! mode, soft-wrap, cursor motion, element handling, paste burst
//! integration — is preserved verbatim from codex. Future codex syncs
//! against this file should be a 3-way merge anchored at SHA d55479
//! with our adaptations re-applied.
//!
//! # Original module documentation follows
//!
//! ---
//!
//! The textarea owns editable composer text, placeholder elements, cursor/wrap state, and a
//! single-entry kill buffer.
//!
//! Whole-buffer replacement APIs intentionally rebuild only the visible draft state. They clear
//! element ranges and derived cursor/wrapping caches, but they keep the kill buffer intact so a
//! caller can clear or rewrite the draft and still allow `Ctrl+Y` to restore the user's most
//! recent `Ctrl+K`. This is the contract higher-level composer flows rely on after submit,
//! slash-command dispatch, and other synthetic clears.
//!
//! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most
//! recent killed span.

// PR 2 of #1178 swapped the consumers (chat handlers + viewport) to use this
// module. PR 3 wired up vim-mode (`set_vim_enabled`, `is_vim_enabled`,
// `vim_mode_label`, `should_handle_vim_insert_escape`) via the `/vim` slash
// command + status-bar pill + Esc routing. PR 5 wired up named elements
// (`insert_element`, element-aware rendering, atomic backspace) via the
// @-mention completion path in `tui_context::events::handle_key`. PR 6
// wired up masked render (`render_ref_masked` + `TextAreaState`) via the
// `PromptMode::WizardInput { mask: true }` branch in `tui_viewport.rs`,
// used by the API-key entry wizard.
//
// Still unused (will go away as each follow-up PR wires them up):
//   - paste-burst integration glue (PR 3 scope was deferred — mac/linux dev
//     focus, niche value for legacy Windows ConHost only)
//   - id-based named-element APIs (`insert_named_element`,
//     `replace_element_by_id`) — PR 5 used the simpler `insert_element`
//     because koda's @-mention flow doesn't need stable IDs for later
//     replacement (cycling Tab clears + reinserts via
//     `set_text_clearing_elements`)
//   - render_ref_styled / render_ref_styled_with_highlights — future
//     syntax-highlighted composer scope (PR 7+)
//   - several vim_normal_keymap fields (operator-pending edge cases not yet
//     exercised by `input(key)` dispatch in koda's call sites)
#![allow(dead_code)]

use super::key_hint::KeyBindingListExt;
use super::key_hint::is_altgr;
use super::keymap::EditorKeymap;
use super::keymap::RuntimeKeymap;
use super::keymap::VimNormalKeymap;
use super::keymap::VimOperatorKeymap;
use super::text_element::ByteRange;
use super::text_element::TextElement as UserTextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::StatefulWidget;
use ratatui::widgets::Widget;
use std::cell::Ref;
use std::cell::RefCell;
use std::ops::Range;
use textwrap::Options;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";

fn is_word_separator(ch: char) -> bool {
    WORD_SEPARATORS.contains(ch)
}

fn split_word_pieces(run: &str) -> Vec<(usize, &str)> {
    let mut pieces = Vec::new();
    for (segment_start, segment) in run.split_word_bound_indices() {
        let mut piece_start = 0;
        let mut chars = segment.char_indices();
        let Some((_, first_char)) = chars.next() else {
            continue;
        };
        let mut in_separator = is_word_separator(first_char);

        for (idx, ch) in chars {
            let is_separator = is_word_separator(ch);
            if is_separator == in_separator {
                continue;
            }
            pieces.push((segment_start + piece_start, &segment[piece_start..idx]));
            piece_start = idx;
            in_separator = is_separator;
        }

        pieces.push((segment_start + piece_start, &segment[piece_start..]));
    }

    pieces
}

#[derive(Debug, Clone)]
struct TextElement {
    id: u64,
    range: Range<usize>,
    name: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TextElementSnapshot {
    pub(crate) id: u64,
    pub(crate) range: Range<usize>,
    pub(crate) text: String,
}

/// `TextArea` is the editable buffer behind the TUI composer.
///
/// It owns the raw UTF-8 text, placeholder-like text elements that must move atomically with
/// edits, cursor/wrapping state for rendering, and a single-entry kill buffer for `Ctrl+K` /
/// `Ctrl+Y` style editing. Callers may replace the entire visible buffer through
/// [`Self::set_text_clearing_elements`] or [`Self::set_text_with_elements`] without disturbing the
/// kill buffer; if they incorrectly assume those methods fully reset editing state, a later yank
/// will appear to restore stale text from the user's perspective.
#[derive(Debug)]
pub(crate) struct TextArea {
    text: String,
    cursor_pos: usize,
    wrap_cache: RefCell<Option<WrapCache>>,
    preferred_col: Option<usize>,
    elements: Vec<TextElement>,
    next_element_id: u64,
    kill_buffer: String,
    kill_buffer_kind: KillBufferKind,
    vim_enabled: bool,
    vim_mode: VimMode,
    vim_operator: Option<VimOperator>,
    editor_keymap: EditorKeymap,
    vim_normal_keymap: VimNormalKeymap,
    vim_operator_keymap: VimOperatorKeymap,
}

#[derive(Debug, Clone)]
struct WrapCache {
    width: u16,
    lines: Vec<Range<usize>>,
}

#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct TextAreaState {
    /// Index into wrapped lines of the first visible line.
    scroll: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimMode {
    /// Normal mode routes printable keys to movement, operators, and mode transitions.
    Normal,
    /// Insert mode routes input through the regular editor keymap until Escape is pressed.
    Insert,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KillBufferKind {
    /// Characterwise kills and yanks paste at the cursor.
    Characterwise,
    /// Linewise kills and yanks paste as whole lines below the cursor line.
    Linewise,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimOperator {
    /// Delete the range selected by the next motion or repeated operator key.
    Delete,
    /// Copy the range selected by the next motion or repeated operator key.
    Yank,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimMotion {
    /// Move one atomic boundary to the left.
    Left,
    /// Move one atomic boundary to the right.
    Right,
    /// Move one visual row up, preserving preferred display column.
    Up,
    /// Move one visual row down, preserving preferred display column.
    Down,
    /// Move to the start of the next word-like run.
    WordForward,
    /// Move to the start of the previous word-like run.
    WordBackward,
    /// Move to the end of the current or next word-like run.
    WordEnd,
    /// Move to the start of the current line.
    LineStart,
    /// Move to the end of the current line.
    LineEnd,
}

impl TextArea {
    pub fn new() -> Self {
        let defaults = RuntimeKeymap::defaults();
        Self {
            text: String::new(),
            cursor_pos: 0,
            wrap_cache: RefCell::new(None),
            preferred_col: None,
            elements: Vec::new(),
            next_element_id: 1,
            kill_buffer: String::new(),
            kill_buffer_kind: KillBufferKind::Characterwise,
            vim_enabled: false,
            vim_mode: VimMode::Insert,
            vim_operator: None,
            editor_keymap: defaults.editor,
            vim_normal_keymap: defaults.vim_normal,
            vim_operator_keymap: defaults.vim_operator,
        }
    }

    /// Replace the editor and Vim keymaps used by subsequent text-editing input.
    ///
    /// This method intentionally swaps only the keymap caches. It does not
    /// reinterpret pending input, change Vim mode, move the cursor, or mutate
    /// the kill buffer, so callers can safely apply a live config update while
    /// preserving the current draft exactly as typed.
    pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
        self.editor_keymap = keymap.editor.clone();
        self.vim_normal_keymap = keymap.vim_normal.clone();
        self.vim_operator_keymap = keymap.vim_operator.clone();
    }

    /// Replace the visible textarea text and clear any existing text elements.
    ///
    /// This is the "fresh buffer" path for callers that want plain text with no placeholder
    /// ranges. It intentionally preserves the current kill buffer, because higher-level flows such
    /// as submit or slash-command dispatch clear the draft through this method and still want
    /// `Ctrl+Y` to recover the user's most recent kill.
    pub fn set_text_clearing_elements(&mut self, text: &str) {
        self.set_text_inner(text, /*elements*/ None);
    }

    /// Replace the visible textarea text and rebuild the provided text elements.
    ///
    /// As with [`Self::set_text_clearing_elements`], this resets only state derived from the
    /// visible buffer. The kill buffer survives so callers restoring drafts or external edits do
    /// not silently discard a pending yank target.
    pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) {
        self.set_text_inner(text, Some(elements));
    }

    fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) {
        // Stage 1: replace the raw text and keep the cursor in a safe byte range.
        self.text = text.to_string();
        self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
        // Stage 2: rebuild element ranges from scratch against the new text.
        self.elements.clear();
        if let Some(elements) = elements {
            for elem in elements {
                let mut start = elem.byte_range.start.min(self.text.len());
                let mut end = elem.byte_range.end.min(self.text.len());
                start = self.clamp_pos_to_char_boundary(start);
                end = self.clamp_pos_to_char_boundary(end);
                if start >= end {
                    continue;
                }
                let id = self.next_element_id();
                self.elements.push(TextElement {
                    id,
                    range: start..end,
                    name: None,
                });
            }
            self.elements.sort_by_key(|e| e.range.start);
        }
        // Stage 3: clamp the cursor and reset derived state tied to the prior content.
        // The kill buffer is editing history rather than visible-buffer state, so full-buffer
        // replacements intentionally leave it alone.
        self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
        self.wrap_cache.replace(None);
        self.preferred_col = None;
    }

    /// Enable or disable modal Vim editing for the textarea.
    ///
    /// Enabling always enters normal mode and disabling always returns to
    /// insert semantics. Pending operators are cleared in both directions so a
    /// toggle cannot leave the next keypress interpreted as the second half of
    /// an old `d` or `y` command.
    pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
        self.vim_enabled = enabled;
        self.vim_operator = None;
        self.vim_mode = if enabled {
            VimMode::Normal
        } else {
            VimMode::Insert
        };
    }

    /// Return whether modal Vim editing is currently enabled.
    pub(crate) fn is_vim_enabled(&self) -> bool {
        self.vim_enabled
    }

    /// Return whether Vim mode is enabled and currently waiting in normal mode.
    ///
    /// Composer-level handlers use this to decide whether Up/Down should be
    /// offered to history navigation only after normal-mode movement reaches a
    /// text boundary.
    pub(crate) fn is_vim_normal_mode(&self) -> bool {
        self.vim_enabled && self.vim_mode == VimMode::Normal
    }

    /// Return the cursor position that represents the last editable item in Vim normal mode.
    pub(crate) fn vim_normal_end_cursor(&self) -> usize {
        if self.text.is_empty() {
            0
        } else {
            self.prev_atomic_boundary(self.text.len())
        }
    }

    /// Return whether a Vim operator is waiting for a motion.
    ///
    /// This is observable so the composer can avoid stealing the second key of
    /// `d{motion}` or `y{motion}` for higher-level shortcuts.
    pub(crate) fn is_vim_operator_pending(&self) -> bool {
        self.vim_operator.is_some()
    }

    /// Enter Vim insert mode if modal editing is enabled.
    ///
    /// Calling this while Vim is disabled is a no-op, which lets parent
    /// workflows reset mode after submissions without first branching on the
    /// current keymap state.
    pub(crate) fn enter_vim_insert_mode(&mut self) {
        if self.vim_enabled {
            self.vim_mode = VimMode::Insert;
            self.vim_operator = None;
        }
    }

    /// Enter Vim normal mode if modal editing is enabled.
    ///
    /// This clears any pending operator and preferred vertical column. The
    /// latter matches normal Vim navigation expectations after leaving insert
    /// mode; preserving the old column would make the next `j` or `k` jump to a
    /// stale visual target.
    pub(crate) fn enter_vim_normal_mode(&mut self) {
        if self.vim_enabled {
            self.vim_mode = VimMode::Normal;
            self.vim_operator = None;
            self.preferred_col = None;
        }
    }

    /// Return whether rapid plain-key bursts should be treated as paste input.
    ///
    /// Paste burst detection is disabled in Vim normal mode so a fast sequence
    /// like `dd` or `yw` remains command input instead of being converted into
    /// literal text.
    pub(crate) fn allows_paste_burst(&self) -> bool {
        !self.vim_enabled || self.vim_mode == VimMode::Insert
    }

    /// Return whether rendering should use the insert-mode cursor style.
    pub(crate) fn uses_vim_insert_cursor(&self) -> bool {
        self.vim_enabled && self.vim_mode == VimMode::Insert
    }

    /// Return whether Escape should be intercepted before composer-level routing.
    ///
    /// In Vim insert mode, Escape is an editing transition rather than a popup
    /// cancel/backtrack shortcut. Letting the composer handle it first would
    /// close UI surfaces while leaving the textarea in insert mode.
    pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool {
        self.vim_enabled
            && self.vim_mode == VimMode::Insert
            && event.code == KeyCode::Esc
            && event.modifiers == KeyModifiers::NONE
            && matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
    }

    /// Return the footer label for the active Vim mode.
    ///
    /// `None` means Vim editing is disabled, so callers should omit the mode
    /// indicator rather than rendering an insert-mode label for normal
    /// non-modal editing.
    pub(crate) fn vim_mode_label(&self) -> Option<&'static str> {
        if !self.vim_enabled {
            return None;
        }
        Some(match self.vim_mode {
            VimMode::Normal => "Normal",
            VimMode::Insert => "Insert",
        })
    }

    pub fn text(&self) -> &str {
        &self.text
    }

    pub fn insert_str(&mut self, text: &str) {
        self.insert_str_at(self.cursor_pos, text);
    }

    pub fn insert_str_at(&mut self, pos: usize, text: &str) {
        let pos = self.clamp_pos_for_insertion(pos);
        self.text.insert_str(pos, text);
        self.wrap_cache.replace(None);
        if pos <= self.cursor_pos {
            self.cursor_pos += text.len();
        }
        self.shift_elements(pos, /*removed*/ 0, text.len());
        self.preferred_col = None;
    }

    pub fn replace_range(&mut self, range: std::ops::Range<usize>, text: &str) {
        let range = self.expand_range_to_element_boundaries(range);
        self.replace_range_raw(range, text);
    }

    fn replace_range_raw(&mut self, range: std::ops::Range<usize>, text: &str) {
        assert!(range.start <= range.end);
        let start = range.start.clamp(0, self.text.len());
        let end = range.end.clamp(0, self.text.len());
        let removed_len = end - start;
        let inserted_len = text.len();
        if removed_len == 0 && inserted_len == 0 {
            return;
        }
        let diff = inserted_len as isize - removed_len as isize;

        self.text.replace_range(range, text);
        self.wrap_cache.replace(None);
        self.preferred_col = None;
        self.update_elements_after_replace(start, end, inserted_len);

        // Update the cursor position to account for the edit.
        self.cursor_pos = if self.cursor_pos < start {
            // Cursor was before the edited range – no shift.
            self.cursor_pos
        } else if self.cursor_pos <= end {
            // Cursor was inside the replaced range – move to end of the new text.
            start + inserted_len
        } else {
            // Cursor was after the replaced range – shift by the length diff.
            ((self.cursor_pos as isize) + diff) as usize
        }
        .min(self.text.len());

        // Ensure cursor is not inside an element
        self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
    }

    pub fn cursor(&self) -> usize {
        self.cursor_pos
    }

    pub fn set_cursor(&mut self, pos: usize) {
        self.cursor_pos = pos.clamp(0, self.text.len());
        self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
        self.preferred_col = None;
    }

    pub fn desired_height(&self, width: u16) -> u16 {
        self.wrapped_lines(width).len() as u16
    }

    /// Compute the on-screen cursor position assuming default scroll state.
    ///
    /// Used by the renderer (`tui_viewport::draw_viewport`) to park the
    /// native terminal cursor at the input caret via
    /// `Frame::set_cursor_position`. Without that call the cursor is
    /// invisible, CJK/IME preedit composes at the screen origin, and
    /// screen readers can't follow the input (#1220).
    pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
        self.cursor_pos_with_state(area, TextAreaState::default())
    }

    /// Compute the on-screen cursor position taking scrolling into account.
    pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> {
        let lines = self.wrapped_lines(area.width);
        let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
        let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?;
        let ls = &lines[i];
        let col = self.text[ls.start..self.cursor_pos].width() as u16;
        let screen_row = i
            .saturating_sub(effective_scroll as usize)
            .try_into()
            .unwrap_or(0);
        Some((area.x + col, area.y + screen_row))
    }

    pub fn is_empty(&self) -> bool {
        self.text.is_empty()
    }

    fn current_display_col(&self) -> usize {
        let bol = self.beginning_of_current_line();
        self.text[bol..self.cursor_pos].width()
    }

    fn wrapped_line_index_by_start(lines: &[Range<usize>], pos: usize) -> Option<usize> {
        // partition_point returns the index of the first element for which
        // the predicate is false, i.e. the count of elements with start <= pos.
        let idx = lines.partition_point(|r| r.start <= pos);
        if idx == 0 { None } else { Some(idx - 1) }
    }

    fn move_to_display_col_on_line(
        &mut self,
        line_start: usize,
        line_end: usize,
        target_col: usize,
    ) {
        let mut width_so_far = 0usize;
        for (i, g) in self.text[line_start..line_end].grapheme_indices(true) {
            width_so_far += g.width();
            if width_so_far > target_col {
                self.cursor_pos = line_start + i;
                // Avoid landing inside an element; round to nearest boundary
                self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
                return;
            }
        }
        self.cursor_pos = line_end;
        self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
    }

    fn beginning_of_line(&self, pos: usize) -> usize {
        self.text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0)
    }
    fn beginning_of_current_line(&self) -> usize {
        self.beginning_of_line(self.cursor_pos)
    }

    fn first_non_blank_of_current_line(&self) -> usize {
        let bol = self.beginning_of_current_line();
        let eol = self.end_of_current_line();
        self.text[bol..eol]
            .char_indices()
            .find_map(|(offset, ch)| (!ch.is_whitespace()).then_some(bol + offset))
            .unwrap_or(eol)
    }

    fn end_of_line(&self, pos: usize) -> usize {
        self.text[pos..]
            .find('\n')
            .map(|i| i + pos)
            .unwrap_or(self.text.len())
    }
    fn end_of_current_line(&self) -> usize {
        self.end_of_line(self.cursor_pos)
    }

    pub fn input(&mut self, event: KeyEvent) {
        // Only process key presses or repeats; ignore releases to avoid inserting
        // characters on key-up events when modifiers are no longer reported.
        if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
            return;
        }
        if self.vim_enabled {
            self.handle_vim_input(event);
        } else {
            let keymap = self.editor_keymap.clone();
            self.input_with_keymap(event, &keymap);
        }
    }

    pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
        if keymap.insert_newline.is_pressed(event) {
            self.insert_str("\n");
            return;
        }

        if keymap.delete_backward_word.is_pressed(event) {
            self.delete_backward_word();
            return;
        }

        // Windows AltGr generates ALT|CONTROL. Preserve typed characters for AltGr users
        // unless a specific shortcut already matched above.
        if let KeyEvent {
            code: KeyCode::Char(c),
            modifiers,
            ..
        } = event
            && is_altgr(modifiers)
        {
            self.insert_str(&c.to_string());
            return;
        }

        if keymap.delete_backward.is_pressed(event) {
            self.delete_backward(/*n*/ 1);
            return;
        }
        if keymap.delete_forward_word.is_pressed(event) {
            self.delete_forward_word();
            return;
        }
        if keymap.delete_forward.is_pressed(event) {
            self.delete_forward(/*n*/ 1);
            return;
        }
        if keymap.kill_line_start.is_pressed(event) {
            self.kill_to_beginning_of_line();
            return;
        }
        if keymap.kill_line_end.is_pressed(event) {
            self.kill_to_end_of_line();
            return;
        }
        if keymap.yank.is_pressed(event) {
            self.yank();
            return;
        }
        if keymap.move_word_left.is_pressed(event) {
            self.set_cursor(self.beginning_of_previous_word());
            return;
        }
        if keymap.move_word_right.is_pressed(event) {
            self.set_cursor(self.end_of_next_word());
            return;
        }
        if keymap.move_left.is_pressed(event) {
            self.move_cursor_left();
            return;
        }
        if keymap.move_right.is_pressed(event) {
            self.move_cursor_right();
            return;
        }
        if keymap.move_up.is_pressed(event) {
            self.move_cursor_up();
            return;
        }
        if keymap.move_down.is_pressed(event) {
            self.move_cursor_down();
            return;
        }
        if keymap.move_line_start.is_pressed(event) {
            let move_up_at_bol = matches!(
                event,
                KeyEvent {
                    code: KeyCode::Char('a'),
                    modifiers: KeyModifiers::CONTROL,
                    ..
                }
            );
            self.move_cursor_to_beginning_of_line(move_up_at_bol);
            return;
        }
        if keymap.move_line_end.is_pressed(event) {
            let move_down_at_eol = matches!(
                event,
                KeyEvent {
                    code: KeyCode::Char('e'),
                    modifiers: KeyModifiers::CONTROL,
                    ..
                }
            );
            self.move_cursor_to_end_of_line(move_down_at_eol);
            return;
        }

        if let KeyEvent {
            code: KeyCode::Char(c),
            modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
            ..
        } = event
        {
            // Insert plain characters (and Shift-modified). Do not insert when ALT is held,
            // because many terminals map Option/Meta combos to ALT+<char>.
            if c.is_ascii_control() {
                return;
            }
            self.insert_str(&c.to_string());
        }

        tracing::debug!("Unhandled key event in TextArea: {:?}", event);
    }

    fn handle_vim_input(&mut self, event: KeyEvent) {
        match self.vim_mode {
            VimMode::Insert => self.handle_vim_insert(event),
            VimMode::Normal => self.handle_vim_normal(event),
        }
    }

    fn handle_vim_insert(&mut self, event: KeyEvent) {
        if matches!(event.code, KeyCode::Esc) {
            let bol = self.beginning_of_current_line();
            if self.cursor_pos > bol {
                self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol);
            }
            self.enter_vim_normal_mode();
            return;
        }
        let keymap = self.editor_keymap.clone();
        self.input_with_keymap(event, &keymap);
    }

    fn handle_vim_normal(&mut self, event: KeyEvent) {
        if let Some(op) = self.vim_operator.take() {
            self.handle_vim_operator(op, event);
            return;
        }

        if self.vim_normal_keymap.enter_insert.is_pressed(event) {
            self.vim_mode = VimMode::Insert;
            return;
        }
        if self.vim_normal_keymap.append_after_cursor.is_pressed(event) {
            let next = self.next_atomic_boundary(self.cursor_pos);
            self.set_cursor(next);
            self.vim_mode = VimMode::Insert;
            return;
        }
        if self.vim_normal_keymap.append_line_end.is_pressed(event) {
            self.set_cursor(self.end_of_current_line());
            self.vim_mode = VimMode::Insert;
            return;
        }
        if self.vim_normal_keymap.insert_line_start.is_pressed(event) {
            self.set_cursor(self.first_non_blank_of_current_line());
            self.vim_mode = VimMode::Insert;
            return;
        }
        if self.vim_normal_keymap.open_line_below.is_pressed(event) {
            let eol = self.end_of_current_line();
            let insert_at = if eol < self.text.len() { eol + 1 } else { eol };
            self.insert_str_at(insert_at, "\n");
            let cursor = if eol < self.text.len() {
                insert_at
            } else {
                insert_at + 1
            };
            self.set_cursor(cursor);
            self.vim_mode = VimMode::Insert;
            return;
        }
        if self.vim_normal_keymap.open_line_above.is_pressed(event) {
            let bol = self.beginning_of_current_line();
            self.insert_str_at(bol, "\n");
            self.set_cursor(bol);
            self.vim_mode = VimMode::Insert;
            return;
        }
        if self.vim_normal_keymap.move_left.is_pressed(event) {
            self.move_cursor_left();
            return;
        }
        if self.vim_normal_keymap.move_right.is_pressed(event) {
            self.move_cursor_right();
            return;
        }
        if self.vim_normal_keymap.move_down.is_pressed(event) {
            self.move_cursor_down();
            return;
        }
        if self.vim_normal_keymap.move_up.is_pressed(event) {
            self.move_cursor_up();
            return;
        }
        if self.vim_normal_keymap.move_word_forward.is_pressed(event) {
            self.set_cursor(self.beginning_of_next_word());
            return;
        }
        if self.vim_normal_keymap.move_word_backward.is_pressed(event) {
            self.set_cursor(self.beginning_of_previous_word());
            return;
        }
        if self.vim_normal_keymap.move_word_end.is_pressed(event) {
            self.set_cursor(self.vim_word_end_cursor());
            return;
        }
        if self.vim_normal_keymap.move_line_start.is_pressed(event) {
            self.set_cursor(self.beginning_of_current_line());
            return;
        }
        if self.vim_normal_keymap.move_line_end.is_pressed(event) {
            self.set_cursor(self.vim_line_end_cursor());
            return;
        }
        if self.vim_normal_keymap.delete_char.is_pressed(event) {
            self.delete_forward_kill(/*n*/ 1);
            return;
        }
        if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) {
            self.kill_to_end_of_line();
            return;
        }
        if self.vim_normal_keymap.yank_line.is_pressed(event) {
            self.yank_current_line();
            return;
        }
        if self.vim_normal_keymap.paste_after.is_pressed(event) {
            self.paste_after_cursor();
            return;
        }
        if self
            .vim_normal_keymap
            .start_delete_operator
            .is_pressed(event)
        {
            self.vim_operator = Some(VimOperator::Delete);
            return;
        }
        if self.vim_normal_keymap.start_yank_operator.is_pressed(event) {
            self.vim_operator = Some(VimOperator::Yank);
            return;
        }
        if self.vim_normal_keymap.cancel_operator.is_pressed(event) {
            self.vim_operator = None;
        }
    }

    fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool {
        if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) {
            self.delete_current_line();
            return true;
        }
        if op == VimOperator::Yank && self.vim_operator_keymap.yank_line.is_pressed(event) {
            self.yank_current_line();
            return true;
        }
        if self.vim_operator_keymap.cancel.is_pressed(event) {
            return true;
        }

        if let Some(motion) = self.vim_motion_for_event(event) {
            self.apply_vim_operator(op, motion);
            return true;
        }
        false
    }

    fn vim_motion_for_event(&self, event: KeyEvent) -> Option<VimMotion> {
        if self.vim_operator_keymap.motion_left.is_pressed(event) {
            return Some(VimMotion::Left);
        }
        if self.vim_operator_keymap.motion_right.is_pressed(event) {
            return Some(VimMotion::Right);
        }
        if self.vim_operator_keymap.motion_down.is_pressed(event) {
            return Some(VimMotion::Down);
        }
        if self.vim_operator_keymap.motion_up.is_pressed(event) {
            return Some(VimMotion::Up);
        }
        if self
            .vim_operator_keymap
            .motion_word_forward
            .is_pressed(event)
        {
            return Some(VimMotion::WordForward);
        }
        if self
            .vim_operator_keymap
            .motion_word_backward
            .is_pressed(event)
        {
            return Some(VimMotion::WordBackward);
        }
        if self.vim_operator_keymap.motion_word_end.is_pressed(event) {
            return Some(VimMotion::WordEnd);
        }
        if self.vim_operator_keymap.motion_line_start.is_pressed(event) {
            return Some(VimMotion::LineStart);
        }
        if self.vim_operator_keymap.motion_line_end.is_pressed(event) {
            return Some(VimMotion::LineEnd);
        }
        None
    }

    fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) {
        let Some(range) = self.range_for_motion(motion) else {
            return;
        };
        match op {
            VimOperator::Delete => self.kill_range(range),
            VimOperator::Yank => self.yank_range(range),
        }
    }

    fn range_for_motion(&mut self, motion: VimMotion) -> Option<Range<usize>> {
        if matches!(motion, VimMotion::Up | VimMotion::Down) {
            return self.linewise_range_for_vertical_motion(motion);
        }
        let start = self.cursor_pos;
        let target = self.target_for_motion(motion);
        if start == target {
            return None;
        }
        let (range_start, range_end) = if target < start {
            (target, start)
        } else {
            (start, target)
        };
        Some(range_start..range_end)
    }

    fn linewise_range_for_vertical_motion(&self, motion: VimMotion) -> Option<Range<usize>> {
        let current = self.current_line_range_with_newline();
        let range = match motion {
            VimMotion::Up => {
                let start = if current.start == 0 {
                    current.start
                } else {
                    self.beginning_of_line(current.start.saturating_sub(1))
                };
                start..current.end
            }
            VimMotion::Down => {
                let end = if current.end >= self.text.len() {
                    current.end
                } else {
                    let next_eol = self.end_of_line(current.end);
                    if next_eol < self.text.len() {
                        next_eol + 1
                    } else {
                        next_eol
                    }
                };
                current.start..end
            }
            VimMotion::Left
            | VimMotion::Right
            | VimMotion::WordForward
            | VimMotion::WordBackward
            | VimMotion::WordEnd
            | VimMotion::LineStart
            | VimMotion::LineEnd => return None,
        };
        (range.start < range.end).then_some(range)
    }

    fn target_for_motion(&mut self, motion: VimMotion) -> usize {
        let original_cursor = self.cursor_pos;
        let original_preferred = self.preferred_col;
        match motion {
            VimMotion::Left => self.move_cursor_left(),
            VimMotion::Right => self.move_cursor_right(),
            VimMotion::Up => self.move_cursor_up(),
            VimMotion::Down => self.move_cursor_down(),
            VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()),
            VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()),
            VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()),
            VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()),
            VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()),
        }
        let target = self.cursor_pos;
        self.cursor_pos = original_cursor;
        self.preferred_col = original_preferred;
        target
    }

    // ####### Input Functions #######
    pub fn delete_backward(&mut self, n: usize) {
        if n == 0 || self.cursor_pos == 0 {
            return;
        }
        let mut target = self.cursor_pos;
        for _ in 0..n {
            target = self.prev_atomic_boundary(target);
            if target == 0 {
                break;
            }
        }
        self.replace_range(target..self.cursor_pos, "");
    }

    pub fn delete_forward(&mut self, n: usize) {
        if n == 0 || self.cursor_pos >= self.text.len() {
            return;
        }
        let mut target = self.cursor_pos;
        for _ in 0..n {
            target = self.next_atomic_boundary(target);
            if target >= self.text.len() {
                break;
            }
        }
        self.replace_range(self.cursor_pos..target, "");
    }

    pub fn delete_forward_kill(&mut self, n: usize) {
        if n == 0 || self.cursor_pos >= self.text.len() {
            return;
        }
        let mut target = self.cursor_pos;
        for _ in 0..n {
            target = self.next_atomic_boundary(target);
            if target >= self.text.len() {
                break;
            }
        }
        self.kill_range(self.cursor_pos..target);
    }

    pub fn delete_backward_word(&mut self) {
        let start = self.beginning_of_previous_word();
        self.kill_range(start..self.cursor_pos);
    }

    /// Delete text to the right of the cursor using "word" semantics.
    ///
    /// Deletes from the current cursor position through the end of the next word as determined
    /// by `end_of_next_word()`. Any whitespace (including newlines) between the cursor and that
    /// word is included in the deletion.
    pub fn delete_forward_word(&mut self) {
        let end = self.end_of_next_word();
        if end > self.cursor_pos {
            self.kill_range(self.cursor_pos..end);
        }
    }

    /// Kill from the cursor to the end of the current logical line.
    ///
    /// If the cursor is already at end-of-line and a trailing newline exists, this kills that
    /// newline so repeated invocations continue making progress. The removed text becomes the next
    /// yank target and remains available even if a caller later clears or rewrites the visible
    /// buffer via `set_text_*`.
    pub fn kill_to_end_of_line(&mut self) {
        let eol = self.end_of_current_line();
        let range = if self.cursor_pos == eol {
            if eol < self.text.len() {
                Some(self.cursor_pos..eol + 1)
            } else {
                None
            }
        } else {
            Some(self.cursor_pos..eol)
        };

        if let Some(range) = range {
            self.kill_range(range);
        }
    }

    pub fn kill_to_beginning_of_line(&mut self) {
        let bol = self.beginning_of_current_line();
        let range = if self.cursor_pos == bol {
            if bol > 0 { Some(bol - 1..bol) } else { None }
        } else {
            Some(bol..self.cursor_pos)
        };

        if let Some(range) = range {
            self.kill_range(range);
        }
    }

    /// Insert the most recently killed text at the cursor.
    ///
    /// This uses the textarea's single-entry kill buffer. Because whole-buffer replacement APIs do
    /// not clear that buffer, `yank` can restore text after composer-level clears such as submit
    /// and slash-command dispatch.
    pub fn yank(&mut self) {
        if self.kill_buffer.is_empty() {
            return;
        }
        let text = self.kill_buffer.clone();
        self.insert_str(&text);
    }

    fn kill_range(&mut self, range: Range<usize>) {
        self.kill_range_with_kind(range, KillBufferKind::Characterwise);
    }

    fn kill_line_range(&mut self, range: Range<usize>) {
        self.kill_range_with_kind(range, KillBufferKind::Linewise);
    }

    fn kill_range_with_kind(&mut self, range: Range<usize>, kind: KillBufferKind) {
        let range = self.expand_range_to_element_boundaries(range);
        if range.start >= range.end {
            return;
        }

        let removed = self.text[range.clone()].to_string();
        if removed.is_empty() {
            return;
        }

        self.store_kill_buffer(removed, kind);
        self.replace_range_raw(range, "");
    }

    fn yank_range(&mut self, range: Range<usize>) {
        self.yank_range_with_kind(range, KillBufferKind::Characterwise);
    }

    fn yank_line_range(&mut self, range: Range<usize>) {
        self.yank_range_with_kind(range, KillBufferKind::Linewise);
    }

    fn yank_range_with_kind(&mut self, range: Range<usize>, kind: KillBufferKind) {
        let range = self.expand_range_to_element_boundaries(range);
        if range.start >= range.end {
            return;
        }
        let removed = self.text[range].to_string();
        if removed.is_empty() {
            return;
        }
        self.store_kill_buffer(removed, kind);
    }

    fn store_kill_buffer(&mut self, text: String, kind: KillBufferKind) {
        self.kill_buffer = text;
        self.kill_buffer_kind = kind;
    }

    fn paste_after_cursor(&mut self) {
        if self.kill_buffer.is_empty() {
            return;
        }
        if self.kill_buffer_kind == KillBufferKind::Linewise {
            self.paste_line_after_current_line();
            return;
        }
        let insert_at = self.next_atomic_boundary(self.cursor_pos);
        self.set_cursor(insert_at);
        let text = self.kill_buffer.clone();
        self.insert_str(&text);
    }

    fn paste_line_after_current_line(&mut self) {
        let eol = self.end_of_current_line();
        let insert_at = if eol < self.text.len() { eol + 1 } else { eol };
        let cursor = if eol < self.text.len() {
            insert_at
        } else {
            insert_at + 1
        };
        let text = if eol < self.text.len() {
            if self.kill_buffer.ends_with('\n') {
                self.kill_buffer.clone()
            } else {
                format!("{}\n", self.kill_buffer)
            }
        } else {
            format!("\n{}", self.kill_buffer.trim_end_matches('\n'))
        };
        self.insert_str_at(insert_at, &text);
        self.set_cursor(cursor.min(self.text.len()));
    }

    fn yank_current_line(&mut self) {
        let range = self.current_line_range_with_newline();
        self.yank_line_range(range);
    }

    fn delete_current_line(&mut self) {
        let range = self.current_line_range_with_newline();
        self.kill_line_range(range);
    }

    fn current_line_range_with_newline(&self) -> Range<usize> {
        let bol = self.beginning_of_current_line();
        let eol = self.end_of_current_line();
        let end = if eol < self.text.len() { eol + 1 } else { eol };
        bol..end
    }

    /// Move the cursor left by a single grapheme cluster.
    pub fn move_cursor_left(&mut self) {
        self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
        self.preferred_col = None;
    }

    /// Move the cursor right by a single grapheme cluster.
    pub fn move_cursor_right(&mut self) {
        self.cursor_pos = self.next_atomic_boundary(self.cursor_pos);
        self.preferred_col = None;
    }

    pub fn move_cursor_up(&mut self) {
        // If we have a wrapping cache, prefer navigating across wrapped (visual) lines.
        if let Some((target_col, maybe_line)) = {
            let cache_ref = self.wrap_cache.borrow();
            if let Some(cache) = cache_ref.as_ref() {
                let lines = &cache.lines;
                if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
                    let cur_range = &lines[idx];
                    let target_col = self
                        .preferred_col
                        .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width());
                    if idx > 0 {
                        let prev = &lines[idx - 1];
                        let line_start = prev.start;
                        let line_end = prev.end.saturating_sub(1);
                        Some((target_col, Some((line_start, line_end))))
                    } else {
                        Some((target_col, None))
                    }
                } else {
                    None
                }
            } else {
                None
            }
        } {
            // We had wrapping info. Apply movement accordingly.
            match maybe_line {
                Some((line_start, line_end)) => {
                    if self.preferred_col.is_none() {
                        self.preferred_col = Some(target_col);
                    }
                    self.move_to_display_col_on_line(line_start, line_end, target_col);
                    return;
                }
                None => {
                    // Already at first visual line -> move to start
                    self.cursor_pos = 0;
                    self.preferred_col = None;
                    return;
                }
            }
        }

        // Fallback to logical line navigation if we don't have wrapping info yet.
        if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') {
            let target_col = match self.preferred_col {
                Some(c) => c,
                None => {
                    let c = self.current_display_col();
                    self.preferred_col = Some(c);
                    c
                }
            };
            let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
            let prev_line_end = prev_nl;
            self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
        } else {
            self.cursor_pos = 0;
            self.preferred_col = None;
        }
    }

    pub fn move_cursor_down(&mut self) {
        // If we have a wrapping cache, prefer navigating across wrapped (visual) lines.
        if let Some((target_col, move_to_last)) = {
            let cache_ref = self.wrap_cache.borrow();
            if let Some(cache) = cache_ref.as_ref() {
                let lines = &cache.lines;
                if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
                    let cur_range = &lines[idx];
                    let target_col = self
                        .preferred_col
                        .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width());
                    if idx + 1 < lines.len() {
                        let next = &lines[idx + 1];
                        let line_start = next.start;
                        let line_end = next.end.saturating_sub(1);
                        Some((target_col, Some((line_start, line_end))))
                    } else {
                        Some((target_col, None))
                    }
                } else {
                    None
                }
            } else {
                None
            }
        } {
            match move_to_last {
                Some((line_start, line_end)) => {
                    if self.preferred_col.is_none() {
                        self.preferred_col = Some(target_col);
                    }
                    self.move_to_display_col_on_line(line_start, line_end, target_col);
                    return;
                }
                None => {
                    // Already on last visual line -> move to end
                    self.cursor_pos = self.text.len();
                    self.preferred_col = None;
                    return;
                }
            }
        }

        // Fallback to logical line navigation if we don't have wrapping info yet.
        let target_col = match self.preferred_col {
            Some(c) => c,
            None => {
                let c = self.current_display_col();
                self.preferred_col = Some(c);
                c
            }
        };
        if let Some(next_nl) = self.text[self.cursor_pos..]
            .find('\n')
            .map(|i| i + self.cursor_pos)
        {
            let next_line_start = next_nl + 1;
            let next_line_end = self.text[next_line_start..]
                .find('\n')
                .map(|i| i + next_line_start)
                .unwrap_or(self.text.len());
            self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
        } else {
            self.cursor_pos = self.text.len();
            self.preferred_col = None;
        }
    }

    pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) {
        let bol = self.beginning_of_current_line();
        if move_up_at_bol && self.cursor_pos == bol {
            self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1)));
        } else {
            self.set_cursor(bol);
        }
        self.preferred_col = None;
    }

    pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) {
        let eol = self.end_of_current_line();
        if move_down_at_eol && self.cursor_pos == eol {
            let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len());
            self.set_cursor(self.end_of_line(next_pos));
        } else {
            self.set_cursor(eol);
        }
    }

    // ===== Text elements support =====

    pub fn element_payloads(&self) -> Vec<String> {
        self.elements
            .iter()
            .filter_map(|e| self.text.get(e.range.clone()).map(str::to_string))
            .collect()
    }

    pub fn text_elements(&self) -> Vec<UserTextElement> {
        self.elements
            .iter()
            .map(|e| {
                let placeholder = self.text.get(e.range.clone()).map(str::to_string);
                UserTextElement::new(
                    ByteRange {
                        start: e.range.start,
                        end: e.range.end,
                    },
                    placeholder,
                )
            })
            .collect()
    }

    pub(crate) fn text_element_snapshots(&self) -> Vec<TextElementSnapshot> {
        self.elements
            .iter()
            .filter_map(|element| {
                self.text
                    .get(element.range.clone())
                    .map(|text| TextElementSnapshot {
                        id: element.id,
                        range: element.range.clone(),
                        text: text.to_string(),
                    })
            })
            .collect()
    }

    pub(crate) fn element_id_for_exact_range(&self, range: Range<usize>) -> Option<u64> {
        self.elements
            .iter()
            .find(|element| element.range == range)
            .map(|element| element.id)
    }

    /// Renames a single text element in-place, keeping it atomic.
    ///
    /// Use this when the element payload is an identifier (e.g. a placeholder) that must be
    /// updated without converting the element back into normal text.
    pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool {
        let Some(idx) = self
            .elements
            .iter()
            .position(|e| self.text.get(e.range.clone()) == Some(old))
        else {
            return false;
        };

        let range = self.elements[idx].range.clone();
        let start = range.start;
        let end = range.end;
        if start > end || end > self.text.len() {
            return false;
        }

        let removed_len = end - start;
        let inserted_len = new.len();
        let diff = inserted_len as isize - removed_len as isize;

        self.text.replace_range(range, new);
        self.wrap_cache.replace(None);
        self.preferred_col = None;

        // Update the modified element's range.
        self.elements[idx].range = start..(start + inserted_len);

        // Shift element ranges that occur after the replaced element.
        if diff != 0 {
            for (j, e) in self.elements.iter_mut().enumerate() {
                if j == idx {
                    continue;
                }
                if e.range.end <= start {
                    continue;
                }
                if e.range.start >= end {
                    e.range.start = ((e.range.start as isize) + diff) as usize;
                    e.range.end = ((e.range.end as isize) + diff) as usize;
                    continue;
                }

                // Elements should not partially overlap each other; degrade gracefully by
                // snapping anything intersecting the replaced range to the new bounds.
                e.range.start = start.min(e.range.start);
                e.range.end = (start + inserted_len).max(e.range.end.saturating_add_signed(diff));
            }
        }

        // Update the cursor position to account for the edit.
        self.cursor_pos = if self.cursor_pos < start {
            self.cursor_pos
        } else if self.cursor_pos <= end {
            start + inserted_len
        } else {
            ((self.cursor_pos as isize) + diff) as usize
        };
        self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);

        // Keep element ordering deterministic.
        self.elements.sort_by_key(|e| e.range.start);

        true
    }

    pub fn insert_element(&mut self, text: &str) -> u64 {
        let start = self.clamp_pos_for_insertion(self.cursor_pos);
        self.insert_str_at(start, text);
        let end = start + text.len();
        let id = self.add_element(start..end);
        // Place cursor at end of inserted element
        self.set_cursor(end);
        id
    }

    #[cfg(not(target_os = "linux"))]
    pub fn insert_named_element(&mut self, text: &str, id: String) {
        let start = self.clamp_pos_for_insertion(self.cursor_pos);
        self.insert_str_at(start, text);
        let end = start + text.len();
        self.add_element_with_id(start..end, Some(id));
        // Place cursor at end of inserted element
        self.set_cursor(end);
    }

    #[cfg(not(target_os = "linux"))]
    pub fn replace_element_by_id(&mut self, id: &str, text: &str) -> bool {
        if let Some(idx) = self
            .elements
            .iter()
            .position(|e| e.name.as_deref() == Some(id))
        {
            let range = self.elements[idx].range.clone();
            self.replace_range_raw(range, text);
            self.elements.retain(|e| e.name.as_deref() != Some(id));
            true
        } else {
            false
        }
    }

    /// Update the element's text in place, preserving its id so callers can
    /// update it again later (e.g. recording -> transcribing -> final).
    #[allow(dead_code)]
    pub fn update_named_element_by_id(&mut self, id: &str, text: &str) -> bool {
        if let Some(elem_idx) = self
            .elements
            .iter()
            .position(|e| e.name.as_deref() == Some(id))
        {
            let old_range = self.elements[elem_idx].range.clone();
            let start = old_range.start;
            self.replace_range_raw(old_range, text);
            // After replace_range_raw, the old element entry was removed if fully overlapped.
            // Re-add an updated element with the same id and new range.
            let new_end = start + text.len();
            self.add_element_with_id(start..new_end, Some(id.to_string()));
            true
        } else {
            false
        }
    }

    #[allow(dead_code)]
    pub fn named_element_range(&self, id: &str) -> Option<std::ops::Range<usize>> {
        self.elements
            .iter()
            .find(|e| e.name.as_deref() == Some(id))
            .map(|e| e.range.clone())
    }

    fn add_element_with_id(&mut self, range: Range<usize>, name: Option<String>) -> u64 {
        let id = self.next_element_id();
        let elem = TextElement { id, range, name };
        self.elements.push(elem);
        self.elements.sort_by_key(|e| e.range.start);
        id
    }

    fn add_element(&mut self, range: Range<usize>) -> u64 {
        self.add_element_with_id(range, /*name*/ None)
    }

    /// Mark an existing text range as an atomic element without changing the text.
    ///
    /// This is used to convert already-typed tokens (like `/plan`) into elements
    /// so they render and edit atomically. Overlapping or duplicate ranges are ignored.
    pub fn add_element_range(&mut self, range: Range<usize>) -> Option<u64> {
        let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
        let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
        if start >= end {
            return None;
        }
        if self
            .elements
            .iter()
            .any(|e| e.range.start == start && e.range.end == end)
        {
            return None;
        }
        if self
            .elements
            .iter()
            .any(|e| start < e.range.end && end > e.range.start)
        {
            return None;
        }
        let id = self.add_element(start..end);
        Some(id)
    }

    pub fn remove_element_range(&mut self, range: Range<usize>) -> bool {
        let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
        let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
        if start >= end {
            return false;
        }
        let len_before = self.elements.len();
        self.elements
            .retain(|elem| elem.range.start != start || elem.range.end != end);
        len_before != self.elements.len()
    }

    fn next_element_id(&mut self) -> u64 {
        let id = self.next_element_id;
        self.next_element_id = self.next_element_id.saturating_add(1);
        id
    }
    fn find_element_containing(&self, pos: usize) -> Option<usize> {
        self.elements
            .iter()
            .position(|e| pos > e.range.start && pos < e.range.end)
    }

    fn clamp_pos_to_char_boundary(&self, pos: usize) -> usize {
        let pos = pos.min(self.text.len());
        if self.text.is_char_boundary(pos) {
            return pos;
        }
        let mut prev = pos;
        while prev > 0 && !self.text.is_char_boundary(prev) {
            prev -= 1;
        }
        let mut next = pos;
        while next < self.text.len() && !self.text.is_char_boundary(next) {
            next += 1;
        }
        if pos.saturating_sub(prev) <= next.saturating_sub(pos) {
            prev
        } else {
            next
        }
    }

    fn clamp_pos_to_nearest_boundary(&self, pos: usize) -> usize {
        let pos = self.clamp_pos_to_char_boundary(pos);
        if let Some(idx) = self.find_element_containing(pos) {
            let e = &self.elements[idx];
            let dist_start = pos.saturating_sub(e.range.start);
            let dist_end = e.range.end.saturating_sub(pos);
            if dist_start <= dist_end {
                self.clamp_pos_to_char_boundary(e.range.start)
            } else {
                self.clamp_pos_to_char_boundary(e.range.end)
            }
        } else {
            pos
        }
    }

    fn clamp_pos_for_insertion(&self, pos: usize) -> usize {
        let pos = self.clamp_pos_to_char_boundary(pos);
        // Do not allow inserting into the middle of an element
        if let Some(idx) = self.find_element_containing(pos) {
            let e = &self.elements[idx];
            // Choose closest edge for insertion
            let dist_start = pos.saturating_sub(e.range.start);
            let dist_end = e.range.end.saturating_sub(pos);
            if dist_start <= dist_end {
                self.clamp_pos_to_char_boundary(e.range.start)
            } else {
                self.clamp_pos_to_char_boundary(e.range.end)
            }
        } else {
            pos
        }
    }

    fn expand_range_to_element_boundaries(&self, mut range: Range<usize>) -> Range<usize> {
        // Expand to include any intersecting elements fully
        loop {
            let mut changed = false;
            for e in &self.elements {
                if e.range.start < range.end && e.range.end > range.start {
                    let new_start = range.start.min(e.range.start);
                    let new_end = range.end.max(e.range.end);
                    if new_start != range.start || new_end != range.end {
                        range.start = new_start;
                        range.end = new_end;
                        changed = true;
                    }
                }
            }
            if !changed {
                break;
            }
        }
        range
    }

    fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) {
        // Generic shift: for pure insert, removed = 0; for delete, inserted = 0.
        let end = at + removed;
        let diff = inserted as isize - removed as isize;
        // Remove elements fully deleted by the operation and shift the rest
        self.elements
            .retain(|e| !(e.range.start >= at && e.range.end <= end));
        for e in &mut self.elements {
            if e.range.end <= at {
                // before edit
            } else if e.range.start >= end {
                // after edit
                e.range.start = ((e.range.start as isize) + diff) as usize;
                e.range.end = ((e.range.end as isize) + diff) as usize;
            } else {
                // Overlap with element but not fully contained (shouldn't happen when using
                // element-aware replace, but degrade gracefully by snapping element to new bounds)
                let new_start = at.min(e.range.start);
                let new_end = at + inserted.max(e.range.end.saturating_sub(end));
                e.range.start = new_start;
                e.range.end = new_end;
            }
        }
    }

    fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) {
        self.shift_elements(start, end.saturating_sub(start), inserted_len);
    }

    fn prev_atomic_boundary(&self, pos: usize) -> usize {
        if pos == 0 {
            return 0;
        }
        // If currently at an element end or inside, jump to start of that element.
        if let Some(idx) = self
            .elements
            .iter()
            .position(|e| pos > e.range.start && pos <= e.range.end)
        {
            return self.elements[idx].range.start;
        }
        let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false);
        match gc.prev_boundary(&self.text, 0) {
            Ok(Some(b)) => {
                if let Some(idx) = self.find_element_containing(b) {
                    self.elements[idx].range.start
                } else {
                    b
                }
            }
            Ok(None) => 0,
            Err(_) => pos.saturating_sub(1),
        }
    }

    fn next_atomic_boundary(&self, pos: usize) -> usize {
        if pos >= self.text.len() {
            return self.text.len();
        }
        // If currently at an element start or inside, jump to end of that element.
        if let Some(idx) = self
            .elements
            .iter()
            .position(|e| pos >= e.range.start && pos < e.range.end)
        {
            return self.elements[idx].range.end;
        }
        let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false);
        match gc.next_boundary(&self.text, 0) {
            Ok(Some(b)) => {
                if let Some(idx) = self.find_element_containing(b) {
                    self.elements[idx].range.end
                } else {
                    b
                }
            }
            Ok(None) => self.text.len(),
            Err(_) => pos.saturating_add(1),
        }
    }

    pub(crate) fn beginning_of_previous_word(&self) -> usize {
        let prefix = &self.text[..self.cursor_pos];
        let Some((first_non_ws_idx, ch)) = prefix
            .char_indices()
            .rev()
            .find(|&(_, ch)| !ch.is_whitespace())
        else {
            return 0;
        };
        let run_start = prefix[..first_non_ws_idx]
            .char_indices()
            .rev()
            .find(|&(_, ch)| ch.is_whitespace())
            .map_or(0, |(idx, ch)| idx + ch.len_utf8());
        let run_end = first_non_ws_idx + ch.len_utf8();
        let pieces = split_word_pieces(&prefix[run_start..run_end]);
        let mut pieces = pieces.into_iter().rev().peekable();
        let Some((piece_start, piece)) = pieces.next() else {
            return run_start;
        };
        let mut start = run_start + piece_start;

        if piece.chars().all(is_word_separator) {
            while let Some((idx, piece)) = pieces.peek() {
                if !piece.chars().all(is_word_separator) {
                    break;
                }
                start = run_start + *idx;
                pieces.next();
            }
        }

        self.adjust_pos_out_of_elements(start, /*prefer_start*/ true)
    }

    pub(crate) fn end_of_next_word(&self) -> usize {
        let suffix = &self.text[self.cursor_pos..];
        let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else {
            return self.text.len();
        };
        let run = &suffix[first_non_ws..];
        let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())];
        let mut pieces = split_word_pieces(run).into_iter().peekable();
        let Some((start, piece)) = pieces.next() else {
            return self.cursor_pos + first_non_ws;
        };
        let word_start = self.cursor_pos + first_non_ws + start;
        let mut end = word_start + piece.len();
        if piece.chars().all(is_word_separator) {
            while let Some((idx, piece)) = pieces.peek() {
                if !piece.chars().all(is_word_separator) {
                    break;
                }
                end = self.cursor_pos + first_non_ws + *idx + piece.len();
                pieces.next();
            }
        }

        self.adjust_pos_out_of_elements(end, /*prefer_start*/ false)
    }

    fn vim_word_end_cursor(&self) -> usize {
        let end = self.end_of_next_word();
        if end > self.cursor_pos {
            self.prev_atomic_boundary(end)
        } else {
            end
        }
    }

    fn vim_line_end_cursor(&self) -> usize {
        let bol = self.beginning_of_current_line();
        let eol = self.end_of_current_line();
        if eol > bol {
            self.prev_atomic_boundary(eol).max(bol)
        } else {
            eol
        }
    }

    pub(crate) fn beginning_of_next_word(&self) -> usize {
        let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace())
        else {
            return self.text.len();
        };
        let word_start = self.cursor_pos + first_non_ws;
        if word_start != self.cursor_pos {
            return self.adjust_pos_out_of_elements(word_start, /*prefer_start*/ true);
        }
        let end = self.end_of_next_word();
        if end >= self.text.len() {
            return self.text.len();
        }
        let Some(next_non_ws) = self.text[end..].find(|c: char| !c.is_whitespace()) else {
            return self.text.len();
        };
        self.adjust_pos_out_of_elements(end + next_non_ws, /*prefer_start*/ true)
    }

    fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize {
        if let Some(idx) = self.find_element_containing(pos) {
            let e = &self.elements[idx];
            if prefer_start {
                e.range.start
            } else {
                e.range.end
            }
        } else {
            pos
        }
    }

    #[expect(clippy::unwrap_used)]
    fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec<Range<usize>>> {
        // Ensure cache is ready (potentially mutably borrow, then drop)
        {
            let mut cache = self.wrap_cache.borrow_mut();
            let needs_recalc = match cache.as_ref() {
                Some(c) => c.width != width,
                None => true,
            };
            if needs_recalc {
                let lines = super::wrapping::wrap_ranges(
                    &self.text,
                    Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
                );
                *cache = Some(WrapCache { width, lines });
            }
        }

        let cache = self.wrap_cache.borrow();
        Ref::map(cache, |c| &c.as_ref().unwrap().lines)
    }

    /// Calculate the scroll offset that should be used to satisfy the
    /// invariants given the current area size and wrapped lines.
    ///
    /// - Cursor is always on screen.
    /// - No scrolling if content fits in the area.
    fn effective_scroll(
        &self,
        area_height: u16,
        lines: &[Range<usize>],
        current_scroll: u16,
    ) -> u16 {
        let total_lines = lines.len() as u16;
        if area_height >= total_lines {
            return 0;
        }

        // Where is the cursor within wrapped lines? Prefer assigning boundary positions
        // (where pos equals the start of a wrapped line) to that later line.
        let cursor_line_idx =
            Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16;

        let max_scroll = total_lines.saturating_sub(area_height);
        let mut scroll = current_scroll.min(max_scroll);

        // Ensure cursor is visible within [scroll, scroll + area_height)
        if cursor_line_idx < scroll {
            scroll = cursor_line_idx;
        } else if cursor_line_idx >= scroll + area_height {
            scroll = cursor_line_idx + 1 - area_height;
        }
        scroll
    }
}

impl Widget for &TextArea {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let lines = self.wrapped_lines(area.width);
        self.render_lines(area, buf, &lines, 0..lines.len(), Style::default(), &[]);
    }
}

impl StatefulWidget for &TextArea {
    type State = TextAreaState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let lines = self.wrapped_lines(area.width);
        let scroll = self.effective_scroll(area.height, &lines, state.scroll);
        state.scroll = scroll;

        let start = scroll as usize;
        let end = (scroll + area.height).min(lines.len() as u16) as usize;
        self.render_lines(area, buf, &lines, start..end, Style::default(), &[]);
    }
}

impl TextArea {
    pub(crate) fn render_ref_masked(
        &self,
        area: Rect,
        buf: &mut Buffer,
        state: &mut TextAreaState,
        mask_char: char,
        base_style: Style,
    ) {
        let lines = self.wrapped_lines(area.width);
        let scroll = self.effective_scroll(area.height, &lines, state.scroll);
        state.scroll = scroll;

        let start = scroll as usize;
        let end = (scroll + area.height).min(lines.len() as u16) as usize;
        self.render_lines_masked(area, buf, &lines, start..end, mask_char, base_style);
    }

    /// Render the textarea with an explicit `base_style` applied to every cell,
    /// used by the Zellij code path to override inherited terminal styles.
    pub(crate) fn render_ref_styled(
        &self,
        area: Rect,
        buf: &mut Buffer,
        state: &mut TextAreaState,
        base_style: Style,
    ) {
        let lines = self.wrapped_lines(area.width);
        let scroll = self.effective_scroll(area.height, &lines, state.scroll);
        state.scroll = scroll;

        let start = scroll as usize;
        let end = (scroll + area.height).min(lines.len() as u16) as usize;
        self.render_lines(area, buf, &lines, start..end, base_style, &[]);
    }

    /// Render the textarea with `base_style` plus additional render-only highlight ranges.
    ///
    /// Highlight ranges are byte ranges in `self.text`. They affect only the buffer rendering and
    /// do not mutate the editable text, cursor, element metadata, or wrapping cache.
    pub(crate) fn render_ref_styled_with_highlights(
        &self,
        area: Rect,
        buf: &mut Buffer,
        state: &mut TextAreaState,
        base_style: Style,
        highlights: &[(Range<usize>, Style)],
    ) {
        let lines = self.wrapped_lines(area.width);
        let scroll = self.effective_scroll(area.height, &lines, state.scroll);
        state.scroll = scroll;

        let start = scroll as usize;
        let end = (scroll + area.height).min(lines.len() as u16) as usize;
        self.render_lines(area, buf, &lines, start..end, base_style, highlights);
    }

    fn render_lines(
        &self,
        area: Rect,
        buf: &mut Buffer,
        lines: &[Range<usize>],
        range: std::ops::Range<usize>,
        base_style: Style,
        highlights: &[(Range<usize>, Style)],
    ) {
        for (row, idx) in range.enumerate() {
            let r = &lines[idx];
            let y = area.y + row as u16;
            let line_range = r.start..r.end - 1;
            buf.set_style(Rect::new(area.x, y, area.width, 1), base_style);
            // Draw base line with the provided style.
            buf.set_string(area.x, y, &self.text[line_range.clone()], base_style);

            // Overlay styled segments for elements that intersect this line.
            for elem in &self.elements {
                // Compute overlap with displayed slice.
                let overlap_start = elem.range.start.max(line_range.start);
                let overlap_end = elem.range.end.min(line_range.end);
                if overlap_start >= overlap_end {
                    continue;
                }
                let styled = &self.text[overlap_start..overlap_end];
                let x_off = self.text[line_range.start..overlap_start].width() as u16;
                let style = base_style.fg(ratatui::style::Color::Cyan);
                buf.set_string(area.x + x_off, y, styled, style);
            }

            // Overlay render-only highlight ranges last so transient search highlighting remains
            // visible even when it intersects attachment placeholders or other styled elements.
            for (highlight_range, style) in highlights {
                let overlap_start = highlight_range.start.max(line_range.start);
                let overlap_end = highlight_range.end.min(line_range.end);
                if overlap_start >= overlap_end {
                    continue;
                }
                let highlighted = &self.text[overlap_start..overlap_end];
                let x_off = self.text[line_range.start..overlap_start].width() as u16;
                buf.set_string(area.x + x_off, y, highlighted, *style);
            }
        }
    }

    fn render_lines_masked(
        &self,
        area: Rect,
        buf: &mut Buffer,
        lines: &[Range<usize>],
        range: std::ops::Range<usize>,
        mask_char: char,
        base_style: Style,
    ) {
        for (row, idx) in range.enumerate() {
            let r = &lines[idx];
            let y = area.y + row as u16;
            let line_range = r.start..r.end - 1;
            buf.set_style(Rect::new(area.x, y, area.width, 1), base_style);
            let masked = self.text[line_range.clone()]
                .chars()
                .map(|_| mask_char)
                .collect::<String>();
            buf.set_string(area.x, y, &masked, base_style);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    // koda adaptation: `super::key_hint` from inside `mod tests` resolves
    // to `composer::textarea::key_hint` (which doesn't exist). The codex
    // original uses `crate::key_hint` since their layout is `tui::key_hint`.
    // We use the explicit absolute path here for clarity.
    use crate::composer::key_hint;
    // crossterm types are intentionally not imported here to avoid unused warnings
    use pretty_assertions::assert_eq;

    // koda adaptation: codex's `fuzz_textarea_randomized` property test uses
    // `rand` + `chrono` for thousands of randomized edits. We don't carry
    // those dev-deps yet (#1178). Both this `use` and the helper + test
    // below are gated under `#[cfg(any())]` (Rust idiom for always-false)
    // so the verbatim codex code is preserved for trivial revival — just
    // swap the cfg and add the dev-deps.
    #[cfg(any())]
    use rand::prelude::*;

    #[cfg(any())]
    fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String {
        let r: u8 = rng.random_range(0..100);
        match r {
            0..=4 => "\n".to_string(),
            5..=12 => " ".to_string(),
            13..=35 => (rng.random_range(b'a'..=b'z') as char).to_string(),
            36..=45 => (rng.random_range(b'A'..=b'Z') as char).to_string(),
            46..=52 => (rng.random_range(b'0'..=b'9') as char).to_string(),
            53..=65 => {
                // Some emoji (wide graphemes)
                let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"];
                choices[rng.random_range(0..choices.len())].to_string()
            }
            66..=75 => {
                // CJK wide characters
                let choices = ["漢", "字", "測", "試", "你", "好", "界", "编", "码"];
                choices[rng.random_range(0..choices.len())].to_string()
            }
            76..=85 => {
                // Combining mark sequences
                let base = ["e", "a", "o", "n", "u"][rng.random_range(0..5)];
                let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"];
                format!("{base}{}", marks[rng.random_range(0..marks.len())])
            }
            86..=92 => {
                // Some non-latin single codepoints (Greek, Cyrillic, Hebrew)
                let choices = ["Ω", "β", "Ж", "ю", "ש", "م", "ह"];
                choices[rng.random_range(0..choices.len())].to_string()
            }
            _ => {
                // ZWJ sequences (single graphemes but multi-codepoint)
                let choices = [
                    "👩\u{200D}💻", // woman technologist
                    "👨\u{200D}💻", // man technologist
                    "🏳️\u{200D}🌈", // rainbow flag
                ];
                choices[rng.random_range(0..choices.len())].to_string()
            }
        }
    }

    fn ta_with(text: &str) -> TextArea {
        let mut t = TextArea::new();
        t.insert_str(text);
        t
    }

    #[test]
    fn insert_and_replace_update_cursor_and_text() {
        // insert helpers
        let mut t = ta_with("hello");
        t.set_cursor(/*pos*/ 5);
        t.insert_str("!");
        assert_eq!(t.text(), "hello!");
        assert_eq!(t.cursor(), 6);

        t.insert_str_at(/*pos*/ 0, "X");
        assert_eq!(t.text(), "Xhello!");
        assert_eq!(t.cursor(), 7);

        // Insert after the cursor should not move it
        t.set_cursor(/*pos*/ 1);
        let end = t.text().len();
        t.insert_str_at(end, "Y");
        assert_eq!(t.text(), "Xhello!Y");
        assert_eq!(t.cursor(), 1);

        // replace_range cases
        // 1) cursor before range
        let mut t = ta_with("abcd");
        t.set_cursor(/*pos*/ 1);
        t.replace_range(2..3, "Z");
        assert_eq!(t.text(), "abZd");
        assert_eq!(t.cursor(), 1);

        // 2) cursor inside range
        let mut t = ta_with("abcd");
        t.set_cursor(/*pos*/ 2);
        t.replace_range(1..3, "Q");
        assert_eq!(t.text(), "aQd");
        assert_eq!(t.cursor(), 2);

        // 3) cursor after range with shifted by diff
        let mut t = ta_with("abcd");
        t.set_cursor(/*pos*/ 4);
        t.replace_range(0..1, "AA");
        assert_eq!(t.text(), "AAbcd");
        assert_eq!(t.cursor(), 5);
    }

    #[test]
    fn insert_str_at_clamps_to_char_boundary() {
        let mut t = TextArea::new();
        t.insert_str("你");
        t.set_cursor(/*pos*/ 0);
        t.insert_str_at(/*pos*/ 1, "A");
        assert_eq!(t.text(), "A你");
        assert_eq!(t.cursor(), 1);
    }

    #[test]
    fn set_text_clamps_cursor_to_char_boundary() {
        let mut t = TextArea::new();
        t.insert_str("abcd");
        t.set_cursor(/*pos*/ 1);
        t.set_text_clearing_elements("你");
        assert_eq!(t.cursor(), 0);
        t.insert_str("a");
        assert_eq!(t.text(), "a你");
    }

    #[test]
    fn delete_backward_and_forward_edges() {
        let mut t = ta_with("abc");
        t.set_cursor(/*pos*/ 1);
        t.delete_backward(/*n*/ 1);
        assert_eq!(t.text(), "bc");
        assert_eq!(t.cursor(), 0);

        // deleting backward at start is a no-op
        t.set_cursor(/*pos*/ 0);
        t.delete_backward(/*n*/ 1);
        assert_eq!(t.text(), "bc");
        assert_eq!(t.cursor(), 0);

        // forward delete removes next grapheme
        t.set_cursor(/*pos*/ 1);
        t.delete_forward(/*n*/ 1);
        assert_eq!(t.text(), "b");
        assert_eq!(t.cursor(), 1);

        // forward delete at end is a no-op
        t.set_cursor(t.text().len());
        t.delete_forward(/*n*/ 1);
        assert_eq!(t.text(), "b");
    }

    #[test]
    fn delete_forward_deletes_element_at_left_edge() {
        let mut t = TextArea::new();
        t.insert_str("a");
        t.insert_element("<element>");
        t.insert_str("b");

        let elem_start = t.elements[0].range.start;
        t.set_cursor(elem_start);
        t.delete_forward(/*n*/ 1);

        assert_eq!(t.text(), "ab");
        assert_eq!(t.cursor(), elem_start);
    }

    #[test]
    fn vim_insert_and_escape() {
        let mut t = TextArea::new();
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert_eq!(t.text(), "h");
        assert_eq!(t.vim_mode_label(), Some("Normal"));
        assert_eq!(t.cursor(), 0);
    }

    #[test]
    fn vim_insert_key_enters_insert_mode() {
        let mut t = TextArea::new();
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));

        assert_eq!(t.text(), "h");
        assert_eq!(t.vim_mode_label(), Some("Insert"));
    }

    #[test]
    fn vim_normal_arrow_keys_move_cursor() {
        let mut t = ta_with("ab\ncd");
        t.set_cursor(/*pos*/ 1);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        assert_eq!(t.cursor(), 2);

        t.input(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(t.cursor(), 5);

        t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        assert_eq!(t.cursor(), 4);

        t.input(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(t.cursor(), 1);
    }

    #[test]
    fn vim_escape_from_insert_at_start_does_not_underflow() {
        let mut t = TextArea::new();
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert_eq!(t.vim_mode_label(), Some("Normal"));
        assert_eq!(t.cursor(), 0);
    }

    #[test]
    fn vim_escape_from_insert_at_line_start_stays_on_line() {
        let mut t = ta_with("one\ntwo");
        t.set_cursor(/*pos*/ "one\n".len());
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert_eq!(t.vim_mode_label(), Some("Normal"));
        assert_eq!(t.cursor(), "one\n".len());
    }

    #[test]
    fn vim_escape_moves_by_grapheme_boundary() {
        let mut t = ta_with("👍👍");
        t.set_cursor(t.text().len());
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert_eq!(t.vim_mode_label(), Some("Normal"));
        assert_eq!(t.cursor(), "👍".len());
    }

    #[test]
    fn vim_escape_respects_atomic_element_boundary() {
        let mut t = TextArea::new();
        t.insert_str("a");
        t.insert_element("<element>");
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

        assert_eq!(t.vim_mode_label(), Some("Normal"));
        assert_eq!(t.cursor(), 1);
    }

    #[test]
    fn vim_shift_i_enters_insert_at_first_non_blank_with_shift_only_binding() {
        let mut t = ta_with("hello\n  world");
        t.vim_normal_keymap.insert_line_start = vec![key_hint::shift(KeyCode::Char('i'))];
        t.set_cursor(/*pos*/ "hello\n  wor".len());
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::NONE));

        assert_eq!(t.vim_mode_label(), Some("Insert"));
        assert_eq!(t.cursor(), "hello\n  ".len());
    }

    #[test]
    fn vim_shift_a_enters_insert_at_line_end_with_shift_only_binding() {
        let mut t = ta_with("hello\nworld");
        t.vim_normal_keymap.append_line_end = vec![key_hint::shift(KeyCode::Char('a'))];
        t.set_cursor(/*pos*/ 8);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));

        assert_eq!(t.vim_mode_label(), Some("Insert"));
        assert_eq!(t.cursor(), 11);
    }

    #[test]
    fn vim_shift_o_opens_line_above_with_shift_only_binding() {
        let mut t = ta_with("hello\nworld");
        t.vim_normal_keymap.open_line_above = vec![key_hint::shift(KeyCode::Char('o'))];
        t.set_cursor(/*pos*/ 8);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::NONE));

        assert_eq!(t.text(), "hello\n\nworld");
        assert_eq!(t.vim_mode_label(), Some("Insert"));
        assert_eq!(t.cursor(), 6);
    }

    #[test]
    fn vim_o_opens_line_below_on_inserted_line() {
        let mut t = ta_with("one\ntwo");
        t.set_cursor(/*pos*/ 1);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));

        assert_eq!(t.text(), "one\n\ntwo");
        assert_eq!(t.vim_mode_label(), Some("Insert"));
        assert_eq!(t.cursor(), "one\n".len());
    }

    #[test]
    fn vim_delete_word() {
        let mut t = ta_with("hello world");
        t.set_cursor(/*pos*/ 0);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));

        assert_eq!(t.text(), "world");
        assert_eq!(t.kill_buffer, "hello ");
    }

    #[test]
    fn vim_operator_invalid_motion_is_consumed() {
        let mut t = ta_with("hello");
        t.set_cursor(/*pos*/ 0);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
        assert!(t.is_vim_operator_pending());

        t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));

        assert_eq!(t.text(), "hello");
        assert_eq!(t.vim_mode_label(), Some("Normal"));
        assert_eq!(t.cursor(), 0);
        assert!(!t.is_vim_operator_pending());
    }

    #[test]
    fn vim_e_lands_on_word_end_character() {
        let mut t = ta_with("abc");
        t.set_cursor(/*pos*/ 0);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));

        assert_eq!(t.cursor(), 2);

        t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));

        assert_eq!(t.text(), "ab");
        assert_eq!(t.kill_buffer, "c");
    }

    #[test]
    fn vim_dollar_lands_on_line_end_character() {
        let mut t = ta_with("abc\n123");
        t.set_cursor(/*pos*/ 1);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE));

        assert_eq!(t.cursor(), 2);

        t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));

        assert_eq!(t.text(), "ab\n123");
        assert_eq!(t.kill_buffer, "c");
    }

    #[test]
    fn vim_linewise_yank_pastes_below_current_line() {
        let mut t = ta_with("abc\n123\nxyz");
        t.set_cursor(/*pos*/ 1);
        t.set_vim_enabled(/*enabled*/ true);

        t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
        t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));

        assert_eq!(t.text(), "abc\nabc\n123\nxyz");
        assert_eq!(t.cursor(), "abc\n".len());
        assert_eq!(t.kill_buffer, "abc\n");
        assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
    }

    #[test]
    fn delete_backward_word_and_kill_line_variants() {
        // delete backward word at end removes the whole previous word
        let mut t = ta_with("hello   world  ");
        t.set_cursor(t.text().len());
        t.delete_backward_word();
        assert_eq!(t.text(), "hello   ");
        assert_eq!(t.cursor(), 8);

        // From inside a word, delete from word start to cursor
        let mut t = ta_with("foo bar");
        t.set_cursor(/*pos*/ 6); // inside "bar" (after 'a')
        t.delete_backward_word();
        assert_eq!(t.text(), "foo r");
        assert_eq!(t.cursor(), 4);

        // From end, delete the last word only
        let mut t = ta_with("foo bar");
        t.set_cursor(t.text().len());
        t.delete_backward_word();
        assert_eq!(t.text(), "foo ");
        assert_eq!(t.cursor(), 4);

        // kill_to_end_of_line when not at EOL
        let mut t = ta_with("abc\ndef");
        t.set_cursor(/*pos*/ 1); // on first line, middle
        t.kill_to_end_of_line();
        assert_eq!(t.text(), "a\ndef");
        assert_eq!(t.cursor(), 1);

        // kill_to_end_of_line when at EOL deletes newline
        let mut t = ta_with("abc\ndef");
        t.set_cursor(/*pos*/ 3); // EOL of first line
        t.kill_to_end_of_line();
        assert_eq!(t.text(), "abcdef");
        assert_eq!(t.cursor(), 3);

        // kill_to_beginning_of_line from middle of line
        let mut t = ta_with("abc\ndef");
        t.set_cursor(/*pos*/ 5); // on second line, after 'e'
        t.kill_to_beginning_of_line();
        assert_eq!(t.text(), "abc\nef");

        // kill_to_beginning_of_line at beginning of non-first line removes the previous newline
        let mut t = ta_with("abc\ndef");
        t.set_cursor(/*pos*/ 4); // beginning of second line
        t.kill_to_beginning_of_line();
        assert_eq!(t.text(), "abcdef");
        assert_eq!(t.cursor(), 3);
    }

    #[test]
    fn delete_forward_word_variants() {
        let mut t = ta_with("hello   world ");
        t.set_cursor(/*pos*/ 0);
        t.delete_forward_word();
        assert_eq!(t.text(), "   world ");
        assert_eq!(t.cursor(), 0);

        let mut t = ta_with("hello   world ");
        t.set_cursor(/*pos*/ 1);
        t.delete_forward_word();
        assert_eq!(t.text(), "h   world ");
        assert_eq!(t.cursor(), 1);

        let mut t = ta_with("hello   world");
        t.set_cursor(t.text().len());
        t.delete_forward_word();
        assert_eq!(t.text(), "hello   world");
        assert_eq!(t.cursor(), t.text().len());

        let mut t = ta_with("foo   \nbar");
        t.set_cursor(/*pos*/ 3);
        t.delete_forward_word();
        assert_eq!(t.text(), "foo");
        assert_eq!(t.cursor(), 3);

        let mut t = ta_with("foo\nbar");
        t.set_cursor(/*pos*/ 3);
        t.delete_forward_word();
        assert_eq!(t.text(), "foo");
        assert_eq!(t.cursor(), 3);

        let mut t = ta_with("hello   world ");
        t.set_cursor(t.text().len() + 10);
        t.delete_forward_word();
        assert_eq!(t.text(), "hello   world ");
        assert_eq!(t.cursor(), t.text().len());
    }

    #[test]
    fn delete_forward_word_handles_atomic_elements() {
        let mut t = TextArea::new();
        t.insert_element("<element>");
        t.insert_str(" tail");

        t.set_cursor(/*pos*/ 0);
        t.delete_forward_word();
        assert_eq!(t.text(), " tail");
        assert_eq!(t.cursor(), 0);

        let mut t = TextArea::new();
        t.insert_str("   ");
        t.insert_element("<element>");
        t.insert_str(" tail");

        t.set_cursor(/*pos*/ 0);
        t.delete_forward_word();
        assert_eq!(t.text(), " tail");
        assert_eq!(t.cursor(), 0);

        let mut t = TextArea::new();
        t.insert_str("prefix ");
        t.insert_element("<element>");
        t.insert_str(" tail");

        // cursor in the middle of the element, delete_forward_word deletes the element
        let elem_range = t.elements[0].range.clone();
        t.cursor_pos = elem_range.start + (elem_range.len() / 2);
        t.delete_forward_word();
        assert_eq!(t.text(), "prefix  tail");
        assert_eq!(t.cursor(), elem_range.start);
    }

    #[test]
    fn delete_backward_word_respects_word_separators() {
        let mut t = ta_with("path/to/file");
        t.set_cursor(t.text().len());
        t.delete_backward_word();
        assert_eq!(t.text(), "path/to/");
        assert_eq!(t.cursor(), t.text().len());

        t.delete_backward_word();
        assert_eq!(t.text(), "path/to");
        assert_eq!(t.cursor(), t.text().len());

        let mut t = ta_with("foo/ ");
        t.set_cursor(t.text().len());
        t.delete_backward_word();
        assert_eq!(t.text(), "foo");
        assert_eq!(t.cursor(), 3);

        let mut t = ta_with("foo /");
        t.set_cursor(t.text().len());
        t.delete_backward_word();
        assert_eq!(t.text(), "foo ");
        assert_eq!(t.cursor(), 4);
    }

    #[test]
    fn delete_forward_word_respects_word_separators() {
        let mut t = ta_with("path/to/file");
        t.set_cursor(/*pos*/ 0);
        t.delete_forward_word();
        assert_eq!(t.text(), "/to/file");
        assert_eq!(t.cursor(), 0);

        t.delete_forward_word();
        assert_eq!(t.text(), "to/file");
        assert_eq!(t.cursor(), 0);

        let mut t = ta_with("/ foo");
        t.set_cursor(/*pos*/ 0);
        t.delete_forward_word();
        assert_eq!(t.text(), " foo");
        assert_eq!(t.cursor(), 0);

        let mut t = ta_with(" /foo");
        t.set_cursor(/*pos*/ 0);
        t.delete_forward_word();
        assert_eq!(t.text(), "foo");
        assert_eq!(t.cursor(), 0);
    }

    #[test]
    fn yank_restores_last_kill() {
        let mut t = ta_with("hello");
        t.set_cursor(/*pos*/ 0);
        t.kill_to_end_of_line();
        assert_eq!(t.text(), "");
        assert_eq!(t.cursor(), 0);

        t.yank();
        assert_eq!(t.text(), "hello");
        assert_eq!(t.cursor(), 5);

        let mut t = ta_with("hello world");
        t.set_cursor(t.text().len());
        t.delete_backward_word();
        assert_eq!(t.text(), "hello ");
        assert_eq!(t.cursor(), 6);

        t.yank();
        assert_eq!(t.text(), "hello world");
        assert_eq!(t.cursor(), 11);

        let mut t = ta_with("hello");
        t.set_cursor(/*pos*/ 5);
        t.kill_to_beginning_of_line();
        assert_eq!(t.text(), "");
        assert_eq!(t.cursor(), 0);

        t.yank();
        assert_eq!(t.text(), "hello");
        assert_eq!(t.cursor(), 5);
    }

    #[test]
    fn kill_buffer_persists_across_set_text() {
        let mut t = ta_with("restore me");
        t.set_cursor(/*pos*/ 0);
        t.kill_to_end_of_line();
        assert!(t.text().is_empty());

        t.set_text_clearing_elements("/diff");
        t.set_text_clearing_elements("");
        t.yank();

        assert_eq!(t.text(), "restore me");
        assert_eq!(t.cursor(), "restore me".len());
    }

    #[test]
    fn cursor_left_and_right_handle_graphemes() {
        let mut t = ta_with("a👍b");
        t.set_cursor(t.text().len());

        t.move_cursor_left(); // before 'b'
        let after_first_left = t.cursor();
        t.move_cursor_left(); // before '👍'
        let after_second_left = t.cursor();
        t.move_cursor_left(); // before 'a'
        let after_third_left = t.cursor();

        assert!(after_first_left < t.text().len());
        assert!(after_second_left < after_first_left);
        assert!(after_third_left < after_second_left);

        // Move right back to end safely
        t.move_cursor_right();
        t.move_cursor_right();
        t.move_cursor_right();
        assert_eq!(t.cursor(), t.text().len());
    }

    #[test]
    fn control_b_and_f_move_cursor() {
        let mut t = ta_with("abcd");
        t.set_cursor(/*pos*/ 1);

        t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
        assert_eq!(t.cursor(), 2);

        t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
        assert_eq!(t.cursor(), 1);
    }

    #[test]
    fn control_b_f_fallback_control_chars_move_cursor() {
        let mut t = ta_with("abcd");
        t.set_cursor(/*pos*/ 2);

        // Simulate terminals that send C0 control chars without CONTROL modifier.
        // ^B (U+0002) should move left
        t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE));
        assert_eq!(t.cursor(), 1);

        // ^F (U+0006) should move right
        t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE));
        assert_eq!(t.cursor(), 2);
    }

    #[test]
    fn c0_control_chars_respect_unbound_editor_movement() {
        let mut t = ta_with("a\nb");
        t.set_cursor(/*pos*/ 2);
        let mut keymap = RuntimeKeymap::defaults().editor;
        keymap.move_up.clear();

        t.input_with_keymap(
            KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE),
            &keymap,
        );

        assert_eq!(t.cursor(), 2);
    }

    #[test]
    fn c0_control_chars_respect_remapped_editor_movement() {
        let mut t = ta_with("a\nb");
        t.set_cursor(/*pos*/ 0);
        let mut keymap = RuntimeKeymap::defaults().editor;
        keymap.move_up.clear();
        keymap.move_down = vec![key_hint::ctrl(KeyCode::Char('p'))];

        t.input_with_keymap(
            KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE),
            &keymap,
        );

        assert_eq!(t.cursor(), 2);
    }

    #[test]
    fn delete_backward_word_alt_keys() {
        // Test the custom Alt+Ctrl+h binding
        let mut t = ta_with("hello world");
        t.set_cursor(t.text().len()); // cursor at the end
        t.input(KeyEvent::new(
            KeyCode::Char('h'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        ));
        assert_eq!(t.text(), "hello ");
        assert_eq!(t.cursor(), 6);

        // Test the standard Alt+Backspace binding
        let mut t = ta_with("hello world");
        t.set_cursor(t.text().len()); // cursor at the end
        t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
        assert_eq!(t.text(), "hello ");
        assert_eq!(t.cursor(), 6);
    }

    #[test]
    fn shift_backspace_and_shift_delete_keep_grapheme_delete_behavior() {
        let mut t = ta_with("abc");
        t.set_cursor(/*pos*/ 2);

        t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SHIFT));
        assert_eq!(t.text(), "ac");
        assert_eq!(t.cursor(), 1);

        let mut t = ta_with("abc");
        t.set_cursor(/*pos*/ 1);

        t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::SHIFT));
        assert_eq!(t.text(), "ac");
        assert_eq!(t.cursor(), 1);
    }

    #[test]
    fn control_backspace_variants_delete_backward_word() {
        for modifiers in [
            KeyModifiers::CONTROL,
            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
        ] {
            let mut t = ta_with("hello world");
            t.set_cursor(t.text().len());

            t.input(KeyEvent::new(KeyCode::Backspace, modifiers));
            assert_eq!(t.text(), "hello ");
            assert_eq!(t.cursor(), 6);
        }
    }

    #[test]
    fn control_delete_variants_delete_forward_word() {
        for modifiers in [
            KeyModifiers::CONTROL,
            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
        ] {
            let mut t = ta_with("hello world");
            t.set_cursor(/*pos*/ 0);

            t.input(KeyEvent::new(KeyCode::Delete, modifiers));
            assert_eq!(t.text(), " world");
            assert_eq!(t.cursor(), 0);
        }
    }

    #[test]
    fn delete_backward_word_handles_narrow_no_break_space() {
        let mut t = ta_with("32\u{202F}AM");
        t.set_cursor(t.text().len());
        t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
        pretty_assertions::assert_eq!(t.text(), "32\u{202F}");
        pretty_assertions::assert_eq!(t.cursor(), t.text().len());
    }

    #[test]
    fn delete_forward_word_with_without_alt_modifier() {
        let mut t = ta_with("hello world");
        t.set_cursor(/*pos*/ 0);
        t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT));
        assert_eq!(t.text(), " world");
        assert_eq!(t.cursor(), 0);

        let mut t = ta_with("hello");
        t.set_cursor(/*pos*/ 0);
        t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
        assert_eq!(t.text(), "ello");
        assert_eq!(t.cursor(), 0);
    }

    #[test]
    fn delete_forward_word_alt_d() {
        let mut t = ta_with("hello world");
        t.set_cursor(/*pos*/ 6);
        t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT));
        pretty_assertions::assert_eq!(t.text(), "hello ");
        pretty_assertions::assert_eq!(t.cursor(), 6);
    }

    #[test]
    fn control_h_backspace() {
        // Test Ctrl+H as backspace
        let mut t = ta_with("12345");
        t.set_cursor(/*pos*/ 3); // cursor after '3'
        t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
        assert_eq!(t.text(), "1245");
        assert_eq!(t.cursor(), 2);

        // Test Ctrl+H at beginning (should be no-op)
        t.set_cursor(/*pos*/ 0);
        t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
        assert_eq!(t.text(), "1245");
        assert_eq!(t.cursor(), 0);

        // Test Ctrl+H at end
        t.set_cursor(t.text().len());
        t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
        assert_eq!(t.text(), "124");
        assert_eq!(t.cursor(), 3);
    }

    // AltGr is a Windows-only concept; on macOS/Linux this code path is
    // unreachable so the test would always be ignored. Gate at compile-time
    // so it doesn't show up in the `0 ignored` line forever (#1224).
    #[cfg(windows)]
    #[test]
    fn altgr_ctrl_alt_char_inserts_literal() {
        let mut t = ta_with("");
        t.input(KeyEvent::new(
            KeyCode::Char('c'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        ));
        assert_eq!(t.text(), "c");
        assert_eq!(t.cursor(), 1);
    }

    #[test]
    fn cursor_vertical_movement_across_lines_and_bounds() {
        let mut t = ta_with("short\nloooooooooong\nmid");
        // Place cursor on second line, column 5
        let second_line_start = 6; // after first '\n'
        t.set_cursor(second_line_start + 5);

        // Move up: target column preserved, clamped by line length
        t.move_cursor_up();
        assert_eq!(t.cursor(), 5); // first line has len 5

        // Move up again goes to start of text
        t.move_cursor_up();
        assert_eq!(t.cursor(), 0);

        // Move down: from start to target col tracked
        t.move_cursor_down();
        // On first move down, we should land on second line, at col 0 (target col remembered as 0)
        let pos_after_down = t.cursor();
        assert!(pos_after_down >= second_line_start);

        // Move down again to third line; clamp to its length
        t.move_cursor_down();
        let third_line_start = t.text().find("mid").unwrap();
        let third_line_end = third_line_start + 3;
        assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end);

        // Moving down at last line jumps to end
        t.move_cursor_down();
        assert_eq!(t.cursor(), t.text().len());
    }

    #[test]
    fn home_end_and_emacs_style_home_end() {
        let mut t = ta_with("one\ntwo\nthree");
        // Position at middle of second line
        let second_line_start = t.text().find("two").unwrap();
        t.set_cursor(second_line_start + 1);

        t.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ false);
        assert_eq!(t.cursor(), second_line_start);

        // Ctrl-A behavior: if at BOL, go to beginning of previous line
        t.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true);
        assert_eq!(t.cursor(), 0); // beginning of first line

        // Move to EOL of first line
        t.move_cursor_to_end_of_line(/*move_down_at_eol*/ false);
        assert_eq!(t.cursor(), 3);

        // Ctrl-E: if at EOL, go to end of next line
        t.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
        // end of second line ("two") is right before its '\n'
        let end_second_nl = t.text().find("\nthree").unwrap();
        assert_eq!(t.cursor(), end_second_nl);
    }

    #[test]
    fn end_of_line_or_down_at_end_of_text() {
        let mut t = ta_with("one\ntwo");
        // Place cursor at absolute end of the text
        t.set_cursor(t.text().len());
        // Should remain at end without panicking
        t.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
        assert_eq!(t.cursor(), t.text().len());

        // Also verify behavior when at EOL of a non-final line:
        let eol_first_line = 3; // index of '\n' in "one\ntwo"
        t.set_cursor(eol_first_line);
        t.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
        assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line
    }

    #[test]
    fn word_navigation_helpers() {
        let t = ta_with("  alpha  beta   gamma");
        let mut t = t; // make mutable for set_cursor
        // Put cursor after "alpha"
        let after_alpha = t.text().find("alpha").unwrap() + "alpha".len();
        t.set_cursor(after_alpha);
        assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces

        // Put cursor at start of beta
        let beta_start = t.text().find("beta").unwrap();
        t.set_cursor(beta_start);
        assert_eq!(t.end_of_next_word(), beta_start + "beta".len());

        // If at end, end_of_next_word returns len
        t.set_cursor(t.text().len());
        assert_eq!(t.end_of_next_word(), t.text().len());
    }

    #[test]
    fn word_navigation_cjk_each_char_is_boundary() {
        let text = "你好世界";
        let mut t = ta_with(text);

        t.set_cursor(/*pos*/ text.len());
        assert_eq!(t.beginning_of_previous_word(), 9);

        t.set_cursor(/*pos*/ 9);
        assert_eq!(t.beginning_of_previous_word(), 6);

        t.set_cursor(/*pos*/ 6);
        assert_eq!(t.beginning_of_previous_word(), 3);

        t.set_cursor(/*pos*/ 3);
        assert_eq!(t.beginning_of_previous_word(), 0);
    }

    #[test]
    fn word_navigation_cjk_forward() {
        let text = "你好世界";
        let mut t = ta_with(text);

        t.set_cursor(/*pos*/ 0);
        assert_eq!(t.end_of_next_word(), 3);

        t.set_cursor(/*pos*/ 3);
        assert_eq!(t.end_of_next_word(), 6);

        t.set_cursor(/*pos*/ 6);
        assert_eq!(t.end_of_next_word(), 9);

        t.set_cursor(/*pos*/ 9);
        assert_eq!(t.end_of_next_word(), 12);
    }

    #[test]
    fn word_navigation_mixed_ascii_cjk() {
        let text = "hello你好";
        let mut t = ta_with(text);

        t.set_cursor(/*pos*/ 0);
        assert_eq!(t.end_of_next_word(), 5);

        t.set_cursor(/*pos*/ 5);
        assert_eq!(t.end_of_next_word(), 8);

        t.set_cursor(/*pos*/ text.len());
        assert_eq!(t.beginning_of_previous_word(), 8);

        t.set_cursor(/*pos*/ 8);
        assert_eq!(t.beginning_of_previous_word(), 5);

        t.set_cursor(/*pos*/ 5);
        assert_eq!(t.beginning_of_previous_word(), 0);
    }

    #[test]
    fn word_navigation_preserves_separator_breaks_within_unicode_segments() {
        let mut t = ta_with("can't 32.3 foo.bar");

        t.set_cursor(/*pos*/ 5);
        assert_eq!(t.beginning_of_previous_word(), 4);

        t.set_cursor(/*pos*/ 4);
        assert_eq!(t.beginning_of_previous_word(), 3);

        t.set_cursor(/*pos*/ 10);
        assert_eq!(t.beginning_of_previous_word(), 9);

        t.set_cursor(/*pos*/ 18);
        assert_eq!(t.beginning_of_previous_word(), 15);
    }

    #[test]
    fn wrapping_and_cursor_positions() {
        let mut t = ta_with("hello world here");
        let area = Rect::new(0, 0, 6, 10); // width 6 -> wraps words
        // desired height counts wrapped lines
        assert!(t.desired_height(area.width) >= 3);

        // Place cursor in "world"
        let world_start = t.text().find("world").unwrap();
        t.set_cursor(world_start + 3);
        let (_x, y) = t.cursor_pos(area).unwrap();
        assert_eq!(y, 1); // world should be on second wrapped line

        // With state and small height, cursor is mapped onto visible row
        let mut state = TextAreaState::default();
        let small_area = Rect::new(0, 0, 6, 1);
        // First call: cursor not visible -> effective scroll ensures it is
        let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
        assert_eq!(y, 0);

        // Render with state to update actual scroll value
        let mut buf = Buffer::empty(small_area);
        ratatui::widgets::StatefulWidget::render(&t, small_area, &mut buf, &mut state);
        // After render, state.scroll should be adjusted so cursor row fits
        let effective_lines = t.desired_height(small_area.width);
        assert!(state.scroll < effective_lines);
    }

    #[test]
    fn render_highlights_apply_style_without_mutating_text() {
        let t = ta_with("hello world");
        let area = Rect::new(0, 0, 20, 1);
        let mut state = TextAreaState::default();
        let mut buf = Buffer::empty(area);
        let highlight_style = Style::default().add_modifier(ratatui::style::Modifier::REVERSED);

        t.render_ref_styled_with_highlights(
            area,
            &mut buf,
            &mut state,
            Style::default(),
            &[(6..11, highlight_style)],
        );

        assert_eq!(t.text(), "hello world");
        assert!(
            !buf[(0, 0)]
                .style()
                .add_modifier
                .contains(ratatui::style::Modifier::REVERSED)
        );
        assert!(
            buf[(6, 0)]
                .style()
                .add_modifier
                .contains(ratatui::style::Modifier::REVERSED)
        );
        assert!(
            buf[(10, 0)]
                .style()
                .add_modifier
                .contains(ratatui::style::Modifier::REVERSED)
        );
    }

    #[test]
    fn cursor_pos_with_state_basic_and_scroll_behaviors() {
        // Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
        let mut t = ta_with("hello world");
        t.set_cursor(/*pos*/ 3);
        let area = Rect::new(2, 5, 20, 3);
        // Even if an absurd scroll is provided, when content fits the area the
        // effective scroll is 0 and the cursor position matches cursor_pos.
        let bad_state = TextAreaState { scroll: 999 };
        let (x1, y1) = t.cursor_pos(area).unwrap();
        let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap();
        assert_eq!((x2, y2), (x1, y1));

        // Case 2: Cursor below the current window — y should be clamped to the
        // bottom row (area.height - 1) after adjusting effective scroll.
        let mut t = ta_with("one two three four five six");
        // Force wrapping to many visual lines.
        let wrap_width = 4;
        let _ = t.desired_height(wrap_width);
        // Put cursor somewhere near the end so it's definitely below the first window.
        t.set_cursor(t.text().len().saturating_sub(2));
        let small_area = Rect::new(0, 0, wrap_width, 2);
        let state = TextAreaState { scroll: 0 };
        let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
        assert_eq!(y, small_area.y + small_area.height - 1);

        // Case 3: Cursor above the current window — y should be top row (0)
        // when the provided scroll is too large.
        let mut t = ta_with("alpha beta gamma delta epsilon zeta");
        let wrap_width = 5;
        let lines = t.desired_height(wrap_width);
        // Place cursor near start so an excessive scroll moves it to top row.
        t.set_cursor(/*pos*/ 1);
        let area = Rect::new(0, 0, wrap_width, 3);
        let state = TextAreaState {
            scroll: lines.saturating_mul(2),
        };
        let (_x, y) = t.cursor_pos_with_state(area, state).unwrap();
        assert_eq!(y, area.y);
    }

    #[test]
    fn wrapped_navigation_across_visual_lines() {
        let mut t = ta_with("abcdefghij");
        // Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"]
        let _ = t.desired_height(/*width*/ 4);

        // From the very start, moving down should go to the start of the next wrapped line (index 4)
        t.set_cursor(/*pos*/ 0);
        t.move_cursor_down();
        assert_eq!(t.cursor(), 4);

        // Cursor at boundary index 4 should be displayed at start of second wrapped line
        t.set_cursor(/*pos*/ 4);
        let area = Rect::new(0, 0, 4, 10);
        let (x, y) = t.cursor_pos(area).unwrap();
        assert_eq!((x, y), (0, 1));

        // With state and small height, cursor should be visible at row 0, col 0
        let small_area = Rect::new(0, 0, 4, 1);
        let state = TextAreaState::default();
        let (x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
        assert_eq!((x, y), (0, 0));

        // Place cursor in the middle of the second wrapped line ("efgh"), at 'g'
        t.set_cursor(/*pos*/ 6);
        // Move up should go to same column on previous wrapped line -> index 2 ('c')
        t.move_cursor_up();
        assert_eq!(t.cursor(), 2);

        // Move down should return to same position on the next wrapped line -> back to index 6 ('g')
        t.move_cursor_down();
        assert_eq!(t.cursor(), 6);

        // Move down again should go to third wrapped line. Target col is 2, but the line has len 2 -> clamp to end
        t.move_cursor_down();
        assert_eq!(t.cursor(), t.text().len());
    }

    #[test]
    fn cursor_pos_with_state_after_movements() {
        let mut t = ta_with("abcdefghij");
        // Wrap width 4 -> visual lines: abcd | efgh | ij
        let _ = t.desired_height(/*width*/ 4);
        let area = Rect::new(0, 0, 4, 2);
        let mut state = TextAreaState::default();
        let mut buf = Buffer::empty(area);

        // Start at beginning
        t.set_cursor(/*pos*/ 0);
        ratatui::widgets::StatefulWidget::render(&t, area, &mut buf, &mut state);
        let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
        assert_eq!((x, y), (0, 0));

        // Move down to second visual line; should be at bottom row (row 1) within 2-line viewport
        t.move_cursor_down();
        ratatui::widgets::StatefulWidget::render(&t, area, &mut buf, &mut state);
        let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
        assert_eq!((x, y), (0, 1));

        // Move down to third visual line; viewport scrolls and keeps cursor on bottom row
        t.move_cursor_down();
        ratatui::widgets::StatefulWidget::render(&t, area, &mut buf, &mut state);
        let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
        assert_eq!((x, y), (0, 1));

        // Move up to second visual line; with current scroll, it appears on top row
        t.move_cursor_up();
        ratatui::widgets::StatefulWidget::render(&t, area, &mut buf, &mut state);
        let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
        assert_eq!((x, y), (0, 0));

        // Column preservation across moves: set to col 2 on first line, move down
        t.set_cursor(/*pos*/ 2);
        ratatui::widgets::StatefulWidget::render(&t, area, &mut buf, &mut state);
        let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap();
        assert_eq!((x0, y0), (2, 0));
        t.move_cursor_down();
        ratatui::widgets::StatefulWidget::render(&t, area, &mut buf, &mut state);
        let (x1, y1) = t.cursor_pos_with_state(area, state).unwrap();
        assert_eq!((x1, y1), (2, 1));
    }

    #[test]
    fn wrapped_navigation_with_newlines_and_spaces() {
        // Include spaces and an explicit newline to exercise boundaries
        let mut t = ta_with("word1  word2\nword3");
        // Width 6 will wrap "word1  " and then "word2" before the newline
        let _ = t.desired_height(/*width*/ 6);

        // Put cursor on the second wrapped line before the newline, at column 1 of "word2"
        let start_word2 = t.text().find("word2").unwrap();
        t.set_cursor(start_word2 + 1);

        // Up should go to first wrapped line, column 1 -> index 1
        t.move_cursor_up();
        assert_eq!(t.cursor(), 1);

        // Down should return to the same visual column on "word2"
        t.move_cursor_down();
        assert_eq!(t.cursor(), start_word2 + 1);

        // Down again should cross the logical newline to the next visual line ("word3"), clamped to its length if needed
        t.move_cursor_down();
        let start_word3 = t.text().find("word3").unwrap();
        assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len());
    }

    #[test]
    fn wrapped_navigation_with_wide_graphemes() {
        // Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries
        let mut t = ta_with("👍👍👍👍");
        let _ = t.desired_height(/*width*/ 3);

        // Put cursor after the second emoji (which should be on first wrapped line)
        t.set_cursor("👍👍".len());

        // Move down should go to the start of the next wrapped line (same column preserved but clamped)
        t.move_cursor_down();
        // We expect to land somewhere within the third emoji or at the start of it
        let pos_after_down = t.cursor();
        assert!(pos_after_down >= "👍👍".len());

        // Moving up should take us back to the original position
        t.move_cursor_up();
        assert_eq!(t.cursor(), "👍👍".len());
    }

    // koda adaptation: see the cfg(any()) note above `use rand::prelude::*;`
    // — this property test is gated out pending a follow-up to add rand+chrono
    // dev-deps. Verbatim codex test preserved below for revival.
    #[cfg(any())]
    #[test]
    fn fuzz_textarea_randomized() {
        // Deterministic seed for reproducibility
        // Seed the RNG based on the current day in Pacific Time (PST/PDT). This
        // keeps the fuzz test deterministic within a day while still varying
        // day-to-day to improve coverage.
        let pst_today_seed: u64 = (chrono::Utc::now() - chrono::Duration::hours(8))
            .date_naive()
            .and_hms_opt(0, 0, 0)
            .unwrap()
            .and_utc()
            .timestamp() as u64;
        let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed);

        for _case in 0..500 {
            let mut ta = TextArea::new();
            let mut state = TextAreaState::default();
            // Track element payloads we insert. Payloads use characters '[' and ']' which
            // are not produced by rand_grapheme(), avoiding accidental collisions.
            let mut elem_texts: Vec<String> = Vec::new();
            let mut next_elem_id: usize = 0;
            // Start with a random base string
            let base_len = rng.random_range(0..30);
            let mut base = String::new();
            for _ in 0..base_len {
                base.push_str(&rand_grapheme(&mut rng));
            }
            ta.set_text_clearing_elements(&base);
            // Choose a valid char boundary for initial cursor
            let mut boundaries: Vec<usize> = vec![0];
            boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1));
            boundaries.push(ta.text().len());
            let init = boundaries[rng.random_range(0..boundaries.len())];
            ta.set_cursor(init);

            let mut width: u16 = rng.random_range(1..=12);
            let mut height: u16 = rng.random_range(1..=4);

            for _step in 0..60 {
                // Mostly stable width/height, occasionally change
                if rng.random_bool(0.1) {
                    width = rng.random_range(1..=12);
                }
                if rng.random_bool(0.1) {
                    height = rng.random_range(1..=4);
                }

                // Pick an operation
                match rng.random_range(0..18) {
                    0 => {
                        // insert small random string at cursor
                        let len = rng.random_range(0..6);
                        let mut s = String::new();
                        for _ in 0..len {
                            s.push_str(&rand_grapheme(&mut rng));
                        }
                        ta.insert_str(&s);
                    }
                    1 => {
                        // replace_range with small random slice
                        let mut b: Vec<usize> = vec![0];
                        b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1));
                        b.push(ta.text().len());
                        let i1 = rng.random_range(0..b.len());
                        let i2 = rng.random_range(0..b.len());
                        let (start, end) = if b[i1] <= b[i2] {
                            (b[i1], b[i2])
                        } else {
                            (b[i2], b[i1])
                        };
                        let insert_len = rng.random_range(0..=4);
                        let mut s = String::new();
                        for _ in 0..insert_len {
                            s.push_str(&rand_grapheme(&mut rng));
                        }
                        let before = ta.text().len();
                        // If the chosen range intersects an element, replace_range will expand to
                        // element boundaries, so the naive size delta assertion does not hold.
                        let intersects_element = elem_texts.iter().any(|payload| {
                            if let Some(pstart) = ta.text().find(payload) {
                                let pend = pstart + payload.len();
                                pstart < end && pend > start
                            } else {
                                false
                            }
                        });
                        ta.replace_range(start..end, &s);
                        if !intersects_element {
                            let after = ta.text().len();
                            assert_eq!(
                                after as isize,
                                before as isize + (s.len() as isize) - ((end - start) as isize)
                            );
                        }
                    }
                    2 => ta.delete_backward(rng.random_range(0..=3)),
                    3 => ta.delete_forward(rng.random_range(0..=3)),
                    4 => ta.delete_backward_word(),
                    5 => ta.kill_to_beginning_of_line(),
                    6 => ta.kill_to_end_of_line(),
                    7 => ta.move_cursor_left(),
                    8 => ta.move_cursor_right(),
                    9 => ta.move_cursor_up(),
                    10 => ta.move_cursor_down(),
                    11 => ta.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true),
                    12 => ta.move_cursor_to_end_of_line(/*move_down_at_eol*/ true),
                    13 => {
                        // Insert an element with a unique sentinel payload
                        let payload =
                            format!("[[EL#{}:{}]]", next_elem_id, rng.random_range(1000..9999));
                        next_elem_id += 1;
                        ta.insert_element(&payload);
                        elem_texts.push(payload);
                    }
                    14 => {
                        // Try inserting inside an existing element (should clamp to boundary)
                        if let Some(payload) = elem_texts.choose(&mut rng).cloned()
                            && let Some(start) = ta.text().find(&payload)
                        {
                            let end = start + payload.len();
                            if end - start > 2 {
                                let pos = rng.random_range(start + 1..end - 1);
                                let ins = rand_grapheme(&mut rng);
                                ta.insert_str_at(pos, &ins);
                            }
                        }
                    }
                    15 => {
                        // Replace a range that intersects an element -> whole element should be replaced
                        if let Some(payload) = elem_texts.choose(&mut rng).cloned()
                            && let Some(start) = ta.text().find(&payload)
                        {
                            let end = start + payload.len();
                            // Create an intersecting range [start-δ, end-δ2)
                            let mut s = start.saturating_sub(rng.random_range(0..=2));
                            let mut e = (end + rng.random_range(0..=2)).min(ta.text().len());
                            // Align to char boundaries to satisfy String::replace_range contract
                            let txt = ta.text();
                            while s > 0 && !txt.is_char_boundary(s) {
                                s -= 1;
                            }
                            while e < txt.len() && !txt.is_char_boundary(e) {
                                e += 1;
                            }
                            if s < e {
                                // Small replacement text
                                let mut srep = String::new();
                                for _ in 0..rng.random_range(0..=2) {
                                    srep.push_str(&rand_grapheme(&mut rng));
                                }
                                ta.replace_range(s..e, &srep);
                            }
                        }
                    }
                    16 => {
                        // Try setting the cursor to a position inside an element; it should clamp out
                        if let Some(payload) = elem_texts.choose(&mut rng).cloned()
                            && let Some(start) = ta.text().find(&payload)
                        {
                            let end = start + payload.len();
                            if end - start > 2 {
                                let pos = rng.random_range(start + 1..end - 1);
                                ta.set_cursor(pos);
                            }
                        }
                    }
                    _ => {
                        // Jump to word boundaries
                        if rng.random_bool(0.5) {
                            let p = ta.beginning_of_previous_word();
                            ta.set_cursor(p);
                        } else {
                            let p = ta.end_of_next_word();
                            ta.set_cursor(p);
                        }
                    }
                }

                // Sanity invariants
                assert!(ta.cursor() <= ta.text().len());

                // Element invariants
                for payload in &elem_texts {
                    if let Some(start) = ta.text().find(payload) {
                        let end = start + payload.len();
                        // 1) Text inside elements matches the initially set payload
                        assert_eq!(&ta.text()[start..end], payload);
                        // 2) Cursor is never strictly inside an element
                        let c = ta.cursor();
                        assert!(
                            c <= start || c >= end,
                            "cursor inside element: {start}..{end} at {c}"
                        );
                    }
                }

                // Render and compute cursor positions; ensure they are in-bounds and do not panic
                let area = Rect::new(0, 0, width, height);
                // Stateless render into an area tall enough for all wrapped lines
                let total_lines = ta.desired_height(width);
                let full_area = Rect::new(0, 0, width, total_lines.max(1));
                let mut buf = Buffer::empty(full_area);
                ratatui::widgets::Widget::render(&ta, full_area, &mut buf);

                // cursor_pos: x must be within width when present
                let _ = ta.cursor_pos(area);

                // cursor_pos_with_state: always within viewport rows
                let (_x, _y) = ta
                    .cursor_pos_with_state(area, state)
                    .unwrap_or((area.x, area.y));

                // Stateful render should not panic, and updates scroll
                let mut sbuf = Buffer::empty(area);
                ratatui::widgets::StatefulWidget::render(&ta, area, &mut sbuf, &mut state);

                // After wrapping, desired height equals the number of lines we would render without scroll
                let total_lines = total_lines as usize;
                // state.scroll must not exceed total_lines when content fits within area height
                if (height as usize) >= total_lines {
                    assert_eq!(state.scroll, 0);
                }
            }
        }
    }

    // ── PR 6 of #1178: masked render path ────────────────────────────
    //
    // `render_ref_masked` is the display-layer protection for API key
    // entry. The buffer underneath stays cleartext (the wizard handler
    // reads it via `text()`); only the rendered glyphs change. These
    // tests pin both halves of that contract.

    #[test]
    fn render_ref_masked_replaces_all_visible_chars_with_mask_glyph() {
        let t = ta_with("sk-secret-api-key-12345");
        let area = Rect::new(0, 0, 30, 1);
        let mut state = TextAreaState::default();
        let mut buf = Buffer::empty(area);
        t.render_ref_masked(area, &mut buf, &mut state, '*', Style::default());

        // First 23 cells (the secret length) must all be `*`. Cells
        // beyond the text length stay blank — we only care that NONE
        // of the original cleartext leaked.
        let secret_len = "sk-secret-api-key-12345".len();
        for x in 0..secret_len as u16 {
            let ch = buf[(area.x + x, area.y)].symbol();
            assert_eq!(
                ch, "*",
                "position {x} leaked the cleartext (got {ch:?}, expected `*`)"
            );
        }
        // Sanity: nothing in the rendered area equals any plaintext char.
        for x in 0..area.width {
            let ch = buf[(area.x + x, area.y)].symbol();
            assert!(
                ch == "*" || ch == " " || ch.is_empty(),
                "unexpected glyph {ch:?} at x={x}"
            );
        }
    }

    #[test]
    fn render_ref_masked_does_not_mutate_underlying_text() {
        let mut t = ta_with("sk-very-secret");
        let area = Rect::new(0, 0, 30, 1);
        let mut state = TextAreaState::default();
        let mut buf = Buffer::empty(area);
        t.render_ref_masked(area, &mut buf, &mut state, '*', Style::default());
        assert_eq!(
            t.text(),
            "sk-very-secret",
            "masked render is display-only; the wizard handler must still\n             read back the cleartext via text()"
        );
        // The element list is also untouched (no element is created or
        // dropped by the render pass).
        assert!(t.text_elements().is_empty());
        // And we can still mutate after a masked render — the textarea
        // remains a fully-functional editable buffer.
        t.insert_str("-extra");
        assert_eq!(t.text(), "sk-very-secret-extra");
    }

    // ── #1220: hardware cursor positioning ────────────────────────────
    //
    // The renderer (`tui_viewport::draw_viewport`) consumes
    // `cursor_pos_with_state` and forwards it to
    // `Frame::set_cursor_position`. Without that wiring the cursor is
    // hidden, CJK/IME preedit composes at the screen origin, and screen
    // readers can't track input. These tests pin the contract by driving
    // a `Terminal<TestBackend>` end-to-end and asserting the backend's
    // recorded cursor matches our `cursor_pos` math.

    #[test]
    fn draw_pipeline_parks_terminal_cursor_at_caret() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(40, 5);
        let mut terminal = Terminal::new(backend).unwrap();
        let mut ta = ta_with("hello world");
        ta.set_cursor(5); // caret right after "hello"

        terminal
            .draw(|f| {
                let area = Rect::new(2, 1, 30, 1); // offset to catch off-by-one
                f.render_widget(&ta, area);
                if let Some((x, y)) = ta.cursor_pos(area) {
                    f.set_cursor_position((x, y));
                }
            })
            .unwrap();

        let cursor = terminal.get_cursor_position().unwrap();
        // x = area.x (2) + display width of "hello" (5) = 7
        // y = area.y (1) + screen row 0 = 1
        assert_eq!((cursor.x, cursor.y), (7, 1));
    }

    #[test]
    fn draw_pipeline_parks_cursor_at_origin_when_empty() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        // Empty composer: cursor must still land at the start of the
        // input area so the user can see where typing will begin
        // (and IME/screen readers can find it).
        let backend = TestBackend::new(40, 5);
        let mut terminal = Terminal::new(backend).unwrap();
        let ta = TextArea::new();

        terminal
            .draw(|f| {
                let area = Rect::new(2, 1, 30, 1);
                f.render_widget(&ta, area);
                if let Some((x, y)) = ta.cursor_pos(area) {
                    f.set_cursor_position((x, y));
                }
            })
            .unwrap();

        let cursor = terminal.get_cursor_position().unwrap();
        assert_eq!((cursor.x, cursor.y), (2, 1));
    }
}
