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}