noesis_bevy 0.14.1

Bevy plugin that drives the Noesis GUI Native SDK and renders its UIs into a Bevy frame via wgpu compositing.
Documentation
//! Per-view typography bridge: restyle the `TextElement` font properties
//! (size, family, weight, style, stretch) of named XAML elements on a
//! single [`NoesisView`](crate::NoesisView).
//!
//! `TextBlock.FontSize`, `Run.FontWeight`, and friends are ordinary
//! `TextElement` attached dependency properties; the generic [`NoesisDp`](crate::dp)
//! bridge can already poke the scalar ones (`FontSize` is a plain `f32` DP). This
//! bridge is the *typed, font-shaped* front for them: one [`NoesisTypography`]
//! component carries a per-`x:Name` [`FontStyling`] block, so a single change can
//! restyle a label's size, family, and weight together without spelling out DP
//! names or worrying that `FontWeight`/`FontStyle`/`FontStretch` are enums rather
//! than bare ints.
//!
//! Add a [`NoesisTypography`] component to the view's camera entity. Its `set` map
//! is the desired [`FontStyling`] per `x:Name`, applied to the view's elements
//! whenever the component changes (Bevy change detection). It is **write-only**:
//! each block's `Some` fields are pushed into the live element; `None` fields are
//! left untouched, so two blocks for the same name compose last-write-wins per
//! field. Read the resulting values back through a [`NoesisDp`](crate::dp) watch
//! (`FontSize` is a readable `f32` DP) when you need observation.
//!
//! ```ignore
//! use noesis_bevy::{NoesisTypography, FontWeight};
//!
//! commands.entity(view).insert(
//!     NoesisTypography::new()
//!         .font_size("Title", 28.0)
//!         .font_family("Title", "#PT Root UI")
//!         .font_weight("Title", FontWeight::Bold),
//! );
//! ```
//!
//! Everything runs on the main thread (Noesis is thread-affine and lives there):
//! the reconcile system reads each view's component and applies the writes against
//! that view's live scene; no cross-world queues.

use std::collections::HashMap;

use bevy::prelude::*;

use crate::render::{NoesisRenderState, NoesisSet};

// Re-export the runtime's typed font enums so callers don't reach across crates.
pub use noesis_runtime::typography::{FontStretch, FontStyle, FontWeight};

/// The desired `TextElement` font properties for one element. Every field is
/// optional: `Some` is written to the live element on apply, `None` leaves the
/// element's current value untouched (so partial restyles compose).
#[derive(Clone, Default, Debug, PartialEq)]
pub struct FontStyling {
    /// `TextElement.FontSize`, in device-independent pixels.
    pub font_size: Option<f32>,
    /// `TextElement.FontFamily` *source* string (e.g. `"Arial"`, `"#PT Root UI"`,
    /// or a comma-separated fallback list). A fresh Noesis `FontFamily` is built
    /// from it on each apply; Noesis takes its own reference.
    pub font_family: Option<String>,
    /// `TextElement.FontWeight`.
    pub font_weight: Option<FontWeight>,
    /// `TextElement.FontStyle`.
    pub font_style: Option<FontStyle>,
    /// `TextElement.FontStretch`.
    pub font_stretch: Option<FontStretch>,
}

impl FontStyling {
    /// True when no field is set. Apply skips empty blocks to avoid needless FFI hops.
    pub(crate) fn is_empty(&self) -> bool {
        self.font_size.is_none()
            && self.font_family.is_none()
            && self.font_weight.is_none()
            && self.font_style.is_none()
            && self.font_stretch.is_none()
    }
}

/// Which typed font property to read back on a watched element. Selects the
/// runtime's *typed* getter.
///
/// `FontWeight` / `FontStyle` / `FontStretch` are Noesis **enum** dependency
/// properties, so they are not reachable through the generic
/// [`NoesisDp`](crate::dp) `i32` read path (a `GetValue<int>` against an enum DP
/// type-mismatches, the same way `Visibility` does); they round-trip only
/// through these dedicated typed getters. This is the typography bridge's own
/// observation surface, exclusive of `NoesisDp`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TypographyField {
    /// `TextElement.FontSize`, read back as a [`TypographyValue::FontSize`] `f32`.
    FontSize,
    /// `TextElement.FontFamily`, read back as the source string.
    FontFamily,
    /// `TextElement.FontWeight`, read back as a typed [`FontWeight`].
    FontWeight,
    /// `TextElement.FontStyle`, read back as a typed [`FontStyle`].
    FontStyle,
    /// `TextElement.FontStretch`, read back as a typed [`FontStretch`].
    FontStretch,
}

/// A typed font value read back from a live element by a [`TypographyWatch`].
#[derive(Debug, Clone, PartialEq)]
pub enum TypographyValue {
    /// `FontSize` in device-independent pixels.
    FontSize(f32),
    /// `FontFamily` *source* string (`None` when the element has a family object
    /// but no source string set).
    FontFamily(Option<String>),
    /// Typed `FontWeight` enum.
    FontWeight(FontWeight),
    /// Typed `FontStyle` enum.
    FontStyle(FontStyle),
    /// Typed `FontStretch` enum.
    FontStretch(FontStretch),
}

/// One read-back subscription: an element's `x:Name` and the typed
/// [`TypographyField`] to observe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypographyWatch {
    /// `x:Name` of the element to observe.
    pub name: String,
    /// Which typed font property to read back.
    pub field: TypographyField,
}

/// Emitted when a watched typed font property differs from the previous frame's
/// snapshot. Read with `MessageReader<NoesisTypographyChanged>`. The first poll
/// after a watch is added always reports, so callers see the current value.
#[derive(Message, Debug, Clone)]
pub struct NoesisTypographyChanged {
    /// The [`NoesisView`](crate::NoesisView) entity whose property changed.
    pub view: Entity,
    /// `x:Name` of the element whose property changed.
    pub name: String,
    /// Current value, read as the watched [`TypographyField`].
    pub value: TypographyValue,
}

/// Per-view typography bridge. Attach to a [`NoesisView`](crate::NoesisView)
/// entity.
#[derive(Component, Clone, Default, Debug)]
pub struct NoesisTypography {
    /// Desired [`FontStyling`] per element `x:Name`. Written to the view's
    /// elements whenever this component changes. Each target should be a
    /// `TextElement` (`TextBlock` / `Run` / `TextBox` / …); a non-text element
    /// silently ignores font properties it doesn't expose.
    pub set: HashMap<String, FontStyling>,
    /// `(x:Name, field)` pairs to observe. Polled every frame; a change vs. the
    /// previous frame emits a [`NoesisTypographyChanged`].
    pub watch: Vec<TypographyWatch>,
}

impl NoesisTypography {
    /// Creates an empty bridge with no styling writes or watches. Chain the
    /// builder methods to fill it in.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Builder: set element `name`'s `FontSize` (device-independent pixels).
    #[must_use]
    pub fn font_size(mut self, name: impl Into<String>, size: f32) -> Self {
        self.entry(name).font_size = Some(size);
        self
    }

    /// Builder: set element `name`'s `FontFamily` from a source string.
    #[must_use]
    pub fn font_family(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
        self.entry(name).font_family = Some(source.into());
        self
    }

    /// Builder: set element `name`'s `FontWeight`.
    #[must_use]
    pub fn font_weight(mut self, name: impl Into<String>, weight: FontWeight) -> Self {
        self.entry(name).font_weight = Some(weight);
        self
    }

    /// Builder: set element `name`'s `FontStyle`.
    #[must_use]
    pub fn font_style(mut self, name: impl Into<String>, style: FontStyle) -> Self {
        self.entry(name).font_style = Some(style);
        self
    }

    /// Builder: set element `name`'s `FontStretch`.
    #[must_use]
    pub fn font_stretch(mut self, name: impl Into<String>, stretch: FontStretch) -> Self {
        self.entry(name).font_stretch = Some(stretch);
        self
    }

    /// Builder: replace element `name`'s full [`FontStyling`] block.
    #[must_use]
    pub fn styling(mut self, name: impl Into<String>, styling: FontStyling) -> Self {
        self.set.insert(name.into(), styling);
        self
    }

    /// Builder: observe element `name`'s typed `field` and surface changes as
    /// [`NoesisTypographyChanged`].
    #[must_use]
    pub fn watch(mut self, name: impl Into<String>, field: TypographyField) -> Self {
        self.watch.push(TypographyWatch {
            name: name.into(),
            field,
        });
        self
    }

    /// Set element `name`'s `FontSize` from a system holding `&mut NoesisTypography`.
    /// The runtime counterpart of [`font_size`](Self::font_size): the next reconcile
    /// applies it to the live element.
    pub fn set_font_size(&mut self, name: impl Into<String>, size: f32) {
        self.entry(name).font_size = Some(size);
    }

    /// Set element `name`'s `FontFamily` from a source string, holding
    /// `&mut NoesisTypography`. The runtime counterpart of
    /// [`font_family`](Self::font_family).
    pub fn set_font_family(&mut self, name: impl Into<String>, source: impl Into<String>) {
        self.entry(name).font_family = Some(source.into());
    }

    /// Set element `name`'s `FontWeight` from a system holding `&mut NoesisTypography`.
    /// The runtime counterpart of [`font_weight`](Self::font_weight).
    pub fn set_font_weight(&mut self, name: impl Into<String>, weight: FontWeight) {
        self.entry(name).font_weight = Some(weight);
    }

    /// Set element `name`'s `FontStyle` from a system holding `&mut NoesisTypography`.
    /// The runtime counterpart of [`font_style`](Self::font_style).
    pub fn set_font_style(&mut self, name: impl Into<String>, style: FontStyle) {
        self.entry(name).font_style = Some(style);
    }

    /// Set element `name`'s `FontStretch` from a system holding `&mut NoesisTypography`.
    /// The runtime counterpart of [`font_stretch`](Self::font_stretch).
    pub fn set_font_stretch(&mut self, name: impl Into<String>, stretch: FontStretch) {
        self.entry(name).font_stretch = Some(stretch);
    }

    /// Replace element `name`'s full [`FontStyling`] block from a system holding
    /// `&mut NoesisTypography`. The runtime counterpart of [`styling`](Self::styling).
    pub fn set_styling(&mut self, name: impl Into<String>, styling: FontStyling) {
        self.set.insert(name.into(), styling);
    }

    /// Observe element `name`'s typed `field` from a system holding
    /// `&mut NoesisTypography`. No-op if that exact `(name, field)` pair is already
    /// watched. The runtime counterpart of [`watch`](Self::watch).
    pub fn observe(&mut self, name: impl Into<String>, field: TypographyField) {
        let watch = TypographyWatch {
            name: name.into(),
            field,
        };
        if !self.watch.contains(&watch) {
            self.watch.push(watch);
        }
    }

    fn entry(&mut self, name: impl Into<String>) -> &mut FontStyling {
        self.set.entry(name.into()).or_default()
    }
}

/// Reconcile every view's [`NoesisTypography`]: apply desired font-property
/// writes when the component changed, then poll its watch list and emit
/// [`NoesisTypographyChanged`] for each typed value that moved.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn sync_typography_bridge(
    views: Query<(Entity, Ref<NoesisTypography>)>,
    state: Option<NonSendMut<NoesisRenderState>>,
    mut changed: MessageWriter<NoesisTypographyChanged>,
) {
    let Some(mut state) = state else {
        return;
    };
    for (entity, typography) in &views {
        if typography.is_changed() || state.scene_rebuilt_this_frame(entity) {
            state.apply_typography_for(entity, &typography.set);
        }
        for (name, value) in state.poll_typography_reads_for(entity, &typography.watch) {
            changed.write(NoesisTypographyChanged {
                view: entity,
                name,
                value,
            });
        }
    }
}

/// Wires the per-view typography bridge. Added transitively by
/// [`crate::NoesisPlugin`].
pub struct NoesisTypographyPlugin;

impl Plugin for NoesisTypographyPlugin {
    fn build(&self, app: &mut App) {
        app.add_message::<NoesisTypographyChanged>()
            .add_systems(PostUpdate, sync_typography_bridge.in_set(NoesisSet::Apply));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builder_collects_per_name_styling() {
        let t = NoesisTypography::new()
            .font_size("Title", 28.0)
            .font_family("Title", "#PT Root UI")
            .font_weight("Title", FontWeight::Bold)
            .font_style("Sub", FontStyle::Italic)
            .font_stretch("Sub", FontStretch::Condensed);

        let title = t.set.get("Title").expect("Title styling present");
        assert_eq!(title.font_size, Some(28.0));
        assert_eq!(title.font_family.as_deref(), Some("#PT Root UI"));
        assert_eq!(title.font_weight, Some(FontWeight::Bold));
        assert_eq!(title.font_style, None);

        let sub = t.set.get("Sub").expect("Sub styling present");
        assert_eq!(sub.font_style, Some(FontStyle::Italic));
        assert_eq!(sub.font_stretch, Some(FontStretch::Condensed));
        assert_eq!(sub.font_size, None);
    }

    #[test]
    fn last_write_wins_per_field() {
        let t = NoesisTypography::new()
            .font_size("Title", 12.0)
            .font_size("Title", 24.0);
        assert_eq!(t.set.get("Title").unwrap().font_size, Some(24.0));
    }
}