diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b4ddff..f343a478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,11 @@ list every change. of a `TabBarView`, get no outline or marker. Their position read as NaN, so stepping through a card's widgets could start on one, say "Showing", and count every tab's widgets. +- A widget that a scroll view clips away, such as a list row scrolled + under the app bar, gets no outline or marker, and an outline covers only + the part of a widget that shows. A list builds rows past its edge, so a + card's outline could start on a hidden row and count rows the screen did + not show. A flipped or scaled widget is outlined where it shows. ## 0.38.0 diff --git a/CLAUDE.md b/CLAUDE.md index 283ecbc2..58a83539 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Runtime performance diagnostics package for Flutter mobile apps. 21 detectors ac ```bash # Always use fvm for all Flutter/Dart commands -fvm flutter test --exclude-tags benchmark # Default run (~5,102 tests; wall-clock benchmarks excluded) +fvm flutter test --exclude-tags benchmark # Default run (~5,109 tests; wall-clock benchmarks excluded) fvm flutter test --tags benchmark --concurrency=1 # Wall-clock benchmarks, serial (44 tests) fvm flutter test test/detectors/ # Run detector tests only fvm flutter analyze # Static analysis (must be 0 issues) @@ -81,7 +81,7 @@ test/ **v1.0.0 API**: `lib/sleuth.dart` exports with `show` lists only (62 names + `Sleuth`); `lib/validation.dart` holds `EvidenceTier`, `DetectorMetadata`(+Provider), `BracketSpec`, `ComponentMetadata`(+Provider), `ProfileCaptureSchema`, `PollTimings`, `StreamResourcePollResult`, `kSleuthPackageVersion` and Repaint/MemoryPressure/Startup/TrackedResource/ImageMemory/GpuPressure/FrameTiming detectors, all `@experimental` (example `analysis_options.yaml` ignores `experimental_member_use`); 15 internal names hidden (tests import `src/`). `StartupTracker` (`lib/src/startup/`) owns startup state (`metrics`, `dartEntryMonotonicUs`, `enrichWithVmData`); `ActiveController.current` / `release` replace `Sleuth._controller` / `notifyControllerDisposed`. Capture statics on `Sleuth` `@experimental`, test statics `@visibleForTesting`. `RouteSession.end()` / `countScanCycle()` `@internal`. Theme: 29 unread tokens + `confidenceColor`/`effortColor`/`sourceAccentColor` removed. Growing enums documented. `test/validation/public_api_test.dart` pins names against `test/validation/_fixtures/public_api_1_0_0.txt` (80 names); CI `api-diff` job: `dart pub unpack` newest 1.x, drop `lib/validation.dart` from both trees, `dart_apitool` 0.23.2 `--version-check-mode onlyBreakingChanges` (fails only a breaking change without a major bump; `fully` also failed minor changes) (added enum value = minor `CF02`; removing an `@experimental` top-level is still breaking), skips before 1.0.0 is on pub.dev, fails on a pub.dev error. API table `doc/api_1_0_0.md`. -**v1.0.0 overlay**: header run label ("Profile · VM", dot filled with VM / amber ring without) + `MoreMenu` (Startup, Rebuild stats, Hidden issues, Encyclopedia, Guide, Copy snapshot, Theme Auto/Light/Dark); severity chips lead the status row (turning one off is refused when no card would remain; the frame rate and the chipless row label are semantics containers); Rebuild stats reads "Off" unless `SleuthController.countsRouteRebuilds` (profile + `enableDeepDebugInstrumentation`); 320 px min width; no footer. `IssueCard`: `SleuthSeverityIcon` (light presets outline warning/OK via `edgeOf`), meta line (category · confidence or Debug timing tag · "seen X of Y scans, label" · effects, Jank/New tags), crosshair 48 px. Expanded: Measured row with `MeasurementGauge` (scale 1.25 × the higher line, 100 for a percent; a value past it fills the track with two slanted cuts; a debug rate critical on its latest window, `HeldRate.latestRate` → `extraTraceArgs.latestRatePerSec`, reads "Critical: the latest second went over the line" instead of the held note), Where (`file:line` per element) / Route / When / Evidence ("... Sleuth rates it possible, since ...") / Check in rows, Copy details and the Ask AI prompt add `measuredSentence` (`copyDetailsText`), Fix with code blocks, buttons Show on screen / Learn more / Ask AI / Copy details / Hide / Not useful, Why it rebuilds (`DebugSnapshot.forcedRebuildsOf`). `status_lines.dart`: Starting / Warming up / No VM link / No issues found / VM link lost / stopped retrying (no VM-lost toast), one debug banner. Highlight flow: crosshair → `selectHighlightForIssue` (`HighlightLookup` found/notFound/pending, no category fallback) → card shrinks to `SleuthHighlightBar` ("Showing X" / "X is off screen", Back to issues, close; edge away from the widget, shield for a double tap, `highlightBarAtTopNotifier` for the label); outline only (markers = "Mark flagged widgets" toggle), in the target issue's severity (`highlightTargetSeverity`), not painted while the widget is off screen; an issue with an `occurrenceId` takes only its element's highlight, a name match skips list-tagged highlights; with the bar up Escape leaves an app dialog or sheet to the app; controller collects highlights while `_highlightTarget` / pending. List cards (`SleuthController.listsWidgets`: always_repaint_painter, missing_repaint_boundary, layout_bottleneck, uncached_images, expensive_gpu_nodes) step through highlights tagged `WidgetHighlight.stableId` + element `occurrenceId`: first on screen, `highlightStepNotifier` (shown place frozen between Next/lookup, refreshed on 0/1/many count change), `nextHighlightStep` (off screen → widget now at its place), `canStepNext`; bar shows "· 1 of 3" + Next and a second row only with Next (GlobalKey keeps Back to issues), shields its old rect when it moves or shrinks. Card list: each card sits in `_CardSlot`, which keeps its `IssueCard` widget while `IssueCard.sameInputsAs` holds (every constructor field; issues by `utils/issue_fingerprint.dart` `issueFingerprint` + `occurrenceId`, callbacks by presence; `issue_card_test` reads the constructor to pin the list) and `_cardEpoch` stays (moves on severity filter / hidden-key change, a change of the shown `(listKey, occurrenceId)` set, `didUpdateWidget`, `didChangeDependencies`, `reassemble`: what `_relatedLinkOf` reads while a card builds); kept cards' callbacks act on the row's latest report (`latest()`). `OverlayUiState.schemaVersion` 2: `loadJson` maps a version 1 hide key without a widget name to an any-widget key (`legacyHideKey`, `base|*` / `base|*!critical`), which `isHiddenBy` / `hideKeysOf` / the Hidden page match for every widget of that id. The Hidden page labels keys with `hideKeyLabel` (`id · Widget`, `id · every widget`, critical suffix left out). Jank tag: `SleuthController.heldJankCauses()` (issues a verdict named in the last `FrameTimingDetector.jankWindow`, recorded by a `verdictNotifier` listener on the controller's monotonic clock or `clockOverrideForTest`, cleared on a route change beside `markRouteEpoch`, so the hold survives closing the dashboard), shown only while a jank symptom is reported; the card arms one timer for `nextExpiry`. Toasts in inverted theme colours (`toastColorsOf`). Example `ext.sleuthDemo.overlay` actions `menu` / `notUseful`. `getGlobalRect` returns null for a non-finite rect (a kept-alive page that is not painted, such as another `TabBarView` tab, maps to NaN), so those widgets get no highlight; `SimpleStructuralDetector` uses it too. The 1.0 card replaced 0.37 internals named in the v0.37.0 paragraphs below: the frozen zone is `_frozen` (`List`, built by `applyFreezeZone` and `_expandInZone`) in place of `_expandedIndices` / `_orderSnapshot` / `_repointExpansions`; the status row's `_SeverityChip`s replace `_IssuesSummaryBar` / `_SeverityChipPill`; the meta line replaces `_badgeLine` / `_inlineBadgesWidth`; `_LeadTrailLine` is gone. Example `FileSleuthStateStore` writes under `overlayStateDirectory` (Android `files` beside `code_cache`, iOS `Library/Application Support` beside `tmp`; installs empty `code_cache`). +**v1.0.0 overlay**: header run label ("Profile · VM", dot filled with VM / amber ring without) + `MoreMenu` (Startup, Rebuild stats, Hidden issues, Encyclopedia, Guide, Copy snapshot, Theme Auto/Light/Dark); severity chips lead the status row (turning one off is refused when no card would remain; the frame rate and the chipless row label are semantics containers); Rebuild stats reads "Off" unless `SleuthController.countsRouteRebuilds` (profile + `enableDeepDebugInstrumentation`); 320 px min width; no footer. `IssueCard`: `SleuthSeverityIcon` (light presets outline warning/OK via `edgeOf`), meta line (category · confidence or Debug timing tag · "seen X of Y scans, label" · effects, Jank/New tags), crosshair 48 px. Expanded: Measured row with `MeasurementGauge` (scale 1.25 × the higher line, 100 for a percent; a value past it fills the track with two slanted cuts; a debug rate critical on its latest window, `HeldRate.latestRate` → `extraTraceArgs.latestRatePerSec`, reads "Critical: the latest second went over the line" instead of the held note), Where (`file:line` per element) / Route / When / Evidence ("... Sleuth rates it possible, since ...") / Check in rows, Copy details and the Ask AI prompt add `measuredSentence` (`copyDetailsText`), Fix with code blocks, buttons Show on screen / Learn more / Ask AI / Copy details / Hide / Not useful, Why it rebuilds (`DebugSnapshot.forcedRebuildsOf`). `status_lines.dart`: Starting / Warming up / No VM link / No issues found / VM link lost / stopped retrying (no VM-lost toast), one debug banner. Highlight flow: crosshair → `selectHighlightForIssue` (`HighlightLookup` found/notFound/pending, no category fallback) → card shrinks to `SleuthHighlightBar` ("Showing X" / "X is off screen", Back to issues, close; edge away from the widget, shield for a double tap, `highlightBarAtTopNotifier` for the label); outline only (markers = "Mark flagged widgets" toggle), in the target issue's severity (`highlightTargetSeverity`), not painted while the widget is off screen; an issue with an `occurrenceId` takes only its element's highlight, a name match skips list-tagged highlights; with the bar up Escape leaves an app dialog or sheet to the app; controller collects highlights while `_highlightTarget` / pending. List cards (`SleuthController.listsWidgets`: always_repaint_painter, missing_repaint_boundary, layout_bottleneck, uncached_images, expensive_gpu_nodes) step through highlights tagged `WidgetHighlight.stableId` + element `occurrenceId`: first on screen, `highlightStepNotifier` (shown place frozen between Next/lookup, refreshed on 0/1/many count change), `nextHighlightStep` (off screen → widget now at its place), `canStepNext`; bar shows "· 1 of 3" + Next and a second row only with Next (GlobalKey keeps Back to issues), shields its old rect when it moves or shrinks. Card list: each card sits in `_CardSlot`, which keeps its `IssueCard` widget while `IssueCard.sameInputsAs` holds (every constructor field; issues by `utils/issue_fingerprint.dart` `issueFingerprint` + `occurrenceId`, callbacks by presence; `issue_card_test` reads the constructor to pin the list) and `_cardEpoch` stays (moves on severity filter / hidden-key change, a change of the shown `(listKey, occurrenceId)` set, `didUpdateWidget`, `didChangeDependencies`, `reassemble`: what `_relatedLinkOf` reads while a card builds); kept cards' callbacks act on the row's latest report (`latest()`). `OverlayUiState.schemaVersion` 2: `loadJson` maps a version 1 hide key without a widget name to an any-widget key (`legacyHideKey`, `base|*` / `base|*!critical`), which `isHiddenBy` / `hideKeysOf` / the Hidden page match for every widget of that id. The Hidden page labels keys with `hideKeyLabel` (`id · Widget`, `id · every widget`, critical suffix left out). Jank tag: `SleuthController.heldJankCauses()` (issues a verdict named in the last `FrameTimingDetector.jankWindow`, recorded by a `verdictNotifier` listener on the controller's monotonic clock or `clockOverrideForTest`, cleared on a route change beside `markRouteEpoch`, so the hold survives closing the dashboard), shown only while a jank symptom is reported; the card arms one timer for `nextExpiry`. Toasts in inverted theme colours (`toastColorsOf`). Example `ext.sleuthDemo.overlay` actions `menu` / `notUseful`. `getGlobalRect` returns null for a non-finite rect (a kept-alive page that is not painted, such as another `TabBarView` tab, maps to NaN), so those widgets get no highlight; `SimpleStructuralDetector` uses it too. `_publishHighlights` publishes what shows: for a highlight measured from a box, `getVisibleGlobalRect` (transformed corners via `MatrixUtils.transformRect`, cut to the ancestors' `describeApproximatePaintClip` in one walk to the root; null when detached, not finite or wholly clipped, such as a list row in the cache extent); `_builtHighlights` keeps the scan's candidates in scan order, hidden ones included (reset by a scan, navigation or collection off), and `refreshHighlightRects` republishes the attached ones, so a row scrolled into view gets its highlight before the next scan. The 1.0 card replaced 0.37 internals named in the v0.37.0 paragraphs below: the frozen zone is `_frozen` (`List`, built by `applyFreezeZone` and `_expandInZone`) in place of `_expandedIndices` / `_orderSnapshot` / `_repointExpansions`; the status row's `_SeverityChip`s replace `_IssuesSummaryBar` / `_SeverityChipPill`; the meta line replaces `_badgeLine` / `_inlineBadgesWidth`; `_LeadTrailLine` is gone. Example `FileSleuthStateStore` writes under `overlayStateDirectory` (Android `files` beside `code_cache`, iOS `Library/Application Support` beside `tmp`; installs empty `code_cache`). **v0.38.0**: detectors-only release on top of 0.37.0. Jank is a missed deadline with Sleuth's own start delay excluded, the VM timeline comes from the `Timeline` stream outside capture mode, new `event_loop_stall`, `material_ui` / `cupertino_ui` support, GC counted once per collection and young-only, `heap_growing` floor leg, 12 more runtimeVerified pairs (27 across 19 stable ids). Sidecar `sleuth_mcp` 0.9.0. The paragraphs below hold the detail per area. diff --git a/doc/release_notes/1.0.0.md b/doc/release_notes/1.0.0.md index 8148669f..04c93d7c 100644 --- a/doc/release_notes/1.0.0.md +++ b/doc/release_notes/1.0.0.md @@ -363,6 +363,21 @@ The full list of changes in sleuth 1.0.0. [CHANGELOG.md](../../CHANGELOG.md) has tabbed screen could start on another tab's image, say "Showing Image" and count the images of every tab. `getGlobalRect` now returns null for a rect that is not finite, as for one it cannot measure. +- A widget that a clip hides gets no outline, marker or step, and an + outline covers only the part of a widget that shows. A list builds rows + past each edge (its cache extent, 250 px by default), and those rows + passed the on-screen check wherever the screen covered them. On a chat + screen, stepping through Oversized Images started on an avatar scrolled + under the app bar, drew its outline over the header and counted the + hidden rows. The overlay now draws only the part of each highlight that + shows. The new `getVisibleGlobalRect` cuts the box to each ancestor's + paint clip (`describeApproximatePaintClip`, which a scroll view answers + with its edge), and a highlight with no part showing is left out. The + controller keeps those hidden highlights while their widget stays + built, so a row that scrolls into view gets its outline while the list + scrolls, without waiting for the next scan. The rect comes from the + box's transformed corners instead of its transformed origin plus its + size, so a flipped or scaled widget is outlined where it shows. ### Theme diff --git a/lib/src/controller/sleuth_controller.dart b/lib/src/controller/sleuth_controller.dart index 5c6af0c8..b620cb80 100644 --- a/lib/src/controller/sleuth_controller.dart +++ b/lib/src/controller/sleuth_controller.dart @@ -86,7 +86,7 @@ import '../utils/own_work_log.dart'; import '../utils/session_markdown_exporter.dart'; import '../utils/session_uuid.dart'; import '../utils/type_name_cache.dart'; -import '../utils/widget_location.dart' show getGlobalRect; +import '../utils/widget_location.dart' show getVisibleGlobalRect; import '../vm/timeline_parser.dart'; import '../startup/startup_tracker.dart'; @@ -1019,8 +1019,8 @@ class SleuthController { HighlightLookup _selectHighlight(PerformanceIssue issue) { if (listsWidgets(issue)) { - // Built widgets include ones scrolled out of view (a list's cache - // extent); the outline starts on the first one on screen. + // A widget past the screen's edge keeps its highlight unless a + // clip hides it; the outline starts on the first one on screen. final steps = _stepsOf(issue, highlightsNotifier.value.items); final screen = _screenRect(); final first = steps.indexWhere( @@ -3237,6 +3237,7 @@ class SleuthController { // FrameVerdict / detector pipeline is skipped for this tick. _scrollIdleTimer?.cancel(); _interactionState = InteractionContext.navigating; + _builtHighlights = const []; if (highlightsNotifier.value.items.isNotEmpty) { _highlightGeneration++; highlightsNotifier.value = ( @@ -3420,9 +3421,15 @@ class SleuthController { _collectHighlights(); _resolvePendingHighlight(); - } else if (highlightsNotifier.value.items.isNotEmpty) { - _highlightGeneration++; - highlightsNotifier.value = (generation: _highlightGeneration, items: []); + } else { + _builtHighlights = const []; + if (highlightsNotifier.value.items.isNotEmpty) { + _highlightGeneration++; + highlightsNotifier.value = ( + generation: _highlightGeneration, + items: [], + ); + } } } @@ -3768,6 +3775,12 @@ class SleuthController { /// callback, so a burst of scroll notifications costs one refresh. bool _highlightRectRefreshScheduled = false; + /// The last scan's highlights whose widgets are still built, in scan + /// order, including ones a clip hides (a list row in the list's cache + /// extent). A rect refresh publishes them again, so a row scrolled into + /// view gets its highlight before the next scan. + List _builtHighlights = const []; + /// Re-measures the current highlights' rects from their render objects /// after the next frame (scroll moved them). Highlights whose render /// object is gone, detached or unsized are dropped. Detector state is @@ -3775,44 +3788,58 @@ class SleuthController { void refreshHighlightRects() { if (_disposed || _highlightRectRefreshScheduled) return; if (!_collectingHighlights) return; - if (highlightsNotifier.value.items.isEmpty) return; + if (_builtHighlights.isEmpty) return; _highlightRectRefreshScheduled = true; SchedulerBinding.instance.addPostFrameCallback((_) { _highlightRectRefreshScheduled = false; if (_disposed || !_collectingHighlights) return; - final current = highlightsNotifier.value.items; - if (current.isEmpty) return; - final items = []; - for (final h in current) { - final ro = h.renderObject; - if (ro == null || !ro.attached) continue; - if (ro is! RenderBox || !ro.hasSize) continue; - final rect = getGlobalRect(ro); - if (rect == null) continue; - items.add( - WidgetHighlight( - rect: rect, - widgetName: h.widgetName, - severity: h.severity, - detectorName: h.detectorName, - detail: h.detail, - renderObject: ro, - occurrenceId: h.occurrenceId, - stableId: h.stableId, - ), - ); - } - _publishHighlights(items); + if (_builtHighlights.isEmpty) return; + _publishHighlights([ + for (final h in _builtHighlights) + if (h.renderObject case final RenderBox ro + when ro.attached && ro.hasSize) + h, + ]); }); // Scrolling already has a frame pending; this only matters when the // request arrives between frames. SchedulerBinding.instance.ensureVisualUpdate(); } - /// Publishes [items] under a new generation and rebinds the selected - /// highlight by detectorName + widgetName (first match wins; cleared when - /// the widget is gone). - void _publishHighlights(List items) { + /// Publishes the part of each of [candidates] that shows, under a new + /// generation, and rebinds the selected highlight by detectorName + + /// widgetName (first match wins; cleared when the widget is gone). + /// + /// A highlight measured from a box is measured again + /// ([getVisibleGlobalRect]) and left out while a clip hides all of it; + /// [_builtHighlights] keeps it for the next refresh. Others are + /// published as given. + void _publishHighlights(List candidates) { + _builtHighlights = candidates; + final items = []; + for (final h in candidates) { + final ro = h.renderObject; + if (ro is! RenderBox) { + items.add(h); + continue; + } + final rect = getVisibleGlobalRect(ro); + if (rect == null) continue; + items.add( + rect == h.rect + ? h + : WidgetHighlight( + rect: rect, + widgetName: h.widgetName, + severity: h.severity, + detectorName: h.detectorName, + detail: h.detail, + renderObject: ro, + occurrenceId: h.occurrenceId, + stableId: h.stableId, + ), + ); + } _highlightGeneration++; highlightsNotifier.value = (generation: _highlightGeneration, items: items); final selected = selectedHighlightNotifier.value; @@ -4227,6 +4254,8 @@ class SleuthController { } } if (!anyHighlights) { + // The last scan's hidden rows are no longer reported. + _builtHighlights = const []; // Defensive: clear stale selection if somehow non-null while // highlights are empty (shouldn't happen, but safe-guards against // future code paths that set selection without highlights). diff --git a/lib/src/models/widget_highlight.dart b/lib/src/models/widget_highlight.dart index 75aaf16a..313dce0b 100644 --- a/lib/src/models/widget_highlight.dart +++ b/lib/src/models/widget_highlight.dart @@ -34,8 +34,10 @@ class WidgetHighlight { final String? detail; /// The render object [rect] was measured from. Lets the overlay re-measure - /// [rect] after a scroll without rescanning the tree. Null for highlights - /// built without one; those are dropped by a rect-only refresh. + /// [rect] after a scroll without rescanning the tree. For a box, the + /// overlay shows the part its ancestors' clips leave visible, and nothing + /// while a clip (such as a list's edge) hides all of it. Null for + /// highlights built without one; those are dropped by a rect-only refresh. final RenderObject? renderObject; /// Identity of the element this highlight was measured from: its diff --git a/lib/src/utils/widget_location.dart b/lib/src/utils/widget_location.dart index 05483011..26b0ef17 100644 --- a/lib/src/utils/widget_location.dart +++ b/lib/src/utils/widget_location.dart @@ -134,6 +134,9 @@ String? extractPackageNameFromElement(Element element) { /// child outside its viewport (a `TabBarView` page that is not showing) a /// zero paint transform, so its global position comes out NaN, and a NaN /// rect passes every `overlaps` check. +/// +/// The rect is where the box is laid out, which a clip can hide; the +/// overlay shows what [getVisibleGlobalRect] measures. Rect? getGlobalRect(RenderObject ro) { try { if (ro is RenderBox && ro.hasSize) { @@ -148,3 +151,49 @@ Rect? getGlobalRect(RenderObject ro) { } return null; } + +/// The part of [ro]'s box that shows on screen, in global coordinates. +/// Its bounds are cut to its ancestors' clips, such as a scroll view's edge. +/// +/// Null when none of the box shows, when it is detached or unsized, and +/// when its position is not finite (see [getGlobalRect]). A list lays out +/// rows past its edge (its cache extent), and a row hidden there under an +/// app bar would otherwise count as on screen. +Rect? getVisibleGlobalRect(RenderObject ro) { + try { + if (ro is RenderBox && ro.attached && ro.hasSize) { + final path = [ro]; + for (var node = ro.parent; node != null; node = node.parent) { + path.add(node); + } + // The walk localToGlobal makes, reading each ancestor's clip on the + // way. Like localToGlobal, it leaves out the root view's transform, + // which maps logical pixels to physical ones. + final transform = Matrix4.identity(); + Rect? clip; + for (var i = path.length - 2; i > 0; i--) { + final parent = path[i]; + final child = path[i - 1]; + final local = parent.describeApproximatePaintClip(child); + if (local != null) { + final global = MatrixUtils.transformRect(transform, local); + clip = clip?.intersect(global) ?? global; + } + parent.applyPaintTransform(child, transform); + } + // The box's corners, not its origin plus its size, so a flipped or + // scaled box is measured where it shows. + final rect = MatrixUtils.transformRect(transform, Offset.zero & ro.size); + if (!rect.isFinite) return null; + if (clip == null) return rect; + if (clip.isEmpty || !rect.overlaps(clip)) return null; + return rect.intersect(clip); + } + } catch (e, s) { + assert(() { + debugPrint('Sleuth: getVisibleGlobalRect failed: $e\n$s'); + return true; + }()); + } + return null; +} diff --git a/test/controller/highlight_behavior_test.dart b/test/controller/highlight_behavior_test.dart index 6ef7637e..2c4797f1 100644 --- a/test/controller/highlight_behavior_test.dart +++ b/test/controller/highlight_behavior_test.dart @@ -1115,6 +1115,57 @@ void main() { expect(c.selectedHighlightNotifier.value, same(after)); }); + testWidgets('a highlight scrolled past the list edge comes back with the ' + 'scroll, before the next scan', (tester) async { + final c = await pumpOverlay(tester); + var ticks = 0; + c.scanTickNotifier.addListener(() => ticks++); + const issue = PerformanceIssue( + severity: IssueSeverity.warning, + category: IssueCategory.build, + confidence: IssueConfidence.possible, + title: 'T', + detail: 'D', + fixHint: 'F', + widgetName: 'Target', + ); + expect(c.selectHighlightForIssue(issue), HighlightLookup.found); + Iterable targets() => c.highlightsNotifier.value.items + .where((h) => h.detectorName == 'Target'); + + // Row 5 sits at y 250 to 300. Scrolled by 320 it stays built in the + // list's cache extent, but the list no longer shows it. + scroll.jumpTo(320); + await tester.pump(); + expect(targets(), isEmpty); + expect(c.selectedHighlightNotifier.value, isNull); + + scroll.jumpTo(0); + await tester.pump(); + expect(targets().single.rect, const Rect.fromLTWH(0, 250, 800, 50)); + expect(c.selectedHighlightNotifier.value, same(targets().single)); + expect(ticks, 0); + }); + + testWidgets('a row the list hid when the scan ran gets its highlight ' + 'when it scrolls into view, before the next scan', (tester) async { + final c = await pumpOverlay(tester); + Iterable targets() => c.highlightsNotifier.value.items + .where((h) => h.detectorName == 'Target'); + + scroll.jumpTo(320); + await tester.pump(); + c.scanTreeFullPathForTest(tester.element(find.byType(MaterialApp))); + expect(targets(), isEmpty); + var ticks = 0; + c.scanTickNotifier.addListener(() => ticks++); + + scroll.jumpTo(0); + await tester.pump(); + expect(targets().single.rect, const Rect.fromLTWH(0, 250, 800, 50)); + expect(ticks, 0); + }); + testWidgets('a highlight whose widget was removed is dropped', ( tester, ) async { diff --git a/test/controller/highlight_steps_test.dart b/test/controller/highlight_steps_test.dart index f8f06b35..3a40d93c 100644 --- a/test/controller/highlight_steps_test.dart +++ b/test/controller/highlight_steps_test.dart @@ -99,6 +99,54 @@ void main() { expect(c.highlightStepNotifier.value, isNull); }); + testWidgets('steps start on the first row the list shows and count only ' + 'the rows it shows', (tester) async { + // The list shows y 100 to 300; scrolled by 75 it builds rows 0 to 10 + // (its cache extent) and shows rows 1 to 5. + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(top: 100), + child: SizedBox( + width: 100, + height: 200, + child: ListView.builder( + controller: ScrollController(initialScrollOffset: 75), + itemExtent: 50, + itemCount: 20, + itemBuilder: (_, i) => SizedBox(key: Key('row-$i')), + ), + ), + ), + ), + ), + ); + final c = SleuthController(); + addTearDown(c.dispose); + c.scanTickNotifier.value = 1; + c.publishHighlightsForTest([ + for (var i = 0; i <= 10; i++) + _h( + i, + renderObject: tester.renderObject( + find.byKey(Key('row-$i'), skipOffstage: false), + ), + ), + ]); + + final items = c.highlightsNotifier.value.items; + expect([for (final h in items) h.occurrenceId], [1, 2, 3, 4, 5]); + expect(items.first.rect, const Rect.fromLTRB(0, 100, 100, 125)); + expect( + c.selectHighlightForIssue(_issue('always_repaint_painter')), + HighlightLookup.found, + ); + expect(c.selectedHighlightNotifier.value?.occurrenceId, 1); + expect(c.highlightStepNotifier.value, (index: 0, count: 5)); + }); + testWidgets('a rect refresh after a scroll keeps the step', (tester) async { final keys = [GlobalKey(), GlobalKey()]; await tester.pumpWidget( diff --git a/test/utils/widget_location_test.dart b/test/utils/widget_location_test.dart index 9d3b717a..794e71ca 100644 --- a/test/utils/widget_location_test.dart +++ b/test/utils/widget_location_test.dart @@ -3,6 +3,7 @@ // implement-only kind. // ignore_for_file: deprecated_member_use import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sleuth/src/utils/widget_location.dart'; @@ -219,10 +220,103 @@ void main() { final shown = tester.renderObject(find.byKey(const Key('page-1'))); expect(getGlobalRect(hidden), isNull); expect(getGlobalRect(shown), isNotNull); + expect(getVisibleGlobalRect(hidden), isNull); + expect(getVisibleGlobalRect(shown), getGlobalRect(shown)); + }); + }); + + group('getVisibleGlobalRect', () { + test('returns null for a laid-out box that is not attached', () { + final box = RenderConstrainedBox( + additionalConstraints: BoxConstraints.tight(const Size(10, 10)), + )..layout(const BoxConstraints()); + expect(box.hasSize, isTrue); + expect(getVisibleGlobalRect(box), isNull); + }); + + testWidgets('cuts a list row to the list edge and is null for a row ' + 'built past it', (tester) async { + // The list shows y 100 to 300; scrolled by 75, row k sits at + // 25 + 50k, so rows 0 and 6 are built in the cache extent while the + // screen (600 tall) still covers them. + await tester.pumpWidget(_scrolledList(Clip.hardEdge)); + RenderObject row(int i) => + tester.renderObject(find.byKey(Key('row-$i'), skipOffstage: false)); + + expect(getVisibleGlobalRect(row(0)), isNull); + expect( + getVisibleGlobalRect(row(1)), + const Rect.fromLTRB(0, 100, 100, 125), + ); + expect( + getVisibleGlobalRect(row(2)), + const Rect.fromLTRB(0, 125, 100, 175), + ); + expect(getVisibleGlobalRect(row(6)), isNull); + }); + + testWidgets('measures a flipped box under a clip where it shows', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: ClipRect( + child: Transform.flip( + flipX: true, + child: const SizedBox( + key: Key('flipped'), + width: 100, + height: 50, + ), + ), + ), + ), + ), + ); + // The box's origin maps to its right edge, x 100. + expect( + getVisibleGlobalRect( + tester.renderObject(find.byKey(const Key('flipped'))), + ), + const Rect.fromLTWH(0, 0, 100, 50), + ); + }); + + testWidgets('keeps the whole rect when the list does not clip', ( + tester, + ) async { + await tester.pumpWidget(_scrolledList(Clip.none)); + final row = tester.renderObject( + find.byKey(const Key('row-0'), skipOffstage: false), + ); + expect(getVisibleGlobalRect(row), const Rect.fromLTRB(0, 25, 100, 75)); }); }); } +Widget _scrolledList(Clip clipBehavior) => MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(top: 100), + child: SizedBox( + width: 100, + height: 200, + child: ListView.builder( + controller: ScrollController(initialScrollOffset: 75), + clipBehavior: clipBehavior, + itemExtent: 50, + itemCount: 20, + itemBuilder: (_, i) => SizedBox(key: Key('row-$i')), + ), + ), + ), + ), +); + // --- Test widgets (public names so they appear in ancestor chains) --- class TestLeafWidget extends StatelessWidget {