diff --git a/README.md b/README.md index d8c0f98..8e0b28c 100644 --- a/README.md +++ b/README.md @@ -136,27 +136,28 @@ 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, and what opens above it by capturing the whole screen: +A state that comes from a gesture or typing 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 +shutter shot lib/preview/login_preview.dart --enter 'label:Email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -`--tap` (repeatable, in the order given), then `--press`, `--hover`, or `--focus`, name their widget by `key:`, `text:`, or `type:`. +`--tap` and `--enter` (repeatable, in the order given), then `--press`, `--hover`, or `--focus`, name their widget by `key:`, `text:`, `label:` (a semantics label, such as a text field's label), 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`, `--capture`/`--viewport`). | -| `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`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`). | +| `diff ` | Compare two runs (`--images`). | ## Exit codes @@ -170,7 +171,7 @@ Each is followed by `--settle` milliseconds; shoot again with another `--settle` ## Limits -* One capture per preview: state comes from the widget's construction expression and from taps, a press, a hover, or focus before the capture; scrolling, dragging, and typing are not shot. +* One capture per preview: state comes from the widget's construction expression and from taps, entered text, a press, a hover, or focus before the capture; scrolling and dragging are not shot, nor is the on-screen keyboard. * HTTP is blocked while rendering, so network images fail to load. * Text renders with the project's fonts plus Roboto; CJK and emoji fall back to the host's system fonts, and Cupertino text uses SF Pro on macOS (Roboto elsewhere), as a device would. Compare runs made on the same machine. diff --git a/doc/agent.md b/doc/agent.md index 7435b41..79840a2 100644 --- a/doc/agent.md +++ b/doc/agent.md @@ -67,16 +67,17 @@ shutter shot --widget 'PrimaryButton(label: "OK")' --import lib/ui/button.dart - * Without `--size` the widget is shot at its own size; `--size` fixes both dimensions and stretches the widget to them. * The same `--widget` and `--import` give the same shot id in every run, so before and after line up in `diff`. -## Pressed, hovered, focused, opened +## Pressed, hovered, focused, opened, typed -A preview shows the state its construction gives. For a state a gesture gives, act on the preview in the shot: +A preview shows the state its construction gives. For a state a gesture or typing gives, act on the preview in the shot: ```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 +shutter shot lib/preview/login_preview.dart --enter 'label:Email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -* `--tap` (repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. +* `--tap` and `--enter =` (both repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, `label:` (a text field's label or hint, a button's 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. diff --git a/doc/manual.md b/doc/manual.md index 064a1f5..b91b834 100644 --- a/doc/manual.md +++ b/doc/manual.md @@ -43,20 +43,22 @@ A widget expression that does not compile is an `error` shot carrying the compil ## Actions -A state that comes from a gesture is shot by acting on the preview before the capture: +A state that comes from a gesture or from typing is shot by acting on the preview before the capture: -* `--tap ` taps the widget: a pointer down and up at its centre. Repeatable; the taps run in the order given. +* `--tap ` taps the widget: a pointer down and up at its centre. Repeatable. +* `--enter =` enters the text into the text field the target is or holds (exactly one `EditableText`), as the platform's keyboard does: the field takes focus and its text is replaced. The target ends at the first `=`. Repeatable; taps and entries run in the order given. * `--press ` holds a pointer down on it through the capture: its pressed state, with the ink the press has drawn by then. * `--hover ` keeps a mouse pointer over it through the capture. * `--focus ` gives it keyboard focus, highlighted as with a keyboard: `flutter_test` runs as a touch device, where focus draws no highlight. The node is the first focus node inside the widget (a button's or text field's own), else the one around it. -* At most one of `--press`, `--hover`, and `--focus`; it follows the taps. +* At most one of `--press`, `--hover`, and `--focus`; it follows the taps and entries. -A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. +A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), `label:` (a widget whose semantics label is exactly that string: a text field's `labelText` or `hintText`, a button's text), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. +`label:` names a text field without a key: `--enter 'label:Email=example@example.com'`. Each action is followed by `--settle` milliseconds drawn in 16 ms frames, as on a device, so `--settle` is also how long after an action the capture comes: an animation the action starts (ink, a check mark, a menu opening) is shot at that point of its course. `flutter test` runs as Android, so Material 3 presses use `InkSparkle`: its sparkle shows from about 100 ms to about 600 ms of a press, then the flat pressed overlay stays, as on a device. The actions apply to every preview of the run. -A preview is an `error` shot, without a PNG and with `at` pointing at the preview, when a target matches no widget or more than one, when a pointer at the target's centre does not reach it (covered, outside the viewport, or ignoring pointers), or when a `--focus` target cannot take focus. +A preview is an `error` shot, without a PNG and with `at` pointing at the preview, when a target matches no widget or more than one, when a pointer at the target's centre does not reach it (covered, outside the viewport, or ignoring pointers), when an `--enter` target holds no text field or more than one, or when a `--focus` target cannot take focus. The run records its actions in `manifest.json`; `shot` prints them, and `diff` prints both runs' when they differ, since they change every image without any widget changing. They are not part of shot ids, so a run with actions pairs shot for shot with one without. @@ -166,7 +168,8 @@ Fonts bundled in the project's assets are used as they are. These come from rendering through `flutter test`. -* One capture per preview, after the actions: scrolling, dragging, and typing are not shot. A sequence of states, such as the course of an animation, is shot as one run per point in time, each with its own `--settle`. +* One capture per preview, after the actions: scrolling and dragging are not shot. A sequence of states, such as the course of an animation, is shot as one run per point in time, each with its own `--settle`. +* No on-screen keyboard: text entered with `--enter` reaches the field, but the keyboard a device would show is not drawn, even with `--capture screen`. * HTTP is blocked, so network images fail to load. * `flutter test` runs the engine with test fonts, which has no system font fallback. Shutter's host fonts are added to the text themes and the default text style, so a text style that sets its own `fontFamilyFallback` does not get them. google_fonts styles do this: glyphs outside the Google font (for example Japanese in a Latin-only font) render as boxes, where a device would fall back to a system font. @@ -231,11 +234,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`, `--capture`/`--viewport`) | -| `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`/`--enter`/`--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 b0d6759..2961bc3 100644 --- a/lib/src/cli/agent_text.dart +++ b/lib/src/cli/agent_text.dart @@ -70,16 +70,17 @@ shutter shot --widget 'PrimaryButton(label: "OK")' --import lib/ui/button.dart - * Without `--size` the widget is shot at its own size; `--size` fixes both dimensions and stretches the widget to them. * The same `--widget` and `--import` give the same shot id in every run, so before and after line up in `diff`. -## Pressed, hovered, focused, opened +## Pressed, hovered, focused, opened, typed -A preview shows the state its construction gives. For a state a gesture gives, act on the preview in the shot: +A preview shows the state its construction gives. For a state a gesture or typing gives, act on the preview in the shot: ```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 +shutter shot lib/preview/login_preview.dart --enter 'label:Email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -* `--tap` (repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. +* `--tap` and `--enter =` (both repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, `label:` (a text field's label or hint, a button's 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. diff --git a/lib/src/cli/manual_text.dart b/lib/src/cli/manual_text.dart index 0a1cee3..1170d60 100644 --- a/lib/src/cli/manual_text.dart +++ b/lib/src/cli/manual_text.dart @@ -46,20 +46,22 @@ A widget expression that does not compile is an `error` shot carrying the compil ## Actions -A state that comes from a gesture is shot by acting on the preview before the capture: +A state that comes from a gesture or from typing is shot by acting on the preview before the capture: -* `--tap ` taps the widget: a pointer down and up at its centre. Repeatable; the taps run in the order given. +* `--tap ` taps the widget: a pointer down and up at its centre. Repeatable. +* `--enter =` enters the text into the text field the target is or holds (exactly one `EditableText`), as the platform's keyboard does: the field takes focus and its text is replaced. The target ends at the first `=`. Repeatable; taps and entries run in the order given. * `--press ` holds a pointer down on it through the capture: its pressed state, with the ink the press has drawn by then. * `--hover ` keeps a mouse pointer over it through the capture. * `--focus ` gives it keyboard focus, highlighted as with a keyboard: `flutter_test` runs as a touch device, where focus draws no highlight. The node is the first focus node inside the widget (a button's or text field's own), else the one around it. -* At most one of `--press`, `--hover`, and `--focus`; it follows the taps. +* At most one of `--press`, `--hover`, and `--focus`; it follows the taps and entries. -A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. +A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), `label:` (a widget whose semantics label is exactly that string: a text field's `labelText` or `hintText`, a button's text), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. +`label:` names a text field without a key: `--enter 'label:Email=example@example.com'`. Each action is followed by `--settle` milliseconds drawn in 16 ms frames, as on a device, so `--settle` is also how long after an action the capture comes: an animation the action starts (ink, a check mark, a menu opening) is shot at that point of its course. `flutter test` runs as Android, so Material 3 presses use `InkSparkle`: its sparkle shows from about 100 ms to about 600 ms of a press, then the flat pressed overlay stays, as on a device. The actions apply to every preview of the run. -A preview is an `error` shot, without a PNG and with `at` pointing at the preview, when a target matches no widget or more than one, when a pointer at the target's centre does not reach it (covered, outside the viewport, or ignoring pointers), or when a `--focus` target cannot take focus. +A preview is an `error` shot, without a PNG and with `at` pointing at the preview, when a target matches no widget or more than one, when a pointer at the target's centre does not reach it (covered, outside the viewport, or ignoring pointers), when an `--enter` target holds no text field or more than one, or when a `--focus` target cannot take focus. The run records its actions in `manifest.json`; `shot` prints them, and `diff` prints both runs' when they differ, since they change every image without any widget changing. They are not part of shot ids, so a run with actions pairs shot for shot with one without. @@ -169,7 +171,8 @@ Fonts bundled in the project's assets are used as they are. These come from rendering through `flutter test`. -* One capture per preview, after the actions: scrolling, dragging, and typing are not shot. A sequence of states, such as the course of an animation, is shot as one run per point in time, each with its own `--settle`. +* One capture per preview, after the actions: scrolling and dragging are not shot. A sequence of states, such as the course of an animation, is shot as one run per point in time, each with its own `--settle`. +* No on-screen keyboard: text entered with `--enter` reaches the field, but the keyboard a device would show is not drawn, even with `--capture screen`. * HTTP is blocked, so network images fail to load. * `flutter test` runs the engine with test fonts, which has no system font fallback. Shutter's host fonts are added to the text themes and the default text style, so a text style that sets its own `fontFamilyFallback` does not get them. google_fonts styles do this: glyphs outside the Google font (for example Japanese in a Latin-only font) render as boxes, where a device would fall back to a system font. @@ -234,12 +237,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`, `--capture`/`--viewport`) | -| `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`/`--enter`/`--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 d6698cf..6321735 100644 --- a/lib/src/cli/shot_command.dart +++ b/lib/src/cli/shot_command.dart @@ -45,7 +45,15 @@ class ShotCommand(final ShutterContext context) extends Command { 'tap', help: 'Tap this widget of every shot before the capture: ' - '$targetHelp. Repeatable, in order.', + '$targetHelp. Repeatable; taps and --enter run in the order ' + 'given.', + splitCommas: false, + ) + ..addMultiOption( + 'enter', + help: + 'Enter text into this text field, as =, e.g. ' + 'key:name=Koji. Repeatable.', splitCommas: false, ) // Multi-options, so that a second --press is rejected rather than diff --git a/lib/src/cli/shot_options.dart b/lib/src/cli/shot_options.dart index c1b1997..295f95f 100644 --- a/lib/src/cli/shot_options.dart +++ b/lib/src/cli/shot_options.dart @@ -53,8 +53,9 @@ int parseCount(ArgResults results, String name) { ); } -/// The actions of `shot`: every `--tap` in order, then the one `--press`, -/// `--hover`, or `--focus`, whose state lasts through the capture. +/// The actions of `shot`: every `--tap` and `--enter` in the order given, +/// then the one `--press`, `--hover`, or `--focus`, whose state lasts +/// through the capture. List parseActions(ArgResults results) { final held = [ for (final kind in const [ @@ -69,29 +70,53 @@ List parseActions(ArgResults results) { 'Give at most one of --press, --hover, and --focus.', ); } + // The parser keeps each option's values in order, but not the order + // between options, which the command line still has. + final steps = { + for (final kind in const [ActionKind.tap, ActionKind.enter]) + kind: [...results.multiOption(kind.name)], + }; return [ - for (final raw in results.multiOption('tap')) _action(ActionKind.tap, raw), + for (final argument in results.arguments.takeWhile((a) => a != '--')) + for (final MapEntry(key: kind, value: values) in steps.entries) + if ((argument == '--${kind.name}' || + argument.startsWith('--${kind.name}=')) && + values.isNotEmpty) + _action(kind, values.removeAt(0)), for (final (kind, raw) in held) _action(kind, raw), ]; } ShotAction _action(ActionKind kind, String raw) { - final colon = raw.indexOf(':'); + var target = raw; + String? text; + if (kind == ActionKind.enter) { + final equals = raw.indexOf('='); + if (equals < 0) { + throw ShutterException.usage( + '--enter must be =, e.g. key:name=Koji (got "$raw").', + ); + } + (target, text) = (raw.substring(0, equals), raw.substring(equals + 1)); + } + final colon = target.indexOf(':'); final by = colon < 0 ? null - : TargetKind.values.asNameMap()[raw.substring(0, colon)]; - final value = raw.substring(colon + 1); + : TargetKind.values.asNameMap()[target.substring(0, colon)]; + final value = target.substring(colon + 1); if (by == null || value.isEmpty) { throw ShutterException.usage( - '--${kind.name} must name its widget by key:, text:, or ' - 'type: (got "$raw").', + '--${kind.name} must name its widget by key:, text:, ' + 'label: