fluor 0.0.3

First-principles GUI compositor library: center-origin RU coordinates, harmonic-mean span scaling, CPU softbuffer rendering, ARM-first.
Documentation
//! Viewport geometry: pixel dimensions, derived universal scaling units, and conversions between RU coordinates and integer pixel coordinates.
//!
//! Relative Units, not pixels (ru)
//! - `span = 2wh/(w+h)` — harmonic mean of pixel dimensions; the project's default scaling base. Slope 1 along `w==h`, smooth at the diagonal, biased toward the smaller dimension on narrow displays.
//! - `perimeter = w + h` — for edge-aware calculations.
//! - `diagonal_sq = w² + h²` — for distance calculations without sqrt.
//!
//! All three are `f32` so consumers can derive sizes via plain arithmetic (e.g. `let margin = vp.span / 64.0;`).
//!
//! Coordinate convention: origin is at viewport center, +x right, +y down. The y-down choice is deliberate — text engines, image scanline order, and pixel storage are all y-down, so +y down means zero flip points below the layout layer.

use crate::coord::{Coord, RuVec2};

/// The photon zoom step curve — asymmetric BY DESIGN (confirmed by Nick, 2026-07-12; the trio 31/32/33 is the spec, born in photon commit 529fbeb): each in-step multiplies by `32/31` (≈ +3.23%), each out-step by `32/33` (≈ −3.03%). The in/out ratios are deliberately incommensurate, so combinations of notches form a DENSE set — any zoom value is reachable by mixing ins and outs instead of being locked to a single ratio's lattice; the ≈0.1% in/out round-trip drift is the price and it's imperceptible. The ONE curve for every zoomable in the ecosystem: the host's RU zoom consumes it via [`Viewport::adjust_zoom`], and apps consume it directly for their own zoom targets (opsin's image wheel) so all wheels speak the same steps. Do NOT "fix" this into reciprocal steps — that trades reachability for a lattice.
pub fn zoom_step_factor(steps: f32) -> f32 {
    if steps.is_sign_negative() {
        (33f32 / 32.).powf(steps)
    } else {
        (31f32 / 32.).powf(-steps)
    }
}

/// Viewport state. Recomputed every time the host window resizes.
#[derive(Clone, Copy, Debug)]
pub struct Viewport {
    pub width_px: u32,
    pub height_px: u32,
    /// Harmonic mean of width and height in pixel units.
    pub span: Coord,
    /// `width + height` in pixel units.
    pub perimeter: Coord,
    /// `width² + height²` in pixel units.
    pub diagonal_sq: Coord,
    /// RU multiplier (zoom). 1 RU corresponds to `span * ru` pixels. Default 1.0 = 100%. Modified at runtime via [`adjust_zoom`](Self::adjust_zoom) / [`reset_zoom`](Self::reset_zoom) bound to `Ctrl/Cmd + plus/minus/0/scroll` in the host. Use [`effective_span`](Self::effective_span) instead of `span` directly anywhere zoom should apply (chrome button size, widget font size, etc.) — bare `span` is the pixel harmonic mean and ignores zoom.
    pub ru: Coord,
    half_w: Coord,
    half_h: Coord,
}

impl Viewport {
    /// Construct a viewport from integer pixel dimensions. `ru` defaults to 1.0; call [`with_ru`](Self::with_ru) to override.
    pub fn new(width_px: u32, height_px: u32) -> Self {
        let w = width_px as Coord;
        let h = height_px as Coord;
        let perimeter = w + h;
        let span = (2. * w * h) / perimeter;
        let diagonal_sq = w * w + h * h;
        Self {
            width_px,
            height_px,
            span,
            perimeter,
            diagonal_sq,
            ru: 1.0,
            half_w: w * 0.5,
            half_h: h * 0.5,
        }
    }

    /// Override the RU multiplier. Returns a new `Viewport` with all other derived units preserved.
    pub fn with_ru(mut self, ru: Coord) -> Self {
        self.ru = ru;
        self
    }

    /// Convert an RU x-coordinate (center-origin) to a pixel x-coordinate (top-left-origin).
    #[inline]
    pub fn ru_to_px_x(&self, x_ru: Coord) -> isize {
        (self.half_w + x_ru * self.span * self.ru) as isize
    }

    /// Convert an RU y-coordinate (center-origin, +y down) to a pixel y-coordinate (top-left-origin, +y down).
    #[inline]
    pub fn ru_to_px_y(&self, y_ru: Coord) -> isize {
        (self.half_h + y_ru * self.span * self.ru) as isize
    }

    /// Convert an RU width/height/distance (no center offset) to a pixel distance.
    #[inline]
    pub fn ru_to_px_d(&self, d_ru: Coord) -> isize {
        (d_ru * self.span * self.ru) as isize
    }

    /// Convert an `RuVec2` point to a `(px_x, px_y)` integer pixel coordinate pair.
    #[inline]
    pub fn ru_to_px(&self, p: RuVec2) -> (isize, isize) {
        (self.ru_to_px_x(p.x), self.ru_to_px_y(p.y))
    }

    /// Convert a pixel coordinate (top-left origin) to an `RuVec2` (center-origin). Inverse of [`ru_to_px`](Self::ru_to_px) up to integer rounding.
    #[inline]
    pub fn px_to_ru(&self, px: i32, py: i32) -> RuVec2 {
        let span_ru = self.span * self.ru;
        RuVec2 {
            x: (px as Coord - self.half_w) / span_ru,
            y: (py as Coord - self.half_h) / span_ru,
        }
    }

    /// Pixel size of 1 RU under the current zoom (`span * ru`). Use this everywhere "scale by viewport" math used to use bare `span` — chrome button size, glyph font size, widget hairlines, anything the user-facing zoom should affect. Bare `span` is the unzoomed pixel harmonic mean and only matters for OS-edge-adjacent things (e.g. WM resize-border hit zones).
    #[inline]
    pub fn effective_span(&self) -> Coord {
        self.span * self.ru
    }

    /// Adjust zoom by `steps` (positive = zoom in, negative = zoom out) along [`zoom_step_factor`] — see there for why the curve is asymmetric on purpose. Matches photon's behaviour exactly so cross-codebase muscle memory transfers.
    pub fn adjust_zoom(&mut self, steps: f32) {
        self.ru *= zoom_step_factor(steps);
        // Production zoom clamp (requested + justified by Nick): below 1/8 (12.5%) or above 3× (300%) the layout/scratch math starts to break, so the shipping build pins ru to that range. WHY this clamp is allowed despite AGENT.md Rule 0: it's a user-requested, user-justified bound on EXTERNAL input (scroll/zoom gestures), not defensive masking of internal math — and it's release-gated so it never hides a bug in dev (debug builds stay unclamped so the breakage threshold can still be probed).
        #[cfg(not(debug_assertions))]
        {
            const ZOOM_MIN: f32 = 1.0 / (1 << 3) as f32; // 12.5%
            const ZOOM_MAX: f32 = 3.0; // 300% — chosen ceiling, deliberately not a power of two
            self.ru = self.ru.clamp(ZOOM_MIN, ZOOM_MAX);
        }
    }

    /// Reset zoom to 1.0 (bound to `Ctrl/Cmd + 0` in the host).
    pub fn reset_zoom(&mut self) {
        self.ru = 1.0;
    }
}