diff --git a/.gitignore b/.gitignore index 61cc91c..2e1a675 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,7 @@ app.*.map.json *.deb *.tar.gz *.exe -!dist/imajviewer_1.5.0_amd64.deb +!dist/imajviewer_1.6.0_amd64.deb !dist/imajviewer_setup_1.1.0.exe deb/usr/lib/imajviewer/ release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2c5bf..7a872bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to imajViewer are documented in this file. ## [Unreleased] +## [1.6.0] - 2026-10-14 + +### Added +- Horizontal flip (`R` key): mirrors the image left-right about the viewport centre and clears + the active effects (same reset as double right click); pressing `R` again restores the + original orientation. Zoom/pan are kept. +- Crop save (`Ctrl+S`) now bakes the flip into the output as well, so the saved JPEG matches + the mirrored view (also in combination with rotation). + ## [1.5.0] - 2026-10-12 ### Added diff --git a/README.md b/README.md index cd1bba2..e4a23bf 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,16 @@ Ultra-lightweight image viewer written in Flutter — Linux & Windows. - **Saturation:** Ctrl + Right drag + vertical move - **Brightness:** Ctrl+Shift + Right drag + vertical move - **Rotate:** Shift + Right drag + vertical move (~100px = 90°) +- **Flip horizontally:** `R` — mirrors the view and clears the active effects (same reset as + double right click); press `R` again to undo - **Reset (all effects):** Double right click - **Navigation arrows:** bottom corners, visible on hover - **Settings (gear):** sort by time/name, print, shortcut info - **Print:** send the image to a printer (A4, fit) - **Save visible area:** title bar save icon or `Ctrl+S` — the on-screen region is written at original resolution as JPEG (quality 93) next to the source file (`photo.jpg` → - `photo_143025.jpg`); active effects (contrast/saturation/brightness) and rotation are baked - into the output; if that name already exists, nothing is written and a warning is shown + `photo_143025.jpg`); active effects (contrast/saturation/brightness), rotation and horizontal + flip are baked into the output; if that name already exists, nothing is written and a warning is shown - Drag & drop to open images - Window size/position memory (JSON persistence) - Set as default image viewer @@ -86,6 +88,7 @@ folder including `imajviewer.exe` and all DLLs). | Ctrl + Right drag + vertical | Saturation (0.0 – 2.0) | | Ctrl+Shift + Right drag + vertical | Brightness (-100 – 100) | | Shift + Right drag + vertical | Rotate (free) | +| R | Flip horizontally (clears effects; press again to undo) | | Double right click | Reset (all effects) | | Left / Right arrow | Previous / next image | | Ctrl + S | Save visible area (JPEG 93, original resolution) | diff --git a/dist/imajviewer_1.5.0_amd64.deb b/dist/imajviewer_1.6.0_amd64.deb similarity index 59% rename from dist/imajviewer_1.5.0_amd64.deb rename to dist/imajviewer_1.6.0_amd64.deb index ab72ab3..ec8fc13 100644 Binary files a/dist/imajviewer_1.5.0_amd64.deb and b/dist/imajviewer_1.6.0_amd64.deb differ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 98b100f..573cd01 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -6,7 +6,7 @@ "sortByName": "By name", "appDescription": "Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.", "shortcuts": "Shortcuts", - "shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness", + "shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness\n• R — flip horizontally (resets effects, press again to undo)", "print": "Print", "close": "Close", "saveCrop": "Save visible area (JPEG 93)", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2b6dffc..9749391 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -137,7 +137,7 @@ abstract class AppLocalizations { /// No description provided for @shortcutsList. /// /// In en, this message translates to: - /// **'• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'** + /// **'• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness\n• R — flip horizontally (resets effects, press again to undo)'** String get shortcutsList; /// No description provided for @print. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 76e9ad2..aba75e3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -29,7 +29,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get shortcutsList => - '• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'; + '• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness\n• R — flip horizontally (resets effects, press again to undo)'; @override String get print => 'Print'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index c42767a..ab874ad 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -29,7 +29,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get shortcutsList => - '• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 93)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık'; + '• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 93)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık\n• R — yatay aynala (efektleri sıfırlar, tekrar basınca geri alır)'; @override String get print => 'Yazdır'; diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index a892e19..6dad7c9 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -6,7 +6,7 @@ "sortByName": "İsme göre", "appDescription": "Ultra hafif ve hızlı resim görüntüleyici. Görselleri açın, gezinti için okları veya klavye tuşlarını kullanın.", "shortcuts": "Kısayollar", - "shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 93)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık", + "shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 93)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık\n• R — yatay aynala (efektleri sıfırlar, tekrar basınca geri alır)", "print": "Yazdır", "close": "Kapat", "saveCrop": "Görünen alanı kaydet (JPEG 93)", diff --git a/lib/services/crop_save_service.dart b/lib/services/crop_save_service.dart index b784865..c6adc5a 100644 --- a/lib/services/crop_save_service.dart +++ b/lib/services/crop_save_service.dart @@ -17,6 +17,7 @@ class CropViewParams { final double vw, vh; // viewport boyutu final double tx, ty, sc; // pan/zoom final double angleDeg; // donus (derece) + final bool flipH; // yatay aynalama (goruntunun kendisi degil, gorunum) final double cx, cy; // donus merkezi (viewport ortasi) final double k; // kaynak -> layout olcegi final double ox, oy; // layout offset @@ -29,6 +30,7 @@ class CropViewParams { required this.ty, required this.sc, required this.angleDeg, + this.flipH = false, required this.cx, required this.cy, required this.k, @@ -73,6 +75,8 @@ class CropSaveService { final out = vp.angleDeg == 0 ? _cropAxisAligned(source, vp) : _cropRotated(source, vp); + // Ekranda yatay aynalama acikken cikti da aynalanir (cikti = viewport'un birebir eslemesi). + if (vp.flipH) img.flipHorizontal(out); // Çıktıda yön fiziksel — EXIF tag'ini temizle (çift dönüşü önler). out.exif.imageIfd.orientation = null; diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index b8172d6..45a090c 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -26,6 +26,7 @@ class ImageCanvasState extends State { double _contrast = 1.0; double _saturation = 1.0; double _brightness = 0; + bool _flipH = false; // ── Gesture tracking ── String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation' | 'brightness' @@ -165,6 +166,13 @@ class ImageCanvasState extends State { bool _onHardwareKey(KeyEvent event) { final key = event.logicalKey; if (event is KeyDownEvent) { + if (key == LogicalKeyboardKey.keyR && + !HardwareKeyboard.instance.isControlPressed && + !HardwareKeyboard.instance.isShiftPressed && + !HardwareKeyboard.instance.isAltPressed) { + _toggleFlipH(); + return true; + } if (key == LogicalKeyboardKey.controlLeft || key == LogicalKeyboardKey.controlRight) { _ctrlPressed = true; @@ -202,6 +210,7 @@ class ImageCanvasState extends State { void _resetView() { _tx = 0; _ty = 0; _sc = 1.0; _isFilled = WindowPersistence.filled; _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; + _flipH = false; _usingFullRes = false; _imageSize = Size.zero; _markRenderDirty(); @@ -210,10 +219,19 @@ class ImageCanvasState extends State { void _toggleFillFit() { _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; + _flipH = false; WindowPersistence.saveFilled(_isFilled); _markRenderDirty(); } + /// R: sağ çift tık gibi efektleri sıfırlar ve yatay aynalar; tekrar basınca geri alır. + void _toggleFlipH() { + _flipH = !_flipH; + _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; + _interactionMode = 'none'; + _markRenderDirty(); + } + void _handleDoubleTap() => _toggleFillFit(); void _handleScroll(PointerScrollEvent e) { @@ -261,6 +279,7 @@ class ImageCanvasState extends State { _contrast = 1.0; _saturation = 1.0; _brightness = 0; + _flipH = false; _interactionMode = 'none'; _markRenderDirty(); _lastRightTapTime = null; @@ -416,6 +435,7 @@ class ImageCanvasState extends State { ty: _ty, sc: _sc, angleDeg: _angle, + flipH: _flipH, cx: _vpSize.width / 2, cy: _vpSize.height / 2, k: k, @@ -518,12 +538,13 @@ class ImageCanvasState extends State { }, ); - if (_angle != 0) { + if (_angle != 0 || _flipH) { imageContent = Transform( alignment: Alignment.topLeft, transform: Matrix4.identity() ..translate(vpCenterX, vpCenterY) ..rotateZ(angleRad) + ..scale(_flipH ? -1.0 : 1.0, 1.0) ..translate(-vpCenterX, -vpCenterY), child: imageContent, ); diff --git a/pubspec.yaml b/pubspec.yaml index 08ede65..65c8659 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: imajviewer description: "Ultra-lightweight image viewer for Linux and Windows." publish_to: 'none' -version: 1.5.0+11 +version: 1.6.0+12 msix_config: display_name: ImajViewer diff --git a/test/flip_test.dart b/test/flip_test.dart new file mode 100644 index 0000000..8519cdf --- /dev/null +++ b/test/flip_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:imajviewer/widgets/image_canvas.dart'; + +/// Aynalama uygulandiginda render zincirinde negatif x olcekli bir Transform bulunur. +bool _mirrored(WidgetTester tester) => tester + .widgetList(find.byType(Transform)) + .any((t) => t.transform.entry(0, 0) < 0); + +Future _pressR(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyR); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyR); + await tester.pump(); + await tester.pump(); +} + +void main() { + late Directory tmp; + late String pngPath; + + setUpAll(() { + tmp = Directory.systemTemp.createTempSync('flip_test'); + pngPath = '${tmp.path}/a.png'; + File(pngPath).writeAsBytesSync(img.encodePng(img.Image(width: 4, height: 2))!); + }); + + tearDownAll(() => tmp.deleteSync(recursive: true)); + + testWidgets('R yatay aynalamayi acar, tekrar basinca kapatir', (tester) async { + await tester.pumpWidget(MaterialApp( + home: SizedBox( + width: 400, + height: 300, + child: ImageCanvas(filePath: pngPath), + ), + )); + await tester.pump(); + + expect(_mirrored(tester), isFalse, reason: 'baslangicta aynalama olmamali'); + + await _pressR(tester); + expect(_mirrored(tester), isTrue, reason: 'R sonrasi aynalama acik olmali'); + + await _pressR(tester); + expect(_mirrored(tester), isFalse, reason: 'ikinci R sonrasi eski haline donmeli'); + }); +} diff --git a/tool/crop_test.dart b/tool/crop_test.dart index 956ab65..b8a1242 100644 --- a/tool/crop_test.dart +++ b/tool/crop_test.dart @@ -67,6 +67,38 @@ Future main() async { print('3) dims ${r3.width}x${r3.height} (25x25 beklenir: zoom x2 + fill k=2 -> 4 src px/ekran px)'); if (r3.width != 25 || r3.height != 25) fails++; + // 4) Yatay aynalama (flipH): gorunum aynalanir, cikti da aynali olmali + final out4 = '${tmp.path}/d'; + Directory(out4).createSync(); + final f4 = '$out4/src.jpg'; + File(f4).writeAsBytesSync(File(srcPath).readAsBytesSync()); + final p4 = CropViewParams(w: 100, h: 50, vw: 100, vh: 50, tx: 0, ty: 0, sc: 1, + angleDeg: 0, flipH: true, cx: 50, cy: 25, k: 1, ox: 0, oy: 0); + final path4 = await CropSaveService.saveCrop(srcPath: f4, source: await CropSaveService.load(f4), vp: p4); + final r4 = img.decodeImage(File(path4).readAsBytesSync())!; + print('4) dims ${r4.width}x${r4.height} (100x50 beklenir)'); + if (r4.width != 100 || r4.height != 50) fails++; + checkPix('4) sol-ust (orij sag-ust YESIL)', imgPx(r4, 2, 2), 0, 255, 0); + checkPix('4) sag-ust (orij sol-ust KIRMIZI)', imgPx(r4, 97, 2), 255, 0, 0); + checkPix('4) sol-alt (orij sag-alt SARI)', imgPx(r4, 2, 47), 255, 255, 0); + checkPix('4) sag-alt (orij sol-alt MAVI)', imgPx(r4, 97, 47), 0, 0, 255); + + // 5) Donus + aynalama: 2. senaryonun aynasi + final out5 = '${tmp.path}/e'; + Directory(out5).createSync(); + final f5 = '$out5/src.jpg'; + File(f5).writeAsBytesSync(File(srcPath).readAsBytesSync()); + final p5 = CropViewParams(w: 100, h: 50, vw: 50, vh: 100, tx: 0, ty: 0, sc: 1, + angleDeg: 90, flipH: true, cx: 25, cy: 50, k: 1, ox: -25, oy: 25); + final path5 = await CropSaveService.saveCrop(srcPath: f5, source: await CropSaveService.load(f5), vp: p5); + final r5 = img.decodeImage(File(path5).readAsBytesSync())!; + print('5) dims ${r5.width}x${r5.height} (50x100 beklenir)'); + if (r5.width != 50 || r5.height != 100) fails++; + checkPix('5) sol-ust (orij sol-ust KIRMIZI)', imgPx(r5, 2, 2), 255, 0, 0); + checkPix('5) sag-ust (orij sol-alt MAVI)', imgPx(r5, 47, 2), 0, 0, 255); + checkPix('5) sol-alt (orij sag-ust YESIL)', imgPx(r5, 2, 97), 0, 255, 0); + checkPix('5) sag-alt (orij sag-alt SARI)', imgPx(r5, 47, 97), 255, 255, 0); + tmp.deleteSync(recursive: true); print(fails == 0 ? 'ALL PASS' : '$fails FAILURES'); exitCode = fails == 0 ? 0 : 1;