Files
Garage-SDKs/garage_ui/lib/text_field.dart
T
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

1621 lines
56 KiB
Dart

// GarageUI text field family. Hand rolled replacement for shadcn's TextField +
// the InputFeature system (spinner / stepper buttons / clear / leading / trailing).
//
// The widget STRUCTURE here is ours, but we still read the theme through shadcn
// (Theme.of / colorScheme / density / typography) and reuse a couple of sibling
// family primitives (IconButton, FocusOutline, ButtonGroup) so the pixels line
// up exactly with the old look. Those prefixed imports get repointed later once
// the button/layout families land.
import "dart:ui" show BoxHeightStyle, BoxWidthStyle;
import "package:flutter/widgets.dart";
import "package:flutter/services.dart";
import "package:flutter/gestures.dart";
import "package:flutter/cupertino.dart"
show cupertinoDesktopTextSelectionHandleControls;
import "package:garage_ui/platform/cursor_lock.dart";
import "package:garage_ui/field_error.dart";
import "package:garage_ui/semantics_scope.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:garage_ui/button.dart";
/// Where an input feature sits relative to the editable text.
enum InputFeaturePosition { leading, trailing, above, below }
/// Visual treatment for the field surface.
enum TextFieldVariant { outline, secondary }
// Applies the optical nudge, or nothing at all when the density has it at 0 —
// no point hanging a transform layer off every control to move it by zero.
Widget _optionallyNudged(double dy, Widget child) {
if (dy == 0) return child;
return Transform.translate(offset: Offset(0, dy), child: child);
}
/// Base class for the little extras that live inside / beside a [TextField] —
/// stepper buttons, a clear button, custom leading/trailing widgets, etc.
///
/// Only the factories the app actually leans on are wired up. Each feature just
/// yields the widgets it wants in one of the four slots; the field lays them out.
abstract class InputFeature {
const InputFeature();
/// Increment (+) button for numeric inputs. Renders as an outline icon button
/// joined onto the field via the surrounding button group.
const factory InputFeature.incrementButton({
double step,
double? invalidValue,
double? min,
double? max,
InputFeaturePosition position,
Widget? icon,
}) = _StepperFeature.increment;
/// Decrement (-) button. Same as increment but steps the other way.
const factory InputFeature.decrementButton({
double step,
double? invalidValue,
double? min,
double? max,
InputFeaturePosition position,
Widget? icon,
}) = _StepperFeature.decrement;
/// Clear button (an x). Shows only while there is text.
const factory InputFeature.clear({
InputFeaturePosition position,
Widget? icon,
}) = _ClearFeature;
/// Up/down chevron spinner stacked on the trailing edge, inside the field.
const factory InputFeature.spinner({
double step,
bool enableGesture,
double? invalidValue,
double? min,
double? max,
}) = _SpinnerFeature;
/// A custom widget dropped on the leading side, inside the field.
const factory InputFeature.leading(Widget child) = _LeadingFeature;
/// A custom widget dropped on the trailing side, inside the field.
const factory InputFeature.trailing(Widget child) = _TrailingFeature;
/// Fills the field horizontally with [color] (the theme's primary colour
/// by default) based on where the current parsed value sits between [min]
/// and [max]. Sits behind the text, inside the fields own clipped border -
/// same idea as the scrubbable numeric fields in the colour picker, just
/// as a background instead of a number you drag.
const factory InputFeature.fillIndicator({
required double min,
required double max,
double invalidValue,
Color? color,
}) = _FillIndicatorFeature;
/// Blender-style numeric drag-to-scrub. While unfocused, the field renders
/// as a plain value display behind a pan gesture instead of the real
/// editable text - horizontal drag adjusts the (parsed-as-double) value
/// directly, hold shift for a finer 0.1x step. A drag under the click
/// threshold focuses the field instead, for normal typing. Only makes
/// sense on fields holding a plain number. Adds its own trailing
/// chevronsUpDown icon unless [icon] overrides it.
const factory InputFeature.scrub({
double sensitivity,
int decimals,
TextAlign? textAlign,
Widget? icon,
VoidCallback? onStart,
ValueChanged<String>? onUpdate,
VoidCallback? onEnd,
}) = _ScrubFeature;
// slots. prefix/suffix live OUTSIDE the decorated box (button group members),
// leading/trailing live INSIDE the box next to the text, background sits
// behind everything else inside the clipped border.
Iterable<Widget> buildPrefix(TextFieldState state) sync* {}
Iterable<Widget> buildSuffix(TextFieldState state) sync* {}
Iterable<Widget> buildLeading(TextFieldState state) sync* {}
Iterable<Widget> buildTrailing(TextFieldState state) sync* {}
Iterable<Widget> buildBackground(TextFieldState state) sync* {}
}
// ---- stepper (+/-) -------------------------------------------------------
class _StepperFeature extends InputFeature {
final double step;
final double? invalidValue;
final double? min;
final double? max;
final InputFeaturePosition position;
final Widget? icon;
const _StepperFeature.increment({
this.step = 1.0,
this.invalidValue = 0.0,
this.min,
this.max,
this.position = InputFeaturePosition.trailing,
this.icon = const Icon(LucideIcons.plus),
});
const _StepperFeature.decrement({
this.step = -1.0,
this.invalidValue = 0.0,
this.min,
this.max,
this.position = InputFeaturePosition.trailing,
this.icon = const Icon(LucideIcons.minus),
});
Widget _button(TextFieldState state) {
return _StepperButton(
controller: state.effectiveController,
step: step,
min: min,
max: max,
invalidValue: invalidValue,
icon: icon ?? const Icon(LucideIcons.plus),
onChanged: state.widget.onChanged,
);
}
@override
Iterable<Widget> buildSuffix(TextFieldState state) sync* {
if (position == InputFeaturePosition.trailing) yield _button(state);
}
@override
Iterable<Widget> buildPrefix(TextFieldState state) sync* {
if (position == InputFeaturePosition.leading) yield _button(state);
}
}
class _StepperButton extends StatelessWidget {
final TextEditingController controller;
final double step;
final double? min;
final double? max;
final double? invalidValue;
final Widget icon;
final ValueChanged<String>? onChanged;
const _StepperButton({
required this.controller,
required this.step,
required this.min,
required this.max,
required this.invalidValue,
required this.icon,
required this.onChanged,
});
double _clamp(double value) {
if (min != null && value < min!) return min!;
if (max != null && value > max!) return max!;
return value;
}
double? _effectiveValue() {
final value = double.tryParse(controller.text);
return value ?? invalidValue;
}
bool _canApplyStep(double? value) {
if (value == null) return false;
if (step >= 0) {
return max == null || value < max!;
}
return min == null || value > min!;
}
// 2 -> "2", 2.5 -> "2.5", 2.0 -> "2". strip trailing zeros like shadcn does
String _newText(double value) {
var newText = value.toString();
if (newText.contains(".")) {
while (newText.endsWith("0")) {
newText = newText.substring(0, newText.length - 1);
}
if (newText.endsWith(".")) {
newText = newText.substring(0, newText.length - 1);
}
}
return newText;
}
void _apply() {
final text = controller.text;
final value = double.tryParse(text);
String newText;
if (value == null) {
if (invalidValue == null) return;
newText = _newText(_clamp(invalidValue!));
} else {
newText = _newText(_clamp(value + step));
}
if (newText != text) {
controller.text = newText;
onChanged?.call(newText);
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, child) {
final current = _effectiveValue();
final clamped = current == null ? null : _clamp(current);
final canStep = _canApplyStep(clamped);
return AspectRatio(
aspectRatio: 1,
child: IconButton.outline(
icon: icon,
onPressed: canStep ? _apply : null,
density: ControlDensity.compact,
size: ButtonSize.small,
),
);
},
);
}
}
// ---- clear (x) -----------------------------------------------------------
class _ClearFeature extends InputFeature {
final InputFeaturePosition position;
final Widget? icon;
const _ClearFeature({
this.position = InputFeaturePosition.trailing,
this.icon,
});
Widget _button(TextFieldState state) {
final controller = state.effectiveController;
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink();
return IconButton.ghost(
icon: icon ?? const Icon(LucideIcons.x),
density: ControlDensity.compact,
onPressed: () {
controller.text = "";
state.widget.onChanged?.call("");
},
);
},
);
}
@override
Iterable<Widget> buildTrailing(TextFieldState state) sync* {
if (position == InputFeaturePosition.trailing) yield _button(state);
}
@override
Iterable<Widget> buildLeading(TextFieldState state) sync* {
if (position == InputFeaturePosition.leading) yield _button(state);
}
}
// ---- spinner (stacked chevrons) -----------------------------------------
class _SpinnerFeature extends InputFeature {
final double step;
final bool enableGesture;
final double? invalidValue;
final double? min;
final double? max;
const _SpinnerFeature({
this.step = 1.0,
this.enableGesture = true,
this.invalidValue = 0.0,
this.min,
this.max,
});
@override
Iterable<Widget> buildTrailing(TextFieldState state) sync* {
yield _Spinner(
controller: state.effectiveController,
step: step,
enableGesture: enableGesture,
min: min,
max: max,
invalidValue: invalidValue,
onChanged: state.widget.onChanged,
);
}
}
class _Spinner extends StatelessWidget {
final TextEditingController controller;
final double step;
final bool enableGesture;
final double? min;
final double? max;
final double? invalidValue;
final ValueChanged<String>? onChanged;
const _Spinner({
required this.controller,
required this.step,
required this.enableGesture,
required this.min,
required this.max,
required this.invalidValue,
required this.onChanged,
});
double _clamp(double value) {
if (min != null && value < min!) return min!;
if (max != null && value > max!) return max!;
return value;
}
double? _effectiveValue() => double.tryParse(controller.text) ?? invalidValue;
bool _canIncrease(double? v) => v != null && (max == null || v < max!);
bool _canDecrease(double? v) => v != null && (min == null || v > min!);
String _newText(double value) {
var newText = value.toString();
if (newText.contains(".")) {
while (newText.endsWith("0")) {
newText = newText.substring(0, newText.length - 1);
}
if (newText.endsWith(".")) {
newText = newText.substring(0, newText.length - 1);
}
}
return newText;
}
void _step(double delta) {
final text = controller.text;
final value = double.tryParse(text);
String newText;
if (value == null) {
if (invalidValue == null) return;
newText = _newText(_clamp(invalidValue!));
} else {
newText = _newText(_clamp(value + delta));
}
if (newText != text) {
controller.text = newText;
onChanged?.call(newText);
}
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
Widget buttons = ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, child) {
final current = _effectiveValue();
final clamped = current == null ? null : _clamp(current);
// Two half-height arrows sharing the field's interior. Expanded, not
// natural size: a compact xSmall button is ~11.5 tall, so the pair
// wanted 23 in a box thats only controlHeight-minus-border (21) and
// overflowed the bottom by 2. Splitting the space they actually get
// means the spinner fits whatever interior the density hands it.
//
// The icon is laid out at half the row too - it's drawn through a 1.5x
// Transform.scale below, which costs nothing in layout, so shrinking
// the layout box doesn't shrink the chevron you see.
return LayoutBuilder(
builder: (context, constraints) {
final half = constraints.maxHeight.isFinite
? constraints.maxHeight / 2
: theme.density.lineBox;
final iconSize = (half - 2 * theme.scaling).clamp(6.0, half);
return IconTheme.merge(
data: IconThemeData(size: iconSize),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: IconButton.ghost(
icon: Transform.translate(
offset: Offset(0, -1 * theme.scaling),
child: Transform.scale(
alignment: Alignment.center,
scale: 1.5,
child: const Icon(LucideIcons.chevron_up),
),
),
onPressed: _canIncrease(clamped)
? () => _step(step)
: null,
density: ControlDensity.compact,
size: ButtonSize.xSmall,
),
),
Expanded(
child: IconButton.ghost(
icon: Transform.translate(
offset: Offset(0, 1 * theme.scaling),
child: Transform.scale(
alignment: Alignment.center,
scale: 1.5,
child: const Icon(LucideIcons.chevron_down),
),
),
onPressed: _canDecrease(clamped)
? () => _step(-step)
: null,
density: ControlDensity.compact,
size: ButtonSize.xSmall,
),
),
],
),
);
},
);
},
);
if (enableGesture) {
buttons = GestureDetector(
// scrub drag surface, not a control - keep it out of the
// semantics tree so it doesn't read as a bogus scrollable
excludeFromSemantics: true,
onVerticalDragUpdate: (details) {
if (details.delta.dy < 0) {
_step(step);
} else {
_step(-step);
}
},
child: buttons,
);
}
return buttons;
}
}
// ---- fill indicator --------------------------------------------------
class _FillIndicatorFeature extends InputFeature {
final double min;
final double max;
final double invalidValue;
final Color? color;
const _FillIndicatorFeature({
required this.min,
required this.max,
this.invalidValue = 0.0,
this.color,
});
@override
Iterable<Widget> buildBackground(TextFieldState state) sync* {
yield _FillIndicatorBar(
controller: state.effectiveController,
min: min,
max: max,
invalidValue: invalidValue,
color: color,
);
}
}
class _FillIndicatorBar extends StatelessWidget {
final TextEditingController controller;
final double min;
final double max;
final double invalidValue;
final Color? color;
const _FillIndicatorBar({
required this.controller,
required this.min,
required this.max,
required this.invalidValue,
required this.color,
});
@override
Widget build(BuildContext context) {
final scheme = GarageTheme.of(context).colorScheme;
final fillColor = color ?? scheme.primary;
final span = max - min;
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
final parsed = double.tryParse(value.text) ?? invalidValue;
final fraction = span <= 0
? 0.0
: ((parsed - min) / span).clamp(0.0, 1.0);
return Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: fraction,
heightFactor: 1.0,
child: ColoredBox(color: fillColor),
),
);
},
);
}
}
// ---- drag-to-scrub ---------------------------------------------------
class _ScrubFeature extends InputFeature {
final double sensitivity;
final int decimals;
final TextAlign? textAlign;
final Widget? icon;
final VoidCallback? onStart;
final ValueChanged<String>? onUpdate;
final VoidCallback? onEnd;
const _ScrubFeature({
this.sensitivity = 0.1,
this.decimals = 1,
this.textAlign,
this.icon,
this.onStart,
this.onUpdate,
this.onEnd,
});
@override
Iterable<Widget> buildTrailing(TextFieldState state) sync* {
if (!state.widget.enabled || state.widget.readOnly) return;
yield icon ?? const Icon(LucideIcons.chevrons_up_down);
}
}
// ---- custom leading / trailing ------------------------------------------
class _LeadingFeature extends InputFeature {
final Widget child;
const _LeadingFeature(this.child);
@override
Iterable<Widget> buildLeading(TextFieldState state) sync* {
yield child;
}
}
class _TrailingFeature extends InputFeature {
final Widget child;
const _TrailingFeature(this.child);
@override
Iterable<Widget> buildTrailing(TextFieldState state) sync* {
yield child;
}
}
// ---- the field itself ----------------------------------------------------
/// A single line text input styled to match the rest of the app.
///
/// This is intentionally a lot smaller than the shadcn original — it carries
/// the params the app actually passes plus a few obvious ones. See the family
/// notes for the pile of exotic params that were dropped.
class TextField extends StatefulWidget {
final TextEditingController? controller;
final String? initialValue;
final FocusNode? focusNode;
final Widget? placeholder;
final bool enabled;
final bool autofocus;
final bool readOnly;
final bool obscureText;
final String obscuringCharacter;
final int? maxLines;
final int? minLines;
final bool expands;
final TextAlign textAlign;
final TextAlignVertical? textAlignVertical;
final CrossAxisAlignment crossAxisAlignment;
final TextStyle? style;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final TextCapitalization textCapitalization;
// handed straight to the EditableText so the platform can offer a saved
// value. mostly a no-op in the editor, but the web apps need it: it's what
// drives password-manager fill, and the "webauthn" hint is what lets a
// browser offer a passkey from inside the field itself.
final Iterable<String>? autofillHints;
final List<TextInputFormatter>? inputFormatters;
final List<TextInputFormatter>? submitFormatters;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
final GestureTapCallback? onTap;
final TapRegionCallback? onTapOutside;
final List<InputFeature> features;
final TextFieldVariant variant;
final EdgeInsetsGeometry? padding;
final BorderRadiusGeometry? borderRadius;
final Border? border;
final bool? filled;
final BoxDecoration? decoration;
final Color? cursorColor;
final double cursorWidth;
final double? cursorHeight;
final Radius cursorRadius;
final bool enableInteractiveSelection;
/// screen reader label for the field. The placeholder is not a substitute —
/// it disappears the moment anything is typed, so a field labelled only by
/// its placeholder goes anonymous exactly when you need to know what you're
/// editing.
final String? semanticLabel;
const TextField({
super.key,
this.controller,
this.initialValue,
this.focusNode,
this.placeholder,
this.enabled = true,
this.autofocus = false,
this.readOnly = false,
this.obscureText = false,
this.obscuringCharacter = "•",
this.maxLines = 1,
this.minLines,
this.expands = false,
this.textAlign = TextAlign.start,
this.textAlignVertical,
this.crossAxisAlignment = CrossAxisAlignment.center,
this.style,
this.keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.autofillHints,
this.inputFormatters,
this.submitFormatters = const [],
this.onChanged,
this.onSubmitted,
this.onEditingComplete,
this.onTap,
this.onTapOutside,
this.features = const [],
this.variant = TextFieldVariant.outline,
this.padding,
this.borderRadius,
this.border,
this.filled,
this.decoration,
this.cursorColor,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius = const Radius.circular(2.0),
this.enableInteractiveSelection = true,
this.semanticLabel,
});
@override
State<TextField> createState() => TextFieldState();
}
class TextFieldState extends State<TextField>
implements TextSelectionGestureDetectorBuilderDelegate {
TextEditingController? _localController;
TextEditingController get effectiveController =>
widget.controller ?? _localController!;
FocusNode? _localFocus;
FocusNode get _effectiveFocusNode =>
widget.focusNode ?? (_localFocus ??= FocusNode());
late _FieldSelectionGestureDetectorBuilder _gestureBuilder;
bool _showSelectionHandles = false;
bool _hovered = false;
// finds the InputFeature.scrub in the feature list, if any. features are a
// plain list not a set, so this is a linear scan - fine, that list is
// never more than a handful of entries long.
_ScrubFeature? get _scrubFeature {
for (final feature in widget.features) {
if (feature is _ScrubFeature) return feature;
}
return null;
}
// drag-to-scrub tracking - see [InputFeature.scrub]. Uses raw Listener
// pointer events rather than GestureDetector's onTap+onPan* - those are
// two separately-competing recognizers in the same arena, and that
// combination was unreliable in practice (tap kept losing to pan even for
// a genuine click). Listener isn't a recognizer at all, so theres no arena
// to lose - it always fires, and WE decide tap vs drag from the
// accumulated movement ourselves.
static const _scrubClickThreshold = 3.0;
double _scrubLiveValue = 0;
double _scrubDistance = 0;
bool _scrubDragStarted = false;
/// Whether the value in here is not yours to change - either the field is
/// disabled outright, or its readOnly and only there to be looked at.
bool get _dimmed => widget.readOnly || !widget.enabled;
void _scrubPointerDown(PointerDownEvent event) {
if (widget.readOnly) return;
_scrubDistance = 0;
_scrubDragStarted = false;
_scrubLiveValue = double.tryParse(effectiveController.text) ?? 0;
}
void _scrubPointerMove(PointerMoveEvent event) {
if (widget.readOnly) return;
final feature = _scrubFeature;
if (feature == null) return;
if (_scrubDragStarted) {
// with a native cursor lock the cursor is pinned and raw deltas arrive
// via CursorLock.lock's callback instead - Flutters own pointer
// position is meaningless from here on (it'd just be measuring
// distance to the anchor, not real movement). Without one (web, and
// anywhere else with no native handler) nothing is pinned and nothing
// is swallowed, so the pointer stream IS the delta source. The cursor
// just walks off the field as you drag, which is the best thats
// available there.
if (!CursorLock.isSupported) _applyScrubDelta(feature, event.delta.dx);
return;
}
_scrubDistance += event.delta.dx.abs() + event.delta.dy.abs();
if (_scrubDistance < _scrubClickThreshold) return;
_scrubDragStarted = true;
feature.onStart?.call();
CursorLock.lock((dx, dy) => _applyScrubDelta(feature, dx));
}
void _applyScrubDelta(_ScrubFeature feature, double dx) {
final sensitivity = HardwareKeyboard.instance.isShiftPressed
? feature.sensitivity * 0.1
: feature.sensitivity;
_scrubLiveValue += dx * sensitivity;
final text = _scrubLiveValue.toStringAsFixed(feature.decimals);
setState(() => effectiveController.text = text);
feature.onUpdate?.call(text);
}
void _scrubPointerUp(PointerUpEvent event) {
if (widget.readOnly) return;
if (_scrubDragStarted) {
CursorLock.unlock();
_scrubFeature?.onEnd?.call();
_scrubDragStarted = false;
return;
}
// never crossed the movement threshold - a click, not a drag. select-all
// and focus - the field's real EditableText is always in the tree now
// (see [showScrub] below), so the focus node is already attached and
// this takes effect immediately, same as clicking any other field.
effectiveController.selection = TextSelection(
baseOffset: 0,
extentOffset: effectiveController.text.length,
);
_effectiveFocusNode.requestFocus();
}
@override
final GlobalKey<EditableTextState> editableTextKey =
GlobalKey<EditableTextState>();
@override
bool get forcePressEnabled => true;
@override
bool get selectionEnabled =>
widget.enableInteractiveSelection && widget.enabled;
@override
void initState() {
super.initState();
_gestureBuilder = _FieldSelectionGestureDetectorBuilder(state: this);
if (widget.controller == null) {
_localController = widget.initialValue != null
? TextEditingController(text: widget.initialValue)
: TextEditingController();
}
_effectiveFocusNode.canRequestFocus = widget.enabled;
_effectiveFocusNode.addListener(_handleFocusChanged);
}
@override
void didUpdateWidget(TextField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller == null && oldWidget.controller != null) {
_localController = TextEditingController.fromValue(
oldWidget.controller!.value,
);
} else if (widget.controller != null && oldWidget.controller == null) {
_localController?.dispose();
_localController = null;
}
if (widget.focusNode != oldWidget.focusNode) {
(oldWidget.focusNode ?? _localFocus)?.removeListener(_handleFocusChanged);
(widget.focusNode ?? _localFocus)?.addListener(_handleFocusChanged);
}
_effectiveFocusNode.canRequestFocus = widget.enabled;
}
@override
void dispose() {
// dont leave the OS cursor hidden/pinned forever if this field gets torn
// down mid-drag (e.g. its panel closes while youre scrubbing).
if (_scrubDragStarted) CursorLock.unlock();
_effectiveFocusNode.removeListener(_handleFocusChanged);
_localFocus?.dispose();
_localController?.dispose();
super.dispose();
}
EditableTextState get _editableText => editableTextKey.currentState!;
void _requestKeyboard() => _editableText.requestKeyboard();
void _handleFocusChanged() {
setState(() {});
if (!_effectiveFocusNode.hasFocus) {
_formatSubmit();
}
}
// run submitFormatters when the field loses focus / on submit, same as shadcn
void _formatSubmit() {
final formatters = widget.submitFormatters;
if (formatters == null || formatters.isEmpty) return;
try {
var value = effectiveController.value;
for (final formatter in formatters) {
value = formatter.formatEditUpdate(value, value);
}
if (value != effectiveController.value) {
effectiveController.value = value;
widget.onChanged?.call(value.text);
}
} catch (e, st) {
// dont eat it silently, but dont blow up the field either
debugPrint("TextField submitFormatters failed: $e\n$st");
}
}
bool get _hasDecoration =>
widget.placeholder != null || widget.features.isNotEmpty;
TextAlignVertical get _textAlignVertical {
if (widget.textAlignVertical != null) return widget.textAlignVertical!;
return _hasDecoration ? TextAlignVertical.center : TextAlignVertical.top;
}
bool _shouldShowSelectionHandles(SelectionChangedCause? cause) {
if (!_gestureBuilder.shouldShowSelectionToolbar) return false;
if (effectiveController.selection.isCollapsed) return false;
if (cause == SelectionChangedCause.keyboard) return false;
if (effectiveController.text.isNotEmpty) return true;
return false;
}
void _handleSelectionChanged(
TextSelection selection,
SelectionChangedCause? cause,
) {
final willShow = _shouldShowSelectionHandles(cause);
if (willShow != _showSelectionHandles) {
setState(() => _showSelectionHandles = willShow);
}
if (cause == SelectionChangedCause.drag) {
_editableText.hideToolbar();
}
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scheme = theme.colorScheme;
// controlGap, not baseGap - this is the icon-to-label gap inside the field.
final densityGap = theme.density.controlGap;
final textDirection = Directionality.of(context);
// text style: control type -> normal(w400) -> foreground -> user.
//
// typography.small, NOT the ambient DefaultTextStyle. The root UI style is
// 11px with no `height`, so a field built from it got a line box of
// 11 * whatever line-height ratio the font happens to have (~1.25 for
// Geist = ~13.75px) against a button's exact 11px - fields came out ~26px
// tall where every other control is 23, and the only one that looked right
// was the explorer search box because a SizedBox was clamping it.
//
// Note `height` is a multiple of fontSize, not of the font's own metrics,
// so pinning it here makes the control box font-independent - which is also
// what lets the geometry tests mean anything (the test host doesnt resolve
// Geist, so a style without an explicit height measures differently there
// than it does in the app).
// inside a property row the variant isnt the call sites to pick - see
// PropertySlotScope. explicit loses here, deliberately.
final variant = PropertySlotScope.of(context)
? TextFieldVariant.secondary
: widget.variant;
final variantForeground = variant == TextFieldVariant.secondary
? scheme.secondaryForeground
: scheme.foreground;
// plain `small`, which is w400. Controls used to merge typography.medium
// over it and render at w500 - two steps above the w300 body copy they
// sit next to, which read as bold rather than as emphasis. The whole
// control family moved together, so a field still matches the buttons
// and selects beside it.
TextStyle textStyle = theme.typography.small.copyWith(
color: variantForeground,
);
if (widget.style != null) textStyle = textStyle.merge(widget.style);
// one rule for "you cant change this", used by the text AND by the icons
// in the field's feature slots. It was readOnly for the text and nothing
// at all for the icons, so a disabled field dimmed its value and left a
// full-brightness lock or calendar sitting next to it.
if (_dimmed) {
textStyle = textStyle.copyWith(color: scheme.mutedForeground);
}
final cursorColor = widget.cursorColor ?? scheme.primary;
final selectionColor = scheme.primary.withValues(alpha: 0.2);
// a row holding this one can say the value was rejected. It doesnt change
// the border, it fades a destructive one in over the top - see
// FieldErrorOutline for why that isnt the same as swapping the colour.
final invalid = FieldErrorScope.of(context);
final effectiveBorder =
widget.border ??
Border.all(
color: scheme.controlBorder,
width: theme.density.controlBorderWidth,
);
final baseRadius =
widget.borderRadius?.resolve(textDirection) ??
BorderRadius.circular(theme.radiusMd);
final group = ButtonGroupScope.maybeOf(context);
final resolvedRadius = group == null
? baseRadius
: group.corners.applyTo(baseRadius, textDirection);
final focused = _effectiveFocusNode.hasFocus;
final scrubFeature = _scrubFeature;
// while unfocused, drag-to-scrub fields swap the gesture LAYER (pan
// instead of the normal text-selection gestures) so scrubbing doesnt
// fight the EditableText's own drag-to-select - but its still the exact
// same EditableText underneath either way (see `content` below). That's
// deliberate: an entirely separate display widget for the unfocused
// state was what caused the corner-radius/height/offset mismatches -
// two different widgets are never guaranteed to lay out identically no
// matter how carefully their style/padding is matched.
final showScrub = scrubFeature != null && widget.enabled && !focused;
final cursor = !widget.enabled
? SystemMouseCursors.forbidden
: widget.readOnly
? SystemMouseCursors.basic
: showScrub
? SystemMouseCursors.resizeLeftRight
: SystemMouseCursors.text;
final Color fieldColor = switch (variant) {
TextFieldVariant.secondary => scheme.secondary,
TextFieldVariant.outline =>
(widget.filled ?? false)
? scheme.muted
: focused
? scheme.controlFillFocused
: (_hovered && !widget.readOnly)
? scheme.controlFillHovered
: scheme.controlFill,
};
Decoration decoration =
widget.decoration ??
BoxDecoration(
borderRadius: resolvedRadius,
color: fieldColor,
border: effectiveBorder,
);
if (group != null && decoration is BoxDecoration) {
final border = decoration.border;
if (border is Border) {
decoration = decoration.copyWith(
border: group.mergedBorder(border, textDirection),
);
}
}
// (ButtonGroup style-override dropped - nothing in the app stacks fields)
final padding = widget.padding ?? theme.density.textFieldPadding;
final formatters = <TextInputFormatter>[...?widget.inputFormatters];
// container: false on purpose - this annotates the EditableText's own
// node rather than wrapping it in a second one, so the field keeps the
// per-line/selection semantics that text editing needs and just gains a
// name. Merging here would flatten those, which is worse than no label.
final editable = Semantics(
label: resolveSemanticLabel(context, widget.semanticLabel),
enabled: widget.enabled,
// Once the semantics tree is on, flutter web lays a real <input> over
// the field and the click lands on THAT, not on our gesture detector.
// All the browser does then is ask through semantics - and with no
// handler here, nobody answered and the field never took focus.
// Same two handlers material's TextField has.
onTap: widget.readOnly || !widget.enabled
? null
: () {
final c = effectiveController;
if (!c.selection.isValid) {
c.selection = TextSelection.collapsed(offset: c.text.length);
}
_effectiveFocusNode.requestFocus();
_requestKeyboard();
},
onFocus: widget.enabled
? () {
if (!_effectiveFocusNode.hasFocus) {
_effectiveFocusNode.requestFocus();
} else if (!widget.readOnly) {
// the platform thinks were not focused even though we are,
// so the keyboard probly went away. ask for it again
_requestKeyboard();
}
}
: null,
child: RepaintBoundary(
child: EditableText(
key: editableTextKey,
controller: effectiveController,
focusNode: _effectiveFocusNode,
readOnly: widget.readOnly || !widget.enabled,
showSelectionHandles: _showSelectionHandles,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
autofillHints: widget.autofillHints,
style: textStyle,
// while showing the scrub display, this can differ from the normal
// alignment (e.g. centered at rest, left-aligned once editing) -
// still the same EditableText either way, just told to align its
// text differently.
textAlign: showScrub
? (scrubFeature!.textAlign ?? widget.textAlign)
: widget.textAlign,
autofocus: widget.autofocus,
obscuringCharacter: widget.obscuringCharacter,
obscureText: widget.obscureText,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null,
selectionControls: selectionEnabled
? cupertinoDesktopTextSelectionHandleControls
: null,
onChanged: (v) => widget.onChanged?.call(v),
onSelectionChanged: _handleSelectionChanged,
onEditingComplete: () {
widget.onEditingComplete?.call();
_formatSubmit();
},
onSubmitted: (v) {
widget.onSubmitted?.call(v);
_formatSubmit();
},
onTapOutside: widget.onTapOutside,
inputFormatters: formatters,
rendererIgnoresPointer: true,
mouseCursor: cursor,
cursorWidth: widget.cursorWidth,
cursorHeight: widget.cursorHeight,
cursorRadius: widget.cursorRadius,
cursorColor: cursorColor,
cursorOpacityAnimates: true,
paintCursorAboveText: true,
backgroundCursorColor: scheme.border,
selectionHeightStyle: BoxHeightStyle.tight,
selectionWidthStyle: BoxWidthStyle.tight,
scrollPadding: const EdgeInsets.all(20.0),
dragStartBehavior: DragStartBehavior.start,
enableInteractiveSelection: widget.enableInteractiveSelection,
clipBehavior: Clip.none,
),
),
);
// the exact same content either way - only the gesture wrapper around it
// differs below. This is deliberate: keeping ONE render tree for both
// scrub and editing states is what guarantees they can never mismatch
// (radius/height/text-offset) - two visually-matched-by-hand widgets
// never actually guarantee identical layout, as the last few rounds of
// whack-a-mole fixes here demonstrated.
final content = Align(
alignment: Alignment(-1.0, _textAlignVertical.y),
// drag-to-scrub fields fill the full row width instead of shrink-
// wrapping to content, so a centered scrubTextAlign actually centers
// in the box rather than in the text's own tight bounds.
widthFactor: scrubFeature != null ? null : 1.0,
heightFactor: 1.0,
child: Builder(
builder: (context) {
final inset = padding.resolve(textDirection);
// Only the HORIZONTAL inset wraps the whole row. The vertical half
// is pushed inward onto the text alone (see _buildContent), because
// otherwise it stacks on top of whatever a feature brought with it:
// a trailing IconButton is already a full control tall, so the field
// came out at the button's height PLUS its own padding - 23 + 12 =
// 35 against a controlHeight of 23. Sitting inside the padding
// instead, the button measures the same 23 as the padded text and
// the field stops growing.
return Padding(
padding: EdgeInsets.only(left: inset.left, right: inset.right),
child: _buildContent(
editable,
textStyle,
theme,
densityGap,
EdgeInsets.only(top: inset.top, bottom: inset.bottom),
),
);
},
),
);
final fieldContent = showScrub
? Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: _scrubPointerDown,
onPointerMove: _scrubPointerMove,
onPointerUp: _scrubPointerUp,
child: content,
)
: _gestureBuilder.buildGestureDetector(
behavior: HitTestBehavior.translucent,
child: content,
);
// background layer (e.g. InputFeature.fillIndicator) sits behind the
// text but inside the same clipped/rounded border, so it never needs its
// own radius handling.
final backgrounds = <Widget>[];
for (final feature in widget.features) {
backgrounds.addAll(feature.buildBackground(this));
}
Widget field = MouseRegion(
cursor: cursor,
onEnter: (_) {
if (!_hovered) setState(() => _hovered = true);
},
onExit: (_) {
if (_hovered) setState(() => _hovered = false);
},
// no focus ring - the fill colour signals focus instead.
child: IconTheme.merge(
data: IconThemeData(
size: theme.iconTheme.small.size,
color: _dimmed
? scheme.mutedForeground
: variant == TextFieldVariant.secondary
? scheme.secondaryForeground
: scheme.mutedForeground,
),
child: TextFieldTapRegion(
child: IgnorePointer(
ignoring: !widget.enabled,
child: FieldErrorOutline(
invalid: invalid,
borderRadius: resolvedRadius,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: decoration,
// backgrounds must be Positioned, not plain Stack children -
// otherwise theyd count as non-positioned and get pulled into
// the Stack's own size computation, which blows up with an
// infinite-height crash the moment this field sits somewhere
// with unbounded height (e.g. a mainAxisSize.min Column).
child: backgrounds.isEmpty
? fieldContent
: Stack(
children: [
// a full-width background (InputFeature.fillIndicator)
// runs right into the corners, so it needs the fields
// own radius applied to it directly - resolvedRadius,
// not baseRadius, so a field inside a ButtonGroup
// still only rounds the corners the group rounds.
Positioned.fill(
child: ClipRRect(
borderRadius: resolvedRadius,
child: Stack(
children: [
for (final background in backgrounds)
Positioned.fill(child: background),
],
),
),
),
fieldContent,
],
),
),
),
),
),
),
);
// gather prefix/suffix (stepper buttons that hang off the sides)
final prefixes = <Widget>[];
final suffixes = <Widget>[];
for (final feature in widget.features) {
prefixes.addAll(feature.buildPrefix(this));
suffixes.addAll(feature.buildSuffix(this));
}
if (prefixes.isEmpty && suffixes.isEmpty) {
return field;
}
return ButtonGroup.horizontal(
children: [
...prefixes,
Flexible(child: field),
...suffixes,
],
);
}
Widget _buildContent(
Widget editable,
TextStyle textStyle,
ThemeData theme,
double densityGap,
EdgeInsets textInset,
) {
return ValueListenableBuilder<TextEditingValue>(
valueListenable: effectiveController,
child: editable,
builder: (context, value, child) {
final hasText = value.text.isNotEmpty;
final placeholder = widget.placeholder == null
? null
: Visibility(
maintainAnimation: true,
maintainSize: true,
maintainState: true,
visible: !hasText,
child: SizedBox(
width: double.infinity,
child: DefaultTextStyle(
// textStyle already resolved small/normal/user style; just
// recolour it. (re-merging typography.small here clobbered
// any custom fontSize, leaving the placeholder stuck at 14.)
style: textStyle.copyWith(
color: theme.colorScheme.mutedForeground,
),
textAlign: widget.textAlign,
maxLines: widget.maxLines,
child: widget.placeholder!,
),
),
);
final leadingChildren = <Widget>[];
final trailingChildren = <Widget>[];
for (final feature in widget.features) {
leadingChildren.addAll(feature.buildLeading(this));
trailingChildren.addAll(feature.buildTrailing(this));
}
final isMultiline =
widget.expands ||
widget.maxLines == null ||
(widget.maxLines != null && widget.maxLines! > 1) ||
(widget.minLines != null && widget.minLines! > 1);
final placeholderAlignment = AlignmentDirectional(
0.0,
isMultiline ? -1.0 : 0.0,
);
// Feature children - an axis label, a "px" suffix - get dropped into
// this Row raw, so without this they measure against the AMBIENT
// DefaultTextStyle: 11px with no `height`, i.e. whatever line-height
// ratio the font fancies (~1.25 in Geist = ~13.75px). The Row takes
// its tallest child, so one bare Text was quietly making the whole
// field ~26 next to a 23 colour swatch in the same properties row.
// Same style the EditableText below runs on, for the same reason the
// placeholder gets it.
// merge, not replace: typography.small carries no fontFamily, so
// replacing outright would drop the ambient Geist and render the
// labels in the platform default.
// Leading and trailing features stay pinned to their own edges. When
// the text is centred, reserving the same width on the empty side is
// what centres it across the FIELD rather than across the space the
// features left behind - without it, one leading icon throws the text
// off centre by half the icon plus the gap.
//
// A plain spacer, not a copy of the feature: a duplicated widget sits
// in the tree twice, which broke a test looking for a single icon
// inside a field.
final centred = widget.textAlign == TextAlign.center;
final featureSlot = theme.iconTheme.small.size ?? 0;
final mirrorTrailing =
centred && leadingChildren.isNotEmpty && trailingChildren.isEmpty;
final mirrorLeading =
centred && trailingChildren.isNotEmpty && leadingChildren.isEmpty;
return DefaultTextStyle.merge(
style: textStyle,
child: Row(
crossAxisAlignment: widget.crossAxisAlignment,
spacing: densityGap,
children: [
if (mirrorLeading) SizedBox(width: featureSlot),
if (leadingChildren.isNotEmpty)
_featureSlot(
theme,
Row(
mainAxisSize: MainAxisSize.min,
spacing: densityGap,
children: leadingChildren,
),
),
Expanded(
child: Padding(
padding: textInset,
child:
// Padding/border give the control its outer height; the offset
// only nudges the ink so a field label lands on the same baseline
// as a button label. It wraps the whole Stack, never the
// placeholder on its own - otherwise the text would hop the
// moment you type.
_optionallyNudged(
theme.density.controlTextOffset,
Stack(
alignment: placeholderAlignment,
children: [?placeholder, child!],
),
),
),
),
if (trailingChildren.isNotEmpty)
_featureSlot(
theme,
Row(
mainAxisSize: MainAxisSize.min,
spacing: densityGap,
children: trailingChildren,
),
),
if (mirrorTrailing) SizedBox(width: featureSlot),
],
),
);
},
);
}
}
// A feature sits INSIDE the field, so one control tall is its ceiling - it is
// never the thing that decides how tall the field is. Two ways that bit us
// without this: a trailing IconButton measured a couple of px over the text it
// sits next to and dragged the field up with it, and the stepper's AspectRatio
// - which has no natural height of its own - saw the unbounded constraint the
// row now hands down and grew to fill the whole viewport.
Widget _featureSlot(ThemeData theme, Widget child) {
// controlHeight MINUS the border: the stroke is drawn outside this content
// box (which is why textFieldPadding subtracts it too), so capping at the
// full control height would let a feature push the field two px over.
final d = theme.density;
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: d.controlHeight - d.controlBorderWidth * 2,
),
child: child,
);
}
class _FieldSelectionGestureDetectorBuilder
extends TextSelectionGestureDetectorBuilder {
_FieldSelectionGestureDetectorBuilder({required TextFieldState state})
: _state = state,
super(delegate: state);
final TextFieldState _state;
@override
void onSingleTapUp(TapDragUpDetails details) {
super.onSingleTapUp(details);
_state.widget.onTap?.call();
}
@override
void onDragSelectionEnd(TapDragEndDetails details) {
_state._requestKeyboard();
super.onDragSelectionEnd(details);
}
}
// shadcn shipped a few canned TextInputFormatters. the app only uses the math
// expression one (for the numeric fields that accept "12+3*2" style input).
class TextInputFormatters {
TextInputFormatters._();
// allow digits, the basic operators, brackets, a decimal point and spaces.
static TextInputFormatter mathExpression() {
return FilteringTextInputFormatter.allow(RegExp(r"[0-9+\-*/().%\s]"));
}
// resolves a typed expression on commit ("12+4" -> "16") and leaves it alone
// otherwise. for fields that dont already reformat their own text on
// commit - most of them do their own tryParse+reformat and just call
// evaluateMathExpression() directly instead of pulling this in.
static TextInputFormatter mathExpressionEvaluator() {
return _MathExpressionEvaluatorFormatter();
}
}
class _MathExpressionEvaluatorFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text.trim();
// already a plain number - leave it exactly as typed, dont go normalising
// trailing zeroes etc on stuff that was never an expression to begin with
if (double.tryParse(text) != null) return newValue;
final resolved = evaluateMathExpression(text);
if (resolved == null) return newValue;
final resolvedText = _formatResolvedNumber(resolved);
return TextEditingValue(
text: resolvedText,
selection: TextSelection.collapsed(offset: resolvedText.length),
);
}
}
String _formatResolvedNumber(double value) {
if (value == value.roundToDouble() && value.abs() < 1e15) {
return value.toInt().toString();
}
var text = value.toStringAsFixed(4);
while (text.endsWith("0")) {
text = text.substring(0, text.length - 1);
}
if (text.endsWith(".")) text = text.substring(0, text.length - 1);
return text;
}
/// Parses a plain number OR a small arithmetic expression - digits, `+ - * /`,
/// parens, unary minus. Mirrors double.tryParse: null on anything it cant
/// make sense of, so callers that already do `double.tryParse(text) ?? ...`
/// can just swap the function and keep everything else (clamping, reverting
/// on null, whatever) unchanged.
///
/// Deliberately doesnt support `%` - shows up in the char allow-list above
/// but its ambiguous here (modulo? percent-of-what?) so its just left
/// unsupported for now rather than guessed at.
double? evaluateMathExpression(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return null;
// fast path - the overwhelming majority of commits are a plain number,
// no need to fire up the parser for those
final direct = double.tryParse(trimmed);
if (direct != null) return direct;
try {
final parser = _ExprParser(trimmed);
final value = parser._parseExpression();
parser._skipSpaces();
if (!parser._atEnd) return null; // trailing junk after a valid expr
if (!value.isFinite) return null; // e.g. divide by zero
return value;
} on FormatException {
return null;
}
}
// tiny recursive-descent parser, no point pulling in a whole expression
// package for +-*/() with one level of precedence
class _ExprParser {
_ExprParser(this._src);
final String _src;
int _pos = 0;
bool get _atEnd => _pos >= _src.length;
void _skipSpaces() {
while (!_atEnd && _src[_pos] == " ") {
_pos++;
}
}
String? _peek() => _atEnd ? null : _src[_pos];
double _parseExpression() {
_skipSpaces();
var value = _parseTerm();
_skipSpaces();
while (_peek() == "+" || _peek() == "-") {
final op = _src[_pos++];
_skipSpaces();
final rhs = _parseTerm();
value = op == "+" ? value + rhs : value - rhs;
_skipSpaces();
}
return value;
}
double _parseTerm() {
var value = _parseUnary();
_skipSpaces();
while (_peek() == "*" || _peek() == "/") {
final op = _src[_pos++];
_skipSpaces();
final rhs = _parseUnary();
value = op == "*" ? value * rhs : value / rhs;
_skipSpaces();
}
return value;
}
double _parseUnary() {
_skipSpaces();
if (_peek() == "-") {
_pos++;
return -_parseUnary();
}
if (_peek() == "+") {
_pos++;
return _parseUnary();
}
return _parsePrimary();
}
double _parsePrimary() {
_skipSpaces();
if (_peek() == "(") {
_pos++;
final value = _parseExpression();
_skipSpaces();
if (_peek() != ")") throw const FormatException("expected )");
_pos++;
return value;
}
final start = _pos;
while (!_atEnd && (_isDigit(_src[_pos]) || _src[_pos] == ".")) {
_pos++;
}
if (_pos == start) throw const FormatException("expected a number");
final value = double.tryParse(_src.substring(start, _pos));
if (value == null) throw const FormatException("bad number literal");
return value;
}
bool _isDigit(String ch) {
final code = ch.codeUnitAt(0);
return code >= 48 && code <= 57;
}
}