diff --git a/CLAUDE.md b/CLAUDE.md index 17f8fd4..0665bd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1299,6 +1299,13 @@ asserted in `shutdown_when_done_test.dart`: exact vertically (stretched horizontally by SAR). The shortcuts reach the widget's zoom through `MainViewModel.previewZoom` (`ComparisonZoomController`); `before_after_comparison_test.dart` covers it. +- **Preview view mode (Original / Split / Processed)** is + `MainViewModel.previewViewMode` (shortcuts B / S / P), passed into the + controlled `BeforeAfterComparisonWidget`, so it survives scrubber moves and + new previews. The divider spans the full 0–1 of the panel (it was 5–95%, + which hid a typical left crop); line and handle are drawn inset at the + edges. Each mode draws only its side at its own aspect, and 1:1 measures the + frame on screen (Original: the source; otherwise the processed frame). - **Themes** are `AppTheme` in `theme_service.dart`, persisted as `appTheme` and provided above the MaterialApp. Theme-specific decoration beyond `ColorScheme` (Pride's stripe) goes through the `AppDecorations` theme diff --git a/README.md b/README.md index da876cd..9896443 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Every filter explains in place what it does and when to use it. Where two filter Preview, timeline and trimming - **Click** a thumbnail to jump there, or **drag** to scrub. The transport buttons step a frame at a time or jump to either end. +- **Original / Split / Processed** (B / S / P) above the preview show either frame whole or both side by side; in Split, drag the divider all the way to either edge. - Zoom the preview with the **mouse wheel** or **pinch**; **double-click** resets it. **1:1** shows actual pixels, the way to judge fine detail, and **Fit** shows the whole frame. - The **mouse wheel** or **pinch** also zooms the timeline around the cursor, and **Shift+wheel** or a **two-finger swipe** pans it. - **Set In** / **Set Out** process only part of the source, handy for trying settings on a short section. @@ -185,6 +186,7 @@ Every button with a shortcut shows it in its tooltip, and **?** lists them all. | Go to in / out point | ⇧I / ⇧O | Shift+I / Shift+O | | Clear in and out points | ⌥X | Alt+X | | Preview at actual pixels (1:1) / fit | ⌘1 / ⌘0 | Ctrl+1 / Ctrl+0 | +| Show original / split / processed | B / S / P | B / S / P | | Zoom timeline in / out / to fit | = / - / \ | = / - / \ | | Add videos | ⌘O | Ctrl+O | | Start processing | ⌘↩ | Ctrl+Enter | diff --git a/app/lib/models/app_shortcut.dart b/app/lib/models/app_shortcut.dart index 1f60bfa..e543dd1 100644 --- a/app/lib/models/app_shortcut.dart +++ b/app/lib/models/app_shortcut.dart @@ -89,6 +89,21 @@ enum AppShortcut { alternates: [LogicalKeyboardKey.numpad0], primary: true, ), + previewOriginal( + 'Show original (before)', + AppShortcutGroup.preview, + LogicalKeyboardKey.keyB, + ), + previewSplit( + 'Split original / processed', + AppShortcutGroup.preview, + LogicalKeyboardKey.keyS, + ), + previewProcessed( + 'Show processed', + AppShortcutGroup.preview, + LogicalKeyboardKey.keyP, + ), zoomIn( 'Zoom in', AppShortcutGroup.zoom, diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart index 0e4a90b..3e36eb9 100644 --- a/app/lib/viewmodels/main_viewmodel.dart +++ b/app/lib/viewmodels/main_viewmodel.dart @@ -34,6 +34,7 @@ import '../services/failure_log_writer.dart'; import '../services/job_runner.dart'; import '../services/system_shutdown.dart'; import '../services/worker_manager.dart'; +import '../widgets/comparison_view_mode.dart'; import '../widgets/comparison_zoom_controller.dart'; /// The display aspect a processed preview is drawn at: its stored size with @@ -151,6 +152,18 @@ class MainViewModel extends ChangeNotifier { /// view state held by the comparison; this lets the shortcuts reach it. final ComparisonZoomController previewZoom = ComparisonZoomController(); + /// Original / Split / Processed on the before/after preview. Held here, not + /// in the comparison or on a queue item, so it survives scrubber moves, new + /// previews and switching videos. + ComparisonViewMode _previewViewMode = ComparisonViewMode.split; + ComparisonViewMode get previewViewMode => _previewViewMode; + + void setPreviewViewMode(ComparisonViewMode mode) { + if (mode == _previewViewMode) return; + _previewViewMode = mode; + notifyListeners(); + } + // Shut down when the queue finishes (issue #123). Armed for one run only: // _stopProcessing disarms it, so a run the user cancelled can't leave it // armed, invisibly, for the next one. diff --git a/app/lib/views/main_window.dart b/app/lib/views/main_window.dart index ae6cd2f..f6a1fa7 100644 --- a/app/lib/views/main_window.dart +++ b/app/lib/views/main_window.dart @@ -16,6 +16,7 @@ import '../services/theme_service.dart'; import '../models/app_shortcut.dart'; import '../widgets/app_shortcut_scope.dart'; import '../widgets/dynamic_range_badge.dart'; +import '../widgets/comparison_view_mode.dart'; import 'keyboard_shortcuts_dialog.dart'; import 'video_file_picker.dart'; import '../viewmodels/main_viewmodel.dart'; @@ -112,6 +113,9 @@ class MainWindow extends StatelessWidget { AppShortcut.previewActualSize: whenPreviewing(viewModel.previewZoom.showActualSize), AppShortcut.previewFit: whenPreviewing(viewModel.previewZoom.fit), + for (final mode in ComparisonViewMode.values) + mode.shortcut: + whenPreviewing(() => viewModel.setPreviewViewMode(mode)), AppShortcut.zoomIn: whenPreviewing(viewModel.zoomIn), AppShortcut.zoomOut: whenPreviewing(viewModel.zoomOut), AppShortcut.zoomToFit: whenPreviewing(viewModel.resetTimelineZoom), diff --git a/app/lib/views/preview_panel.dart b/app/lib/views/preview_panel.dart index 77d15f3..8be6405 100644 --- a/app/lib/views/preview_panel.dart +++ b/app/lib/views/preview_panel.dart @@ -66,6 +66,8 @@ class _PreviewPanelState extends State { beforeAspectRatio: viewModel.sourceDisplayAspect, afterAspectRatio: viewModel.comparisonAfterAspect, zoomController: viewModel.previewZoom, + viewMode: viewModel.previewViewMode, + onViewModeChanged: viewModel.setPreviewViewMode, ), ), diff --git a/app/lib/widgets/before_after_comparison.dart b/app/lib/widgets/before_after_comparison.dart index 496bf53..48004d1 100644 --- a/app/lib/widgets/before_after_comparison.dart +++ b/app/lib/widgets/before_after_comparison.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import '../models/app_shortcut.dart'; import '../services/image_dimensions.dart'; +import 'comparison_view_mode.dart'; import 'comparison_zoom_controller.dart'; /// A widget that displays a before/after comparison with a draggable divider. @@ -26,7 +27,8 @@ class BeforeAfterComparisonWidget extends StatefulWidget { /// The "after" image data (shown on the right). final Uint8List? afterImage; - /// Initial divider position (0.0 to 1.0, default 0.5). + /// Initial divider position (0.0 to 1.0, default 0.5). The divider spans + /// the whole panel: at 0 the processed frame fills it, at 1 the original. final double initialDividerPosition; /// Whether the before image is loading. @@ -51,6 +53,14 @@ class BeforeAfterComparisonWidget extends StatefulWidget { /// Lets keyboard shortcuts drive Fit / 1:1 from outside the widget. final ComparisonZoomController? zoomController; + /// Which frame is shown: the original, the processed one, or both split by + /// the divider. Controlled by the parent so it survives a new preview. + final ComparisonViewMode viewMode; + + /// Called when the user picks a mode in the Original | Split | Processed + /// control. The control is shown only when this is set. + final ValueChanged? onViewModeChanged; + const BeforeAfterComparisonWidget({ super.key, this.beforeImage, @@ -61,6 +71,8 @@ class BeforeAfterComparisonWidget extends StatefulWidget { this.beforeAspectRatio, this.afterAspectRatio, this.zoomController, + this.viewMode = ComparisonViewMode.split, + this.onViewModeChanged, }); @override @@ -176,16 +188,25 @@ class _BeforeAfterComparisonWidgetState ); } - /// Display aspect of the frame [_imagePixels] measures: the processed - /// frame's when there is one, else the source's. - double? get _measuredAspect => widget.afterImage != null - ? widget.afterAspectRatio - : widget.beforeAspectRatio; + /// The frame the 1:1 scale is measured against, with its display aspect: + /// the frame on screen. In Original mode that is the source; otherwise the + /// processed frame, which is what is being inspected, else the source + /// standing in for it. One getter, so size and aspect can't come from + /// different frames. + ({Uint8List? bytes, double? aspect}) get _measuredFrame { + if (widget.viewMode == ComparisonViewMode.original) { + return (bytes: widget.beforeImage, aspect: widget.beforeAspectRatio); + } + return widget.afterImage != null + ? (bytes: widget.afterImage, aspect: widget.afterAspectRatio) + : (bytes: widget.beforeImage, aspect: widget.beforeAspectRatio); + } + + double? get _measuredAspect => _measuredFrame.aspect; - /// Pixel size of the frame the 1:1 scale is measured against: the - /// processed frame, which is what is being inspected, else the source. + /// Pixel size of the frame in [_measuredFrame]. Size? get _imagePixels { - final bytes = widget.afterImage ?? widget.beforeImage; + final bytes = _measuredFrame.bytes; if (!identical(bytes, _sizedBytes)) { _sizedBytes = bytes; _sizedResult = bytes == null ? null : encodedImageSize(bytes); @@ -315,6 +336,12 @@ class _BeforeAfterComparisonWidgetState }); } + bool get _isSplit => widget.viewMode == ComparisonViewMode.split; + + void _setDivider(double position) { + setState(() => _dividerPosition = position.clamp(0.0, 1.0)); + } + void _handlePanZoomStart(PointerPanZoomStartEvent event) { _panZoomStartScale = _scale; _isPanZooming = true; @@ -407,113 +434,125 @@ class _BeforeAfterComparisonWidgetState fit: StackFit.expand, children: [ // After image (full width, shown on right side) - Transform( - alignment: Alignment.center, - transform: Matrix4.identity() - ..translate(drawnOffset.dx, drawnOffset.dy) - ..scale(_scale, _scale), - child: _buildImageLayer( - imageData: widget.afterImage, - displayAspectRatio: widget.afterAspectRatio, - isLoading: widget.isAfterLoading, - colorScheme: colorScheme, - label: 'Processed', - ), - ), - - // Before image (clipped to left of divider) - // Clip stays in screen space so the split aligns with the divider - ClipRect( - clipper: _SplitClipper(dividerPosition: _dividerPosition), - child: Transform( + if (widget.viewMode != ComparisonViewMode.original) + Transform( alignment: Alignment.center, transform: Matrix4.identity() ..translate(drawnOffset.dx, drawnOffset.dy) ..scale(_scale, _scale), child: _buildImageLayer( - imageData: widget.beforeImage, - displayAspectRatio: widget.beforeAspectRatio, - isLoading: widget.isBeforeLoading, + imageData: widget.afterImage, + displayAspectRatio: widget.afterAspectRatio, + isLoading: widget.isAfterLoading, colorScheme: colorScheme, - label: 'Original', + label: 'Processed', ), ), - ), - - // Labels - Positioned( - left: 12, - top: 12, - child: _buildLabel('Original', colorScheme), - ), - Positioned( - right: 12, - top: 12, - child: _buildLabel('Processed', colorScheme), - ), - // Divider line - Positioned( - left: constraints.maxWidth * _dividerPosition - 1.5, - top: 0, - bottom: 0, - child: Container( - width: 3, - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 4, - ), - ], + // Before image: clipped to the left of the divider in Split + // mode (the clip stays in screen space so the split aligns + // with the divider), whole in Original mode. + if (widget.viewMode != ComparisonViewMode.processed) + ClipRect( + clipper: _SplitClipper( + dividerPosition: _isSplit ? _dividerPosition : 1.0, + ), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..translate(drawnOffset.dx, drawnOffset.dy) + ..scale(_scale, _scale), + child: _buildImageLayer( + imageData: widget.beforeImage, + displayAspectRatio: widget.beforeAspectRatio, + isLoading: widget.isBeforeLoading, + colorScheme: colorScheme, + label: 'Original', + ), ), ), - ), - // Divider handle - Positioned( - left: constraints.maxWidth * _dividerPosition - 20, - top: (constraints.maxHeight - 40) / 2, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], + // Labels: only the side(s) on screen. + if (widget.viewMode != ComparisonViewMode.processed) + Positioned( + left: 12, + top: 12, + child: _buildLabel('Original', colorScheme), + ), + if (widget.viewMode != ComparisonViewMode.original) + Positioned( + right: 12, + top: 12, + child: _buildLabel('Processed', colorScheme), + ), + + // Divider line. It reaches both edges of the panel; drawn + // inset there so the whole line stays visible. + if (_isSplit) + Positioned( + key: const Key('comparison-divider'), + left: (constraints.maxWidth * _dividerPosition - 1.5) + .clamp(0.0, math.max(0.0, constraints.maxWidth - 3)), + top: 0, + bottom: 0, + child: Container( + width: 3, + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 4, + ), + ], + ), ), - child: const Icon( - Icons.drag_handle, - color: Colors.black54, - size: 20, + ), + + // Divider handle, inset at the edges so it stays fully on + // screen while the line sits at the edge. + if (_isSplit) + Positioned( + key: const Key('comparison-divider-handle'), + left: (constraints.maxWidth * _dividerPosition - 20) + .clamp(0.0, math.max(0.0, constraints.maxWidth - 40)), + top: (constraints.maxHeight - 40) / 2, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.drag_handle, + color: Colors.black54, + size: 20, + ), ), ), - ), // Gesture detector for dragging divider and double-tap reset Positioned.fill( child: GestureDetector( onHorizontalDragUpdate: (details) { - if (_isPanZooming || _isMiddleDragging) return; - setState(() { - _dividerPosition += - details.delta.dx / constraints.maxWidth; - _dividerPosition = _dividerPosition.clamp(0.05, 0.95); - }); + if (_isPanZooming || _isMiddleDragging || !_isSplit) { + return; + } + _setDivider(_dividerPosition + + details.delta.dx / constraints.maxWidth); }, onTapDown: (details) { - setState(() { - _dividerPosition = - details.localPosition.dx / constraints.maxWidth; - _dividerPosition = _dividerPosition.clamp(0.05, 0.95); - }); + if (!_isSplit) return; + _setDivider( + details.localPosition.dx / constraints.maxWidth); }, onDoubleTap: _resetZoom, child: Container( @@ -522,6 +561,20 @@ class _BeforeAfterComparisonWidgetState ), ), + // Original | Split | Processed (top centre) + if (widget.onViewModeChanged != null) + Positioned( + top: 12, + left: 96, + right: 96, + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: _buildViewModeControl(), + ), + ), + ), + // Zoom controls (bottom-left) if (widget.beforeImage != null || widget.afterImage != null) Positioned( @@ -647,14 +700,14 @@ class _BeforeAfterComparisonWidgetState child: Row( mainAxisSize: MainAxisSize.min, children: [ - _zoomButton( + _pillButton( 'Fit', tooltip: AppShortcut.previewFit .tooltipFor('Fit the whole frame in the panel'), selected: _isAtFit, onPressed: _isAtFit ? null : _resetZoom, ), - _zoomButton( + _pillButton( '1:1', tooltip: AppShortcut.previewActualSize.tooltipFor( 'Actual pixels — one video pixel per screen pixel'), @@ -681,7 +734,31 @@ class _BeforeAfterComparisonWidgetState ); } - Widget _zoomButton( + /// Original | Split | Processed, styled like the zoom pill, in the order + /// the panes sit. + Widget _buildViewModeControl() { + return Container( + key: const Key('comparison-view-mode'), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final mode in ComparisonViewMode.values) + _pillButton( + mode.label, + tooltip: mode.tooltip, + selected: widget.viewMode == mode, + onPressed: () => widget.onViewModeChanged?.call(mode), + ), + ], + ), + ); + } + + Widget _pillButton( String text, { required String tooltip, required bool selected, diff --git a/app/lib/widgets/comparison_view_mode.dart b/app/lib/widgets/comparison_view_mode.dart new file mode 100644 index 0000000..8e10d55 --- /dev/null +++ b/app/lib/widgets/comparison_view_mode.dart @@ -0,0 +1,24 @@ +import '../models/app_shortcut.dart'; + +/// What the before/after preview shows: either frame whole, or the two split +/// by the draggable divider. Lives in `MainViewModel`, so it survives +/// scrubber moves and new previews. +enum ComparisonViewMode { + original('Original', 'Show only the original frame'), + split('Split', 'Compare original and processed with the divider'), + processed('Processed', 'Show only the processed frame'); + + final String label; + final String description; + const ComparisonViewMode(this.label, this.description); + + /// The keyboard shortcut that selects this mode. + AppShortcut get shortcut => switch (this) { + ComparisonViewMode.original => AppShortcut.previewOriginal, + ComparisonViewMode.split => AppShortcut.previewSplit, + ComparisonViewMode.processed => AppShortcut.previewProcessed, + }; + + /// The button's tooltip, with the shortcut's keys. + String get tooltip => shortcut.tooltipFor(description); +} diff --git a/app/test/app_shortcut_test.dart b/app/test/app_shortcut_test.dart index aedb5fd..f9f69af 100644 --- a/app/test/app_shortcut_test.dart +++ b/app/test/app_shortcut_test.dart @@ -12,6 +12,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:vapourbox/models/app_shortcut.dart'; import 'package:vapourbox/widgets/app_shortcut_scope.dart'; +import 'package:vapourbox/widgets/comparison_view_mode.dart'; void main() { group('AppShortcut', () { @@ -142,6 +143,33 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('B / S / P pick the preview view mode, but not while typing', + (tester) async { + final fired = []; + final field = await pump(tester, { + for (final mode in ComparisonViewMode.values) + mode.shortcut: () => fired.add(mode), + }); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyEvent(LogicalKeyboardKey.keyP); + expect(fired, [ + ComparisonViewMode.original, + ComparisonViewMode.split, + ComparisonViewMode.processed, + ]); + + fired.clear(); + field.fieldFocus.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyEvent(LogicalKeyboardKey.keyP); + await tester.pump(); + expect(fired, isEmpty); + }); + testWidgets('a null binding lets the key through', (tester) async { await pump(tester, {AppShortcut.setInPoint: null}); // Nothing to assert beyond not throwing: there is no handler to call. diff --git a/app/test/before_after_comparison_test.dart b/app/test/before_after_comparison_test.dart index ba3eee4..48ded31 100644 --- a/app/test/before_after_comparison_test.dart +++ b/app/test/before_after_comparison_test.dart @@ -10,9 +10,11 @@ import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:vapourbox/services/image_dimensions.dart'; import 'package:vapourbox/widgets/before_after_comparison.dart'; +import 'package:vapourbox/widgets/comparison_view_mode.dart'; import 'package:vapourbox/widgets/comparison_zoom_controller.dart'; Future _png(int width, int height) async { @@ -261,4 +263,265 @@ void main() { controller.fit(); }); }); + + // Forum feedback (videohelp 419911): the divider stopped 5% short of each + // edge, hiding a typical left crop; and there was no way to see either frame + // whole. + group('divider and view modes', () { + const panel = Size(400, 300); + + Future pump( + WidgetTester tester, { + required Uint8List before, + required Uint8List after, + double? beforeAspectRatio, + double? afterAspectRatio, + ComparisonViewMode viewMode = ComparisonViewMode.split, + ValueChanged? onViewModeChanged, + ComparisonZoomController? zoomController, + }) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 1000); + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: panel.width, + height: panel.height, + child: BeforeAfterComparisonWidget( + beforeImage: before, + afterImage: after, + beforeAspectRatio: beforeAspectRatio, + afterAspectRatio: afterAspectRatio, + viewMode: viewMode, + onViewModeChanged: onViewModeChanged, + zoomController: zoomController, + ), + ), + ), + )); + await tester.pumpAndSettle(); + } + + Rect panelRect(WidgetTester tester) => + tester.getRect(find.byType(BeforeAfterComparisonWidget)); + + /// The width of the screen-space clip that shows the original. + double originalClipWidth(WidgetTester tester) { + final clip = tester.renderObject(find + .descendant( + of: find.byType(BeforeAfterComparisonWidget), + matching: find.byType(ClipRect), + ) + .first); + return clip.clipper!.getClip(clip.size).width; + } + + Finder images() => find.descendant( + of: find.byType(BeforeAfterComparisonWidget), + matching: find.byType(Image), + ); + + testWidgets('a drag past either edge takes the divider to 0 and 1', + (tester) async { + final image = await tester.runAsync(() => _png(200, 100)); + await pump(tester, before: image!, after: image); + final bounds = panelRect(tester); + final centre = bounds.center; + + await tester.dragFrom(centre, const Offset(-1000, 0)); + await tester.pumpAndSettle(); + expect(originalClipWidth(tester), 0); + final lineAtLeft = + tester.getRect(find.byKey(const Key('comparison-divider'))); + final handleAtLeft = + tester.getRect(find.byKey(const Key('comparison-divider-handle'))); + // The 1 px border insets the content; the line touches its left edge. + expect(lineAtLeft.left, closeTo(bounds.left + 1, 1e-6)); + expect(handleAtLeft.left, greaterThanOrEqualTo(bounds.left)); + + await tester.dragFrom(centre, const Offset(2000, 0)); + await tester.pumpAndSettle(); + final contentWidth = bounds.width - 2; + expect(originalClipWidth(tester), closeTo(contentWidth, 1e-6)); + final lineAtRight = + tester.getRect(find.byKey(const Key('comparison-divider'))); + final handleAtRight = + tester.getRect(find.byKey(const Key('comparison-divider-handle'))); + expect(lineAtRight.right, closeTo(bounds.right - 1, 1e-6)); + expect(handleAtRight.right, lessThanOrEqualTo(bounds.right)); + }); + + testWidgets('a tap at the edge puts the divider there, not 5% in', + (tester) async { + final image = await tester.runAsync(() => _png(200, 100)); + await pump(tester, before: image!, after: image); + final bounds = panelRect(tester); + + await tester.tapAt(Offset(bounds.left + 1.5, bounds.center.dy)); + await tester.pump(const Duration(milliseconds: 500)); + expect(originalClipWidth(tester), lessThan(1)); + }); + + group('each mode shows only its own side', () { + late Uint8List source; + late Uint8List processed; + + Future load(WidgetTester tester) async { + source = (await tester.runAsync(() => _png(720, 576)))!; + processed = (await tester.runAsync(() => _png(1920, 1080)))!; + } + + Future pumpMode(WidgetTester tester, ComparisonViewMode mode) => + pump( + tester, + before: source, + after: processed, + beforeAspectRatio: 4 / 3, + afterAspectRatio: 16 / 9, + viewMode: mode, + ); + + testWidgets('Original', (tester) async { + await load(tester); + await pumpMode(tester, ComparisonViewMode.original); + expect(images(), findsOneWidget); + final rect = tester.getRect(images()); + expect(rect.width / rect.height, closeTo(4 / 3, 1e-6)); + expect(find.text('Original'), findsOneWidget); + expect(find.text('Processed'), findsNothing); + expect(find.byKey(const Key('comparison-divider')), findsNothing); + expect( + find.byKey(const Key('comparison-divider-handle')), findsNothing); + }); + + testWidgets('Processed', (tester) async { + await load(tester); + await pumpMode(tester, ComparisonViewMode.processed); + expect(images(), findsOneWidget); + final rect = tester.getRect(images()); + expect(rect.width / rect.height, closeTo(16 / 9, 1e-6)); + expect(find.text('Processed'), findsOneWidget); + expect(find.text('Original'), findsNothing); + expect(find.byKey(const Key('comparison-divider')), findsNothing); + }); + + testWidgets('Split', (tester) async { + await load(tester); + await pumpMode(tester, ComparisonViewMode.split); + expect(images(), findsNWidgets(2)); + expect(find.text('Original'), findsOneWidget); + expect(find.text('Processed'), findsOneWidget); + expect(find.byKey(const Key('comparison-divider')), findsOneWidget); + }); + + testWidgets('dragging does nothing outside Split', (tester) async { + await load(tester); + await pumpMode(tester, ComparisonViewMode.original); + await tester.dragFrom( + panelRect(tester).center, const Offset(-1000, 0)); + await tester.pumpAndSettle(); + // Back in Split, the divider is still in the middle. + await pumpMode(tester, ComparisonViewMode.split); + expect(originalClipWidth(tester), + closeTo((panel.width - 2) / 2, 1e-6)); + }); + }); + + testWidgets('1:1 is measured against the frame on screen', + (tester) async { + final controller = ComparisonZoomController(); + final source = await tester.runAsync(() => _png(720, 576)); + final processed = await tester.runAsync(() => _png(1920, 1080)); + Future pumpMode(ComparisonViewMode mode) => pump( + tester, + before: source!, + after: processed!, + beforeAspectRatio: 4 / 3, + afterAspectRatio: 16 / 9, + viewMode: mode, + zoomController: controller, + ); + + await pumpMode(ComparisonViewMode.processed); + controller.showActualSize(); + await tester.pumpAndSettle(); + double scale() => tester + .widget(find + .descendant( + of: find.byType(BeforeAfterComparisonWidget), + matching: find.byType(Transform), + ) + .first) + .transform + .getMaxScaleOnAxis(); + // 398 wide at 16:9; 1080 rows on 1080 pixels. + expect(scale(), closeTo(1080 / (398 / (16 / 9)), 1e-9)); + expect(find.text('100%'), findsOneWidget); + + // The 4:3 source fits by height (298); 576 rows on 576 pixels. + await pumpMode(ComparisonViewMode.original); + expect(scale(), closeTo(576 / 298, 1e-9)); + expect(find.text('100%'), findsOneWidget); + }); + + testWidgets('the mode and the divider survive a new processed frame', + (tester) async { + final source = await tester.runAsync(() => _png(720, 576)); + final first = await tester.runAsync(() => _png(1920, 1080)); + final second = await tester.runAsync(() => _png(1280, 720)); + + // Move the divider in Split first. + await pump(tester, before: source!, after: first!); + await tester.dragFrom(panelRect(tester).center, const Offset(-100, 0)); + await tester.pumpAndSettle(); + final moved = originalClipWidth(tester); + expect(moved, lessThan((panel.width - 2) / 2)); + + await pump(tester, + before: source, + after: first, + viewMode: ComparisonViewMode.processed); + await pump(tester, + before: source, + after: second!, + viewMode: ComparisonViewMode.processed); + expect(images(), findsOneWidget); + final shown = tester.widget(images()).image as MemoryImage; + expect(identical(shown.bytes, second), isTrue); + + // Back to Split: the divider is where the user left it. + await pump(tester, before: source, after: second); + expect(originalClipWidth(tester), closeTo(moved, 1e-6)); + }); + + testWidgets('the view-mode control reports a tap, with shortcut tooltips', + (tester) async { + final image = await tester.runAsync(() => _png(200, 100)); + final picked = []; + await pump(tester, + before: image!, after: image, onViewModeChanged: picked.add); + + final control = find.byKey(const Key('comparison-view-mode')); + expect(control, findsOneWidget); + await tester.tap( + find.descendant(of: control, matching: find.text('Processed'))); + await tester.tap( + find.descendant(of: control, matching: find.text('Original'))); + expect(picked, + [ComparisonViewMode.processed, ComparisonViewMode.original]); + + for (final mode in ComparisonViewMode.values) { + expect(find.byTooltip(mode.shortcut.tooltipFor(mode.description)), + findsOneWidget); + } + }); + + testWidgets('no control without a callback', (tester) async { + final image = await tester.runAsync(() => _png(200, 100)); + await pump(tester, before: image!, after: image); + expect(find.byKey(const Key('comparison-view-mode')), findsNothing); + }); + }); } diff --git a/app/test/preview_aspect_test.dart b/app/test/preview_aspect_test.dart index 33108a8..124337c 100644 --- a/app/test/preview_aspect_test.dart +++ b/app/test/preview_aspect_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:vapourbox/models/preview_frame_info.dart'; import 'package:vapourbox/services/field_order_detector.dart'; import 'package:vapourbox/viewmodels/main_viewmodel.dart'; +import 'package:vapourbox/widgets/comparison_view_mode.dart'; /// Just enough of a PNG for its header to be read: signature, IHDR length and /// type, width, height. @@ -138,6 +139,29 @@ void viewModelAspects() { expect(vm.comparisonAfterAspect, closeTo(16 / 9, 1e-9)); }); + test('the preview view mode defaults to Split and notifies on change', () { + expect(vm.previewViewMode, ComparisonViewMode.split); + var notified = 0; + vm.addListener(() => notified++); + vm.setPreviewViewMode(ComparisonViewMode.processed); + expect(vm.previewViewMode, ComparisonViewMode.processed); + expect(notified, 1); + vm.setPreviewViewMode(ComparisonViewMode.processed); + expect(notified, 1, reason: 'no change, no rebuild'); + }); + + test('the view mode survives a scrubber move and a new preview', () async { + vm.debugAddQueueItemForTest('/tmp/vb_pal.mkv', + width: 720, height: 576, sar: '16:15'); + vm.setPreviewViewMode(ComparisonViewMode.original); + vm.debugAcceptProcessedPreview(_pngHeader(1920, 1080), + PreviewFrameInfo.parse('PREVIEW_INFO:frame=0')); + await vm.setScrubberPosition(0.5); + vm.debugAcceptProcessedPreview(_pngHeader(1920, 1080), + PreviewFrameInfo.parse('PREVIEW_INFO:frame=3')); + expect(vm.previewViewMode, ComparisonViewMode.original); + }); + test('a failed preview clears the stored aspect with the image', () { vm.debugAddQueueItemForTest('/tmp/vb_pal.mkv', width: 720, height: 576, sar: '16:15');