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            TerminalEvent::ScreenCleared { .. } => "screen_cleared".to_string(),
76        }
77    }
78
79    /// Convert a core `TerminalEvent` into the scripting protocol `ScriptEvent`.
80    fn convert_event(event: &TerminalEvent) -> ScriptEvent {
81        let kind = Self::event_kind_name(event);
82
83        let data = match event {
84            TerminalEvent::BellRang(_) => ScriptEventData::Empty {},
85
86            TerminalEvent::TitleChanged(title) => ScriptEventData::TitleChanged {
87                title: title.clone(),
88            },
89
90            TerminalEvent::SizeChanged(cols, rows) => ScriptEventData::SizeChanged {
91                cols: *cols,
92                rows: *rows,
93            },
94
95            TerminalEvent::CwdChanged(cwd_change) => ScriptEventData::CwdChanged {
96                cwd: cwd_change.new_cwd.clone(),
97            },
98
99            TerminalEvent::UserVarChanged {
100                name,
101                value,
102                old_value,
103            } => ScriptEventData::VariableChanged {
104                name: name.clone(),
105                value: value.clone(),
106                old_value: old_value.clone(),
107            },
108
109            TerminalEvent::EnvironmentChanged {
110                key,
111                value,
112                old_value,
113            } => ScriptEventData::EnvironmentChanged {
114                key: key.clone(),
115                value: value.clone(),
116                old_value: old_value.clone(),
117            },
118
119            TerminalEvent::BadgeChanged(text) => {
120                ScriptEventData::BadgeChanged { text: text.clone() }
121            }
122
123            TerminalEvent::ShellIntegrationEvent {
124                command, exit_code, ..
125            } => ScriptEventData::CommandComplete {
126                command: command.clone().unwrap_or_default(),
127                exit_code: *exit_code,
128            },
129
130            TerminalEvent::TriggerMatched(trigger_match) => ScriptEventData::TriggerMatched {
131                pattern: format!("trigger:{}", trigger_match.trigger_id),
132                matched_text: trigger_match.text.clone(),
133                line: trigger_match.row,
134            },
135
136            TerminalEvent::ZoneOpened {
137                zone_id, zone_type, ..
138            } => ScriptEventData::ZoneEvent {
139                zone_id: *zone_id as u64,
140                zone_type: zone_type.to_string(),
141                event: "opened".to_string(),
142            },
143
144            TerminalEvent::ZoneClosed {
145                zone_id, zone_type, ..
146            } => ScriptEventData::ZoneEvent {
147                zone_id: *zone_id as u64,
148                zone_type: zone_type.to_string(),
149                event: "closed".to_string(),
150            },
151
152            TerminalEvent::ZoneScrolledOut {
153                zone_id, zone_type, ..
154            } => ScriptEventData::ZoneEvent {
155                zone_id: *zone_id as u64,
156                zone_type: zone_type.to_string(),
157                event: "scrolled_out".to_string(),
158            },
159
160            // Fallback: capture arbitrary fields via Debug representation.
161            other => {
162                let mut fields = HashMap::new();
163                fields.insert(
164                    "debug".to_string(),
165                    serde_json::Value::String(format!("{:?}", other)),
166                );
167                ScriptEventData::Generic { fields }
168            }
169        };
170
171        ScriptEvent { kind, data }
172    }
173}
174
175// The core library's `TerminalEventKind` subscription filter is separate from
176// our string-based filter. We implement *both*:
177//  1. `subscriptions()` returns `None` so the core dispatches every event to us.
178//  2. `on_event()` applies our string-based filter before buffering.
179//
180// This keeps the filtering logic in one place (the string names match the
181// scripting protocol) while still being efficient — the core won't call us
182// for events we've filtered via `TerminalEventKind` if we chose to use that,
183// but since our filter is string-based we handle it ourselves.
184
185impl TerminalObserver for ScriptEventForwarder {
186    fn on_event(&self, event: &TerminalEvent) {
187        // Apply string-based subscription filter.
188        if let Some(ref filter) = self.subscription_filter {
189            let kind = Self::event_kind_name(event);
190            if !filter.contains(&kind) {
191                return;
192            }
193        }
194
195        let script_event = Self::convert_event(event);
196        let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
197        buf.push(script_event);
198    }
199
200    // We do NOT override `subscriptions()` — returning `None` means
201    // "interested in all events" at the core level. Our own string filter
202    // is applied in `on_event` above.
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_event_kind_name_bell() {
211        let event =
212            TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
213        assert_eq!(ScriptEventForwarder::event_kind_name(&event), "bell_rang");
214    }
215
216    #[test]
217    fn test_event_kind_name_title() {
218        let event = TerminalEvent::TitleChanged("hello".to_string());
219        assert_eq!(
220            ScriptEventForwarder::event_kind_name(&event),
221            "title_changed"
222        );
223    }
224
225    #[test]
226    fn test_convert_bell_event() {
227        let event =
228            TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
229        let script_event = ScriptEventForwarder::convert_event(&event);
230        assert_eq!(script_event.kind, "bell_rang");
231        assert_eq!(script_event.data, ScriptEventData::Empty {});
232    }
233
234    #[test]
235    fn test_convert_title_event() {
236        let event = TerminalEvent::TitleChanged("My Title".to_string());
237        let script_event = ScriptEventForwarder::convert_event(&event);
238        assert_eq!(script_event.kind, "title_changed");
239        assert_eq!(
240            script_event.data,
241            ScriptEventData::TitleChanged {
242                title: "My Title".to_string(),
243            }
244        );
245    }
246
247    #[test]
248    fn test_convert_size_event() {
249        let event = TerminalEvent::SizeChanged(120, 40);
250        let script_event = ScriptEventForwarder::convert_event(&event);
251        assert_eq!(script_event.kind, "size_changed");
252        assert_eq!(
253            script_event.data,
254            ScriptEventData::SizeChanged {
255                cols: 120,
256                rows: 40,
257            }
258        );
259    }
260
261    #[test]
262    fn test_forwarder_no_filter_captures_all() {
263        let fwd = ScriptEventForwarder::new(None);
264        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
265        let title = TerminalEvent::TitleChanged("t".to_string());
266
267        fwd.on_event(&bell);
268        fwd.on_event(&title);
269
270        let events = fwd.drain_events();
271        assert_eq!(events.len(), 2);
272        assert_eq!(events[0].kind, "bell_rang");
273        assert_eq!(events[1].kind, "title_changed");
274    }
275
276    #[test]
277    fn test_forwarder_filters_by_subscription() {
278        let filter = HashSet::from(["bell_rang".to_string()]);
279        let fwd = ScriptEventForwarder::new(Some(filter));
280
281        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
282        let title = TerminalEvent::TitleChanged("t".to_string());
283
284        fwd.on_event(&bell);
285        fwd.on_event(&title);
286
287        let events = fwd.drain_events();
288        assert_eq!(events.len(), 1);
289        assert_eq!(events[0].kind, "bell_rang");
290    }
291
292    #[test]
293    fn test_drain_clears_buffer() {
294        let fwd = ScriptEventForwarder::new(None);
295        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
296
297        fwd.on_event(&bell);
298        let events = fwd.drain_events();
299        assert_eq!(events.len(), 1);
300
301        // Second drain should be empty.
302        let events2 = fwd.drain_events();
303        assert!(events2.is_empty());
304    }
305}