feat: crop save (save visible area at original resolution) v1.4.0
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled

- Save visible area at original resolution as JPEG q90 (title bar button or Ctrl+S)
- Output name: original name + _HHmmss suffix; no overwrite, warning shown
- Active color effects baked in; rotation saves bounding box
- New: crop_save_service, l10n strings TR/EN, docs/plan-crop-save.md
This commit is contained in:
Alhan committed 2026-10-11 21:36:20 +03:00
1 parent 73c1ff51d6
commit 168e087c8b
16 files changed
+399 -14

No files matched your search

+11 -1
View File
@@ -6,9 +6,19 @@
"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+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 90)\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",
"print": "Print",
"close": "Close",
"saveCrop": "Save visible area (JPEG 90)",
"cropSaved": "Saved: {path}",
"@cropSaved": {
"placeholders": {
"path": { "type": "String" }
}
},
"cropFileExists": "A file with the same name already exists — nothing was written",
"cropUnsupported": "This image format cannot be saved as a crop",
"cropFailed": "Failed to save crop",
"dragDropHint": "Drag images here\nor press Ctrl+O to open",
"dropHere": "Drop",
"imageLoadError": "Failed to load image"
+31 -1
View File
@@ -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+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 90)\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'**
String get shortcutsList;
/// No description provided for @print.
@@ -152,6 +152,36 @@ abstract class AppLocalizations {
/// **'Close'**
String get close;
/// No description provided for @saveCrop.
///
/// In en, this message translates to:
/// **'Save visible area (JPEG 90)'**
String get saveCrop;
/// No description provided for @cropSaved.
///
/// In en, this message translates to:
/// **'Saved: {path}'**
String cropSaved(String path);
/// No description provided for @cropFileExists.
///
/// In en, this message translates to:
/// **'A file with the same name already exists — nothing was written'**
String get cropFileExists;
/// No description provided for @cropUnsupported.
///
/// In en, this message translates to:
/// **'This image format cannot be saved as a crop'**
String get cropUnsupported;
/// No description provided for @cropFailed.
///
/// In en, this message translates to:
/// **'Failed to save crop'**
String get cropFailed;
/// No description provided for @dragDropHint.
///
/// In en, this message translates to:
+19 -1
View File
@@ -29,7 +29,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get shortcutsList =>
'• Left/Right arrow — previous/next image\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 90)\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';
@override
String get print => 'Print';
@@ -37,6 +37,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get close => 'Close';
@override
String get saveCrop => 'Save visible area (JPEG 90)';
@override
String cropSaved(String path) {
return 'Saved: $path';
}
@override
String get cropFileExists =>
'A file with the same name already exists — nothing was written';
@override
String get cropUnsupported => 'This image format cannot be saved as a crop';
@override
String get cropFailed => 'Failed to save crop';
@override
String get dragDropHint => 'Drag images here\nor press Ctrl+O to open';
+18 -1
View File
@@ -29,7 +29,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get shortcutsList =>
'• Sol/Sağ ok — önceki/sonraki resim\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 90)\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';
@override
String get print => 'Yazdır';
@@ -37,6 +37,23 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get close => 'Kapat';
@override
String get saveCrop => 'Görünen alanı kaydet (JPEG 90)';
@override
String cropSaved(String path) {
return 'Kaydedildi: $path';
}
@override
String get cropFileExists => 'Aynı adlı dosya zaten var — yazılmadı';
@override
String get cropUnsupported => 'Bu resim formatı crop olarak kaydedilemiyor';
@override
String get cropFailed => 'Crop kaydedilemedi';
@override
String get dragDropHint =>
'Resimleri buraya sürükleyin\nveya Ctrl+O ile açın';
+11 -1
View File
@@ -6,9 +6,19 @@
"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+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 90)\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",
"print": "Yazdır",
"close": "Kapat",
"saveCrop": "Görünen alanı kaydet (JPEG 90)",
"cropSaved": "Kaydedildi: {path}",
"@cropSaved": {
"placeholders": {
"path": { "type": "String" }
}
},
"cropFileExists": "Aynı adlı dosya zaten var — yazılmadı",
"cropUnsupported": "Bu resim formatı crop olarak kaydedilemiyor",
"cropFailed": "Crop kaydedilemedi",
"dragDropHint": "Resimleri buraya sürükleyin\nveya Ctrl+O ile açın",
"dropHere": "Bırakın",
"imageLoadError": "Resim yüklenemedi"
+48
View File
@@ -8,6 +8,7 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../services/image_manager.dart';
import '../services/file_handler.dart';
import '../services/crop_save_service.dart';
import '../widgets/custom_title_bar.dart';
import '../widgets/image_canvas.dart';
import '../widgets/window_resize_zones.dart';
@@ -28,6 +29,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
bool _controlsVisible = false;
Timer? _saveDebounce;
Rect _lastBounds = Rect.zero;
final GlobalKey<ImageCanvasState> _canvasKey = GlobalKey<ImageCanvasState>();
@override
void initState() {
@@ -188,9 +190,53 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
);
}
Future<void> _cropSave() async {
final path = ImageManager.instance.currentImagePath;
final state = _canvasKey.currentState;
if (path.isEmpty || state == null) return;
final l10n = AppLocalizations.of(context)!;
String? message;
try {
final source = await CropSaveService.load(path);
final rect = state.cropSourceRect(source.width, source.height);
if (rect == null || rect.isEmpty || !rect.isFinite) {
message = l10n.cropFailed;
} else {
final outPath = await CropSaveService.saveCrop(
srcPath: path,
source: source,
rect: rect,
matrix: state.activeColorMatrix,
);
message = l10n.cropSaved(outPath);
}
} on CropSaveError catch (e) {
switch (e.code) {
case 'file_exists':
message = l10n.cropFileExists;
case 'decode':
message = l10n.cropUnsupported;
default:
message = l10n.cropFailed;
}
} catch (_) {
message = l10n.cropFailed;
}
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(message, style: const TextStyle(fontSize: 13)),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
width: 520,
));
}
@override
Widget build(BuildContext context) {
return AppShortcuts(
onCropSave: _hasImages ? _cropSave : null,
child: Scaffold(
backgroundColor: Colors.transparent,
body: WindowResizeZones(
@@ -223,6 +269,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
color: Colors.transparent,
child: _hasImages
? ImageCanvas(
key: _canvasKey,
filePath: ImageManager.instance.currentImagePath,
)
: Center(
@@ -242,6 +289,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
),
CustomTitleBar(
onSettings: _showSettings,
onSaveCrop: _hasImages ? _cropSave : null,
visible: _controlsVisible,
),
if (_hasImages && _controlsVisible) ...[
+75
View File
@@ -0,0 +1,75 @@
import 'dart:io';
import 'dart:ui' show Rect;
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
class CropSaveError implements Exception {
final String code;
const CropSaveError(this.code);
@override
String toString() => 'CropSaveError($code)';
}
class CropSaveService {
/// Tam çözünürlükte decode + EXIF yönünü gömme (engine görüntüsüyle tutarlı).
static Future<img.Image> load(String srcPath) async {
final bytes = await File(srcPath).readAsBytes();
final src = img.decodeImage(bytes);
if (src == null) throw const CropSaveError('decode');
return img.bakeOrientation(src);
}
/// Hedef yol: `resim.jpg` -> `resim_143025.jpg` (çakışmada null).
static String? buildOutputPath(String srcPath) {
final ext = p.extension(srcPath);
final base = p.basenameWithoutExtension(srcPath);
final dir = p.dirname(srcPath);
final now = DateTime.now();
final hh = now.hour.toString().padLeft(2, '0');
final mm = now.minute.toString().padLeft(2, '0');
final ss = now.second.toString().padLeft(2, '0');
final candidate = p.join(dir, '${base}_$hh$mm$ss$ext');
return File(candidate).existsSync() ? null : candidate;
}
/// Crop + aktif renk matrisi + JPEG q90.
/// [matrix] 4x5 row-major renk matrisi (image_canvas ile aynı form, 0-255 alanı);
/// null = gömme yok.
static Future<String> saveCrop({
required String srcPath,
required img.Image source,
required Rect rect,
List<double>? matrix,
}) async {
final outPath = buildOutputPath(srcPath);
if (outPath == null) throw const CropSaveError('file_exists');
final iw = source.width, ih = source.height;
final x = rect.left.round().clamp(0, iw - 1);
final y = rect.top.round().clamp(0, ih - 1);
final w = rect.right.round().clamp(x + 1, iw) - x;
final h = rect.bottom.round().clamp(y + 1, ih) - y;
if (w <= 0 || h <= 0) throw const CropSaveError('empty_rect');
final crop = img.copyCrop(source, x: x, y: y, width: w, height: h);
// Çıktıda yön fiziksel — EXIF tag'ini temizle (çift dönüşü önler).
crop.exif.imageIfd.orientation = null;
if (matrix != null) _applyMatrix(crop, matrix);
final jpeg = img.JpegEncoder(quality: 90).encode(crop);
await File(outPath).writeAsBytes(jpeg);
return outPath;
}
/// Flutter'la aynı 0-255 alan/semantiğindeki 4x5 matrisi uygular.
static void _applyMatrix(img.Image image, List<double> m) {
for (final pixel in image) {
final r = pixel.r.toDouble(), g = pixel.g.toDouble(), b = pixel.b.toDouble();
pixel.r = (m[0] * r + m[1] * g + m[2] * b + m[4]).clamp(0, 255).round();
pixel.g = (m[5] * r + m[6] * g + m[7] * b + m[9]).clamp(0, 255).round();
pixel.b = (m[10] * r + m[11] * g + m[12] * b + m[13]).clamp(0, 255).round();
}
}
}
+10 -1
View File
@@ -7,8 +7,9 @@ import '../services/file_handler.dart';
class AppShortcuts extends StatelessWidget {
final Widget child;
final VoidCallback? onCropSave;
const AppShortcuts({super.key, required this.child});
const AppShortcuts({super.key, required this.child, this.onCropSave});
@override
Widget build(BuildContext context) {
@@ -35,6 +36,14 @@ class AppShortcuts extends StatelessWidget {
return KeyEventResult.handled;
}
// Ctrl+S: görünen alanı kaydet (crop save)
if (ctrl && !shift && event.logicalKey == LogicalKeyboardKey.keyS) {
if (onCropSave != null) {
onCropSave!();
return KeyEventResult.handled;
}
}
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
ImageManager.instance.previousImage();
+7 -1
View File
@@ -3,9 +3,10 @@ import 'package:window_manager/window_manager.dart';
class CustomTitleBar extends StatelessWidget {
final VoidCallback? onSettings;
final VoidCallback? onSaveCrop;
final bool visible;
const CustomTitleBar({super.key, this.onSettings, this.visible = true});
const CustomTitleBar({super.key, this.onSettings, this.onSaveCrop, this.visible = true});
@override
Widget build(BuildContext context) {
@@ -43,6 +44,11 @@ class CustomTitleBar extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onSaveCrop != null)
_BarButton(
icon: Icons.save_outlined,
onTap: onSaveCrop!,
),
_BarButton(
icon: Icons.horizontal_rule,
onTap: () => windowManager.minimize(),
+54 -4
View File
@@ -10,10 +10,10 @@ class ImageCanvas extends StatefulWidget {
final String filePath;
const ImageCanvas({super.key, required this.filePath});
@override
State<ImageCanvas> createState() => _ImageCanvasState();
State<ImageCanvas> createState() => ImageCanvasState();
}
class _ImageCanvasState extends State<ImageCanvas> {
class ImageCanvasState extends State<ImageCanvas> {
bool _isFilled = true;
final GlobalKey _imageKey = GlobalKey();
double _tx = 0, _ty = 0, _sc = 1.0;
@@ -314,6 +314,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
static const double _bLum = 0.0722;
ColorFilter? _cachedFilter;
List<double>? _cachedMatrix;
double _cachedSat = -1;
double _cachedCon = -1;
double _cachedBri = double.nan;
@@ -380,11 +381,60 @@ class _ImageCanvasState extends State<ImageCanvas> {
];
// M_final = M_contrast * M_brightness * M_saturation
_cachedFilter = ColorFilter.matrix(
_multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix));
final matrix =
_multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix);
_cachedMatrix = matrix;
_cachedFilter = ColorFilter.matrix(matrix);
return _cachedFilter!;
}
/// Aktif renk matrisi (4x5 row-major); identity ise null.
/// CropSaveService'e aynen taşınır (aynı alan/semantik: 0-255).
List<double>? get activeColorMatrix {
if (_saturation == 1.0 && _contrast == 1.0 && _brightness == 0.0) return null;
_buildCombinedFilter();
return _cachedMatrix;
}
/// Viewport'un görünen bölgesini kaynak (tam çözünürlük) uzayına eşler.
/// [w]x[h] = gerçek dosya boyutu. Degrade durumda null.
Rect? cropSourceRect(int w, int h) {
if (_vpSize.width <= 0 || _vpSize.height <= 0 || w <= 0 || h <= 0) return null;
final cx = _vpSize.width / 2, cy = _vpSize.height / 2;
final rad = -_angle * math.pi / 180;
final cos = math.cos(rad), sin = math.sin(rad);
Offset toSrc(Offset p) {
final double qx = (p.dx - _tx) / _sc, qy = (p.dy - _ty) / _sc;
final double rx = (_angle == 0) ? qx : (cos * (qx - cx) - sin * (qy - cy) + cx);
final double ry = (_angle == 0) ? qy : (sin * (qx - cx) + cos * (qy - cy) + cy);
final double k = _isFilled
? math.max(_vpSize.width / w, _vpSize.height / h)
: math.min(_vpSize.width / w, _vpSize.height / h);
final double ox = (_vpSize.width - w * k) / 2, oy = (_vpSize.height - h * k) / 2;
return Offset((rx - ox) / k, (ry - oy) / k);
}
final corners = [
toSrc(Offset.zero),
toSrc(Offset(_vpSize.width, 0)),
toSrc(Offset(_vpSize.width, _vpSize.height)),
toSrc(Offset(0, _vpSize.height)),
];
double xmin = double.infinity, xmax = -double.infinity;
double ymin = double.infinity, ymax = -double.infinity;
for (final c in corners) {
xmin = math.min(xmin, c.dx); xmax = math.max(xmax, c.dx);
ymin = math.min(ymin, c.dy); ymax = math.max(ymax, c.dy);
}
return Rect.fromLTRB(
xmin.clamp(0.0, w.toDouble()),
ymin.clamp(0.0, h.toDouble()),
xmax.clamp(0.0, w.toDouble()),
ymax.clamp(0.0, h.toDouble()),
);
}
FileImage? _cachedFileImage;
ImageProvider<Object>? _cachedProvider;
String? _cachedPath;