Skip to main content

plushie_renderer_lib/
update.rs

1//! Message dispatcher and stdin handler. Routes iced messages to event
2//! handlers, emitters, or the apply pipeline.
3
4use iced::{Task, Theme, window};
5
6use plushie_widget_sdk::protocol::{IncomingMessage, OutgoingEvent};
7use plushie_widget_sdk::runtime::{Message, StdinEvent};
8
9use crate::App;
10use crate::constants::*;
11use crate::emitter::CoalesceKey;
12
13/// True when the incoming message must observe wire-ordering
14/// against the coalesce buffer. Anything tree-affecting,
15/// state-mutating, or response-bearing returns true; setup-only
16/// subscription and effect-stub bookkeeping returns false so a
17/// rapid stream of those messages doesn't collapse the coalesce
18/// window for unrelated event streams.
19fn incoming_requires_flush(message: &IncomingMessage) -> bool {
20    match message {
21        IncomingMessage::Subscribe { .. }
22        | IncomingMessage::Unsubscribe { .. }
23        | IncomingMessage::RegisterEffectStub { .. }
24        | IncomingMessage::UnregisterEffectStub { .. } => false,
25        IncomingMessage::Snapshot { .. }
26        | IncomingMessage::Patch { .. }
27        | IncomingMessage::Effect { .. }
28        | IncomingMessage::WidgetOp { .. }
29        | IncomingMessage::WindowOp { .. }
30        | IncomingMessage::SystemOp { .. }
31        | IncomingMessage::SystemQuery { .. }
32        | IncomingMessage::Settings { .. }
33        | IncomingMessage::Query { .. }
34        | IncomingMessage::Interact { .. }
35        | IncomingMessage::TreeHash { .. }
36        | IncomingMessage::Screenshot { .. }
37        | IncomingMessage::Reset { .. }
38        | IncomingMessage::ImageOp { .. }
39        | IncomingMessage::Command { .. }
40        | IncomingMessage::Commands { .. }
41        | IncomingMessage::AdvanceFrame { .. }
42        | IncomingMessage::LoadFont { .. } => true,
43    }
44}
45
46/// Best-effort emit of a `session_error` carrying a write-failure
47/// reason, mirroring the panic-hook pattern: the host gets one
48/// final structured event before the renderer exits, so it can tell
49/// a clean shutdown from a write-side crash. Failures here are
50/// swallowed because the sink is already broken.
51fn emit_write_failure(emitter: &crate::emitter::EventEmitter, where_: &str, err: &std::io::Error) {
52    let event = OutgoingEvent::generic(
53        "session_error",
54        "",
55        Some(serde_json::json!({
56            "code": "renderer_write_failed",
57            "where": where_,
58            "error": err.to_string(),
59        })),
60    );
61    let _ = emitter.emit_event(event);
62}
63
64impl App {
65    pub fn update(&mut self, message: Message) -> Task<Message> {
66        // Fan subscription-source messages out to widgets that asked
67        // to be woken. Widget dispatch runs before the host-level
68        // handler so the widget's handle_message can observe the
69        // event and emit outgoing events, matching the contract
70        // process_message already uses for widget-owned messages.
71        let widget_task = self.dispatch_to_widget_subscribers(&message);
72        let host_task = self.update_inner(message);
73        Task::batch([widget_task, host_task])
74    }
75
76    fn update_inner(&mut self, message: Message) -> Task<Message> {
77        match message {
78            Message::Stdin(event) => self.handle_stdin(event),
79            Message::NoOp | Message::TimerTick(_) => Task::none(),
80            Message::FlushCoalesce => self.emitter.flush(),
81
82            // Widget messages shared between daemon and headless modes.
83            // The shared processor handles slider tracking, text editor
84            // mutation and pane grid state.
85            //
86            // Redraw contract: iced::daemon rebuilds UIs after every
87            // update() call regardless of the returned Task. Widgets
88            // using canvas::Cache must clear caches themselves (see
89            // GenerationCounter in registry.rs).
90            msg @ (Message::CanvasElementFocusChanged { .. }
91            | Message::Diagnostic { .. }
92            | Message::TextEditorAction(..)
93            | Message::Event { .. }
94            | Message::PaneFocusCycle(..)
95            | Message::PaneResized(..)
96            | Message::PaneDragged(..)
97            | Message::PaneClicked(..)) => {
98                // Functional disabled interception. iced's
99                // `Status::Disabled` is style-only; text-input-family
100                // widgets with `disabled: true` still propagate events.
101                // Swallow them here so disabled widgets behave as
102                // documented across every host SDK. Covers both the
103                // `Event { id, .. }` path and TextEditorAction, which
104                // the text editor widget converts into an input event
105                // without going through Message::Event first.
106                let disabled_target: Option<&str> = match &msg {
107                    Message::Event { id, .. } => Some(id.as_str()),
108                    Message::TextEditorAction(_, id, _) => Some(id.as_str()),
109                    _ => None,
110                };
111                if let Some(id) = disabled_target
112                    && self.is_widget_disabled_for_interception(id)
113                {
114                    log::trace!("disabled widget {id} suppressed event");
115                    return Task::none();
116                }
117
118                let events = self.registry.process_message(&msg);
119                let mut task = Task::none();
120                for event in events {
121                    let t = if event.coalesce_hint().is_some() {
122                        // Lazily cache event_rate from the widget's tree node.
123                        if !event.id.is_empty()
124                            && !self.emitter.has_widget_rate(&event.id)
125                            && let Some(rate) = self.lookup_widget_event_rate(&event.id)
126                        {
127                            self.emitter.set_widget_rate(&event.id, rate);
128                        }
129                        let key = crate::emitter::widget_coalesce_key(&event);
130                        self.emitter.coalesce(key, event)
131                    } else {
132                        self.emitter.emit_immediate(event)
133                    };
134                    task = Task::batch([task, t]);
135                }
136                task
137            }
138
139            Message::MarkdownUrl(url) => {
140                log::debug!("markdown link clicked: {url}");
141                Task::none()
142            }
143
144            // -- Keyboard events --
145            Message::KeyPressed(data, iced_id) => self.handle_key_pressed(data, iced_id),
146            Message::KeyReleased(data, iced_id) => self.handle_key_released(data, iced_id),
147            Message::ModifiersChanged(mods, iced_id, captured) => {
148                self.handle_modifiers_changed(mods, iced_id, captured)
149            }
150
151            // -- Mouse events --
152            Message::CursorMoved(pos, iced_id, captured) => {
153                self.handle_cursor_moved(pos, iced_id, captured)
154            }
155            Message::CursorEntered(iced_id, captured) => {
156                self.handle_cursor_entered(iced_id, captured)
157            }
158            Message::CursorLeft(iced_id, captured) => self.handle_cursor_left(iced_id, captured),
159            Message::MouseButtonPressed(button, iced_id, captured) => {
160                self.handle_mouse_button_pressed(button, iced_id, captured)
161            }
162            Message::MouseButtonReleased(button, iced_id, captured) => {
163                self.handle_mouse_button_released(button, iced_id, captured)
164            }
165            Message::WheelScrolled(delta, iced_id, captured) => {
166                self.handle_wheel_scrolled(delta, iced_id, captured)
167            }
168
169            // -- Touch events --
170            Message::FingerPressed(finger, pos, iced_id, captured) => {
171                self.handle_finger_pressed(finger, pos, iced_id, captured)
172            }
173            Message::FingerMoved(finger, pos, iced_id, captured) => {
174                self.handle_finger_moved(finger, pos, iced_id, captured)
175            }
176            Message::FingerLifted(finger, pos, iced_id, captured) => {
177                self.handle_finger_lifted(finger, pos, iced_id, captured)
178            }
179            Message::FingerLost(finger, pos, iced_id, captured) => {
180                self.handle_finger_lost(finger, pos, iced_id, captured)
181            }
182
183            // -- IME events --
184            Message::ImeOpened(iced_id, captured) => self.handle_ime_opened(iced_id, captured),
185            Message::ImePreedit(text, cursor, iced_id, captured) => {
186                self.handle_ime_preedit(text, cursor, iced_id, captured)
187            }
188            Message::ImeCommit(text, iced_id, captured) => {
189                self.handle_ime_commit(text, iced_id, captured)
190            }
191            Message::ImeClosed(iced_id, captured) => self.handle_ime_closed(iced_id, captured),
192
193            // -- Window lifecycle events --
194            Message::WindowCloseRequested(iced_id) => {
195                // Do NOT close the window or remove from maps here. The host
196                // decides whether to close by sending a close_window command
197                // or removing the window from the tree. Closing immediately
198                // would bypass app-level confirmation dialogs.
199                let Some(window_id) = self.windows.get_window_id(&iced_id) else {
200                    return Task::none();
201                };
202                let entries = self
203                    .core
204                    .matching_entries(SUB_WINDOW_CLOSE, Some(window_id));
205                if entries.is_empty() {
206                    return Task::none();
207                }
208                let owned_window_id = window_id.to_string();
209                let tasks: Vec<_> = entries
210                    .into_iter()
211                    .map(|entry| {
212                        self.emitter
213                            .emit_direct(OutgoingEvent::window_close_requested(
214                                entry.tag.as_str(),
215                                owned_window_id.as_str(),
216                            ))
217                    })
218                    .collect();
219                Task::batch(tasks)
220            }
221            Message::WindowClosed(iced_id) => {
222                if let Some(window_id) = self.windows.remove_by_iced(&iced_id) {
223                    let wid = Some(window_id.as_str());
224                    let tasks: Vec<_> = self
225                        .core
226                        .matching_entries(SUB_WINDOW_EVENT, wid)
227                        .into_iter()
228                        .map(|entry| {
229                            self.emitter.emit_direct(OutgoingEvent::window_closed(
230                                entry.tag.as_str(),
231                                window_id.as_str(),
232                            ))
233                        })
234                        .collect();
235                    if !tasks.is_empty() {
236                        return Task::batch(tasks);
237                    }
238                    log::info!("window closed: {window_id}");
239                }
240                // All managed windows gone: notify the host.
241                // The host can choose to exit, send a new Snapshot, or take other action.
242                // We do NOT call iced::exit() here because the daemon should stay alive
243                // to receive new tree snapshots (e.g. after a Reset or window re-creation).
244                if self.windows.is_empty() && self.core.tree.root().is_some() {
245                    log::info!("all windows closed, notifying host");
246                    return self.emitter.emit_direct(OutgoingEvent::generic(
247                        "all_windows_closed".to_string(),
248                        String::new(),
249                        None,
250                    ));
251                }
252                Task::none()
253            }
254            Message::WindowOpened(iced_id, window_id) => {
255                log::info!("window opened: {window_id} -> {iced_id:?}");
256                self.windows.insert(window_id, iced_id);
257                Task::none()
258            }
259            Message::WindowEvent(iced_id, evt) => self.handle_window_event(iced_id, evt),
260
261            // -- System / animation --
262            Message::AnimationFrame(instant) => {
263                // Advance renderer-side transitions on every frame tick
264                let completions = self
265                    .transition_manager
266                    .advance_all(instant, &mut self.core.caches.interpolated_props);
267
268                // Emit transition_complete events
269                for c in completions {
270                    let event = OutgoingEvent::generic(
271                        "transition_complete",
272                        c.widget_id.clone(),
273                        Some(serde_json::json!({
274                            "tag": c.tag,
275                            "prop": c.prop_name,
276                        })),
277                    );
278                    let _ = self.emitter.emit_immediate(event);
279                }
280
281                // Forward animation_frame to SDK if subscribed
282                let entries = self.core.matching_entries(SUB_ANIMATION_FRAME, None);
283                if let Some(entry) = entries.first() {
284                    let epoch = *self.animation_epoch.get_or_insert(instant);
285                    let millis = u64::try_from(instant.duration_since(epoch).as_millis())
286                        .unwrap_or(u64::MAX);
287                    let event = OutgoingEvent::animation_frame(entry.tag.as_str(), millis);
288                    self.emitter.coalesce(
289                        CoalesceKey::Subscription(SUB_ANIMATION_FRAME.to_string()),
290                        event,
291                    )
292                } else {
293                    Task::none()
294                }
295            }
296            Message::ThemeChanged(mode) => {
297                // Track system theme so "system" theme value follows OS preference
298                self.system_theme = match mode {
299                    iced::theme::Mode::Light => Theme::Light,
300                    iced::theme::Mode::Dark => Theme::Dark,
301                    _ => Theme::Dark,
302                };
303                // Theme changes are global (not window-scoped), use first entry
304                let entries = self.core.matching_entries(SUB_THEME_CHANGE, None);
305                if let Some(entry) = entries.first() {
306                    let mode_str = match mode {
307                        iced::theme::Mode::Light => "light",
308                        iced::theme::Mode::Dark => "dark",
309                        _ => "system",
310                    };
311                    let event = OutgoingEvent::theme_changed(entry.tag.as_str(), mode_str);
312                    self.emitter.coalesce(
313                        CoalesceKey::Subscription(SUB_THEME_CHANGE.to_string()),
314                        event,
315                    )
316                } else {
317                    Task::none()
318                }
319            }
320        }
321    }
322
323    /// Classify `message` against the subscription-kind taxonomy and
324    /// deliver it to every widget with an active subscription for the
325    /// matching kind.
326    ///
327    /// Non-subscription messages (stdin, NoOp, Timer, FlushCoalesce,
328    /// window lifecycle plumbing, etc.) return [`Task::none`]. Only
329    /// messages that originate from an iced [`Subscription`] source
330    /// are eligible, since those are the sources a widget would have
331    /// declared via [`PlushieWidget::subscriptions`].
332    ///
333    /// Keyboard, mouse, touch, and IME messages also fire any
334    /// widgets subscribed to the catch-all `on_event` kind, matching
335    /// how the host catch-all subscription works.
336    fn dispatch_to_widget_subscribers(&mut self, message: &Message) -> Task<Message> {
337        // (specific kind, whether the catch-all `on_event` also applies,
338        // optional window-scoped delivery). `None` for window_id means
339        // window-agnostic (e.g. theme change).
340        let (kind, catchall, window_id): (&str, bool, Option<&str>) = match message {
341            Message::KeyPressed(_, iced_id) => {
342                (SUB_KEY_PRESS, true, self.windows.get_window_id(iced_id))
343            }
344            Message::KeyReleased(_, iced_id) => {
345                (SUB_KEY_RELEASE, true, self.windows.get_window_id(iced_id))
346            }
347            Message::ModifiersChanged(_, iced_id, _) => (
348                SUB_MODIFIERS_CHANGED,
349                true,
350                self.windows.get_window_id(iced_id),
351            ),
352            Message::CursorMoved(_, iced_id, _)
353            | Message::CursorEntered(iced_id, _)
354            | Message::CursorLeft(iced_id, _) => {
355                (SUB_POINTER_MOVE, true, self.windows.get_window_id(iced_id))
356            }
357            Message::MouseButtonPressed(_, iced_id, _)
358            | Message::MouseButtonReleased(_, iced_id, _) => (
359                SUB_POINTER_BUTTON,
360                true,
361                self.windows.get_window_id(iced_id),
362            ),
363            Message::WheelScrolled(_, iced_id, _) => (
364                SUB_POINTER_SCROLL,
365                true,
366                self.windows.get_window_id(iced_id),
367            ),
368            Message::FingerPressed(_, _, iced_id, _)
369            | Message::FingerMoved(_, _, iced_id, _)
370            | Message::FingerLifted(_, _, iced_id, _)
371            | Message::FingerLost(_, _, iced_id, _) => {
372                (SUB_POINTER_TOUCH, true, self.windows.get_window_id(iced_id))
373            }
374            Message::ImeOpened(iced_id, _)
375            | Message::ImePreedit(_, _, iced_id, _)
376            | Message::ImeCommit(_, iced_id, _)
377            | Message::ImeClosed(iced_id, _) => {
378                (SUB_IME, true, self.windows.get_window_id(iced_id))
379            }
380            Message::WindowEvent(iced_id, _) => {
381                (SUB_WINDOW_EVENT, false, self.windows.get_window_id(iced_id))
382            }
383            Message::WindowCloseRequested(iced_id) => {
384                (SUB_WINDOW_CLOSE, false, self.windows.get_window_id(iced_id))
385            }
386            Message::AnimationFrame(_) => (SUB_ANIMATION_FRAME, false, None),
387            Message::ThemeChanged(_) => (SUB_THEME_CHANGE, false, None),
388            _ => return Task::none(),
389        };
390        // Split-borrow: window_id borrows into self.windows. Going
391        // through the &mut self method form would force the borrow
392        // checker to take a whole-self mutable borrow.
393        let specific = crate::app::dispatch_widget_subscription_into(
394            &mut self.registry,
395            &mut self.emitter,
396            kind,
397            window_id,
398            message,
399        );
400        if catchall {
401            let event = crate::app::dispatch_widget_subscription_into(
402                &mut self.registry,
403                &mut self.emitter,
404                SUB_EVENT,
405                window_id,
406                message,
407            );
408            Task::batch([specific, event])
409        } else {
410            specific
411        }
412    }
413
414    pub fn handle_stdin(&mut self, event: StdinEvent) -> Task<Message> {
415        match event {
416            StdinEvent::Message(incoming) => {
417                // Flush pending coalesced events only on messages whose
418                // semantics depend on the host having seen everything we
419                // queued before this point. Subscribe / Unsubscribe /
420                // RegisterEffectStub / UnregisterEffectStub are pure
421                // setup; flushing on every one of them under a rapid
422                // subscription stream collapses the coalesce window for
423                // unrelated streams. Tree, widget, window, system,
424                // command, image, font, screenshot, query, interact,
425                // reset, and animation-frame messages all do depend on
426                // wire ordering relative to coalesced events. The
427                // flush task may carry an exit signal if a buffered
428                // write hit a broken pipe; batch it with the
429                // per-branch task below.
430                let flush_task = if incoming_requires_flush(&incoming) {
431                    self.emitter.flush()
432                } else {
433                    Task::none()
434                };
435                // Handle scripting messages directly instead of passing
436                // them to Core::apply. All other messages fall through.
437                let branch_task = match incoming {
438                    IncomingMessage::Query {
439                        id,
440                        target,
441                        selector,
442                    } => {
443                        if let Err(e) = crate::scripting::handle_query(
444                            &self.emitter,
445                            &self.codec,
446                            &self.core,
447                            id,
448                            target,
449                            selector,
450                        ) {
451                            log::error!("write error: {e}");
452                            emit_write_failure(&self.emitter, "query", &e);
453                            return iced::exit();
454                        }
455                        Task::none()
456                    }
457                    IncomingMessage::Interact {
458                        id,
459                        action,
460                        selector,
461                        payload,
462                    } => {
463                        if let Err(e) = crate::scripting::handle_interact(
464                            &self.emitter,
465                            &self.codec,
466                            &self.core,
467                            id,
468                            action,
469                            selector,
470                            payload,
471                        ) {
472                            log::error!("write error: {e}");
473                            emit_write_failure(&self.emitter, "interact", &e);
474                            return iced::exit();
475                        }
476                        Task::none()
477                    }
478                    IncomingMessage::Reset { id } => {
479                        // Flush any pending coalesced events before reset.
480                        let pre_reset_flush = self.emitter.flush();
481
482                        // Reset core and emit the response.
483                        if let Err(e) = crate::scripting::handle_reset(
484                            &self.emitter,
485                            &self.codec,
486                            &mut self.core,
487                            id,
488                        ) {
489                            log::error!("write error: {e}");
490                            emit_write_failure(&self.emitter, "reset", &e);
491                            return iced::exit();
492                        }
493
494                        // Close all open windows and clear maps.
495                        let mut close_tasks: Vec<Task<Message>> = self
496                            .windows
497                            .iced_ids()
498                            .map(|&iced_id| window::close(iced_id))
499                            .collect();
500                        close_tasks.push(pre_reset_flush);
501                        self.windows.clear();
502
503                        // Reset remaining App-level state.
504                        self.image_registry =
505                            plushie_widget_sdk::image_registry::ImageRegistry::new();
506                        self.theme = DEFAULT_THEME;
507                        self.theme_follows_system = false;
508                        self.scale_factor = 1.0;
509                        self.pending_tasks.clear();
510                        self.animation_epoch = None;
511                        self.emitter = crate::emitter::EventEmitter::new(self.emitter.sink());
512
513                        Task::batch(close_tasks)
514                    }
515                    IncomingMessage::TreeHash { id, name, .. } => {
516                        if let Err(e) = crate::scripting::handle_tree_hash(
517                            &self.emitter,
518                            &self.codec,
519                            &self.core,
520                            id,
521                            name,
522                        ) {
523                            log::error!("write error: {e}");
524                            emit_write_failure(&self.emitter, "tree_hash", &e);
525                            return iced::exit();
526                        }
527                        Task::none()
528                    }
529                    IncomingMessage::Screenshot { id, name, .. } => {
530                        // Capture real GPU-rendered pixels via iced
531                        if let Some((_, &iced_id)) = self.windows.iter().next() {
532                            let sink = self.emitter.sink();
533                            window::screenshot(iced_id).map(move |shot| {
534                                use sha2::{Digest, Sha256};
535                                let rgba: &[u8] = &shot.rgba;
536                                let mut hasher = Sha256::new();
537                                hasher.update(rgba);
538                                let hash = format!("{:x}", hasher.finalize());
539                                let w = shot.size.width;
540                                let h = shot.size.height;
541                                // sink lock is the innermost; no
542                                // nested locks in this continuation.
543                                let mut guard = sink.lock();
544                                if let Err(e) =
545                                    guard.emit_screenshot_response(&id, &name, &hash, w, h, rgba)
546                                {
547                                    log::error!("write error in screenshot: {e}");
548                                }
549                                Message::NoOp
550                            })
551                        } else {
552                            if let Err(e) =
553                                self.emitter
554                                    .emit_screenshot_response(&id, &name, "", 0, 0, &[])
555                            {
556                                log::error!("write error: {e}");
557                                return iced::exit();
558                            }
559                            Task::none()
560                        }
561                    }
562                    other => {
563                        if let Err(e) = self.apply(other) {
564                            log::error!("write error: {e}");
565                            return iced::exit();
566                        }
567                        let tasks: Vec<Task<Message>> = self.pending_tasks.drain(..).collect();
568                        Task::batch(tasks)
569                    }
570                };
571                Task::batch([flush_task, branch_task])
572            }
573            StdinEvent::Warning(msg) => {
574                log::warn!("stdin warning: {msg}");
575                Task::none()
576            }
577            StdinEvent::Closed => {
578                log::info!("stdin closed, exiting");
579                iced::exit()
580            }
581        }
582    }
583}