1use std::any::TypeId;
2use std::cell::RefCell;
3use std::collections::HashMap;
4use std::marker::PhantomData;
5use std::rc::Rc;
6
7use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent};
8use ratatui::{layout::Rect, Frame};
9use tui_dispatch_core::{
10 Action as ActionTrait, BindingContext, ComponentId, DefaultBindingContext, EventBus, EventKind,
11 EventType, HandlerResponse, RoutedEvent,
12};
13
14#[derive(Debug, Clone)]
16pub enum ComponentInput<'a, Ctx> {
17 Command {
18 name: &'a str,
19 ctx: Ctx,
20 },
21 Key(KeyEvent),
22 Mouse(MouseEvent),
23 Scroll {
24 column: u16,
25 row: u16,
26 delta: isize,
27 modifiers: KeyModifiers,
28 },
29 Resize(u16, u16),
30 Tick,
31}
32
33impl<'a, Id, Ctx> From<RoutedEvent<'a, Id, Ctx>> for ComponentInput<'a, Ctx>
34where
35 Id: ComponentId,
36 Ctx: BindingContext,
37{
38 fn from(event: RoutedEvent<'a, Id, Ctx>) -> Self {
39 if let Some(name) = event.command {
40 return Self::Command {
41 name,
42 ctx: event.binding_ctx,
43 };
44 }
45
46 match event.kind {
47 EventKind::Key(key) => Self::Key(key),
48 EventKind::Mouse(mouse) => Self::Mouse(mouse),
49 EventKind::Scroll {
50 column,
51 row,
52 delta,
53 modifiers,
54 } => Self::Scroll {
55 column,
56 row,
57 delta,
58 modifiers,
59 },
60 EventKind::Resize(width, height) => Self::Resize(width, height),
61 EventKind::Tick => Self::Tick,
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ComponentDebugEntry {
69 pub key: String,
70 pub value: String,
71}
72
73impl ComponentDebugEntry {
74 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
75 Self {
76 key: key.into(),
77 value: value.into(),
78 }
79 }
80}
81
82pub trait ComponentDebugState {
84 fn debug_state(&self) -> Vec<ComponentDebugEntry> {
85 Vec::new()
86 }
87}
88
89pub trait InteractiveComponent<A, Ctx = DefaultBindingContext>: ComponentDebugState {
91 type Props<'a>
92 where
93 Self: 'a;
94
95 fn subscriptions() -> &'static [EventType] {
101 &[EventType::Key]
102 }
103
104 #[allow(unused_variables)]
105 fn update(
106 &mut self,
107 input: ComponentInput<'_, Ctx>,
108 props: Self::Props<'_>,
109 ) -> HandlerResponse<A> {
110 HandlerResponse::ignored()
111 }
112
113 fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>);
114}
115
116pub trait PropsFactory<S, C, A, Ctx>: 'static
118where
119 C: InteractiveComponent<A, Ctx> + 'static,
120{
121 fn props<'a>(&self, state: &'a S) -> C::Props<'a>;
122}
123
124impl<S, C, A, Ctx, F> PropsFactory<S, C, A, Ctx> for F
125where
126 C: InteractiveComponent<A, Ctx> + 'static,
127 F: for<'a> Fn(&'a S) -> C::Props<'a> + 'static,
128{
129 fn props<'a>(&self, state: &'a S) -> C::Props<'a> {
130 (self)(state)
131 }
132}
133
134#[derive(Debug, PartialEq, Eq, Hash)]
136pub struct Mounted<C> {
137 raw: u32,
138 _marker: PhantomData<fn() -> C>,
139}
140
141impl<C> Mounted<C> {
142 fn new(raw: u32) -> Self {
143 Self {
144 raw,
145 _marker: PhantomData,
146 }
147 }
148}
149
150impl<C> Copy for Mounted<C> {}
151
152impl<C> Clone for Mounted<C> {
153 fn clone(&self) -> Self {
154 *self
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum HostLifecycleError<Id> {
160 NotMounted(u32),
161 StillBound(Id),
162 TypeMismatch {
163 expected: &'static str,
164 actual: &'static str,
165 },
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct MountedComponentInfo<Id> {
170 pub raw: u32,
171 pub type_name: &'static str,
172 pub bound_id: Option<Id>,
173 pub last_area: Option<Rect>,
174 pub debug_state: Vec<ComponentDebugEntry>,
175}
176
177pub struct ComponentHost<S, A, Id, Ctx> {
178 inner: Rc<RefCell<ComponentHostInner<S, A, Id, Ctx>>>,
179}
180
181impl<S, A, Id, Ctx> Clone for ComponentHost<S, A, Id, Ctx> {
182 fn clone(&self) -> Self {
183 Self {
184 inner: Rc::clone(&self.inner),
185 }
186 }
187}
188
189impl<S, A, Id, Ctx> Default for ComponentHost<S, A, Id, Ctx>
190where
191 S: 'static,
192 A: 'static,
193 Id: ComponentId + 'static,
194 Ctx: BindingContext + 'static,
195{
196 fn default() -> Self {
197 Self::new()
198 }
199}
200
201trait ErasedMounted<S, A, Id, Ctx> {
202 fn type_id(&self) -> TypeId;
203 fn type_name(&self) -> &'static str;
204 fn binding(&self) -> Option<Id>;
205 fn set_binding(&mut self, id: Option<Id>);
206 fn last_area(&self) -> Option<Rect>;
207 fn render_epoch(&self) -> u64;
208 fn update(&mut self, input: ComponentInput<'_, Ctx>, state: &S) -> HandlerResponse<A>;
209 fn render(&mut self, frame: &mut Frame, area: Rect, state: &S, frame_epoch: u64);
210 fn reset_local_state(&mut self);
211 fn debug_state(&self) -> Vec<ComponentDebugEntry>;
212}
213
214struct MountedEntry<S, A, Id, Ctx, C>
215where
216 C: InteractiveComponent<A, Ctx> + 'static,
217{
218 component: C,
219 factory: Box<dyn Fn() -> C>,
220 props_factory: Box<dyn PropsFactory<S, C, A, Ctx>>,
221 bound_id: Option<Id>,
222 last_area: Option<Rect>,
223 last_render_epoch: u64,
224}
225
226impl<S, A, Id, Ctx, C> ErasedMounted<S, A, Id, Ctx> for MountedEntry<S, A, Id, Ctx, C>
227where
228 S: 'static,
229 A: 'static,
230 Ctx: 'static,
231 C: InteractiveComponent<A, Ctx> + 'static,
232 Id: Copy,
233{
234 fn type_id(&self) -> TypeId {
235 TypeId::of::<C>()
236 }
237
238 fn type_name(&self) -> &'static str {
239 std::any::type_name::<C>()
240 }
241
242 fn binding(&self) -> Option<Id> {
243 self.bound_id
244 }
245
246 fn set_binding(&mut self, id: Option<Id>) {
247 self.bound_id = id;
248 }
249
250 fn last_area(&self) -> Option<Rect> {
251 self.last_area
252 }
253
254 fn render_epoch(&self) -> u64 {
255 self.last_render_epoch
256 }
257
258 fn update(&mut self, input: ComponentInput<'_, Ctx>, state: &S) -> HandlerResponse<A> {
259 let props = self.props_factory.props(state);
260 self.component.update(input, props)
261 }
262
263 fn render(&mut self, frame: &mut Frame, area: Rect, state: &S, frame_epoch: u64) {
264 self.last_area = Some(area);
265 self.last_render_epoch = frame_epoch;
266 let props = self.props_factory.props(state);
267 self.component.render(frame, area, props);
268 }
269
270 fn reset_local_state(&mut self) {
271 self.component = (self.factory)();
272 }
273
274 fn debug_state(&self) -> Vec<ComponentDebugEntry> {
275 self.component.debug_state()
276 }
277}
278
279struct ComponentHostInner<S, A, Id, Ctx> {
280 next_raw: u32,
281 frame_epoch: u64,
282 mounted: HashMap<u32, Box<dyn ErasedMounted<S, A, Id, Ctx>>>,
283 bindings: HashMap<Id, u32>,
284}
285
286impl<S, A, Id, Ctx> ComponentHostInner<S, A, Id, Ctx>
287where
288 Id: ComponentId,
289 Ctx: BindingContext,
290{
291 fn new() -> Self {
292 Self {
293 next_raw: 1,
294 frame_epoch: 1,
295 mounted: HashMap::new(),
296 bindings: HashMap::new(),
297 }
298 }
299}
300
301impl<S, A, Id, Ctx> ComponentHost<S, A, Id, Ctx>
302where
303 S: 'static,
304 A: 'static,
305 Id: ComponentId + 'static,
306 Ctx: BindingContext + 'static,
307{
308 pub fn new() -> Self {
309 Self {
310 inner: Rc::new(RefCell::new(ComponentHostInner::new())),
311 }
312 }
313
314 pub fn mount<C, P>(&self, factory: impl Fn() -> C + 'static, props: P) -> Mounted<C>
315 where
316 C: InteractiveComponent<A, Ctx> + 'static,
317 P: PropsFactory<S, C, A, Ctx> + 'static,
318 {
319 let mut inner = self.inner.borrow_mut();
320 let raw = inner.next_raw;
321 inner.next_raw = inner.next_raw.saturating_add(1);
322 let component = factory();
323 inner.mounted.insert(
324 raw,
325 Box::new(MountedEntry {
326 component,
327 factory: Box::new(factory),
328 props_factory: Box::new(props),
329 bound_id: None,
330 last_area: None,
331 last_render_epoch: 0,
332 }),
333 );
334 Mounted::new(raw)
335 }
336
337 pub fn unmount<C>(&self, mounted: Mounted<C>) -> Result<(), HostLifecycleError<Id>>
338 where
339 C: InteractiveComponent<A, Ctx> + 'static,
340 {
341 let mut inner = self.inner.borrow_mut();
342 inner.ensure_type::<C>(mounted.raw)?;
343 let entry = inner
344 .mounted
345 .get(&mounted.raw)
346 .ok_or(HostLifecycleError::NotMounted(mounted.raw))?;
347
348 if let Some(id) = entry.binding() {
349 return Err(HostLifecycleError::StillBound(id));
350 }
351
352 inner.mounted.remove(&mounted.raw);
353 Ok(())
354 }
355
356 pub fn update<C>(
357 &self,
358 mounted: Mounted<C>,
359 input: ComponentInput<'_, Ctx>,
360 state: &S,
361 ) -> HandlerResponse<A>
362 where
363 C: InteractiveComponent<A, Ctx> + 'static,
364 {
365 let mut inner = self.inner.borrow_mut();
366 inner.expect_type::<C>(mounted.raw);
367 inner
368 .mounted
369 .get_mut(&mounted.raw)
370 .expect("component handle points to an unmounted component")
371 .update(input, state)
372 }
373
374 pub fn render<C>(&self, mounted: Mounted<C>, frame: &mut Frame, area: Rect, state: &S)
375 where
376 C: InteractiveComponent<A, Ctx> + 'static,
377 {
378 let mut inner = self.inner.borrow_mut();
379 inner.expect_type::<C>(mounted.raw);
380 let frame_epoch = inner.frame_epoch;
381 inner
382 .mounted
383 .get_mut(&mounted.raw)
384 .expect("component handle points to an unmounted component")
385 .render(frame, area, state, frame_epoch);
386 }
387
388 pub fn reset_local_state(&self) {
389 let mut inner = self.inner.borrow_mut();
390 for entry in inner.mounted.values_mut() {
391 entry.reset_local_state();
392 }
393 }
394
395 pub fn mounted_components(&self) -> Vec<MountedComponentInfo<Id>> {
396 let inner = self.inner.borrow();
397 let mut info: Vec<_> = inner
398 .mounted
399 .iter()
400 .map(|(raw, entry)| MountedComponentInfo {
401 raw: *raw,
402 type_name: entry.type_name(),
403 bound_id: entry.binding(),
404 last_area: entry.last_area(),
405 debug_state: entry.debug_state(),
406 })
407 .collect();
408 info.sort_by_key(|entry| entry.raw);
409 info
410 }
411}
412
413impl<S, A, Id, Ctx> ComponentHost<S, A, Id, Ctx>
414where
415 S: 'static,
416 A: 'static + ActionTrait,
417 Id: ComponentId + 'static,
418 Ctx: BindingContext + 'static,
419{
420 pub fn bind<C>(&self, bus: &mut EventBus<S, A, Id, Ctx>, id: Id, mounted: Mounted<C>)
421 where
422 C: InteractiveComponent<A, Ctx> + 'static,
423 {
424 let (replaced_route, previous_binding) = {
425 let mut inner = self.inner.borrow_mut();
426 inner.expect_type::<C>(mounted.raw);
427
428 let previous_binding = inner
429 .mounted
430 .get(&mounted.raw)
431 .expect("component handle points to an unmounted component")
432 .binding();
433 let replaced_route = inner.bindings.get(&id).copied();
434
435 if let Some(previous_raw) = replaced_route {
436 if previous_raw != mounted.raw {
437 if let Some(entry) = inner.mounted.get_mut(&previous_raw) {
438 entry.set_binding(None);
439 }
440 }
441 }
442
443 if let Some(previous_id) = previous_binding {
444 if previous_id != id {
445 inner.bindings.remove(&previous_id);
446 }
447 }
448
449 inner.bindings.insert(id, mounted.raw);
450 if let Some(entry) = inner.mounted.get_mut(&mounted.raw) {
451 entry.set_binding(Some(id));
452 }
453
454 (replaced_route, previous_binding)
455 };
456
457 if replaced_route.is_some() {
458 bus.unregister(id);
459 }
460 if let Some(previous_id) = previous_binding {
461 if previous_id != id {
462 bus.unregister(previous_id);
463 bus.context_mut().remove_component_area(previous_id);
464 }
465 }
466
467 let host = self.clone();
468 bus.register(id, move |event, state| {
469 host.update(mounted, event.into(), state)
470 });
471 bus.subscribe_many(id, C::subscriptions());
472 }
473
474 pub fn unbind(&self, bus: &mut EventBus<S, A, Id, Ctx>, id: Id) {
475 let mut inner = self.inner.borrow_mut();
476 if let Some(raw) = inner.bindings.remove(&id) {
477 if let Some(entry) = inner.mounted.get_mut(&raw) {
478 entry.set_binding(None);
479 }
480 }
481 bus.unregister(id);
482 bus.context_mut().remove_component_area(id);
483 }
484
485 pub fn sync_areas(&self, bus: &mut EventBus<S, A, Id, Ctx>) {
486 let mut areas = Vec::new();
487 {
488 let mut inner = self.inner.borrow_mut();
489 let frame_epoch = inner.frame_epoch;
490 for entry in inner.mounted.values() {
491 if let Some(id) = entry.binding() {
492 let area = if entry.render_epoch() == frame_epoch {
493 entry.last_area()
494 } else {
495 None
496 };
497 areas.push((id, area));
498 }
499 }
500 inner.frame_epoch = inner.frame_epoch.saturating_add(1);
501 }
502
503 let context = bus.context_mut();
504 for (id, area) in areas {
505 if let Some(area) = area {
506 context.set_component_area(id, area);
507 } else {
508 context.remove_component_area(id);
509 }
510 }
511 }
512}
513
514impl<S, A, Id, Ctx> ComponentHostInner<S, A, Id, Ctx>
515where
516 Id: ComponentId,
517 Ctx: BindingContext,
518{
519 fn ensure_type<C>(&self, raw: u32) -> Result<(), HostLifecycleError<Id>>
520 where
521 C: 'static,
522 {
523 let Some(entry) = self.mounted.get(&raw) else {
524 return Err(HostLifecycleError::NotMounted(raw));
525 };
526
527 if entry.type_id() == TypeId::of::<C>() {
528 Ok(())
529 } else {
530 Err(HostLifecycleError::TypeMismatch {
531 expected: std::any::type_name::<C>(),
532 actual: entry.type_name(),
533 })
534 }
535 }
536
537 fn expect_type<C>(&self, raw: u32)
538 where
539 C: 'static,
540 {
541 if let Err(err) = self.ensure_type::<C>(raw) {
542 panic!("invalid mounted component handle: {err:?}");
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use std::cell::Cell;
550 use std::rc::Rc;
551
552 use crossterm::event::{
553 KeyCode, KeyEvent, KeyEventKind, KeyEventState, MouseButton, MouseEventKind,
554 };
555 use tui_dispatch_core::testing::{key, RenderHarness};
556 use tui_dispatch_core::{
557 Action, BindingContext, EventRoutingState, EventType, Keybindings, RouteTarget,
558 };
559
560 use super::*;
561
562 #[derive(Clone, Debug, PartialEq, Eq)]
563 enum TestAction {
564 Emit(String),
565 }
566
567 impl Action for TestAction {
568 fn name(&self) -> &'static str {
569 "emit"
570 }
571 }
572
573 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
574 enum TestId {
575 A,
576 }
577
578 impl ComponentId for TestId {
579 fn name(&self) -> &'static str {
580 match self {
581 Self::A => "a",
582 }
583 }
584 }
585
586 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
587 enum TestCtx {
588 #[default]
589 Default,
590 Nav,
591 }
592
593 impl BindingContext for TestCtx {
594 fn name(&self) -> &'static str {
595 match self {
596 Self::Default => "default",
597 Self::Nav => "nav",
598 }
599 }
600
601 fn from_name(name: &str) -> Option<Self> {
602 match name {
603 "default" => Some(Self::Default),
604 "nav" => Some(Self::Nav),
605 _ => None,
606 }
607 }
608
609 fn all() -> &'static [Self] {
610 &[Self::Default, Self::Nav]
611 }
612 }
613
614 struct TestState {
615 focused: Option<TestId>,
616 context: TestCtx,
617 label: String,
618 }
619
620 fn label_props(state: &TestState) -> &str {
621 state.label.as_str()
622 }
623
624 impl EventRoutingState<TestId, TestCtx> for TestState {
625 fn focused(&self) -> Option<TestId> {
626 self.focused
627 }
628
629 fn modal(&self) -> Option<TestId> {
630 None
631 }
632
633 fn binding_context(&self, _id: TestId) -> TestCtx {
634 self.context
635 }
636
637 fn default_context(&self) -> TestCtx {
638 TestCtx::Default
639 }
640 }
641
642 struct EchoComponent {
643 resets: Rc<Cell<usize>>,
644 }
645
646 impl ComponentDebugState for EchoComponent {
647 fn debug_state(&self) -> Vec<ComponentDebugEntry> {
648 vec![ComponentDebugEntry::new(
649 "resets",
650 self.resets.get().to_string(),
651 )]
652 }
653 }
654
655 impl InteractiveComponent<TestAction, TestCtx> for EchoComponent {
656 type Props<'a> = &'a str;
657
658 fn update(
659 &mut self,
660 input: ComponentInput<'_, TestCtx>,
661 props: Self::Props<'_>,
662 ) -> HandlerResponse<TestAction> {
663 match input {
664 ComponentInput::Command { name, .. } => {
665 HandlerResponse::action(TestAction::Emit(name.to_string()))
666 }
667 ComponentInput::Key(key) if key.code == KeyCode::Char('x') => {
668 HandlerResponse::action(TestAction::Emit("raw".into()))
669 }
670 ComponentInput::Mouse(mouse)
671 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) =>
672 {
673 HandlerResponse::action(TestAction::Emit("mouse".into())).with_render()
674 }
675 ComponentInput::Scroll { .. } => {
676 HandlerResponse::action(TestAction::Emit(props.to_string())).with_render()
677 }
678 ComponentInput::Resize(..) => HandlerResponse::ignored().with_render(),
679 ComponentInput::Tick => HandlerResponse::action(TestAction::Emit("tick".into())),
680 _ => HandlerResponse::ignored(),
681 }
682 }
683
684 fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
685 use ratatui::widgets::Paragraph;
686 frame.render_widget(Paragraph::new(props.to_string()), area);
687 }
688 }
689
690 struct TickComponent;
691
692 impl ComponentDebugState for TickComponent {}
693
694 impl InteractiveComponent<TestAction, TestCtx> for TickComponent {
695 type Props<'a> = &'a str;
696
697 fn subscriptions() -> &'static [EventType] {
698 &[EventType::Tick]
699 }
700
701 fn update(
702 &mut self,
703 input: ComponentInput<'_, TestCtx>,
704 _props: Self::Props<'_>,
705 ) -> HandlerResponse<TestAction> {
706 match input {
707 ComponentInput::Tick => {
708 HandlerResponse::action(TestAction::Emit("tick-subscription".into()))
709 }
710 _ => HandlerResponse::ignored(),
711 }
712 }
713
714 fn render(&mut self, frame: &mut Frame, area: Rect, _props: Self::Props<'_>) {
715 use ratatui::widgets::Paragraph;
716 frame.render_widget(Paragraph::new("tick"), area);
717 }
718 }
719
720 fn key_event(code: KeyCode) -> KeyEvent {
721 KeyEvent {
722 code,
723 modifiers: KeyModifiers::NONE,
724 kind: KeyEventKind::Press,
725 state: KeyEventState::empty(),
726 }
727 }
728
729 #[test]
730 fn mounted_host_reuses_props_and_resets_local_state() {
731 let host = ComponentHost::<TestState, TestAction, TestId, TestCtx>::new();
732 let resets = Rc::new(Cell::new(0));
733 let mounted: Mounted<EchoComponent> = host.mount::<EchoComponent, _>(
734 {
735 let resets = Rc::clone(&resets);
736 move || {
737 resets.set(resets.get() + 1);
738 EchoComponent {
739 resets: Rc::clone(&resets),
740 }
741 }
742 },
743 label_props,
744 );
745
746 let mut harness = RenderHarness::new(12, 1);
747 let state = TestState {
748 focused: Some(TestId::A),
749 context: TestCtx::Default,
750 label: "hello".into(),
751 };
752 let output = harness
753 .render_to_string_plain(|frame| host.render(mounted, frame, frame.area(), &state));
754 assert!(output.contains("hello"));
755
756 let response = host.update(
757 mounted,
758 ComponentInput::Scroll {
759 column: 0,
760 row: 0,
761 delta: 1,
762 modifiers: KeyModifiers::NONE,
763 },
764 &state,
765 );
766 assert_eq!(response.actions, vec![TestAction::Emit("hello".into())]);
767 assert!(response.needs_render);
768
769 host.reset_local_state();
770 let info = host.mounted_components();
771 assert_eq!(
772 info[0].debug_state[0],
773 ComponentDebugEntry::new("resets", "2")
774 );
775 }
776
777 #[test]
778 fn bind_routes_commands_and_syncs_areas() {
779 let host = ComponentHost::<TestState, TestAction, TestId, TestCtx>::new();
780 let mounted: Mounted<EchoComponent> = host.mount::<EchoComponent, _>(
781 || EchoComponent {
782 resets: Rc::new(Cell::new(1)),
783 },
784 label_props,
785 );
786
787 let mut bus = EventBus::<TestState, TestAction, TestId, TestCtx>::new();
788 host.bind(&mut bus, TestId::A, mounted);
789
790 let mut bindings = Keybindings::new();
791 bindings.add(TestCtx::Nav, "next", vec!["j".into()]);
792 let state = TestState {
793 focused: Some(TestId::A),
794 context: TestCtx::Nav,
795 label: "bound".into(),
796 };
797
798 let outcome = bus.handle_event(&EventKind::Key(key("j")), &state, &bindings);
799 assert_eq!(outcome.actions, vec![TestAction::Emit("next".into())]);
800
801 let tick = bus.handle_event(&EventKind::Tick, &state, &bindings);
802 assert_eq!(tick.actions, vec![TestAction::Emit("tick".into())]);
803
804 let mut render = RenderHarness::new(10, 1);
805 render.render(|frame| host.render(mounted, frame, frame.area(), &state));
806 host.sync_areas(&mut bus);
807 assert_eq!(
808 bus.context().component_areas.get(&TestId::A).copied(),
809 Some(Rect::new(0, 0, 10, 1))
810 );
811
812 host.sync_areas(&mut bus);
813 assert_eq!(bus.context().component_areas.get(&TestId::A).copied(), None);
814 }
815
816 #[test]
817 fn bind_subscribes_default_key_events() {
818 let host = ComponentHost::<TestState, TestAction, TestId, TestCtx>::new();
819 let mounted: Mounted<EchoComponent> = host.mount::<EchoComponent, _>(
820 || EchoComponent {
821 resets: Rc::new(Cell::new(1)),
822 },
823 label_props,
824 );
825 let mut bus = EventBus::<TestState, TestAction, TestId, TestCtx>::new();
826
827 host.bind(&mut bus, TestId::A, mounted);
828
829 assert_eq!(bus.get_subscribers(EventType::Key), vec![TestId::A]);
830
831 let mut bindings = Keybindings::new();
832 bindings.add(TestCtx::Nav, "next", vec!["j".into()]);
833 let state = TestState {
834 focused: None,
835 context: TestCtx::Nav,
836 label: "bound".into(),
837 };
838
839 let outcome = bus.handle_event(&EventKind::Key(key("j")), &state, &bindings);
840 assert_eq!(outcome.actions, vec![TestAction::Emit("next".into())]);
841 }
842
843 #[test]
844 fn bind_subscribes_declared_event_types() {
845 let host = ComponentHost::<TestState, TestAction, TestId, TestCtx>::new();
846 let mounted: Mounted<TickComponent> =
847 host.mount::<TickComponent, _>(|| TickComponent, label_props);
848 let mut bus = EventBus::<TestState, TestAction, TestId, TestCtx>::new();
849
850 host.bind(&mut bus, TestId::A, mounted);
851
852 assert!(bus.get_subscribers(EventType::Key).is_empty());
853 assert_eq!(bus.get_subscribers(EventType::Tick), vec![TestId::A]);
854
855 let state = TestState {
856 focused: None,
857 context: TestCtx::Default,
858 label: "bound".into(),
859 };
860 let outcome = bus.handle_event(&EventKind::Tick, &state, &Keybindings::new());
861 assert_eq!(
862 outcome.actions,
863 vec![TestAction::Emit("tick-subscription".into())]
864 );
865 }
866
867 #[test]
868 fn unmount_requires_unbinding_first() {
869 let host = ComponentHost::<TestState, TestAction, TestId, TestCtx>::new();
870 let mounted: Mounted<EchoComponent> = host.mount::<EchoComponent, _>(
871 || EchoComponent {
872 resets: Rc::new(Cell::new(1)),
873 },
874 label_props,
875 );
876 let mut bus = EventBus::<TestState, TestAction, TestId, TestCtx>::new();
877
878 host.bind(&mut bus, TestId::A, mounted);
879 assert_eq!(
880 host.unmount(mounted),
881 Err(HostLifecycleError::StillBound(TestId::A))
882 );
883
884 host.unbind(&mut bus, TestId::A);
885 assert_eq!(host.unmount(mounted), Ok(()));
886 }
887
888 #[test]
889 fn routed_event_prefers_command_over_raw_key() {
890 let routed = RoutedEvent {
891 kind: EventKind::Key(key_event(KeyCode::Char('j'))),
892 command: Some("next"),
893 binding_ctx: TestCtx::Nav,
894 target: RouteTarget::Focused(TestId::A),
895 context: &Default::default(),
896 };
897
898 match ComponentInput::from(routed) {
899 ComponentInput::Command { name, ctx } => {
900 assert_eq!(name, "next");
901 assert_eq!(ctx, TestCtx::Nav);
902 }
903 other => panic!("unexpected routed input: {other:?}"),
904 }
905 }
906}