diff --git a/README.md b/README.md index cbd2ece..d8c0f98 100644 --- a/README.md +++ b/README.md @@ -136,25 +136,27 @@ shutter shot --widget 'PrimaryButton(label: "OK")' --import lib/ui/button.dart - `--import` names a file under `lib/`, or a `package:` URI of a dependency, that the widget expression needs imported (repeatable; `package:flutter/widgets.dart` is always imported). The same `--widget` and `--import` give the same shot id, so two such runs line up in `diff`. -A state that comes from a gesture is shot by acting on the preview first: +A state that comes from a gesture is shot by acting on the preview first, and what opens above it by capturing the whole screen: ```bash shutter shot lib/preview/button_preview.dart --press text:Save +shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 ``` `--tap` (repeatable, in the order given), then `--press`, `--hover`, or `--focus`, name their widget by `key:`, `text:`, or `type:`. Each is followed by `--settle` milliseconds; shoot again with another `--settle` for another point of an animation. +`--capture screen` holds the whole viewport, with the menus, dialogs, and tooltips the app draws above the preview. ## 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`/`--press`/`--hover`/`--focus`). | -| `diff ` | Compare two runs (`--images`). | +| 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`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`). | +| `diff ` | Compare two runs (`--images`). | ## Exit codes diff --git a/doc/agent.md b/doc/agent.md index ee56c42..7435b41 100644 --- a/doc/agent.md +++ b/doc/agent.md @@ -73,10 +73,12 @@ A preview shows the state its construction gives. For a state a gesture gives, a ```bash shutter shot lib/preview/button_preview.dart --press text:Save +shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 ``` * `--tap` (repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. * The actions apply to every preview of the named files; a preview where the target matches no widget, or several, is an `error` shot. Name the files whose previews have the target, and give the same actions to the before and after shots. +* Menus, dialogs, bottom sheets, and tooltips open above the preview and are in the image only with `--capture screen`; `--viewport` gives a small preview the room they open into. * `--settle` (default 300 ms) is how long after the last action the image is taken. A tapped button's ink is still fading at 300 ms and gone by 700 ms: pass `--settle 700` for the state after a tap without it. * A page a tap navigates to is shot through its own preview, not through the tap. * An animation is shot point by point: shoot again with another `--settle`. The clock is simulated, so the same `--settle` gives the same image, and `diff` pairs a shot across the runs. diff --git a/doc/manual.md b/doc/manual.md index 0ea5ae1..064a1f5 100644 --- a/doc/manual.md +++ b/doc/manual.md @@ -64,7 +64,16 @@ They are not part of shot ids, so a run with actions pairs shot for shot with on ### Taps that navigate A page is shot through a preview of its own: returned from a preview function, it is shot from its first frame, with an id of its own. -A tap that pushes a page covers the preview, which is then no longer painted: the shot is an `error` saying so. +A tap that pushes a page covers the preview, which is then no longer painted: without `--capture screen` the shot is an `error` saying so. +With `--capture screen` the page is shot; its transition takes 450 ms (`flutter test` runs as Android), so `--settle 700` shoots it once it is in place. + +## Capturing the screen + +`--capture screen` captures the whole viewport instead of the captured region: the shell's surface, and what the app draws above the preview in its overlay, such as menus, dialogs, bottom sheets, and tooltips, which the captured region never holds. +The shot's size is the viewport's. +`--viewport x` sets the viewport, with the preview at its top left: give a small preview the room a menu or a dialog opens into. +Without it, the viewport is the drawing model's. +The run records the capture and the viewport as it does its actions. ## Drawing model @@ -78,7 +87,7 @@ Per preview, from the outside in: 3. The preview at the top left. 4. The captured region: `SizedBox(size)`, then `theme.apply`, then `wrapper`, then the preview. -The PNG holds what is painted inside the captured region, and nothing outside it: the shell's surface lies outside, so where the preview paints no background the PNG is transparent, and the viewer's own background shows through. +The PNG holds what is painted inside the captured region (the whole viewport with `--capture screen`), and nothing outside it: the shell's surface lies outside, so where the preview paints no background the PNG is transparent, and the viewer's own background shows through. A screen with a `Scaffold` paints its own; a widget is shot on no background, since shutter cannot know where the app places it. To shoot a widget on the surface it sits on, paint it inside the preview: a `wrapper`, or a `ColoredBox` or `Material` around the widget in the preview function. The same holds for ink: a press, hover, or focus is drawn on the nearest `Material` above the widget, which for a widget without its own (an `InkWell`, a `ListTile`) is the shell's surface; a `Material` in the preview brings it into the PNG. @@ -91,10 +100,10 @@ A shell made for one task and not meant to be committed goes under `.dart_tool/` `--shell` takes any path; a shell outside `lib/` is imported by its file URI, so it imports the app with `package:` URIs. Each run records its shell file in `manifest.json`, with the sha256 of its bytes (not of the files it imports). -Viewport: `size` when both dimensions are finite; a missing or infinite dimension uses 800×600 logical pixels. +Viewport: `--viewport` when given; else `size` when both dimensions are finite; a missing or infinite dimension uses 800×600 logical pixels. The captured region takes a finite dimension of `size` as it is. Without a finite width, the preview takes its own width, up to the viewport's, as on a screen. -Without a finite height, the preview gets unbounded height, as in a scrolling list, and is shot at its own height, even past the viewport: `Size(360, double.infinity)` shoots a widget 360 wide at the height it has in a list. +Without a finite height, the preview gets unbounded height, as in a scrolling list, and is shot at its own height, even past the viewport (with `--capture screen`, the PNG stops at the viewport): `Size(360, double.infinity)` shoots a widget 360 wide at the height it has in a list. A widget that needs a bounded height (a `Scaffold`, a `ListView`, an `Expanded` in a `Column`) fails there; give it a finite height. `brightness` sets the platform brightness and `textScaleFactor` the platform text scale, so the shell's app widget picks them up as on a device. Images render at a device pixel ratio of 2. @@ -171,7 +180,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`, `shell` when a shell file was used, `actions` when given, `shots`). +`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `shell` when a shell file was used, `actions`, `capture`, and `viewport` 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. @@ -207,8 +216,8 @@ When the two runs were shot with different shells (path or sha256), `shell` show ## Output `shot` 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`), `actions` (when given), `summary`, and `shots`, errors first. -`diff` gives `diff` (with `--images`), `before`, `after`, `shell` and `actions` (each when the two runs differ in it), `summary`, and `entries` in the order changed → added → removed → unchanged. +`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `actions`, `capture`, and `viewport` (when given), `summary`, and `shots`, errors first. +`diff` gives `diff` (with `--images`), `before`, `after`, `shell`, `actions`, `capture`, and `viewport` (each when the two runs differ in it), `summary`, and `entries` in the order changed → added → removed → unchanged. ## Exit codes @@ -222,11 +231,11 @@ 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`/`--press`/`--hover`/`--focus`) | -| `diff` | compare two runs (`--images`) | +| 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`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`) | +| `diff` | compare two runs (`--images`) | diff --git a/lib/src/cli/agent_text.dart b/lib/src/cli/agent_text.dart index 1b77b26..b0d6759 100644 --- a/lib/src/cli/agent_text.dart +++ b/lib/src/cli/agent_text.dart @@ -76,10 +76,12 @@ A preview shows the state its construction gives. For a state a gesture gives, a ```bash shutter shot lib/preview/button_preview.dart --press text:Save +shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 ``` * `--tap` (repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. * The actions apply to every preview of the named files; a preview where the target matches no widget, or several, is an `error` shot. Name the files whose previews have the target, and give the same actions to the before and after shots. +* Menus, dialogs, bottom sheets, and tooltips open above the preview and are in the image only with `--capture screen`; `--viewport` gives a small preview the room they open into. * `--settle` (default 300 ms) is how long after the last action the image is taken. A tapped button's ink is still fading at 300 ms and gone by 700 ms: pass `--settle 700` for the state after a tap without it. * A page a tap navigates to is shot through its own preview, not through the tap. * An animation is shot point by point: shoot again with another `--settle`. The clock is simulated, so the same `--settle` gives the same image, and `diff` pairs a shot across the runs. diff --git a/lib/src/cli/manual_text.dart b/lib/src/cli/manual_text.dart index c464bb9..0a1cee3 100644 --- a/lib/src/cli/manual_text.dart +++ b/lib/src/cli/manual_text.dart @@ -67,7 +67,16 @@ They are not part of shot ids, so a run with actions pairs shot for shot with on ### Taps that navigate A page is shot through a preview of its own: returned from a preview function, it is shot from its first frame, with an id of its own. -A tap that pushes a page covers the preview, which is then no longer painted: the shot is an `error` saying so. +A tap that pushes a page covers the preview, which is then no longer painted: without `--capture screen` the shot is an `error` saying so. +With `--capture screen` the page is shot; its transition takes 450 ms (`flutter test` runs as Android), so `--settle 700` shoots it once it is in place. + +## Capturing the screen + +`--capture screen` captures the whole viewport instead of the captured region: the shell's surface, and what the app draws above the preview in its overlay, such as menus, dialogs, bottom sheets, and tooltips, which the captured region never holds. +The shot's size is the viewport's. +`--viewport x` sets the viewport, with the preview at its top left: give a small preview the room a menu or a dialog opens into. +Without it, the viewport is the drawing model's. +The run records the capture and the viewport as it does its actions. ## Drawing model @@ -81,7 +90,7 @@ Per preview, from the outside in: 3. The preview at the top left. 4. The captured region: `SizedBox(size)`, then `theme.apply`, then `wrapper`, then the preview. -The PNG holds what is painted inside the captured region, and nothing outside it: the shell's surface lies outside, so where the preview paints no background the PNG is transparent, and the viewer's own background shows through. +The PNG holds what is painted inside the captured region (the whole viewport with `--capture screen`), and nothing outside it: the shell's surface lies outside, so where the preview paints no background the PNG is transparent, and the viewer's own background shows through. A screen with a `Scaffold` paints its own; a widget is shot on no background, since shutter cannot know where the app places it. To shoot a widget on the surface it sits on, paint it inside the preview: a `wrapper`, or a `ColoredBox` or `Material` around the widget in the preview function. The same holds for ink: a press, hover, or focus is drawn on the nearest `Material` above the widget, which for a widget without its own (an `InkWell`, a `ListTile`) is the shell's surface; a `Material` in the preview brings it into the PNG. @@ -94,10 +103,10 @@ A shell made for one task and not meant to be committed goes under `.dart_tool/` `--shell` takes any path; a shell outside `lib/` is imported by its file URI, so it imports the app with `package:` URIs. Each run records its shell file in `manifest.json`, with the sha256 of its bytes (not of the files it imports). -Viewport: `size` when both dimensions are finite; a missing or infinite dimension uses 800×600 logical pixels. +Viewport: `--viewport` when given; else `size` when both dimensions are finite; a missing or infinite dimension uses 800×600 logical pixels. The captured region takes a finite dimension of `size` as it is. Without a finite width, the preview takes its own width, up to the viewport's, as on a screen. -Without a finite height, the preview gets unbounded height, as in a scrolling list, and is shot at its own height, even past the viewport: `Size(360, double.infinity)` shoots a widget 360 wide at the height it has in a list. +Without a finite height, the preview gets unbounded height, as in a scrolling list, and is shot at its own height, even past the viewport (with `--capture screen`, the PNG stops at the viewport): `Size(360, double.infinity)` shoots a widget 360 wide at the height it has in a list. A widget that needs a bounded height (a `Scaffold`, a `ListView`, an `Expanded` in a `Column`) fails there; give it a finite height. `brightness` sets the platform brightness and `textScaleFactor` the platform text scale, so the shell's app widget picks them up as on a device. Images render at a device pixel ratio of 2. @@ -174,7 +183,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`, `shell` when a shell file was used, `actions` when given, `shots`). +`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `shell` when a shell file was used, `actions`, `capture`, and `viewport` 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. @@ -210,8 +219,8 @@ When the two runs were shot with different shells (path or sha256), `shell` show ## Output `shot` 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`), `actions` (when given), `summary`, and `shots`, errors first. -`diff` gives `diff` (with `--images`), `before`, `after`, `shell` and `actions` (each when the two runs differ in it), `summary`, and `entries` in the order changed → added → removed → unchanged. +`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `actions`, `capture`, and `viewport` (when given), `summary`, and `shots`, errors first. +`diff` gives `diff` (with `--images`), `before`, `after`, `shell`, `actions`, `capture`, and `viewport` (each when the two runs differ in it), `summary`, and `entries` in the order changed → added → removed → unchanged. ## Exit codes @@ -225,12 +234,12 @@ 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`/`--press`/`--hover`/`--focus`) | -| `diff` | compare two runs (`--images`) | +| 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`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`) | +| `diff` | compare two runs (`--images`) | '''; diff --git a/lib/src/cli/shot_command.dart b/lib/src/cli/shot_command.dart index 4ab683c..d6698cf 100644 --- a/lib/src/cli/shot_command.dart +++ b/lib/src/cli/shot_command.dart @@ -66,6 +66,20 @@ class ShotCommand(final ShutterContext context) extends Command { 'Give this widget keyboard focus, highlighted as with a ' 'keyboard.', splitCommas: false, + ) + ..addOption( + 'capture', + help: + 'What the PNG holds: the preview, or the whole screen (the ' + 'viewport, with menus and dialogs above the preview).', + allowed: ['preview', 'screen'], + defaultsTo: 'preview', + ) + ..addOption( + 'viewport', + help: + 'Logical size of the screen for --capture screen, e.g. 390x844; ' + 'the preview sits at its top left.', ); } @@ -92,10 +106,18 @@ class ShotCommand(final ShutterContext context) extends Command { null => null, }; final actions = parseActions(args); + final screen = args.option('capture') == 'screen'; + final viewport = switch (args.option('viewport')) { + final raw? => parseSize(raw, name: 'viewport'), + null => null, + }; final files = args.rest; if (source == null && (imports.isNotEmpty || size != null)) { throw ShutterException.usage('--import and --size need --widget.'); } + if (viewport != null && !screen) { + throw ShutterException.usage('--viewport needs --capture screen.'); + } if ((source == null) == files.isEmpty) { throw ShutterException.usage( 'Name the preview files to shoot, or give --widget; not both.', @@ -160,6 +182,8 @@ class ShotCommand(final ShutterContext context) extends Command { widget: widget, shell: shell, actions: actions, + screen: screen, + viewport: viewport, ), ); final manifest = RunManifest( @@ -167,6 +191,8 @@ class ShotCommand(final ShutterContext context) extends Command { shots: [...shots]..sort(Shot.bySource), shell: shellRecord, actions: [for (final action in actions) action.label], + screen: screen, + viewport: viewport, )..write(runDir); reportShots(manifest, runDir, ShutterIO.stdoutSink); return manifest.exitCode; diff --git a/lib/src/cli/shot_options.dart b/lib/src/cli/shot_options.dart index 38ae61c..c1b1997 100644 --- a/lib/src/cli/shot_options.dart +++ b/lib/src/cli/shot_options.dart @@ -39,8 +39,9 @@ int parseCount(ArgResults results, String name) { return value; } -/// Parses `--size` as `x` in logical pixels. -(double, double) parseSize(String raw) { +/// Parses option [name] (`--size`, `--viewport`) as `x` +/// in logical pixels. +(double, double) parseSize(String raw, {String name = 'size'}) { final parts = raw.split('x'); final values = [for (final part in parts) double.tryParse(part)]; if (values case [final width?, final height?] @@ -48,7 +49,7 @@ int parseCount(ArgResults results, String name) { return (width, height); } throw ShutterException.usage( - '--size must be x, e.g. 390x844 (got "$raw").', + '--$name must be x, e.g. 390x844 (got "$raw").', ); } diff --git a/lib/src/engine/engine.dart b/lib/src/engine/engine.dart index 1870580..491e80b 100644 --- a/lib/src/engine/engine.dart +++ b/lib/src/engine/engine.dart @@ -23,6 +23,13 @@ class const CaptureRequest({ /// Performed on every preview before the capture, in order. final List actions = const [], + + /// Capture the whole viewport, overlays included, rather than the + /// preview. + final bool screen = false, + + /// Logical viewport replacing the one the preview's size gives. + final (double, double)? viewport, }); /// The only swappable layer: turns scanned previews and a `--widget` into diff --git a/lib/src/engine/generator.dart b/lib/src/engine/generator.dart index af0fab3..6d4dbd6 100644 --- a/lib/src/engine/generator.dart +++ b/lib/src/engine/generator.dart @@ -167,6 +167,10 @@ String mainSource(List helpers, GeneratorConfig config) { } buffer.writeln(' ],'); } + if (request.screen) buffer.writeln(' screen: true,'); + if (request.viewport case (final width, final height)?) { + buffer.writeln(' viewport: ($width, $height),'); + } buffer ..writeln(' ),') ..writeln(r' defaultShell: $design.defaultShell,') diff --git a/lib/src/engine/harness_text.dart b/lib/src/engine/harness_text.dart index 97a700f..cf63a8f 100644 --- a/lib/src/engine/harness_text.dart +++ b/lib/src/engine/harness_text.dart @@ -38,6 +38,13 @@ class const ShutterConfig({ /// Performed on every preview before the capture, in order. final List actions = const [], + + /// Capture the whole viewport, overlays included, rather than the + /// preview. + final bool screen = false, + + /// Logical viewport replacing the one the preview's size gives. + final (double, double)? viewport, }); /// What an action does to its target. @@ -257,10 +264,13 @@ Future _capture( final size = preview.size; final width = size != null && size.width.isFinite ? size.width : null; final height = size != null && size.height.isFinite ? size.height : null; - final viewport = Size( - width ?? _fallbackViewport.width, - height ?? _fallbackViewport.height, - ); + final viewport = switch (config.viewport) { + (final width, final height)? => Size(width, height), + null => Size( + width ?? _fallbackViewport.width, + height ?? _fallbackViewport.height, + ), + }; tester.view.devicePixelRatio = _pixelRatio; tester.view.physicalSize = viewport * _pixelRatio; if (preview.brightness != null) { @@ -328,17 +338,29 @@ Future _capture( // A preview the shell never paints has nothing to act on. final shown = painted() != null; if (shown) await _act(tester, config, releases); + // The screen is the root layer: the shell's surface and what is drawn + // above the preview (menus, dialogs, tooltips, a pushed route). + final screen = config.screen && shown + ? tester.binding.renderViews.first + : null; final boundary = painted(); - if (shown && boundary == null) { + if (shown && screen == null && boundary == null) { firstError ??= { 'error': 'the preview is no longer painted after the actions (a ' - 'page covers it); shoot that page through its own preview', + 'page covers it); shoot that page through its own preview, or ' + 'use --capture screen', ..._entryAt(entry), }; } - if (boundary != null) { - final size = boundary.size; - final image = boundary.toImageSync(pixelRatio: _pixelRatio); + if (screen != null || boundary != null) { + final size = screen?.size ?? boundary!.size; + // The root layer already scales to the device pixel ratio, so it is + // drawn at 1 over its bounds in physical pixels. + final image = screen == null + ? boundary!.toImageSync(pixelRatio: _pixelRatio) + : (screen.debugLayer! as OffsetLayer).toImageSync( + screen.paintBounds, + ); final bytes = await tester.runAsync( () => image.toByteData(format: ui.ImageByteFormat.png), ); diff --git a/lib/src/reporters/diff_reporter.dart b/lib/src/reporters/diff_reporter.dart index b02677f..4f4c4c3 100644 --- a/lib/src/reporters/diff_reporter.dart +++ b/lib/src/reporters/diff_reporter.dart @@ -27,11 +27,17 @@ void reportDiff(RunDiff diff, String? dir, IOSink sink) { ..writeln(' before: ${formatShell(diff.beforeShell)}') ..writeln(' after: ${formatShell(diff.afterShell)}'); } - // Likewise the actions: pressing a button changes the image without the - // widget changing. + // Likewise the actions and what was captured: pressing a button, or + // capturing the screen, changes the image without the widget changing. final (before, after) = (diff.beforeSetup, diff.afterSetup); for (final (key, a, b) in [ ('actions', formatActions(before.actions), formatActions(after.actions)), + ('capture', formatCapture(before.screen), formatCapture(after.screen)), + ( + 'viewport', + formatViewport(before.viewport), + formatViewport(after.viewport), + ), ]) { if (a != b) { body diff --git a/lib/src/reporters/format.dart b/lib/src/reporters/format.dart index 85e644c..e55bff9 100644 --- a/lib/src/reporters/format.dart +++ b/lib/src/reporters/format.dart @@ -19,11 +19,25 @@ String formatShell(ShellFile? shell) => switch (shell) { String formatActions(List actions) => '[${actions.map(yamlScalar).join(', ')}]'; -/// The `actions` line of a run's [setup], only when it has actions. +/// `screen` or `preview`. +String formatCapture(bool screen) => screen ? 'screen' : 'preview'; + +/// `[390, 844]`, or `default` for the viewport the preview's size gives. +String formatViewport((double, double)? viewport) => switch (viewport) { + final size? => formatSize(size), + null => 'default', +}; + +/// The `actions`, `capture`, and `viewport` lines of a run's [setup], +/// each only when set. void writeSetup(StringBuffer body, RunSetup setup) { if (setup.actions.isNotEmpty) { body.writeln('actions: ${formatActions(setup.actions)}'); } + if (setup.screen) body.writeln('capture: ${formatCapture(true)}'); + if (setup.viewport case final viewport?) { + body.writeln('viewport: ${formatViewport(viewport)}'); + } } /// `: []` or `:`, the list header of a report. diff --git a/lib/src/run/manifest.dart b/lib/src/run/manifest.dart index 27ffc04..ff6980b 100644 --- a/lib/src/run/manifest.dart +++ b/lib/src/run/manifest.dart @@ -109,11 +109,16 @@ class const Shot({ typedef ShellFile = ({String path, String sha256}); /// What a run did to every preview besides rendering it: the actions -/// performed before the capture. -typedef RunSetup = ({List actions}); +/// performed before the capture, whether the whole viewport was +/// captured, and the `--viewport`. +typedef RunSetup = ({ + List actions, + bool screen, + (double, double)? viewport, +}); -/// The setup of a run shot without actions. -const RunSetup plainSetup = (actions: []); +/// The setup of a run shot without actions, of the preview alone. +const RunSetup plainSetup = (actions: [], screen: false, viewport: null); /// `manifest.json` of one run directory. class const RunManifest({ @@ -127,6 +132,12 @@ class const RunManifest({ /// The actions performed on every preview before the capture, as given /// (`tap text:Save`). final List actions = const [], + + /// The whole viewport was captured (`--capture screen`). + final bool screen = false, + + /// The `--viewport` the shots were taken in. + final (double, double)? viewport, }) { factory RunManifest.fromJson(Map json) => RunManifest( run: json['run'] as String, @@ -142,6 +153,11 @@ class const RunManifest({ _ => null, }, actions: [...?(json['actions'] as List?)?.cast()], + screen: json['capture'] == 'screen', + viewport: switch (json['viewport']) { + [final num w, final num h] => (w.toDouble(), h.toDouble()), + _ => null, + }, ); /// Reads `/manifest.json`. @@ -150,7 +166,7 @@ class const RunManifest({ as Map, ); - RunSetup get setup => (actions: actions); + RunSetup get setup => (actions: actions, screen: screen, viewport: viewport); /// 0 when every shot is ok, 2 when any is an `error`. int get exitCode => shots.any((s) => s.status == ShotStatus.error) ? 2 : 0; @@ -160,6 +176,9 @@ class const RunManifest({ if (shell case (:final path, :final sha256)?) 'shell': {'path': path, 'sha256': sha256}, if (actions.isNotEmpty) 'actions': actions, + if (screen) 'capture': 'screen', + if (viewport case (final w, final h)?) + 'viewport': [jsonNumber(w), jsonNumber(h)], 'shots': [for (final shot in shots) shot.toJson()], }; diff --git a/skills/shutter-visual-check/SKILL.md b/skills/shutter-visual-check/SKILL.md index 50cc8dd..3eac938 100644 --- a/skills/shutter-visual-check/SKILL.md +++ b/skills/shutter-visual-check/SKILL.md @@ -1,6 +1,6 @@ --- name: shutter-visual-check -description: Render Flutter widgets to PNG and compare before / after with the shutter CLI. Use when a change affects how a Flutter widget or screen looks (layout, padding, text, colour, theme, new screen or state, including a pressed, hovered, focused, or opened state), to check the result by image instead of by "it compiles" or "tests pass". +description: Render Flutter widgets to PNG and compare before / after with the shutter CLI. Use when a change affects how a Flutter widget or screen looks (layout, padding, text, colour, theme, new screen or state, including a pressed, hovered, focused, or opened (menu, dialog) state), to check the result by image instead of by "it compiles" or "tests pass". --- # shutter: close a visual change with an image @@ -16,5 +16,5 @@ shutter shot ... # after shutter diff latest~1 latest # the last two runs ``` -* For a state a gesture gives, add `--tap`, `--press`, `--hover`, or `--focus` to both shots; `shutter agent` has the details. +* For a state a gesture gives, add `--tap`, `--press`, `--hover`, or `--focus` to both shots, and `--capture screen` for what opens above the widget; `shutter agent` has the details. * Open the PNG paths `diff` prints and check them against the intended change. Deciding whether the change is right is yours; shutter makes no judgement. diff --git a/test/cli/shot_command_test.dart b/test/cli/shot_command_test.dart index b6a906b..8bb5ce3 100644 --- a/test/cli/shot_command_test.dart +++ b/test/cli/shot_command_test.dart @@ -235,7 +235,7 @@ void main() { }); test('actions: every --tap in order, then the held one; recorded in the ' - 'manifest and the report', () async { + 'manifest and the report with the capture', () async { final root = createProject(); final engine = FakeEngine(const []); final result = await runCli([ @@ -248,6 +248,10 @@ void main() { 'key:save', '--tap', 'type:DropdownButton', + '--capture', + 'screen', + '--viewport', + '390x844', ], fakeContext(root, engine: engine)); expect(result.exitCode, 0, reason: result.stderr); final request = engine.requests.single; @@ -262,6 +266,7 @@ void main() { (ActionKind.press, TargetKind.key, 'save'), ], ); + expect((request.screen, request.viewport), (true, (390.0, 844.0))); final report = loadYaml(result.stdout) as YamlMap; const labels = [ 'tap text:Open, then close', @@ -269,8 +274,11 @@ void main() { 'press key:save', ]; expect(report['actions'], labels); + expect(report['capture'], 'screen'); + expect(report['viewport'], [390, 844]); final manifest = RunManifest.read(report['run'] as String); expect(manifest.actions, labels); + expect((manifest.screen, manifest.viewport), (true, (390.0, 844.0))); for (final (flag, kind) in [ ('--hover', ActionKind.hover), @@ -284,7 +292,9 @@ void main() { flag, 'type:TextField', ], fakeContext(root, engine: held)); - expect(held.requests.single.actions.single.kind, kind); + final request = held.requests.single; + expect(request.actions.single.kind, kind); + expect((request.screen, request.viewport), (false, null)); } }); @@ -362,6 +372,19 @@ void main() { expect(held.exitCode, 64, reason: second); expect(held.stderr, contains('at most one of --press, --hover')); } + final viewport = await run(['--widget', 'x', '--viewport', '390x844']); + expect(viewport.exitCode, 64); + expect(viewport.stderr, contains('--viewport needs --capture screen.')); + final badViewport = await run([ + '--widget', + 'x', + '--capture', + 'screen', + '--viewport', + '390', + ]); + expect(badViewport.exitCode, 64); + expect(badViewport.stderr, contains('--viewport must be x')); final outside = await run(['--widget', 'x', '--import', 'outside.dart']); expect(outside.exitCode, 64); expect(outside.stderr, contains('--import must be under lib/')); diff --git a/test/diff/diff_engine_test.dart b/test/diff/diff_engine_test.dart index 1e82a61..0253e07 100644 --- a/test/diff/diff_engine_test.dart +++ b/test/diff/diff_engine_test.dart @@ -42,6 +42,8 @@ void main() { shots: [], shell: (path: 'lib/preview/shell.dart', sha256: '1f2e'), actions: ['press text:OK'], + screen: true, + viewport: (390, 844), )..write(dir), ); final diff = diffRuns(run('plain', const [], const {}), pressed); @@ -49,6 +51,10 @@ void main() { expect(diff.afterShell?.path, 'lib/preview/shell.dart'); expect(diff.beforeSetup.actions, isEmpty); expect(diff.afterSetup.actions, ['press text:OK']); + expect( + (diff.afterSetup.screen, diff.afterSetup.viewport), + (true, (390.0, 844.0)), + ); }); test('classifies every id; --images writes diff images', () { diff --git a/test/e2e/e2e_test.dart b/test/e2e/e2e_test.dart index cdcf549..5c72cbc 100644 --- a/test/e2e/e2e_test.dart +++ b/test/e2e/e2e_test.dart @@ -86,6 +86,25 @@ Widget route() => Builder( ); '''; +/// A dropdown whose menu opens below its 60-high preview. +const menuPreview = ''' +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview(name: 'State / menu', size: Size(200, 60)) +Widget menu() => Align( + alignment: Alignment.topLeft, + child: DropdownButton( + value: 'a', + items: const [ + DropdownMenuItem(value: 'a', child: Text('Alpha')), + DropdownMenuItem(value: 'b', child: Text('Beta')), + ], + onChanged: (_) {}, + ), +); +'''; + void main() { test('shot → edit → shot → diff on the example app', () async { final root = await exampleCopy(); @@ -617,6 +636,88 @@ class _AgreeState extends State<_Agree> { }, ); + test('--capture screen holds the viewport and what opens above the ' + 'preview', () async { + final root = await exampleCopy(); + Future<(String, Map)> shoot(List args) => + shootIn(root, args); + writeFiles(root, { + 'lib/preview/menu_state_preview.dart': menuPreview, + 'lib/preview/route_preview.dart': routePreview, + 'lib/preview/box_preview.dart': ''' +import 'package:flutter/widget_previews.dart'; +import 'package:flutter/widgets.dart'; + +@Preview(name: 'Box', size: Size(100, 50)) +Widget box() => const ColoredBox(color: Color(0xFF0000FF)); +''', + }); + // The screen is drawn at the same scale as the preview: a 100x50 box + // at the top left of a 200x100 viewport fills a quarter of the image. + final (boxRun, boxShots) = await shoot([ + 'lib/preview/box_preview.dart', + '--capture', + 'screen', + '--viewport', + '200x100', + ]); + final boxImage = img.decodePng( + File(p.join(boxRun, boxShots['Box']!.png!)).readAsBytesSync(), + )!; + expect((boxImage.width, boxImage.height), (400, 200)); + bool blue(int x, int y) { + final pixel = boxImage.getPixel(x, y); + return (pixel.r, pixel.g, pixel.b) == (0, 0, 255); + } + + expect(blue(199, 99), isTrue); + expect(blue(201, 50), isFalse); + expect(blue(50, 101), isFalse); + + // The menu's second item lies below the 60-high preview, in the + // screen only. + const menu = 'lib/preview/menu_state_preview.dart'; + final screen = ['--capture', 'screen', '--viewport', '200x240']; + final (closed, closedShots) = await shoot([menu, ...screen]); + expect(closedShots['State / menu']!.size, (200.0, 240.0)); + final (open, openShots) = await shoot([ + menu, + ...screen, + '--tap', + 'type:DropdownButton', + ]); + final opened = openShots['State / menu']!; + expect(opened.status, ShotStatus.ok, reason: opened.error); + final image = img.decodePng( + File(p.join(open, opened.png!)).readAsBytesSync(), + )!; + expect((image.width, image.height), (400, 480)); + final closedImage = img.decodePng( + File(p.join(closed, closedShots['State / menu']!.png!)).readAsBytesSync(), + )!; + var differing = 0; + for (var y = 120; y < 480; y++) { + for (var x = 0; x < 400; x++) { + final (a, b) = (image.getPixel(x, y), closedImage.getPixel(x, y)); + if ((a.r, a.g, a.b, a.a) != (b.r, b.g, b.b, b.a)) differing++; + } + } + expect(differing, greaterThan(0)); + + // A page a tap pushes is in the screen; its transition takes 450 ms, + // still under way at the default 300, over by 700. + const route = ['lib/preview/route_preview.dart', '--tap', 'text:Go']; + Future settled(String ms) async => + (await shoot([...route, '--capture', 'screen', '--settle', ms])).$1; + final at300 = await settled('300'); + final pushed = RunManifest.read(at300).shots.single; + expect(pushed.status, ShotStatus.ok, reason: pushed.error); + expect(pushed.size, (200.0, 100.0)); + final (at700, at800) = (await settled('700'), await settled('800')); + expect((await shutter(root, ['diff', at300, at700])).exitCode, 1); + expect((await shutter(root, ['diff', at700, at800])).exitCode, 0); + }); + test('shadowing, throwing, resolved, unpainted, and crashing previews ' 'are reported', () async { final root = await exampleCopy(); diff --git a/test/engine/generator_test.dart b/test/engine/generator_test.dart index 557c880..6e5f6b5 100644 --- a/test/engine/generator_test.dart +++ b/test/engine/generator_test.dart @@ -118,7 +118,8 @@ void main() { expect(withFonts, contains("asset: 'Lobster-Regular.ttf',")); }); - test('mainSource passes the actions to the harness; none by default', () { + test('mainSource passes the actions and the capture to the harness; ' + 'none by default', () { final project = Project.load(createProject()); final source = mainSource( const [], @@ -132,6 +133,8 @@ void main() { ShotAction(kind: .tap, by: .text, value: r"it's $1"), ShotAction(kind: .focus, by: .type, value: 'TextField'), ], + screen: true, + viewport: (390, 844.5), ), materialFontsDir: '/sdk/fonts', design: const DesignSupport(available: [], shell: null), @@ -145,10 +148,15 @@ void main() { ' actions: [\n' " \$shutter.ShutterAction(.tap, .text, 'it\\'s \\\$1'),\n" " \$shutter.ShutterAction(.focus, .type, 'TextField'),\n" - ' ],\n', + ' ],\n' + ' screen: true,\n' + ' viewport: (390.0, 844.5),\n', ), ); - expect(mainSource(const [], config(project)), isNot(contains('actions:'))); + final plain = mainSource(const [], config(project)); + expect(plain, isNot(contains('actions:'))); + expect(plain, isNot(contains('screen:'))); + expect(plain, isNot(contains('viewport:'))); }); 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 9e115c0..782aa56 100644 --- a/test/reporters/diff_reporter_test.dart +++ b/test/reporters/diff_reporter_test.dart @@ -146,7 +146,8 @@ entries: }); }); - test('the actions of both runs, only when they differ', () async { + test('the actions, capture, and viewport of both runs, each only when ' + 'they differ', () async { Future report(RunSetup before, RunSetup after) async => loadYaml( await collect( (sink) => reportDiff( @@ -162,12 +163,29 @@ entries: ), ), ) as YamlMap; - const pressed = (actions: ['press text:OK']); - expect((await report(pressed, pressed)).containsKey('actions'), isFalse); - expect((await report(plainSetup, pressed))['actions'], { + const pressed = (actions: ['press text:OK'], screen: false, viewport: null); + const screen = ( + actions: ['press text:OK'], + screen: true, + viewport: (390.0, 844.0), + ); + final same = await report(pressed, pressed); + for (final key in ['actions', 'capture', 'viewport']) { + expect(same.containsKey(key), isFalse, reason: key); + } + final pressedOnly = await report(plainSetup, pressed); + expect(pressedOnly['actions'], { 'before': [], 'after': ['press text:OK'], }); + expect(pressedOnly.containsKey('capture'), isFalse); + final captured = await report(pressed, screen); + expect(captured.containsKey('actions'), isFalse); + expect(captured['capture'], {'before': 'preview', 'after': 'screen'}); + expect(captured['viewport'], { + 'before': 'default', + 'after': [390, 844], + }); }); test('a change of one pixel in many keeps a nonzero ratio', () async { diff --git a/test/reporters/shot_reporter_test.dart b/test/reporters/shot_reporter_test.dart index 21c0cb3..8e9105d 100644 --- a/test/reporters/shot_reporter_test.dart +++ b/test/reporters/shot_reporter_test.dart @@ -78,12 +78,14 @@ shots: ); }); - test('the actions after the shell', () async { + test('the actions and the capture after the shell', () async { final text = await render( const RunManifest( run: 'r', shots: [], actions: ['tap text:Open', 'press key:save'], + screen: true, + viewport: (390, 844), ), ); expect( @@ -91,9 +93,15 @@ shots: contains( 'shell: default\n' 'actions: ["tap text:Open", "press key:save"]\n' + 'capture: screen\n' + 'viewport: [390, 844]\n' 'summary:', ), ); + final screenOnly = await render( + const RunManifest(run: 'r', shots: [], screen: true), + ); + expect(screenOnly, contains('shell: default\ncapture: screen\nsummary:')); }); test('no shots; the shell file with its sha256', () async { diff --git a/test/run/manifest_test.dart b/test/run/manifest_test.dart index 51eec5e..ee8b7fc 100644 --- a/test/run/manifest_test.dart +++ b/test/run/manifest_test.dart @@ -71,18 +71,30 @@ void main() { expect(RunManifest.fromJson(const {'run': 'r', 'shots': []}).shell, isNull); }); - test('the actions round-trip; a plain run writes none', () { + test('the actions and the capture round-trip; a plain run writes none', () { final dir = tempDir(); const RunManifest( run: 'r', shots: [], actions: ['tap text:Open', 'press key:save'], + screen: true, + viewport: (390, 844.5), ).write(dir); final back = RunManifest.read(dir); - expect(back.setup.actions, ['tap text:Open', 'press key:save']); - expect(back.toJson().keys, ['run', 'actions', 'shots']); + final setup = back.setup; + expect(setup.actions, ['tap text:Open', 'press key:save']); + expect((setup.screen, setup.viewport), (true, (390.0, 844.5))); + expect(back.toJson().keys, [ + 'run', + 'actions', + 'capture', + 'viewport', + 'shots', + ]); + expect(jsonEncode(back.toJson()['viewport']), '[390,844.5]'); final plain = RunManifest.fromJson(const {'run': 'r', 'shots': []}); expect(plain.toJson().keys, ['run', 'shots']); expect(plain.setup.actions, isEmpty); + expect((plain.setup.screen, plain.setup.viewport), (false, null)); }); }