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,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<GroupNode>()) {
|
||||
_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<String, CanvasNode> 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;
|
||||
}
|
||||
@@ -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