From 9a50d05bf4de323299392cb4573951b1ccc33b7e Mon Sep 17 00:00:00 2001 From: Koji Wakamiya Date: Thu, 5 Nov 2026 08:05:42 +0900 Subject: [PATCH 1/2] feat(shot): lay out with a device's safe area with --safe-area shot --safe-area , (and semantics, and shot(safeArea: SafeAreaInsets(...))) lays every preview out with those safe area insets in logical pixels, which flutter test reports as none. The view reports them as its padding and view padding, so an AppBar grows by the top inset and a SafeArea keeps out of them as on the device; with --keyboard, the keyboard absorbs the bottom inset from the padding but not from the view padding. The status bar and home indicator are not drawn. The run records safe_area, shot prints it, and diff prints both runs' when they differ and returns it as ShotSetup.safeArea; it is not part of shot ids. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 21 +++---- doc/agent.md | 1 + doc/manual.md | 37 +++++++----- lib/src/api/diff.dart | 11 +++- lib/src/api/shoot.dart | 12 ++++ lib/src/api/shot.dart | 27 +++++++++ lib/src/cli/agent_text.dart | 1 + lib/src/cli/manual_text.dart | 37 +++++++----- lib/src/cli/shot_command.dart | 12 ++++ lib/src/cli/shot_options.dart | 13 +++++ lib/src/engine/engine.dart | 4 ++ lib/src/engine/generator.dart | 3 + lib/src/engine/harness_text.dart | 17 ++++++ lib/src/reporters/diff_reporter.dart | 5 ++ lib/src/reporters/format.dart | 18 ++++-- lib/src/run/manifest.dart | 16 ++++- test/api/diff_test.dart | 2 + test/api/shot_test.dart | 17 ++++++ test/api/value_test.dart | 8 ++- test/cli/shot_command_test.dart | 17 ++++++ test/diff/diff_engine_test.dart | 1 + test/e2e/device_test.dart | 81 ++++++++++++++++++++++++++ test/engine/generator_test.dart | 5 +- test/reporters/diff_reporter_test.dart | 28 +++++++++ test/reporters/shot_reporter_test.dart | 3 + test/run/manifest_test.dart | 5 ++ 27 files changed, 357 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e40aa33..559b7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The device a shot is drawn as. * `shutter shot --pixel-ratio ` (and `semantics`, and `shot(pixelRatio:)`) draws every preview at that device pixel ratio instead of 2: the PNG is the shot's logical size times the ratio, and what reads the ratio, such as `MediaQuery.devicePixelRatioOf` and an `AssetImage` picking its variant, follows it as on the device. `shot` prints it, `manifest.json` records it, `diff` prints both runs' when they differ and returns it as `ShotSetup.pixelRatio`. It is not part of shot ids. +* `shutter shot --safe-area ,` (and `semantics`, and `shot(safeArea: SafeAreaInsets(...))`) lays every preview out with those safe area insets in logical pixels, which `flutter test` reports as none: the view reports them as its padding and view padding, so an `AppBar` grows by the top inset and a `SafeArea` keeps out of them as on the device; with `--keyboard`, the keyboard absorbs the bottom inset from the padding. The status bar and home indicator are not drawn. `shot` prints the insets, `manifest.json` records them, `diff` prints both runs' when they differ and returns them as `ShotSetup.safeArea`. They are not part of shot ids. ## 1.3.0 diff --git a/README.md b/README.md index 4d173e1..ffb6655 100644 --- a/README.md +++ b/README.md @@ -154,19 +154,20 @@ Each is followed by `--settle` milliseconds; shoot again with another `--settle` `--keyboard ` lays a screen out as with its on-screen keyboard up, which `--enter` does not raise; the keyboard itself is not drawn, and `--keyboard-tint 9E9E9E` paints its area gray. `--platform ios` draws the app as iOS instead of Android, the platform `flutter test` runs as: the theme, the back button, page transitions, and adaptive widgets follow. `--pixel-ratio 3` draws at the device pixel ratio of the device a screen is laid out for, instead of 2. +`--safe-area 59,34` lays a screen out with that device's safe area insets, top and bottom, which `flutter test` reports as none; the status bar and home indicator are not drawn. ## Subcommands -| Command | Purpose | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agent` | The step-by-step playbook for AI agents. | -| `manual` | The reference: preview files, drawing model, engine, runs, ids, diff, output, exit codes. | -| `doctor` | Check the Flutter SDK version, its font cache, and the project's shell (`--shell`). | -| `init` | Write `/shell.dart`, or the `--shell` file. | -| `shot` | Render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`). | -| `semantics` | Draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint`. | -| `diff ` | Compare two runs (`--images`, `--composite`). | -| `prune` | Delete every run but the newest `--keep `. | +| Command | Purpose | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `agent` | The step-by-step playbook for AI agents. | +| `manual` | The reference: preview files, drawing model, engine, runs, ids, diff, output, exit codes. | +| `doctor` | Check the Flutter SDK version, its font cache, and the project's shell (`--shell`). | +| `init` | Write `/shell.dart`, or the `--shell` file. | +| `shot` | Render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`). | +| `semantics` | Draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint`. | +| `diff ` | Compare two runs (`--images`, `--composite`). | +| `prune` | Delete every run but the newest `--keep `. | ## Exit codes diff --git a/doc/agent.md b/doc/agent.md index c4598a2..20b233f 100644 --- a/doc/agent.md +++ b/doc/agent.md @@ -47,6 +47,7 @@ Widget settingsSignedOut() => const SettingsPage(user: null); * Top-level functions, static methods, and constructors without required arguments are all valid targets; `MultiPreview` and `Preview` subclasses work too. * Shots are drawn as Android, the platform `flutter test` runs as. For an app that ships on iOS, or a change to iOS-only code, pass `--platform ios` to the before and after shots alike: the theme, the back button, page transitions, adaptive widgets, and the app's own `defaultTargetPlatform` checks then draw as on an iPhone. `diff` prints `platform:` when the two runs differ in it. * Shots are drawn at a device pixel ratio of 2. For the device a screen is laid out for, pass its ratio to the before and after shots alike, `--pixel-ratio 3` for an iPhone: an `AssetImage` then picks that ratio's variant. The PNG is the shot's `size` times the ratio; when you post it, show it at `size`'s width. `diff` prints `pixel_ratio:` when the two runs differ in it. +* Shots are laid out without a safe area, as no phone is. For a screen, pass the device's insets, top and bottom in logical pixels, to the before and after shots alike, `--safe-area 59,34` for example (the device's `MediaQuery.viewPaddingOf(context)`): the `AppBar` then grows under the status bar and a `SafeArea` keeps out of the insets as on the device. The status bar and the home indicator are not drawn; say so when you post the image. ## Shell diff --git a/doc/manual.md b/doc/manual.md index f3c95a9..be70eb5 100644 --- a/doc/manual.md +++ b/doc/manual.md @@ -104,6 +104,17 @@ The semantics tree is drawn at the same ratio. The run records `pixel_ratio` in `manifest.json` when it is not 2; `shot` prints it, and `diff` prints both runs' when they differ, since the ratio changes the size of every PNG without any widget changing. It is not part of shot ids, so a run at 3 pairs shot for shot with one at 2. +## Safe area + +`flutter test` lays the app out without a safe area: the view reports no padding, as no phone does. +`--safe-area ,` lays every preview of the run out as a phone with those insets, in logical pixels, does: the view reports them as its padding and view padding (`MediaQuery.paddingOf`, `MediaQuery.viewPaddingOf`), so an `AppBar` grows by the top inset, and a `SafeArea`, or a layout of the app's own that pads itself by the padding, keeps out of them. +The status bar, the notch, and the home indicator are not drawn: the insets show what the app paints there, such as an `AppBar`'s background. +Insets differ by device, so there is no default: give those of the device the screen is laid out for, which the app reads there as `MediaQuery.viewPaddingOf(context)`, top and bottom. +With `--keyboard`, the keyboard absorbs the bottom inset from the padding, as on a device; the view padding keeps it. + +The run records `safe_area` in `manifest.json`; `shot` prints it, and `diff` prints both runs' when they differ, since the insets change the layout without any widget changing. +They are not part of shot ids, so a run with `--safe-area` pairs shot for shot with one without. + ## The semantics tree `shutter semantics` takes the same preview files, `--widget`, and options as `shot`, except `--keyboard-tint`, and shoots the same way, actions included. @@ -277,7 +288,7 @@ When Flutter ships a capture command in the previewer itself, it replaces v1 wit ## Runs -`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `image` for a `semantics` run, `shell` when a shell file was used, `platform` for a run as iOS, `pixel_ratio` for a ratio other than 2, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` when given, `shots`). +`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `image` for a `semantics` run, `shell` when a shell file was used, `platform` for a run as iOS, `pixel_ratio` for a ratio other than 2, `safe_area`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` when given, `shots`). `` is the UTC time of the shot (`20260918T101530Z`), suffixed `-2`, `-3`, ... when taken; a hidden `.` file claims the name, so runs started in the same second get distinct ids. `shot` prints the run directory as `run:`; `diff` accepts a run's directory, its id, `latest` for the newest run, or `latest~N` for the run N before it. `latest` counts runs in id order (time, then suffix) and skips a run still being shot, whose `manifest.json` is not written yet. @@ -323,9 +334,9 @@ When the two runs were shot with different shells (path or sha256), `shell` show ## Output `shot`, `semantics`, and `diff` print YAML starting with the comment `# shutter ai-report v1`, with absolute paths to open. -`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `platform` (as iOS), `pixel_ratio` (other than 2), `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (when given), `summary`, and `shots`, errors first. +`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `platform` (as iOS), `pixel_ratio` (other than 2), `safe_area`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (when given), `summary`, and `shots`, errors first. `semantics` gives the same, with `image: semantics` after `shell`. -`diff` gives `diff` (with `--images` or `--composite`), `before`, `after`, `shell`, `image`, `platform`, `pixel_ratio`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (each when the two runs differ in it; `image` is `paint` for a `shot` run), `summary`, and `entries` in the order changed → added → removed → unchanged. +`diff` gives `diff` (with `--images` or `--composite`), `before`, `after`, `shell`, `image`, `platform`, `pixel_ratio`, `safe_area`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (each when the two runs differ in it; `image` is `paint` for a `shot` run), `summary`, and `entries` in the order changed → added → removed → unchanged. ## Exit codes @@ -340,13 +351,13 @@ Other failures follow sysexits: 64 usage, 66 missing run or file, 69 no Flutter ## Commands -| command | purpose | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agent` | the step-by-step playbook | -| `manual` | this document | -| `doctor` | SDK version, font cache, shell (`--shell`) | -| `init` | write `shell.dart` (`--shell`) | -| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`) | -| `semantics` | draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint` | -| `diff` | compare two runs (`--images`, `--composite`/`--direction`/`--title`) | -| `prune` | delete every run but the newest (`--keep`) | +| command | purpose | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent` | the step-by-step playbook | +| `manual` | this document | +| `doctor` | SDK version, font cache, shell (`--shell`) | +| `init` | write `shell.dart` (`--shell`) | +| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`) | +| `semantics` | draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint` | +| `diff` | compare two runs (`--images`, `--composite`/`--direction`/`--title`) | +| `prune` | delete every run but the newest (`--keep`) | diff --git a/lib/src/api/diff.dart b/lib/src/api/diff.dart index a6aa878..418538a 100644 --- a/lib/src/api/diff.dart +++ b/lib/src/api/diff.dart @@ -74,6 +74,7 @@ final class const ShotSetup({ /// The device pixel ratio the app was drawn at. final double pixelRatio = 2, + final SafeAreaInsets? safeArea, }) { @override bool operator ==(Object other) => @@ -88,7 +89,8 @@ final class const ShotSetup({ ) && other.semantics == semantics && other.platform == platform && - other.pixelRatio == pixelRatio; + other.pixelRatio == pixelRatio && + other.safeArea == safeArea; @override int get hashCode => Object.hash( @@ -100,11 +102,12 @@ final class const ShotSetup({ semantics, platform, pixelRatio, + safeArea, ); @override String toString() => - 'ShotSetup(${[if (shell != null) 'shell: $shell', 'actions: $actions', 'capture: $capture', if (keyboard != null) 'keyboard: $keyboard', 'networkImages: $networkImages', 'semantics: $semantics', 'platform: ${platform.name}', 'pixelRatio: ${formatNumber(pixelRatio)}'].join(', ')})'; + 'ShotSetup(${[if (shell != null) 'shell: $shell', 'actions: $actions', 'capture: $capture', if (keyboard != null) 'keyboard: $keyboard', 'networkImages: $networkImages', 'semantics: $semantics', 'platform: ${platform.name}', 'pixelRatio: ${formatNumber(pixelRatio)}', if (safeArea != null) 'safeArea: $safeArea'].join(', ')})'; } /// The outcome of [diff]: the two runs and each shot id across them. @@ -295,6 +298,10 @@ ShotSetup _setup(ShellFile? shell, RunSetup setup) => ShotSetup( semantics: setup.semantics, platform: setup.platform, pixelRatio: setup.pixelRatio, + safeArea: switch (setup.safeArea) { + (final top, final bottom)? => SafeAreaInsets(top: top, bottom: bottom), + null => null, + }, ); ShotFile? _file(RecordedFile? file) => switch (file) { diff --git a/lib/src/api/shoot.dart b/lib/src/api/shoot.dart index 8ce73a3..c0a5ad2 100644 --- a/lib/src/api/shoot.dart +++ b/lib/src/api/shoot.dart @@ -34,6 +34,7 @@ Future shoot( Map networkImages = const {}, ShotPlatform platform = ShotPlatform.android, double pixelRatio = defaultPixelRatio, + SafeAreaInsets? safeArea, bool semantics = false, void Function(ShotProgress event)? onProgress, }) async { @@ -45,6 +46,15 @@ Future shoot( 'pixel ratio must be > 0 and finite (got ${formatNumber(pixelRatio)}).', ); } + final insets = switch (safeArea) { + SafeAreaInsets(:final top, :final bottom) + when top >= 0 && bottom >= 0 && top.isFinite && bottom.isFinite => + (top, bottom), + final safeArea? => throw ShutterException.usage( + 'safe area insets must be logical sizes ≥ 0 (got $safeArea).', + ), + null => null, + }; _checkActions(actions); final viewport = switch (capture) { ScreenCapture(:final viewport?) => _size(viewport, 'viewport'), @@ -138,6 +148,7 @@ Future shoot( semantics: semantics, platform: platform, pixelRatio: pixelRatio, + safeArea: insets, onProgress: onProgress, ), ); @@ -155,6 +166,7 @@ Future shoot( semantics: semantics, platform: platform, pixelRatio: pixelRatio, + safeArea: insets, ), )..write(runDir); return StoredRun(runDir, manifest); diff --git a/lib/src/api/shot.dart b/lib/src/api/shot.dart index c16438c..a9a417c 100644 --- a/lib/src/api/shot.dart +++ b/lib/src/api/shot.dart @@ -157,6 +157,29 @@ final class const Keyboard( 'Keyboard(${['height: ${formatNumber(height)}', if (tint case final tint?) 'tint: ${colorLiteral(tint)}'].join(', ')})'; } +/// The safe area of the device the app is laid out for, as `--safe-area`: +/// the insets of its status bar or notch and of its home indicator, in +/// logical pixels. They are reported to the app as the view padding and +/// not drawn. +final class const SafeAreaInsets({ + /// The inset at the top, such as the status bar's. + final double top = 0, + + /// The inset at the bottom, such as the home indicator's. + final double bottom = 0, +}) { + @override + bool operator ==(Object other) => + other is SafeAreaInsets && other.top == top && other.bottom == bottom; + + @override + int get hashCode => Object.hash(top, bottom); + + @override + String toString() => + 'SafeAreaInsets(top: ${formatNumber(top)}, bottom: ${formatNumber(bottom)})'; +} + /// The outcome of [shot]: the run directory and each shot in it. final class const ShotRun({ /// Run id, e.g. `20260919T143503Z`; `shutter diff` names the run by it. @@ -275,6 +298,8 @@ final class const ShotResult({ /// - [platform]: the platform the app is drawn as. /// - [pixelRatio]: the device pixel ratio the app is drawn at, > 0: pixels /// of the PNG per logical pixel. +/// - [safeArea]: the safe area the app is laid out with; none lays it out +/// without one. /// - [onProgress]: told of what the run is doing as it does it. Future shot( String projectDir, @@ -287,6 +312,7 @@ Future shot( Map networkImages = const {}, ShotPlatform platform = ShotPlatform.android, double pixelRatio = 2, + SafeAreaInsets? safeArea, bool semantics = false, void Function(ShotProgress event)? onProgress, }) => shoot( @@ -300,6 +326,7 @@ Future shot( networkImages: networkImages, platform: platform, pixelRatio: pixelRatio, + safeArea: safeArea, semantics: semantics, onProgress: onProgress, ).then(shotRun); diff --git a/lib/src/cli/agent_text.dart b/lib/src/cli/agent_text.dart index 9123ee8..084bcf8 100644 --- a/lib/src/cli/agent_text.dart +++ b/lib/src/cli/agent_text.dart @@ -50,6 +50,7 @@ Widget settingsSignedOut() => const SettingsPage(user: null); * Top-level functions, static methods, and constructors without required arguments are all valid targets; `MultiPreview` and `Preview` subclasses work too. * Shots are drawn as Android, the platform `flutter test` runs as. For an app that ships on iOS, or a change to iOS-only code, pass `--platform ios` to the before and after shots alike: the theme, the back button, page transitions, adaptive widgets, and the app's own `defaultTargetPlatform` checks then draw as on an iPhone. `diff` prints `platform:` when the two runs differ in it. * Shots are drawn at a device pixel ratio of 2. For the device a screen is laid out for, pass its ratio to the before and after shots alike, `--pixel-ratio 3` for an iPhone: an `AssetImage` then picks that ratio's variant. The PNG is the shot's `size` times the ratio; when you post it, show it at `size`'s width. `diff` prints `pixel_ratio:` when the two runs differ in it. +* Shots are laid out without a safe area, as no phone is. For a screen, pass the device's insets, top and bottom in logical pixels, to the before and after shots alike, `--safe-area 59,34` for example (the device's `MediaQuery.viewPaddingOf(context)`): the `AppBar` then grows under the status bar and a `SafeArea` keeps out of the insets as on the device. The status bar and the home indicator are not drawn; say so when you post the image. ## Shell diff --git a/lib/src/cli/manual_text.dart b/lib/src/cli/manual_text.dart index 6504f21..4b4ddcf 100644 --- a/lib/src/cli/manual_text.dart +++ b/lib/src/cli/manual_text.dart @@ -107,6 +107,17 @@ The semantics tree is drawn at the same ratio. The run records `pixel_ratio` in `manifest.json` when it is not 2; `shot` prints it, and `diff` prints both runs' when they differ, since the ratio changes the size of every PNG without any widget changing. It is not part of shot ids, so a run at 3 pairs shot for shot with one at 2. +## Safe area + +`flutter test` lays the app out without a safe area: the view reports no padding, as no phone does. +`--safe-area ,` lays every preview of the run out as a phone with those insets, in logical pixels, does: the view reports them as its padding and view padding (`MediaQuery.paddingOf`, `MediaQuery.viewPaddingOf`), so an `AppBar` grows by the top inset, and a `SafeArea`, or a layout of the app's own that pads itself by the padding, keeps out of them. +The status bar, the notch, and the home indicator are not drawn: the insets show what the app paints there, such as an `AppBar`'s background. +Insets differ by device, so there is no default: give those of the device the screen is laid out for, which the app reads there as `MediaQuery.viewPaddingOf(context)`, top and bottom. +With `--keyboard`, the keyboard absorbs the bottom inset from the padding, as on a device; the view padding keeps it. + +The run records `safe_area` in `manifest.json`; `shot` prints it, and `diff` prints both runs' when they differ, since the insets change the layout without any widget changing. +They are not part of shot ids, so a run with `--safe-area` pairs shot for shot with one without. + ## The semantics tree `shutter semantics` takes the same preview files, `--widget`, and options as `shot`, except `--keyboard-tint`, and shoots the same way, actions included. @@ -280,7 +291,7 @@ When Flutter ships a capture command in the previewer itself, it replaces v1 wit ## Runs -`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `image` for a `semantics` run, `shell` when a shell file was used, `platform` for a run as iOS, `pixel_ratio` for a ratio other than 2, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` when given, `shots`). +`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `image` for a `semantics` run, `shell` when a shell file was used, `platform` for a run as iOS, `pixel_ratio` for a ratio other than 2, `safe_area`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` when given, `shots`). `` is the UTC time of the shot (`20260918T101530Z`), suffixed `-2`, `-3`, ... when taken; a hidden `.` file claims the name, so runs started in the same second get distinct ids. `shot` prints the run directory as `run:`; `diff` accepts a run's directory, its id, `latest` for the newest run, or `latest~N` for the run N before it. `latest` counts runs in id order (time, then suffix) and skips a run still being shot, whose `manifest.json` is not written yet. @@ -326,9 +337,9 @@ When the two runs were shot with different shells (path or sha256), `shell` show ## Output `shot`, `semantics`, and `diff` print YAML starting with the comment `# shutter ai-report v1`, with absolute paths to open. -`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `platform` (as iOS), `pixel_ratio` (other than 2), `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (when given), `summary`, and `shots`, errors first. +`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `platform` (as iOS), `pixel_ratio` (other than 2), `safe_area`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (when given), `summary`, and `shots`, errors first. `semantics` gives the same, with `image: semantics` after `shell`. -`diff` gives `diff` (with `--images` or `--composite`), `before`, `after`, `shell`, `image`, `platform`, `pixel_ratio`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (each when the two runs differ in it; `image` is `paint` for a `shot` run), `summary`, and `entries` in the order changed → added → removed → unchanged. +`diff` gives `diff` (with `--images` or `--composite`), `before`, `after`, `shell`, `image`, `platform`, `pixel_ratio`, `safe_area`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (each when the two runs differ in it; `image` is `paint` for a `shot` run), `summary`, and `entries` in the order changed → added → removed → unchanged. ## Exit codes @@ -343,14 +354,14 @@ Other failures follow sysexits: 64 usage, 66 missing run or file, 69 no Flutter ## Commands -| command | purpose | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agent` | the step-by-step playbook | -| `manual` | this document | -| `doctor` | SDK version, font cache, shell (`--shell`) | -| `init` | write `shell.dart` (`--shell`) | -| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`) | -| `semantics` | draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint` | -| `diff` | compare two runs (`--images`, `--composite`/`--direction`/`--title`) | -| `prune` | delete every run but the newest (`--keep`) | +| command | purpose | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent` | the step-by-step playbook | +| `manual` | this document | +| `doctor` | SDK version, font cache, shell (`--shell`) | +| `init` | write `shell.dart` (`--shell`) | +| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`) | +| `semantics` | draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint` | +| `diff` | compare two runs (`--images`, `--composite`/`--direction`/`--title`) | +| `prune` | delete every run but the newest (`--keep`) | '''; diff --git a/lib/src/cli/shot_command.dart b/lib/src/cli/shot_command.dart index 61b67d2..c210bc7 100644 --- a/lib/src/cli/shot_command.dart +++ b/lib/src/cli/shot_command.dart @@ -131,6 +131,13 @@ class ShotCommand(final ShutterContext context, {final bool semantics = false}) 'Device pixel ratio the app is drawn at: pixels of the PNG per ' 'logical pixel, e.g. 3.', defaultsTo: formatNumber(defaultPixelRatio), + ) + ..addOption( + 'safe-area', + help: + 'Lay out as on a device with these safe area insets, as ' + ', in logical pixels, e.g. 59,34; the status bar ' + 'and home indicator are not drawn.', ); } @@ -179,6 +186,10 @@ class ShotCommand(final ShutterContext context, {final bool semantics = false}) final networkImages = parseNetworkImages(args.multiOption('network-image')); final platform = ShotPlatform.values.byName(args.option('platform')!); final pixelRatio = parsePixelRatio(args.option('pixel-ratio')!); + final safeArea = switch (args.option('safe-area')) { + final raw? => parseSafeArea(raw), + null => null, + }; final files = args.rest; if (source == null && (imports.isNotEmpty || rawSize != null)) { throw ShutterException.usage('--import and --size need --widget.'); @@ -213,6 +224,7 @@ class ShotCommand(final ShutterContext context, {final bool semantics = false}) networkImages: networkImages, platform: platform, pixelRatio: pixelRatio, + safeArea: safeArea, semantics: semantics, ); reportShots(run.manifest, run.dir, ShutterIO.stdoutSink); diff --git a/lib/src/cli/shot_options.dart b/lib/src/cli/shot_options.dart index e810f0b..710fac2 100644 --- a/lib/src/cli/shot_options.dart +++ b/lib/src/cli/shot_options.dart @@ -51,6 +51,19 @@ double parsePixelRatio(String raw) { ); } +/// Parses `--safe-area` as `,` in logical pixels; `shoot` +/// checks the insets. +SafeAreaInsets parseSafeArea(String raw) { + final values = [for (final part in raw.split(',')) double.tryParse(part)]; + if (values case [final top?, final bottom?]) { + return SafeAreaInsets(top: top, bottom: bottom); + } + throw ShutterException.usage( + '--safe-area must be , in logical pixels, e.g. 59,34 ' + '(got "$raw").', + ); +} + final _hexColor = RegExp(r'^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$'); /// Parses `--keyboard-tint` as `RRGGBB` or `AARRGGBB`, `#` optional, into diff --git a/lib/src/engine/engine.dart b/lib/src/engine/engine.dart index a4d9831..672600e 100644 --- a/lib/src/engine/engine.dart +++ b/lib/src/engine/engine.dart @@ -54,6 +54,10 @@ class const CaptureRequest({ /// logical pixel. final double pixelRatio = defaultPixelRatio, + /// Logical safe area insets, top and bottom, reported to the app as the + /// view padding. + final (double, double)? safeArea, + /// Told of each render as it starts, each shot as it finishes, and each /// font download. final void Function(ShotProgress event)? onProgress, diff --git a/lib/src/engine/generator.dart b/lib/src/engine/generator.dart index acc6374..b6f3eab 100644 --- a/lib/src/engine/generator.dart +++ b/lib/src/engine/generator.dart @@ -196,6 +196,9 @@ String mainSource(List helpers, GeneratorConfig config) { if (request.pixelRatio != defaultPixelRatio) { buffer.writeln(' pixelRatio: ${request.pixelRatio},'); } + if (request.safeArea case (final top, final bottom)?) { + buffer.writeln(' safeArea: ($top, $bottom),'); + } if (config.fallbackFonts.isNotEmpty) { buffer.writeln(' fallbackFonts: {'); for (final MapEntry(:key, :value) in config.fallbackFonts.entries) { diff --git a/lib/src/engine/harness_text.dart b/lib/src/engine/harness_text.dart index 7ab2c81..40764d9 100644 --- a/lib/src/engine/harness_text.dart +++ b/lib/src/engine/harness_text.dart @@ -70,6 +70,10 @@ class const ShutterConfig({ /// Device pixel ratio every shot is rendered at. final double pixelRatio = 2, + /// Logical safe area insets, top and bottom, reported to the app as the + /// view padding. + final (double, double)? safeArea, + /// Host font files by family, in fallback order, chosen by the CLI. final Map> fallbackFonts = const {}, }); @@ -330,6 +334,19 @@ Future _capture( if (keyboard != null) { tester.view.viewInsets = FakeViewPadding(bottom: keyboard * ratio); } + // The insets of a status bar, a notch, and a home indicator, none of + // them drawn. As on a device, the keyboard absorbs the bottom one from + // the padding, but not from the view padding. + if (config.safeArea case (final top, final bottom)?) { + tester.view.viewPadding = FakeViewPadding( + top: top * ratio, + bottom: bottom * ratio, + ); + tester.view.padding = FakeViewPadding( + top: top * ratio, + bottom: math.max(0, bottom - (keyboard ?? 0)) * ratio, + ); + } if (preview.brightness != null) { tester.platformDispatcher.platformBrightnessTestValue = preview.brightness!; diff --git a/lib/src/reporters/diff_reporter.dart b/lib/src/reporters/diff_reporter.dart index ac3405c..b3a2e77 100644 --- a/lib/src/reporters/diff_reporter.dart +++ b/lib/src/reporters/diff_reporter.dart @@ -39,6 +39,11 @@ void reportDiff(RunDiff diff, String? dir, IOSink sink) { formatNumber(before.pixelRatio), formatNumber(after.pixelRatio), ), + ( + 'safe_area', + formatSafeArea(before.safeArea), + formatSafeArea(after.safeArea), + ), ('actions', formatActions(before.actions), formatActions(after.actions)), ('capture', formatCapture(before.screen), formatCapture(after.screen)), ( diff --git a/lib/src/reporters/format.dart b/lib/src/reporters/format.dart index d707a48..af197dc 100644 --- a/lib/src/reporters/format.dart +++ b/lib/src/reporters/format.dart @@ -56,13 +56,20 @@ String formatKeyboard(double? keyboard) => switch (keyboard) { null => 'none', }; +/// `[59, 34]`, top and bottom, or `none` for a run shot without +/// `--safe-area`. +String formatSafeArea((double, double)? safeArea) => switch (safeArea) { + final insets? => formatSize(insets), + null => 'none', +}; + /// `semantics` for a run of `shutter semantics`, else `paint`. String formatImage(bool semantics) => semantics ? 'semantics' : 'paint'; -/// The `image`, `platform`, `pixel_ratio`, `actions`, `capture`, -/// `viewport`, `keyboard`, `keyboard_tint`, and `network_images` lines of -/// a run's [setup], each only when set (`platform` when not Android, -/// `pixel_ratio` when not the default). +/// The `image`, `platform`, `pixel_ratio`, `safe_area`, `actions`, +/// `capture`, `viewport`, `keyboard`, `keyboard_tint`, and +/// `network_images` lines of a run's [setup], each only when set +/// (`platform` when not Android, `pixel_ratio` when not the default). void writeSetup(StringBuffer body, RunSetup setup) { if (setup.semantics) body.writeln('image: ${formatImage(true)}'); if (setup.platform != ShotPlatform.android) { @@ -71,6 +78,9 @@ void writeSetup(StringBuffer body, RunSetup setup) { if (setup.pixelRatio != defaultPixelRatio) { body.writeln('pixel_ratio: ${formatNumber(setup.pixelRatio)}'); } + if (setup.safeArea case final safeArea?) { + body.writeln('safe_area: ${formatSafeArea(safeArea)}'); + } if (setup.actions.isNotEmpty) { body.writeln('actions: ${formatActions(setup.actions)}'); } diff --git a/lib/src/run/manifest.dart b/lib/src/run/manifest.dart index a035b85..ba6495b 100644 --- a/lib/src/run/manifest.dart +++ b/lib/src/run/manifest.dart @@ -133,8 +133,8 @@ const double defaultPixelRatio = 2; /// `--keyboard-tint` (ARGB), the file served for each `--network-image` /// URL, whether the images are of the semantics tree /// (`shutter semantics`) rather than of what the app paints, the -/// `--platform` the app was drawn as, and the `--pixel-ratio` it was -/// drawn at. +/// `--platform` the app was drawn as, the `--pixel-ratio` it was drawn +/// at, and the `--safe-area` insets, top and bottom. typedef RunSetup = ({ List actions, bool screen, @@ -145,6 +145,7 @@ typedef RunSetup = ({ bool semantics, ShotPlatform platform, double pixelRatio, + (double, double)? safeArea, }); /// [argb] as `#AARRGGBB`. @@ -165,6 +166,7 @@ const RunSetup plainSetup = ( semantics: false, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ); RecordedFile? _recordedFile(Object? json) => switch (json) { @@ -224,6 +226,13 @@ class const RunManifest({ }, pixelRatio: (json['pixel_ratio'] as num?)?.toDouble() ?? defaultPixelRatio, + safeArea: switch (json['safe_area']) { + [final num top, final num bottom] => ( + top.toDouble(), + bottom.toDouble(), + ), + _ => null, + }, ), ); @@ -247,6 +256,7 @@ class const RunManifest({ :semantics, :platform, :pixelRatio, + :safeArea, ) = setup; return { 'run': run, @@ -255,6 +265,8 @@ class const RunManifest({ if (platform != ShotPlatform.android) 'platform': platform.name, if (pixelRatio != defaultPixelRatio) 'pixel_ratio': jsonNumber(pixelRatio), + if (safeArea case (final top, final bottom)?) + 'safe_area': [jsonNumber(top), jsonNumber(bottom)], if (actions.isNotEmpty) 'actions': actions, if (screen) 'capture': 'screen', if (viewport case (final w, final h)?) diff --git a/test/api/diff_test.dart b/test/api/diff_test.dart index a905b60..15de4cb 100644 --- a/test/api/diff_test.dart +++ b/test/api/diff_test.dart @@ -91,6 +91,7 @@ void main() { semantics: true, platform: ShotPlatform.ios, pixelRatio: 3, + safeArea: (59, 34), ), ); @@ -115,6 +116,7 @@ void main() { semantics: true, platform: ShotPlatform.ios, pixelRatio: 3, + safeArea: SafeAreaInsets(top: 59, bottom: 34), ), entries: [ ShotDiffEntry( diff --git a/test/api/shot_test.dart b/test/api/shot_test.dart index ffa35fd..0cc975f 100644 --- a/test/api/shot_test.dart +++ b/test/api/shot_test.dart @@ -62,6 +62,7 @@ void main() { networkImages: {'https://example.com/a.png': 'test/images/a.png'}, platform: ShotPlatform.ios, pixelRatio: 3, + safeArea: const SafeAreaInsets(top: 59, bottom: 34), ); final run = shotRun(stored); final dir = p.join(root, '.dart_tool', 'shutter', 'runs', run.id); @@ -121,8 +122,10 @@ void main() { }); expect((request.platform, request.semantics), (ShotPlatform.ios, false)); expect(request.pixelRatio, 3); + expect(request.safeArea, (59.0, 34.0)); final setup = RunManifest.read(dir).setup; expect((setup.platform, setup.pixelRatio), (ShotPlatform.ios, 3)); + expect(setup.safeArea, (59.0, 34.0)); }, ); @@ -173,6 +176,7 @@ void main() { expect((request.keyboard, request.keyboardTint), (300.0, null)); expect(request.semantics, isTrue); expect(request.pixelRatio, defaultPixelRatio); + expect(request.safeArea, isNull); final unbounded = FakeEngine(const []); await shoot( @@ -194,6 +198,7 @@ void main() { ShotCapture capture = const PreviewCapture(), Keyboard? keyboard, double pixelRatio = defaultPixelRatio, + SafeAreaInsets? safeArea, bool semantics = false, }) => shoot( fakeContext(root), @@ -203,6 +208,7 @@ void main() { capture: capture, keyboard: keyboard, pixelRatio: pixelRatio, + safeArea: safeArea, semantics: semantics, ); await expectLater( @@ -215,6 +221,17 @@ void main() { throwsA(usageError('pixel ratio must be > 0 and finite')), ); } + for (final safeArea in const [ + SafeAreaInsets(top: -1), + SafeAreaInsets(bottom: -1), + SafeAreaInsets(top: double.infinity), + SafeAreaInsets(bottom: double.nan), + ]) { + await expectLater( + run(safeArea: safeArea), + throwsA(usageError('safe area insets must be logical sizes ≥ 0')), + ); + } await expectLater( run(subject: const PreviewFiles([])), throwsA(usageError('PreviewFiles names no file.')), diff --git a/test/api/value_test.dart b/test/api/value_test.dart index 443bec7..6d7e0ca 100644 --- a/test/api/value_test.dart +++ b/test/api/value_test.dart @@ -79,6 +79,10 @@ void main() { const Keyboard(300, tint: 0x809E9E9E), ], 'Keyboard(height: 336, tint: 0x809E9E9E)'); expect('${Keyboard(336.5)}', 'Keyboard(height: 336.5)'); + valueType(() => SafeAreaInsets(top: 59, bottom: 34.5), [ + const SafeAreaInsets(top: 59), + const SafeAreaInsets(bottom: 34.5), + ], 'SafeAreaInsets(top: 59, bottom: 34.5)'); valueType(() => LogicalSize(390, 844.5), [ const LogicalSize(844.5, 390), WidgetSize.unbounded, @@ -171,6 +175,7 @@ void main() { semantics: true, platform: ShotPlatform.ios, pixelRatio: 2.625, + safeArea: SafeAreaInsets(top: 59, bottom: 34), ); valueType( () => setup(action: fresh()), @@ -185,7 +190,8 @@ void main() { 'keyboard: Keyboard(height: 336), ' 'networkImages: {https://example.com/a.png: ' 'ShotFile(path: a.png, sha256: b)}, ' - 'semantics: true, platform: ios, pixelRatio: 2.625)', + 'semantics: true, platform: ios, pixelRatio: 2.625, ' + 'safeArea: SafeAreaInsets(top: 59, bottom: 34))', ); expect( '${const ShotSetup()}', diff --git a/test/cli/shot_command_test.dart b/test/cli/shot_command_test.dart index cd83fc4..f5876a1 100644 --- a/test/cli/shot_command_test.dart +++ b/test/cli/shot_command_test.dart @@ -286,11 +286,14 @@ void main() { 'ios', '--pixel-ratio', '2.625', + '--safe-area', + '59,34.5', ], fakeContext(root, engine: engine)); expect(result.exitCode, 0, reason: result.stderr); final request = engine.requests.single; expect(request.platform, ShotPlatform.ios); expect(request.pixelRatio, 2.625); + expect(request.safeArea, (59.0, 34.5)); expect( [ for (final action in request.actions) @@ -322,6 +325,7 @@ void main() { expect(report['keyboard_tint'], '#FF9E9E9E'); expect(report['platform'], 'ios'); expect(report['pixel_ratio'], 2.625); + expect(report['safe_area'], [59, 34.5]); final manifest = RunManifest.read(report['run'] as String); final setup = manifest.setup; expect(setup.actions, labels); @@ -330,6 +334,7 @@ void main() { expect(setup.keyboardTint, 0xFF9E9E9E); expect(setup.platform, ShotPlatform.ios); expect(setup.pixelRatio, 2.625); + expect(setup.safeArea, (59.0, 34.5)); for (final (flag, kind) in [ ('--hover', ActionKind.hover), @@ -350,6 +355,7 @@ void main() { expect(request.keyboardTint, isNull); expect(request.platform, ShotPlatform.android); expect(request.pixelRatio, defaultPixelRatio); + expect(request.safeArea, isNull); } }); @@ -499,6 +505,17 @@ void main() { expect(bad.exitCode, 64, reason: ratio); expect(bad.stderr, contains('pixel ratio must be > 0 and finite')); } + for (final safeArea in ['59', '59,34,0', 'top,34', '59x34']) { + final bad = await run(['--widget', 'x', '--safe-area', safeArea]); + expect(bad.exitCode, 64, reason: safeArea); + expect(bad.stderr, contains('--safe-area must be ,')); + } + final negative = await run(['--widget', 'x', '--safe-area', '59,-1']); + expect(negative.exitCode, 64); + expect( + negative.stderr, + contains('safe area insets must be logical sizes ≥ 0'), + ); for (final (option, target) in [ ('--tap', 'Save'), ('--tap', 'name:Save'), diff --git a/test/diff/diff_engine_test.dart b/test/diff/diff_engine_test.dart index d40f554..9e5408e 100644 --- a/test/diff/diff_engine_test.dart +++ b/test/diff/diff_engine_test.dart @@ -52,6 +52,7 @@ void main() { semantics: false, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ), )..write(dir), ); diff --git a/test/e2e/device_test.dart b/test/e2e/device_test.dart index e07bed5..141de33 100644 --- a/test/e2e/device_test.dart +++ b/test/e2e/device_test.dart @@ -128,4 +128,85 @@ Widget odd() => const ColoredBox(color: Color(0xFF00FF00)); } expect(_color(_png(oddRun, oddShots['Odd']!), 54, 21), (0, 255, 0)); }); + + test('--safe-area pads the view: SafeArea keeps out of it, and a keyboard ' + 'absorbs the bottom inset from the padding only', () async { + final root = await shared(); + File(p.join(root, 'lib/preview/safe_area_preview.dart')) + .writeAsStringSync(''' +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview(name: 'SafeArea', size: Size(20, 40)) +Widget safeArea() => const ColoredBox( + color: Color(0xFFFF0000), + child: SafeArea(child: ColoredBox(color: Color(0xFF00FF00))), +); + +@Preview(name: 'ViewPadding', size: Size(20, 10)) +Widget viewPadding() => Builder( + builder: (context) => ColoredBox( + color: MediaQuery.viewPaddingOf(context) == const EdgeInsets.only(top: 10, bottom: 5) + ? const Color(0xFF0000FF) + : const Color(0xFF00FF00), + ), +); +'''); + const file = 'lib/preview/safe_area_preview.dart'; + const red = (255, 0, 0); + const green = (0, 255, 0); + final (plain, plainShots) = await shootIn(root, [file]); + final (padded, paddedShots) = await shootIn(root, [ + file, + '--safe-area', + '10,5', + ]); + final (typing, typingShots) = await shootIn(root, [ + file, + '--safe-area', + '10,5', + '--keyboard', + '20', + ]); + for (final shots in [plainShots, paddedShots, typingShots]) { + for (final shot in shots.values) { + expect(shot.status, ShotStatus.ok, reason: shot.error); + } + } + // Rows at 2x: the top inset ends at 20, the bottom one starts at 70. + final plainPng = _png(plain, plainShots['SafeArea']!); + expect( + [ + for (final y in [5, 40, 75]) _color(plainPng, 5, y), + ], + [green, green, green], + ); + final paddedPng = _png(padded, paddedShots['SafeArea']!); + expect( + [ + for (final y in [5, 40, 75]) _color(paddedPng, 5, y), + ], + [red, green, red], + ); + final typingPng = _png(typing, typingShots['SafeArea']!); + expect( + [ + for (final y in [5, 40, 75]) _color(typingPng, 5, y), + ], + [red, green, green], + ); + for (final (run, shots) in [(padded, paddedShots), (typing, typingShots)]) { + expect(_color(_png(run, shots['ViewPadding']!), 5, 5), (0, 0, 255)); + } + expect(RunManifest.read(padded).setup.safeArea, (10.0, 5.0)); + expect(RunManifest.read(plain).setup.safeArea, isNull); + + final diff = await shutter(root, ['diff', plain, padded]); + expect(diff.exitCode, 1, reason: diff.stdout + diff.stderr); + final report = loadYaml(diff.stdout) as YamlMap; + expect(report['safe_area'], { + 'before': 'none', + 'after': [10, 5], + }); + }); } diff --git a/test/engine/generator_test.dart b/test/engine/generator_test.dart index 19ca290..b28562d 100644 --- a/test/engine/generator_test.dart +++ b/test/engine/generator_test.dart @@ -143,6 +143,7 @@ void main() { semantics: true, platform: ShotPlatform.ios, pixelRatio: 3, + safeArea: (59, 34.5), ), materialFontsDir: '/sdk/fonts', design: const DesignSupport(available: [], shell: null), @@ -168,7 +169,8 @@ void main() { ' },\n' ' semantics: true,\n' ' platform: .iOS,\n' - ' pixelRatio: 3.0,\n', + ' pixelRatio: 3.0,\n' + ' safeArea: (59.0, 34.5),\n', ), ); final plain = mainSource(const [], config(project)); @@ -180,6 +182,7 @@ void main() { expect(plain, isNot(contains('semantics:'))); expect(plain, isNot(contains('platform:'))); expect(plain, isNot(contains('pixelRatio:'))); + expect(plain, isNot(contains('safeArea:'))); }); test('a --widget helper imports unprefixed, escapes the expression in the ' diff --git a/test/reporters/diff_reporter_test.dart b/test/reporters/diff_reporter_test.dart index 952fc11..4f82bf4 100644 --- a/test/reporters/diff_reporter_test.dart +++ b/test/reporters/diff_reporter_test.dart @@ -179,6 +179,7 @@ entries: semantics: false, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ); const screen = ( actions: ['press text:OK'], @@ -190,12 +191,14 @@ entries: semantics: false, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ); final same = await report(pressed, pressed); for (final key in [ 'image', 'platform', 'pixel_ratio', + 'safe_area', 'actions', 'capture', 'viewport', @@ -229,6 +232,7 @@ entries: semantics: false, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ); final keyboard = await report(plainSetup, typing); expect(keyboard['keyboard'], {'before': 'none', 'after': 336}); @@ -244,6 +248,7 @@ entries: semantics: false, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ); final tint = await report(typing, tinted); expect(tint.containsKey('keyboard'), isFalse); @@ -260,6 +265,7 @@ entries: semantics: false, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ); final images = await report(plainSetup, served); expect(images['network_images'], { @@ -278,6 +284,7 @@ entries: semantics: true, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ); final image = await report(plainSetup, tree); expect(image['image'], {'before': 'paint', 'after': 'semantics'}); @@ -292,6 +299,7 @@ entries: semantics: false, platform: ShotPlatform.ios, pixelRatio: defaultPixelRatio, + safeArea: null, ); final platform = await report(plainSetup, ios); expect(platform['platform'], {'before': 'android', 'after': 'ios'}); @@ -307,10 +315,30 @@ entries: semantics: false, platform: ShotPlatform.android, pixelRatio: 2.625, + safeArea: null, ); final ratio = await report(plainSetup, dense); expect(ratio['pixel_ratio'], {'before': 2, 'after': 2.625}); expect(ratio.containsKey('platform'), isFalse); + expect(ratio.containsKey('safe_area'), isFalse); + const notched = ( + actions: [], + screen: false, + viewport: null, + keyboard: null, + keyboardTint: null, + networkImages: {}, + semantics: false, + platform: ShotPlatform.android, + pixelRatio: defaultPixelRatio, + safeArea: (59.0, 34.0), + ); + final safeArea = await report(plainSetup, notched); + expect(safeArea['safe_area'], { + 'before': 'none', + 'after': [59, 34], + }); + expect(safeArea.containsKey('pixel_ratio'), isFalse); }, ); diff --git a/test/reporters/shot_reporter_test.dart b/test/reporters/shot_reporter_test.dart index c314c55..95bf444 100644 --- a/test/reporters/shot_reporter_test.dart +++ b/test/reporters/shot_reporter_test.dart @@ -96,6 +96,7 @@ shots: semantics: true, platform: ShotPlatform.ios, pixelRatio: 3, + safeArea: (59, 34.5), ), ), ); @@ -106,6 +107,7 @@ shots: 'image: semantics\n' 'platform: ios\n' 'pixel_ratio: 3\n' + 'safe_area: [59, 34.5]\n' 'actions: ["tap text:Open", "press key:save"]\n' 'capture: screen\n' 'viewport: [390, 844]\n' @@ -131,6 +133,7 @@ shots: semantics: false, platform: ShotPlatform.android, pixelRatio: defaultPixelRatio, + safeArea: null, ), ), ); diff --git a/test/run/manifest_test.dart b/test/run/manifest_test.dart index 55568ad..d385ab0 100644 --- a/test/run/manifest_test.dart +++ b/test/run/manifest_test.dart @@ -88,6 +88,7 @@ void main() { semantics: true, platform: ShotPlatform.ios, pixelRatio: 2.625, + safeArea: (59, 34.5), ), ).write(dir); final back = RunManifest.read(dir); @@ -102,11 +103,13 @@ void main() { expect(setup.semantics, isTrue); expect(setup.platform, ShotPlatform.ios); expect(setup.pixelRatio, 2.625); + expect(setup.safeArea, (59.0, 34.5)); expect(back.toJson().keys, [ 'run', 'image', 'platform', 'pixel_ratio', + 'safe_area', 'actions', 'capture', 'viewport', @@ -121,6 +124,7 @@ void main() { expect(back.toJson()['image'], 'semantics'); expect(back.toJson()['platform'], 'ios'); expect(back.toJson()['pixel_ratio'], 2.625); + expect(jsonEncode(back.toJson()['safe_area']), '[59,34.5]'); final plain = RunManifest.fromJson(const {'run': 'r', 'shots': []}); expect(plain.toJson().keys, ['run', 'shots']); expect(plain.setup.actions, isEmpty); @@ -131,5 +135,6 @@ void main() { expect(plain.setup.semantics, isFalse); expect(plain.setup.platform, ShotPlatform.android); expect(plain.setup.pixelRatio, defaultPixelRatio); + expect(plain.setup.safeArea, isNull); }); } From 1eb351c78867afeb3cb379c9e9db90e5557fd73a Mon Sep 17 00:00:00 2001 From: Koji Wakamiya Date: Thu, 5 Nov 2026 08:37:33 +0900 Subject: [PATCH 2/2] docs(api): say what ShotSetup.safeArea holds for a run without one Co-Authored-By: Claude Opus 5.5 --- lib/src/api/diff.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/api/diff.dart b/lib/src/api/diff.dart index 418538a..a5449bf 100644 --- a/lib/src/api/diff.dart +++ b/lib/src/api/diff.dart @@ -74,6 +74,8 @@ final class const ShotSetup({ /// The device pixel ratio the app was drawn at. final double pixelRatio = 2, + + /// The safe area the app was laid out with; none for a run without one. final SafeAreaInsets? safeArea, }) { @override