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!;
+}