htui 0.2.0

A terminal HTML/CSS renderer with an embedded JavaScript engine — build interactive TUIs from HTML, CSS and JS
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
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use compact_str::CompactString;

use crate::tdom::node::NodeId;

mod cmd;
mod edit;
mod field;
mod focus;
mod key_info;
mod mouse;
mod page;
mod refresh;
mod scroll_anim;
mod select;

pub use cmd::{DirtyInfo, DrainedBatch, RenderCmd, SelectionMode};
pub use edit::{
    apply_input_key, apply_input_word_key, is_input_field_key, next_word_boundary,
    paste_into_field, prev_word_boundary, selection_range,
};
pub use field::{
    KEY_GATE_TIMEOUT, KeyGate, apply_textarea_key, collect_input_ids, number_input_allows_char,
};
pub use focus::apply_focus_requests;
pub use key_info::key_event_info;
pub use mouse::{handle_click, handle_mouse_down, handle_mouse_move, handle_mouse_up};
pub use refresh::{default_wheel_scroll, trigger_refresh};
pub use scroll_anim::{
    ANIM_POLL_MS, ScrollAnim, ScrollIntoViewBlock, ScrollTarget, active_scroll_target,
    ease_out_cubic, scroll_anim_duration, scroll_into_view_target, step_target,
};
pub use select::{OverlayKind, SelectOpenState};

use field::clear_text_selection;

const SCROLL_LINES_PER_TICK: u32 = 3;
const SCROLLBAR_VISIBLE_FOR: Duration = Duration::from_millis(900);

/// Shared visible-scrollbar set. A missing node is hidden.
pub type SharedScrollbarVisibility = Arc<Mutex<HashMap<NodeId, bool>>>;
const ZOOM_STEP: f32 = 0.1;
const ZOOM_MIN: f32 = 0.2;
const ZOOM_MAX: f32 = 5.0;

/// Minimum pointer travel (Chebyshev distance in cells) before a press turns
/// into a page text selection drag. A press alone never selects, and a
/// jittery click — press, one-cell wiggle, release — must not leave a single
/// highlighted character behind (browsers use a similar threshold).
const TEXT_SEL_DRAG_THRESHOLD: u32 = 2;

/// Input-loop state that persists between events.
///
/// Groups the variables the input loop mutates (`scroll_y`, `zoom`) with the
/// mouse-tracking state (press position, hover target), the active
/// `<input>` edit session (focused node, value, caret) and the page text
/// selection (drag selection over normal content), so the loop only passes one
/// state value around.
#[derive(Debug)]
pub struct InputState {
    pub scroll_y: u32,
    /// Current offsets for native scroll containers.
    pub scroll_offsets: HashMap<NodeId, u32>,
    /// Current horizontal offsets for native `overflow-x` containers.
    pub scroll_x_offsets: HashMap<NodeId, u32>,
    /// Active eased scroll animation (`None` = none). Wheel and page-jump
    /// input glides toward its target through this state; any direct scroll
    /// write (arrow keys, scrollbar drag) cancels it.
    pub scroll_anim: Option<ScrollAnim>,
    /// Per-container scrollbar visibility shared with the render thread. A
    /// missing node is hidden; geometry remains hittable while chrome is off.
    pub scrollbars_visible: SharedScrollbarVisibility,
    /// Per-container wall-clock deadlines after which the shared flag clears.
    pub scrollbar_hide_at: HashMap<NodeId, Instant>,
    pub zoom: f32,
    /// Screen coordinates of the last left-button press; a click is only
    /// recognised when the release lands on the same cell.
    pub pressed: Option<(u16, u16)>,
    /// The `<input>` / `<textarea>` node currently focused (receives typed
    /// characters).
    pub focused_input: Option<NodeId>,
    /// Whether the focused node is a `<textarea>` (multi-line editing:
    /// Enter inserts a newline, ↑/↓ move the caret, click maps to rows).
    pub focused_is_textarea: bool,
    /// Whether the focused node is a discrete `<input type=range>` slider.
    pub focused_is_range: bool,
    /// Edit buffer of the focused input; the DOM `value` attribute is the
    pub input_value: CompactString,
    /// Caret position in [`InputState::input_value`] (character index).
    pub input_cursor: usize,
    /// Selection anchor (character index). `Some(a)` selects the character
    /// range between `a` and [`InputState::input_cursor`]; `None` means no
    /// selection.
    pub input_sel_anchor: Option<usize>,
    /// Session clipboard used by `Ctrl+C` / `Ctrl+X` / `Ctrl+V`. Terminal-
    /// local only: copy stores a selection, paste inserts it (single-line
    /// fields collapse newlines). System-clipboard paste arrives separately
    /// through bracketed paste (`Event::Paste`).
    pub input_clipboard: String,
    /// Whether the pointer is mid-drag inside a focused `<input>` (mouse
    /// selection as opposed to a plain click).
    pub drag_selecting: bool,
    /// Screen cell where the active press began. Unlike `pressed`, this is
    /// not advanced by drag motion; click thresholding uses this origin.
    press_screen: Option<(u16, u16)>,
    /// Canvas cell where the active drag began (or will begin once motion
    /// crosses the threshold). Field and page selection share this stable
    /// anchor so intermediate moves never shift the origin.
    drag_origin: Option<(u32, u32)>,
    /// Whether the in-field drag has passed the shared click/drag threshold.
    /// Before this, pointer motion remains a jittery click; at/after it the
    /// field selection follows the pointer.
    field_drag_started: bool,
    /// Whether the active press resolved to an actionable click target.
    /// Disabled controls and scrollbar/dropdown-only presses leave it clear.
    pressed_target: Option<NodeId>,
    /// Native scrollbar thumb currently dragged: owning node + grab offset.
    /// The axis flag selects the vertical track or the bottom horizontal one.
    pub scroll_drag: Option<(NodeId, u32)>,
    /// Grab offset of the active horizontal scrollbar drag (set alongside
    /// [`Self::scroll_drag`]).
    pub scroll_drag_x: Option<u32>,
    /// Screen coordinates of the last pointer event (any button state). Lets
    /// the run loop re-validate hover against a freshly rebuilt hit map after
    /// a viewport change by synthesising a `Move` at the known position.
    pub last_mouse: Option<(u16, u16)>,
    /// Set when the viewport changed (resize poll or `Event::Resize`) so the
    /// run loop re-validates the pointer hover once the rebuilt hit map is in
    /// place (consumed by [`InputState::take_resize_revalidation`]).
    pub resize_pending: bool,
    pub pointer_revalidation: bool,
    /// Canvas-space anchor of the page text selection (start point; `None`
    /// when no text selection is active).
    pub text_sel_anchor: Option<(u32, u32)>,
    /// Canvas-space current endpoint of the page text selection.
    pub text_sel_end: Option<(u32, u32)>,
    /// Shape of the active page text selection: left-drag is flow (reading
    /// order), right-drag is a block rectangle.
    pub text_sel_mode: SelectionMode,
    /// Whether the current press armed a page text selection — the mode above
    /// was set by a press whose copy feature is enabled. Without this, a
    /// disabled feature's press would leave a stale mode behind and a later
    /// drag could still start a selection in the wrong shape.
    text_sel_armed: bool,
    /// Whether the flow (left-drag) page text selection + copy is enabled.
    /// Seeded from [`Config`](crate::config::Config); when `false` a left drag
    /// over page content never starts a selection (and `Ctrl+C` copies
    /// nothing).
    pub copy_flow: bool,
    /// Whether wheel and page-jump scrolling glides (`Config`'s
    /// `smooth_scroll`); when `false` those inputs scroll instantly.
    pub smooth_scroll: bool,
    /// Whether the block (right-drag) rectangle selection + copy is enabled.
    /// Seeded from [`Config`](crate::config::Config); when `false` a right
    /// drag never starts a rectangle selection (and `Ctrl+C` copies nothing).
    pub copy_block: bool,
    /// Text inside the current page selection, refreshed by the render thread
    /// on every `TextSelection` command; read on `Ctrl+C` to copy. Shared
    /// with the render thread through `Arc<Mutex<_>>`.
    pub selection_text: Arc<Mutex<String>>,
    /// The `<select>` dropdown currently open (`None` = closed). While set,
    /// clicks inside the overlay pick an option and clicks outside close it;
    /// `↑` / `↓` / `Enter` / `Esc` navigate it.
    pub open_select: Option<SelectOpenState>,
    /// Monotonic id source for gated `keydown` dispatches; each press echoes
    /// the id back in the script thread's response so the input loop can match
    /// the gate verdict (`preventDefault`).
    pub next_key_id: u32,
    /// A page-level built-in action (scroll / zoom / quit) waiting for its
    /// `keydown` gate verdict (`None` = no key pending). Set by
    /// [`trigger_refresh`], consumed by the run loop after the wait.
    pub key_gate: Option<KeyGate>,
    /// A wheel tick waiting for its gated `wheel` verdict. When the verdict
    /// clears (no `preventDefault()`), [`Self::pending_wheel`] re-runs the
    /// built-in container / page scroll for that tick.
    pub wheel_gate: Option<KeyGate>,
    /// Coordinates + direction of the wheel tick held behind
    /// [`Self::wheel_gate`] (the default scroll runs only when not
    /// prevented).
    pub pending_wheel: Option<(u32, u32, bool)>,
    /// Time + cell of the last synthesized click (double-click detection:
    /// a second click on the same cell within 500 ms emits `DblClick`).
    pub last_click: Option<(Instant, u32, u32)>,
    /// The input loop's shared UI state (viewport / scroll / focus atomics
    /// plus the `wheel`-listener flag the wheel path gates on). `None` in
    /// bare-test states without a run loop.
    pub ui: Option<Arc<crate::script::SharedUiState>>,
}

/// Two clicks on the same cell within this window are a double click.
const DBLCLICK_MS: u128 = 500;

impl InputState {
    /// Create the default input state (top of page, 100% zoom, no focus or selection).
    pub fn new() -> Self {
        Self {
            scroll_y: 0,
            scroll_offsets: HashMap::new(),
            scroll_x_offsets: HashMap::new(),
            scroll_anim: None,
            scrollbars_visible: Arc::new(Mutex::new(HashMap::new())),
            scrollbar_hide_at: HashMap::new(),
            zoom: 1.0,
            pressed: None,
            focused_input: None,
            focused_is_textarea: false,
            focused_is_range: false,
            input_value: CompactString::new(""),
            input_cursor: 0,
            input_sel_anchor: None,
            input_clipboard: String::new(),
            drag_selecting: false,
            press_screen: None,
            drag_origin: None,
            field_drag_started: false,
            pressed_target: None,
            scroll_drag: None,
            scroll_drag_x: None,
            last_mouse: None,
            resize_pending: false,
            pointer_revalidation: false,
            text_sel_anchor: None,
            text_sel_end: None,
            text_sel_mode: SelectionMode::Flow,
            text_sel_armed: false,
            copy_flow: true,
            smooth_scroll: true,
            copy_block: true,
            selection_text: Arc::new(Mutex::new(String::new())),
            open_select: None,
            next_key_id: 0,
            key_gate: None,
            wheel_gate: None,
            pending_wheel: None,
            last_click: None,
            ui: None,
        }
    }

    /// Make one scrollbar visible and restart only its auto-hide deadline.
    ///
    /// Visibility is shared with the render thread so the reveal is atomic with
    /// the frame that draws the changed chrome.
    pub fn show_scrollbar(&mut self, node_id: NodeId) {
        if let Ok(mut visible) = self.scrollbars_visible.lock() {
            visible.insert(node_id, true);
        }
        self.scrollbar_hide_at
            .insert(node_id, Instant::now() + SCROLLBAR_VISIBLE_FOR);
    }

    /// Clear expired deadlines and return the ids whose visibility changed.
    /// The caller must emit a repaint covering those scroll containers.
    pub fn take_expired_scrollbar_visibility(&mut self) -> Vec<NodeId> {
        let now = Instant::now();
        let expired: Vec<NodeId> = self
            .scrollbar_hide_at
            .iter()
            .filter(|(_, deadline)| **deadline <= now)
            .map(|(node_id, _)| *node_id)
            .collect();
        if expired.is_empty() {
            return expired;
        }
        for node_id in &expired {
            self.scrollbar_hide_at.remove(node_id);
        }
        if let Ok(mut visible) = self.scrollbars_visible.lock() {
            for node_id in &expired {
                visible.insert(*node_id, false);
            }
        }
        expired
    }

    /// Clear the auto-hide state immediately. Used when replaced document node
    /// ids make previous reveal state stale.
    pub fn hide_scrollbars(&mut self) {
        self.scrollbar_hide_at.clear();
        if let Ok(mut visible) = self.scrollbars_visible.lock() {
            visible.clear();
        }
    }

    /// A viewport change (terminal resize) invalidates every canvas coordinate
    /// the input loop caches: a press whose release the console dropped would
    /// otherwise stick and turn all later pointer motion into a drag (hover /
    /// click events go permanently silent), and a stale text selection / open
    /// dropdown overlay would highlight or paint stale cells. Resets that
    /// state and emits the render commands that clear the render-side mirrors.
    pub fn invalidate_viewport(&mut self, cmds: &mut Vec<RenderCmd>) {
        self.clear_press_tracking();
        // A resize re-lays out every container, so a container-targeted glide
        // would keep scrolling by stale geometry; it is cancelled. A page
        // glide survives — the viewport offset stays meaningful across a
        // re-layout.
        if let Some(anim) = &self.scroll_anim
            && matches!(anim.target(), ScrollTarget::Container(_))
        {
            self.scroll_anim = None;
        }
        clear_text_selection(self, cmds);
        if let Some(open) = self.open_select.take() {
            cmds.push(RenderCmd::SelectClose {
                node_id: open.node_id,
            });
        }
    }

    /// Reset interaction state tied to a replaced document's node ids.
    ///
    /// A whole-document reload (hot reload or `ScriptMsg::ReloadHtml`)
    /// replaces the arena, so focused-field edit buffers, an open dropdown,
    /// drag state, an active scroll animation and page-selection coordinates
    /// must not survive. Scroll, zoom, clipboard and the last pointer position
    /// do survive; the latter is reused to re-validate hover against the fresh
    /// hit map.
    pub fn reset_document_state(&mut self, cmds: &mut Vec<RenderCmd>) {
        self.clear_press_tracking();
        self.scroll_offsets.clear();
        self.scroll_anim = None;
        self.hide_scrollbars();
        self.focused_input = None;
        self.focused_is_textarea = false;
        self.focused_is_range = false;
        self.input_value = CompactString::new("");
        self.input_cursor = 0;
        self.input_sel_anchor = None;
        clear_text_selection(self, cmds);
        if let Some(open) = self.open_select.take() {
            cmds.push(RenderCmd::SelectClose {
                node_id: open.node_id,
            });
        }
        self.request_resize_revalidation();
    }

    /// Clear every field that tracks the active press / drag: the press
    /// marker, drag anchors, the resolved click target and the scrollbar
    /// grab. Shared by viewport invalidation, document reload and stale-press
    /// drops so no reset site can miss a field.
    fn clear_press_tracking(&mut self) {
        self.pressed = None;
        self.drag_selecting = false;
        self.press_screen = None;
        self.drag_origin = None;
        self.field_drag_started = false;
        self.pressed_target = None;
        self.scroll_drag = None;
    }

    /// Drop a press whose release was never received: a button-free `Moved`
    /// event proves no button is held, so any recorded press is stale. Without
    /// this, a lost release (e.g. the console dropping the `Up` during a
    /// resize) would make every later move look like a drag and hover / click
    /// events would stay silent forever. Returns whether a press was dropped.
    pub fn drop_stale_press(&mut self) -> bool {
        if self.pressed.take().is_some() {
            self.clear_press_tracking();
            true
        } else {
            false
        }
    }

    /// Remember that the viewport changed; the run loop re-validates hover
    /// after the rebuilt hit map is published (see
    /// [`InputState::take_resize_revalidation`]).
    pub fn request_resize_revalidation(&mut self) {
        self.resize_pending = true;
    }

    /// Consume the pending viewport-change flag; `true` when the run loop
    /// should synthesise a `Move` at [`InputState::last_mouse`] against the
    /// fresh hit map.
    pub fn take_resize_revalidation(&mut self) -> bool {
        std::mem::take(&mut self.resize_pending)
    }

    /// Remember that a pointer move was dropped; the run loop re-synthesises
    /// a `Move` at [`InputState::last_mouse`] (see
    /// [`InputState::take_pointer_revalidation`]).
    pub fn request_pointer_revalidation(&mut self) {
        self.pointer_revalidation = true;
    }

    /// Consume the pending dropped-move flag; `true` when the run loop
    /// should synthesise a `Move` at [`InputState::last_mouse`] so the
    /// script thread hover state converges on the pointer true position.
    pub fn take_pointer_revalidation(&mut self) -> bool {
        std::mem::take(&mut self.pointer_revalidation)
    }

    /// Mirror the render thread's scroll clamp: after a terminal grow / a
    /// shorter document reload the content ceiling (`max_scroll`) can drop
    /// below the current [`InputState::scroll_y`]. Keeping the two in sync
    /// prevents the "wheel dead zone" where every scroll `Rerender` is clamped
    /// to the same value in the render thread and dropped as a no-op, making
    /// the mouse wheel and arrow keys appear dead after a resize.
    pub fn clamp_scroll_to(&mut self, max_scroll: u32) {
        if self.scroll_y > max_scroll {
            self.scroll_y = max_scroll;
        }
    }
}

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

/// Re-apply the console input mode after a terminal resize.
///
/// On Windows, resizing the console / ConPTY buffer can reset the console's
/// input mode: mouse capture is dropped (`ENABLE_MOUSE_INPUT` cleared, quick-
/// edit selection re-engages) and the keyboard falls back to cooked mode, so
/// input appears completely dead until the app re-issues raw mode + mouse
/// capture. Both calls are idempotent, so this is safe to run on every loop
/// iteration. On other platforms a resize never touches the terminal modes, so
/// this is a no-op.
#[cfg(windows)]
pub(crate) fn reapply_console_modes() {
    if let Err(e) = crossterm::terminal::enable_raw_mode() {
        log::error!("Failed to re-enable raw mode after resize: {}", e);
    }
    // Pass a *sink* writer: `EnableMouseCapture` is a WinAPI call (an
    // input-handle `SetConsoleMode`) on Windows, but `execute!` flushes the
    // passed writer first to preserve command ordering. Flushing
    // `std::io::stdout()` would take the global stdout lock — which the
    // render thread can hold while blocked on a slow console write — and a
    // fast wheel scroll (large diffs every frame) makes that stall long
    // enough to look like the whole app froze. A sink flushes nothing and
    // still runs the WinAPI path. Bracketed paste is re-sent too so a
    // terminal reset does not silently drop it.
    if let Err(e) = crossterm::execute!(
        std::io::sink(),
        crossterm::event::EnableMouseCapture,
        crossterm::event::EnableBracketedPaste
    ) {
        log::error!("Failed to re-enable input modes after resize: {}", e);
    }
}

/// Non-Windows resize does not touch the terminal modes; nothing to re-apply.
#[cfg(not(windows))]
pub(crate) fn reapply_console_modes() {}

#[cfg(test)]
mod scrollbar_visibility_tests {
    use slotmap::Key;
    use std::time::{Duration, Instant};

    use super::{InputState, NodeId};

    #[test]
    fn expired_scrollbar_visibility_transitions_once() {
        let mut state = InputState::new();
        let node_id = NodeId::null();

        state.show_scrollbar(node_id);
        assert_eq!(
            state.scrollbars_visible.lock().unwrap().get(&node_id),
            Some(&true)
        );

        state
            .scrollbar_hide_at
            .insert(node_id, Instant::now() - Duration::from_millis(1));
        assert_eq!(state.take_expired_scrollbar_visibility(), vec![node_id]);
        assert_eq!(
            state.scrollbars_visible.lock().unwrap().get(&node_id),
            Some(&false)
        );
        assert!(state.take_expired_scrollbar_visibility().is_empty());
    }
}