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}