diff --git a/CHANGELOG.md b/CHANGELOG.md index b50ad20b..2ef18fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Graph cards.** `clide draw --file graph.json` (template `graph`) renders a + nodes/edges graph in the conversation as a circular layout — labelled nodes, + lines between them. Honest error on a duplicate id or an edge to an unknown + node. (T-321) - **Piped `--stdin` payloads.** `cat icons.json | clide icon show --stdin` (and `image show`) accept a JSON payload on stdin — the ergonomic peer of `--file` for structured commands. (T-315) diff --git a/lib/main.dart b/lib/main.dart index 567d759c..81f05c05 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,6 +43,7 @@ import 'package:clide/src/daemon/dispatcher.dart'; import 'package:clide/src/daemon/draw_commands.dart'; import 'package:clide/src/draw/compare_template.dart' show compareTemplateHandler; import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler; +import 'package:clide/src/draw/graph_template.dart' show graphTemplateHandler; import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/git_commands.dart'; @@ -405,6 +406,7 @@ Future main() async { () => kernelMessages?.publish, registry: DrawingRegistry() ..register('d2', d2TemplateHandler()) + ..register('graph', graphTemplateHandler()) ..register( 'compare', compareTemplateHandler( diff --git a/lib/src/draw/graph_template.dart b/lib/src/draw/graph_template.dart new file mode 100644 index 00000000..de281312 --- /dev/null +++ b/lib/src/draw/graph_template.dart @@ -0,0 +1,87 @@ +/// The `graph` drawing-card template (T-321 / D-91 / D-103). +/// +/// Lowers a `{nodes:[{id,label}], edges:[{from,to}]}` payload to an SVG the +/// shared renderer (T-320) paints: a deterministic circular layout — nodes on a +/// ring as labelled ``s, edges as ``s between them. Display-only +/// per D-78; the card-level label/description (T-318) renders beneath. Distinct +/// from the interactive force-directed graph PANE (T-323) — this is a static +/// graph dropped into the conversation. +/// +/// Self-contained like a d2 diagram: it carries its own light backdrop + content +/// colors (the graph is content, not clide chrome — its own palette). Honest +/// [DrawErr] on an empty node set, a duplicate id, or an edge to an unknown node. +/// +/// Flutter-free: pure Dart, runs under `dart test`. +library; + +import 'dart:math' as math; + +import 'draw_dispatch.dart'; + +const _nodeR = 9.0; // node circle radius +const _ringR = 140.0; // layout ring radius +const _pad = 52.0; // room for labels around the ring + +DrawingTemplateHandler graphTemplateHandler() { + return (doc) async { + final nodesRaw = doc.fields['nodes']; + if (nodesRaw is! List || nodesRaw.isEmpty) { + return const DrawErr('the graph template needs a non-empty "nodes" array of {id,label}'); + } + + final ids = []; + final labels = []; + for (final node in nodesRaw) { + if (node is! Map) return const DrawErr('each graph node must be a JSON object {id,label}'); + final id = _str(node['id']); + if (id == null) return const DrawErr('each graph node needs an "id"'); + if (ids.contains(id)) return DrawErr('duplicate node id: $id'); + ids.add(id); + labels.add(_str(node['label']) ?? id); + } + + final n = ids.length; + final cx = _ringR + _pad, cy = _ringR + _pad; + final px = List.filled(n, cx), py = List.filled(n, cy); + for (var i = 0; i < n && n > 1; i++) { + final a = -math.pi / 2 + 2 * math.pi * i / n; // start at top, clockwise + px[i] = cx + _ringR * math.cos(a); + py[i] = cy + _ringR * math.sin(a); + } + final index = {for (var i = 0; i < n; i++) ids[i]: i}; + + // Edges first so nodes paint on top of them. + final edges = StringBuffer(); + final edgesRaw = doc.fields['edges']; + if (edgesRaw is List) { + for (final edge in edgesRaw) { + if (edge is! Map) return const DrawErr('each graph edge must be a JSON object {from,to}'); + final from = _str(edge['from']), to = _str(edge['to']); + if (from == null || to == null) return const DrawErr('each graph edge needs a "from" and a "to"'); + final fi = index[from], ti = index[to]; + if (fi == null) return DrawErr('edge references unknown node: $from'); + if (ti == null) return DrawErr('edge references unknown node: $to'); + edges.write(''); + } + } + + final nodes = StringBuffer(); + for (var i = 0; i < n; i++) { + nodes.write(''); + nodes.write('${_esc(labels[i])}'); + } + + final size = 2 * (_ringR + _pad); + return DrawOk( + '' + '' + '$edges$nodes', + ); + }; +} + +String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null; + +String _fmt(double v) => v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2); + +String _esc(String s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); diff --git a/test/draw/graph_template_test.dart b/test/draw/graph_template_test.dart new file mode 100644 index 00000000..9f5bc4c1 --- /dev/null +++ b/test/draw/graph_template_test.dart @@ -0,0 +1,81 @@ +import 'package:clide/src/draw/draw_dispatch.dart'; +import 'package:clide/src/draw/draw_doc.dart'; +import 'package:clide/src/draw/graph_template.dart'; +import 'package:test/test.dart'; + +void main() { + final handler = graphTemplateHandler(); + DrawingCardDoc doc(Map fields) => DrawingCardDoc(template: 'graph', fields: {'template': 'graph', ...fields}); + + test('lays out nodes as labelled circles and edges as lines', () async { + final r = await handler( + doc({ + 'nodes': [ + {'id': 'a', 'label': 'Alpha'}, + {'id': 'b', 'label': 'Beta'}, + ], + 'edges': [ + {'from': 'a', 'to': 'b'}, + ], + }), + ); + final svg = (r as DrawOk).svg; + expect('Alpha<')); + expect(svg, contains('>Beta<')); + }); + + test('a node label defaults to its id', () async { + final r = await handler( + doc({ + 'nodes': [ + {'id': 'solo'}, + ], + }), + ); + expect((r as DrawOk).svg, contains('>solo<')); + }); + + test('empty or missing nodes is an honest error', () async { + expect(await handler(doc({'nodes': const []})), isA()); + expect(await handler(doc(const {})), isA()); + }); + + test('a duplicate node id is an error', () async { + final r = await handler( + doc({ + 'nodes': [ + {'id': 'x'}, + {'id': 'x'}, + ], + }), + ); + expect((r as DrawErr).message, contains('duplicate')); + }); + + test('an edge to an unknown node is an error', () async { + final r = await handler( + doc({ + 'nodes': [ + {'id': 'a'}, + ], + 'edges': [ + {'from': 'a', 'to': 'ghost'}, + ], + }), + ); + expect((r as DrawErr).message, contains('ghost')); + }); + + test('escapes label text', () async { + final r = await handler( + doc({ + 'nodes': [ + {'id': 'a', 'label': '&'}, + ], + }), + ); + expect((r as DrawOk).svg, contains('<b>&')); + }); +}