Skip to main content

tui_lipan/core/
component.rs

1use rustc_hash::FxHashMap;
2use std::any::TypeId;
3use std::cell::{Cell, RefCell};
4use std::marker::PhantomData;
5use std::sync::Arc;
6
7use crate::app::context::SurfaceMode;
8use crate::app::input::command_registry::CommandEntry;
9use crate::app::input::command_registry::CommandRegistry;
10use crate::callback::{CancellationToken, CommandLink, CommandTx, Dispatcher, Link, ScopeId};
11use crate::core::context_value::ContextValue;
12use crate::core::element::{Element, Key};
13use crate::core::event::KeyEvent;
14use crate::core::node::{NodeId, NodeKind, NodeTree};
15use crate::core::runtime_env::{
16    DevToolsRequest, MemoDependency, MemoDependencySnapshot, RuntimeEnv, TranscriptEntry,
17};
18use crate::style::{HostTerminalColors, Rect, RichText, Theme, ThemeExtension};
19
20/// Side-effect command returned from `Component::update`.
21///
22/// A `Command` represents work that should happen *outside* the synchronous `update` call
23/// (HTTP/IO/timers), typically executed by the runtime and eventually producing more messages.
24#[non_exhaustive]
25pub struct Command {
26    action: Box<dyn CommandAction>,
27}
28
29/// Coalescing behavior for keyed background tasks.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum TaskPolicy {
32    /// Always enqueue every task.
33    QueueAll,
34    /// If a task with the same key is already active, drop the new one.
35    DropIfRunning,
36    /// Keep only the latest pending task for the key.
37    LatestOnly,
38}
39
40mod task_policy;
41
42use task_policy::Task;
43
44#[cfg(not(target_arch = "wasm32"))]
45mod executor_native;
46#[cfg(target_arch = "wasm32")]
47mod executor_wasm;
48
49#[cfg(not(target_arch = "wasm32"))]
50use executor_native::TaskExecutor;
51#[cfg(target_arch = "wasm32")]
52use executor_wasm::TaskExecutor;
53
54impl std::fmt::Debug for Command {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("Command").finish_non_exhaustive()
57    }
58}
59
60impl Command {
61    /// Run a one-shot action on the UI thread.
62    pub fn new(action: impl FnOnce() + 'static) -> Self {
63        Self {
64            action: Box::new(RunAction(Some(action))),
65        }
66    }
67
68    /// Spawn a background task that can send messages back.
69    pub fn spawn<Msg, F>(f: F) -> Self
70    where
71        Msg: Send + 'static,
72        F: FnOnce(CommandLink<Msg>) + Send + 'static,
73    {
74        Self {
75            action: Box::new(SpawnAction::<Msg, F> {
76                f: Some(f),
77                _marker: PhantomData,
78            }),
79        }
80    }
81
82    /// Spawn a keyed background task with explicit coalescing policy.
83    pub fn spawn_keyed<Msg, F>(key: impl Into<Arc<str>>, policy: TaskPolicy, f: F) -> Self
84    where
85        Msg: Send + 'static,
86        F: FnOnce(CommandLink<Msg>) + Send + 'static,
87    {
88        Self {
89            action: Box::new(SpawnKeyedAction::<Msg, F> {
90                key: key.into(),
91                policy,
92                f: Some(f),
93                _marker: PhantomData,
94            }),
95        }
96    }
97
98    pub(crate) fn run(self, runtime: CommandRuntime) {
99        self.action.run(runtime);
100    }
101}
102
103pub(crate) struct CommandRuntime {
104    pub(crate) scope: ScopeId,
105    pub(crate) tx: CommandTx,
106}
107
108trait CommandAction {
109    fn run(self: Box<Self>, runtime: CommandRuntime);
110}
111
112struct RunAction<F>(Option<F>);
113
114impl<F> CommandAction for RunAction<F>
115where
116    F: FnOnce() + 'static,
117{
118    fn run(mut self: Box<Self>, _runtime: CommandRuntime) {
119        if let Some(f) = self.0.take() {
120            f();
121        }
122    }
123}
124
125struct SpawnAction<Msg, F> {
126    f: Option<F>,
127    _marker: PhantomData<fn(Msg)>,
128}
129
130struct SpawnKeyedAction<Msg, F> {
131    key: Arc<str>,
132    policy: TaskPolicy,
133    f: Option<F>,
134    _marker: PhantomData<fn(Msg)>,
135}
136
137impl<Msg, F> CommandAction for SpawnAction<Msg, F>
138where
139    Msg: Send + 'static,
140    F: FnOnce(CommandLink<Msg>) + Send + 'static,
141{
142    fn run(mut self: Box<Self>, runtime: CommandRuntime) {
143        let Some(f) = self.f.take() else {
144            return;
145        };
146
147        let token = CancellationToken::default();
148        let link = CommandLink::new(runtime.scope, runtime.tx, token.clone());
149        TaskExecutor::global().execute(Task::with_token(move || f(link), token));
150    }
151}
152
153impl<Msg, F> CommandAction for SpawnKeyedAction<Msg, F>
154where
155    Msg: Send + 'static,
156    F: FnOnce(CommandLink<Msg>) + Send + 'static,
157{
158    fn run(mut self: Box<Self>, runtime: CommandRuntime) {
159        let Some(f) = self.f.take() else {
160            return;
161        };
162
163        let key = Arc::clone(&self.key);
164        let policy = self.policy;
165        let token = CancellationToken::default();
166        let link = CommandLink::new(runtime.scope, runtime.tx, token.clone());
167        TaskExecutor::global().execute_keyed(key, policy, Task::with_token(move || f(link), token));
168    }
169}
170
171impl<Msg: 'static> Link<Msg> {
172    /// Create a background `Command` that can send messages back to this component.
173    pub fn command<F>(&self, f: F) -> Command
174    where
175        Msg: Send + 'static,
176        F: FnOnce(CommandLink<Msg>) + Send + 'static,
177    {
178        Command::spawn::<Msg, F>(f)
179    }
180
181    /// Create a keyed background `Command` with coalescing policy.
182    pub fn command_keyed<F>(&self, key: impl Into<Arc<str>>, policy: TaskPolicy, f: F) -> Command
183    where
184        Msg: Send + 'static,
185        F: FnOnce(CommandLink<Msg>) + Send + 'static,
186    {
187        Command::spawn_keyed::<Msg, F>(key, policy, f)
188    }
189}
190
191/// Result from a component's `update()` method.
192///
193/// `level` refines the minimum refresh work needed by the runtime.
194#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
195pub(crate) enum UpdateLevel {
196    #[default]
197    None,
198    Paint,
199    Layout,
200    Full,
201}
202
203/// Component update result with optional side-effect command.
204pub struct Update {
205    /// Whether the component's view needs re-rendering.
206    pub dirty: bool,
207    /// Granularity of the requested refresh.
208    pub(crate) level: UpdateLevel,
209    /// An optional async command to execute.
210    pub command: Option<Command>,
211}
212
213impl Update {
214    /// Request a paint-only update.
215    pub fn paint() -> Self {
216        Self {
217            dirty: true,
218            level: UpdateLevel::Paint,
219            command: None,
220        }
221    }
222
223    /// Request a layout reconcile update.
224    pub fn layout() -> Self {
225        Self {
226            dirty: true,
227            level: UpdateLevel::Layout,
228            command: None,
229        }
230    }
231
232    /// Request a full update.
233    pub fn full() -> Self {
234        Self {
235            dirty: true,
236            level: UpdateLevel::Full,
237            command: None,
238        }
239    }
240
241    /// Run a command without marking the component dirty (e.g. background fetch with a follow-up message).
242    pub fn command_only(command: Command) -> Self {
243        Self {
244            dirty: false,
245            level: UpdateLevel::None,
246            command: Some(command),
247        }
248    }
249
250    /// Nothing changed, no command.
251    pub fn none() -> Self {
252        Self {
253            dirty: false,
254            level: UpdateLevel::None,
255            command: None,
256        }
257    }
258
259    /// Full refresh, and optionally run a command when [`Some`]; when [`None`], same as [`Self::full`].
260    pub fn with_command(command: impl Into<Option<Command>>) -> Self {
261        match command.into() {
262            Some(cmd) => Self {
263                dirty: true,
264                level: UpdateLevel::Full,
265                command: Some(cmd),
266            },
267            None => Self::full(),
268        }
269    }
270
271    pub(crate) fn level(&self) -> UpdateLevel {
272        self.level
273    }
274}
275
276/// The return type of `Component::on_key`.
277///
278/// `handled` is tracked separately from `dirty` and `command`.
279pub struct KeyUpdate {
280    /// Whether the key event was handled.
281    pub handled: bool,
282    /// State change and side-effect work, same as `Update`.
283    pub update: Update,
284}
285
286impl KeyUpdate {
287    /// Mark the key event as handled.
288    pub fn handled(update: Update) -> Self {
289        Self {
290            handled: true,
291            update,
292        }
293    }
294
295    /// Mark the key event as unhandled.
296    pub fn unhandled(update: Update) -> Self {
297        Self {
298            handled: false,
299            update,
300        }
301    }
302}
303
304/// A stateful, reusable UI component.
305///
306/// Components own their dependencies via the struct instance (dependency injection),
307/// while all UI state mutations happen through `update()` via `Context`.
308pub trait Component: Sized + 'static {
309    /// Messages (events) that can be sent to this component.
310    type Message: 'static;
311
312    /// Properties passed from the parent.
313    type Properties: Clone + PartialEq + 'static;
314
315    /// Local state owned by the runtime.
316    type State: 'static;
317
318    /// Create the initial state for this component.
319    fn create_state(&self, props: &Self::Properties) -> Self::State;
320
321    /// Stable memo key used to retain this component's previously expanded subtree.
322    ///
323    /// Return `Some(key)` to opt into retained subtree reuse. When the key is unchanged,
324    /// the runtime may skip `view()` and reuse the prior expanded subtree until local state,
325    /// props, or observed context dependencies require a refresh.
326    fn memo_key(&self, _props: &Self::Properties, _ctx: &Context<Self>) -> Option<u64> {
327        None
328    }
329
330    /// Called once when the component is first mounted.
331    ///
332    /// This is the right place to kick off background work (HTTP/IO) by returning a `Command`.
333    fn init(&mut self, _ctx: &mut Context<Self>) -> Option<Command> {
334        None
335    }
336
337    /// Declarative UI definition.
338    fn view(&self, ctx: &Context<Self>) -> Element;
339
340    /// Handle a keyboard event that was not handled by the focused node.
341    ///
342    /// This enables global shortcuts (e.g. `Ctrl+S`) without attaching handlers to every widget.
343    /// Return `KeyUpdate::handled` to stop bubbling.
344    fn on_key(&mut self, _key: KeyEvent, _ctx: &mut Context<Self>) -> KeyUpdate {
345        KeyUpdate::unhandled(Update::none())
346    }
347
348    /// Update state in response to a message.
349    ///
350    /// Returns `(dirty, command)`.
351    fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update;
352
353    /// Called when properties have changed.
354    ///
355    /// Returns `(dirty, command)`.
356    fn on_props_changed(
357        &mut self,
358        _old_props: &Self::Properties,
359        _ctx: &mut Context<Self>,
360    ) -> Update {
361        Update::none()
362    }
363
364    /// Called once when the component is being unmounted.
365    fn unmount(&mut self, _ctx: &mut Context<Self>) {}
366}
367
368/// Simple width breakpoint derived from the current viewport.
369#[derive(Clone, Copy, Debug, PartialEq, Eq)]
370pub enum Breakpoint {
371    /// Narrow terminal / compact layout.
372    Small,
373    /// Medium-width terminal.
374    Medium,
375    /// Wide terminal.
376    Large,
377}
378
379/// Resolved scrollbar visibility for a keyed [`TextArea`](crate::widgets::TextArea).
380#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
381pub struct ScrollbarVisibility {
382    /// Whether the vertical scrollbar is visible.
383    pub v: bool,
384    /// Whether the horizontal scrollbar is visible.
385    pub h: bool,
386}
387
388/// Shared ancestor-walk state used by both [`FocusContext`] and [`HoverContext`].
389#[derive(Default)]
390struct NodeChainState {
391    current_node: Cell<Option<NodeId>>,
392    chain_scopes: RefCell<Vec<ScopeId>>,
393    chain_keys: RefCell<Vec<Key>>,
394    generation: Cell<u64>,
395}
396
397impl NodeChainState {
398    fn node_id(&self) -> Option<NodeId> {
399        self.current_node.get()
400    }
401
402    fn has_within_scope(&self, scope: ScopeId) -> bool {
403        self.chain_scopes.borrow().contains(&scope)
404    }
405
406    fn has_within_key(&self, key: &Key) -> bool {
407        self.chain_keys
408            .borrow()
409            .iter()
410            .any(|candidate| candidate == key)
411    }
412
413    fn push_scope_if_missing(scopes: &mut Vec<ScopeId>, scope: ScopeId) {
414        if !scopes.contains(&scope) {
415            scopes.push(scope);
416        }
417    }
418
419    fn push_key_if_missing(keys: &mut Vec<Key>, key: &Key) {
420        if !keys.iter().any(|candidate| candidate == key) {
421            keys.push(key.clone());
422        }
423    }
424
425    fn replace_snapshot(&self, node: Option<NodeId>, scopes: Vec<ScopeId>, keys: Vec<Key>) {
426        let mut scope_ref = self.chain_scopes.borrow_mut();
427        let mut key_ref = self.chain_keys.borrow_mut();
428        let changed = self.current_node.get() != node || *scope_ref != scopes || *key_ref != keys;
429
430        self.current_node.set(node);
431        *scope_ref = scopes;
432        *key_ref = keys;
433
434        if changed {
435            self.generation
436                .set(self.generation.get().wrapping_add(1).max(1));
437        }
438    }
439
440    /// Walk the ancestor chain from `node` upward, populating scopes and keys.
441    fn update_chain(&self, tree: &NodeTree, node: Option<NodeId>) {
442        let mut scopes = Vec::new();
443        let mut keys = Vec::new();
444
445        if let Some(mut cur) = node {
446            Self::push_scope_if_missing(&mut scopes, ScopeId(1));
447
448            loop {
449                if !tree.is_valid(cur) {
450                    break;
451                }
452
453                let node_ref = tree.node(cur);
454
455                if let Some(k) = &node_ref.key {
456                    Self::push_key_if_missing(&mut keys, k);
457                }
458
459                if let NodeKind::Group(group) = &node_ref.kind {
460                    Self::push_scope_if_missing(&mut scopes, group.scope);
461                }
462
463                let Some(parent) = node_ref.parent else {
464                    break;
465                };
466                cur = parent;
467            }
468        }
469
470        self.replace_snapshot(node, scopes, keys);
471    }
472
473    fn generation(&self) -> u64 {
474        self.generation.get()
475    }
476}
477
478/// Shared render-time focus information from the previous frame.
479#[derive(Default)]
480pub(crate) struct FocusContext {
481    inner: NodeChainState,
482}
483
484impl FocusContext {
485    pub(crate) fn update_from_tree(
486        &self,
487        tree: &NodeTree,
488        focused: Option<NodeId>,
489        focused_key: Option<&Key>,
490    ) {
491        let mut cur = focused.filter(|id| tree.is_valid(*id));
492
493        if cur.is_none()
494            && let Some(key) = focused_key
495        {
496            if let Some(id) = tree
497                .iter()
498                .find(|n| n.key.as_ref() == Some(key))
499                .map(|n| n.id)
500            {
501                cur = Some(id);
502            } else {
503                self.inner
504                    .replace_snapshot(None, Vec::new(), vec![key.clone()]);
505                return;
506            }
507        }
508
509        self.inner.update_chain(tree, cur);
510    }
511
512    pub(crate) fn focused_node_id(&self) -> Option<NodeId> {
513        self.inner.node_id()
514    }
515
516    pub(crate) fn has_focus_within_scope(&self, scope: ScopeId) -> bool {
517        self.inner.has_within_scope(scope)
518    }
519
520    pub(crate) fn has_focus_within_key(&self, key: &Key) -> bool {
521        self.inner.has_within_key(key)
522    }
523
524    pub(crate) fn generation(&self) -> u64 {
525        self.inner.generation()
526    }
527}
528
529/// Shared render-time hover information from the previous frame.
530#[derive(Default)]
531pub(crate) struct HoverContext {
532    inner: NodeChainState,
533}
534
535impl HoverContext {
536    pub(crate) fn update_from_tree(&self, tree: &NodeTree, hovered: Option<NodeId>) {
537        let cur = hovered.filter(|id| tree.is_valid(*id));
538        self.inner.update_chain(tree, cur);
539    }
540
541    pub(crate) fn hovered_node_id(&self) -> Option<NodeId> {
542        self.inner.node_id()
543    }
544
545    pub(crate) fn has_hover_within_scope(&self, scope: ScopeId) -> bool {
546        self.inner.has_within_scope(scope)
547    }
548
549    pub(crate) fn has_hover_within_key(&self, key: &Key) -> bool {
550        self.inner.has_within_key(key)
551    }
552
553    pub(crate) fn generation(&self) -> u64 {
554        self.inner.generation()
555    }
556}
557
558/// Shared render-time scrollable information from the previous frame.
559#[derive(Default)]
560pub(crate) struct ScrollContext {
561    by_key: RefCell<FxHashMap<Key, ScrollbarVisibility>>,
562    text_area_metrics_by_key: RefCell<FxHashMap<Key, crate::widgets::TextAreaMetrics>>,
563    generation: Cell<u64>,
564}
565
566impl ScrollContext {
567    pub(crate) fn update_from_tree(&self, tree: &NodeTree) {
568        let mut map = self.by_key.borrow_mut();
569        let prev = std::mem::take(&mut *map);
570        let mut metrics_map = self.text_area_metrics_by_key.borrow_mut();
571        let prev_metrics = std::mem::take(&mut *metrics_map);
572
573        for node in tree.iter_with_overlays() {
574            if let (Some(key), NodeKind::TextArea(text_area)) = (&node.key, &node.kind) {
575                let metrics = text_area.metrics(node.rect);
576                map.insert(key.clone(), metrics.scrollbars);
577                metrics_map.insert(key.clone(), metrics);
578            }
579        }
580
581        if *map != prev || *metrics_map != prev_metrics {
582            self.generation
583                .set(self.generation.get().wrapping_add(1).max(1));
584        }
585    }
586
587    pub(crate) fn get(&self, key: &Key) -> Option<ScrollbarVisibility> {
588        self.by_key.borrow().get(key).copied()
589    }
590
591    pub(crate) fn text_area_metrics(&self, key: &Key) -> Option<crate::widgets::TextAreaMetrics> {
592        self.text_area_metrics_by_key.borrow().get(key).cloned()
593    }
594
595    pub(crate) fn generation(&self) -> u64 {
596        self.generation.get()
597    }
598}
599
600/// Per-component runtime context.
601pub struct Context<C: Component> {
602    /// Component-local state.
603    pub state: C::State,
604
605    /// Component properties.
606    pub props: C::Properties,
607
608    viewport: Rect,
609    link: Link<C::Message>,
610    env: RuntimeEnv,
611    scope: ScopeId,
612}
613
614impl<C: Component> Context<C> {
615    pub(crate) fn new(
616        component: &C,
617        scope: ScopeId,
618        dispatcher: Dispatcher,
619        props: C::Properties,
620        env: RuntimeEnv,
621        viewport: Rect,
622    ) -> Self {
623        let state = component.create_state(&props);
624        Self {
625            state,
626            props,
627            viewport,
628            link: Link::new(scope, dispatcher),
629            env,
630            scope,
631        }
632    }
633
634    /// Link used to create callbacks.
635    pub fn link(&self) -> &Link<C::Message> {
636        &self.link
637    }
638
639    /// Access the toast notification API.
640    pub fn toast(&self) -> crate::overlay::ToastHandle {
641        crate::overlay::ToastHandle::new(self.env.overlay_manager.clone())
642    }
643
644    /// Access the clipboard API.
645    pub fn clipboard(&self) -> crate::clipboard::ClipboardHandle {
646        crate::clipboard::ClipboardHandle::new(
647            self.env.clipboard.clone(),
648            self.env.clipboard_config.clone(),
649        )
650    }
651
652    /// Access the command registry API.
653    pub fn command_registry(&self) -> CommandRegistry {
654        self.env.command_registry.clone()
655    }
656
657    /// Register a command scoped to this component instance.
658    pub fn register_command(&self, entry: CommandEntry) {
659        self.env
660            .command_registry
661            .register_for_scope(self.scope, entry);
662    }
663
664    /// Current viewport bounds (content area) for this render.
665    pub fn viewport(&self) -> Rect {
666        self.env.note_memo_dependency(MemoDependency::Viewport);
667        self.viewport
668    }
669
670    /// Returns the active theme for this component's subtree.
671    pub fn theme(&self) -> Theme {
672        self.env.note_memo_dependency(MemoDependency::Theme);
673        self.env.active_theme.borrow().clone()
674    }
675
676    /// Returns a cloned typed theme extension from the active theme.
677    pub fn theme_extension<T>(&self) -> Option<T>
678    where
679        T: ThemeExtension,
680    {
681        self.env.note_memo_dependency(MemoDependency::Theme);
682        self.env.active_theme.borrow().extension_cloned::<T>()
683    }
684
685    /// Returns a cloned typed value from the nearest active `ContextProvider<T>`.
686    pub fn use_context<T>(&self) -> Option<T>
687    where
688        T: ContextValue,
689    {
690        self.env
691            .note_memo_dependency(MemoDependency::Context(TypeId::of::<T>()));
692        self.env
693            .contexts
694            .borrow()
695            .get(&TypeId::of::<T>())
696            .and_then(|value| value.as_ref().downcast_ref::<T>())
697            .cloned()
698    }
699
700    /// Returns a cloned typed value from the nearest active `ContextProvider<T>`.
701    pub fn context<T>(&self) -> Option<T>
702    where
703        T: ContextValue,
704    {
705        self.use_context::<T>()
706    }
707
708    /// Returns `true` when the app runs in inline viewport mode.
709    pub fn is_inline(&self) -> bool {
710        self.env.surface_mode.is_inline()
711    }
712
713    /// Returns the app surface mode.
714    pub fn surface_mode(&self) -> SurfaceMode {
715        self.env.surface_mode
716    }
717
718    /// Returns the current renderer animation phase used by built-in visual effects.
719    ///
720    /// Capture this value when starting a one-shot phase-based effect such as
721    /// [`VisualEffect::centered_burst_ripple`](crate::style::VisualEffect::centered_burst_ripple).
722    pub fn effect_phase(&self) -> u64 {
723        self.env.effect_phase.get()
724    }
725
726    /// Return the latest host terminal colors known to the runner.
727    ///
728    /// Values are available after `App::system_theme()` or
729    /// `App::live_host_terminal_colors(true)` is enabled and the runner completes
730    /// at least one successful OSC 4/10/11 probe. The cache is updated on the UI
731    /// thread, not by app background tasks.
732    pub fn host_terminal_colors(&self) -> Option<HostTerminalColors> {
733        self.env.host_terminal_colors()
734    }
735
736    /// Return the generation for the cached host terminal colors.
737    ///
738    /// Starts at `0` and increments whenever the runner observes a different
739    /// host terminal palette. Components can compare this value to decide when
740    /// to rebuild app-specific theme tokens.
741    pub fn host_terminal_color_generation(&self) -> u64 {
742        self.env.host_terminal_color_generation()
743    }
744
745    /// Ask the runner to refresh host terminal colors on the UI thread.
746    ///
747    /// This is a no-op unless `App::system_theme()` or
748    /// `App::live_host_terminal_colors(true)` was enabled. Those app settings
749    /// also refresh on focus gained. The actual OSC query is performed later by
750    /// the runner while coordinating with its input reader, so apps should call
751    /// this instead of polling
752    /// [`query_host_colors`](crate::style::query_host_colors) from background
753    /// threads.
754    pub fn request_host_terminal_color_refresh(&self) {
755        self.env.request_host_terminal_color_refresh();
756    }
757
758    /// Returns whether terminal mouse capture is currently enabled.
759    pub fn mouse_capture_enabled(&self) -> bool {
760        self.env.note_memo_dependency(MemoDependency::MouseCapture);
761        self.env.mouse_capture.get()
762    }
763
764    /// Enable or disable terminal mouse capture at runtime.
765    pub fn set_mouse_capture(&self, enabled: bool) {
766        if self.env.mouse_capture.get() != enabled {
767            self.env.mouse_capture.set(enabled);
768            self.env.mouse_capture_generation.set(
769                self.env
770                    .mouse_capture_generation
771                    .get()
772                    .wrapping_add(1)
773                    .max(1),
774            );
775        }
776    }
777
778    /// Toggle terminal mouse capture at runtime and return the new state.
779    pub fn toggle_mouse_capture(&self) -> bool {
780        let next = !self.env.mouse_capture.get();
781        self.set_mouse_capture(next);
782        next
783    }
784
785    /// Append plain rich-text lines to transcript history above the inline viewport.
786    ///
787    /// This is a no-op outside inline transcript mode.
788    pub fn append_transcript_lines<I, L>(&mut self, lines: I)
789    where
790        I: IntoIterator<Item = L>,
791        L: Into<RichText>,
792    {
793        if !matches!(self.env.surface_mode, SurfaceMode::InlineTranscript { .. }) {
794            return;
795        }
796
797        let lines: Vec<RichText> = lines.into_iter().map(Into::into).collect();
798        if lines.is_empty() {
799            return;
800        }
801
802        self.env
803            .transcript_history
804            .borrow_mut()
805            .push(TranscriptEntry::Lines(lines.clone()));
806        self.env
807            .pending_transcript_entries
808            .borrow_mut()
809            .push_back(TranscriptEntry::Lines(lines));
810    }
811
812    /// Append a rendered element to transcript history above the inline viewport.
813    ///
814    /// This is a no-op outside inline transcript mode. The appended subtree must
815    /// already be expanded: this API accepts widget trees, not `Component` elements.
816    pub fn append_transcript_element(&mut self, element: impl Into<Element>) {
817        if !matches!(self.env.surface_mode, SurfaceMode::InlineTranscript { .. }) {
818            return;
819        }
820
821        let element = element.into();
822        if element.contains_unexpanded_component() {
823            crate::debug::internal_log!(
824                "[tui-lipan] append_transcript_element ignored an element containing Component nodes"
825            );
826            return;
827        }
828
829        self.env
830            .transcript_history
831            .borrow_mut()
832            .push(TranscriptEntry::Element(Box::new(element.clone())));
833        self.env
834            .pending_transcript_entries
835            .borrow_mut()
836            .push_back(TranscriptEntry::Element(Box::new(element)));
837    }
838
839    /// Returns `true` if the currently focused node (from the previous frame) is inside this
840    /// component's subtree.
841    pub fn has_focus_within(&self) -> bool {
842        self.env.note_memo_dependency(MemoDependency::Focus);
843        self.env.focus.has_focus_within_scope(self.scope)
844    }
845
846    /// Returns `true` if the currently focused node (from the previous frame) is inside the
847    /// subtree of the element identified by `key`.
848    pub fn has_focus_within_key(&self, key: impl Into<Key>) -> bool {
849        let key = key.into();
850        self.env.note_memo_dependency(MemoDependency::Focus);
851        self.env.focus.has_focus_within_key(&key)
852    }
853
854    /// Returns resolved scrollbar visibility for the keyed `TextArea` from the previous frame.
855    ///
856    /// The `TextArea` element must have an `Element::key`; missing or first-frame entries return
857    /// `ScrollbarVisibility::default()`.
858    pub fn text_area_scrollbars(&self, key: impl Into<Key>) -> ScrollbarVisibility {
859        let key = key.into();
860        self.env.note_memo_dependency(MemoDependency::Scroll);
861        self.text_area_metrics(key.clone())
862            .map(|metrics| metrics.scrollbars)
863            .or_else(|| self.env.scroll.get(&key))
864            .unwrap_or_default()
865    }
866
867    /// Returns previous-frame metrics for a keyed `TextArea`.
868    pub fn text_area_metrics(
869        &self,
870        key: impl Into<Key>,
871    ) -> Option<crate::widgets::TextAreaMetrics> {
872        let key = key.into();
873        self.env.note_memo_dependency(MemoDependency::Scroll);
874        self.env.scroll.text_area_metrics(&key)
875    }
876
877    /// Returns `true` if the hovered node (from the previous frame) is inside this
878    /// component's subtree.
879    pub fn has_hover_within(&self) -> bool {
880        self.env.note_memo_dependency(MemoDependency::Hover);
881        self.env.hover.has_hover_within_scope(self.scope)
882    }
883
884    /// Returns `true` if the hovered node (from the previous frame) is inside the
885    /// subtree of the element identified by `key`.
886    pub fn has_hover_within_key(&self, key: impl Into<Key>) -> bool {
887        let key = key.into();
888        self.env.note_memo_dependency(MemoDependency::Hover);
889        self.env.hover.has_hover_within_key(&key)
890    }
891
892    /// Returns the focused node id from the previous frame, if any.
893    pub fn focused_node_id(&self) -> Option<NodeId> {
894        self.env.note_memo_dependency(MemoDependency::Focus);
895        self.env.focus.focused_node_id()
896    }
897
898    /// Returns the hovered node id from the previous frame, if any.
899    pub fn hovered_node_id(&self) -> Option<NodeId> {
900        self.env.note_memo_dependency(MemoDependency::Hover);
901        self.env.hover.hovered_node_id()
902    }
903
904    /// Property-scoped transition for a single style value.
905    ///
906    /// Pass the desired *final* `target` each frame. The first call for a given
907    /// `key` records the target as the resting value. When `target` differs
908    /// from the previously stored target, a transition starts from the current
909    /// value to the new target using `config`. Returns the current interpolated
910    /// value for this frame — embed it directly in a `Style` slot.
911    ///
912    /// Animations are driven by the runtime: while a transition is in flight,
913    /// the component re-renders each animation tick (~16 ms) so the new value
914    /// flows into the style. Keys not read during a frame are dropped, so
915    /// transitions for hidden elements are automatically cleaned up.
916    ///
917    /// ```ignore
918    /// let edge_fg = ctx.transition(
919    ///     "prompt-edge",
920    ///     if focused { theme.primary } else { theme.muted },
921    ///     TransitionConfig::default(),
922    /// );
923    /// EdgeDecoration::new(Edge::Left).style(Style::new().fg(edge_fg))
924    /// ```
925    ///
926    /// # Panics
927    /// Panics if the same `key` is used with two different value types.
928    pub fn transition<T>(
929        &self,
930        key: impl Into<Key>,
931        target: T,
932        config: crate::animation::TransitionConfig,
933    ) -> T
934    where
935        T: crate::animation::Lerp + PartialEq + 'static,
936    {
937        let key = key.into();
938        self.env.note_memo_dependency(MemoDependency::Transition);
939        self.env.animations.transition(key, target, config)
940    }
941
942    /// Convenience helper for responsive layouts based on viewport width.
943    ///
944    /// Returns:
945    /// - `Breakpoint::Small` if `viewport().w < medium`,
946    /// - `Breakpoint::Medium` if `viewport().w < large`,
947    /// - `Breakpoint::Large` otherwise.
948    pub fn breakpoint(&self, medium: u16, large: u16) -> Breakpoint {
949        let (medium, large) = if medium <= large {
950            (medium, large)
951        } else {
952            (large, medium)
953        };
954
955        self.env.note_memo_dependency(MemoDependency::Viewport);
956        let w = self.viewport.w;
957        if w < medium {
958            Breakpoint::Small
959        } else if w < large {
960            Breakpoint::Medium
961        } else {
962            Breakpoint::Large
963        }
964    }
965
966    pub(crate) fn env(&self) -> &RuntimeEnv {
967        &self.env
968    }
969
970    pub(crate) fn set_viewport(&mut self, viewport: Rect) {
971        self.viewport = viewport;
972    }
973
974    pub(crate) fn set_active_theme(&mut self, theme: Theme) {
975        let mut active_theme = self.env.active_theme.borrow_mut();
976        if *active_theme != theme {
977            *active_theme = theme;
978            self.env.active_theme_generation.set(
979                self.env
980                    .active_theme_generation
981                    .get()
982                    .wrapping_add(1)
983                    .max(1),
984            );
985        }
986    }
987
988    pub(crate) fn set_contexts(
989        &mut self,
990        contexts: rustc_hash::FxHashMap<TypeId, std::sync::Arc<dyn std::any::Any>>,
991        generations: rustc_hash::FxHashMap<TypeId, u64>,
992    ) {
993        *self.env.contexts.borrow_mut() = contexts;
994        *self.env.context_generations.borrow_mut() = generations;
995    }
996
997    pub(crate) fn memo_key(&self, component: &C) -> Option<u64> {
998        component.memo_key(&self.props, self)
999    }
1000
1001    pub(crate) fn begin_memo_dependency_capture(&self) {
1002        self.env.begin_memo_dependency_capture();
1003    }
1004
1005    pub(crate) fn finish_memo_dependency_capture(&self) -> MemoDependencySnapshot {
1006        self.env.finish_memo_dependency_capture(self.viewport)
1007    }
1008
1009    pub(crate) fn memo_dependencies_match(&self, snapshot: &MemoDependencySnapshot) -> bool {
1010        snapshot.matches(&self.env, self.viewport)
1011    }
1012
1013    /// Request application shutdown.
1014    pub fn quit(&mut self) {
1015        self.env.quit.set(true);
1016    }
1017
1018    /// Request focus to move to the focusable node with `key`.
1019    ///
1020    /// This takes effect on the next event loop tick / render.
1021    pub fn request_focus(&mut self, key: impl Into<Key>) {
1022        *self.env.focus_request.borrow_mut() = Some(key.into());
1023    }
1024
1025    /// Request a full layout and paint pass on the next frame.
1026    ///
1027    /// Use after the host terminal was repainted by another process (external editor,
1028    /// pager, etc.) so nested components are not stuck on a layout-only update path.
1029    pub fn request_full_repaint(&self) {
1030        self.env.full_repaint.set(true);
1031    }
1032
1033    /// Request that the built-in devtools panel becomes visible.
1034    pub fn show_devtools(&self) {
1035        *self.env.devtools_request.borrow_mut() = Some(DevToolsRequest::Show);
1036    }
1037
1038    /// Request that the built-in devtools panel becomes hidden.
1039    pub fn hide_devtools(&self) {
1040        *self.env.devtools_request.borrow_mut() = Some(DevToolsRequest::Hide);
1041    }
1042
1043    /// Request that the built-in devtools panel toggles visibility.
1044    pub fn toggle_devtools(&self) {
1045        *self.env.devtools_request.borrow_mut() = Some(DevToolsRequest::Toggle);
1046    }
1047
1048    pub(crate) fn take_focus_request(&self) -> Option<Key> {
1049        self.env.focus_request.borrow_mut().take()
1050    }
1051
1052    pub(crate) fn take_full_repaint_request(&self) -> bool {
1053        self.env.full_repaint.replace(false)
1054    }
1055
1056    pub(crate) fn take_devtools_request(&self) -> Option<DevToolsRequest> {
1057        self.env.devtools_request.borrow_mut().take()
1058    }
1059
1060    /// Queue a UI snapshot write to `path` after the next render.
1061    ///
1062    /// Uses JSON when `path` ends with `.json` and the `ui-snapshot-json` feature is
1063    /// enabled, or PNG when `path` ends with `.png` and the `ui-snapshot-png` feature
1064    /// is enabled; otherwise writes markdown.
1065    ///
1066    /// A pending request replaces any earlier one (last writer wins). Triggers a full
1067    /// repaint so idle apps still deliver the snapshot.
1068    pub fn request_ui_snapshot_to(&self, path: impl AsRef<std::path::Path>) {
1069        let path = path.as_ref().to_path_buf();
1070        let extension = path.extension();
1071        let format = if extension.is_some_and(|ext| ext == "json" || ext == "JSON") {
1072            #[cfg(feature = "ui-snapshot-json")]
1073            {
1074                crate::ui_snapshot::UiSnapshotFileFormat::Json
1075            }
1076            #[cfg(not(feature = "ui-snapshot-json"))]
1077            {
1078                crate::ui_snapshot::UiSnapshotFileFormat::Markdown
1079            }
1080        } else if extension.is_some_and(|ext| ext == "png" || ext == "PNG") {
1081            #[cfg(feature = "ui-snapshot-png")]
1082            {
1083                crate::ui_snapshot::UiSnapshotFileFormat::Png
1084            }
1085            #[cfg(not(feature = "ui-snapshot-png"))]
1086            {
1087                crate::ui_snapshot::UiSnapshotFileFormat::Markdown
1088            }
1089        } else {
1090            crate::ui_snapshot::UiSnapshotFileFormat::Markdown
1091        };
1092        *self.env.ui_snapshot_request.borrow_mut() =
1093            Some(crate::ui_snapshot::UiSnapshotRequest::Write { path, format });
1094        self.request_full_repaint();
1095    }
1096
1097    /// Queue delivery of a UI snapshot into `slot` after the next render.
1098    ///
1099    /// A pending request replaces any earlier one (last writer wins). Triggers a full
1100    /// repaint so idle apps still deliver the snapshot.
1101    pub fn request_ui_snapshot_to_slot(&self, slot: &crate::ui_snapshot::UiSnapshotSlot) {
1102        *self.env.ui_snapshot_request.borrow_mut() = Some(
1103            crate::ui_snapshot::UiSnapshotRequest::Deliver(slot.shared()),
1104        );
1105        self.request_full_repaint();
1106    }
1107
1108    pub(crate) fn take_ui_snapshot_request(&self) -> Option<crate::ui_snapshot::UiSnapshotRequest> {
1109        self.env.ui_snapshot_request.borrow_mut().take()
1110    }
1111
1112    pub(crate) fn should_quit(&self) -> bool {
1113        self.env.quit.get()
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests;