• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kekland / inspector / 24732943568

21 Apr 2026 04:05PM UTC coverage: 46.197% (-1.2%) from 47.435%
24732943568

Pull #25

github

web-flow
Merge 7c16c3179 into 60f989097
Pull Request #25: feat: tap-to-compare mode and expanded property inspection

342 of 800 new or added lines in 11 files covered. (42.75%)

3 existing lines in 1 file now uncovered.

832 of 1801 relevant lines covered (46.2%)

1.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

49.29
/lib/src/inspector_controller.dart
1
import 'dart:async';
2
import 'dart:ui' as ui;
3

4
import 'package:flutter/foundation.dart';
5
import 'package:flutter/gestures.dart';
6
import 'package:flutter/material.dart';
7
import 'package:flutter/rendering.dart';
8
import 'package:flutter/services.dart';
9

10
import 'keyboard_handler.dart';
11
import 'utils.dart';
12
import 'widgets/color_picker/color_picker_snackbar.dart';
13
import 'widgets/color_picker/utils.dart';
14
import 'widgets/inspector/box_info.dart';
15

16
enum InspectorMode {
17
  none,
18
  inspector,
19
  inspectAndCompare,
20
  compareSelect,
21
  colorPicker,
22
  zoom,
23
}
24

25
class InspectorController {
26
  InspectorController({
3✔
27
    this.isEnabled = true,
28
    this.isWidgetInspectorEnabled = true,
29
    this.isWidgetInspectAndCompareEnabled = true,
30
    this.isColorPickerEnabled = true,
31
    this.isColorSchemeHintEnabled = true,
32
    this.isZoomEnabled = true,
33
    this.widgetInspectorShortcuts = const [
34
      LogicalKeyboardKey.alt,
35
      LogicalKeyboardKey.altLeft,
36
      LogicalKeyboardKey.altRight,
37
      LogicalKeyboardKey.meta,
38
      LogicalKeyboardKey.metaLeft,
39
      LogicalKeyboardKey.metaRight,
40
    ],
41
    this.widgetInspectAndCompareShortcuts = const [
42
      LogicalKeyboardKey.keyY,
43
    ],
44
    this.colorPickerShortcuts = const [
45
      LogicalKeyboardKey.shift,
46
      LogicalKeyboardKey.shiftLeft,
47
      LogicalKeyboardKey.shiftRight,
48
    ],
49
    this.zoomShortcuts = const [
50
      LogicalKeyboardKey.keyZ,
51
    ],
52
  }) {
53
    _keyboardHandler = KeyboardHandler(
6✔
54
      onInspectorStateChanged: (v) => _toggleMode(v, InspectorMode.inspector),
2✔
55
      onInspectAndCompareChanged: (v) =>
1✔
56
          _toggleMode(v, InspectorMode.inspectAndCompare),
1✔
57
      onColorPickerStateChanged: (v) =>
×
58
          _toggleMode(v, InspectorMode.colorPicker),
×
59
      onZoomStateChanged: (v) => _toggleMode(v, InspectorMode.zoom),
×
60
      colorPickerStateKeys: colorPickerShortcuts,
3✔
61
      inspectorStateKeys: widgetInspectorShortcuts,
3✔
62
      inspectAndCompareKeys: widgetInspectAndCompareShortcuts,
3✔
63
      zoomStateKeys: zoomShortcuts,
3✔
64
    );
65
  }
66

67
  final bool isEnabled;
68
  final bool isWidgetInspectorEnabled;
69
  final bool isWidgetInspectAndCompareEnabled;
70
  final bool isColorPickerEnabled;
71
  final bool isColorSchemeHintEnabled;
72
  final bool isZoomEnabled;
73

74
  final List<LogicalKeyboardKey> widgetInspectorShortcuts;
75
  final List<LogicalKeyboardKey> widgetInspectAndCompareShortcuts;
76
  final List<LogicalKeyboardKey> colorPickerShortcuts;
77
  final List<LogicalKeyboardKey> zoomShortcuts;
78

79
  final GlobalKey stackKey = GlobalKey();
80
  final GlobalKey repaintBoundaryKey = GlobalKey();
81
  final GlobalKey ignoringPointerKey = GlobalKey();
82

83
  final modeNotifier = ValueNotifier<InspectorMode>(InspectorMode.none);
84

85
  final byteDataStateNotifier = ValueNotifier<ByteData?>(null);
86

87
  final currentRenderBoxNotifier = ValueNotifier<BoxInfo?>(null);
88
  final hoveredRenderBoxNotifier = ValueNotifier<BoxInfo?>(null);
89
  final comparedRenderBoxNotifier = ValueNotifier<BoxInfo?>(null);
90

91
  final selectedColorOffsetNotifier = ValueNotifier<Offset?>(null);
92
  final selectedColorStateNotifier = ValueNotifier<Color?>(null);
93
  final selectedColorImageOffsetNotifier = ValueNotifier<Offset?>(null);
94

95
  final zoomImageOffsetNotifier = ValueNotifier<Offset?>(null);
96
  final zoomScaleNotifier = ValueNotifier<double>(2.0);
97
  final zoomOverlayOffsetNotifier = ValueNotifier<Offset?>(null);
98

99
  ui.Image? _image;
100
  ui.Image? get image => _image;
×
101
  Offset? _pointerHoverPosition;
102
  Timer? _onPointerHoverDebounce;
103
  late final KeyboardHandler _keyboardHandler;
104

105
  void registerKeyboardHandler() {
3✔
106
    _keyboardHandler.register();
6✔
107
  }
108

109
  void unregisterKeyboardHandler() {
×
110
    _keyboardHandler.dispose();
×
111
  }
112

113
  void dispose() {
3✔
114
    _image?.dispose();
3✔
115
    modeNotifier.dispose();
6✔
116
    byteDataStateNotifier.dispose();
6✔
117
    currentRenderBoxNotifier.dispose();
6✔
118
    hoveredRenderBoxNotifier.dispose();
6✔
119
    comparedRenderBoxNotifier.dispose();
6✔
120
    selectedColorOffsetNotifier.dispose();
6✔
121
    selectedColorStateNotifier.dispose();
6✔
122
    selectedColorImageOffsetNotifier.dispose();
6✔
123
    zoomImageOffsetNotifier.dispose();
6✔
124
    zoomScaleNotifier.dispose();
6✔
125
    zoomOverlayOffsetNotifier.dispose();
6✔
126
    _onPointerHoverDebounce?.cancel();
3✔
127
    _keyboardHandler.dispose();
6✔
128
  }
129

130
  void _toggleMode(bool enable, InspectorMode targetMode) {
2✔
131
    if (targetMode == InspectorMode.inspectAndCompare) {
2✔
132
      if (enable) {
133
        if (modeNotifier.value == InspectorMode.compareSelect) {
3✔
134
          exitCompareMode();
1✔
135
        } else {
136
          enterCompareMode();
1✔
137
        }
138
      }
139
      return;
140
    }
141

142
    if (enable) {
143
      setMode(targetMode);
1✔
144
    } else if (modeNotifier.value == targetMode) {
3✔
145
      setMode(InspectorMode.none);
1✔
146
    }
147
  }
148

149
  /// Enter compare mode: wait for the user to tap a second widget.
150
  void enterCompareMode() {
1✔
151
    if (currentRenderBoxNotifier.value == null) return;
2✔
152
    setMode(InspectorMode.compareSelect);
1✔
153
  }
154

155
  /// Exit compare mode and reset compare state.
156
  void exitCompareMode() {
1✔
157
    setMode(InspectorMode.inspector);
1✔
158
  }
159

160
  void setMode(InspectorMode mode, {BuildContext? context}) {
3✔
161
    if (mode == modeNotifier.value) return;
9✔
162

163
    // Check if mode is enabled
164
    switch (mode) {
165
      case InspectorMode.inspector:
3✔
166
        if (!isWidgetInspectorEnabled) return;
3✔
167
        break;
168
      case InspectorMode.inspectAndCompare:
2✔
169
        if (!isWidgetInspectorEnabled || !isWidgetInspectAndCompareEnabled) {
×
170
          return;
171
        }
172
        break;
173
      case InspectorMode.compareSelect:
2✔
174
        if (!isWidgetInspectorEnabled || !isWidgetInspectAndCompareEnabled) {
2✔
175
          return;
176
        }
177
        if (currentRenderBoxNotifier.value == null) return;
2✔
178
        break;
179
      case InspectorMode.colorPicker:
2✔
180
        if (!isColorPickerEnabled) return;
×
181
        break;
182
      case InspectorMode.zoom:
2✔
183
        if (!isZoomEnabled) return;
×
184
        break;
185
      case InspectorMode.none:
2✔
186
        break;
187
    }
188

189
    // Cleanup previous mode
190
    _cleanupMode(modeNotifier.value, mode, context);
9✔
191

192
    modeNotifier.value = mode;
6✔
193

194
    // Setup new mode
195
    _setupMode(mode);
3✔
196
  }
197

198
  void _cleanupMode(
3✔
199
      InspectorMode oldMode, InspectorMode newMode, BuildContext? context) {
200
    switch (oldMode) {
201
      case InspectorMode.inspector:
3✔
202
      case InspectorMode.inspectAndCompare:
3✔
203
      case InspectorMode.compareSelect:
3✔
204
        if (newMode != InspectorMode.inspector &&
2✔
205
            newMode != InspectorMode.inspectAndCompare &&
2✔
206
            newMode != InspectorMode.compareSelect) {
2✔
207
          currentRenderBoxNotifier.value = null;
4✔
208
          hoveredRenderBoxNotifier.value = null;
4✔
209
          comparedRenderBoxNotifier.value = null;
4✔
210
        } else {
211
          hoveredRenderBoxNotifier.value = null;
2✔
212
          comparedRenderBoxNotifier.value = null;
2✔
213
        }
214
        break;
215
      case InspectorMode.colorPicker:
3✔
216
        if (selectedColorStateNotifier.value != null && context != null) {
×
217
          showColorPickerResultSnackbar(
×
218
            context: context,
219
            color: selectedColorStateNotifier.value!,
×
220
          );
221
        }
222
        _cleanupImage();
×
223
        selectedColorOffsetNotifier.value = null;
×
224
        selectedColorStateNotifier.value = null;
×
225
        selectedColorImageOffsetNotifier.value = null;
×
226
        break;
227
      case InspectorMode.zoom:
3✔
228
        _cleanupImage();
×
229
        zoomImageOffsetNotifier.value = null;
×
230
        zoomOverlayOffsetNotifier.value = null;
×
231
        zoomScaleNotifier.value = 2.0;
×
232
        break;
233
      case InspectorMode.none:
3✔
234
        break;
235
    }
236
  }
237

238
  void _setupMode(InspectorMode mode) {
3✔
239
    switch (mode) {
240
      case InspectorMode.inspector:
3✔
241
      case InspectorMode.inspectAndCompare:
2✔
242
      case InspectorMode.compareSelect:
2✔
243
        break;
244
      case InspectorMode.colorPicker:
2✔
245
        WidgetsBinding.instance.addPostFrameCallback((_) {
×
246
          _extractByteData();
×
247
        });
248
        break;
249
      case InspectorMode.zoom:
2✔
250
        zoomScaleNotifier.value = 2.0;
×
251
        WidgetsBinding.instance.addPostFrameCallback((_) async {
×
252
          await _extractByteData();
×
253
          if (_pointerHoverPosition != null &&
×
254
              stackKey.currentContext != null) {
×
255
            _onZoomHover(_pointerHoverPosition!, stackKey.currentContext!);
×
256
          }
257
        });
258
        break;
259
      case InspectorMode.none:
2✔
260
        break;
261
    }
262
  }
263

264
  void _cleanupImage() {
×
265
    _image?.dispose();
×
266
    _image = null;
×
267
    byteDataStateNotifier.value = null;
×
268
  }
269

270
  void onTap(Offset? pointerOffset, BuildContext context) {
2✔
271
    final mode = modeNotifier.value;
4✔
272
    if (mode == InspectorMode.none) return;
2✔
273

274
    if (mode == InspectorMode.colorPicker) {
2✔
275
      if (pointerOffset != null) {
276
        _onColorPickerHover(pointerOffset, context);
×
277
      }
278
      setMode(InspectorMode.none, context: context);
×
279
      return;
280
    }
281

282
    if (mode == InspectorMode.zoom) {
2✔
283
      setMode(InspectorMode.none);
×
284
      return;
285
    }
286

287
    if (mode == InspectorMode.compareSelect) {
2✔
288
      if (pointerOffset == null) return;
289
      final compared = _computeBoxInfoAt(pointerOffset);
1✔
290
      setMode(InspectorMode.inspector);
1✔
291
      if (compared != null &&
292
          compared.targetRenderBox !=
2✔
293
              currentRenderBoxNotifier.value?.targetRenderBox) {
3✔
294
        comparedRenderBoxNotifier.value = compared;
2✔
295
      }
296
      return;
297
    }
298

299
    if (mode == InspectorMode.inspector ||
2✔
300
        mode == InspectorMode.inspectAndCompare) {
×
301
      if (pointerOffset == null) return;
302
      hoveredRenderBoxNotifier.value = null;
4✔
303
      comparedRenderBoxNotifier.value = null;
4✔
304
      currentRenderBoxNotifier.value = _computeBoxInfoAt(
6✔
305
        pointerOffset,
306
        findContainer: true,
307
      );
308
    }
309
  }
310

311
  void onPointerMove(Offset pointerOffset, BuildContext context) {
2✔
312
    _pointerHoverPosition = pointerOffset;
2✔
313
    final mode = modeNotifier.value;
4✔
314

315
    if (mode == InspectorMode.colorPicker) {
2✔
316
      _onColorPickerHover(pointerOffset, context);
×
317
    } else if (mode == InspectorMode.zoom) {
2✔
318
      _onZoomHover(pointerOffset, context);
×
319
    }
320
  }
321

322
  void onPointerHoverDebounced(Offset pointerOffset, BuildContext context) {
×
323
    if (_onPointerHoverDebounce?.isActive ?? false) return;
×
324
    _onPointerHoverDebounce = Timer(
×
325
      const Duration(milliseconds: 0),
326
      () => _onPointerHover(pointerOffset),
×
327
    );
328
  }
329

330
  void _onPointerHover(Offset pointerOffset) {
×
331
    _pointerHoverPosition = pointerOffset;
×
332
    final mode = modeNotifier.value;
×
333

334
    if (mode == InspectorMode.zoom) {
×
335
      final context = stackKey.currentContext;
×
336
      if (context != null) {
337
        _onZoomHover(pointerOffset, context);
×
338
      }
339
      return;
340
    }
341

342
    if (mode == InspectorMode.inspector ||
×
NEW
343
        mode == InspectorMode.inspectAndCompare ||
×
NEW
344
        mode == InspectorMode.compareSelect) {
×
345
      if (mode == InspectorMode.inspectAndCompare) {
×
346
        hoveredRenderBoxNotifier.value = null;
×
347
        final compare = _computeBoxInfoAt(pointerOffset);
×
348
        if (compare?.targetRenderBox !=
×
349
            currentRenderBoxNotifier.value?.targetRenderBox) {
×
350
          comparedRenderBoxNotifier.value = compare;
×
351
        } else {
352
          comparedRenderBoxNotifier.value = null;
×
353
        }
354
      } else {
355
        final hover = _computeBoxInfoAt(pointerOffset);
×
356
        if (hover?.targetRenderBox !=
×
357
            currentRenderBoxNotifier.value?.targetRenderBox) {
×
358
          hoveredRenderBoxNotifier.value = hover;
×
359
        } else {
360
          hoveredRenderBoxNotifier.value = null;
×
361
        }
362
      }
363
    }
364
  }
365

366
  void onPointerExit(Offset pointerOffset) {
×
367
    hoveredRenderBoxNotifier.value = null;
×
368
  }
369

370
  void onPointerScroll(PointerScrollEvent scrollEvent) {
×
371
    if (modeNotifier.value == InspectorMode.zoom) {
×
372
      final newValue =
373
          zoomScaleNotifier.value + 1.0 * -scrollEvent.scrollDelta.dy.sign;
×
374

375
      if (newValue < 1.0) {
×
376
        return;
377
      }
378

379
      zoomScaleNotifier.value = newValue;
×
380
    }
381
  }
382

383
  BoxInfo? _computeBoxInfoAt(Offset offset, {bool findContainer = false}) {
2✔
384
    if (ignoringPointerKey.currentContext == null) return null;
4✔
385

386
    final boxes = InspectorUtils.findRenderObjectsAt(
2✔
387
        ignoringPointerKey.currentContext!, offset);
4✔
388

389
    if (boxes.isEmpty) return null;
2✔
390

391
    if (stackKey.currentContext == null) return null;
4✔
392

393
    final overlayOffset =
394
        (stackKey.currentContext!.findRenderObject() as RenderStack)
6✔
395
            .localToGlobal(Offset.zero);
2✔
396

397
    return BoxInfo.fromHitTestResults(
2✔
398
      boxes,
399
      overlayOffset: overlayOffset,
400
      findContainer: findContainer,
401
    );
402
  }
403

404
  Future<void> _extractByteData() async {
×
405
    if (_image != null) return;
×
406
    if (repaintBoundaryKey.currentContext == null) return;
×
407

408
    final boundary = repaintBoundaryKey.currentContext!.findRenderObject()!
×
409
        as RenderRepaintBoundary;
410

411
    final context = repaintBoundaryKey.currentContext!;
×
412
    final pixelRatio = MediaQuery.of(context).devicePixelRatio;
×
413

414
    _image = await boundary.toImage(pixelRatio: pixelRatio);
×
415
    byteDataStateNotifier.value = await _image!.toByteData();
×
416
  }
417

418
  Offset _extractShiftedOffset(Offset offset, BuildContext context) {
×
419
    final pixelRatio = MediaQuery.of(context).devicePixelRatio;
×
420

421
    if (repaintBoundaryKey.currentContext == null) return Offset.zero;
×
422

423
    var _offset = (repaintBoundaryKey.currentContext!.findRenderObject()!
×
424
            as RenderRepaintBoundary)
425
        .globalToLocal(offset);
×
426

427
    _offset *= pixelRatio;
×
428

429
    return _offset;
430
  }
431

432
  void _onColorPickerHover(Offset offset, BuildContext context) {
×
433
    if (_image == null || byteDataStateNotifier.value == null) return;
×
434

435
    final shiftedOffset = _extractShiftedOffset(offset, context);
×
436
    final _x = shiftedOffset.dx.round();
×
437
    final _y = shiftedOffset.dy.round();
×
438

439
    final color = getPixelFromByteData(
×
440
      byteDataStateNotifier.value!,
×
441
      width: _image!.width,
×
442
      height: _image!.height,
×
443
      x: _x,
444
      y: _y,
445
    );
446

447
    if (color == null) return;
448

449
    selectedColorStateNotifier.value = color;
×
450
    selectedColorImageOffsetNotifier.value = shiftedOffset;
×
451

452
    if (stackKey.currentContext == null) return;
×
453

454
    final overlayOffset =
455
        (stackKey.currentContext!.findRenderObject() as RenderStack)
×
456
            .localToGlobal(Offset.zero);
×
457

458
    selectedColorOffsetNotifier.value = offset - overlayOffset;
×
459
  }
460

461
  void _onZoomHover(Offset offset, BuildContext context) {
×
462
    if (_image == null || byteDataStateNotifier.value == null) return;
×
463

464
    final shiftedOffset = _extractShiftedOffset(offset, context);
×
465

466
    if (stackKey.currentContext == null) return;
×
467

468
    final overlayOffset =
469
        (stackKey.currentContext!.findRenderObject() as RenderStack)
×
470
            .localToGlobal(Offset.zero);
×
471

472
    zoomImageOffsetNotifier.value = shiftedOffset;
×
473
    zoomOverlayOffsetNotifier.value = offset - overlayOffset;
×
474
  }
475
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc