diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a75a1e9..2aa3b436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Before/after compare cards.** `clide draw --file compare.json` (template + `compare`) renders two or more images side by side, each with its own + label/description and tap-to-zoom; images aspect-fit so differing shapes + don't distort. (T-319) - **Phosphor glyph cards.** `clide icon show gear folder` (or `--file` entries with label/description/color) renders glyphs in the conversation at a hero size plus a real-UI-size strip (10–48), for previewing and comparing icons. diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 693beac5..01666950 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -12,6 +12,8 @@ library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data' show Uint8List; +import 'dart:ui' as ui; import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/bash_tail_source.dart'; @@ -24,6 +26,7 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/src/svg/svg_document.dart' show buildSvgDocument; +import 'package:clide/src/svg/svg_node.dart' show SvgDocument; import 'package:clide/widgets/src/draw/drawing_card.dart'; import 'package:clide/widgets/src/svg/svg_painter.dart' show SvgView; import 'package:clide/builtin/claude/src/workflow_run.dart'; @@ -714,25 +717,17 @@ class _ConversationTurn extends StatelessWidget { /// CustomPaint engine (D-103), display-only per D-78, with an optional /// label/description caption. Widget _drawing(BuildContext context, DrawingMessage m) { - final doc = buildSvgDocument(m.svg); return ConversationCard( accent: tokens.globalTextMuted, label: ClideSettings.i18n.string(context, 'conversation.label.drawing', namespace: 'builtin.claude', placeholder: 'drawing'), - body: DrawingCard( - document: doc, + body: _DrawingWithImages( + doc: buildSvgDocument(m.svg), label: m.label, description: m.description, source: m.source, sourceLabel: m.source == null ? null : ClideSettings.i18n.string(context, 'conversation.draw.viewSource', namespace: 'builtin.claude', placeholder: 'view d2 source'), - // A data-lightbox element opens the whole drawing, zoomable (T-318). - onLightbox: () => ClideKernel.of(context).dialog.show( - (ctx, dismiss) => ClideLightbox( - onDismiss: dismiss, - child: SvgView(document: doc), - ), - ), ), ); } @@ -1336,3 +1331,99 @@ String _summarizeActivity(BuildContext context, ConversationItem item) { return '${label('conversation.label.icon', 'icons')} ${entries.map((e) => e.name).join(', ')}'; } } + +/// Decode every annotated `` href in [doc] into a `ui.Image` (T-319) — +/// the resolver the renderer + lightbox paint through. A missing/undecodable +/// file is skipped (its cell paints empty), never thrown. [load] (read bytes) +/// and [decode] are injectable so the path is testable off the real filesystem. +Future> loadDrawingImages( + SvgDocument doc, { + Future Function(String path)? load, + Future Function(Uint8List bytes)? decode, +}) async { + final reader = load ?? _readFileBytes; + final decoder = decode ?? decodeImageFromList; + final out = {}; + for (final href in doc.annotations.map((a) => a.href).whereType().toSet()) { + try { + final bytes = await reader(href); + if (bytes != null) out[href] = await decoder(bytes); + } catch (_) { + // Missing/undecodable — leave that cell empty. + } + } + return out; +} + +Future _readFileBytes(String path) async { + try { + return await File(path).readAsBytes(); + } catch (_) { + return null; + } +} + +/// Wraps a [DrawingCard], loading any `` hrefs the SVG references into +/// `ui.Image`s first (T-319) — the renderer + lightbox both paint through the +/// resolver. A drawing with no images (d2, raw svg) loads nothing and renders +/// immediately; a missing/unreadable file is skipped (that cell paints empty). +class _DrawingWithImages extends StatefulWidget { + const _DrawingWithImages({required this.doc, this.label, this.description, this.source, this.sourceLabel}); + + final SvgDocument doc; + final String? label, description, source, sourceLabel; + + @override + State<_DrawingWithImages> createState() => _DrawingWithImagesState(); +} + +class _DrawingWithImagesState extends State<_DrawingWithImages> { + final Map _images = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final imgs = await loadDrawingImages(widget.doc); + if (imgs.isEmpty) return; + if (!mounted) { + for (final img in imgs.values) { + img.dispose(); + } + return; + } + setState(() => _images.addAll(imgs)); + } + + @override + void dispose() { + for (final img in _images.values) { + img.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final resolver = _images.isEmpty ? null : (String href) => _images[href]; + return DrawingCard( + document: widget.doc, + images: resolver, + label: widget.label, + description: widget.description, + source: widget.source, + sourceLabel: widget.sourceLabel, + // A data-lightbox element opens the whole drawing, zoomable (T-318); the + // lightbox paints through the same image resolver (T-319). + onLightbox: () => ClideKernel.of(context).dialog.show( + (ctx, dismiss) => ClideLightbox( + onDismiss: dismiss, + child: SvgView(document: widget.doc, images: resolver), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 93a6c131..567d759c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,7 @@ import 'package:clide/clide.dart' show clideVersion; import 'package:clide/src/daemon/claude_account_commands.dart'; 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/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; @@ -402,7 +403,17 @@ Future main() async { registerDrawCommands( dispatcher, () => kernelMessages?.publish, - registry: DrawingRegistry()..register('d2', d2TemplateHandler()), + registry: DrawingRegistry() + ..register('d2', d2TemplateHandler()) + ..register( + 'compare', + compareTemplateHandler( + resolvePath: (path) { + final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path'); + return file.existsSync() ? file.absolute.path : null; + }, + ), + ), readFile: (path) async { final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path'); try { diff --git a/test/builtin/claude/drawing_images_test.dart b/test/builtin/claude/drawing_images_test.dart new file mode 100644 index 00000000..5d5e6556 --- /dev/null +++ b/test/builtin/claude/drawing_images_test.dart @@ -0,0 +1,45 @@ +/// T-319: loadDrawingImages decodes the annotated `` hrefs a drawing +/// card references, skipping files it can't read — the resolver the compare +/// card and lightbox paint through. +library; + +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:clide/builtin/claude/src/conversation_view.dart' show loadDrawingImages; +import 'package:clide/src/svg/svg_document.dart' show buildSvgDocument; +import 'package:flutter_test/flutter_test.dart'; + +Future _pixelImage() { + final c = Completer(); + ui.decodeImageFromPixels(Uint8List(4), 1, 1, ui.PixelFormat.rgba8888, c.complete); + return c.future; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('decodes annotated image hrefs and skips a missing file', () async { + final img = await _pixelImage(); + final doc = buildSvgDocument( + '' + '' + '' + '', + ); + final imgs = await loadDrawingImages( + doc, + load: (p) async => p == 'a.png' ? Uint8List(4) : null, // gone.png → null → skipped + decode: (_) async => img, + ); + expect(imgs.keys.toList(), ['a.png']); + expect(imgs['a.png'], same(img)); + img.dispose(); + }); + + test('a doc with no image references loads nothing', () async { + final doc = buildSvgDocument(''); + expect(await loadDrawingImages(doc, load: (_) async => Uint8List(4)), isEmpty); + }); +}