From 076e66db808cd14bc385d16886191753a501497f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 7 Jun 2026 10:32:16 +0200 Subject: [PATCH] =?UTF-8?q?add=20ClideLightbox=20=E2=80=94=20click=20image?= =?UTF-8?q?=20cards=20to=20enlarge=20(T-252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline image cards (T-249) are often too small to read. Clicking one now opens a full-screen lightbox: zoom (scroll wheel / pinch), pan when zoomed, double-click to reset to fit, Esc / close button / backdrop click to dismiss. ClideLightbox is a reusable primitive (lib/widgets/) over Flutter's InteractiveViewer with clide-owned zoom gestures, shown via the DialogRouter (dimmed backdrop, single modal at a time, D-78). The card stays display-only; the click is a navigation gesture, not an inline control. CLI parity (D-6): `clide image show --fullscreen` opens straight into the lightbox instead of injecting a card. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/tickets/2026-06.sql | 38 ++++ CHANGELOG.md | 5 + lib/builtin/claude/src/conversation_view.dart | 45 +++-- lib/builtin/claude/src/extension.dart | 11 ++ lib/src/daemon/image_commands.dart | 5 +- lib/widgets/src/clide_lightbox.dart | 176 ++++++++++++++++++ lib/widgets/widgets.dart | 1 + test/daemon/image_commands_test.dart | 8 + test/widgets/clide_lightbox_test.dart | 74 ++++++++ 9 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 lib/widgets/src/clide_lightbox.dart create mode 100644 test/widgets/clide_lightbox_test.dart diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 544b78aa..ec2e0d13 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -1155,3 +1155,41 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-54', 'story', NULL, 'output and log panel', 'Bottom panel or context tab showing daemon logs, build output, extension logs, and pql sync output. Filterable by source. Auto-scrolls to latest. Useful for debugging extension and IPC issues. UX design (2026-06-06, D-87): the panel is a bottom OUTPUT DOCK, read-only, two tabs — Output (the Logger stream, filter by source/level/text, auto-scroll) + Problems (moved out of the sidebar; no duplication). Toggled by a single status-bar widget that REPLACES the app-status indicator (merged health+log: green check when clean, warn/error counts when not, chevron for open state) — opens with click or Cmd/Ctrl+J. Needs a bounded in-memory ring sink on the Logger (no history today). Layout amends D-47 (dock pushes Claude up, capped so Claude stays >=50%). Terminal is NOT in the dock — kept first-class in the editor pane, tracked by T-258. Resolves Q-28. Wireframe: docs/design/wireframes/output-dock/.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-07 08:03:17', NULL, 'ced24a85b437a7c7f91515dd0ddf86cc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-252', 'story', NULL, 'Full-screen lightbox overlay (zoom + pan) for image cards', 'Click an inline image card (T-249) to open it in a full-screen overlay with zoom + pan — the standard lightbox UX from other apps (dimmed backdrop, image fit-to-screen, scroll/pinch to zoom, drag to pan when zoomed past fit, Esc or backdrop-click to dismiss). + +Build the overlay as a SHARED PRIMITIVE WIDGET, not image-specific. A reusable zoomable-viewer / lightbox primitive under lib/widgets/ (e.g. ClideLightbox + ClideZoomPanViewer) that takes any child/content; the image card is its first consumer, and the canvas, graph, and diff/image previews can adopt it later. Own-the-rendering-stack + prefer-zero-deps still apply: Flutter''s SDK InteractiveViewer is the SDK-first starting point (allowed), but keep the gesture/zoom logic in the primitive so we control the UX, not an opinionated package. + +Scope: +- Shared primitive (lib/widgets/): a full-screen overlay with a dimmed backdrop hosted via the existing DialogRouter (single-at-a-time modal, D-78 interaction surface). Content scales to fit on open; zoom via scroll wheel / pinch / +- ; pan by drag when zoomed in; double-click toggles fit<->100%/2x; Esc and backdrop-click dismiss. Min/max zoom clamp. Keyboard + AT reachable (ClideTappable / Semantics), theme tokens / ui-design for any chrome (close affordance, zoom hint). +- Image card wiring (T-249): the inline card stays display-only per D-78 — the click is a navigation gesture that opens the lightbox, not an inline control. Render the same resolved file at full resolution in the overlay; missing-file degrades to the existing placeholder. +- Parity (D-6): a CLI counterpart to open an image straight into the lightbox (e.g. clide image show --fullscreen, or clide image open ), registered so it surfaces in clide capabilities (T-248). + +Refs: D-78 (interaction zone / display-only conversation widgets), D-6 (CLI/UI parity), DialogRouter (lib/kernel/src/dialog.dart). Builds on T-249 (image card + clide image show). Related: T-248.', 'in_progress', 'medium', NULL, NULL, 'D-78', '2026-06-06 08:28:43', '2026-06-07 08:07:42', NULL, '4abfa2ec36914b4ffcb8115a154f12c4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-262', 'story', NULL, 'Merge tool-call + success result into one card with header status check', 'Today a Claude tool call renders as TWO stacked cards in the conversation log: the tool-use card (e.g. "Write " with a collapsible call body) and a separate success/result card ("Write · result / File created successfully..."). Collapse the successful pair into ONE card; failure keeps the current two-card interaction. + +Current code: lib/builtin/claude/src/conversation_view.dart — _toolUse (~L294) renders AssistantToolUse, _toolResult (~L329) renders ToolResultMessage; the two ConversationItems are linked by toolUseId (ToolResultMessage.isError marks failure). ConversationCard header is built in lib/builtin/claude/src/conversation_card.dart _header (~L200). Builds on T-168 (per-tool body rendering). + +Desired behavior (success): +- One card per successful tool call — the tool-use card. The standalone success result card is suppressed (folded in), not rendered separately. +- A success-green check mark sits at the RIGHT END of the card header row (reuse tokens.statusSuccess + a check glyph: PhosphorIcons.check or CheckIcon). No new theme tokens. +- Collapsed by default: header shows the existing summary + the check. Expanding the card reveals the CALL segment (the input — path / diff / command, as today) and, BELOW it, the RESULT segment (the swallowed output). This applies to ALL tools, not just Write/Edit: for Bash/Read/Grep/LS the real output relocates into the expandable body so nothing is lost; for Write/Edit the trivial confirmation folds in the same way. +- Render the result segment as an inline ClideCodeBlock (lib/widgets/src/clide_code_block.dart) WITH editor syntax colorization. ClideCodeBlock already colorizes via TreeSitterService when handed a language — so the work is inferring/passing the right language per tool: Read → from the file extension/path (language_map.grammarForPath), Bash → bash, Grep/LS/others → text fallback. Where a path is available, prefer it so highlighting matches the editor. + +Desired behavior (failure) — UNCHANGED: +- Leave the current interaction as-is: the tool-use card plus the prominent red "· error" result card (borderColor statusError, expanded-by-default). Optionally show a red status mark on the tool-use header for symmetry, but do NOT fold the error into one card. + +What to build: +1. ConversationCard: a trailing header status slot (e.g. status: success|error|none) rendered in _header between the Spacer/summary and the hover action buttons. +2. ConversationCard: support a second body segment so an expanded card can show CALL then RESULT with a clear visual separator / sub-label (e.g. a divider or a muted "result" label) so the user can tell the call from its output. +3. conversation_view: a result-by-toolUseId lookup (reverse of the existing toolUseById) so a tool-use card knows its outcome; on a successful pair, stamp the check + fold the result body and SUPPRESS the standalone success ToolResultMessage from the rendered list. Errors render both cards as today. +4. Per-tool result language inference for the folded code block. + +Edge cases to handle: +- In-flight tool-use with no result yet: render the call card as today (no check, no folded result); status/result appear once the result arrives. +- Orphan result with no paired tool-use: keep rendering it standalone. +- Permission-resolved tool-use cards (conversation_view.dart ~L297, already green/red bordered + collapsed): reconcile so the merged-card + check treatment is consistent and not duplicated with the existing border-outcome styling. +- Result items may not be strictly adjacent to their tool-use in the list; suppression must be keyed by toolUseId, not list position. + +Tests: unit/widget coverage for the merged success card (collapsed shows check; expanded shows call + colorized result), language inference per tool, the unchanged error path, and the in-flight/orphan cases. Add a golden for the merged success card. + +Refs: D-78 (interaction zone / display-only conversation widgets). Builds on T-168 (per-tool tool-use/result body rendering).', 'backlog', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:10:42', '2026-06-07 08:10:42', NULL, '3eec2008cc279ef886612e74651cb081', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index fc63dd9d..85fd9bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Click an inline image card to open it in a full-screen **lightbox** — zoom + (scroll/pinch), pan, double-click to reset, `Esc`/backdrop to dismiss — since + the cards are often too small to read. `clide image show --fullscreen` + opens straight into it. The lightbox is a reusable `ClideLightbox` primitive. + (T-252) - A bottom **output dock**: toggle it from a status-bar widget (or `⌘J`/`Ctrl+J`) to see logs (Output) and diagnostics (Problems) as tabs — filterable by source/level/text, auto-scrolling. The status widget doubles as a health diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index f9b0c377..c7c5ee12 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -17,6 +17,7 @@ import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:clide/kernel/src/facade.dart'; import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/tokens.dart'; import 'package:clide/widgets/widgets.dart'; @@ -234,7 +235,7 @@ class _ConversationTurn extends StatelessWidget { ), AssistantToolUse() => _toolUse(i), ToolResultMessage() => _toolResult(i), - ImageMessage() => _image(i), + ImageMessage() => _image(context, i), }; } @@ -243,7 +244,7 @@ class _ConversationTurn extends StatelessWidget { /// Bounded so a large image scales down to the pane width and never pushes /// past a readable height; a missing/unreadable file degrades to a muted /// placeholder rather than throwing. - Widget _image(ImageMessage m) { + Widget _image(BuildContext context, ImageMessage m) { final caption = m.caption; return ConversationCard( accent: tokens.globalTextMuted, @@ -252,15 +253,24 @@ class _ConversationTurn extends StatelessWidget { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 360), - child: Image.file( - File(m.path), - fit: BoxFit.contain, - alignment: Alignment.centerLeft, - errorBuilder: (_, __, ___) => _imagePlaceholder(m.path), + // The card stays display-only (D-78); the click is a navigation + // gesture that opens the full-screen lightbox (T-252), not an inline + // control. + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _openLightbox(context, m.path), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 360), + child: Image.file( + File(m.path), + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + errorBuilder: (_, __, ___) => _imagePlaceholder(m.path), + ), + ), ), ), ), @@ -273,6 +283,19 @@ class _ConversationTurn extends StatelessWidget { ); } + void _openLightbox(BuildContext context, String path) { + ClideKernel.of(context).dialog.show( + (ctx, dismiss) => ClideLightbox( + onDismiss: dismiss, + child: Image.file( + File(path), + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => _imagePlaceholder(path), + ), + ), + ); + } + Widget _imagePlaceholder(String path) => Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 2296b434..0670d6dc 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -336,6 +336,17 @@ class ClaudeExtension extends ClideExtension { void _onImageShow(Message m) { final path = m.data['path'] as String?; if (path == null || path.isEmpty) return; + // `clide image show --fullscreen` (T-252): open straight into the + // lightbox instead of injecting an inline card. + if (m.data['fullscreen'] == true) { + _ctx?.dialog.show( + (c, dismiss) => ClideLightbox( + onDismiss: dismiss, + child: Image.file(File(path), fit: BoxFit.contain), + ), + ); + return; + } final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull; if (target == null) return; target.conversation.inject(ImageMessage( diff --git a/lib/src/daemon/image_commands.dart b/lib/src/daemon/image_commands.dart index d43fd486..b9028708 100644 --- a/lib/src/daemon/image_commands.dart +++ b/lib/src/daemon/image_commands.dart @@ -48,6 +48,7 @@ void registerImageCommands( args: { 'path': ArgSpec(required: true, rejectLeadingDash: true), 'caption': ArgSpec(), + 'fullscreen': ArgSpec(type: ArgType.boolean), }, ), ); @@ -107,11 +108,13 @@ Future _show( error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'), ); } + final fullscreen = req.args['fullscreen'] == true; publish('cli', imageShowChannel, { 'path': resolved, if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(), + if (fullscreen) 'fullscreen': true, }); - return IpcResponse.ok(id: req.id, data: {'path': resolved, if (caption != null) 'caption': caption, 'shown': true}); + return IpcResponse.ok(id: req.id, data: {'path': resolved, if (caption != null) 'caption': caption, 'fullscreen': fullscreen, 'shown': true}); } /// Lower-cased extension (without the dot) of [path], or '' if none. diff --git a/lib/widgets/src/clide_lightbox.dart b/lib/widgets/src/clide_lightbox.dart new file mode 100644 index 00000000..ba153258 --- /dev/null +++ b/lib/widgets/src/clide_lightbox.dart @@ -0,0 +1,176 @@ +/// Full-screen zoom + pan overlay (T-252 / D-78). A reusable primitive: it +/// takes any [child] and shows it over the [DialogRouter]'s dimmed backdrop +/// (the host supplies the backdrop + outside-click dismiss). The image card is +/// its first consumer; canvas / graph / diff previews can adopt it later. +/// +/// Open: content fits the viewport. Scroll wheel / pinch zooms; drag pans when +/// zoomed in; double-click resets to fit; Esc (or the close button, or a +/// backdrop click) dismisses. Min/max scale clamp. Own-the-rendering-stack: +/// Flutter's [InteractiveViewer] is the SDK-first base, with the zoom gestures +/// kept here so the UX is ours. +library; + +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/widgets/src/clide_icon.dart'; +import 'package:clide/widgets/src/clide_text.dart'; +import 'package:clide/widgets/src/icons/phosphor.dart'; +import 'package:clide/widgets/src/typography.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class ClideLightbox extends StatefulWidget { + const ClideLightbox({ + super.key, + required this.child, + required this.onDismiss, + this.minScale = 0.5, + this.maxScale = 8.0, + }); + + /// The content to zoom — constrained to the viewport on open (pass an image + /// with `fit: BoxFit.contain` so it fits, then scales on zoom). + final Widget child; + final VoidCallback onDismiss; + final double minScale; + final double maxScale; + + @override + State createState() => _ClideLightboxState(); +} + +class _ClideLightboxState extends State { + final TransformationController _tc = TransformationController(); + final FocusNode _focus = FocusNode(debugLabel: 'ClideLightbox'); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focus.requestFocus(); + }); + } + + @override + void dispose() { + _tc.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _reset() => _tc.value = Matrix4.identity(); + + void _onScroll(PointerSignalEvent e) { + if (e is! PointerScrollEvent) return; + final current = _tc.value.getMaxScaleOnAxis(); + final factor = e.scrollDelta.dy < 0 ? 1.12 : 1 / 1.12; + final applied = (current * factor).clamp(widget.minScale, widget.maxScale) / current; + if (applied == 1.0) return; + final box = context.findRenderObject() as RenderBox?; + final p = box == null ? Offset.zero : box.globalToLocal(e.position); + _tc.value = _tc.value.clone() + ..translateByDouble(p.dx, p.dy, 0, 1) + ..scaleByDouble(applied, applied, applied, 1) + ..translateByDouble(-p.dx, -p.dy, 0, 1); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent e) { + if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) { + widget.onDismiss(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final size = MediaQuery.of(context).size; + return Focus( + focusNode: _focus, + onKeyEvent: _onKey, + child: SizedBox( + // Leave a margin so the host backdrop is clickable to dismiss. + width: size.width * 0.94, + height: size.height * 0.94, + child: Stack( + children: [ + Positioned.fill( + child: Listener( + onPointerSignal: _onScroll, + child: GestureDetector( + onDoubleTap: _reset, + child: InteractiveViewer( + transformationController: _tc, + minScale: widget.minScale, + maxScale: widget.maxScale, + boundaryMargin: const EdgeInsets.all(double.infinity), + child: widget.child, + ), + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: _IconChip(icon: PhosphorIcons.xMark, label: 'close', onTap: widget.onDismiss, tokens: tokens), + ), + Positioned( + bottom: 8, + left: 0, + right: 0, + child: Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: tokens.panelHeader, + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: ClideText( + 'scroll to zoom · double-click to reset · Esc to close', + fontSize: clideFontMeta, + color: tokens.globalTextMuted, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _IconChip extends StatelessWidget { + const _IconChip({required this.icon, required this.label, required this.onTap, required this.tokens}); + final ClideIconPainter icon; + final String label; + final VoidCallback onTap; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: label, + child: GestureDetector( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: tokens.panelHeader, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: tokens.panelBorder), + ), + child: ClideIcon(icon, size: 16, color: tokens.globalForeground), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index de2c9c55..618cfeb6 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -11,6 +11,7 @@ export 'src/clide_column_hat.dart'; export 'src/clide_code_block.dart'; export 'src/clide_divider.dart'; export 'src/clide_filter_box.dart'; +export 'src/clide_lightbox.dart'; export 'src/clide_markdown.dart'; export 'src/clide_marquee.dart'; export 'src/clide_svg_view.dart'; diff --git a/test/daemon/image_commands_test.dart b/test/daemon/image_commands_test.dart index 65ca8aa1..161e5183 100644 --- a/test/daemon/image_commands_test.dart +++ b/test/daemon/image_commands_test.dart @@ -49,6 +49,14 @@ void main() { expect(published.single.data, {'path': '/abs/docs/diagram.png', 'caption': 'before the fix'}); }); + test('--fullscreen rides along in the payload (T-252)', () async { + wire(); + final r = await show(['docs/diagram.png'], flags: {'fullscreen': true}); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['fullscreen'], isTrue); + expect(published.single.data, {'path': '/abs/docs/diagram.png', 'fullscreen': true}); + }); + test('accepts the documented formats case-insensitively', () async { for (final name in ['a.PNG', 'b.jpg', 'c.jpeg', 'd.gif', 'e.webp', 'f.bmp']) { wire(found: {name}); diff --git a/test/widgets/clide_lightbox_test.dart b/test/widgets/clide_lightbox_test.dart new file mode 100644 index 00000000..53057abb --- /dev/null +++ b/test/widgets/clide_lightbox_test.dart @@ -0,0 +1,74 @@ +/// T-252: ClideLightbox — full-screen zoom/pan overlay primitive. +library; + +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +bool _textHas(Object? w, String s) => w is ClideText && w.data.contains(s); + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Widget box(VoidCallback onDismiss) => MediaQuery( + data: const MediaQueryData(size: Size(800, 600)), + child: ClideLightbox(onDismiss: onDismiss, child: const SizedBox(width: 200, height: 150)), + ); + + testWidgets('renders the zoom hint and an InteractiveViewer', (tester) async { + await tester.pumpWidget(harness(f, box(() {}))); + await tester.pumpAndSettle(); + expect(find.byType(InteractiveViewer), findsOneWidget); + expect(find.byWidgetPredicate((w) => _textHas(w, 'Esc to close')), findsOneWidget); + }); + + testWidgets('Esc dismisses', (tester) async { + var dismissed = false; + await tester.pumpWidget(harness(f, box(() => dismissed = true))); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(dismissed, isTrue); + }); + + testWidgets('the close button dismisses', (tester) async { + var dismissed = false; + await tester.pumpWidget(harness(f, box(() => dismissed = true))); + await tester.pumpAndSettle(); + await tester.tap(find.ancestor(of: find.byType(ClideIcon), matching: find.byType(GestureDetector))); + await tester.pumpAndSettle(); + expect(dismissed, isTrue); + }); + + testWidgets('double-tap resets without dismissing', (tester) async { + var dismissed = false; + await tester.pumpWidget(harness(f, box(() => dismissed = true))); + await tester.pumpAndSettle(); + final viewer = find.byType(InteractiveViewer); + await tester.tap(viewer); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(viewer); + await tester.pumpAndSettle(); + expect(dismissed, isFalse); + expect(find.byType(InteractiveViewer), findsOneWidget); + }); + + testWidgets('scroll wheel zooms in and out', (tester) async { + await tester.pumpWidget(harness(f, box(() {}))); + await tester.pumpAndSettle(); + final center = tester.getCenter(find.byType(InteractiveViewer)); + final pointer = TestPointer(1, PointerDeviceKind.mouse)..hover(center); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -100))); // in + await tester.pumpAndSettle(); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, 100))); // out + await tester.pumpAndSettle(); + expect(find.byType(InteractiveViewer), findsOneWidget); + }); +}