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}