Skip to main content

tui_lipan/
test_backend.rs

1use std::cell::Cell;
2use std::collections::HashMap;
3use std::rc::Rc;
4
5use crate::Result;
6use crate::app::context::{SurfaceMode, TextAreaNewlineBinding};
7use crate::app::copy_feedback::CopyFeedbackState;
8use crate::app::input::focus;
9use crate::app::input::handlers::KeyCtx;
10use crate::app::input::hex_history::HexHistory;
11use crate::app::input::keyboard;
12use crate::app::input::keymap::{Action, Keymap, KeymapConfig, KeymapRuntime, KeymapRuntimeResult};
13use crate::app::input::text_area_vim::TextAreaVimState;
14use crate::app::interaction_state::{DragState, HexPendingEdit, MouseTrackingState};
15use crate::callback::Link;
16use crate::capture::CapturedFrame;
17use crate::clipboard::ClipboardConfig;
18use crate::core::component::Component;
19use crate::core::element::{Element, Key};
20use crate::core::event::{KeyEvent, MouseEvent};
21use crate::core::node::{NodeId, OverlayRoot};
22use crate::core::runtime_env::TranscriptEntry;
23use crate::layout::tag::Tag;
24use crate::runtime::{BubbleKeyResult, RuntimeCore};
25use crate::style::{Rect, Theme};
26use crate::text::editor::TextEditor;
27use crate::text::input::TextInput;
28
29const DEFAULT_VIEWPORT: Rect = Rect {
30    x: 0,
31    y: 0,
32    w: 80,
33    h: 24,
34};
35
36/// Headless runtime for unit-testing components.
37///
38/// `TestBackend` runs the same reconciliation, nested-component expansion, and message processing as
39/// `AppRunner`, but without entering terminal raw mode or rendering via a backend.
40///
41/// This is intended for:
42/// - verifying state transitions in response to messages
43/// - validating `view()` output (as an `Element`)
44/// - exercising `Command` scheduling and message routing
45/// - testing keyboard-driven flows via [`send_key`](Self::send_key)
46pub struct TestBackend<C: Component> {
47    pub(crate) core: RuntimeCore<C>,
48    viewport: Rect,
49    pub(crate) focused: Option<NodeId>,
50    pub(crate) focused_key: Option<Key>,
51    pub(crate) focused_tag: Option<Tag>,
52    keymap: Keymap,
53    keymap_runtime: KeymapRuntime,
54    text_area_newline_binding: TextAreaNewlineBinding,
55    pub(crate) input_history: HashMap<NodeId, TextInput>,
56    pub(crate) textarea_history: HashMap<NodeId, TextEditor>,
57    pub(crate) text_area_vim_state: HashMap<NodeId, TextAreaVimState>,
58    pub(crate) hex_history: HashMap<NodeId, HexHistory>,
59    pub(crate) hex_pending_edit: HashMap<NodeId, HexPendingEdit>,
60    focus_stack: Vec<Option<Key>>,
61    pub(crate) mouse: MouseTrackingState,
62    pub(crate) drag: DragState,
63    pub(crate) read_only_selection: HashMap<NodeId, (usize, Option<usize>)>,
64    pub(crate) copy_feedback: CopyFeedbackState,
65    pub(crate) screen_background: Option<crate::style::Style>,
66}
67
68impl<C> TestBackend<C>
69where
70    C: Component,
71{
72    /// Mount a root component with default properties.
73    pub fn new(component: C) -> Self
74    where
75        C::Properties: Default,
76    {
77        Self::new_with_props_inner(component, C::Properties::default(), false)
78    }
79
80    #[allow(missing_docs)]
81    pub fn new_transcript(component: C) -> Self
82    where
83        C::Properties: Default,
84    {
85        Self::new_with_props_inner(component, C::Properties::default(), true)
86    }
87
88    /// Mount a root component with explicit properties.
89    pub fn new_with_props(component: C, props: C::Properties) -> Self {
90        Self::new_with_props_inner(component, props, false)
91    }
92
93    #[allow(missing_docs)]
94    pub fn new_transcript_with_props(component: C, props: C::Properties) -> Self {
95        Self::new_with_props_inner(component, props, true)
96    }
97
98    fn new_with_props_inner(
99        component: C,
100        props: C::Properties,
101        inline_transcript_mode: bool,
102    ) -> Self {
103        let viewport = DEFAULT_VIEWPORT;
104        let mouse_capture = Rc::new(Cell::new(false));
105        let clipboard_config = ClipboardConfig::default();
106        let keymap = Keymap::new(KeymapConfig::from_clipboard_config(&clipboard_config));
107        let keymap_runtime = KeymapRuntime::new(&keymap);
108        let mut backend = Self {
109            core: if inline_transcript_mode {
110                RuntimeCore::new_test_transcript(
111                    component,
112                    props,
113                    viewport,
114                    Theme::default(),
115                    mouse_capture,
116                )
117            } else {
118                RuntimeCore::new_test(
119                    component,
120                    props,
121                    viewport,
122                    Theme::default(),
123                    SurfaceMode::Fullscreen,
124                    mouse_capture,
125                )
126            },
127            viewport,
128            focused: None,
129            focused_key: None,
130            focused_tag: None,
131            keymap,
132            keymap_runtime,
133            text_area_newline_binding: TextAreaNewlineBinding::default(),
134            input_history: HashMap::new(),
135            textarea_history: HashMap::new(),
136            text_area_vim_state: HashMap::new(),
137            hex_history: HashMap::new(),
138            hex_pending_edit: HashMap::new(),
139            focus_stack: Vec::new(),
140            mouse: MouseTrackingState::default(),
141            drag: DragState::default(),
142            read_only_selection: HashMap::new(),
143            copy_feedback: CopyFeedbackState::default(),
144            screen_background: None,
145        };
146
147        backend.core.init();
148        backend.render();
149        backend
150    }
151
152    /// Returns the current viewport used for layout.
153    pub fn viewport(&self) -> Rect {
154        self.viewport
155    }
156
157    #[allow(missing_docs)]
158    pub fn transcript_history_len(&self) -> usize {
159        self.core.transcript_history_snapshot().len()
160    }
161
162    #[allow(missing_docs)]
163    pub fn transcript_replay_summary(&self, include_live_viewport: bool) -> Vec<String> {
164        self.core
165            .transcript_replay_document(include_live_viewport)
166            .iter()
167            .map(summarize_transcript_entry)
168            .collect()
169    }
170
171    /// Set the viewport used for layout.
172    pub fn set_viewport(&mut self, viewport: Rect) {
173        self.viewport = viewport;
174        self.core.ctx.set_viewport(viewport);
175    }
176
177    /// Returns a cloneable link to the root component.
178    pub fn link(&self) -> Link<C::Message> {
179        self.core.ctx.link().clone()
180    }
181
182    /// Borrow the mounted component instance.
183    pub fn component(&self) -> &C {
184        &self.core.component
185    }
186
187    /// Mutably borrow the mounted component instance.
188    pub fn component_mut(&mut self) -> &mut C {
189        &mut self.core.component
190    }
191
192    /// Borrow the component state.
193    pub fn state(&self) -> &C::State {
194        &self.core.ctx.state
195    }
196
197    /// Mutably borrow the component state.
198    pub fn state_mut(&mut self) -> &mut C::State {
199        &mut self.core.ctx.state
200    }
201
202    /// Returns the currently focused node, if any.
203    pub fn focused(&self) -> Option<NodeId> {
204        self.focused
205    }
206
207    /// Manually set which node has focus.
208    pub fn set_focused(&mut self, id: NodeId) {
209        self.focused = Some(id);
210        self.focused_key = self.core.tree.node(id).key.clone();
211        self.focused_tag = Some(crate::layout::tag::tag_of_node(self.core.tree.node(id)));
212    }
213
214    /// Borrow the last rendered `Element` tree.
215    pub fn element(&self) -> &Element {
216        let element = self
217            .core
218            .cached_expanded_element
219            .as_ref()
220            .expect("render() must be called before element()");
221        if let crate::core::element::ElementKind::ThemeProvider(provider) = &element.kind {
222            &provider.child
223        } else {
224            element
225        }
226    }
227
228    /// Returns the minimum content size of the last rendered `Element` tree.
229    ///
230    /// Call [`Self::render`] first so this measures the current element, just like
231    /// [`Self::element`]. Returned dimensions are clamped to at least `1` so they
232    /// are safe to use as capture viewport dimensions.
233    pub fn content_min_size(&self) -> (u16, u16) {
234        let (w, h) = crate::layout::measure::min_size(self.element());
235        (w.max(1), h.max(1))
236    }
237
238    /// Enqueue a message for the root component without processing it.
239    pub fn enqueue(&self, msg: C::Message) {
240        self.core.ctx.link().send(msg);
241    }
242
243    /// Enqueue a message and process the runtime until idle.
244    pub fn dispatch(&mut self, msg: C::Message) -> Result<bool> {
245        self.enqueue(msg);
246        self.pump()
247    }
248
249    /// Inject a key event through the same dispatch pipeline as the real runner.
250    ///
251    /// 1. Dispatches to the focused widget via `keyboard::dispatch_key`
252    /// 2. If unhandled, bubbles up through component scopes via `on_key`
253    /// 3. If still unhandled, PageUp/PageDown may target one ambient `ScrollView`
254    /// 4. Processes any queued messages and re-renders if dirty
255    ///
256    /// Returns `true` if the focused widget, a bubbling `on_key` scope, or an
257    /// ambient page-scroll target consumed the key.
258    ///
259    /// Draining the message queue in [`Self::pump`] can re-render without this key having been
260    /// handled; that work is not counted here so browser embeddings can use the return value for
261    /// `preventDefault` without stealing shortcuts when unrelated updates flush.
262    pub fn send_key(&mut self, key: KeyEvent) -> Result<bool> {
263        let runtime_match = match self.keymap_runtime.feed(key) {
264            KeymapRuntimeResult::Pending => return Ok(true),
265            KeymapRuntimeResult::Matched(binding) => Some(binding),
266            KeymapRuntimeResult::None => None,
267        };
268        let action_from_chord = runtime_match.is_some_and(|binding| binding.is_chord);
269        let matches = runtime_match
270            .map(|binding| vec![binding.binding_match()])
271            .unwrap_or_else(|| self.keymap.matches(key));
272        let dismiss_handled = matches
273            .iter()
274            .any(|binding| binding.action == Action::DismissOverlay)
275            && self.handle_overlay_escape();
276        let quit_requested = matches.iter().any(|binding| binding.action == Action::Quit);
277
278        if dismiss_handled {
279            let _ = self.pump()?;
280            self.render();
281            return Ok(true);
282        }
283
284        if self.top_capturing_overlay_is_empty() {
285            if quit_requested {
286                self.core.ctx.quit();
287                let _ = self.pump()?;
288            }
289            return Ok(true);
290        }
291
292        if action_from_chord && quit_requested {
293            self.core.ctx.quit();
294            let _ = self.pump()?;
295            self.render();
296            return Ok(true);
297        }
298
299        let clipboard = Rc::clone(&self.core.ctx.env().clipboard);
300        let clipboard_config = self.core.ctx.env().clipboard_config.clone();
301
302        let mut key_ctx = KeyCtx {
303            read_only_selection: None,
304            input_history: &mut self.input_history,
305            textarea_history: &mut self.textarea_history,
306            text_area_vim_state: &mut self.text_area_vim_state,
307            hex_history: &mut self.hex_history,
308            hex_pending_edit: &mut self.hex_pending_edit,
309            keymap: &self.keymap,
310            text_area_newline_binding: self.text_area_newline_binding,
311            clipboard: &clipboard,
312            clipboard_config: &clipboard_config,
313            copy_feedback: &mut self.copy_feedback,
314            dirty_override: None,
315        };
316
317        let widget_handled =
318            keyboard::dispatch_key(&mut self.core.tree, self.focused, key, &mut key_ctx);
319        let widget_dirty_override = key_ctx.dirty_override;
320
321        let bubble = if !widget_handled {
322            self.core
323                .bubble_key(self.focused, self.focused_key.as_ref(), key)
324        } else {
325            BubbleKeyResult::default()
326        };
327
328        let ambient_handled = if !widget_handled && !bubble.handled && !bubble.dirty {
329            keyboard::dispatch_ambient_page_scroll(&mut self.core.tree, key)
330        } else {
331            false
332        };
333
334        let quit_handled = quit_requested && !widget_handled && !bubble.handled;
335        if quit_handled {
336            self.core.ctx.quit();
337        }
338
339        let pump_dirty = self.pump()?;
340        let widget_dirty = matches!(
341            widget_dirty_override,
342            Some(crate::app::interaction_state::DirtyLevel::PaintOnly)
343                | Some(crate::app::interaction_state::DirtyLevel::LayoutOnly)
344                | Some(crate::app::interaction_state::DirtyLevel::Full)
345        );
346        if (widget_dirty || bubble.dirty || ambient_handled || quit_handled) && !pump_dirty {
347            self.render();
348        }
349
350        Ok(widget_handled || bubble.handled || ambient_handled || quit_handled)
351    }
352
353    /// Inject a text paste event through the same focused-widget pipeline as the real runner.
354    pub fn send_paste(&mut self, text: &str) -> Result<bool> {
355        if self.top_capturing_overlay_is_empty() {
356            return Ok(true);
357        }
358
359        let clipboard = Rc::clone(&self.core.ctx.env().clipboard);
360        let clipboard_config = self.core.ctx.env().clipboard_config.clone();
361
362        let mut key_ctx = KeyCtx {
363            read_only_selection: None,
364            input_history: &mut self.input_history,
365            textarea_history: &mut self.textarea_history,
366            text_area_vim_state: &mut self.text_area_vim_state,
367            hex_history: &mut self.hex_history,
368            hex_pending_edit: &mut self.hex_pending_edit,
369            keymap: &self.keymap,
370            text_area_newline_binding: self.text_area_newline_binding,
371            clipboard: &clipboard,
372            clipboard_config: &clipboard_config,
373            copy_feedback: &mut self.copy_feedback,
374            dirty_override: None,
375        };
376
377        let handled =
378            keyboard::dispatch_paste(&mut self.core.tree, self.focused, text, &mut key_ctx);
379
380        let pump_dirty = self.pump()?;
381        if handled && !pump_dirty {
382            self.render();
383        }
384
385        Ok(handled)
386    }
387
388    /// Dispatch a mouse event through the same pipeline as the real runner.
389    ///
390    /// Returns `true` if the event was handled by any widget or component.
391    pub fn send_mouse(&mut self, ev: MouseEvent) -> Result<bool> {
392        use crate::app::mouse_dispatch;
393        let bubble_dirty = mouse_dispatch::dispatch_mouse_test_backend(self, ev);
394        let pump_dirty = self.pump()?;
395        if bubble_dirty && !pump_dirty {
396            self.render();
397        }
398        Ok(bubble_dirty || pump_dirty)
399    }
400
401    /// Focus the node at `id` or the first focusable descendant beneath it.
402    ///
403    /// Returns `true` if the focused node changed.
404    pub(crate) fn focus_for_node(&mut self, id: NodeId) -> bool {
405        if !self.core.tree.is_valid(id) {
406            return false;
407        }
408        let focusable = self.core.tree.node(id).is_focusable();
409        if focusable {
410            let changed = self.focused != Some(id);
411            if changed {
412                self.set_focused(id);
413            }
414            return changed;
415        }
416        if let Some(desc) = focus::find_first_focusable_descendant(&self.core.tree, id) {
417            let changed = self.focused != Some(desc);
418            if changed {
419                self.set_focused(desc);
420                return true;
421            }
422        }
423        false
424    }
425
426    /// Move focus to the next focusable node (Tab behavior).
427    pub fn focus_next(&mut self) {
428        if self.focus_overlay_next() {
429            return;
430        }
431
432        focus::focus_next(
433            &self.core.tree,
434            &mut self.focused,
435            &mut self.focused_key,
436            &mut self.focused_tag,
437        );
438    }
439
440    /// Move focus to the previous focusable node (Shift+Tab behavior).
441    pub fn focus_prev(&mut self) {
442        if self.focus_overlay_prev() {
443            return;
444        }
445
446        focus::focus_prev(
447            &self.core.tree,
448            &mut self.focused,
449            &mut self.focused_key,
450            &mut self.focused_tag,
451        );
452    }
453
454    /// Process all queued messages and any messages produced by background commands.
455    ///
456    /// Returns `true` if any update requested a re-render.
457    pub fn pump(&mut self) -> Result<bool> {
458        let mut dirty = false;
459
460        loop {
461            self.core.drain_commands();
462
463            let next = { self.core.queue.borrow_mut().pop_front() };
464            let Some((scope, msg)) = next else {
465                break;
466            };
467
468            dirty |= !matches!(
469                self.core.update_from_boxed(scope, msg)?,
470                crate::core::component::UpdateLevel::None
471            );
472        }
473
474        if let Some(key) = self.core.ctx.take_focus_request() {
475            self.focused = None;
476            self.focused_key = Some(key);
477            self.focused_tag = None;
478            dirty = true;
479        }
480
481        if dirty {
482            self.render();
483        }
484
485        Ok(dirty)
486    }
487
488    /// Recompute the current `Element` tree and layout.
489    pub fn render(&mut self) {
490        let bounds = self.viewport;
491        self.core
492            .render_element(bounds, self.focused, self.focused_key.as_ref(), None);
493        focus::restore_focus(
494            &self.core.tree,
495            &mut self.focused,
496            &mut self.focused_key,
497            &mut self.focused_tag,
498        );
499        self.ensure_overlay_focus();
500        self.refresh_hover_from_last_mouse();
501    }
502
503    fn ensure_overlay_focus(&mut self) {
504        let Some(overlay_id) = self
505            .core
506            .tree
507            .top_capturing_overlay()
508            .map(|overlay| overlay.id)
509        else {
510            return;
511        };
512        let focused_in_overlay = self
513            .focused
514            .filter(|id| self.core.tree.is_descendant(overlay_id, *id))
515            .is_some();
516        if focused_in_overlay {
517            return;
518        }
519
520        let focusables = self.core.tree.focusables_in_subtree(overlay_id);
521        if focusables.is_empty() {
522            self.suspend_focus_for_empty_overlay();
523            return;
524        }
525
526        self.push_focus_stack_for_overlay();
527        let next = focusables[0];
528        self.focused = Some(next);
529        self.focused_key = self.core.tree.node(next).key.clone();
530        self.focused_tag = Some(crate::layout::tag::tag_of_node(self.core.tree.node(next)));
531    }
532
533    fn top_capturing_overlay_is_empty(&self) -> bool {
534        self.core
535            .tree
536            .top_capturing_overlay()
537            .is_some_and(|overlay| self.core.tree.focusables_in_subtree(overlay.id).is_empty())
538    }
539
540    fn push_focus_stack_for_overlay(&mut self) {
541        let should_push = self
542            .focus_stack
543            .last()
544            .is_none_or(|top| *top != self.focused_key);
545        if !should_push {
546            return;
547        }
548
549        const MAX_FOCUS_STACK_DEPTH: usize = 32;
550        if self.focus_stack.len() >= MAX_FOCUS_STACK_DEPTH {
551            self.focus_stack.remove(0);
552        }
553        self.focus_stack.push(self.focused_key.clone());
554    }
555
556    fn suspend_focus_for_empty_overlay(&mut self) {
557        if self.focused.is_none() && self.focused_key.is_none() && self.focused_tag.is_none() {
558            return;
559        }
560
561        self.push_focus_stack_for_overlay();
562        self.focused = None;
563        self.focused_tag = None;
564    }
565
566    fn focus_overlay_next(&mut self) -> bool {
567        let Some(overlay) = self.core.tree.top_capturing_overlay() else {
568            return false;
569        };
570        let mut focusables = self.core.tree.focusables_in_subtree(overlay.id);
571        if focusables.is_empty() {
572            return true;
573        }
574
575        focusables.sort_by_key(|id| id.index());
576        let next = if let Some(curr) = self.focused
577            && let Some(idx) = focusables.iter().position(|id| *id == curr)
578        {
579            focusables[(idx + 1) % focusables.len()]
580        } else {
581            focusables[0]
582        };
583        self.focused = Some(next);
584        self.focused_key = self.core.tree.node(next).key.clone();
585        self.focused_tag = Some(crate::layout::tag::tag_of_node(self.core.tree.node(next)));
586        true
587    }
588
589    fn focus_overlay_prev(&mut self) -> bool {
590        let Some(overlay) = self.core.tree.top_capturing_overlay() else {
591            return false;
592        };
593        let mut focusables = self.core.tree.focusables_in_subtree(overlay.id);
594        if focusables.is_empty() {
595            return true;
596        }
597
598        focusables.sort_by_key(|id| id.index());
599        let prev = if let Some(curr) = self.focused
600            && let Some(idx) = focusables.iter().position(|id| *id == curr)
601        {
602            focusables[(idx + focusables.len().saturating_sub(1)) % focusables.len()]
603        } else {
604            focusables[focusables.len().saturating_sub(1)]
605        };
606        self.focused = Some(prev);
607        self.focused_key = self.core.tree.node(prev).key.clone();
608        self.focused_tag = Some(crate::layout::tag::tag_of_node(self.core.tree.node(prev)));
609        true
610    }
611
612    fn refresh_hover_from_last_mouse(&mut self) {
613        let Some((x, y)) = self.mouse.last_mouse else {
614            return;
615        };
616
617        crate::app::mouse_dispatch::update_hover_test_backend(self, x, y, true);
618    }
619
620    fn handle_overlay_escape(&mut self) -> bool {
621        let overlays: Vec<_> = self.core.tree.overlay_roots().to_vec();
622        for overlay in overlays.iter().rev() {
623            if overlay.dismiss_policy.dismiss_on_escape() {
624                return self.dismiss_overlay(overlay);
625            }
626        }
627
628        overlays.iter().any(|overlay| overlay.captures_focus)
629    }
630
631    fn dismiss_overlay(&mut self, overlay: &OverlayRoot) -> bool {
632        let dismissed = if let Some(id) = overlay.overlay_id {
633            self.core.overlay_manager.borrow_mut().dismiss(id)
634        } else if let Some(cb) = &overlay.on_dismiss {
635            cb.emit(());
636            true
637        } else {
638            false
639        };
640
641        if dismissed
642            && overlay.captures_focus
643            && let Some(saved_key) = self.focus_stack.pop()
644        {
645            self.focused_key = saved_key;
646            self.focused = None;
647            focus::restore_focus(
648                &self.core.tree,
649                &mut self.focused,
650                &mut self.focused_key,
651                &mut self.focused_tag,
652            );
653        }
654        dismissed
655    }
656
657    /// Set an opt-in root viewport background fill for captures.
658    ///
659    /// Mirrors `App::screen_background` for headless rendering: pass a resolved
660    /// style (typically `Style::new().bg(color)`), or `None` to leave the
661    /// background transparent. Useful for previewing a theme's filled backdrop.
662    pub fn set_screen_background(&mut self, style: Option<crate::style::Style>) {
663        self.screen_background = style;
664    }
665
666    fn screen_background_ratatui(&self) -> Option<ratatui::style::Style> {
667        self.screen_background
668            .map(crate::backend::ratatui_backend::common::to_ratatui_style)
669    }
670
671    fn capture_interaction(
672        &self,
673    ) -> crate::backend::ratatui_backend::capture_render::CaptureInteraction {
674        crate::backend::ratatui_backend::capture_render::CaptureInteraction {
675            focused: self.focused,
676            hovered: self.mouse.hovered,
677            mouse_pos: self.mouse.last_mouse,
678        }
679    }
680
681    /// Render and capture the current frame output.
682    pub fn capture_frame(&self) -> CapturedFrame {
683        crate::backend::ratatui_backend::capture_render::render_to_captured_frame_with_interaction(
684            &self.core.tree,
685            self.viewport,
686            self.capture_interaction(),
687            0,
688            self.screen_background_ratatui(),
689        )
690    }
691
692    /// Capture a frame using a temporary fit-to-content viewport plus margins.
693    ///
694    /// `margin_w` and `margin_h` are columns and rows added to the measured content minimum size.
695    /// The backend temporarily lays out at `(content_min_size + margin)`, captures via
696    /// [`Self::capture_frame`], then restores the original viewport and layout before returning.
697    pub fn capture_frame_with_margin(&mut self, margin_w: u16, margin_h: u16) -> CapturedFrame {
698        self.capture_with_margin(margin_w, margin_h, Self::capture_frame)
699    }
700
701    /// Capture a combined visual + semantic UI snapshot.
702    ///
703    /// Call [`Self::render`] first so the node tree and layout reflect the latest state.
704    pub fn capture_ui_snapshot(&self) -> crate::ui_snapshot::UiSnapshot {
705        self.capture_ui_snapshot_with_options(&crate::ui_snapshot::UiSnapshotOptions::default())
706    }
707
708    /// Capture a UI snapshot with custom describe options.
709    pub fn capture_ui_snapshot_with_options(
710        &self,
711        options: &crate::ui_snapshot::UiSnapshotOptions,
712    ) -> crate::ui_snapshot::UiSnapshot {
713        crate::ui_snapshot::build_ui_snapshot(
714            &self.core.tree,
715            self.viewport,
716            self.capture_interaction(),
717            0,
718            self.screen_background_ratatui(),
719            options,
720        )
721    }
722
723    /// Capture a UI snapshot using a temporary fit-to-content viewport plus margins.
724    ///
725    /// `margin_w` and `margin_h` are columns and rows added to the measured content minimum size.
726    /// The backend temporarily lays out at `(content_min_size + margin)`, captures via
727    /// [`Self::capture_ui_snapshot_with_options`], then restores the original viewport and layout
728    /// before returning.
729    pub fn capture_ui_snapshot_with_margin(
730        &mut self,
731        margin_w: u16,
732        margin_h: u16,
733        options: &crate::ui_snapshot::UiSnapshotOptions,
734    ) -> crate::ui_snapshot::UiSnapshot {
735        self.capture_with_margin(margin_w, margin_h, |backend| {
736            backend.capture_ui_snapshot_with_options(options)
737        })
738    }
739
740    fn capture_with_margin<T>(
741        &mut self,
742        margin_w: u16,
743        margin_h: u16,
744        capture: impl FnOnce(&Self) -> T,
745    ) -> T {
746        let original_viewport = self.viewport;
747        let (min_w, min_h) = self.content_min_size();
748        let target_viewport = Rect {
749            x: 0,
750            y: 0,
751            w: min_w.saturating_add(margin_w).max(1),
752            h: min_h.saturating_add(margin_h).max(1),
753        };
754
755        self.set_viewport(target_viewport);
756        self.render();
757        let captured = capture(self);
758        self.set_viewport(original_viewport);
759        self.render();
760        captured
761    }
762
763    /// Returns the reconciliation key of the currently focused node, if any.
764    pub fn focused_key(&self) -> Option<&crate::core::element::Key> {
765        self.focused_key.as_ref()
766    }
767
768    /// Returns the node id currently under the mouse, if any.
769    pub fn hovered(&self) -> Option<NodeId> {
770        self.mouse.hovered
771    }
772
773    #[cfg(all(feature = "web", target_arch = "wasm32"))]
774    pub(crate) fn capture_frame_with_effect_phase(&self, effect_phase: u64) -> CapturedFrame {
775        crate::backend::ratatui_backend::capture_render::render_to_captured_frame_with_interaction(
776            &self.core.tree,
777            self.viewport,
778            self.capture_interaction(),
779            effect_phase,
780            self.screen_background_ratatui(),
781        )
782    }
783}
784
785fn summarize_transcript_entry(entry: &TranscriptEntry) -> String {
786    match entry {
787        TranscriptEntry::Lines(lines) => {
788            let payload = lines
789                .iter()
790                .map(|line| line.plain_content().into_owned())
791                .collect::<Vec<_>>()
792                .join("|");
793            format!("lines:{payload}")
794        }
795        TranscriptEntry::Element(element) => {
796            let payload = summarize_element_texts(element).join("|");
797            format!("element:{payload}")
798        }
799    }
800}
801
802fn summarize_element_texts(element: &Element) -> Vec<String> {
803    let mut out = Vec::new();
804    collect_element_texts(element, &mut out);
805    out
806}
807
808fn collect_element_texts(element: &Element, out: &mut Vec<String>) {
809    if let crate::core::element::ElementKind::Text(text) = &element.kind {
810        out.push(text.plain_content());
811    }
812    for child in element.kind.children() {
813        collect_element_texts(child, out);
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use std::cell::RefCell;
820    use std::rc::Rc;
821
822    use super::TestBackend;
823    use crate::Length;
824    use crate::app::ContrastPolicy;
825    use crate::app::input::keymap::{Action, BindingMode, binding_for_test, keymap_for_test};
826    use crate::callback::Callback;
827    use crate::core::component::{Component, Context, KeyUpdate, Update};
828    use crate::core::element::{Element, ElementKind, IntoElement, Key};
829    use crate::core::event::{KeyCode, KeyEvent, KeyMods, MouseButton, MouseEvent, MouseKind};
830    use crate::core::node::NodeKind;
831    use crate::style::resolve::{resolve_base_style, resolve_muted_style};
832    use crate::style::{
833        Color, DocumentViewPalette, InputPalette, Paint, Rect, Span, Style, TextAreaPalette, Theme,
834        ThemeRole, resolve_slot,
835    };
836    use crate::text::editor::TextEditor;
837    #[cfg(feature = "syntax-syntect")]
838    use crate::widgets::SyntectStrategy;
839    use crate::widgets::{
840        Animated, Button, DocumentView, EffectScope, Frame, HStack, Input, InputEvent, List,
841        ListItem, Modal, MouseRegion, Popover, SENTINEL_BASE, ScrollKeymap, ScrollView, SearchItem,
842        SearchPalette, Spinner, SpinnerStyle, StatusBar, Tab, Tabs, Text, TextArea, TextAreaEvent,
843        TextAreaLineNumberMode, TextAreaSentinel, TextAreaVimConfig,
844        TextAreaVimCurrentLineHighlight, TextAreaVimMode, TextAreaVirtualText, ThemeProvider,
845        VStack,
846    };
847    #[cfg(feature = "diff-view")]
848    use crate::widgets::{DiffView, DiffViewBackend, DiffViewMode};
849
850    fn unwrap_theme_provider(element: &Element) -> &Element {
851        if let ElementKind::ThemeProvider(provider) = &element.kind {
852            &provider.child
853        } else {
854            element
855        }
856    }
857
858    fn first_cell_with_symbol<'a>(
859        frame: &'a crate::capture::CapturedFrame,
860        symbol: &str,
861    ) -> &'a crate::capture::CapturedCell {
862        frame
863            .cells
864            .iter()
865            .find(|cell| cell.symbol == symbol)
866            .unwrap_or_else(|| panic!("expected symbol `{symbol}` in captured frame"))
867    }
868
869    fn configure_test_keymap<C: Component>(
870        backend: &mut TestBackend<C>,
871        bindings: Vec<crate::app::input::keymap::Binding>,
872    ) {
873        backend.keymap = keymap_for_test(bindings);
874        backend.keymap_runtime = crate::app::input::keymap::KeymapRuntime::new(&backend.keymap);
875    }
876
877    fn plain_key(c: char) -> KeyEvent {
878        KeyEvent {
879            code: KeyCode::Char(c),
880            mods: KeyMods::default(),
881        }
882    }
883
884    fn ctrl_key(c: char) -> KeyEvent {
885        KeyEvent {
886            code: KeyCode::Char(c),
887            mods: KeyMods {
888                ctrl: true,
889                ..KeyMods::default()
890            },
891        }
892    }
893
894    struct Counter;
895
896    #[derive(Clone, Copy, Debug)]
897    enum Msg {
898        Inc,
899    }
900
901    impl Component for Counter {
902        type Message = Msg;
903        type Properties = ();
904        type State = u32;
905
906        fn create_state(&self, _props: &Self::Properties) -> Self::State {
907            0
908        }
909
910        fn view(&self, ctx: &Context<Self>) -> Element {
911            Text::new(format!("{}", ctx.state)).into()
912        }
913
914        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
915            match msg {
916                Msg::Inc => {
917                    ctx.state += 1;
918                    Update::full()
919                }
920            }
921        }
922    }
923
924    #[test]
925    fn can_update_state_headlessly() {
926        let mut backend = TestBackend::new(Counter);
927        assert_eq!(*backend.state(), 0);
928
929        backend.dispatch(Msg::Inc).expect("dispatch should succeed");
930        assert_eq!(*backend.state(), 1);
931    }
932
933    struct ChordInputRoot;
934
935    #[derive(Clone, Debug)]
936    enum ChordInputMsg {
937        Changed(InputEvent),
938    }
939
940    impl Component for ChordInputRoot {
941        type Message = ChordInputMsg;
942        type Properties = ();
943        type State = String;
944
945        fn create_state(&self, _props: &Self::Properties) -> Self::State {
946            String::new()
947        }
948
949        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
950            match msg {
951                ChordInputMsg::Changed(event) => ctx.state = event.value.to_string(),
952            }
953            Update::full()
954        }
955
956        fn view(&self, ctx: &Context<Self>) -> Element {
957            Input::new(ctx.state.clone())
958                .on_change(ctx.link().callback(ChordInputMsg::Changed))
959                .into()
960        }
961    }
962
963    fn focused_chord_input_backend() -> TestBackend<ChordInputRoot> {
964        let mut backend = TestBackend::new(ChordInputRoot);
965        let input_id = backend
966            .core
967            .tree
968            .iter()
969            .find(|node| matches!(node.kind, NodeKind::Input(_)))
970            .map(|node| node.id)
971            .expect("input exists");
972        backend.set_focused(input_id);
973        backend
974    }
975
976    #[test]
977    fn send_key_consumes_pending_chord_prefix_before_focused_widget() {
978        let mut backend = focused_chord_input_backend();
979        configure_test_keymap(
980            &mut backend,
981            vec![binding_for_test(
982                "ctrl-x q",
983                Action::Quit,
984                BindingMode::Always,
985            )],
986        );
987
988        assert!(backend.send_key(ctrl_key('x')).expect("prefix succeeds"));
989        assert_eq!(backend.state(), "");
990    }
991
992    #[test]
993    fn send_key_chord_match_triggers_action_without_widget_insert() {
994        let mut backend = focused_chord_input_backend();
995        configure_test_keymap(
996            &mut backend,
997            vec![binding_for_test(
998                "ctrl-x q",
999                Action::Quit,
1000                BindingMode::Always,
1001            )],
1002        );
1003
1004        assert!(backend.send_key(ctrl_key('x')).expect("prefix succeeds"));
1005        assert!(!backend.core.ctx.should_quit());
1006        assert!(backend.send_key(plain_key('q')).expect("match succeeds"));
1007        assert!(backend.core.ctx.should_quit());
1008        assert_eq!(backend.state(), "");
1009    }
1010
1011    #[test]
1012    fn send_key_pending_chord_mismatch_resets_and_dispatches_unmatched_key() {
1013        let mut backend = focused_chord_input_backend();
1014        configure_test_keymap(
1015            &mut backend,
1016            vec![binding_for_test(
1017                "ctrl-x q",
1018                Action::Quit,
1019                BindingMode::Always,
1020            )],
1021        );
1022
1023        assert!(backend.send_key(ctrl_key('x')).expect("prefix succeeds"));
1024        assert!(backend.send_key(plain_key('z')).expect("mismatch succeeds"));
1025        assert_eq!(backend.state(), "z");
1026        assert!(!backend.core.ctx.should_quit());
1027    }
1028
1029    struct ButtonActivationRoot {
1030        disabled: bool,
1031        on_key_returns: Option<bool>,
1032    }
1033
1034    #[derive(Default)]
1035    struct ButtonActivationState {
1036        clicks: Vec<MouseEvent>,
1037        keys: Vec<KeyEvent>,
1038    }
1039
1040    enum ButtonActivationMsg {
1041        Clicked(MouseEvent),
1042        Keyed(KeyEvent),
1043    }
1044
1045    impl ButtonActivationRoot {
1046        fn enabled() -> Self {
1047            Self {
1048                disabled: false,
1049                on_key_returns: None,
1050            }
1051        }
1052
1053        fn disabled() -> Self {
1054            Self {
1055                disabled: true,
1056                on_key_returns: None,
1057            }
1058        }
1059
1060        fn with_handling_key() -> Self {
1061            Self {
1062                disabled: false,
1063                on_key_returns: Some(true),
1064            }
1065        }
1066
1067        fn with_nonhandling_key() -> Self {
1068            Self {
1069                disabled: false,
1070                on_key_returns: Some(false),
1071            }
1072        }
1073    }
1074
1075    impl Component for ButtonActivationRoot {
1076        type Message = ButtonActivationMsg;
1077        type Properties = ();
1078        type State = ButtonActivationState;
1079
1080        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1081            ButtonActivationState::default()
1082        }
1083
1084        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1085            match msg {
1086                ButtonActivationMsg::Clicked(event) => ctx.state.clicks.push(event),
1087                ButtonActivationMsg::Keyed(key) => ctx.state.keys.push(key),
1088            }
1089            Update::full()
1090        }
1091
1092        fn view(&self, ctx: &Context<Self>) -> Element {
1093            let mut button = Button::new("Activate")
1094                .disabled(self.disabled)
1095                .on_click(ctx.link().callback(ButtonActivationMsg::Clicked));
1096
1097            if let Some(handled) = self.on_key_returns {
1098                button = button
1099                    .on_key(ctx.link().key_handler(move |key| {
1100                        handled.then_some(ButtonActivationMsg::Keyed(key))
1101                    }));
1102            }
1103
1104            button.key("activate-button")
1105        }
1106    }
1107
1108    fn focused_button_backend(root: ButtonActivationRoot) -> TestBackend<ButtonActivationRoot> {
1109        let mut backend = TestBackend::new(root);
1110        backend.focus_next();
1111        backend
1112    }
1113
1114    #[test]
1115    fn focused_button_plain_enter_and_space_invoke_on_click() {
1116        let mut backend = focused_button_backend(ButtonActivationRoot::enabled());
1117
1118        assert!(
1119            backend
1120                .send_key(KeyEvent {
1121                    code: KeyCode::Enter,
1122                    mods: KeyMods::NONE,
1123                })
1124                .expect("enter should dispatch")
1125        );
1126        assert!(
1127            backend
1128                .send_key(plain_key(' '))
1129                .expect("space should dispatch")
1130        );
1131
1132        let clicks = &backend.state().clicks;
1133        assert_eq!(clicks.len(), 2);
1134        assert!(clicks.iter().all(|event| {
1135            event.kind == MouseKind::Up(MouseButton::Left) && event.mods == KeyMods::NONE
1136        }));
1137    }
1138
1139    #[test]
1140    fn focused_button_on_key_true_suppresses_on_click_activation() {
1141        let mut backend = focused_button_backend(ButtonActivationRoot::with_handling_key());
1142
1143        let handled = backend
1144            .send_key(KeyEvent {
1145                code: KeyCode::Enter,
1146                mods: KeyMods::NONE,
1147            })
1148            .expect("enter should dispatch");
1149
1150        assert!(handled);
1151        assert!(backend.state().clicks.is_empty());
1152        assert_eq!(backend.state().keys.len(), 1);
1153    }
1154
1155    #[test]
1156    fn focused_button_on_key_false_allows_on_click_activation() {
1157        let mut backend = focused_button_backend(ButtonActivationRoot::with_nonhandling_key());
1158
1159        let handled = backend
1160            .send_key(KeyEvent {
1161                code: KeyCode::Enter,
1162                mods: KeyMods::NONE,
1163            })
1164            .expect("enter should dispatch");
1165
1166        assert!(handled);
1167        assert_eq!(backend.state().clicks.len(), 1);
1168        assert!(backend.state().keys.is_empty());
1169    }
1170
1171    #[test]
1172    fn disabled_focused_button_does_not_activate_from_keyboard() {
1173        let mut backend = focused_button_backend(ButtonActivationRoot::disabled());
1174
1175        let handled = backend
1176            .send_key(KeyEvent {
1177                code: KeyCode::Enter,
1178                mods: KeyMods::NONE,
1179            })
1180            .expect("enter should dispatch");
1181
1182        assert!(!handled);
1183        assert!(backend.state().clicks.is_empty());
1184    }
1185
1186    #[test]
1187    fn modified_enter_and_space_do_not_activate_focused_button() {
1188        let mut backend = focused_button_backend(ButtonActivationRoot::enabled());
1189
1190        assert!(
1191            !backend
1192                .send_key(KeyEvent {
1193                    code: KeyCode::Enter,
1194                    mods: KeyMods::CTRL,
1195                })
1196                .expect("modified enter should dispatch")
1197        );
1198        assert!(
1199            !backend
1200                .send_key(KeyEvent {
1201                    code: KeyCode::Char(' '),
1202                    mods: KeyMods::SHIFT,
1203                })
1204                .expect("modified space should dispatch")
1205        );
1206        assert!(backend.state().clicks.is_empty());
1207    }
1208
1209    #[test]
1210    fn focused_key_exposes_keyed_button_after_focus_traversal() {
1211        let mut backend = TestBackend::new(ButtonActivationRoot::enabled());
1212        let key = Key::from("activate-button");
1213
1214        backend.focused = None;
1215        backend.focused_key = None;
1216        backend.focused_tag = None;
1217
1218        backend.focus_next();
1219
1220        assert_eq!(backend.focused_key(), Some(&key));
1221    }
1222
1223    struct BubbleLeaf {
1224        calls: Rc<RefCell<Vec<&'static str>>>,
1225    }
1226
1227    impl Component for BubbleLeaf {
1228        type Message = ();
1229        type Properties = ();
1230        type State = ();
1231
1232        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
1233
1234        fn on_key(&mut self, _key: KeyEvent, _ctx: &mut Context<Self>) -> KeyUpdate {
1235            self.calls.borrow_mut().push("leaf");
1236            KeyUpdate::handled(Update::none())
1237        }
1238
1239        fn view(&self, _ctx: &Context<Self>) -> Element {
1240            Text::new("leaf").into()
1241        }
1242
1243        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
1244            Update::none()
1245        }
1246    }
1247
1248    struct BubbleRoot {
1249        calls: Rc<RefCell<Vec<&'static str>>>,
1250    }
1251
1252    impl Component for BubbleRoot {
1253        type Message = ();
1254        type Properties = ();
1255        type State = ();
1256
1257        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
1258
1259        fn on_key(&mut self, _key: KeyEvent, _ctx: &mut Context<Self>) -> KeyUpdate {
1260            self.calls.borrow_mut().push("root");
1261            KeyUpdate::unhandled(Update::none())
1262        }
1263
1264        fn view(&self, _ctx: &Context<Self>) -> Element {
1265            crate::child(
1266                {
1267                    let calls = Rc::clone(&self.calls);
1268                    move || BubbleLeaf {
1269                        calls: Rc::clone(&calls),
1270                    }
1271                },
1272                (),
1273            )
1274            .key("bubble-child")
1275        }
1276
1277        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
1278            Update::none()
1279        }
1280    }
1281
1282    #[test]
1283    fn send_key_with_no_focused_widget_uses_focused_key_bubble_scope() {
1284        let calls = Rc::new(RefCell::new(Vec::new()));
1285        let mut backend = TestBackend::new(BubbleRoot {
1286            calls: Rc::clone(&calls),
1287        });
1288        backend.focused = None;
1289        backend.focused_key = Some(Key::from("bubble-child"));
1290
1291        let handled = backend
1292            .send_key(KeyEvent {
1293                code: KeyCode::Char('x'),
1294                mods: Default::default(),
1295            })
1296            .expect("send_key should succeed");
1297
1298        assert!(handled);
1299        assert_eq!(calls.borrow().as_slice(), ["leaf"]);
1300    }
1301
1302    struct VimTextAreaRoot;
1303
1304    struct VimSearchRenderRoot;
1305
1306    struct VimAutoHeightSearchRenderRoot;
1307
1308    struct VimAutoHeightFrameSearchRenderRoot;
1309
1310    struct VimAutoHeightPopoverFrameSearchRenderRoot;
1311
1312    struct VimAutoHeightPopoverTriggerShortcutsRenderRoot;
1313
1314    struct VimAutoHeightAnimatedPromptShellRenderRoot;
1315
1316    struct VimAutoHeightEffectWrappedPromptShellRenderRoot;
1317
1318    struct VimStyledSearchBarRoot;
1319
1320    struct VimBackwardSearchRenderRoot;
1321
1322    struct VimWrappedSearchRenderRoot;
1323
1324    struct VimCollapsedContentSearchRoot;
1325
1326    struct VimCurrentLineRenderRoot;
1327
1328    struct TextAreaRelativeLineNumbersRoot;
1329
1330    struct TextAreaVirtualTextRenderRoot;
1331
1332    struct TextAreaVirtualTextCursorRoot;
1333
1334    struct TextAreaVirtualTextSentinelRoot;
1335
1336    #[derive(Clone, Debug)]
1337    enum VimTextAreaMsg {
1338        Changed(TextAreaEvent),
1339        Mode(TextAreaVimMode),
1340    }
1341
1342    #[derive(Debug)]
1343    struct VimTextAreaState {
1344        editor: TextEditor,
1345        modes: Vec<TextAreaVimMode>,
1346    }
1347
1348    impl Component for VimSearchRenderRoot {
1349        type Message = TextAreaEvent;
1350        type Properties = ();
1351        type State = TextEditor;
1352
1353        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1354            TextEditor::new("alpha beta alpha")
1355        }
1356
1357        fn view(&self, ctx: &Context<Self>) -> Element {
1358            TextArea::bound(&ctx.state)
1359                .border(false)
1360                .line_numbers(true)
1361                .height(Length::Px(4))
1362                .vim_motions(true)
1363                .vim_config(
1364                    TextAreaVimConfig::new()
1365                        .current_search_match_style(Style::new().bg(Color::Blue)),
1366                )
1367                .on_change(ctx.link().callback(|event| event))
1368                .into()
1369        }
1370
1371        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1372            msg.apply_to(&mut ctx.state);
1373            Update::full()
1374        }
1375    }
1376
1377    impl Component for VimAutoHeightSearchRenderRoot {
1378        type Message = TextAreaEvent;
1379        type Properties = ();
1380        type State = TextEditor;
1381
1382        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1383            TextEditor::new("one\ntwo")
1384        }
1385
1386        fn view(&self, ctx: &Context<Self>) -> Element {
1387            VStack::new()
1388                .width(Length::Px(24))
1389                .height(Length::Auto)
1390                .child(
1391                    TextArea::bound(&ctx.state)
1392                        .width(Length::Px(18))
1393                        .height(Length::Auto)
1394                        .border(false)
1395                        .scrollbar(false)
1396                        .line_numbers(true)
1397                        .vim_motions(true)
1398                        .on_change(ctx.link().callback(|event| event)),
1399                )
1400                .child(Text::new("below"))
1401                .into()
1402        }
1403
1404        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1405            msg.apply_to(&mut ctx.state);
1406            Update::full()
1407        }
1408    }
1409
1410    impl Component for VimAutoHeightFrameSearchRenderRoot {
1411        type Message = TextAreaEvent;
1412        type Properties = ();
1413        type State = TextEditor;
1414
1415        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1416            TextEditor::new("one\ntwo")
1417        }
1418
1419        fn view(&self, ctx: &Context<Self>) -> Element {
1420            VStack::new()
1421                .width(Length::Px(24))
1422                .height(Length::Auto)
1423                .child(
1424                    Frame::new()
1425                        .width(Length::Px(20))
1426                        .height(Length::Auto)
1427                        .child(
1428                            TextArea::bound(&ctx.state)
1429                                .height(Length::Auto)
1430                                .border(false)
1431                                .scrollbar(false)
1432                                .line_numbers(true)
1433                                .vim_motions(true)
1434                                .on_change(ctx.link().callback(|event| event)),
1435                        ),
1436                )
1437                .child(Text::new("below"))
1438                .into()
1439        }
1440
1441        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1442            msg.apply_to(&mut ctx.state);
1443            Update::full()
1444        }
1445    }
1446
1447    impl Component for VimAutoHeightPopoverFrameSearchRenderRoot {
1448        type Message = TextAreaEvent;
1449        type Properties = ();
1450        type State = TextEditor;
1451
1452        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1453            TextEditor::new("one\ntwo")
1454        }
1455
1456        fn view(&self, ctx: &Context<Self>) -> Element {
1457            let dock: Element = Frame::new()
1458                .border(false)
1459                .padding((1, 2, 0, 2))
1460                .width(Length::Px(24))
1461                .height(Length::Auto)
1462                .child(
1463                    VStack::new()
1464                        .height(Length::Auto)
1465                        .gap(1)
1466                        .child(
1467                            TextArea::bound(&ctx.state)
1468                                .height(Length::Auto)
1469                                .border(false)
1470                                .scrollbar(false)
1471                                .line_numbers(true)
1472                                .vim_motions(true)
1473                                .on_change(ctx.link().callback(|event| event)),
1474                        )
1475                        .child(
1476                            HStack::new()
1477                                .height(Length::Px(1))
1478                                .width(Length::Percent(100))
1479                                .child(Text::new("status"))
1480                                .key("prompt-status"),
1481                        ),
1482                )
1483                .into();
1484
1485            VStack::new()
1486                .width(Length::Px(24))
1487                .height(Length::Auto)
1488                .child(
1489                    Popover::new()
1490                        .trigger(dock)
1491                        .content(Text::new("popup"))
1492                        .open(false),
1493                )
1494                .child(Text::new("shortcuts").key("prompt-shortcuts"))
1495                .into()
1496        }
1497
1498        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1499            msg.apply_to(&mut ctx.state);
1500            Update::full()
1501        }
1502    }
1503
1504    impl Component for VimAutoHeightPopoverTriggerShortcutsRenderRoot {
1505        type Message = TextAreaEvent;
1506        type Properties = ();
1507        type State = TextEditor;
1508
1509        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1510            TextEditor::new("one\ntwo")
1511        }
1512
1513        fn view(&self, ctx: &Context<Self>) -> Element {
1514            let dock: Element = Frame::new()
1515                .border(false)
1516                .padding((1, 2, 0, 2))
1517                .width(Length::Px(24))
1518                .height(Length::Auto)
1519                .child(
1520                    VStack::new()
1521                        .height(Length::Auto)
1522                        .gap(1)
1523                        .child(
1524                            TextArea::bound(&ctx.state)
1525                                .height(Length::Auto)
1526                                .border(false)
1527                                .scrollbar(false)
1528                                .line_numbers(true)
1529                                .vim_motions(true)
1530                                .on_change(ctx.link().callback(|event| event)),
1531                        )
1532                        .child(
1533                            HStack::new()
1534                                .height(Length::Px(1))
1535                                .width(Length::Percent(100))
1536                                .child(Text::new("status"))
1537                                .key("prompt-status"),
1538                        ),
1539                )
1540                .into();
1541
1542            VStack::new()
1543                .height(Length::Auto)
1544                .child(
1545                    Popover::new()
1546                        .trigger(
1547                            VStack::new()
1548                                .height(Length::Auto)
1549                                .gap(0)
1550                                .child(dock)
1551                                .child(Text::new("shortcuts").key("prompt-shortcuts")),
1552                        )
1553                        .content(Text::new("popup"))
1554                        .open(false),
1555                )
1556                .into()
1557        }
1558
1559        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1560            msg.apply_to(&mut ctx.state);
1561            Update::full()
1562        }
1563    }
1564
1565    impl Component for VimAutoHeightAnimatedPromptShellRenderRoot {
1566        type Message = TextAreaEvent;
1567        type Properties = ();
1568        type State = TextEditor;
1569
1570        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1571            TextEditor::new("one\ntwo")
1572        }
1573
1574        fn view(&self, ctx: &Context<Self>) -> Element {
1575            let dock: Element = Frame::new()
1576                .border(false)
1577                .padding((1, 2, 0, 2))
1578                .width(Length::Px(24))
1579                .height(Length::Auto)
1580                .child(
1581                    VStack::new()
1582                        .height(Length::Auto)
1583                        .gap(1)
1584                        .child(
1585                            TextArea::bound(&ctx.state)
1586                                .height(Length::Auto)
1587                                .border(false)
1588                                .scrollbar(false)
1589                                .line_numbers(true)
1590                                .vim_motions(true)
1591                                .on_change(ctx.link().callback(|event| event)),
1592                        )
1593                        .child(
1594                            HStack::new()
1595                                .height(Length::Px(1))
1596                                .width(Length::Percent(100))
1597                                .child(Text::new("status"))
1598                                .key("prompt-status"),
1599                        ),
1600                )
1601                .into();
1602
1603            VStack::new()
1604                .height(Length::Auto)
1605                .child(Animated::new(
1606                    VStack::new()
1607                        .height(Length::Auto)
1608                        .gap(0)
1609                        .child(
1610                            Popover::new()
1611                                .trigger(dock)
1612                                .content(Text::new("popup"))
1613                                .open(false),
1614                        )
1615                        .child(Text::new("shortcuts").key("prompt-shortcuts")),
1616                ))
1617                .into()
1618        }
1619
1620        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1621            msg.apply_to(&mut ctx.state);
1622            Update::full()
1623        }
1624    }
1625
1626    impl Component for VimAutoHeightEffectWrappedPromptShellRenderRoot {
1627        type Message = TextAreaEvent;
1628        type Properties = ();
1629        type State = TextEditor;
1630
1631        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1632            TextEditor::new("one\ntwo")
1633        }
1634
1635        fn view(&self, ctx: &Context<Self>) -> Element {
1636            let dock: Element = Frame::new()
1637                .border(false)
1638                .padding((1, 2, 0, 2))
1639                .width(Length::Px(24))
1640                .height(Length::Auto)
1641                .child(
1642                    VStack::new()
1643                        .height(Length::Auto)
1644                        .gap(1)
1645                        .child(
1646                            TextArea::bound(&ctx.state)
1647                                .height(Length::Auto)
1648                                .border(false)
1649                                .scrollbar(false)
1650                                .line_numbers(true)
1651                                .vim_motions(true)
1652                                .on_change(ctx.link().callback(|event| event)),
1653                        )
1654                        .child(
1655                            HStack::new()
1656                                .height(Length::Px(1))
1657                                .width(Length::Percent(100))
1658                                .child(Text::new("status"))
1659                                .key("prompt-status"),
1660                        ),
1661                )
1662                .into();
1663
1664            VStack::new()
1665                .height(Length::Auto)
1666                .child(
1667                    ScrollView::new()
1668                        .height(Length::Flex(1))
1669                        .child(Text::new("messages"))
1670                        .key("messages-scroll"),
1671                )
1672                .child(
1673                    EffectScope::new().dim_by(0.0).child(
1674                        Popover::new()
1675                            .trigger(
1676                                VStack::new()
1677                                    .height(Length::Auto)
1678                                    .gap(0)
1679                                    .child(dock)
1680                                    .child(Text::new("shortcuts").key("prompt-shortcuts")),
1681                            )
1682                            .content(Text::new("popup"))
1683                            .open(false),
1684                    ),
1685                )
1686                .into()
1687        }
1688
1689        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1690            msg.apply_to(&mut ctx.state);
1691            Update::full()
1692        }
1693    }
1694
1695    impl Component for VimStyledSearchBarRoot {
1696        type Message = TextAreaEvent;
1697        type Properties = ();
1698        type State = TextEditor;
1699
1700        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1701            TextEditor::new("alpha beta alpha")
1702        }
1703
1704        fn view(&self, ctx: &Context<Self>) -> Element {
1705            TextArea::bound(&ctx.state)
1706                .border(false)
1707                .line_numbers(true)
1708                .height(Length::Px(4))
1709                .vim_motions(true)
1710                .vim_config(
1711                    TextAreaVimConfig::new()
1712                        .search_bar_prefix_style(Style::new().fg(Color::Cyan))
1713                        .search_bar_count_style(Style::new().fg(Color::Yellow)),
1714                )
1715                .on_change(ctx.link().callback(|event| event))
1716                .into()
1717        }
1718
1719        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1720            msg.apply_to(&mut ctx.state);
1721            Update::full()
1722        }
1723    }
1724
1725    impl Component for VimBackwardSearchRenderRoot {
1726        type Message = TextAreaEvent;
1727        type Properties = ();
1728        type State = TextEditor;
1729
1730        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1731            let mut editor = TextEditor::new("alpha beta alpha");
1732            editor.set_cursor(10);
1733            editor
1734        }
1735
1736        fn view(&self, ctx: &Context<Self>) -> Element {
1737            TextArea::bound(&ctx.state)
1738                .border(false)
1739                .line_numbers(true)
1740                .height(Length::Px(4))
1741                .vim_motions(true)
1742                .vim_config(
1743                    TextAreaVimConfig::new()
1744                        .current_search_match_style(Style::new().bg(Color::Blue)),
1745                )
1746                .on_change(ctx.link().callback(|event| event))
1747                .into()
1748        }
1749
1750        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1751            msg.apply_to(&mut ctx.state);
1752            Update::full()
1753        }
1754    }
1755
1756    impl Component for VimWrappedSearchRenderRoot {
1757        type Message = TextAreaEvent;
1758        type Properties = ();
1759        type State = TextEditor;
1760
1761        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1762            TextEditor::new("xxabcdyy")
1763        }
1764
1765        fn view(&self, ctx: &Context<Self>) -> Element {
1766            TextArea::bound(&ctx.state)
1767                .border(false)
1768                .scrollbar(false)
1769                .width(Length::Px(6))
1770                .height(Length::Px(4))
1771                .vim_motions(true)
1772                .vim_config(
1773                    TextAreaVimConfig::new()
1774                        .current_search_match_style(Style::new().bg(Color::Blue)),
1775                )
1776                .on_change(ctx.link().callback(|event| event))
1777                .into()
1778        }
1779
1780        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1781            msg.apply_to(&mut ctx.state);
1782            Update::full()
1783        }
1784    }
1785
1786    impl Component for VimCurrentLineRenderRoot {
1787        type Message = TextAreaEvent;
1788        type Properties = ();
1789        type State = TextEditor;
1790
1791        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1792            let mut editor = TextEditor::new("alpha\nbeta");
1793            editor.set_cursor(6);
1794            editor
1795        }
1796
1797        fn view(&self, ctx: &Context<Self>) -> Element {
1798            TextArea::bound(&ctx.state)
1799                .border(false)
1800                .style(Style::new().bg(Color::Red))
1801                .line_numbers(true)
1802                .height(Length::Px(3))
1803                .vim_motions(true)
1804                .vim_config(
1805                    TextAreaVimConfig::new()
1806                        .current_line_highlight(TextAreaVimCurrentLineHighlight::Full)
1807                        .current_line_style(Style::new().bg(Color::Blue))
1808                        .current_line_number_style(Style::new().fg(Color::Yellow).bold()),
1809                )
1810                .on_change(ctx.link().callback(|event| event))
1811                .into()
1812        }
1813
1814        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1815            msg.apply_to(&mut ctx.state);
1816            Update::full()
1817        }
1818    }
1819
1820    impl Component for TextAreaRelativeLineNumbersRoot {
1821        type Message = TextAreaEvent;
1822        type Properties = ();
1823        type State = TextEditor;
1824
1825        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1826            let mut editor = TextEditor::new("one\ntwo\nthree\nfour\nfive");
1827            editor.set_cursor("one\ntwo\n".len());
1828            editor
1829        }
1830
1831        fn view(&self, ctx: &Context<Self>) -> Element {
1832            TextArea::bound(&ctx.state)
1833                .border(false)
1834                .scrollbar(false)
1835                .line_numbers(true)
1836                .line_number_mode(TextAreaLineNumberMode::Relative)
1837                .height(Length::Px(5))
1838                .on_change(ctx.link().callback(|event| event))
1839                .into()
1840        }
1841
1842        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1843            msg.apply_to(&mut ctx.state);
1844            Update::full()
1845        }
1846    }
1847
1848    impl Component for TextAreaVirtualTextRenderRoot {
1849        type Message = ();
1850        type Properties = ();
1851        type State = ();
1852
1853        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
1854
1855        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
1856            Update::none()
1857        }
1858
1859        fn view(&self, _ctx: &Context<Self>) -> Element {
1860            TextArea::new("ab\ncd")
1861                .border(false)
1862                .scrollbar(false)
1863                .wrap(false)
1864                .width(Length::Px(20))
1865                .height(Length::Px(2))
1866                .virtual_text(TextAreaVirtualText::inline(
1867                    1,
1868                    vec![Span::new("<x>").fg(Color::Cyan)],
1869                ))
1870                .virtual_text(TextAreaVirtualText::eol(
1871                    2,
1872                    vec![Span::new(" // diag").fg(Color::Red)],
1873                ))
1874                .into()
1875        }
1876    }
1877
1878    impl Component for TextAreaVirtualTextCursorRoot {
1879        type Message = TextAreaEvent;
1880        type Properties = ();
1881        type State = TextEditor;
1882
1883        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1884            let mut editor = TextEditor::new("ab");
1885            editor.set_cursor(1);
1886            editor
1887        }
1888
1889        fn view(&self, ctx: &Context<Self>) -> Element {
1890            TextArea::bound(&ctx.state)
1891                .border(false)
1892                .scrollbar(false)
1893                .wrap(false)
1894                .width(Length::Px(10))
1895                .height(Length::Px(1))
1896                .virtual_text(TextAreaVirtualText::inline(
1897                    1,
1898                    vec![Span::new("<x>").fg(Color::Cyan)],
1899                ))
1900                .on_change(ctx.link().callback(|event| event))
1901                .into()
1902        }
1903
1904        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1905            msg.apply_to(&mut ctx.state);
1906            Update::full()
1907        }
1908    }
1909
1910    impl Component for TextAreaVirtualTextSentinelRoot {
1911        type Message = ();
1912        type Properties = ();
1913        type State = ();
1914
1915        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
1916
1917        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
1918            Update::none()
1919        }
1920
1921        fn view(&self, _ctx: &Context<Self>) -> Element {
1922            let value = format!("a{}b", SENTINEL_BASE);
1923            let sentinel_end = 1 + SENTINEL_BASE.len_utf8();
1924
1925            TextArea::new(value)
1926                .border(false)
1927                .scrollbar(false)
1928                .wrap(false)
1929                .cursor(sentinel_end)
1930                .anchor(Some(1))
1931                .selection_style(Style::new().bg(Color::Blue))
1932                .sentinels(vec![TextAreaSentinel::new("[S]")])
1933                .virtual_text(TextAreaVirtualText::inline(
1934                    1,
1935                    vec![Span::new("<v>").fg(Color::Cyan)],
1936                ))
1937                .width(Length::Px(12))
1938                .height(Length::Px(1))
1939                .into()
1940        }
1941    }
1942
1943    impl Component for VimCollapsedContentSearchRoot {
1944        type Message = TextAreaEvent;
1945        type Properties = ();
1946        type State = TextEditor;
1947
1948        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1949            TextEditor::new("alpha")
1950        }
1951
1952        fn view(&self, ctx: &Context<Self>) -> Element {
1953            TextArea::bound(&ctx.state)
1954                .border(false)
1955                .line_numbers(true)
1956                .min_line_number_width(100)
1957                .vim_motions(true)
1958                .on_change(ctx.link().callback(|event| event))
1959                .into()
1960        }
1961
1962        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1963            msg.apply_to(&mut ctx.state);
1964            Update::full()
1965        }
1966    }
1967
1968    impl Component for VimTextAreaRoot {
1969        type Message = VimTextAreaMsg;
1970        type Properties = ();
1971        type State = VimTextAreaState;
1972
1973        fn create_state(&self, _props: &Self::Properties) -> Self::State {
1974            let mut editor = TextEditor::new("abc");
1975            editor.set_cursor(2);
1976            VimTextAreaState {
1977                editor,
1978                modes: Vec::new(),
1979            }
1980        }
1981
1982        fn view(&self, ctx: &Context<Self>) -> Element {
1983            TextArea::bound(&ctx.state.editor)
1984                .vim_motions(true)
1985                .on_change(ctx.link().callback(VimTextAreaMsg::Changed))
1986                .on_vim_mode_change(ctx.link().callback(VimTextAreaMsg::Mode))
1987                .into()
1988        }
1989
1990        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
1991            match msg {
1992                VimTextAreaMsg::Changed(event) => event.apply_to(&mut ctx.state.editor),
1993                VimTextAreaMsg::Mode(mode) => ctx.state.modes.push(mode),
1994            }
1995            Update::full()
1996        }
1997    }
1998
1999    #[test]
2000    fn send_key_drives_bound_text_area_vim_mode_and_cursor_updates() {
2001        let mut backend = TestBackend::new(VimTextAreaRoot);
2002        let root = backend.core.tree.root;
2003        backend.set_focused(root);
2004
2005        assert!(
2006            backend
2007                .send_key(char_key('i'))
2008                .expect("insert mode key succeeds")
2009        );
2010        assert_eq!(backend.state().modes.as_slice(), [TextAreaVimMode::Insert]);
2011
2012        assert!(
2013            backend
2014                .send_key(char_key('x'))
2015                .expect("insert key succeeds")
2016        );
2017        assert_eq!(backend.state().editor.text(), "abxc");
2018        assert_eq!(backend.state().editor.cursor(), 3);
2019
2020        assert!(
2021            backend
2022                .send_key(KeyEvent {
2023                    code: KeyCode::Esc,
2024                    mods: Default::default(),
2025                })
2026                .expect("escape key succeeds")
2027        );
2028        assert_eq!(
2029            backend.state().modes.as_slice(),
2030            [TextAreaVimMode::Insert, TextAreaVimMode::Normal]
2031        );
2032
2033        assert!(
2034            backend
2035                .send_key(char_key('h'))
2036                .expect("motion key succeeds")
2037        );
2038        assert_eq!(backend.state().editor.text(), "abxc");
2039        assert_eq!(backend.state().editor.cursor(), 2);
2040
2041        assert!(
2042            backend
2043                .send_key(char_key('v'))
2044                .expect("visual key succeeds")
2045        );
2046        assert_eq!(
2047            backend.state().modes.as_slice(),
2048            [
2049                TextAreaVimMode::Insert,
2050                TextAreaVimMode::Normal,
2051                TextAreaVimMode::Visual,
2052            ]
2053        );
2054        assert_eq!(backend.state().editor.cursor(), 2);
2055        assert_eq!(backend.state().editor.anchor(), Some(2));
2056
2057        assert!(
2058            backend
2059                .send_key(char_key('l'))
2060                .expect("visual motion succeeds")
2061        );
2062        assert_eq!(backend.state().editor.cursor(), 3);
2063        assert_eq!(backend.state().editor.anchor(), Some(2));
2064
2065        assert!(
2066            backend
2067                .send_key(KeyEvent {
2068                    code: KeyCode::Esc,
2069                    mods: Default::default(),
2070                })
2071                .expect("visual escape succeeds")
2072        );
2073        assert!(
2074            backend
2075                .send_key(char_key('V'))
2076                .expect("visual line key succeeds")
2077        );
2078        assert_eq!(
2079            backend.state().modes.as_slice(),
2080            [
2081                TextAreaVimMode::Insert,
2082                TextAreaVimMode::Normal,
2083                TextAreaVimMode::Visual,
2084                TextAreaVimMode::Normal,
2085                TextAreaVimMode::VisualLine,
2086            ]
2087        );
2088        assert_eq!(backend.state().editor.anchor(), Some(0));
2089        assert_eq!(
2090            backend.state().editor.cursor(),
2091            backend.state().editor.text().len()
2092        );
2093    }
2094
2095    #[test]
2096    fn capture_frame_renders_text_area_relative_line_numbers() {
2097        let mut backend = TestBackend::new(TextAreaRelativeLineNumbersRoot);
2098        backend.set_viewport(Rect {
2099            x: 0,
2100            y: 0,
2101            w: 20,
2102            h: 5,
2103        });
2104        backend.render();
2105
2106        let rows = backend.capture_frame().to_fixed_grid_lines();
2107        assert!(rows[0].starts_with("2 │one"), "row 0 was {:?}", rows[0]);
2108        assert!(rows[1].starts_with("1 │two"), "row 1 was {:?}", rows[1]);
2109        assert!(rows[2].starts_with("3 │three"), "row 2 was {:?}", rows[2]);
2110        assert!(rows[3].starts_with("1 │four"), "row 3 was {:?}", rows[3]);
2111        assert!(rows[4].starts_with("2 │five"), "row 4 was {:?}", rows[4]);
2112    }
2113
2114    #[test]
2115    fn capture_frame_renders_text_area_virtual_text() {
2116        let mut backend = TestBackend::new(TextAreaVirtualTextRenderRoot);
2117        backend.set_viewport(Rect {
2118            x: 0,
2119            y: 0,
2120            w: 20,
2121            h: 2,
2122        });
2123        backend.render();
2124
2125        let frame = backend.capture_frame();
2126        let rows = frame.to_fixed_grid_lines();
2127        assert!(
2128            rows[0].starts_with("a<x>b // diag"),
2129            "row 0 was {:?}",
2130            rows[0]
2131        );
2132        assert!(rows[1].starts_with("cd"), "row 1 was {:?}", rows[1]);
2133        assert_eq!(frame.cell(1, 0).fg, Color::Cyan);
2134        assert_eq!(frame.cell(6, 0).fg, Color::Red);
2135    }
2136
2137    #[test]
2138    fn screen_background_fills_cells_untouched_by_the_tree() {
2139        let mut backend = TestBackend::new(Counter);
2140        backend.set_viewport(Rect {
2141            x: 0,
2142            y: 0,
2143            w: 10,
2144            h: 3,
2145        });
2146
2147        // Without a screen background the empty cells stay reset/transparent.
2148        backend.render();
2149        assert_eq!(backend.capture_frame().cell(9, 2).bg, Color::Reset);
2150
2151        // Opt in: every cell the tree does not paint should carry the fill.
2152        let fill = Color::Rgb(4, 9, 13);
2153        backend.set_screen_background(Some(crate::style::Style::new().bg(fill)));
2154        backend.render();
2155        let frame = backend.capture_frame();
2156        assert_eq!(frame.cell(9, 2).bg, fill, "corner cell should be filled");
2157        assert_eq!(frame.cell(5, 1).bg, fill, "interior gap should be filled");
2158    }
2159
2160    #[test]
2161    fn capture_frame_places_cursor_after_inline_virtual_text_at_anchor() {
2162        let mut backend = TestBackend::new(TextAreaVirtualTextCursorRoot);
2163        let root = backend.core.tree.root;
2164        backend.set_focused(root);
2165        backend.set_viewport(Rect {
2166            x: 0,
2167            y: 0,
2168            w: 10,
2169            h: 1,
2170        });
2171        backend.render();
2172
2173        let frame = backend.capture_frame();
2174        let rows = frame.to_fixed_grid_lines();
2175        assert!(rows[0].starts_with("a<x>b"), "row 0 was {:?}", rows[0]);
2176        assert_eq!(frame.cursor.as_ref().map(|cursor| cursor.x), Some(4));
2177    }
2178
2179    #[test]
2180    fn capture_frame_renders_inline_virtual_text_without_corrupting_sentinel_selection() {
2181        let mut backend = TestBackend::new(TextAreaVirtualTextSentinelRoot);
2182        let root = backend.core.tree.root;
2183        backend.set_focused(root);
2184        backend.set_viewport(Rect {
2185            x: 0,
2186            y: 0,
2187            w: 12,
2188            h: 1,
2189        });
2190        backend.render();
2191
2192        let frame = backend.capture_frame();
2193        let rows = frame.to_fixed_grid_lines();
2194        assert!(rows[0].starts_with("a<v>[S]b"), "row 0 was {:?}", rows[0]);
2195        assert_eq!(frame.cell(1, 0).fg, Color::Cyan);
2196        assert_eq!(frame.cell(4, 0).bg, Color::Blue);
2197    }
2198
2199    #[test]
2200    fn capture_frame_shows_vim_search_prompt_and_match_highlight() {
2201        let mut backend = TestBackend::new(VimSearchRenderRoot);
2202        let root = backend.core.tree.root;
2203        backend.set_focused(root);
2204
2205        backend
2206            .send_key(char_key('/'))
2207            .expect("search key succeeds");
2208        backend.send_key(char_key('a')).expect("query key succeeds");
2209        backend.send_key(char_key('l')).expect("query key succeeds");
2210
2211        let pending = backend.capture_frame();
2212        let pending_rows = pending.to_fixed_grid_lines();
2213        let search_y = pending.height.saturating_sub(1);
2214        assert!(
2215            pending_rows[usize::from(search_y)].starts_with("  al"),
2216            "search row was {:?}",
2217            pending_rows[usize::from(search_y)]
2218        );
2219        assert!(pending_rows[usize::from(search_y)].ends_with("[2/2]"));
2220        assert!(!pending_rows[usize::from(search_y)].contains("alpha"));
2221        assert_eq!(pending.cell(0, search_y).symbol, "");
2222        assert_eq!(pending.cell(2, search_y).symbol, "");
2223        assert!(pending.cell(3, 0).modifiers.underline);
2224        assert!(pending.cell(4, 0).modifiers.underline);
2225        assert!(!pending_rows[0].contains("[2/2]"));
2226        assert_ne!(pending.cell(3, 0).bg, Color::Blue);
2227        assert_eq!(pending.cell(14, 0).bg, Color::Blue);
2228        assert_eq!(pending.cell(15, 0).bg, Color::Blue);
2229        assert_eq!(
2230            pending.cursor.as_ref().map(|cursor| cursor.y),
2231            Some(search_y)
2232        );
2233
2234        backend
2235            .send_key(KeyEvent {
2236                code: KeyCode::Enter,
2237                mods: Default::default(),
2238            })
2239            .expect("search submit succeeds");
2240
2241        let committed = backend.capture_frame();
2242        let committed_rows = committed.to_fixed_grid_lines();
2243        assert!(!committed_rows[usize::from(search_y)].starts_with(""));
2244        assert!(committed_rows[0].contains("alpha beta alpha [2/2]"));
2245        assert_eq!(committed.cell(19, 0).symbol, " ");
2246        assert!(!committed.cell(19, 0).modifiers.reverse);
2247        assert_eq!(committed.cell(20, 0).symbol, "[");
2248        assert!(committed.cell(20, 0).modifiers.reverse);
2249        assert!(committed.cell(3, 0).modifiers.underline);
2250        assert!(committed.cell(4, 0).modifiers.underline);
2251        assert_eq!(committed.cell(14, 0).bg, Color::Blue);
2252        assert_eq!(committed.cell(15, 0).bg, Color::Blue);
2253
2254        backend
2255            .send_key(char_key('n'))
2256            .expect("search repeat succeeds");
2257        let repeated = backend.capture_frame();
2258        let repeated_rows = repeated.to_fixed_grid_lines();
2259        assert!(!repeated_rows[usize::from(search_y)].starts_with(""));
2260        assert!(repeated_rows[0].contains("alpha beta alpha [1/2]"));
2261        assert_eq!(repeated.cell(3, 0).bg, Color::Blue);
2262        assert_ne!(repeated.cell(14, 0).bg, Color::Blue);
2263    }
2264
2265    #[test]
2266    fn capture_frame_reserves_auto_height_row_for_pending_vim_search() {
2267        let mut backend = TestBackend::new(VimAutoHeightSearchRenderRoot);
2268        backend.set_viewport(Rect {
2269            x: 0,
2270            y: 0,
2271            w: 24,
2272            h: 5,
2273        });
2274        backend.render();
2275        let text_area_id = backend
2276            .core
2277            .tree
2278            .iter()
2279            .find(|node| matches!(node.kind, NodeKind::TextArea(_)))
2280            .map(|node| node.id)
2281            .expect("expected a textarea node");
2282        backend.set_focused(text_area_id);
2283
2284        backend
2285            .send_key(char_key('/'))
2286            .expect("search key succeeds");
2287        backend.send_key(char_key('o')).expect("query key succeeds");
2288        backend.render();
2289
2290        let frame = backend.capture_frame();
2291        let rows = frame.to_fixed_grid_lines();
2292        assert!(rows[0].starts_with("1 │one"), "row 0 was {:?}", rows[0]);
2293        assert!(rows[1].starts_with("2 │two"), "row 1 was {:?}", rows[1]);
2294        assert!(rows[2].starts_with("  o"), "search row was {:?}", rows[2]);
2295        assert!(rows[3].starts_with("below"), "row 3 was {:?}", rows[3]);
2296    }
2297
2298    #[test]
2299    fn capture_frame_resizes_auto_height_frame_for_pending_vim_search() {
2300        let mut backend = TestBackend::new(VimAutoHeightFrameSearchRenderRoot);
2301        backend.set_viewport(Rect {
2302            x: 0,
2303            y: 0,
2304            w: 24,
2305            h: 7,
2306        });
2307        backend.render();
2308        let text_area_id = backend
2309            .core
2310            .tree
2311            .iter()
2312            .find(|node| matches!(node.kind, NodeKind::TextArea(_)))
2313            .map(|node| node.id)
2314            .expect("expected a textarea node");
2315        backend.set_focused(text_area_id);
2316
2317        backend
2318            .send_key(char_key('/'))
2319            .expect("search key succeeds");
2320        backend.send_key(char_key('o')).expect("query key succeeds");
2321        backend.render();
2322
2323        let frame = backend.capture_frame();
2324        let rows = frame.to_fixed_grid_lines();
2325        assert!(rows[1].starts_with("│1 │one"), "row 1 was {:?}", rows[1]);
2326        assert!(rows[2].starts_with("│2 │two"), "row 2 was {:?}", rows[2]);
2327        assert!(
2328            rows[3].starts_with("│  o"),
2329            "search row was {:?}",
2330            rows[3]
2331        );
2332        assert!(rows[4].starts_with("└"), "row 4 was {:?}", rows[4]);
2333        assert!(rows[5].starts_with("below"), "row 5 was {:?}", rows[5]);
2334    }
2335
2336    #[test]
2337    fn capture_frame_resizes_nested_popover_frame_for_pending_vim_search() {
2338        let mut backend = TestBackend::new(VimAutoHeightPopoverFrameSearchRenderRoot);
2339        backend.set_viewport(Rect {
2340            x: 0,
2341            y: 0,
2342            w: 28,
2343            h: 8,
2344        });
2345        backend.render();
2346        let text_area_id = backend
2347            .core
2348            .tree
2349            .iter()
2350            .find(|node| matches!(node.kind, NodeKind::TextArea(_)))
2351            .map(|node| node.id)
2352            .expect("expected a textarea node");
2353        backend.set_focused(text_area_id);
2354
2355        backend
2356            .send_key(char_key('/'))
2357            .expect("search key succeeds");
2358        backend.send_key(char_key('o')).expect("query key succeeds");
2359        backend.render();
2360
2361        let frame = backend.capture_frame();
2362        let rows = frame.to_fixed_grid_lines();
2363        assert!(rows[1].starts_with("  1 │one"), "row 1 was {:?}", rows[1]);
2364        assert!(rows[2].starts_with("  2 │two"), "row 2 was {:?}", rows[2]);
2365        assert!(rows[3].starts_with("    o"), "row 3 was {:?}", rows[3]);
2366        assert!(rows[5].starts_with("  status"), "row 5 was {:?}", rows[5]);
2367        assert!(rows[6].starts_with("shortcuts"), "row 6 was {:?}", rows[6]);
2368        assert_eq!(rect_by_key(&backend, "prompt-status").y, 5);
2369        assert_eq!(rect_by_key(&backend, "prompt-shortcuts").y, 6);
2370    }
2371
2372    #[test]
2373    fn capture_frame_resizes_popover_trigger_shortcuts_for_pending_vim_search() {
2374        let mut backend = TestBackend::new(VimAutoHeightPopoverTriggerShortcutsRenderRoot);
2375        backend.set_viewport(Rect {
2376            x: 0,
2377            y: 0,
2378            w: 28,
2379            h: 8,
2380        });
2381        backend.render();
2382        let text_area_id = backend
2383            .core
2384            .tree
2385            .iter()
2386            .find(|node| matches!(node.kind, NodeKind::TextArea(_)))
2387            .map(|node| node.id)
2388            .expect("expected a textarea node");
2389        backend.set_focused(text_area_id);
2390
2391        backend
2392            .send_key(char_key('/'))
2393            .expect("search key succeeds");
2394        backend.send_key(char_key('o')).expect("query key succeeds");
2395        backend.render();
2396
2397        let frame = backend.capture_frame();
2398        let rows = frame.to_fixed_grid_lines();
2399        assert!(rows[3].starts_with("    o"), "row 3 was {:?}", rows[3]);
2400        assert!(rows[5].starts_with("  status"), "row 5 was {:?}", rows[5]);
2401        assert!(rows[6].starts_with("shortcuts"), "row 6 was {:?}", rows[6]);
2402        assert_eq!(rect_by_key(&backend, "prompt-shortcuts").y, 6);
2403    }
2404
2405    #[test]
2406    fn capture_frame_resizes_animated_prompt_shell_for_pending_vim_search() {
2407        let mut backend = TestBackend::new(VimAutoHeightAnimatedPromptShellRenderRoot);
2408        backend.set_viewport(Rect {
2409            x: 0,
2410            y: 0,
2411            w: 28,
2412            h: 8,
2413        });
2414        backend.render();
2415        let text_area_id = backend
2416            .core
2417            .tree
2418            .iter()
2419            .find(|node| matches!(node.kind, NodeKind::TextArea(_)))
2420            .map(|node| node.id)
2421            .expect("expected a textarea node");
2422        backend.set_focused(text_area_id);
2423
2424        backend
2425            .send_key(char_key('/'))
2426            .expect("search key succeeds");
2427        backend.send_key(char_key('o')).expect("query key succeeds");
2428        backend.render();
2429
2430        let frame = backend.capture_frame();
2431        let rows = frame.to_fixed_grid_lines();
2432        assert!(rows[3].starts_with("    o"), "row 3 was {:?}", rows[3]);
2433        assert!(rows[5].starts_with("  status"), "row 5 was {:?}", rows[5]);
2434        assert!(rows[6].starts_with("shortcuts"), "row 6 was {:?}", rows[6]);
2435        assert_eq!(rect_by_key(&backend, "prompt-shortcuts").y, 6);
2436    }
2437
2438    #[test]
2439    fn capture_frame_resizes_effect_wrapped_prompt_shell_for_pending_vim_search() {
2440        let mut backend = TestBackend::new(VimAutoHeightEffectWrappedPromptShellRenderRoot);
2441        backend.set_viewport(Rect {
2442            x: 0,
2443            y: 0,
2444            w: 28,
2445            h: 8,
2446        });
2447        backend.render();
2448        let text_area_id = backend
2449            .core
2450            .tree
2451            .iter()
2452            .find(|node| matches!(node.kind, NodeKind::TextArea(_)))
2453            .map(|node| node.id)
2454            .expect("expected a textarea node");
2455        backend.set_focused(text_area_id);
2456
2457        backend
2458            .send_key(char_key('/'))
2459            .expect("search key succeeds");
2460        backend.send_key(char_key('o')).expect("query key succeeds");
2461        backend.render();
2462
2463        let frame = backend.capture_frame();
2464        let rows = frame.to_fixed_grid_lines();
2465        assert!(rows[4].starts_with("    o"), "row 4 was {:?}", rows[4]);
2466        assert!(rows[6].starts_with("  status"), "row 6 was {:?}", rows[6]);
2467        assert!(rows[7].starts_with("shortcuts"), "row 7 was {:?}", rows[7]);
2468        assert_eq!(rect_by_key(&backend, "prompt-shortcuts").y, 7);
2469        assert!(
2470            rect_by_key(&backend, "messages-scroll").h < 3,
2471            "messages region should yield height to the growing prompt shell"
2472        );
2473    }
2474
2475    #[test]
2476    fn capture_frame_styles_vim_search_prefix_and_count() {
2477        let mut backend = TestBackend::new(VimStyledSearchBarRoot);
2478        let root = backend.core.tree.root;
2479        backend.set_focused(root);
2480
2481        backend
2482            .send_key(char_key('/'))
2483            .expect("search key succeeds");
2484        backend.send_key(char_key('a')).expect("query key succeeds");
2485        backend.send_key(char_key('l')).expect("query key succeeds");
2486
2487        let pending = backend.capture_frame();
2488        let search_y = pending.height.saturating_sub(1);
2489        let rows = pending.to_fixed_grid_lines();
2490        assert!(
2491            rows[usize::from(search_y)].ends_with("[2/2]"),
2492            "search row was {:?}",
2493            rows[usize::from(search_y)]
2494        );
2495        assert_eq!(pending.cell(0, search_y).fg, Color::Cyan);
2496        assert_eq!(pending.cell(2, search_y).fg, Color::Cyan);
2497        assert_ne!(pending.cell(4, search_y).fg, Color::Cyan);
2498
2499        let count_start = pending.width.saturating_sub(5);
2500        assert_eq!(pending.cell(count_start, search_y).symbol, "[");
2501        for x in count_start..pending.width {
2502            assert_eq!(pending.cell(x, search_y).fg, Color::Yellow);
2503        }
2504
2505        backend
2506            .send_key(KeyEvent {
2507                code: KeyCode::Enter,
2508                mods: Default::default(),
2509            })
2510            .expect("search submit succeeds");
2511        let committed = backend.capture_frame();
2512        assert_eq!(committed.cell(20, 0).symbol, "[");
2513        for x in 20..25 {
2514            assert_eq!(committed.cell(x, 0).fg, Color::Yellow);
2515        }
2516    }
2517
2518    #[test]
2519    fn capture_frame_esc_cancels_pending_vim_search_bar() {
2520        let mut backend = TestBackend::new(VimSearchRenderRoot);
2521        let root = backend.core.tree.root;
2522        backend.set_focused(root);
2523
2524        backend
2525            .send_key(char_key('/'))
2526            .expect("search key succeeds");
2527        backend.send_key(char_key('a')).expect("query key succeeds");
2528        backend.send_key(char_key('l')).expect("query key succeeds");
2529
2530        let pending = backend.capture_frame();
2531        let search_y = pending.height.saturating_sub(1);
2532        assert!(pending.to_fixed_grid_lines()[usize::from(search_y)].starts_with("  al"));
2533
2534        backend
2535            .send_key(KeyEvent {
2536                code: KeyCode::Esc,
2537                mods: Default::default(),
2538            })
2539            .expect("escape cancels search");
2540
2541        let cancelled = backend.capture_frame();
2542        let cancelled_rows = cancelled.to_fixed_grid_lines();
2543        assert!(
2544            !cancelled_rows[usize::from(search_y)].starts_with(""),
2545            "search row stayed visible: {:?}",
2546            cancelled_rows[usize::from(search_y)]
2547        );
2548        assert_ne!(
2549            cancelled.cursor.as_ref().map(|cursor| cursor.y),
2550            Some(search_y)
2551        );
2552    }
2553
2554    #[test]
2555    fn capture_frame_esc_hides_committed_vim_search_but_repeat_restores_status() {
2556        let mut backend = TestBackend::new(VimSearchRenderRoot);
2557        let root = backend.core.tree.root;
2558        backend.set_focused(root);
2559
2560        backend
2561            .send_key(char_key('/'))
2562            .expect("search key succeeds");
2563        backend.send_key(char_key('a')).expect("query key succeeds");
2564        backend.send_key(char_key('l')).expect("query key succeeds");
2565        backend
2566            .send_key(KeyEvent {
2567                code: KeyCode::Enter,
2568                mods: Default::default(),
2569            })
2570            .expect("search submit succeeds");
2571
2572        let committed = backend.capture_frame();
2573        let search_y = committed.height.saturating_sub(1);
2574        let committed_rows = committed.to_fixed_grid_lines();
2575        assert!(!committed_rows[usize::from(search_y)].starts_with(""));
2576        assert!(committed_rows[0].contains("alpha beta alpha [2/2]"));
2577
2578        backend
2579            .send_key(KeyEvent {
2580                code: KeyCode::Esc,
2581                mods: Default::default(),
2582            })
2583            .expect("escape hides committed search");
2584        let hidden = backend.capture_frame();
2585        let hidden_rows = hidden.to_fixed_grid_lines();
2586        assert!(
2587            !hidden_rows[usize::from(search_y)].starts_with(""),
2588            "committed search row stayed visible: {:?}",
2589            hidden_rows[usize::from(search_y)]
2590        );
2591        assert!(!hidden_rows[0].contains("[2/2]"));
2592
2593        backend
2594            .send_key(char_key('n'))
2595            .expect("repeat search succeeds");
2596        let repeated = backend.capture_frame();
2597        let repeated_rows = repeated.to_fixed_grid_lines();
2598        assert!(!repeated_rows[usize::from(search_y)].starts_with(""));
2599        assert!(repeated_rows[0].contains("alpha beta alpha [1/2]"));
2600    }
2601
2602    #[test]
2603    fn capture_frame_highlights_backward_vim_search_target_match() {
2604        let mut backend = TestBackend::new(VimBackwardSearchRenderRoot);
2605        let root = backend.core.tree.root;
2606        backend.set_focused(root);
2607
2608        backend
2609            .send_key(char_key('?'))
2610            .expect("search key succeeds");
2611        backend.send_key(char_key('a')).expect("query key succeeds");
2612        backend.send_key(char_key('l')).expect("query key succeeds");
2613
2614        let pending = backend.capture_frame();
2615        let pending_rows = pending.to_fixed_grid_lines();
2616        let search_y = pending.height.saturating_sub(1);
2617
2618        assert!(
2619            pending_rows[usize::from(search_y)].starts_with("  al"),
2620            "search row was {:?}",
2621            pending_rows[usize::from(search_y)]
2622        );
2623        assert!(pending_rows[usize::from(search_y)].ends_with("[1/2]"));
2624        assert_eq!(pending.cell(3, 0).bg, Color::Blue);
2625        assert_eq!(pending.cell(4, 0).bg, Color::Blue);
2626        assert_ne!(pending.cell(14, 0).bg, Color::Blue);
2627
2628        backend
2629            .send_key(KeyEvent {
2630                code: KeyCode::Enter,
2631                mods: Default::default(),
2632            })
2633            .expect("search submit succeeds");
2634        backend
2635            .send_key(char_key('n'))
2636            .expect("next search repeat succeeds");
2637        let next = backend.capture_frame();
2638        let next_rows = next.to_fixed_grid_lines();
2639        assert!(next_rows[0].contains("alpha beta alpha [2/2]"));
2640        assert_ne!(next.cell(3, 0).bg, Color::Blue);
2641        assert_eq!(next.cell(14, 0).bg, Color::Blue);
2642
2643        backend
2644            .send_key(char_key('N'))
2645            .expect("previous search repeat succeeds");
2646        let previous = backend.capture_frame();
2647        let previous_rows = previous.to_fixed_grid_lines();
2648        assert!(previous_rows[0].contains("alpha beta alpha [1/2]"));
2649        assert_eq!(previous.cell(3, 0).bg, Color::Blue);
2650        assert_ne!(previous.cell(14, 0).bg, Color::Blue);
2651    }
2652
2653    #[test]
2654    fn capture_frame_highlights_vim_search_match_across_wrap_boundary() {
2655        let mut backend = TestBackend::new(VimWrappedSearchRenderRoot);
2656        backend.set_viewport(Rect {
2657            x: 0,
2658            y: 0,
2659            w: 6,
2660            h: 4,
2661        });
2662        backend.render();
2663        let root = backend.core.tree.root;
2664        backend.set_focused(root);
2665
2666        backend
2667            .send_key(char_key('/'))
2668            .expect("search key succeeds");
2669        for ch in ['a', 'b', 'c', 'd'] {
2670            backend.send_key(char_key(ch)).expect("query key succeeds");
2671        }
2672
2673        let pending = backend.capture_frame();
2674        let pending_rows = pending.to_fixed_grid_lines();
2675        let search_y = pending.height.saturating_sub(1);
2676
2677        assert!(pending_rows[usize::from(search_y)].starts_with(""));
2678        for (x, y) in [(2, 0), (3, 0), (4, 0), (0, 1)] {
2679            assert!(
2680                pending.cell(x, y).modifiers.underline,
2681                "missing underline at ({x}, {y})"
2682            );
2683            assert_eq!(
2684                pending.cell(x, y).bg,
2685                Color::Blue,
2686                "missing current match at ({x}, {y})"
2687            );
2688        }
2689    }
2690
2691    #[test]
2692    fn capture_frame_highlights_vim_current_line_full_width() {
2693        let mut backend = TestBackend::new(VimCurrentLineRenderRoot);
2694        let root = backend.core.tree.root;
2695        backend.set_focused(root);
2696
2697        let frame = backend.capture_frame();
2698
2699        assert_ne!(frame.cell(0, 0).bg, Color::Blue);
2700        assert_eq!(frame.cell(0, 1).bg, Color::Blue);
2701        assert_eq!(frame.cell(3, 1).bg, Color::Blue);
2702        assert_eq!(frame.cell(0, 1).fg, Color::Yellow);
2703        assert_ne!(frame.cell(3, 1).fg, Color::Yellow);
2704
2705        backend
2706            .send_key(char_key('v'))
2707            .expect("visual mode key succeeds");
2708        let visual_frame = backend.capture_frame();
2709        assert_ne!(visual_frame.cell(0, 1).bg, Color::Blue);
2710        assert_ne!(visual_frame.cell(3, 1).bg, Color::Blue);
2711    }
2712
2713    #[test]
2714    fn capture_frame_shows_vim_search_bar_when_content_width_collapses() {
2715        let mut backend = TestBackend::new(VimCollapsedContentSearchRoot);
2716        let root = backend.core.tree.root;
2717        backend.set_focused(root);
2718
2719        backend
2720            .send_key(char_key('/'))
2721            .expect("search key succeeds");
2722        backend.send_key(char_key('a')).expect("query key succeeds");
2723
2724        let pending = backend.capture_frame();
2725        let rows = pending.to_fixed_grid_lines();
2726        let search_y = pending.height.saturating_sub(1);
2727
2728        assert!(
2729            rows[usize::from(search_y)].starts_with("  a"),
2730            "search row was {:?}",
2731            rows[usize::from(search_y)]
2732        );
2733        assert_eq!(
2734            pending.cursor.as_ref().map(|cursor| cursor.y),
2735            Some(search_y)
2736        );
2737    }
2738
2739    fn page_down() -> KeyEvent {
2740        KeyEvent {
2741            code: KeyCode::PageDown,
2742            mods: Default::default(),
2743        }
2744    }
2745
2746    fn scroll_offset_by_key<C: Component>(backend: &TestBackend<C>, key: &str) -> usize {
2747        let target = Key::from(key.to_string());
2748        backend
2749            .core
2750            .tree
2751            .iter()
2752            .find(|node| node.key.as_ref() == Some(&target))
2753            .and_then(|node| match &node.kind {
2754                NodeKind::ScrollView(scroll) => Some(scroll.offset),
2755                _ => None,
2756            })
2757            .unwrap_or_else(|| panic!("scroll view with key {key:?} not found"))
2758    }
2759
2760    fn rect_by_key<C: Component>(backend: &TestBackend<C>, key: &str) -> crate::style::Rect {
2761        let target = Key::from(key.to_string());
2762        backend
2763            .core
2764            .tree
2765            .iter()
2766            .find(|node| node.key.as_ref() == Some(&target))
2767            .map(|node| node.rect)
2768            .unwrap_or_else(|| panic!("node with key {key:?} not found"))
2769    }
2770
2771    struct AmbientPageScrollRoot;
2772
2773    struct ModalSearchPaletteRoot;
2774
2775    impl Component for ModalSearchPaletteRoot {
2776        type Message = ();
2777        type Properties = ();
2778        type State = ();
2779
2780        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
2781
2782        fn view(&self, _ctx: &Context<Self>) -> Element {
2783            let palette: Element = SearchPalette::<usize>::new()
2784                .items((0..20).map(|i| SearchItem::new(format!("item-{i}"), i)))
2785                .height(Length::Auto)
2786                .list_padding((0, 0, 1, 0))
2787                .input_divider(false)
2788                .into();
2789
2790            let content: Element = VStack::new()
2791                .height(Length::Auto)
2792                .gap(1)
2793                .child(
2794                    HStack::new()
2795                        .height(Length::Px(1))
2796                        .child(Text::new("header")),
2797                )
2798                .child(palette.key("modal-palette"))
2799                .child(
2800                    HStack::new()
2801                        .height(Length::Px(1))
2802                        .child(Text::new("hint"))
2803                        .key("modal-hints"),
2804                )
2805                .into();
2806
2807            Element::from(
2808                Modal::new().border(false).width(Length::Px(30)).child(
2809                    Frame::new()
2810                        .border(false)
2811                        .child(content)
2812                        .key("modal-inner-frame"),
2813                ),
2814            )
2815            .max_height(Length::Percent(50))
2816        }
2817
2818        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
2819            Update::none()
2820        }
2821    }
2822
2823    impl Component for AmbientPageScrollRoot {
2824        type Message = ();
2825        type Properties = ();
2826        type State = ();
2827
2828        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
2829
2830        fn view(&self, _ctx: &Context<Self>) -> Element {
2831            ScrollView::new()
2832                .scroll_keys(ScrollKeymap::DEFAULT)
2833                .focusable(false)
2834                .ambient_page_scroll(true)
2835                .children((0..40).map(|i| {
2836                    Text::new(format!("row {i}"))
2837                        .height(Length::Px(1))
2838                        .key(format!("ambient-row-{i}"))
2839                }))
2840                .key("ambient-scroll")
2841        }
2842
2843        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
2844            Update::none()
2845        }
2846    }
2847
2848    struct AmbientPageScrollHandledByOnKey;
2849
2850    impl Component for AmbientPageScrollHandledByOnKey {
2851        type Message = ();
2852        type Properties = ();
2853        type State = ();
2854
2855        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
2856
2857        fn on_key(&mut self, key: KeyEvent, _ctx: &mut Context<Self>) -> KeyUpdate {
2858            if matches!(key.code, KeyCode::PageDown) {
2859                KeyUpdate::handled(Update::none())
2860            } else {
2861                KeyUpdate::unhandled(Update::none())
2862            }
2863        }
2864
2865        fn view(&self, _ctx: &Context<Self>) -> Element {
2866            ScrollView::new()
2867                .scroll_keys(ScrollKeymap::DEFAULT)
2868                .focusable(false)
2869                .ambient_page_scroll(true)
2870                .children((0..40).map(|i| {
2871                    Text::new(format!("row {i}"))
2872                        .height(Length::Px(1))
2873                        .key(format!("handled-row-{i}"))
2874                }))
2875                .key("handled-scroll")
2876        }
2877
2878        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
2879            Update::none()
2880        }
2881    }
2882
2883    struct AmbiguousAmbientPageScrollRoot;
2884
2885    impl Component for AmbiguousAmbientPageScrollRoot {
2886        type Message = ();
2887        type Properties = ();
2888        type State = ();
2889
2890        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
2891
2892        fn view(&self, _ctx: &Context<Self>) -> Element {
2893            VStack::new()
2894                .child(
2895                    ScrollView::new()
2896                        .scroll_keys(ScrollKeymap::DEFAULT)
2897                        .focusable(false)
2898                        .ambient_page_scroll(true)
2899                        .children((0..20).map(|i| {
2900                            Text::new(format!("left {i}"))
2901                                .height(Length::Px(1))
2902                                .key(format!("left-row-{i}"))
2903                        }))
2904                        .key("ambient-left"),
2905                )
2906                .child(
2907                    ScrollView::new()
2908                        .scroll_keys(ScrollKeymap::DEFAULT)
2909                        .focusable(false)
2910                        .ambient_page_scroll(true)
2911                        .children((0..20).map(|i| {
2912                            Text::new(format!("right {i}"))
2913                                .height(Length::Px(1))
2914                                .key(format!("right-row-{i}"))
2915                        }))
2916                        .key("ambient-right"),
2917                )
2918                .into()
2919        }
2920
2921        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
2922            Update::none()
2923        }
2924    }
2925
2926    #[test]
2927    fn ambient_page_scroll_handles_page_down_without_focus() {
2928        let mut backend = TestBackend::new(AmbientPageScrollRoot);
2929        backend.focused = None;
2930        backend.focused_key = None;
2931
2932        let handled = backend
2933            .send_key(page_down())
2934            .expect("send_key should succeed");
2935
2936        assert!(handled);
2937        assert!(scroll_offset_by_key(&backend, "ambient-scroll") > 0);
2938    }
2939
2940    #[test]
2941    fn ambient_page_scroll_runs_after_component_on_key_bubbling() {
2942        let mut backend = TestBackend::new(AmbientPageScrollHandledByOnKey);
2943        backend.focused = None;
2944        backend.focused_key = None;
2945
2946        let handled = backend
2947            .send_key(page_down())
2948            .expect("send_key should succeed");
2949
2950        assert!(handled);
2951        assert_eq!(scroll_offset_by_key(&backend, "handled-scroll"), 0);
2952    }
2953
2954    #[test]
2955    fn modal_search_palette_keeps_hints_visible_under_cap() {
2956        let mut backend = TestBackend::new(ModalSearchPaletteRoot);
2957        backend.set_viewport(Rect {
2958            x: 0,
2959            y: 0,
2960            w: 40,
2961            h: 20,
2962        });
2963        backend.render();
2964
2965        let palette = rect_by_key(&backend, "modal-palette");
2966        let hints = rect_by_key(&backend, "modal-hints");
2967
2968        assert!(palette.h > 0, "palette should retain visible height");
2969        assert_eq!(hints.h, 1, "hints row should retain its one-line height");
2970        assert!(
2971            hints.y.saturating_add(hints.h as i16) <= 15,
2972            "hints row should remain inside the capped modal bounds"
2973        );
2974    }
2975
2976    enum EmptyModalMsg {
2977        Close,
2978    }
2979
2980    struct EmptyModalFocusRoot {
2981        show_modal: bool,
2982        changes: Rc<RefCell<Vec<String>>>,
2983    }
2984
2985    impl Component for EmptyModalFocusRoot {
2986        type Message = EmptyModalMsg;
2987        type Properties = ();
2988        type State = ();
2989
2990        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
2991
2992        fn view(&self, ctx: &Context<Self>) -> Element {
2993            let changes = Rc::clone(&self.changes);
2994            let editor = TextArea::new("")
2995                .on_change(Callback::new(move |event: TextAreaEvent| {
2996                    changes.borrow_mut().push(event.value.to_string());
2997                }))
2998                .key("under-editor");
2999
3000            let mut root = VStack::new()
3001                .child(Button::new("before").key("before-button"))
3002                .child(editor);
3003            if self.show_modal {
3004                root = root.child(
3005                    Modal::new()
3006                        .border(false)
3007                        .padding(0)
3008                        .child(Text::new("modal"))
3009                        .on_close(ctx.link().callback(|_| EmptyModalMsg::Close)),
3010                );
3011            }
3012            root.into()
3013        }
3014
3015        fn update(&mut self, msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3016            match msg {
3017                EmptyModalMsg::Close => self.show_modal = false,
3018            }
3019            Update::full()
3020        }
3021    }
3022
3023    fn char_key(ch: char) -> KeyEvent {
3024        KeyEvent {
3025            code: KeyCode::Char(ch),
3026            mods: Default::default(),
3027        }
3028    }
3029
3030    #[test]
3031    fn empty_modal_suspends_underlying_text_area_focus() {
3032        let changes = Rc::new(RefCell::new(Vec::new()));
3033        let mut backend = TestBackend::new(EmptyModalFocusRoot {
3034            show_modal: false,
3035            changes: Rc::clone(&changes),
3036        });
3037
3038        let editor_key = Key::from("under-editor");
3039        let editor_id = backend
3040            .core
3041            .tree
3042            .iter()
3043            .find(|node| node.key.as_ref() == Some(&editor_key))
3044            .map(|node| node.id)
3045            .expect("editor should be mounted");
3046        backend.set_focused(editor_id);
3047        backend.component_mut().show_modal = true;
3048        backend.render();
3049
3050        assert!(backend.focused().is_none());
3051
3052        let handled = backend
3053            .send_key(char_key('x'))
3054            .expect("send_key should succeed");
3055        assert!(handled, "empty modal should swallow text input");
3056        assert!(changes.borrow().is_empty());
3057
3058        let dismissed = backend
3059            .send_key(KeyEvent {
3060                code: KeyCode::Esc,
3061                mods: Default::default(),
3062            })
3063            .expect("dismiss should succeed");
3064        assert!(dismissed);
3065        assert!(backend.focused().is_some());
3066        assert_eq!(backend.focused_key, Some(editor_key));
3067
3068        backend
3069            .send_key(char_key('x'))
3070            .expect("text input should succeed after dismiss");
3071        assert_eq!(changes.borrow().as_slice(), ["x"]);
3072    }
3073
3074    struct BackdropHoverRoot {
3075        show_modal: bool,
3076    }
3077
3078    struct HoverCaptureButtonRoot;
3079
3080    impl Component for BackdropHoverRoot {
3081        type Message = ();
3082        type Properties = ();
3083        type State = ();
3084
3085        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3086
3087        fn on_key(&mut self, key: KeyEvent, _ctx: &mut Context<Self>) -> KeyUpdate {
3088            if matches!(key.code, KeyCode::Char('m')) {
3089                self.show_modal = true;
3090                KeyUpdate::handled(Update::full())
3091            } else {
3092                KeyUpdate::unhandled(Update::none())
3093            }
3094        }
3095
3096        fn view(&self, _ctx: &Context<Self>) -> Element {
3097            let hover_target = MouseRegion::new()
3098                .hover_style(Style::new().bg(Color::Blue))
3099                .child(Text::new("under"));
3100
3101            let mut root = VStack::new().child(hover_target);
3102            if self.show_modal {
3103                root = root.child(
3104                    Modal::new()
3105                        .border(false)
3106                        .padding(0)
3107                        .child(Text::new("modal")),
3108                );
3109            }
3110            root.into()
3111        }
3112
3113        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3114            Update::none()
3115        }
3116    }
3117
3118    impl Component for HoverCaptureButtonRoot {
3119        type Message = ();
3120        type Properties = ();
3121        type State = ();
3122
3123        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3124
3125        fn view(&self, _ctx: &Context<Self>) -> Element {
3126            let panel_bg = Color::Rgb(0x15, 0x15, 0x19);
3127            let alpha_fg = Paint::rgba(0xff, 0xff, 0xff, 0x40);
3128
3129            Button::filled("Hover")
3130                .style(Style::new().bg(panel_bg).fg(Color::White))
3131                .focus_style(Style::default())
3132                .hover_style(
3133                    Style::new()
3134                        .fg(alpha_fg)
3135                        .contrast_policy(ContrastPolicy::Off),
3136                )
3137                .into()
3138        }
3139
3140        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3141            Update::none()
3142        }
3143    }
3144
3145    #[test]
3146    fn modal_backdrop_clears_underlying_hover() {
3147        let mut backend = TestBackend::new(BackdropHoverRoot { show_modal: false });
3148
3149        backend
3150            .send_mouse(MouseEvent {
3151                x: 0,
3152                y: 0,
3153                kind: MouseKind::Moved,
3154                mods: Default::default(),
3155            })
3156            .expect("mouse move should succeed");
3157        assert!(backend.mouse.hovered.is_some());
3158
3159        backend
3160            .send_key(char_key('m'))
3161            .expect("opening modal should succeed");
3162        assert!(backend.core.tree.top_capturing_overlay().is_some());
3163        assert!(backend.mouse.hovered.is_none());
3164
3165        backend
3166            .send_mouse(MouseEvent {
3167                x: 1,
3168                y: 0,
3169                kind: MouseKind::Moved,
3170                mods: Default::default(),
3171            })
3172            .expect("mouse move should succeed");
3173        assert!(backend.mouse.hovered.is_none());
3174    }
3175
3176    #[test]
3177    fn capture_frame_preserves_mouse_hover_state() {
3178        let mut backend = TestBackend::new(HoverCaptureButtonRoot);
3179
3180        backend
3181            .send_mouse(MouseEvent {
3182                x: 1,
3183                y: 0,
3184                kind: MouseKind::Moved,
3185                mods: Default::default(),
3186            })
3187            .expect("mouse move should succeed");
3188
3189        assert!(backend.mouse.hovered.is_some());
3190
3191        let frame = backend.capture_frame();
3192        let cell = first_cell_with_symbol(&frame, "H");
3193
3194        assert_eq!(cell.fg, Color::Rgb(0x50, 0x50, 0x53));
3195        assert_eq!(cell.bg, Color::Rgb(0x15, 0x15, 0x19));
3196    }
3197
3198    #[test]
3199    fn ambient_page_scroll_requires_a_unique_target() {
3200        let mut backend = TestBackend::new(AmbiguousAmbientPageScrollRoot);
3201        backend.focused = None;
3202        backend.focused_key = None;
3203
3204        let handled = backend
3205            .send_key(page_down())
3206            .expect("send_key should succeed");
3207
3208        assert!(!handled);
3209        assert_eq!(scroll_offset_by_key(&backend, "ambient-left"), 0);
3210        assert_eq!(scroll_offset_by_key(&backend, "ambient-right"), 0);
3211    }
3212
3213    #[derive(Clone, Debug, PartialEq)]
3214    struct BrandTheme {
3215        badge: Style,
3216    }
3217
3218    struct ThemeLeaf;
3219
3220    impl Component for ThemeLeaf {
3221        type Message = ();
3222        type Properties = ();
3223        type State = ();
3224
3225        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3226
3227        fn view(&self, ctx: &Context<Self>) -> Element {
3228            let label = if ctx.theme_extension::<BrandTheme>().is_some() {
3229                "brand"
3230            } else {
3231                "plain"
3232            };
3233            Text::new(label).into()
3234        }
3235
3236        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3237            Update::none()
3238        }
3239    }
3240
3241    struct ThemeParent;
3242
3243    impl Component for ThemeParent {
3244        type Message = ();
3245        type Properties = ();
3246        type State = ();
3247
3248        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3249
3250        fn view(&self, _ctx: &Context<Self>) -> Element {
3251            ThemeProvider::new(Theme::default().with_extension(BrandTheme {
3252                badge: Style::new().fg(Color::rgb(12, 34, 56)),
3253            }))
3254            .child(crate::child::<ThemeLeaf, _>(|| ThemeLeaf, ()))
3255            .into()
3256        }
3257
3258        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3259            Update::none()
3260        }
3261    }
3262
3263    #[test]
3264    fn nested_components_can_read_theme_extensions() {
3265        let backend = TestBackend::new(ThemeParent);
3266
3267        fn text_content(element: &Element) -> Option<String> {
3268            match &element.kind {
3269                ElementKind::Text(text) => Some(text.plain_content()),
3270                ElementKind::Group(group) => text_content(&group.child),
3271                _ => None,
3272            }
3273        }
3274
3275        assert_eq!(text_content(backend.element()).as_deref(), Some("brand"));
3276    }
3277
3278    struct AsyncCounter;
3279
3280    #[derive(Clone, Copy, Debug)]
3281    enum AsyncMsg {
3282        Start,
3283        Done(u32),
3284    }
3285
3286    impl Component for AsyncCounter {
3287        type Message = AsyncMsg;
3288        type Properties = ();
3289        type State = u32;
3290
3291        fn create_state(&self, _props: &Self::Properties) -> Self::State {
3292            0
3293        }
3294
3295        fn view(&self, ctx: &Context<Self>) -> Element {
3296            Text::new(format!("{}", ctx.state)).into()
3297        }
3298
3299        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
3300            match msg {
3301                AsyncMsg::Start => Update {
3302                    dirty: false,
3303                    level: crate::core::component::UpdateLevel::None,
3304                    command: Some(ctx.link().command(|link| {
3305                        link.send(AsyncMsg::Done(42));
3306                    })),
3307                },
3308                AsyncMsg::Done(v) => {
3309                    ctx.state = v;
3310                    Update::full()
3311                }
3312            }
3313        }
3314    }
3315
3316    #[test]
3317    fn commands_can_send_messages_back() {
3318        let mut backend = TestBackend::new(AsyncCounter);
3319        backend
3320            .dispatch(AsyncMsg::Start)
3321            .expect("start dispatch should succeed");
3322
3323        for _ in 0..100 {
3324            backend.pump().expect("pump should succeed");
3325            if *backend.state() == 42 {
3326                return;
3327            }
3328            std::thread::sleep(std::time::Duration::from_millis(1));
3329        }
3330
3331        panic!("expected background command to update state");
3332    }
3333
3334    struct ThemedText;
3335
3336    impl Component for ThemedText {
3337        type Message = ();
3338        type Properties = ();
3339        type State = ();
3340
3341        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3342
3343        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3344            Update::none()
3345        }
3346
3347        fn view(&self, _ctx: &Context<Self>) -> Element {
3348            Text::new("themed").into()
3349        }
3350    }
3351
3352    #[test]
3353    fn default_theme_is_applied_to_root_tree() {
3354        let backend = TestBackend::new(ThemedText);
3355        let theme = Theme::default();
3356
3357        match &backend.element().kind {
3358            ElementKind::Text(text) => {
3359                assert_eq!(text.style.fg, None);
3360                assert_eq!(resolve_base_style(&theme, text.style).fg, theme.primary.fg);
3361                assert_eq!(text.style.bg, None);
3362            }
3363            _ => panic!("expected root text element"),
3364        }
3365    }
3366
3367    struct ThemedList;
3368
3369    impl Component for ThemedList {
3370        type Message = ();
3371        type Properties = ();
3372        type State = ();
3373
3374        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3375
3376        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3377            Update::none()
3378        }
3379
3380        fn view(&self, _ctx: &Context<Self>) -> Element {
3381            List::new()
3382                .items([ListItem::new("one"), ListItem::new("two")])
3383                .into()
3384        }
3385    }
3386
3387    #[test]
3388    fn default_theme_disables_generic_hover_styles() {
3389        let backend = TestBackend::new(ThemedList);
3390        let theme = Theme::default();
3391
3392        assert!(theme.hover.is_empty());
3393
3394        match &backend.element().kind {
3395            ElementKind::List(list) => {
3396                assert!(resolve_slot(&theme, ThemeRole::Hover, &list.hover_style).is_empty());
3397                assert!(
3398                    resolve_slot(&theme, ThemeRole::ItemHover, &list.item_hover_style).is_empty()
3399                );
3400            }
3401            _ => panic!("expected root list element"),
3402        }
3403    }
3404
3405    struct ThemedButton;
3406
3407    impl Component for ThemedButton {
3408        type Message = ();
3409        type Properties = ();
3410        type State = ();
3411
3412        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3413
3414        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3415            Update::none()
3416        }
3417
3418        fn view(&self, _ctx: &Context<Self>) -> Element {
3419            Button::new("Run").into()
3420        }
3421    }
3422
3423    #[test]
3424    fn buttons_keep_default_hover_feedback() {
3425        let backend = TestBackend::new(ThemedButton);
3426        let theme = Theme::default();
3427
3428        match &backend.element().kind {
3429            ElementKind::Button(button) => {
3430                let hover = resolve_slot(&theme, ThemeRole::Accent, &button.hover_style);
3431                assert_eq!(hover.fg, theme.accent.fg.or(theme.primary.fg));
3432                assert_eq!(hover.bg, None);
3433            }
3434            _ => panic!("expected root button element"),
3435        }
3436    }
3437
3438    struct OptInListHover;
3439
3440    impl Component for OptInListHover {
3441        type Message = ();
3442        type Properties = ();
3443        type State = ();
3444
3445        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3446
3447        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3448            Update::none()
3449        }
3450
3451        fn view(&self, _ctx: &Context<Self>) -> Element {
3452            ThemeProvider::new(Theme::default().hover(Style::new().bg(Color::Rgb(12, 34, 56))))
3453                .child(List::new().items([ListItem::new("one"), ListItem::new("two")]))
3454                .into()
3455        }
3456    }
3457
3458    #[test]
3459    fn generic_hover_can_be_opted_back_in() {
3460        let backend = TestBackend::new(OptInListHover);
3461
3462        match &backend.element().kind {
3463            ElementKind::List(list) => {
3464                let theme = Theme::default().hover(Style::new().bg(Color::Rgb(12, 34, 56)));
3465                assert_eq!(
3466                    resolve_slot(&theme, ThemeRole::Hover, &list.hover_style).bg,
3467                    Some(Color::Rgb(12, 34, 56).into())
3468                );
3469                assert_eq!(
3470                    resolve_slot(&theme, ThemeRole::ItemHover, &list.item_hover_style).bg,
3471                    Some(Color::Rgb(12, 34, 56).into())
3472                );
3473            }
3474            _ => panic!("expected themed list element"),
3475        }
3476    }
3477
3478    struct AccentAndSelectionAreIndependent;
3479
3480    impl Component for AccentAndSelectionAreIndependent {
3481        type Message = ();
3482        type Properties = ();
3483        type State = ();
3484
3485        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3486
3487        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3488            Update::none()
3489        }
3490
3491        fn view(&self, _ctx: &Context<Self>) -> Element {
3492            ThemeProvider::new(
3493                Theme::default()
3494                    .accent(Style::new().fg(Color::Rgb(200, 40, 40)))
3495                    .selection(Style::new().bg(Color::Rgb(20, 40, 80))),
3496            )
3497            .child(Button::new("Run"))
3498            .into()
3499        }
3500    }
3501
3502    #[test]
3503    fn button_hover_uses_accent_not_selection_background() {
3504        let backend = TestBackend::new(AccentAndSelectionAreIndependent);
3505
3506        match &backend.element().kind {
3507            ElementKind::Button(button) => {
3508                let theme = Theme::default()
3509                    .accent(Style::new().fg(Color::Rgb(200, 40, 40)))
3510                    .selection(Style::new().bg(Color::Rgb(20, 40, 80)));
3511                let hover = resolve_slot(&theme, ThemeRole::Accent, &button.hover_style);
3512                assert_eq!(hover.fg, Some(Color::Rgb(200, 40, 40).into()));
3513                assert_eq!(hover.bg, None);
3514            }
3515            _ => panic!("expected themed button element"),
3516        }
3517    }
3518
3519    struct ThemedPlainInput;
3520
3521    impl Component for ThemedPlainInput {
3522        type Message = ();
3523        type Properties = ();
3524        type State = ();
3525
3526        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3527
3528        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3529            Update::none()
3530        }
3531
3532        fn view(&self, _ctx: &Context<Self>) -> Element {
3533            ThemeProvider::new(Theme::default())
3534                .child(Input::new("plain input"))
3535                .into()
3536        }
3537    }
3538
3539    #[test]
3540    fn input_theme_sets_focus_chrome_without_forcing_focus_content() {
3541        let backend = TestBackend::new(ThemedPlainInput);
3542
3543        match &backend.element().kind {
3544            ElementKind::Input(input) => {
3545                assert_eq!(
3546                    resolve_slot(&Theme::default(), ThemeRole::Focus, &input.focus_style).fg,
3547                    Theme::default().focus.fg
3548                );
3549                assert!(input.focus_content_style.is_empty());
3550                assert_eq!(
3551                    resolve_base_style(&Theme::default(), input.style).fg,
3552                    Theme::default().primary.fg
3553                );
3554            }
3555            _ => panic!("expected themed input element"),
3556        }
3557    }
3558
3559    struct ThemeDefinedInputFocus;
3560
3561    impl Component for ThemeDefinedInputFocus {
3562        type Message = ();
3563        type Properties = ();
3564        type State = ();
3565
3566        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3567
3568        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3569            Update::none()
3570        }
3571
3572        fn view(&self, _ctx: &Context<Self>) -> Element {
3573            ThemeProvider::new(Theme::default().input(InputPalette {
3574                focus: Style::new().fg(Color::Rgb(200, 120, 40)).bold(),
3575            }))
3576            .child(Input::new("themed input"))
3577            .into()
3578        }
3579    }
3580
3581    #[test]
3582    fn theme_can_define_input_focus_content_style_explicitly() {
3583        let backend = TestBackend::new(ThemeDefinedInputFocus);
3584
3585        match &backend.element().kind {
3586            ElementKind::Input(input) => {
3587                let focus_content = crate::style::resolve::resolve_style_defaults(
3588                    input.focus_content_style,
3589                    Theme::default()
3590                        .input(InputPalette {
3591                            focus: Style::new().fg(Color::Rgb(200, 120, 40)).bold(),
3592                        })
3593                        .input
3594                        .focus,
3595                );
3596                assert_eq!(focus_content.fg, Some(Color::Rgb(200, 120, 40).into()));
3597                assert_eq!(focus_content.bold, Some(true));
3598                assert_eq!(
3599                    resolve_slot(&Theme::default(), ThemeRole::Focus, &input.focus_style).fg,
3600                    Theme::default().focus.fg
3601                );
3602            }
3603            _ => panic!("expected themed input element"),
3604        }
3605    }
3606
3607    struct ThemedPlainTextArea;
3608
3609    impl Component for ThemedPlainTextArea {
3610        type Message = ();
3611        type Properties = ();
3612        type State = ();
3613
3614        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3615
3616        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3617            Update::none()
3618        }
3619
3620        fn view(&self, _ctx: &Context<Self>) -> Element {
3621            ThemeProvider::new(Theme::default())
3622                .child(TextArea::new("plain text"))
3623                .into()
3624        }
3625    }
3626
3627    #[test]
3628    fn text_area_theme_sets_focus_chrome_without_forcing_focus_content() {
3629        let backend = TestBackend::new(ThemedPlainTextArea);
3630
3631        match &backend.element().kind {
3632            ElementKind::TextArea(text_area) => {
3633                assert_eq!(
3634                    resolve_slot(&Theme::default(), ThemeRole::Focus, &text_area.focus_style).fg,
3635                    Theme::default().focus.fg
3636                );
3637                assert!(text_area.focus_content_style.is_empty());
3638                assert_eq!(
3639                    resolve_base_style(&Theme::default(), text_area.style).fg,
3640                    Theme::default().primary.fg
3641                );
3642            }
3643            _ => panic!("expected themed text area element"),
3644        }
3645    }
3646
3647    struct ThemedPlainDocumentView;
3648
3649    impl Component for ThemedPlainDocumentView {
3650        type Message = ();
3651        type Properties = ();
3652        type State = ();
3653
3654        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3655
3656        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3657            Update::none()
3658        }
3659
3660        fn view(&self, _ctx: &Context<Self>) -> Element {
3661            ThemeProvider::new(Theme::default())
3662                .child(DocumentView::new("plain document"))
3663                .into()
3664        }
3665    }
3666
3667    #[test]
3668    fn document_view_theme_sets_focus_chrome_without_forcing_focus_content() {
3669        let backend = TestBackend::new(ThemedPlainDocumentView);
3670
3671        match &backend.element().kind {
3672            ElementKind::DocumentView(doc_view) => {
3673                assert_eq!(
3674                    resolve_slot(&Theme::default(), ThemeRole::Focus, &doc_view.focus_style).fg,
3675                    Theme::default().focus.fg
3676                );
3677                assert!(doc_view.focus_content_style.is_empty());
3678                assert_eq!(
3679                    resolve_base_style(&Theme::default(), doc_view.style).fg,
3680                    Theme::default().primary.fg
3681                );
3682            }
3683            _ => panic!("expected themed document view element"),
3684        }
3685    }
3686
3687    struct ThemeDefinedTextSurfacesFocus;
3688
3689    impl Component for ThemeDefinedTextSurfacesFocus {
3690        type Message = ();
3691        type Properties = ();
3692        type State = ();
3693
3694        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3695
3696        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3697            Update::none()
3698        }
3699
3700        fn view(&self, _ctx: &Context<Self>) -> Element {
3701            ThemeProvider::new(
3702                Theme::default()
3703                    .text_area(TextAreaPalette {
3704                        focus: Style::new().fg(Color::Rgb(80, 200, 120)),
3705                    })
3706                    .document_view(DocumentViewPalette {
3707                        focus: Style::new().fg(Color::Rgb(120, 180, 240)).italic(),
3708                    }),
3709            )
3710            .child(
3711                VStack::new()
3712                    .child(TextArea::new("editor"))
3713                    .child(DocumentView::new("document")),
3714            )
3715            .into()
3716        }
3717    }
3718
3719    #[test]
3720    fn theme_can_define_text_surface_focus_content_styles_explicitly() {
3721        let backend = TestBackend::new(ThemeDefinedTextSurfacesFocus);
3722
3723        let ElementKind::VStack(root) = &backend.element().kind else {
3724            panic!("expected stack root");
3725        };
3726
3727        let ElementKind::TextArea(text_area) = &root.children[0].kind else {
3728            panic!("expected text area child");
3729        };
3730        let theme = Theme::default()
3731            .text_area(TextAreaPalette {
3732                focus: Style::new().fg(Color::Rgb(80, 200, 120)),
3733            })
3734            .document_view(DocumentViewPalette {
3735                focus: Style::new().fg(Color::Rgb(120, 180, 240)).italic(),
3736            });
3737        let text_area_focus_content = crate::style::resolve::resolve_style_defaults(
3738            text_area.focus_content_style,
3739            theme.text_area.focus,
3740        );
3741        assert_eq!(
3742            text_area_focus_content.fg,
3743            Some(Color::Rgb(80, 200, 120).into())
3744        );
3745        assert_eq!(
3746            resolve_slot(&Theme::default(), ThemeRole::Focus, &text_area.focus_style).fg,
3747            Theme::default().focus.fg
3748        );
3749
3750        let ElementKind::DocumentView(doc_view) = &root.children[1].kind else {
3751            panic!("expected document view child");
3752        };
3753        let doc_focus_content = crate::style::resolve::resolve_style_defaults(
3754            doc_view.focus_content_style,
3755            theme.document_view.focus,
3756        );
3757        assert_eq!(doc_focus_content.fg, Some(Color::Rgb(120, 180, 240).into()));
3758        assert_eq!(doc_focus_content.italic, Some(true));
3759        assert_eq!(
3760            resolve_slot(&Theme::default(), ThemeRole::Focus, &doc_view.focus_style).fg,
3761            Theme::default().focus.fg
3762        );
3763    }
3764
3765    struct ThemedPlainTabs;
3766
3767    impl Component for ThemedPlainTabs {
3768        type Message = ();
3769        type Properties = ();
3770        type State = ();
3771
3772        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3773
3774        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3775            Update::none()
3776        }
3777
3778        fn view(&self, _ctx: &Context<Self>) -> Element {
3779            ThemeProvider::new(Theme::default())
3780                .child(Tabs::new().tabs(vec![Tab::new("One"), Tab::new("Two")]))
3781                .into()
3782        }
3783    }
3784
3785    #[test]
3786    fn tabs_theme_sets_focus_style_without_forcing_bold() {
3787        let backend = TestBackend::new(ThemedPlainTabs);
3788
3789        match &backend.element().kind {
3790            ElementKind::Tabs(tabs) => {
3791                let focus = resolve_slot(&Theme::default(), ThemeRole::Focus, &tabs.focus_style);
3792                assert_eq!(focus.fg, Theme::default().focus.fg);
3793                assert_eq!(focus.bold, Theme::default().focus.bold);
3794                assert_eq!(
3795                    resolve_slot(&Theme::default(), ThemeRole::Selection, &tabs.active_style).fg,
3796                    Theme::default().selection.fg
3797                );
3798            }
3799            _ => panic!("expected themed tabs element"),
3800        }
3801    }
3802
3803    #[cfg(feature = "syntax-syntect")]
3804    struct ThemedSyntaxTextArea;
3805
3806    #[cfg(feature = "syntax-syntect")]
3807    impl Component for ThemedSyntaxTextArea {
3808        type Message = ();
3809        type Properties = ();
3810        type State = ();
3811
3812        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3813
3814        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3815            Update::none()
3816        }
3817
3818        fn view(&self, _ctx: &Context<Self>) -> Element {
3819            ThemeProvider::new(Theme::default())
3820                .child(
3821                    TextArea::new("let n = 42;")
3822                        .language("rust")
3823                        .theme("One Dark (Atom)")
3824                        .color_strategy(
3825                            SyntectStrategy::default().default_theme("One Dark (Atom)"),
3826                        ),
3827                )
3828                .into()
3829        }
3830    }
3831
3832    #[cfg(feature = "syntax-syntect")]
3833    #[test]
3834    fn theme_provider_sets_syntect_palette_from_theme() {
3835        let backend = TestBackend::new(ThemedSyntaxTextArea);
3836        let theme = Theme::default();
3837
3838        let ElementKind::TextArea(text_area) = &backend.element().kind else {
3839            panic!("expected themed text area element");
3840        };
3841        let strategy = text_area
3842            .color_strategy
3843            .as_ref()
3844            .and_then(|strategy| strategy.as_any().downcast_ref::<SyntectStrategy>())
3845            .expect("expected syntect strategy");
3846
3847        assert_eq!(strategy.effective_syntax_palette(), Some(theme.syntax));
3848    }
3849
3850    #[cfg(feature = "diff-view")]
3851    struct ThemedUnifiedDiffTextArea;
3852
3853    #[cfg(feature = "diff-view")]
3854    impl Component for ThemedUnifiedDiffTextArea {
3855        type Message = ();
3856        type Properties = ();
3857        type State = ();
3858
3859        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3860
3861        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3862            Update::none()
3863        }
3864
3865        fn view(&self, _ctx: &Context<Self>) -> Element {
3866            let mut diff = Theme::default().diff;
3867            diff.added_marker = Style::new().fg(Color::Rgb(1, 200, 120));
3868
3869            ThemeProvider::new(Theme::default().diff(diff))
3870                .child(
3871                    DiffView::with_content("alpha\nbeta", "alpha\nbeta\ngamma")
3872                        .backend(DiffViewBackend::TextArea)
3873                        .mode(DiffViewMode::Unified),
3874                )
3875                .into()
3876        }
3877    }
3878
3879    #[cfg(feature = "diff-view")]
3880    #[test]
3881    fn diff_text_area_gutter_uses_theme_diff_palette() {
3882        let backend = TestBackend::new(ThemedUnifiedDiffTextArea);
3883
3884        let ElementKind::Frame(outer) = &backend.element().kind else {
3885            panic!("expected outer frame");
3886        };
3887        let Some(inner_frame) = &outer.child else {
3888            panic!("expected diff inner frame");
3889        };
3890        let ElementKind::Frame(inner) = &inner_frame.kind else {
3891            panic!("expected pane frame");
3892        };
3893        let Some(text_area) = &inner.child else {
3894            panic!("expected text area child");
3895        };
3896        let ElementKind::TextArea(text_area) = &text_area.kind else {
3897            panic!("expected text area element");
3898        };
3899        let gutter = text_area
3900            .gutter_lines
3901            .as_ref()
3902            .expect("expected diff gutter");
3903        let added_fg = gutter
3904            .iter()
3905            .flat_map(|line| line.iter())
3906            .find(|span| span.content.as_ref().contains('+'))
3907            .and_then(|span| span.style.fg);
3908        assert_eq!(added_fg, Some(Color::Rgb(1, 200, 120).into()));
3909    }
3910
3911    #[cfg(feature = "diff-view")]
3912    struct ThemedUnifiedDiffDocumentView;
3913
3914    #[cfg(feature = "diff-view")]
3915    impl Component for ThemedUnifiedDiffDocumentView {
3916        type Message = ();
3917        type Properties = ();
3918        type State = ();
3919
3920        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3921
3922        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3923            Update::none()
3924        }
3925
3926        fn view(&self, _ctx: &Context<Self>) -> Element {
3927            let mut diff = Theme::default().diff;
3928            diff.removed_marker = Style::new().fg(Color::Rgb(220, 60, 60));
3929
3930            ThemeProvider::new(Theme::default().diff(diff))
3931                .child(
3932                    DiffView::with_content("alpha\nbeta\ngamma", "alpha\nbeta")
3933                        .backend(DiffViewBackend::DocumentView)
3934                        .mode(DiffViewMode::Unified),
3935                )
3936                .into()
3937        }
3938    }
3939
3940    #[cfg(feature = "diff-view")]
3941    #[test]
3942    fn diff_document_view_gutter_uses_theme_diff_palette() {
3943        let backend = TestBackend::new(ThemedUnifiedDiffDocumentView);
3944
3945        let ElementKind::Frame(outer) = &backend.element().kind else {
3946            panic!("expected outer frame");
3947        };
3948        let Some(inner_frame) = &outer.child else {
3949            panic!("expected diff inner frame");
3950        };
3951        let ElementKind::Frame(inner) = &inner_frame.kind else {
3952            panic!("expected pane frame");
3953        };
3954        let Some(doc_view) = &inner.child else {
3955            panic!("expected document view child");
3956        };
3957        let ElementKind::DocumentView(doc_view) = &doc_view.kind else {
3958            panic!("expected document view element");
3959        };
3960        let gutter = doc_view
3961            .gutter_lines
3962            .as_ref()
3963            .expect("expected diff gutter");
3964        let removed_fg = gutter
3965            .iter()
3966            .flat_map(|line| line.iter())
3967            .find(|span| span.content.as_ref().contains('-'))
3968            .and_then(|span| span.style.fg);
3969        assert_eq!(removed_fg, Some(Color::Rgb(220, 60, 60).into()));
3970    }
3971
3972    struct ExplicitFrameBackground;
3973
3974    impl Component for ExplicitFrameBackground {
3975        type Message = ();
3976        type Properties = ();
3977        type State = ();
3978
3979        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
3980
3981        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
3982            Update::none()
3983        }
3984
3985        fn view(&self, _ctx: &Context<Self>) -> Element {
3986            Frame::new()
3987                .style(Style::new().bg(Color::Rgb(40, 41, 54)))
3988                .child(VStack::new().child(Text::new("content")))
3989                .into()
3990        }
3991    }
3992
3993    #[test]
3994    fn explicit_frame_bg_is_not_overridden_by_theme_defaults() {
3995        let backend = TestBackend::new(ExplicitFrameBackground);
3996
3997        match &backend.element().kind {
3998            ElementKind::Frame(frame) => {
3999                assert_eq!(frame.props.style.bg, Some(Color::Rgb(40, 41, 54).into()));
4000                assert_eq!(frame.props.inner_style(), None);
4001
4002                let child = frame.child.as_ref().expect("frame should have child");
4003                match &child.kind {
4004                    ElementKind::VStack(stack) => {
4005                        assert_eq!(stack.props.style.bg, None);
4006                    }
4007                    _ => panic!("expected frame child to be a vstack"),
4008                }
4009            }
4010            _ => panic!("expected root frame element"),
4011        }
4012    }
4013
4014    struct FrameTabsWithoutBackground;
4015
4016    impl Component for FrameTabsWithoutBackground {
4017        type Message = ();
4018        type Properties = ();
4019        type State = ();
4020
4021        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4022
4023        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4024            Update::none()
4025        }
4026
4027        fn view(&self, _ctx: &Context<Self>) -> Element {
4028            Frame::new()
4029                .tab_titles(["Files", "Branches"])
4030                .active_tab_style(Style::new().fg(Color::Rgb(125, 207, 255)))
4031                .child(VStack::new().child(Text::new("content")))
4032                .into()
4033        }
4034    }
4035
4036    #[test]
4037    fn frame_active_tab_style_does_not_inherit_theme_primary_background() {
4038        let backend = TestBackend::new(FrameTabsWithoutBackground);
4039
4040        match &backend.element().kind {
4041            ElementKind::Frame(frame) => {
4042                assert_eq!(
4043                    frame.props.active_tab_style.fg,
4044                    Some(Color::Rgb(125, 207, 255).into())
4045                );
4046                assert_eq!(frame.props.active_tab_style.bg, None);
4047            }
4048            _ => panic!("expected root frame element"),
4049        }
4050    }
4051
4052    struct FrameHeaderStylesInheritFrameColor;
4053
4054    impl Component for FrameHeaderStylesInheritFrameColor {
4055        type Message = ();
4056        type Properties = ();
4057        type State = ();
4058
4059        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4060
4061        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4062            Update::none()
4063        }
4064
4065        fn view(&self, _ctx: &Context<Self>) -> Element {
4066            Frame::new()
4067                .style(Style::new().fg(Color::Rgb(90, 91, 92)))
4068                .tab_titles(["Files", "Branches"])
4069                .title_prefix("[2]")
4070                .status("ready")
4071                .child(VStack::new().child(Text::new("content")))
4072                .into()
4073        }
4074    }
4075
4076    #[test]
4077    fn frame_header_styles_receive_theme_colors() {
4078        let backend = TestBackend::new(FrameHeaderStylesInheritFrameColor);
4079        let theme = Theme::default();
4080
4081        match &backend.element().kind {
4082            ElementKind::Frame(frame) => {
4083                // Explicit frame border fg is preserved
4084                assert_eq!(frame.props.style.fg, Some(Color::Rgb(90, 91, 92).into()));
4085                // Sub-styles resolve against the active theme at render time.
4086                assert_eq!(
4087                    resolve_muted_style(&theme, frame.props.inactive_tab_style).fg,
4088                    theme.muted.fg.or(theme.primary.fg)
4089                );
4090                assert_eq!(
4091                    resolve_base_style(&theme, frame.props.title_style).fg,
4092                    theme.primary.fg
4093                );
4094                assert_eq!(
4095                    resolve_muted_style(&theme, frame.props.status_style).fg,
4096                    theme.muted.fg.or(theme.primary.fg)
4097                );
4098            }
4099            _ => panic!("expected root frame element"),
4100        }
4101    }
4102
4103    struct ExplicitSpinnerColor;
4104
4105    impl Component for ExplicitSpinnerColor {
4106        type Message = ();
4107        type Properties = ();
4108        type State = ();
4109
4110        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4111
4112        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4113            Update::none()
4114        }
4115
4116        fn view(&self, _ctx: &Context<Self>) -> Element {
4117            Spinner::new()
4118                .spinner_style(SpinnerStyle::Lightsaber)
4119                .style(Style::new().fg(Color::Rgb(220, 0, 0)))
4120                .into()
4121        }
4122    }
4123
4124    #[test]
4125    fn explicit_widget_color_wins_over_theme() {
4126        let backend = TestBackend::new(ExplicitSpinnerColor);
4127
4128        match &backend.element().kind {
4129            ElementKind::Spinner(spinner) => {
4130                assert_eq!(spinner.style.fg, Some(Color::Rgb(220, 0, 0).into()));
4131            }
4132            _ => panic!("expected root spinner element"),
4133        }
4134    }
4135
4136    struct ThreeDotSpinner;
4137
4138    impl Component for ThreeDotSpinner {
4139        type Message = ();
4140        type Properties = ();
4141        type State = ();
4142
4143        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4144
4145        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4146            Update::none()
4147        }
4148
4149        fn view(&self, _ctx: &Context<Self>) -> Element {
4150            Spinner::new()
4151                .spinner_style(SpinnerStyle::ThreeDot)
4152                .frame(0)
4153                .label("ThreeDot")
4154                .into()
4155        }
4156    }
4157
4158    #[test]
4159    fn multi_glyph_spinner_frame_draws_each_cell() {
4160        let backend = TestBackend::new(ThreeDotSpinner);
4161        let line = backend.capture_frame().to_fixed_grid_lines().remove(0);
4162
4163        assert!(line.starts_with("∙∙∙ ThreeDot"), "captured line: {line:?}");
4164    }
4165
4166    struct StyledStatusBar;
4167
4168    impl Component for StyledStatusBar {
4169        type Message = ();
4170        type Properties = ();
4171        type State = ();
4172
4173        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4174
4175        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4176            Update::none()
4177        }
4178
4179        fn view(&self, _ctx: &Context<Self>) -> Element {
4180            StatusBar::new()
4181                .style(Style::new().fg(Color::Rgb(120, 121, 122)))
4182                .left(Text::new("left"))
4183                .center(Text::new("center"))
4184                .right(Text::new("right"))
4185                .into()
4186        }
4187    }
4188
4189    #[test]
4190    fn status_bar_style_applies_to_unstyled_slot_text() {
4191        let backend = TestBackend::new(StyledStatusBar);
4192
4193        let frame = backend.capture_frame();
4194        assert_eq!(
4195            first_cell_with_symbol(&frame, "l").fg,
4196            Color::Rgb(120, 121, 122)
4197        );
4198    }
4199
4200    struct SlotStyledStatusBar;
4201
4202    impl Component for SlotStyledStatusBar {
4203        type Message = ();
4204        type Properties = ();
4205        type State = ();
4206
4207        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4208
4209        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4210            Update::none()
4211        }
4212
4213        fn view(&self, _ctx: &Context<Self>) -> Element {
4214            StatusBar::new()
4215                .style(Style::new().fg(Color::Rgb(120, 121, 122)))
4216                .left_style(Style::new().fg(Color::Rgb(240, 90, 40)))
4217                .left(Text::new("left"))
4218                .right(Text::new("right"))
4219                .into()
4220        }
4221    }
4222
4223    #[test]
4224    fn status_bar_slot_style_overrides_base_style_for_slot_text() {
4225        let backend = TestBackend::new(SlotStyledStatusBar);
4226
4227        let frame = backend.capture_frame();
4228        assert_eq!(
4229            first_cell_with_symbol(&frame, "l").fg,
4230            Color::Rgb(240, 90, 40)
4231        );
4232    }
4233
4234    struct CompactStatusBar;
4235
4236    impl Component for CompactStatusBar {
4237        type Message = ();
4238        type Properties = ();
4239        type State = ();
4240
4241        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4242
4243        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4244            Update::none()
4245        }
4246
4247        fn view(&self, _ctx: &Context<Self>) -> Element {
4248            StatusBar::new()
4249                .left(Text::new("left"))
4250                .right(Text::new("right"))
4251                .into()
4252        }
4253    }
4254
4255    #[test]
4256    fn status_bar_without_center_uses_two_lane_layout() {
4257        let backend = TestBackend::new(CompactStatusBar);
4258
4259        match &backend.element().kind {
4260            ElementKind::HStack(root) => {
4261                assert_eq!(
4262                    root.children.len(),
4263                    3,
4264                    "expected left, spacer, right children"
4265                );
4266                match &root
4267                    .children
4268                    .get(1)
4269                    .expect("spacer should be inserted between left and right")
4270                    .kind
4271                {
4272                    ElementKind::Spacer(_) => {}
4273                    _ => panic!("expected center child to be spacer in two-lane layout"),
4274                }
4275            }
4276            _ => panic!("expected root hstack"),
4277        }
4278    }
4279
4280    struct ReservedCenterStatusBar;
4281
4282    impl Component for ReservedCenterStatusBar {
4283        type Message = ();
4284        type Properties = ();
4285        type State = ();
4286
4287        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4288
4289        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4290            Update::none()
4291        }
4292
4293        fn view(&self, _ctx: &Context<Self>) -> Element {
4294            StatusBar::new()
4295                .reserve_center_space(true)
4296                .left(Text::new("left"))
4297                .right(Text::new("right"))
4298                .into()
4299        }
4300    }
4301
4302    #[test]
4303    fn status_bar_can_reserve_center_lane_when_empty() {
4304        let mut backend = TestBackend::new(ReservedCenterStatusBar);
4305        backend.set_viewport(Rect {
4306            x: 0,
4307            y: 0,
4308            w: 47,
4309            h: 1,
4310        });
4311        backend.render();
4312
4313        let first_line = backend
4314            .capture_frame()
4315            .to_lines()
4316            .into_iter()
4317            .next()
4318            .expect("captured frame should include first line");
4319        assert!(first_line.contains("left"));
4320        assert!(first_line.contains("right"));
4321    }
4322
4323    struct ContentAwareStatusBar;
4324
4325    impl Component for ContentAwareStatusBar {
4326        type Message = ();
4327        type Properties = ();
4328        type State = ();
4329
4330        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4331
4332        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4333            Update::none()
4334        }
4335
4336        fn view(&self, _ctx: &Context<Self>) -> Element {
4337            StatusBar::new()
4338                .left(Text::new("abcdefghijklmnopqrst"))
4339                .center(Text::new("C"))
4340                .right(Text::new("R"))
4341                .into()
4342        }
4343    }
4344
4345    #[test]
4346    fn status_bar_center_layout_does_not_clip_long_left_section_to_thirds() {
4347        let mut backend = TestBackend::new(ContentAwareStatusBar);
4348        backend.set_viewport(Rect {
4349            x: 0,
4350            y: 0,
4351            w: 47,
4352            h: 1,
4353        });
4354        backend.render();
4355
4356        let frame = backend.capture_frame();
4357        let first_line = frame
4358            .to_lines()
4359            .into_iter()
4360            .next()
4361            .expect("captured frame should include first line");
4362        assert!(
4363            first_line.contains("abcdefghijklmnopqrst"),
4364            "expected full 20-character left section in first line, got {first_line:?}",
4365        );
4366        assert_eq!(
4367            frame.cell(23, 0).symbol,
4368            "C",
4369            "expected center content to be pinned at the mathematical center cell"
4370        );
4371        assert_eq!(
4372            frame.cell(45, 0).symbol,
4373            "R",
4374            "expected right content to stay flush with the right edge"
4375        );
4376    }
4377
4378    struct LoadingCenteredStatusBar;
4379
4380    impl Component for LoadingCenteredStatusBar {
4381        type Message = ();
4382        type Properties = ();
4383        type State = ();
4384
4385        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4386
4387        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4388            Update::none()
4389        }
4390
4391        fn view(&self, _ctx: &Context<Self>) -> Element {
4392            StatusBar::new()
4393                .center(Text::new("C"))
4394                .loading(true)
4395                .loading_label("LOAD")
4396                .into()
4397        }
4398    }
4399
4400    #[test]
4401    fn status_bar_center_layout_keeps_loading_indicator_on_right() {
4402        let mut backend = TestBackend::new(LoadingCenteredStatusBar);
4403        backend.set_viewport(Rect {
4404            x: 0,
4405            y: 0,
4406            w: 47,
4407            h: 1,
4408        });
4409        backend.render();
4410
4411        let frame = backend.capture_frame();
4412        let first_line = frame
4413            .to_lines()
4414            .into_iter()
4415            .next()
4416            .expect("captured frame should include first line");
4417        assert_eq!(
4418            frame.cell(23, 0).symbol,
4419            "C",
4420            "expected center content to remain pinned with loading content present"
4421        );
4422        assert!(
4423            first_line.ends_with("LOAD"),
4424            "expected loading label to be right-aligned, got {first_line:?}",
4425        );
4426    }
4427
4428    struct LongRightCenteredStatusBar;
4429
4430    impl Component for LongRightCenteredStatusBar {
4431        type Message = ();
4432        type Properties = ();
4433        type State = ();
4434
4435        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4436
4437        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4438            Update::none()
4439        }
4440
4441        fn view(&self, _ctx: &Context<Self>) -> Element {
4442            StatusBar::new()
4443                .left(Text::new("[untitled] [text]"))
4444                .center(Text::new("1:1 sel:0"))
4445                .right(Text::new("tabs: 1 | theme: One Dark | Ctrl-P commands"))
4446                .into()
4447        }
4448    }
4449
4450    #[test]
4451    fn status_bar_center_stays_pinned_when_right_content_overflows() {
4452        let mut backend = TestBackend::new(LongRightCenteredStatusBar);
4453        backend.set_viewport(Rect {
4454            x: 0,
4455            y: 0,
4456            w: 47,
4457            h: 1,
4458        });
4459        backend.render();
4460
4461        let frame = backend.capture_frame();
4462        let first_line = frame
4463            .to_lines()
4464            .into_iter()
4465            .next()
4466            .expect("captured frame should include first line");
4467        assert!(
4468            first_line.contains("[untitled] [text]"),
4469            "expected left section to stay visible, got {first_line:?}",
4470        );
4471        assert_eq!(
4472            frame.cell(19, 0).symbol,
4473            "1",
4474            "expected wider center content to start at the mathematical center band"
4475        );
4476        assert_eq!(
4477            frame.cell(28, 0).symbol,
4478            " ",
4479            "expected configured gap between center and overflowing right content"
4480        );
4481    }
4482
4483    struct ReservedContentAwareStatusBar;
4484
4485    impl Component for ReservedContentAwareStatusBar {
4486        type Message = ();
4487        type Properties = ();
4488        type State = ();
4489
4490        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4491
4492        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4493            Update::none()
4494        }
4495
4496        fn view(&self, _ctx: &Context<Self>) -> Element {
4497            StatusBar::new()
4498                .reserve_center_space(true)
4499                .left(Text::new("abcdefghijklmnopqrst"))
4500                .right(Text::new("R"))
4501                .into()
4502        }
4503    }
4504
4505    #[test]
4506    fn status_bar_reserved_center_layout_does_not_clip_long_left_section_to_thirds() {
4507        let mut backend = TestBackend::new(ReservedContentAwareStatusBar);
4508        backend.set_viewport(Rect {
4509            x: 0,
4510            y: 0,
4511            w: 47,
4512            h: 1,
4513        });
4514        backend.render();
4515
4516        let frame = backend.capture_frame();
4517        let first_line = frame
4518            .to_lines()
4519            .into_iter()
4520            .next()
4521            .expect("captured frame should include first line");
4522        assert!(
4523            first_line.contains("abcdefghijklmnopqrst"),
4524            "expected full 20-character left section in first line, got {first_line:?}",
4525        );
4526    }
4527
4528    struct LeftOnlyStatusBar;
4529
4530    impl Component for LeftOnlyStatusBar {
4531        type Message = ();
4532        type Properties = ();
4533        type State = ();
4534
4535        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4536
4537        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4538            Update::none()
4539        }
4540
4541        fn view(&self, _ctx: &Context<Self>) -> Element {
4542            StatusBar::new().left(Text::new("left")).into()
4543        }
4544    }
4545
4546    #[test]
4547    fn status_bar_left_only_omits_right_lane() {
4548        let backend = TestBackend::new(LeftOnlyStatusBar);
4549
4550        match &backend.element().kind {
4551            ElementKind::HStack(root) => {
4552                assert_eq!(root.children.len(), 1, "expected only left section");
4553                match &unwrap_theme_provider(
4554                    root.children.first().expect("left section should exist"),
4555                )
4556                .kind
4557                {
4558                    ElementKind::HStack(_) => {}
4559                    _ => panic!("expected only child to be left section hstack"),
4560                }
4561            }
4562            _ => panic!("expected root hstack"),
4563        }
4564    }
4565
4566    struct RightOnlyStatusBar;
4567
4568    impl Component for RightOnlyStatusBar {
4569        type Message = ();
4570        type Properties = ();
4571        type State = ();
4572
4573        fn create_state(&self, _props: &Self::Properties) -> Self::State {}
4574
4575        fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
4576            Update::none()
4577        }
4578
4579        fn view(&self, _ctx: &Context<Self>) -> Element {
4580            StatusBar::new().right(Text::new("right")).into()
4581        }
4582    }
4583
4584    #[test]
4585    fn status_bar_right_only_omits_left_lane() {
4586        let backend = TestBackend::new(RightOnlyStatusBar);
4587
4588        match &backend.element().kind {
4589            ElementKind::HStack(root) => {
4590                assert_eq!(root.children.len(), 2, "expected spacer and right section");
4591                match &root.children.first().expect("spacer should exist").kind {
4592                    ElementKind::Spacer(_) => {}
4593                    _ => panic!("expected first child to be spacer"),
4594                }
4595                match &unwrap_theme_provider(
4596                    root.children.get(1).expect("right section should exist"),
4597                )
4598                .kind
4599                {
4600                    ElementKind::HStack(_) => {}
4601                    _ => panic!("expected second child to be right section hstack"),
4602                }
4603            }
4604            _ => panic!("expected root hstack"),
4605        }
4606    }
4607
4608    // ── Component lifecycle tests ──────────────────────────────────────
4609
4610    use crate::core::component::Command;
4611
4612    /// Component whose `init()` sends a message back via Link, verifying
4613    /// that init commands are processed during TestBackend construction.
4614    struct InitSender;
4615
4616    #[derive(Clone, Copy, Debug)]
4617    enum InitMsg {
4618        Initialized,
4619    }
4620
4621    impl Component for InitSender {
4622        type Message = InitMsg;
4623        type Properties = ();
4624        type State = bool;
4625
4626        fn create_state(&self, _props: &Self::Properties) -> Self::State {
4627            false
4628        }
4629
4630        fn init(&mut self, _ctx: &mut Context<Self>) -> Option<Command> {
4631            Some(Command::spawn(move |link| {
4632                link.send(InitMsg::Initialized);
4633            }))
4634        }
4635
4636        fn view(&self, ctx: &Context<Self>) -> Element {
4637            Text::new(if ctx.state { "ready" } else { "pending" }).into()
4638        }
4639
4640        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
4641            match msg {
4642                InitMsg::Initialized => {
4643                    ctx.state = true;
4644                    Update::full()
4645                }
4646            }
4647        }
4648    }
4649
4650    #[test]
4651    fn init_command_processed_on_creation() {
4652        let mut backend = TestBackend::new(InitSender);
4653
4654        // The init command spawns a background thread that sends a message.
4655        // Pump with retries to allow the thread to complete.
4656        for _ in 0..100 {
4657            backend.pump().expect("pump should succeed");
4658            if *backend.state() {
4659                return;
4660            }
4661            std::thread::sleep(std::time::Duration::from_millis(1));
4662        }
4663
4664        panic!("expected init command to set state to true");
4665    }
4666
4667    /// Component that tracks key events via `on_key`.
4668    ///
4669    /// NOTE: `TestBackend` does not currently expose a `send_key()` method, so
4670    /// key events cannot be injected directly through the public API.  This test
4671    /// verifies the `on_key` lifecycle by simulating its effect: the component
4672    /// converts key events into messages via `Link::send`, and we dispatch those
4673    /// messages manually to confirm the state transition is correct.
4674    struct KeyTracker;
4675
4676    #[derive(Clone, Debug)]
4677    enum KeyMsg {
4678        KeyPressed(KeyCode),
4679    }
4680
4681    #[derive(Default)]
4682    struct KeyTrackerState {
4683        keys: Vec<KeyCode>,
4684    }
4685
4686    impl Component for KeyTracker {
4687        type Message = KeyMsg;
4688        type Properties = ();
4689        type State = KeyTrackerState;
4690
4691        fn create_state(&self, _props: &Self::Properties) -> Self::State {
4692            KeyTrackerState::default()
4693        }
4694
4695        fn on_key(&mut self, key: KeyEvent, ctx: &mut Context<Self>) -> KeyUpdate {
4696            ctx.link().send(KeyMsg::KeyPressed(key.code));
4697            KeyUpdate::handled(Update::none())
4698        }
4699
4700        fn view(&self, ctx: &Context<Self>) -> Element {
4701            Text::new(format!("keys: {}", ctx.state.keys.len())).into()
4702        }
4703
4704        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
4705            match msg {
4706                KeyMsg::KeyPressed(code) => {
4707                    ctx.state.keys.push(code);
4708                    Update::full()
4709                }
4710            }
4711        }
4712    }
4713
4714    #[test]
4715    fn on_key_receives_key_events() {
4716        // TestBackend does not expose direct key injection, so we simulate the
4717        // effect of `on_key` by dispatching the same messages the handler would
4718        // produce.  This validates the message→state path that `on_key` relies on.
4719        let mut backend = TestBackend::new(KeyTracker);
4720        assert!(backend.state().keys.is_empty());
4721
4722        backend
4723            .dispatch(KeyMsg::KeyPressed(KeyCode::Char('a')))
4724            .expect("dispatch should succeed");
4725        backend
4726            .dispatch(KeyMsg::KeyPressed(KeyCode::Enter))
4727            .expect("dispatch should succeed");
4728        backend
4729            .dispatch(KeyMsg::KeyPressed(KeyCode::Esc))
4730            .expect("dispatch should succeed");
4731
4732        assert_eq!(backend.state().keys.len(), 3);
4733        assert_eq!(backend.state().keys[0], KeyCode::Char('a'));
4734        assert_eq!(backend.state().keys[1], KeyCode::Enter);
4735        assert_eq!(backend.state().keys[2], KeyCode::Esc);
4736    }
4737
4738    #[test]
4739    fn multiple_rapid_dispatches_all_processed() {
4740        let mut backend = TestBackend::new(Counter);
4741        assert_eq!(*backend.state(), 0);
4742
4743        for _ in 0..100 {
4744            backend.dispatch(Msg::Inc).expect("dispatch should succeed");
4745        }
4746
4747        assert_eq!(*backend.state(), 100);
4748    }
4749
4750    /// Component whose `update` returns a `Command::spawn` that sends a message
4751    /// back from a background thread.
4752    struct SpawnEcho;
4753
4754    #[derive(Clone, Copy, Debug)]
4755    enum SpawnMsg {
4756        Trigger,
4757        FromBackground(u64),
4758    }
4759
4760    impl Component for SpawnEcho {
4761        type Message = SpawnMsg;
4762        type Properties = ();
4763        type State = Option<u64>;
4764
4765        fn create_state(&self, _props: &Self::Properties) -> Self::State {
4766            None
4767        }
4768
4769        fn view(&self, ctx: &Context<Self>) -> Element {
4770            Text::new(format!("{:?}", ctx.state)).into()
4771        }
4772
4773        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
4774            match msg {
4775                SpawnMsg::Trigger => Update {
4776                    dirty: false,
4777                    level: crate::core::component::UpdateLevel::None,
4778                    command: Some(Command::spawn(|link| {
4779                        link.send(SpawnMsg::FromBackground(99));
4780                    })),
4781                },
4782                SpawnMsg::FromBackground(v) => {
4783                    ctx.state = Some(v);
4784                    Update::full()
4785                }
4786            }
4787        }
4788    }
4789
4790    #[test]
4791    fn command_spawn_sends_message_back() {
4792        let mut backend = TestBackend::new(SpawnEcho);
4793        assert_eq!(*backend.state(), None);
4794
4795        backend
4796            .dispatch(SpawnMsg::Trigger)
4797            .expect("dispatch should succeed");
4798
4799        // The spawn command runs on a background thread. Pump with retries.
4800        for _ in 0..100 {
4801            backend.pump().expect("pump should succeed");
4802            if *backend.state() == Some(99) {
4803                return;
4804            }
4805            std::thread::sleep(std::time::Duration::from_millis(1));
4806        }
4807
4808        panic!("expected background command to set state to Some(99)");
4809    }
4810
4811    struct PasteIntoInput;
4812
4813    #[derive(Clone, Debug, PartialEq, Eq)]
4814    enum PasteMsg {
4815        InputChanged(String),
4816        TextAreaChanged(String),
4817    }
4818
4819    #[derive(Default)]
4820    struct PasteState {
4821        input: String,
4822        text_area: String,
4823    }
4824
4825    impl Component for PasteIntoInput {
4826        type Message = PasteMsg;
4827        type Properties = ();
4828        type State = PasteState;
4829
4830        fn create_state(&self, _props: &Self::Properties) -> Self::State {
4831            PasteState::default()
4832        }
4833
4834        fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
4835            match msg {
4836                PasteMsg::InputChanged(value) => ctx.state.input = value,
4837                PasteMsg::TextAreaChanged(value) => ctx.state.text_area = value,
4838            }
4839            Update::full()
4840        }
4841
4842        fn view(&self, ctx: &Context<Self>) -> Element {
4843            VStack::new()
4844                .child(
4845                    Input::new(ctx.state.input.clone()).on_change(
4846                        ctx.link().callback(|ev: InputEvent| {
4847                            PasteMsg::InputChanged(ev.value.to_string())
4848                        }),
4849                    ),
4850                )
4851                .child(TextArea::new(ctx.state.text_area.clone()).on_change(
4852                    ctx.link().callback(|ev: TextAreaEvent| {
4853                        PasteMsg::TextAreaChanged(ev.value.to_string())
4854                    }),
4855                ))
4856                .into()
4857        }
4858    }
4859
4860    #[test]
4861    fn send_paste_inserts_into_focused_input() {
4862        let mut backend = TestBackend::new(PasteIntoInput);
4863        let input_id = backend
4864            .core
4865            .tree
4866            .iter()
4867            .find(|node| matches!(node.kind, NodeKind::Input(_)))
4868            .map(|node| node.id)
4869            .expect("input exists");
4870        backend.set_focused(input_id);
4871
4872        assert!(
4873            backend
4874                .send_paste("file:///tmp/demo.pdf")
4875                .expect("paste should succeed")
4876        );
4877        assert_eq!(backend.state().input, "file:///tmp/demo.pdf");
4878    }
4879
4880    #[test]
4881    fn send_paste_inserts_into_focused_text_area() {
4882        let mut backend = TestBackend::new(PasteIntoInput);
4883        let text_area_id = backend
4884            .core
4885            .tree
4886            .iter()
4887            .find(|node| matches!(node.kind, NodeKind::TextArea(_)))
4888            .map(|node| node.id)
4889            .expect("text area exists");
4890        backend.set_focused(text_area_id);
4891
4892        assert!(
4893            backend
4894                .send_paste("alpha\nbeta")
4895                .expect("paste should succeed")
4896        );
4897        assert_eq!(backend.state().text_area, "alpha\nbeta");
4898    }
4899}