feat(canvas): CustomPaint renderer for .canvas docs (T-322)
Paints a CanvasDoc — group frames behind, edges with arrowheads, then node cards (text / file / link) — fitted by a shared pan/zoom CanvasViewport so a later hit-test lands on what's drawn. Node colours come from the file (Obsidian presets 1..6 or hex), not clide tokens, since a .canvas is arbitrary content; only the chrome is themed. Read-only for now; selection, drag, and edit affordances follow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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(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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user