Files
imajviewer/lib/widgets/image_canvas.dart
T
Alhan 3b1fad18fa
Build & Release / build-linux (push) Canceled after 0s
Build & Release / build-windows (push) Canceled after 0s
Build & Release / create-release (push) Canceled after 0s
fix: right-button double click no longer wipes rotation mid-drag (v1.7.2)
Two rotate drags started quickly at nearly the same spot were treated as a
double click: the reset was detected on button down against the previous down
time/position, so it wiped all effects and ignored the second drag, leaving
Ctrl+S to save an unrotated crop.

- Double click is now only counted for actual clicks (movement <= 4px) and is
  decided on button up; a drag breaks the chain.
- Pointer up is matched by pointer id, so a left-drag pan never counts as a click.
- dist: 1.7.2 .deb replaces the tracked 1.7.1 package.
2026-10-25 20:48:59 +03:00

685 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
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;
/// Görüntünün gerçek piksel boyutuna göre ölçek yüzdesi (100% = 1:1).
final ValueNotifier<double>? zoomPercent;
const ImageCanvas({super.key, required this.filePath, this.zoomPercent});
@override
State<ImageCanvas> createState() => ImageCanvasState();
}
class ImageCanvasState extends State<ImageCanvas> {
bool _isFilled = WindowPersistence.filled;
final GlobalKey _imageKey = GlobalKey();
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;
double _brightness = 0;
bool _flipH = false;
// ── Gesture tracking ──
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;
/// Sağ tuş basılıyken anlamlı hareket oldu mu? (tıklama mı sürükleme mi)
bool _rightDragMoved = false;
int? _rightPointerId;
/// Sürükleme sayılma eşiği (px) — rotate/contrast ince ayarında el titremesi.
static const double _dragClickSlop = 4;
// ── Klavye modifier tracking ──
bool _ctrlPressed = false;
bool _shiftPressed = false;
// ── Downsampling + swap ──
bool _usingFullRes = false;
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<int> _renderTick = ValueNotifier(0);
bool _frameScheduled = false;
// ── Shader warmup (ilk kullanım derlemesini açılışa çek) ──
bool _warmupDone = false;
int _warmupStep = 0; // 1=filtre, 2=filtre+rotate, 3=bitti
void _markRenderDirty() {
if (_frameScheduled) return;
_frameScheduled = true;
SchedulerBinding.instance.scheduleFrameCallback((_) {
_frameScheduled = false;
_renderTick.value++;
});
}
void _clamp() {
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
if (_angle != 0) return; // rotate varsa serbest pan
if (_isFilled) {
final double w = _vpSize.width * _sc;
final double h = _vpSize.height * _sc;
if (w >= _vpSize.width) {
if (_tx > 0) _tx = 0;
else if (_tx + w < _vpSize.width) _tx = _vpSize.width - w;
} else { if (_tx != 0) _tx = 0; }
if (h >= _vpSize.height) {
if (_ty > 0) _ty = 0;
else if (_ty + h < _vpSize.height) _ty = _vpSize.height - h;
} else { if (_ty != 0) _ty = 0; }
return;
}
double m = 0, n = 0;
double w = _vpSize.width * _sc;
double h = _vpSize.height * _sc;
final box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null && box.hasSize && box.size.width > 0) {
final Size img = box.size;
m = (_vpSize.width - img.width) / 2;
n = (_vpSize.height - img.height) / 2;
w = img.width * _sc;
h = img.height * _sc;
}
if (w >= _vpSize.width) {
final double lo = -m * _sc;
final double hi = _vpSize.width - m * _sc - w;
if (lo <= hi) {
if (_tx < lo) _tx = lo;
if (_tx > hi) _tx = hi;
} else {
if (_tx < hi) _tx = hi;
if (_tx > lo) _tx = lo;
}
} else {
final double lo = -m * _sc;
final double hi = _vpSize.width - m * _sc - w;
if (_tx < lo) _tx = lo;
if (_tx > hi) _tx = hi;
}
if (h >= _vpSize.height) {
final double lo = -n * _sc;
final double hi = _vpSize.height - n * _sc - h;
if (lo <= hi) {
if (_ty < lo) _ty = lo;
if (_ty > hi) _ty = hi;
} else {
if (_ty < hi) _ty = hi;
if (_ty > lo) _ty = lo;
}
} else {
final double lo = -n * _sc;
final double hi = _vpSize.height - n * _sc - h;
if (_ty < lo) _ty = lo;
if (_ty > hi) _ty = hi;
}
}
@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
/// filtre ve rotate render edilir, sonra değerler sıfırlanır.
void _startWarmup() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_warmupStep = 1;
_markRenderDirty();
WidgetsBinding.instance.addPostFrameCallback((_) => _warmupNext());
});
}
void _warmupNext() {
if (!mounted) return;
if (_warmupStep >= 2) {
_warmupDone = true;
_markRenderDirty();
return;
}
_warmupStep++;
_markRenderDirty();
WidgetsBinding.instance.addPostFrameCallback((_) => _warmupNext());
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
_renderTick.dispose();
_imageSizeStream?.removeListener(_imageSizeListener!);
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) {
_origSize = pixelSizeOfFile(widget.filePath);
_cachedFileImage?.evict();
_cachedProvider?.evict();
_cachedFileImage = null;
_cachedProvider = null;
_cachedPath = null;
_cachedCacheWidth = null;
PaintingBinding.instance.imageCache.evict(FileImage(File(old.filePath)));
_resetView();
}
}
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();
_syncZoom();
}
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();
_syncZoom();
}
/// Ctrl+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) {
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
final double vx = _angle == 0 ? e.localPosition.dx : _vpSize.width / 2;
final double vy = _angle == 0 ? e.localPosition.dy : _vpSize.height / 2;
final double cx = (vx - _tx) / _sc;
final double cy = (vy - _ty) / _sc;
final double factor = _ctrlPressed ? 1.01 : 1.1;
final double rawSc = _sc * (e.scrollDelta.dy < 0 ? factor : 1.0 / factor);
final double newSc = rawSc.clamp(1.0, 20.0);
if (_angle == 0) {
_tx = _tx + cx * _sc - cx * newSc;
_ty = _ty + cy * _sc - cy * newSc;
} else {
// Rotate'li zoom: viewport merkezini koru
final double dw = _vpSize.width * (newSc - _sc);
final double dh = _vpSize.height * (newSc - _sc);
_tx -= dw / 2;
_ty -= dh / 2;
}
_sc = newSc;
_checkFullResSwap();
_clamp();
_markRenderDirty();
_syncZoom();
}
void _onPanUpdate(DragUpdateDetails d) {
_tx += d.delta.dx;
_ty += d.delta.dy;
_clamp();
_markRenderDirty();
}
void _onPointerDown(PointerDownEvent e) {
if (e.buttons != kSecondaryMouseButton) return;
_pointerDownPos = e.localPosition;
_rightDragMoved = false;
_rightPointerId = e.pointer;
if (_ctrlPressed && _shiftPressed) {
_interactionMode = 'brightness';
_startBrightness = _brightness;
} else 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;
if (!_rightDragMoved &&
(e.localPosition - _pointerDownPos).distance > _dragClickSlop) {
_rightDragMoved = true;
}
final dy = e.localPosition.dy - _pointerDownPos.dy;
switch (_interactionMode) {
case 'contrast':
_contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0);
break;
case 'rotate':
_angle = _startAngle + dy * 0.9;
break;
case 'saturation':
_saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0);
break;
case 'brightness':
_brightness = (_startBrightness + dy * 0.5).clamp(-100.0, 100.0);
break;
}
_markRenderDirty();
}
void _onPointerUp(PointerUpEvent e) {
if (e.pointer != _rightPointerId) return;
_rightPointerId = null;
_interactionMode = 'none';
// Sürükleme ile biten etkileşim sağ çift tık zincirini keser; yalnızca
// gerçek tıklamalar (hareketsiz bas-bırak) çift tık sayılır.
if (_rightDragMoved) {
_lastRightTapTime = null;
_rightDragMoved = false;
return;
}
final now = DateTime.now();
final isDouble = _lastRightTapTime != null &&
now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) &&
(e.localPosition - _lastRightTapPos).distance < 20;
_lastRightTapTime = isDouble ? null : now;
_lastRightTapPos = e.localPosition;
if (isDouble) {
_angle = 0;
_contrast = 1.0;
_saturation = 1.0;
_brightness = 0;
_flipH = false;
_markRenderDirty();
}
}
// ── Combined color filter (brightness × saturation × contrast) ──
static const double _rLum = 0.2126;
static const double _gLum = 0.7152;
static const double _bLum = 0.0722;
ColorFilter? _cachedFilter;
List<double>? _cachedMatrix;
double _cachedSat = -1;
double _cachedCon = -1;
double _cachedBri = double.nan;
List<double> _multiply4x5(List<double> a, List<double> b) {
final result = List<double>.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 &&
_cachedBri == _brightness) {
return _cachedFilter!;
}
_cachedSat = _saturation;
_cachedCon = _contrast;
_cachedBri = _brightness;
final s = _saturation;
final c = _contrast;
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;
final saturationMatrix = <double>[
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 = <double>[
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 = <double>[
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
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 gorunen bolgesinin tam cozunurluktaki eslemesi (crop_save_service'e).
/// Degrade durumda null.
CropViewParams? cropViewParams(int w, int h) {
if (_vpSize.width <= 0 || _vpSize.height <= 0 || w <= 0 || h <= 0) return null;
final k = _isFilled
? math.max(_vpSize.width / w, _vpSize.height / h)
: math.min(_vpSize.width / w, _vpSize.height / h);
final ox = (_vpSize.width - w * k) / 2, oy = (_vpSize.height - h * k) / 2;
return CropViewParams(
w: w,
h: h,
vw: _vpSize.width,
vh: _vpSize.height,
tx: _tx,
ty: _ty,
sc: _sc,
angleDeg: _angle,
flipH: _flipH,
cx: _vpSize.width / 2,
cy: _vpSize.height / 2,
k: k,
ox: ox,
oy: oy,
);
}
FileImage? _cachedFileImage;
ImageProvider<Object>? _cachedProvider;
String? _cachedPath;
int? _cachedCacheWidth;
ImageStream? _imageSizeStream;
ImageStreamListener? _imageSizeListener;
/// Provider değiştiğinde boyutu tek sefer oku (listener sızıntısı olmadan).
void _readImageSize(ImageProvider<Object> provider) {
_imageSizeStream?.removeListener(_imageSizeListener!);
_imageSizeStream = provider.resolve(const ImageConfiguration());
_imageSizeListener = ImageStreamListener((info, _) {
if (mounted) {
_imageSize = Size(info.image.width.toDouble(), info.image.height.toDouble());
}
});
_imageSizeStream!.addListener(_imageSizeListener!);
}
void _checkFullResSwap() {
if (_usingFullRes || _imageSize.isEmpty) return;
final double stretchX = _vpSize.width * _sc / _imageSize.width;
final double stretchY = _vpSize.height * _sc / _imageSize.height;
if (math.max(stretchX, stretchY) >= 1.5) {
_cachedProvider?.evict(); // eski downsample provider'ı cache'ten at
setState(() {
_usingFullRes = true;
_cachedProvider = _cachedFileImage;
_cachedCacheWidth = null;
});
}
}
Widget _buildImage() {
final needFullRes = _usingFullRes;
final effectiveWidth = needFullRes ? null : _downsampleTarget;
if (_cachedPath != widget.filePath || _cachedCacheWidth != effectiveWidth) {
_cachedPath = widget.filePath;
_cachedCacheWidth = effectiveWidth;
_cachedFileImage = FileImage(File(widget.filePath));
_cachedProvider = effectiveWidth != null
? ResizeImage(
_cachedFileImage!,
width: effectiveWidth,
height: effectiveWidth,
policy: ResizeImagePolicy.fit,
)
: _cachedFileImage;
_readImageSize(_cachedProvider!);
}
final ImageProvider<Object> provider = _cachedProvider ?? _cachedFileImage!;
return Image(
key: _imageKey,
image: provider,
fit: _isFilled ? BoxFit.cover : BoxFit.contain,
filterQuality: FilterQuality.high,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _errorWidget(),
);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.biggest != _vpSize) {
_vpSize = constraints.biggest;
_syncZoom();
}
_clamp();
return RepaintBoundary(
child: ValueListenableBuilder<int>(
valueListenable: _renderTick,
builder: (context, _, _) => _buildRenderLayer(),
),
);
},
);
}
Widget _buildRenderLayer() {
final vpCenterX = _vpSize.width / 2;
final vpCenterY = _vpSize.height / 2;
final angleRad = _angle * math.pi / 180;
Widget imageContent = Builder(
builder: (context) {
final hasFilter = _saturation != 1.0 || _contrast != 1.0 || _brightness != 0.0;
final image = _isFilled
? SizedBox.expand(child: _buildImage())
: Center(child: _buildImage());
return hasFilter
? ColorFiltered(colorFilter: _buildCombinedFilter(), child: image)
: image;
},
);
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,
);
}
return SizedBox.expand(
child: Stack(
children: [
ClipRect(
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(_tx, _ty)
..scale(_sc),
child: imageContent,
),
),
),
),
_buildWarmupLayer(),
],
),
);
}
/// Görünmez 1x1 katman — ColorFiltered ve Transform shader'larını açılışta
/// derletir; kullanıcı ilk sağ tıkta derleme beklemesin.
Widget _buildWarmupLayer() {
if (_warmupDone) return const SizedBox.shrink();
final filter = ColorFilter.matrix(const <double>[
1.5, 0, 0, 0, 0,
0, 1.5, 0, 0, 0,
0, 0, 1.5, 0, 0,
0, 0, 0, 1, 0,
]);
Widget child = const SizedBox.expand();
if (_warmupStep >= 2) {
child = Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateZ(0.3),
child: child,
);
}
child = ColorFiltered(colorFilter: filter, child: child);
return Opacity(
opacity: 0,
child: IgnorePointer(
child: SizedBox(width: 1, height: 1, child: child),
),
);
}
Widget _errorWidget() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.broken_image, size: 48, color: Colors.white38),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.imageLoadError,
style: const TextStyle(color: Colors.white38)),
],
),
);
}
}