Skip to main content

tui_dispatch_components/
host.rs

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/// Routed input delivered to interactive components.
15#[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/// Optional component-local state inspection for debug tooling.
67#[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
82/// Optional debug surface for interactive components.
83pub trait ComponentDebugState {
84    fn debug_state(&self) -> Vec<ComponentDebugEntry> {
85        Vec::new()
86    }
87}
88
89/// Richer component contract used by [`ComponentHost`].
90pub trait InteractiveComponent<A, Ctx = DefaultBindingContext>: ComponentDebugState {
91    type Props<'a>
92    where
93        Self: 'a;
94
95    /// Event types this component should receive when bound through [`ComponentHost`].
96    ///
97    /// Focused and hovered components still receive routed input through the normal
98    /// EventBus routing path. Subscriptions are for events the component wants even
99    /// when it is not the focused or hovered target.
100    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
116/// Factory that builds component props from store state.
117pub 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/// Typed handle to a mounted component.
135#[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}