feat(graph): CustomPaint renderer for the vault graph (T-323)

GraphPainter draws a solver-positioned VaultGraph — edges as lines, nodes as
labelled dots — fitting the layout space into the canvas (aspect-preserving,
centered), with a hovered node's neighbourhood lit and the rest dimmed.
Paints through SurfaceTokens (the graph is IDE chrome here, D-7).
Pixel-tested. Solver + model + painter now stand; the interactive pane (pql
wiring, hover/click, filter, tab/slot) is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 08:31:37 +02:00
co-authored by Claude Opus 4.8
parent 084bf84258
commit 6bea1723bb
2 changed files with 151 additions and 0 deletions
+80
View File
@@ -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<String, GraphPoint> 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<String>? 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;
}
@@ -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<SurfaceTokens> 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<bool> 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);
});
}