From be7bf84e9f3fbc63f5dfdeeca42b2fd07adb9fef Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 28 Sep 2026 12:10:15 +0300 Subject: [PATCH 1/6] feat(perf): count W-widget builds, wrapper emissions and inherited reads; profile-safe resolvers --- CHANGELOG.md | 5 + doc/core-concepts/debugging.md | 21 ++-- lib/src/utils/wind_perf_counters.dart | 64 ++++++++++ lib/src/widgets/w_anchor.dart | 11 ++ lib/src/widgets/w_badge.dart | 3 + lib/src/widgets/w_breakpoint.dart | 2 + lib/src/widgets/w_button.dart | 6 + lib/src/widgets/w_card.dart | 3 + lib/src/widgets/w_checkbox.dart | 5 + lib/src/widgets/w_date_picker.dart | 3 + lib/src/widgets/w_div.dart | 93 ++++++++++---- lib/src/widgets/w_form_checkbox.dart | 3 + lib/src/widgets/w_form_date_picker.dart | 3 + lib/src/widgets/w_form_input.dart | 3 + lib/src/widgets/w_form_select.dart | 3 + lib/src/widgets/w_icon.dart | 7 ++ lib/src/widgets/w_image.dart | 8 ++ lib/src/widgets/w_input.dart | 26 +++- lib/src/widgets/w_keyboard_actions.dart | 2 + lib/src/widgets/w_popover.dart | 2 + lib/src/widgets/w_radio.dart | 5 + lib/src/widgets/w_select.dart | 3 + lib/src/widgets/w_spacer.dart | 4 + lib/src/widgets/w_svg.dart | 6 + lib/src/widgets/w_switch.dart | 5 + lib/src/widgets/w_tabs.dart | 3 + lib/src/widgets/w_text.dart | 26 ++-- lib/src/wind_facade.dart | 16 ++- skills/wind-ui/SKILL.md | 4 +- skills/wind-ui/references/debug.md | 2 +- test/utils/wind_perf_counters_test.dart | 155 +++++++++++++++++++++++- test/wind_facade_test.dart | 6 +- 32 files changed, 457 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a1d588a..7b959d94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- **`WindPerfCounters` gains `widgetBuilds`, `wrapperEmissions` and `inheritedReads`.** The existing six counters (`cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds`) answered "how much did the style cache do" and "how many `WDiv`/`WText` builds ran", but nothing counted every OTHER W-widget's builds, the Flutter (or Wind-authored render-layer) wrapper widgets Wind inserts around content (`Container`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), or the four inherited-widget reads on the hot path (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`). `widgetBuilds` and `wrapperEmissions` are `Map` keyed by type name; `inheritedReads` is fixed to the four keys above. Every increment is one inline call next to the emission or the read it counts, behind the existing `enabled` flag, so a disabled counter still costs one bool check and nothing is added, removed or reordered in the widget tree. `Wind.installPerfResolver()` publishes all nine keys through `WindDebugRegistry.currentPerf?.stats()`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/wind_facade.dart`, `lib/src/widgets/*.dart`, `test/utils/wind_perf_counters_test.dart`, `doc/core-concepts/debugging.md`, `skills/wind-ui/`) +- **`Wind.installDebugResolver()` / `Wind.installPerfResolver()` widen from `kDebugMode` to `!kReleaseMode`.** A profile build (`flutter run --profile`) now carries both resolvers, so a performance measurement session can run against a build that is actually representative of production timing; release still tree-shakes both. (`lib/src/wind_facade.dart`, `test/wind_facade_test.dart`) + --- ## [1.7.0] - 2026-09-27 diff --git a/doc/core-concepts/debugging.md b/doc/core-concepts/debugging.md index 7a23883f..110c56b7 100644 --- a/doc/core-concepts/debugging.md +++ b/doc/core-concepts/debugging.md @@ -156,14 +156,14 @@ If neither applies, you can skip this step. Wind itself does not depend on the r ### Wiring it up -Call `Wind.installDebugResolver()` once during app startup, inside a `kDebugMode` guard. The call is idempotent (safe to call multiple times) and is automatically a no-op in release builds, so the bridge is tree-shaken out of production binaries. +Call `Wind.installDebugResolver()` once during app startup, inside a `!kReleaseMode` guard (so a profile build still carries it). The call is idempotent (safe to call multiple times) and is automatically a no-op in release builds, so the bridge is tree-shaken out of production binaries. ```dart import 'package:flutter/foundation.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; void main() { - if (kDebugMode) { + if (!kReleaseMode) { Wind.installDebugResolver(); } runApp(const MyApp()); @@ -190,7 +190,7 @@ Fields whose value is null are omitted; the map only carries what the widget act ### Release-build safety -`Wind.installDebugResolver()` is a no-op in release builds. The `kDebugMode` guard in user code further ensures dart2js / dart2native tree-shakes the entire branch on every platform. There is no production cost from leaving the call in place. +`Wind.installDebugResolver()` and `Wind.installPerfResolver()` are no-ops in release builds (`!kReleaseMode`, not `kDebugMode`, so a profile build still carries them for a profiling session). The guard in user code further ensures dart2js / dart2native tree-shakes the entire branch in release. There is no production cost from leaving the call in place. ## Aggregate Performance Counters @@ -218,23 +218,28 @@ Counting is off by default and every increment sits behind one static bool check ```dart WindPerfCounters.enabled = true; // ... drive the interaction you want to measure ... -print(WindPerfCounters.cacheHits); // and cacheMisses, cacheBypasses -print(WindPerfCounters.wDivBuilds); // and wTextBuilds +print(WindPerfCounters.cacheHits); // and cacheMisses, cacheBypasses +print(WindPerfCounters.wDivBuilds); // and wTextBuilds +print(WindPerfCounters.widgetBuilds); // {'WButton': 3, 'WDiv': 12, ...} +print(WindPerfCounters.wrapperEmissions); // {'Container': 5, 'MouseRegion': 2, ...} +print(WindPerfCounters.inheritedReads); // mediaQuerySize, mediaQueryBrightness, windTheme, defaultTextStyle ``` +`widgetBuilds` counts every W-widget with a build method, by its type name (`'WButton'`, `'WDiv'`, ...); `wDivBuilds`/`wTextBuilds` stay as dedicated counters alongside it rather than being folded in, since they are the pinned six-key contract's own fields. `wrapperEmissions` counts the Flutter (or Wind-authored render-layer) wrapper widget Wind inserts at each composition-pipeline branch (`Container`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), one inline call beside the construction it never changes. `inheritedReads` is fixed to four keys: `mediaQuerySize` (a `MediaQuery.of(context).size` read), `mediaQueryBrightness` (`MediaQuery.maybePlatformBrightnessOf`), `windTheme` (`WindTheme.maybeDataOf`), `defaultTextStyle` (`DefaultTextStyle.of`). + `WindParser.clearCache()` resets the counters as well as the cache, so a hit rate is always reported against the cache it was measured on. That is also why a theme change mid-measurement zeroes them. ### Reading them from outside -`Wind.installPerfResolver()` publishes the totals through the diagnostics contracts package, the same bridge `installDebugResolver()` uses and a separate slot on the same registry. It is idempotent, `kDebugMode`-gated, and installing it costs nothing on its own since counting stays off until the flag is set: +`Wind.installPerfResolver()` publishes the totals through the diagnostics contracts package, the same bridge `installDebugResolver()` uses and a separate slot on the same registry. It is idempotent, `!kReleaseMode`-gated (so a profile build still carries it), and installing it costs nothing on its own since counting stays off until the flag is set: ```dart -if (kDebugMode) { +if (!kReleaseMode) { Wind.installDebugResolver(); // per-Element state Wind.installPerfResolver(); // aggregate counters } ``` -A tool then reads `WindDebugRegistry.currentPerf?.stats()` and gets exactly six `int` keys: `cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds`. The `null` when no resolver is registered is meaningful and worth passing through rather than flattening to zeros: it is what lets a caller tell "wind never installed one" from "the counters really are zero". +A tool then reads `WindDebugRegistry.currentPerf?.stats()` and gets exactly nine keys: `cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds` (all `int`), plus `widgetBuilds`, `wrapperEmissions`, `inheritedReads` (all `Map`). The `null` when no resolver is registered is meaningful and worth passing through rather than flattening to zeros: it is what lets a caller tell "wind never installed one" from "the counters really are zero". `fluttersdk_dusk` reads this during a `dusk:perf_begin` / `dusk:perf_end` session. diff --git a/lib/src/utils/wind_perf_counters.dart b/lib/src/utils/wind_perf_counters.dart index c7a03a9f..53b2b0d4 100644 --- a/lib/src/utils/wind_perf_counters.dart +++ b/lib/src/utils/wind_perf_counters.dart @@ -35,6 +35,25 @@ class WindPerfCounters { static int _wDivBuilds = 0; static int _wTextBuilds = 0; + /// Per-type build counts for every W-widget with a build method (e.g. + /// `'WButton'`), keyed by [Widget.runtimeType] as printed at the call site, + /// not via reflection. + static final Map _widgetBuilds = {}; + + /// Per-type emission counts for the Flutter (or Wind-authored render-layer) + /// wrapper widgets Wind inserts around content (`'Container'`, `'Padding'`, + /// `'MouseRegion'`, ...). + static final Map _wrapperEmissions = {}; + + /// Inherited-widget read counts, fixed to four keys: `mediaQuerySize`, + /// `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`. + static final Map _inheritedReads = { + 'mediaQuerySize': 0, + 'mediaQueryBrightness': 0, + 'windTheme': 0, + 'defaultTextStyle': 0, + }; + /// Parses served from the style cache. static int get cacheHits => _cacheHits; @@ -50,6 +69,20 @@ class WindPerfCounters { /// `WText` builds that reached the style-resolution step. static int get wTextBuilds => _wTextBuilds; + /// Per-W-widget-type build counts. Read-only snapshot; mutate only through + /// [recordWidgetBuild]. + static Map get widgetBuilds => Map.unmodifiable(_widgetBuilds); + + /// Per-wrapper-type emission counts. Read-only snapshot; mutate only + /// through [recordWrapperEmission]. + static Map get wrapperEmissions => + Map.unmodifiable(_wrapperEmissions); + + /// Inherited-widget read counts. Read-only snapshot; mutate only through + /// [recordInheritedRead]. + static Map get inheritedReads => + Map.unmodifiable(_inheritedReads); + /// Records a style served from the cache. @internal static void recordCacheHit() { @@ -85,6 +118,34 @@ class WindPerfCounters { _wTextBuilds++; } + /// Records one build of the W-widget named [typeName] (e.g. `'WButton'`). + /// + /// Additive to [recordWDivBuild] / [recordWTextBuild]: those two dedicated + /// counters stay for backward compatibility with the pinned six-key + /// contract, and this call sits next to them rather than replacing them. + @internal + static void recordWidgetBuild(String typeName) { + if (!enabled) return; + _widgetBuilds[typeName] = (_widgetBuilds[typeName] ?? 0) + 1; + } + + /// Records one emission of the wrapper widget named [typeName] (e.g. + /// `'Container'`, `'MouseRegion'`). + @internal + static void recordWrapperEmission(String typeName) { + if (!enabled) return; + _wrapperEmissions[typeName] = (_wrapperEmissions[typeName] ?? 0) + 1; + } + + /// Records one read of the inherited widget named [key], one of + /// `'mediaQuerySize'`, `'mediaQueryBrightness'`, `'windTheme'`, + /// `'defaultTextStyle'`. + @internal + static void recordInheritedRead(String key) { + if (!enabled) return; + _inheritedReads[key] = (_inheritedReads[key] ?? 0) + 1; + } + /// Zeroes every counter, leaving [enabled] alone. /// /// Public, unlike the recorders: a measurement session opens by zeroing the @@ -102,5 +163,8 @@ class WindPerfCounters { _cacheBypasses = 0; _wDivBuilds = 0; _wTextBuilds = 0; + _widgetBuilds.clear(); + _wrapperEmissions.clear(); + _inheritedReads.updateAll((_, __) => 0); } } diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index 55371b21..32e3cca6 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../state/wind_anchor_state.dart'; import '../state/wind_anchor_state_provider.dart'; +import '../utils/wind_perf_counters.dart'; /// **The Foundational State Wrapper** /// @@ -229,6 +230,8 @@ class _WAnchorState extends State { /// `GestureDetector` for tap events, disabling them if `widget.isDisabled` is true. @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WAnchor'); + final hasGestures = widget.onTap != null || widget.onLongPress != null || widget.onDoubleTap != null; @@ -283,6 +286,7 @@ class _WAnchorState extends State { // and the ring was on the second one while the gesture was on the first. // The node itself stays, because `FocusNode.hasFocus` covers descendants // and that is what draws the ring around a `WInput` inside a styled div. + WindPerfCounters.recordWrapperEmission('Focus'); Widget innerChild = Focus( focusNode: _focusNode, canRequestFocus: !widget.isDisabled && (hasGestures || inherited == null), @@ -300,11 +304,13 @@ class _WAnchorState extends State { // `PrioritizedIntents([ActivateIntent, ScrollIntent])`, and an // always-enabled action wins that race. if (widget.onTap != null && !widget.isDisabled) { + WindPerfCounters.recordWrapperEmission('Actions'); innerChild = Actions(actions: _actions, child: innerChild); } // Only wrap with GestureDetector if there are actual gesture callbacks if (hasGestures) { + WindPerfCounters.recordWrapperEmission('GestureDetector'); innerChild = GestureDetector( // Translucent so the whole anchor bounds are tappable, not only the // opaque descendants. The GestureDetector defaults to @@ -331,6 +337,8 @@ class _WAnchorState extends State { // the structural problem of WAnchor not knowing its eventual textual // content when callers (e.g. WButton) interpose a Builder between // WAnchor and its leaf widgets. + WindPerfCounters.recordWrapperEmission('WindAnchorStateProvider'); + WindPerfCounters.recordWrapperEmission('MouseRegion'); Widget result = WindAnchorStateProvider( state: currentState, child: MouseRegion( @@ -356,6 +364,7 @@ class _WAnchorState extends State { // still trigger them. `onDoubleTap` has no SemanticsAction equivalent // and is intentionally not exposed here. if (widget.semanticLabel != null) { + WindPerfCounters.recordWrapperEmission('Semantics'); return Semantics( button: true, enabled: !widget.isDisabled, @@ -400,6 +409,8 @@ class _WAnchorState extends State { // 3. Gestures, no explicit label: keep the MergeSemantics path so the // descendant Text/WText nodes collapse into this node and supply the // name. + WindPerfCounters.recordWrapperEmission('MergeSemantics'); + WindPerfCounters.recordWrapperEmission('Semantics'); return MergeSemantics( child: Semantics( button: true, diff --git a/lib/src/widgets/w_badge.dart b/lib/src/widgets/w_badge.dart index e8109c82..c9e94de0 100644 --- a/lib/src/widgets/w_badge.dart +++ b/lib/src/widgets/w_badge.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_div.dart'; import 'w_text.dart'; @@ -66,6 +67,8 @@ class WBadge extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WBadge'); + // 1. Resolve styles for debug logging only (WDiv re-parses internally). final String composedClassName = className != null ? '$_baseClasses $className' : _baseClasses; diff --git a/lib/src/widgets/w_breakpoint.dart b/lib/src/widgets/w_breakpoint.dart index bae59448..306e4bd4 100644 --- a/lib/src/widgets/w_breakpoint.dart +++ b/lib/src/widgets/w_breakpoint.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../parser/wind_context.dart'; +import '../utils/wind_perf_counters.dart'; /// **Declarative Breakpoint-Keyed Builder** /// @@ -89,6 +90,7 @@ class WBreakpoint extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WBreakpoint'); final windContext = WindContext.build(context); return _resolve(windContext)(context); } diff --git a/lib/src/widgets/w_button.dart b/lib/src/widgets/w_button.dart index c6fb0571..08a97ce7 100644 --- a/lib/src/widgets/w_button.dart +++ b/lib/src/widgets/w_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; import '../state/wind_anchor_state_provider.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_anchor.dart'; import 'w_div.dart'; import 'w_text.dart'; @@ -134,6 +135,8 @@ class WButton extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WButton'); + // Determine if button is interactive final bool isInteractive = !isLoading && !disabled; @@ -181,6 +184,8 @@ class WButton extends StatelessWidget { } // Wrap in styled container + WindPerfCounters.recordWrapperEmission('Container'); + WindPerfCounters.recordWrapperEmission('DefaultTextStyle'); Widget styledButton = Container( width: styles.widthFactor != null ? double.infinity : styles.width, height: @@ -197,6 +202,7 @@ class WButton extends StatelessWidget { // Wrap with AbsorbPointer when not interactive if (!isInteractive) { + WindPerfCounters.recordWrapperEmission('AbsorbPointer'); styledButton = AbsorbPointer(child: styledButton); } diff --git a/lib/src/widgets/w_card.dart b/lib/src/widgets/w_card.dart index 87604e05..cd0dfe9c 100644 --- a/lib/src/widgets/w_card.dart +++ b/lib/src/widgets/w_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_div.dart'; /// **A Surface Container for Card-Style Layouts** @@ -76,6 +77,8 @@ class WCard extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WCard'); + // 1. Resolve the effective className for the root container. final String effectiveClassName = className ?? _defaultClassName; diff --git a/lib/src/widgets/w_checkbox.dart b/lib/src/widgets/w_checkbox.dart index 19280779..fb2bf1ac 100644 --- a/lib/src/widgets/w_checkbox.dart +++ b/lib/src/widgets/w_checkbox.dart @@ -4,6 +4,7 @@ import 'w_anchor.dart'; import 'w_div.dart'; import 'w_icon.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; @@ -91,6 +92,8 @@ class WCheckbox extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WCheckbox'); + // A null onChanged means the checkbox is non-interactive: treat it exactly // like disabled == true so the disabled: prefix activates, no gesture is // attached, and Semantics reports the control as not enabled. WRadio and @@ -129,6 +132,8 @@ class WCheckbox extends StatelessWidget { // row is a visible label, not a semantic one: the two stay separate nodes. // Callers who want one named node wrap the pair in `MergeSemantics`, which // folds this node into it and carries the label plus the checked state. + WindPerfCounters.recordWrapperEmission('Semantics'); + WindPerfCounters.recordWrapperEmission('MergeSemantics'); return Semantics( container: true, checked: value, diff --git a/lib/src/widgets/w_date_picker.dart b/lib/src/widgets/w_date_picker.dart index 318bdb44..dca9f6fa 100644 --- a/lib/src/widgets/w_date_picker.dart +++ b/lib/src/widgets/w_date_picker.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_div.dart'; import 'w_icon.dart'; import 'w_popover.dart'; @@ -531,6 +532,8 @@ class _WDatePickerState extends State { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WDatePicker'); + final Set activeStates = { ...widget.states, if (widget.disabled) 'disabled', diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 0d5ed42e..40475220 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -160,6 +160,7 @@ class WDiv extends StatelessWidget { // WAnchor whose Builder calls this method, so this is the one place every // WDiv style resolution passes through exactly once. WindPerfCounters.recordWDivBuild(); + WindPerfCounters.recordWidgetBuild('WDiv'); // 1. RESOLVE STYLES (The Logic Layer) // Fetch state from WindAnchorStateProvider (if present) @@ -244,6 +245,7 @@ class WDiv extends StatelessWidget { hasTextOverflow || hasMaxLines) { logger.wrapWith("DefaultTextStyle.merge", "style: $textStyle"); + WindPerfCounters.recordWrapperEmission('DefaultTextStyle'); finalWidget = DefaultTextStyle.merge( style: textStyle, @@ -259,6 +261,7 @@ class WDiv extends StatelessWidget { // Wrap with AspectRatio widget if aspectRatio is set. if (styles.aspectRatio != null) { logger.wrapWith("AspectRatio", "aspectRatio: ${styles.aspectRatio}"); + WindPerfCounters.recordWrapperEmission('AspectRatio'); finalWidget = AspectRatio( aspectRatio: styles.aspectRatio!, child: finalWidget, @@ -270,6 +273,7 @@ class WDiv extends StatelessWidget { if (styles.opacity != null) { if (styles.transitionDuration != null) { logger.wrapWith("AnimatedOpacity", "opacity: ${styles.opacity}"); + WindPerfCounters.recordWrapperEmission('AnimatedOpacity'); finalWidget = AnimatedOpacity( duration: styles.transitionDuration!, curve: styles.transitionCurve ?? Curves.linear, @@ -278,6 +282,7 @@ class WDiv extends StatelessWidget { ); } else { logger.wrapWith("Opacity", "opacity: ${styles.opacity}"); + WindPerfCounters.recordWrapperEmission('Opacity'); finalWidget = Opacity(opacity: styles.opacity!, child: finalWidget); } } @@ -291,6 +296,7 @@ class WDiv extends StatelessWidget { // so its cursor wins for the area it covers. if (styles.mouseCursor != null) { logger.wrapWith("MouseRegion", "cursor: ${styles.mouseCursor}"); + WindPerfCounters.recordWrapperEmission('MouseRegion'); finalWidget = MouseRegion( cursor: styles.mouseCursor!, child: finalWidget, @@ -302,6 +308,7 @@ class WDiv extends StatelessWidget { if (styles.animationType != null && styles.animationType != WindAnimationType.none) { logger.wrapWith("Animation", "type: ${styles.animationType}"); + WindPerfCounters.recordWrapperEmission('WindAnimationWrapper'); finalWidget = wrapWithAnimation( child: finalWidget, animationType: styles.animationType, @@ -1288,9 +1295,13 @@ class WDiv extends StatelessWidget { builder: (context, constraints) { // Calculate item width based on available space and columns final totalGapWidth = gapX * (cols - 1); - final availableWidth = constraints.maxWidth.isFinite - ? constraints.maxWidth - : MediaQuery.of(context).size.width; + final double availableWidth; + if (constraints.maxWidth.isFinite) { + availableWidth = constraints.maxWidth; + } else { + availableWidth = MediaQuery.of(context).size.width; + WindPerfCounters.recordInheritedRead('mediaQuerySize'); + } final itemWidth = (availableWidth - totalGapWidth) / cols; return Wrap( @@ -1558,25 +1569,34 @@ class WDiv extends StatelessWidget { final Curve curve = styles.transitionCurve ?? Curves.linear; Widget clipped = widgetToBuild; if (containerAlignment != null) { - clipped = duration == null - ? Align(alignment: containerAlignment, child: clipped) - : AnimatedAlign( - alignment: containerAlignment, - duration: duration, - curve: curve, - child: clipped, - ); + if (duration == null) { + WindPerfCounters.recordWrapperEmission('Align'); + clipped = Align(alignment: containerAlignment, child: clipped); + } else { + WindPerfCounters.recordWrapperEmission('AnimatedAlign'); + clipped = AnimatedAlign( + alignment: containerAlignment, + duration: duration, + curve: curve, + child: clipped, + ); + } } if (containerPadding != null) { - clipped = duration == null - ? Padding(padding: containerPadding, child: clipped) - : AnimatedPadding( - padding: containerPadding, - duration: duration, - curve: curve, - child: clipped, - ); + if (duration == null) { + WindPerfCounters.recordWrapperEmission('Padding'); + clipped = Padding(padding: containerPadding, child: clipped); + } else { + WindPerfCounters.recordWrapperEmission('AnimatedPadding'); + clipped = AnimatedPadding( + padding: containerPadding, + duration: duration, + curve: curve, + child: clipped, + ); + } } + WindPerfCounters.recordWrapperEmission('ClipRRect'); widgetToBuild = ClipRRect( borderRadius: paddingBoxRadius, // A border as wide as the corner leaves a square padding box, and @@ -1599,6 +1619,7 @@ class WDiv extends StatelessWidget { "AnimatedContainer", "duration: ${styles.transitionDuration!.inMilliseconds}ms", ); + WindPerfCounters.recordWrapperEmission('AnimatedContainer'); widgetToBuild = AnimatedContainer( duration: styles.transitionDuration!, curve: styles.transitionCurve ?? Curves.linear, @@ -1612,6 +1633,7 @@ class WDiv extends StatelessWidget { ); } else { logger.wrapWith("Container", "decoration/constraints/shadow"); + WindPerfCounters.recordWrapperEmission('Container'); widgetToBuild = Container( width: wantFullWidth ? double.infinity : null, height: wantFullHeight ? double.infinity : null, @@ -1632,6 +1654,7 @@ class WDiv extends StatelessWidget { "SizedBox", "width: ${styles.width}, height: ${styles.height}", ); + WindPerfCounters.recordWrapperEmission('SizedBox'); widgetToBuild = SizedBox( width: styles.width, height: styles.height, @@ -1652,6 +1675,7 @@ class WDiv extends StatelessWidget { "SingleChildScrollView", "horizontal${scrollPrimary ? ', primary' : ''}", ); + WindPerfCounters.recordWrapperEmission('SingleChildScrollView'); final port = WindViewportWidthPort(); widgetToBuild = WindViewportWidthProvider( port: port, @@ -1673,6 +1697,7 @@ class WDiv extends StatelessWidget { "SingleChildScrollView", "vertical${scrollPrimary ? ', primary' : ''}", ); + WindPerfCounters.recordWrapperEmission('SingleChildScrollView'); widgetToBuild = SingleChildScrollView( scrollDirection: Axis.vertical, primary: scrollPrimary, @@ -1688,6 +1713,10 @@ class WDiv extends StatelessWidget { "SingleChildScrollView", "both (nested)${scrollPrimary ? ', primary on outer' : ''}", ); + // Two SingleChildScrollView instances are constructed below (outer + // vertical, inner horizontal); both count. + WindPerfCounters.recordWrapperEmission('SingleChildScrollView'); + WindPerfCounters.recordWrapperEmission('SingleChildScrollView'); final port = WindViewportWidthPort(); widgetToBuild = WindViewportWidthProvider( port: port, @@ -1708,6 +1737,7 @@ class WDiv extends StatelessWidget { } } else if (hasOverflowClip && !clippedInsideBorder) { logger.wrapWith("ClipRRect", "overflow-hidden"); + WindPerfCounters.recordWrapperEmission('ClipRRect'); // Use ClipRRect to clip content that overflows // This respects the container's border radius if present final borderRadius = styles.decoration?.borderRadius; @@ -1742,6 +1772,7 @@ class WDiv extends StatelessWidget { // Apply outer sizing for overflow cases if (outerConstraints != null) { logger.wrapWith("SizedBox", "outer sizing for overflow"); + WindPerfCounters.recordWrapperEmission('SizedBox'); widgetToBuild = SizedBox( width: outerConstraints.maxWidth.isFinite ? outerConstraints.maxWidth @@ -1808,6 +1839,7 @@ class WDiv extends StatelessWidget { // SizedBox(width: infinity) would assert on the scroll's unbounded // width; WindMinWidthBox reads the threaded viewport width instead. logger.wrapWith("WindMinWidthBox", "w-full in horizontal scroll"); + WindPerfCounters.recordWrapperEmission('WindMinWidthBox'); widgetToBuild = WindMinWidthBox( port: scrollScope!.horizontalPort!, floorMinWidth: styles.constraints?.minWidth ?? 0, @@ -1816,6 +1848,7 @@ class WDiv extends StatelessWidget { } else if (isFullWidth) { // w-full: expand to fill available width logger.wrapWith("SizedBox", "w-full (no LayoutBuilder)"); + WindPerfCounters.recordWrapperEmission('SizedBox'); widgetToBuild = SizedBox( width: double.infinity, child: innerChild, @@ -1823,6 +1856,7 @@ class WDiv extends StatelessWidget { } else { // w-1/2, w-1/3, etc: use FractionallySizedBox (render-layer, no LayoutBuilder) logger.wrapWith("FractionallySizedBox", "w-fraction"); + WindPerfCounters.recordWrapperEmission('FractionallySizedBox'); widgetToBuild = FractionallySizedBox( widthFactor: styles.widthFactor, child: innerChild, @@ -1832,6 +1866,7 @@ class WDiv extends StatelessWidget { // Apply max constraints via ConstrainedBox if present if (hasMaxWidthConstraint || hasMaxHeightConstraint) { logger.wrapWith("ConstrainedBox", "max constraints"); + WindPerfCounters.recordWrapperEmission('ConstrainedBox'); widgetToBuild = ConstrainedBox( constraints: BoxConstraints( maxWidth: hasMaxWidthConstraint @@ -1856,9 +1891,12 @@ class WDiv extends StatelessWidget { // height bounded"), and asking it with a LayoutBuilder defers this // whole subtree into a second layout pass. A consumer measured 1056 // of them in one eight-scroll session against 258 widget builds. + final double fallbackHeight = MediaQuery.of(context).size.height; + WindPerfCounters.recordInheritedRead('mediaQuerySize'); logger.wrapWith("WindFullHeightBox", "h-full"); + WindPerfCounters.recordWrapperEmission('WindFullHeightBox'); widgetToBuild = WindFullHeightBox( - fallbackHeight: MediaQuery.of(context).size.height, + fallbackHeight: fallbackHeight, maxHeight: hasMaxHeightConstraint ? styles.constraints!.maxHeight : null, child: innerChild, @@ -1866,11 +1904,13 @@ class WDiv extends StatelessWidget { } else { // h-1/2, h-1/3, etc: FractionallySizedBox (no LayoutBuilder) logger.wrapWith("FractionallySizedBox", "h-fraction"); + WindPerfCounters.recordWrapperEmission('FractionallySizedBox'); widgetToBuild = FractionallySizedBox( heightFactor: styles.heightFactor, child: innerChild, ); if (hasMaxHeightConstraint) { + WindPerfCounters.recordWrapperEmission('ConstrainedBox'); widgetToBuild = ConstrainedBox( constraints: BoxConstraints( maxHeight: styles.constraints!.maxHeight, @@ -1886,9 +1926,12 @@ class WDiv extends StatelessWidget { if (isFullHeight) { // Both axes, height full: the same render-layer box as the // height-only path, carrying the width factor too. + final double fallbackHeight = MediaQuery.of(context).size.height; + WindPerfCounters.recordInheritedRead('mediaQuerySize'); logger.wrapWith("WindFullHeightBox", "w+h-full"); + WindPerfCounters.recordWrapperEmission('WindFullHeightBox'); widgetToBuild = WindFullHeightBox( - fallbackHeight: MediaQuery.of(context).size.height, + fallbackHeight: fallbackHeight, widthFactor: styles.widthFactor, maxWidth: hasMaxWidthConstraint ? styles.constraints!.maxWidth : null, @@ -1899,12 +1942,14 @@ class WDiv extends StatelessWidget { } else { // Both fractional, neither h-full: FractionallySizedBox handles it logger.wrapWith("FractionallySizedBox", "w+h fraction"); + WindPerfCounters.recordWrapperEmission('FractionallySizedBox'); widgetToBuild = FractionallySizedBox( widthFactor: styles.widthFactor, heightFactor: styles.heightFactor, child: innerChild, ); if (hasMaxWidthConstraint || hasMaxHeightConstraint) { + WindPerfCounters.recordWrapperEmission('ConstrainedBox'); widgetToBuild = ConstrainedBox( constraints: BoxConstraints( maxWidth: hasMaxWidthConstraint @@ -1926,6 +1971,7 @@ class WDiv extends StatelessWidget { styles.padding != null && styles.padding != EdgeInsets.zero) { logger.wrapWith("Padding", "${styles.padding}"); + WindPerfCounters.recordWrapperEmission('Padding'); widgetToBuild = Padding(padding: styles.padding!, child: widgetToBuild); } @@ -1933,6 +1979,7 @@ class WDiv extends StatelessWidget { // Note: Margin wraps the element, but is inside layout wrappers like Expanded. if (styles.margin != null && styles.margin != EdgeInsets.zero) { logger.wrapWith("Padding (Margin)", "${styles.margin}"); + WindPerfCounters.recordWrapperEmission('Padding'); widgetToBuild = Padding(padding: styles.margin!, child: widgetToBuild); } @@ -1943,12 +1990,14 @@ class WDiv extends StatelessWidget { // Apply Alignment (align-self-*) if (styles.alignment != null) { logger.wrapWith("Align", "${styles.alignment}"); + WindPerfCounters.recordWrapperEmission('Align'); widgetToBuild = Align(alignment: styles.alignment!, child: widgetToBuild); } // Apply mx-auto (horizontal centering like Tailwind) if (styles.marginXAuto) { logger.wrapWith("Align", "horizontal center (mx-auto)"); + WindPerfCounters.recordWrapperEmission('Align'); widgetToBuild = Align( alignment: Alignment.topCenter, child: widgetToBuild, @@ -1967,12 +2016,14 @@ class WDiv extends StatelessWidget { if (!skipFlexWrap) { if (styles.flex != null) { logger.wrapWith("Expanded", "flex: ${styles.flex}"); + WindPerfCounters.recordWrapperEmission('Expanded'); widgetToBuild = Expanded( flex: styles.flex!, child: widgetToBuild ?? const SizedBox.shrink(), ); } else if (styles.flexFit != null) { logger.wrapWith("Flexible", "fit: ${styles.flexFit}"); + WindPerfCounters.recordWrapperEmission('Flexible'); widgetToBuild = Flexible( fit: styles.flexFit!, child: widgetToBuild ?? const SizedBox.shrink(), diff --git a/lib/src/widgets/w_form_checkbox.dart b/lib/src/widgets/w_form_checkbox.dart index 4fb2fae4..2acd2d3f 100644 --- a/lib/src/widgets/w_form_checkbox.dart +++ b/lib/src/widgets/w_form_checkbox.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_anchor.dart'; import 'w_checkbox.dart'; import 'w_text.dart'; @@ -184,6 +185,8 @@ class _WFormCheckboxContent extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WFormCheckbox'); + // Build states set including error state when validation fails final Set effectiveStates = { ...?states, diff --git a/lib/src/widgets/w_form_date_picker.dart b/lib/src/widgets/w_form_date_picker.dart index d87e79ac..f741b2a0 100644 --- a/lib/src/widgets/w_form_date_picker.dart +++ b/lib/src/widgets/w_form_date_picker.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_date_picker.dart'; import 'w_div.dart'; import 'w_text.dart'; @@ -248,6 +249,8 @@ class _WFormDatePickerContentState extends State<_WFormDatePickerContent> { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WFormDatePicker'); + final Set effectiveStates = { ...?widget.states, if (widget.state.hasError) 'error', diff --git a/lib/src/widgets/w_form_input.dart b/lib/src/widgets/w_form_input.dart index 528ad29d..b06e8efd 100644 --- a/lib/src/widgets/w_form_input.dart +++ b/lib/src/widgets/w_form_input.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_input.dart'; import 'w_text.dart'; import 'w_div.dart'; @@ -424,6 +425,8 @@ class _WFormInputContentState extends State<_WFormInputContent> { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WFormInput'); + // Build states set including error state when validation fails final Set effectiveStates = { ...?widget.states, diff --git a/lib/src/widgets/w_form_select.dart b/lib/src/widgets/w_form_select.dart index 29b88aec..86a3e863 100644 --- a/lib/src/widgets/w_form_select.dart +++ b/lib/src/widgets/w_form_select.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../utils/wind_perf_counters.dart'; import 'select_option.dart'; import 'w_select.dart'; import 'w_div.dart'; @@ -269,6 +270,7 @@ class _WFormSelectContent extends StatelessWidget { if (state.hasError) 'error', }; + WindPerfCounters.recordWidgetBuild('WFormSelect'); final select = WSelect( value: state.value, options: options, @@ -603,6 +605,7 @@ class _WFormMultiSelectContent extends StatelessWidget { if (state.hasError) 'error', }; + WindPerfCounters.recordWidgetBuild('WFormMultiSelect'); final select = WSelect( isMulti: true, values: state.value, diff --git a/lib/src/widgets/w_icon.dart b/lib/src/widgets/w_icon.dart index 7cc416d6..fbf34433 100644 --- a/lib/src/widgets/w_icon.dart +++ b/lib/src/widgets/w_icon.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; import 'wind_animation_wrapper.dart'; /// **The Utility-First Icon Component** @@ -76,8 +77,11 @@ class WIcon extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WIcon'); + // Get inherited text style from parent (e.g., from WDiv's DefaultTextStyle) final TextStyle inheritedStyle = DefaultTextStyle.of(context).style; + WindPerfCounters.recordInheritedRead('defaultTextStyle'); final double? inheritedSize = inheritedStyle.fontSize; final Color? inheritedColor = inheritedStyle.color; @@ -117,6 +121,7 @@ class WIcon extends StatelessWidget { // Apply opacity if specified (animated when duration is set) if (styles.opacity != null) { if (styles.transitionDuration != null) { + WindPerfCounters.recordWrapperEmission('AnimatedOpacity'); iconWidget = AnimatedOpacity( duration: styles.transitionDuration!, curve: styles.transitionCurve ?? Curves.linear, @@ -124,6 +129,7 @@ class WIcon extends StatelessWidget { child: iconWidget, ); } else { + WindPerfCounters.recordWrapperEmission('Opacity'); iconWidget = Opacity(opacity: styles.opacity!, child: iconWidget); } } @@ -131,6 +137,7 @@ class WIcon extends StatelessWidget { // Apply animation if specified if (styles.animationType != null && styles.animationType != WindAnimationType.none) { + WindPerfCounters.recordWrapperEmission('WindAnimationWrapper'); iconWidget = wrapWithAnimation( child: iconWidget, animationType: styles.animationType, diff --git a/lib/src/widgets/w_image.dart b/lib/src/widgets/w_image.dart index be8bfa16..481f43e7 100644 --- a/lib/src/widgets/w_image.dart +++ b/lib/src/widgets/w_image.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; /// Signature for building a custom error widget. typedef ImageErrorBuilder = Widget Function( @@ -105,6 +106,8 @@ class WImage extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WImage'); + // Parse styles from className final WindStyle styles = className != null ? WindParser.parse(className!, context, states: states) @@ -147,11 +150,13 @@ class WImage extends StatelessWidget { (styles.decoration?.borderRadius as BorderRadius?) ?? BorderRadius.zero; if (styles.decoration != null && borderRadius != BorderRadius.zero) { + WindPerfCounters.recordWrapperEmission('ClipRRect'); imageWidget = ClipRRect(borderRadius: borderRadius, child: imageWidget); } // Apply border if (styles.decoration != null) { + WindPerfCounters.recordWrapperEmission('Container'); imageWidget = Container( decoration: styles.decoration, child: imageWidget, @@ -160,6 +165,7 @@ class WImage extends StatelessWidget { // Apply aspect ratio (must be inside sized container) if (styles.aspectRatio != null) { + WindPerfCounters.recordWrapperEmission('AspectRatio'); imageWidget = AspectRatio( aspectRatio: styles.aspectRatio!, child: imageWidget, @@ -168,6 +174,7 @@ class WImage extends StatelessWidget { // Apply sizing (outermost wrapper to provide constraints) if (styles.width != null || styles.height != null) { + WindPerfCounters.recordWrapperEmission('SizedBox'); imageWidget = SizedBox( width: styles.width, height: styles.height, @@ -177,6 +184,7 @@ class WImage extends StatelessWidget { // Apply opacity if (styles.opacity != null) { + WindPerfCounters.recordWrapperEmission('Opacity'); imageWidget = Opacity(opacity: styles.opacity!, child: imageWidget); } diff --git a/lib/src/widgets/w_input.dart b/lib/src/widgets/w_input.dart index dcc31599..6dcd1754 100644 --- a/lib/src/widgets/w_input.dart +++ b/lib/src/widgets/w_input.dart @@ -9,6 +9,7 @@ import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; import '../theme/wind_theme.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_keyboard_actions.dart'; /// Input type enum for WInput widget @@ -627,6 +628,8 @@ class _WInputState extends State @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WInput'); + // Read once, here, rather than from inside the `_scrollPadding` getter. // Recording it there made a getter that MUTATES: any second read, a debug // print or a future caller, silently rearmed `_onCaretMoved`'s difference @@ -676,9 +679,16 @@ class _WInputState extends State // Prefer Wind's effective brightness (the source `dark:` classes resolve // against) so the default color matches the rendered background; fall back // to the platform brightness only when no WindTheme is present. - final Brightness brightness = WindTheme.maybeDataOf(context)?.brightness ?? - MediaQuery.maybePlatformBrightnessOf(context) ?? - Brightness.light; + final Brightness? windBrightness = + WindTheme.maybeDataOf(context)?.brightness; + WindPerfCounters.recordInheritedRead('windTheme'); + Brightness? platformBrightness; + if (windBrightness == null) { + platformBrightness = MediaQuery.maybePlatformBrightnessOf(context); + WindPerfCounters.recordInheritedRead('mediaQueryBrightness'); + } + final Brightness brightness = + windBrightness ?? platformBrightness ?? Brightness.light; final Color baselineColor = brightness == Brightness.dark ? const Color(0xFFFFFFFF) : const Color(0xFF000000); @@ -851,6 +861,7 @@ class _WInputState extends State // Wrap in the decorated box (className → BoxDecoration, same path WDiv // uses), recomputing on focus so the ring/border reacts to `_isFocused`. + WindPerfCounters.recordWrapperEmission('DecoratedBox'); Widget result = DecoratedBox( decoration: _buildDecoration(styles), child: field, @@ -866,6 +877,9 @@ class _WInputState extends State // builder's own `onSingleTapUp`/`requestKeyboard`; `onUserTap` only fires // `widget.onTap` (see the builder subclass below). if (widget.enabled) { + // The concrete wrapper widget here is a framework-internal type + // returned by TextSelectionGestureDetectorBuilder.buildGestureDetector, + // not a stable public class Wind can name; left uncounted. result = _selectionGestureDetectorBuilder.buildGestureDetector( behavior: HitTestBehavior.translucent, child: result, @@ -874,6 +888,7 @@ class _WInputState extends State // Disabled: swallow all pointer events so the field cannot be tapped, // focused, or have its cursor placed, honoring the enabled: false // contract (the EditableText otherwise still reacts to taps on glyphs). + WindPerfCounters.recordWrapperEmission('IgnorePointer'); result = IgnorePointer(child: result); } @@ -888,6 +903,7 @@ class _WInputState extends State // (a disabled field is read-only), so without this a screen reader could // not distinguish "disabled" from "read-only". The Material TextField in // 1.0.0 exposed this flag; the Material-free rewrite must keep parity. + WindPerfCounters.recordWrapperEmission('Semantics'); result = Semantics( enabled: widget.enabled, label: widget.semanticLabel ?? widget.placeholder, @@ -911,6 +927,7 @@ class _WInputState extends State if (styles.widthFactor == 1.0) width = double.infinity; if (styles.heightFactor == 1.0) height = double.infinity; + WindPerfCounters.recordWrapperEmission('Container'); result = Container( margin: styles.margin, width: width, @@ -923,8 +940,10 @@ class _WInputState extends State // Apply Flexible/Expanded wrapper if flex-auto or flex-1 is present // This allows WInput to properly expand in flex containers if (styles.flex != null) { + WindPerfCounters.recordWrapperEmission('Expanded'); result = Expanded(flex: styles.flex!, child: result); } else if (styles.flexFit != null) { + WindPerfCounters.recordWrapperEmission('Flexible'); result = Flexible(fit: styles.flexFit!, child: result); } @@ -932,6 +951,7 @@ class _WInputState extends State // ancestor supplies one, so bare usages outside MaterialApp/WidgetsApp do // not throw "No Directionality widget found" (mirrors WText:228-236). if (Directionality.maybeOf(context) == null) { + WindPerfCounters.recordWrapperEmission('Directionality'); result = Directionality(textDirection: TextDirection.ltr, child: result); } diff --git a/lib/src/widgets/w_keyboard_actions.dart b/lib/src/widgets/w_keyboard_actions.dart index 3e32b9a2..c0e53ed6 100644 --- a/lib/src/widgets/w_keyboard_actions.dart +++ b/lib/src/widgets/w_keyboard_actions.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import '../parser/wind_parser.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_keyboard_platform.dart'; /// A Wind-styled wrapper that adds keyboard actions (Done button, navigation) @@ -444,6 +445,7 @@ class _WKeyboardActionsState extends State { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WKeyboardActions'); return WKeyboardToolbarInset( height: _toolbarHeight, child: widget.child, diff --git a/lib/src/widgets/w_popover.dart b/lib/src/widgets/w_popover.dart index 3ac71052..752ad29c 100644 --- a/lib/src/widgets/w_popover.dart +++ b/lib/src/widgets/w_popover.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../parser/wind_parser.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_div.dart'; /// **Popover Alignment Options** @@ -621,6 +622,7 @@ class _WPopoverState extends State { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WPopover'); return OverlayPortal( controller: _overlayController, overlayChildBuilder: _buildOverlay, diff --git a/lib/src/widgets/w_radio.dart b/lib/src/widgets/w_radio.dart index b97223c9..0933ebec 100644 --- a/lib/src/widgets/w_radio.dart +++ b/lib/src/widgets/w_radio.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'w_anchor.dart'; import 'w_div.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; @@ -103,6 +104,8 @@ class WRadio extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WRadio'); + // A null onChanged means the radio is non-interactive: treat it exactly // like disabled == true so the disabled: prefix activates, no gesture is // attached, and Semantics reports the control as not enabled. @@ -164,6 +167,8 @@ class WRadio extends StatelessWidget { // // `inMutuallyExclusiveGroup: true` surfaces the radio group relationship // in the accessibility tree alongside the checked state. + WindPerfCounters.recordWrapperEmission('Semantics'); + WindPerfCounters.recordWrapperEmission('MergeSemantics'); return Semantics( container: true, checked: _isSelected, diff --git a/lib/src/widgets/w_select.dart b/lib/src/widgets/w_select.dart index 971d26bd..086a8494 100644 --- a/lib/src/widgets/w_select.dart +++ b/lib/src/widgets/w_select.dart @@ -10,6 +10,7 @@ import 'w_input.dart'; import 'w_text.dart'; import '../utils/wind_extensions.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; /// Signature for building a custom trigger widget for [WSelect]. /// @@ -611,6 +612,8 @@ class _WSelectState extends State> { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WSelect'); + // Accessibility: surface the closed trigger as a `button` SemanticsNode // labelled with the placeholder (or the selected option label as // fallback) so Playwright `getByRole('button', { name: ... })` resolves diff --git a/lib/src/widgets/w_spacer.dart b/lib/src/widgets/w_spacer.dart index 8377cf76..78e79270 100644 --- a/lib/src/widgets/w_spacer.dart +++ b/lib/src/widgets/w_spacer.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; +import '../utils/wind_perf_counters.dart'; /// **WSpacer - Lightweight Spacing Widget** /// @@ -75,6 +76,9 @@ class WSpacer extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WSpacer'); + WindPerfCounters.recordWrapperEmission('SizedBox'); + // Early return for no className - render empty SizedBox if (className == null || className!.trim().isEmpty) { return const SizedBox(); diff --git a/lib/src/widgets/w_svg.dart b/lib/src/widgets/w_svg.dart index 125fee49..99a35ea3 100644 --- a/lib/src/widgets/w_svg.dart +++ b/lib/src/widgets/w_svg.dart @@ -4,6 +4,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; /// **The Utility-First SVG Component** /// @@ -78,8 +79,11 @@ class WSvg extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WSvg'); + // Get inherited text style from parent (like WIcon) final TextStyle inheritedStyle = DefaultTextStyle.of(context).style; + WindPerfCounters.recordInheritedRead('defaultTextStyle'); final double? inheritedSize = inheritedStyle.fontSize; final Color? inheritedColor = inheritedStyle.color; @@ -134,6 +138,7 @@ class WSvg extends StatelessWidget { // Apply opacity if specified (animated when duration is set) if (styles.opacity != null) { if (styles.transitionDuration != null) { + WindPerfCounters.recordWrapperEmission('AnimatedOpacity'); svgWidget = AnimatedOpacity( duration: styles.transitionDuration!, curve: styles.transitionCurve ?? Curves.linear, @@ -141,6 +146,7 @@ class WSvg extends StatelessWidget { child: svgWidget, ); } else { + WindPerfCounters.recordWrapperEmission('Opacity'); svgWidget = Opacity(opacity: styles.opacity!, child: svgWidget); } } diff --git a/lib/src/widgets/w_switch.dart b/lib/src/widgets/w_switch.dart index 65d99bff..69e10efb 100644 --- a/lib/src/widgets/w_switch.dart +++ b/lib/src/widgets/w_switch.dart @@ -5,6 +5,7 @@ import 'w_div.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; /// **A Utility-First Toggle Switch Component** /// @@ -108,6 +109,8 @@ class WSwitch extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WSwitch'); + // A null onChanged means the switch is non-interactive: treat it exactly // like disabled == true so the disabled: prefix activates, no gesture is // attached, and Semantics reports the control as not enabled. @@ -147,6 +150,8 @@ class WSwitch extends StatelessWidget { // 3. Wrap with Semantics to surface toggle role + state for accessibility // and Playwright `getByRole('switch')` resolution. + WindPerfCounters.recordWrapperEmission('Semantics'); + WindPerfCounters.recordWrapperEmission('MergeSemantics'); return Semantics( container: true, toggled: value, diff --git a/lib/src/widgets/w_tabs.dart b/lib/src/widgets/w_tabs.dart index 4785e25b..f0c9d13d 100644 --- a/lib/src/widgets/w_tabs.dart +++ b/lib/src/widgets/w_tabs.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import '../parser/wind_parser.dart'; import '../parser/wind_style.dart'; import '../utils/wind_logger.dart'; +import '../utils/wind_perf_counters.dart'; import 'w_anchor.dart'; import 'w_div.dart'; import 'w_text.dart'; @@ -125,6 +126,8 @@ class WTabs extends StatelessWidget { @override Widget build(BuildContext context) { + WindPerfCounters.recordWidgetBuild('WTabs'); + // 1. Parse panel className for debug: delegates the tab-level parse to each tab builder. final WindStyle panelStyles = panelClassName != null ? WindParser.parse(panelClassName!, context) diff --git a/lib/src/widgets/w_text.dart b/lib/src/widgets/w_text.dart index 937dc367..dbc58de1 100644 --- a/lib/src/widgets/w_text.dart +++ b/lib/src/widgets/w_text.dart @@ -100,6 +100,7 @@ class WText extends StatelessWidget { // WText has no _buildImpl indirection: build() resolves styles directly, // so this is its equivalent of WDiv's counting site. WindPerfCounters.recordWTextBuild(); + WindPerfCounters.recordWidgetBuild('WText'); // 1. CALL THE "ORCHESTRATOR": Style Resolution final WindStyle styles = className != null @@ -197,13 +198,17 @@ class WText extends StatelessWidget { // ancestor, which would otherwise render Flutter's debug yellow // underline) fall back to a brightness-aware neutral so the text stays // legible. Explicitly supplied colors always win. - if (finalTextStyle.color == null && - DefaultTextStyle.of(context).style.color == null) { - final isDark = - MediaQuery.maybePlatformBrightnessOf(context) == Brightness.dark; - finalTextStyle = finalTextStyle.copyWith( - color: isDark ? Colors.white : Colors.black, - ); + if (finalTextStyle.color == null) { + final Color? ancestorColor = DefaultTextStyle.of(context).style.color; + WindPerfCounters.recordInheritedRead('defaultTextStyle'); + if (ancestorColor == null) { + final isDark = + MediaQuery.maybePlatformBrightnessOf(context) == Brightness.dark; + WindPerfCounters.recordInheritedRead('mediaQueryBrightness'); + finalTextStyle = finalTextStyle.copyWith( + color: isDark ? Colors.white : Colors.black, + ); + } } // B. Apply Text Transformation (uppercase, lowercase) @@ -244,6 +249,7 @@ class WText extends StatelessWidget { // no ancestor supplies one, so bare usages outside MaterialApp/WidgetsApp // do not throw "No Directionality widget found". if (Directionality.maybeOf(context) == null) { + WindPerfCounters.recordWrapperEmission('Directionality'); return Directionality( textDirection: TextDirection.ltr, child: textWidget, @@ -286,6 +292,7 @@ class WText extends StatelessWidget { : styles.constraints; logger.wrapWith("Container", "decoration: ${styles.decoration}"); + WindPerfCounters.recordWrapperEmission('Container'); widgetToBuild = Container( decoration: styles.decoration, constraints: constraints, @@ -296,18 +303,21 @@ class WText extends StatelessWidget { // Step B: Padding if (styles.padding != null && styles.padding != EdgeInsets.zero) { logger.wrapWith("Padding", "padding: ${styles.padding}"); + WindPerfCounters.recordWrapperEmission('Padding'); widgetToBuild = Padding(padding: styles.padding!, child: widgetToBuild); } // Step C: Margin if (styles.margin != null && styles.margin != EdgeInsets.zero) { logger.wrapWith("Padding (Margin)", "padding: ${styles.margin}"); + WindPerfCounters.recordWrapperEmission('Padding'); widgetToBuild = Padding(padding: styles.margin!, child: widgetToBuild); } // Step D: Alignment (align-self) if (styles.alignment != null) { logger.wrapWith("Align", "alignment: ${styles.alignment}"); + WindPerfCounters.recordWrapperEmission('Align'); widgetToBuild = Align(alignment: styles.alignment!, child: widgetToBuild); } @@ -315,9 +325,11 @@ class WText extends StatelessWidget { if (!skipFlexWrap) { if (styles.flex != null) { logger.wrapWith("Expanded", "flex: ${styles.flex}"); + WindPerfCounters.recordWrapperEmission('Expanded'); widgetToBuild = Expanded(flex: styles.flex!, child: widgetToBuild); } else if (styles.flexFit != null) { logger.wrapWith("Flexible", "fit: ${styles.flexFit}"); + WindPerfCounters.recordWrapperEmission('Flexible'); widgetToBuild = Flexible(fit: styles.flexFit!, child: widgetToBuild); } } diff --git a/lib/src/wind_facade.dart b/lib/src/wind_facade.dart index 134cbde4..e3c40de1 100644 --- a/lib/src/wind_facade.dart +++ b/lib/src/wind_facade.dart @@ -24,6 +24,9 @@ class WindPerfResolverImpl implements WindPerfResolver { 'cacheSize': WindParser.cacheSize, 'wDivBuilds': WindPerfCounters.wDivBuilds, 'wTextBuilds': WindPerfCounters.wTextBuilds, + 'widgetBuilds': WindPerfCounters.widgetBuilds, + 'wrapperEmissions': WindPerfCounters.wrapperEmissions, + 'inheritedReads': WindPerfCounters.inheritedReads, }; } @@ -31,7 +34,7 @@ class WindPerfResolverImpl implements WindPerfResolver { /// /// Consumer host integration in lib/main.dart: /// ```dart -/// if (kDebugMode) { +/// if (!kReleaseMode) { /// Wind.installDebugResolver(); /// Wind.installPerfResolver(); /// } @@ -44,15 +47,18 @@ class Wind { /// Installs the Wind diagnostics resolver into the global /// fluttersdk_wind_diagnostics_contracts registry. Idempotent. No-op in - /// release builds (kDebugMode gate). + /// release builds (`!kReleaseMode` gate, so a profile build still carries + /// it for a profiling session; release tree-shakes it). static void installDebugResolver() { - if (!kDebugMode || _installed) return; + if (kReleaseMode || _installed) return; _installed = true; WindDebugRegistry.register(const WindDebugResolverImpl()); } /// Installs the Wind performance resolver into the perf slot of the same - /// registry. Idempotent. No-op in release builds (kDebugMode gate). + /// registry. Idempotent. No-op in release builds (`!kReleaseMode` gate, so + /// a profile build still carries it for a profiling session; release + /// tree-shakes it). /// /// Separate from [installDebugResolver] because the two answer different /// questions and a host may want one without the other: the debug resolver @@ -60,7 +66,7 @@ class Wind { /// process-wide counters. Installing it costs nothing on its own, since /// counting stays off until [WindPerfCounters.enabled] is set. static void installPerfResolver() { - if (!kDebugMode || _perfInstalled) return; + if (kReleaseMode || _perfInstalled) return; _perfInstalled = true; WindDebugRegistry.registerPerf(const WindPerfResolverImpl()); } diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 3ab87e8e..5618a3ea 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,10 +2,10 @@ name: wind-ui description: "fluttersdk_wind 1.7: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside." when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists." -version: 2.20.0 +version: 2.20.1 --- - + # Wind UI 1.7 diff --git a/skills/wind-ui/references/debug.md b/skills/wind-ui/references/debug.md index 19ea8093..9f1da5cc 100644 --- a/skills/wind-ui/references/debug.md +++ b/skills/wind-ui/references/debug.md @@ -235,4 +235,4 @@ A bypass is a property of a caller writing `style:`, NOT of using `WDiv` / `WTex `WindParser.clearCache()` resets the counters too, which is why a theme change mid-measurement zeroes them. -`Wind.installPerfResolver()` publishes six `int` keys through `WindDebugRegistry.currentPerf?.stats()`: `cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds`. A `null` there means no resolver was installed, which is deliberately distinguishable from "the counters are zero". +`Wind.installPerfResolver()` publishes nine keys through `WindDebugRegistry.currentPerf?.stats()`: the six `int` counters above plus three `Map` counters — `widgetBuilds` (every W-widget with a build method, by type name: `'WButton'`, `'WDiv'`, ...), `wrapperEmissions` (the Flutter or Wind-authored render-layer wrapper widget inserted at each composition-pipeline branch: `'Container'`, `'Padding'`, `'MouseRegion'`, `'Semantics'`, `'DefaultTextStyle'`, ...), `inheritedReads` (fixed to `mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`). A `null` there means no resolver was installed, which is deliberately distinguishable from "the counters are zero". Both `installDebugResolver()` and `installPerfResolver()` gate on `!kReleaseMode`, not `kDebugMode`, so a profile build still carries them. diff --git a/test/utils/wind_perf_counters_test.dart b/test/utils/wind_perf_counters_test.dart index 7ebb9d5e..a226d2a6 100644 --- a/test/utils/wind_perf_counters_test.dart +++ b/test/utils/wind_perf_counters_test.dart @@ -81,6 +81,12 @@ void main() { expect(WindPerfCounters.cacheHits, 0); expect(WindPerfCounters.cacheMisses, 0); expect(WindPerfCounters.cacheBypasses, 0); + expect(WindPerfCounters.widgetBuilds, isEmpty); + expect(WindPerfCounters.wrapperEmissions, isEmpty); + expect( + WindPerfCounters.inheritedReads.values.every((int v) => v == 0), + isTrue, + ); }); test('reset() zeroes the counters and leaves the session enabled', () { @@ -90,6 +96,9 @@ void main() { WindPerfCounters.recordCacheBypass(); WindPerfCounters.recordWDivBuild(); WindPerfCounters.recordWTextBuild(); + WindPerfCounters.recordWidgetBuild('WButton'); + WindPerfCounters.recordWrapperEmission('Container'); + WindPerfCounters.recordInheritedRead('windTheme'); WindPerfCounters.reset(); @@ -98,11 +107,139 @@ void main() { expect(WindPerfCounters.cacheBypasses, 0); expect(WindPerfCounters.wDivBuilds, 0); expect(WindPerfCounters.wTextBuilds, 0); + expect(WindPerfCounters.widgetBuilds, isEmpty); + expect(WindPerfCounters.wrapperEmissions, isEmpty); + // The inherited-reads map keeps its four fixed keys, all zeroed, rather + // than being cleared: the resolver's `stats()` output always carries + // the same four keys whether or not a session has run yet. + expect( + WindPerfCounters.inheritedReads, + { + 'mediaQuerySize': 0, + 'mediaQueryBrightness': 0, + 'windTheme': 0, + 'defaultTextStyle': 0, + }, + ); // A measurement session outlives a cache clear: clearCache() calls // reset(), and turning the flag off there would end the session that // asked for the numbers. expect(WindPerfCounters.enabled, isTrue); }); + + testWidgets( + 'widgetBuilds and wrapperEmissions count a pumped WButton', + (tester) async { + WindPerfCounters.enabled = true; + + await tester.pumpWidget( + wrapWithTheme( + WButton( + onTap: () {}, + className: 'bg-blue-600', + child: const WText('x'), + ), + ), + ); + + // w_button.dart:184 (Container) and w_anchor.dart:336 (MouseRegion, + // unconditional) are the two cited emission sites. + expect(WindPerfCounters.widgetBuilds['WButton'], 1); + expect( + WindPerfCounters.wrapperEmissions['Container'], + greaterThanOrEqualTo(1), + ); + expect( + WindPerfCounters.wrapperEmissions['MouseRegion'], + greaterThanOrEqualTo(1), + ); + }, + ); + + testWidgets( + 'inheritedReads counts windTheme/mediaQueryBrightness on a pumped WInput', + (tester) async { + WindPerfCounters.enabled = true; + + await tester.pumpWidget(wrapWithTheme(const WInput())); + + // w_input.dart:679-681: WindTheme.maybeDataOf is always read; the + // ambient WindTheme supplies a brightness, so the MediaQuery fallback + // is not reached in this fixture (a WindTheme ancestor is present). + expect( + WindPerfCounters.inheritedReads['windTheme'], + greaterThanOrEqualTo(1), + ); + }, + ); + + testWidgets( + 'inheritedReads counts defaultTextStyle/mediaQueryBrightness on a bare WText', + (tester) async { + WindPerfCounters.enabled = true; + + // No Material ancestor and no explicit text color, so WText's + // fallback (w_text.dart:200-207) reads both. Mirrors the "bare + // context" fixture in test/widgets/w_text/baseline_test.dart. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: WindThemeData(), + child: const WText('Latency'), + ), + ), + ); + + expect( + WindPerfCounters.inheritedReads['defaultTextStyle'], + greaterThanOrEqualTo(1), + ); + expect( + WindPerfCounters.inheritedReads['mediaQueryBrightness'], + greaterThanOrEqualTo(1), + ); + }, + ); + + testWidgets( + 'counting changes nothing emitted: identical find.byType inventory on and off', + (tester) async { + // A representative WDiv className set exercising the Container, + // Padding, Align, Expanded and DefaultTextStyle.merge branches of the + // composition pipeline (w_div.dart), so a counting call that ever + // added, removed or reordered a wrapper would show up as a differing + // widget-type inventory below. + Widget buildTree() => wrapWithTheme( + WDiv( + className: 'flex flex-col p-4 gap-2 items-center', + children: const [ + WDiv( + className: + 'bg-blue-500 rounded-lg shadow-md m-2 flex-1 text-white', + child: WText('Row 1', className: 'text-sm font-bold'), + ), + WDiv( + className: 'w-1/2 self-center opacity-75', + child: WText('Row 2'), + ), + ], + ), + ); + + WindPerfCounters.enabled = false; + await tester.pumpWidget(buildTree()); + final List typesOff = + tester.allWidgets.map((Widget w) => w.runtimeType).toList(); + + WindPerfCounters.enabled = true; + await tester.pumpWidget(buildTree()); + final List typesOn = + tester.allWidgets.map((Widget w) => w.runtimeType).toList(); + + expect(typesOn, typesOff); + }, + ); }); group('Wind.installPerfResolver()', () { @@ -134,7 +271,9 @@ void main() { expect(WindDebugRegistry.currentPerf, isA()); }); - testWidgets('stats() reports exactly the six pinned keys', (tester) async { + testWidgets('stats() reports exactly the nine pinned keys', ( + tester, + ) async { WindPerfCounters.enabled = true; await tester.pumpWidget( wrapWithTheme( @@ -159,6 +298,9 @@ void main() { 'cacheSize', 'wDivBuilds', 'wTextBuilds', + 'widgetBuilds', + 'wrapperEmissions', + 'inheritedReads', ], ); expect(stats['wDivBuilds'], 1); @@ -169,6 +311,17 @@ void main() { // that is worth asserting rather than assuming. expect(stats['cacheBypasses'], 0); expect(stats['cacheMisses'], greaterThanOrEqualTo(2)); + expect((stats['widgetBuilds'] as Map)['WDiv'], 1); + expect((stats['widgetBuilds'] as Map)['WText'], 1); + expect( + (stats['inheritedReads'] as Map).keys.toSet(), + { + 'mediaQuerySize', + 'mediaQueryBrightness', + 'windTheme', + 'defaultTextStyle', + }, + ); }); testWidgets('a widget given an explicit style bypasses the cache', ( diff --git a/test/wind_facade_test.dart b/test/wind_facade_test.dart index 2fb55d04..e8dfe305 100644 --- a/test/wind_facade_test.dart +++ b/test/wind_facade_test.dart @@ -5,9 +5,9 @@ import 'package:fluttersdk_wind_diagnostics_contracts/fluttersdk_wind_diagnostic /// Tests for `Wind` facade — install flag management and registry integration. /// -/// `kDebugMode` is always `true` under `flutter test`, so the `!kDebugMode` -/// branch in `installDebugResolver()` is unreachable from this suite and -/// intentionally left uncovered. +/// `kReleaseMode` is always `false` under `flutter test`, so the +/// `kReleaseMode` early-return branch in `installDebugResolver()` is +/// unreachable from this suite and intentionally left uncovered. void main() { setUp(() { Wind.resetForTesting(); From 72546cefa4cd65d1b0d96707af2ced4e0fe8fb3d Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 28 Sep 2026 20:16:21 +0300 Subject: [PATCH 2/6] fix(perf): count WindContext's MediaQuery and theme reads; typed inherited-read keys --- CHANGELOG.md | 6 ++++++ lib/src/parser/wind_context.dart | 3 +++ lib/src/utils/wind_perf_counters.dart | 26 +++++++++++++++++++++---- lib/src/widgets/w_div.dart | 9 ++++++--- lib/src/widgets/w_icon.dart | 2 +- lib/src/widgets/w_input.dart | 6 ++++-- lib/src/widgets/w_svg.dart | 2 +- lib/src/widgets/w_text.dart | 6 ++++-- test/utils/wind_perf_counters_test.dart | 24 ++++++++++++++++++++++- 9 files changed, 70 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b959d94..78381fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,14 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed + +- **`WindPerfCounters.inheritedReads` now counts the `windTheme` and `mediaQuerySize` reads `WindContext.build` makes on every `WindParser.parse`, not only the ones WInput and the `h-full` paths already recorded.** `WindContext.build` (`lib/src/parser/wind_context.dart:70-71`) calls `WindTheme.dataOf(context)` and `MediaQuery.of(context).size` on every W-widget build, but before this fix only `WInput`'s brightness fallback and `WDiv`'s `h-full` render-layer box called `recordInheritedRead`, so a plain `WDiv`/`WText` build under-reported both keys. (`lib/src/parser/wind_context.dart`, `test/utils/wind_perf_counters_test.dart`) + ### Added +- **`WindPerfCounters.recordInheritedRead` takes a `WindInheritedRead` enum value instead of a free string.** The four keys (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`) were previously any `String`, so a typo at a call site would silently sit outside the pinned four and never show up in `stats()`. The stats map keys are unchanged: the enum serializes through `.name`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/widgets/w_text.dart`, `lib/src/widgets/w_icon.dart`, `lib/src/widgets/w_svg.dart`, `lib/src/widgets/w_input.dart`, `lib/src/widgets/w_div.dart`, `lib/src/parser/wind_context.dart`, `test/utils/wind_perf_counters_test.dart`) + - **`WindPerfCounters` gains `widgetBuilds`, `wrapperEmissions` and `inheritedReads`.** The existing six counters (`cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds`) answered "how much did the style cache do" and "how many `WDiv`/`WText` builds ran", but nothing counted every OTHER W-widget's builds, the Flutter (or Wind-authored render-layer) wrapper widgets Wind inserts around content (`Container`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), or the four inherited-widget reads on the hot path (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`). `widgetBuilds` and `wrapperEmissions` are `Map` keyed by type name; `inheritedReads` is fixed to the four keys above. Every increment is one inline call next to the emission or the read it counts, behind the existing `enabled` flag, so a disabled counter still costs one bool check and nothing is added, removed or reordered in the widget tree. `Wind.installPerfResolver()` publishes all nine keys through `WindDebugRegistry.currentPerf?.stats()`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/wind_facade.dart`, `lib/src/widgets/*.dart`, `test/utils/wind_perf_counters_test.dart`, `doc/core-concepts/debugging.md`, `skills/wind-ui/`) - **`Wind.installDebugResolver()` / `Wind.installPerfResolver()` widen from `kDebugMode` to `!kReleaseMode`.** A profile build (`flutter run --profile`) now carries both resolvers, so a performance measurement session can run against a build that is actually representative of production timing; release still tree-shakes both. (`lib/src/wind_facade.dart`, `test/wind_facade_test.dart`) diff --git a/lib/src/parser/wind_context.dart b/lib/src/parser/wind_context.dart index 79b89bea..6afe3f60 100644 --- a/lib/src/parser/wind_context.dart +++ b/lib/src/parser/wind_context.dart @@ -5,6 +5,7 @@ import '../state/wind_anchor_state.dart'; import '../state/wind_anchor_state_provider.dart'; import '../theme/wind_theme.dart'; import '../theme/wind_theme_data.dart'; +import '../utils/wind_perf_counters.dart'; /// **The styling context context** /// @@ -68,7 +69,9 @@ class WindContext { factory WindContext.build(BuildContext context, {Set? states}) { final theme = WindTheme.dataOf(context); + WindPerfCounters.recordInheritedRead(WindInheritedRead.windTheme); final size = MediaQuery.of(context).size; + WindPerfCounters.recordInheritedRead(WindInheritedRead.mediaQuerySize); final screenWidth = size.width; final screenHeight = size.height; diff --git a/lib/src/utils/wind_perf_counters.dart b/lib/src/utils/wind_perf_counters.dart index 53b2b0d4..cb95b20e 100644 --- a/lib/src/utils/wind_perf_counters.dart +++ b/lib/src/utils/wind_perf_counters.dart @@ -1,5 +1,24 @@ import 'package:flutter/foundation.dart'; +/// The four inherited-widget reads [WindPerfCounters.recordInheritedRead] +/// tracks. Backed by an enum rather than a free string so a call site cannot +/// typo a key that would then silently sit outside the pinned four; `.name` +/// is what gets written into [WindPerfCounters.inheritedReads], so the stats +/// map keys are unchanged by this type. +enum WindInheritedRead { + /// `MediaQuery.of(context).size`. + mediaQuerySize, + + /// `MediaQuery.maybePlatformBrightnessOf(context)`. + mediaQueryBrightness, + + /// `WindTheme.dataOf(context)` / `WindTheme.maybeDataOf(context)`. + windTheme, + + /// `DefaultTextStyle.of(context)`. + defaultTextStyle, +} + /// Opt-in aggregate counters for Wind's hottest path. /// /// `WindParser.parse` runs on every build of every W-widget, so these counters @@ -137,12 +156,11 @@ class WindPerfCounters { _wrapperEmissions[typeName] = (_wrapperEmissions[typeName] ?? 0) + 1; } - /// Records one read of the inherited widget named [key], one of - /// `'mediaQuerySize'`, `'mediaQueryBrightness'`, `'windTheme'`, - /// `'defaultTextStyle'`. + /// Records one read of the inherited widget [read]. @internal - static void recordInheritedRead(String key) { + static void recordInheritedRead(WindInheritedRead read) { if (!enabled) return; + final String key = read.name; _inheritedReads[key] = (_inheritedReads[key] ?? 0) + 1; } diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 40475220..ea77b56e 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -1300,7 +1300,8 @@ class WDiv extends StatelessWidget { availableWidth = constraints.maxWidth; } else { availableWidth = MediaQuery.of(context).size.width; - WindPerfCounters.recordInheritedRead('mediaQuerySize'); + WindPerfCounters.recordInheritedRead( + WindInheritedRead.mediaQuerySize); } final itemWidth = (availableWidth - totalGapWidth) / cols; @@ -1892,7 +1893,8 @@ class WDiv extends StatelessWidget { // whole subtree into a second layout pass. A consumer measured 1056 // of them in one eight-scroll session against 258 widget builds. final double fallbackHeight = MediaQuery.of(context).size.height; - WindPerfCounters.recordInheritedRead('mediaQuerySize'); + WindPerfCounters.recordInheritedRead( + WindInheritedRead.mediaQuerySize); logger.wrapWith("WindFullHeightBox", "h-full"); WindPerfCounters.recordWrapperEmission('WindFullHeightBox'); widgetToBuild = WindFullHeightBox( @@ -1927,7 +1929,8 @@ class WDiv extends StatelessWidget { // Both axes, height full: the same render-layer box as the // height-only path, carrying the width factor too. final double fallbackHeight = MediaQuery.of(context).size.height; - WindPerfCounters.recordInheritedRead('mediaQuerySize'); + WindPerfCounters.recordInheritedRead( + WindInheritedRead.mediaQuerySize); logger.wrapWith("WindFullHeightBox", "w+h-full"); WindPerfCounters.recordWrapperEmission('WindFullHeightBox'); widgetToBuild = WindFullHeightBox( diff --git a/lib/src/widgets/w_icon.dart b/lib/src/widgets/w_icon.dart index fbf34433..f956f80f 100644 --- a/lib/src/widgets/w_icon.dart +++ b/lib/src/widgets/w_icon.dart @@ -81,7 +81,7 @@ class WIcon extends StatelessWidget { // Get inherited text style from parent (e.g., from WDiv's DefaultTextStyle) final TextStyle inheritedStyle = DefaultTextStyle.of(context).style; - WindPerfCounters.recordInheritedRead('defaultTextStyle'); + WindPerfCounters.recordInheritedRead(WindInheritedRead.defaultTextStyle); final double? inheritedSize = inheritedStyle.fontSize; final Color? inheritedColor = inheritedStyle.color; diff --git a/lib/src/widgets/w_input.dart b/lib/src/widgets/w_input.dart index 6dcd1754..974d79a3 100644 --- a/lib/src/widgets/w_input.dart +++ b/lib/src/widgets/w_input.dart @@ -681,11 +681,13 @@ class _WInputState extends State // to the platform brightness only when no WindTheme is present. final Brightness? windBrightness = WindTheme.maybeDataOf(context)?.brightness; - WindPerfCounters.recordInheritedRead('windTheme'); + WindPerfCounters.recordInheritedRead(WindInheritedRead.windTheme); Brightness? platformBrightness; if (windBrightness == null) { platformBrightness = MediaQuery.maybePlatformBrightnessOf(context); - WindPerfCounters.recordInheritedRead('mediaQueryBrightness'); + WindPerfCounters.recordInheritedRead( + WindInheritedRead.mediaQueryBrightness, + ); } final Brightness brightness = windBrightness ?? platformBrightness ?? Brightness.light; diff --git a/lib/src/widgets/w_svg.dart b/lib/src/widgets/w_svg.dart index 99a35ea3..b7d9f25f 100644 --- a/lib/src/widgets/w_svg.dart +++ b/lib/src/widgets/w_svg.dart @@ -83,7 +83,7 @@ class WSvg extends StatelessWidget { // Get inherited text style from parent (like WIcon) final TextStyle inheritedStyle = DefaultTextStyle.of(context).style; - WindPerfCounters.recordInheritedRead('defaultTextStyle'); + WindPerfCounters.recordInheritedRead(WindInheritedRead.defaultTextStyle); final double? inheritedSize = inheritedStyle.fontSize; final Color? inheritedColor = inheritedStyle.color; diff --git a/lib/src/widgets/w_text.dart b/lib/src/widgets/w_text.dart index dbc58de1..c3f5e9dd 100644 --- a/lib/src/widgets/w_text.dart +++ b/lib/src/widgets/w_text.dart @@ -200,11 +200,13 @@ class WText extends StatelessWidget { // legible. Explicitly supplied colors always win. if (finalTextStyle.color == null) { final Color? ancestorColor = DefaultTextStyle.of(context).style.color; - WindPerfCounters.recordInheritedRead('defaultTextStyle'); + WindPerfCounters.recordInheritedRead(WindInheritedRead.defaultTextStyle); if (ancestorColor == null) { final isDark = MediaQuery.maybePlatformBrightnessOf(context) == Brightness.dark; - WindPerfCounters.recordInheritedRead('mediaQueryBrightness'); + WindPerfCounters.recordInheritedRead( + WindInheritedRead.mediaQueryBrightness, + ); finalTextStyle = finalTextStyle.copyWith( color: isDark ? Colors.white : Colors.black, ); diff --git a/test/utils/wind_perf_counters_test.dart b/test/utils/wind_perf_counters_test.dart index a226d2a6..1b21d296 100644 --- a/test/utils/wind_perf_counters_test.dart +++ b/test/utils/wind_perf_counters_test.dart @@ -98,7 +98,7 @@ void main() { WindPerfCounters.recordWTextBuild(); WindPerfCounters.recordWidgetBuild('WButton'); WindPerfCounters.recordWrapperEmission('Container'); - WindPerfCounters.recordInheritedRead('windTheme'); + WindPerfCounters.recordInheritedRead(WindInheritedRead.windTheme); WindPerfCounters.reset(); @@ -173,6 +173,28 @@ void main() { }, ); + testWidgets( + 'inheritedReads counts mediaQuerySize/windTheme on a plain WDiv build', + (tester) async { + WindPerfCounters.enabled = true; + + // wind_context.dart:70-71: WindContext.build reads WindTheme.dataOf + // and MediaQuery.of(context).size on every WindParser.parse, which + // runs for every WDiv/WText build, not only WInput and the h-full + // paths that were the only recorders before this fix. + await tester.pumpWidget(wrapWithTheme(const WDiv(className: 'p-2'))); + + expect( + WindPerfCounters.inheritedReads['mediaQuerySize'], + greaterThanOrEqualTo(1), + ); + expect( + WindPerfCounters.inheritedReads['windTheme'], + greaterThanOrEqualTo(1), + ); + }, + ); + testWidgets( 'inheritedReads counts defaultTextStyle/mediaQueryBrightness on a bare WText', (tester) async { From 9d36e01f83c745c3957467e39d7fc3713e44f57e Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 28 Sep 2026 23:32:20 +0300 Subject: [PATCH 3/6] perf(widgets): drop Focus from styling wrappers, build WDiv's box from primitives, size-aspect MediaQuery A gestureless WAnchor around a WDiv installs Focus only when the className carries focus:. WDiv's non-animated Container is replaced by Container.build's own primitives in the same order, skipping a zero Padding. WindContext and WDiv read MediaQuery.sizeOf, so a keyboard inset no longer rebuilds every styled widget. --- CHANGELOG.md | 7 + doc/core-concepts/debugging.md | 4 +- doc/widgets/w-anchor.md | 4 + doc/widgets/w-div.md | 2 +- lib/src/parser/wind_context.dart | 6 +- lib/src/utils/wind_perf_counters.dart | 3 +- lib/src/widgets/w_anchor.dart | 38 ++++- lib/src/widgets/w_div.dart | 98 +++++++++++- skills/wind-ui/SKILL.md | 6 +- skills/wind-ui/references/debug.md | 6 +- skills/wind-ui/references/widgets.md | 6 +- .../hover_focus_disabled_test.dart | 2 +- test/parser/parsers/shadow_parser_test.dart | 43 ++--- test/parser/wind_context_test.dart | 63 ++++++++ test/utils/wind_perf_counters_test.dart | 78 +++++++++ .../w_anchor/dpad_activation_test.dart | 79 +++++++++ test/widgets/w_checkbox_test.dart | 21 +-- test/widgets/w_div/background_test.dart | 23 +-- test/widgets/w_div/border_test.dart | 36 +++-- test/widgets/w_div/shadow_test.dart | 24 +-- test/widgets/w_div/sizing_test.dart | 150 +++++++++++++++++- test/widgets/w_div/transition_test.dart | 13 +- test/widgets/w_div_test.dart | 19 +-- test/widgets/w_radio_test.dart | 9 +- 24 files changed, 618 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78381fb8..60962482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,19 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed + +- **A `WDiv` whose only state classes are `hover:` or `active:` is no longer a Tab or D-pad stop, and its wrapper builds no `Focus`.** Its auto-wrapped `WAnchor` installed a focus node for every interactive className, so a hover-only row was a keyboard stop that lit nothing and paid for a node attachment plus a `Semantics(focusable:)` on every build; a consumer's dashboard carried about 193 of them among 399 anchors per frame. `WDiv` now passes `trackFocus: className.contains('focus:')`. A `focus:` div keeps its node and stop exactly as before, focus-within still lights a ring through a hover-only div in between (the ring's own node covers descendants), and primary focus chained from an anchor above still passes through, because it travels in `WindAnchorStateProvider`, which stays unconditional with `MouseRegion`. Gesture anchors are untouched. (`lib/src/widgets/w_anchor.dart`, `lib/src/widgets/w_div.dart`, `test/widgets/w_anchor/dpad_activation_test.dart`) +- **`WDiv` builds its decorated box from primitives instead of a `Container`.** `Container` is a `StatelessWidget` that only stacks `Align`, `Padding`, `DecoratedBox` and `ConstrainedBox`, so it cost one element per decorated div on the hottest path (1032 per painted frame on the same dashboard). The primitives follow `Container.build`'s order and its three behaviours: a childless box expands through `LimitedBox`, padding is inset by the border widths, and `w-full` tightens the constraints so `max-w-*` still clamps it. A zero `Padding`, which `Container` emitted for every borderless decoration, is skipped. Geometry is pinned by `tester.getSize` cases written against the old `Container` first; `AnimatedContainer` and the inside-the-border clip are unchanged. `wrapperEmissions` now counts `DecoratedBox`, `ConstrainedBox`, `Padding`, `Align` and `LimitedBox` where it counted `Container`. Tests that read decoration off `find.byType(Container)` now read it off `DecoratedBox`. (`lib/src/widgets/w_div.dart`, `test/widgets/w_div/sizing_test.dart`, `test/utils/wind_perf_counters_test.dart`) +- **`WindContext.build` and the `h-full` fallback depend on `MediaQuery.sizeOf`, not the whole `MediaQueryData`.** Every parse subscribed every W-widget to all of `MediaQuery`, so each frame of a keyboard inset animation rebuilt every styled widget on screen. The size aspect still carries both axes, which `w-screen` / `h-screen` need, so `widthOf` would not do. A `viewInsets` change no longer rebuilds a plain `WDiv`; a size change still does. (`lib/src/parser/wind_context.dart`, `lib/src/widgets/w_div.dart`, `test/parser/wind_context_test.dart`) + ### Fixed - **`WindPerfCounters.inheritedReads` now counts the `windTheme` and `mediaQuerySize` reads `WindContext.build` makes on every `WindParser.parse`, not only the ones WInput and the `h-full` paths already recorded.** `WindContext.build` (`lib/src/parser/wind_context.dart:70-71`) calls `WindTheme.dataOf(context)` and `MediaQuery.of(context).size` on every W-widget build, but before this fix only `WInput`'s brightness fallback and `WDiv`'s `h-full` render-layer box called `recordInheritedRead`, so a plain `WDiv`/`WText` build under-reported both keys. (`lib/src/parser/wind_context.dart`, `test/utils/wind_perf_counters_test.dart`) ### Added +- **`WAnchor.trackFocus`.** Defaults to `true`; `false` drops the `Focus` node of a gestureless anchor. Ignored when the anchor has a gesture. (`lib/src/widgets/w_anchor.dart`, `doc/widgets/w-anchor.md`) - **`WindPerfCounters.recordInheritedRead` takes a `WindInheritedRead` enum value instead of a free string.** The four keys (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`) were previously any `String`, so a typo at a call site would silently sit outside the pinned four and never show up in `stats()`. The stats map keys are unchanged: the enum serializes through `.name`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/widgets/w_text.dart`, `lib/src/widgets/w_icon.dart`, `lib/src/widgets/w_svg.dart`, `lib/src/widgets/w_input.dart`, `lib/src/widgets/w_div.dart`, `lib/src/parser/wind_context.dart`, `test/utils/wind_perf_counters_test.dart`) - **`WindPerfCounters` gains `widgetBuilds`, `wrapperEmissions` and `inheritedReads`.** The existing six counters (`cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds`) answered "how much did the style cache do" and "how many `WDiv`/`WText` builds ran", but nothing counted every OTHER W-widget's builds, the Flutter (or Wind-authored render-layer) wrapper widgets Wind inserts around content (`Container`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), or the four inherited-widget reads on the hot path (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`). `widgetBuilds` and `wrapperEmissions` are `Map` keyed by type name; `inheritedReads` is fixed to the four keys above. Every increment is one inline call next to the emission or the read it counts, behind the existing `enabled` flag, so a disabled counter still costs one bool check and nothing is added, removed or reordered in the widget tree. `Wind.installPerfResolver()` publishes all nine keys through `WindDebugRegistry.currentPerf?.stats()`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/wind_facade.dart`, `lib/src/widgets/*.dart`, `test/utils/wind_perf_counters_test.dart`, `doc/core-concepts/debugging.md`, `skills/wind-ui/`) diff --git a/doc/core-concepts/debugging.md b/doc/core-concepts/debugging.md index 110c56b7..65148e59 100644 --- a/doc/core-concepts/debugging.md +++ b/doc/core-concepts/debugging.md @@ -221,11 +221,11 @@ WindPerfCounters.enabled = true; print(WindPerfCounters.cacheHits); // and cacheMisses, cacheBypasses print(WindPerfCounters.wDivBuilds); // and wTextBuilds print(WindPerfCounters.widgetBuilds); // {'WButton': 3, 'WDiv': 12, ...} -print(WindPerfCounters.wrapperEmissions); // {'Container': 5, 'MouseRegion': 2, ...} +print(WindPerfCounters.wrapperEmissions); // {'DecoratedBox': 5, 'MouseRegion': 2, ...} print(WindPerfCounters.inheritedReads); // mediaQuerySize, mediaQueryBrightness, windTheme, defaultTextStyle ``` -`widgetBuilds` counts every W-widget with a build method, by its type name (`'WButton'`, `'WDiv'`, ...); `wDivBuilds`/`wTextBuilds` stay as dedicated counters alongside it rather than being folded in, since they are the pinned six-key contract's own fields. `wrapperEmissions` counts the Flutter (or Wind-authored render-layer) wrapper widget Wind inserts at each composition-pipeline branch (`Container`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), one inline call beside the construction it never changes. `inheritedReads` is fixed to four keys: `mediaQuerySize` (a `MediaQuery.of(context).size` read), `mediaQueryBrightness` (`MediaQuery.maybePlatformBrightnessOf`), `windTheme` (`WindTheme.maybeDataOf`), `defaultTextStyle` (`DefaultTextStyle.of`). +`widgetBuilds` counts every W-widget with a build method, by its type name (`'WButton'`, `'WDiv'`, ...); `wDivBuilds`/`wTextBuilds` stay as dedicated counters alongside it rather than being folded in, since they are the pinned six-key contract's own fields. `wrapperEmissions` counts the Flutter (or Wind-authored render-layer) wrapper widget Wind inserts at each composition-pipeline branch (`DecoratedBox`, `ConstrainedBox`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), one inline call beside the construction it never changes. `inheritedReads` is fixed to four keys: `mediaQuerySize` (a `MediaQuery.sizeOf(context)` read, which depends on the size aspect only), `mediaQueryBrightness` (`MediaQuery.maybePlatformBrightnessOf`), `windTheme` (`WindTheme.maybeDataOf`), `defaultTextStyle` (`DefaultTextStyle.of`). `WindParser.clearCache()` resets the counters as well as the cache, so a hit rate is always reported against the cache it was measured on. That is also why a theme change mid-measurement zeroes them. diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index d14588c5..47ae2f24 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -56,6 +56,7 @@ const WAnchor({ Set? states, MouseCursor? mouseCursor, String? semanticLabel, + bool trackFocus = true, }) ``` @@ -71,6 +72,7 @@ const WAnchor({ | `states` | `Set?` | `null` | Custom states for dynamic styling (e.g., `{'active'}`). | | `mouseCursor` | `MouseCursor?` | `null` | Custom cursor. Defaults to click when interactive. | | `semanticLabel` | `String?` | `null` | Accessible name for icon-only controls. When set, excludes the child subtree from semantics so the label overrides any child text; prefer it for icon-only controls rather than controls that already expose readable text. | +| `trackFocus` | `bool` | `true` | Whether a gestureless anchor installs a focus node. `WDiv` passes `false` when its className has no `focus:` class. Ignored when the anchor has a gesture, which always keeps its node. | ## Layout Modes @@ -140,6 +142,8 @@ WAnchor( ) ``` +A `WDiv` whose only state classes are `hover:` or `active:` carries no focus node at all (its wrapper gets `trackFocus: false`): it cannot draw focus, so a stop on it was a key press that lit nothing. Hover, press and the primary focus chained down from an anchor above it still pass through. + Two shapes are unaffected. A `WDiv` carrying `focus:` with no anchor above it keeps its own focus node, because that is how a consumer styles a custom control. And a focusable descendant still lights the wrapper's ring: `FocusNode.hasFocus` covers descendants, so a `WInput` inside a ring-styled `WDiv` draws the ring around the field the user is typing in. What the wrapper inherits is deliberately narrow: the ancestor's PRIMARY focus, never its focus-within. It passes that signal on rather than stopping at it, because any `hover:` or `active:` class on a div in between creates a second wrapper whose own node never holds focus, and a ring two wrappers deep would otherwise stay dark. The two are different questions and `WindAnchorState` now exposes both. A tappable card containing a text field reports focus-within the whole time the user types, so a wrapper inheriting that would light up even when it sits *beside* the field rather than around it: diff --git a/doc/widgets/w-div.md b/doc/widgets/w-div.md index 8b74e5e3..eabb4c1a 100644 --- a/doc/widgets/w-div.md +++ b/doc/widgets/w-div.md @@ -116,7 +116,7 @@ Precedence: inline `backgroundColor` wins over any `bg-*` / `dark:bg-*` resolved That wrap is styling only: the auto-wrapped `WAnchor` carries no gesture, so it publishes no `button` node to assistive technology. A `WDiv` with `hover:bg-slate-100` styles on hover and still announces as whatever its content is, rather than as a control that does nothing when activated. -It is not a keyboard or remote stop either. Inside a tappable `WAnchor` the wrapper inherits that anchor's `focus` and `disabled` rather than claiming a stop of its own, so the control is reached once and the ring is drawn on the element the user activates. Standing alone, with no anchor above it, the `WDiv` keeps its own focus node and stays reachable. `hover` is never inherited, so sibling divs inside one anchor still highlight independently. See [WAnchor](./w-anchor.md#keyboard-and-remote-control). +It is not a keyboard or remote stop either. Inside a tappable `WAnchor` the wrapper inherits that anchor's `focus` and `disabled` rather than claiming a stop of its own, so the control is reached once and the ring is drawn on the element the user activates. Standing alone, with no anchor above it, a `WDiv` carrying `focus:` keeps its own focus node and stays reachable; one whose only state classes are `hover:` or `active:` has no focus node and is never a stop, since it has nothing to show when focused. `hover` is never inherited, so sibling divs inside one anchor still highlight independently. See [WAnchor](./w-anchor.md#keyboard-and-remote-control). For direct gesture support (taps, long presses) or to create semantic buttons, use [WAnchor](w-anchor.md) or [WButton](w-button.md). diff --git a/lib/src/parser/wind_context.dart b/lib/src/parser/wind_context.dart index 6afe3f60..06d8a7fb 100644 --- a/lib/src/parser/wind_context.dart +++ b/lib/src/parser/wind_context.dart @@ -70,7 +70,11 @@ class WindContext { factory WindContext.build(BuildContext context, {Set? states}) { final theme = WindTheme.dataOf(context); WindPerfCounters.recordInheritedRead(WindInheritedRead.windTheme); - final size = MediaQuery.of(context).size; + // The size aspect only: breakpoints read the width and `w-screen` / + // `h-screen` read both axes, but nothing here reads the rest of + // MediaQueryData. Depending on all of it rebuilt every styled widget on + // each frame of a keyboard inset animation. + final Size size = MediaQuery.sizeOf(context); WindPerfCounters.recordInheritedRead(WindInheritedRead.mediaQuerySize); final screenWidth = size.width; final screenHeight = size.height; diff --git a/lib/src/utils/wind_perf_counters.dart b/lib/src/utils/wind_perf_counters.dart index cb95b20e..561c4e71 100644 --- a/lib/src/utils/wind_perf_counters.dart +++ b/lib/src/utils/wind_perf_counters.dart @@ -6,7 +6,8 @@ import 'package:flutter/foundation.dart'; /// is what gets written into [WindPerfCounters.inheritedReads], so the stats /// map keys are unchanged by this type. enum WindInheritedRead { - /// `MediaQuery.of(context).size`. + /// `MediaQuery.sizeOf(context)`: the size aspect only, so a keyboard inset + /// does not rebuild the reader. mediaQuerySize, /// `MediaQuery.maybePlatformBrightnessOf(context)`. diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index 32e3cca6..d1a9ec1f 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -88,6 +88,17 @@ class WAnchor extends StatefulWidget { /// Semantics node would be suppressed. final String? semanticLabel; + /// Whether a gestureless anchor installs a [Focus] node. + /// + /// `WDiv` passes `false` when its className carries no `focus:` class. A + /// styling wrapper that cannot show focus gains nothing from a node, and the + /// node it had was a Tab and D-pad stop that lit nothing. Hover, press and + /// the state published to descendants are unaffected. + /// + /// Ignored when the anchor has a gesture: a tappable anchor is a keyboard + /// and remote target, so it always keeps its node. + final bool trackFocus; + /// Creates a `WAnchor` widget. /// /// The [child] argument is required and represents the interactive area. @@ -102,6 +113,7 @@ class WAnchor extends StatefulWidget { this.states, this.mouseCursor, this.semanticLabel, + this.trackFocus = true, }); @override @@ -278,7 +290,8 @@ class _WAnchorState extends State { customStates: widget.states, ); - // Focus is always present, needed for focus: class prefix to work. + // Focus is present wherever something can show it: every gesture anchor, + // and every styling wrapper whose div carries a `focus:` class. // // A gestureless wrapper keeps the node but stops competing for it. It is // not a traversal stop, because one control has to cost one press of the @@ -286,12 +299,23 @@ class _WAnchorState extends State { // and the ring was on the second one while the gesture was on the first. // The node itself stays, because `FocusNode.hasFocus` covers descendants // and that is what draws the ring around a `WInput` inside a styled div. - WindPerfCounters.recordWrapperEmission('Focus'); - Widget innerChild = Focus( - focusNode: _focusNode, - canRequestFocus: !widget.isDisabled && (hasGestures || inherited == null), - child: widget.child, - ); + // + // A wrapper without `focus:` drops the node altogether. `Focus` builds a + // node attachment and a `Semantics(focusable:)` on every build, which a + // consumer measured on nearly half of its anchors, all of them hover-only + // rows that could never draw focus. Chained primary focus still passes + // through such a wrapper, because that travels in the provider below, + // not in the node. + Widget innerChild = widget.child; + if (hasGestures || widget.trackFocus) { + WindPerfCounters.recordWrapperEmission('Focus'); + innerChild = Focus( + focusNode: _focusNode, + canRequestFocus: + !widget.isDisabled && (hasGestures || inherited == null), + child: innerChild, + ); + } // The action map goes on only where there is a primary action to run, and // that is narrower than `hasGestures` on purpose. diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index ea77b56e..c8c52232 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -148,6 +148,7 @@ class WDiv extends StatelessWidget { if (isInteractive) { return WAnchor( isDisabled: false, + trackFocus: className!.contains('focus:'), child: Builder(builder: (innerContext) => _buildImpl(innerContext)), ); } @@ -1299,7 +1300,7 @@ class WDiv extends StatelessWidget { if (constraints.maxWidth.isFinite) { availableWidth = constraints.maxWidth; } else { - availableWidth = MediaQuery.of(context).size.width; + availableWidth = MediaQuery.sizeOf(context).width; WindPerfCounters.recordInheritedRead( WindInheritedRead.mediaQuerySize); } @@ -1416,6 +1417,90 @@ class WDiv extends StatelessWidget { ); } + /// The box `Container` would build, composed from its primitives directly. + /// + /// `Container` is a `StatelessWidget` whose `build` only stacks these same + /// primitives, so on the hottest path in Wind it cost one extra element per + /// decorated div. The order and the three behaviours below follow + /// `Container.build` (`widgets/container.dart`) so the geometry is + /// identical; `test/widgets/w_div/sizing_test.dart` pins it. + /// + /// 1. A null child under loose constraints expands to fill a bounded + /// parent and collapses on an unbounded axis (`LimitedBox`). + /// 2. The padding is inset by the decoration's own padding, the border + /// widths, so content never sits under the stroke. + /// 3. [width] and [height] tighten [constraints] rather than replace them, + /// which is what lets `w-full` be clamped by `max-w-*`. + /// + /// A zero padding is skipped where `Container` would emit it: it neither + /// moves nor resizes anything. + Widget _buildBox({ + required Widget? child, + required double? width, + required double? height, + required BoxConstraints? constraints, + required BoxDecoration? decoration, + required EdgeInsetsGeometry? padding, + required AlignmentGeometry? alignment, + required WindLogger logger, + }) { + final BoxConstraints? effectiveConstraints = + (width != null || height != null) + ? constraints?.tighten(width: width, height: height) ?? + BoxConstraints.tightFor(width: width, height: height) + : constraints; + + Widget? current = child; + if (child == null && + (effectiveConstraints == null || !effectiveConstraints.isTight)) { + WindPerfCounters.recordWrapperEmission('LimitedBox'); + WindPerfCounters.recordWrapperEmission('ConstrainedBox'); + current = LimitedBox( + maxWidth: 0.0, + maxHeight: 0.0, + child: ConstrainedBox(constraints: const BoxConstraints.expand()), + ); + } else if (alignment != null) { + logger.wrapWith("Align", "$alignment"); + WindPerfCounters.recordWrapperEmission('Align'); + current = Align(alignment: alignment, child: current); + } + + final EdgeInsetsGeometry? effectivePadding = switch (( + padding, + decoration?.padding, + )) { + (null, final EdgeInsetsGeometry? inset) => inset, + (final EdgeInsetsGeometry? own, null) => own, + (final EdgeInsetsGeometry own, final EdgeInsetsGeometry inset) => + own.add(inset), + }; + if (effectivePadding != null && effectivePadding != EdgeInsets.zero) { + logger.wrapWith("Padding", "$effectivePadding"); + WindPerfCounters.recordWrapperEmission('Padding'); + current = Padding(padding: effectivePadding, child: current); + } + + if (decoration != null) { + logger.wrapWith("DecoratedBox", "decoration/shadow"); + WindPerfCounters.recordWrapperEmission('DecoratedBox'); + current = DecoratedBox(decoration: decoration, child: current); + } + + if (effectiveConstraints != null) { + logger.wrapWith("ConstrainedBox", "$effectiveConstraints"); + WindPerfCounters.recordWrapperEmission('ConstrainedBox'); + current = ConstrainedBox( + constraints: effectiveConstraints, + child: current, + ); + } + + // Non-null by construction: a null child without a tight constraint took + // the `LimitedBox`, and a tight constraint is non-null and wrapped above. + return current!; + } + /// **The Composition Pipeline** /// /// Wraps the core content with styling widgets. @@ -1633,16 +1718,15 @@ class WDiv extends StatelessWidget { child: widgetToBuild, ); } else { - logger.wrapWith("Container", "decoration/constraints/shadow"); - WindPerfCounters.recordWrapperEmission('Container'); - widgetToBuild = Container( + widgetToBuild = _buildBox( + child: widgetToBuild, width: wantFullWidth ? double.infinity : null, height: wantFullHeight ? double.infinity : null, constraints: innerConstraints, decoration: finalDecoration, padding: containerPadding, alignment: containerAlignment, - child: widgetToBuild, + logger: logger, ); } } @@ -1892,7 +1976,7 @@ class WDiv extends StatelessWidget { // height bounded"), and asking it with a LayoutBuilder defers this // whole subtree into a second layout pass. A consumer measured 1056 // of them in one eight-scroll session against 258 widget builds. - final double fallbackHeight = MediaQuery.of(context).size.height; + final double fallbackHeight = MediaQuery.sizeOf(context).height; WindPerfCounters.recordInheritedRead( WindInheritedRead.mediaQuerySize); logger.wrapWith("WindFullHeightBox", "h-full"); @@ -1928,7 +2012,7 @@ class WDiv extends StatelessWidget { if (isFullHeight) { // Both axes, height full: the same render-layer box as the // height-only path, carrying the width factor too. - final double fallbackHeight = MediaQuery.of(context).size.height; + final double fallbackHeight = MediaQuery.sizeOf(context).height; WindPerfCounters.recordInheritedRead( WindInheritedRead.mediaQuerySize); logger.wrapWith("WindFullHeightBox", "w+h-full"); diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 5618a3ea..536e3476 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,10 +2,10 @@ name: wind-ui description: "fluttersdk_wind 1.7: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside." when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists." -version: 2.20.1 +version: 2.21.0 --- - + # Wind UI 1.7 @@ -63,7 +63,7 @@ These hold for every line of Wind code. Apply each as a hard constraint, not a s 10. **`active:` prefix is reserved but not wired.** `WAnchor` tracks hover and focus only; there is no onTapDown/onTapUp tracking. Don't rely on `active:bg-blue-700` for press feedback. Use a transient state in the consumer's controller and `states: {'pressed'}` if you genuinely need press feedback today. -11. **An `onTap` on `WAnchor` is reachable by keyboard and remote, and a gestureless anchor is not a stop.** `onTap` runs on `ActivateIntent`, which covers `Enter`, `Space`, the gamepad A button and `select` (the D-pad centre on Android TV, the click on the Apple TV remote). Only `onTap` is bound, and the action map is installed only when an enabled `onTap` exists: a `CallbackAction` is always enabled and `ShortcutManager` reports a key handled for any enabled action, so an anchor carrying only `onLongPress` would otherwise swallow the activation key belonging to the row around it, and on web swallow `Space`'s scroll with it. Only an anchor carrying a gesture is a traversal stop: the gestureless `WAnchor` that `WDiv` wraps itself in for `hover:` / `focus:` / `active:` inherits from the nearest anchor above it rather than claiming a second stop, so `WAnchor(onTap:) > WDiv('focus:ring-2')` is one control the user tabs to once and the ring lands on the thing they activate. What it inherits is narrow: the ancestor's PRIMARY focus and its `disabled`, never the ancestor's focus-within, because a tappable card containing a text field reports focus-within while the user types and every wrapper under it would light up. `hover` is never inherited. Wind ships no traversal policy; directional movement is Flutter's default, and `FocusTraversalGroup` is the consumer's tool for region memory and edge behaviour. +11. **An `onTap` on `WAnchor` is reachable by keyboard and remote, and a gestureless anchor is not a stop.** `onTap` runs on `ActivateIntent`, which covers `Enter`, `Space`, the gamepad A button and `select` (the D-pad centre on Android TV, the click on the Apple TV remote). Only `onTap` is bound, and the action map is installed only when an enabled `onTap` exists: a `CallbackAction` is always enabled and `ShortcutManager` reports a key handled for any enabled action, so an anchor carrying only `onLongPress` would otherwise swallow the activation key belonging to the row around it, and on web swallow `Space`'s scroll with it. Only an anchor carrying a gesture is a traversal stop: the gestureless `WAnchor` that `WDiv` wraps itself in for `hover:` / `focus:` / `active:` inherits from the nearest anchor above it rather than claiming a second stop, so `WAnchor(onTap:) > WDiv('focus:ring-2')` is one control the user tabs to once and the ring lands on the thing they activate. A `WDiv` whose only state classes are `hover:` / `active:` has no focus node at all and is never a stop. What it inherits is narrow: the ancestor's PRIMARY focus and its `disabled`, never the ancestor's focus-within, because a tappable card containing a text field reports focus-within while the user types and every wrapper under it would light up. `hover` is never inherited. Wind ships no traversal policy; directional movement is Flutter's default, and `FocusTraversalGroup` is the consumer's tool for region memory and edge behaviour. ## 2. The 27 public widgets (+ WindRecipe) at a glance diff --git a/skills/wind-ui/references/debug.md b/skills/wind-ui/references/debug.md index 9f1da5cc..d540a145 100644 --- a/skills/wind-ui/references/debug.md +++ b/skills/wind-ui/references/debug.md @@ -130,13 +130,13 @@ Output (sample): ``` [Wind] WDiv: className: 'flex flex-col gap-4 p-6 bg-white dark:bg-gray-800' - composition: Container(decoration) → Padding(EdgeInsets.all(24)) → Column(crossAxisAlignment: start, mainAxisSize: max, children: [...]) + composition: DecoratedBox(decoration) → Padding(EdgeInsets.all(24)) → Column(crossAxisAlignment: start, mainAxisSize: max, children: [...]) final style: WindStyle(displayType: flex, flexDirection: vertical, gapY: 16, padding: EdgeInsets.all(24), color: 0xFFFFFFFF, ...) build: 142µs ``` What it shows: -1. **Composition tree**: pseudo-Dart pseudo-code of the widget hierarchy Wind constructs (Container → Padding → Column / Row / etc.). +1. **Composition tree**: pseudo-Dart pseudo-code of the widget hierarchy Wind constructs (DecoratedBox → Padding → Column / Row / etc.). 2. **Final WindStyle**: the immutable value object after all parsers have run. 3. **Build time**: microseconds from className-receive to widget-return. @@ -235,4 +235,4 @@ A bypass is a property of a caller writing `style:`, NOT of using `WDiv` / `WTex `WindParser.clearCache()` resets the counters too, which is why a theme change mid-measurement zeroes them. -`Wind.installPerfResolver()` publishes nine keys through `WindDebugRegistry.currentPerf?.stats()`: the six `int` counters above plus three `Map` counters — `widgetBuilds` (every W-widget with a build method, by type name: `'WButton'`, `'WDiv'`, ...), `wrapperEmissions` (the Flutter or Wind-authored render-layer wrapper widget inserted at each composition-pipeline branch: `'Container'`, `'Padding'`, `'MouseRegion'`, `'Semantics'`, `'DefaultTextStyle'`, ...), `inheritedReads` (fixed to `mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`). A `null` there means no resolver was installed, which is deliberately distinguishable from "the counters are zero". Both `installDebugResolver()` and `installPerfResolver()` gate on `!kReleaseMode`, not `kDebugMode`, so a profile build still carries them. +`Wind.installPerfResolver()` publishes nine keys through `WindDebugRegistry.currentPerf?.stats()`: the six `int` counters above plus three `Map` counters: `widgetBuilds` (every W-widget with a build method, by type name: `'WButton'`, `'WDiv'`, ...), `wrapperEmissions` (the Flutter or Wind-authored render-layer wrapper widget inserted at each composition-pipeline branch: `'DecoratedBox'`, `'ConstrainedBox'`, `'Padding'`, `'MouseRegion'`, `'Semantics'`, `'DefaultTextStyle'`, ...), `inheritedReads` (fixed to `mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`). A `null` there means no resolver was installed, which is deliberately distinguishable from "the counters are zero". Both `installDebugResolver()` and `installPerfResolver()` gate on `!kReleaseMode`, not `kDebugMode`, so a profile build still carries them. diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index fbd09365..33dc63f5 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -64,7 +64,7 @@ const WDiv({ Behavior: - Auto-wraps in `WAnchor` when className contains the literal substrings `hover:`, `focus:`, or `active:`. (Note: `active:` prefix is not yet wired; the substring check is for forward-compatibility.) - Honors layout tokens (flex, grid, wrap, block), sizing, spacing (padding/margin/gap), positioning, borders, shadows, opacity, transitions, animations. -- Composition pipeline (innermost → outermost): aspect ratio → opacity → animation → Container (decoration + constraints + padding) → scroll/clip → outer sizing → fractional sizing → margin → alignment → flex/expanded. +- Composition pipeline (innermost → outermost): aspect ratio → opacity → animation → box (`Align` → `Padding` inset by the border → `DecoratedBox` → `ConstrainedBox`, the primitives `Container` would build, without the `Container`; `AnimatedContainer` under `duration-*`) → scroll/clip → outer sizing → fractional sizing → margin → alignment → flex/expanded. - `scrollPrimary: true` only applies when className contains `overflow-y-auto`, `overflow-y-scroll`, `overflow-x-auto`, `overflow-x-scroll`, or `overflow-scroll`. - `flex-*` classes skip the Expanded wrap when `WindFlexOverflowScope.skipExpanded` is true (the main axis is scrollable). This is invisible to consumers and prevents unbounded-constraint assertions. @@ -242,6 +242,7 @@ const WAnchor({ Set? states, MouseCursor? mouseCursor, // defaults to SystemMouseCursors.click when gestures exist String? semanticLabel, // accessible name for icon-only anchors (no child text for Semantics to absorb) + bool trackFocus = true, // gestureless only: false drops the Focus node (WDiv passes false without focus:) }) ``` @@ -252,7 +253,7 @@ Three accessibility paths, checked in this order: 2. No label and no gesture: no node of its own. 3. No label, with a gesture: `MergeSemantics` → `Semantics(button: true, enabled: !isDisabled)` → the rest below. -The rest, in all three: `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → optional `GestureDetector` and `Actions` (both only if any callback is non-null) → `Focus` → `child`. +The rest, in all three: `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → optional `GestureDetector` and `Actions` (both only if any callback is non-null) → `Focus` (with a gesture, or when `trackFocus` is true) → `child`. State tracking: - Hover: `MouseRegion.onEnter` / `onExit` set `_isHovering`; calls `setState` only on change. @@ -262,6 +263,7 @@ State tracking: Keyboard and remote activation: - `Actions` maps `ActivateIntent` and `ButtonActivateIntent` to `onTap`, and is installed only when `onTap != null && !isDisabled`. `WidgetsApp` raises those intents for `Enter`, `Space`, numpad `Enter`, gamepad A and `select` (the Android TV D-pad centre, the Apple TV remote click), so `WAnchor` binds no key itself and inherits whatever the platform adds. Only `onTap` is bound: `ActivateIntent` is the primary action and there is no second key for `onLongPress` / `onDoubleTap`. - The install gate is narrower than `hasGestures` on purpose. A `CallbackAction` is always enabled and `ShortcutManager` reports a key HANDLED for any enabled action, so an always-installed map made a long-press-only or disabled anchor swallow the activation key belonging to its parent, and on web beat `Space`'s `PrioritizedIntents([ActivateIntent, ScrollIntent])` race so the page stopped scrolling. An early return inside the callback does NOT fix that: the action still reports enabled. +- `Focus` is installed only when the anchor has a gesture or `trackFocus` is true. `WDiv` passes `trackFocus: className.contains('focus:')`, so a hover-only or active-only div has no node, is no Tab or D-pad stop, and publishes no `focusable` semantics. The provider and `MouseRegion` stay unconditional: every parse reads the provider, and hover needs the region. - `canRequestFocus: !isDisabled && (hasGestures || no ancestor anchor state)`. A gestureless `WAnchor` under another anchor is a styling wrapper, not a traversal stop, so one control costs one press of the remote. Standalone (nothing to inherit from) it keeps its node, because that is how a consumer styles a custom control. - A gestureless wrapper inherits the ancestor's `hasPrimaryFocus` and `isDisabled`, NOT its `isFocused`, and it REPUBLISHES `hasPrimaryFocus` with the inherited value ORed in so the signal chains: a wrapper's own node never holds primary focus, so stopping at it left a ring two wrappers deep dark, and any `hover:` / `active:` class on an intermediate div creates that second wrapper. `WindAnchorState.isFocused` is focus-WITHIN (it comes from `FocusNode.hasFocus`, true for an ancestor of the real holder), so inheriting it lit the ring on every wrapper under a tappable card while the user typed in a field inside that card, including wrappers sitting beside the field. The container case still works without inheritance: a ring-styled `WDiv` wrapping a `WInput` lights through its own node. `isHovering` is never inherited: hover is a pointer position and sibling divs inside one anchor highlight independently. diff --git a/test/interaction/hover_focus_disabled_test.dart b/test/interaction/hover_focus_disabled_test.dart index e02aba73..62b7984c 100644 --- a/test/interaction/hover_focus_disabled_test.dart +++ b/test/interaction/hover_focus_disabled_test.dart @@ -15,7 +15,7 @@ Widget wrapWithTheme(Widget child) { } /// Reads the resolved background color from the first [DecoratedBox] beneath -/// [finder]. WDiv emits a Container (a DecoratedBox under the hood) when a +/// [finder]. WDiv emits a DecoratedBox when a /// background color is resolved. Color? resolvedBackgroundColor(WidgetTester tester, Finder finder) { final decoratedBoxes = tester.widgetList( diff --git a/test/parser/parsers/shadow_parser_test.dart b/test/parser/parsers/shadow_parser_test.dart index e7910e64..9b215e99 100644 --- a/test/parser/parsers/shadow_parser_test.dart +++ b/test/parser/parsers/shadow_parser_test.dart @@ -6,6 +6,9 @@ void main() { group('Shadow Parsing Tests', () { setUp(WindParser.clearCache); + // WDiv emits its decoration on a DecoratedBox since the box model was + // rebuilt from primitives; there is no Container to read it from. + testWidgets('renders shadow-sm', (tester) async { await tester.pumpWidget( MaterialApp( @@ -19,8 +22,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); expect(decoration.boxShadow!.length, 1); expect(decoration.boxShadow![0].blurRadius, 2); @@ -39,12 +42,14 @@ void main() { ), ); - // shadow-none carries no visual, so it wraps no shadow-bearing - // Container. The child renders and no decoration applies a box shadow. + // shadow-none carries no visual, so it wraps no shadow-bearing box. + // The child renders and no decoration applies a box shadow. WDiv emits + // decoration on a DecoratedBox since its box was rebuilt from + // primitives, so that is the type scanned here. expect(find.text('Shadow'), findsOneWidget); - for (final container - in tester.widgetList(find.byType(Container))) { - final decoration = container.decoration; + for (final box + in tester.widgetList(find.byType(DecoratedBox))) { + final decoration = box.decoration; if (decoration is BoxDecoration) { expect(decoration.boxShadow ?? const [], isEmpty); } @@ -61,8 +66,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); expect(decoration.boxShadow!.length, 2); // Default has 2 shadows }); @@ -80,8 +85,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); expect(decoration.boxShadow!.length, 2); expect(decoration.boxShadow![0].blurRadius, 25); @@ -100,8 +105,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); // shadow-lg has 2 shadows. Both should be tinted red. expect( @@ -125,8 +130,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); // shadow-lg has 2 shadows. First has alpha ~0.1 (exact value depends on WindBoxShadows) // If code ignores input opacity, it remains ~0.1. @@ -153,8 +158,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); expect( decoration.boxShadow![0].color, @@ -166,8 +171,8 @@ void main() { // `{3,8}` used to take this through to int.parse and paint // Color(0x00012345), an invisible shadow rather than a dropped class. Color renderedShadowColor() { - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; return decoration.boxShadow![0].color; } diff --git a/test/parser/wind_context_test.dart b/test/parser/wind_context_test.dart index ae0762e9..722e17b8 100644 --- a/test/parser/wind_context_test.dart +++ b/test/parser/wind_context_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart' show WDiv, WindParser; +import 'package:fluttersdk_wind/src/utils/wind_perf_counters.dart'; import 'package:fluttersdk_wind/src/parser/wind_context.dart'; import 'package:fluttersdk_wind/src/state/wind_anchor_state.dart'; import 'package:fluttersdk_wind/src/state/wind_anchor_state_provider.dart'; @@ -296,6 +298,67 @@ void main() { expect(context1.cacheKey('p-4'), isNot(context2.cacheKey('p-4'))); }); }); + + group('MediaQuery dependency', () { + setUp(() { + WindParser.clearCache(); + WindPerfCounters.enabled = true; + }); + + tearDown(() { + WindPerfCounters.enabled = false; + WindParser.clearCache(); + }); + + Widget tree(MediaQueryData data) { + return MediaQuery( + data: data, + child: Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: testTheme, + child: const WDiv( + className: 'p-2', + child: SizedBox(width: 10, height: 10), + ), + ), + ), + ); + } + + testWidgets('a keyboard inset does not rebuild a plain WDiv', ( + tester, + ) async { + // The context only needs the screen size (breakpoints, `w-screen`, + // `h-screen`), so it subscribes to that aspect alone. Subscribing to + // the whole MediaQuery rebuilt every styled widget on each frame of + // a keyboard animation. + const MediaQueryData closed = MediaQueryData(size: Size(390, 800)); + await tester.pumpWidget(tree(closed)); + final int builds = WindPerfCounters.wDivBuilds; + + await tester.pumpWidget( + tree( + closed.copyWith(viewInsets: const EdgeInsets.only(bottom: 300)), + ), + ); + + expect(WindPerfCounters.wDivBuilds, builds); + }); + + testWidgets('a size change still rebuilds a plain WDiv', (tester) async { + await tester.pumpWidget( + tree(const MediaQueryData(size: Size(390, 800))), + ); + final int builds = WindPerfCounters.wDivBuilds; + + await tester.pumpWidget( + tree(const MediaQueryData(size: Size(1024, 800))), + ); + + expect(WindPerfCounters.wDivBuilds, builds + 1); + }); + }); }); } diff --git a/test/utils/wind_perf_counters_test.dart b/test/utils/wind_perf_counters_test.dart index 1b21d296..03228f6f 100644 --- a/test/utils/wind_perf_counters_test.dart +++ b/test/utils/wind_perf_counters_test.dart @@ -156,6 +156,84 @@ void main() { }, ); + testWidgets( + 'a decorated WDiv counts its box primitives, not a Container', + (tester) async { + WindPerfCounters.enabled = true; + + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + className: 'w-20 p-2 bg-white border rounded-lg', + child: WText('Box'), + ), + ), + ); + + final Map emitted = WindPerfCounters.wrapperEmissions; + expect(emitted['Container'], isNull); + expect(emitted['DecoratedBox'], 1); + expect(emitted['Padding'], 1); + expect(emitted['ConstrainedBox'], 1); + }, + ); + + testWidgets( + 'a transitioned WDiv still counts one AnimatedContainer', + (tester) async { + WindPerfCounters.enabled = true; + + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + className: 'p-2 bg-white duration-300', + child: WText('Box'), + ), + ), + ); + + expect(WindPerfCounters.wrapperEmissions['AnimatedContainer'], 1); + expect(WindPerfCounters.wrapperEmissions['DecoratedBox'], isNull); + }, + ); + + testWidgets( + 'a hover-only WDiv counts no Focus, a focus: WDiv counts one', + (tester) async { + WindPerfCounters.enabled = true; + + await tester.pumpWidget( + wrapWithTheme( + const WDiv(className: 'hover:bg-gray-100', child: WText('Row')), + ), + ); + expect(WindPerfCounters.wrapperEmissions['Focus'], isNull); + expect(WindPerfCounters.wrapperEmissions['MouseRegion'], 1); + expect(WindPerfCounters.wrapperEmissions['WindAnchorStateProvider'], 1); + + WindPerfCounters.reset(); + await tester.pumpWidget( + wrapWithTheme( + const WDiv(className: 'focus:ring-2', child: WText('Row')), + ), + ); + expect(WindPerfCounters.wrapperEmissions['Focus'], 1); + }, + ); + + testWidgets( + 'a WText with its own colour reads no DefaultTextStyle', + (tester) async { + WindPerfCounters.enabled = true; + + await tester.pumpWidget( + wrapWithTheme(const WText('Latency', className: 'text-gray-900')), + ); + + expect(WindPerfCounters.inheritedReads['defaultTextStyle'], 0); + }, + ); + testWidgets( 'inheritedReads counts windTheme/mediaQueryBrightness on a pumped WInput', (tester) async { diff --git a/test/widgets/w_anchor/dpad_activation_test.dart b/test/widgets/w_anchor/dpad_activation_test.dart index 945bdd66..f37d4c19 100644 --- a/test/widgets/w_anchor/dpad_activation_test.dart +++ b/test/widgets/w_anchor/dpad_activation_test.dart @@ -557,4 +557,83 @@ void main() { expect(WindAnchorStateProvider.of(inner())?.hasPrimaryFocus, isFalse); }); }); + + group('styling wrappers without focus: classes', () { + for (final String className in [ + 'p-2 hover:bg-gray-100', + 'p-2 active:bg-gray-200', + ]) { + testWidgets('"$className" is not a traversal stop and has no Focus', ( + tester, + ) async { + // The one deliberate change to what a keyboard reaches: a div that only + // styles hover or press cannot show focus, so a Tab or remote stop on + // it was a press that lit nothing. + await pump( + tester, + WDiv(className: className, child: const WText('Row')), + ); + + expect(traversalStops(tester), isEmpty); + expect( + find.descendant( + of: find.byType(WAnchor), + matching: find.byType(Focus), + ), + findsNothing, + ); + }); + } + + testWidgets('a hover-only div still reports hover', (tester) async { + await pump( + tester, + const WDiv(className: 'p-2 hover:bg-gray-100', child: WText('Row')), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: Offset.zero); + await gesture.moveTo(tester.getCenter(find.text('Row'))); + await tester.pump(); + + expect( + WindAnchorStateProvider.of(tester.element(find.text('Row'))) + ?.isHovering, + isTrue, + ); + }); + + testWidgets('focus-within still reaches a ring through a hover-only div', ( + tester, + ) async { + // The hover-only div in the middle no longer carries a focus node, so + // the ring has to light through its OWN node, whose `hasFocus` covers + // every descendant, the text field two levels down included. + const Key ringKey = ValueKey('ring'); + await pump( + tester, + const WDiv( + key: ringKey, + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WDiv( + className: 'p-2 hover:bg-gray-100', + child: WInput(placeholder: 'Search'), + ), + ), + ); + + await tester.tap(find.byType(WInput)); + await tester.pump(); + + final BuildContext ring = tester.element( + find + .descendant(of: find.byKey(ringKey), matching: find.byType(Builder)) + .first, + ); + expect(WindAnchorStateProvider.of(ring)?.isFocused, isTrue); + }); + }); } diff --git a/test/widgets/w_checkbox_test.dart b/test/widgets/w_checkbox_test.dart index 493ea26d..0b24dd60 100644 --- a/test/widgets/w_checkbox_test.dart +++ b/test/widgets/w_checkbox_test.dart @@ -25,12 +25,13 @@ Widget wrapWithPrimary(Widget child, MaterialColor primary) { /// Returns the first non-null `BoxDecoration` background color under [of]. Color? firstDecorationColor(WidgetTester tester, Finder of) { - final containers = tester.widgetList( - find.descendant(of: of, matching: find.byType(Container)), + // WDiv paints its fill through a DecoratedBox; it emits no Container. + final boxes = tester.widgetList( + find.descendant(of: of, matching: find.byType(DecoratedBox)), ); - for (final container in containers) { - final decoration = container.decoration; + for (final box in boxes) { + final decoration = box.decoration; if (decoration is BoxDecoration && decoration.color != null) { return decoration.color; } @@ -201,15 +202,15 @@ void main() { ); await tester.pump(); - final container = tester.widget( + final box = tester.widget( find .descendant( of: find.byType(WCheckbox), - matching: find.byType(Container), + matching: find.byType(DecoratedBox), ) .first, ); - final border = (container.decoration as BoxDecoration).border as Border; + final border = (box.decoration as BoxDecoration).border as Border; expect(border.top.color, const Color(0x00000000)); }); @@ -219,15 +220,15 @@ void main() { ); await tester.pump(); - final container = tester.widget( + final box = tester.widget( find .descendant( of: find.byType(WCheckbox), - matching: find.byType(Container), + matching: find.byType(DecoratedBox), ) .first, ); - final border = (container.decoration as BoxDecoration).border as Border; + final border = (box.decoration as BoxDecoration).border as Border; expect(border.top.color, isNot(const Color(0x00000000))); }); diff --git a/test/widgets/w_div/background_test.dart b/test/widgets/w_div/background_test.dart index 3ddc3109..77ee11dc 100644 --- a/test/widgets/w_div/background_test.dart +++ b/test/widgets/w_div/background_test.dart @@ -6,6 +6,9 @@ void main() { group('Background Parsing Tests', () { setUp(WindParser.clearCache); + // WDiv emits its decoration on a DecoratedBox since the box model was + // rebuilt from primitives; there is no Container to read it from. + testWidgets('a later bg-transparent clears an earlier fill', ( tester, ) async { @@ -24,8 +27,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.color, const Color(0x00000000)); }); @@ -42,8 +45,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.color!.a, closeTo(0.5, 0.01)); }); @@ -60,8 +63,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.color!.a, closeTo(0.2, 0.01)); }); @@ -78,8 +81,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; expect(decoration.gradient, isA()); final gradient = decoration.gradient as LinearGradient; expect(gradient.begin, Alignment.centerLeft); @@ -101,8 +104,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration; final gradient = decoration.gradient as LinearGradient; expect(gradient.colors.length, 3); }); diff --git a/test/widgets/w_div/border_test.dart b/test/widgets/w_div/border_test.dart index c93035e0..08c15fa5 100644 --- a/test/widgets/w_div/border_test.dart +++ b/test/widgets/w_div/border_test.dart @@ -4,6 +4,8 @@ import 'package:fluttersdk_wind/fluttersdk_wind.dart'; void main() { group('WDiv Border Feature Tests', () { + // WDiv emits its decoration on a DecoratedBox since the box model was + // rebuilt from primitives; there is no Container to read it from. testWidgets('applies border-radius via rounded-lg class', (tester) async { await tester.pumpWidget( MaterialApp( @@ -14,9 +16,9 @@ void main() { ), ); - // Find the Container that has the decoration - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration?; + // Find the box that carries the decoration + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration?; expect(decoration, isNotNull); expect(decoration!.borderRadius, BorderRadius.circular(8.0)); @@ -32,8 +34,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration?; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration?; expect(decoration, isNotNull); expect(decoration!.border, isNotNull); @@ -54,8 +56,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration?; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration?; expect(decoration, isNotNull); expect(decoration!.border, isNotNull); @@ -76,8 +78,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration?; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration?; expect(decoration, isNotNull); @@ -109,8 +111,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration?; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration?; expect(decoration, isNotNull); // Custom theme merge adds value but doesn't override existing @@ -132,8 +134,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration?; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration?; expect(decoration, isNotNull); final border = decoration!.border as Border; @@ -150,8 +152,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration?; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration?; expect(decoration, isNotNull); expect(decoration!.borderRadius, BorderRadius.circular(9999.0)); @@ -169,8 +171,8 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration?; + final box = tester.widget(find.byType(DecoratedBox)); + final decoration = box.decoration as BoxDecoration?; expect(decoration, isNotNull); final border = decoration!.border as Border; diff --git a/test/widgets/w_div/shadow_test.dart b/test/widgets/w_div/shadow_test.dart index dc58f999..173a5b2a 100644 --- a/test/widgets/w_div/shadow_test.dart +++ b/test/widgets/w_div/shadow_test.dart @@ -17,21 +17,21 @@ void main() { ), ); - final containerFinder = find.byType(Container); + final containerFinder = find.byType(DecoratedBox); // WDiv might create multiple containers (one for decoration). // We look for the one with boxShadow. final containerWithShadow = containerFinder.evaluate().firstWhere(( element, ) { - final widget = element.widget as Container; + final widget = element.widget as DecoratedBox; final decoration = widget.decoration; if (decoration is BoxDecoration) { return decoration.boxShadow != null; } return false; - }, orElse: () => throw Exception('Container with shadow not found')); + }, orElse: () => throw Exception('DecoratedBox with shadow not found')); - final container = containerWithShadow.widget as Container; + final container = containerWithShadow.widget as DecoratedBox; final decoration = container.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); @@ -52,19 +52,19 @@ void main() { ), ); - final containerFinder = find.byType(Container); + final containerFinder = find.byType(DecoratedBox); final containerWithShadow = containerFinder.evaluate().firstWhere(( element, ) { - final widget = element.widget as Container; + final widget = element.widget as DecoratedBox; final decoration = widget.decoration; if (decoration is BoxDecoration) { return decoration.boxShadow != null; } return false; - }, orElse: () => throw Exception('Container with shadow not found')); + }, orElse: () => throw Exception('DecoratedBox with shadow not found')); - final container = containerWithShadow.widget as Container; + final container = containerWithShadow.widget as DecoratedBox; final decoration = container.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); @@ -94,19 +94,19 @@ void main() { ), ); - final containerFinder = find.byType(Container); + final containerFinder = find.byType(DecoratedBox); final containerWithShadow = containerFinder.evaluate().firstWhere(( element, ) { - final widget = element.widget as Container; + final widget = element.widget as DecoratedBox; final decoration = widget.decoration; if (decoration is BoxDecoration) { return decoration.boxShadow != null; } return false; - }, orElse: () => throw Exception('Container with shadow not found')); + }, orElse: () => throw Exception('DecoratedBox with shadow not found')); - final container = containerWithShadow.widget as Container; + final container = containerWithShadow.widget as DecoratedBox; final decoration = container.decoration as BoxDecoration; expect(decoration.boxShadow, isNotNull); diff --git a/test/widgets/w_div/sizing_test.dart b/test/widgets/w_div/sizing_test.dart index 19b53cd8..3a9b5c8e 100644 --- a/test/widgets/w_div/sizing_test.dart +++ b/test/widgets/w_div/sizing_test.dart @@ -63,6 +63,137 @@ void main() { }); }); + // Pinned against the geometry `Container` produced before the box model was + // rebuilt from primitives. Each case exercises one of the three behaviours + // that rebuild has to reproduce: a childless box expanding through + // `LimitedBox`, padding inset by the border widths, and `width`/`height` + // tightening the constraints. + group('Box model geometry', () { + Future pumpInColumn(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [child], + ), + ), + ), + ), + ); + } + + Future pumpCentered(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Center(child: child), + ), + ), + ); + } + + testWidgets('an empty divider fills the cross axis of a start column', ( + tester, + ) async { + await pumpInColumn( + tester, + const WDiv(className: 'h-[1px] bg-gray-200'), + ); + + expect(tester.getSize(find.byType(WDiv)), const Size(800, 1)); + }); + + testWidgets('an empty box with no height collapses in a start column', ( + tester, + ) async { + // `h-px` is not a sizing token, so no height reaches the box. The + // childless box then expands across the bounded width and collapses on + // the unbounded height through `LimitedBox`. + await pumpInColumn( + tester, + const WDiv(className: 'h-px bg-gray-200'), + ); + + expect(tester.getSize(find.byType(WDiv)), const Size(800, 0)); + }); + + testWidgets('padding sits inside the border of a rounded box', ( + tester, + ) async { + const Key childKey = ValueKey('child'); + await pumpCentered( + tester, + const WDiv( + className: 'border-2 rounded-lg p-4 bg-white', + child: SizedBox(key: childKey, width: 20, height: 10), + ), + ); + + final Finder box = find.byType(WDiv); + expect(tester.getSize(box), const Size(56, 46)); + expect( + tester.getTopLeft(find.byKey(childKey)) - tester.getTopLeft(box), + const Offset(18, 18), + ); + }); + + testWidgets('w-full stretches a decorated box across the column', ( + tester, + ) async { + await pumpInColumn( + tester, + const WDiv( + className: 'w-full bg-white p-2', + child: SizedBox(width: 10, height: 10), + ), + ); + + expect(tester.getSize(find.byType(WDiv)), const Size(800, 26)); + }); + + testWidgets('w-full is clamped by max-w-* when tightened', (tester) async { + await pumpInColumn( + tester, + const WDiv( + className: 'w-full max-w-sm bg-white', + child: SizedBox(width: 10, height: 10), + ), + ); + + expect(tester.getSize(find.byType(WDiv)), const Size(384, 10)); + }); + + testWidgets('an aligned box positions its child inside a fixed size', ( + tester, + ) async { + const Key childKey = ValueKey('child'); + await pumpCentered( + tester, + const WDiv( + className: 'self-center w-40 h-20 bg-white', + child: SizedBox(key: childKey, width: 10, height: 10), + ), + ); + + // The outer `Align` for `self-center` fills the screen; the decorated + // box inside it keeps its fixed size and centres the child. + final Finder decorated = find + .descendant( + of: find.byType(WDiv), + matching: find.byType(DecoratedBox), + ) + .first; + expect(tester.getSize(find.byType(WDiv)), const Size(800, 600)); + expect(tester.getSize(decorated), const Size(160, 80)); + expect(tester.getTopLeft(decorated), const Offset(320, 260)); + expect(tester.getTopLeft(find.byKey(childKey)), const Offset(395, 295)); + }); + }); + group('Sizing Parsing Tests', () { testWidgets('Parsing width/height numeric values correctly (using theme)', ( tester, @@ -79,13 +210,18 @@ void main() { // w-10 -> 10 * 4 = 40.0 // h-20 -> 20 * 4 = 80.0 - final containerFinder = find.byType(Container); - expect(containerFinder, findsOneWidget); - final Container container = tester.widget(containerFinder); - expect(container.constraints!.minWidth, 40.0); - expect(container.constraints!.maxWidth, 40.0); - expect(container.constraints!.minHeight, 80.0); - expect(container.constraints!.maxHeight, 80.0); + // The box carries its constraints on a ConstrainedBox since it was + // rebuilt from primitives; there is no Container to read them from. + final boxFinder = find.descendant( + of: find.byType(WDiv), + matching: find.byType(ConstrainedBox), + ); + expect(boxFinder, findsOneWidget); + final ConstrainedBox box = tester.widget(boxFinder); + expect(box.constraints.minWidth, 40.0); + expect(box.constraints.maxWidth, 40.0); + expect(box.constraints.minHeight, 80.0); + expect(box.constraints.maxHeight, 80.0); }); testWidgets('Parsing fractions', (tester) async { diff --git a/test/widgets/w_div/transition_test.dart b/test/widgets/w_div/transition_test.dart index b2b1f0d0..3c9ba49f 100644 --- a/test/widgets/w_div/transition_test.dart +++ b/test/widgets/w_div/transition_test.dart @@ -22,7 +22,7 @@ void main() { expect(find.byType(AnimatedContainer), findsOneWidget); }); - testWidgets('uses regular Container when no duration', (tester) async { + testWidgets('uses a static box when no duration', (tester) async { await tester.pumpWidget( MaterialApp( home: WindTheme( @@ -33,13 +33,14 @@ void main() { ), ); - // Should use Container, not AnimatedContainer - expect(find.byType(Container), findsWidgets); + // A static DecoratedBox, not AnimatedContainer. WDiv builds the static + // box from primitives, so there is no Container to find. + expect(find.byType(DecoratedBox), findsWidgets); expect(find.byType(AnimatedContainer), findsNothing); }); testWidgets( - 'uses regular Container when only ease is present without duration', + 'uses a static box when only ease is present without duration', (tester) async { await tester.pumpWidget( MaterialApp( @@ -54,8 +55,8 @@ void main() { ), ); - // Should use Container, not AnimatedContainer because duration is missing - expect(find.byType(Container), findsWidgets); + // A static DecoratedBox, not AnimatedContainer: duration is missing. + expect(find.byType(DecoratedBox), findsWidgets); expect(find.byType(AnimatedContainer), findsNothing); }, ); diff --git a/test/widgets/w_div_test.dart b/test/widgets/w_div_test.dart index f652006e..9e724b51 100644 --- a/test/widgets/w_div_test.dart +++ b/test/widgets/w_div_test.dart @@ -127,7 +127,7 @@ void main() { expect(paddingFinder, findsOneWidget); }); - testWidgets('renders Container with decoration when bg-red-500 is used', ( + testWidgets('renders a decorated box when bg-red-500 is used', ( tester, ) async { const testKey = Key('test-wdiv-bg'); @@ -145,11 +145,12 @@ void main() { ); // Assert - // Look for a Container that has a BoxDecoration with red color + // Look for the DecoratedBox carrying a red BoxDecoration. WDiv builds its + // box from primitives, so there is no Container around it. final containerFinder = find.descendant( of: find.byKey(testKey), matching: find.byWidgetPredicate((widget) { - if (widget is Container && widget.decoration is BoxDecoration) { + if (widget is DecoratedBox && widget.decoration is BoxDecoration) { final decoration = widget.decoration as BoxDecoration; return decoration.color == default_colors.colors['red']![500]; } @@ -282,7 +283,7 @@ void main() { }); testWidgets( - 'paints Container color when backgroundColor is provided ' + 'paints the box color when backgroundColor is provided ' 'without any bg-* className', (tester) async { const color = Color(0xFF123456); const testKey = Key('bg-only'); @@ -302,7 +303,7 @@ void main() { final containerFinder = find.descendant( of: find.byKey(testKey), matching: find.byWidgetPredicate((widget) { - if (widget is! Container) return false; + if (widget is! DecoratedBox) return false; final decoration = widget.decoration; return decoration is BoxDecoration && decoration.color == color; }), @@ -330,7 +331,7 @@ void main() { final containerFinder = find.descendant( of: find.byKey(testKey), matching: find.byWidgetPredicate((widget) { - if (widget is! Container) return false; + if (widget is! DecoratedBox) return false; final decoration = widget.decoration; return decoration is BoxDecoration && decoration.color == override; }), @@ -356,13 +357,13 @@ void main() { ), ); - final container = tester.widget( + final box = tester.widget( find.descendant( of: find.byKey(testKey), - matching: find.byType(Container), + matching: find.byType(DecoratedBox), ), ); - final decoration = container.decoration as BoxDecoration; + final decoration = box.decoration as BoxDecoration; expect(decoration.color, override); expect(decoration.gradient, isNull); expect(decoration.image, isNull); diff --git a/test/widgets/w_radio_test.dart b/test/widgets/w_radio_test.dart index 07960d8d..271b9234 100644 --- a/test/widgets/w_radio_test.dart +++ b/test/widgets/w_radio_test.dart @@ -25,12 +25,13 @@ Widget wrapWithPrimary(Widget child, MaterialColor primary) { /// Returns the first non-null `BoxDecoration` background color under [of]. Color? firstDecorationColor(WidgetTester tester, Finder of) { - final containers = tester.widgetList( - find.descendant(of: of, matching: find.byType(Container)), + // WDiv paints its fill through a DecoratedBox; it emits no Container. + final boxes = tester.widgetList( + find.descendant(of: of, matching: find.byType(DecoratedBox)), ); - for (final container in containers) { - final decoration = container.decoration; + for (final box in boxes) { + final decoration = box.decoration; if (decoration is BoxDecoration && decoration.color != null) { return decoration.color; } From e11a5a54c7d293da5337db83c964aaafd4df4983 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 29 Sep 2026 03:34:15 +0300 Subject: [PATCH 4/6] perf(helpers): wScreenIs and wScreenCurrent depend on the size aspect only Both read MediaQuery.of(context).size, so a shell that swaps its tree on wScreenIs(context, 'lg') rebuilt on every frame of a keyboard animation. They now read MediaQuery.sizeOf, as do WPopover's and WSelect's flip checks; wind no longer reads the whole MediaQueryData anywhere. --- CHANGELOG.md | 2 +- lib/src/utils/wind_helpers.dart | 5 ++- lib/src/widgets/w_popover.dart | 2 +- lib/src/widgets/w_select.dart | 2 +- test/utils/wind_helpers_test.dart | 61 +++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60962482..d4fefa49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **A `WDiv` whose only state classes are `hover:` or `active:` is no longer a Tab or D-pad stop, and its wrapper builds no `Focus`.** Its auto-wrapped `WAnchor` installed a focus node for every interactive className, so a hover-only row was a keyboard stop that lit nothing and paid for a node attachment plus a `Semantics(focusable:)` on every build; a consumer's dashboard carried about 193 of them among 399 anchors per frame. `WDiv` now passes `trackFocus: className.contains('focus:')`. A `focus:` div keeps its node and stop exactly as before, focus-within still lights a ring through a hover-only div in between (the ring's own node covers descendants), and primary focus chained from an anchor above still passes through, because it travels in `WindAnchorStateProvider`, which stays unconditional with `MouseRegion`. Gesture anchors are untouched. (`lib/src/widgets/w_anchor.dart`, `lib/src/widgets/w_div.dart`, `test/widgets/w_anchor/dpad_activation_test.dart`) - **`WDiv` builds its decorated box from primitives instead of a `Container`.** `Container` is a `StatelessWidget` that only stacks `Align`, `Padding`, `DecoratedBox` and `ConstrainedBox`, so it cost one element per decorated div on the hottest path (1032 per painted frame on the same dashboard). The primitives follow `Container.build`'s order and its three behaviours: a childless box expands through `LimitedBox`, padding is inset by the border widths, and `w-full` tightens the constraints so `max-w-*` still clamps it. A zero `Padding`, which `Container` emitted for every borderless decoration, is skipped. Geometry is pinned by `tester.getSize` cases written against the old `Container` first; `AnimatedContainer` and the inside-the-border clip are unchanged. `wrapperEmissions` now counts `DecoratedBox`, `ConstrainedBox`, `Padding`, `Align` and `LimitedBox` where it counted `Container`. Tests that read decoration off `find.byType(Container)` now read it off `DecoratedBox`. (`lib/src/widgets/w_div.dart`, `test/widgets/w_div/sizing_test.dart`, `test/utils/wind_perf_counters_test.dart`) -- **`WindContext.build` and the `h-full` fallback depend on `MediaQuery.sizeOf`, not the whole `MediaQueryData`.** Every parse subscribed every W-widget to all of `MediaQuery`, so each frame of a keyboard inset animation rebuilt every styled widget on screen. The size aspect still carries both axes, which `w-screen` / `h-screen` need, so `widthOf` would not do. A `viewInsets` change no longer rebuilds a plain `WDiv`; a size change still does. (`lib/src/parser/wind_context.dart`, `lib/src/widgets/w_div.dart`, `test/parser/wind_context_test.dart`) +- **`WindContext.build` and the `h-full` fallback depend on `MediaQuery.sizeOf`, not the whole `MediaQueryData`.** Every parse subscribed every W-widget to all of `MediaQuery`, so each frame of a keyboard inset animation rebuilt every styled widget on screen. The size aspect still carries both axes, which `w-screen` / `h-screen` need, so `widthOf` would not do. A `viewInsets` change no longer rebuilds a plain `WDiv`; a size change still does. `wScreenIs` and `wScreenCurrent` read the same aspect, so a shell that swaps its tree on `wScreenIs(context, 'lg')` no longer rebuilds on every keyboard frame, and `WPopover` / `WSelect`'s flip checks do too. (`lib/src/parser/wind_context.dart`, `lib/src/widgets/w_div.dart`, `lib/src/utils/wind_helpers.dart`, `lib/src/widgets/w_popover.dart`, `lib/src/widgets/w_select.dart`, `test/parser/wind_context_test.dart`, `test/utils/wind_helpers_test.dart`) ### Fixed diff --git a/lib/src/utils/wind_helpers.dart b/lib/src/utils/wind_helpers.dart index e908488f..84e31933 100644 --- a/lib/src/utils/wind_helpers.dart +++ b/lib/src/utils/wind_helpers.dart @@ -105,7 +105,8 @@ int? wScreen(BuildContext context, String name) { /// } /// ``` bool wScreenIs(BuildContext context, String name) { - final screenWidth = MediaQuery.of(context).size.width; + // The size aspect only, so a keyboard inset does not rebuild the caller. + final screenWidth = MediaQuery.sizeOf(context).width; final breakpointValue = wScreen(context, name); if (breakpointValue == null) return false; @@ -121,7 +122,7 @@ bool wScreenIs(BuildContext context, String name) { /// ``` String wScreenCurrent(BuildContext context) { final theme = WindTheme.dataOf(context); - final screenWidth = MediaQuery.of(context).size.width; + final screenWidth = MediaQuery.sizeOf(context).width; // Sort breakpoints by value descending final sortedBreakpoints = theme.screens.entries.toList() diff --git a/lib/src/widgets/w_popover.dart b/lib/src/widgets/w_popover.dart index 752ad29c..3948c635 100644 --- a/lib/src/widgets/w_popover.dart +++ b/lib/src/widgets/w_popover.dart @@ -517,7 +517,7 @@ class _WPopoverState extends State { _triggerKey.currentContext?.findRenderObject() as RenderBox?; if (triggerBox != null && triggerBox.hasSize) { final triggerPosition = triggerBox.localToGlobal(Offset.zero); - final screenSize = MediaQuery.of(context).size; + final screenSize = MediaQuery.sizeOf(context); // Estimate the overlay width the way the overlay itself sizes it, so the // flip decision matches the rendered box. A fixed width (`width` prop or a diff --git a/lib/src/widgets/w_select.dart b/lib/src/widgets/w_select.dart index 086a8494..e2b88b0d 100644 --- a/lib/src/widgets/w_select.dart +++ b/lib/src/widgets/w_select.dart @@ -401,7 +401,7 @@ class _WSelectState extends State> { _triggerKey.currentContext?.findRenderObject() as RenderBox?; if (triggerBox != null) { final triggerPosition = triggerBox.localToGlobal(Offset.zero); - final screenHeight = MediaQuery.of(context).size.height; + final screenHeight = MediaQuery.sizeOf(context).height; final spaceBelow = screenHeight - triggerPosition.dy - triggerBox.size.height; final menuMaxHeight = widget.maxMenuHeight; diff --git a/test/utils/wind_helpers_test.dart b/test/utils/wind_helpers_test.dart index 6928b644..b88b02a2 100644 --- a/test/utils/wind_helpers_test.dart +++ b/test/utils/wind_helpers_test.dart @@ -237,6 +237,50 @@ void main() { }); }); + group('screen helpers read the size aspect only', () { + Widget tree(MediaQueryData data) { + return MediaQuery( + data: data, + child: Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: WindThemeData(), + child: const _ScreenReader(), + ), + ), + ); + } + + testWidgets('a keyboard inset does not rebuild a breakpoint reader', ( + tester, + ) async { + // A shell that swaps its whole tree on `wScreenIs(context, 'lg')` + // rebuilt on every frame of a keyboard animation while it depended + // on all of MediaQuery. + const MediaQueryData closed = MediaQueryData(size: Size(390, 800)); + await tester.pumpWidget(tree(closed)); + final int builds = _ScreenReader.builds; + + await tester.pumpWidget( + tree(closed.copyWith(viewInsets: const EdgeInsets.only(bottom: 300))), + ); + + expect(_ScreenReader.builds, builds); + }); + + testWidgets('a size change still rebuilds it', (tester) async { + await tester + .pumpWidget(tree(const MediaQueryData(size: Size(390, 800)))); + final int builds = _ScreenReader.builds; + + await tester.pumpWidget( + tree(const MediaQueryData(size: Size(1280, 800))), + ); + + expect(_ScreenReader.builds, builds + 1); + }); + }); + group('wScreenCurrent', () { testWidgets('returns current breakpoint name', (tester) async { String? result; @@ -277,3 +321,20 @@ void main() { }); }); } + +/// Reads both screen helpers and counts its builds; const, so only a +/// dependency it registered can rebuild it. +class _ScreenReader extends StatelessWidget { + const _ScreenReader(); + + static int builds = 0; + + @override + Widget build(BuildContext context) { + builds++; + wScreenIs(context, 'lg'); + wScreenCurrent(context); + + return const SizedBox(); + } +} From df6b5a2c9b7b6f0df66a180051ea4b7f40319d30 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 29 Sep 2026 04:57:34 +0300 Subject: [PATCH 5/6] docs: describe WDiv's box primitives and the focus-node remount The widgets rule still described the Container WDiv no longer builds, and w-anchor did not say that adding or dropping a focus: class at runtime remounts the children below. --- .claude/rules/widgets.md | 4 ++-- doc/widgets/w-anchor.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/rules/widgets.md b/.claude/rules/widgets.md index 8d26df06..37523918 100644 --- a/.claude/rules/widgets.md +++ b/.claude/rules/widgets.md @@ -79,9 +79,9 @@ className: ''' ''', ``` -## Container elision +## Box elision -A `Container` is emitted only when `styles.decoration != null` OR a non-empty `boxShadow` / `ringShadow` exists. Padding-only, text-only, and `shadow-none` paths must stay Container-free. `WindStyle.copyWith` must NOT fabricate an empty `BoxDecoration`, or the `decoration != null` gate breaks and every styled widget grows a needless Container. +WDiv's non-animated box is `_buildBox`, `Container.build`'s own primitives in its order (`LimitedBox` + expand for a null child, `Align`, `Padding` inset by the border widths, `DecoratedBox`, `ConstrainedBox`), and each is emitted only when its input exists: a `DecoratedBox` only when `styles.decoration != null` OR a non-empty `boxShadow` / `ringShadow` exists, a `Padding` only when non-zero. Padding-only, text-only, and `shadow-none` paths must stay free of a `DecoratedBox`. `WindStyle.copyWith` must NOT fabricate an empty `BoxDecoration`, or the `decoration != null` gate breaks and every styled widget grows a needless one. `test/widgets/w_div/sizing_test.dart` pins the geometry against `Container`'s; `AnimatedContainer` stays on the transition path. ## What never goes in `lib/src/widgets/` diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index 47ae2f24..29799014 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -146,6 +146,8 @@ A `WDiv` whose only state classes are `hover:` or `active:` carries no focus nod Two shapes are unaffected. A `WDiv` carrying `focus:` with no anchor above it keeps its own focus node, because that is how a consumer styles a custom control. And a focusable descendant still lights the wrapper's ring: `FocusNode.hasFocus` covers descendants, so a `WInput` inside a ring-styled `WDiv` draws the ring around the field the user is typing in. +One consequence to know: whether the node exists follows the className, so a `WDiv` that adds or drops a `focus:` class at runtime changes its subtree's shape, and the children below remount and lose their `State` (a `WInput`'s text among it). Keep a `focus:` class present on a div whose children hold state, and switch what it paints instead. + What the wrapper inherits is deliberately narrow: the ancestor's PRIMARY focus, never its focus-within. It passes that signal on rather than stopping at it, because any `hover:` or `active:` class on a div in between creates a second wrapper whose own node never holds focus, and a ring two wrappers deep would otherwise stay dark. The two are different questions and `WindAnchorState` now exposes both. A tappable card containing a text field reports focus-within the whole time the user types, so a wrapper inheriting that would light up even when it sits *beside* the field rather than around it: ```dart From a8cdf754415e6588bc1b347592977c823f1f3c86 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 29 Sep 2026 13:23:13 +0300 Subject: [PATCH 6/6] fix(anchor): document the focus-within a hover-only div stops publishing, keep resolver snapshots out of the perf counters --- CHANGELOG.md | 4 +- doc/widgets/w-anchor.md | 2 + lib/src/debug_resolver.dart | 19 ++- skills/wind-ui/references/widgets.md | 2 +- test/debug_resolver_test.dart | 29 +++++ test/utils/wind_perf_counters_test.dart | 123 ++++++++++++++++++ .../w_anchor/dpad_activation_test.dart | 35 +++++ 7 files changed, 208 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4fefa49..29844071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Changed -- **A `WDiv` whose only state classes are `hover:` or `active:` is no longer a Tab or D-pad stop, and its wrapper builds no `Focus`.** Its auto-wrapped `WAnchor` installed a focus node for every interactive className, so a hover-only row was a keyboard stop that lit nothing and paid for a node attachment plus a `Semantics(focusable:)` on every build; a consumer's dashboard carried about 193 of them among 399 anchors per frame. `WDiv` now passes `trackFocus: className.contains('focus:')`. A `focus:` div keeps its node and stop exactly as before, focus-within still lights a ring through a hover-only div in between (the ring's own node covers descendants), and primary focus chained from an anchor above still passes through, because it travels in `WindAnchorStateProvider`, which stays unconditional with `MouseRegion`. Gesture anchors are untouched. (`lib/src/widgets/w_anchor.dart`, `lib/src/widgets/w_div.dart`, `test/widgets/w_anchor/dpad_activation_test.dart`) +- **A `WDiv` whose only state classes are `hover:` or `active:` is no longer a Tab or D-pad stop, and its wrapper builds no `Focus`.** Its auto-wrapped `WAnchor` installed a focus node for every interactive className, so a hover-only row was a keyboard stop that lit nothing and paid for a node attachment plus a `Semantics(focusable:)` on every build; a consumer's dashboard carried about 193 of them among 399 anchors per frame. `WDiv` now passes `trackFocus: className.contains('focus:')`. A `focus:` div keeps its node and stop exactly as before, focus-within still lights a ring through a hover-only div in between (the ring's own node covers descendants), and primary focus chained from an anchor above still passes through, because it travels in `WindAnchorStateProvider`, which stays unconditional with `MouseRegion`. Gesture anchors are untouched. What the hover-only div stops doing is publishing focus-within to its own children: a sibling label with a `focus:` class, as in `WDiv('hover:bg-gray-50', children: [WText('Email', className: 'focus:text-blue-600'), WInput(...)])`, used to light through the div's node while the field had focus and now stays unlit; adding any `focus:` class to the row brings the node back. A className string cannot see what its descendants style, so gating on them was not an option. (`lib/src/widgets/w_anchor.dart`, `lib/src/widgets/w_div.dart`, `test/widgets/w_anchor/dpad_activation_test.dart`) - **`WDiv` builds its decorated box from primitives instead of a `Container`.** `Container` is a `StatelessWidget` that only stacks `Align`, `Padding`, `DecoratedBox` and `ConstrainedBox`, so it cost one element per decorated div on the hottest path (1032 per painted frame on the same dashboard). The primitives follow `Container.build`'s order and its three behaviours: a childless box expands through `LimitedBox`, padding is inset by the border widths, and `w-full` tightens the constraints so `max-w-*` still clamps it. A zero `Padding`, which `Container` emitted for every borderless decoration, is skipped. Geometry is pinned by `tester.getSize` cases written against the old `Container` first; `AnimatedContainer` and the inside-the-border clip are unchanged. `wrapperEmissions` now counts `DecoratedBox`, `ConstrainedBox`, `Padding`, `Align` and `LimitedBox` where it counted `Container`. Tests that read decoration off `find.byType(Container)` now read it off `DecoratedBox`. (`lib/src/widgets/w_div.dart`, `test/widgets/w_div/sizing_test.dart`, `test/utils/wind_perf_counters_test.dart`) - **`WindContext.build` and the `h-full` fallback depend on `MediaQuery.sizeOf`, not the whole `MediaQueryData`.** Every parse subscribed every W-widget to all of `MediaQuery`, so each frame of a keyboard inset animation rebuilt every styled widget on screen. The size aspect still carries both axes, which `w-screen` / `h-screen` need, so `widthOf` would not do. A `viewInsets` change no longer rebuilds a plain `WDiv`; a size change still does. `wScreenIs` and `wScreenCurrent` read the same aspect, so a shell that swaps its tree on `wScreenIs(context, 'lg')` no longer rebuilds on every keyboard frame, and `WPopover` / `WSelect`'s flip checks do too. (`lib/src/parser/wind_context.dart`, `lib/src/widgets/w_div.dart`, `lib/src/utils/wind_helpers.dart`, `lib/src/widgets/w_popover.dart`, `lib/src/widgets/w_select.dart`, `test/parser/wind_context_test.dart`, `test/utils/wind_helpers_test.dart`) @@ -24,7 +24,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **`WindPerfCounters.recordInheritedRead` takes a `WindInheritedRead` enum value instead of a free string.** The four keys (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`) were previously any `String`, so a typo at a call site would silently sit outside the pinned four and never show up in `stats()`. The stats map keys are unchanged: the enum serializes through `.name`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/widgets/w_text.dart`, `lib/src/widgets/w_icon.dart`, `lib/src/widgets/w_svg.dart`, `lib/src/widgets/w_input.dart`, `lib/src/widgets/w_div.dart`, `lib/src/parser/wind_context.dart`, `test/utils/wind_perf_counters_test.dart`) - **`WindPerfCounters` gains `widgetBuilds`, `wrapperEmissions` and `inheritedReads`.** The existing six counters (`cacheHits`, `cacheMisses`, `cacheBypasses`, `cacheSize`, `wDivBuilds`, `wTextBuilds`) answered "how much did the style cache do" and "how many `WDiv`/`WText` builds ran", but nothing counted every OTHER W-widget's builds, the Flutter (or Wind-authored render-layer) wrapper widgets Wind inserts around content (`Container`, `Padding`, `MouseRegion`, `Semantics`, `DefaultTextStyle`, ...), or the four inherited-widget reads on the hot path (`mediaQuerySize`, `mediaQueryBrightness`, `windTheme`, `defaultTextStyle`). `widgetBuilds` and `wrapperEmissions` are `Map` keyed by type name; `inheritedReads` is fixed to the four keys above. Every increment is one inline call next to the emission or the read it counts, behind the existing `enabled` flag, so a disabled counter still costs one bool check and nothing is added, removed or reordered in the widget tree. `Wind.installPerfResolver()` publishes all nine keys through `WindDebugRegistry.currentPerf?.stats()`. (`lib/src/utils/wind_perf_counters.dart`, `lib/src/wind_facade.dart`, `lib/src/widgets/*.dart`, `test/utils/wind_perf_counters_test.dart`, `doc/core-concepts/debugging.md`, `skills/wind-ui/`) -- **`Wind.installDebugResolver()` / `Wind.installPerfResolver()` widen from `kDebugMode` to `!kReleaseMode`.** A profile build (`flutter run --profile`) now carries both resolvers, so a performance measurement session can run against a build that is actually representative of production timing; release still tree-shakes both. (`lib/src/wind_facade.dart`, `test/wind_facade_test.dart`) +- **`Wind.installDebugResolver()` / `Wind.installPerfResolver()` widen from `kDebugMode` to `!kReleaseMode`.** A profile build (`flutter run --profile`) now carries both resolvers, so a performance measurement session can run against a build that is actually representative of production timing; release still tree-shakes both. The debug resolver suspends `WindPerfCounters` while it resolves a widget, so a diagnostic snapshot taken during a measurement session adds nothing to `inheritedReads` or the cache counters. (`lib/src/wind_facade.dart`, `test/wind_facade_test.dart`) --- diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index 29799014..ccf12de9 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -144,6 +144,8 @@ WAnchor( A `WDiv` whose only state classes are `hover:` or `active:` carries no focus node at all (its wrapper gets `trackFocus: false`): it cannot draw focus, so a stop on it was a key press that lit nothing. Hover, press and the primary focus chained down from an anchor above it still pass through. +One thing it no longer publishes is focus-within to its own descendants. A `focus:` class on a label reads the nearest wrapper, so in `WDiv(className: 'hover:bg-gray-50', children: [WText('Email', className: 'focus:text-blue-600'), WInput(...)])` the label stays gray while the field has focus. Give the row any `focus:` class of its own (`hover:bg-gray-50 focus:bg-gray-50`) and its wrapper keeps the node, so the label lights again. + Two shapes are unaffected. A `WDiv` carrying `focus:` with no anchor above it keeps its own focus node, because that is how a consumer styles a custom control. And a focusable descendant still lights the wrapper's ring: `FocusNode.hasFocus` covers descendants, so a `WInput` inside a ring-styled `WDiv` draws the ring around the field the user is typing in. One consequence to know: whether the node exists follows the className, so a `WDiv` that adds or drops a `focus:` class at runtime changes its subtree's shape, and the children below remount and lose their `State` (a `WInput`'s text among it). Keep a `focus:` class present on a div whose children hold state, and switch what it paints instead. diff --git a/lib/src/debug_resolver.dart b/lib/src/debug_resolver.dart index 2f4ededa..d78e1a1f 100644 --- a/lib/src/debug_resolver.dart +++ b/lib/src/debug_resolver.dart @@ -7,6 +7,7 @@ import 'parser/wind_parser.dart'; // transitively re-exported by wind_parser.dart. Importing it here // is mandatory; without it Dart fails with "Undefined name 'WindStyle'". import 'parser/wind_style.dart'; +import 'utils/wind_perf_counters.dart'; /// Concrete WindDebugResolver wired into the registry at app boot /// via `Wind.installDebugResolver()`. Resolves up to 7 fields (5 always @@ -26,9 +27,21 @@ class WindDebugResolverImpl implements WindDebugResolver { if (className == null || className.isEmpty) { return const {}; } - // 2. Resolve context + style at snapshot time (Element IS a BuildContext) - final WindContext ctx = WindContext.build(element); - final WindStyle style = WindParser.parse(className, element); + // 2. Resolve context + style at snapshot time (Element IS a BuildContext). + // Counting is suspended around it: this is snapshot work, and since + // the resolver also runs in profile builds, next to a measurement + // session, counting it would add one inherited read and one cache + // lookup per inspected widget to reads no build caused. + final bool counting = WindPerfCounters.enabled; + WindPerfCounters.enabled = false; + final WindContext ctx; + final WindStyle style; + try { + ctx = WindContext.build(element); + style = WindParser.parse(className, element); + } finally { + WindPerfCounters.enabled = counting; + } // 3. Build the field map: 5 fields always present, bgColor + textColor // conditional on non-null resolved values (up to 7 total) when the // widget is a valid W-widget. diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index 33dc63f5..2e3821af 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -263,7 +263,7 @@ State tracking: Keyboard and remote activation: - `Actions` maps `ActivateIntent` and `ButtonActivateIntent` to `onTap`, and is installed only when `onTap != null && !isDisabled`. `WidgetsApp` raises those intents for `Enter`, `Space`, numpad `Enter`, gamepad A and `select` (the Android TV D-pad centre, the Apple TV remote click), so `WAnchor` binds no key itself and inherits whatever the platform adds. Only `onTap` is bound: `ActivateIntent` is the primary action and there is no second key for `onLongPress` / `onDoubleTap`. - The install gate is narrower than `hasGestures` on purpose. A `CallbackAction` is always enabled and `ShortcutManager` reports a key HANDLED for any enabled action, so an always-installed map made a long-press-only or disabled anchor swallow the activation key belonging to its parent, and on web beat `Space`'s `PrioritizedIntents([ActivateIntent, ScrollIntent])` race so the page stopped scrolling. An early return inside the callback does NOT fix that: the action still reports enabled. -- `Focus` is installed only when the anchor has a gesture or `trackFocus` is true. `WDiv` passes `trackFocus: className.contains('focus:')`, so a hover-only or active-only div has no node, is no Tab or D-pad stop, and publishes no `focusable` semantics. The provider and `MouseRegion` stay unconditional: every parse reads the provider, and hover needs the region. +- `Focus` is installed only when the anchor has a gesture or `trackFocus` is true. `WDiv` passes `trackFocus: className.contains('focus:')`, so a hover-only or active-only div has no node, is no Tab or D-pad stop, and publishes no `focusable` semantics. The provider and `MouseRegion` stay unconditional: every parse reads the provider, and hover needs the region. It also publishes no focus-within to its children: a `focus:` label beside a `WInput` under a hover-only row stays unlit, so give the row any `focus:` class when a sibling has to light. - `canRequestFocus: !isDisabled && (hasGestures || no ancestor anchor state)`. A gestureless `WAnchor` under another anchor is a styling wrapper, not a traversal stop, so one control costs one press of the remote. Standalone (nothing to inherit from) it keeps its node, because that is how a consumer styles a custom control. - A gestureless wrapper inherits the ancestor's `hasPrimaryFocus` and `isDisabled`, NOT its `isFocused`, and it REPUBLISHES `hasPrimaryFocus` with the inherited value ORed in so the signal chains: a wrapper's own node never holds primary focus, so stopping at it left a ring two wrappers deep dark, and any `hover:` / `active:` class on an intermediate div creates that second wrapper. `WindAnchorState.isFocused` is focus-WITHIN (it comes from `FocusNode.hasFocus`, true for an ancestor of the real holder), so inheriting it lit the ring on every wrapper under a tappable card while the user typed in a field inside that card, including wrappers sitting beside the field. The container case still works without inheritance: a ring-styled `WDiv` wrapping a `WInput` lights through its own node. `isHovering` is never inherited: hover is a pointer position and sibling divs inside one anchor highlight independently. diff --git a/test/debug_resolver_test.dart b/test/debug_resolver_test.dart index 4736e82e..65174d27 100644 --- a/test/debug_resolver_test.dart +++ b/test/debug_resolver_test.dart @@ -771,4 +771,33 @@ void main() { }, ); }); + + group('perf counters', () { + tearDown(() { + WindPerfCounters.enabled = false; + WindPerfCounters.reset(); + }); + + // The resolver runs in profile builds now, next to a measurement session. + // Its own WindContext.build and parse are snapshot work, not a build, so + // counting them inflated `inheritedReads` and the cache counters by one + // per inspected widget. + testWidgets('resolving a widget records nothing in the counters', ( + tester, + ) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'p-4 bg-blue-500')), + ); + final element = _firstElementOf(tester); + WindPerfCounters.enabled = true; + WindPerfCounters.reset(); + + final data = const WindDebugResolverImpl().resolve(element); + + expect(data['className'], 'p-4 bg-blue-500'); + expect(WindPerfCounters.inheritedReads.values, everyElement(0)); + expect(WindPerfCounters.cacheHits + WindPerfCounters.cacheMisses, 0); + expect(WindPerfCounters.enabled, isTrue); + }); + }); } diff --git a/test/utils/wind_perf_counters_test.dart b/test/utils/wind_perf_counters_test.dart index 03228f6f..398d83fb 100644 --- a/test/utils/wind_perf_counters_test.dart +++ b/test/utils/wind_perf_counters_test.dart @@ -458,4 +458,127 @@ void main() { expect(stats['cacheSize'], 0); }); }); + + // The less common wrapper paths each record their own emission, so a perf + // report can say which wrapper a frame paid for. Each case pumps the one + // shape that reaches its path and reads the counter it should move. + group('wrapper emissions and inherited reads on the rarer paths', () { + Future measure(WidgetTester tester, Widget app) async { + WindPerfCounters.enabled = true; + WindPerfCounters.reset(); + await tester.pumpWidget(app); + } + + testWidgets('WText padding and margin each emit a Padding', ( + tester, + ) async { + await measure( + tester, + wrapWithTheme(const WText('Latency', className: 'p-2 m-2')), + ); + + expect(WindPerfCounters.wrapperEmissions['Padding'], 2); + }); + + testWidgets('WText align-self and flex emit Align, Expanded and Flexible', + (tester) async { + await measure( + tester, + wrapWithTheme( + const WDiv( + className: 'flex flex-row', + children: [ + WText('Up', className: 'self-center'), + WText('Latency', className: 'flex-1'), + WText('Region', className: 'flex-initial'), + ], + ), + ), + ); + + expect(WindPerfCounters.wrapperEmissions['Align'], greaterThan(0)); + expect(WindPerfCounters.wrapperEmissions['Expanded'], 1); + expect(WindPerfCounters.wrapperEmissions['Flexible'], 1); + }); + + testWidgets('a clipped, padded box emits its Padding inside the clip', ( + tester, + ) async { + await measure( + tester, + wrapWithTheme( + const WDiv( + className: 'overflow-hidden rounded-lg border p-4', + child: WText('Status'), + ), + ), + ); + + expect(WindPerfCounters.wrapperEmissions['ClipRRect'], 1); + // Two: the border-width inset outside the clip, as `Container` does, + // and `p-4` moved inside it. + expect(WindPerfCounters.wrapperEmissions['Padding'], 2); + }); + + for (final String className in [ + 'h-1/2 max-h-20', + 'w-1/2 h-1/2 max-w-20', + ]) { + testWidgets('"$className" emits the ConstrainedBox that clamps it', ( + tester, + ) async { + await measure( + tester, + wrapWithTheme( + SizedBox( + height: 400, + child: WDiv(className: className, child: const WText('Chart')), + ), + ), + ); + + // Two: the box's own `max-*` constraint, and the clamp around the + // FractionallySizedBox, which cannot carry a max itself. + expect(WindPerfCounters.wrapperEmissions['ConstrainedBox'], 2); + }); + } + + testWidgets('a grid with unbounded width reads the media query size', ( + tester, + ) async { + Widget grid() => const WDiv( + className: 'grid grid-cols-2 gap-2', + children: [WText('A'), WText('B')], + ); + + await measure(tester, wrapWithTheme(grid())); + final int bounded = WindPerfCounters.inheritedReads['mediaQuerySize']!; + + await measure( + tester, + wrapWithTheme( + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: grid(), + ), + ), + ); + + expect(WindPerfCounters.inheritedReads['mediaQuerySize'], bounded + 1); + }); + + testWidgets('WInput without a WindTheme reads the platform brightness', ( + tester, + ) async { + await measure( + tester, + const MaterialApp(home: Scaffold(body: WInput(placeholder: 'Email'))), + ); + + expect( + WindPerfCounters.inheritedReads['mediaQueryBrightness'], + greaterThan(0), + ); + }); + }); } diff --git a/test/widgets/w_anchor/dpad_activation_test.dart b/test/widgets/w_anchor/dpad_activation_test.dart index f37d4c19..e64dc25b 100644 --- a/test/widgets/w_anchor/dpad_activation_test.dart +++ b/test/widgets/w_anchor/dpad_activation_test.dart @@ -635,5 +635,40 @@ void main() { ); expect(WindAnchorStateProvider.of(ring)?.isFocused, isTrue); }); + + // A sibling label reads focus from the nearest wrapper, so it lights only + // when that wrapper carries a `focus:` class of its own. A hover-only row + // used to light it through a node it no longer has; this pins the new + // behaviour and the one-class way back that the docs describe. + for (final (String className, bool lights) in <(String, bool)>[ + ('p-2 hover:bg-gray-50', false), + ('p-2 hover:bg-gray-50 focus:bg-gray-50', true), + ]) { + testWidgets( + 'a sibling label under "$className" ' + '${lights ? 'reads' : 'does not read'} the field\'s focus', ( + tester, + ) async { + await pump( + tester, + WDiv( + className: className, + children: const [ + WText('Email', className: 'focus:text-blue-600'), + WInput(placeholder: 'you@example.com'), + ], + ), + ); + + await tester.tap(find.byType(WInput)); + await tester.pump(); + + expect( + WindAnchorStateProvider.of(tester.element(find.text('Email'))) + ?.isFocused, + lights, + ); + }); + } }); }