abstracttui 0.3.7

A reactive, compositor-grade terminal UI engine: fine-grained signals, layered rendering with damage tracking, images (kitty/iTerm2/sixel/mosaic), software-rasterized 3D (GLB), themes and animation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
//! TextInput: single-line editable text field.
//!
//! Editing model: insert/overwrite at a cursor, selection via
//! Shift+arrows (anchor + cursor), word jumps via Alt+arrows, Home/End,
//! Backspace/Delete, whole-`Paste` insertion (never per-char synthesis),
//! horizontal scroll keeps the cursor visible, `on_change` after every
//! edit, `on_submit` on Enter.
//!
//! CLUSTER-ATOMIC EDITING (RT3-2 closed): cursor positions index
//! GRAPHEME CLUSTERS via `text::segments` — a combining sequence or ZWJ
//! emoji family is ONE cursor stop, one Backspace, one selection step;
//! widths come from the same authority, so columns and rendering agree.
//! There is no IME path: composed input arrives as whatever the terminal
//! sends (kitty text events land as chars); dead-key composition happens
//! terminal-side or not at all.
//!
//! MASKED MODE (0510 §masked): `.masked(true)` substitutes one `•` per
//! grapheme cluster in BOTH the draw and the `access_value` export —
//! the accessibility snapshot is shipped off-process by the
//! control-plane band, so a masked field must never export plaintext
//! through the semantic tree (PLATFORM cycle-2 F2). Editing, selection,
//! cursor math, and paste are untouched: masking is a presentation +
//! export substitution, never a second editing model. One deliberate
//! editing-model exception (cycle-3 review F7): WORD JUMPS degrade to
//! whole-field jumps — `word_step` over the real text would park the
//! caret on the secret's word boundaries, telegraphing word count and
//! word lengths to a shoulder-surfer through cursor motion alone, so a
//! masked field treats the whole value as ONE word (the native
//! password-field convention).
//!
//! OWNER: REACT.

use std::cell::RefCell;
use std::rc::Rc;

use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::{Scope, Signal};
use crate::render::Style;
use crate::theme::TokenSet;
use crate::ui::{dyn_view, Element, EventCtx, Key, Mods, Phase, UiEvent};

#[path = "paste_hook.rs"]
mod paste_hook;

pub use paste_hook::PasteAction;
pub(crate) use paste_hook::{run_paste_hook, PasteHook, PasteHookFn};

/// Boxed text callback (`on_change`/`on_submit` builder slots).
pub(crate) type BoxedTextFn = Box<dyn FnMut(&str)>;
/// The same callback SHARED between the key handler and Enter
/// submission (RT4-2 hygiene aliases; HRTB over the borrowed argument
/// comes from the inner `dyn FnMut(&str)`). `pub(crate)`: `TextArea`
/// reuses the alias + `notify` for the same borrow discipline.
pub(crate) type TextCallback = Rc<RefCell<Option<BoxedTextFn>>>;

/// The single-line text editor: bind [`value`](TextInput::value) to a
/// `Signal<String>` and the field is controlled — cursor movement,
/// Shift+arrow selection, word jumps, clipboard paste and masked
/// (secret) mode come built in.
///
/// [`on_change`](TextInput::on_change) fires after every edit,
/// [`on_submit`](TextInput::on_submit) on Enter; the placeholder
/// renders `text_faint` and disappears on first input. For a multiline
/// composer that grows with content, use
/// [`TextArea`](crate::widgets::TextArea). The canonical build is
/// `.view(cx)`; see the [module docs](crate::widgets::input).
pub struct TextInput {
    value: Option<Signal<String>>,
    placeholder: String,
    placeholder_while_focused: bool,
    masked: bool,
    layout: Option<LayoutStyle>,
    on_change: Option<BoxedTextFn>,
    on_submit: Option<BoxedTextFn>,
    on_paste: Option<PasteHookFn>,
}

/// Editing state shared between the key handler and the renderer.
#[derive(Copy, Clone)]
struct Caret {
    /// CLUSTER index of the cursor (0..=cluster_count).
    cursor: usize,
    /// Selection anchor (cluster index); None = no selection.
    anchor: Option<usize>,
    /// First visible COLUMN (horizontal scroll).
    scroll: i32,
}

/// Cluster geometry of a string, computed once per edit/draw from
/// `text::segments` (THE cluster/width authority): byte boundaries
/// (`len+1` entries) and per-cluster display widths.
///
/// `pub(crate)`: `TextArea` (textarea.rs) reuses this map per visual
/// row — one cluster-math authority for both editors (backlog 0120's
/// "reuse, not re-derive" requirement).
pub(crate) struct ClusterMap {
    bounds: Vec<usize>,
    widths: Vec<i32>,
}

impl ClusterMap {
    pub(crate) fn of(text: &str) -> ClusterMap {
        let mut bounds = Vec::new();
        let mut widths = Vec::new();
        for seg in crate::text::segments(text) {
            bounds.push(seg.offset);
            widths.push(seg.width);
        }
        bounds.push(text.len());
        ClusterMap { bounds, widths }
    }

    /// Cluster count.
    pub(crate) fn len(&self) -> usize {
        self.widths.len()
    }

    /// Byte offset of cluster index `idx` (== text.len() at the end).
    pub(crate) fn byte(&self, idx: usize) -> usize {
        self.bounds[idx.min(self.len())]
    }

    /// Display column where cluster `idx` starts.
    pub(crate) fn col(&self, idx: usize) -> i32 {
        self.widths[..idx.min(self.len())].iter().sum()
    }

    /// Cursor position after the content ending at byte `byte_end`. When
    /// the byte lands MID-cluster (an inserted scalar merged into its
    /// neighbor — ZWJ, combining mark), the cursor snaps past the whole
    /// merged cluster: positions are cluster boundaries, never interiors.
    pub(crate) fn cluster_after(&self, byte_end: usize) -> usize {
        self.bounds.partition_point(|&b| b < byte_end)
    }
}

impl TextInput {
    pub fn new() -> TextInput {
        TextInput {
            value: None,
            placeholder: String::new(),
            placeholder_while_focused: false,
            masked: false,
            layout: None,
            on_change: None,
            on_submit: None,
            on_paste: None,
        }
    }

    /// Bind an external value signal (owned elsewhere); default is an
    /// internal one.
    pub fn value(mut self, value: Signal<String>) -> TextInput {
        self.value = Some(value);
        self
    }

    pub fn placeholder(mut self, text: impl Into<String>) -> TextInput {
        self.placeholder = text.into();
        self
    }

    /// Paint the placeholder while focused-and-empty too, beside the
    /// caret (backlog first-app/0291 — `TextArea` parity). Default OFF:
    /// the classic yield-to-caret rule keeps existing apps
    /// byte-identical; see
    /// [`TextArea::placeholder_while_focused`](crate::widgets::TextArea::placeholder_while_focused)
    /// for the full rationale.
    pub fn placeholder_while_focused(mut self, on: bool) -> TextInput {
        self.placeholder_while_focused = on;
        self
    }

    /// Secret/password mode: the DRAW substitutes one `•` per grapheme
    /// cluster (count-honest: a ZWJ family is one bullet; each bullet
    /// occupies its cluster's own width so scroll/cursor geometry is
    /// byte-identical to the unmasked field), and `access_value` — the
    /// accessibility/automation export, a leak surface shipped
    /// off-process — exports the same bullets, never the plaintext.
    /// Editing, selection, cursor math, and paste are untouched; the
    /// bound value signal still holds the real text (the app owns it).
    /// One exception: Alt+arrow WORD jumps treat the whole value as a
    /// single word (they go to start/end, like Home/End, including
    /// Shift extension) — real word boundaries would reveal the
    /// secret's word structure through caret positions.
    /// The placeholder is not secret and renders as usual. A reveal
    /// toggle is a rebuild with `masked(false)` (wrap the field in your
    /// `dyn_view_scoped` on the reveal signal). The
    /// [`on_paste`](TextInput::on_paste) intercept still fires in
    /// masked fields — return [`PasteAction::Consume`] to block
    /// pasting into a password field.
    pub fn masked(mut self, masked: bool) -> TextInput {
        self.masked = masked;
        self
    }

    pub fn layout(mut self, layout: LayoutStyle) -> TextInput {
        self.layout = Some(layout);
        self
    }

    pub fn on_change(mut self, f: impl FnMut(&str) + 'static) -> TextInput {
        self.on_change = Some(Box::new(f));
        self
    }

    pub fn on_submit(mut self, f: impl FnMut(&str) + 'static) -> TextInput {
        self.on_submit = Some(Box::new(f));
        self
    }

    /// Intercept pastes BEFORE insertion (backlog first-app/0273): the
    /// hook receives the RAW paste text exactly as the terminal
    /// delivered it (line breaks intact — the single-line fold to
    /// spaces happens only on the [`PasteAction::Insert`] path) and
    /// decides. [`PasteAction::Insert`] = today's behavior,
    /// byte-identical; [`PasteAction::Consume`] = the field inserts
    /// NOTHING and the event is consumed — the app already acted (a
    /// terminal file DROP arrives as a paste of the file's path; see
    /// [`input::paste::classify`](crate::input::paste::classify)).
    ///
    /// The hook fires in [`masked`](TextInput::masked) fields too — an
    /// app may want to BLOCK pastes into password fields (return
    /// `Consume` unconditionally). Disposal-safe from both arms: a
    /// hook that disposes the field's scope is treated as consumed
    /// even when it answers `Insert` (nothing left to insert into).
    pub fn on_paste(mut self, f: impl FnMut(&str) -> PasteAction + 'static) -> TextInput {
        self.on_paste = Some(Box::new(f));
        self
    }

    /// Canonical one-call build (cycle 8): tokens resolve from the
    /// app's THEME CONTEXT (a tracked read — building inside a
    /// `dyn_view` re-renders on theme switch) and the finished `View`
    /// comes back ready for `.child(..)`. Use `element(cx, &tokens)`
    /// when you need explicit theming or extra Element customization.
    pub fn view(self, cx: Scope) -> crate::ui::View {
        let t = crate::widgets::theme_tokens(cx);
        self.element(cx, &t).build()
    }

    pub fn element(self, cx: Scope, t: &TokenSet) -> Element {
        // Style guide §3.3: the input is a FRAMED widget — side strokes
        // `border` -> `border_focus` on focus (the frame carries focus;
        // the ground never doubles as a focus signal), placeholder
        // `text_faint`, caret = the `cursor` token.
        let text_fg = t.text;
        let ground = t.surface;
        let stroke = t.border;
        let stroke_focus = t.border_focus;
        let placeholder_fg = t.text_faint;
        let sel_bg = t.selection_bg;
        let sel_fg = t.selection_fg;
        let cursor_bg = t.cursor;

        let value = self.value.unwrap_or_else(|| cx.signal(String::new()));
        let caret = cx.signal(Caret {
            cursor: 0,
            anchor: None,
            scroll: 0,
        });
        let focused = cx.signal(false);
        let placeholder = self.placeholder;
        let placeholder_while_focused = self.placeholder_while_focused;
        let masked = self.masked;
        let on_change: TextCallback = Rc::new(RefCell::new(self.on_change));
        let on_submit: TextCallback = Rc::new(RefCell::new(self.on_submit));
        let on_paste: PasteHook = Rc::new(RefCell::new(self.on_paste));

        // shrink 0: the input's one row never vanishes under column
        // overflow (0240 #2); width stays flexible through grow.
        let layout = self.layout.unwrap_or_else(|| {
            LayoutStyle::default()
                .height(Dimension::Cells(1))
                .grow(1.0)
                .shrink(0.0)
        });

        let handler = {
            let on_change = on_change.clone();
            move |ctx: &mut EventCtx, ev: &UiEvent| {
                // Text area = rect minus the two stroke columns.
                let width = (ctx.current_rect().w - 2).max(1);
                match ev {
                    UiEvent::Key(k) => {
                        if edit_key(
                            k.key, k.mods, value, caret, width, masked, &on_change, &on_submit,
                        ) {
                            ctx.stop_propagation();
                        }
                    }
                    UiEvent::Paste(s) => {
                        // Paste intercept (0273): the hook sees the RAW
                        // text before any insertion and decides; it
                        // fires in masked fields too. Unbound = None =
                        // today's path, byte-identical.
                        match run_paste_hook(&on_paste, s) {
                            Some(PasteAction::Consume) => {
                                ctx.stop_propagation();
                                return;
                            }
                            // The hook may have disposed this field's
                            // scope while answering Insert: with the
                            // signals gone there is nothing to insert
                            // into — consume instead of panicking on
                            // dead signals (the 0297 law, interceptor
                            // arm).
                            Some(PasteAction::Insert) if !value.is_alive() => {
                                ctx.stop_propagation();
                                return;
                            }
                            Some(PasteAction::Insert) | None => {}
                        }
                        // Single-line field: fold line breaks to spaces.
                        let clean: String = s
                            .chars()
                            .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
                            .collect();
                        insert_text(&clean, value, caret, width);
                        notify(&on_change, value);
                        ctx.stop_propagation();
                    }
                    _ => {}
                }
            }
        };

        Element::new()
            .style(layout)
            .role(crate::ui::Role::Input)
            .access_label(placeholder.clone())
            .access_value(move || {
                // Masked fields redact AT THE WIDGET (0510 §masked):
                // this closure is the semantic-tree export the
                // control-plane band ships off-process — plaintext must
                // never leave through it. Bullets keep the
                // grapheme-cluster count (and nothing else).
                if masked {
                    value.with_untracked(|v| "•".repeat(crate::text::segments(v).count()))
                } else {
                    value.get_untracked()
                }
            })
            .focusable()
            .focus_signal(focused)
            .on(Phase::Bubble, handler)
            .child(dyn_view(
                LayoutStyle::default()
                    .width(Dimension::Percent(1.0))
                    .height(Dimension::Cells(1)),
                move || {
                    let text = value.get();
                    let caret_now = caret.get();
                    let focused = focused.get();
                    let placeholder = placeholder.clone();
                    Element::new()
                        .style(LayoutStyle::default().width(Dimension::Percent(1.0)))
                        .draw(move |canvas, rect| {
                            if rect.is_empty() || rect.w < 3 {
                                return;
                            }
                            let bg = ground;
                            canvas.fill_styled(rect, ' ', &Style::new().fg(text_fg).bg(bg));
                            // Frame strokes (§3.2 bordered row): border ->
                            // border_focus on focus. One glyph per side in
                            // a 1-row field.
                            let stroke_style = Style::new()
                                .fg(if focused { stroke_focus } else { stroke })
                                .bg(bg);
                            canvas.print_styled(rect.origin(), "▐", &stroke_style);
                            canvas.print_styled(
                                crate::base::Point::new(rect.right() - 1, rect.y),
                                "▌",
                                &stroke_style,
                            );
                            let tx = rect.x + 1; // text area start
                            let tw = rect.w - 2;
                            // Both placeholder branches clip to the
                            // interior (first-app/0284): draw closures
                            // clip to damage regions, not element rects —
                            // an unbounded print overwrote the right
                            // stroke and escaped the rect when narrow.
                            if text.is_empty() && !focused {
                                canvas.print_styled(
                                    crate::base::Point::new(tx, rect.y),
                                    &crate::text::truncate_ellipsis(&placeholder, tw),
                                    &Style::new().fg(placeholder_fg).bg(bg),
                                );
                                return;
                            }
                            // Focused-and-empty opt-in (first-app/0291):
                            // hint one cell PAST the caret cell, same ink;
                            // the trailing-cursor paint below keeps the
                            // caret block visible at column 0. `tw > 1`
                            // guards the one-column degenerate field.
                            if text.is_empty() && focused && placeholder_while_focused && tw > 1 {
                                canvas.print_styled(
                                    crate::base::Point::new(tx + 1, rect.y),
                                    &crate::text::truncate_ellipsis(&placeholder, tw - 1),
                                    &Style::new().fg(placeholder_fg).bg(bg),
                                );
                            }
                            // Cluster-indexed paint: selection and cursor
                            // highlight whole clusters — a wide emoji
                            // cursor is a two-cell block, never half.
                            let (sel_lo, sel_hi) = selection_range(&caret_now);
                            let mut col = -caret_now.scroll;
                            let mut count = 0usize;
                            for (i, seg) in crate::text::segments(&text).enumerate() {
                                count = i + 1;
                                let w = seg.width;
                                if w > 0 && col + w > 0 && col + w <= tw {
                                    let selected = i >= sel_lo && i < sel_hi;
                                    let at_cursor = focused && i == caret_now.cursor;
                                    let style = if at_cursor {
                                        Style::new().fg(bg).bg(cursor_bg)
                                    } else if selected {
                                        Style::new().fg(sel_fg).bg(sel_bg)
                                    } else {
                                        Style::new().fg(text_fg).bg(bg)
                                    };
                                    if masked {
                                        // One bullet per cluster in the
                                        // cluster's own width slot —
                                        // count-honest, geometry
                                        // identical to the plain draw
                                        // (padding cells carry the same
                                        // style so selection/cursor
                                        // blocks stay whole).
                                        canvas.print_styled(
                                            crate::base::Point::new(tx + col, rect.y),
                                            "•",
                                            &style,
                                        );
                                        for pad in 1..w {
                                            canvas.print_styled(
                                                crate::base::Point::new(tx + col + pad, rect.y),
                                                " ",
                                                &style,
                                            );
                                        }
                                    } else {
                                        canvas.print_styled(
                                            crate::base::Point::new(tx + col, rect.y),
                                            seg.cluster,
                                            &style,
                                        );
                                    }
                                }
                                col += w;
                            }
                            // Cursor past the last cluster: a styled blank.
                            if focused && caret_now.cursor >= count && col < tw && col >= 0 {
                                canvas.print_styled(
                                    crate::base::Point::new(tx + col, rect.y),
                                    " ",
                                    &Style::new().fg(bg).bg(cursor_bg),
                                );
                            }
                        })
                        .build()
                },
            ))
    }
}

impl Default for TextInput {
    fn default() -> Self {
        TextInput::new()
    }
}

/// Run a text callback with the CURRENT value. The value is cloned OUT
/// first — running user code inside `with_untracked` holds the cell
/// borrow, so a handler that writes the same signal (clear-on-submit,
/// input masks) would hit "RefCell already borrowed". Found by the
/// cycle-7 sixty-line-app proof; a String clone is the honest price.
pub(crate) fn notify(cb: &TextCallback, value: Signal<String>) {
    let snapshot = value.get_untracked();
    if let Some(f) = cb.borrow_mut().as_mut() {
        f(&snapshot);
    }
}

fn selection_range(c: &Caret) -> (usize, usize) {
    match c.anchor {
        Some(a) if a != c.cursor => (a.min(c.cursor), a.max(c.cursor)),
        _ => (usize::MAX, usize::MAX), // empty range
    }
}

/// Keep the cursor column visible inside `width` (1-cell margin).
fn adjust_scroll(caret: &mut Caret, map: &ClusterMap, width: i32) {
    let col = map.col(caret.cursor);
    if col < caret.scroll {
        caret.scroll = col;
    }
    if col >= caret.scroll + width {
        caret.scroll = col - width + 1;
    }
    caret.scroll = caret.scroll.max(0);
}

/// Delete the selection if any; returns true when something was removed.
/// Cluster indices convert to byte ranges through the map — the removal
/// is cluster-atomic by construction.
fn delete_selection(text: &mut String, caret: &mut Caret) -> bool {
    let (lo, hi) = selection_range(caret);
    if lo == usize::MAX {
        // Same rule as the TextArea's (1310): an anchor parked on the
        // caret is not a selection, and leaving it there turns the next
        // delete into a phantom one-cluster selection.
        caret.anchor = None;
        return false;
    }
    let map = ClusterMap::of(text);
    text.replace_range(map.byte(lo)..map.byte(hi), "");
    caret.cursor = lo;
    caret.anchor = None;
    true
}

fn insert_text(s: &str, value: Signal<String>, caret: Signal<Caret>, width: i32) {
    let mut c = caret.get_untracked();
    value.update(|text| {
        delete_selection(text, &mut c);
        let map = ClusterMap::of(text);
        let insert_at = c.cursor.min(map.len());
        let insert_byte = map.byte(insert_at);
        text.insert_str(insert_byte, s);
        // Re-anchor on the POST-insert map: an inserted ZWJ/combining
        // scalar can MERGE clusters, so `old index + inserted clusters`
        // may not exist — the byte end always does.
        let map = ClusterMap::of(text);
        c.cursor = map.cluster_after(insert_byte + s.len());
        adjust_scroll(&mut c, &map, width);
    });
    caret.set(c);
}

/// Word-ness of the cluster starting at byte `at` (first scalar decides —
/// a ZWJ family is "not word", which groups emoji runs like separators).
/// `pub(crate)`: shared with `TextArea` (one word-jump policy).
pub(crate) fn cluster_is_word(text: &str, at: usize) -> bool {
    text[at..]
        .chars()
        .next()
        .is_some_and(|c| c.is_alphanumeric() || c == '_')
}

/// Next word boundary in CLUSTER indices (alt+arrows): skip separators,
/// then a word run. `pub(crate)`: shared with `TextArea`.
pub(crate) fn word_step(text: &str, map: &ClusterMap, from: usize, dir: i32) -> usize {
    let n = map.len();
    let is_word = |i: usize| cluster_is_word(text, map.byte(i));
    if dir > 0 {
        let mut i = from;
        while i < n && !is_word(i) {
            i += 1;
        }
        while i < n && is_word(i) {
            i += 1;
        }
        i
    } else {
        let mut i = from;
        while i > 0 && !is_word(i - 1) {
            i -= 1;
        }
        while i > 0 && is_word(i - 1) {
            i -= 1;
        }
        i
    }
}

/// Apply one key to the editing state. Returns true when consumed.
/// `masked` degrades word jumps to whole-field jumps (F7: word
/// boundaries over the real text would leak the secret's word
/// structure through caret positions).
#[allow(clippy::too_many_arguments)]
fn edit_key(
    key: Key,
    mods: Mods,
    value: Signal<String>,
    caret: Signal<Caret>,
    width: i32,
    masked: bool,
    on_change: &TextCallback,
    on_submit: &TextCallback,
) -> bool {
    let shift = mods.contains(Mods::SHIFT);
    let alt = mods.contains(Mods::ALT);
    let ctrl = mods.contains(Mods::CTRL);
    let mut c = caret.get_untracked();
    let (map, text_snapshot) = value.with_untracked(|v| (ClusterMap::of(v), v.clone()));
    let len = map.len();
    // Defensive re-clamp: external value.set / merge-on-insert can leave
    // a stale index past the cluster count.
    c.cursor = c.cursor.min(len);
    if let Some(a) = c.anchor {
        c.anchor = Some(a.min(len));
    }

    // Cursor motion (with optional selection extension) --------------------
    let move_to = |c: &mut Caret, target: usize| {
        if shift {
            if c.anchor.is_none() {
                c.anchor = Some(c.cursor);
            }
        } else {
            c.anchor = None;
        }
        c.cursor = target.min(len);
    };
    // ---- word-wise chords, every terminal spelling (1310) -------------
    // The gesture arrives as Alt+←, Ctrl+← or `ESC b` depending on the
    // emulator; `widgets::edit_keys` owns the table (Codex's) for both
    // text widgets. The F7 masked rule survives intact BELOW: a
    // masked field is ONE word, so every word gesture runs to the FIELD
    // EDGE (start for a backward gesture, end for a forward one) instead
    // of to a real boundary — no caret position and no partial delete
    // can report where the secret's words are.
    if let Some(intent) = crate::widgets::edit_keys::word_intent(key, mods) {
        use crate::widgets::edit_keys::WordIntent;
        let back = |cur: usize| {
            if masked {
                0
            } else {
                word_step(&text_snapshot, &map, cur, -1)
            }
        };
        let fwd = |cur: usize| {
            if masked {
                len
            } else {
                word_step(&text_snapshot, &map, cur, 1)
            }
        };
        match intent {
            WordIntent::Left => {
                let target = back(c.cursor);
                move_to(&mut c, target);
                adjust_scroll(&mut c, &map, width);
                caret.set(c);
                return true;
            }
            WordIntent::Right => {
                let target = fwd(c.cursor);
                move_to(&mut c, target);
                adjust_scroll(&mut c, &map, width);
                caret.set(c);
                return true;
            }
            // `delete_selection` runs first on both arms, so every path
            // clears the anchor (an empty one included).
            WordIntent::DeleteBack => {
                let cut = back(c.cursor);
                let mut edited = false;
                value.update(|text| {
                    edited = delete_selection(text, &mut c);
                    if !edited && c.cursor > cut {
                        let map = ClusterMap::of(text);
                        text.replace_range(map.byte(cut)..map.byte(c.cursor), "");
                        c.cursor = cut;
                        edited = true;
                    }
                    let map = ClusterMap::of(text);
                    adjust_scroll(&mut c, &map, width);
                });
                caret.set(c);
                // At the field edge nothing was removed: a repeat must
                // not fire `on_change` over and over.
                if edited {
                    notify(on_change, value);
                }
                return true;
            }
            WordIntent::DeleteForward => {
                let end = fwd(c.cursor);
                let mut edited = false;
                value.update(|text| {
                    edited = delete_selection(text, &mut c);
                    if !edited && end > c.cursor {
                        let map = ClusterMap::of(text);
                        text.replace_range(map.byte(c.cursor)..map.byte(end), "");
                        edited = true;
                    }
                    let map = ClusterMap::of(text);
                    adjust_scroll(&mut c, &map, width);
                });
                caret.set(c);
                if edited {
                    notify(on_change, value);
                }
                return true;
            }
        }
    }

    match key {
        Key::Left => {
            let target = c.cursor.saturating_sub(1);
            move_to(&mut c, target);
            adjust_scroll(&mut c, &map, width);
            caret.set(c);
            return true;
        }
        Key::Right => {
            let target = c.cursor + 1;
            move_to(&mut c, target);
            adjust_scroll(&mut c, &map, width);
            caret.set(c);
            return true;
        }
        // Ctrl+A / Ctrl+E ride with Home/End (Codex's
        // `editor.move_line_start` / `move_line_end`); a single-line
        // field has one line, so they are the field ends.
        Key::Home | Key::Char('a') if !alt && (key == Key::Home || ctrl) => {
            move_to(&mut c, 0);
            adjust_scroll(&mut c, &map, width);
            caret.set(c);
            return true;
        }
        Key::End | Key::Char('e') if !alt && (key == Key::End || ctrl) => {
            move_to(&mut c, len);
            adjust_scroll(&mut c, &map, width);
            caret.set(c);
            return true;
        }
        _ => {}
    }

    // Edits (all cluster-atomic: byte ranges come from the map) -----------
    match key {
        Key::Char(ch) if !ctrl && !alt => {
            let mut buf = [0u8; 4];
            insert_text(ch.encode_utf8(&mut buf), value, caret, width);
            notify(on_change, value);
            true
        }
        Key::Backspace => {
            value.update(|text| {
                if !delete_selection(text, &mut c) && c.cursor > 0 {
                    let map = ClusterMap::of(text);
                    // ONE cluster gone — a ZWJ family or combining
                    // sequence deletes whole (RT3-2).
                    text.replace_range(map.byte(c.cursor - 1)..map.byte(c.cursor), "");
                    c.cursor -= 1;
                }
                let map = ClusterMap::of(text);
                adjust_scroll(&mut c, &map, width);
            });
            caret.set(c);
            notify(on_change, value);
            true
        }
        Key::Delete => {
            value.update(|text| {
                if !delete_selection(text, &mut c) {
                    let map = ClusterMap::of(text);
                    if c.cursor < map.len() {
                        text.replace_range(map.byte(c.cursor)..map.byte(c.cursor + 1), "");
                    }
                }
            });
            caret.set(c);
            notify(on_change, value);
            true
        }
        Key::Enter => {
            // Same borrow rule as `notify`: clone out, then call — a
            // submit handler clearing the input must not deadlock.
            notify(on_submit, value);
            true
        }
        _ => false,
    }
}

#[cfg(test)]
#[path = "input_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "input_paste_tests.rs"]
mod paste_tests;