add ClideLightbox — click image cards to enlarge (T-252)

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 <path> --fullscreen` opens straight into
the lightbox instead of injecting a card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 10:32:16 +02:00
co-authored by Claude Opus 4.8
parent 7c2d140146
commit 076e66db80
9 changed files with 351 additions and 12 deletions
+34 -11
View File
@@ -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<Object>(
(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(
+11
View File
@@ -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 <path> --fullscreen` (T-252): open straight into the
// lightbox instead of injecting an inline card.
if (m.data['fullscreen'] == true) {
_ctx?.dialog.show<Object>(
(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(