Skip to main content

textual_rs/widget/
context.rs

1//! Application context passed to every widget for state and service access.
2use super::Widget;
3use super::WidgetId;
4use crate::css::cascade::Stylesheet;
5use crate::css::render_style;
6use crate::css::theme::{self, Theme};
7use crate::css::types::{ComputedStyle, Declaration, PseudoClassSet};
8use crate::event::AppEvent;
9use crate::terminal::{MouseCaptureStack, TerminalCaps};
10use ratatui::style::Style;
11use slotmap::{DenseSlotMap, SecondaryMap};
12use std::any::Any;
13use std::cell::{Cell, RefCell};
14use std::collections::HashMap;
15use super::toast::{ToastEntry, ToastSeverity, push_toast};
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    pub pending_screen_wait_pushes: RefCell<Vec<(Box<dyn Widget>, tokio::sync::oneshot::Sender<Box<dyn Any + Send>>)>>,
106    /// Maps screen WidgetId -> oneshot sender for typed result delivery.
107    /// Populated when `push_screen_wait` processes a deferred push; consumed when `pop_screen_with` fires.
108    pub screen_result_senders: RefCell<HashMap<WidgetId, tokio::sync::oneshot::Sender<Box<dyn Any + Send>>>>,
109    /// Single-slot typed result for the next `pop_screen_with` call.
110    /// Set by `pop_screen_with`, consumed by `process_deferred_screens` when the pop fires.
111    pub pending_pop_result: RefCell<Option<Box<dyn Any + Send>>>,
112}
113
114impl Default for AppContext {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl AppContext {
121    /// Create a new empty `AppContext` with default state and the dark theme.
122    pub fn new() -> Self {
123        Self {
124            arena: DenseSlotMap::with_key(),
125            children: SecondaryMap::new(),
126            parent: SecondaryMap::new(),
127            computed_styles: SecondaryMap::new(),
128            inline_styles: SecondaryMap::new(),
129            dirty: SecondaryMap::new(),
130            pseudo_classes: SecondaryMap::new(),
131            focused_widget: None,
132            hovered_widget: None,
133            screen_stack: Vec::new(),
134            focus_history: Vec::new(),
135            pending_mounts: Vec::new(),
136            input_buffer: String::new(),
137            event_tx: None,
138            message_queue: RefCell::new(Vec::new()),
139            pending_screen_pushes: RefCell::new(Vec::new()),
140            pending_screen_pops: Cell::new(0),
141            theme: theme::default_dark_theme(),
142            stylesheets: Vec::new(),
143            worker_tx: None,
144            worker_handles: RefCell::new(SecondaryMap::new()),
145            pending_recompose: RefCell::new(Vec::new()),
146            active_overlay: RefCell::new(None),
147            pending_overlay_dismiss: Cell::new(false),
148            terminal_caps: crate::terminal::detect_capabilities(),
149            skip_animations: false,
150            mouse_capture_stack: MouseCaptureStack::new(),
151            pending_mouse_push: RefCell::new(Vec::new()),
152            pending_mouse_pops: Cell::new(0),
153            loading_widgets: RefCell::new(SecondaryMap::new()),
154            spinner_tick: Cell::new(0),
155            toast_entries: RefCell::new(Vec::new()),
156            pending_screen_wait_pushes: RefCell::new(Vec::new()),
157            screen_result_senders: RefCell::new(HashMap::new()),
158            pending_pop_result: RefCell::new(None),
159        }
160    }
161
162    /// Set the active theme, replacing all CSS variable colors.
163    /// After calling this, a full re-cascade should be triggered to apply new theme colors.
164    pub fn set_theme(&mut self, theme: Theme) {
165        self.theme = theme;
166    }
167
168    /// Schedule a widget for recomposition on the next event loop tick.
169    /// Used by widgets like TabbedContent when their compose() output changes.
170    pub fn request_recompose(&self, id: WidgetId) {
171        self.pending_recompose.borrow_mut().push(id);
172    }
173
174    /// Schedule the active overlay for dismissal. Actual removal happens after the
175    /// current event handler returns (avoids RefCell borrow conflict).
176    pub fn dismiss_overlay(&self) {
177        self.pending_overlay_dismiss.set(true);
178    }
179
180    /// Push a new screen onto the screen stack.
181    ///
182    /// The current screen is kept in memory; the new screen receives keyboard
183    /// focus immediately. When the new screen is later popped, focus returns
184    /// to the widget that was focused before the push.
185    ///
186    /// Call this from `on_action` (where only `&self` is available). The
187    /// push is applied at the end of the current event cycle.
188    ///
189    /// To present a modal dialog that blocks input to all screens beneath it,
190    /// wrap your widget in [`crate::widget::screen::ModalScreen`]:
191    ///
192    /// ```no_run
193    /// # use textual_rs::widget::context::AppContext;
194    /// # use textual_rs::widget::screen::ModalScreen;
195    /// # use textual_rs::{Widget, WidgetId};
196    /// # use ratatui::{buffer::Buffer, layout::Rect};
197    /// struct ConfirmDialog;
198    /// impl Widget for ConfirmDialog {
199    ///     fn widget_type_name(&self) -> &'static str { "ConfirmDialog" }
200    ///     fn render(&self, _ctx: &AppContext, _area: Rect, _buf: &mut Buffer) {}
201    ///     fn on_action(&self, action: &str, ctx: &AppContext) {
202    ///         if action == "confirm" || action == "cancel" {
203    ///             ctx.pop_screen_deferred();
204    ///         }
205    ///     }
206    /// }
207    ///
208    /// struct MyScreen;
209    /// impl Widget for MyScreen {
210    ///     fn widget_type_name(&self) -> &'static str { "MyScreen" }
211    ///     fn render(&self, _ctx: &AppContext, _area: Rect, _buf: &mut Buffer) {}
212    ///     fn on_action(&self, action: &str, ctx: &AppContext) {
213    ///         if action == "open_dialog" {
214    ///             ctx.push_screen_deferred(Box::new(ModalScreen::new(Box::new(ConfirmDialog))));
215    ///         }
216    ///     }
217    /// }
218    /// ```
219    pub fn push_screen_deferred(&self, screen: Box<dyn Widget>) {
220        self.pending_screen_pushes.borrow_mut().push(screen);
221    }
222
223    /// Pop the top screen from the stack and restore focus to the previous screen.
224    ///
225    /// The popped screen and its entire widget subtree are unmounted. Focus
226    /// returns to whichever widget was focused when the screen was pushed — or
227    /// advances to the next focusable widget if that widget no longer exists.
228    ///
229    /// Call this from `on_action` (where only `&self` is available). The pop
230    /// is applied at the end of the current event cycle.
231    ///
232    /// Calling `pop_screen_deferred` on the last remaining screen is a no-op.
233    ///
234    /// # Example
235    ///
236    /// ```no_run
237    /// # use textual_rs::widget::context::AppContext;
238    /// # use textual_rs::{Widget, WidgetId};
239    /// # use ratatui::{buffer::Buffer, layout::Rect};
240    /// struct Dialog;
241    /// impl Widget for Dialog {
242    ///     fn widget_type_name(&self) -> &'static str { "Dialog" }
243    ///     fn render(&self, _ctx: &AppContext, _area: Rect, _buf: &mut Buffer) {}
244    ///     fn on_action(&self, action: &str, ctx: &AppContext) {
245    ///         match action {
246    ///             "ok" | "cancel" | "close" => ctx.pop_screen_deferred(),
247    ///             _ => {}
248    ///         }
249    ///     }
250    /// }
251    /// ```
252    pub fn pop_screen_deferred(&self) {
253        self.pending_screen_pops
254            .set(self.pending_screen_pops.get() + 1);
255    }
256
257    /// Push a modal screen and asynchronously await a typed result.
258    ///
259    /// Returns a [`tokio::sync::oneshot::Receiver`] that resolves when the modal screen
260    /// calls [`pop_screen_with`](AppContext::pop_screen_with). The caller downcasts the
261    /// `Box<dyn Any>` to the expected type.
262    ///
263    /// Because `on_action` is synchronous, the typical usage pattern is to capture the
264    /// receiver in a worker:
265    ///
266    /// ```ignore
267    /// let rx = ctx.push_screen_wait(Box::new(ModalScreen::new(Box::new(dialog))));
268    /// ctx.run_worker(self_id, async move {
269    ///     if let Ok(boxed) = rx.await {
270    ///         let confirmed: bool = *boxed.downcast::<bool>().unwrap();
271    ///         confirmed
272    ///     } else {
273    ///         false
274    ///     }
275    /// });
276    /// ```
277    pub fn push_screen_wait(
278        &self,
279        screen: Box<dyn Widget>,
280    ) -> tokio::sync::oneshot::Receiver<Box<dyn Any + Send>> {
281        let (tx, rx) = tokio::sync::oneshot::channel();
282        self.pending_screen_wait_pushes.borrow_mut().push((screen, tx));
283        rx
284    }
285
286    /// Pop the top screen and deliver a typed result to the awaiting `push_screen_wait` caller.
287    ///
288    /// The value is boxed and stored; `process_deferred_screens` delivers it through the
289    /// oneshot channel when the pop is processed. If the top screen was not pushed via
290    /// `push_screen_wait`, the result is silently discarded and the pop still occurs normally.
291    ///
292    /// Call this from `on_action` in a modal's inner widget to dismiss and return a value.
293    ///
294    /// ```ignore
295    /// // Inside a dialog widget's on_action:
296    /// fn on_action(&self, action: &str, ctx: &AppContext) {
297    ///     match action {
298    ///         "ok"     => ctx.pop_screen_with(true),
299    ///         "cancel" => ctx.pop_screen_with(false),
300    ///         _ => {}
301    ///     }
302    /// }
303    /// ```
304    pub fn pop_screen_with<T: Any + Send + 'static>(&self, value: T) {
305        *self.pending_pop_result.borrow_mut() = Some(Box::new(value));
306        self.pop_screen_deferred();
307    }
308
309    /// Post a typed message from a widget.
310    /// It will be dispatched via bubbling in the next event loop iteration.
311    /// Takes &self so this can be called from on_event or on_action without borrow conflict.
312    pub fn post_message(&self, source: WidgetId, message: impl Any + 'static) {
313        self.message_queue
314            .borrow_mut()
315            .push((source, Box::new(message)));
316    }
317
318    /// Convenience alias: post a message that bubbles up from the source widget.
319    /// Equivalent to post_message — provided for API symmetry with Python Textual's notify().
320    pub fn notify(&self, source: WidgetId, message: impl Any + 'static) {
321        self.post_message(source, message);
322    }
323
324    /// Spawn an async worker tied to a widget. The worker runs on the Tokio LocalSet.
325    /// On completion, the result is delivered as a `WorkerResult<T>` message to the
326    /// source widget via the message queue. T must be Send + 'static.
327    ///
328    /// Returns an AbortHandle for manual cancellation. Workers are also automatically
329    /// cancelled when the owning widget is unmounted.
330    ///
331    /// # Panics
332    /// Panics if called outside of App::run() (worker_tx not initialized).
333    pub fn run_worker<T: Send + 'static>(
334        &self,
335        source_id: WidgetId,
336        fut: impl std::future::Future<Output = T> + 'static,
337    ) -> tokio::task::AbortHandle {
338        let tx = self
339            .worker_tx
340            .clone()
341            .expect("worker_tx not initialized — run_worker called outside App::run()");
342        let handle = tokio::task::spawn_local(async move {
343            let result = fut.await;
344            let _ = tx.send((
345                source_id,
346                Box::new(crate::worker::WorkerResult {
347                    source_id,
348                    value: result,
349                }),
350            ));
351        });
352        let abort = handle.abort_handle();
353        // Track handle for auto-cancel on unmount
354        self.worker_handles
355            .borrow_mut()
356            .entry(source_id)
357            .unwrap()
358            .or_default()
359            .push(abort.clone());
360        abort
361    }
362
363    /// Spawn an async worker with a progress channel. The worker receives a
364    /// `flume::Sender<P>` for sending progress updates, and its final result is
365    /// delivered as a `WorkerResult<T>` message. Progress updates are delivered
366    /// as `WorkerProgress<P>` messages to the source widget.
367    ///
368    /// # Example
369    /// ```ignore
370    /// ctx.run_worker_with_progress(my_id, |progress_tx| {
371    ///     Box::pin(async move {
372    ///         for i in 0..100 {
373    ///             let _ = progress_tx.send(i as f32 / 100.0);
374    ///             tokio::time::sleep(Duration::from_millis(50)).await;
375    ///         }
376    ///         "done"
377    ///     })
378    /// });
379    /// ```
380    pub fn run_worker_with_progress<T, P>(
381        &self,
382        source_id: WidgetId,
383        progress_fn: impl FnOnce(flume::Sender<P>) -> std::pin::Pin<Box<dyn std::future::Future<Output = T>>>
384            + 'static,
385    ) -> tokio::task::AbortHandle
386    where
387        T: Send + 'static,
388        P: Send + 'static,
389    {
390        let worker_tx = self.worker_tx.clone().expect(
391            "worker_tx not initialized — run_worker_with_progress called outside App::run()",
392        );
393
394        let (progress_sender, progress_receiver) = flume::unbounded::<P>();
395
396        // Spawn progress forwarding task — receives P from the worker and wraps
397        // it as a WorkerProgress<P> message to the owning widget.
398        let ptx = worker_tx.clone();
399        let sid = source_id;
400        tokio::task::spawn_local(async move {
401            while let Ok(p) = progress_receiver.recv_async().await {
402                let msg = crate::worker::WorkerProgress {
403                    source_id: sid,
404                    progress: p,
405                };
406                let _ = ptx.send((sid, Box::new(msg)));
407            }
408        });
409
410        // Create the main future using the progress sender
411        let fut = progress_fn(progress_sender);
412        self.run_worker(source_id, fut)
413    }
414
415    /// Schedule a mouse capture push deferred to the next event loop tick.
416    /// Use from `on_action(&self, ...)` or `on_event(&self, ...)` where only &self is available.
417    pub fn push_mouse_capture(&self, enabled: bool) {
418        self.pending_mouse_push.borrow_mut().push(enabled);
419    }
420
421    /// Schedule a mouse capture pop deferred to the next event loop tick.
422    /// Use from `on_action(&self, ...)` or `on_event(&self, ...)` where only &self is available.
423    pub fn pop_mouse_capture(&self) {
424        self.pending_mouse_pops
425            .set(self.pending_mouse_pops.get() + 1);
426    }
427
428    /// Set or clear the loading overlay for a widget.
429    ///
430    /// When loading is true, `render_widget_tree` will draw a spinner overlay
431    /// on top of the widget's area after calling its `render()` method.
432    /// When loading is false, the overlay is removed.
433    ///
434    /// This is the textual-rs equivalent of Python Textual's `widget.loading = True`.
435    ///
436    /// # Example
437    /// ```ignore
438    /// // In on_action or on_message:
439    /// ctx.set_loading(self.own_id.get().unwrap(), true);
440    /// // Start async work...
441    /// // In worker result handler:
442    /// ctx.set_loading(self.own_id.get().unwrap(), false);
443    /// ```
444    pub fn set_loading(&self, id: WidgetId, loading: bool) {
445        let mut map = self.loading_widgets.borrow_mut();
446        if loading {
447            map.insert(id, true);
448        } else {
449            map.remove(id);
450        }
451    }
452
453    /// Display a toast notification in the bottom-right corner.
454    ///
455    /// `severity` controls the border color: Info=$primary, Warning=$warning, Error=$error.
456    /// `timeout_ms` controls auto-dismiss: 0 = persistent (never dismissed automatically).
457    ///
458    /// Maximum 5 toasts are shown simultaneously; adding a 6th drops the oldest.
459    pub fn toast(&self, message: impl Into<String>, severity: ToastSeverity, timeout_ms: u64) {
460        let mut toasts = self.toast_entries.borrow_mut();
461        push_toast(&mut toasts, message.into(), severity, timeout_ms);
462    }
463
464    /// Display an Info toast with default 3000ms timeout.
465    pub fn toast_info(&self, message: impl Into<String>) {
466        self.toast(message, ToastSeverity::Info, 3000);
467    }
468
469    /// Request a clean application exit. The event loop will break after the current frame.
470    pub fn quit(&self) {
471        if let Some(tx) = &self.event_tx {
472            let _ = tx.send(AppEvent::Quit);
473        }
474    }
475
476    /// Cancel all workers associated with a widget. Called automatically during unmount.
477    pub fn cancel_workers(&self, widget_id: WidgetId) {
478        if let Some(handles) = self.worker_handles.borrow_mut().remove(widget_id) {
479            for handle in handles {
480                handle.abort();
481            }
482        }
483    }
484
485    /// Get the ratatui text style (fg + bg) for a widget from its computed CSS.
486    /// Returns Style::default() if the widget has no computed style.
487    pub fn text_style(&self, id: WidgetId) -> Style {
488        self.computed_styles
489            .get(id)
490            .map(render_style::text_style)
491            .unwrap_or_default()
492    }
493}