Skip to main content

repame_ui/
layout.rs

1//! Design-space placement: the viewport scale every game recomputes, and the
2//! anchor/offset rectangle they all place panels with.
3//!
4//! Two scales come out of one viewport: `fit` (the whole design rectangle is
5//! visible, so nothing is cropped) and `cover` (the design rectangle covers the
6//! viewport, so nothing is letterboxed). Games use `fit` for HUD and menus and
7//! `cover` for overlays that must reach past the design box.
8//!
9//! [`anchor_rect`] is the games' `rect(vp, s, anchor, offset)`: the anchor is
10//! four viewport fractions (left, top, right, bottom) and the offset adds four
11//! design-unit distances to those edges before the right/bottom edges are
12//! clamped into the viewport. The left edge is not clamped, matching the copies,
13//! so an off-screen panel stays off-screen.
14
15use repose_core::prelude::{Color, Modifier, View};
16use repose_core::Dp;
17use repose_ui::{Box as RBox, Spacer, ViewExt as _};
18
19/// The design box the template line draws at (960 x 540, 16:9).
20pub const DESIGN: [f32; 2] = [960.0, 540.0];
21
22/// Why a viewport or design box cannot produce a scale.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum ScaleError {
25    /// A side is NaN or infinite. Viewport size is read from the platform, so
26    /// this means a divide went wrong upstream.
27    NotFinite,
28    /// A side is zero or negative: the window has no area yet, or the caller
29    /// swapped an argument.
30    NotPositive,
31}
32
33impl std::fmt::Display for ScaleError {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            ScaleError::NotFinite => f.write_str("viewport or design side is not finite"),
37            ScaleError::NotPositive => f.write_str("viewport or design side is not positive"),
38        }
39    }
40}
41
42impl std::error::Error for ScaleError {}
43
44/// The two viewport scales, computed once per frame.
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct Scale {
47    fit: f32,
48    cover: f32,
49}
50
51impl Scale {
52    /// Scales for `viewport` (design units on screen) and `design` (the box
53    /// the game was drawn in).
54    pub fn new(viewport: [f32; 2], design: [f32; 2]) -> Result<Self, ScaleError> {
55        for side in viewport.into_iter().chain(design) {
56            if !side.is_finite() {
57                return Err(ScaleError::NotFinite);
58            }
59            if side <= 0.0 {
60                return Err(ScaleError::NotPositive);
61            }
62        }
63        let fit = (viewport[0] / design[0]).min(viewport[1] / design[1]);
64        let cover = (viewport[0] / design[0]).max(viewport[1] / design[1]);
65        Ok(Self { fit, cover })
66    }
67
68    /// The design box scaled to the design box itself: a neutral fallback for a
69    /// viewport that has not been measured yet.
70    pub const fn unit() -> Self {
71        Self {
72            fit: 1.0,
73            cover: 1.0,
74        }
75    }
76
77    /// [`new`](Self::new) for a frame loop: a viewport that is not measured yet
78    /// (the first frames, a minimized window) reads as [`unit`](Self::unit)
79    /// instead of failing the frame.
80    pub fn fit_or_unit(viewport: [f32; 2], design: [f32; 2]) -> Self {
81        Self::new(viewport, design).unwrap_or_else(|_| Self::unit())
82    }
83
84    /// Everything fits: no cropping.
85    pub const fn fit(&self) -> f32 {
86        self.fit
87    }
88
89    /// Everything is covered: no letterboxing.
90    pub const fn cover(&self) -> f32 {
91        self.cover
92    }
93
94    /// Scale a design-unit measurement for the `fit` scale.
95    pub fn fit_units(&self, units: f32) -> f32 {
96        units * self.fit
97    }
98
99    /// Scale a design-unit measurement for the `cover` scale.
100    pub fn cover_units(&self, units: f32) -> f32 {
101        units * self.cover
102    }
103}
104
105impl Default for Scale {
106    fn default() -> Self {
107        Self::unit()
108    }
109}
110
111/// The four design-space edges [`anchor_rect`] produces.
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct AnchorEdges {
114    pub left: f32,
115    pub top: f32,
116    pub right: f32,
117    pub bottom: f32,
118}
119
120/// Resolve an anchor and offset against a viewport.
121///
122/// `anchor` is `[left, top, right, bottom]` as fractions of the viewport;
123/// `offset` is `[left, top, right, bottom]` in design units, scaled by `scale`.
124/// Only the right and bottom edges are clamped into the viewport.
125pub fn anchor_edges(
126    viewport: [f32; 2],
127    scale: f32,
128    anchor: [f32; 4],
129    offset: [f32; 4],
130) -> AnchorEdges {
131    let left = anchor[0] * viewport[0] + offset[0] * scale;
132    let top = anchor[1] * viewport[1] + offset[1] * scale;
133    let right = anchor[2] * viewport[0] + offset[2] * scale;
134    let bottom = anchor[3] * viewport[1] + offset[3] * scale;
135    AnchorEdges {
136        left,
137        top,
138        right: (viewport[0] - right).max(0.0),
139        bottom: (viewport[1] - bottom).max(0.0),
140    }
141}
142
143/// An absolutely positioned, full-bleed helper box: the games' `rect(...)`.
144///
145/// The returned modifier carries no paint or input behaviour, so the caller
146/// adds a background, a border, a click handler or `hit_passthrough()`.
147pub fn anchor_rect(viewport: [f32; 2], scale: f32, anchor: [f32; 4], offset: [f32; 4]) -> Modifier {
148    let edges = anchor_edges(viewport, scale, anchor, offset);
149    Modifier::new()
150        .absolute()
151        .offset_left(Dp(edges.left))
152        .offset_top(Dp(edges.top))
153        .offset_right(Dp(edges.right))
154        .offset_bottom(Dp(edges.bottom))
155}
156
157/// A filled circle at a design-space point, with a design-unit radius.
158pub fn disc(viewport: [f32; 2], scale: f32, at: [f32; 2], radius: f32, color: Color) -> View {
159    RBox(
160        anchor_rect(
161            viewport,
162            scale,
163            [0.0; 4],
164            [
165                at[0] - radius,
166                at[1] - radius,
167                at[0] + radius,
168                at[1] + radius,
169            ],
170        )
171        .background(color)
172        .clip_rounded(Dp(radius * scale))
173        .hit_passthrough(),
174    )
175    .child(Spacer())
176}
177
178/// An unfilled circle: the virtual stick's ring.
179pub fn ring(
180    viewport: [f32; 2],
181    scale: f32,
182    at: [f32; 2],
183    radius: f32,
184    width: f32,
185    color: Color,
186) -> View {
187    RBox(
188        anchor_rect(
189            viewport,
190            scale,
191            [0.0; 4],
192            [
193                at[0] - radius,
194                at[1] - radius,
195                at[0] + radius,
196                at[1] + radius,
197            ],
198        )
199        .border(Dp(width * scale), color, Dp(radius * scale))
200        .background(Color::TRANSPARENT)
201        .hit_passthrough(),
202    )
203    .child(Spacer())
204}
205
206/// Pointer position in physical pixels to design units.
207///
208/// The platform reports pointers in physical pixels and layout in density
209/// pixels; this is the division the games do before comparing a pointer against
210/// design coordinates. `scale` is the frame's [`Scale::fit`].
211pub fn design_units(pixels: repose_core::Vec2, scale: f32) -> glam::Vec2 {
212    let density = repose_core::locals::effective_density_scale().max(1e-6);
213    let factor = (density * scale.max(1e-6)).max(1e-6);
214    glam::Vec2::new(pixels.x, pixels.y) / factor
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn fit_is_the_smaller_ratio_and_cover_the_larger() {
223        let scale = Scale::new([1280.0, 720.0], DESIGN).expect("16:9 viewport scales");
224        assert!((scale.fit() - 1280.0 / 960.0).abs() < 1e-6);
225        assert!((scale.cover() - 1280.0 / 960.0).abs() < 1e-6);
226
227        let tall = Scale::new([600.0, 900.0], DESIGN).expect("4:3-ish viewport scales");
228        assert!((tall.fit() - 600.0 / 960.0).abs() < 1e-6);
229        assert!((tall.cover() - 900.0 / 540.0).abs() < 1e-6);
230        assert!(tall.cover() > tall.fit());
231    }
232
233    #[test]
234    fn unit_scales_are_neutral() {
235        let unit = Scale::unit();
236        assert_eq!(unit.fit_units(46.0), 46.0);
237        assert_eq!(unit.cover_units(46.0), 46.0);
238        assert_eq!(Scale::fit_or_unit([0.0, 0.0], DESIGN), Scale::unit());
239        assert_eq!(
240            Scale::fit_or_unit([960.0, 540.0], DESIGN),
241            Scale::new([960.0, 540.0], DESIGN).expect("scales")
242        );
243    }
244
245    #[test]
246    fn a_zero_sized_viewport_is_rejected_until_it_is_measured() {
247        assert_eq!(Scale::new([0.0, 720.0], DESIGN), Err(ScaleError::NotPositive));
248        assert_eq!(Scale::new([1280.0, -1.0], DESIGN), Err(ScaleError::NotPositive));
249        assert_eq!(Scale::new([1280.0, 720.0], [960.0, 0.0]), Err(ScaleError::NotPositive));
250    }
251
252    #[test]
253    fn a_non_finite_viewport_is_rejected() {
254        assert_eq!(Scale::new([f32::NAN, 720.0], DESIGN), Err(ScaleError::NotFinite));
255        assert_eq!(
256            Scale::new([1280.0, 720.0], [f32::INFINITY, 540.0]),
257            Err(ScaleError::NotFinite)
258        );
259    }
260
261    #[test]
262    fn anchor_edges_follow_the_viewport_fractions_and_offsets() {
263        let edges = anchor_edges([1000.0, 500.0], 2.0, [0.5, 0.5, 0.5, 0.5], [-100.0, -50.0, 100.0, 50.0]);
264        assert_eq!(edges.left, 500.0 - 200.0);
265        assert_eq!(edges.top, 250.0 - 100.0);
266        assert_eq!(edges.right, 1000.0 - 700.0);
267        assert_eq!(edges.bottom, 500.0 - 350.0);
268    }
269
270    #[test]
271    fn right_and_bottom_edges_clamp_into_the_viewport_left_does_not() {
272        let edges = anchor_edges([1000.0, 500.0], 1.0, [0.0, 0.0, 2.0, 2.0], [-40.0, 0.0, 0.0, 0.0]);
273        assert_eq!(edges.left, -40.0, "an off-screen left edge stays off-screen");
274        assert_eq!(edges.right, 0.0);
275        assert_eq!(edges.bottom, 0.0);
276    }
277
278    #[test]
279    fn a_full_bleed_anchor_covers_the_viewport() {
280        let edges = anchor_edges([1000.0, 500.0], 3.0, [0.0, 0.0, 1.0, 1.0], [0.0; 4]);
281        assert_eq!((edges.left, edges.top), (0.0, 0.0));
282        assert_eq!((edges.right, edges.bottom), (0.0, 0.0));
283    }
284}