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.
15///
16/// Non-exhaustive: a `match` on it outside teksilo-core needs a wildcard arm.
17#[non_exhaustive]
18pub enum AppEvent {
19    /// A background operation completed.
20    BackgroundComplete { operation_id: String },
21
22    /// A background operation reports progress.
23    BackgroundProgress {
24        operation_id: String,
25        percent: f32,
26        message: String,
27    },
28
29    /// An external event from any source (type-erased for extensibility).
30    External(Box<dyn Any + Send>),
31
32    /// A backend event delivered to a widget that subscribed via
33    /// `BuildContext::subscribe_event`. The `sub_id` keys the UI-side
34    /// callback in the tree's `TreeAppContext::subscription_callbacks` map;
35    /// the `event` is downcast back to the subscriber's expected type.
36    SubscriptionEvent {
37        sub_id: SubscriptionId,
38        event: Box<dyn Any + Send>,
39    },
40
41    /// An `.ftl` translation file registered via
42    /// `I18nConfig::runtime_override(locale, path)` changed on disk.
43    /// The teksilo-app handler calls `I18nManager::reload_from_path` and
44    /// bumps the translation version signal.
45    I18nReload { locale: String, path: PathBuf },
46
47    /// A settings file managed by `teksilo-settings` changed on disk —
48    /// either a peer process's write, or (harmlessly) this very
49    /// process's own write being noticed by its own watcher. The
50    /// teksilo-app handler looks `path` up in the app's
51    /// `teksilo_settings::SettingsRegistry` (via `app_state`) and calls
52    /// `Reloadable::reload_from_disk` on whatever owns it — a no-op if
53    /// nothing is registered for that path, or if the content is
54    /// unchanged (the self-write case).
55    SettingsReload { path: PathBuf },
56
57    /// A `teksilo-settings` `DebouncedWriter` gave up on a queued write —
58    /// after `MAX_WRITE_ATTEMPTS` retries, or at `Unregister` teardown
59    /// with a write still failing. The queued patches for `path` were
60    /// permanently discarded.
61    SettingsWriteFailed {
62        path: PathBuf,
63        attempts: u32,
64        dropped_patches: usize,
65        message: String,
66    },
67}
68
69/// A thread-safe "please repaint this window now" request, posted as an
70/// [`AppEvent::External`] payload from a background thread via
71/// [`AppEventPoster::post_external`](crate::AppEventPoster::post_external).
72///
73/// teksilo-app routes it by marking **every** widget of the named window's
74/// tree paint-dirty
75/// ([`WidgetTree::mark_all_needs_paint_only`](crate::widget_tree::WidgetTree::mark_all_needs_paint_only))
76/// and redrawing **every** window. Attach a [`RepaintTrigger`](crate::RepaintTrigger)
77/// instead: it repaints only the widgets it is attached to and wakes only
78/// their window, needs no window id or poster, and can also relayout a widget
79/// or have it take content in when it is not shown. Removed at the next
80/// breaking release; it still works as before.
81#[deprecated(
82    since = "0.16.0",
83    note = "attach a `RepaintTrigger` with `BuildContext::attach_repaint_trigger`: it repaints only the attached widgets and redraws only their window"
84)]
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct RepaintWindowRequest {
87    /// The window whose tree should be marked paint-dirty and redrawn.
88    pub window_id: crate::window::TeksiloWindowId,
89}
90
91impl std::fmt::Debug for AppEvent {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::BackgroundComplete { operation_id } => f
95                .debug_struct("BackgroundComplete")
96                .field("operation_id", operation_id)
97                .finish(),
98            Self::BackgroundProgress {
99                operation_id,
100                percent,
101                message,
102            } => f
103                .debug_struct("BackgroundProgress")
104                .field("operation_id", operation_id)
105                .field("percent", percent)
106                .field("message", message)
107                .finish(),
108            Self::External(_) => f.debug_tuple("External").field(&"..").finish(),
109            Self::SubscriptionEvent { sub_id, .. } => f
110                .debug_struct("SubscriptionEvent")
111                .field("sub_id", sub_id)
112                .field("event", &"..")
113                .finish(),
114            Self::I18nReload { locale, path } => f
115                .debug_struct("I18nReload")
116                .field("locale", locale)
117                .field("path", path)
118                .finish(),
119            Self::SettingsReload { path } => f
120                .debug_struct("SettingsReload")
121                .field("path", path)
122                .finish(),
123            Self::SettingsWriteFailed {
124                path,
125                attempts,
126                dropped_patches,
127                message,
128            } => f
129                .debug_struct("SettingsWriteFailed")
130                .field("path", path)
131                .field("attempts", attempts)
132                .field("dropped_patches", dropped_patches)
133                .field("message", message)
134                .finish(),
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn app_event_background_complete() {
145        let event = AppEvent::BackgroundComplete {
146            operation_id: "export-123".to_string(),
147        };
148        if let AppEvent::BackgroundComplete { operation_id } = event {
149            assert_eq!(operation_id, "export-123");
150        } else {
151            panic!("Expected BackgroundComplete");
152        }
153    }
154
155    #[test]
156    fn app_event_background_progress() {
157        let event = AppEvent::BackgroundProgress {
158            operation_id: "export-123".to_string(),
159            percent: 0.5,
160            message: "Halfway done".to_string(),
161        };
162        if let AppEvent::BackgroundProgress { percent, .. } = event {
163            assert!((percent - 0.5).abs() < 0.001);
164        } else {
165            panic!("Expected BackgroundProgress");
166        }
167    }
168
169    #[test]
170    fn app_event_external_any() {
171        let event = AppEvent::External(Box::new(42i32));
172        if let AppEvent::External(payload) = event {
173            assert_eq!(*payload.downcast_ref::<i32>().unwrap(), 42);
174        } else {
175            panic!("Expected External");
176        }
177    }
178}