diff --git a/README.md b/README.md index c1f0a1a..7f6f14c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Ultra hafif görüntü izleyici. Flutter ile yazıldı — Linux & Windows deste - **Fill/Fit geçişi:** Sol çift tık - **Contrast ayarı:** Sağ tık + dikey sürükle - **Saturation ayarı:** Ctrl + Sağ tık + dikey sürükle +- **Brightness ayarı:** Ctrl+Shift + Sağ tık + dikey sürükle - **Rotate:** Shift + Sağ tık + dikey sürükle (~100px = 90°) - **Reset (tüm efektler):** Sağ çift tık - Sürükle-bırak ile resim açma @@ -110,6 +111,7 @@ flutter build windows --release | Sol çift tık | Fill / Fit geçişi | | Sağ tık + dikey sürükle | Contrast (0.0 – 2.0) | | Ctrl + Sağ tık + dikey sürükle | Saturation (0.0 – 2.0) | +| Ctrl+Shift + Sağ tık + dikey sürükle | Brightness (-100 – 100) | | Shift + Sağ tık + dikey sürükle | Rotate (serbest) | | Sağ çift tık | Reset (tüm efektler) | | Ctrl + O | Dosya aç | diff --git a/dist/imajviewer_1.0.0_amd64.deb b/dist/imajviewer_1.0.0_amd64.deb index c0e5afc..0df3d9b 100644 Binary files a/dist/imajviewer_1.0.0_amd64.deb and b/dist/imajviewer_1.0.0_amd64.deb differ diff --git a/docs/galeri-klasor-tabanli.md b/docs/galeri-klasor-tabanli.md new file mode 100644 index 0000000..61cecd2 --- /dev/null +++ b/docs/galeri-klasor-tabanli.md @@ -0,0 +1,65 @@ +# Galeri: Klasör Tabanlı Gezinme — Rapor ve Plan + +Tarih: 2026-08-03 +Durum: Uygulandı (2026-08-03) + +## 1. Mevcut Sorunun Analizi + +### RAM şişmesi — `lib/services/image_manager.dart` +- `addImages()` her eklenen yol için `precacheImage(FileImage(...))` çağırıyor (satır 33-35). +- Sonuç: toplu sürüklemede tüm resimler tam çözünürlükte decode edilip ImageCache'e yazılıyor. +- Örnek: 12MP JPEG dekode ≈ 48MB RGBA; 30 resim ≈ 1.4GB. +- Decode UI thread'inde yapılıyor → pencere donması, "çok pencere açmış gibi" hissi. +- `configureCache()` tavanı: 1000 görsel / 1GB (satır 62-65). + +### Sağ tık efektlerinde çökme — `lib/widgets/image_canvas.dart` +- Her pointer move'da `setState` → `ColorFiltered` tüm görseli yeniden boyuyor (satır 224-240). +- Bellek zaten yüksekken + VM'de yazılımsal render → çökme en çok burada görülüyor. +- Kök neden RAM şişmesi; o çözülünce bu senaryo da hafifler. + +## 2. Alınan Kararlar (kullanıcı onayı) + +1. **Precache tamamen kaldırılacak** — hiçbir resim önceden decode edilmeyecek. +2. Görüntüleme anında decode: sıradaki resme geçişte yüklenene kadar beklenir. +3. **RAM kısıtlaması yok** — cache limitlerine dokunulmayacak, daraltılmayacak. +4. Önceki resim cache'ten düşebilir (zorunlu tutma yok). Precache olmadığı için + bellekte aynı anda yalnızca açık olan resim(ler) durur; kabarık galeri listesi + sorun yaratmaz. + +## 3. Yeni Özellik — Klasör Tabanlı Galeri + +Amaç: resimleri topluca seçip sürükleyerek galeri kurmak yerine, açılan herhangi +bir resmin bulunduğu dizin otomatik galeri olsun. + +Davranış: +- Bir resim açıldığında (çift tık, CLI argümanı, sürükle-bırak) bulunduğu dizin taranır. +- Dizindeki desteklenen imaj dosyaları (png/jpg/jpeg/jfif/jpe/webp/bmp/gif/wbmp) + galeri listesi olarak set edilir. +- Açılan resim listedeki konumunu bulur ve o an gösterilir. +- Ctrl+Tab / Ctrl+Shift+Tab ile klasör içinde ileri-geri gezilir. +- Mevcut pencere başına singleton yapısı korunur — her pencere ayrı process, + her biri kendi klasör galerisini kurar. + +### Sıralama +- Dosya yöneticisinin (Thunar/PCManFM/Nautilus) klasör sort'u OS seviyesinde + okunamaz — uygulama içi ayardır, dosya sisteminde saklanmaz. Bu seçenek pratikte yok. +- Bu yüzden **tarihe göre** sıralanacak (dosyanın değiştirilme zamanı). +- Yön: **en yeni önce, en eski sonra** (azalan). + +## 4. Uygulama (uygulandı) + +- `lib/services/image_manager.dart` — precache döngüsü ve `buildContext` kaldırıldı. + `addImages` yerine `setGalleryFromPaths(paths)` geldi: ilk geçerli resmin klasörünü + tarar, desteklenenleri filtreler, değiştirilme zamanına göre en yeni önce sıralar, + açılan resmi o anki konum yapar. (Aynı zaman damgasında dosya adına göre sabit sıra) +- `lib/services/file_handler.dart` — `addImages` çağrıları `setGalleryFromPaths` oldu + (Ctrl+O ve sürükle-bırak aynı akıştan geçiyor). +- `lib/screens/viewer_screen.dart` — `buildContext` ataması kaldırıldı (precache yok). +- RAM kısıtlaması eklenmedi — `configureCache` aynen duruyor. + +## 5. Açık Sorular + +Çözüldü: +1. ~~Tarih sıralama yönü~~ → **en yeni önce, en eski sonra** +2. ~~Pencereye birden fazla resim sürüklenirse~~ → **sürüklenen resmin klasörüne geçilir** + (galeri açıkken yeni resim gelirse de aynı: o resmin klasörü yeni galeri olur) diff --git a/docs/index.md b/docs/index.md index 771bb70..dc72eb8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,8 @@ | Tarih | Dosya | Konu | |---|---|---| +| 2026-08-03 | [rapor-brightness.md](rapor-brightness.md) | **Rapor** — brightness efekti (Ctrl+Shift+sağ tık) + 4x5 matris birleştirme | +| 2026-08-03 | [galeri-klasor-tabanli.md](galeri-klasor-tabanli.md) | **Rapor** — klasör tabanlı galeri + precache/RAM kararı | | 2026-08-03 | [session-2026-08-03.md](session-2026-08-03.md) | **Session özeti** — jfif/jpe/wbmp, pencere cascade, konum kaydı düzeltmesi | | 2026-08-03 | [format-destegi-ek-formatlar.md](format-destegi-ek-formatlar.md) | **Plan** — jfif/jpe/wbmp desteği (TIFF vazgeçildi) | | 2026-08-03 | [pencere-cascade-yerlesim.md](pencere-cascade-yerlesim.md) | **Plan** — yeni pencereyi yan yana / alt satıra açma (screen_retriever) | @@ -9,3 +11,9 @@ | 2026-07-27 | [session-2026-07-27.md](session-2026-07-27.md) | **Session özeti** — rotate aktif, StartupWMClass ile taskbar gruplama | | 2026-07-27 | [image-canvas-interactions.md](image-canvas-interactions.md) | Detaylı analiz: contrast, saturation, rotate implementasyon planı | | önceki | [zoom-clamp-mekanizmasi.md](zoom-clamp-mekanizmasi.md) | Zoom clamp mekanizması (letterbox-aware) | + +## Geliştirme Akışı — Otomatik Build ve Kurulum + +Projede değişiklik yapıldıktan sonra otomatik yapılır (ayrıca istenmez): +1. Debug build: `flutter build linux --debug` +2. Sisteme kurulum: `./install.sh` (debug bundle tercih edilir → `~/.local/lib/imajviewer/`) diff --git a/docs/rapor-brightness.md b/docs/rapor-brightness.md new file mode 100644 index 0000000..ea3e27d --- /dev/null +++ b/docs/rapor-brightness.md @@ -0,0 +1,62 @@ +# Rapor: Brightness Efekti + Sağ Tık Etkileşim Düzeni + +Tarih: 2026-08-03 +Durum: Rapor — uygulama için onay bekleniyor + +## 1. Gönderilen Araştırmanın Özeti + +- `ColorMatrixHelper`: Rec.709 luma katsayıları (0.2126 / 0.7152 / 0.0722) — projedekiyle aynı. +- Üç ayrı 4x5 renk matrisi: + - **Brightness:** RGB'ye offset ekler (`b` → -255..255) + - **Contrast:** `c` ile çarpar + `128*(1-c)` offset (0..2 arası, 1.0 orijinal) + - **Saturation:** Rec.709 ağırlıklı karışım (0..2 arası, 1.0 orijinal) +- Birleştirme: `M_final = M_contrast * M_brightness * M_saturation` — genel 4x5 matris + çarpımı; uygulama sırası saturation → brightness → contrast. +- Uygulama: `ColorFiltered` + `ColorFilter.matrix` ile GPU shader'ı — projedekiyle aynı mekanizma. +- Örnek widget (`ImageAdjusterWidget`) **slider tabanlı**; bizim uygulama jest tabanlı + olduğu için bu kısım direkt uygulanmayacak. Asıl değer matris hesabında. + +## 2. Projedeki Mevcut Durum — `lib/widgets/image_canvas.dart` + +- State: `_contrast` (1.0), `_saturation` (1.0) — brightness yok. +- `_buildCombinedFilter()`: saturation × contrast tek matriste elle birleştiriliyor + (contrast offset: `t = (1-c)/2` → 255 ölçeğinde 127.5*(1-c)). +- Cache: `_cachedFilter` + `_cachedSat` / `_cachedCon`. +- Etkileşim: sağ tık = contrast, Ctrl+sağ tık = saturation, Shift+sağ tık = rotate, + sağ çift tık = reset (tüm efektler). +- Modifier takibi: `_onHardwareKey` yalnızca ctrl ve shift'i izliyor. + +## 3. Karşılaştırma — Araştırma vs Mevcut Kod + +| Konu | Mevcut | Araştırma | Fark | +|---|---|---|---| +| Saturation matrisi | Rec.709, aynı formül | Rec.709, aynı formül | Matematik birebir aynı | +| Contrast offset | 127.5*(1-c) | 128*(1-c) | ~%0.4, görsel fark yok | +| Brightness | yok | `b` offset matrisi | **Eklenecek** | +| Birleştirme | elle 2 matris | genel 4x5 çarpım | 3 efekt için genel çarpım daha temiz | +| Arayüz | jest (sağ tık + sürükle) | slider | Widget değil, matris katmanı alınacak | + +## 4. Uygulama Planı + +1. `_brightness` state ekle (varsayılan 0.0, aralık -100..100). +2. `_onPointerDown`: öncelik sırası — **Ctrl+Shift → brightness**, Ctrl → saturation, + Shift → rotate, yoksa contrast. (Başlangıçta Alt denenmişti; XFCE Alt+klik'i + pencere hareketine kaptığı için **Ctrl+Shift**'e geçildi.) +3. `_onPointerMove`: brightness modunda `_brightness = _startBrightness + dy * 0.5` + (clamp -100..100; hassasiyet açık soru). +4. `_buildCombinedFilter`: araştırmadaki 4x5 çarpım mantığıyla 3 efekt birleştir + (saturation → brightness → contrast); cache anahtarına `_brightness` ekle. +5. Reset (sağ çift tık), `_resetView`, `_toggleFillFit`: brightness 0'a döner. +6. `hasFilter` koşuluna `_brightness != 0` eklenir (ColorFiltered yalnızca gerektiğinde). + +## 5. Dikkat Edilecekler + +- **Linux/XFCE:** Alt+klik WM tarafından pencere hareketine kaptırıldığı için + brightness **Ctrl+Shift+sağ tık**'a alındı (Alt kaldırıldı). +- **Uygulama sırası:** saturation → brightness → contrast sabit kalacak; farklı sıra farklı sonuç verir. +- Reset davranışı brightness'i de kapsar. + +## 6. Açık Sorular + +1. Brightness hassasiyeti: `dy * 0.5`, aralık -100..100 uygun mu? +2. Rotate Shift+sağ tık'ta kalıyor (değişiklik yok) — onay? diff --git a/lib/screens/viewer_screen.dart b/lib/screens/viewer_screen.dart index e51ba21..e00ec93 100644 --- a/lib/screens/viewer_screen.dart +++ b/lib/screens/viewer_screen.dart @@ -86,7 +86,6 @@ class _ViewerScreenState extends State with WindowListener { @override Widget build(BuildContext context) { - ImageManager.instance.buildContext = context; return AppShortcuts( child: Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/services/file_handler.dart b/lib/services/file_handler.dart index e90fd8c..1ebeaf9 100644 --- a/lib/services/file_handler.dart +++ b/lib/services/file_handler.dart @@ -15,14 +15,14 @@ class FileHandler { .where((f) => f.path != null) .map((f) => f.path!) .toList(); - ImageManager.instance.addImages(paths); + ImageManager.instance.setGalleryFromPaths(paths); } } /// Load images from given file paths static void loadImagesFromPaths(List paths) { if (paths.isNotEmpty) { - ImageManager.instance.addImages(paths); + ImageManager.instance.setGalleryFromPaths(paths); } } } diff --git a/lib/services/image_manager.dart b/lib/services/image_manager.dart index f35ef2e..83b451e 100644 --- a/lib/services/image_manager.dart +++ b/lib/services/image_manager.dart @@ -6,6 +6,10 @@ class ImageManager extends ChangeNotifier { static ImageManager get instance => _instance; ImageManager._(); + static const _supportedExtensions = [ + 'png', 'jpg', 'jpeg', 'jfif', 'jpe', 'webp', 'bmp', 'gif', 'wbmp', + ]; + final List _images = []; int _currentIndex = 0; @@ -18,22 +22,47 @@ class ImageManager extends ChangeNotifier { return _images[_currentIndex]; } - void addImages(List paths) { - final validPaths = paths.where((p) { + /// Açılan resmin bulunduğu klasörü galeri olarak kurar (en yeni önce). + void setGalleryFromPaths(List paths) { + String? anchor; + for (final p in paths) { final ext = p.toLowerCase().split('.').last; - const supported = ['png', 'jpg', 'jpeg', 'jfif', 'jpe', 'webp', 'bmp', 'gif', 'wbmp']; - return supported.contains(ext) && File(p).existsSync(); - }).toList(); - - if (validPaths.isEmpty) return; - - _images.addAll(validPaths); - - // Precache images - for (final path in validPaths) { - precacheImage(FileImage(File(path)), context!); + if (_supportedExtensions.contains(ext) && File(p).existsSync()) { + anchor = p; + break; + } } + if (anchor == null) return; + final dir = File(anchor).parent; + if (!dir.existsSync()) return; + + final List<(String, DateTime)> entries = []; + for (final entity in dir.listSync()) { + if (entity is! File) continue; + final p = entity.path; + final ext = p.toLowerCase().split('.').last; + if (!_supportedExtensions.contains(ext)) continue; + try { + entries.add((p, entity.lastModifiedSync())); + } catch (_) {} + } + entries.sort((a, b) { + final byDate = b.$2.compareTo(a.$2); + return byDate != 0 ? byDate : a.$1.compareTo(b.$1); + }); + + _images + ..clear() + ..addAll(entries.map((e) => e.$1)); + + _currentIndex = _images.indexOf(anchor); + if (_currentIndex < 0) { + final base = anchor.split(Platform.pathSeparator).last; + _currentIndex = _images + .indexWhere((p) => p.split(Platform.pathSeparator).last == base); + } + if (_currentIndex < 0) _currentIndex = 0; notifyListeners(); } @@ -55,19 +84,12 @@ class ImageManager extends ChangeNotifier { notifyListeners(); } - // Precache needs a BuildContext - BuildContext? context; - /// Configure image cache for high-res images static void configureCache() { PaintingBinding.instance.imageCache.maximumSize = 1000; PaintingBinding.instance.imageCache.maximumSizeBytes = 1024 * 1024 * 1024; // 1 GB } - set buildContext(BuildContext ctx) { - context = ctx; - } - void clear() { _images.clear(); _currentIndex = 0; diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index a81c463..25e207b 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -21,13 +21,15 @@ class _ImageCanvasState extends State { double _angle = 0; double _contrast = 1.0; double _saturation = 1.0; + double _brightness = 0; // ── Gesture tracking ── - String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation' + String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation' | 'brightness' Offset _pointerDownPos = Offset.zero; double _startAngle = 0; double _startContrast = 1.0; double _startSaturation = 1.0; + double _startBrightness = 0; DateTime? _lastRightTapTime; Offset _lastRightTapPos = Offset.zero; @@ -139,14 +141,14 @@ class _ImageCanvasState extends State { void _resetView() { setState(() { _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero; - _angle = 0; _contrast = 1.0; _saturation = 1.0; + _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; }); } void _toggleFillFit() { setState(() { _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; - _angle = 0; _contrast = 1.0; _saturation = 1.0; + _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; }); } @@ -196,6 +198,7 @@ class _ImageCanvasState extends State { _angle = 0; _contrast = 1.0; _saturation = 1.0; + _brightness = 0; _interactionMode = 'none'; }); _lastRightTapTime = null; @@ -204,7 +207,10 @@ class _ImageCanvasState extends State { _lastRightTapTime = now; _lastRightTapPos = e.localPosition; - if (_ctrlPressed) { + if (_ctrlPressed && _shiftPressed) { + _interactionMode = 'brightness'; + _startBrightness = _brightness; + } else if (_ctrlPressed) { _interactionMode = 'saturation'; _startSaturation = _saturation; } else if (_shiftPressed) { @@ -237,6 +243,11 @@ class _ImageCanvasState extends State { _saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0); }); break; + case 'brightness': + setState(() { + _brightness = (_startBrightness + dy * 0.5).clamp(-100.0, 100.0); + }); + break; } } @@ -244,7 +255,7 @@ class _ImageCanvasState extends State { _interactionMode = 'none'; } - // ── Combined color filter (saturation × contrast) ── + // ── Combined color filter (brightness × saturation × contrast) ── static const double _rLum = 0.2126; static const double _gLum = 0.7152; @@ -253,29 +264,72 @@ class _ImageCanvasState extends State { ColorFilter? _cachedFilter; double _cachedSat = -1; double _cachedCon = -1; + double _cachedBri = double.nan; + + List _multiply4x5(List a, List b) { + final result = List.filled(20, 0.0); + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + double sum = 0.0; + for (int k = 0; k < 4; k++) { + sum += a[row * 5 + k] * b[k * 5 + col]; + } + result[row * 5 + col] = sum; + } + double offsetSum = a[row * 5 + 4]; + for (int k = 0; k < 4; k++) { + offsetSum += a[row * 5 + k] * b[k * 5 + 4]; + } + result[row * 5 + 4] = offsetSum; + } + return result; + } ColorFilter _buildCombinedFilter() { - if (_cachedFilter != null && _cachedSat == _saturation && _cachedCon == _contrast) { + if (_cachedFilter != null && + _cachedSat == _saturation && + _cachedCon == _contrast && + _cachedBri == _brightness) { return _cachedFilter!; } _cachedSat = _saturation; _cachedCon = _contrast; + _cachedBri = _brightness; final s = _saturation; final c = _contrast; - final t = (1.0 - c) / 2.0; + final b = _brightness; final invS = 1.0 - s; final sR_R = invS * _rLum + s, sR_G = invS * _gLum, sR_B = invS * _bLum; final sG_R = invS * _rLum, sG_G = invS * _gLum + s, sG_B = invS * _bLum; final sB_R = invS * _rLum, sB_G = invS * _gLum, sB_B = invS * _bLum + s; - _cachedFilter = ColorFilter.matrix([ - c * sR_R, c * sR_G, c * sR_B, 0, t, - c * sG_R, c * sG_G, c * sG_B, 0, t, - c * sB_R, c * sB_G, c * sB_B, 0, t, - 0, 0, 0, 1, 0, - ]); + final saturationMatrix = [ + sR_R, sR_G, sR_B, 0, 0, + sG_R, sG_G, sG_B, 0, 0, + sB_R, sB_G, sB_B, 0, 0, + 0, 0, 0, 1, 0, + ]; + + final brightnessMatrix = [ + 1, 0, 0, 0, b, + 0, 1, 0, 0, b, + 0, 0, 1, 0, b, + 0, 0, 0, 1, 0, + ]; + + final offset = 128.0 * (1.0 - c); + final contrastMatrix = [ + c, 0, 0, 0, offset, + 0, c, 0, 0, offset, + 0, 0, c, 0, offset, + 0, 0, 0, 1, 0, + ]; + + // M_final = M_contrast * M_brightness * M_saturation + _cachedFilter = ColorFilter.matrix( + _multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix)); return _cachedFilter!; } @@ -302,7 +356,7 @@ class _ImageCanvasState extends State { Widget imageContent = Builder( builder: (context) { - final hasFilter = _saturation != 1.0 || _contrast != 1.0; + final hasFilter = _saturation != 1.0 || _contrast != 1.0 || _brightness != 0.0; final image = _isFilled ? SizedBox.expand(child: _buildImage()) : Center(child: _buildImage());