tui-lipan 0.2.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
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
//! Shared runtime environment passed to every component.

use rustc_hash::FxHashMap;
use std::any::{Any, TypeId};
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use web_time::Instant;

use smallvec::SmallVec;

use crate::animation::AnimationRegistry;
use crate::app::context::SurfaceMode;
use crate::app::input::command_registry::CommandRegistry;
use crate::callback::ScopeId;
use crate::clipboard::{ClipboardConfig, ClipboardService};
use crate::core::component::{FocusContext, HoverContext, ScrollContext};
use crate::core::element::Element;
use crate::core::element::Key;
use crate::core::node::NodeId;
use crate::runtime::FocusRequest;
use crate::style::{HostTerminalColors, Rect, RichText, Theme};
use crate::utils::GridSelection;

/// A queued copy-flash request: the target node and, optionally, an explicit range
/// to paint instead of the node's live selection.
pub(crate) type CopyFeedbackRequest = (NodeId, Option<GridSelection>);

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DevToolsRequest {
    Show,
    Hide,
    Toggle,
}

#[cfg(feature = "devtools")]
#[derive(Debug, Default, PartialEq)]
pub(crate) struct DevToolsMetrics {
    pub(crate) rows: RefCell<Vec<crate::app::DevToolsMetric>>,
    visible: Cell<bool>,
}

#[cfg(feature = "devtools")]
impl DevToolsMetrics {
    pub(crate) fn replace(&self, rows: Vec<crate::app::DevToolsMetric>) {
        *self.rows.borrow_mut() = rows;
    }

    pub(crate) fn set_visible(&self, visible: bool) {
        self.visible.set(visible);
    }

    pub(crate) fn is_visible(&self) -> bool {
        self.visible.get()
    }
}

#[derive(Clone)]
pub(crate) enum TranscriptEntry {
    Lines(Vec<RichText>),
    Element(Box<Element>),
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct MemoDependencies {
    pub(crate) theme: bool,
    pub(crate) focus: bool,
    pub(crate) hover: bool,
    pub(crate) scroll: SmallVec<[ScrollDependency; 2]>,
    pub(crate) mouse_capture: bool,
    pub(crate) viewport: bool,
    pub(crate) transition: bool,
    pub(crate) host_terminal_colors: bool,
    pub(crate) contexts: SmallVec<[(TypeId, &'static str); 2]>,
}

impl MemoDependencies {
    fn note(&mut self, dependency: MemoDependency) {
        match dependency {
            MemoDependency::Theme => self.theme = true,
            MemoDependency::Context { type_id, name } => {
                if !self.contexts.iter().any(|(id, _)| *id == type_id) {
                    self.contexts.push((type_id, name));
                }
            }
            MemoDependency::Focus => self.focus = true,
            MemoDependency::Hover => self.hover = true,
            MemoDependency::Scroll(dependency) => {
                if !self.scroll.contains(&dependency) {
                    self.scroll.push(dependency);
                }
            }
            MemoDependency::MouseCapture => self.mouse_capture = true,
            MemoDependency::Viewport => self.viewport = true,
            MemoDependency::Transition => self.transition = true,
            MemoDependency::HostTerminalColors => self.host_terminal_colors = true,
        }
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct MemoDependencySnapshot {
    pub(crate) dependencies: MemoDependencies,
    pub(crate) theme_generation: u64,
    pub(crate) focus_generation: u64,
    pub(crate) hover_generation: u64,
    pub(crate) scroll_generations: SmallVec<[(ScrollDependency, u64); 2]>,
    pub(crate) mouse_capture_generation: u64,
    pub(crate) transition_generation: u64,
    pub(crate) host_terminal_color_generation: u64,
    pub(crate) viewport: Rect,
    pub(crate) context_generations: SmallVec<[(TypeId, &'static str, u64); 2]>,
}

impl MemoDependencySnapshot {
    pub(crate) fn matches(&self, env: &RuntimeEnv, viewport: Rect) -> bool {
        let context_generations = env.context_generations.borrow();
        (!self.dependencies.theme || self.theme_generation == env.active_theme_generation.get())
            && (!self.dependencies.focus || self.focus_generation == env.focus.generation())
            && (!self.dependencies.hover || self.hover_generation == env.hover.generation())
            && self
                .scroll_generations
                .iter()
                .all(|(dependency, generation)| {
                    env.scroll.dependency_generation(dependency) == *generation
                })
            && (!self.dependencies.mouse_capture
                || self.mouse_capture_generation == env.mouse_capture_generation.get())
            && (!self.dependencies.viewport || self.viewport == viewport)
            && (!self.dependencies.transition
                || self.transition_generation == env.animations.generation())
            && (!self.dependencies.host_terminal_colors
                || self.host_terminal_color_generation == env.host_terminal_color_generation.get())
            && self
                .context_generations
                .iter()
                .all(|(type_id, _name, generation)| {
                    context_generations.get(type_id).copied().unwrap_or(0) == *generation
                })
    }

    /// First dependency that fails the retain check (devtools diagnostics only).
    #[cfg(feature = "devtools")]
    pub(crate) fn first_mismatch(
        &self,
        env: &RuntimeEnv,
        viewport: Rect,
    ) -> Option<crate::core::nested::MemoDependencyKind> {
        use crate::core::nested::MemoDependencyKind;

        if self.dependencies.theme && self.theme_generation != env.active_theme_generation.get() {
            return Some(MemoDependencyKind::Theme);
        }
        if self.dependencies.focus && self.focus_generation != env.focus.generation() {
            return Some(MemoDependencyKind::Focus);
        }
        if self.dependencies.hover && self.hover_generation != env.hover.generation() {
            return Some(MemoDependencyKind::Hover);
        }
        if self
            .scroll_generations
            .iter()
            .any(|(dependency, generation)| {
                env.scroll.dependency_generation(dependency) != *generation
            })
        {
            return Some(MemoDependencyKind::Scroll);
        }
        if self.dependencies.mouse_capture
            && self.mouse_capture_generation != env.mouse_capture_generation.get()
        {
            return Some(MemoDependencyKind::MouseCapture);
        }
        if self.dependencies.viewport && self.viewport != viewport {
            return Some(MemoDependencyKind::Viewport);
        }
        if self.dependencies.transition && self.transition_generation != env.animations.generation()
        {
            return Some(MemoDependencyKind::Transition);
        }
        if self.dependencies.host_terminal_colors
            && self.host_terminal_color_generation != env.host_terminal_color_generation.get()
        {
            return Some(MemoDependencyKind::HostTerminalColors);
        }
        let context_generations = env.context_generations.borrow();
        for &(type_id, name, generation) in &self.context_generations {
            if context_generations.get(&type_id).copied().unwrap_or(0) != generation {
                return Some(MemoDependencyKind::Context(name));
            }
        }
        None
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum MemoDependency {
    Theme,
    Context { type_id: TypeId, name: &'static str },
    Focus,
    Hover,
    Scroll(ScrollDependency),
    MouseCapture,
    Viewport,
    Transition,
    HostTerminalColors,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct ScrollIdentity {
    pub(crate) scope: ScopeId,
    pub(crate) key: Key,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum ScrollDependencyKind {
    Metrics,
    Scrollbars,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct ScrollDependency {
    pub(crate) identity: ScrollIdentity,
    pub(crate) kind: ScrollDependencyKind,
}

/// Bundle of shared runtime handles cloned into every component context.
///
/// All `Rc`-wrapped fields are cheap to clone; `inline_mode` is `Copy`.
#[derive(Clone)]
pub(crate) struct RuntimeEnv {
    pub command_registry: CommandRegistry,
    pub quit: Rc<Cell<bool>>,
    pub focus: Rc<FocusContext>,
    pub hover: Rc<HoverContext>,
    pub scroll: Rc<ScrollContext>,
    pub animations: Rc<AnimationRegistry>,
    pub overlay_manager: Rc<RefCell<crate::overlay::OverlayManager>>,
    pub focus_request: Rc<RefCell<Option<FocusRequest>>>,
    pub mouse_capture: Rc<Cell<bool>>,
    pub surface_mode: SurfaceMode,
    pub transcript_history: Rc<RefCell<Vec<TranscriptEntry>>>,
    pub pending_transcript_entries: Rc<RefCell<VecDeque<TranscriptEntry>>>,
    pub clipboard: Rc<ClipboardService>,
    pub clipboard_config: ClipboardConfig,
    pub active_theme: Rc<RefCell<Theme>>,
    pub active_theme_generation: Rc<Cell<u64>>,
    pub effect_phase: Rc<Cell<u64>>,
    pub contexts: Rc<RefCell<FxHashMap<TypeId, Arc<dyn Any>>>>,
    pub context_generations: Rc<RefCell<FxHashMap<TypeId, u64>>>,
    pub host_terminal_colors: Rc<Cell<Option<HostTerminalColors>>>,
    pub host_terminal_color_generation: Rc<Cell<u64>>,
    pub host_terminal_color_refresh_requested: Rc<Cell<bool>>,
    pub host_terminal_color_refresh_enabled: bool,
    pub mouse_capture_generation: Rc<Cell<u64>>,
    pub memo_dependency_recorder: Rc<RefCell<Option<MemoDependencies>>>,
    /// When set, the next frame performs a full reconcile and draw (e.g. after an external
    /// program repainted the host terminal).
    pub full_repaint: Rc<Cell<bool>>,
    /// Pending request to change devtools visibility on the UI thread.
    pub devtools_request: Rc<RefCell<Option<DevToolsRequest>>>,
    /// Host-application metric rows shown by the DevTools App tab.
    #[cfg(feature = "devtools")]
    pub devtools_metrics: Rc<DevToolsMetrics>,
    /// Pending UI snapshot export/delivery after the next render.
    pub ui_snapshot_request: Rc<RefCell<Option<crate::ui_snapshot::UiSnapshotRequest>>>,
    /// Pending requests to flash copy feedback on specific nodes.
    pub copy_feedback_request: Rc<RefCell<Vec<CopyFeedbackRequest>>>,
    /// When the currently pending multi-step command chord started, or `None` when no chord is
    /// pending. Carrying the instant rather than a flag is what lets an app defer chord chrome
    /// (a which-key panel, a hint bar) until the chord has been held for a while, instead of
    /// flashing it on every chord the user completes from muscle memory.
    pub command_chord_pending_since: Rc<std::cell::Cell<Option<Instant>>>,
    /// How long a chord must stay pending before [`Context::command_chord_revealed`] reports it.
    /// Zero (the default) reveals immediately.
    pub command_chord_reveal_delay: Rc<std::cell::Cell<Duration>>,
    /// Offset added to [`Instant::now`] for headless capture and tests, so time-gated UI
    /// (chord reveal, animations, blink) can be settled without waiting on the wall clock.
    pub clock_offset: Rc<Cell<Duration>>,
    /// Last pointer in terminal content coordinates, shared with the runner's mouse state.
    ///
    /// Updated on motion even when the event is forwarded to a tracking terminal, so an app can
    /// place something at the pointer from a key binding without having seen a move callback.
    pub last_mouse: Rc<Cell<Option<(u16, u16)>>>,
    /// Identity of the runtime this env belongs to.
    ///
    /// Carried here because the delayed-task queue is process-wide while runtimes are not, so a clock
    /// advance has to name whose timers it may fire. Copied into every clone, which all describe the
    /// same runtime.
    pub runtime_id: crate::core::component::RuntimeId,
}

impl RuntimeEnv {
    /// Record whether a command chord is pending, stamping the start instant on the rising edge.
    ///
    /// Returns whether the state changed, which is what tells the caller that chord chrome needs a
    /// redraw. Re-asserting the same state keeps the original instant, so a chord that advances
    /// through several steps is timed from the first one.
    pub(crate) fn set_command_chord_pending(&self, pending: bool) -> bool {
        if self.command_chord_pending_since.get().is_some() == pending {
            return false;
        }
        self.command_chord_pending_since
            .set(pending.then(|| self.now()));
        true
    }

    /// Current time as seen by the runtime, including any virtual-clock offset.
    ///
    /// Live apps keep the offset at zero, so this is wall-clock time. Headless capture and
    /// [`TestBackend::advance`](crate::TestBackend::advance) shift it forward so delays can
    /// elapse without sleeping.
    pub(crate) fn now(&self) -> Instant {
        Instant::now()
            .checked_add(self.clock_offset.get())
            .unwrap_or_else(Instant::now)
    }

    /// Elapsed time since `start`, honouring the virtual-clock offset.
    pub(crate) fn elapsed(&self, start: Instant) -> Duration {
        self.now().saturating_duration_since(start)
    }

    /// Shift the virtual clock forward by `dt`.
    ///
    /// This also brings [`Command::after`](crate::Command::after) timers forward by the same amount,
    /// running whatever becomes due. A clock that moved without firing the timers hung off it left a
    /// gap no caller could close: the deferred command is framework-owned, so an application could
    /// not settle it itself, and the harness had no wall clock for it to wait on. Deferred tasks run
    /// inline here, so the messages they send are queued before this returns.
    pub(crate) fn advance_clock(&self, dt: Duration) {
        self.clock_offset
            .set(self.clock_offset.get().saturating_add(dt));
        crate::core::component::advance_deferred_commands(self.now(), self.runtime_id);
    }

    /// How long until a pending chord becomes revealed, or `None` when nothing is pending or it is
    /// revealed already. The event loop uses this to schedule the frame that draws the reveal.
    pub(crate) fn command_chord_reveal_due_in(&self) -> Option<Duration> {
        let since = self.command_chord_pending_since.get()?;
        self.command_chord_reveal_delay
            .get()
            .checked_sub(self.elapsed(since))
            .filter(|remaining| !remaining.is_zero())
    }

    pub(crate) fn set_effect_phase(&self, phase: u64) {
        self.effect_phase.set(phase);
    }

    pub(crate) fn note_memo_dependency(&self, dependency: MemoDependency) {
        if let Some(recorder) = self.memo_dependency_recorder.borrow_mut().as_mut() {
            recorder.note(dependency);
        }
    }

    pub(crate) fn begin_memo_dependency_capture(&self) {
        *self.memo_dependency_recorder.borrow_mut() = Some(MemoDependencies::default());
    }

    pub(crate) fn finish_memo_dependency_capture(&self, viewport: Rect) -> MemoDependencySnapshot {
        let dependencies = self
            .memo_dependency_recorder
            .borrow_mut()
            .take()
            .unwrap_or_default();
        let context_generations_map = self.context_generations.borrow();
        let mut context_generations = SmallVec::new();
        for &(type_id, name) in &dependencies.contexts {
            context_generations.push((
                type_id,
                name,
                context_generations_map.get(&type_id).copied().unwrap_or(0),
            ));
        }
        let scroll_generations = dependencies
            .scroll
            .iter()
            .cloned()
            .map(|dependency| {
                let generation = self.scroll.dependency_generation(&dependency);
                (dependency, generation)
            })
            .collect();

        MemoDependencySnapshot {
            dependencies,
            theme_generation: self.active_theme_generation.get(),
            focus_generation: self.focus.generation(),
            hover_generation: self.hover.generation(),
            scroll_generations,
            mouse_capture_generation: self.mouse_capture_generation.get(),
            transition_generation: self.animations.generation(),
            host_terminal_color_generation: self.host_terminal_color_generation.get(),
            viewport,
            context_generations,
        }
    }

    pub(crate) fn host_terminal_colors(&self) -> Option<HostTerminalColors> {
        self.note_memo_dependency(MemoDependency::HostTerminalColors);
        self.host_terminal_colors.get()
    }

    pub(crate) fn host_terminal_color_generation(&self) -> u64 {
        self.note_memo_dependency(MemoDependency::HostTerminalColors);
        self.host_terminal_color_generation.get()
    }

    pub(crate) fn request_host_terminal_color_refresh(&self) {
        if self.host_terminal_color_refresh_enabled {
            self.host_terminal_color_refresh_requested.set(true);
        }
    }

    pub(crate) fn request_copy_feedback(&self, node_id: NodeId, range: Option<GridSelection>) {
        self.copy_feedback_request
            .borrow_mut()
            .push((node_id, range));
    }

    pub(crate) fn take_copy_feedback_requests(&self) -> Vec<CopyFeedbackRequest> {
        std::mem::take(&mut *self.copy_feedback_request.borrow_mut())
    }

    pub(crate) fn take_host_terminal_color_refresh_request(&self) -> bool {
        self.host_terminal_color_refresh_requested.replace(false)
    }

    pub(crate) fn set_host_terminal_colors(&self, colors: Option<HostTerminalColors>) -> bool {
        if self.host_terminal_colors.get() == colors {
            return false;
        }

        self.host_terminal_colors.set(colors);
        self.advance_host_terminal_color_generation();
        true
    }

    fn advance_host_terminal_color_generation(&self) {
        self.host_terminal_color_generation.set(
            self.host_terminal_color_generation
                .get()
                .wrapping_add(1)
                .max(1),
        );
    }
}