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