Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion doc/widgets/w-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input>`. 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.
Expand Down
57 changes: 57 additions & 0 deletions example/lib/pages/forms/input_basic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ class _InputBasicExamplePageState extends State<InputBasicExamplePage> {
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
Expand Down Expand Up @@ -111,6 +116,58 @@ class _InputBasicExamplePageState extends State<InputBasicExamplePage> {
],
),
),
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:
Expand Down
121 changes: 101 additions & 20 deletions lib/src/widgets/w_input.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -272,7 +273,8 @@ class WInput extends StatefulWidget {
State<WInput> createState() => _WInputState();
}

class _WInputState extends State<WInput> {
class _WInputState extends State<WInput>
implements TextSelectionGestureDetectorBuilderDelegate {
/// Default content padding (12 horizontal / 8 vertical) used when className
/// supplies no `p-*`.
static const EdgeInsets _defaultContentPadding =
Expand All @@ -295,13 +297,49 @@ class _WInputState extends State<WInput> {
/// 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<EditableTextState>` 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<EditableTextState> _editableTextKey =
GlobalKey<EditableTextState>();

/// 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<EditableTextState> 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() {
Expand Down Expand Up @@ -501,9 +539,23 @@ class _WInputState extends State<WInput> {
// 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,
Expand Down Expand Up @@ -585,18 +637,18 @@ class _WInputState extends State<WInput> {
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 {
Expand Down Expand Up @@ -839,6 +891,35 @@ class _WInputState extends State<WInput> {
}
}

/// 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`).
Expand Down
Loading