diff --git a/lib/builtin/graph/src/graph_painter.dart b/lib/builtin/graph/src/graph_painter.dart new file mode 100644 index 00000000..932e22d0 --- /dev/null +++ b/lib/builtin/graph/src/graph_painter.dart @@ -0,0 +1,80 @@ +/// Paints a laid-out [VaultGraph] (T-323): edges as lines, nodes as labelled +/// dots, with a hover neighbourhood lit and everything else dimmed. +/// +/// The [ForceLayout] solver produces [positions] in a [layoutSize] space; the +/// painter fits that into the canvas (aspect-preserving, centered). The graph is +/// clide's own UI here (not arbitrary content), so it paints through +/// [SurfaceTokens] — theme chrome, per D-7. +library; + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/src/graph/force_layout.dart'; +import 'package:clide/src/graph/vault_graph.dart'; +import 'package:flutter/widgets.dart'; + +class GraphPainter extends CustomPainter { + GraphPainter({required this.graph, required this.positions, required this.tokens, this.highlight, this.layoutSize = const Size(800, 600)}); + + final VaultGraph graph; + final Map positions; + final SurfaceTokens tokens; + + /// When non-null, only these node ids (a hovered node's neighbourhood) render + /// at full strength; the rest dim. + final Set? highlight; + + final Size layoutSize; + + static const double nodeRadius = 5; + + @override + void paint(ui.Canvas canvas, Size size) { + if (graph.isEmpty || positions.isEmpty) return; + final scale = math.min(size.width / layoutSize.width, size.height / layoutSize.height); + final dx = (size.width - layoutSize.width * scale) / 2; + final dy = (size.height - layoutSize.height * scale) / 2; + Offset at(GraphPoint p) => Offset(dx + p.x * scale, dy + p.y * scale); + bool lit(String id) => highlight == null || highlight!.contains(id); + + final edge = Paint() + ..strokeWidth = 1 + ..color = tokens.dividerColor; + final edgeDim = Paint() + ..strokeWidth = 1 + ..color = tokens.dividerColor.withValues(alpha: 0.2); + for (final e in graph.edges) { + final a = positions[e.from], b = positions[e.to]; + if (a == null || b == null) continue; + canvas.drawLine(at(a), at(b), lit(e.from) && lit(e.to) ? edge : edgeDim); + } + + for (final n in graph.nodes) { + final p = positions[n.id]; + if (p == null) continue; + final o = at(p); + final on = lit(n.id); + canvas.drawCircle(o, nodeRadius, Paint()..color = on ? tokens.globalForeground : tokens.globalTextMuted.withValues(alpha: 0.35)); + if (on) _label(canvas, n.label, o); + } + } + + void _label(ui.Canvas canvas, String text, Offset o) { + final tp = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontSize: 10, color: tokens.globalForeground), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: 120); + tp.paint(canvas, Offset(o.dx - tp.width / 2, o.dy + nodeRadius + 2)); + } + + @override + bool shouldRepaint(GraphPainter old) => + !identical(old.graph, graph) || !identical(old.positions, positions) || old.highlight != highlight || old.tokens != tokens; +} diff --git a/test/builtin/graph/graph_painter_test.dart b/test/builtin/graph/graph_painter_test.dart new file mode 100644 index 00000000..e69bd056 --- /dev/null +++ b/test/builtin/graph/graph_painter_test.dart @@ -0,0 +1,71 @@ +import 'dart:ui' as ui; + +import 'package:clide/builtin/graph/src/graph_painter.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/src/graph/force_layout.dart'; +import 'package:clide/src/graph/vault_graph.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()); + + 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(GraphPainter 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; + } + + VaultGraph twoNodes() => VaultGraph.fromOutlinks({ + 'a.md': const ['b.md'], + 'b.md': const [], + }); + + testWidgets('paints nodes, edges, and labels', (tester) async { + final tokens = await tokensFrom(tester); + final g = twoNodes(); + final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs); + // toImage/toByteData is real engine async — run it off the fake test clock. + expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens))), isTrue); + }); + + testWidgets('dims nodes outside the hover neighbourhood but still draws ink', (tester) async { + final tokens = await tokensFrom(tester); + final g = twoNodes(); + final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs); + // Highlight only a.md — b.md and the edge dim. + expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens, highlight: g.neighborhood('a.md')))), isTrue); + }); + + testWidgets('an empty graph paints nothing (no throw)', (tester) async { + final tokens = await tokensFrom(tester); + expect(await tester.runAsync(() => hasInk(GraphPainter(graph: const VaultGraph([], []), positions: const {}, tokens: tokens))), isFalse); + }); +}