jugar_probar/brick/
event.rs

1//! EventBrick: DOM event handler generation from brick definitions (PROBAR-SPEC-009-P7)
2//!
3//! Generates JavaScript event handlers from brick definitions.
4//! Zero hand-written event handling code.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use probar::brick::event::{EventBrick, EventType, EventHandler};
10//!
11//! let events = EventBrick::new()
12//!     .on("#record", EventType::Click, EventHandler::dispatch_state("toggle_recording"))
13//!     .on("#clear", EventType::Click, EventHandler::call_wasm("clear_transcript"));
14//!
15//! let js = events.to_event_js();
16//! ```
17
18use super::{Brick, BrickAssertion, BrickBudget, BrickVerification};
19use std::time::Duration;
20
21/// DOM event types
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum EventType {
24    /// Mouse click
25    Click,
26    /// Double click
27    DoubleClick,
28    /// Mouse down
29    MouseDown,
30    /// Mouse up
31    MouseUp,
32    /// Mouse enter (hover start)
33    MouseEnter,
34    /// Mouse leave (hover end)
35    MouseLeave,
36    /// Key down
37    KeyDown,
38    /// Key up
39    KeyUp,
40    /// Key press
41    KeyPress,
42    /// Input value change
43    Input,
44    /// Form change
45    Change,
46    /// Form submit
47    Submit,
48    /// Focus gained
49    Focus,
50    /// Focus lost
51    Blur,
52    /// Scroll
53    Scroll,
54    /// Touch start
55    TouchStart,
56    /// Touch end
57    TouchEnd,
58    /// Touch move
59    TouchMove,
60    /// Custom event
61    Custom(&'static str),
62}
63
64impl EventType {
65    /// Get the JavaScript event name
66    #[must_use]
67    pub fn js_name(&self) -> &str {
68        match self {
69            Self::Click => "click",
70            Self::DoubleClick => "dblclick",
71            Self::MouseDown => "mousedown",
72            Self::MouseUp => "mouseup",
73            Self::MouseEnter => "mouseenter",
74            Self::MouseLeave => "mouseleave",
75            Self::KeyDown => "keydown",
76            Self::KeyUp => "keyup",
77            Self::KeyPress => "keypress",
78            Self::Input => "input",
79            Self::Change => "change",
80            Self::Submit => "submit",
81            Self::Focus => "focus",
82            Self::Blur => "blur",
83            Self::Scroll => "scroll",
84            Self::TouchStart => "touchstart",
85            Self::TouchEnd => "touchend",
86            Self::TouchMove => "touchmove",
87            Self::Custom(name) => name,
88        }
89    }
90}
91
92/// Event handler action
93#[derive(Debug, Clone)]
94pub enum EventHandler {
95    /// Dispatch a state change event
96    DispatchState(String),
97
98    /// Call a WASM exported function
99    CallWasm {
100        /// Function name
101        function: String,
102        /// Arguments to pass (JavaScript expressions)
103        args: Vec<String>,
104    },
105
106    /// Post a message to a worker
107    PostMessage {
108        /// Target worker name
109        worker: String,
110        /// Message type
111        message_type: String,
112        /// Message fields (key = field name, value = JS expression)
113        fields: Vec<(String, String)>,
114    },
115
116    /// Update a DOM element
117    UpdateElement {
118        /// Target selector
119        selector: String,
120        /// Property to update
121        property: String,
122        /// New value (JavaScript expression)
123        value: String,
124    },
125
126    /// Toggle a CSS class
127    ToggleClass {
128        /// Target selector
129        selector: String,
130        /// Class name
131        class: String,
132    },
133
134    /// Prevent default and stop propagation
135    PreventDefault,
136
137    /// Chain multiple handlers
138    Chain(Vec<EventHandler>),
139
140    /// Conditional handler
141    If {
142        /// Condition (JavaScript expression)
143        condition: String,
144        /// Handler if true
145        then: Box<EventHandler>,
146        /// Handler if false (optional)
147        otherwise: Option<Box<EventHandler>>,
148    },
149}
150
151impl EventHandler {
152    /// Create a state dispatch handler
153    #[must_use]
154    pub fn dispatch_state(state: impl Into<String>) -> Self {
155        Self::DispatchState(state.into())
156    }
157
158    /// Create a WASM call handler
159    #[must_use]
160    pub fn call_wasm(function: impl Into<String>) -> Self {
161        Self::CallWasm {
162            function: function.into(),
163            args: Vec::new(),
164        }
165    }
166
167    /// Create a WASM call handler with arguments
168    #[must_use]
169    pub fn call_wasm_with_args(function: impl Into<String>, args: Vec<String>) -> Self {
170        Self::CallWasm {
171            function: function.into(),
172            args,
173        }
174    }
175
176    /// Create a worker message handler
177    #[must_use]
178    pub fn post_to_worker(worker: impl Into<String>, message_type: impl Into<String>) -> Self {
179        Self::PostMessage {
180            worker: worker.into(),
181            message_type: message_type.into(),
182            fields: Vec::new(),
183        }
184    }
185
186    /// Create an element update handler
187    #[must_use]
188    pub fn update_element(
189        selector: impl Into<String>,
190        property: impl Into<String>,
191        value: impl Into<String>,
192    ) -> Self {
193        Self::UpdateElement {
194            selector: selector.into(),
195            property: property.into(),
196            value: value.into(),
197        }
198    }
199
200    /// Create a class toggle handler
201    #[must_use]
202    pub fn toggle_class(selector: impl Into<String>, class: impl Into<String>) -> Self {
203        Self::ToggleClass {
204            selector: selector.into(),
205            class: class.into(),
206        }
207    }
208
209    /// Chain handlers
210    #[must_use]
211    pub fn chain(handlers: Vec<EventHandler>) -> Self {
212        Self::Chain(handlers)
213    }
214
215    /// Create a conditional handler
216    #[must_use]
217    pub fn when(
218        condition: impl Into<String>,
219        then: EventHandler,
220        otherwise: Option<EventHandler>,
221    ) -> Self {
222        Self::If {
223            condition: condition.into(),
224            then: Box::new(then),
225            otherwise: otherwise.map(Box::new),
226        }
227    }
228
229    /// Generate JavaScript code for this handler
230    #[must_use]
231    pub fn to_js(&self, indent: usize) -> String {
232        let pad = "    ".repeat(indent);
233
234        match self {
235            Self::DispatchState(state) => {
236                format!(
237                    "{}window.dispatchEvent(new CustomEvent('state-change', {{ detail: '{}' }}));",
238                    pad, state
239                )
240            }
241
242            Self::CallWasm { function, args } => {
243                let args_str = args.join(", ");
244                format!("{}window.wasm.{}({});", pad, function, args_str)
245            }
246
247            Self::PostMessage {
248                worker,
249                message_type,
250                fields,
251            } => {
252                let fields_str = if fields.is_empty() {
253                    String::new()
254                } else {
255                    let f: Vec<_> = fields
256                        .iter()
257                        .map(|(k, v)| format!("{}: {}", k, v))
258                        .collect();
259                    format!(", {}", f.join(", "))
260                };
261                format!(
262                    "{}{}.postMessage({{ type: '{}'{} }});",
263                    pad, worker, message_type, fields_str
264                )
265            }
266
267            Self::UpdateElement {
268                selector,
269                property,
270                value,
271            } => {
272                format!(
273                    "{}document.querySelector('{}').{} = {};",
274                    pad, selector, property, value
275                )
276            }
277
278            Self::ToggleClass { selector, class } => {
279                format!(
280                    "{}document.querySelector('{}').classList.toggle('{}');",
281                    pad, selector, class
282                )
283            }
284
285            Self::PreventDefault => {
286                format!("{}e.preventDefault();\n{}e.stopPropagation();", pad, pad)
287            }
288
289            Self::Chain(handlers) => handlers
290                .iter()
291                .map(|h| h.to_js(indent))
292                .collect::<Vec<_>>()
293                .join("\n"),
294
295            Self::If {
296                condition,
297                then,
298                otherwise,
299            } => {
300                let then_js = then.to_js(indent + 1);
301                let else_js = otherwise
302                    .as_ref()
303                    .map(|h| format!(" else {{\n{}\n{}}}", h.to_js(indent + 1), pad))
304                    .unwrap_or_default();
305
306                format!(
307                    "{}if ({}) {{\n{}\n{}}}{}",
308                    pad, condition, then_js, pad, else_js
309                )
310            }
311        }
312    }
313}
314
315/// A single event binding
316#[derive(Debug, Clone)]
317pub struct EventBinding {
318    /// CSS selector for the target element
319    pub selector: String,
320    /// Event type to listen for
321    pub event_type: EventType,
322    /// Handler to execute
323    pub handler: EventHandler,
324    /// Use capture phase
325    pub capture: bool,
326    /// Only fire once
327    pub once: bool,
328    /// Passive listener (performance optimization)
329    pub passive: bool,
330}
331
332impl EventBinding {
333    /// Create a new event binding
334    #[must_use]
335    pub fn new(selector: impl Into<String>, event_type: EventType, handler: EventHandler) -> Self {
336        Self {
337            selector: selector.into(),
338            event_type,
339            handler,
340            capture: false,
341            once: false,
342            passive: false,
343        }
344    }
345
346    /// Use capture phase
347    #[must_use]
348    pub fn capture(mut self) -> Self {
349        self.capture = true;
350        self
351    }
352
353    /// Only fire once
354    #[must_use]
355    pub fn once(mut self) -> Self {
356        self.once = true;
357        self
358    }
359
360    /// Mark as passive (for scroll/touch performance)
361    #[must_use]
362    pub fn passive(mut self) -> Self {
363        self.passive = true;
364        self
365    }
366
367    /// Generate JavaScript for this binding
368    #[must_use]
369    pub fn to_js(&self) -> String {
370        let handler_js = self.handler.to_js(2);
371
372        let options = if self.capture || self.once || self.passive {
373            let mut opts = Vec::new();
374            if self.capture {
375                opts.push("capture: true");
376            }
377            if self.once {
378                opts.push("once: true");
379            }
380            if self.passive {
381                opts.push("passive: true");
382            }
383            format!(", {{ {} }}", opts.join(", "))
384        } else {
385            String::new()
386        };
387
388        format!(
389            "document.querySelector('{}').addEventListener('{}', (e) => {{\n{}\n}}{}); ",
390            self.selector,
391            self.event_type.js_name(),
392            handler_js,
393            options
394        )
395    }
396}
397
398/// EventBrick: Generates DOM event handlers from brick definition
399#[derive(Debug, Clone, Default)]
400pub struct EventBrick {
401    /// Event bindings
402    bindings: Vec<EventBinding>,
403    /// Global window event handlers
404    window_handlers: Vec<(EventType, EventHandler)>,
405}
406
407impl EventBrick {
408    /// Create a new event brick
409    #[must_use]
410    pub fn new() -> Self {
411        Self::default()
412    }
413
414    /// Add an event binding
415    #[must_use]
416    pub fn on(
417        mut self,
418        selector: impl Into<String>,
419        event_type: EventType,
420        handler: EventHandler,
421    ) -> Self {
422        self.bindings
423            .push(EventBinding::new(selector, event_type, handler));
424        self
425    }
426
427    /// Add an event binding with options
428    #[must_use]
429    pub fn on_with(mut self, binding: EventBinding) -> Self {
430        self.bindings.push(binding);
431        self
432    }
433
434    /// Add a window-level event handler
435    #[must_use]
436    pub fn on_window(mut self, event_type: EventType, handler: EventHandler) -> Self {
437        self.window_handlers.push((event_type, handler));
438        self
439    }
440
441    /// Generate JavaScript for all event handlers
442    #[must_use]
443    pub fn to_event_js(&self) -> String {
444        let mut js = String::new();
445
446        js.push_str("// Event Handlers\n");
447        js.push_str("// Generated by probar - DO NOT EDIT MANUALLY\n\n");
448
449        // Element bindings
450        for binding in &self.bindings {
451            js.push_str(&binding.to_js());
452            js.push('\n');
453        }
454
455        // Window handlers
456        for (event_type, handler) in &self.window_handlers {
457            let handler_js = handler.to_js(1);
458            js.push_str(&format!(
459                "window.addEventListener('{}', (e) => {{\n{}\n}});\n",
460                event_type.js_name(),
461                handler_js
462            ));
463        }
464
465        js
466    }
467
468    /// Get all selectors referenced by this brick
469    #[must_use]
470    pub fn selectors(&self) -> Vec<&str> {
471        self.bindings.iter().map(|b| b.selector.as_str()).collect()
472    }
473}
474
475impl Brick for EventBrick {
476    fn brick_name(&self) -> &'static str {
477        "EventBrick"
478    }
479
480    fn assertions(&self) -> &[BrickAssertion] {
481        &[]
482    }
483
484    fn budget(&self) -> BrickBudget {
485        BrickBudget::uniform(100)
486    }
487
488    fn verify(&self) -> BrickVerification {
489        let passed = vec![BrickAssertion::Custom {
490            name: "event_bindings_valid".into(),
491            validator_id: 10,
492        }];
493
494        BrickVerification {
495            passed,
496            failed: Vec::new(),
497            verification_time: Duration::from_micros(50),
498        }
499    }
500
501    fn to_html(&self) -> String {
502        String::new()
503    }
504
505    fn to_css(&self) -> String {
506        String::new()
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn test_event_type_js_name() {
516        assert_eq!(EventType::Click.js_name(), "click");
517        assert_eq!(EventType::KeyDown.js_name(), "keydown");
518        assert_eq!(EventType::Custom("my-event").js_name(), "my-event");
519    }
520
521    #[test]
522    fn test_event_handler_dispatch_state() {
523        let handler = EventHandler::dispatch_state("recording");
524        let js = handler.to_js(0);
525
526        assert!(js.contains("dispatchEvent"));
527        assert!(js.contains("state-change"));
528        assert!(js.contains("recording"));
529    }
530
531    #[test]
532    fn test_event_handler_call_wasm() {
533        let handler = EventHandler::call_wasm("start_recording");
534        let js = handler.to_js(0);
535
536        assert!(js.contains("window.wasm.start_recording()"));
537    }
538
539    #[test]
540    fn test_event_handler_update_element() {
541        let handler = EventHandler::update_element("#status", "textContent", "'Ready'");
542        let js = handler.to_js(0);
543
544        assert!(js.contains("#status"));
545        assert!(js.contains("textContent"));
546        assert!(js.contains("'Ready'"));
547    }
548
549    #[test]
550    fn test_event_binding_basic() {
551        let binding = EventBinding::new(
552            "#button",
553            EventType::Click,
554            EventHandler::dispatch_state("clicked"),
555        );
556
557        let js = binding.to_js();
558
559        assert!(js.contains("#button"));
560        assert!(js.contains("click"));
561        assert!(js.contains("addEventListener"));
562    }
563
564    #[test]
565    fn test_event_binding_options() {
566        let binding = EventBinding::new(
567            "#scroll",
568            EventType::Scroll,
569            EventHandler::call_wasm("on_scroll"),
570        )
571        .passive()
572        .capture();
573
574        let js = binding.to_js();
575
576        assert!(js.contains("passive: true"));
577        assert!(js.contains("capture: true"));
578    }
579
580    #[test]
581    fn test_event_brick_generation() {
582        let events = EventBrick::new()
583            .on(
584                "#record",
585                EventType::Click,
586                EventHandler::dispatch_state("toggle"),
587            )
588            .on("#clear", EventType::Click, EventHandler::call_wasm("clear"));
589
590        let js = events.to_event_js();
591
592        assert!(js.contains("Generated by probar"));
593        assert!(js.contains("#record"));
594        assert!(js.contains("#clear"));
595    }
596
597    #[test]
598    fn test_event_handler_chain() {
599        let handler = EventHandler::chain(vec![
600            EventHandler::PreventDefault,
601            EventHandler::dispatch_state("clicked"),
602        ]);
603
604        let js = handler.to_js(0);
605
606        assert!(js.contains("preventDefault"));
607        assert!(js.contains("dispatchEvent"));
608    }
609
610    #[test]
611    fn test_event_handler_conditional() {
612        let handler = EventHandler::when(
613            "isRecording",
614            EventHandler::dispatch_state("stop"),
615            Some(EventHandler::dispatch_state("start")),
616        );
617
618        let js = handler.to_js(0);
619
620        assert!(js.contains("if (isRecording)"));
621        assert!(js.contains("stop"));
622        assert!(js.contains("else"));
623        assert!(js.contains("start"));
624    }
625}