Skip to main content

frus_core/
device.rs

1//! Device profiles — what a phone or a tablet looks like to an application, as data.
2//!
3//! A [`DeviceProfile`] describes a device closely enough for a desktop window to stand in
4//! for it: the screen's logical size and density, the corners and the cutout that take
5//! part of it, the areas the system keeps (status bar, home indicator) in each
6//! orientation, how high the soft keyboard comes, the platform whose conventions it
7//! follows, and the outline of the device drawn around the screen.
8//!
9//! It holds no behaviour: the shell reads a profile to size the window, inject the
10//! insets and paint the outline. The built-in profiles are [`DeviceProfile::builtin`];
11//! an application or a developer can describe any other device the same way.
12//!
13//! A simulated device is a **preview**: it gets the layout, the safe areas, the
14//! keyboard and the platform's conventions right, not the device's own fonts, graphics
15//! driver, native services or speed.
16
17use crate::{Insets, Orientation, Rect, Size, TargetPlatform, WindowInsets};
18
19/// A device a desktop window can stand in for: its screen, the areas its system keeps,
20/// its keyboard, its platform and its outline.
21///
22/// Everything is in **logical** pixels and given for the device held **upright**
23/// (portrait); [`Orientation::Landscape`] is the device turned a quarter turn
24/// counter-clockwise, so that what was at the top of the screen — the cutout — is on the
25/// left.
26#[derive(Clone, Debug, PartialEq)]
27pub struct DeviceProfile {
28    /// What `frus run -d` and `frus devices` call it: lowercase, no spaces
29    /// (`"iphone-15"`).
30    pub id: String,
31    /// The name shown to people (`"iPhone 15"`).
32    pub name: String,
33    /// The platform whose conventions the application follows on it.
34    pub platform: TargetPlatform,
35    /// The screen held upright, in logical pixels.
36    pub screen: Size,
37    /// Physical pixels per logical pixel.
38    pub pixel_ratio: f32,
39    /// The radius of the screen's own rounded corners; 0 for a square screen.
40    pub screen_radius: f32,
41    /// The part of the screen taken by the camera, held upright.
42    pub cutout: Cutout,
43    /// The system's areas and the keyboard, held upright.
44    pub portrait: DeviceInsets,
45    /// The system's areas and the keyboard, turned on its side.
46    pub landscape: DeviceInsets,
47    /// The outline drawn around the screen.
48    pub frame: DeviceFrame,
49}
50
51/// The areas of the screen a device's system keeps, in one orientation.
52#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct DeviceInsets {
54    /// What the status bar, the cutout and the home indicator take, keyboard down.
55    pub safe: Insets,
56    /// How high the soft keyboard comes, measured from the bottom edge of the screen
57    /// (so including the home indicator it covers).
58    pub keyboard: f32,
59}
60
61/// The part of a screen taken by the camera, described for the device held upright.
62#[derive(Clone, Copy, Debug, PartialEq)]
63pub enum Cutout {
64    /// The screen is whole.
65    None,
66    /// A notch cut into the top edge, centred: it touches the edge.
67    Notch {
68        /// Across the screen.
69        width: f32,
70        /// From the top edge down.
71        height: f32,
72        /// The radius of its lower corners.
73        radius: f32,
74    },
75    /// A pill floating below the top edge, centred.
76    Island {
77        /// Across the screen.
78        width: f32,
79        /// Its height; the pill's radius is half of it.
80        height: f32,
81        /// From the top edge of the screen to the top of the pill.
82        top: f32,
83    },
84    /// A round hole below the top edge, centred.
85    Hole {
86        /// Its diameter.
87        diameter: f32,
88        /// From the top edge of the screen to the top of the hole.
89        top: f32,
90    },
91}
92
93/// The outline of a device, drawn around its screen.
94#[derive(Clone, Debug, PartialEq)]
95pub struct DeviceFrame {
96    /// How far the body reaches past the screen on each side, held upright.
97    pub bezel: Insets,
98    /// The radius of the body's corners.
99    pub radius: f32,
100    /// A round home button below the screen, centred in the bottom bezel.
101    pub home_button: bool,
102    /// The buttons on the body's edges, held upright.
103    pub buttons: Vec<SideButton>,
104}
105
106/// A button on the edge of a device's body.
107#[derive(Clone, Copy, Debug, PartialEq)]
108pub struct SideButton {
109    /// Which edge it is on, held upright.
110    pub edge: ButtonEdge,
111    /// From the top of the body to the top of the button.
112    pub top: f32,
113    /// How long it is along the edge.
114    pub length: f32,
115}
116
117/// The left or the right edge of a device held upright.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum ButtonEdge {
120    /// The left edge.
121    Left,
122    /// The right edge.
123    Right,
124}
125
126impl DeviceProfile {
127    /// The screen in `orientation`, in logical pixels.
128    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    /// The screen in `orientation`, in physical pixels.
136    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    /// The system's areas and the keyboard in `orientation`.
145    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    /// The insets an application on this device is told about in `orientation`, with
153    /// the soft keyboard up or down — split as a real device's shell splits them.
154    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    /// Where the cutout is on the screen in `orientation`, in logical pixels from the
164    /// screen's top-left corner; `None` for a whole screen.
165    ///
166    /// Turned on its side, the cutout is on the left edge, centred top to bottom.
167    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    /// The whole device — screen and body — in `orientation`, in logical pixels.
182    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    /// Where the screen sits inside [`outer_size`](Self::outer_size) in `orientation`.
195    ///
196    /// Turned counter-clockwise, the top bezel is on the left and the left bezel at the
197    /// bottom.
198    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    /// The profile among `profiles` whose id or name is `wanted`, ignoring case.
208    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    /// A device a developer describes, as `key=value` pairs separated by `;`
215    /// (milestone 727). The keys are those of a project's `[[package.metadata.frus.devices]]`,
216    /// which `frus run` hands over this way; a list is written `a,b,c`.
217    ///
218    /// Required: `id`, `platform` (`ios` or `android`), `width`, `height` (logical, upright)
219    /// and `ratio`. The rest has a default: `name` (the id), `corner-radius` (0), `safe-area`
220    /// (`top,right,bottom,left`, none), `landscape-safe-area` (the upright one turned with the
221    /// device), `keyboard` (two fifths of the height), `landscape-keyboard` (three fifths of
222    /// the width), `cutout` (`none`, `notch`, `island` or `hole`) with `cutout-size`
223    /// (`width,height`) and `cutout-top`. The outline is a plain body around the screen.
224    ///
225    /// An unknown key is an error rather than ignored: a misspelt one would otherwise leave
226    /// its default in place without a word.
227    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        // Turned counter-clockwise, what was at the top is on the left, and so on round.
306        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    /// The devices frus knows without being told: phones and a tablet for iOS, and a
370    /// phone for Android.
371    ///
372    /// The iOS screens, densities, cutouts and safe areas are the devices' published
373    /// figures; the Android phone's are what Android reports for its shape, measured. The
374    /// keyboard heights are typical ones — they change with the keyboard, the language and
375    /// the suggestions bar — and the outlines are frus's own drawing, not a picture of any
376    /// device.
377    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                // 1080 × 2400 physical pixels at 420 dpi: the common shape of a
526                // mid-range phone. Its bars, hole and keyboard are what Android 16 reports
527                // for that shape with a punch hole, gesture navigation and Gboard, in
528                // physical pixels (milestone 723); the hole is put in the middle, where most
529                // phones have it.
530                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                    // The status bar is as tall as the band the hole takes.
539                    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                    // On its side the status bar stays, the hole's band goes to the left.
544                    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    /// **The built-in list can be relied on**: one id each, written as a command line
581    /// wants it, and found by either of its names.
582    #[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    /// **The figures describe a device that can exist**: whole physical pixels, safe
603    /// areas and a keyboard that fit on the screen, a cutout inside the area kept for it
604    /// — upright and on its side.
605    #[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    /// **Turning the device swaps the screen and moves the cutout to the left.**
653    #[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        // The top bezel goes to the left, the left one to the bottom.
685        assert_eq!(
686            se.screen_rect(Orientation::Landscape),
687            Rect::new(96.0, 18.0, 667.0, 375.0)
688        );
689    }
690
691    /// **The keyboard is reported as a device reports it**: it covers the home
692    /// indicator, so nothing pads the bottom twice, and the bars that do not move stay
693    /// in `view_padding`.
694    #[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    /// **A developer's own device reads as it is written** (milestone 727): every key, then
714    /// only the required ones with the rest defaulted.
715    #[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        // On its side, the upright safe area turns with the device: its top goes to the left.
756        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    /// **A mistake is named**, rather than a default quietly taking its place.
764    #[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}