diff --git a/lib/builtin/canvas/src/canvas_painter.dart b/lib/builtin/canvas/src/canvas_painter.dart new file mode 100644 index 00000000..1dc11fb3 --- /dev/null +++ b/lib/builtin/canvas/src/canvas_painter.dart @@ -0,0 +1,213 @@ +/// Paints a [CanvasDoc] (T-322): group frames, edges, and node cards fitted +/// into the pane with a shared pan/zoom [CanvasViewport] — so hit-testing (a +/// later slice) lands exactly on what's drawn. +/// +/// A `.canvas` is arbitrary user content, so node *colours* come from the file +/// (Obsidian presets `1`..`6` or `#rrggbb`), NOT clide's theme tokens; only the +/// chrome — the surface, default card, selection ring — is themed. +library; + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/src/canvas/json_canvas.dart'; +import 'package:flutter/widgets.dart'; + +/// The axis-aligned bounds of a doc's node rects, in canvas coordinates. +class CanvasBounds { + const CanvasBounds(this.left, this.top, this.right, this.bottom); + + final double left, top, right, bottom; + double get width => right - left; + double get height => bottom - top; + + factory CanvasBounds.of(CanvasDoc doc) { + if (doc.nodes.isEmpty) return const CanvasBounds(0, 0, 1, 1); + var l = double.infinity, t = double.infinity, r = double.negativeInfinity, b = double.negativeInfinity; + for (final n in doc.nodes) { + l = math.min(l, n.x); + t = math.min(t, n.y); + r = math.max(r, n.x + n.width); + b = math.max(b, n.y + n.height); + } + return CanvasBounds(l, t, r, b); + } +} + +/// Fits a doc's [content] bounds into the pane (aspect-preserving, padded, and +/// centred), then applies the user's [zoom] (about the pane centre) and [pan]. +class CanvasViewport { + CanvasViewport(this.scale, this.dx, this.dy); + final double scale, dx, dy; + + factory CanvasViewport.fit(Size canvas, CanvasBounds content, {double zoom = 1, Offset pan = Offset.zero, double padding = 24}) { + final cw = content.width <= 0 ? 1.0 : content.width; + final ch = content.height <= 0 ? 1.0 : content.height; + final avw = math.max(1.0, canvas.width - 2 * padding); + final avh = math.max(1.0, canvas.height - 2 * padding); + final base = math.min(avw / cw, avh / ch); + final scale = (base.isFinite && base > 0 ? base : 1.0) * zoom; + final dx = canvas.width / 2 + pan.dx - scale * (content.left + cw / 2); + final dy = canvas.height / 2 + pan.dy - scale * (content.top + ch / 2); + return CanvasViewport(scale, dx, dy); + } + + Offset toPixel(double x, double y) => Offset(dx + x * scale, dy + y * scale); + + Rect rectOf(CanvasNode n) => Rect.fromLTWH(dx + n.x * scale, dy + n.y * scale, n.width * scale, n.height * scale); +} + +/// The content colour of a node/edge: an Obsidian preset `"1".."6"`, a +/// `#rgb`/`#rrggbb` hex, or null when unset. Kept theme-independent on purpose. +Color? canvasContentColor(String? spec) { + if (spec == null || spec.isEmpty) return null; + if (spec.startsWith('#')) return _hex(spec); + return switch (spec) { + '1' => const Color(0xFFFB464C), // red + '2' => const Color(0xFFE9973F), // orange + '3' => const Color(0xFFE0DE71), // yellow + '4' => const Color(0xFF44CF6E), // green + '5' => const Color(0xFF53DFDD), // cyan + '6' => const Color(0xFFA882FF), // purple + _ => null, + }; +} + +Color? _hex(String s) { + var h = s.substring(1); + if (h.length == 3) h = h.split('').map((c) => '$c$c').join(); + if (h.length != 6) return null; + final v = int.tryParse(h, radix: 16); + return v == null ? null : Color(0xFF000000 | v); +} + +class CanvasPainter extends CustomPainter { + CanvasPainter({required this.doc, required this.tokens, this.zoom = 1, this.pan = Offset.zero, this.selected}); + + final CanvasDoc doc; + final SurfaceTokens tokens; + final double zoom; + final Offset pan; + + /// The id of the currently-selected node, drawn with a focus ring. + final String? selected; + + @override + void paint(ui.Canvas canvas, Size size) { + if (doc.isEmpty) return; + final vp = CanvasViewport.fit(size, CanvasBounds.of(doc), zoom: zoom, pan: pan); + final byId = {for (final n in doc.nodes) n.id: n}; + + // Groups sit behind everything as translucent framed regions. + for (final n in doc.nodes.whereType()) { + _group(canvas, vp, n); + } + for (final e in doc.edges) { + _edge(canvas, vp, e, byId); + } + for (final n in doc.nodes) { + if (n is! GroupNode) _card(canvas, vp, n); + } + } + + void _group(ui.Canvas canvas, CanvasViewport vp, GroupNode n) { + final rect = vp.rectOf(n); + final tint = canvasContentColor(n.color) ?? tokens.globalTextMuted; + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(6)), Paint()..color = tint.withValues(alpha: 0.06)); + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(6)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = tint.withValues(alpha: 0.5), + ); + if (n.label != null && n.label!.isNotEmpty) { + _text(canvas, n.label!, Offset(rect.left + 6, rect.top + 4), tint, maxWidth: rect.width - 12, size: 11); + } + } + + void _card(ui.Canvas canvas, CanvasViewport vp, CanvasNode n) { + final rect = vp.rectOf(n); + final content = canvasContentColor(n.color); + final border = content ?? tokens.globalBorder; + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(5)), Paint()..color = content?.withValues(alpha: 0.10) ?? tokens.panelBackground); + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(5)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = n.id == selected ? 2 : 1 + ..color = n.id == selected ? tokens.globalFocus : border, + ); + final label = switch (n) { + TextNode(:final text) => text, + FileNode(:final file) => _basename(file), + LinkNode(:final url) => url, + _ => '', + }; + if (label.isNotEmpty && rect.width > 16 && rect.height > 12) { + _text(canvas, label, Offset(rect.left + 6, rect.top + 5), tokens.globalForeground, maxWidth: rect.width - 12, maxLines: (rect.height ~/ 16).clamp(1, 6)); + } + } + + void _edge(ui.Canvas canvas, CanvasViewport vp, CanvasEdge e, Map byId) { + final from = byId[e.fromNode], to = byId[e.toNode]; + if (from == null || to == null) return; + final a = _anchor(vp.rectOf(from), e.fromSide); + final b = _anchor(vp.rectOf(to), e.toSide); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = canvasContentColor(e.color) ?? tokens.dividerColor; + canvas.drawLine(a, b, paint); + if (e.toEnd == CanvasEnd.arrow) _arrowhead(canvas, b, a, paint.color); + if (e.fromEnd == CanvasEnd.arrow) _arrowhead(canvas, a, b, paint.color); + } + + /// The attach point on a node rect: the midpoint of [side], or the centre. + Offset _anchor(Rect r, CanvasSide? side) => switch (side) { + CanvasSide.top => r.topCenter, + CanvasSide.bottom => r.bottomCenter, + CanvasSide.left => r.centerLeft, + CanvasSide.right => r.centerRight, + null => r.center, + }; + + void _arrowhead(ui.Canvas canvas, Offset tip, Offset from, Color color) { + final dir = (tip - from); + final len = dir.distance; + if (len < 0.01) return; + final u = dir / len; + const size = 7.0; + final base = tip - u * size; + final perp = Offset(-u.dy, u.dx) * (size * 0.5); + final path = Path() + ..moveTo(tip.dx, tip.dy) + ..lineTo(base.dx + perp.dx, base.dy + perp.dy) + ..lineTo(base.dx - perp.dx, base.dy - perp.dy) + ..close(); + canvas.drawPath(path, Paint()..color = color); + } + + void _text(ui.Canvas canvas, String text, Offset at, Color color, {required double maxWidth, int maxLines = 1, double size = 12}) { + if (maxWidth < 4) return; + final tp = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontSize: size, color: color), + ), + textDirection: TextDirection.ltr, + maxLines: maxLines, + ellipsis: '…', + )..layout(maxWidth: maxWidth); + tp.paint(canvas, at); + } + + static String _basename(String path) { + final slash = path.lastIndexOf('/'); + return slash >= 0 ? path.substring(slash + 1) : path; + } + + @override + bool shouldRepaint(CanvasPainter old) => !identical(old.doc, doc) || old.tokens != tokens || old.zoom != zoom || old.pan != pan || old.selected != selected; +} diff --git a/test/builtin/canvas/canvas_painter_test.dart b/test/builtin/canvas/canvas_painter_test.dart new file mode 100644 index 00000000..d407c581 --- /dev/null +++ b/test/builtin/canvas/canvas_painter_test.dart @@ -0,0 +1,113 @@ +import 'dart:ui' as ui; + +import 'package:clide/builtin/canvas/src/canvas_painter.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/src/canvas/json_canvas.dart'; +import 'package:clide/widgets/src/clide_settings.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()); + + CanvasDoc sampleDoc() => const CanvasDoc( + nodes: [ + TextNode(id: 't', x: 0, y: 0, width: 200, height: 100, text: 'hello', color: '4'), + FileNode(id: 'f', x: 300, y: 0, width: 200, height: 100, file: 'notes/a.md'), + ], + edges: [CanvasEdge(id: 'e', fromNode: 't', toNode: 'f', fromSide: CanvasSide.right, toSide: CanvasSide.left)], + ); + + Future tokensFrom(WidgetTester tester) async { + late SurfaceTokens tokens; + await tester.pumpWidget( + anchoredHarness( + f, + Builder( + builder: (ctx) { + tokens = ClideSettings.theme.of(ctx).surface; + return const SizedBox(); + }, + ), + ), + ); + return tokens; + } + + Future hasInk(CanvasPainter painter, [double s = 200]) async { + final rec = ui.PictureRecorder(); + painter.paint(ui.Canvas(rec, Rect.fromLTWH(0, 0, s, s)), Size(s, s)); + final img = await rec.endRecording().toImage(s.round(), s.round()); + final data = (await img.toByteData())!; + for (var i = 3; i < data.lengthInBytes; i += 4) { + if (data.getUint8(i) != 0) return true; + } + return false; + } + + group('canvasContentColor', () { + test('maps presets, hex (long + short), and null/unknown', () { + expect(canvasContentColor('4'), const Color(0xFF44CF6E)); + expect(canvasContentColor('#ff0000'), const Color(0xFFFF0000)); + expect(canvasContentColor('#f00'), const Color(0xFFFF0000)); + expect(canvasContentColor(null), isNull); + expect(canvasContentColor('nonsense'), isNull); + }); + }); + + group('CanvasBounds', () { + test('spans every node rect', () { + const doc = CanvasDoc( + nodes: [ + TextNode(id: 'a', x: -10, y: 5, width: 20, height: 20, text: ''), + TextNode(id: 'b', x: 100, y: 0, width: 50, height: 80, text: ''), + ], + ); + final b = CanvasBounds.of(doc); + expect((b.left, b.top, b.right, b.bottom), (-10.0, 0.0, 150.0, 80.0)); + }); + }); + + group('CanvasViewport.fit', () { + test('centres content in the padded pane', () { + const doc = CanvasDoc( + nodes: [TextNode(id: 'a', x: 0, y: 0, width: 100, height: 100, text: '')], + ); + final vp = CanvasViewport.fit(const Size(400, 400), CanvasBounds.of(doc)); + final r = vp.rectOf(doc.nodes.first); + expect(r.center.dx, closeTo(200, 0.001)); + expect(r.center.dy, closeTo(200, 0.001)); + }); + }); + + testWidgets('paints nodes, edges, and a group', (tester) async { + final tokens = await tokensFrom(tester); + expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: sampleDoc(), tokens: tokens))), isTrue); + }); + + testWidgets('an empty canvas paints nothing (no throw)', (tester) async { + final tokens = await tokensFrom(tester); + expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: const CanvasDoc(), tokens: tokens))), isFalse); + }); + + testWidgets('a selected node still paints (draws the focus ring)', (tester) async { + final tokens = await tokensFrom(tester); + expect(await tester.runAsync(() => hasInk(CanvasPainter(doc: sampleDoc(), tokens: tokens, selected: 't'))), isTrue); + }); + + testWidgets('repaints on zoom / pan / selection change, not when identical', (tester) async { + final tokens = await tokensFrom(tester); + final doc = sampleDoc(); + CanvasPainter p({double zoom = 1, Offset pan = Offset.zero, String? selected}) => + CanvasPainter(doc: doc, tokens: tokens, zoom: zoom, pan: pan, selected: selected); + expect(p().shouldRepaint(p()), isFalse); + expect(p(zoom: 2).shouldRepaint(p()), isTrue); + expect(p(pan: const Offset(1, 0)).shouldRepaint(p()), isTrue); + expect(p(selected: 't').shouldRepaint(p()), isTrue); + }); +}