Skip to main content

teksilo_core/
app_event.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Application-level events for cross-thread communication.
5//!
6//! Background threads post `AppEvent`s to the UI thread via an event loop
7//! proxy. The UI thread processes them like any other input event.
8
9use std::any::Any;
10use std::path::PathBuf;
11
12use crate::event_source::SubscriptionId;
13
14/// Events posted to the UI thread from background threads or timers.
15pub enum AppEvent {
16    /// A background operation completed.
17    BackgroundComplete { operation_id: String },
18
19    /// A background operation reports progress.
20    BackgroundProgress {
21        operation_id: String,
22        percent: f32,
23        message: String,
24    },
25
26    /// An external event from any source (type-erased for extensibility).
27    External(Box<dyn Any + Send>),
28
29    /// A backend event delivered to a widget that subscribed via
30    /// `BuildContext::subscribe_event`. The `sub_id` keys the UI-side
31    /// callback in the tree's `TreeAppContext::subscription_callbacks` map;
32    /// the `event` is downcast back to the subscriber's expected type.
33    SubscriptionEvent {
34        sub_id: SubscriptionId,
35        event: Box<dyn Any + Send>,
36    },
37
38    /// An `.ftl` translation file registered via
39    /// `I18nConfig::runtime_override(locale, path)` changed on disk.
40    /// The teksilo-app handler calls `I18nManager::reload_from_path` and
41    /// bumps the translation version signal.
42    I18nReload { locale: String, path: PathBuf },
43
44    /// A settings file managed by `teksilo-settings` changed on disk —
45    /// either a peer process's write, or (harmlessly) this very
46    /// process's own write being noticed by its own watcher. The
47    /// teksilo-app handler looks `path` up in the app's
48    /// `teksilo_settings::SettingsRegistry` (via `app_state`) and calls
49    /// `Reloadable::reload_from_disk` on whatever owns it — a no-op if
50    /// nothing is registered for that path, or if the content is
51    /// unchanged (the self-write case).
52    SettingsReload { path: PathBuf },
53
54    /// A `teksilo-settings` `DebouncedWriter` gave up on a queued write —
55    /// after `MAX_WRITE_ATTEMPTS` retries, or at `Unregister` teardown
56    /// with a write still failing. The queued patches for `path` were
57    /// permanently discarded.
58    SettingsWriteFailed {
59        path: PathBuf,
60        attempts: u32,
61        dropped_patches: usize,
62        message: String,
63    },
64}
65
66/// A generic, thread-safe "please repaint this window now" request, posted as
67/// an [`AppEvent::External`] payload from a background thread via
68/// [`AppEventPoster::post_external`](crate::AppEventPoster::post_external).
69///
70/// A bare redraw request re-presents each node's cached paint frame, so a
71/// widget whose content changed **off the UI thread** — a terminal emulator's
72/// PTY-reader thread, a video decoder, a streaming data source — would not have
73/// its `paint()` re-run. teksilo-app routes this request by marking the named
74/// window's tree paint-dirty ([`WidgetTree::mark_all_needs_paint_only`](crate::widget_tree::WidgetTree::mark_all_needs_paint_only))
75/// before the redraw, so the changed widget repaints. It is the off-thread
76/// analogue of `ctx.request_frame()` (which is UI-thread only).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct RepaintWindowRequest {
79    /// The window whose tree should be marked paint-dirty and redrawn.
80    pub window_id: crate::window::TeksiloWindowId,
81}
82
83impl std::fmt::Debug for AppEvent {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            Self::BackgroundComplete { operation_id } => f
87                .debug_struct("BackgroundComplete")
88                .field("operation_id", operation_id)
89                .finish(),
90            Self::BackgroundProgress {
91                operation_id,
92                percent,
93                message,
94            } => f
95                .debug_struct("BackgroundProgress")
96                .field("operation_id", operation_id)
97                .field("percent", percent)
98                .field("message", message)
99                .finish(),
100            Self::External(_) => f.debug_tuple("External").field(&"..").finish(),
101            Self::SubscriptionEvent { sub_id, .. } => f
102                .debug_struct("SubscriptionEvent")
103                .field("sub_id", sub_id)
104                .field("event", &"..")
105                .finish(),
106            Self::I18nReload { locale, path } => f
107                .debug_struct("I18nReload")
108                .field("locale", locale)
109                .field("path", path)
110                .finish(),
111            Self::SettingsReload { path } => f
112                .debug_struct("SettingsReload")
113                .field("path", path)
114                .finish(),
115            Self::SettingsWriteFailed {
116                path,
117                attempts,
118                dropped_patches,
119                message,
120            } => f
121                .debug_struct("SettingsWriteFailed")
122                .field("path", path)
123                .field("attempts", attempts)
124                .field("dropped_patches", dropped_patches)
125                .field("message", message)
126                .finish(),
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn app_event_background_complete() {
137        let event = AppEvent::BackgroundComplete {
138            operation_id: "export-123".to_string(),
139        };
140        if let AppEvent::BackgroundComplete { operation_id } = event {
141            assert_eq!(operation_id, "export-123");
142        } else {
143            panic!("Expected BackgroundComplete");
144        }
145    }
146
147    #[test]
148    fn app_event_background_progress() {
149        let event = AppEvent::BackgroundProgress {
150            operation_id: "export-123".to_string(),
151            percent: 0.5,
152            message: "Halfway done".to_string(),
153        };
154        if let AppEvent::BackgroundProgress { percent, .. } = event {
155            assert!((percent - 0.5).abs() < 0.001);
156        } else {
157            panic!("Expected BackgroundProgress");
158        }
159    }
160
161    #[test]
162    fn app_event_external_any() {
163        let event = AppEvent::External(Box::new(42i32));
164        if let AppEvent::External(payload) = event {
165            assert_eq!(*payload.downcast_ref::<i32>().unwrap(), 42);
166        } else {
167            panic!("Expected External");
168        }
169    }
170}