From 7700fc7a83595c71db7374a39e30f2d17e443eb1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 2 Jul 2026 23:14:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20interactive=20CanvasView=20?= =?UTF-8?q?=E2=80=94=20pan,=20zoom,=20click-select=20(T-322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hitTestCanvasNode (topmost node under a point, cards over the group frames behind them, via the same viewport the painter draws with) and the CanvasView widget: scroll-zoom, drag-pan, and click-to-select with a focus ring. Node drag/resize and edit affordances follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/builtin/canvas/src/canvas_painter.dart | 15 +++ lib/builtin/canvas/src/canvas_view.dart | 74 +++++++++++++ test/builtin/canvas/canvas_view_test.dart | 119 +++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 lib/builtin/canvas/src/canvas_view.dart create mode 100644 test/builtin/canvas/canvas_view_test.dart diff --git a/lib/builtin/canvas/src/canvas_painter.dart b/lib/builtin/canvas/src/canvas_painter.dart index 1dc11fb3..f6b3d207 100644 --- a/lib/builtin/canvas/src/canvas_painter.dart +++ b/lib/builtin/canvas/src/canvas_painter.dart @@ -74,6 +74,21 @@ Color? canvasContentColor(String? spec) { }; } +/// The id of the topmost node under [local], or null. Uses the same +/// [CanvasViewport.fit] the painter draws with, so a click lands on what's +/// shown. Cards (drawn last) win over the group frames behind them. +String? hitTestCanvasNode(CanvasDoc doc, Offset local, Size size, {double zoom = 1, Offset pan = Offset.zero}) { + if (doc.isEmpty) return null; + final vp = CanvasViewport.fit(size, CanvasBounds.of(doc), zoom: zoom, pan: pan); + for (final n in doc.nodes.reversed) { + if (n is! GroupNode && vp.rectOf(n).contains(local)) return n.id; + } + for (final n in doc.nodes.reversed) { + if (n is GroupNode && vp.rectOf(n).contains(local)) return n.id; + } + return null; +} + Color? _hex(String s) { var h = s.substring(1); if (h.length == 3) h = h.split('').map((c) => '$c$c').join(); diff --git a/lib/builtin/canvas/src/canvas_view.dart b/lib/builtin/canvas/src/canvas_view.dart new file mode 100644 index 00000000..e2ceaf47 --- /dev/null +++ b/lib/builtin/canvas/src/canvas_view.dart @@ -0,0 +1,74 @@ +/// The interactive `.canvas` view (T-322): lays a [CanvasDoc] out with +/// [CanvasPainter] and wires pan (drag), zoom (scroll wheel), and click-select +/// via [hitTestCanvasNode] so the selection ring lands on what's drawn. Node +/// drag/resize and edit affordances layer on later. +library; + +import 'package:clide/builtin/canvas/src/canvas_painter.dart'; +import 'package:clide/src/canvas/json_canvas.dart'; +import 'package:clide/widgets/src/clide_settings.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +class CanvasView extends StatefulWidget { + const CanvasView({super.key, required this.doc, this.onSelect}); + + final CanvasDoc doc; + + /// Fired with the selected node's id (or null when the click misses). + final void Function(String? nodeId)? onSelect; + + @override + State createState() => _CanvasViewState(); +} + +class _CanvasViewState extends State { + static const double _minZoom = 0.2, _maxZoom = 5; + + double _zoom = 1; + Offset _pan = Offset.zero; + String? _selected; + + @override + void didUpdateWidget(CanvasView old) { + super.didUpdateWidget(old); + if (!identical(old.doc, widget.doc)) { + _zoom = 1; + _pan = Offset.zero; + _selected = null; + } + } + + void _onScroll(PointerScrollEvent e) { + final factor = e.scrollDelta.dy < 0 ? 1.1 : 0.9; + final next = (_zoom * factor).clamp(_minZoom, _maxZoom); + if (next != _zoom) setState(() => _zoom = next); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideSettings.theme.of(context).surface; + return LayoutBuilder( + builder: (ctx, constraints) { + final size = constraints.biggest; + return Listener( + onPointerSignal: (s) { + if (s is PointerScrollEvent) _onScroll(s); + }, + child: GestureDetector( + onTapUp: (d) { + final hit = hitTestCanvasNode(widget.doc, d.localPosition, size, zoom: _zoom, pan: _pan); + if (hit != _selected) setState(() => _selected = hit); + widget.onSelect?.call(hit); + }, + onPanUpdate: (d) => setState(() => _pan += d.delta), + child: CustomPaint( + size: size, + painter: CanvasPainter(doc: widget.doc, tokens: tokens, zoom: _zoom, pan: _pan, selected: _selected), + ), + ), + ); + }, + ); + } +} diff --git a/test/builtin/canvas/canvas_view_test.dart b/test/builtin/canvas/canvas_view_test.dart new file mode 100644 index 00000000..5f30d447 --- /dev/null +++ b/test/builtin/canvas/canvas_view_test.dart @@ -0,0 +1,119 @@ +import 'package:clide/builtin/canvas/src/canvas_painter.dart'; +import 'package:clide/builtin/canvas/src/canvas_view.dart'; +import 'package:clide/src/canvas/json_canvas.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Widget view(CanvasDoc doc, {void Function(String?)? onSelect, double side = 400}) => anchoredHarness( + f, + SizedBox( + width: side, + height: side, + child: CanvasView(doc: doc, onSelect: onSelect), + ), + ); + + group('hitTestCanvasNode', () { + test('finds the card under the point; cards beat the group behind them', () { + const doc = CanvasDoc( + nodes: [ + GroupNode(id: 'g', x: 0, y: 0, width: 400, height: 400), + TextNode(id: 't', x: 150, y: 150, width: 100, height: 100, text: 'hi'), + ], + ); + // 400×400 doc into an 800×600 canvas, scale 1.5, centred. The text node's + // centre (200,200 in canvas coords) → the card wins over the group. + const size = Size(800, 600); + final vp = CanvasViewport.fit(size, CanvasBounds.of(doc)); + final centre = vp.rectOf(doc.nodes[1]).center; + expect(hitTestCanvasNode(doc, centre, size), 't'); + // A point inside the group but outside the card hits the group. + final groupOnly = vp.rectOf(doc.nodes[0]).topLeft + const Offset(4, 4); + expect(hitTestCanvasNode(doc, groupOnly, size), 'g'); + }); + + test('returns null off any node and on an empty doc', () { + const doc = CanvasDoc( + nodes: [TextNode(id: 't', x: 0, y: 0, width: 10, height: 10, text: '')], + ); + expect(hitTestCanvasNode(doc, const Offset(-999, -999), const Size(400, 400)), isNull); + expect(hitTestCanvasNode(const CanvasDoc(), Offset.zero, const Size(400, 400)), isNull); + }); + }); + + testWidgets('tapping a node selects it, tapping empty space clears it', (tester) async { + String? picked = '__unset__'; + // One node fills the padded canvas, so its centre is the view centre. + const doc = CanvasDoc( + nodes: [TextNode(id: 'only', x: 0, y: 0, width: 100, height: 100, text: 'hi')], + ); + await tester.pumpWidget(view(doc, onSelect: (id) => picked = id)); + await tester.pump(); + + await tester.tap(find.byType(CanvasView)); // centre → the node + expect(picked, 'only'); + + await tester.tapAt(tester.getTopLeft(find.byType(CanvasView)) + const Offset(3, 3)); // padding → miss + expect(picked, isNull); + }); + + testWidgets('dragging pans; a reverse drag restores the hit location', (tester) async { + String? picked = '__unset__'; + // Two small, spread-out nodes so each renders small enough that a pan moves + // it clear of its old screen spot (a single node would fill the pane). + const doc = CanvasDoc( + nodes: [ + TextNode(id: 'a', x: 0, y: 0, width: 60, height: 60, text: 'a'), + TextNode(id: 'b', x: 400, y: 400, width: 60, height: 60, text: 'b'), + ], + ); + await tester.pumpWidget(view(doc, onSelect: (id) => picked = id)); + await tester.pump(); + final origin = tester.getTopLeft(find.byType(CanvasView)); + final size = tester.getSize(find.byType(CanvasView)); + final aSpot = origin + CanvasViewport.fit(size, CanvasBounds.of(doc)).rectOf(doc.nodes[0]).center; + + await tester.tapAt(aSpot); + expect(picked, 'a'); // baseline hit + + await tester.drag(find.byType(CanvasView), const Offset(140, 0)); + await tester.pump(); + await tester.tapAt(aSpot); + expect(picked, isNull); // node a panned away from its old spot + + await tester.drag(find.byType(CanvasView), const Offset(-140, 0)); // equal, opposite → net zero + await tester.pump(); + await tester.tapAt(aSpot); + expect(picked, 'a'); // panned back + }); + + testWidgets('a scroll signal zooms without throwing', (tester) async { + const doc = CanvasDoc( + nodes: [TextNode(id: 'a', x: 0, y: 0, width: 100, height: 100, text: '')], + ); + await tester.pumpWidget(view(doc)); + await tester.pump(); + final centre = tester.getCenter(find.byType(CanvasView)); + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(centre)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -120))); + await tester.pump(); + expect(find.byType(CanvasView), findsOneWidget); + }); + + testWidgets('an empty canvas takes a tap without throwing', (tester) async { + await tester.pumpWidget(view(const CanvasDoc())); + await tester.pump(); + await tester.tap(find.byType(CanvasView)); + expect(find.byType(CanvasView), findsOneWidget); + }); +}