Skip to main content

plushie_renderer_lib/
app.rs

1//! Application struct and core utility methods.
2//!
3//! Defines the [`App`] struct (the iced daemon's state) and the methods
4//! that the rest of the renderer uses to query window titles, themes,
5//! scale factors, and emit subscription events.
6
7use std::sync::Arc;
8
9use iced::{Task, Theme, keyboard, window};
10
11use plushie_widget_sdk::protocol::OutgoingEvent;
12use plushie_widget_sdk::registry::WidgetRegistry;
13use plushie_widget_sdk::runtime::{Message, ThemeChrome};
14
15use crate::constants::*;
16use crate::effects::EffectHandler;
17use crate::emitter::{CoalesceKey, EventEmitter};
18use crate::emitters::SinkMutex;
19use crate::window_map;
20
21/// Validate and clamp a scale factor. Returns 1.0 for invalid values
22/// (zero, negative, NaN, infinity).
23pub fn validate_scale_factor(sf: f32) -> f32 {
24    if sf <= 0.0 || !sf.is_finite() {
25        log::warn!("invalid scale_factor {sf}, using 1.0");
26        1.0
27    } else {
28        sf
29    }
30}
31
32// ---------------------------------------------------------------------------
33// App state
34// ---------------------------------------------------------------------------
35
36/// The iced daemon application. Owns the rendering engine, window
37/// state, widget registry, and all runtime state needed to translate
38/// between the wire protocol and iced's update/view cycle.
39pub struct App {
40    pub core: plushie_renderer_engine::Core,
41    pub theme: Theme,
42    pub theme_chrome: ThemeChrome,
43    /// Widget ops and effects return iced Tasks, but `apply()` doesn't
44    /// return them. They accumulate here and are drained via `Task::batch`
45    /// in `update()` after `apply()` returns.
46    pub pending_tasks: Vec<Task<Message>>,
47    /// Bidirectional plushie ID <-> iced window ID mapping with per-window state.
48    pub windows: window_map::WindowMap,
49    /// In-memory image handles for use by Image widgets and canvas draw.
50    pub image_registry: plushie_widget_sdk::image_registry::ImageRegistry,
51    /// Current system theme, tracked via ThemeChanged subscription.
52    pub system_theme: Theme,
53    /// True when the app-level theme is "system" (follow OS preference).
54    pub theme_follows_system: bool,
55    /// Global scale factor multiplier (1.0 = follow OS DPI).
56    pub scale_factor: f32,
57    /// Unified widget registry. All widget types are dispatched through
58    /// this registry.
59    pub registry: WidgetRegistry,
60    /// Epoch for animation_frame timestamp calculation.
61    pub animation_epoch: Option<iced::time::Instant>,
62    /// Rate-limited event emitter with coalescing.
63    pub emitter: EventEmitter,
64    /// Platform-specific effect handler injected at construction.
65    /// Native and WASM crates each provide their own [`EffectHandler`]
66    /// implementation.
67    pub effect_handler: Box<dyn EffectHandler>,
68    /// Renderer-side animation manager. Tracks transitions, springs,
69    /// and exit ghosts. Advances on frame ticks and writes interpolated
70    /// values to SharedState.interpolated_props.
71    pub transition_manager: plushie_widget_sdk::animation::TransitionManager,
72    /// Current keyboard modifier state, updated on every ModifiersChanged
73    /// event. Included on all outgoing pointer events.
74    pub current_modifiers: keyboard::Modifiers,
75    /// Wire protocol codec. Used for encoding stub acks and scripting
76    /// responses. Stored here so these paths don't need the global.
77    pub codec: plushie_renderer_engine::Codec,
78}
79
80impl App {
81    pub fn new(
82        registry: WidgetRegistry,
83        effect_handler: Box<dyn EffectHandler>,
84        sink: Arc<SinkMutex>,
85    ) -> Self {
86        Self {
87            core: plushie_renderer_engine::Core::new(),
88            theme: DEFAULT_THEME,
89            theme_chrome: ThemeChrome::default(),
90            pending_tasks: Vec::new(),
91            windows: window_map::WindowMap::new(),
92            image_registry: plushie_widget_sdk::image_registry::ImageRegistry::new(),
93            system_theme: DEFAULT_THEME,
94            theme_follows_system: false,
95            scale_factor: 1.0,
96            registry,
97            animation_epoch: None,
98            emitter: EventEmitter::new(sink),
99            effect_handler,
100            transition_manager: plushie_widget_sdk::animation::TransitionManager::new(),
101            current_modifiers: keyboard::Modifiers::default(),
102            codec: plushie_renderer_engine::Codec::MsgPack,
103        }
104    }
105
106    /// Set the wire protocol codec. Called during startup after
107    /// codec negotiation. Defaults to MsgPack.
108    pub fn set_codec(&mut self, codec: plushie_renderer_engine::Codec) {
109        self.codec = codec;
110    }
111
112    pub fn title_for_window(&self, iced_id: window::Id) -> String {
113        if let Some(window_id) = self.windows.get_window_id(&iced_id)
114            && let Some(node) = self.core.tree.find_window(window_id)
115            && let Some(title) = node.props.get_str("title")
116        {
117            return title.chars().filter(|c| !c.is_control()).collect();
118        }
119        DEFAULT_WINDOW_TITLE.to_string()
120    }
121
122    pub fn theme_for_window(&self, iced_id: window::Id) -> Theme {
123        self.theme_ref_for_window(iced_id).clone()
124    }
125
126    pub fn theme_ref_for_window(&self, iced_id: window::Id) -> &Theme {
127        if let Some(window_id) = self.windows.get_window_id(&iced_id)
128            && self.windows.theme_follows_system(window_id)
129        {
130            return &self.system_theme;
131        }
132        if let Some(window_id) = self.windows.get_window_id(&iced_id)
133            && let Some(cached) = self.windows.cached_theme(window_id)
134        {
135            return cached;
136        }
137        if self.theme_follows_system {
138            &self.system_theme
139        } else {
140            &self.theme
141        }
142    }
143
144    pub fn theme_chrome_for_window(&self, iced_id: window::Id) -> ThemeChrome {
145        if let Some(window_id) = self.windows.get_window_id(&iced_id)
146            && self.windows.theme_follows_system(window_id)
147        {
148            return ThemeChrome::default();
149        }
150        if let Some(window_id) = self.windows.get_window_id(&iced_id)
151            && let Some(chrome) = self.windows.cached_theme_chrome(window_id)
152        {
153            return chrome;
154        }
155        if self.theme_follows_system {
156            ThemeChrome::default()
157        } else {
158            self.theme_chrome
159        }
160    }
161
162    pub fn scale_factor_for_window(&self, iced_id: window::Id) -> f32 {
163        let window_id = self.windows.get_window_id(&iced_id);
164
165        // Per-window override from WindowState (set via window open/update ops).
166        if let Some(sf) = window_id.and_then(|jid| self.windows.scale_factor(jid)) {
167            return validate_scale_factor(sf);
168        }
169
170        // Fall back to the tree node's scale_factor prop.
171        let sf = window_id
172            .and_then(|jid| self.core.tree.find_window(jid))
173            .and_then(|node| node.props.get_f32("scale_factor"))
174            .unwrap_or(self.scale_factor);
175        validate_scale_factor(sf)
176    }
177
178    /// Emit a subscription event to all matching entries (specific kind +
179    /// catch-all SUB_EVENT), filtered by window_id. The event_fn is called
180    /// once per matching entry with the entry's tag.
181    ///
182    /// `event_fn` receives the tag as `&str`; the OutgoingEvent constructor
183    /// allocates the owned tag internally via `impl Into<String>`. Earlier
184    /// versions cloned the tag at the call site before passing it in.
185    pub fn emit_subscription(
186        &self,
187        key: &str,
188        captured: bool,
189        event_fn: impl Fn(&str) -> OutgoingEvent,
190    ) -> Task<Message> {
191        self.emit_subscription_for_window(key, None, captured, event_fn)
192    }
193
194    /// Emit a subscription event scoped to a specific window.
195    pub fn emit_subscription_for_window(
196        &self,
197        key: &str,
198        window_id: Option<&str>,
199        captured: bool,
200        event_fn: impl Fn(&str) -> OutgoingEvent,
201    ) -> Task<Message> {
202        let entries = self
203            .core
204            .matching_entries_with_catchall(key, SUB_EVENT, window_id);
205        // Fast paths for the common 0- and 1-entry cases avoid
206        // allocating a `Vec` and a `Task::batch` per event.
207        match entries.len() {
208            0 => Task::none(),
209            1 => {
210                let entry = &entries[0];
211                self.emitter
212                    .emit_direct(event_fn(entry.tag.as_str()).with_captured(captured))
213            }
214            _ => {
215                let tasks: Vec<_> = entries
216                    .into_iter()
217                    .map(|entry| {
218                        self.emitter
219                            .emit_direct(event_fn(entry.tag.as_str()).with_captured(captured))
220                    })
221                    .collect();
222                Task::batch(tasks)
223            }
224        }
225    }
226
227    pub fn lookup_widget_event_rate(&self, widget_id: &str) -> Option<u32> {
228        let node = self.core.tree.find_by_id(widget_id)?;
229        node.props
230            .get("event_rate")
231            .and_then(|v| v.as_u64())
232            .map(|v| v as u32)
233    }
234
235    /// True when the widget at `widget_id` is declared disabled and is
236    /// of a type that participates in functional disabled interception.
237    ///
238    /// iced's native `Status::Disabled` is style-only: events still reach
239    /// `update()` from widgets the user considers disabled. This helper
240    /// lets the dispatcher swallow events for input-family widgets so
241    /// `disabled: true` blocks interaction as every host SDK documents.
242    ///
243    /// Widget types in scope: `text_input`, `text_editor`, `combo_box`,
244    /// `pick_list`. Extending coverage to other widgets (e.g. slider)
245    /// is a future step once their behaviour is audited.
246    pub fn is_widget_disabled_for_interception(&self, widget_id: &str) -> bool {
247        let Some(node) = self.core.tree.find_by_id(widget_id) else {
248            return false;
249        };
250        if !matches!(
251            node.type_name.as_str(),
252            "text_input" | "text_editor" | "combo_box" | "pick_list"
253        ) {
254            return false;
255        }
256        node.props
257            .get("disabled")
258            .and_then(|v| v.as_bool())
259            .unwrap_or(false)
260    }
261
262    /// Coalesce a subscription event for all matching entries.
263    pub fn coalesce_subscription(
264        &mut self,
265        key: &str,
266        captured: bool,
267        event_fn: impl Fn(&str) -> OutgoingEvent,
268    ) -> Task<Message> {
269        coalesce_subscription_into(&self.core, &mut self.emitter, key, None, captured, event_fn)
270    }
271
272    /// Coalesce a subscription event scoped to a specific window.
273    /// Each matching entry gets its own coalesce buffer (keyed by tag)
274    /// so rate limiting is isolated per subscription entry.
275    pub fn coalesce_subscription_for_window(
276        &mut self,
277        key: &str,
278        window_id: Option<&str>,
279        captured: bool,
280        event_fn: impl Fn(&str) -> OutgoingEvent,
281    ) -> Task<Message> {
282        coalesce_subscription_into(
283            &self.core,
284            &mut self.emitter,
285            key,
286            window_id,
287            captured,
288            event_fn,
289        )
290    }
291
292    /// Route a [`Message`] to every widget with an active subscription
293    /// for `kind` (optionally scoped to `window_id`) and emit the
294    /// resulting outgoing events.
295    ///
296    /// Fast path: returns [`Task::none`] when no widget cares about
297    /// `kind`, so handlers can call this unconditionally without
298    /// cloning message payloads in the common case.
299    pub fn dispatch_widget_subscription(
300        &mut self,
301        kind: &str,
302        window_id: Option<&str>,
303        msg: &Message,
304    ) -> Task<Message> {
305        dispatch_widget_subscription_into(
306            &mut self.registry,
307            &mut self.emitter,
308            kind,
309            window_id,
310            msg,
311        )
312    }
313}
314
315/// Free-function form of [`App::dispatch_widget_subscription`] for
316/// the same split-borrow reason as [`coalesce_subscription_into`]:
317/// callers can hold a `&str` borrowed from `App.windows` while
318/// passing `&mut App.emitter` and `&mut App.registry` separately.
319pub(crate) fn dispatch_widget_subscription_into(
320    registry: &mut WidgetRegistry,
321    emitter: &mut EventEmitter,
322    kind: &str,
323    window_id: Option<&str>,
324    msg: &Message,
325) -> Task<Message> {
326    if !registry.has_widget_subscription(kind) {
327        return Task::none();
328    }
329    let events = registry.dispatch_widget_subscription(kind, window_id, msg);
330    if events.is_empty() {
331        return Task::none();
332    }
333    let tasks: Vec<_> = events
334        .into_iter()
335        .map(|event| emitter.emit_immediate(event))
336        .collect();
337    Task::batch(tasks)
338}
339
340/// Free-function form of [`App::coalesce_subscription_for_window`] so
341/// callers can hold a `&str` borrowed from `App.windows` while
342/// passing `&mut App.emitter` and `&App.core` separately. Going
343/// through the `&mut self` method form would force the borrow
344/// checker to take a whole-self mutable borrow, which conflicts
345/// with the live `&str` into windows.
346pub(crate) fn coalesce_subscription_into(
347    core: &plushie_renderer_engine::Core,
348    emitter: &mut EventEmitter,
349    key: &str,
350    window_id: Option<&str>,
351    captured: bool,
352    event_fn: impl Fn(&str) -> OutgoingEvent,
353) -> Task<Message> {
354    let entries = core.matching_entries_with_catchall(key, SUB_EVENT, window_id);
355    // Fast paths for the common 0- and 1-entry cases avoid
356    // allocating a `Vec` and a `Task::batch` per high-frequency
357    // event (cursor move, scroll, etc.).
358    //
359    // The closure receives `&str`; the event constructor allocates
360    // the owned tag internally via `impl Into<String>`. Earlier
361    // versions cloned the tag at the call site before passing it in.
362    match entries.len() {
363        0 => Task::none(),
364        1 => {
365            let entry = &entries[0];
366            let event = event_fn(entry.tag.as_str()).with_captured(captured);
367            emitter.coalesce(CoalesceKey::Subscription(entry.tag.clone()), event)
368        }
369        _ => {
370            let tasks: Vec<_> = entries
371                .into_iter()
372                .map(|entry| {
373                    let event = event_fn(entry.tag.as_str()).with_captured(captured);
374                    emitter.coalesce(CoalesceKey::Subscription(entry.tag.clone()), event)
375                })
376                .collect();
377            Task::batch(tasks)
378        }
379    }
380}