Skip to main content

par_term_scripting/
observer.rs

1//! Observer bridge that converts core library `TerminalEvent`s into scripting `ScriptEvent`s.
2//!
3//! [`ScriptEventForwarder`] implements the `TerminalObserver` trait from
4//! `par-term-emu-core-rust`.  It captures events into a thread-safe buffer
5//! that the main event loop drains and forwards to script sub-processes.
6
7use std::collections::{HashMap, HashSet};
8use std::sync::Mutex;
9
10use par_term_emu_core_rust::observer::TerminalObserver;
11use par_term_emu_core_rust::terminal::TerminalEvent;
12
13use super::protocol::{ScriptEvent, ScriptEventData};
14
15/// Bridge between core terminal observer events and the scripting JSON protocol.
16///
17/// Register an `Arc<ScriptEventForwarder>` with `Terminal::add_observer()`.
18/// The forwarder buffers converted events; the owner drains them via
19/// [`drain_events`] and serialises them to script sub-processes.
20pub struct ScriptEventForwarder {
21    /// Optional subscription filter expressed as snake_case kind names.
22    /// `None` means "forward everything".
23    subscription_filter: Option<HashSet<String>>,
24    /// Thread-safe event buffer (uses std Mutex since observer callbacks are
25    /// invoked from the PTY reader thread).
26    event_buffer: Mutex<Vec<ScriptEvent>>,
27}
28
29impl ScriptEventForwarder {
30    /// Create a new forwarder.
31    ///
32    /// # Arguments
33    /// * `subscriptions` - If `Some`, only events whose snake_case kind name
34    ///   is in the set will be captured. If `None`, all events are captured.
35    pub fn new(subscriptions: Option<HashSet<String>>) -> Self {
36        Self {
37            subscription_filter: subscriptions,
38            event_buffer: Mutex::new(Vec::new()),
39        }
40    }
41
42    /// Drain all buffered events, returning them and clearing the buffer.
43    pub fn drain_events(&self) -> Vec<ScriptEvent> {
44        let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
45        std::mem::take(&mut *buf)
46    }
47
48    /// Map a `TerminalEvent` to its snake_case kind name used by the protocol.
49    fn event_kind_name(event: &TerminalEvent) -> String {
50        match event {
51            TerminalEvent::BellRang(_) => "bell_rang".to_string(),
52            TerminalEvent::TitleChanged(_) => "title_changed".to_string(),
53            TerminalEvent::SizeChanged(_, _) => "size_changed".to_string(),
54            TerminalEvent::ModeChanged(_, _) => "mode_changed".to_string(),
55            TerminalEvent::GraphicsAdded(_) => "graphics_added".to_string(),
56            TerminalEvent::HyperlinkAdded { .. } => "hyperlink_added".to_string(),
57            TerminalEvent::DirtyRegion(_, _) => "dirty_region".to_string(),
58            TerminalEvent::CwdChanged(_) => "cwd_changed".to_string(),
59            TerminalEvent::TriggerMatched(_) => "trigger_matched".to_string(),
60            TerminalEvent::UserVarChanged { .. } => "user_var_changed".to_string(),
61            TerminalEvent::ProgressBarChanged { .. } => "progress_bar_changed".to_string(),
62            TerminalEvent::BadgeChanged(_) => "badge_changed".to_string(),
63            TerminalEvent::ShellIntegrationEvent { .. } => "command_complete".to_string(),
64            TerminalEvent::ZoneOpened { .. } => "zone_opened".to_string(),
65            TerminalEvent::ZoneClosed { .. } => "zone_closed".to_string(),
66            TerminalEvent::ZoneScrolledOut { .. } => "zone_scrolled_out".to_string(),
67            TerminalEvent::EnvironmentChanged { .. } => "environment_changed".to_string(),
68            TerminalEvent::RemoteHostTransition { .. } => "remote_host_transition".to_string(),
69            TerminalEvent::SubShellDetected { .. } => "sub_shell_detected".to_string(),
70            TerminalEvent::FileTransferStarted { .. } => "file_transfer_started".to_string(),
71            TerminalEvent::FileTransferProgress { .. } => "file_transfer_progress".to_string(),
72            TerminalEvent::FileTransferCompleted { .. } => "file_transfer_completed".to_string(),
73            TerminalEvent::FileTransferFailed { .. } => "file_transfer_failed".to_string(),
74            TerminalEvent::UploadRequested { .. } => "upload_requested".to_string(),
75        }
76    }
77
78    /// Convert a core `TerminalEvent` into the scripting protocol `ScriptEvent`.
79    fn convert_event(event: &TerminalEvent) -> ScriptEvent {
80        let kind = Self::event_kind_name(event);
81
82        let data = match event {
83            TerminalEvent::BellRang(_) => ScriptEventData::Empty {},
84
85            TerminalEvent::TitleChanged(title) => ScriptEventData::TitleChanged {
86                title: title.clone(),
87            },
88
89            TerminalEvent::SizeChanged(cols, rows) => ScriptEventData::SizeChanged {
90                cols: *cols,
91                rows: *rows,
92            },
93
94            TerminalEvent::CwdChanged(cwd_change) => ScriptEventData::CwdChanged {
95                cwd: cwd_change.new_cwd.clone(),
96            },
97
98            TerminalEvent::UserVarChanged {
99                name,
100                value,
101                old_value,
102            } => ScriptEventData::VariableChanged {
103                name: name.clone(),
104                value: value.clone(),
105                old_value: old_value.clone(),
106            },
107
108            TerminalEvent::EnvironmentChanged {
109                key,
110                value,
111                old_value,
112            } => ScriptEventData::EnvironmentChanged {
113                key: key.clone(),
114                value: value.clone(),
115                old_value: old_value.clone(),
116            },
117
118            TerminalEvent::BadgeChanged(text) => {
119                ScriptEventData::BadgeChanged { text: text.clone() }
120            }
121
122            TerminalEvent::ShellIntegrationEvent {
123                command, exit_code, ..
124            } => ScriptEventData::CommandComplete {
125                command: command.clone().unwrap_or_default(),
126                exit_code: *exit_code,
127            },
128
129            TerminalEvent::TriggerMatched(trigger_match) => ScriptEventData::TriggerMatched {
130                pattern: format!("trigger:{}", trigger_match.trigger_id),
131                matched_text: trigger_match.text.clone(),
132                line: trigger_match.row,
133            },
134
135            TerminalEvent::ZoneOpened {
136                zone_id, zone_type, ..
137            } => ScriptEventData::ZoneEvent {
138                zone_id: *zone_id as u64,
139                zone_type: zone_type.to_string(),
140                event: "opened".to_string(),
141            },
142
143            TerminalEvent::ZoneClosed {
144                zone_id, zone_type, ..
145            } => ScriptEventData::ZoneEvent {
146                zone_id: *zone_id as u64,
147                zone_type: zone_type.to_string(),
148                event: "closed".to_string(),
149            },
150
151            TerminalEvent::ZoneScrolledOut {
152                zone_id, zone_type, ..
153            } => ScriptEventData::ZoneEvent {
154                zone_id: *zone_id as u64,
155                zone_type: zone_type.to_string(),
156                event: "scrolled_out".to_string(),
157            },
158
159            // Fallback: capture arbitrary fields via Debug representation.
160            other => {
161                let mut fields = HashMap::new();
162                fields.insert(
163                    "debug".to_string(),
164                    serde_json::Value::String(format!("{:?}", other)),
165                );
166                ScriptEventData::Generic { fields }
167            }
168        };
169
170        ScriptEvent { kind, data }
171    }
172}
173
174// The core library's `TerminalEventKind` subscription filter is separate from
175// our string-based filter. We implement *both*:
176//  1. `subscriptions()` returns `None` so the core dispatches every event to us.
177//  2. `on_event()` applies our string-based filter before buffering.
178//
179// This keeps the filtering logic in one place (the string names match the
180// scripting protocol) while still being efficient — the core won't call us
181// for events we've filtered via `TerminalEventKind` if we chose to use that,
182// but since our filter is string-based we handle it ourselves.
183
184impl TerminalObserver for ScriptEventForwarder {
185    fn on_event(&self, event: &TerminalEvent) {
186        // Apply string-based subscription filter.
187        if let Some(ref filter) = self.subscription_filter {
188            let kind = Self::event_kind_name(event);
189            if !filter.contains(&kind) {
190                return;
191            }
192        }
193
194        let script_event = Self::convert_event(event);
195        let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
196        buf.push(script_event);
197    }
198
199    // We do NOT override `subscriptions()` — returning `None` means
200    // "interested in all events" at the core level. Our own string filter
201    // is applied in `on_event` above.
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_event_kind_name_bell() {
210        let event =
211            TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
212        assert_eq!(ScriptEventForwarder::event_kind_name(&event), "bell_rang");
213    }
214
215    #[test]
216    fn test_event_kind_name_title() {
217        let event = TerminalEvent::TitleChanged("hello".to_string());
218        assert_eq!(
219            ScriptEventForwarder::event_kind_name(&event),
220            "title_changed"
221        );
222    }
223
224    #[test]
225    fn test_convert_bell_event() {
226        let event =
227            TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
228        let script_event = ScriptEventForwarder::convert_event(&event);
229        assert_eq!(script_event.kind, "bell_rang");
230        assert_eq!(script_event.data, ScriptEventData::Empty {});
231    }
232
233    #[test]
234    fn test_convert_title_event() {
235        let event = TerminalEvent::TitleChanged("My Title".to_string());
236        let script_event = ScriptEventForwarder::convert_event(&event);
237        assert_eq!(script_event.kind, "title_changed");
238        assert_eq!(
239            script_event.data,
240            ScriptEventData::TitleChanged {
241                title: "My Title".to_string(),
242            }
243        );
244    }
245
246    #[test]
247    fn test_convert_size_event() {
248        let event = TerminalEvent::SizeChanged(120, 40);
249        let script_event = ScriptEventForwarder::convert_event(&event);
250        assert_eq!(script_event.kind, "size_changed");
251        assert_eq!(
252            script_event.data,
253            ScriptEventData::SizeChanged {
254                cols: 120,
255                rows: 40,
256            }
257        );
258    }
259
260    #[test]
261    fn test_forwarder_no_filter_captures_all() {
262        let fwd = ScriptEventForwarder::new(None);
263        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
264        let title = TerminalEvent::TitleChanged("t".to_string());
265
266        fwd.on_event(&bell);
267        fwd.on_event(&title);
268
269        let events = fwd.drain_events();
270        assert_eq!(events.len(), 2);
271        assert_eq!(events[0].kind, "bell_rang");
272        assert_eq!(events[1].kind, "title_changed");
273    }
274
275    #[test]
276    fn test_forwarder_filters_by_subscription() {
277        let filter = HashSet::from(["bell_rang".to_string()]);
278        let fwd = ScriptEventForwarder::new(Some(filter));
279
280        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
281        let title = TerminalEvent::TitleChanged("t".to_string());
282
283        fwd.on_event(&bell);
284        fwd.on_event(&title);
285
286        let events = fwd.drain_events();
287        assert_eq!(events.len(), 1);
288        assert_eq!(events[0].kind, "bell_rang");
289    }
290
291    #[test]
292    fn test_drain_clears_buffer() {
293        let fwd = ScriptEventForwarder::new(None);
294        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
295
296        fwd.on_event(&bell);
297        let events = fwd.drain_events();
298        assert_eq!(events.len(), 1);
299
300        // Second drain should be empty.
301        let events2 = fwd.drain_events();
302        assert!(events2.is_empty());
303    }
304}