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}