teksilo-widgets 0.9.0

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
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
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Shared mutable state for a single `TextInput` instance.
//!
//! Mirrors the `Rc<RefCell<State>>` pattern from
//! `rich_text::state` but stripped down
//! for single-line plain-text editing: no scroll bars, no rich
//! formatting, no table/cell state, no image cache.

use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::rc::Rc;
use std::sync::{Arc, Mutex};

use teksilo_canvas::Point;
use teksilo_core::signal::Signal;
use teksilo_core::widget::EventContext;
use teksilo_core::widget_id::WidgetId;
use teksilo_text::text_document::{DocumentEvent, Subscription, TextCursor, TextDocument};
use teksilo_text::{RichTextEngine, WrapMode};

use super::{AtRevealPolicy, EchoMode};
use crate::common::editor_runtime::{CaretBlink, Debounce};
use crate::rich_text::image_cache::ImageCache;

/// Type-erased action closure, identical to the one in `button.rs`.
pub(crate) type CommandFactory = Box<dyn Fn(&mut EventContext)>;

/// Per-character input-filter predicate. Returning `false` rejects the
/// character before it enters the document. Applied uniformly to
/// keyboard input, IME commits, and clipboard paste so a filtered
/// field cannot receive disallowed characters through any path.
pub(crate) type CharFilter = Rc<dyn Fn(char) -> bool>;

pub(crate) type SharedState = Rc<RefCell<TextInputState>>;

/// Drag-select session lifecycle.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum DragState {
    Idle,
    Selecting,
}

pub(crate) struct TextInputState {
    pub document: TextDocument,
    pub engine: RichTextEngine,
    pub cursor: TextCursor,

    // ── Reactive signals ────────────────────────────────────────────
    /// Current text content, kept in sync with the document by the
    /// frame-tick effect. Placeholder visibility and clear-button
    /// visibility bind to this.
    pub text_signal: Signal<String>,
    pub cursor_position: Signal<usize>,
    pub cursor_anchor: Signal<usize>,
    pub has_selection: Signal<bool>,
    pub caret_visible: Signal<bool>,
    pub can_undo: Signal<bool>,
    pub can_redo: Signal<bool>,

    // ── Frame infrastructure ────────────────────────────────────────
    pub frame_request: Option<Rc<Cell<bool>>>,
    pub frame_wake_at: Option<Rc<Cell<Option<std::time::Instant>>>>,
    /// Wall-clock of the last caret blink toggle.
    /// Caret blink phase — shared with the other text surfaces, see
    /// [`common::editor_runtime::CaretBlink`](crate::common::editor_runtime::CaretBlink).
    pub blink: CaretBlink,

    // ── Horizontal scroll ───────────────────────────────────────────
    /// Pixel offset applied via canvas translation when text overflows
    /// the viewport width. Managed by `ensure_caret_visible_h`.
    pub scroll_x: f32,
    pub viewport_width: f32,
    pub viewport_origin: Point,

    // ── Pending chars (batched per frame) ────────────────────────────
    pub pending_chars: String,
    pub pending_text_changed: bool,
    /// Text update deferred from tick() to avoid RefCell double-borrow.
    /// The frame-tick effect reads and applies this after dropping the
    /// mutable borrow on state.
    pub deferred_text_update: Option<String>,

    // ── Debounce ────────────────────────────────────────────────────
    /// Coalescing window — shared with the other text surfaces, see
    /// [`common::editor_runtime::Debounce`](crate::common::editor_runtime::Debounce).
    pub debounce: Debounce,
    pub pending_undo_redo: Option<(bool, bool)>,

    // ── Document event subscription ─────────────────────────────────
    pub event_queue: Arc<Mutex<VecDeque<DocumentEvent>>>,
    pub _event_subscription: Subscription,

    // ── Input state ─────────────────────────────────────────────────
    pub has_focus: bool,
    /// The observable twin of [`has_focus`](Self::has_focus).
    ///
    /// The bool is what the widget's own drawing and key handling read; this is
    /// what an *outside* observer needs — an application routing Undo to
    /// whichever text surface holds the caret cannot poll a `bool` behind a
    /// `RefCell` every frame. Same shape and same purpose as
    /// `RichTextEditor`'s `focus_signal`.
    pub focus_signal: Signal<bool>,
    /// Whether the host window is currently active (`focused AND not
    /// occluded`). Mirrored from `BuildContext::window_active_signal` by an
    /// effect in `TextInputField::build` (the frame-loop `tick` has no
    /// context). Gates the caret alongside `has_focus` — the caret hides in an
    /// inactive window. Starts `true` to match the tree's initial value.
    pub window_active: bool,
    /// The selection tint last handed to the engine, so "what is this field
    /// painting its selection in" is answerable without reaching into the
    /// engine (whose `flow` keeps no getter). Both axes that decide it —
    /// `window_active` and `has_focus` — change independently, and each has to
    /// re-apply; see `field_selection_color`.
    pub selection_tint: [f32; 4],
    pub drag_state: DragState,
    pub needs_full_layout: bool,
    pub content_dirty: bool,
    /// Last applied global text-scale factor (`ctx.text_scale`). Tracked so the
    /// engine's logical `font_scale` is only re-set (and a relayout forced) when
    /// the accessibility scale actually changes.
    pub last_text_scale: f32,
    /// Empty image cache kept to satisfy paint_frame's API. TextInput
    /// never has inline images.
    pub image_cache: ImageCache,

    // ── Configuration (copied from TextInput at build time) ─────────
    pub max_length: Option<usize>,
    pub read_only: bool,
    pub on_submit: Option<Rc<CommandFactory>>,
    /// Fired exactly once per focus-loss, AFTER the cursor/selection
    /// have been cleared and scroll reset. Used by SpinBox-style
    /// widgets to parse/clamp/reformat on blur.
    pub on_blur: Option<Rc<CommandFactory>>,
    /// Per-character input-filter predicate. `None` admits every
    /// non-control character; `Some(f)` additionally requires `f(c)
    /// == true`. Applied to keyboard input, IME commits, and paste.
    pub char_filter: Option<CharFilter>,
    pub placeholder: String,

    // ── Non-editable suffix ─────────────────────────────────────────
    /// Fixed trailing string rendered flush-right inside the border,
    /// the cursor can never enter it. Empty string = no suffix.
    pub suffix: String,
    /// Independent engine holding the suffix's own single-block flow.
    /// Shares the app's `SharedTypesetter` with the main engine so
    /// glyphs land in the same atlas. `None` until the first paint
    /// that sees a non-empty suffix, which is when we can count on
    /// `SharedTypesetter` being available via `app_state`.
    pub suffix_engine: Option<RichTextEngine>,
    /// Cached logical width of the suffix in pixels, filled when
    /// `suffix_engine` is laid out. Drives both the reduced text
    /// viewport (so text scrolls behind a fixed suffix) and the
    /// suffix paint origin.
    pub suffix_width: f32,

    // ── Secure / password masking ───────────────────────────────────
    /// When `true`, this is a secure (password) field: glyphs are
    /// masked per `echo_mode` unless currently revealed.
    pub secure: bool,
    /// How a secure field echoes characters. Ignored when `secure` is
    /// `false`.
    pub echo_mode: EchoMode,
    /// Replacement glyph for `Masked` / `RevealWhileTyping` modes
    /// (default `'•'`). Applied at the engine layer so plaintext never
    /// reaches the shaper or glyph atlas while masked.
    pub echo_char: char,
    /// External reveal toggle, shared with the eye button. `Some(true)`
    /// shows plaintext regardless of `echo_mode`; `None` is a secure
    /// field with no reveal affordance.
    pub revealed: Option<Signal<bool>>,
    /// How a revealed secure field reports to assistive technology.
    pub at_reveal_policy: AtRevealPolicy,
    /// Whether copy / cut are permitted. Plain fields default `true`;
    /// secure fields default `false` (still copyable when revealed).
    pub allow_copy: bool,
    /// Empty document used as the layout source for `NoEcho` masking so
    /// nothing — not even length — is shown. The real `document` stays
    /// the source of truth for editing.
    pub empty_doc: TextDocument,

    /// The field widget's own id, used as anchor for overlays (e.g.
    /// the autocomplete popup) and for downstream tests that snapshot
    /// AT trees keyed by widget id.
    pub field_widget_id: Option<WidgetId>,

    // ── IME composition (preedit) ────────────────────────────────────
    /// Active IME preedit (composition) string, or `None` when not
    /// composing. The text is inserted into `document` tentatively and
    /// tracked by `ime_preedit_range`; secure masking / echo applies to
    /// it like any other content, so a password preedit shows as bullets.
    pub ime_preedit: Option<String>,
    /// Character range in `document` currently occupied by the live
    /// preedit, so a follow-up composition event can remove and replace it.
    pub ime_preedit_range: Option<std::ops::Range<usize>>,
    /// Last IME candidate-window rectangle reported to the platform. Reporting
    /// is deduped against this: re-sending an unchanged area is wasted work and,
    /// on some winit IME backends (ibus / fcitx), echoes back a fresh empty
    /// `Ime::Preedit`, a self-sustaining feedback loop. Cleared on blur so a
    /// refocus re-seeds the (per-window) OS area a sibling field may have moved.
    pub last_ime_area: Option<teksilo_canvas::Rect>,
}

/// Configuration bundle passed from `TextInput::build()` to
/// `TextInputState::new()`. Grouped into a struct to keep the public
/// constructor stable as new hooks are added (SpinBox needs three
/// extra fields over plain TextInput, and a positional argument
/// constructor would have grown to eight or nine parameters).
pub(crate) struct TextInputConfig {
    pub initial_text: String,
    pub max_length: Option<usize>,
    pub read_only: bool,
    pub on_submit: Option<Rc<CommandFactory>>,
    pub on_blur: Option<Rc<CommandFactory>>,
    pub char_filter: Option<CharFilter>,
    pub placeholder: String,
    pub suffix: String,
    pub secure: bool,
    pub echo_mode: EchoMode,
    pub echo_char: char,
    pub revealed: Option<Signal<bool>>,
    pub at_reveal_policy: AtRevealPolicy,
    pub allow_copy: bool,
    /// Handed in rather than minted here, so a [`TextFieldHandle`] taken before
    /// the widget is built observes the same signal the built widget writes.
    ///
    /// [`TextFieldHandle`]: crate::primitives::TextFieldHandle
    pub focus_signal: Signal<bool>,
}

impl TextInputState {
    pub fn new(config: TextInputConfig) -> SharedState {
        let TextInputConfig {
            initial_text,
            max_length,
            read_only,
            on_submit,
            on_blur,
            char_filter,
            placeholder,
            suffix,
            secure,
            echo_mode,
            echo_char,
            revealed,
            at_reveal_policy,
            allow_copy,
            focus_signal,
        } = config;
        let document = TextDocument::new();
        if !initial_text.is_empty() {
            let _ = document.set_plain_text(&initial_text);
        }
        let cursor = document.cursor();

        let mut engine = RichTextEngine::private_default();
        engine.set_wrap_mode(WrapMode::None);

        let event_queue = Arc::new(Mutex::new(VecDeque::<DocumentEvent>::new()));
        let subscription = {
            let queue = event_queue.clone();
            document.on_change(move |event| {
                if let Ok(mut q) = queue.lock() {
                    q.push_back(event);
                }
            })
        };

        let initial_can_undo = document.can_undo();
        let initial_can_redo = document.can_redo();

        Rc::new(RefCell::new(Self {
            document,
            engine,
            cursor,
            text_signal: Signal::new(initial_text.clone()),
            cursor_position: Signal::new(0),
            cursor_anchor: Signal::new(0),
            has_selection: Signal::new(false),
            caret_visible: Signal::new(true),
            can_undo: Signal::new(initial_can_undo),
            can_redo: Signal::new(initial_can_redo),
            frame_request: None,
            frame_wake_at: None,
            blink: CaretBlink::new(),
            scroll_x: 0.0,
            viewport_width: 0.0,
            viewport_origin: Point::ZERO,
            pending_chars: String::new(),
            pending_text_changed: false,
            deferred_text_update: None,
            debounce: Debounce::new(), // starts expired so the first tick flushes
            pending_undo_redo: None,
            event_queue,
            _event_subscription: subscription,
            has_focus: false,
            focus_signal,
            window_active: true,
            selection_tint: [0.0; 4],
            drag_state: DragState::Idle,
            needs_full_layout: true,
            content_dirty: true,
            last_text_scale: 1.0,
            image_cache: ImageCache::new(),
            max_length,
            read_only,
            on_submit,
            on_blur,
            char_filter,
            placeholder,
            suffix,
            secure,
            echo_mode,
            echo_char,
            revealed,
            at_reveal_policy,
            allow_copy,
            empty_doc: TextDocument::new(),
            suffix_engine: None,
            suffix_width: 0.0,
            field_widget_id: None,
            ime_preedit: None,
            ime_preedit_range: None,
            last_ime_area: None,
        }))
    }

    /// Whether the configured `char_filter` (if any) admits this
    /// character. `None` admits every character; inverted so callers
    /// can write `if !st.char_filter_admits(c) { skip }`.
    pub fn char_filter_admits(&self, c: char) -> bool {
        self.char_filter.as_ref().is_none_or(|f| f(c))
    }

    // ── Secure-field masking ────────────────────────────────────────

    /// Whether plaintext is currently shown despite `secure` — the
    /// reveal toggle is on, or `RevealWhileTyping` is active and the
    /// field is focused. Always `true` for non-secure fields.
    pub fn reveal_active(&self) -> bool {
        if !self.secure {
            return true;
        }
        let toggled = self.revealed.as_ref().is_some_and(|s| s.get());
        toggled || (self.echo_mode == EchoMode::RevealWhileTyping && self.has_focus)
    }

    /// Whether the displayed glyphs should be masked right now.
    pub fn should_mask(&self) -> bool {
        self.secure && !self.reveal_active()
    }

    /// Whether copy / cut of the field's text is currently permitted.
    /// Plain fields always allow it; secure fields allow it only when
    /// the developer opted in (`allow_copy`) or the text is currently
    /// revealed.
    pub fn copy_allowed(&self) -> bool {
        !self.secure || self.allow_copy || self.reveal_active()
    }

    /// Run a full layout, applying secure masking. Installs the echo
    /// char on the engine (or clears it), and for `NoEcho` while masked
    /// lays out an empty source so nothing — not even length — is
    /// shown. The real `document` is never mutated: masking is
    /// display-only, so caret / selection / hit-test (all char-indexed)
    /// stay aligned because one echo char is emitted per source char.
    /// Apply the global accessibility text scale to the shaping engine(s).
    ///
    /// `scale` is `ctx.text_scale` (combined user×OS factor). When it changes,
    /// the main engine's logical `font_scale` is updated so the value text grows
    /// (advances + line height + content height), a full relayout is forced, and
    /// the suffix engine is re-laid out at the new scale so its width stays
    /// correct. Cheap no-op when the scale is unchanged.
    pub fn apply_font_scale(&mut self, scale: f32) {
        if (self.last_text_scale - scale).abs() <= f32::EPSILON {
            return;
        }
        self.last_text_scale = scale;
        self.engine.set_font_scale(scale);
        self.needs_full_layout = true;
        if !self.suffix.is_empty()
            && let Some(engine) = self.suffix_engine.as_mut()
        {
            engine.set_font_scale(scale);
            let doc = TextDocument::new();
            let _ = doc.set_plain_text(&self.suffix);
            let flow = doc.snapshot_flow();
            engine.layout_full(&flow);
            self.suffix_width = engine.max_content_width();
        }
    }

    /// Adopt `bounds` as the field's viewport — the single writer of
    /// `viewport_origin` / `viewport_width`.
    ///
    /// Called from BOTH `TextInputField::place_children` (the authority — layout
    /// runs first) and `TextInputField::paint` (an idempotent echo). `viewport_width`
    /// is itself the change detector for `needs_full_layout`, so writing it
    /// without also raising that flag would blind the detector and silently drop
    /// the re-layout a resize is supposed to trigger. Keep the two welded here.
    ///
    /// Returns `true` if the width actually changed.
    pub fn sync_viewport(&mut self, bounds: teksilo_canvas::Rect) -> bool {
        self.viewport_origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
        let changed = (self.viewport_width - bounds.width).abs() > 0.5;
        if changed {
            self.viewport_width = bounds.width;
            self.needs_full_layout = true;
        }
        changed
    }

    pub fn layout_full_masked(&mut self) {
        let masked = self.should_mask();
        let echo = if masked && self.echo_mode != EchoMode::NoEcho {
            Some(self.echo_char)
        } else {
            None
        };
        if self.engine.echo_char() != echo {
            self.engine.set_echo_char(echo);
        }
        if masked && self.echo_mode == EchoMode::NoEcho {
            let flow = self.empty_doc.snapshot_flow();
            self.engine.layout_full(&flow);
        } else {
            let flow = self.document.snapshot_flow();
            self.engine.layout_full(&flow);
        }
    }

    /// Drain the local event queue. Returns `true` if any events
    /// were processed (the frame loop should re-layout).
    pub fn drain_events(&mut self) -> bool {
        let drained: Vec<DocumentEvent> = {
            let mut q = self.event_queue.lock().expect("event queue mutex poisoned");
            q.drain(..).collect()
        };

        let mut had_events = false;
        for event in drained {
            had_events = true;
            match event {
                DocumentEvent::ContentsChanged { .. }
                | DocumentEvent::DocumentReset
                | DocumentEvent::FlowElementsInserted { .. }
                | DocumentEvent::FlowElementsRemoved { .. }
                | DocumentEvent::BlockCountChanged(_) => {
                    self.pending_text_changed = true;
                    self.needs_full_layout = true;
                }
                DocumentEvent::FormatChanged { .. }
                // text_input_field never installs a SyntaxHighlighter, so this
                // cannot arrive in practice; handle conservatively (full
                // relayout) for exhaustiveness and shared-document safety.
                | DocumentEvent::HighlightPaintChanged { .. } => {
                    self.needs_full_layout = true;
                }
                DocumentEvent::UndoRedoChanged { can_undo, can_redo } => {
                    self.pending_undo_redo = Some((can_undo, can_redo));
                }
                // `TextInserted` is attribution, not layout: it says which
                // channel some text arrived through, alongside the
                // `ContentsChanged` that already told this state everything it
                // needs in order to relay out. Reacting to it as well would
                // relayout twice for one edit.
                DocumentEvent::TextInserted { .. }
                | DocumentEvent::ModificationChanged(_)
                | DocumentEvent::LongOperationProgress { .. }
                | DocumentEvent::LongOperationFinished { .. } => {}
            }
        }

        if had_events {
            self.content_dirty = true;
        }

        had_events
    }
}

/// Sync the cursor position and selection signals from the current
/// cursor state. Called after any keyboard or mouse action that moves
/// the caret.
pub(crate) fn sync_cursor_signals(state: &SharedState) {
    let st = state.borrow();
    let pos = st.cursor.position();
    let anchor = st.cursor.anchor();
    let has_sel = st.cursor.has_selection();
    if st.cursor_position.get() != pos {
        st.cursor_position.set(pos);
    }
    if st.cursor_anchor.get() != anchor {
        st.cursor_anchor.set(anchor);
    }
    if st.has_selection.get() != has_sel {
        st.has_selection.set(has_sel);
    }
    // Reset blink phase so the caret pops on immediately after movement.
    drop(st);
    let mut st = state.borrow_mut();
    st.blink.restart();
    st.caret_visible.set(true);
}

#[cfg(test)]
mod secure_tests {
    use super::*;

    fn cfg(
        secure: bool,
        echo_mode: EchoMode,
        revealed: Option<Signal<bool>>,
        allow_copy: bool,
    ) -> TextInputConfig {
        TextInputConfig {
            initial_text: "abc".to_string(),
            max_length: None,
            read_only: false,
            on_submit: None,
            on_blur: None,
            char_filter: None,
            placeholder: String::new(),
            suffix: String::new(),
            secure,
            echo_mode,
            echo_char: '\u{2022}',
            revealed,
            at_reveal_policy: AtRevealPolicy::SwapRole,
            allow_copy,
            focus_signal: Signal::new(false),
        }
    }

    #[test]
    fn plain_field_never_masks_and_allows_copy() {
        let st = TextInputState::new(cfg(false, EchoMode::Masked, None, true));
        let st = st.borrow();
        assert!(!st.should_mask());
        assert!(st.reveal_active());
        assert!(st.copy_allowed());
    }

    #[test]
    fn masked_secure_field_masks_and_blocks_copy() {
        let st = TextInputState::new(cfg(true, EchoMode::Masked, None, false));
        let st = st.borrow();
        assert!(st.should_mask());
        assert!(!st.reveal_active());
        assert!(!st.copy_allowed(), "masked secure field must block copy");
    }

    #[test]
    fn revealed_secure_field_unmasks_and_allows_copy() {
        let revealed = Signal::new(true);
        let st = TextInputState::new(cfg(true, EchoMode::Masked, Some(revealed), false));
        let st = st.borrow();
        assert!(!st.should_mask());
        assert!(st.copy_allowed(), "copy allowed once revealed");
    }

    #[test]
    fn allow_copy_opt_in_permits_copy_while_masked() {
        let st = TextInputState::new(cfg(true, EchoMode::Masked, None, true));
        let st = st.borrow();
        assert!(st.should_mask(), "still visually masked");
        assert!(st.copy_allowed(), "developer opted into copy");
    }

    #[test]
    fn reveal_while_typing_unmasks_only_when_focused() {
        let st = TextInputState::new(cfg(true, EchoMode::RevealWhileTyping, None, false));
        assert!(st.borrow().should_mask(), "masked when unfocused");
        {
            let mut st = st.borrow_mut();
            st.has_focus = true;
            st.focus_signal.set(true);
        }
        let s = st.borrow();
        assert!(!s.should_mask(), "revealed while focused");
        assert!(s.copy_allowed(), "copy allowed while revealed by typing");
    }
}