Skip to main content

textual_rs/widget/
context.rs

1//! Application context passed to every widget for state and service access.
2use super::toast::{push_toast, ToastEntry, ToastSeverity};
3use super::Widget;
4use super::WidgetId;
5use crate::css::cascade::Stylesheet;
6use crate::css::render_style;
7use crate::css::theme::{self, Theme};
8use crate::css::types::{ComputedStyle, Declaration, PseudoClassSet};
9use crate::event::AppEvent;
10use crate::terminal::{MouseCaptureStack, TerminalCaps};
11use ratatui::style::Style;
12use slotmap::{DenseSlotMap, SecondaryMap};
13use std::any::Any;
14use std::cell::{Cell, RefCell};
15use std::collections::HashMap;
16
17/// Shared application state passed by reference to every widget callback.
18///
19/// Provides access to the widget arena, CSS computed styles, focus state, screen stack,
20/// event/message queues, and service methods (push_screen, post_message, run_worker, toast).
21pub struct AppContext {
22    /// Widget arena — all mounted widgets stored by their [`WidgetId`].
23    pub arena: DenseSlotMap<WidgetId, Box<dyn Widget>>,
24    /// Parent-to-children mapping for the widget tree.
25    pub children: SecondaryMap<WidgetId, Vec<WidgetId>>,
26    /// Child-to-parent mapping for the widget tree.
27    pub parent: SecondaryMap<WidgetId, Option<WidgetId>>,
28    /// CSS-cascaded styles for each mounted widget.
29    pub computed_styles: SecondaryMap<WidgetId, ComputedStyle>,
30    /// Per-widget inline style declarations (set via `Widget::inline_styles`).
31    pub inline_styles: SecondaryMap<WidgetId, Vec<Declaration>>,
32    /// Per-widget dirty flag; set when the widget needs re-render.
33    pub dirty: SecondaryMap<WidgetId, bool>,
34    /// CSS pseudo-class state (hover, focus, etc.) for each widget.
35    pub pseudo_classes: SecondaryMap<WidgetId, PseudoClassSet>,
36    /// Currently focused widget, or `None` if nothing has focus.
37    pub focused_widget: Option<WidgetId>,
38    /// Currently hovered widget (under mouse cursor). Updated by MouseMove events.
39    pub hovered_widget: Option<WidgetId>,
40    /// Stack of active screen widget IDs. Top of stack is the active screen.
41    pub screen_stack: Vec<WidgetId>,
42    /// Saved focus state for each screen push. Parallel to screen_stack.
43    /// `push_screen` saves `focused_widget` here; `pop_screen` restores it.
44    pub focus_history: Vec<Option<WidgetId>>,
45    /// Widgets scheduled to receive `on_mount` on the next event loop tick.
46    pub pending_mounts: Vec<WidgetId>,
47    /// Temporary input buffer for demo purposes (Phase 3 replaces with proper reactive state).
48    pub input_buffer: String,
49    /// Event bus sender — widgets and reactive effects post events here.
50    pub event_tx: Option<flume::Sender<AppEvent>>,
51    /// Message queue for widget-to-widget communication.
52    /// Uses RefCell so widgets can post messages from &self (on_event/on_action) without &mut.
53    /// Drained by the event loop after each event is processed.
54    pub message_queue: RefCell<Vec<(WidgetId, Box<dyn Any>)>>,
55    /// Deferred screen pushes from widgets.
56    /// Widgets in on_action(&self) can use push_screen_deferred() to schedule a new screen push
57    /// without needing &mut AppContext. The event loop drains this after each action.
58    pub pending_screen_pushes: RefCell<Vec<Box<dyn Widget>>>,
59    /// Number of screens to pop, deferred from widgets.
60    /// Widgets in on_action(&self) use pop_screen_deferred() to schedule a screen pop.
61    /// The event loop drains this counter after each action cycle.
62    pub pending_screen_pops: Cell<usize>,
63    /// Active theme for CSS variable resolution (e.g., `$primary`, `$accent-lighten-2`).
64    /// Defaults to `default_dark_theme()`. Set a custom theme to change all variable colors.
65    pub theme: Theme,
66    /// User stylesheets — stored here so ad-hoc pane rendering can resolve styles.
67    pub stylesheets: Vec<Stylesheet>,
68    /// Dedicated channel for worker results. Set by App::run_async before the event loop starts.
69    /// Workers send (WidgetId, Box<dyn Any + Send>) through this channel to the event loop.
70    pub worker_tx: Option<flume::Sender<(WidgetId, Box<dyn Any + Send>)>>,
71    /// Per-widget abort handles for active workers. Used for auto-cancellation on unmount.
72    pub worker_handles: RefCell<SecondaryMap<WidgetId, Vec<tokio::task::AbortHandle>>>,
73    /// Widgets that need recomposition (e.g. TabbedContent after tab switch).
74    /// Drained by the event loop after each event cycle.
75    pub pending_recompose: RefCell<Vec<WidgetId>>,
76    /// Active floating overlay (context menu, etc.). Rendered last, on top of everything.
77    /// Not part of the widget tree — painted directly to the frame buffer at absolute coords.
78    pub active_overlay: RefCell<Option<Box<dyn Widget>>>,
79    /// Deferred overlay dismissal flag. Set by dismiss_overlay(), drained after event handling.
80    pub pending_overlay_dismiss: Cell<bool>,
81    /// Detected terminal capabilities (color depth, unicode, mouse, title).
82    /// Widgets can inspect this to degrade gracefully on limited terminals.
83    pub terminal_caps: TerminalCaps,
84    /// When true, animations snap to their target value instead of interpolating.
85    /// Set by TestApp to ensure deterministic rendering in tests.
86    pub skip_animations: bool,
87    /// Stack-based mouse capture state. Screens/widgets push/pop to temporarily
88    /// enable or disable mouse capture without competing callers clobbering each other.
89    pub mouse_capture_stack: MouseCaptureStack,
90    /// Deferred mouse capture pushes from widgets (drained by event loop).
91    pub pending_mouse_push: RefCell<Vec<bool>>,
92    /// Deferred mouse capture pop count from widgets (drained by event loop).
93    pub pending_mouse_pops: Cell<usize>,
94    /// Per-widget loading state. When a widget's ID is present and true,
95    /// render_widget_tree draws a spinner overlay on top of that widget.
96    /// Manipulated via set_loading(). Uses SecondaryMap (same as computed_styles, dirty, etc.).
97    pub loading_widgets: RefCell<SecondaryMap<WidgetId, bool>>,
98    /// Global spinner tick counter. Incremented once per full_render_pass.
99    /// All loading overlays and LoadingIndicator widgets use this for synchronized animation.
100    pub spinner_tick: Cell<u8>,
101    /// Stacked toast notifications, rendered bottom-right. Max 5 visible.
102    pub toast_entries: RefCell<Vec<ToastEntry>>,
103    /// Deferred push_screen_wait requests: each entry is `(screen_box, oneshot_sender)`.
104    /// Drained by `process_deferred_screens`; the sender is stored keyed by the new screen's WidgetId.
105    #[allow(clippy::type_complexity)]
106    pub pending_screen_wait_pushes: RefCell<
107        Vec<(
108            Box<dyn Widget>,
109            tokio::sync::oneshot::Sender<Box<dyn Any + Send>>,
110        )>,
111    >,
112    /// Maps screen WidgetId -> oneshot sender for typed result delivery.
113    /// Populated when `push_screen_wait` processes a deferred push; consumed when `pop_screen_with` fires.
114    pub screen_result_senders:
115        RefCell<HashMap<WidgetId, tokio::sync::oneshot::Sender<Box<dyn Any + Send>>>>,
116    /// Single-slot typed result for the next `pop_screen_with` call.
117    /// Set by `pop_screen_with`, consumed by `process_deferred_screens` when the pop fires.
118    pub pending_pop_result: RefCell<Option<Box<dyn Any + Send>>>,
119}
120
121impl Default for AppContext {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl AppContext {
128    /// Create a new empty `AppContext` with default state and the dark theme.
129    pub fn new() -> Self {
130        Self {
131            arena: DenseSlotMap::with_key(),
132            children: SecondaryMap::new(),
133            parent: SecondaryMap::new(),
134            computed_styles: SecondaryMap::new(),
135            inline_styles: SecondaryMap::new(),
136            dirty: SecondaryMap::new(),
137            pseudo_classes: SecondaryMap::new(),
138            focused_widget: None,
139            hovered_widget: None,
140            screen_stack: Vec::new(),
141            focus_history: Vec::new(),
142            pending_mounts: Vec::new(),
143            input_buffer: String::new(),
144            event_tx: None,
145            message_queue: RefCell::new(Vec::new()),
146            pending_screen_pushes: RefCell::new(Vec::new()),
147            pending_screen_pops: Cell::new(0),
148            theme: theme::default_dark_theme(),
149            stylesheets: Vec::new(),
150            worker_tx: None,
151            worker_handles: RefCell::new(SecondaryMap::new()),
152            pending_recompose: RefCell::new(Vec::new()),
153            active_overlay: RefCell::new(None),
154            pending_overlay_dismiss: Cell::new(false),
155            terminal_caps: crate::terminal::detect_capabilities(),
156            skip_animations: false,
157            mouse_capture_stack: MouseCaptureStack::new(),
158            pending_mouse_push: RefCell::new(Vec::new()),
159            pending_mouse_pops: Cell::new(0),
160            loading_widgets: RefCell::new(SecondaryMap::new()),
161            spinner_tick: Cell::new(0),
162            toast_entries: RefCell::new(Vec::new()),
163            pending_screen_wait_pushes: RefCell::new(Vec::new()),
164            screen_result_senders: RefCell::new(HashMap::new()),
165            pending_pop_result: RefCell::new(None),
166        }
167    }
168
169    /// Set the active theme, replacing all CSS variable colors.
170    /// After calling this, a full re-cascade should be triggered to apply new theme colors.
171    pub fn set_theme(&mut self, theme: Theme) {
172        self.theme = theme;
173    }
174
175    /// Schedule a widget for recomposition on the next event loop tick.
176    /// Used by widgets like TabbedContent when their compose() output changes.
177    pub fn request_recompose(&self, id: WidgetId) {
178        self.pending_recompose.borrow_mut().push(id);
179    }
180
181    /// Schedule the active overlay for dismissal. Actual removal happens after the
182    /// current event handler returns (avoids RefCell borrow conflict).
183    pub fn dismiss_overlay(&self) {
184        self.pending_overlay_dismiss.set(true);
185    }
186
187    /// Push a new screen onto the screen stack.
188    ///
189    /// The current screen is kept in memory; the new screen receives keyboard
190    /// focus immediately. When the new screen is later popped, focus returns
191    /// to the widget that was focused before the push.
192    ///
193    /// Call this from `on_action` (where only `&self` is available). The
194    /// push is applied at the end of the current event cycle.
195    ///
196    /// To present a modal dialog that blocks input to all screens beneath it,
197    /// wrap your widget in [`crate::widget::screen::ModalScreen`]:
198    ///
199    /// ```no_run
200    /// # use textual_rs::widget::context::AppContext;
201    /// # use textual_rs::widget::screen::ModalScreen;
202    /// # use textual_rs::{Widget, WidgetId};
203    /// # use ratatui::{buffer::Buffer, layout::Rect};
204    /// struct ConfirmDialog;
205    /// impl Widget for ConfirmDialog {
206    ///     fn widget_type_name(&self) -> &'static str { "ConfirmDialog" }
207    ///     fn render(&self, _ctx: &AppContext, _area: Rect, _buf: &mut Buffer) {}
208    ///     fn on_action(&self, action: &str, ctx: &AppContext) {
209    ///         if action == "confirm" || action == "cancel" {
210    ///             ctx.pop_screen_deferred();
211    ///         }
212    ///     }
213    /// }
214    ///
215    /// struct MyScreen;
216    /// impl Widget for MyScreen {
217    ///     fn widget_type_name(&self) -> &'static str { "MyScreen" }
218    ///     fn render(&self, _ctx: &AppContext, _area: Rect, _buf: &mut Buffer) {}
219    ///     fn on_action(&self, action: &str, ctx: &AppContext) {
220    ///         if action == "open_dialog" {
221    ///             ctx.push_screen_deferred(Box::new(ModalScreen::new(Box::new(ConfirmDialog))));
222    ///         }
223    ///     }
224    /// }
225    /// ```
226    pub fn push_screen_deferred(&self, screen: Box<dyn Widget>) {
227        self.pending_screen_pushes.borrow_mut().push(screen);
228    }
229
230    /// Pop the top screen from the stack and restore focus to the previous screen.
231    ///
232    /// The popped screen and its entire widget subtree are unmounted. Focus
233    /// returns to whichever widget was focused when the screen was pushed — or
234    /// advances to the next focusable widget if that widget no longer exists.
235    ///
236    /// Call this from `on_action` (where only `&self` is available). The pop
237    /// is applied at the end of the current event cycle.
238    ///
239    /// Calling `pop_screen_deferred` on the last remaining screen is a no-op.
240    ///
241    /// # Example
242    ///
243    /// ```no_run
244    /// # use textual_rs::widget::context::AppContext;
245    /// # use textual_rs::{Widget, WidgetId};
246    /// # use ratatui::{buffer::Buffer, layout::Rect};
247    /// struct Dialog;
248    /// impl Widget for Dialog {
249    ///     fn widget_type_name(&self) -> &'static str { "Dialog" }
250    ///     fn render(&self, _ctx: &AppContext, _area: Rect, _buf: &mut Buffer) {}
251    ///     fn on_action(&self, action: &str, ctx: &AppContext) {
252    ///         match action {
253    ///             "ok" | "cancel" | "close" => ctx.pop_screen_deferred(),
254    ///             _ => {}
255    ///         }
256    ///     }
257    /// }
258    /// ```
259    pub fn pop_screen_deferred(&self) {
260        self.pending_screen_pops
261            .set(self.pending_screen_pops.get() + 1);
262    }
263
264    /// Push a modal screen and asynchronously await a typed result.
265    ///
266    /// Returns a [`tokio::sync::oneshot::Receiver`] that resolves when the modal screen
267    /// calls [`pop_screen_with`](AppContext::pop_screen_with). The caller downcasts the
268    /// `Box<dyn Any>` to the expected type.
269    ///
270    /// Because `on_action` is synchronous, the typical usage pattern is to capture the
271    /// receiver in a worker:
272    ///
273    /// ```ignore
274    /// let rx = ctx.push_screen_wait(Box::new(ModalScreen::new(Box::new(dialog))));
275    /// ctx.run_worker(self_id, async move {
276    ///     if let Ok(boxed) = rx.await {
277    ///         let confirmed: bool = *boxed.downcast::<bool>().unwrap();
278    ///         confirmed
279    ///     } else {
280    ///         false
281    ///     }
282    /// });
283    /// ```
284    pub fn push_screen_wait(
285        &self,
286        screen: Box<dyn Widget>,
287    ) -> tokio::sync::oneshot::Receiver<Box<dyn Any + Send>> {
288        let (tx, rx) = tokio::sync::oneshot::channel();
289        self.pending_screen_wait_pushes
290            .borrow_mut()
291            .push((screen, tx));
292        rx
293    }
294
295    /// Pop the top screen and deliver a typed result to the awaiting `push_screen_wait` caller.
296    ///
297    /// The value is boxed and stored; `process_deferred_screens` delivers it through the
298    /// oneshot channel when the pop is processed. If the top screen was not pushed via
299    /// `push_screen_wait`, the result is silently discarded and the pop still occurs normally.
300    ///
301    /// Call this from `on_action` in a modal's inner widget to dismiss and return a value.
302    ///
303    /// ```ignore
304    /// // Inside a dialog widget's on_action:
305    /// fn on_action(&self, action: &str, ctx: &AppContext) {
306    ///     match action {
307    ///         "ok"     => ctx.pop_screen_with(true),
308    ///         "cancel" => ctx.pop_screen_with(false),
309    ///         _ => {}
310    ///     }
311    /// }
312    /// ```
313    pub fn pop_screen_with<T: Any + Send + 'static>(&self, value: T) {
314        *self.pending_pop_result.borrow_mut() = Some(Box::new(value));
315        self.pop_screen_deferred();
316    }
317
318    /// Post a typed message from a widget.
319    /// It will be dispatched via bubbling in the next event loop iteration.
320    /// Takes &self so this can be called from on_event or on_action without borrow conflict.
321    pub fn post_message(&self, source: WidgetId, message: impl Any + 'static) {
322        self.message_queue
323            .borrow_mut()
324            .push((source, Box::new(message)));
325    }
326
327    /// Convenience alias: post a message that bubbles up from the source widget.
328    /// Equivalent to post_message — provided for API symmetry with Python Textual's notify().
329    pub fn notify(&self, source: WidgetId, message: impl Any + 'static) {
330        self.post_message(source, message);
331    }
332
333    /// Spawn an async worker tied to a widget. The worker runs on the Tokio LocalSet.
334    /// On completion, the result is delivered as a `WorkerResult<T>` message to the
335    /// source widget via the message queue. T must be Send + 'static.
336    ///
337    /// Returns an AbortHandle for manual cancellation. Workers are also automatically
338    /// cancelled when the owning widget is unmounted.
339    ///
340    /// # Panics
341    /// Panics if called outside of App::run() (worker_tx not initialized).
342    pub fn run_worker<T: Send + 'static>(
343        &self,
344        source_id: WidgetId,
345        fut: impl std::future::Future<Output = T> + 'static,
346    ) -> tokio::task::AbortHandle {
347        let tx = self
348            .worker_tx
349            .clone()
350            .expect("worker_tx not initialized — run_worker called outside App::run()");
351        let handle = tokio::task::spawn_local(async move {
352            let result = fut.await;
353            let _ = tx.send((
354                source_id,
355                Box::new(crate::worker::WorkerResult {
356                    source_id,
357                    value: result,
358                }),
359            ));
360        });
361        let abort = handle.abort_handle();
362        // Track handle for auto-cancel on unmount
363        self.worker_handles
364            .borrow_mut()
365            .entry(source_id)
366            .unwrap()
367            .or_default()
368            .push(abort.clone());
369        abort
370    }
371
372    /// Spawn an async worker with a progress channel. The worker receives a
373    /// `flume::Sender<P>` for sending progress updates, and its final result is
374    /// delivered as a `WorkerResult<T>` message. Progress updates are delivered
375    /// as `WorkerProgress<P>` messages to the source widget.
376    ///
377    /// # Example
378    /// ```ignore
379    /// ctx.run_worker_with_progress(my_id, |progress_tx| {
380    ///     Box::pin(async move {
381    ///         for i in 0..100 {
382    ///             let _ = progress_tx.send(i as f32 / 100.0);
383    ///             tokio::time::sleep(Duration::from_millis(50)).await;
384    ///         }
385    ///         "done"
386    ///     })
387    /// });
388    /// ```
389    pub fn run_worker_with_progress<T, P>(
390        &self,
391        source_id: WidgetId,
392        progress_fn: impl FnOnce(flume::Sender<P>) -> std::pin::Pin<Box<dyn std::future::Future<Output = T>>>
393            + 'static,
394    ) -> tokio::task::AbortHandle
395    where
396        T: Send + 'static,
397        P: Send + 'static,
398    {
399        let worker_tx = self.worker_tx.clone().expect(
400            "worker_tx not initialized — run_worker_with_progress called outside App::run()",
401        );
402
403        let (progress_sender, progress_receiver) = flume::unbounded::<P>();
404
405        // Spawn progress forwarding task — receives P from the worker and wraps
406        // it as a WorkerProgress<P> message to the owning widget.
407        let ptx = worker_tx.clone();
408        let sid = source_id;
409        tokio::task::spawn_local(async move {
410            while let Ok(p) = progress_receiver.recv_async().await {
411                let msg = crate::worker::WorkerProgress {
412                    source_id: sid,
413                    progress: p,
414                };
415                let _ = ptx.send((sid, Box::new(msg)));
416            }
417        });
418
419        // Create the main future using the progress sender
420        let fut = progress_fn(progress_sender);
421        self.run_worker(source_id, fut)
422    }
423
424    /// Schedule a mouse capture push deferred to the next event loop tick.
425    /// Use from `on_action(&self, ...)` or `on_event(&self, ...)` where only &self is available.
426    pub fn push_mouse_capture(&self, enabled: bool) {
427        self.pending_mouse_push.borrow_mut().push(enabled);
428    }
429
430    /// Schedule a mouse capture pop deferred to the next event loop tick.
431    /// Use from `on_action(&self, ...)` or `on_event(&self, ...)` where only &self is available.
432    pub fn pop_mouse_capture(&self) {
433        self.pending_mouse_pops
434            .set(self.pending_mouse_pops.get() + 1);
435    }
436
437    /// Set or clear the loading overlay for a widget.
438    ///
439    /// When loading is true, `render_widget_tree` will draw a spinner overlay
440    /// on top of the widget's area after calling its `render()` method.
441    /// When loading is false, the overlay is removed.
442    ///
443    /// This is the textual-rs equivalent of Python Textual's `widget.loading = True`.
444    ///
445    /// # Example
446    /// ```ignore
447    /// // In on_action or on_message:
448    /// ctx.set_loading(self.own_id.get().unwrap(), true);
449    /// // Start async work...
450    /// // In worker result handler:
451    /// ctx.set_loading(self.own_id.get().unwrap(), false);
452    /// ```
453    pub fn set_loading(&self, id: WidgetId, loading: bool) {
454        let mut map = self.loading_widgets.borrow_mut();
455        if loading {
456            map.insert(id, true);
457        } else {
458            map.remove(id);
459        }
460    }
461
462    /// Display a toast notification in the bottom-right corner.
463    ///
464    /// `severity` controls the border color: Info=$primary, Warning=$warning, Error=$error.
465    /// `timeout_ms` controls auto-dismiss: 0 = persistent (never dismissed automatically).
466    ///
467    /// Maximum 5 toasts are shown simultaneously; adding a 6th drops the oldest.
468    pub fn toast(&self, message: impl Into<String>, severity: ToastSeverity, timeout_ms: u64) {
469        let mut toasts = self.toast_entries.borrow_mut();
470        push_toast(&mut toasts, message.into(), severity, timeout_ms);
471    }
472
473    /// Display an Info toast with default 3000ms timeout.
474    pub fn toast_info(&self, message: impl Into<String>) {
475        self.toast(message, ToastSeverity::Info, 3000);
476    }
477
478    /// Request a clean application exit. The event loop will break after the current frame.
479    pub fn quit(&self) {
480        if let Some(tx) = &self.event_tx {
481            let _ = tx.send(AppEvent::Quit);
482        }
483    }
484
485    /// Cancel all workers associated with a widget. Called automatically during unmount.
486    pub fn cancel_workers(&self, widget_id: WidgetId) {
487        if let Some(handles) = self.worker_handles.borrow_mut().remove(widget_id) {
488            for handle in handles {
489                handle.abort();
490            }
491        }
492    }
493
494    /// Get the ratatui text style (fg + bg) for a widget from its computed CSS.
495    /// Returns Style::default() if the widget has no computed style.
496    pub fn text_style(&self, id: WidgetId) -> Style {
497        self.computed_styles
498            .get(id)
499            .map(render_style::text_style)
500            .unwrap_or_default()
501    }
502}