// 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? 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 buildPrefix(TextFieldState state) sync* {} Iterable buildSuffix(TextFieldState state) sync* {} Iterable buildLeading(TextFieldState state) sync* {} Iterable buildTrailing(TextFieldState state) sync* {} Iterable 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 buildSuffix(TextFieldState state) sync* { if (position == InputFeaturePosition.trailing) yield _button(state); } @override Iterable 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? 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( 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( 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 buildTrailing(TextFieldState state) sync* { if (position == InputFeaturePosition.trailing) yield _button(state); } @override Iterable 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 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? 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( 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 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( 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? 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 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 buildLeading(TextFieldState state) sync* { yield child; } } class _TrailingFeature extends InputFeature { final Widget child; const _TrailingFeature(this.child); @override Iterable 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? autofillHints; final List? inputFormatters; final List? submitFormatters; final ValueChanged? onChanged; final ValueChanged? onSubmitted; final VoidCallback? onEditingComplete; final GestureTapCallback? onTap; final TapRegionCallback? onTapOutside; final List 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 createState() => TextFieldState(); } class TextFieldState extends State 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 editableTextKey = GlobalKey(); @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 = [...?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 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 = []; 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 = []; final suffixes = []; 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( 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 = []; final trailingChildren = []; 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; } }