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}