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, VecDeque};
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/// Maximum events retained in a forwarder's buffer.
16///
17/// The buffer is filled from the PTY reader thread and emptied by the event
18/// loop, so anything that stops the loop draining a particular forwarder makes
19/// it grow for the life of the process. A script that exits on its own leaves
20/// its observer registered — only an explicit stop unregisters it — so that
21/// case is reachable no matter how thoroughly the event loop sweeps its tabs,
22/// and this cap is the only thing bounding it.
23const MAX_BUFFERED_EVENTS: usize = 1024;
24
25/// The buffered events plus the once-only overflow latch, behind one lock.
26struct EventBuffer {
27    events: VecDeque<ScriptEvent>,
28    /// Set the first time the cap is reached, so overflow is reported once per
29    /// forwarder instead of once per discarded event. Never cleared: a drain
30    /// does not mean the underlying leak was fixed, and re-arming would let a
31    /// forwarder that overflows and drains repeatedly flood the log.
32    overflow_reported: bool,
33}
34
35/// Bridge between core terminal observer events and the scripting JSON protocol.
36///
37/// Register an `Arc<ScriptEventForwarder>` with `Terminal::add_observer()`.
38/// The forwarder buffers converted events; the owner drains them via
39/// [`drain_events`] and serialises them to script sub-processes.
40pub struct ScriptEventForwarder {
41    /// Optional subscription filter expressed as snake_case kind names.
42    /// `None` means "forward everything".
43    subscription_filter: Option<HashSet<String>>,
44    /// Thread-safe event buffer (uses std Mutex since observer callbacks are
45    /// invoked from the PTY reader thread), capped at [`MAX_BUFFERED_EVENTS`].
46    event_buffer: Mutex<EventBuffer>,
47}
48
49impl ScriptEventForwarder {
50    /// Create a new forwarder.
51    ///
52    /// # Arguments
53    /// * `subscriptions` - If `Some`, only events whose snake_case kind name
54    ///   is in the set will be captured. If `None`, all events are captured.
55    pub fn new(subscriptions: Option<HashSet<String>>) -> Self {
56        Self {
57            subscription_filter: subscriptions,
58            event_buffer: Mutex::new(EventBuffer {
59                events: VecDeque::new(),
60                overflow_reported: false,
61            }),
62        }
63    }
64
65    /// Drain all buffered events, returning them and clearing the buffer.
66    pub fn drain_events(&self) -> Vec<ScriptEvent> {
67        let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
68        buf.events.drain(..).collect()
69    }
70
71    /// Map a `TerminalEvent` to its snake_case kind name used by the protocol.
72    fn event_kind_name(event: &TerminalEvent) -> String {
73        match event {
74            TerminalEvent::BellRang(_) => "bell_rang".to_string(),
75            TerminalEvent::TitleChanged(_) => "title_changed".to_string(),
76            TerminalEvent::SizeChanged(_, _) => "size_changed".to_string(),
77            TerminalEvent::ModeChanged(_, _) => "mode_changed".to_string(),
78            TerminalEvent::GraphicsAdded(_) => "graphics_added".to_string(),
79            TerminalEvent::HyperlinkAdded { .. } => "hyperlink_added".to_string(),
80            TerminalEvent::DirtyRegion(_, _) => "dirty_region".to_string(),
81            TerminalEvent::CwdChanged(_) => "cwd_changed".to_string(),
82            TerminalEvent::TriggerMatched(_) => "trigger_matched".to_string(),
83            TerminalEvent::UserVarChanged { .. } => "user_var_changed".to_string(),
84            TerminalEvent::ProgressBarChanged { .. } => "progress_bar_changed".to_string(),
85            TerminalEvent::BadgeChanged(_) => "badge_changed".to_string(),
86            TerminalEvent::ShellIntegrationEvent { .. } => "command_complete".to_string(),
87            TerminalEvent::ZoneOpened { .. } => "zone_opened".to_string(),
88            TerminalEvent::ZoneClosed { .. } => "zone_closed".to_string(),
89            TerminalEvent::ZoneScrolledOut { .. } => "zone_scrolled_out".to_string(),
90            TerminalEvent::EnvironmentChanged { .. } => "environment_changed".to_string(),
91            TerminalEvent::RemoteHostTransition { .. } => "remote_host_transition".to_string(),
92            TerminalEvent::SubShellDetected { .. } => "sub_shell_detected".to_string(),
93            TerminalEvent::FileTransferStarted { .. } => "file_transfer_started".to_string(),
94            TerminalEvent::FileTransferProgress { .. } => "file_transfer_progress".to_string(),
95            TerminalEvent::FileTransferCompleted { .. } => "file_transfer_completed".to_string(),
96            TerminalEvent::FileTransferFailed { .. } => "file_transfer_failed".to_string(),
97            TerminalEvent::UploadRequested { .. } => "upload_requested".to_string(),
98            TerminalEvent::ScreenCleared { .. } => "screen_cleared".to_string(),
99        }
100    }
101
102    /// Convert a core `TerminalEvent` into the scripting protocol `ScriptEvent`.
103    fn convert_event(event: &TerminalEvent) -> ScriptEvent {
104        let kind = Self::event_kind_name(event);
105
106        let data = match event {
107            TerminalEvent::BellRang(_) => ScriptEventData::Empty {},
108
109            TerminalEvent::TitleChanged(title) => ScriptEventData::TitleChanged {
110                title: title.clone(),
111            },
112
113            TerminalEvent::SizeChanged(cols, rows) => ScriptEventData::SizeChanged {
114                cols: *cols,
115                rows: *rows,
116            },
117
118            TerminalEvent::CwdChanged(cwd_change) => ScriptEventData::CwdChanged {
119                cwd: cwd_change.new_cwd.clone(),
120            },
121
122            TerminalEvent::UserVarChanged {
123                name,
124                value,
125                old_value,
126            } => ScriptEventData::VariableChanged {
127                name: name.clone(),
128                value: value.clone(),
129                old_value: old_value.clone(),
130            },
131
132            TerminalEvent::EnvironmentChanged {
133                key,
134                value,
135                old_value,
136            } => ScriptEventData::EnvironmentChanged {
137                key: key.clone(),
138                value: value.clone(),
139                old_value: old_value.clone(),
140            },
141
142            TerminalEvent::BadgeChanged(text) => {
143                ScriptEventData::BadgeChanged { text: text.clone() }
144            }
145
146            TerminalEvent::ShellIntegrationEvent {
147                command, exit_code, ..
148            } => ScriptEventData::CommandComplete {
149                command: command.clone().unwrap_or_default(),
150                exit_code: *exit_code,
151            },
152
153            TerminalEvent::TriggerMatched(trigger_match) => ScriptEventData::TriggerMatched {
154                pattern: format!("trigger:{}", trigger_match.trigger_id),
155                matched_text: trigger_match.text.clone(),
156                line: trigger_match.row,
157            },
158
159            TerminalEvent::ZoneOpened {
160                zone_id, zone_type, ..
161            } => ScriptEventData::ZoneEvent {
162                zone_id: *zone_id as u64,
163                zone_type: zone_type.to_string(),
164                event: "opened".to_string(),
165            },
166
167            TerminalEvent::ZoneClosed {
168                zone_id, zone_type, ..
169            } => ScriptEventData::ZoneEvent {
170                zone_id: *zone_id as u64,
171                zone_type: zone_type.to_string(),
172                event: "closed".to_string(),
173            },
174
175            TerminalEvent::ZoneScrolledOut {
176                zone_id, zone_type, ..
177            } => ScriptEventData::ZoneEvent {
178                zone_id: *zone_id as u64,
179                zone_type: zone_type.to_string(),
180                event: "scrolled_out".to_string(),
181            },
182
183            // Fallback: capture arbitrary fields via Debug representation.
184            other => {
185                let mut fields = HashMap::new();
186                fields.insert(
187                    "debug".to_string(),
188                    serde_json::Value::String(format!("{:?}", other)),
189                );
190                ScriptEventData::Generic { fields }
191            }
192        };
193
194        ScriptEvent { kind, data }
195    }
196}
197
198// The core library's `TerminalEventKind` subscription filter is separate from
199// our string-based filter. We implement *both*:
200//  1. `subscriptions()` returns `None` so the core dispatches every event to us.
201//  2. `on_event()` applies our string-based filter before buffering.
202//
203// This keeps the filtering logic in one place (the string names match the
204// scripting protocol) while still being efficient — the core won't call us
205// for events we've filtered via `TerminalEventKind` if we chose to use that,
206// but since our filter is string-based we handle it ourselves.
207
208impl TerminalObserver for ScriptEventForwarder {
209    fn on_event(&self, event: &TerminalEvent) {
210        // Apply string-based subscription filter.
211        if let Some(ref filter) = self.subscription_filter {
212            let kind = Self::event_kind_name(event);
213            if !filter.contains(&kind) {
214                return;
215            }
216        }
217
218        let script_event = Self::convert_event(event);
219        let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
220
221        if buf.events.len() >= MAX_BUFFERED_EVENTS {
222            // Drop the oldest rather than the newest. A script acts on what just
223            // happened, so discarding the incoming event would hide the very
224            // thing that is still worth reacting to and leave a stale prefix.
225            buf.events.pop_front();
226            if !buf.overflow_reported {
227                buf.overflow_reported = true;
228                log::warn!(
229                    "Script event buffer reached its {} event cap; discarding oldest \
230                     events from here on. Nothing is draining this forwarder — the \
231                     script most likely exited while its observer stayed registered.",
232                    MAX_BUFFERED_EVENTS
233                );
234            }
235        }
236
237        buf.events.push_back(script_event);
238    }
239
240    // We do NOT override `subscriptions()` — returning `None` means
241    // "interested in all events" at the core level. Our own string filter
242    // is applied in `on_event` above.
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_event_kind_name_bell() {
251        let event =
252            TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
253        assert_eq!(ScriptEventForwarder::event_kind_name(&event), "bell_rang");
254    }
255
256    #[test]
257    fn test_event_kind_name_title() {
258        let event = TerminalEvent::TitleChanged("hello".to_string());
259        assert_eq!(
260            ScriptEventForwarder::event_kind_name(&event),
261            "title_changed"
262        );
263    }
264
265    #[test]
266    fn test_convert_bell_event() {
267        let event =
268            TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
269        let script_event = ScriptEventForwarder::convert_event(&event);
270        assert_eq!(script_event.kind, "bell_rang");
271        assert_eq!(script_event.data, ScriptEventData::Empty {});
272    }
273
274    #[test]
275    fn test_convert_title_event() {
276        let event = TerminalEvent::TitleChanged("My Title".to_string());
277        let script_event = ScriptEventForwarder::convert_event(&event);
278        assert_eq!(script_event.kind, "title_changed");
279        assert_eq!(
280            script_event.data,
281            ScriptEventData::TitleChanged {
282                title: "My Title".to_string(),
283            }
284        );
285    }
286
287    #[test]
288    fn test_convert_size_event() {
289        let event = TerminalEvent::SizeChanged(120, 40);
290        let script_event = ScriptEventForwarder::convert_event(&event);
291        assert_eq!(script_event.kind, "size_changed");
292        assert_eq!(
293            script_event.data,
294            ScriptEventData::SizeChanged {
295                cols: 120,
296                rows: 40,
297            }
298        );
299    }
300
301    #[test]
302    fn test_forwarder_no_filter_captures_all() {
303        let fwd = ScriptEventForwarder::new(None);
304        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
305        let title = TerminalEvent::TitleChanged("t".to_string());
306
307        fwd.on_event(&bell);
308        fwd.on_event(&title);
309
310        let events = fwd.drain_events();
311        assert_eq!(events.len(), 2);
312        assert_eq!(events[0].kind, "bell_rang");
313        assert_eq!(events[1].kind, "title_changed");
314    }
315
316    #[test]
317    fn test_forwarder_filters_by_subscription() {
318        let filter = HashSet::from(["bell_rang".to_string()]);
319        let fwd = ScriptEventForwarder::new(Some(filter));
320
321        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
322        let title = TerminalEvent::TitleChanged("t".to_string());
323
324        fwd.on_event(&bell);
325        fwd.on_event(&title);
326
327        let events = fwd.drain_events();
328        assert_eq!(events.len(), 1);
329        assert_eq!(events[0].kind, "bell_rang");
330    }
331
332    /// Extract the title from a `title_changed` event, so the drop-oldest tests
333    /// can identify which events survived.
334    fn title_of(event: &ScriptEvent) -> &str {
335        match &event.data {
336            ScriptEventData::TitleChanged { title } => title,
337            other => panic!("expected a title_changed event, got {other:?}"),
338        }
339    }
340
341    #[test]
342    fn buffer_stops_growing_at_the_cap() {
343        let fwd = ScriptEventForwarder::new(None);
344        for i in 0..(MAX_BUFFERED_EVENTS * 2) {
345            fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
346        }
347        assert_eq!(fwd.drain_events().len(), MAX_BUFFERED_EVENTS);
348    }
349
350    #[test]
351    fn overflow_drops_the_oldest_events_not_the_newest() {
352        let fwd = ScriptEventForwarder::new(None);
353        let overflow = 10;
354        for i in 0..(MAX_BUFFERED_EVENTS + overflow) {
355            fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
356        }
357
358        let events = fwd.drain_events();
359        assert_eq!(events.len(), MAX_BUFFERED_EVENTS);
360        // The first `overflow` events were discarded; the most recent survived.
361        assert_eq!(title_of(&events[0]), overflow.to_string());
362        assert_eq!(
363            title_of(&events[MAX_BUFFERED_EVENTS - 1]),
364            (MAX_BUFFERED_EVENTS + overflow - 1).to_string()
365        );
366    }
367
368    #[test]
369    fn draining_frees_the_capacity_again() {
370        let fwd = ScriptEventForwarder::new(None);
371        for i in 0..MAX_BUFFERED_EVENTS {
372            fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
373        }
374        assert_eq!(fwd.drain_events().len(), MAX_BUFFERED_EVENTS);
375
376        // A drained forwarder accepts a full buffer again without dropping.
377        fwd.on_event(&TerminalEvent::TitleChanged("after-drain".to_string()));
378        let events = fwd.drain_events();
379        assert_eq!(events.len(), 1);
380        assert_eq!(title_of(&events[0]), "after-drain");
381    }
382
383    #[test]
384    fn filtered_out_events_do_not_consume_buffer_capacity() {
385        let filter = HashSet::from(["bell_rang".to_string()]);
386        let fwd = ScriptEventForwarder::new(Some(filter));
387        for i in 0..(MAX_BUFFERED_EVENTS * 2) {
388            fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
389        }
390        assert!(fwd.drain_events().is_empty());
391    }
392
393    #[test]
394    fn test_drain_clears_buffer() {
395        let fwd = ScriptEventForwarder::new(None);
396        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
397
398        fwd.on_event(&bell);
399        let events = fwd.drain_events();
400        assert_eq!(events.len(), 1);
401
402        // Second drain should be empty.
403        let events2 = fwd.drain_events();
404        assert!(events2.is_empty());
405    }
406}