diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f0747..ab1a372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed + +- `WInput`: tap-to-focus and `onTap` dispatch now flow through Flutter's native `TextSelectionGestureDetectorBuilder` (the same gesture path `TextField` uses) instead of a hand-built whole-box `GestureDetector`. `onTap` continues to fire when the user taps the field; the change is the gesture mechanism (which also brings drag-select and double-tap word selection), not a new `onTap` contract. + ### Fixed +- `WInput` now supports native text selection: mouse drag-select, double-tap word, and long-press all work again. The Material-free rewrite (#106) had dropped the selection gesture layer, so only keyboard select-all (CTRL+A) worked and dragging on web selected nothing. The field now uses the framework's canonical selectable-input recipe (the one `CupertinoTextField` uses): `_WInputState` implements `TextSelectionGestureDetectorBuilderDelegate`, the whole decorated box is wrapped by a `TextSelectionGestureDetectorBuilder` (so a tap anywhere focuses and a drag over the glyphs selects), and `EditableText.rendererIgnoresPointer` is `true` so the gesture layer is the only pointer handler. Selection handles stay Cupertino-style on all platforms (unchanged from 1.1.0), keeping `WInput` cupertino-only with no `package:flutter/material.dart` import; `cupertinoTextSelectionHandleControls` mixes in `TextSelectionHandleControls`, so the toolbar still flows through the Material-free `contextMenuBuilder` and no `Overlay`-less long-press throws. Read-only fields stay selectable; disabled fields stay fully inert. (`lib/src/widgets/w_input.dart`; covered by `test/widgets/w_input/selection_test.dart`.) - `WText` with no color in its own `className` now inherits an ancestor `DefaultTextStyle` color (the CSS text-color cascade) before falling back to the platform-brightness baseline. A parent `WDiv` with a `text-*` class publishes its color through `DefaultTextStyle.merge`, but `WText` previously ignored it and forced `Colors.white`/`Colors.black` from the OS platform brightness. That made colorless text vanish whenever the app theme disagreed with the OS theme: a secondary/outline button whose text color lives on the container (e.g. a dialog Cancel button) rendered an invisible label in a light app theme on a dark-mode OS. The brightness-aware baseline still applies only when no ancestor supplies a color (bare text with no Material ancestor), preserving the no-yellow-underline guarantee. (`lib/src/widgets/w_text.dart`; covered by `WText baseline rendering > inherits an ancestor color (CSS cascade)`.) ## [1.1.0] - 2026-06-17 diff --git a/doc/widgets/w-input.md b/doc/widgets/w-input.md index 5946909..893084b 100644 --- a/doc/widgets/w-input.md +++ b/doc/widgets/w-input.md @@ -167,7 +167,10 @@ WInput( When the field is `enabled: false`, it is non-editable and reports `isReadOnly` in the semantics tree. > [!NOTE] -> `WInput` uses Cupertino-style selection handles on all platforms. The long-press selection toolbar and handles require an `Overlay` ancestor. Under a bare root without an `Overlay` (unusual in practice), typing, cursor movement, and focus still work; only the selection toolbar does not appear. +> `WInput` supports native text selection (drag-select, double-tap word, long-press) with Cupertino-style selection handles on all platforms. This keeps `WInput` cupertino-only (no `package:flutter/material.dart` import) and consistent with the rest of Wind's own look. Interactive selection requires an `Overlay` ancestor (`MaterialApp` / `CupertinoApp` / `WidgetsApp` all provide one). Under a bare root with no `Overlay` (unusual in practice), typing and focus still work, but all interactive selection (drag-select, double-tap word, long-press, the drag handles, and the toolbar) is suppressed rather than throwing. + +> [!NOTE] +> **Web:** Flutter renders to a canvas (CanvasKit or Skwasm) and does not use a native browser ``. Mouse drag-select, double-click word, and keyboard copy (Ctrl+C / Cmd+C) all work through Flutter's own selection layer. Right-click opens the browser's native context menu by default (not WInput's selection toolbar). Apps that want WInput's selection toolbar on right-click can call `BrowserContextMenu.disableContextMenu()` once at startup; this is an app-level opt-in and not WInput behavior. No magnifier is shown on web. > [!WARNING] > Passing both `value` and `controller` at the same time throws an `AssertionError` in debug mode. Use either `value` + `onChanged` (controlled) or `controller` (external), not both. diff --git a/example/lib/pages/forms/input_basic.dart b/example/lib/pages/forms/input_basic.dart index 6c18f90..6d43684 100644 --- a/example/lib/pages/forms/input_basic.dart +++ b/example/lib/pages/forms/input_basic.dart @@ -14,6 +14,11 @@ class _InputBasicExamplePageState extends State { String _email = ''; bool _hasError = false; + // Realistic prefilled text for the selection demo. + static const _selectionSample = + 'Order #ORD-20240614 was shipped to 42 Maple Street, Portland OR 97201. ' + 'Expected delivery: Monday 17 June. Tracking: 1Z999AA10123456784.'; + static const _inputCls = ''' w-full px-3 py-2 rounded-lg bg-white dark:bg-slate-800 @@ -111,6 +116,58 @@ class _InputBasicExamplePageState extends State { ], ), ), + ExampleSection( + title: 'Text Selection', + description: + 'Mouse-drag to select a range, double-click to select a word, ' + 'or long-press on mobile for selection handles. ' + 'The field is read-only so the value stays intact while you explore.', + child: WDiv( + className: 'flex flex-col gap-3', + children: [ + WInput( + readOnly: true, + value: _selectionSample, + className: ''' + w-full px-3 py-2 rounded-lg + bg-white dark:bg-slate-800 + border border-slate-300 dark:border-slate-600 + text-slate-800 dark:text-slate-200 + focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 + ''', + ), + WDiv( + className: ''' + flex flex-row items-start gap-2 px-3 py-2 rounded-lg + bg-blue-50 dark:bg-blue-950 + border border-blue-200 dark:border-blue-800 + ''', + children: [ + WIcon( + Icons.info_outline_rounded, + className: + 'text-blue-500 dark:text-blue-400 w-4 h-4 mt-0.5', + ), + WDiv( + className: 'flex flex-col gap-1', + children: [ + WText( + 'Native text selection', + className: + 'text-sm font-semibold text-blue-700 dark:text-blue-300', + ), + WText( + 'Desktop / web: drag to select, double-click for a word, Ctrl+A for all. ' + 'Mobile: long-press for handles, then drag.', + className: 'text-xs text-blue-600 dark:text-blue-400', + ), + ], + ), + ], + ), + ], + ), + ), ExampleSection( title: 'Disabled vs Read-only', description: diff --git a/lib/src/widgets/w_input.dart b/lib/src/widgets/w_input.dart index 187ce29..2579163 100644 --- a/lib/src/widgets/w_input.dart +++ b/lib/src/widgets/w_input.dart @@ -1,4 +1,5 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/services.dart'; import '../parser/wind_parser.dart'; @@ -37,11 +38,11 @@ enum InputType { /// ancestor. /// /// ### Ancestor requirements: -/// - Interactive text selection (the long-press toolbar and drag handles) -/// needs an [Overlay] ancestor. `MaterialApp`, `CupertinoApp`, and -/// `WidgetsApp` all provide one; under a custom root with no `Overlay`, -/// typing, cursor movement, and focus still work, but selection UI is -/// suppressed rather than throwing. +/// - Interactive text selection (drag-select, double-tap word, long-press, the +/// drag handles, and the toolbar) needs an [Overlay] ancestor. `MaterialApp`, +/// `CupertinoApp`, and `WidgetsApp` all provide one; under a custom root with +/// no `Overlay`, typing and focus still work, but all interactive selection +/// is suppressed rather than throwing. /// - A tap anywhere in the input box (not only on the text glyphs) focuses the /// field and fires [onTap], restoring `TextField`'s whole-box tap target. /// @@ -272,7 +273,8 @@ class WInput extends StatefulWidget { State createState() => _WInputState(); } -class _WInputState extends State { +class _WInputState extends State + implements TextSelectionGestureDetectorBuilderDelegate { /// Default content padding (12 horizontal / 8 vertical) used when className /// supplies no `p-*`. static const EdgeInsets _defaultContentPadding = @@ -295,13 +297,49 @@ class _WInputState extends State { /// field's ancestor chain between the Row and no-Row layouts; without a /// GlobalKey Flutter would destroy and rebuild the EditableText on that /// switch, dropping focus mid-typing. The key moves the same element instead. - final GlobalKey _editableTextKey = GlobalKey(); + /// + /// Typed `GlobalKey` because it doubles as the delegate's + /// [editableTextKey]: the [TextSelectionGestureDetectorBuilder] reaches + /// through it to `currentState!.renderEditable` to hit-test taps/drags in + /// global coordinates. There is exactly ONE key (no second instance), so the + /// gesture layer and the focus-preserving element move are the same node. + final GlobalKey _editableTextKey = + GlobalKey(); + + /// Builds the native selection gesture layer (tap-to-focus, drag-select, + /// double-tap word, long-press) around the EditableText. This is the + /// framework's canonical Material-free recipe (the one [CupertinoTextField] + /// uses): instead of a hand-rolled whole-box [GestureDetector], the builder + /// hit-tests against the EditableText's [RenderEditable] in global coords, so + /// it handles glyph-level selection AND whole-box taps in one detector. + late final _WInputSelectionGestureDetectorBuilder + _selectionGestureDetectorBuilder; + + // === TextSelectionGestureDetectorBuilderDelegate === + + /// The single [GlobalKey] the gesture builder uses to reach the EditableText. + @override + GlobalKey get editableTextKey => _editableTextKey; + + /// iOS force-press (3D Touch) selection support. A getter, not an initState + /// cache, so `debugDefaultTargetPlatformOverride` in tests takes effect. + @override + bool get forcePressEnabled => defaultTargetPlatform == TargetPlatform.iOS; + + /// Whether the user may select text. Mirrors the EditableText's own + /// `enableInteractiveSelection` gate: selection UI needs an [Overlay] to host + /// its handles/toolbar and a disabled field exposes none of it. + @override + bool get selectionEnabled => + Overlay.maybeOf(context) != null && widget.enabled; @override void initState() { super.initState(); _initController(); _initFocusNode(); + _selectionGestureDetectorBuilder = + _WInputSelectionGestureDetectorBuilder(state: this); } void _initController() { @@ -501,9 +539,23 @@ class _WInputState extends State { // cursor color. backgroundCursorColor: CupertinoColors.inactiveGray, selectionColor: _isFocused ? selectionColor : null, - // Selection UI needs an Overlay to host its toolbar/handles, and a - // disabled field must expose none of it (a read-only field stays - // selectable so its text can be copied). + // RenderEditable must NOT handle pointers itself: the gesture layer is the + // `_selectionGestureDetectorBuilder` wrapping the whole box (below), which + // hit-tests the RenderEditable in global coords. Leaving this false would + // double-handle taps (the render box and the builder both reacting). + rendererIgnoresPointer: true, + // Interactive selection is gated on an Overlay (which hosts the + // handles/toolbar): with no Overlay, `enableInteractiveSelection` below + // is false too, so drag-select / double-tap / long-press are all off and + // only typing + focus work. A disabled field must expose none of it (a + // read-only field stays selectable so its text can be copied). Cupertino + // handle controls on + // every platform keep WInput cupertino-only (no `package:flutter/material.dart` + // import): `cupertinoTextSelectionHandleControls` mixes in + // [TextSelectionHandleControls], which suppresses the legacy `buildToolbar` + // (so the Material-free `contextMenuBuilder` stays the only toolbar path and + // the MaterialLocalizations assert is never hit) and is NOT deprecated (so + // `dart analyze` stays clean). selectionControls: hasOverlay && widget.enabled ? cupertinoTextSelectionHandleControls : null, @@ -585,18 +637,18 @@ class _WInputState extends State { child: field, ); - // Tapping anywhere in the box focuses the field (the box, not just the text - // glyphs, is the tap target) and fires onTap. EditableText alone only reacts - // to taps on the text itself; this restores TextField's whole-box behavior. + // Native selection gesture layer wraps the WHOLE decorated box (padding + + // prefix/suffix Row + DecoratedBox). The builder hit-tests against the + // EditableText's RenderEditable in global coordinates, so a tap anywhere in + // the box focuses the field (whole-box tap target, restoring TextField's + // behavior) while a drag over the glyphs selects text and a double-tap + // selects a word. Translucent behavior lets the box-area taps reach the + // builder even where there is no opaque child. Focus flows through the + // builder's own `onSingleTapUp`/`requestKeyboard`; `onUserTap` only fires + // `widget.onTap` (see the builder subclass below). if (widget.enabled) { - result = GestureDetector( + result = _selectionGestureDetectorBuilder.buildGestureDetector( behavior: HitTestBehavior.translucent, - onTap: () { - if (!_focusNode.hasFocus) { - _focusNode.requestFocus(); - } - widget.onTap?.call(); - }, child: result, ); } else { @@ -839,6 +891,35 @@ class _WInputState extends State { } } +/// The gesture layer for [WInput]'s native text selection. +/// +/// This is the framework's canonical Material-free recipe: it extends +/// [TextSelectionGestureDetectorBuilder] (the same base [CupertinoTextField] +/// uses) and inherits every default selection gesture (tap-to-focus, +/// drag-select, double-tap word, triple-tap line, long-press, force-press on +/// iOS). It hit-tests against the EditableText's [RenderEditable] in global +/// coordinates via the delegate's `editableTextKey`, so wrapping the whole +/// decorated box preserves both whole-box tap and glyph-level drag-select. +/// +/// Only [onUserTap] is overridden, to forward [WInput.onTap]. Focus is NOT +/// requested here: the builder's own `onSingleTapUp` already calls +/// `requestKeyboard`, which focuses the field. Calling `requestFocus` again +/// would be redundant (and the docs of `onUserTap` warn it fires AFTER the +/// focusing tap handler). +class _WInputSelectionGestureDetectorBuilder + extends TextSelectionGestureDetectorBuilder { + _WInputSelectionGestureDetectorBuilder({required _WInputState state}) + : _state = state, + super(delegate: state); + + final _WInputState _state; + + @override + void onUserTap() { + _state.widget.onTap?.call(); + } +} + /// Restricts input to a signed decimal number: an optional leading minus, any /// number of digits, and at most one decimal point. Allows the in-progress /// states a user types through (`-`, `1.`, `.5`). diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 86c61af..8b4addc 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -3,10 +3,10 @@ name: wind-ui description: "fluttersdk_wind 1.1: utility-first Flutter styling with Tailwind-syntax className strings. 22 public widgets (WDiv, WText, WButton, WInput, WSelect, WCheckbox, WDatePicker, WPopover, WAnchor, WIcon, WImage, WSvg, WSpacer, WBreakpoint, WDynamic, WKeyboardActions, WindAnimationWrapper + 5 WForm* wrappers) consume className through a 19-parser pipeline (19 implementation files organized into 12 token families for teaching) that emits a cached immutable WindStyle. Prefixes stack freely (dark: / hover: / focus: / md: / lg: / ios: / android: / web: / mobile: / selected: / loading: / disabled: / readonly: / error: / checked: / custom). Last class wins; unknown tokens fail silently. Every color token (bg-, text-, border-, ring-, shadow-, fill-) needs a dark: pair in the same className. TRIGGER when: writing or editing any UI in a Flutter app that depends on `fluttersdk_wind`; any className string; any W-prefix widget; any WindTheme / WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend / API / state-management work that does not touch a widget tree; Flutter projects that do not have fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) without Wind content inside them." when_to_use: | Any task that produces, modifies, or audits Wind-styled Flutter UI: composing a className string, picking the right W-widget for a use case, integrating with a Form / FormField, customizing WindThemeData, wiring dark-mode pairs, debugging an unexpected layout, recovering from RenderFlex overflow, building a popover or dropdown, rendering a JSON tree via WDynamic, wiring Wind.installDebugResolver for kDebugMode tooling, or migrating a Tailwind className from web. Apply BEFORE writing the first line of UI in a Wind-using file, not as an audit pass. -version: 2.5.0 +version: 2.6.0 --- - + # Wind UI 1.1 @@ -82,7 +82,7 @@ The headline 20 (table below) are the ones an agent reaches for daily. Two more | `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)`. | | `WButton` | Interactive | `child: Widget` | Wraps `WAnchor` + `WDiv` + built-in spinner. `isLoading: true` injects `loading:` state. `disabled: true` injects `disabled:` state and blocks taps. | | `WPopover` | Overlay | none (requires builders) | `OverlayPortal`-based; `triggerBuilder(ctx, isOpen, isHovering)` + `contentBuilder(ctx, close)` + optional `PopoverController`. Auto-flips alignment when bottom space is insufficient. | -| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | +| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | | `WCheckbox` | Form (raw) | none | Boolean checkbox; auto-injects `checked:` state when `value: true`. Default className includes `checked:bg-primary` (requires a `primary` color in theme). | | `WSelect` | Form (raw) | `options: List>` | Single OR multi-select dropdown with overlay. Supports searchable, async search, async create (tagging), pagination via `onLoadMore` + `hasMore`. Auto-flips upward when bottom space < `maxMenuHeight`. | | `WDatePicker` | Form (raw) | none | Single date OR `DateRange` mode; popover-based calendar; min/max constraints. | diff --git a/skills/wind-ui/references/forms.md b/skills/wind-ui/references/forms.md index 7a9486c..c310683 100644 --- a/skills/wind-ui/references/forms.md +++ b/skills/wind-ui/references/forms.md @@ -25,6 +25,8 @@ Building forms with validation. Use this file when picking between raw `W*` and Both families share the same className surface and visual output. The split is purely about validation flow. +`WInput` (and by extension `WFormInput`) uses native text selection: mouse-drag selects a substring, double-click or double-tap selects a word, and tapping the box moves the cursor. Selection handles are Cupertino-style on all platforms (keeps `WInput` cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Both widget families are Material-free at their core (`EditableText`), so no Material ancestor is required for the input itself. + --- ## 2. Standard form skeleton diff --git a/test/widgets/w_input/selection_test.dart b/test/widgets/w_input/selection_test.dart new file mode 100644 index 0000000..7138f6a --- /dev/null +++ b/test/widgets/w_input/selection_test.dart @@ -0,0 +1,367 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/src/parser/wind_parser.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme_data.dart'; +import 'package:fluttersdk_wind/src/widgets/w_input.dart'; + +/// Bare harness mirroring [material_free_test.dart]: [Directionality] > +/// [WindTheme] > child, with NO Material ancestor. Selection that needs an +/// Overlay wraps with [_overlayHarness] instead; this one is for assertions +/// that must hold under a Material-free root. +Widget _bareHarness(Widget child) { + return Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: WindThemeData(), + child: child, + ), + ); +} + +/// Bare harness that DOES provide an [Overlay] (so interactive selection is +/// enabled) but still no Material/Cupertino app. Built on the minimal +/// [WidgetsApp] surface that selection drag/handles need, kept Material-free so +/// the `*HandleControls` mixin path (which suppresses `buildToolbar` and the +/// `MaterialLocalizations` assert) is what is exercised. +Widget _overlayHarness(Widget child) { + return WidgetsApp( + color: const Color(0xFF000000), + // WidgetsApp supplies Directionality + DefaultWidgetsLocalizations + + // MediaQuery (Material-free). The explicit Overlay hosts the selection + // handles/toolbar that interactive selection reaches for, so the field's + // `selectionEnabled` gate (Overlay.maybeOf != null) is satisfied. + builder: (context, _) => Overlay( + initialEntries: [ + OverlayEntry( + builder: (context) => WindTheme( + data: WindThemeData(), + child: child, + ), + ), + ], + ), + ); +} + +void main() { + setUp(WindParser.clearCache); + + group('WInput native selection', () { + // QA (1): mouse drag across "hello" produces a non-collapsed selection. + group('mouse drag selection', () { + testWidgets( + 'dragging the mouse across "hello" selects it', + (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _overlayHarness( + WInput( + controller: controller, + className: 'text-base', + ), + ), + ); + + await tester.enterText(find.byType(EditableText), 'hello world'); + await tester.pump(); + + // Anchor at the very start of the text, drag to just past "hello". + final RenderEditable renderEditable = _findRenderEditable( + tester.renderObject(find.byType(EditableText))); + final Offset startGlyph = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 0)) + .center, + ); + final Offset endOfHello = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 5)) + .center, + ); + + final TestGesture gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: startGlyph); + await gesture.down(startGlyph); + await tester.pump(); + await gesture.moveTo(endOfHello); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect( + controller.selection.isCollapsed, + isFalse, + reason: 'A mouse drag must produce a non-collapsed selection.', + ); + expect( + controller.text.substring( + controller.selection.start, + controller.selection.end, + ), + 'hello', + reason: 'The drag must cover exactly the dragged-over "hello".', + ); + }, + ); + }); + + // QA (2): double-tap selects the tapped word. + group('double tap', () { + testWidgets('double-tapping a word selects that word', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _overlayHarness( + WInput(controller: controller), + ), + ); + + await tester.enterText(find.byType(EditableText), 'hello world'); + await tester.pump(); + + final RenderEditable renderEditable = + _findRenderEditable(tester.renderObject(find.byType(EditableText))); + // Center of the first word "hello" (offset 2 sits inside it). + final Offset insideHello = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 2)) + .center, + ); + + await tester.tapAt(insideHello); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(insideHello); + await tester.pump(); + + expect( + controller.text.substring( + controller.selection.start, + controller.selection.end, + ), + 'hello', + reason: 'A double tap must select the word under the pointer.', + ); + }); + }); + + // QA (3): tapping empty box area focuses the field and fires onTap. + group('whole-box tap', () { + testWidgets('tapping the empty box focuses and fires onTap', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + bool tapped = false; + + await tester.pumpWidget( + _overlayHarness( + WInput( + controller: controller, + focusNode: focusNode, + onTap: () => tapped = true, + // Wide box so the right padding area is far from the glyphs. + className: 'w-full p-4', + ), + ), + ); + + // Tap the far-right padding region (inside WInput, outside the glyphs). + final Rect rect = tester.getRect(find.byType(WInput)); + await tester.tapAt(Offset(rect.right - 4, rect.center.dy)); + await tester.pump(); + + expect( + focusNode.hasFocus, + isTrue, + reason: 'A whole-box tap must focus the field via the builder.', + ); + expect( + tapped, + isTrue, + reason: 'onTap fires from the builder onUserTap override.', + ); + }); + }); + + // QA (4): disabled field is fully inert (IgnorePointer). + group('disabled', () { + testWidgets('disabled field ignores taps (no focus, no onTap)', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + bool tapped = false; + + await tester.pumpWidget( + _overlayHarness( + WInput( + controller: controller, + focusNode: focusNode, + enabled: false, + onTap: () => tapped = true, + className: 'w-full p-4', + ), + ), + ); + + final Rect rect = tester.getRect(find.byType(WInput)); + await tester.tapAt(Offset(rect.right - 4, rect.center.dy)); + await tester.pump(); + + // IgnorePointer swallows the tap: the field neither focuses nor fires + // onTap. (No text-entry assertion here: tester.enterText focuses via + // the test binding and would bypass IgnorePointer, so it proves + // nothing about pointer suppression.) + expect(focusNode.hasFocus, isFalse); + expect(tapped, isFalse); + }); + }); + + // QA (5): read-only field stays selectable. + group('read-only', () { + testWidgets('read-only field is still selectable by drag', ( + tester, + ) async { + final controller = TextEditingController(text: 'hello world'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _overlayHarness( + WInput( + controller: controller, + readOnly: true, + ), + ), + ); + await tester.pump(); + + final RenderEditable renderEditable = + _findRenderEditable(tester.renderObject(find.byType(EditableText))); + final Offset startGlyph = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 0)) + .center, + ); + final Offset endOfHello = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 5)) + .center, + ); + + final TestGesture gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: startGlyph); + await gesture.down(startGlyph); + await tester.pump(); + await gesture.moveTo(endOfHello); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect( + controller.selection.isCollapsed, + isFalse, + reason: + 'A read-only field must remain selectable so text can be copied.', + ); + }); + }); + + // QA (6): no Overlay ancestor — typing works, long-press does not crash. + group('no Overlay ancestor', () { + testWidgets('typing works and long-press does not crash', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _bareHarness( + WInput(controller: controller, placeholder: 'Type here'), + ), + ); + + await tester.enterText(find.byType(EditableText), 'hello'); + await tester.pump(); + expect(controller.text, 'hello'); + + // A long-press must degrade gracefully (selection UI suppressed), not + // throw on the missing Overlay. + final Offset center = tester.getCenter(find.byType(EditableText)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(const Duration(milliseconds: 600)); + await gesture.up(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + }); + + // QA (7): WInput wires Cupertino handle controls on EVERY platform (it stays + // cupertino-only / no package:flutter/material.dart import), and the bare + // harness never trips debugCheckHasMaterialLocalizations on any platform. + group('cupertino-only handle controls', () { + for (final TargetPlatform platform in TargetPlatform.values) { + testWidgets('wires Cupertino handle controls on $platform', + (tester) async { + debugDefaultTargetPlatformOverride = platform; + try { + final controller = TextEditingController(text: 'sample'); + addTearDown(controller.dispose); + + // Bare harness + Overlay: the *HandleControls mixin must keep the + // toolbar off the buildToolbar path so MaterialLocalizations is + // never required under this Material-free root. + await tester.pumpWidget( + _overlayHarness( + WInput(controller: controller), + ), + ); + + final EditableText editable = + tester.widget(find.byType(EditableText)); + expect( + editable.selectionControls.runtimeType, + cupertinoTextSelectionHandleControls.runtimeType, + reason: 'WInput must wire Cupertino handle controls on every ' + 'platform (cupertino-only, no material.dart import).', + ); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + } + }); + }); +} + +/// Walks down from [object] to the first [RenderEditable] (the EditableText's +/// own render box sits a couple of layers below its element). +RenderEditable _findRenderEditable(RenderObject object) { + if (object is RenderEditable) { + return object; + } + RenderEditable? found; + object.visitChildren((child) { + found ??= _findRenderEditable(child); + }); + if (found == null) { + throw StateError('No RenderEditable found in the subtree.'); + } + return found!; +}