1use crate::{Insets, Orientation, Rect, Size, TargetPlatform, WindowInsets};
18
19#[derive(Clone, Debug, PartialEq)]
27pub struct DeviceProfile {
28 pub id: String,
31 pub name: String,
33 pub platform: TargetPlatform,
35 pub screen: Size,
37 pub pixel_ratio: f32,
39 pub screen_radius: f32,
41 pub cutout: Cutout,
43 pub portrait: DeviceInsets,
45 pub landscape: DeviceInsets,
47 pub frame: DeviceFrame,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct DeviceInsets {
54 pub safe: Insets,
56 pub keyboard: f32,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq)]
63pub enum Cutout {
64 None,
66 Notch {
68 width: f32,
70 height: f32,
72 radius: f32,
74 },
75 Island {
77 width: f32,
79 height: f32,
81 top: f32,
83 },
84 Hole {
86 diameter: f32,
88 top: f32,
90 },
91}
92
93#[derive(Clone, Debug, PartialEq)]
95pub struct DeviceFrame {
96 pub bezel: Insets,
98 pub radius: f32,
100 pub home_button: bool,
102 pub buttons: Vec<SideButton>,
104}
105
106#[derive(Clone, Copy, Debug, PartialEq)]
108pub struct SideButton {
109 pub edge: ButtonEdge,
111 pub top: f32,
113 pub length: f32,
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum ButtonEdge {
120 Left,
122 Right,
124}
125
126impl DeviceProfile {
127 pub fn screen_size(&self, orientation: Orientation) -> Size {
129 match orientation {
130 Orientation::Portrait => self.screen,
131 Orientation::Landscape => Size::new(self.screen.height, self.screen.width),
132 }
133 }
134
135 pub fn physical_size(&self, orientation: Orientation) -> Size {
137 let size = self.screen_size(orientation);
138 Size::new(
139 (size.width * self.pixel_ratio).round(),
140 (size.height * self.pixel_ratio).round(),
141 )
142 }
143
144 pub fn insets_in(&self, orientation: Orientation) -> DeviceInsets {
146 match orientation {
147 Orientation::Portrait => self.portrait,
148 Orientation::Landscape => self.landscape,
149 }
150 }
151
152 pub fn window_insets(&self, orientation: Orientation, keyboard: bool) -> WindowInsets {
155 let insets = self.insets_in(orientation);
156 let mut current = insets.safe;
157 if keyboard {
158 current.bottom = current.bottom.max(insets.keyboard);
159 }
160 WindowInsets::from_baseline(insets.safe, current)
161 }
162
163 pub fn cutout_rect(&self, orientation: Orientation) -> Option<Rect> {
168 let (width, height, top) = match self.cutout {
169 Cutout::None => return None,
170 Cutout::Notch { width, height, .. } => (width, height, 0.0),
171 Cutout::Island { width, height, top } => (width, height, top),
172 Cutout::Hole { diameter, top } => (diameter, diameter, top),
173 };
174 let across = (self.screen.width - width) / 2.0;
175 Some(match orientation {
176 Orientation::Portrait => Rect::new(across, top, width, height),
177 Orientation::Landscape => Rect::new(top, across, height, width),
178 })
179 }
180
181 pub fn outer_size(&self, orientation: Orientation) -> Size {
183 let b = self.frame.bezel;
184 let upright = Size::new(
185 self.screen.width + b.left + b.right,
186 self.screen.height + b.top + b.bottom,
187 );
188 match orientation {
189 Orientation::Portrait => upright,
190 Orientation::Landscape => Size::new(upright.height, upright.width),
191 }
192 }
193
194 pub fn screen_rect(&self, orientation: Orientation) -> Rect {
199 let b = self.frame.bezel;
200 let size = self.screen_size(orientation);
201 match orientation {
202 Orientation::Portrait => Rect::new(b.left, b.top, size.width, size.height),
203 Orientation::Landscape => Rect::new(b.top, b.right, size.width, size.height),
204 }
205 }
206
207 pub fn find<'a>(profiles: &'a [DeviceProfile], wanted: &str) -> Option<&'a DeviceProfile> {
209 profiles
210 .iter()
211 .find(|p| p.id.eq_ignore_ascii_case(wanted) || p.name.eq_ignore_ascii_case(wanted))
212 }
213
214 pub fn parse(spec: &str) -> Result<DeviceProfile, String> {
228 const KEYS: [&str; 14] = [
229 "id",
230 "name",
231 "platform",
232 "width",
233 "height",
234 "ratio",
235 "corner-radius",
236 "safe-area",
237 "landscape-safe-area",
238 "keyboard",
239 "landscape-keyboard",
240 "cutout",
241 "cutout-size",
242 "cutout-top",
243 ];
244 let mut pairs: Vec<(&str, &str)> = Vec::new();
245 for part in spec.split(';').map(str::trim).filter(|p| !p.is_empty()) {
246 let (key, value) = part
247 .split_once('=')
248 .ok_or_else(|| format!("`{part}` is not `key = value`"))?;
249 let key = key.trim();
250 if !KEYS.contains(&key) {
251 return Err(format!(
252 "`{key}` is not a device key; the keys: {}",
253 KEYS.join(", ")
254 ));
255 }
256 pairs.push((key, value.trim()));
257 }
258 let get = |key: &str| pairs.iter().rev().find(|(k, _)| *k == key).map(|(_, v)| *v);
259 let id = get("id")
260 .filter(|v| !v.is_empty())
261 .ok_or("a device needs an `id`")?;
262 let numbers = |key: &str, count: usize| -> Result<Option<Vec<f32>>, String> {
263 let Some(value) = get(key) else {
264 return Ok(None);
265 };
266 let parsed: Result<Vec<f32>, _> =
267 value.split(',').map(|n| n.trim().parse::<f32>()).collect();
268 match parsed {
269 Ok(numbers) if numbers.len() == count && numbers.iter().all(|n| n.is_finite()) => {
270 Ok(Some(numbers))
271 }
272 _ if count == 1 => Err(format!("{id}: `{key}` is {value}, not a number")),
273 _ => Err(format!("{id}: `{key}` is {value}, not {count} numbers")),
274 }
275 };
276 let number = |key: &str| numbers(key, 1).map(|n| n.map(|n| n[0]));
277 let positive = |key: &str| -> Result<f32, String> {
278 match number(key)? {
279 Some(n) if n > 0.0 => Ok(n),
280 Some(n) => Err(format!("{id}: `{key}` is {n}; it must be more than 0")),
281 None => Err(format!("{id}: a device needs a `{key}`")),
282 }
283 };
284 let insets =
285 |key: &str| numbers(key, 4).map(|n| n.map(|n| Insets::new(n[0], n[1], n[2], n[3])));
286
287 if !id
288 .chars()
289 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
290 {
291 return Err(format!(
292 "`{id}` is not a device id: lowercase letters, digits and `-` only"
293 ));
294 }
295 let platform = match get("platform").map(str::to_ascii_lowercase).as_deref() {
296 Some("ios") => TargetPlatform::Ios,
297 Some("android") => TargetPlatform::Android,
298 Some(other) => return Err(format!("{id}: `platform` is {other}; ios or android")),
299 None => return Err(format!("{id}: a device needs a `platform`, ios or android")),
300 };
301 let (width, height, pixel_ratio) =
302 (positive("width")?, positive("height")?, positive("ratio")?);
303 let screen_radius = number("corner-radius")?.unwrap_or(0.0).max(0.0);
304 let safe = insets("safe-area")?.unwrap_or(Insets::ZERO);
305 let landscape_safe = insets("landscape-safe-area")?.unwrap_or(Insets::new(
307 safe.right,
308 safe.bottom,
309 safe.left,
310 safe.top,
311 ));
312 let cutout = match get("cutout").unwrap_or("none") {
313 "none" => Cutout::None,
314 kind => {
315 let size = numbers("cutout-size", 2)?
316 .ok_or_else(|| format!("{id}: a `{kind}` cutout needs a `cutout-size`"))?;
317 let top = number("cutout-top")?.unwrap_or(0.0);
318 match kind {
319 "notch" => Cutout::Notch {
320 width: size[0],
321 height: size[1],
322 radius: size[1] / 3.0,
323 },
324 "island" => Cutout::Island {
325 width: size[0],
326 height: size[1],
327 top,
328 },
329 "hole" => Cutout::Hole {
330 diameter: size[0],
331 top,
332 },
333 other => {
334 return Err(format!(
335 "{id}: `cutout` is {other}; none, notch, island or hole"
336 ))
337 }
338 }
339 }
340 };
341 Ok(DeviceProfile {
342 id: id.to_string(),
343 name: get("name")
344 .filter(|n| !n.is_empty())
345 .unwrap_or(id)
346 .to_string(),
347 platform,
348 screen: Size::new(width, height),
349 pixel_ratio,
350 screen_radius,
351 cutout,
352 portrait: DeviceInsets {
353 safe,
354 keyboard: number("keyboard")?.unwrap_or(height * 2.0 / 5.0),
355 },
356 landscape: DeviceInsets {
357 safe: landscape_safe,
358 keyboard: number("landscape-keyboard")?.unwrap_or(width * 3.0 / 5.0),
359 },
360 frame: DeviceFrame {
361 bezel: Insets::new(12.0, 12.0, 12.0, 12.0),
362 radius: screen_radius + 12.0,
363 home_button: false,
364 buttons: Vec::new(),
365 },
366 })
367 }
368
369 pub fn builtin() -> Vec<DeviceProfile> {
378 let island = Cutout::Island {
379 width: 126.0,
380 height: 37.0,
381 top: 11.0,
382 };
383 let island_landscape = DeviceInsets {
384 safe: Insets::new(0.0, 59.0, 21.0, 59.0),
385 keyboard: 209.0,
386 };
387 let iphone_buttons = vec![
388 SideButton {
389 edge: ButtonEdge::Left,
390 top: 120.0,
391 length: 32.0,
392 },
393 SideButton {
394 edge: ButtonEdge::Left,
395 top: 180.0,
396 length: 62.0,
397 },
398 SideButton {
399 edge: ButtonEdge::Left,
400 top: 256.0,
401 length: 62.0,
402 },
403 SideButton {
404 edge: ButtonEdge::Right,
405 top: 210.0,
406 length: 98.0,
407 },
408 ];
409 vec![
410 DeviceProfile {
411 id: "iphone-se".into(),
412 name: "iPhone SE".into(),
413 platform: TargetPlatform::Ios,
414 screen: Size::new(375.0, 667.0),
415 pixel_ratio: 2.0,
416 screen_radius: 0.0,
417 cutout: Cutout::None,
418 portrait: DeviceInsets {
419 safe: Insets::new(20.0, 0.0, 0.0, 0.0),
420 keyboard: 260.0,
421 },
422 landscape: DeviceInsets {
423 safe: Insets::ZERO,
424 keyboard: 200.0,
425 },
426 frame: DeviceFrame {
427 bezel: Insets::new(96.0, 18.0, 96.0, 18.0),
428 radius: 58.0,
429 home_button: true,
430 buttons: vec![
431 SideButton {
432 edge: ButtonEdge::Left,
433 top: 100.0,
434 length: 30.0,
435 },
436 SideButton {
437 edge: ButtonEdge::Left,
438 top: 160.0,
439 length: 56.0,
440 },
441 SideButton {
442 edge: ButtonEdge::Left,
443 top: 230.0,
444 length: 56.0,
445 },
446 SideButton {
447 edge: ButtonEdge::Right,
448 top: 160.0,
449 length: 56.0,
450 },
451 ],
452 },
453 },
454 DeviceProfile {
455 id: "iphone-15".into(),
456 name: "iPhone 15".into(),
457 platform: TargetPlatform::Ios,
458 screen: Size::new(393.0, 852.0),
459 pixel_ratio: 3.0,
460 screen_radius: 55.0,
461 cutout: island,
462 portrait: DeviceInsets {
463 safe: Insets::new(59.0, 0.0, 34.0, 0.0),
464 keyboard: 336.0,
465 },
466 landscape: island_landscape,
467 frame: DeviceFrame {
468 bezel: Insets::new(14.0, 14.0, 14.0, 14.0),
469 radius: 69.0,
470 home_button: false,
471 buttons: iphone_buttons.clone(),
472 },
473 },
474 DeviceProfile {
475 id: "iphone-15-pro-max".into(),
476 name: "iPhone 15 Pro Max".into(),
477 platform: TargetPlatform::Ios,
478 screen: Size::new(430.0, 932.0),
479 pixel_ratio: 3.0,
480 screen_radius: 55.0,
481 cutout: island,
482 portrait: DeviceInsets {
483 safe: Insets::new(59.0, 0.0, 34.0, 0.0),
484 keyboard: 346.0,
485 },
486 landscape: island_landscape,
487 frame: DeviceFrame {
488 bezel: Insets::new(14.0, 14.0, 14.0, 14.0),
489 radius: 69.0,
490 home_button: false,
491 buttons: iphone_buttons,
492 },
493 },
494 DeviceProfile {
495 id: "ipad".into(),
496 name: "iPad".into(),
497 platform: TargetPlatform::Ios,
498 screen: Size::new(820.0, 1180.0),
499 pixel_ratio: 2.0,
500 screen_radius: 18.0,
501 cutout: Cutout::None,
502 portrait: DeviceInsets {
503 safe: Insets::new(24.0, 0.0, 20.0, 0.0),
504 keyboard: 314.0,
505 },
506 landscape: DeviceInsets {
507 safe: Insets::new(24.0, 0.0, 20.0, 0.0),
508 keyboard: 398.0,
509 },
510 frame: DeviceFrame {
511 bezel: Insets::new(26.0, 26.0, 26.0, 26.0),
512 radius: 44.0,
513 home_button: false,
514 buttons: vec![SideButton {
515 edge: ButtonEdge::Right,
516 top: 60.0,
517 length: 90.0,
518 }],
519 },
520 },
521 DeviceProfile {
522 id: "android-phone".into(),
523 name: "Android phone".into(),
524 platform: TargetPlatform::Android,
525 screen: Size::new(1080.0 / 2.625, 2400.0 / 2.625),
531 pixel_ratio: 2.625,
532 screen_radius: 32.0,
533 cutout: Cutout::Hole {
534 diameter: 88.0 / 2.625,
535 top: 39.0 / 2.625,
536 },
537 portrait: DeviceInsets {
538 safe: Insets::new(136.0 / 2.625, 0.0, 63.0 / 2.625, 0.0),
540 keyboard: 883.0 / 2.625,
541 },
542 landscape: DeviceInsets {
543 safe: Insets::new(63.0 / 2.625, 0.0, 63.0 / 2.625, 136.0 / 2.625),
545 keyboard: 686.0 / 2.625,
546 },
547 frame: DeviceFrame {
548 bezel: Insets::new(12.0, 12.0, 12.0, 12.0),
549 radius: 44.0,
550 home_button: false,
551 buttons: vec![
552 SideButton {
553 edge: ButtonEdge::Right,
554 top: 170.0,
555 length: 56.0,
556 },
557 SideButton {
558 edge: ButtonEdge::Right,
559 top: 250.0,
560 length: 100.0,
561 },
562 ],
563 },
564 },
565 ]
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 fn inside(inner: Rect, outer: Rect) -> bool {
574 inner.x >= outer.x
575 && inner.y >= outer.y
576 && inner.x + inner.width <= outer.x + outer.width + 1e-3
577 && inner.y + inner.height <= outer.y + outer.height + 1e-3
578 }
579
580 #[test]
583 fn every_builtin_profile_has_its_own_id() {
584 let profiles = DeviceProfile::builtin();
585 for (i, p) in profiles.iter().enumerate() {
586 assert!(
587 p.id.chars()
588 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
589 "{} is not a command-line id",
590 p.id
591 );
592 assert!(profiles[..i].iter().all(|q| q.id != p.id), "{} twice", p.id);
593 assert_eq!(DeviceProfile::find(&profiles, &p.id), Some(p));
594 assert_eq!(
595 DeviceProfile::find(&profiles, &p.name.to_uppercase()),
596 Some(p)
597 );
598 }
599 assert_eq!(DeviceProfile::find(&profiles, "nokia-3310"), None);
600 }
601
602 #[test]
606 fn every_builtin_profile_is_consistent() {
607 for p in DeviceProfile::builtin() {
608 let upright = p.physical_size(Orientation::Portrait);
609 let exact = Size::new(
610 p.screen.width * p.pixel_ratio,
611 p.screen.height * p.pixel_ratio,
612 );
613 assert!(
614 (upright.width - exact.width).abs() < 0.01
615 && (upright.height - exact.height).abs() < 0.01,
616 "{}: {exact:?} is not whole pixels",
617 p.id
618 );
619 for o in [Orientation::Portrait, Orientation::Landscape] {
620 let size = p.screen_size(o);
621 let insets = p.insets_in(o);
622 let s = insets.safe;
623 assert!(s.left + s.right < size.width, "{} {o:?}", p.id);
624 assert!(s.top + s.bottom < size.height, "{} {o:?}", p.id);
625 assert!(
626 insets.keyboard >= s.bottom && insets.keyboard < size.height - s.top,
627 "{} {o:?}: keyboard {}",
628 p.id,
629 insets.keyboard
630 );
631 if let Some(cut) = p.cutout_rect(o) {
632 assert!(inside(cut, Rect::new(0.0, 0.0, size.width, size.height)));
633 match o {
634 Orientation::Portrait => assert!(cut.y + cut.height <= s.top, "{}", p.id),
635 Orientation::Landscape => {
636 assert!(cut.x + cut.width <= s.left, "{}", p.id)
637 }
638 }
639 }
640 let outer = p.outer_size(o);
641 let screen = p.screen_rect(o);
642 assert!(inside(
643 screen,
644 Rect::new(0.0, 0.0, outer.width, outer.height)
645 ));
646 assert_eq!((screen.width, screen.height), (size.width, size.height));
647 }
648 assert!(p.screen_radius <= p.frame.radius, "{}", p.id);
649 }
650 }
651
652 #[test]
654 fn landscape_is_a_quarter_turn_counter_clockwise() {
655 let profiles = DeviceProfile::builtin();
656 let p = DeviceProfile::find(&profiles, "iphone-15").unwrap();
657 assert_eq!(
658 p.screen_size(Orientation::Landscape),
659 Size::new(852.0, 393.0)
660 );
661 assert_eq!(
662 p.physical_size(Orientation::Landscape),
663 Size::new(2556.0, 1179.0)
664 );
665 assert_eq!(
666 p.cutout_rect(Orientation::Portrait),
667 Some(Rect::new(133.5, 11.0, 126.0, 37.0))
668 );
669 assert_eq!(
670 p.cutout_rect(Orientation::Landscape),
671 Some(Rect::new(11.0, 133.5, 37.0, 126.0))
672 );
673 assert_eq!(
674 p.outer_size(Orientation::Landscape),
675 Size::new(880.0, 421.0)
676 );
677 assert_eq!(
678 p.screen_rect(Orientation::Landscape),
679 Rect::new(14.0, 14.0, 852.0, 393.0)
680 );
681
682 let se = DeviceProfile::find(&profiles, "iphone-se").unwrap();
683 assert_eq!(se.cutout_rect(Orientation::Portrait), None);
684 assert_eq!(
686 se.screen_rect(Orientation::Landscape),
687 Rect::new(96.0, 18.0, 667.0, 375.0)
688 );
689 }
690
691 #[test]
695 fn the_keyboard_moves_the_view_insets_only() {
696 let profiles = DeviceProfile::builtin();
697 let p = DeviceProfile::find(&profiles, "iphone-15").unwrap();
698
699 let down = p.window_insets(Orientation::Portrait, false);
700 assert_eq!(down, WindowInsets::bars(Insets::new(59.0, 0.0, 34.0, 0.0)));
701
702 let up = p.window_insets(Orientation::Portrait, true);
703 assert_eq!(up.view_insets, Insets::new(0.0, 0.0, 336.0, 0.0));
704 assert_eq!(up.padding, Insets::new(59.0, 0.0, 0.0, 0.0));
705 assert_eq!(up.view_padding, Insets::new(59.0, 0.0, 34.0, 0.0));
706 assert_eq!(up.safe(), Insets::new(59.0, 0.0, 336.0, 0.0));
707
708 let side = p.window_insets(Orientation::Landscape, true);
709 assert_eq!(side.view_insets.bottom, 209.0);
710 assert_eq!(side.padding, Insets::new(0.0, 59.0, 0.0, 59.0));
711 }
712
713 #[test]
716 fn a_described_device_is_read() {
717 let full = DeviceProfile::parse(
718 "id=pixel-9; name=Pixel 9; platform=android; width=412; height=924; ratio=2.625; \
719 corner-radius=40; safe-area=52,0,24,0; landscape-safe-area=24,0,24,52; \
720 keyboard=336; landscape-keyboard=260; cutout=hole; cutout-size=34,34; cutout-top=15",
721 )
722 .unwrap();
723 assert_eq!(
724 (full.id.as_str(), full.name.as_str()),
725 ("pixel-9", "Pixel 9")
726 );
727 assert_eq!(full.platform, TargetPlatform::Android);
728 assert_eq!(
729 (full.screen, full.pixel_ratio),
730 (Size::new(412.0, 924.0), 2.625)
731 );
732 assert_eq!(full.portrait.safe, Insets::new(52.0, 0.0, 24.0, 0.0));
733 assert_eq!(full.landscape.safe, Insets::new(24.0, 0.0, 24.0, 52.0));
734 assert_eq!(
735 (full.portrait.keyboard, full.landscape.keyboard),
736 (336.0, 260.0)
737 );
738 assert_eq!(
739 full.cutout,
740 Cutout::Hole {
741 diameter: 34.0,
742 top: 15.0
743 }
744 );
745 assert_eq!(full.frame.radius, 52.0);
746
747 let bare =
748 DeviceProfile::parse("id=tab;platform=IOS;width=800;height=1200;ratio=2").unwrap();
749 assert_eq!(bare.name, "tab");
750 assert_eq!(bare.platform, TargetPlatform::Ios);
751 assert_eq!(bare.cutout, Cutout::None);
752 assert_eq!(bare.portrait.keyboard, 480.0);
753 assert_eq!(bare.landscape.keyboard, 480.0);
754
755 let turned = DeviceProfile::parse(
757 "id=t;platform=ios;width=390;height=844;ratio=3;safe-area=47,0,34,0",
758 )
759 .unwrap();
760 assert_eq!(turned.landscape.safe, Insets::new(0.0, 34.0, 0.0, 47.0));
761 }
762
763 #[test]
765 fn a_wrong_description_says_what_is_wrong() {
766 let base = "id=p;platform=android;width=400;height=800;ratio=2";
767 let error = |extra: &str| DeviceProfile::parse(&format!("{base};{extra}")).unwrap_err();
768 assert!(error("heigth=900").contains("`heigth` is not a device key"));
769 assert!(error("keyboard=tall").contains("`keyboard` is tall, not a number"));
770 assert!(error("safe-area=1,2,3").contains("not 4 numbers"));
771 assert!(error("cutout=island").contains("needs a `cutout-size`"));
772 assert!(error("cutout=slot;cutout-size=1,1").contains("none, notch, island or hole"));
773 assert!(error("platform=windows").contains("ios or android"));
774 assert!(error("width=0").contains("more than 0"));
775 assert!(
776 DeviceProfile::parse("platform=ios;width=1;height=1;ratio=1")
777 .unwrap_err()
778 .contains("needs an `id`")
779 );
780 assert!(
781 DeviceProfile::parse("id=Big Phone;platform=ios;width=1;height=1;ratio=1")
782 .unwrap_err()
783 .contains("not a device id")
784 );
785 assert!(DeviceProfile::parse("id=p;width=1;height=1;ratio=1")
786 .unwrap_err()
787 .contains("needs a `platform`"));
788 assert!(DeviceProfile::parse("id=p;platform=ios;height=1;ratio=1")
789 .unwrap_err()
790 .contains("needs a `width`"));
791 assert!(DeviceProfile::parse("id=p;nonsense")
792 .unwrap_err()
793 .contains("key = value"));
794 }
795}