Skip to main content

tui_dispatch_core/
event.rs

1//! Event types for the pub/sub system
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
4use ratatui::layout::Rect;
5use std::collections::HashMap;
6use std::fmt::Debug;
7use std::hash::Hash;
8
9/// Trait for user-defined component identifiers
10///
11/// Implement this trait for your own component ID enum, or use `#[derive(ComponentId)]`
12/// from `tui-dispatch-macros` to auto-generate the implementation.
13///
14/// # Example
15/// ```ignore
16/// #[derive(ComponentId, Clone, Copy, PartialEq, Eq, Hash, Debug)]
17/// pub enum MyComponentId {
18///     Sidebar,
19///     MainContent,
20///     StatusBar,
21/// }
22/// ```
23pub trait ComponentId: Clone + Copy + Eq + Hash + Debug {
24    /// Get the component name as a string (for debugging/logging)
25    fn name(&self) -> &'static str;
26}
27
28/// A simple numeric component ID for basic use cases
29///
30/// Use this if you don't need named components, or use your own enum
31/// with `#[derive(ComponentId)]` for named components.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub struct NumericComponentId(pub u32);
34
35impl ComponentId for NumericComponentId {
36    fn name(&self) -> &'static str {
37        "component"
38    }
39}
40
41/// Event types that components can subscribe to
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum EventType {
44    /// Keyboard events
45    Key,
46    /// Mouse click/drag events
47    Mouse,
48    /// Scroll wheel events
49    Scroll,
50    /// Terminal resize events
51    Resize,
52    /// Periodic tick for animations
53    Tick,
54    /// Global events delivered to all components
55    Global,
56}
57
58/// The actual event payload
59#[derive(Debug, Clone)]
60pub enum EventKind {
61    /// Keyboard event
62    Key(KeyEvent),
63    /// Mouse event
64    Mouse(MouseEvent),
65    /// Scroll event with position, delta, and modifiers
66    Scroll {
67        column: u16,
68        row: u16,
69        delta: isize,
70        modifiers: KeyModifiers,
71    },
72    /// Terminal resize
73    Resize(u16, u16),
74    /// Periodic tick
75    Tick,
76}
77
78impl EventKind {
79    /// Get the event type for this event kind
80    pub fn event_type(&self) -> EventType {
81        match self {
82            EventKind::Key(_) => EventType::Key,
83            EventKind::Mouse(_) => EventType::Mouse,
84            EventKind::Scroll { .. } => EventType::Scroll,
85            EventKind::Resize(_, _) => EventType::Resize,
86            EventKind::Tick => EventType::Tick,
87        }
88    }
89
90    /// Check if this is a global event (should be delivered to all components)
91    pub fn is_global(&self) -> bool {
92        match self {
93            EventKind::Key(key) => {
94                use crossterm::event::KeyCode;
95                matches!(key.code, KeyCode::Esc)
96                    || (key.modifiers.contains(KeyModifiers::CONTROL)
97                        && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('q')))
98            }
99            EventKind::Resize(_, _) => true,
100            _ => false,
101        }
102    }
103
104    /// Check if this is a broadcast event (delivered to all subscribers, never consumed)
105    pub fn is_broadcast(&self) -> bool {
106        matches!(self, EventKind::Resize(..) | EventKind::Tick)
107    }
108}
109
110/// Policy for determining which events are treated as global.
111///
112/// Global events bypass modal blocking and are delivered to global subscribers
113/// even when a modal is active. Resize events are always treated as global
114/// regardless of this policy.
115///
116/// By default, Esc, Ctrl+C, and Ctrl+Q are global keys. Use this to customize
117/// that behavior — for example, if your app uses Esc for "close modal" and
118/// doesn't want it treated as global.
119///
120/// # Example
121///
122/// ```ignore
123/// use tui_dispatch::{EventBus, GlobalKeyPolicy};
124///
125/// // Remove Esc from global keys (keep only Ctrl+C, Ctrl+Q)
126/// let bus = EventBus::new()
127///     .with_global_key_policy(GlobalKeyPolicy::without_esc());
128///
129/// // Custom set of global keys
130/// let bus = EventBus::new()
131///     .with_global_key_policy(GlobalKeyPolicy::keys(vec![
132///         (KeyCode::Char('c'), KeyModifiers::CONTROL),
133///     ]));
134///
135/// // No key events are global (only Resize remains global)
136/// let bus = EventBus::new()
137///     .with_global_key_policy(GlobalKeyPolicy::none());
138/// ```
139#[derive(Default)]
140pub enum GlobalKeyPolicy {
141    /// Default: Esc, Ctrl+C, Ctrl+Q are global. Same as `EventKind::is_global()`.
142    #[default]
143    Default,
144    /// Only these specific key combinations are global.
145    /// Each entry is `(KeyCode, required_modifiers)`.
146    Keys(Vec<(KeyCode, KeyModifiers)>),
147    /// Custom predicate function.
148    Custom(Box<dyn Fn(&EventKind) -> bool + Send + Sync>),
149}
150
151impl GlobalKeyPolicy {
152    /// No key events are global (only Resize remains global).
153    pub fn none() -> Self {
154        Self::Keys(vec![])
155    }
156
157    /// Default without Esc — only Ctrl+C and Ctrl+Q are global.
158    ///
159    /// Useful for apps that use Esc to close modals/dialogs.
160    pub fn without_esc() -> Self {
161        Self::Keys(vec![
162            (KeyCode::Char('c'), KeyModifiers::CONTROL),
163            (KeyCode::Char('q'), KeyModifiers::CONTROL),
164        ])
165    }
166
167    /// Only these specific key combinations are global.
168    ///
169    /// Each entry is `(KeyCode, required_modifiers)` — the key matches if
170    /// its code equals the given code and its modifiers contain the
171    /// required modifiers.
172    pub fn keys(keys: Vec<(KeyCode, KeyModifiers)>) -> Self {
173        Self::Keys(keys)
174    }
175
176    /// Custom predicate for full control over global classification.
177    ///
178    /// Resize events are still always global regardless of the predicate.
179    pub fn custom(f: impl Fn(&EventKind) -> bool + Send + Sync + 'static) -> Self {
180        Self::Custom(Box::new(f))
181    }
182
183    /// Check whether an event is global under this policy.
184    ///
185    /// Resize events always return true regardless of the policy.
186    pub fn is_global(&self, event: &EventKind) -> bool {
187        // Resize is always global
188        if matches!(event, EventKind::Resize(..)) {
189            return true;
190        }
191        match self {
192            Self::Default => event.is_global(),
193            Self::Keys(keys) => {
194                if let EventKind::Key(key) = event {
195                    keys.iter()
196                        .any(|(code, mods)| key.code == *code && key.modifiers.contains(*mods))
197                } else {
198                    false
199                }
200            }
201            Self::Custom(f) => f(event),
202        }
203    }
204}
205
206/// Context passed with every event
207///
208/// Generic over the component ID type `C` which must implement `ComponentId`.
209#[derive(Debug, Clone)]
210pub struct EventContext<C: ComponentId> {
211    /// Current mouse position (if known)
212    pub mouse_position: Option<(u16, u16)>,
213    /// Active key modifiers
214    pub modifiers: KeyModifiers,
215    /// Component areas for hit-testing
216    pub component_areas: HashMap<C, Rect>,
217}
218
219impl<C: ComponentId> Default for EventContext<C> {
220    fn default() -> Self {
221        Self {
222            mouse_position: None,
223            modifiers: KeyModifiers::NONE,
224            component_areas: HashMap::new(),
225        }
226    }
227}
228
229impl<C: ComponentId> EventContext<C> {
230    /// Create a new event context
231    pub fn new() -> Self {
232        Self::default()
233    }
234
235    /// Check if a point is within a component's area
236    pub fn point_in_component(&self, component: C, x: u16, y: u16) -> bool {
237        self.component_areas
238            .get(&component)
239            .map(|area| {
240                x >= area.x
241                    && x < area.x.saturating_add(area.width)
242                    && y >= area.y
243                    && y < area.y.saturating_add(area.height)
244            })
245            .unwrap_or(false)
246    }
247
248    /// Get the first component containing a point
249    ///
250    /// The ordering is based on the internal map iteration order and should be
251    /// treated as undefined. Prefer routing through `EventBus` when ordering
252    /// matters.
253    pub fn component_at(&self, x: u16, y: u16) -> Option<C> {
254        self.component_areas
255            .iter()
256            .find(|(_, area)| {
257                x >= area.x
258                    && x < area.x.saturating_add(area.width)
259                    && y >= area.y
260                    && y < area.y.saturating_add(area.height)
261            })
262            .map(|(id, _)| *id)
263    }
264
265    /// Update the area for a component
266    pub fn set_component_area(&mut self, component: C, area: Rect) {
267        self.component_areas.insert(component, area);
268    }
269}
270
271/// An event with its context
272///
273/// Generic over the component ID type `C` which must implement `ComponentId`.
274#[derive(Debug, Clone)]
275pub struct Event<C: ComponentId> {
276    /// The event payload
277    pub kind: EventKind,
278    /// Context at the time of the event
279    pub context: EventContext<C>,
280}
281
282impl<C: ComponentId> Event<C> {
283    /// Create a new event
284    pub fn new(kind: EventKind, context: EventContext<C>) -> Self {
285        Self { kind, context }
286    }
287
288    /// Get the event type
289    pub fn event_type(&self) -> EventType {
290        self.kind.event_type()
291    }
292
293    /// Check if this is a global event
294    pub fn is_global(&self) -> bool {
295        self.kind.is_global()
296    }
297}