feat(draw): wire compare card image loading + register the template (T-319)

The drawing card now loads any <image> hrefs its SVG references into
ui.Images before painting (loadDrawingImages, decoder injectable for
tests) — the renderer and the lightbox both paint through the resolver, so
a compare card shows real images and zooms. main registers the `compare`
template with a workspace path resolver. A drawing with no images is
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 15:02:21 +02:00
co-authored by Claude Opus 4.8
parent a651c9e026
commit 1f6d31ac73
4 changed files with 162 additions and 11 deletions
+4
View File
@@ -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 (1048), for previewing and comparing icons.
+101 -10
View File
@@ -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<Object>(
(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 `<image>` 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<Map<String, ui.Image>> loadDrawingImages(
SvgDocument doc, {
Future<Uint8List?> Function(String path)? load,
Future<ui.Image> Function(Uint8List bytes)? decode,
}) async {
final reader = load ?? _readFileBytes;
final decoder = decode ?? decodeImageFromList;
final out = <String, ui.Image>{};
for (final href in doc.annotations.map((a) => a.href).whereType<String>().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<Uint8List?> _readFileBytes(String path) async {
try {
return await File(path).readAsBytes();
} catch (_) {
return null;
}
}
/// Wraps a [DrawingCard], loading any `<image>` 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<String, ui.Image> _images = {};
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: SvgView(document: widget.doc, images: resolver),
),
),
);
}
}
+12 -1
View File
@@ -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<void> 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 {
@@ -0,0 +1,45 @@
/// T-319: loadDrawingImages decodes the annotated `<image>` 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<ui.Image> _pixelImage() {
final c = Completer<ui.Image>();
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(
'<svg viewBox="0 0 40 10">'
'<image href="a.png" x="0" y="0" width="20" height="10" data-label="A"/>'
'<image href="gone.png" x="20" y="0" width="20" height="10" data-label="B"/>'
'</svg>',
);
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('<svg viewBox="0 0 10 10"><rect width="10" height="10" data-label="x"/></svg>');
expect(await loadDrawingImages(doc, load: (_) async => Uint8List(4)), isEmpty);
});
}