diff --git a/.gitignore b/.gitignore index 547f758..4604363 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,7 @@ app.*.map.json *.deb *.tar.gz *.exe -!dist/imajviewer_1.7.0_amd64.deb +!dist/imajviewer_1.7.1_amd64.deb !dist/imajviewer_setup_1.1.0.exe deb/usr/lib/imajviewer/ release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cc500f3..83d8df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to imajViewer are documented in this file. ## [Unreleased] +## [1.7.1] - 2026-10-25 + +### Added +- Zoom percentage badge at the bottom center, between the previous/next buttons: shown + together with the hover controls, translucent background, live while wheeling + (100% = one image pixel per screen pixel), recomputed on fill/fit toggle, image + change and window resize. + +### Changed +- Image header parsing (pixel size without decoding) moved from the viewer screen into + `lib/services/image_header.dart`; the settings file-properties panel and the zoom badge + use the same code. + ## [1.7.0] - 2026-10-20 ### Added diff --git a/dist/imajviewer_1.7.0_amd64.deb b/dist/imajviewer_1.7.1_amd64.deb similarity index 56% rename from dist/imajviewer_1.7.0_amd64.deb rename to dist/imajviewer_1.7.1_amd64.deb index a083d45..f39ee32 100644 Binary files a/dist/imajviewer_1.7.0_amd64.deb and b/dist/imajviewer_1.7.1_amd64.deb differ diff --git a/lib/screens/viewer_screen.dart b/lib/screens/viewer_screen.dart index 3d353bb..bbb5db4 100644 --- a/lib/screens/viewer_screen.dart +++ b/lib/screens/viewer_screen.dart @@ -1,13 +1,12 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:image/image.dart' as img; import 'package:window_manager/window_manager.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; +import '../services/image_header.dart'; import '../services/image_manager.dart'; import '../services/file_handler.dart'; import '../services/crop_save_service.dart'; @@ -18,7 +17,7 @@ import '../shortcuts/app_shortcuts.dart'; import '../services/window_persistence.dart'; import '../l10n/app_localizations.dart'; -const appVersion = '1.7.0'; +const appVersion = '1.7.1'; class ViewerScreen extends StatefulWidget { final List initialFiles; @@ -33,6 +32,7 @@ class _ViewerScreenState extends State with WindowListener { bool _controlsVisible = false; Timer? _saveDebounce; Rect _lastBounds = Rect.zero; + final ValueNotifier _zoomPercent = ValueNotifier(100); final GlobalKey _canvasKey = GlobalKey(); @override @@ -53,6 +53,7 @@ class _ViewerScreenState extends State with WindowListener { windowManager.removeListener(this); ImageManager.instance.removeListener(_onImageManagerChanged); _saveDebounce?.cancel(); + _zoomPercent.dispose(); super.dispose(); } @@ -222,16 +223,8 @@ class _ViewerScreenState extends State with WindowListener { final l10n = AppLocalizations.of(context)!; final file = File(path); final rows = <(String, String)>[]; - Size? size; - try { - final stat = file.statSync(); - const maxHead = 512 * 1024; - final raf = file.openSync(); - final header = raf.readSync(stat.size < maxHead ? stat.size : maxHead); - raf.closeSync(); - size = _headerPixelSize(header); - } catch (_) {} - rows.add((l10n.propResolution, size == null ? '—' : '${size!.width.toInt()} × ${size!.height.toInt()}')); + final Size? size = pixelSizeOfFile(path); + rows.add((l10n.propResolution, size == null ? '—' : '${size.width.toInt()} × ${size.height.toInt()}')); try { rows.add((l10n.propFileSize, _fmtSize(file.statSync().size))); } catch (_) { @@ -247,60 +240,6 @@ class _ViewerScreenState extends State with WindowListener { return rows; } - /// İlk 512 KB'dan çözünürlük (piksel decode yok). - /// PNG/GIF/BMP/WebP: boyut ilk 33 baytta → elle parse. - /// JPEG: SOF büyük APP/EXIF segmentlerinin ardından gelebilir → package:image startDecode. - Size? _headerPixelSize(Uint8List b) { - try { - if (b.length >= 24 && b[0] == 0x89 && b[1] == 0x50) { - // PNG - final w = (b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]; - final h = (b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]; - return Size(w.toDouble(), h.toDouble()); - } - if (b.length >= 10 && b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46) { - // GIF (WBMP da GIF tabanlı olabilir) - final w = b[6] | (b[7] << 8); - final h = b[8] | (b[9] << 8); - return Size(w.toDouble(), h.toDouble()); - } - if (b.length >= 26 && b[0] == 0x42 && b[1] == 0x4D) { - // BMP - final w = b[18] | (b[19] << 8) | (b[20] << 16) | (b[21] << 24); - final h = b[22] | (b[23] << 8) | (b[24] << 16) | (b[25] << 24); - return Size(w.toDouble(), h.abs().toDouble()); - } - if (b.length >= 30 && b[0] == 0x52 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x46 && - b[8] == 0x57 && b[9] == 0x45 && b[10] == 0x42 && b[11] == 0x50) { - // WebP - if (b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x58) { - // VP8X - final w = b[24] | (b[25] << 8) | (b[26] << 16); - final h = b[27] | (b[28] << 8) | (b[29] << 16); - return Size((w + 1).toDouble(), (h + 1).toDouble()); - } - if (b.length >= 25 && b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x4C && b[20] == 0x2F) { - // VP8L - final w = (b[21] & 0x3F) | ((b[22] & 0x0F) << 6); - final h = (b[23] & 0x3F) | ((b[24] & 0x0F) << 6); - return Size((w + 1).toDouble(), (h + 1).toDouble()); - } - if (b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x20) { - // VP8 (lossy) - final w = (b[26] | (b[27] << 8)) & 0x3FFF; - final h = (b[28] | (b[29] << 8)) & 0x3FFF; - return Size(w.toDouble(), h.toDouble()); - } - } - } catch (_) {} - try { - // JPEG - final info = img.JpegDecoder().startDecode(b); - if (info != null) return Size(info.width.toDouble(), info.height.toDouble()); - } catch (_) {} - return null; - } - String _fmtSize(int bytes) { if (bytes < 1024) return '$bytes B'; const units = ['KB', 'MB', 'GB', 'TB']; @@ -402,6 +341,7 @@ class _ViewerScreenState extends State with WindowListener { ? ImageCanvas( key: _canvasKey, filePath: ImageManager.instance.currentImagePath, + zoomPercent: _zoomPercent, ) : Center( child: Column( @@ -440,6 +380,36 @@ class _ViewerScreenState extends State with WindowListener { onTap: () => ImageManager.instance.nextImage(), ), ), + Positioned( + left: 0, + right: 0, + bottom: 12, + child: IgnorePointer( + child: Center( + child: ValueListenableBuilder( + valueListenable: _zoomPercent, + builder: (context, zoom, _) { + final pct = zoom.round(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(13), + ), + child: Text( + pct < 1 ? '<1%' : '$pct%', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + }, + ), + ), + ), + ), ], if (_isDragging) Positioned.fill( diff --git a/lib/services/image_header.dart b/lib/services/image_header.dart new file mode 100644 index 0000000..091a106 --- /dev/null +++ b/lib/services/image_header.dart @@ -0,0 +1,80 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart' show Size; +import 'package:image/image.dart' as img; + +/// Görüntü dosyasının gerçek piksel boyutu — tam decode yapmadan. +/// +/// PNG/GIF/BMP/WebP: boyut ilk 33 baytta → elle parse. +/// JPEG: SOF büyük APP/EXIF segmentlerinin ardından gelebilir → package:image startDecode. +Size? pixelSizeOfFile(String path) { + try { + final file = File(path); + final stat = file.statSync(); + const maxHead = 512 * 1024; + final raf = file.openSync(); + final header = raf.readSync(stat.size < maxHead ? stat.size : maxHead); + raf.closeSync(); + return _headerPixelSize(header); + } catch (_) {} + return null; +} + +Size? _headerPixelSize(Uint8List b) { + try { + if (b.length >= 24 && b[0] == 0x89 && b[1] == 0x50) { + // PNG + final w = (b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]; + final h = (b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]; + return Size(w.toDouble(), h.toDouble()); + } + if (b.length >= 10 && b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46) { + // GIF (WBMP da GIF tabanlı olabilir) + final w = b[6] | (b[7] << 8); + final h = b[8] | (b[9] << 8); + return Size(w.toDouble(), h.toDouble()); + } + if (b.length >= 26 && b[0] == 0x42 && b[1] == 0x4D) { + // BMP + final w = b[18] | (b[19] << 8) | (b[20] << 16) | (b[21] << 24); + final h = b[22] | (b[23] << 8) | (b[24] << 16) | (b[25] << 24); + return Size(w.toDouble(), h.abs().toDouble()); + } + if (b.length >= 30 && + b[0] == 0x52 && + b[1] == 0x49 && + b[2] == 0x46 && + b[3] == 0x46 && + b[8] == 0x57 && + b[9] == 0x45 && + b[10] == 0x42 && + b[11] == 0x50) { + // WebP + if (b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x58) { + // VP8X + final w = b[24] | (b[25] << 8) | (b[26] << 16); + final h = b[27] | (b[28] << 8) | (b[29] << 16); + return Size((w + 1).toDouble(), (h + 1).toDouble()); + } + if (b.length >= 25 && b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x4C && b[20] == 0x2F) { + // VP8L + final w = (b[21] & 0x3F) | ((b[22] & 0x0F) << 6); + final h = (b[23] & 0x3F) | ((b[24] & 0x0F) << 6); + return Size((w + 1).toDouble(), (h + 1).toDouble()); + } + if (b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x20) { + // VP8 (lossy) + final w = (b[26] | (b[27] << 8)) & 0x3FFF; + final h = (b[28] | (b[29] << 8)) & 0x3FFF; + return Size(w.toDouble(), h.toDouble()); + } + } + } catch (_) {} + try { + // JPEG + final info = img.JpegDecoder().startDecode(b); + if (info != null) return Size(info.width.toDouble(), info.height.toDouble()); + } catch (_) {} + return null; +} diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index f00f354..650a835 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -6,11 +6,15 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import '../l10n/app_localizations.dart'; import '../services/crop_save_service.dart'; +import '../services/image_header.dart'; import '../services/window_persistence.dart'; class ImageCanvas extends StatefulWidget { final String filePath; - const ImageCanvas({super.key, required this.filePath}); + + /// Görüntünün gerçek piksel boyutuna göre ölçek yüzdesi (100% = 1:1). + final ValueNotifier? zoomPercent; + const ImageCanvas({super.key, required this.filePath, this.zoomPercent}); @override State createState() => ImageCanvasState(); } @@ -47,6 +51,44 @@ class ImageCanvasState extends State { Size _imageSize = Size.zero; static const int _downsampleTarget = 2048; + // ── Gerçek piksel boyutu + zoom yüzdesi ── + Size? _origSize; + bool _zoomSyncScheduled = false; + + /// Yüzdeyi bir sonraki kare sonunda yayınla (build sırasında setState tetiklememek için). + void _syncZoom() { + if (_zoomSyncScheduled) return; + _zoomSyncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _zoomSyncScheduled = false; + if (!mounted) return; + final n = widget.zoomPercent; + if (n == null) return; + final v = _zoomPercentValue(); + if (n.value != v) n.value = v; + }); + } + + /// Ekrandaki ölçeğin görüntünün gerçek piksel boyutuna oranı (100% = 1:1). + double _zoomPercentValue() { + final o = _origSize; + if (o == null || o.isEmpty || _vpSize.isEmpty) return _sc * 100; + final double ratio; + if (_isFilled) { + // cover: kutu viewport, boya ölçeği max oran + ratio = math.max(_vpSize.width / o.width, _vpSize.height / o.height); + } else { + // contain: yerleşmiş görüntü kutusu (downsample sınırı dahil) → gerçek ölçek + final box = _imageKey.currentContext?.findRenderObject() as RenderBox?; + if (box != null && box.hasSize && box.size.width > 0) { + ratio = box.size.width / o.width; + } else { + ratio = math.min(_vpSize.width / o.width, _vpSize.height / o.height); + } + } + return _sc * ratio * 100; + } + // ── Kare hızına eşitlenmiş render tick ── final ValueNotifier _renderTick = ValueNotifier(0); bool _frameScheduled = false; @@ -128,8 +170,10 @@ class ImageCanvasState extends State { @override void initState() { super.initState(); + _origSize = pixelSizeOfFile(widget.filePath); HardwareKeyboard.instance.addHandler(_onHardwareKey); _startWarmup(); + _syncZoom(); } /// İlk kullanım shader derlemesini açılışa çek: gizli katmanda sırayla @@ -189,6 +233,7 @@ class ImageCanvasState extends State { void didUpdateWidget(ImageCanvas old) { super.didUpdateWidget(old); if (old.filePath != widget.filePath) { + _origSize = pixelSizeOfFile(widget.filePath); _cachedFileImage?.evict(); _cachedProvider?.evict(); _cachedFileImage = null; @@ -207,6 +252,7 @@ class ImageCanvasState extends State { _usingFullRes = false; _imageSize = Size.zero; _markRenderDirty(); + _syncZoom(); } void _toggleFillFit() { @@ -215,6 +261,7 @@ class ImageCanvasState extends State { _flipH = false; WindowPersistence.saveFilled(_isFilled); _markRenderDirty(); + _syncZoom(); } /// Ctrl+R: sağ çift tık gibi efektleri sıfırlar ve yatay aynalar; tekrar basınca geri alır. @@ -250,6 +297,7 @@ class ImageCanvasState extends State { _checkFullResSwap(); _clamp(); _markRenderDirty(); + _syncZoom(); } void _onPanUpdate(DragUpdateDetails d) { @@ -502,7 +550,10 @@ class ImageCanvasState extends State { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - _vpSize = constraints.biggest; + if (constraints.biggest != _vpSize) { + _vpSize = constraints.biggest; + _syncZoom(); + } _clamp(); return RepaintBoundary( child: ValueListenableBuilder( diff --git a/pubspec.yaml b/pubspec.yaml index 012da15..d54a22d 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.7.0+16 +version: 1.7.1+17 msix_config: display_name: ImajViewer