Skip to main content

pfx_gpu/screens/
policy.rs

1use std::fmt;
2
3use serde::Deserialize;
4
5use super::device::Device;
6use crate::window::{
7    Letterbox, LetterboxRounding, Rect, RenderScale, Size, Viewport, letterbox_nearest,
8    viewport_with,
9};
10
11pub const TABLE: &str = "screen";
12pub const DEFAULT_TOLERANCE: f64 = 0.03;
13pub const DEFAULT_LAYOUT_HEIGHT: u32 = 1080;
14pub const LAYOUT_WIDTHS: std::ops::RangeInclusive<u32> = 240..=15360;
15pub const DECK_SAFE: f32 = 0.03;
16pub const DECK_UI_SCALE: f32 = 1.4;
17pub const BACKDROP: &str = "backdrop";
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub struct Aspect {
21    pub width: u32,
22    pub height: u32,
23}
24
25impl Aspect {
26    pub const DECK: Aspect = Aspect {
27        width: 16,
28        height: 10,
29    };
30    pub const WIDE: Aspect = Aspect {
31        width: 16,
32        height: 9,
33    };
34
35    pub fn new(width: u32, height: u32) -> Option<Self> {
36        (width > 0 && height > 0).then_some(Self { width, height })
37    }
38
39    pub fn of(size: Size) -> Option<Self> {
40        let divisor = gcd(size.width, size.height);
41        if divisor == 0 {
42            return None;
43        }
44        Self::new(size.width / divisor, size.height / divisor)
45    }
46
47    pub fn ratio(self) -> f64 {
48        f64::from(self.width) / f64::from(self.height)
49    }
50
51    pub fn parse(text: &str) -> Option<Self> {
52        let (width, height) = text.trim().split_once(':')?;
53        Self::new(width.trim().parse().ok()?, height.trim().parse().ok()?)
54    }
55}
56
57impl fmt::Display for Aspect {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        write!(f, "{}:{}", self.width, self.height)
60    }
61}
62
63fn gcd(mut a: u32, mut b: u32) -> u32 {
64    while b != 0 {
65        (a, b) = (b, a % b);
66    }
67    a
68}
69
70#[derive(Clone, Copy, Debug, Default, PartialEq)]
71pub struct Inset {
72    pub left: f32,
73    pub right: f32,
74    pub top: f32,
75    pub bottom: f32,
76}
77
78impl Inset {
79    pub const NONE: Inset = Inset::all(0.0);
80
81    pub const fn all(fraction: f32) -> Self {
82        Self {
83            left: fraction,
84            right: fraction,
85            top: fraction,
86            bottom: fraction,
87        }
88    }
89}
90
91#[derive(Clone, Copy, Debug, PartialEq)]
92pub struct PerDevice<T> {
93    pub deck: T,
94    pub desktop: T,
95}
96
97impl<T: Copy> PerDevice<T> {
98    pub fn get(&self, device: Device) -> T {
99        if device.is_deck() {
100            self.deck
101        } else {
102            self.desktop
103        }
104    }
105}
106
107#[derive(Clone, Debug, PartialEq)]
108pub struct ScreenPolicy {
109    pub deck: Vec<Aspect>,
110    pub desktop: Vec<Aspect>,
111    pub tolerance: f64,
112    pub bars: [f32; 4],
113    pub backdrop: bool,
114    pub layout_height: u32,
115    pub layout_width: Option<u32>,
116    pub safe: PerDevice<Inset>,
117    pub ui_scale: PerDevice<f32>,
118}
119
120impl Default for ScreenPolicy {
121    fn default() -> Self {
122        Self {
123            deck: vec![Aspect::DECK],
124            desktop: vec![Aspect::WIDE],
125            tolerance: DEFAULT_TOLERANCE,
126            bars: [0.0, 0.0, 0.0, 1.0],
127            backdrop: false,
128            layout_height: DEFAULT_LAYOUT_HEIGHT,
129            layout_width: None,
130            safe: PerDevice {
131                deck: Inset::all(DECK_SAFE),
132                desktop: Inset::NONE,
133            },
134            ui_scale: PerDevice {
135                deck: DECK_UI_SCALE,
136                desktop: 1.0,
137            },
138        }
139    }
140}
141
142#[derive(Clone, Debug, PartialEq, Eq)]
143pub struct PolicyError {
144    pub key: String,
145    pub reason: String,
146}
147
148impl fmt::Display for PolicyError {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        write!(f, "[{TABLE}] {}: {}", self.key, self.reason)
151    }
152}
153
154impl std::error::Error for PolicyError {}
155
156fn refuse(key: &str, reason: impl Into<String>) -> PolicyError {
157    PolicyError {
158        key: key.to_owned(),
159        reason: reason.into(),
160    }
161}
162
163#[derive(Deserialize, Default)]
164#[serde(deny_unknown_fields)]
165struct RawPolicy {
166    deck: Option<Vec<String>>,
167    desktop: Option<Vec<String>>,
168    tolerance: Option<f64>,
169    bars: Option<String>,
170    layout_height: Option<u32>,
171    layout_width: Option<u32>,
172    #[serde(default)]
173    safe: RawPerDevice<RawInset>,
174    #[serde(default)]
175    ui_scale: RawPerDevice<f32>,
176}
177
178#[derive(Deserialize)]
179#[serde(deny_unknown_fields)]
180struct RawPerDevice<T> {
181    deck: Option<T>,
182    desktop: Option<T>,
183}
184
185impl<T> Default for RawPerDevice<T> {
186    fn default() -> Self {
187        Self {
188            deck: None,
189            desktop: None,
190        }
191    }
192}
193
194#[derive(Deserialize, Clone, Copy)]
195#[serde(untagged)]
196enum RawInset {
197    All(f32),
198    Sides {
199        left: f32,
200        right: f32,
201        top: f32,
202        bottom: f32,
203    },
204}
205
206impl RawInset {
207    fn inset(self) -> Inset {
208        match self {
209            RawInset::All(fraction) => Inset::all(fraction),
210            RawInset::Sides {
211                left,
212                right,
213                top,
214                bottom,
215            } => Inset {
216                left,
217                right,
218                top,
219                bottom,
220            },
221        }
222    }
223}
224
225impl ScreenPolicy {
226    pub fn from_toml(text: &str) -> Result<Self, PolicyError> {
227        let document: toml::Table =
228            toml::from_str(text).map_err(|error| refuse(TABLE, error.message().to_owned()))?;
229        match document.get(TABLE) {
230            Some(toml::Value::Table(table)) => Self::from_table(table),
231            Some(_) => Err(refuse(TABLE, "must be a table")),
232            None => Ok(Self::default()),
233        }
234    }
235
236    pub fn from_table(table: &toml::Table) -> Result<Self, PolicyError> {
237        let raw: RawPolicy = toml::Value::Table(table.clone())
238            .try_into()
239            .map_err(|error: toml::de::Error| refuse(TABLE, error.message().to_owned()))?;
240        let defaults = Self::default();
241        if raw.layout_height.is_some() && raw.layout_width.is_some() {
242            return Err(refuse(
243                "layout_width",
244                "set layout_width or layout_height, not both",
245            ));
246        }
247        let policy = Self {
248            deck: aspects("deck", raw.deck, defaults.deck)?,
249            desktop: aspects("desktop", raw.desktop, defaults.desktop)?,
250            tolerance: raw.tolerance.unwrap_or(defaults.tolerance),
251            bars: match raw.bars.as_deref() {
252                Some(BACKDROP) | None => defaults.bars,
253                Some(text) => colour(text).ok_or_else(|| {
254                    refuse(
255                        "bars",
256                        format!("{text:?} is not \"{BACKDROP}\" or a #rrggbb or #rrggbbaa colour"),
257                    )
258                })?,
259            },
260            backdrop: raw.bars.as_deref() == Some(BACKDROP),
261            layout_height: raw.layout_height.unwrap_or(defaults.layout_height),
262            layout_width: raw.layout_width,
263            safe: PerDevice {
264                deck: raw.safe.deck.map_or(defaults.safe.deck, RawInset::inset),
265                desktop: raw
266                    .safe
267                    .desktop
268                    .map_or(defaults.safe.desktop, RawInset::inset),
269            },
270            ui_scale: PerDevice {
271                deck: raw.ui_scale.deck.unwrap_or(defaults.ui_scale.deck),
272                desktop: raw.ui_scale.desktop.unwrap_or(defaults.ui_scale.desktop),
273            },
274        };
275        policy.validate()?;
276        Ok(policy)
277    }
278
279    pub fn validate(&self) -> Result<(), PolicyError> {
280        if !(0.0..=0.25).contains(&self.tolerance) {
281            return Err(refuse("tolerance", "must be from 0 to 0.25"));
282        }
283        if !(240..=8640).contains(&self.layout_height) {
284            return Err(refuse("layout_height", "must be from 240 to 8640"));
285        }
286        if self
287            .layout_width
288            .is_some_and(|width| !LAYOUT_WIDTHS.contains(&width))
289        {
290            return Err(refuse("layout_width", "must be from 240 to 15360"));
291        }
292        if self
293            .bars
294            .iter()
295            .any(|channel| !(0.0..=1.0).contains(channel))
296        {
297            return Err(refuse("bars", "channels must be from 0 to 1"));
298        }
299        for (key, inset) in [
300            ("safe.deck", self.safe.deck),
301            ("safe.desktop", self.safe.desktop),
302        ] {
303            let sides = [inset.left, inset.right, inset.top, inset.bottom];
304            if sides.iter().any(|side| !(0.0..=0.25).contains(side)) {
305                return Err(refuse(key, "each side must be from 0 to 0.25"));
306            }
307        }
308        for (key, scale) in [
309            ("ui_scale.deck", self.ui_scale.deck),
310            ("ui_scale.desktop", self.ui_scale.desktop),
311        ] {
312            if !(0.25..=4.0).contains(&scale) {
313                return Err(refuse(key, "must be from 0.25 to 4"));
314            }
315        }
316        for (key, list) in [("deck", &self.deck), ("desktop", &self.desktop)] {
317            if list.iter().any(|aspect| {
318                let ratio = aspect.ratio();
319                !(0.25..=8.0).contains(&ratio)
320            }) {
321                return Err(refuse(key, "aspects must be from 1:4 to 8:1"));
322            }
323        }
324        Ok(())
325    }
326
327    pub fn aspects(&self, device: Device) -> &[Aspect] {
328        if device.is_deck() {
329            &self.deck
330        } else {
331            &self.desktop
332        }
333    }
334
335    pub fn fit(&self, device: Device, window: Size) -> Option<ScreenReport> {
336        let actual = Aspect::of(window)?;
337        let ratio = f64::from(window.width) / f64::from(window.height);
338        let supported = self.aspects(device);
339        let nearest = supported.iter().copied().min_by(|a, b| {
340            distance(ratio, a.ratio())
341                .total_cmp(&distance(ratio, b.ratio()))
342                .then(a.width.cmp(&b.width))
343        });
344        let (aspect, native, layout_ratio) = match nearest {
345            None => (actual, true, ratio),
346            Some(aspect) if (ratio / aspect.ratio() - 1.0).abs() <= self.tolerance => {
347                (aspect, true, ratio)
348            }
349            Some(aspect) => (aspect, false, aspect.ratio()),
350        };
351        let layout = self.layout_size(layout_ratio)?;
352        let letterbox = letterbox_nearest(layout.width as f32, layout.height as f32, window)?;
353        let content = letterbox.visible;
354        let bars = if native {
355            Bars::None
356        } else if aspect.ratio() < ratio {
357            Bars::Pillarbox
358        } else {
359            Bars::Letterbox
360        };
361        let inset = self.safe.get(device);
362        let short = content.width.min(content.height);
363        let safe = Rect {
364            x: content.x + inset.left * short,
365            y: content.y + inset.top * short,
366            width: content.width - (inset.left + inset.right) * short,
367            height: content.height - (inset.top + inset.bottom) * short,
368        };
369        let [lx, ly] = letterbox.unmap(safe.x, safe.y);
370        let [rx, ry] = letterbox.unmap(safe.x + safe.width, safe.y + safe.height);
371        Some(ScreenReport {
372            device,
373            window,
374            aspect,
375            native,
376            bars,
377            bar_colour: self.bars,
378            backdrop: self.backdrop,
379            layout,
380            letterbox,
381            content,
382            safe,
383            safe_layout: Rect {
384                x: lx,
385                y: ly,
386                width: rx - lx,
387                height: ry - ly,
388            },
389            ui_scale: self.ui_scale.get(device),
390        })
391    }
392}
393
394impl ScreenPolicy {
395    pub fn layout_size(&self, ratio: f64) -> Option<Size> {
396        if !(ratio.is_finite() && ratio > 0.0) {
397            return None;
398        }
399        let units = |value: f64| u32::try_from(value.round().max(1.0) as u64).ok();
400        match self.layout_width {
401            Some(width) => Some(Size {
402                width,
403                height: units(f64::from(width) / ratio)?,
404            }),
405            None => Some(Size {
406                width: units(f64::from(self.layout_height) * ratio)?,
407                height: self.layout_height,
408            }),
409        }
410    }
411}
412
413fn distance(a: f64, b: f64) -> f64 {
414    (a / b).ln().abs()
415}
416
417fn aspects(
418    key: &str,
419    raw: Option<Vec<String>>,
420    defaults: Vec<Aspect>,
421) -> Result<Vec<Aspect>, PolicyError> {
422    let Some(raw) = raw else {
423        return Ok(defaults);
424    };
425    raw.iter()
426        .map(|text| {
427            Aspect::parse(text)
428                .ok_or_else(|| refuse(key, format!("{text:?} is not an aspect such as \"16:9\"")))
429        })
430        .collect()
431}
432
433fn colour(text: &str) -> Option<[f32; 4]> {
434    let hex = text.trim().strip_prefix('#')?;
435    if !(hex.len() == 6 || hex.len() == 8) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
436        return None;
437    }
438    let channel = |index: usize| {
439        u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16)
440            .ok()
441            .map(|value| f32::from(value) / 255.0)
442    };
443    Some([
444        channel(0)?,
445        channel(1)?,
446        channel(2)?,
447        if hex.len() == 8 { channel(3)? } else { 1.0 },
448    ])
449}
450
451#[derive(Clone, Copy, Debug, PartialEq, Eq)]
452pub enum Bars {
453    None,
454    Pillarbox,
455    Letterbox,
456}
457
458#[derive(Clone, Copy, Debug, PartialEq)]
459pub struct ScreenReport {
460    pub device: Device,
461    pub window: Size,
462    pub aspect: Aspect,
463    pub native: bool,
464    pub bars: Bars,
465    pub bar_colour: [f32; 4],
466    pub backdrop: bool,
467    pub layout: Size,
468    pub letterbox: Letterbox,
469    pub content: Rect,
470    pub safe: Rect,
471    pub safe_layout: Rect,
472    pub ui_scale: f32,
473}
474
475impl ScreenReport {
476    pub fn layout_units(&self) -> [f32; 2] {
477        [self.layout.width as f32, self.layout.height as f32]
478    }
479
480    pub fn pixels_per_unit(&self) -> f32 {
481        self.letterbox.matrix[0]
482    }
483
484    pub fn window_units(&self) -> [f32; 2] {
485        let scale = self.pixels_per_unit();
486        [
487            self.window.width as f32 / scale,
488            self.window.height as f32 / scale,
489        ]
490    }
491
492    pub fn content_units(&self) -> Rect {
493        let scale = self.pixels_per_unit();
494        Rect {
495            x: self.content.x / scale,
496            y: self.content.y / scale,
497            width: self.layout.width as f32,
498            height: self.layout.height as f32,
499        }
500    }
501
502    pub fn units_for_pixels(&self, pixels: f32) -> f32 {
503        pixels / self.pixels_per_unit()
504    }
505
506    pub fn viewport(&self) -> Option<Viewport> {
507        viewport_with(
508            self.window,
509            RenderScale::Native,
510            self.layout,
511            LetterboxRounding::Nearest,
512        )
513    }
514
515    pub fn render_size(&self, scale: f32) -> Option<Size> {
516        let content = Size {
517            width: self.content.width.round() as u32,
518            height: self.content.height.round() as u32,
519        };
520        crate::window::render_size(content, RenderScale::Factor(scale))
521    }
522
523    pub fn in_bars(&self, window_x: f32, window_y: f32) -> bool {
524        !self.letterbox.contains(window_x, window_y)
525    }
526}