diff --git a/deb/usr/lib/imajviewer/data/flutter_assets/NOTICES.Z b/deb/usr/lib/imajviewer/data/flutter_assets/NOTICES.Z index 89eeec9..db068ac 100644 Binary files a/deb/usr/lib/imajviewer/data/flutter_assets/NOTICES.Z and b/deb/usr/lib/imajviewer/data/flutter_assets/NOTICES.Z differ diff --git a/deb/usr/lib/imajviewer/lib/libapp.so b/deb/usr/lib/imajviewer/lib/libapp.so index b62456e..bdbe287 100644 Binary files a/deb/usr/lib/imajviewer/lib/libapp.so and b/deb/usr/lib/imajviewer/lib/libapp.so differ diff --git a/dist/imajviewer_1.0.0_amd64.deb b/dist/imajviewer_1.0.0_amd64.deb index 30393de..fa526bc 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/image-canvas-interactions.md b/docs/image-canvas-interactions.md new file mode 100644 index 0000000..58386ea --- /dev/null +++ b/docs/image-canvas-interactions.md @@ -0,0 +1,187 @@ +# Image Canvas Interactions — Analiz ve İmplementasyon Planı + +**Tarih:** 2026-07-27 +**Kapsam:** `lib/widgets/image_canvas.dart` + +--- + +## 1. Mevcut Durum + +`ImageCanvas` widget'ı şu anda: + +- **Sol tık + sürükle:** Pan (imleç hareketiyle resmi kaydırma) — `GestureDetector.onPanUpdate` +- **Tekerlek:** Zoom (imleç pozisyonu merkezli) — `Listener.onPointerSignal → PointerScrollEvent` +- **Çift tık:** Fill/Fit mod geçişi — `GestureDetector.onDoubleTap` +- **Transform:** `Matrix4` ile sadece `translate(_tx, _ty)` + `scale(_sc)`, rotate yok +- **Renk/efekt filtresi:** Yok + +Sorun: `GestureDetector.onPanUpdate` hangi mouse tuşuna basıldığını ayırt etmez. Tüm tuşlarla yapılan sürüklemeyi `onPanUpdate` olarak yakalar. Sağ tık için ayrı bir işlem yapılamaz. + +--- + +## 2. Gereksinimler + +| # | Etkileşim | Davranış | +|---|---|---| +| R1 | **Sağ tık + Dikey sürükle** | Rotate. Yukarı sürükle → saat yönü, aşağı sürükle → saat tersi. Viewport merkezinden dönmeli. Hassasiyet: ~100px = 90° | +| R2 | **Ctrl + Sağ tık + Dikey sürükle** | Contrast ayarı. Yukarı sürükle → artır, aşağı sürükle → azalt. Aralık: 0.0 – 2.0 (default: 1.0) | +| R3 | **Shift + Sağ tık + Dikey sürükle** | Brightness ayarı. Yukarı sürükle → artır, aşağı sürükle → azalt. Aralık: -1.0 – 1.0 (default: 0.0) | +| R4 | **Sağ çift tık** | Reset: rotate, contrast, brightness sıfırlanır (0°, 1.0, 0.0) | +| R5 | **Sol tık + sürükle** | Mevcut pan davranışı korunacak | + +--- + +## 3. Teknik Tasarım + +### 3.1 Gesture Handling: `GestureDetector` → `Listener` + +Mevcut `GestureDetector` tuş ayrımı yapamadığı için, **`Listener` widget'ı** ile pointer event'leri manuel yakalayacağız: + +``` +Listener( + onPointerDown: _onPointerDown, // hangi tuş + modifier → mod belirle + onPointerMove: _onPointerMove, // moda göre pan / contrast / brightness + onPointerUp: _onPointerUp, // tıklama ise rotate uygula + onPointerSignal: _handleScroll, // zoom (mevcut) +) +``` + +**Mod State Machine:** + +``` +PointerDown: + if (sol tuş) → mod = pan + if (sağ tuş, no modifier) → mod = rotate, başlangıç açısını kaydet + if (sağ tuş + Ctrl) → mod = contrast, başlangıç değerini kaydet + if (sağ tuş + Shift) → mod = brightness, başlangıç değerini kaydet + +PointerMove: + pan: _tx += dx, _ty += dy + rotate: _angle += dy * sensitivity (clamp yok, serbest dönüş) + contrast: _contrast += dy * sensitivity, clamp(0.0, 2.0) + brightness: _brightness += dy * sensitivity, clamp(-1.0, 1.0) + +PointerUp: + modu temizle + +Çift tık (sağ tuş): + _angle = 0; _contrast = 1.0; _brightness = 0.0 +``` + +**Tuş tespiti:** `PointerEvent.buttons` bitmask'i: +- `kPrimaryMouseButton` (1) = sol +- `kSecondaryMouseButton` (2) = sağ +- `kMiddleMouseButton` (4) = orta + +**Modifier tespiti:** `HardwareKeyboard.instance.isControlPressed` / `isShiftPressed` + +### 3.2 Rotate — Matrix4 Güncellemesi + +Rotate, viewport'un **tam merkezi** etrafında dönmeli. Matrix4 işlem sırası (son yazılan ilk uygulanır): + +```dart +Matrix4.identity() + ..translate(vpCenterX, vpCenterY) // 4. pivot noktasına geri taşı + ..rotateZ(_angleRad) // 3. döndür + ..translate(-vpCenterX, -vpCenterY) // 2. pivot'u orijine taşı + ..translate(_tx, _ty) // 1. pan offset'i + ..scale(_sc); // 0. zoom +``` + +`_angleRad = _angle * pi / 180` + +### 3.3 Contrast ve Brightness — ColorFilter + +`Image` widget'ını `ColorFiltered` ile saracağız. Hem contrast hem brightness tek bir matrix'te birleştirilebilir: + +```dart +ColorFiltered( + colorFilter: ColorFilter.matrix(_buildColorMatrix()), + child: imageWidget, +) +``` + +**Birleşik matrix (contrast × brightness):** + +Önce brightness, sonra contrast uygulanır: + +``` +b = brightness (-1.0 .. 1.0) +c = contrast (0.0 .. 2.0) +t = (1.0 - c) / 2.0 + +Matrix: + c, 0, 0, 0, t + b, + 0, c, 0, 0, t + b, + 0, 0, c, 0, t + b, + 0, 0, 0, 1, 0, +``` + +Default değerler: `c = 1.0, b = 0.0` → birim matrix (görüntü değişmez). + +### 3.4 Sensitivity (Hassasiyet) + +- **Rotate:** `_angle += dy * 0.9` (100px sürükleme ≈ 90°) +- **Contrast:** `_contrast += dy * 0.005` (200px sürükleme = 1.0 birim) +- **Brightness:** `_brightness += dy * 0.005` (200px sürükleme = 1.0 birim) + +Değerler `setState` içinde clamp'lenir (rotate hariç, serbest dönüş). + +### 3.5 State Değişkenleri (Eklenecekler) + +```dart +double _angle = 0; // derece cinsinden, serbest +double _contrast = 1.0; // 0.0 – 2.0 +double _brightness = 0.0; // -1.0 – 1.0 + +// Gesture tracking +String _interactionMode = 'none'; // 'pan' | 'rotate' | 'contrast' | 'brightness' +Offset _pointerDownPos = Offset.zero; +double _startAngle = 0; +double _startContrast = 1.0; +double _startBrightness = 0.0; +``` + +--- + +## 4. Riskler ve Dikkat Edilecek Noktalar + +| Risk | Etki | Çözüm | +|---|---|---| +| Sağ tık + sürükleme, pan ile çakışabilir | Yanlış moda girme | `rotate_pending` modunda eşik kontrolü: 5px'den az hareket → rotate, fazla → pan | +| Rotate sonrası pan yönü bozulabilir | Kullanıcı deneyimi | Rotate, pan offset'lerinden (`_tx, _ty`) önce değil sonra uygulandığı için etkilenmez. Ama fit mode'da letterbox offset'i (`m, n`) rotate'den etkilenir — test edilecek | +| ColorFilter performansı | Büyük resimlerde takılma | Flutter'da `ColorFiltered` GPU'da çalışır, performans sorunu beklenmiyor | +| Ctrl/Shift + scroll zoom ile çakışma | Zoom yerine contrast değişebilir | Scroll event'leri `onPointerSignal` ile ayrı yakalanıyor, karışmaz | +| Fill/Fit mod geçişinde rotate sıfırlanmalı mı? | UX kararı | `_toggleFillFit()` içinde `_angle = 0` yapalım — fill ↔ fit geçişinde rotate sıfırlansın | + +--- + +## 5. İmplementasyon Planı + +### Adım 1: Listener tabanlı gesture sistemine geçiş +- `GestureDetector` yerine `Listener` kullan +- `onPointerDown`, `onPointerMove`, `onPointerUp` handler'larını yaz +- Sol tık pan'i koru, sağ tık modlarını ekle + +### Adım 2: Rotate +- `_angle` state değişkeni ekle +- `Matrix4`'e rotate transform'u ekle +- `_resetView()` ve `_toggleFillFit()` içinde `_angle = 0` + +### Adım 3: Contrast ve Brightness +- `_contrast`, `_brightness` state değişkenleri ekle +- `ColorFiltered` widget'ı ile image'ı sar +- `_buildColorMatrix()` metodunu yaz + +### Adım 4: Test ve clamp +- Tüm modlarda clamp/değer aralığı kontrolü +- Rotate + zoom + pan birlikte çalışıyor mu test et + +--- + +## 6. Etkilenen Dosyalar + +| Dosya | Değişiklik | +|---|---| +| `lib/widgets/image_canvas.dart` | Ana değişikliklerin tümü burada | +| (başka dosya yok) | Diğer widget'lar etkilenmez | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1a0e59f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,6 @@ +# imajViewer — Dokümantasyon İndeksi + +| Tarih | Dosya | Konu | +|---|---|---| +| 2026-07-27 | [image-canvas-interactions.md](image-canvas-interactions.md) | Sağ tık etkileşimleri: contrast, saturation, rotate | +| önceki | [zoom-clamp-mekanizmasi.md](zoom-clamp-mekanizmasi.md) | Zoom clamp mekanizması (letterbox-aware) | diff --git a/install.sh b/install.sh index 09bec68..503387e 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,11 @@ set -euo pipefail APP=imajviewer SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle" +BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/debug/bundle" +# Debug yoksa release dene +if [ ! -f "$BUNDLE_DIR/${APP}" ]; then + BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle" +fi INSTALL_DIR="${HOME}/.local/lib/${APP}" BIN_LINK="${HOME}/.local/bin/${APP}" DESKTOP_FILE="${HOME}/.local/share/applications/${APP}.desktop" @@ -24,7 +28,10 @@ if [ ! -f "$BUNDLE_DIR/${APP}" ]; then info "Binary bulunamadı, Flutter build başlatılıyor…" cd "$SCRIPT_DIR" flutter build linux 2>&1 | tail -1 || err "Flutter build başarısız! Sistemde Flutter SDK kurulu olmalı veya dist/imajviewer_*.deb paketini kullanın." - BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle" + BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/debug/bundle" + if [ ! -f "$BUNDLE_DIR/${APP}" ]; then + BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle" + fi [ -f "$BUNDLE_DIR/${APP}" ] || err "Build başarısız!" fi diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index abafbba..1e39228 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; class ImageCanvas extends StatefulWidget { final String filePath; @@ -15,10 +17,27 @@ class _ImageCanvasState extends State { double _tx = 0, _ty = 0, _sc = 1.0; Size _vpSize = Size.zero; + // ── Görüntü ayarları ── + double _angle = 0; + double _contrast = 1.0; + double _saturation = 1.0; + + // ── Gesture tracking ── + String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation' + Offset _pointerDownPos = Offset.zero; + double _startAngle = 0; + double _startContrast = 1.0; + double _startSaturation = 1.0; + DateTime? _lastRightTapTime; + Offset _lastRightTapPos = Offset.zero; + + // ── Klavye modifier tracking ── + bool _ctrlPressed = false; + bool _shiftPressed = false; + void _clamp() { if (_vpSize.width <= 0 || _vpSize.height <= 0) return; if (_isFilled) { - // Fill mode: viewport tabanli clamp final double w = _vpSize.width * _sc; final double h = _vpSize.height * _sc; if (w >= _vpSize.width) { @@ -31,7 +50,6 @@ class _ImageCanvasState extends State { } else { if (_ty != 0) _ty = 0; } return; } - // Fit mode: resmin gercek boyutuna gore clamp (letterbox dahil) double m = 0, n = 0; double w = _vpSize.width * _sc; double h = _vpSize.height * _sc; @@ -77,17 +95,60 @@ class _ImageCanvasState extends State { } } + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_onHardwareKey); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_onHardwareKey); + super.dispose(); + } + + bool _onHardwareKey(KeyEvent event) { + final key = event.logicalKey; + if (event is KeyDownEvent) { + if (key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + _ctrlPressed = true; + } else if (key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + _shiftPressed = true; + } + } else if (event is KeyUpEvent) { + if (key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + _ctrlPressed = false; + } else if (key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + _shiftPressed = false; + } + } + return false; + } + @override void didUpdateWidget(ImageCanvas old) { super.didUpdateWidget(old); if (old.filePath != widget.filePath) _resetView(); } + void _resetView() { - setState(() { _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero; }); + setState(() { + _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero; + _angle = 0; _contrast = 1.0; _saturation = 1.0; + }); } + void _toggleFillFit() { - setState(() { _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; }); + setState(() { + _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; + _angle = 0; _contrast = 1.0; _saturation = 1.0; + }); } + void _handleDoubleTap() => _toggleFillFit(); void _handleScroll(PointerScrollEvent e) { @@ -113,6 +174,95 @@ class _ImageCanvasState extends State { setState(() {}); } + void _onPointerDown(PointerDownEvent e) { + if (e.buttons != kSecondaryMouseButton) return; + + _pointerDownPos = e.localPosition; + + final now = DateTime.now(); + if (_lastRightTapTime != null && + now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) && + (e.localPosition - _lastRightTapPos).distance < 20) { + setState(() { + _angle = 0; + _contrast = 1.0; + _saturation = 1.0; + _interactionMode = 'none'; + }); + _lastRightTapTime = null; + return; + } + _lastRightTapTime = now; + _lastRightTapPos = e.localPosition; + + if (_ctrlPressed) { + _interactionMode = 'saturation'; + _startSaturation = _saturation; + } else if (_shiftPressed) { + _interactionMode = 'rotate'; + _startAngle = _angle; + } else { + _interactionMode = 'contrast'; + _startContrast = _contrast; + } + } + + void _onPointerMove(PointerMoveEvent e) { + if (e.buttons != kSecondaryMouseButton) return; + + final dy = e.localPosition.dy - _pointerDownPos.dy; + + switch (_interactionMode) { + case 'contrast': + setState(() { + _contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0); + }); + break; + case 'rotate': + setState(() { + _angle = _startAngle + dy * 0.9; + }); + break; + case 'saturation': + setState(() { + _saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0); + }); + break; + } + } + + void _onPointerUp(PointerUpEvent e) { + _interactionMode = 'none'; + } + + // ── Color filters ── + + static const double _rLum = 0.2126; + static const double _gLum = 0.7152; + static const double _bLum = 0.0722; + + ColorFilter _buildSaturationFilter() { + final s = _saturation; + final invS = 1.0 - s; + return ColorFilter.matrix([ + invS * _rLum + s, invS * _gLum, invS * _bLum, 0, 0, + invS * _rLum, invS * _gLum + s, invS * _bLum, 0, 0, + invS * _rLum, invS * _gLum, invS * _bLum + s, 0, 0, + 0, 0, 0, 1, 0, + ]); + } + + ColorFilter _buildContrastFilter() { + final c = _contrast; + final t = (1.0 - c) / 2.0; + return ColorFilter.matrix([ + c, 0, 0, 0, t, + 0, c, 0, 0, t, + 0, 0, c, 0, t, + 0, 0, 0, 1, 0, + ]); + } + Widget _buildImage() { return Image.file( key: _imageKey, @@ -129,25 +279,42 @@ class _ImageCanvasState extends State { builder: (context, constraints) { _vpSize = constraints.biggest; _clamp(); + + final vpCenterX = _vpSize.width / 2; + final vpCenterY = _vpSize.height / 2; + final angleRad = _angle * math.pi / 180; + return SizedBox.expand( child: Stack( children: [ ClipRect( - child: GestureDetector( - onDoubleTap: _handleDoubleTap, - onPanUpdate: _onPanUpdate, - child: Listener( - onPointerSignal: (event) { - if (event is PointerScrollEvent) _handleScroll(event); - }, + child: Listener( + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerSignal: (event) { + if (event is PointerScrollEvent) _handleScroll(event); + }, + child: GestureDetector( + onDoubleTap: _handleDoubleTap, + onPanUpdate: _onPanUpdate, child: Transform( alignment: Alignment.topLeft, transform: Matrix4.identity() + ..translate(vpCenterX, vpCenterY) + ..rotateZ(angleRad) + ..translate(-vpCenterX, -vpCenterY) ..translate(_tx, _ty) ..scale(_sc), - child: _isFilled - ? SizedBox.expand(child: _buildImage()) - : Center(child: _buildImage()), + child: ColorFiltered( + colorFilter: _buildSaturationFilter(), + child: ColorFiltered( + colorFilter: _buildContrastFilter(), + child: _isFilled + ? SizedBox.expand(child: _buildImage()) + : Center(child: _buildImage()), + ), + ), ), ), ),