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
36pub 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 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 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 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 pub fn set_viewport(&mut self, viewport: Rect) {
173 self.viewport = viewport;
174 self.core.ctx.set_viewport(viewport);
175 }
176
177 pub fn link(&self) -> Link<C::Message> {
179 self.core.ctx.link().clone()
180 }
181
182 pub fn component(&self) -> &C {
184 &self.core.component
185 }
186
187 pub fn component_mut(&mut self) -> &mut C {
189 &mut self.core.component
190 }
191
192 pub fn state(&self) -> &C::State {
194 &self.core.ctx.state
195 }
196
197 pub fn state_mut(&mut self) -> &mut C::State {
199 &mut self.core.ctx.state
200 }
201
202 pub fn focused(&self) -> Option<NodeId> {
204 self.focused
205 }
206
207 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 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 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 pub fn enqueue(&self, msg: C::Message) {
240 self.core.ctx.link().send(msg);
241 }
242
243 pub fn dispatch(&mut self, msg: C::Message) -> Result<bool> {
245 self.enqueue(msg);
246 self.pump()
247 }
248
249 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn focused_key(&self) -> Option<&crate::core::element::Key> {
765 self.focused_key.as_ref()
766 }
767
768 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 backend.render();
2149 assert_eq!(backend.capture_frame().cell(9, 2).bg, Color::Reset);
2150
2151 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 assert_eq!(frame.props.style.fg, Some(Color::Rgb(90, 91, 92).into()));
4085 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 use crate::core::component::Command;
4611
4612 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 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 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 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 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 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}