toddy 0.3.1

Native GUI renderer driven by a wire protocol over stdin/stdout
Documentation
//! Application struct and core utility methods.
//!
//! Defines the [`App`] struct (the iced daemon's state) and the methods
//! that the rest of the renderer module uses to query window titles,
//! themes, scale factors, and emit subscription events.

use std::collections::HashMap;

use iced::{Task, Theme, window};

use toddy_core::extensions::ExtensionDispatcher;
use toddy_core::message::Message;
use toddy_core::protocol::OutgoingEvent;

use super::constants::*;
use super::emitters;
use super::window_map;

/// Validate and clamp a scale factor. Returns 1.0 for invalid values
/// (zero, negative, NaN, infinity).
pub(super) fn validate_scale_factor(sf: f32) -> f32 {
    if sf <= 0.0 || !sf.is_finite() {
        log::warn!("invalid scale_factor {sf}, using 1.0");
        1.0
    } else {
        sf
    }
}

// ---------------------------------------------------------------------------
// App state
// ---------------------------------------------------------------------------

/// The iced daemon application. Owns the rendering engine, window
/// state, extension dispatcher, and all runtime state needed to
/// translate between the stdin/stdout protocol and iced's update/view
/// cycle.
pub(super) struct App {
    pub(super) core: toddy_core::engine::Core,
    pub(super) theme: Theme,
    /// Tasks generated by apply() that must be returned from update().
    /// Widget ops (focus, scroll, close_window) produce iced Tasks, but
    /// apply() doesn't return them. They accumulate here and are drained
    /// via Task::batch in the Tick handler.
    pub(super) pending_tasks: Vec<Task<Message>>,
    /// Bidirectional toddy ID <-> iced window ID mapping with per-window state.
    pub(super) windows: window_map::WindowMap,
    /// In-memory image handles for use by Image widgets and canvas draw.
    pub(super) image_registry: toddy_core::image_registry::ImageRegistry,
    /// Current system theme, tracked via ThemeChanged subscription.
    /// Used when a window or app theme is set to "system".
    pub(super) system_theme: Theme,
    /// True when the app-level theme is "system" (follow OS preference).
    pub(super) theme_follows_system: bool,
    /// Global scale factor multiplier (1.0 = follow OS DPI).
    pub(super) scale_factor: f32,
    /// Last slider value per widget ID, for correct on_release events.
    pub(super) last_slide_values: HashMap<String, f64>,
    /// Extension dispatcher for custom widget types.
    pub(super) dispatcher: ExtensionDispatcher,
}

impl App {
    pub(super) fn new(dispatcher: ExtensionDispatcher) -> Self {
        Self {
            core: toddy_core::engine::Core::new(),
            theme: DEFAULT_THEME,
            pending_tasks: Vec::new(),
            windows: window_map::WindowMap::new(),
            image_registry: toddy_core::image_registry::ImageRegistry::new(),
            system_theme: DEFAULT_THEME,
            theme_follows_system: false,
            scale_factor: 1.0,
            last_slide_values: HashMap::new(),
            dispatcher,
        }
    }

    pub(super) fn title_for_window(&self, window_id: window::Id) -> String {
        if let Some(toddy_id) = self.windows.get_toddy(&window_id)
            && let Some(node) = self.core.tree.find_window(toddy_id)
            && let Some(title) = node.props.get("title").and_then(|v| v.as_str())
        {
            // Strip control characters to prevent injection into
            // window titles / terminal escape sequences.
            return title.chars().filter(|c| !c.is_control()).collect();
        }
        DEFAULT_WINDOW_TITLE.to_string()
    }

    pub(super) fn theme_for_window(&self, window_id: window::Id) -> Theme {
        self.theme_ref_for_window(window_id).clone()
    }

    /// Like theme_for_window but returns a borrowed reference, avoiding a
    /// clone when the caller needs a &Theme with the same lifetime as &self
    /// (e.g. view_window where the returned Element borrows from &self).
    pub(super) fn theme_ref_for_window(&self, window_id: window::Id) -> &Theme {
        if let Some(toddy_id) = self.windows.get_toddy(&window_id)
            && let Some(cached) = self.windows.cached_theme(toddy_id)
        {
            return cached;
        }
        if self.theme_follows_system {
            &self.system_theme
        } else {
            &self.theme
        }
    }

    /// Return the scale factor for a window. Per-window overrides take
    /// precedence over the global setting.
    pub(super) fn scale_factor_for_window(&self, window_id: window::Id) -> f32 {
        let sf = self
            .windows
            .get_toddy(&window_id)
            .and_then(|jid| self.core.tree.find_window(jid))
            .and_then(|node| node.props.get("scale_factor"))
            .and_then(|v| v.as_f64())
            .map(|v| v as f32)
            .unwrap_or(self.scale_factor);
        validate_scale_factor(sf)
    }

    /// Check if a subscription event should be emitted, and if so, emit it.
    /// Falls back to the catch-all "on_event" subscription if the specific
    /// key isn't registered.
    pub(super) fn emit_subscription(
        &self,
        key: &str,
        captured: bool,
        event_fn: impl FnOnce(String) -> OutgoingEvent,
    ) -> Task<Message> {
        let tag = self
            .core
            .active_subscriptions
            .get(key)
            .or_else(|| self.core.active_subscriptions.get(SUB_EVENT));
        if let Some(tag) = tag {
            emitters::emit_or_exit(event_fn(tag.clone()).with_captured(captured))
        } else {
            Task::none()
        }
    }
}