Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6de71ff6 | ||
|
|
a5969cf28f | ||
|
|
eed0646237 | ||
|
|
9a7480cd68 | ||
|
|
8a80cba116 | ||
|
|
3b1fad18fa | ||
|
|
32fb12c210 | ||
|
|
abbde04e50 | ||
|
|
e6808aaf8e | ||
|
|
805c7eedf2 | ||
|
|
2ae32e351d | ||
|
|
ee456773d4 | ||
|
|
57fa3d92dd | ||
|
|
5f4d4ffe1a | ||
|
|
a2d2c0d151 | ||
|
|
6e13892ed1 | ||
|
|
bad48167c8 | ||
|
|
24cab6f908 | ||
|
|
2cd9e6c357 | ||
|
|
b3bd540549 | ||
|
|
afe99198ff | ||
|
|
c34a3bfd95 | ||
|
|
268dc879c8 | ||
|
|
168e087c8b | ||
|
|
73c1ff51d6 | ||
|
|
1002156cd4 | ||
|
|
72c35a8a26 | ||
|
|
addece7ade | ||
|
|
45cf56a1e6 |
No files matched your search
@@ -45,8 +45,17 @@ app.*.map.json
|
|||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
# Distribution binaries — release assets, not tracked in git
|
# Distribution binaries — release assets, not tracked in git
|
||||||
|
# (only the latest build of each type stays tracked for direct download)
|
||||||
*.deb
|
*.deb
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.exe
|
*.exe
|
||||||
|
!dist/imajviewer_1.9.0_amd64.deb
|
||||||
|
!dist/imajviewer_setup_1.1.0.exe
|
||||||
deb/usr/lib/imajviewer/
|
deb/usr/lib/imajviewer/
|
||||||
release/
|
release/
|
||||||
|
|
||||||
|
# Local backup snapshots (see soul.md §5)
|
||||||
|
docs/.bak/
|
||||||
|
|
||||||
|
# Internal dev docs stay local, not in the public repo
|
||||||
|
/docs/
|
||||||
@@ -4,12 +4,154 @@ All notable changes to imajViewer are documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-10-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Taskbar/alt-tab title now shows the opened image's file name (falls back to
|
||||||
|
"imajViewer" when no image is open).
|
||||||
|
- Full hicolor icon size set (16–256 px) instead of only 64 px, so small-icon
|
||||||
|
contexts (Thunar "Open With", Xfce app menu) keep the symbol visible.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `install.sh` generates the full icon size set and rewrites the hicolor
|
||||||
|
index; uninstall hint updated.
|
||||||
|
- Settings size grid is now a fixed 4-column layout (the wide/narrow
|
||||||
|
transposition introduced in this release was removed).
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Window size presets: 4×8 screen-ratio grid in settings; clicking a preset
|
||||||
|
resizes the window to that fraction of the screen work area (taskbar
|
||||||
|
excluded). Minimum window size lowered to 160×100.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Settings file-properties rows were centered under the longest row (path);
|
||||||
|
now left-aligned.
|
||||||
|
|
||||||
|
## [1.7.2] - 2026-10-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Rotating sometimes vanished before saving: the right-button double-click reset was
|
||||||
|
detected on button *down* against the previous down time and position, so two rotate
|
||||||
|
drags started quickly at nearly the same spot were treated as a double click — all
|
||||||
|
effects (rotation included) were wiped and the second drag was ignored, so `Ctrl+S`
|
||||||
|
saved an unrotated crop. A double click is now only counted for actual clicks
|
||||||
|
(movement ≤ 4 px, decided on button up), a drag breaks the chain, and
|
||||||
|
`pointer up` is matched by pointer id so a left-drag pan never counts as a click.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- Settings dialog shows the app version next to the name.
|
||||||
|
- Fine zoom: `Ctrl`+wheel zooms 1% per step (plain wheel stays 10% per step).
|
||||||
|
- Settings shortcut list completed: `Ctrl+O` (open), `Esc` (exit fullscreen), scroll/zoom,
|
||||||
|
`Ctrl`+scroll fine zoom, left-drag pan, double right click (reset effects).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- README: zoom feature line and shortcut table updated to match.
|
||||||
|
|
||||||
|
## [1.6.3] - 2026-10-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `SIGSEGV` while resizing the window. The GTK embedder's OpenGL compositor uploaded the frame using
|
||||||
|
the window's new dimensions while its pixel buffer still held the previous size, so `memcpy` read
|
||||||
|
past the end of the buffer (`engine/.../linux/fl_compositor_opengl.cc`). Fixed upstream in
|
||||||
|
flutter#187626, which is not part of Flutter 3.44.7 — the Linux build now uses Flutter 3.47.7,
|
||||||
|
where the compositor derives the texture size from the framebuffer itself.
|
||||||
|
|
||||||
|
## [1.6.2] - 2026-10-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Crop save JPEG quality raised from 93 to 95.
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-10-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Horizontal flip shortcut (`Ctrl+R`) never fired when the keyboard layout resolves the R key to a
|
||||||
|
different logical key (reported with a Turkish/remote-desktop setup where the R key arrives as
|
||||||
|
logical `T`). The shortcut now matches the physical R key position as well.
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-10-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Horizontal flip (`Ctrl+R`): mirrors the image left-right about the viewport centre and clears
|
||||||
|
the active effects (same reset as double right click); pressing `Ctrl+R` again restores the
|
||||||
|
original orientation. Zoom/pan are kept.
|
||||||
|
- Crop save (`Ctrl+S`) now bakes the flip into the output as well, so the saved JPEG matches
|
||||||
|
the mirrored view (also in combination with rotation).
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-10-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Fill/fit mode (double-click) is now persisted in `window.json` and restored on the next launch
|
||||||
|
and when switching images, instead of always resetting to fill.
|
||||||
|
|
||||||
|
## [1.4.2] - 2026-10-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Crop save dropped the blue channel's brightness/contrast offset (matrix index `m[13]`, the
|
||||||
|
alpha coefficient, instead of `m[14]`), so saved JPEGs came out with a color cast whenever
|
||||||
|
brightness or contrast was applied. Saturation/rotate were unaffected.
|
||||||
|
|
||||||
|
## [1.4.1] - 2026-10-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Crop save always writes JPEG (previously kept the source extension, e.g. PNG) — quality 93
|
||||||
|
- Crop save with rotation now saves the rotated view 1:1 instead of an unrotated bounding box
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-10-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Crop save: saves the visible area at original resolution as JPEG quality 90 — title bar save button or Ctrl+S
|
||||||
|
- Output name: original name + `_HHmmss` time suffix (e.g. `photo.jpg` → `photo_143025.jpg`)
|
||||||
|
- If a file with the same name exists, nothing is written and a warning is shown
|
||||||
|
- Active color effects (contrast/saturation/brightness) are baked into the output
|
||||||
|
- With rotation active, the bounding box around the visible area is saved
|
||||||
|
|
||||||
|
## [1.3.2] - 2026-08-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Window position/size persistence under Wayland — forces X11 backend (XWayland) when an X server is available
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-08-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Aspect ratio distortion on image switch — downsample now uses `ResizeImagePolicy.fit` (was `exact`, which forced 2048×2048 square and stretched non-square images)
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-08-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `_imageSize` now resets on image switch (wrong swap threshold on navigation)
|
||||||
|
- Downsample now caps both dimensions, not just width (tall images no longer full-res decode)
|
||||||
|
- Image size listener leak in `loadingBuilder` — replaced with single `_readImageSize`
|
||||||
|
- Old downsample provider now evicted on full-res swap
|
||||||
|
- Removed `imageCache.clear()` that also dropped the live image (gallery flash)
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-08-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Downsampling: large images decode at max 2048px on first load (RAM ~1.6MB)
|
||||||
|
- Full-res swap: zooming in past 1.5x stretch swaps to original resolution
|
||||||
- Internationalization: Turkish + English, auto-detected from system locale
|
- Internationalization: Turkish + English, auto-detected from system locale
|
||||||
- Zoom decodes at original resolution while zooming in (crisp details)
|
- Zoom decodes at original resolution while zooming in (crisp details)
|
||||||
- Max zoom increased to 20x
|
- Max zoom increased to 20x
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- RAM leak on image switch — old providers now evicted + `ImageCache.evict()`
|
||||||
- Black screen flash during zoom (gapless playback)
|
- Black screen flash during zoom (gapless playback)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# imajViewer
|
# imajViewer
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/alhan/imajviewer/actions/workflows/build.yml)
|
|
||||||
[](https://github.com/alhan/imajviewer/releases/latest)
|
[Releases](https://git.softmediadesign.com/git_alhan/imajviewer/releases)
|
||||||
|
|
||||||
Ultra-lightweight image viewer written in Flutter — Linux & Windows.
|
Ultra-lightweight image viewer written in Flutter — Linux & Windows.
|
||||||
|
|
||||||
@@ -12,16 +12,24 @@ Ultra-lightweight image viewer written in Flutter — Linux & Windows.
|
|||||||
|
|
||||||
- **Formats:** PNG, JPEG, JFIF, WebP, BMP, GIF, WBMP
|
- **Formats:** PNG, JPEG, JFIF, WebP, BMP, GIF, WBMP
|
||||||
- **Pan:** Left drag
|
- **Pan:** Left drag
|
||||||
- **Zoom:** Mouse wheel (cursor-centered; viewport-centered in rotate mode)
|
- **Zoom:** Mouse wheel — 10% per step, cursor-centered (viewport-centered in rotate mode);
|
||||||
|
hold `Ctrl` for fine zoom (1% per step)
|
||||||
- **Fill/Fit toggle:** Double left click
|
- **Fill/Fit toggle:** Double left click
|
||||||
- **Contrast:** Right drag + vertical move
|
- **Contrast:** Right drag + vertical move
|
||||||
- **Saturation:** Ctrl + Right drag + vertical move
|
- **Saturation:** Ctrl + Right drag + vertical move
|
||||||
- **Brightness:** Ctrl+Shift + Right drag + vertical move
|
- **Brightness:** Ctrl+Shift + Right drag + vertical move
|
||||||
- **Rotate:** Shift + Right drag + vertical move (~100px = 90°)
|
- **Rotate:** Shift + Right drag + vertical move (~100px = 90°)
|
||||||
|
- **Flip horizontally:** `Ctrl+R` — mirrors the view and clears the active effects (same reset as
|
||||||
|
double right click); press `Ctrl+R` again to undo. Also matches the physical R key position, so
|
||||||
|
keymaps that report the R key as a different character still work
|
||||||
- **Reset (all effects):** Double right click
|
- **Reset (all effects):** Double right click
|
||||||
- **Navigation arrows:** bottom corners, visible on hover
|
- **Navigation arrows:** bottom corners, visible on hover
|
||||||
- **Settings (gear):** sort by time/name, print, shortcut info
|
- **Settings (gear):** sort by time/name, print, shortcut info
|
||||||
- **Print:** send the image to a printer (A4, fit)
|
- **Print:** send the image to a printer (A4, fit)
|
||||||
|
- **Save visible area:** title bar save icon or `Ctrl+S` — the on-screen region is written at
|
||||||
|
original resolution as JPEG (quality 95) next to the source file (`photo.jpg` →
|
||||||
|
`photo_143025.jpg`); active effects (contrast/saturation/brightness), rotation and horizontal
|
||||||
|
flip are baked into the output; if that name already exists, nothing is written and a warning is shown
|
||||||
- Drag & drop to open images
|
- Drag & drop to open images
|
||||||
- Window size/position memory (JSON persistence)
|
- Window size/position memory (JSON persistence)
|
||||||
- Set as default image viewer
|
- Set as default image viewer
|
||||||
@@ -33,7 +41,7 @@ Ultra-lightweight image viewer written in Flutter — Linux & Windows.
|
|||||||
### Linux (.deb, recommended)
|
### Linux (.deb, recommended)
|
||||||
|
|
||||||
No Flutter SDK needed — grab the latest `.deb` from the
|
No Flutter SDK needed — grab the latest `.deb` from the
|
||||||
[Releases page](https://github.com/alhan/imajviewer/releases/latest):
|
[Releases page](https://git.softmediadesign.com/git_alhan/imajviewer/releases):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo dpkg -i imajviewer_*_amd64.deb
|
sudo dpkg -i imajviewer_*_amd64.deb
|
||||||
@@ -45,7 +53,7 @@ After install, open an image with `xdg-open <file>` or double-click it.
|
|||||||
### Windows (installer)
|
### Windows (installer)
|
||||||
|
|
||||||
Download `imajviewer_setup_*.exe` from the
|
Download `imajviewer_setup_*.exe` from the
|
||||||
[Releases page](https://github.com/alhan/imajviewer/releases/latest) and run it.
|
[Releases page](https://git.softmediadesign.com/git_alhan/imajviewer/releases) and run it.
|
||||||
The installer:
|
The installer:
|
||||||
|
|
||||||
- Installs to Program Files
|
- Installs to Program Files
|
||||||
@@ -76,22 +84,30 @@ folder including `imajviewer.exe` and all DLLs).
|
|||||||
| Interaction | Action |
|
| Interaction | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Left drag | Pan |
|
| Left drag | Pan |
|
||||||
| Mouse wheel | Zoom (cursor-centered) |
|
| Mouse wheel | Zoom (cursor-centered, 10% per step) |
|
||||||
|
| Ctrl + Mouse wheel | Fine zoom (1% per step) |
|
||||||
| Double left click | Fill / Fit toggle |
|
| Double left click | Fill / Fit toggle |
|
||||||
| Right drag + vertical | Contrast (0.0 – 2.0) |
|
| Right drag + vertical | Contrast (0.0 – 2.0) |
|
||||||
| Ctrl + Right drag + vertical | Saturation (0.0 – 2.0) |
|
| Ctrl + Right drag + vertical | Saturation (0.0 – 2.0) |
|
||||||
| Ctrl+Shift + Right drag + vertical | Brightness (-100 – 100) |
|
| Ctrl+Shift + Right drag + vertical | Brightness (-100 – 100) |
|
||||||
| Shift + Right drag + vertical | Rotate (free) |
|
| Shift + Right drag + vertical | Rotate (free) |
|
||||||
|
| Ctrl + R | Flip horizontally (clears effects; press again to undo) |
|
||||||
| Double right click | Reset (all effects) |
|
| Double right click | Reset (all effects) |
|
||||||
| Left / Right arrow | Previous / next image |
|
| Left / Right arrow | Previous / next image |
|
||||||
|
| Ctrl + O | Open file |
|
||||||
|
| Ctrl + S | Save visible area (JPEG 95, original resolution) |
|
||||||
|
| Esc | Exit fullscreen |
|
||||||
| Ctrl + Q | Close window |
|
| Ctrl + Q | Close window |
|
||||||
| Ctrl + Shift + Q | Close all windows |
|
| Ctrl + Shift + Q | Close all windows |
|
||||||
|
|
||||||
## CI / Releases
|
## Releases
|
||||||
|
|
||||||
GitHub Actions builds Linux `.deb` and Windows installer on every push to
|
Installers are published on the
|
||||||
`main`. Pushing a `v*` tag creates a GitHub Release with both installers as
|
[Gitea Releases page](https://git.softmediadesign.com/git_alhan/imajviewer/releases):
|
||||||
assets.
|
|
||||||
|
- Linux `.deb` — `flutter build linux --release` + `bash dist/rebuild-deb.sh`
|
||||||
|
- Windows installer — on a Windows machine: `.\build-windows.ps1`
|
||||||
|
(Flutter + VS 2022 Build Tools + Inno Setup 6)
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- windows/**
|
||||||
|
- linux/**
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ if (-not $SkipInno) {
|
|||||||
Write-Err "Installer scripti bulunamadi: $installerScript"
|
Write-Err "Installer scripti bulunamadi: $installerScript"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Installer surumunu pubspec.yaml'dan aynala (CI'daki adimla ayni)
|
||||||
|
$pubVersion = (Select-String -Path (Join-Path $ProjectRoot 'pubspec.yaml') -Pattern '^version:\s*([0-9]+\.[0-9]+\.[0-9]+)').Matches[0].Groups[1].Value
|
||||||
|
(Get-Content $installerScript -Raw) -replace '#define MyAppVersion "[^"]+"', "#define MyAppVersion `"$pubVersion`"" | Set-Content $installerScript
|
||||||
|
Write-Ok "Inno Setup surumu pubspec'ten: $pubVersion"
|
||||||
|
|
||||||
$distDir = Join-Path $ProjectRoot "dist"
|
$distDir = Join-Path $ProjectRoot "dist"
|
||||||
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 796 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -1,209 +0,0 @@
|
|||||||
# Implementation — imajViewer
|
|
||||||
|
|
||||||
> Teknik uygulama dokümanı
|
|
||||||
> Versiyon: 1.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Mimari Genel Bakış
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────┐
|
|
||||||
│ Flutter App │
|
|
||||||
│ ┌────────────┐ ┌────────────────────────────┐ │
|
|
||||||
│ │ window_mgr │ │ ViewerScreen │ │
|
|
||||||
│ │ (frameless)│ │ ┌──────────────────────┐ │ │
|
|
||||||
│ └────────────┘ │ │ CustomTitleBar │ │ │
|
|
||||||
│ │ │ (minimize, close) │ │ │
|
|
||||||
│ │ ├──────────────────────┤ │ │
|
|
||||||
│ │ │ ImageCanvas │ │ │
|
|
||||||
│ │ │ (InteractiveViewer) │ │ │
|
|
||||||
│ │ │ - Scroll → Zoom │ │ │
|
|
||||||
│ │ │ - Drag → Pan │ │ │
|
|
||||||
│ │ └──────────────────────┘ │ │
|
|
||||||
│ └────────────────────────────┘ │
|
|
||||||
│ ┌────────────────────────────────────────────┐ │
|
|
||||||
│ │ ImageCacheManager │ │
|
|
||||||
│ │ - PrecacheImage │ │
|
|
||||||
│ │ - LRU eviction (> N images) │ │
|
|
||||||
│ │ - File → Memory → GPU Texture │ │
|
|
||||||
│ └────────────────────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Bileşen Ağacı (Widget Tree)
|
|
||||||
|
|
||||||
```
|
|
||||||
MaterialApp (dark theme)
|
|
||||||
└── ViewerScreen (StatefulWidget)
|
|
||||||
├── Stack
|
|
||||||
│ ├── InteractiveViewer (zoom + pan)
|
|
||||||
│ │ └── RawImage (resim)
|
|
||||||
│ ├── CustomTitleBar (Positioned: top-right)
|
|
||||||
│ │ ├── IconButton (minimize)
|
|
||||||
│ │ └── IconButton (close)
|
|
||||||
│ └── (koyu arka plan)
|
|
||||||
└── DragTarget (sürükle-bırak dosya alanı)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Kullanıcı dosya bırakır / açar
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
ViewerScreen.dragResult / openFileDialog
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
ImageList (List<String> filePaths)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
preloadImages() → ImageCache'e ekle
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Indexed, currentIndex değişince
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
InteractiveViewer.builder → Image.file(path)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Flutter ImageCache → GPU Texture → Render
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Adım Adım Uygulama
|
|
||||||
|
|
||||||
### Adım 1: Proje İskeleti
|
|
||||||
- `flutter create imajViewer`
|
|
||||||
- `--platforms=linux` ile oluştur
|
|
||||||
- Temele dark tema, frameless window ekle
|
|
||||||
|
|
||||||
**Dosyalar:** `main.dart`, `app.dart`
|
|
||||||
|
|
||||||
### Adım 2: window_manager Kurulumu
|
|
||||||
- pubspec.yaml: `window_manager: ^0.4.3`
|
|
||||||
- `main()` içinde `windowManager.ensureInitialized()`
|
|
||||||
- `waitUntilReadyToShow` → frameless ayarları:
|
|
||||||
- `setTitleBarStyle(TitleBarStyle.hidden)`
|
|
||||||
- `setSize(Size(1280, 800))`
|
|
||||||
- `setMinimumSize(Size(800, 600))`
|
|
||||||
- `setBackgroundColor(Colors.transparent)`
|
|
||||||
- Linux'ta `linux/my_app.cc`'de window_manager için ek ayar gerekebilir
|
|
||||||
|
|
||||||
**Dosyalar:** `lib/main.dart`, `pubspec.yaml`
|
|
||||||
|
|
||||||
### Adım 3: CustomTitleBar Widget
|
|
||||||
- Sağ üst köşede, sabit konumda
|
|
||||||
- Siyah yarı saydam arka plan
|
|
||||||
- minimize (—) ve kapat (X) butonları
|
|
||||||
- **Püf nokta:** Butonların üzerine gelince InteractiveViewer zoom yapmamalı
|
|
||||||
- `AbsorbPointer` veya `IgnorePointer` ile InteractiveViewer etkileşimini engelle
|
|
||||||
- Pencere boyutlandırma ve sürükleme kenarlıkları (window_manager'in `setResizable` özelliği)
|
|
||||||
|
|
||||||
**Dosya:** `lib/widgets/custom_title_bar.dart`
|
|
||||||
|
|
||||||
### Adım 4: ImageCanvas (Ana Görüntüleme)
|
|
||||||
- `InteractiveViewer` widget'ı
|
|
||||||
- `boundaryMargin: EdgeInsets.all(double.infinity)` → sınırsız kaydırma
|
|
||||||
- `minScale: 0.1`, `maxScale: 10.0`
|
|
||||||
- `onInteractionUpdate` → zoom oranını göstermek için (ops.)
|
|
||||||
- `transformationController` ile programatik zoom kontrolü
|
|
||||||
- Mouse wheel event: `Listener` ile `onPointerSignal` yakala
|
|
||||||
- `PointerScrollEvent` → transformationController ile zoom
|
|
||||||
- Fare imlecinin resim üzerindeki konumuna göre zoom odağı
|
|
||||||
|
|
||||||
**Püf nokta:**
|
|
||||||
`InteractiveViewer`'ın default scroll-to-zoom desteği vardır ama imleç odağında zoom
|
|
||||||
için `TransformationController`'a manuel matrix hesaplaması yapmak gerekir.
|
|
||||||
|
|
||||||
**Dosya:** `lib/widgets/image_canvas.dart`
|
|
||||||
|
|
||||||
### Adım 5: Resim Yönetimi (ImageManager)
|
|
||||||
- `ChangeNotifier` ile state yönetimi
|
|
||||||
- Dışarıdan dosya eklendiğinde `notifyListeners()` + `precacheImage()`
|
|
||||||
- `ImageCache.maximumSize` ve `maximumSizeBytes` ayarı
|
|
||||||
- **Performans stratejisi:**
|
|
||||||
- Sadece görünen resim bellekte tutulur
|
|
||||||
- 12+ resim için `ImageCache.maximumSizeBytes` = 512MB
|
|
||||||
- Resim değiştirince önceki resmin cache'ini düşürmeye gerek yok (Flutter LRU)
|
|
||||||
- Sürükle-bırak: `DragTarget<List<File>>` widget'ı
|
|
||||||
|
|
||||||
**Dosya:** `lib/services/image_manager.dart`
|
|
||||||
|
|
||||||
### Adım 6: Dosya Açma (Dialog + DragDrop)
|
|
||||||
- `file_picker` paketi ile `Ctrl+O` kısayolu
|
|
||||||
- `DragTarget` ile sürükle-bırak
|
|
||||||
- Desteklenen format filtreleri: PNG, JPEG, WebP, BMP, GIF
|
|
||||||
- **Dikkat:** Flutter Linux'ta DragTarget çalışıyor — ayrıca `window_manager`'dan `onDragFile` event'i de var
|
|
||||||
|
|
||||||
**Dosya:** `lib/services/file_handler.dart`
|
|
||||||
|
|
||||||
### Adım 7: Klavye Kısayolları
|
|
||||||
- Flutter'daki `Shortcuts` + `Actions` sistemi
|
|
||||||
- `← →` → resim değiştir
|
|
||||||
- `Ctrl+O` → dosya aç
|
|
||||||
- `F11` → tam ekran (fullscreen toggle)
|
|
||||||
- `Esc` → tam ekrandan çık
|
|
||||||
- `Ctrl+Q` → çıkış
|
|
||||||
|
|
||||||
**Dosya:** `lib/shortcuts/app_shortcuts.dart`
|
|
||||||
|
|
||||||
### Adım 8: Birleştirme ve Test
|
|
||||||
- Ana ekranı `ViewerScreen`'de topla
|
|
||||||
- `Listener(onPointerSignal)` + `InteractiveViewer` entegrasyonu
|
|
||||||
- `flutter run -d linux` ile test
|
|
||||||
- Performance profiling: `flutter run --profile`
|
|
||||||
|
|
||||||
### Adım 9: Derleme
|
|
||||||
- `flutter build linux --release`
|
|
||||||
- Binary: `build/linux/x64/release/bundle/imajViewer`
|
|
||||||
|
|
||||||
## 5. pubspec.yaml Bağımlılıkları
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
dependencies:
|
|
||||||
flutter:
|
|
||||||
sdk: flutter
|
|
||||||
window_manager: ^0.4.3
|
|
||||||
file_picker: ^8.0.0
|
|
||||||
path: ^1.9.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Önemli Dikkat Noktaları
|
|
||||||
|
|
||||||
### Zoom Odağı (Cursor-based Zoom)
|
|
||||||
InteractiveViewer varsayılan zoom odağı merkezdir. İmleç odağında zoom için
|
|
||||||
`TransformationController` ile manuel matrix çarpımı:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Scroll event yakala (PointerScrollEvent)
|
|
||||||
2. İmlecin resim üzerindeki koordinatını hesapla
|
|
||||||
3. Matrix4.translationValues(-txt) * Matrix4.diagonal3(values(scale)) * Matrix4.translationValues(txt)
|
|
||||||
```
|
|
||||||
|
|
||||||
Detaylı implementasyon `image_canvas.dart`'da yapılacak.
|
|
||||||
|
|
||||||
### Frameless Window Sürükleme
|
|
||||||
Window_manager zaten varsayılan olarak fare ile pencerenin herhangi bir
|
|
||||||
yerinden sürüklenmesine izin vermez. Bunun için:
|
|
||||||
- CustomTitleBar alanını `DragToMoveArea` ile sarmak
|
|
||||||
- Veya `windowManager.startDragging()` çağırmak
|
|
||||||
|
|
||||||
Biz `DragToMoveArea` widget'ını kullanacağız.
|
|
||||||
|
|
||||||
### Performans
|
|
||||||
- Flutter'ın `ImageCache`'i varsayılan olarak 50 MB ve 1000 entry ile sınırlıdır
|
|
||||||
- 12 adet 4K resim için `ImageCache.maximumSizeBytes` 1 GB'a çıkarılmalı
|
|
||||||
- `RepaintBoundary` ile sadece değişen bölgelerin yeniden çizilmesi sağlanır
|
|
||||||
- `Texture` bazlı render ile GPU'da işleme devam eder
|
|
||||||
|
|
||||||
## 7. Timeline
|
|
||||||
|
|
||||||
| Adım | İş | Tahmini Süre |
|
|
||||||
|---|---|---|
|
|
||||||
| 1-2 | Proje iskeleti + window_manager | 15 dk |
|
|
||||||
| 3 | CustomTitleBar | 15 dk |
|
|
||||||
| 4 | ImageCanvas + zoom | 30 dk |
|
|
||||||
| 5-6 | Resim yönetimi + dosya açma | 30 dk |
|
|
||||||
| 7 | Kısayollar | 10 dk |
|
|
||||||
| 8-9 | Birleştirme + build | 20 dk |
|
|
||||||
| **Toplam** | | **~2 saat** |
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# PRD — imajViewer
|
|
||||||
|
|
||||||
> Product Requirements Document
|
|
||||||
> Versiyon: 1.0
|
|
||||||
> Tarih: 2026-07-22
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Ürün Özeti
|
|
||||||
|
|
||||||
**imajViewer**, Flutter ile geliştirilmiş, Linux masaüstünde çalışan ultra hafif bir görüntü izleyici uygulamasıdır.
|
|
||||||
12'den fazla yüksek çözünürlüklü resmi aynı anda açabilir, fare tekerleği ile anlık zoom yapabilir ve
|
|
||||||
tüm pencere alanını resim göstermek için kullanır.
|
|
||||||
|
|
||||||
## 2. Hedef Kitle
|
|
||||||
|
|
||||||
- Linux kullanıcıları
|
|
||||||
- Fotoğrafçılar, tasarımcılar (hızlı önizleme ihtiyacı)
|
|
||||||
- Yüksek sayıda resim arasında hızlıca gezinmek isteyen kullanıcılar
|
|
||||||
|
|
||||||
## 3. Fonksiyonel Gereksinimler
|
|
||||||
|
|
||||||
### F-01: Resim Açma
|
|
||||||
- Kullanıcı dosya yöneticisinden sürükle-bırak ile resim ekleyebilir
|
|
||||||
- Aynı anda 12+ resim açılabilir
|
|
||||||
- Desteklenen formatlar: PNG, JPEG, WebP, BMP, GIF (statik)
|
|
||||||
|
|
||||||
### F-02: Zoom
|
|
||||||
- Fare tekerleği (mouse scroll) ile kademesiz zoom
|
|
||||||
- Zoom odağı fare imlecinin bulunduğu nokta olmalı
|
|
||||||
- Zoom oranı: %10 - %1000 arası
|
|
||||||
|
|
||||||
### F-03: Frameless Pencere
|
|
||||||
- Native title bar yok, sadece sağ üstte küçült (minimize) ve kapat (close) butonları
|
|
||||||
- Pencere sürüklenebilir (üst kısımdan)
|
|
||||||
- Pencere boyutlandırılabilir (kenarlardan/köşelerden)
|
|
||||||
|
|
||||||
### F-04: Görüntüleme
|
|
||||||
- Scroll bar yok
|
|
||||||
- Resim pencereye sığacak şekilde başlangıçta yerleşir (fit)
|
|
||||||
- Siyah/zemin renkli arka plan
|
|
||||||
- Pencere yeniden boyutlandırılınca resim yeniden ortalanır
|
|
||||||
|
|
||||||
### F-05: Navigasyon (ileri versiyon)
|
|
||||||
- Klavye kısayolları (← → ile resim değiştirme)
|
|
||||||
- Alt kısımda thumbnail strip
|
|
||||||
|
|
||||||
## 4. Fonksiyonel Olmayan Gereksinimler
|
|
||||||
|
|
||||||
### NF-01: Performans
|
|
||||||
- 12 adet 4K (3840x2160) resim aynı anda açıkken akıcı zoom (en az 60 FPS)
|
|
||||||
- Bellek kullanımı 2 GB'ı geçmemeli
|
|
||||||
- Resimler GPU'da işlenmeli, CPU yeniden ölçekleme yapılmamalı
|
|
||||||
|
|
||||||
### NF-02: Başlangıç Süresi
|
|
||||||
- Uygulama 2 saniyeden kısa sürede açılmalı
|
|
||||||
|
|
||||||
### NF-03: Binary Boyutu
|
|
||||||
- Derlenmiş uygulama 50 MB'ı geçmemeli
|
|
||||||
|
|
||||||
### NF-04: Güvenilirlik
|
|
||||||
- Büyük resimler (10000x10000 pixel) uygulamayı çökertmemeli
|
|
||||||
- Bellek sınırı aşılınca en eski resim otomatik boşaltılmalı
|
|
||||||
|
|
||||||
## 5. UI Tasarım İlkeleri
|
|
||||||
|
|
||||||
- **Minimalist:** Sadece resim var, gereksiz hiçbir UI öğesi yok
|
|
||||||
- **Karanlık tema:** Siyah (#1a1a1a) zemin
|
|
||||||
- **Frameless:** Hiçbir pencere çerçevesi görünmez
|
|
||||||
- **Duyarlı:** Pencere boyutu değişince resim otomatik yeniden boyutlanır
|
|
||||||
|
|
||||||
## 6. Teknik Yığın
|
|
||||||
|
|
||||||
| Bileşen | Teknoloji |
|
|
||||||
|---|---|
|
|
||||||
| UI Framework | Flutter 3.44+ |
|
|
||||||
| Platform | Linux Desktop (GTK arkaplan) |
|
|
||||||
| Dil | Dart 3.12+ |
|
|
||||||
| Window Yönetimi | window_manager |
|
|
||||||
| State Yönetimi | ValueNotifier / ChangeNotifier |
|
|
||||||
| Resim İşleme | dart:ui Image + ImageCache |
|
|
||||||
| Render | Skia / Impeller (GPU) |
|
|
||||||
|
|
||||||
## 7. Kullanıcı Akışı
|
|
||||||
|
|
||||||
1. Uygulama açılır → boş siyah ekran
|
|
||||||
2. Kullanıcı dosyaları sürükler veya `Ctrl+O` ile açar
|
|
||||||
3. Resim(ler) pencereye sığacak şekilde görüntülenir
|
|
||||||
4. Fare tekerleği → zoom in/out (imleç odağında)
|
|
||||||
5. Fare sürükleme → resmi kaydırma (pan)
|
|
||||||
6. Pencere yeniden boyutlandırma → resim yeniden ortalanır
|
|
||||||
7. Sağ üst X → uygulama kapanır
|
|
||||||
|
|
||||||
## 8. Başarı Kriterleri
|
|
||||||
|
|
||||||
- [ ] 12 adet 4K resim açıkken zoom akıcı (60 FPS)
|
|
||||||
- [ ] Frameless pencere sorunsuz çalışıyor
|
|
||||||
- [ ] Scroll zoom imleç odağında çalışıyor
|
|
||||||
- [ ] Bellek kullanımı 2 GB altında
|
|
||||||
- [ ] Binary boyutu 50 MB altında
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Ek Görüntü Formatları Destek Planı
|
|
||||||
|
|
||||||
> Tarih: 2026-08-03 · Değerlendirme + implementasyon planı
|
|
||||||
> Not: TIFF değerlendirildi ve **vazgeçildi** (kullanıcı kararı). Bu plan yalnızca mevcut mimariye sıfır maliyetle eklenebilen formatları kapsar.
|
|
||||||
|
|
||||||
## 1. Hedef
|
|
||||||
|
|
||||||
imajViewer'ın desteklediği format listesini, **ek decode katmanı gerektirmeden** (Flutter/Skia'nın zaten açabildiği) formatlarla genişletmek.
|
|
||||||
|
|
||||||
## 2. Mevcut Durum
|
|
||||||
|
|
||||||
### Desteklenen uzantılar (kod içi, 3 yerde)
|
|
||||||
| Dosya | Satır | Liste |
|
|
||||||
|---|---|---|
|
|
||||||
| `lib/services/file_handler.dart` | 9 | png, jpg, jpeg, webp, bmp, gif |
|
|
||||||
| `lib/services/image_manager.dart` | 24 | png, jpg, jpeg, webp, bmp, gif |
|
|
||||||
| `imajviewer.desktop` + `deb/.../imajviewer.desktop` | 9 | image/png;image/jpeg;image/webp;image/bmp;image/gif;image/jpg |
|
|
||||||
| `install.sh` | 77 | aynı MimeType listesi |
|
|
||||||
|
|
||||||
### Flutter'ın yerleşik codec'leri (Skia)
|
|
||||||
Resmi dokümantasyona göre Flutter açabilir: **JPEG, PNG, GIF, WebP, BMP, WBMP**
|
|
||||||
|
|
||||||
Yani Skia zaten açabiliyor ama listemizde olmayanlar:
|
|
||||||
- `.jfif` — JPEG/JFIF içerik (aslında JPEG, magic-byte aynı)
|
|
||||||
- `.jpe` — JPEG alternatif uzantısı
|
|
||||||
- `.wbmp` — Skia'da WBMP codec var
|
|
||||||
- `.avif` — **YOK**: Flutter issue #61229 açık, native plugin (`flutter_avif`/libavif) gerekir → kapsam dışı
|
|
||||||
- `.tif/.tiff` — **YOK**: image paketi gerekir → vazgeçildi
|
|
||||||
|
|
||||||
## 3. Eklenecekler
|
|
||||||
|
|
||||||
| Uzantı | MIME | Neden açılır | Maliyet |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `.jfif` | image/jpeg | JPEG codec, aynı magic byte | sadece filtre |
|
|
||||||
| `.jpe` | image/jpeg | JPEG alternatif uzantısı | sadece filtre |
|
|
||||||
| `.wbmp` | image/vnd.wap.wbmp | Skia WBMP codec | sadece filtre |
|
|
||||||
|
|
||||||
> `.avif` bilinçli olarak eklenmiyor: Flutter'ın yerleşik decoder'ı yok; native libavif bağımlılığı "ultra hafif" felsefeyi bozar. İstenirse ileride ayrı plan yapılır.
|
|
||||||
|
|
||||||
## 4. Yapılacaklar (sıralı)
|
|
||||||
|
|
||||||
1. [ ] `file_handler.dart` → allowedExtensions: `'jfif', 'jpe', 'wbmp'` ekle
|
|
||||||
2. [ ] `image_manager.dart` → supported: `'jfif', 'jpe', 'wbmp'` ekle
|
|
||||||
3. [ ] `imajviewer.desktop` → MimeType'a `image/jpeg` zaten var (jfif/jpe kapsar), `image/vnd.wap.wbmp;` ekle
|
|
||||||
4. [ ] `deb/usr/share/applications/imajviewer.desktop` → aynı
|
|
||||||
5. [ ] `install.sh` → aynı MimeType güncellemesi
|
|
||||||
6. [ ] Windows installer (`.iss`) dosya ilişkilendirme listesi kontrolü — varsa `image/jpeg` jfif/jpe'yi kapsar
|
|
||||||
|
|
||||||
## 5. Riskler / Açık Sorular
|
|
||||||
|
|
||||||
- **JFIF varyantı:** Bazı .jfif dosyaları CMYK-JPEG olabilir; Skia çoğunu açıyor ama tamamını değil → açılmayanlar `errorBuilder` ekranı gösterir (mevcut davranış, çökme yok)
|
|
||||||
- **WBMP nadir:** Çoğu kullanıcı hiç karşılaşmaz; eklemesi zararsız
|
|
||||||
- **Windows ilişkilendirme:** Inno Setup `.iss` dosyası `image/jpeg` kayıtlıysa .jfif/.jpe otomatik kapsanır; `.wbmp` için ayrı registry gerekebilir (opsiyonel)
|
|
||||||
|
|
||||||
## 6. Kabul Kriterleri
|
|
||||||
|
|
||||||
- [ ] `.jfif`, `.jpe`, `.wbmp` dosyaları dosya seçicide görünür
|
|
||||||
- [ ] JFIF dosyası pan/zoom/filtre ile görüntülenir
|
|
||||||
- [ ] Mevcut PNG/JPEG/WebP/GIF/BMP davranışı değişmez
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# imajViewer — Dokümantasyon İndeksi
|
|
||||||
|
|
||||||
| Tarih | Dosya | Konu |
|
|
||||||
||---|---|---|
|
|
||||||
| 2026-08-06 | [rapor-github-acik-kaynak.md](rapor-github-acik-kaynak.md) | **Rapor** — GitHub açık kaynak hazırlığı (gereksinimler, riskler, plan) |
|
|
||||||
| 2026-08-06 | [Implementation.md](Implementation.md) | Teknik uygulama dokümanı (kökten taşındı) |
|
|
||||||
| 2026-08-06 | [PRD.md](PRD.md) | Product Requirements Document (kökten taşındı) |
|
|
||||||
| 2026-08-06 | [update_plan_01.md](update_plan_01.md) | Pan sınırlama & zoom merkezi sorunu (kökten taşındı) |
|
|
||||||
| 2026-08-06 | [rapor-pan-kilitleme-nuks.md](rapor-pan-kilitleme-nuks.md) | **Rapor** — efekt uygulanmış görselde pan sırasında kilitlenme nüksü (analiz, açık sorular) |
|
|
||||||
| 2026-08-06 | [rapor-gezinme-ayarlar-print.md](rapor-gezinme-ayarlar-print.md) | **Rapor** — gezinme okları (hover), ayarlar/gear (sıralama), yazdırma (printing paketi) |
|
|
||||||
| 2026-08-06 | [rapor-sag-tik-donma-analizi.md](rapor-sag-tik-donma-analizi.md) | **Rapor** — sağ tuş efektlerinde donma analizi + slider UI değerlendirmesi |
|
|
||||||
| 2026-08-03 | [zoom-clamp-mekanizmasi.md](zoom-clamp-mekanizmasi.md) | **Düzeltme** — `_vpSize` sıfırlama bug'ı: çift tıklama sonrası zoom ölümü/pan serbestliği |
|
|
||||||
| 2026-08-03 | [performance_llvmpipe_issue.md](performance_llvmpipe_issue.md) | **Rapor** — llvmpipe donma analizi, kare hızı throttle, ölçüm sonuçları |
|
|
||||||
| 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) |
|
|
||||||
| 2026-07-27 | [session-windows-ci.md](session-windows-ci.md) | **Windows setup.exe yeniden derlendi, Gitea CI eklendi** |
|
|
||||||
| 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ı |
|
|
||||||
| 2026-08-10 | [windows-setup.md](windows-setup.md) | **Windows Development Ortam Kurulum Rehberi** — Flutter SDK, VS Build Tools, CMake, Inno Setup kurulum adımları |
|
|
||||||
| önceki | [zoom-clamp-mekanizmasi.md](zoom-clamp-mekanizmasi.md) | Zoom clamp mekanizması (letterbox-aware) |
|
|
||||||
|
|
||||||
## Geliştirme Akışı — Otomatik Build ve Kurulum
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
1. Debug build: `flutter build linux --debug`
|
|
||||||
2. Sisteme kurulum: `./install.sh` (debug bundle tercih edilir → `~/.local/lib/imajviewer/`)
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
1. Ortam kurulumu: [windows-setup.md](windows-setup.md) rehberini takip edin
|
|
||||||
2. Build + installer: `.\build-windows.ps1`
|
|
||||||
3. Manuel kurulum (build çıktılarından): `.\install.ps1`
|
|
||||||
4. Kaldırma: `.\uninstall.ps1`
|
|
||||||
|
|
||||||
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/`)
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Pencere Cascade Yerleşimi Planı
|
|
||||||
|
|
||||||
> Tarih: 2026-08-03 · Değerlendirme + implementasyon planı (henüz kod değişikliği yapılmadı)
|
|
||||||
|
|
||||||
## 1. Sorun
|
|
||||||
|
|
||||||
imajViewer'a yeni bir resim açıldığında pencere **hep aynı koordinat ve boyutta** açılıyor (önceki pencerenin üstüne biniyor). İstenen davranış:
|
|
||||||
|
|
||||||
- Yeni pencere, önceki pencerenin **x + width** kadar sağına açılsın (yan yana)
|
|
||||||
- Ekrandan **taşacaksa** alt satıra, satır başından (x baştan) açılsın
|
|
||||||
|
|
||||||
Bu, Windows'un klasik "cascade windows" davranışının basit hali.
|
|
||||||
|
|
||||||
## 2. Mevcut Durum Analizi
|
|
||||||
|
|
||||||
### Pencere açılış akışı (`lib/main.dart`)
|
|
||||||
```
|
|
||||||
main(args)
|
|
||||||
→ WindowPersistence.restore() // window.json'dan son konum/boyut
|
|
||||||
→ windowOptions = WindowOptions(size: saved ?? 1280x800)
|
|
||||||
→ waitUntilReadyToShow:
|
|
||||||
saved varsa → setPosition(saved.position) + setSize(saved.size)
|
|
||||||
saved yoksa → center()
|
|
||||||
→ runApp(ImajViewerApp(initialFiles: args))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Konum kaydetme akışı (`lib/screens/viewer_screen.dart` + `lib/services/window_persistence.dart`)
|
|
||||||
- `_startPolling()`: 2 saniyede bir `windowManager.getBounds()` → `WindowPersistence.saveSync()` → `~/.config/imajviewer/window.json` (Linux) / `%APPDATA%/imajviewer/window.json` (Windows)
|
|
||||||
- `onWindowEvent` (move/resize/close) → debounce'lu `_doSave()`
|
|
||||||
|
|
||||||
### Sorunun kökü
|
|
||||||
- Her yeni pencere `window.json`'daki **son kaydedilen** konumu okuyor → aynı yere açılıyor
|
|
||||||
- Cascade mantığı yok; ekran genişliği bilgisi hiç sorgulanmıyor
|
|
||||||
|
|
||||||
## 3. Teknik Araştırma (doğrulandı)
|
|
||||||
|
|
||||||
### window_manager 0.4.3 (mevcut bağımlılık)
|
|
||||||
- `setPosition`, `setSize`, `getBounds`, `getPosition`, `getSize`, `center()` → var
|
|
||||||
- **Ekran/display API'si → YOK** (getScreen/getPrimaryDisplay yok)
|
|
||||||
|
|
||||||
### window_manager 0.5.2 (güncel)
|
|
||||||
- Ekran API'si yine yok — `screen_retriever`'ı bağımlılık olarak içeriyor ama expose etmiyor
|
|
||||||
|
|
||||||
### screen_retriever 0.2.2 (pub.dev, window_manager'ın zaten bağımlısı)
|
|
||||||
- `ScreenRetriever.instance.getPrimaryDisplay()` → `Display`
|
|
||||||
- `getAllDisplays()` → `List<Display>`
|
|
||||||
- `Display` alanları:
|
|
||||||
- `size` (ekran tam boyutu, logical px)
|
|
||||||
- `visiblePosition` (Offset — çalışma alanı başlangıcı)
|
|
||||||
- `visibleSize` (Size — görev çubuğu hariç çalışma alanı)
|
|
||||||
- `scaleFactor`
|
|
||||||
- Linux (X11) + Windows desteği var
|
|
||||||
|
|
||||||
**Karar: `screen_retriever` doğrudan bağımlılık olarak eklenir** (window_manager sürümü değişmez).
|
|
||||||
|
|
||||||
## 4. Önerilen Algoritma
|
|
||||||
|
|
||||||
`main.dart`'ta, `windowOptions` oluşturulmadan önce:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. saved = WindowPersistence.restore()
|
|
||||||
2. display = await ScreenRetriever.instance.getPrimaryDisplay()
|
|
||||||
workArea = display.visiblePosition + display.visibleSize (yoksa display.size)
|
|
||||||
3. Eğer saved != null:
|
|
||||||
newX = saved.x + saved.width // önceki pencerenin sağına
|
|
||||||
newY = saved.y
|
|
||||||
|
|
||||||
// Yatay taşma → alt satıra, satır başına
|
|
||||||
if (newX + pencereGenişliği > workArea.right):
|
|
||||||
newX = workArea.left
|
|
||||||
newY = saved.y + saved.height
|
|
||||||
|
|
||||||
// Dikey taşma da varsa → en başa sar (0,0)
|
|
||||||
if (newY + pencereYüksekliği > workArea.bottom):
|
|
||||||
newX = workArea.left
|
|
||||||
newY = workArea.top
|
|
||||||
|
|
||||||
position = Offset(newX, newY)
|
|
||||||
4. saved yoksa → center() (mevcut davranış)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Örnek
|
|
||||||
- Ekran 1920×1080, pencere 1280×800, önceki pencere (100, 50)
|
|
||||||
- 1. yeni: (1380, 50) — yan yana ✓
|
|
||||||
- 2. yeni: (100+1280*2 = hesaplanır) → 1380+1280=2660 > 1920 → (0, 850) — alt satır başı ✓
|
|
||||||
|
|
||||||
> Not: `saved` her 2 saniyede bir güncellendiği için "önceki pencere" = en son kaydedilmiş konum. Bu, en son açılan pencerenin konumu olur — istenen davranışla uyumlu.
|
|
||||||
|
|
||||||
## 5. Yapılacaklar (sıralı)
|
|
||||||
|
|
||||||
1. [ ] `pubspec.yaml` → `screen_retriever: ^0.2.2` ekle, `flutter pub get`
|
|
||||||
2. [ ] `lib/main.dart` → cascade hesabı ekle (yeni bir servis fonksiyonuna taşınabilir)
|
|
||||||
3. [ ] `lib/services/window_persistence.dart` → `restore()` dışında, cascade hesabı için yardımcı fonksiyon:
|
|
||||||
- `static Future<Offset?> cascadePosition({required Size windowSize})` (opsiyonel, temizlik için)
|
|
||||||
4. [ ] Ekran boyutu alınamazsa (hata / çoklu monitör) → mevcut davranışa düş (saved veya center)
|
|
||||||
5. [ ] Test: ardışık 3-4 resim aç → yan yana dizilim + taşınca alt satır
|
|
||||||
|
|
||||||
## 6. Riskler / Açık Sorular
|
|
||||||
|
|
||||||
- **Çoklu monitör:** `getPrimaryDisplay` birincil ekranı verir. İmleç hangi ekrandaysa oraya açılsın mı? (başlangıç: primary)
|
|
||||||
- **Pencere kapatılıp yenisi açılırsa:** `window.json` son kapanan pencerenin konumunu tutar; yeni pencere onun yanına açılır — boşluk oluşabilir, kabul edilebilir
|
|
||||||
- **Tam ekran / maximize durumu:** saved boyut maximize ise cascade hesabı yine de eski boyutla yapılır; `window.json` sadece normal durumda yazıldığı için risk düşük
|
|
||||||
- **Pencere ekrandan tamamen dışarıdaysa:** `restore()` zaten `x < -1000 || y < -1000` guard'ı var (window_persistence.dart:44) — yine de sağlamlaştırılabilir
|
|
||||||
|
|
||||||
## 7. Kabul Kriterleri
|
|
||||||
|
|
||||||
- [ ] Ardışık 2 pencere: ikincisi birincinin hemen sağında
|
|
||||||
- [ ] Yatay taşma: yeni pencere alt satıra, satır başından başlar
|
|
||||||
- [ ] İlk açılışta (kayıt yok) pencere yine ortalanır
|
|
||||||
- [ ] Pencere boyutu/konumu kaydetme davranışı bozulmaz
|
|
||||||
- [ ] Linux ve Windows'ta çalışır (screen_retriever her ikisini destekliyor)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Performans: llvmpipe (Yazılımsal Render) Altında Efekt Donmaları
|
|
||||||
|
|
||||||
Tarih: 2026-08-03
|
|
||||||
Durum: Uygulandı (ölçüm sonuçları bölüm 3'te)
|
|
||||||
|
|
||||||
## 1. Teşhis
|
|
||||||
|
|
||||||
- VM'de donanım GPU'su yok: `glxinfo -B` → `Device: llvmpipe (LLVM 21.1.8, 128 bits)`,
|
|
||||||
`/dev/dri` yok. Tüm çizim 4 CPU çekirdeğinde LLVM ile yapılıyor.
|
|
||||||
- Efekt sürüklemesi (contrast/saturation/brightness/rotate): her `PointerMoveEvent`
|
|
||||||
doğrudan `setState` → tüm görsel katman `ColorFiltered` ile yeniden boyanıyor.
|
|
||||||
- Fare sürüklemesinde olay hızı 100-250+/sn; llvmpipe kare süresi 20-100ms+.
|
|
||||||
Üretim > tüketim → kuyruk birikmesi → UI thread %100 → donma. Kuyruk boşalınca
|
|
||||||
arayüz yakalıyor ("1 dk sonra normale döndü").
|
|
||||||
- Rotate'in önce kilitlenmesi normal: `Transform` ikinci bir composite geçişi ekliyor.
|
|
||||||
Contrast aynı duvara galeri akışıyla (daha büyük görseller, dolu ImageCache) çarptı.
|
|
||||||
|
|
||||||
## 2. Uygulanan Değişiklikler
|
|
||||||
|
|
||||||
### Render izolasyonu ve kare hızı eşitleme — `lib/widgets/image_canvas.dart`
|
|
||||||
|
|
||||||
- Pointer move / scroll / pan / efekt işleyicilerinden `setState` kaldırıldı.
|
|
||||||
- `ValueNotifier<int> _renderTick` + `ValueListenableBuilder` — görsel katman yalnızca
|
|
||||||
tick ile yeniden kurulur.
|
|
||||||
- `SchedulerBinding.instance.scheduleFrameCallback` — saniyede en fazla 1 rebuild/kare
|
|
||||||
(ekran yenileme hızıyla sınırlı); olaylar kareye eşitlenir, kuyruk birikmez.
|
|
||||||
- `RepaintBoundary` — efekt repaint'i pencere/title bar/üst widget render'ını etkilemez.
|
|
||||||
- Not: per-efekt `ValueNotifier` yerine tek tick notifier kullanıldı. Nedeni:
|
|
||||||
`ValueListenableBuilder` her değer değişiminde yeniden kurulur (olay hızında kalır);
|
|
||||||
throttling için tek noktadan kare başına tick gerekli.
|
|
||||||
|
|
||||||
### Image pipeline — `lib/widgets/image_canvas.dart` + `lib/services/image_manager.dart`
|
|
||||||
|
|
||||||
- `ResizeImage.resizeIfNeeded(cacheWidth: viewport×2)` — yüksek çözünürlüklü görseller
|
|
||||||
görüntüleme boyutunun ~2 katıyla sınırlı decode edilir: decode süresi, bellek ve
|
|
||||||
llvmpipe CPU yükü düşer. (Zoom 2x'e kadar net; ötesi canlı önizleme için kabul edilen takas.)
|
|
||||||
- `configureCache`: 1000 görsel / 1GB → **16 görsel / 256MB**.
|
|
||||||
- Galeri/klasör geçişinde `imageCache.clear()` — canlı (gösterilen) görsel korunur,
|
|
||||||
eski klasörün decode edilmiş `ui.Image` nesneleri bellekten düşer.
|
|
||||||
|
|
||||||
## 3. Ölçüm (llvmpipe)
|
|
||||||
|
|
||||||
Ölçüm yöntemi: 4000×3000 PNG açıldı; kullanıcı elle contrast / saturation /
|
|
||||||
brightness sürüklemeleri yaptı. Kare süreleri `addTimingsCallback` ile (geçici
|
|
||||||
enstrümantasyon, sonra kaldırıldı), CPU `pidstat` ile örneklendi.
|
|
||||||
|
|
||||||
| Metrik | Değer |
|
|
||||||
|---|---|
|
|
||||||
| Kare sayısı (contrast turu) | 76 |
|
|
||||||
| build ort / max | 3.9 ms / 78 ms |
|
|
||||||
| raster ort / max | 15.1 ms / 52 ms |
|
|
||||||
| Kare sayısı (sat/brightness turu) | 133 |
|
|
||||||
| build ort / max | 3.0 ms / 82 ms |
|
|
||||||
| raster ort / max | 14.1 ms / 48 ms |
|
|
||||||
| CPU — boştayken | %0-14 |
|
|
||||||
| CPU — aktif sürüklemede | %100-163 (1-1.6 çekirdek) |
|
|
||||||
|
|
||||||
Değerlendirme: raster ort. ~14-15 ms (16.7 ms kare bütçesinin altında), sürükleme
|
|
||||||
sırasında maksimum ~50 ms → ara sıra kare atlanır ama **kuyruk birikmesi ve
|
|
||||||
dakikalarca süren donma yok**. CPU 4 çekirdeğin tamamını asla doyurmuyor
|
|
||||||
(önceki durumda sürükleme anında tam doygunluk + 1 dk donma vardı).
|
|
||||||
|
|
||||||
## 4. VM Sistem Gereksinimleri (öneri — uygulama dışı)
|
|
||||||
|
|
||||||
llvmpipe tamamen yazılımsal; kalıcı çözüm VM tarafında donanım hızlandırmasıdır:
|
|
||||||
|
|
||||||
- **virtio-gpu + VirGL**: QEMU/libvirt'ta `-device virtio-vga-gl` / `virtio-gpu-gl`
|
|
||||||
(mesa'nın VirGL yoluyla hızlandırılmış GL'si). `/dev/dri` görünür hale gelir.
|
|
||||||
- **PCIe Passthrough**: gerçek GPU'yı VM'e bağlama (en yüksek performans, donanım gerektirir).
|
|
||||||
- Bu olmadan uygulama yukarıdaki yazılımsal önlemlerle çalışır, ancak büyük görsellerde
|
|
||||||
efekt sürüklemesi yine de kare atlayabilir.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 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?
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Rapor — Gezinme Okları, Ayarlar (Sıralama) ve Yazdırma
|
|
||||||
|
|
||||||
Tarih: 2026-08-06
|
|
||||||
Durum: Uygulandı
|
|
||||||
|
|
||||||
## Amaç
|
|
||||||
|
|
||||||
Klavyede zaten çalışan sol/sağ ok tuşu gezinmesine görsel karşılık eklemek;
|
|
||||||
ayarlar (gear) penceresiyle klasör sıralamasını kullanıcıya bırakmak; resmi
|
|
||||||
yazıcıya göndermek.
|
|
||||||
|
|
||||||
## Yapılan Değişiklikler
|
|
||||||
|
|
||||||
### 1. Gezinme okları (görsel arayüz)
|
|
||||||
|
|
||||||
- Sol/sağ alt köşeye sade yuvarlak ok butonları eklendi (`_NavButton`,
|
|
||||||
`lib/screens/viewer_screen.dart`).
|
|
||||||
- Normalde görünmez; mouse görüntü alanına girince belirir
|
|
||||||
(`MouseRegion` → `_controlsVisible`).
|
|
||||||
- Hover'da kırmızı arka plan (kapatma butonuyla aynı stil).
|
|
||||||
- Tooltip yok (kullanıcı istemedi).
|
|
||||||
- `ImageManager.instance.previousImage()/nextImage()` çağırır — klavye
|
|
||||||
kısayoluyla aynı yolu kullanır.
|
|
||||||
|
|
||||||
### 2. PITFALL — MouseRegion flicker döngüsü (çözüldü)
|
|
||||||
|
|
||||||
İlk uygulamada MouseRegion yalnızca resim alanını sarıyordu, butonlar
|
|
||||||
Stack'te üstte ayrı duruyordu. Mouse butonun üzerine gelince hit-test
|
|
||||||
butona gidiyor → MouseRegion `onExit` → butonlar kayboluyor → mouse tekrar
|
|
||||||
resim alanına düşüyor → `onEnter` → sonsuz yanıp sönme ve tıklanamama.
|
|
||||||
|
|
||||||
Çözüm: `MouseRegion`'ı **tüm Stack'in** üzerine taşı (DropTarget'ın
|
|
||||||
çocuğu). Butonlar da Stack içinde kaldığı için onların üzerindeyken
|
|
||||||
bölgeden çıkılmaz; `onExit` yalnızca pencere alanı terk edilince tetiklenir.
|
|
||||||
|
|
||||||
### 3. Gear (Ayarlar) butonu
|
|
||||||
|
|
||||||
- İlk olarak görüntü alanının sol üstüne kondu; kullanıcı "title yüzünden
|
|
||||||
aşağıda çıkıyor" deyince **title bar'ın soluna** taşındı
|
|
||||||
(`CustomTitleBar(onSettings: ...)`, `lib/widgets/custom_title_bar.dart`).
|
|
||||||
- Title bar `visible` parametresiyle `_controlsVisible`'a bağlandı: mouse
|
|
||||||
resmin dışındayken title (gear dahil) saydam (AnimatedOpacity 0), mouse
|
|
||||||
girince görünür.
|
|
||||||
- Hover arka planı kırmızı (`_BarButton` varsayılanı).
|
|
||||||
|
|
||||||
### 4. Ayarlar penceresi (`_showSettings`)
|
|
||||||
|
|
||||||
Gear'a tıklayınca açılan AlertDialog:
|
|
||||||
- **Sıralama**: Zamana göre / İsme göre (RadioListTile).
|
|
||||||
`ImageManager.sortByName` — `setGalleryFromPaths` içinde sıralama
|
|
||||||
karşılaştırıcısı buna göre değişir; isim sıralaması `toLowerCase`
|
|
||||||
karşılaştırma ile (büyük/küçük harf duyarsız). Mod değişince mevcut
|
|
||||||
galeri `setGalleryFromPaths([_images[_currentIndex]])` ile yeniden
|
|
||||||
kurulur (açık resim korunur).
|
|
||||||
- Uygulamanın amacı (kısa paragraf).
|
|
||||||
- Kısayollar listesi (F11 satırı kaldırıldı — tam ekran özelliği yok).
|
|
||||||
|
|
||||||
### 5. Yazdırma (`_printImage`)
|
|
||||||
|
|
||||||
- Yeni bağımlılıklar: `printing: ^5.13.4`, `pdf: ^3.11.1` (Linux dahil
|
|
||||||
masaüstü destekli).
|
|
||||||
- Buton ayarlar penceresinin actions kısmında ("Yazdır"); ayrı köşe butonu
|
|
||||||
değil (kullanıcı istediği yerleşim).
|
|
||||||
- Akış: `File.readAsBytes` → `pw.MemoryImage` → `pw.Document` A4 sayfa,
|
|
||||||
`BoxFit.contain` → `Printing.layoutPdf` (sistem yazıcı dialogu).
|
|
||||||
|
|
||||||
### 6. `_BarButton.hoverColor` kaldırıldı
|
|
||||||
|
|
||||||
Gear title'a taşınıp tek hover rengi (kırmızı) kaldığı için opsiyonel
|
|
||||||
parametre gereksizleşti; analyze uyarısı (unused_element_parameter)
|
|
||||||
temizlendi.
|
|
||||||
|
|
||||||
## Doğrulama
|
|
||||||
|
|
||||||
- `flutter analyze`: 21 info (pre-existing baseline; yeni hata yok).
|
|
||||||
- `flutter build linux --release`: ✓ Built.
|
|
||||||
- `./install.sh` (debug bundle silinip release kuruldu): exit 0.
|
|
||||||
- `bash dist/rebuild-deb.sh`: `dist/imajviewer_1.0.1_amd64.deb` (11M) üretildi.
|
|
||||||
- Görsel davranış kullanıcı tarafından manuel test edildi (oklar,
|
|
||||||
gear/title saydamlığı, hover renkleri, sıralama, print dialogu).
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# Rapor — imajViewer'ı GitHub'da Açık Kaynak Yapmak
|
|
||||||
|
|
||||||
> Tarih: 2026-08-06
|
|
||||||
> Konu: GitHub'a açık kaynak repo koymak için gerekenler + mevcut repo durumu + geçmiş/silinme sorusu + "pro" hale getirme planı
|
|
||||||
|
|
||||||
## 1. Sorunun cevabı: dosya silersem GitHub'a eski dosyalar gider mi?
|
|
||||||
|
|
||||||
**Çalışma ağacından gider, geçmişten GİTMEZ.**
|
|
||||||
|
|
||||||
- Aynı repo geçmişini (aynı commit zincirini) GitHub'a push edersen: sildiğin dosyalar
|
|
||||||
son sürümde (en son commit'te) görünmez, ama **git geçmişindeki eski commit'lerde
|
|
||||||
aynen durur**. GitHub'da eski commit'lere tıklayan herkes o dosyaları görür ve indirebilir.
|
|
||||||
- Üstelik bu geçmiş 391 MB binary taşıyor (aşağıda detay). GitHub'a geçmişle gitmek hem
|
|
||||||
bu yükü hem de "eski dosyalar hâlâ orada" sorununu getirir.
|
|
||||||
- **Öneri:** GitHub için geçmişsiz / tek commit'li temiz başlangıç yap. Ya `git init` ile
|
|
||||||
yeni repo (sıfır geçmiş), ya da squash ile tek commit. O zaman ne sildiysen tamamen gider.
|
|
||||||
|
|
||||||
## 2. GitHub'a açık kaynak repo koymak için gerekenler (checklist)
|
|
||||||
|
|
||||||
| Öğe | Durum | Not |
|
|
||||||
|---|---|---|
|
|
||||||
| LICENSE dosyası | ❌ YOK | README'de "MIT" yazıyor ama kökte LICENSE dosyası yok. GitHub lisansı bunu algılamaz |
|
|
||||||
| README | ⚠️ Var | Gitea URL'leri içeriyor, Türkçe, badge yok (detay: madde 4) |
|
|
||||||
| CONTRIBUTING.md | ❌ YOK | Katkı kuralları (kod stili, test, PR süreci) |
|
|
||||||
| CODE_OF_CONDUCT.md | ❌ YOK | GitHub topluluk standartları için beklenir |
|
|
||||||
| SECURITY.md | ❌ YOK | Güvenlik açığı bildirim yolu (private advisory) |
|
|
||||||
| CHANGELOG.md | ❌ YOK | 1.0.0 → 1.1.0 geçmişi yazılı değil |
|
|
||||||
| CI (GitHub Actions) | ❌ YOK | Sadece `.gitea/workflows/` var — GitHub bunu ÇALIŞTIRMAZ, `.github/workflows/` gerekir |
|
|
||||||
| Issue/PR template | ❌ YOK | `.github/ISSUE_TEMPLATE/` + `PULL_REQUEST_TEMPLATE.md` |
|
|
||||||
| .gitignore | ✅ Var | İyi durumda (build/, .dart_tool/ ignore ediliyor) |
|
|
||||||
| Repo metadata | ❌ | description + topics (image-viewer, flutter, linux, windows) + lisans seçimi |
|
|
||||||
| Releases | ⚠️ | v1.1.0 tag + .deb/.exe asset'leri GitHub Releases'e taşınmalı |
|
|
||||||
| FUNDING.yml | ❌ | Opsiyonel |
|
|
||||||
|
|
||||||
## 3. KRİTİK: Sızıntı / hijyen riskleri
|
|
||||||
|
|
||||||
1. **Gömülü token:** `git remote -v` çıktısında origin URL'si
|
|
||||||
`https://git_alhan:1254baa3...@git.softmediadesign.com/...` — token URL'e gömülü.
|
|
||||||
Bu dosya `.git/config`'te; push ile GitHub'a gitmez (config transfer edilmez).
|
|
||||||
Ama GitHub'a yeni remote eklerken bu URL'yi yanlışlıkla kopyalamamaya dikkat.
|
|
||||||
Token'ı Gitea'dan yenilemek en temizi.
|
|
||||||
2. **Kişisel dosyalar repoda:** `imajviewer.desktop` (yerel .desktop, mode 600),
|
|
||||||
`Implementation.md`, `PRD.md`, `update_plan_01.md`, `docs/session-*.md`,
|
|
||||||
`docs/rapor-*.md` — iç geliştirme notları. Açık kaynak repo'da hepsi durmamalı.
|
|
||||||
3. **Binary dosyalar git'te:** `deb/` (16 MB libflutter_linux_gtk.so vb. — build çıktısı)
|
|
||||||
ve `dist/` (.deb, .exe — sürüm paketleri) git ile takip ediliyor. Binary'ler git'e
|
|
||||||
girmez; release asset'i olur. `.gitignore`'da `*.deb`/`*.exe` var ama `!dist/*.deb`
|
|
||||||
istisnası ve `deb/` dizini bunları açıkta bırakıyor.
|
|
||||||
4. **Mutlak yol:** `pubspec.yaml` → `msix_config.logo_path: C:/projects/imajviewer/...`
|
|
||||||
— yerel Windows yolu. GitHub runner'ında msix adımı kırılır; göreli yol olmalı.
|
|
||||||
5. **Geçmiş 391 MB:** En büyük blob'lar: setup.exe 24 MB ×4 kopya, libflutter_linux_gtk.so
|
|
||||||
16 MB, .deb'ler 8-11 MB ×8, tar.gz 9 MB. GitHub'da dosya limiti 100 MB — şu an
|
|
||||||
geçmiyor ama repo 1 GB'a yaklaşırsa GitHub uyarır, şişkinlik de kötü görünür.
|
|
||||||
|
|
||||||
## 4. README düzeltmeleri
|
|
||||||
|
|
||||||
- Klon/curl komutlarındaki `git.softmediadesign.com` URL'leri GitHub URL'si olmalı
|
|
||||||
- `.deb` kurulum adımındaki sürüm `1.0.0` yazılmış, güncel `1.1.0`
|
|
||||||
- Türkçe → açık kaynak için İngilizce (veya TR+EN iki dilli) önerilir
|
|
||||||
- Badge'ler (build status, license, release) + ekran görüntüsü (imajviewer.png var)
|
|
||||||
- "Gitea Actions" bölümü → "GitHub Actions" olarak yeniden yazılmalı
|
|
||||||
|
|
||||||
## 5. "Pro" hale getirme planı (uygulama adımları, onay sonrası)
|
|
||||||
|
|
||||||
1. **Kök temizliği:** `imajviewer.desktop` sil; iç dokümanları (Implementation/PRD/
|
|
||||||
update_plan/docs) taşı ya da sil — karar gerekiyor (aşağıda).
|
|
||||||
2. **Binary'leri git'ten çıkar:** `git rm --cached deb/ dist/*.deb dist/*.exe`,
|
|
||||||
`.gitignore`'u düzelt (deb/ tamamen ignore; dist/'te sadece rebuild-deb.sh kalsın).
|
|
||||||
Not: `git rm --cached` sadece ileriye dönük temizler; geçmişteki 391 MB durur —
|
|
||||||
tam temizlik için ya filter-repo ya da temiz başlangıç gerekir.
|
|
||||||
3. **Lisans:** Köke `LICENSE` (MIT) ekle.
|
|
||||||
4. **README'yi yeniden yaz:** İngilizce, GitHub linkleri, badge, screenshot.
|
|
||||||
5. **Topluluk dosyaları:** CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md,
|
|
||||||
CHANGELOG.md, `.github/` (issue/PR template + workflows/build-windows.yml).
|
|
||||||
6. **CI'ı GitHub'a uyarla:** `.gitea/workflows/build-windows.yml` → `.github/workflows/`:
|
|
||||||
`GITEA_TOKEN` → `GITHUB_TOKEN` (otomatik), release adımı
|
|
||||||
`softprops/action-gh-release` ile GitHub Release + .deb/.exe asset.
|
|
||||||
7. **pubspec.yaml:** `logo_path`'i göreli yap (CI'da msix kırılmasın).
|
|
||||||
8. **GitHub'a aktarım:** Yeni boş GitHub repo → `git remote add github <url>` →
|
|
||||||
temiz tek commit (squash/orphan) → `git push github main`. Gitea ayrı kalır.
|
|
||||||
|
|
||||||
## 6. Açık kararlar (senin onayın gerekiyor)
|
|
||||||
|
|
||||||
1. README dili: İngilizce mi, TR mi, iki dilli mi? (açık kaynak görünürlüğü için İngilizce önerilir)
|
|
||||||
2. `docs/` ve kök dokümanlar: iç raporlar/session notları ne olacak? (yerel yedek alınır mı?)
|
|
||||||
3. Geçmiş: temiz tek commit mi, yoksa tüm geçmiş GitHub'a taşınsın mı? (öneri: temiz tek commit)
|
|
||||||
4. GitHub hesabı/org: repo hangi hesaba? Repo adı: `imajviewer` mi `imajViewer` mi?
|
|
||||||
5. Lisans: MIT kesin mi? (README'de MIT yazıyor, dosya yok)
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# Rapor — Efekt Uygulanmış Görselde Pan Sırasında Kilitlenme (Nüks)
|
|
||||||
|
|
||||||
Tarih: 2026-08-06
|
|
||||||
Durum: Analiz — hipotezler hazır, açık sorular kullanıcı yanıtını bekliyor
|
|
||||||
|
|
||||||
## 1. Semptom (yeni bildirim)
|
|
||||||
|
|
||||||
- Donma sorunu **yeniden ortaya çıktı** — bu kez **"diğer bilgisayarda"** (VM değilse
|
|
||||||
ortamı bilinmiyor; önceki testler Xubuntu VM + llvmpipe idi).
|
|
||||||
- Tetikleyici farklı: önceki nüksler **efekt sürüklemesi sırasında** idi
|
|
||||||
(contrast/saturation/brightness/rotate drag). Şimdi **efekt uygulanmış durumda
|
|
||||||
(değerler sabit) iken PAN yaparken** kilitlenme.
|
|
||||||
- "Olmadık zamanlarda" — rastgele zamanlarda, her pan'da değil.
|
|
||||||
|
|
||||||
## 2. Önceki Teşhisler ve Çözümler (kayıt özeti)
|
|
||||||
|
|
||||||
| Tarih | Teşhis | Çözüm | Durum |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 03 Ağu | **Kuyruk birikmesi**: pointer event hızı (100-250/sn) > llvmpipe kare hızı → setState her event'te → UI thread %100, 30-60sn donma | `_markRenderDirty()` frame-throttle (kare başına max 1 rebuild) + `ResizeImage` (viewport×2) + provider memoize + cache 16/256MB + galeri geçişinde `imageCache.clear()` | Uygulandı, ölçüldü (raster ort ~15ms, max ~50ms, kuyruk birikmesi yok) |
|
|
||||||
| 06 Ağu | **İlk kullanım shader derlemesi (B1)**: process başına soğuk Skia/GL cache; ilk ColorFiltered/Transform kullanımı 15sn-1dk derleme | **Shader warmup**: açılışta gizli Opacity(0) katmanla filtre + rotate shader'larını derlet | Uygulandı (release build + kurulum 06 Ağu) |
|
|
||||||
|
|
||||||
## 2b. Kullanıcı Yanıtları (2026-08-06, ikinci bildirim)
|
|
||||||
|
|
||||||
- Ortam: **gerçek makine** — `Mesa Intel(R) HD Graphics 3000 (SNB GT2)` (Sandy
|
|
||||||
Bridge iGPU, 2011). mesa i965 driver GLSL'i **LLVM ile CPU'da derler** → karmaşık
|
|
||||||
shader varyantları 30sn'ye çıkabilir. 12GB RAM, son sürüm kurulu.
|
|
||||||
- Test sonuçları (kullanıcı):
|
|
||||||
- **Resim büyükse (dosya ~1MB+) oluyor**; küçük görselde olmuyor.
|
|
||||||
- **Fill modda olmuyor, fit modda oluyor.**
|
|
||||||
- **İlk açılışta fit moda geçip rotate edilirse oluyor** — tek seferlik, sonra
|
|
||||||
tekrar değil (kullanıcı "sanırım" ile ifade etti, teyit beklenecek).
|
|
||||||
- Bu bilgiler **Aday F'yi doğruluyor**: warmup 1x1 gizli katmanda yapılıyor, gerçek
|
|
||||||
kullanımın (büyük doku + fit BoxFit.contain + rotate + ColorFiltered) shader
|
|
||||||
varyantı warmup'ta derlenmiyor → ilk gerçek kullanımda 30sn derleme.
|
|
||||||
- Aday A (ColorFiltered CPU filtrelemesi) ve C (bellek) elendi — GPU var, RAM bol.
|
|
||||||
- **EKLENTİ (kullanıcı kurcalayınca):** Fill modda da oluyor; **"biraz zorlayınca
|
|
||||||
aynı resimde tekrar oldu"** — yani tek seferlik DEĞİL. Bu Aday F'yi (ilk kullanım
|
|
||||||
derlemesi, cache'li kalır) **zayıflatıyor**; varyant sayısı fazlaysa her yeni
|
|
||||||
kombinasyon yeni derleme olabilir (aşağıda Aday H/I).
|
|
||||||
|
|
||||||
Her iki çözüm de mevcut kodda aktif: `image_canvas.dart` → `_markRenderDirty`,
|
|
||||||
provider memoize (`_cachedProvider`), `_buildWarmupLayer()`.
|
|
||||||
|
|
||||||
## 3. Yeni Analiz — Neden "Efekt + Pan" Kilitlenir?
|
|
||||||
|
|
||||||
Pan akışı: `_onPanUpdate` → `_tx/_ty += delta` → `_clamp()` → `_markRenderDirty()`.
|
|
||||||
|
|
||||||
### Aday A — ColorFiltered her pan frame'inde tüm görseli CPU'da filtreliyor ⭐
|
|
||||||
|
|
||||||
- Efekt aktifken (`hasFilter == true`) her rebuild'te `ColorFiltered` → `Image` tüm
|
|
||||||
görsel 5x4 matrisle filtrelenir. Pan'da translate değişir → her frame'de yeniden
|
|
||||||
boyama + filtreleme.
|
|
||||||
- 4000×3000 görsel ≈ 12M piksel; llvmpipe'ta filtre + ölçekleme + translate ≈
|
|
||||||
20-60ms/kare. FPS 15-50'ye düşer → **"takılma" hissi**.
|
|
||||||
- `_markRenderDirty` kuyruk birikmesini engeller ama kare başına maliyet yüksekse
|
|
||||||
düşük FPS kalıcı olur. Büyük görsel + zayıf makinede bu "kilitlenme" gibi algılanabilir.
|
|
||||||
- **Rotate aktifse** (pan serbest, `_clamp` erken return) Transform + ColorFiltered
|
|
||||||
birlikte — en ağır kombinasyon.
|
|
||||||
|
|
||||||
### Aday B — `_clamp()` pan event hızında `findRenderObject()` çağırıyor ⭐
|
|
||||||
|
|
||||||
- `_onPanUpdate` her event'te `_clamp()` çağırır; fit modda `_clamp()` içinde
|
|
||||||
`_imageKey.currentContext?.findRenderObject()` (satır 77) **pointer hızında**
|
|
||||||
(throttle'ın DIŞINDA) render tree sorgusu yapar. Pan event'leri 100-250/sn →
|
|
||||||
gereksiz layout/tree traversal yükü. Kilitlenme üretmez ama takılmayı artırır.
|
|
||||||
|
|
||||||
### Aday C — Bellek/GC baskısı ("olmadık zamanlarda" rastgeleliğin en iyi adayı)
|
|
||||||
|
|
||||||
- Rastgele kilitlenme imzası GC/swap'e işaret eder. "Diğer bilgisayar"da RAM azsa
|
|
||||||
(8GB altı) veya başka işler varsa: büyük görsel decode + 256MB cache + pan sırasında
|
|
||||||
GC pause → kısa ama algılanabilir donmalar.
|
|
||||||
- Pan sırasında yeni decode yok (memoize) → cache baskısı düşük; ama makine belleği
|
|
||||||
dar ise OS seviyesinde swap thrashing olabilir.
|
|
||||||
|
|
||||||
### Aday D — Eski binary / farklı ortam ("diğer bilgisayar")
|
|
||||||
|
|
||||||
- "Diğer bilgisayarda" kurulu sürüm bilinmiyor. Eski .deb (warmup/memoize öncesi)
|
|
||||||
kuruluysa sorun zaten açıklanır. Ayrıca o makinede donanım GPU'su VARSA teşhis
|
|
||||||
tamamen değişir (llvmpipe değil) — ColorFiltered donanımda ucuzdur, o zaman aday
|
|
||||||
C veya farklı bir sebep aranır.
|
|
||||||
|
|
||||||
### Aday E — Pan sırasında yeni frame kuyruğu (zayıf)
|
|
||||||
|
|
||||||
- `scheduleFrameCallback` kare başına 1 callback garanti eder; kare süresi uzarsa FPS
|
|
||||||
düşer ama kuyruk birikmez (03 Ağu ölçümü). Kilitlenme üretmez.
|
|
||||||
|
|
||||||
### Aday F — Warmup varyant eksikliği: 1x1 katman gerçek kullanımı derlemiyor ⭐⭐⭐
|
|
||||||
|
|
||||||
- Warmup (`_buildWarmupLayer`) **1x1 boyutunda** gizli katmanla filtre + rotate
|
|
||||||
shader'larını derletiyor. Skia/GL'de shader varyantları **doku boyutu, sampling
|
|
||||||
modu, filtre kalitesi** gibi parametrelere göre ayrı derlenir.
|
|
||||||
- Gerçek kullanımda **büyük görsel** (4000x3000) + `FilterQuality.high` +
|
|
||||||
`BoxFit.cover/contain` + `ColorFiltered` + pan/zoom `Transform` — bu kombinasyonun
|
|
||||||
shader'ı 1x1 warmup'ta derlenenle **aynı varyant olmayabilir** → ilk gerçek
|
|
||||||
kullanımda derleme yine olur → 30sn kilitlenme.
|
|
||||||
- "Efekt uygulanmışken pan yaparken" tetikleyicisi: pan/zoom + ColorFiltered birlikte
|
|
||||||
ilk kez kullanıldığında varyant derlenir; sonrası cache'li → "olmadık zamanlarda"
|
|
||||||
ve "her pan'da değil" imzası birebir uyuyor.
|
|
||||||
- GPU'da shader derleme süresi genelde ms seviyesindedir; **zayıf/iç ekran kartı +
|
|
||||||
mesa/LLVM backend** ile 30sn'ye çıkabilir. "İyi kötü ekran kartı" ifadesi bu
|
|
||||||
olasılığı güçlendiriyor.
|
|
||||||
- **Warmup'ın gerçek boyutla yapılması gerekir**: gizli katman 1x1 yerine mevcut
|
|
||||||
görselin boyutunda (veya viewport boyutunda) olmalı; ayrıca pan/zoom Transform'u
|
|
||||||
da warmup'a dahil edilmeli.
|
|
||||||
|
|
||||||
### Aday G — Gerçek GPU driver / doku sınırı
|
|
||||||
|
|
||||||
- Zayıf GPU'da büyük doku (12MP RGBA ≈ 48MB) + ColorFiltered → driver'da aşırı
|
|
||||||
yavaş path veya GL sync beklemesi. `glxinfo -B` renderer string'i kritik
|
|
||||||
(NVIDIA nouveau / AMD radeon / Intel / llvmpipe?).
|
|
||||||
|
|
||||||
### Aday H — Skia varyant cache dolması / her kombinasyon yeni derleme ⭐⭐
|
|
||||||
|
|
||||||
- "Aynı resimde tekrar oldu" → tek seferlik derleme değil. Skia'da her farklı
|
|
||||||
kombinasyon (zoom seviyesi × rotate açısı × efekt değeri × fit/fill × filtre
|
|
||||||
kalitesi) farklı shader varyantı üretebilir; varyant cache'i sınırlıysa (LRU)
|
|
||||||
eski varyant atılır → aynı kombinasyon tekrar kullanılınca YENİDEN derlenir →
|
|
||||||
30sn. "Zorlayınca" = çok varyant üretmek = cache'i doldurmak = tekrar derleme.
|
|
||||||
- Bu, "ilk kullanımda" + "sonra tekrar" imzasını birlikte açıklayan hipotez.
|
|
||||||
|
|
||||||
### Aday I — Intel HD 3000 GL sync / doku upload darboğazı ⭐⭐
|
|
||||||
|
|
||||||
- Sandy Bridge iGPU paylaşımlı bellek kullanır; büyük doku upload + ColorFiltered
|
|
||||||
+ Transform her frame'de GL sync bekleyebilir. `_markRenderDirty` throttle frame
|
|
||||||
üretimini sınırlar ama **raster thread** yavaş kalırsa UI thread frame kuyruğunu
|
|
||||||
tüketemez → 30sn'lik görünür donma (olaylar işlenir, görsel gecikir — önceki
|
|
||||||
raporlardaki imza).
|
|
||||||
- Doku boyutuna duyarlılık (1MB+ görsel) + fill/fit farkı (BoxFit.cover kırpar →
|
|
||||||
daha küçük etkin doku; contain → tam doku işlenir) bu hipotezle uyumlu.
|
|
||||||
- Intel HD 3000'de `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe) ile karşılaştırma testi
|
|
||||||
ayırt edici: GL sync sorunu ise software'de farklı davranır.
|
|
||||||
|
|
||||||
## 4. Hipotez Önceliklendirme
|
|
||||||
|
|
||||||
| Aday | Açıkladığı | Gücü |
|
|
||||||
|---|---|---|
|
|
||||||
| A (ColorFiltered her frame) | Efekt+pan takılması, "kilitlenme" hissi | ⭐⭐ (efekt açıkken kaçınılmaz maliyet; azaltılabilir) |
|
|
||||||
| B (`_clamp` findRenderObject) | Ek pan yükü | ⭐ (kolay düzeltilir) |
|
|
||||||
| C (bellek/GC) | "Olmadık zamanlarda" rastgelelik | ⭐⭐ (makine belleği bilinmiyor) |
|
|
||||||
| D (ortam/eski binary) | Tamamen farklı kök | ⭐ (önce doğrulanmalı) |
|
|
||||||
|
|
||||||
## 5. Önerilen Çözüm Yolları (rapor onayı sonrası)
|
|
||||||
|
|
||||||
### Seçenek 1 — Pan sırasında filtre maliyetini azalt (Aday A)
|
|
||||||
|
|
||||||
- Efekt aktifken pan/zoom sırasında `FilterQuality.high` → `FilterQuality.medium`
|
|
||||||
(görsel kalite farkı minimal, llvmpipe yükü düşer) — tek satır.
|
|
||||||
- Pan sırasında `cacheWidth`'i düşürme (memoize zaten sabit tutuyor) — gerek yok.
|
|
||||||
- **Alternatif:** pan sırasında geçici düşük çözünürlük önizlemesi (mevcut görseli
|
|
||||||
küçük boyutta filtrele) — karmaşık, önce basit yol denenir.
|
|
||||||
|
|
||||||
### Seçenek 2 — `_clamp()` içindeki `findRenderObject` yükünü azalt (Aday B)
|
|
||||||
|
|
||||||
- `_imageKey.currentContext?.findRenderObject()` yalnızca fit modunda lazım; pan'da
|
|
||||||
`_vpSize` değişmediği sürece tekrar sorgulamaya gerek yok — `_clamp`'in render
|
|
||||||
sorgusunu her pan event'inde değil, sadece layout değişince yap. (Dikkatli:
|
|
||||||
rotate varken `_clamp` zaten erken return ediyor; fit modunda kilitlenme riski yok.)
|
|
||||||
|
|
||||||
### Seçenek 3 — Ortam doğrulaması (Aday D) — rapor öncesi şart
|
|
||||||
|
|
||||||
- "Diğer bilgisayar"da: `glxinfo -B | grep -i renderer` (llvmpipe mi GPU mu),
|
|
||||||
`free -h`, kurulu sürüm (`.deb` tarihi / `imajviewer --version` yoksa binary
|
|
||||||
timestamp). Ayrıca aynı görselle **bu VM'de** tekrar dener misin — nüks VM'de
|
|
||||||
de mi, sadece diğer makinede mi?
|
|
||||||
|
|
||||||
## 6. Kalan Açık Sorular
|
|
||||||
|
|
||||||
1. "Diğer bilgisayar" hangisi — başka VM mi, gerçek makine mi? Donanım GPU'su var mı
|
|
||||||
(`glxinfo -B` çıktısı)?
|
|
||||||
2. O makinede kurulu sürüm: son `.deb` (2026-08-06 sonrası) mi, yoksa eski mi?
|
|
||||||
3. Kilitlenme süresi ne kadar — 1-2sn mi, 10sn+ mı, yoksa "kapatmak zorunda kaldım" mı?
|
|
||||||
4. Hangi efekt uygulanmışken pan yapıyorsun — contrast mi, rotate mi (rotate en ağırı)?
|
|
||||||
5. Görsel boyutu ne — büyük fotoğraf mı (4000px+), küçük ekran görüntüsü mü?
|
|
||||||
6. Aynı görselle bu VM'de (llvmpipe) pan dener misin — nüks VM'de de oluyor mu?
|
|
||||||
|
|
||||||
## 7. Ölçüm Planı (onaylanırsa)
|
|
||||||
|
|
||||||
Skill'deki `references/llvmpipe-perf-measurement.md` akışı: geçici
|
|
||||||
`addTimingsCallback` (FRAME build/raster süreleri) + `pidstat` CPU. Ölçüm sırasında
|
|
||||||
kullanıcı elle pan yapar ("sen aç ben yaparım"). Enstrümantasyon geçicidir ve
|
|
||||||
sonrası temizlenir. VM'de ölçüm yapılabilir; "diğer bilgisayar"da enstrümantasyonlu
|
|
||||||
build kurmak gerekir (kullanıcı onayı şart).
|
|
||||||
|
|
||||||
## 8. Sonuç
|
|
||||||
|
|
||||||
İki önceki çözüm (throttle + warmup) doğru teşhislere dayanıyordu ve ilk iki donma
|
|
||||||
imzasını çözdü. Yeni imza **farklı**: efekt SABİT iken pan. En güçlü açıklama
|
|
||||||
Aday A (ColorFiltered'in her pan frame'inde tam görseli CPU'da filtrelemesi) —
|
|
||||||
özellikle büyük görsel + zayıf makinede "kilitlenme" hissi yaratır. Rastgelelik için
|
|
||||||
Aday C (bellek/GC) ve Aday D (ortam) doğrulanmalı. Kullanıcı yanıtları gelince
|
|
||||||
rapor güncellenir ve çözüm yolları netleşir.
|
|
||||||
|
|
||||||
## 9. Kapanış Notu (2026-08-06)
|
|
||||||
|
|
||||||
Kullanıcı donma sırasında `top -b -d 1` ölçümü başlattıktan sonra **nüks olmadı**
|
|
||||||
(ne denediyse kilitlenme/takılma görülmedi). Nüks koşulları belirsiz; konu bu
|
|
||||||
raporla **askıda bırakıldı** — kullanıcı talep ederse yeniden açılır. Olası ipucu:
|
|
||||||
ölçüm sırasında sistem yükü değişince davranış değişti (Aday I — GL sync/GPU yükü
|
|
||||||
ile ilişkili olabilir; kesin teşhis yapılmadı).
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# Rapor — Sağ Tuş Efektlerinde Donma Analizi ve Slider UI Değerlendirmesi
|
|
||||||
|
|
||||||
Tarih: 2026-08-06
|
|
||||||
Durum: Analiz — B teşhisi netleşti, uygulama bekliyor
|
|
||||||
|
|
||||||
## 1. Semptom
|
|
||||||
|
|
||||||
- Sağ tuş ile kullanılan özelliklerde (contrast / saturation / brightness / rotate) uygulama **geçici olarak donuyor**.
|
|
||||||
- Donma süresi **15sn - 1dk arası** (kısa değil, ciddi bir tek seferlik maliyet).
|
|
||||||
- **Release build** kullanılıyor (.deb'den kurulu) → Dart JIT hipotezi elendi, donma release AOT'ta da var.
|
|
||||||
- Donma **kalıcı değil**: kullanıcı "kapatayım mı" diye soruyor, "bekle" deyince **kendiliğinden düzeliyor**.
|
|
||||||
- Donma sırasında yapılan işlemler (sağ tık + aşağı çekme, pan) donma açıldığında **yapılmış olarak görülüyor** — olaylar işleniyor, sadece görsel gecikiyor.
|
|
||||||
- **Sadece çok pencereli (multi-instance) kullanımda oluyor**, tek pencerede olmuyor.
|
|
||||||
- **Her pencere açılışında ilk efekt kullanımında bir kez oluyor; o donma açıldıktan sonra aynı pencerede tekrar olmuyor.**
|
|
||||||
- Eskiden sadece rotate'te olurdu; şimdi **tüm efektlerde** oluyor, en çok rotate'te.
|
|
||||||
- Donan pencere kilitli kalıyor; diğer pencereler normal çalışıyor. Task bardan kapatılabiliyor.
|
|
||||||
- Kullanıcı şüphesi: klavye tuşlarının (Ctrl/Shift) basılı tutulmasıyla ilgili olabilir — "farklı bir şey olma ihtimali yüksek".
|
|
||||||
- Soru: bu özellikleri **açılan kutu içinde slider UI** ile yapsak takılmalar çözülür mü?
|
|
||||||
|
|
||||||
## 2. Mevcut Mimari (lib/widgets/image_canvas.dart)
|
|
||||||
|
|
||||||
| Katman | Mekanizma |
|
|
||||||
|---|---|
|
|
||||||
| Mod seçimi | `_onPointerDown`: sağ tık + `_ctrlPressed`/`_shiftPressed` state'i → contrast (yok) / rotate (Shift) / saturation (Ctrl) / brightness (Ctrl+Shift) |
|
|
||||||
| Modifier takibi | `HardwareKeyboard.instance.addHandler(_onHardwareKey)` — global, `return false` (yutmaz) |
|
|
||||||
| Değer güncelleme | `_onPointerMove`: `dy * sensitivity` → `_markRenderDirty()` |
|
|
||||||
| Render eşitleme | `_renderTick` ValueNotifier + `scheduleFrameCallback` — kare başına max 1 rebuild |
|
|
||||||
| Image pipeline | `ResizeImage.resizeIfNeeded(cacheWidth: viewport×2)` + provider memoize (03 Ağu fix) |
|
|
||||||
| Efekt | `ColorFiltered` (birleşik 4x5 matrix, cache'li) + rotate için `Transform` |
|
|
||||||
| Varsayılan | `_isFilled = true` (BoxFit.cover), `FilterQuality.high` |
|
|
||||||
|
|
||||||
## 3. Kök Neden Analizi — Geliştirilmiş Teşhis (kullanıcı geri bildirimiyle)
|
|
||||||
|
|
||||||
Kullanıcının verdiği yeni bilgiler adayları eliyor:
|
|
||||||
|
|
||||||
| Gözlem | Hangi adayı eler/güçlendirir |
|
|
||||||
|---|---|
|
|
||||||
| Donma geçici, kendiliğinden düzeliyor | Aday A (kalıcı frame üretimi durması) **elendi** → B güçlendi |
|
|
||||||
| Donma sırasında yapılan işlemler sonradan görülüyor | UI thread olayları **işliyor**; sadece render/raster geride → Aday A elendi, B güçlendi |
|
|
||||||
| Sadece çok pencerede oluyor | Process başına soğuk kaynak → B güçlendi |
|
|
||||||
| Her pencerede İLK efekt kullanımında bir kez, sonra tekrar olmuyor | **İlk kullanım maliyeti** — shader derleme / kaynak ısınması → B1 (aşağıda) |
|
|
||||||
| Eskiden sadece rotate, şimdi tüm efektlerde | Efekt başına ilk-kullanım maliyeti → B1 |
|
|
||||||
| En çok rotate'te | Rotate en pahalı (Transform + ColorFiltered birlikte) → B |
|
|
||||||
|
|
||||||
### Teşhis: B1 — İlk Kullanım Render Maliyeti (shader derleme + kaynak ısınması) ⭐⭐
|
|
||||||
|
|
||||||
Semptom setinin tamamını açıklayan tek hipotez:
|
|
||||||
|
|
||||||
- Her pencere **ayrı process** (multi-instance). Her process'in kendi Skia/GL kaynakları **soğuk** başlar.
|
|
||||||
- İlk kez `ColorFiltered` (color matrix shader) ve/veya `Transform` (rotate) kullanıldığında llvmpipe **shader'ı derler + ilgili render path'i kurar** — bu tek seferlik maliyet 1-10sn+ sürebilir (yazılımsal render, 4 CPU çekirdeği).
|
|
||||||
- **Sonraki kullanımlarda shader cache'li** → donma tekrar etmez. "Bir kere oluyor, açıldıktan sonra tekrar olmuyor" = tam olarak bu.
|
|
||||||
- **Tüm efektlerde olması**: brightness (03 Ağu'da eklendi, yeni birleşik 4x5 matrix) dahil her efekt kendi shader'ını ilk kez derliyor. Eskiden sadece rotate'te olması: o günlerde contrast/saturation zaten ısınmış/kullanılmıştı; rotate her oturumda en son eklenen ve en pahalı olandı.
|
|
||||||
- **En çok rotate'te**: rotate iki shader path'ini birleştirir (ColorFiltered matrix + Transform composite) → en uzun ilk derleme.
|
|
||||||
- **Çok pencerede olması**: her pencere soğuk başlar → her pencerede ilk efekt kullanımında ayrı donma. Tek pencerede donma tek seferlik olduğu için "sorun yok" gibi görünür.
|
|
||||||
|
|
||||||
### Aday B — llvmpipe raster thread uzun kare (arka plan maliyeti)
|
|
||||||
|
|
||||||
- Donma anında UI thread çalışıyor (işlemler işleniyor) ama görsel gecikiyor → raster/kompozisyon kuyruğu geride.
|
|
||||||
- llvmpipe'ta her kare 15-50ms (03 Ağu ölçümü); ilk shader derlemesi bu kareleri 100x şişirir.
|
|
||||||
- B1'in "ilk kullanım" tetikleyicisi, B'nin "uzun kare" mekanizması — ikisi aynı olayın iki yüzü: **soğuk kaynakla ilk efekt frame'i çok pahalı**.
|
|
||||||
|
|
||||||
### Aday C — Bellek baskısı / swap thrashing
|
|
||||||
|
|
||||||
- Çok pencerede olması C'yi güçlendirir (her process ayrı decode + doku). Ama "tekrar olmuyor" imzası C ile tam uymuyor (bellek baskısı her seferinde olur). İkincil faktör olabilir.
|
|
||||||
|
|
||||||
### Aday D — `_clamp()` build sırasında `findRenderObject()`
|
|
||||||
|
|
||||||
- Donma yapmaz (null döner), performans etkisi minimal. Kırmızı ringa.
|
|
||||||
|
|
||||||
## 4. Klavye Hipotezi Değerlendirmesi (kullanıcının şüphesi)
|
|
||||||
|
|
||||||
**Donmaya klavye neden olmuyor.** `_onHardwareKey` handler'ı sadece iki boolean set/clear ediyor — maliyeti ihmal edilebilir, render tetiklemiyor, event yutmuyor. Kullanıcının "farklı bir şey" sezgisi doğru.
|
|
||||||
|
|
||||||
Ancak klavye **yanlış mod tetikleme** (takılma hissi) üretebilir:
|
|
||||||
|
|
||||||
- X11'de Shift durumu pointer event sırasında güvenilmez (skill'de belgeli: `isShiftPressed` false dönebilir). `_onHardwareKey` ile takip doğru çözüm ama yine de key repeat / focus kaybı durumlarında state bozulabilir.
|
|
||||||
- Shift basılıyken sağ tık → kullanıcı farkında olmadan **rotate** modu açılır → en pahalı efekt + beklenmedik davranış → "takılıyor" hissi.
|
|
||||||
- Alt + tık XFCE WM tarafından yutulur (bilinen pitfall) — zaten Ctrl/Ctrl+Shift'e geçilmişti.
|
|
||||||
|
|
||||||
## 5. Slider UI Değerlendirmesi (kullanıcının önerisi)
|
|
||||||
|
|
||||||
**Kısa cevap: B1 (ilk kullanım shader derlemesi) teşhisi doğruysa, slider UI donmayı ÇÖZMEZ — çünkü donma render maliyetinden değil, ilk kullanımdaki tek seferlik shader derlemesinden kaynaklanıyor.** Slider ile contrast'ı ilk kez açtığın anda yine aynı shader derlenecek ve yine aynı donma olacak. Ama doğru tasarımla yine de değerli:
|
|
||||||
|
|
||||||
| Bileşen | Slider UI etkisi |
|
|
||||||
|---|---|
|
|
||||||
| İlk kullanım shader derlemesi (B1) | **Çözmez** — shader yine o an derlenir. Çözüm warmup (aşağıda) |
|
|
||||||
| Olay hızı | **Aynı kalır** — slider da sürüklerken `onChanged` pointer hızında çağrılır. Ama ilk derleme bittikten sonra gerisi akıcıdır |
|
|
||||||
| Mod bilinirliği | **Çözer** — hangi modda olduğun görünür; gizli modifier mapping'i biter. "Takılıyor" hissinin büyük kısmı modun ne olduğunu bilmemektir |
|
|
||||||
| Klavye bağımlılığı | **Tamamen kaldırır** — `HardwareKeyboard` handler'ına, X11 Shift güvenilmezliğine, Alt yutma pitfall'ına gerek kalmaz |
|
|
||||||
| Render maliyeti (ilk derleme sonrası) | **Ancak tasarımla düşer** — `onChangeEnd`'de uygula + sürükleme sırasında düşük çözünürlüklü önizleme (küçük cacheWidth) yapılırsa render başına maliyet ciddi düşer |
|
|
||||||
|
|
||||||
**Sonuç:** Slider UI tek başına "sihirli çözüm" değil. Donmanın asıl çözümü B1'e karşı: **shader warmup + ilk derlemeyi kullanıcı etkileşiminden önce yaptırmak**.
|
|
||||||
|
|
||||||
## 6. Önerilen Çözüm Yolları (B1 teşhisine göre)
|
|
||||||
|
|
||||||
### Seçenek 1 — Shader Warmup (ilk derlemeyi öne çek) ⭐ UYGULANDI (2026-08-06)
|
|
||||||
|
|
||||||
Açılışta, kullanıcı daha sağ tıka basmadan efekt shader'ları derletilir:
|
|
||||||
|
|
||||||
- `ImageCanvas.initState` → ilk frame sonrası `_startWarmup()`.
|
|
||||||
- Görünmez 1x1 gizli katman (`_buildWarmupLayer`): adım 1'de `ColorFiltered` (filtre shader'ı), adım 2'de `ColorFiltered` + `Transform` rotate (rotate shader'ı) render edilir; sonra `_warmupDone = true` ile katman kaldırılır.
|
|
||||||
- Kullanıcı ilk sağ tıka bastığında shader'lar hazırdır → donma hissi kaybolmalı.
|
|
||||||
- Maliyet: pencere açılışında 2-3 ekstra gizli frame (arka planda, etkileşim yokken).
|
|
||||||
- Not: çok pencerede her process kendi warmup'ını yapar — aynı mantık.
|
|
||||||
- Doğrulama: release build + kurulum yapıldı (2026-08-06). Kullanıcı test edecek — ilk sağ tık donması yoksa teşhis doğrulanır.
|
|
||||||
|
|
||||||
### Seçenek 2 — Release build ile test (JIT yükü teyidi)
|
|
||||||
|
|
||||||
- **GÜNCELLEME:** Kullanıcı zaten release build kullanıyor (.deb'den kurulu) — JIT hipotezi **elendi**. Donma release AOT'ta da oluyor.
|
|
||||||
- 15sn-1dk süre, llvmpipe'ta ilk render path kurulumunun (shader derleme + layer/doku hazırlığı) toplam maliyeti olabilir; "bir kez oluyor, tekrar olmuyor" imzası korunuyor.
|
|
||||||
|
|
||||||
### Seçenek 3 — Rotate maliyetini düşür
|
|
||||||
|
|
||||||
- Rotate aktifken `FilterQuality.high` → `FilterQuality.medium` (rotate sırasında görsel kalite farkı minimal, maliyet düşer).
|
|
||||||
- `Transform` + `ColorFiltered` aynı anda aktifken tek composite path önerilir (mevcut yapı zaten tek ColorFiltered kullanıyor).
|
|
||||||
|
|
||||||
### Seçenek 4 — Slider UI (UX netliği, donma çözümü değil)
|
|
||||||
|
|
||||||
- Donma B1'den kaynaklanıyorsa slider UI donmayı çözmez; ama mod bilinirliği + klavye bağımsızlığı sağlar.
|
|
||||||
- Warmup (Seçenek 1) uygulandıktan sonra slider UI istenirse eklenir — ikisi birlikte: donma yok + kullanım net.
|
|
||||||
|
|
||||||
## 7. Kalan Açık Sorular
|
|
||||||
|
|
||||||
1. Donma süresi yaklaşık ne kadar — 1-2sn mi, 10sn+ mi? (B1 shader derleme süresi tahminini doğrular)
|
|
||||||
2. Debug build mi kullanıyorsun (install.sh ile kurulan), yoksa release mi? → Seçenek 2'yi doğrular
|
|
||||||
3. Slider UI hâlâ isteniyor mu, yoksa önce donma çözümü mü? (Sıra: warmup → slider opsiyonel)
|
|
||||||
4. Warmup için "açılışta arkada 1-3sn hazırlık" kabul edilir mi? (Alternatif: ilk efekt kullanımında 1 kez donma kabul edilir mi?)
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Session Özeti — 2026-07-27
|
|
||||||
|
|
||||||
## Eklenen Özellikler
|
|
||||||
|
|
||||||
### Çalışan
|
|
||||||
| Özellik | Tetikleyici | Durum |
|
|
||||||
|---|---|---|
|
|
||||||
| Pan (sürükleme) | Sol tık | ✓ |
|
|
||||||
| Zoom (mouse tekeri) | Scroll | ✓ |
|
|
||||||
| Fill/Fit geçişi | Sol çift tık | ✓ |
|
|
||||||
| Contrast | Sağ tık + dikey sürükleme | ✓ (matrix row-major) |
|
|
||||||
| Saturation | Ctrl + sağ tık + dikey sürükleme | ✓ (desaturate + oversaturate) |
|
|
||||||
| Reset (tüm efektler) | Sağ çift tık | ✓ |
|
|
||||||
|
|
||||||
### Tamamlanmamış / İptal
|
|
||||||
| Özellik | Durum |
|
|
||||||
|---|---|
|
|
||||||
|| Rotate | Shift + Sağ tık + dikey sürükleme | ✓ (viewport merkezi, ~100px=90°) |
|
|
||||||
| Brightness | **Kaldırıldı** — hiç çalışmadı |
|
|
||||||
| Single-instance | **İptal** — kullanıcı istemedi. Bunun yerine StartupWMClass ile taskbar gruplama |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performans Durumu
|
|
||||||
|
|
||||||
- **Çıplak mod (pan + zoom)**: Süper hızlı ✓
|
|
||||||
- **ColorFiltered (contrast + saturation)**: Ciddi performans düşüşü ✗
|
|
||||||
- **Çözüm**: `hasFilter` mantığı eklendi. `_saturation == 1.0 && _contrast == 1.0` ise `ColorFiltered` widget'ı **hiç render edilmez**. Efekt uygulanınca devreye girer.
|
|
||||||
|- **Rotate**: Ayrı `Transform` katmanı, sadece `_angle != 0` iken render edilir. Sıfır ek yük.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Teknik Detaylar
|
|
||||||
|
|
||||||
### Color Matrix
|
|
||||||
- **Format**: Row-major 5x4 (column-major çalışmadı!)
|
|
||||||
- **Birleşik matrix**: `M_combined = M_contrast × M_saturation` (tek GPU pass)
|
|
||||||
- **Cache**: `_cachedFilter` ile değerler değişmediğinde yeniden oluşturulmaz
|
|
||||||
- **Lazy render**: `hasFilter` false ise `ColorFiltered` widget ağacına eklenmez
|
|
||||||
|
|
||||||
### Klavye Modifier Tracking
|
|
||||||
- `HardwareKeyboard.instance.addHandler(_onHardwareKey)` ile global seviyede
|
|
||||||
- `HardwareKeyboard.instance.isShiftPressed` / `isControlPressed` **çalışmıyor** (Linux bug'ı)
|
|
||||||
- Kendi `_ctrlPressed` / `_shiftPressed` state'imizi tuttuk
|
|
||||||
|
|
||||||
### install.sh
|
|
||||||
- Önce `build/linux/x64/debug/bundle/`, yoksa `release/bundle/` yolunu dener
|
|
||||||
|
|
||||||
### Gesture Sistemi
|
|
||||||
- `Listener` (sağ tık event'leri) + `GestureDetector` (sol tık pan/çift tık) hibrit yapısı
|
|
||||||
- `Listener.onPointerDown/Move/Up` ile manuel gesture handling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bilinen Sorunlar
|
|
||||||
|
|
||||||
1. **Zoom throttle kaldırıldı** — her scroll event'inde `setState`, bu da performansı etkiliyor olabilir
|
|
||||||
2. **Rotate düzeltildi** — viewport merkezi, lazy Transform. Pan/zoom ile uyumlu.
|
|
||||||
3. **BlendMode saturation oversaturate yapamıyor** — matrix'e geçildi (çalışıyor)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dosya Yapısı
|
|
||||||
|
|
||||||
| Dosya | Açıklama |
|
|
||||||
|---|---|
|
|
||||||
| `lib/widgets/image_canvas.dart` | Tüm görüntü işleme, filtreler, gesture'lar |
|
|
||||||
| `install.sh` | Kurulum scripti (debug/release otomatik seçim) |
|
|
||||||
| `docs/image-canvas-interactions.md` | Detaylı analiz raporu |
|
|
||||||
| `docs/index.md` | Dokümantasyon indeksi |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sonraki Session İçin
|
|
||||||
|
|
||||||
1. Single-instance implementasyonu (lock file / Unix socket)
|
|
||||||
2. Rotate'i düzeltip tekrar ekleme (performans ve pan yönü sorunları)
|
|
||||||
3. Zoom performans optimizasyonu (throttle veya GestureDetector.onScaleUpdate)
|
|
||||||
4. `.deb` ve Windows installer güncellemesi
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Session Özeti — 2026-08-03
|
|
||||||
|
|
||||||
## Yapılanlar
|
|
||||||
|
|
||||||
### 1. Ek Görüntü Formatları (jfif / jpe / wbmp)
|
|
||||||
- TIFF değerlendirildi, **vazgeçildi** (kullanıcı kararı — image paketi/decode katmanı istenmedi)
|
|
||||||
- AVIF değerlendirildi, **eklenmedi** (Flutter'da yerleşik decoder yok — native libavif gerekir, "ultra hafif" felsefeye aykırı)
|
|
||||||
- Eklendi: `.jfif`, `.jpe` (JPEG içerikli, Skia zaten açıyor), `.wbmp` (Skia WBMP codec)
|
|
||||||
- Değişen yerler: file_handler (seçici), image_manager (filtre), 2× .desktop + install.sh (MimeType)
|
|
||||||
|
|
||||||
### 2. Pencere Cascade Yerleşimi
|
|
||||||
- Yeni pencere artık öncekinin **x + width** kadar sağında açılıyor
|
|
||||||
- Ekrandan taşarsa → alt satıra, satır başından
|
|
||||||
- O da taşarsa → ekran sol-üst köşesine
|
|
||||||
- Ekran bilgisi: `screen_retriever` paketi (window_manager'ın zaten transitive bağımlısıydı, doğrudan bağımlılık yapıldı)
|
|
||||||
- Yeni: `WindowPersistence.cascadePosition()` — getPrimaryDisplay → visibleSize/visiblePosition
|
|
||||||
|
|
||||||
### 3. Pencere Konum Kaydı Düzeltmesi (kök neden)
|
|
||||||
- **Sorun:** Konum kaydedilemiyordu → çözüm olarak 5 yere kayıt koyulmuştu (initState + 2sn polling + move/resize debounce + close + main). Birden fazla pencere açıkken polling konum yarışı yaratıyordu → yeni pencere eski konuma göre hesaplanıp üstüne düşüyordu.
|
|
||||||
- **Çözüm:** Polling kaldırıldı. Tek akış:
|
|
||||||
1. `main.dart` — pencere açılışında konum anında saveSync (sıradaki pencere güncel referans okur)
|
|
||||||
2. `viewer_screen` move/resize — son bilinen bounds hemen `_lastBounds`'a, 300ms debounce ile saveSync
|
|
||||||
3. `viewer_screen` close — `_lastBounds` **senkron** saveSync (async çağrı kapanışta tamamlanmıyordu)
|
|
||||||
|
|
||||||
## Doğrulama
|
|
||||||
- `flutter analyze` — 18 info uyarı (mevcut kod stil/deprecated), hata yok
|
|
||||||
- `flutter build linux --release` ✓
|
|
||||||
- Kurulum: release bundle `~/.local/lib/imajviewer/` (install.sh debug bundle'ı önceliklendirdiği için elle kopyalandı)
|
|
||||||
- Kullanıcı manuel test: WebP açılıyor ✓, cascade düzgün çalışıyor ✓
|
|
||||||
|
|
||||||
## Not
|
|
||||||
- `docs/session-windows-ci.md` bozuk encoding (mojibake) — içerik git log'dan biliniyor, düzeltilmedi
|
|
||||||
- Windows setup.exe **yeniden derlenmedi** — sadece Linux build + .deb güncellendi (Windows CI zaten Gitea Actions'ta)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Session — Windows CI & Installer
|
|
||||||
|
|
||||||
**Tarih:** 2026-07-27
|
|
||||||
|
|
||||||
## Yapılanlar
|
|
||||||
|
|
||||||
1. **Gitea'dan son değişiklikler çekildi**
|
|
||||||
- `feat: rotate (Shift+sag tik), StartupWMClass taskbar gruplama`
|
|
||||||
- `feat: right-click contrast, Ctrl+saturation, Shift+rotate filters`
|
|
||||||
- `feat: use imajviewericon.png as app icon`
|
|
||||||
- `chore: reorganize dist/, remove release/, update install paths`
|
|
||||||
|
|
||||||
2. **Windows release yeniden derlendi**
|
|
||||||
- `flutter pub get` ile bağımlılıklar güncellendi
|
|
||||||
- `flutter build windows --release` ile yeni özellikleri (contrast, saturation, rotate) içeren binary oluşturuldu
|
|
||||||
- Yeni ikon (imajviewericon.png) build'e dahil edildi
|
|
||||||
|
|
||||||
3. **Inno Setup installer yeniden oluşturuldu**
|
|
||||||
- `dist/imajviewer_setup_1.0.0.exe` — 24 MB
|
|
||||||
- İçerir: imajviewer.exe + tüm DLL'ler + data dosyaları
|
|
||||||
- Dil desteği: Türkçe + İngilizce
|
|
||||||
- Dosya ilişkilendirmeleri: .png, .jpg, .jpeg, .webp, .bmp, .gif
|
|
||||||
- İsteğe bağlı masaüstü kısayolu
|
|
||||||
|
|
||||||
4. **Gitea CI workflow güncellendi** (`.gitea/workflows/build-windows.yml`)
|
|
||||||
- Flutter build adımı korundu
|
|
||||||
- Inno Setup kurulumu (`choco install innosetup`) eklendi
|
|
||||||
- `ISCC.exe` ile installer build adımı eklendi
|
|
||||||
- Release artifact'e hem build çıktısı hem de `dist/*.exe` eklendi
|
|
||||||
- Tag bazlı release job'ı eklendi (opsiyonel, `GITEA_TOKEN` secret gerektirir)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# ImajViewer — Pan Sınırlama & Zoom Merkezi Sorunu ✅ ÇÖZÜLDÜ
|
|
||||||
|
|
||||||
> **Tarih:** 2026-07-23
|
|
||||||
> **Dosya:** `lib/widgets/image_canvas.dart`
|
|
||||||
> **Durum:** ✅ Çözüldü
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nihai Çözüm
|
|
||||||
|
|
||||||
### 1. Zoom — `Matrix4.copy(m)..translate..scale..translate`
|
|
||||||
Cursor merkezli zoomAroundPoint. Hem fill hem fit modunda çalışır çünkü `localPosition` her zaman Listener'a göre doğru koordinat verir. `_currentScale` listener'da güncellenir.
|
|
||||||
|
|
||||||
### 2. `_clampAfterZoom` — Image offset + sadece boundary clamp
|
|
||||||
**Kritik:** Fit modunda (BoxFit.contain) Image, Center widget içinde **ortalanır**. Offset hesaplanarak image'ın viewport'taki gerçek sınırları bulunur:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
imgOffsetX = (viewportW - imageW) / 2 // fill modunda 0, fit modunda >0
|
|
||||||
imgLeft = tx + imgOffsetX * scale
|
|
||||||
imgRight = tx + (imgOffsetX + imageW) * scale
|
|
||||||
```
|
|
||||||
|
|
||||||
**SADECE** image viewport'tan büyükse clamp yapılır. Küçükse **dokunulmaz** — zoom-around-point merkezi korunur. Kullanıcı double-tap ile istediği zaman ortalamayı sıfırlayabilir (`Matrix4.identity()`).
|
|
||||||
|
|
||||||
### 3. `translate` — asla `setTranslationRaw` kullanma
|
|
||||||
`setTranslationRaw` matrisin translation sütununu ezer → zoom merkezini bozar. `translate(dx,dy)` ekleme yapar → zoom merkezi korunur.
|
|
||||||
|
|
||||||
### 4. Listener'da clamp YOK
|
|
||||||
Sadece `_currentScale` takip edilir. Pan → `boundaryMargin: EdgeInsets.zero`, Zoom → `_handleScroll` içinde `_clampAfterZoom`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Akış
|
|
||||||
|
|
||||||
```
|
|
||||||
Scroll → _handleScroll → zoomAroundPoint → _controller.value
|
|
||||||
↓
|
|
||||||
_clampAfterZoom()
|
|
||||||
├─ image > viewport → translate ile clamp
|
|
||||||
└─ image ≤ viewport → dokunma
|
|
||||||
|
|
||||||
Pan → InteractiveViewer → _controller.value (boundaryMargin sınırlar)
|
|
||||||
↓
|
|
||||||
_onTransformChanged → sadece _currentScale
|
|
||||||
```
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
# Windows Development Ortam Kurulum Rehberi
|
|
||||||
|
|
||||||
Bu doküman, ImajViewer Flutter projesini Windows üzerinde geliştirmek için gerekli ortamın sıfırdan kurulmasını adım adım anlatır.
|
|
||||||
|
|
||||||
## Gereksinimler
|
|
||||||
|
|
||||||
| Araç | Minimum Versiyon | Açıklama |
|
|
||||||
|------|-----------------|----------|
|
|
||||||
| Flutter SDK | 3.27+ (stable) | UI framework |
|
|
||||||
| Visual Studio 2022 | Build Tools veya Community | C++ derleyici (MSVC) |
|
|
||||||
| CMake | 3.14+ | Build sistemi |
|
|
||||||
| Inno Setup | 6.x | Windows installer (.exe) oluşturma |
|
|
||||||
|
|
||||||
## Adım 1: Flutter SDK Kurulumu
|
|
||||||
|
|
||||||
### Yöntem A: Resmi kurulum (Önerilen)
|
|
||||||
|
|
||||||
1. [Flutter SDK'yı indirin](https://docs.flutter.dev/get-started/install/windows)
|
|
||||||
2. ZIP'i `C:\src\flutter` dizinine çıkarın
|
|
||||||
3. PATH'e ekleyin:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# PowerShell (Admin)
|
|
||||||
[Environment]::SetEnvironmentVariable(
|
|
||||||
"PATH",
|
|
||||||
"$([Environment]::GetEnvironmentVariable("PATH", "Machine"))\;C:\src\flutter\bin",
|
|
||||||
"Machine"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Yeni terminalde doğrulayın:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
flutter doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
### Yöntem B: Winget ile kurulum
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
winget install --id OpenFlutter.FlutterSDK
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flutter doctor kontrolleri
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
flutter doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
Çıktıda `[✗]` işaretli bileşenler için önerilen adımları uygulayın. En azından şunlar `✓` olmalı:
|
|
||||||
|
|
||||||
- Flutter
|
|
||||||
- Android toolchain (opsiyonel, sadece mobile geliştirme için)
|
|
||||||
- Visual Studio (eğer kuruluysa)
|
|
||||||
- Connected device (Windows)
|
|
||||||
|
|
||||||
## Adım 2: Visual Studio 2022 Build Tools Kurulumu
|
|
||||||
|
|
||||||
Flutter Windows build'i için MSVC C++ derleyicisi gerekir.
|
|
||||||
|
|
||||||
### Seçenek A: Visual Studio Build Tools (Sadece build araçları)
|
|
||||||
|
|
||||||
1. [Visual Studio Build Tools 2022](https://visualstudio.microsoft.com/visual-cpp-build-tools/) indirin
|
|
||||||
2. Kurulum sihirbazında **"Desktop development with C++"** workload'unu işaretleyin
|
|
||||||
3. Aşağıdaki bileşenler otomatik seçilecektir:
|
|
||||||
- MSVC v143 - VS 2022 C++ x64/x86 build tools
|
|
||||||
- Windows 10/11 SDK
|
|
||||||
- CMake tools (opsiyonel ama önerilir)
|
|
||||||
|
|
||||||
### Seçenek B: Visual Studio Community (Tam IDE)
|
|
||||||
|
|
||||||
1. [Visual Studio Community 2022](https://visualstudio.microsoft.com/vs/community/) indirin
|
|
||||||
2. Kurulumda **"Desktop development with C++"** workload'unu seçin
|
|
||||||
|
|
||||||
### Doğrulama
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cl
|
|
||||||
```
|
|
||||||
|
|
||||||
Çıktıda `Microsoft (R) C/C++ Optimizing Compiler` görünmelidir.
|
|
||||||
|
|
||||||
## Adım 3: CMake Kurulumu
|
|
||||||
|
|
||||||
Visual Studio Build Tools ile birlikte gelir ancak bağımsız kurulum da yapılabilir.
|
|
||||||
|
|
||||||
### Winget ile:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
winget install --id Kitware.CMake
|
|
||||||
```
|
|
||||||
|
|
||||||
### Doğrulama:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cmake --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adım 4: Inno Setup 6 Kurulumu
|
|
||||||
|
|
||||||
Windows installer (.exe) oluşturmak için gereklidir.
|
|
||||||
|
|
||||||
1. [Inno Setup 6](http://www.jrsoftware.org/isdl.php#stable) indirin (`is.exe`)
|
|
||||||
2. Kurulumu tamamlayın (varsayılan: `C:\Program Files (x86)\Inno Setup 6`)
|
|
||||||
|
|
||||||
### Doğrulama:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adım 5: Projeyi Hazırlama
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd C:\projects\imajviewer
|
|
||||||
|
|
||||||
# Bağımlılıkları indir
|
|
||||||
flutter pub get
|
|
||||||
|
|
||||||
# Analiz et
|
|
||||||
flutter analyze
|
|
||||||
|
|
||||||
# Test çalıştır
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adım 6: Windows Build
|
|
||||||
|
|
||||||
### Debug build:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
flutter build windows --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
Çıktı: `build\windows\x64\runner\Debug\`
|
|
||||||
|
|
||||||
### Release build:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
flutter build windows --release
|
|
||||||
```
|
|
||||||
|
|
||||||
Çıktı: `build\windows\x64\runner\Release\`
|
|
||||||
|
|
||||||
## Adım 7: Installer Oluşturma (Inno Setup)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Release build yapıldıktan sonra
|
|
||||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" windows\installer.iss
|
|
||||||
```
|
|
||||||
|
|
||||||
Çıktı: `dist\imajviewer_setup_1.1.0.exe`
|
|
||||||
|
|
||||||
## Tek Komutla Her Şey
|
|
||||||
|
|
||||||
Tüm build + installer pipeline'ını tek komutla çalıştırın:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build-windows.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Bu script:
|
|
||||||
1. `flutter pub get`
|
|
||||||
2. `flutter build windows --release`
|
|
||||||
3. Inno Setup ile `.exe` installer oluşturur
|
|
||||||
|
|
||||||
## Sorun Giderme
|
|
||||||
|
|
||||||
### "Unable to find Visual Studio installation" hatası
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
flutter config --enable-windows-desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
Visual Studio 2022'nin doğru kurulduğunu `flutter doctor` ile kontrol edin.
|
|
||||||
|
|
||||||
### CMake hataları
|
|
||||||
|
|
||||||
CMake'in PATH'te olduğundan emin olun:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:PATH += ";C:\Program Files\CMake\bin"
|
|
||||||
cmake --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### Inno Setup bulunamadı
|
|
||||||
|
|
||||||
`build-windows.ps1` script'i otomatik arar. Manuel çalıştırmak için:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" windows\installer.iss
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flutter doctor'da Android SDK uyarısı
|
|
||||||
|
|
||||||
Sadece desktop geliştirme yapıyorsanız bu uyarıları görmezden gelebilirsiniz.
|
|
||||||
|
|
||||||
## Kurulum Sonrası
|
|
||||||
|
|
||||||
Ortam hazır olduğunda:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Build + installer
|
|
||||||
.\build-windows.ps1
|
|
||||||
|
|
||||||
# Manuel kurulum (build çıktılarından)
|
|
||||||
.\install.ps1
|
|
||||||
|
|
||||||
# Uninstall
|
|
||||||
.\uninstall.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sistem Gereksinimleri (Minimum)
|
|
||||||
|
|
||||||
- Windows 10 (1809) veya üzeri
|
|
||||||
- 4 GB RAM (8 GB önerilir)
|
|
||||||
- ~4 GB disk alanı (Flutter SDK + VS Build Tools + proje)
|
|
||||||
- x64 mimari
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
# Zoom / Pan / Clamp Mekanizması — Referans Doküman
|
|
||||||
|
|
||||||
**Dosya:** `lib/widgets/image_canvas.dart`
|
|
||||||
**Son güncelleme:** 23 Temmuz 2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Mimari Karar: Neden InteractiveViewer Yok?
|
|
||||||
|
|
||||||
Başlangıçta `InteractiveViewer` kullanılıyordu. Ancak InteractiveViewer'ın built-in pan sınırlaması (`boundaryMargin`) çocuk widget'ın **tam boyutuna** göre çalışır. Fit modda `Center` kullanılınca, Center widget'ı viewport boyutunda olduğu halde içindeki Image daha küçüktür. IV, Image'ın değil Center'ın kenarlarına göre sınırlama yapar → Image'ın etrafında boşluk kalır.
|
|
||||||
|
|
||||||
**Çözüm:** InteractiveViewer tamamen kaldırıldı. Yerine:
|
|
||||||
|
|
||||||
```
|
|
||||||
ClipRect → GestureDetector → Listener → Transform → Center/SizedBox → Image
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Transform`: zoom/pan uygular (`_tx`, `_ty`, `_sc`)
|
|
||||||
- `ClipRect`: taşan kısımları kırpar
|
|
||||||
- `GestureDetector`: pan (drag) algılar
|
|
||||||
- `Listener`: scroll (tekerlek) algılar
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Durum Değişkenleri
|
|
||||||
|
|
||||||
```dart
|
|
||||||
double _tx = 0; // viewport-space X kaydırma (piksel)
|
|
||||||
double _ty = 0; // viewport-space Y kaydırma (piksel)
|
|
||||||
double _sc = 1.0; // ölçek (1.0 = orijinal boyut)
|
|
||||||
bool _isFilled; // true=fill mod, false=fit mod
|
|
||||||
Size _vpSize; // LayoutBuilder'dan alınan viewport boyutu
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dönüşüm matrisi** (Transform widget'ına verilen):
|
|
||||||
|
|
||||||
```
|
|
||||||
M = T(_tx, _ty) · S(_sc)
|
|
||||||
```
|
|
||||||
|
|
||||||
Yani: child-space'deki (cx, cy) noktası → viewport-space: `vx = cx * _sc + _tx`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Widget Ağacı (build metodu, satır 111–154)
|
|
||||||
|
|
||||||
```
|
|
||||||
LayoutBuilder
|
|
||||||
ClipRect
|
|
||||||
GestureDetector
|
|
||||||
onDoubleTap → _toggleFillFit()
|
|
||||||
onPanUpdate → _onPanUpdate()
|
|
||||||
Listener
|
|
||||||
onPointerSignal → _handleScroll()
|
|
||||||
Transform(translate(_tx,_ty) · scale(_sc))
|
|
||||||
_isFilled ?
|
|
||||||
SizedBox.expand(Image.file(fit: BoxFit.cover)) // FILL
|
|
||||||
: Center(Image.file(fit: BoxFit.contain)) // FIT
|
|
||||||
```
|
|
||||||
|
|
||||||
- Viewport boyutu (`_vpSize`), `LayoutBuilder`'ın `constraints.biggest`'inden okunur (satır 115). Her build'te güncellenir. `context.findRenderObject()` kullanılmaz çünkü LayoutBuilder'ın verdiği constraints daha güvenilirdir.
|
|
||||||
- `_imageKey`, `Image.file` widget'ına bağlıdır. Sadece errorBuilder için kullanılır (clamping'te kullanılmaz).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Scroll Zoom Mekanizması (satır 50–71)
|
|
||||||
|
|
||||||
```
|
|
||||||
_handleScroll(PointerScrollEvent e)
|
|
||||||
```
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// 1. İmleci viewport-space'den child-space'e çevir
|
|
||||||
cx = (vx - _tx) / _sc
|
|
||||||
cy = (vy - _ty) / _sc
|
|
||||||
|
|
||||||
// 2. Yeni ölçek (her tick'te %10)
|
|
||||||
factor = 1.1
|
|
||||||
newSc = (_sc * (tekerlek_yönüne_göre factor veya 1/factor)).clamp(0.1, 10.0)
|
|
||||||
|
|
||||||
// 3. Zoom'u imleç merkezli uygula
|
|
||||||
_tx = _tx + cx * _sc - cx * newSc
|
|
||||||
_ty = _ty + cy * _sc - cy * newSc
|
|
||||||
_sc = newSc
|
|
||||||
```
|
|
||||||
|
|
||||||
**Formülün türetilmesi:** Zoom öncesi imleç altındaki child-space noktası `(cx, cy)`, viewport'ta `(vx, vy)` konumundadır. Zoom sonrası aynı noktanın yine `(vx, vy)`'de kalması için:
|
|
||||||
|
|
||||||
```
|
|
||||||
vx = _tx_old + cx * _sc_old (zoom öncesi)
|
|
||||||
vx = _tx_new + cx * _sc_new (zoom sonrası, aynı nokta)
|
|
||||||
→ _tx_new = vx - cx * _sc_new
|
|
||||||
→ _tx_new = (_tx_old + cx * _sc_old) - cx * _sc_new
|
|
||||||
→ _tx_new = _tx_old + cx * _sc_old - cx * _sc_new
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Pan Mekanizması (satır 75–80)
|
|
||||||
|
|
||||||
```
|
|
||||||
_onPanUpdate(DragUpdateDetails d)
|
|
||||||
```
|
|
||||||
|
|
||||||
```dart
|
|
||||||
_tx += d.delta.dx; // her drag update'te delta biriktir
|
|
||||||
_ty += d.delta.dy;
|
|
||||||
if (_isFilled) _clampFill(_viewport());
|
|
||||||
setState(() {});
|
|
||||||
```
|
|
||||||
|
|
||||||
`d.delta`, son update'ten beri olan değişimi verir (toplam değil). Her update'te delta eklenir, clamp yapılır, setState ile yeniden çizilir.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Clamp Sistemi — SADECE Fill Mod (satır 84–107)
|
|
||||||
|
|
||||||
Fit modda hiçbir sınırlama yoktur. Kullanıcı özgürce pan/zoom yapabilir.
|
|
||||||
|
|
||||||
### `_clampFill(Size vp)`
|
|
||||||
|
|
||||||
```
|
|
||||||
imgL = _tx → Image'ın viewport-space sol kenarı
|
|
||||||
imgR = _tx + vpw * _sc → Image'ın viewport-space sağ kenarı
|
|
||||||
vw = imgR - imgL → Image'ın viewport-space genişliği
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mantık:** Fill modda `SizedBox.expand(BoxFit.cover)` kullanıldığı için Image her zaman viewport'u tam doldurur. Yani Image'ın child-space genişliği = vpw. Ölçeklenmiş genişliği = `vpw * _sc`.
|
|
||||||
|
|
||||||
**Yatay sınırlama:**
|
|
||||||
```
|
|
||||||
if (vw > vpw + 0.5) // Image viewport'tan geniş mi?
|
|
||||||
if (imgL > 0) // sol kenar viewport içinde → sola çek
|
|
||||||
_tx -= imgL
|
|
||||||
else if (imgR < vpw) // sağ kenar viewport içinde → sağa çek
|
|
||||||
_tx += vpw - imgR
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dikey sınırlama:** Aynı mantık `imgT`/`imgB` ve `vph` ile.
|
|
||||||
|
|
||||||
**Epsilon (`0.5`):** Floating-point hatalarını önlemek için. Tam eşitlik durumunda (vw = vpw) hiçbir işlem yapılmaz.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Fill / Fit Geçişi (satır 39–45)
|
|
||||||
|
|
||||||
```
|
|
||||||
_toggleFillFit()
|
|
||||||
```
|
|
||||||
|
|
||||||
```dart
|
|
||||||
_isFilled = !_isFilled;
|
|
||||||
_tx = 0; _ty = 0; _sc = 1.0; // sıfırla
|
|
||||||
```
|
|
||||||
|
|
||||||
Çift tıklandığında:
|
|
||||||
- Mod değiştirilir
|
|
||||||
- Transform sıfırlanır (identity)
|
|
||||||
- Viewport cache'i sıfırlanır (sonraki build'te yeniden okunur)
|
|
||||||
|
|
||||||
Fill → Fit: Image Center içinde BoxFit.contain ile ortalanır, sınırlama yok.
|
|
||||||
Fit → Fill: Image SizedBox.expand içinde BoxFit.cover ile doldurur, sınırlama aktif.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Viewport Boyutu (satır 46, 115)
|
|
||||||
|
|
||||||
```dart
|
|
||||||
Size _viewport() => _vpSize;
|
|
||||||
```
|
|
||||||
|
|
||||||
`_vpSize` değeri `LayoutBuilder`'ın `builder` callback'inde `constraints.biggest` olarak alınır (satır 115). Her build'te güncellenir. Neden `context.findRenderObject()` değil?
|
|
||||||
|
|
||||||
- `LayoutBuilder`'ın constraints'i, widget'ın parent'tan aldığı en güncel boyut bilgisidir
|
|
||||||
- `context.findRenderObject()?.size` bazen null veya eski değer döndürebilir (henüz layout olmamışsa)
|
|
||||||
- `constraints.biggest` her zaman doğru ve günceldir
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Görüntü Boyutu Nereden Alınıyor?
|
|
||||||
|
|
||||||
**Fill mod:** `SizedBox.expand(BoxFit.cover)` → Image viewport'u tam doldurur. Görüntü boyutu = viewport boyutu. Clamp'ta `vp.width` ve `vp.height` kullanılır.
|
|
||||||
|
|
||||||
**Fit mod:** `Center(BoxFit.contain)` → Image fitted boyutta çizilir. Clamp OLMADIĞI için görüntü boyutuna ihtiyaç yoktur.
|
|
||||||
|
|
||||||
Eski versiyonlarda `_imageKey.currentContext?.findRenderObject()` ile render box okunuyordu. Bunun iki sorunu vardı:
|
|
||||||
1. `frameBuilder` içinde çağrılınca render object henüz oluşmamış olabiliyor → null veya 0 boyut dönüyor
|
|
||||||
2. `SizedBox.expand` içinde render box viewport boyutunu döndürüyor (fitted boyutu değil)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Özet: Hangi Fonksiyon Ne Yapıyor?
|
|
||||||
|
|
||||||
| Fonksiyon | Satır | Görevi |
|
|
||||||
|-----------|-------|--------|
|
|
||||||
| `_handleScroll()` | 50 | Mouse tekerleği ile zoom (imleç merkezli) |
|
|
||||||
| `_onPanUpdate()` | 75 | Sürükleme ile pan |
|
|
||||||
| `_clampFill()` | 84 | Fill modda kenar sınırlaması |
|
|
||||||
| `_toggleFillFit()` | 39 | Fill/Fit geçişi ve sıfırlama |
|
|
||||||
| `_resetView()` | 33 | Yeni resim açıldığında sıfırlama |
|
|
||||||
| `_viewport()` | 46 | Viewport boyutunu döndürür |
|
|
||||||
| `build()` | 112 | Widget ağacını kurar, `_vpSize`'ı günceller |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Widget Tree (görsel)
|
|
||||||
|
|
||||||
```
|
|
||||||
ImageCanvas
|
|
||||||
└─ LayoutBuilder ─(constraints.biggest→_vpSize)─
|
|
||||||
└─ ClipRect
|
|
||||||
└─ GestureDetector (doubleTap, panUpdate)
|
|
||||||
└─ Listener (onPointerSignal→scroll)
|
|
||||||
└─ Transform [T(_tx,_ty)·S(_sc)]
|
|
||||||
└─ [_isFilled ? SizedBox.expand : Center]
|
|
||||||
└─ Image.file(key:_imageKey, fit:cover/contain)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Geçmiş / Alınan Dersler
|
|
||||||
|
|
||||||
1. **InteractiveViewer çıkarıldı** — boundaryMargin çocuk widget'ın tam boyutuna göre çalışıyor, Center içindeki Image'ın asıl boyutunu bilmiyor.
|
|
||||||
|
|
||||||
2. **Center yerine SizedBox.expand denenmesi** — `_baseContentRect` ile fitted boyut hesaplandı ama `_intrinsicSize` asenkron yükleniyor, ilk frame'lerde yanlış boyut kullanılıyordu.
|
|
||||||
|
|
||||||
3. **Render box'tan boyut okuma** — `frameBuilder` içinde render object henüz oluşmamış olabiliyor (build → layout sırası). Retry mekanizması eklendi ama güvenilir olmadı.
|
|
||||||
|
|
||||||
4. **Fit modda sınırlamanın tamamen kaldırılması** — En temiz çözüm. Center widget'ı zaten layout seviyesinde ortalar. Zoom sonrası kullanıcı istediği gibi pan yapabilir.
|
|
||||||
|
|
||||||
5. **translateByDouble / scaleByDouble hatası** — `w` parametresi `1` olmalıydı, `0` kullanılınca transform bozuldu. Eski `translate`/`scale` API'sine geri dönüldü.
|
|
||||||
|
|
||||||
6. **_vpSize = Size.zero sıfırlaması (2026-08-03 düzeltildi)** — `_resetView()` ve `_toggleFillFit()` içinde `_vpSize = Size.zero` vardı. Çift tıklama (fill/fit toggle) `setState` yerine yalnızca `_markRenderDirty()` çağırdığı için LayoutBuilder yeniden çalışmıyor, `_vpSize` sıfır kalıyordu → `_handleScroll` ve `_clamp` erken return edip zoom ölüyor, pan sınırsız oluyordu. Çözüm: metot içi sıfırlama kaldırıldı; `_vpSize` yalnızca LayoutBuilder'ın `constraints.biggest`'inden beslenir (alan tanımındaki ilk değer korunur). Linux'ta da mevcuttu, Windows testinde fark edildi.
|
|
||||||
@@ -11,8 +11,8 @@ fi
|
|||||||
INSTALL_DIR="${HOME}/.local/lib/${APP}"
|
INSTALL_DIR="${HOME}/.local/lib/${APP}"
|
||||||
BIN_LINK="${HOME}/.local/bin/${APP}"
|
BIN_LINK="${HOME}/.local/bin/${APP}"
|
||||||
DESKTOP_FILE="${HOME}/.local/share/applications/${APP}.desktop"
|
DESKTOP_FILE="${HOME}/.local/share/applications/${APP}.desktop"
|
||||||
ICON_DIR="${HOME}/.local/share/icons/hicolor/64x64/apps"
|
ICON_BASE="${HOME}/.local/share/icons/hicolor"
|
||||||
ICON_FILE="${ICON_DIR}/${APP}.png"
|
ICON_SIZES="16 22 24 32 48 64 128 256"
|
||||||
|
|
||||||
# ── Renkler ──
|
# ── Renkler ──
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; NC='\033[0m'
|
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
@@ -36,7 +36,7 @@ if [ ! -f "$BUNDLE_DIR/${APP}" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Kurulum dizinleri ──
|
# ── Kurulum dizinleri ──
|
||||||
mkdir -p "$INSTALL_DIR" "$(dirname "$BIN_LINK")" "$(dirname "$DESKTOP_FILE")" "$ICON_DIR"
|
mkdir -p "$INSTALL_DIR" "$(dirname "$BIN_LINK")" "$(dirname "$DESKTOP_FILE")"
|
||||||
|
|
||||||
# ── Binary + kütüphaneler ──
|
# ── Binary + kütüphaneler ──
|
||||||
info "Binary ve kütüphaneler kopyalanıyor…"
|
info "Binary ve kütüphaneler kopyalanıyor…"
|
||||||
@@ -47,37 +47,39 @@ chmod +x "$INSTALL_DIR/${APP}"
|
|||||||
ln -sf "$INSTALL_DIR/${APP}" "$BIN_LINK"
|
ln -sf "$INSTALL_DIR/${APP}" "$BIN_LINK"
|
||||||
ok "Symlink: $BIN_LINK → $INSTALL_DIR/${APP}"
|
ok "Symlink: $BIN_LINK → $INSTALL_DIR/${APP}"
|
||||||
|
|
||||||
# ── İkon (64×64 PNG) ──
|
# ── İkon (hicolor boyut seti: 16–256) ──
|
||||||
if [ ! -f "$ICON_FILE" ]; then
|
if [ -f "$SCRIPT_DIR/imajviewericon.png" ]; then
|
||||||
if [ -f "$SCRIPT_DIR/imajviewericon.png" ]; then
|
info "İkon boyutları üretiliyor (16–256)…"
|
||||||
info "İkon kopyalanıyor (64x64)…"
|
for s in $ICON_SIZES; do
|
||||||
|
d="${ICON_BASE}/${s}x${s}/apps"
|
||||||
|
mkdir -p "$d"
|
||||||
python3 -c "
|
python3 -c "
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
im = Image.open('$SCRIPT_DIR/imajviewericon.png')
|
im = Image.open('$SCRIPT_DIR/imajviewericon.png')
|
||||||
im.resize((64,64), Image.LANCZOS).save('$ICON_FILE')
|
(im if $s == 256 else im.resize(($s, $s), Image.LANCZOS)).save('$d/${APP}.png')
|
||||||
" && ok "İkon: $ICON_FILE" || err "İkon dönüştürme başarısız!"
|
" || err "İkon dönüştürme başarısız! ($s)"
|
||||||
else
|
done
|
||||||
err "imajviewericon.png bulunamadı!"
|
ok "İkon: ${ICON_BASE} (${ICON_SIZES} px)"
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
ok "İkon zaten mevcut"
|
err "imajviewericon.png bulunamadı!"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Hicolor tema indeksi (GTK ikon temasını tanısın) ──
|
# ── Hicolor tema indeksi (GTK ikon temasını tanısın) ──
|
||||||
if [ ! -f "${HOME}/.local/share/icons/hicolor/index.theme" ]; then
|
{
|
||||||
mkdir -p "${HOME}/.local/share/icons/hicolor/64x64/apps"
|
echo "[Icon Theme]"
|
||||||
cat > "${HOME}/.local/share/icons/hicolor/index.theme" << 'EOF'
|
echo "Name=Hicolor"
|
||||||
[Icon Theme]
|
echo "Comment=Fallback icon theme"
|
||||||
Name=Hicolor
|
dirs=""
|
||||||
Comment=Fallback icon theme
|
for s in $ICON_SIZES; do dirs="${dirs:+$dirs,}${s}x${s}/apps"; done
|
||||||
Directories=64x64/apps
|
echo "Directories=$dirs"
|
||||||
|
for s in $ICON_SIZES; do
|
||||||
[64x64/apps]
|
echo ""
|
||||||
Size=64
|
echo "[${s}x${s}/apps]"
|
||||||
Context=Applications
|
echo "Size=$s"
|
||||||
Type=Fixed
|
echo "Context=Applications"
|
||||||
EOF
|
echo "Type=Fixed"
|
||||||
fi
|
done
|
||||||
|
} > "${ICON_BASE}/index.theme"
|
||||||
|
|
||||||
# ── .desktop dosyası ──
|
# ── .desktop dosyası ──
|
||||||
info ".desktop dosyası oluşturuluyor…"
|
info ".desktop dosyası oluşturuluyor…"
|
||||||
@@ -113,4 +115,4 @@ echo -e "${GREEN} Bir PNG/JPG/WebP dosyasına çift tıklayın${NC}"
|
|||||||
echo -e "${GREEN} veya 'xdg-open <dosya>' ile test edin${NC}"
|
echo -e "${GREEN} veya 'xdg-open <dosya>' ile test edin${NC}"
|
||||||
echo -e "${GREEN}══════════════════════════════════════════${NC}"
|
echo -e "${GREEN}══════════════════════════════════════════${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Kaldırmak için: rm -rf ${INSTALL_DIR} ${BIN_LINK} ${DESKTOP_FILE} ${ICON_FILE}"
|
echo "Kaldırmak için: rm -rf \"$INSTALL_DIR\" \"$BIN_LINK\" \"$DESKTOP_FILE\" && rm -f ${ICON_BASE}/*x*/apps/${APP}.png"
|
||||||
@@ -4,11 +4,28 @@
|
|||||||
"sortBy": "Sort by",
|
"sortBy": "Sort by",
|
||||||
"sortByTime": "By time",
|
"sortByTime": "By time",
|
||||||
"sortByName": "By name",
|
"sortByName": "By name",
|
||||||
|
"sizeSection": "Size",
|
||||||
"appDescription": "Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.",
|
"appDescription": "Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.",
|
||||||
"shortcuts": "Shortcuts",
|
"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+O — open file\n• Ctrl+S — save visible area (JPEG 95)\n• Ctrl+R — flip horizontally (clears effects, press again to undo)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• F11 — toggle fullscreen\n• Esc — exit fullscreen\n• Scroll — zoom (10% per step)\n• Ctrl+scroll — fine zoom (1% per step)\n• Left-drag — pan\n• Double click — toggle fill/fit\n• Right double click — reset effects\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness",
|
||||||
"print": "Print",
|
"print": "Print",
|
||||||
|
"fileProperties": "File properties",
|
||||||
|
"propResolution": "Resolution",
|
||||||
|
"propFileSize": "File size",
|
||||||
|
"propFormat": "Format",
|
||||||
|
"propModified": "Modified",
|
||||||
|
"propPath": "Path",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"saveCrop": "Save visible area (JPEG 95)",
|
||||||
|
"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",
|
"dragDropHint": "Drag images here\nor press Ctrl+O to open",
|
||||||
"dropHere": "Drop",
|
"dropHere": "Drop",
|
||||||
"imageLoadError": "Failed to load image"
|
"imageLoadError": "Failed to load image"
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'By name'**
|
/// **'By name'**
|
||||||
String get sortByName;
|
String get sortByName;
|
||||||
|
|
||||||
|
/// No description provided for @sizeSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Size'**
|
||||||
|
String get sizeSection;
|
||||||
|
|
||||||
/// No description provided for @appDescription.
|
/// No description provided for @appDescription.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -137,7 +143,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @shortcutsList.
|
/// No description provided for @shortcutsList.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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+O — open file\n• Ctrl+S — save visible area (JPEG 95)\n• Ctrl+R — flip horizontally (clears effects, press again to undo)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• F11 — toggle fullscreen\n• Esc — exit fullscreen\n• Scroll — zoom (10% per step)\n• Ctrl+scroll — fine zoom (1% per step)\n• Left-drag — pan\n• Double click — toggle fill/fit\n• Right double click — reset effects\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'**
|
||||||
String get shortcutsList;
|
String get shortcutsList;
|
||||||
|
|
||||||
/// No description provided for @print.
|
/// No description provided for @print.
|
||||||
@@ -146,12 +152,78 @@ abstract class AppLocalizations {
|
|||||||
/// **'Print'**
|
/// **'Print'**
|
||||||
String get print;
|
String get print;
|
||||||
|
|
||||||
|
/// No description provided for @fileProperties.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'File properties'**
|
||||||
|
String get fileProperties;
|
||||||
|
|
||||||
|
/// No description provided for @propResolution.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Resolution'**
|
||||||
|
String get propResolution;
|
||||||
|
|
||||||
|
/// No description provided for @propFileSize.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'File size'**
|
||||||
|
String get propFileSize;
|
||||||
|
|
||||||
|
/// No description provided for @propFormat.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Format'**
|
||||||
|
String get propFormat;
|
||||||
|
|
||||||
|
/// No description provided for @propModified.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Modified'**
|
||||||
|
String get propModified;
|
||||||
|
|
||||||
|
/// No description provided for @propPath.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Path'**
|
||||||
|
String get propPath;
|
||||||
|
|
||||||
/// No description provided for @close.
|
/// No description provided for @close.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Close'**
|
/// **'Close'**
|
||||||
String get close;
|
String get close;
|
||||||
|
|
||||||
|
/// No description provided for @saveCrop.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save visible area (JPEG 95)'**
|
||||||
|
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.
|
/// No description provided for @dragDropHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sortByName => 'By name';
|
String get sortByName => 'By name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sizeSection => 'Size';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appDescription =>
|
String get appDescription =>
|
||||||
'Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.';
|
'Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.';
|
||||||
@@ -29,14 +32,50 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get shortcutsList =>
|
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+O — open file\n• Ctrl+S — save visible area (JPEG 95)\n• Ctrl+R — flip horizontally (clears effects, press again to undo)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• F11 — toggle fullscreen\n• Esc — exit fullscreen\n• Scroll — zoom (10% per step)\n• Ctrl+scroll — fine zoom (1% per step)\n• Left-drag — pan\n• Double click — toggle fill/fit\n• Right double click — reset effects\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get print => 'Print';
|
String get print => 'Print';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileProperties => 'File properties';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propResolution => 'Resolution';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propFileSize => 'File size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propModified => 'Modified';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propPath => 'Path';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get close => 'Close';
|
String get close => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveCrop => 'Save visible area (JPEG 95)';
|
||||||
|
|
||||||
|
@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
|
@override
|
||||||
String get dragDropHint => 'Drag images here\nor press Ctrl+O to open';
|
String get dragDropHint => 'Drag images here\nor press Ctrl+O to open';
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sortByName => 'İsme göre';
|
String get sortByName => 'İsme göre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sizeSection => 'Boyut';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appDescription =>
|
String get 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.';
|
'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.';
|
||||||
@@ -29,14 +32,49 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get shortcutsList =>
|
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+O — dosya aç\n• Ctrl+S — görünen alanı kaydet (JPEG 95)\n• Ctrl+R — yatay aynala (efektleri sıfırlar, tekrar basınca geri alır)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• F11 — tam ekran / normal geçişi\n• Esc — tam ekrandan çık\n• Kaydırma — yakınlaştır (tık başına %10)\n• Ctrl+kaydırma — ince yakınlaştırma (tık başına %1)\n• Sol tık sürükle — kaydır\n• Çift tık — doldur/sığdır geçişi\n• Sağ çift tık — efektleri sıfırla\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
|
@override
|
||||||
String get print => 'Yazdır';
|
String get print => 'Yazdır';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileProperties => 'Dosya özellikleri';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propResolution => 'Çözünürlük';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propFileSize => 'Dosya boyutu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propFormat => 'Biçim';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propModified => 'Değiştirilme';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get propPath => 'Yol';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get close => 'Kapat';
|
String get close => 'Kapat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveCrop => 'Görünen alanı kaydet (JPEG 95)';
|
||||||
|
|
||||||
|
@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
|
@override
|
||||||
String get dragDropHint =>
|
String get dragDropHint =>
|
||||||
'Resimleri buraya sürükleyin\nveya Ctrl+O ile açın';
|
'Resimleri buraya sürükleyin\nveya Ctrl+O ile açın';
|
||||||
|
|||||||
@@ -4,11 +4,28 @@
|
|||||||
"sortBy": "Sıralama",
|
"sortBy": "Sıralama",
|
||||||
"sortByTime": "Zamana göre",
|
"sortByTime": "Zamana göre",
|
||||||
"sortByName": "İsme göre",
|
"sortByName": "İsme göre",
|
||||||
|
"sizeSection": "Boyut",
|
||||||
"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.",
|
"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",
|
"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+O — dosya aç\n• Ctrl+S — görünen alanı kaydet (JPEG 95)\n• Ctrl+R — yatay aynala (efektleri sıfırlar, tekrar basınca geri alır)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• F11 — tam ekran / normal geçişi\n• Esc — tam ekrandan çık\n• Kaydırma — yakınlaştır (tık başına %10)\n• Ctrl+kaydırma — ince yakınlaştırma (tık başına %1)\n• Sol tık sürükle — kaydır\n• Çift tık — doldur/sığdır geçişi\n• Sağ çift tık — efektleri sıfırla\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",
|
"print": "Yazdır",
|
||||||
|
"fileProperties": "Dosya özellikleri",
|
||||||
|
"propResolution": "Çözünürlük",
|
||||||
|
"propFileSize": "Dosya boyutu",
|
||||||
|
"propFormat": "Biçim",
|
||||||
|
"propModified": "Değiştirilme",
|
||||||
|
"propPath": "Yol",
|
||||||
"close": "Kapat",
|
"close": "Kapat",
|
||||||
|
"saveCrop": "Görünen alanı kaydet (JPEG 95)",
|
||||||
|
"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",
|
"dragDropHint": "Resimleri buraya sürükleyin\nveya Ctrl+O ile açın",
|
||||||
"dropHere": "Bırakın",
|
"dropHere": "Bırakın",
|
||||||
"imageLoadError": "Resim yüklenemedi"
|
"imageLoadError": "Resim yüklenemedi"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ void main(List<String> args) async {
|
|||||||
|
|
||||||
final windowOptions = WindowOptions(
|
final windowOptions = WindowOptions(
|
||||||
size: saved?.size ?? defaultSize,
|
size: saved?.size ?? defaultSize,
|
||||||
minimumSize: const Size(400, 300),
|
minimumSize: const Size(160, 100),
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
titleBarStyle: TitleBarStyle.hidden,
|
titleBarStyle: TitleBarStyle.hidden,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'package:desktop_drop/desktop_drop.dart';
|
import 'package:desktop_drop/desktop_drop.dart';
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
|
import '../services/image_header.dart';
|
||||||
import '../services/image_manager.dart';
|
import '../services/image_manager.dart';
|
||||||
import '../services/file_handler.dart';
|
import '../services/file_handler.dart';
|
||||||
|
import '../services/crop_save_service.dart';
|
||||||
import '../widgets/custom_title_bar.dart';
|
import '../widgets/custom_title_bar.dart';
|
||||||
import '../widgets/image_canvas.dart';
|
import '../widgets/image_canvas.dart';
|
||||||
import '../widgets/window_resize_zones.dart';
|
import '../widgets/window_resize_zones.dart';
|
||||||
|
import '../widgets/window_size_selector.dart';
|
||||||
import '../shortcuts/app_shortcuts.dart';
|
import '../shortcuts/app_shortcuts.dart';
|
||||||
import '../services/window_persistence.dart';
|
import '../services/window_persistence.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
const appVersion = '1.9.0';
|
||||||
|
|
||||||
class ViewerScreen extends StatefulWidget {
|
class ViewerScreen extends StatefulWidget {
|
||||||
final List<String> initialFiles;
|
final List<String> initialFiles;
|
||||||
const ViewerScreen({super.key, this.initialFiles = const []});
|
const ViewerScreen({super.key, this.initialFiles = const []});
|
||||||
@@ -28,6 +34,8 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
bool _controlsVisible = false;
|
bool _controlsVisible = false;
|
||||||
Timer? _saveDebounce;
|
Timer? _saveDebounce;
|
||||||
Rect _lastBounds = Rect.zero;
|
Rect _lastBounds = Rect.zero;
|
||||||
|
final ValueNotifier<double> _zoomPercent = ValueNotifier<double>(100);
|
||||||
|
final GlobalKey<ImageCanvasState> _canvasKey = GlobalKey<ImageCanvasState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -38,7 +46,10 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
if (widget.initialFiles.isNotEmpty) {
|
if (widget.initialFiles.isNotEmpty) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
FileHandler.loadImagesFromPaths(widget.initialFiles);
|
FileHandler.loadImagesFromPaths(widget.initialFiles);
|
||||||
|
_updateTitle();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateTitle());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +58,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
windowManager.removeListener(this);
|
windowManager.removeListener(this);
|
||||||
ImageManager.instance.removeListener(_onImageManagerChanged);
|
ImageManager.instance.removeListener(_onImageManagerChanged);
|
||||||
_saveDebounce?.cancel();
|
_saveDebounce?.cancel();
|
||||||
|
_zoomPercent.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +89,18 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
|
|
||||||
void _onImageManagerChanged() {
|
void _onImageManagerChanged() {
|
||||||
setState(() => _hasImages = ImageManager.instance.images.isNotEmpty);
|
setState(() => _hasImages = ImageManager.instance.images.isNotEmpty);
|
||||||
|
_updateTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Taskbar/alt-tab başlığı = açılan dosyanın adı (görsel yoksa uygulama adı).
|
||||||
|
Future<void> _updateTitle() async {
|
||||||
|
final image = ImageManager.instance.currentImagePath;
|
||||||
|
final name = image.isEmpty ? 'imajViewer' : p.basename(image);
|
||||||
|
try {
|
||||||
|
await windowManager.setTitle(name);
|
||||||
|
} catch (_) {
|
||||||
|
// Başlık güncellenemezse sessizce geç.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doSave() async {
|
Future<void> _doSave() async {
|
||||||
@@ -103,6 +127,36 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (_hasImages) ...[
|
||||||
|
Text(AppLocalizations.of(context)!.fileProperties,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
FutureBuilder<List<(String, String)>>(
|
||||||
|
future: _fileProperties(ImageManager.instance.currentImagePath),
|
||||||
|
builder: (context, snap) {
|
||||||
|
if (!snap.hasData) return const SizedBox(height: 20);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: snap.data!.map((r) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(children: [
|
||||||
|
TextSpan(text: '${r.$1}: ', style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||||
|
TextSpan(text: r.$2, style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(color: Colors.white24, height: 24),
|
||||||
|
],
|
||||||
|
WindowSizeSelector(
|
||||||
|
workArea: _workArea,
|
||||||
|
onPick: _applyWindowSize,
|
||||||
|
),
|
||||||
|
const Divider(color: Colors.white24, height: 24),
|
||||||
Text(AppLocalizations.of(context)!.sortBy,
|
Text(AppLocalizations.of(context)!.sortBy,
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||||
RadioListTile<bool>(
|
RadioListTile<bool>(
|
||||||
@@ -129,8 +183,8 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
setDialogState(() {});
|
setDialogState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(color: Colors.white24, height: 24),
|
Text('imajViewer $appVersion',
|
||||||
const Text('imajViewer', style: TextStyle(color: Colors.white, fontSize: 15)),
|
style: const TextStyle(color: Colors.white, fontSize: 15)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.appDescription,
|
AppLocalizations.of(context)!.appDescription,
|
||||||
@@ -188,9 +242,107 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<(String, String)>> _fileProperties(String path) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final file = File(path);
|
||||||
|
final rows = <(String, String)>[];
|
||||||
|
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 (_) {
|
||||||
|
rows.add((l10n.propFileSize, '—'));
|
||||||
|
}
|
||||||
|
rows.add((l10n.propFormat, path.toLowerCase().split('.').last.toUpperCase()));
|
||||||
|
try {
|
||||||
|
rows.add((l10n.propModified, _fmtDate(file.statSync().modified)));
|
||||||
|
} catch (_) {
|
||||||
|
rows.add((l10n.propModified, '—'));
|
||||||
|
}
|
||||||
|
rows.add((l10n.propPath, path));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmtSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||||
|
double v = bytes / 1024;
|
||||||
|
var i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return '${v.toStringAsFixed(v >= 100 ? 0 : 1)} ${units[i]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmtDate(DateTime t) {
|
||||||
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
return '${t.year}-${two(t.month)}-${two(t.day)} ${two(t.hour)}:${two(t.minute)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _flipHorizontal() => _canvasKey.currentState?.toggleFlipH();
|
||||||
|
|
||||||
|
Future<Size?> _workArea() async {
|
||||||
|
final pos = await windowManager.getPosition();
|
||||||
|
return WindowPersistence.workAreaSize(windowPosition: pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyWindowSize(int rows, int cols, Size work) async {
|
||||||
|
try {
|
||||||
|
await windowManager.setSize(Size(work.width / cols, work.height / rows));
|
||||||
|
} catch (_) {
|
||||||
|
// Boyutlandırma başarısız olursa sessizce geç (buton saf eylem).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 vp = state.cropViewParams(source.width, source.height);
|
||||||
|
if (vp == null) {
|
||||||
|
message = l10n.cropFailed;
|
||||||
|
} else {
|
||||||
|
final outPath = await CropSaveService.saveCrop(
|
||||||
|
srcPath: path,
|
||||||
|
source: source,
|
||||||
|
vp: vp,
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppShortcuts(
|
return AppShortcuts(
|
||||||
|
onCropSave: _hasImages ? _cropSave : null,
|
||||||
|
onFlipH: _hasImages ? _flipHorizontal : null,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: WindowResizeZones(
|
body: WindowResizeZones(
|
||||||
@@ -223,7 +375,9 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: _hasImages
|
child: _hasImages
|
||||||
? ImageCanvas(
|
? ImageCanvas(
|
||||||
|
key: _canvasKey,
|
||||||
filePath: ImageManager.instance.currentImagePath,
|
filePath: ImageManager.instance.currentImagePath,
|
||||||
|
zoomPercent: _zoomPercent,
|
||||||
)
|
)
|
||||||
: Center(
|
: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -242,6 +396,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
),
|
),
|
||||||
CustomTitleBar(
|
CustomTitleBar(
|
||||||
onSettings: _showSettings,
|
onSettings: _showSettings,
|
||||||
|
onSaveCrop: _hasImages ? _cropSave : null,
|
||||||
visible: _controlsVisible,
|
visible: _controlsVisible,
|
||||||
),
|
),
|
||||||
if (_hasImages && _controlsVisible) ...[
|
if (_hasImages && _controlsVisible) ...[
|
||||||
@@ -261,6 +416,36 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
onTap: () => ImageManager.instance.nextImage(),
|
onTap: () => ImageManager.instance.nextImage(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 12,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Center(
|
||||||
|
child: ValueListenableBuilder<double>(
|
||||||
|
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)
|
if (_isDragging)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
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)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Viewport -> tam cozunurluk kaynak eslemesi (image_canvas'tan gelir).
|
||||||
|
class CropViewParams {
|
||||||
|
final int w, h; // kaynak dosya boyutu
|
||||||
|
final double vw, vh; // viewport boyutu
|
||||||
|
final double tx, ty, sc; // pan/zoom
|
||||||
|
final double angleDeg; // donus (derece)
|
||||||
|
final bool flipH; // yatay aynalama (goruntunun kendisi degil, gorunum)
|
||||||
|
final double cx, cy; // donus merkezi (viewport ortasi)
|
||||||
|
final double k; // kaynak -> layout olcegi
|
||||||
|
final double ox, oy; // layout offset
|
||||||
|
const CropViewParams({
|
||||||
|
required this.w,
|
||||||
|
required this.h,
|
||||||
|
required this.vw,
|
||||||
|
required this.vh,
|
||||||
|
required this.tx,
|
||||||
|
required this.ty,
|
||||||
|
required this.sc,
|
||||||
|
required this.angleDeg,
|
||||||
|
this.flipH = false,
|
||||||
|
required this.cx,
|
||||||
|
required this.cy,
|
||||||
|
required this.k,
|
||||||
|
required this.ox,
|
||||||
|
required this.oy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class CropSaveService {
|
||||||
|
/// Tam cozunurlukte decode + EXIF yonunu gomme (engine goruntusuyle tutarli).
|
||||||
|
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.png` -> `resim_143025.jpg` (hep JPEG; cakismada null).
|
||||||
|
static String? buildOutputPath(String 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.jpg');
|
||||||
|
return File(candidate).existsSync() ? null : candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ekranda gorunen bolgeyi birebir (donus dahil) tam cozunurlukte kaydedir.
|
||||||
|
/// Duzlem: viewport -> (tx,ty,sc) -> (vp ortasi etrafinda donus) -> (k,ox,oy) -> kaynak px.
|
||||||
|
static Future<String> saveCrop({
|
||||||
|
required String srcPath,
|
||||||
|
required img.Image source,
|
||||||
|
required CropViewParams vp,
|
||||||
|
List<double>? matrix,
|
||||||
|
int jpegQuality = 95,
|
||||||
|
}) async {
|
||||||
|
final outPath = buildOutputPath(srcPath);
|
||||||
|
if (outPath == null) throw const CropSaveError('file_exists');
|
||||||
|
|
||||||
|
final out = vp.angleDeg == 0
|
||||||
|
? _cropAxisAligned(source, vp)
|
||||||
|
: _cropRotated(source, vp);
|
||||||
|
// Ekranda yatay aynalama acikken cikti da aynalanir (cikti = viewport'un birebir eslemesi).
|
||||||
|
if (vp.flipH) img.flipHorizontal(out);
|
||||||
|
// Çıktıda yön fiziksel — EXIF tag'ini temizle (çift dönüşü önler).
|
||||||
|
out.exif.imageIfd.orientation = null;
|
||||||
|
|
||||||
|
if (matrix != null) _applyMatrix(out, matrix);
|
||||||
|
|
||||||
|
final jpeg = img.JpegEncoder(quality: jpegQuality).encode(out);
|
||||||
|
await File(outPath).writeAsBytes(jpeg);
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Donus yok: gorulen dikdortgeni birebir kes (hizli yol).
|
||||||
|
static img.Image _cropAxisAligned(img.Image src, CropViewParams vp) {
|
||||||
|
double toX(double vx) => ((vx - vp.tx) / vp.sc - vp.ox) / vp.k;
|
||||||
|
double toY(double vy) => ((vy - vp.ty) / vp.sc - vp.oy) / vp.k;
|
||||||
|
final x0 = toX(0), x1 = toX(vp.vw), y0 = toY(0), y1 = toY(vp.vh);
|
||||||
|
final x = x0.round().clamp(0, src.width - 1);
|
||||||
|
final y = y0.round().clamp(0, src.height - 1);
|
||||||
|
final xr = x1.round().clamp(x + 1, src.width);
|
||||||
|
final yr = y1.round().clamp(y + 1, src.height);
|
||||||
|
return img.copyCrop(src, x: x, y: y, width: xr - x, height: yr - y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Donus var: ekran grid'ini kaynaga birebir ornekler (bilinear).
|
||||||
|
static img.Image _cropRotated(img.Image src, CropViewParams vp) {
|
||||||
|
final step = vp.sc * vp.k; // ekran px basina kaynak px
|
||||||
|
if (step <= 0) throw const CropSaveError('empty_rect');
|
||||||
|
final ow = (vp.vw / step).round().clamp(1, 1 << 14);
|
||||||
|
final oh = (vp.vh / step).round().clamp(1, 1 << 14);
|
||||||
|
final angle = vp.angleDeg * math.pi / 180;
|
||||||
|
final cos = math.cos(-angle), sin = math.sin(-angle);
|
||||||
|
|
||||||
|
(double, double) toSrc(double vx, double vy) {
|
||||||
|
final x = (vx - vp.tx) / vp.sc - vp.cx;
|
||||||
|
final y = (vy - vp.ty) / vp.sc - vp.cy;
|
||||||
|
return ((cos * x - sin * y + vp.cx - vp.ox) / vp.k,
|
||||||
|
(sin * x + cos * y + vp.cy - vp.oy) / vp.k);
|
||||||
|
}
|
||||||
|
|
||||||
|
final out = img.Image(width: ow, height: oh);
|
||||||
|
for (var y = 0; y < oh; y++) {
|
||||||
|
final vy = (y + 0.5) * step;
|
||||||
|
for (var x = 0; x < ow; x++) {
|
||||||
|
final s = toSrc((x + 0.5) * step, vy);
|
||||||
|
final c = _sampleBilinear(src, s.$1, s.$2);
|
||||||
|
out.setPixelRgb(x, y, c.$1, c.$2, c.$3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manuel bilinear ornekleme (package:image 4.x API'si).
|
||||||
|
static (int, int, int) _sampleBilinear(img.Image src, double fx, double fy) {
|
||||||
|
final x0 = fx.floor(), y0 = fy.floor();
|
||||||
|
final tx = (fx - x0).clamp(0.0, 1.0), ty = (fy - y0).clamp(0.0, 1.0);
|
||||||
|
final c00 = src.getPixelClamped(x0, y0), c10 = src.getPixelClamped(x0 + 1, y0),
|
||||||
|
c01 = src.getPixelClamped(x0, y0 + 1), c11 = src.getPixelClamped(x0 + 1, y0 + 1);
|
||||||
|
int lerp(num a, num b, double t) => (a + (b - a) * t).toInt();
|
||||||
|
return (
|
||||||
|
lerp(lerp(c00.r, c10.r, tx), lerp(c01.r, c11.r, tx), ty),
|
||||||
|
lerp(lerp(c00.g, c10.g, tx), lerp(c01.g, c11.g, tx), ty),
|
||||||
|
lerp(lerp(c00.b, c10.b, tx), lerp(c01.b, c11.b, tx), ty),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flutter'la ayni 0-255 alan/semantigindaki 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[14]).clamp(0, 255).round();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -69,8 +69,6 @@ class ImageManager extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
if (_currentIndex < 0) _currentIndex = 0;
|
if (_currentIndex < 0) _currentIndex = 0;
|
||||||
|
|
||||||
// Eski klasörün decode edilmiş görsellerini bırak; canlı görsel korunur
|
|
||||||
PaintingBinding.instance.imageCache.clear();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,28 +15,59 @@ class WindowPersistence {
|
|||||||
return File('$dir/window.json');
|
return File('$dir/window.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Görüntü ekran modu: true = pencereyi kapla (cover), false = sığdır (contain).
|
||||||
|
/// Anahtar yoksa/bozuksa true (eski davranış).
|
||||||
|
static bool get filled {
|
||||||
|
final v = _read()?['filled'];
|
||||||
|
return v is bool ? v : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sadece modu günceller; x/y/w/h korunur.
|
||||||
|
static void saveFilled(bool value) => _write({'filled': value});
|
||||||
|
|
||||||
/// Save window rect synchronously (safe to call before destroy).
|
/// Save window rect synchronously (safe to call before destroy).
|
||||||
static void saveSync({
|
static void saveSync({
|
||||||
required Offset position,
|
required Offset position,
|
||||||
required Size size,
|
required Size size,
|
||||||
}) {
|
}) {
|
||||||
final file = _configFile;
|
_write({
|
||||||
file.parent.createSync(recursive: true);
|
|
||||||
file.writeAsStringSync(jsonEncode({
|
|
||||||
'x': position.dx.toInt(),
|
'x': position.dx.toInt(),
|
||||||
'y': position.dy.toInt(),
|
'y': position.dy.toInt(),
|
||||||
'width': size.width.toInt(),
|
'width': size.width.toInt(),
|
||||||
'height': size.height.toInt(),
|
'height': size.height.toInt(),
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dosyayı oku (yok/bozuksa null).
|
||||||
|
static Map<String, dynamic>? _read() {
|
||||||
|
final file = _configFile;
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verilen anahtarları yaz; dosyadaki diğer anahtarlar korunur.
|
||||||
|
static void _write(Map<String, dynamic> values) {
|
||||||
|
try {
|
||||||
|
final file = _configFile;
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
final data = _read() ?? <String, dynamic>{};
|
||||||
|
data.addAll(values);
|
||||||
|
file.writeAsStringSync(jsonEncode(data));
|
||||||
|
} catch (_) {
|
||||||
|
// Disk hatası uygulamayı durdurmasın.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore saved window rect. Returns null if no saved config.
|
/// Restore saved window rect. Returns null if no saved config.
|
||||||
static ({Offset position, Size size})? restore() {
|
static ({Offset position, Size size})? restore() {
|
||||||
final file = _configFile;
|
final data = _read();
|
||||||
if (!file.existsSync()) return null;
|
if (data == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
|
||||||
final x = (data['x'] as num?)?.toDouble() ?? 0;
|
final x = (data['x'] as num?)?.toDouble() ?? 0;
|
||||||
final y = (data['y'] as num?)?.toDouble() ?? 0;
|
final y = (data['y'] as num?)?.toDouble() ?? 0;
|
||||||
final w = (data['width'] as num?)?.toDouble() ?? 1280;
|
final w = (data['width'] as num?)?.toDouble() ?? 1280;
|
||||||
@@ -53,6 +84,36 @@ class WindowPersistence {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Work area (taskbar excluded) of the display containing the window,
|
||||||
|
/// in logical px. Returns null if screen info is unavailable.
|
||||||
|
static Future<Size?> workAreaSize({
|
||||||
|
required Offset windowPosition,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final displays = await screenRetriever.getAllDisplays();
|
||||||
|
|
||||||
|
// Pencerenin bulunduğu monitörü bul (çoklu monitör desteği)
|
||||||
|
Display display = await screenRetriever.getPrimaryDisplay();
|
||||||
|
for (final d in displays) {
|
||||||
|
final pos = d.visiblePosition;
|
||||||
|
final size = d.visibleSize ?? d.size;
|
||||||
|
if (pos != null &&
|
||||||
|
windowPosition.dx >= pos.dx &&
|
||||||
|
windowPosition.dx < pos.dx + size.width &&
|
||||||
|
windowPosition.dy >= pos.dy &&
|
||||||
|
windowPosition.dy < pos.dy + size.height) {
|
||||||
|
display = d;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return display.visibleSize ?? display.size;
|
||||||
|
} catch (_) {
|
||||||
|
// Fallback: screen info unavailable → caller silently no-ops.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cascade: place new window to the right of the previous one.
|
/// Cascade: place new window to the right of the previous one.
|
||||||
/// Falls to the next row (row start) when it would overflow the screen,
|
/// Falls to the next row (row start) when it would overflow the screen,
|
||||||
/// and wraps to the top-left if the next row also overflows.
|
/// and wraps to the top-left if the next row also overflows.
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ import '../services/file_handler.dart';
|
|||||||
|
|
||||||
class AppShortcuts extends StatelessWidget {
|
class AppShortcuts extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final VoidCallback? onCropSave;
|
||||||
|
final VoidCallback? onFlipH;
|
||||||
|
|
||||||
const AppShortcuts({super.key, required this.child});
|
const AppShortcuts({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.onCropSave,
|
||||||
|
this.onFlipH,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -35,6 +42,25 @@ class AppShortcuts extends StatelessWidget {
|
|||||||
return KeyEventResult.handled;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+R: yatay aynala / geri al (fiziksel R: bazi klavye haritalarinda
|
||||||
|
// mantiksal tus T gibi farkli cozuluyor)
|
||||||
|
if (ctrl &&
|
||||||
|
(event.logicalKey == LogicalKeyboardKey.keyR ||
|
||||||
|
event.physicalKey == PhysicalKeyboardKey.keyR)) {
|
||||||
|
if (onFlipH != null) {
|
||||||
|
onFlipH!();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.logicalKey) {
|
switch (event.logicalKey) {
|
||||||
case LogicalKeyboardKey.arrowLeft:
|
case LogicalKeyboardKey.arrowLeft:
|
||||||
ImageManager.instance.previousImage();
|
ImageManager.instance.previousImage();
|
||||||
@@ -42,9 +68,10 @@ class AppShortcuts extends StatelessWidget {
|
|||||||
case LogicalKeyboardKey.arrowRight:
|
case LogicalKeyboardKey.arrowRight:
|
||||||
ImageManager.instance.nextImage();
|
ImageManager.instance.nextImage();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
|
case LogicalKeyboardKey.f11:
|
||||||
case LogicalKeyboardKey.escape:
|
case LogicalKeyboardKey.escape:
|
||||||
windowManager.isFullScreen().then((isFullscreen) {
|
windowManager.isFullScreen().then((isFullscreen) {
|
||||||
if (isFullscreen) windowManager.setFullScreen(false);
|
windowManager.setFullScreen(event.logicalKey == LogicalKeyboardKey.f11 ? !isFullscreen : false);
|
||||||
});
|
});
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import 'package:window_manager/window_manager.dart';
|
|||||||
|
|
||||||
class CustomTitleBar extends StatelessWidget {
|
class CustomTitleBar extends StatelessWidget {
|
||||||
final VoidCallback? onSettings;
|
final VoidCallback? onSettings;
|
||||||
|
final VoidCallback? onSaveCrop;
|
||||||
final bool visible;
|
final bool visible;
|
||||||
|
|
||||||
const CustomTitleBar({super.key, this.onSettings, this.visible = true});
|
const CustomTitleBar({super.key, this.onSettings, this.onSaveCrop, this.visible = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -43,6 +44,11 @@ class CustomTitleBar extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (onSaveCrop != null)
|
||||||
|
_BarButton(
|
||||||
|
icon: Icons.save_outlined,
|
||||||
|
onTap: onSaveCrop!,
|
||||||
|
),
|
||||||
_BarButton(
|
_BarButton(
|
||||||
icon: Icons.horizontal_rule,
|
icon: Icons.horizontal_rule,
|
||||||
onTap: () => windowManager.minimize(),
|
onTap: () => windowManager.minimize(),
|
||||||
|
|||||||
@@ -5,16 +5,22 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../l10n/app_localizations.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 {
|
class ImageCanvas extends StatefulWidget {
|
||||||
final String filePath;
|
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<double>? zoomPercent;
|
||||||
|
const ImageCanvas({super.key, required this.filePath, this.zoomPercent});
|
||||||
@override
|
@override
|
||||||
State<ImageCanvas> createState() => _ImageCanvasState();
|
State<ImageCanvas> createState() => ImageCanvasState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ImageCanvasState extends State<ImageCanvas> {
|
class ImageCanvasState extends State<ImageCanvas> {
|
||||||
bool _isFilled = true;
|
bool _isFilled = WindowPersistence.filled;
|
||||||
final GlobalKey _imageKey = GlobalKey();
|
final GlobalKey _imageKey = GlobalKey();
|
||||||
double _tx = 0, _ty = 0, _sc = 1.0;
|
double _tx = 0, _ty = 0, _sc = 1.0;
|
||||||
Size _vpSize = Size.zero;
|
Size _vpSize = Size.zero;
|
||||||
@@ -24,6 +30,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
double _contrast = 1.0;
|
double _contrast = 1.0;
|
||||||
double _saturation = 1.0;
|
double _saturation = 1.0;
|
||||||
double _brightness = 0;
|
double _brightness = 0;
|
||||||
|
bool _flipH = false;
|
||||||
|
|
||||||
// ── Gesture tracking ──
|
// ── Gesture tracking ──
|
||||||
String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation' | 'brightness'
|
String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation' | 'brightness'
|
||||||
@@ -34,11 +41,59 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
double _startBrightness = 0;
|
double _startBrightness = 0;
|
||||||
DateTime? _lastRightTapTime;
|
DateTime? _lastRightTapTime;
|
||||||
Offset _lastRightTapPos = Offset.zero;
|
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 ──
|
// ── Klavye modifier tracking ──
|
||||||
bool _ctrlPressed = false;
|
bool _ctrlPressed = false;
|
||||||
bool _shiftPressed = 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 ──
|
// ── Kare hızına eşitlenmiş render tick ──
|
||||||
final ValueNotifier<int> _renderTick = ValueNotifier(0);
|
final ValueNotifier<int> _renderTick = ValueNotifier(0);
|
||||||
bool _frameScheduled = false;
|
bool _frameScheduled = false;
|
||||||
@@ -120,8 +175,10 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_origSize = pixelSizeOfFile(widget.filePath);
|
||||||
HardwareKeyboard.instance.addHandler(_onHardwareKey);
|
HardwareKeyboard.instance.addHandler(_onHardwareKey);
|
||||||
_startWarmup();
|
_startWarmup();
|
||||||
|
_syncZoom();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// İlk kullanım shader derlemesini açılışa çek: gizli katmanda sırayla
|
/// İlk kullanım shader derlemesini açılışa çek: gizli katmanda sırayla
|
||||||
@@ -151,6 +208,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
|
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
|
||||||
_renderTick.dispose();
|
_renderTick.dispose();
|
||||||
|
_imageSizeStream?.removeListener(_imageSizeListener!);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,18 +237,43 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(ImageCanvas old) {
|
void didUpdateWidget(ImageCanvas old) {
|
||||||
super.didUpdateWidget(old);
|
super.didUpdateWidget(old);
|
||||||
if (old.filePath != widget.filePath) _resetView();
|
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() {
|
void _resetView() {
|
||||||
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true;
|
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = WindowPersistence.filled;
|
||||||
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
||||||
|
_flipH = false;
|
||||||
|
_usingFullRes = false;
|
||||||
|
_imageSize = Size.zero;
|
||||||
_markRenderDirty();
|
_markRenderDirty();
|
||||||
|
_syncZoom();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFillFit() {
|
void _toggleFillFit() {
|
||||||
_isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0;
|
_isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0;
|
||||||
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 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();
|
_markRenderDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +285,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
final double vy = _angle == 0 ? e.localPosition.dy : _vpSize.height / 2;
|
final double vy = _angle == 0 ? e.localPosition.dy : _vpSize.height / 2;
|
||||||
final double cx = (vx - _tx) / _sc;
|
final double cx = (vx - _tx) / _sc;
|
||||||
final double cy = (vy - _ty) / _sc;
|
final double cy = (vy - _ty) / _sc;
|
||||||
const double factor = 1.1;
|
final double factor = _ctrlPressed ? 1.01 : 1.1;
|
||||||
final double rawSc = _sc * (e.scrollDelta.dy < 0 ? factor : 1.0 / factor);
|
final double rawSc = _sc * (e.scrollDelta.dy < 0 ? factor : 1.0 / factor);
|
||||||
final double newSc = rawSc.clamp(1.0, 20.0);
|
final double newSc = rawSc.clamp(1.0, 20.0);
|
||||||
if (_angle == 0) {
|
if (_angle == 0) {
|
||||||
@@ -216,8 +299,10 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
_ty -= dh / 2;
|
_ty -= dh / 2;
|
||||||
}
|
}
|
||||||
_sc = newSc;
|
_sc = newSc;
|
||||||
|
_checkFullResSwap();
|
||||||
_clamp();
|
_clamp();
|
||||||
_markRenderDirty();
|
_markRenderDirty();
|
||||||
|
_syncZoom();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanUpdate(DragUpdateDetails d) {
|
void _onPanUpdate(DragUpdateDetails d) {
|
||||||
@@ -231,22 +316,8 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
if (e.buttons != kSecondaryMouseButton) return;
|
if (e.buttons != kSecondaryMouseButton) return;
|
||||||
|
|
||||||
_pointerDownPos = e.localPosition;
|
_pointerDownPos = e.localPosition;
|
||||||
|
_rightDragMoved = false;
|
||||||
final now = DateTime.now();
|
_rightPointerId = e.pointer;
|
||||||
if (_lastRightTapTime != null &&
|
|
||||||
now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) &&
|
|
||||||
(e.localPosition - _lastRightTapPos).distance < 20) {
|
|
||||||
_angle = 0;
|
|
||||||
_contrast = 1.0;
|
|
||||||
_saturation = 1.0;
|
|
||||||
_brightness = 0;
|
|
||||||
_interactionMode = 'none';
|
|
||||||
_markRenderDirty();
|
|
||||||
_lastRightTapTime = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_lastRightTapTime = now;
|
|
||||||
_lastRightTapPos = e.localPosition;
|
|
||||||
|
|
||||||
if (_ctrlPressed && _shiftPressed) {
|
if (_ctrlPressed && _shiftPressed) {
|
||||||
_interactionMode = 'brightness';
|
_interactionMode = 'brightness';
|
||||||
@@ -265,6 +336,10 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
|
|
||||||
void _onPointerMove(PointerMoveEvent e) {
|
void _onPointerMove(PointerMoveEvent e) {
|
||||||
if (e.buttons != kSecondaryMouseButton) return;
|
if (e.buttons != kSecondaryMouseButton) return;
|
||||||
|
if (!_rightDragMoved &&
|
||||||
|
(e.localPosition - _pointerDownPos).distance > _dragClickSlop) {
|
||||||
|
_rightDragMoved = true;
|
||||||
|
}
|
||||||
|
|
||||||
final dy = e.localPosition.dy - _pointerDownPos.dy;
|
final dy = e.localPosition.dy - _pointerDownPos.dy;
|
||||||
|
|
||||||
@@ -286,7 +361,30 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onPointerUp(PointerUpEvent e) {
|
void _onPointerUp(PointerUpEvent e) {
|
||||||
|
if (e.pointer != _rightPointerId) return;
|
||||||
|
_rightPointerId = null;
|
||||||
_interactionMode = 'none';
|
_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) ──
|
// ── Combined color filter (brightness × saturation × contrast) ──
|
||||||
@@ -296,6 +394,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
static const double _bLum = 0.0722;
|
static const double _bLum = 0.0722;
|
||||||
|
|
||||||
ColorFilter? _cachedFilter;
|
ColorFilter? _cachedFilter;
|
||||||
|
List<double>? _cachedMatrix;
|
||||||
double _cachedSat = -1;
|
double _cachedSat = -1;
|
||||||
double _cachedCon = -1;
|
double _cachedCon = -1;
|
||||||
double _cachedBri = double.nan;
|
double _cachedBri = double.nan;
|
||||||
@@ -362,26 +461,96 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// M_final = M_contrast * M_brightness * M_saturation
|
// M_final = M_contrast * M_brightness * M_saturation
|
||||||
_cachedFilter = ColorFilter.matrix(
|
final matrix =
|
||||||
_multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix));
|
_multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix);
|
||||||
|
_cachedMatrix = matrix;
|
||||||
|
_cachedFilter = ColorFilter.matrix(matrix);
|
||||||
return _cachedFilter!;
|
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;
|
FileImage? _cachedFileImage;
|
||||||
ImageProvider<Object>? _cachedProvider;
|
ImageProvider<Object>? _cachedProvider;
|
||||||
String? _cachedPath;
|
String? _cachedPath;
|
||||||
int? _cachedCacheWidth;
|
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() {
|
Widget _buildImage() {
|
||||||
final longest = _vpSize.longestSide;
|
final needFullRes = _usingFullRes;
|
||||||
final cacheWidth = longest > 0 ? (longest * _sc * 2).round() : null;
|
final effectiveWidth = needFullRes ? null : _downsampleTarget;
|
||||||
if (_cachedPath != widget.filePath || _cachedCacheWidth != cacheWidth) {
|
if (_cachedPath != widget.filePath || _cachedCacheWidth != effectiveWidth) {
|
||||||
_cachedPath = widget.filePath;
|
_cachedPath = widget.filePath;
|
||||||
_cachedCacheWidth = cacheWidth;
|
_cachedCacheWidth = effectiveWidth;
|
||||||
_cachedFileImage = FileImage(File(widget.filePath));
|
_cachedFileImage = FileImage(File(widget.filePath));
|
||||||
_cachedProvider = cacheWidth != null
|
_cachedProvider = effectiveWidth != null
|
||||||
? ResizeImage.resizeIfNeeded(cacheWidth, null, _cachedFileImage!)
|
? ResizeImage(
|
||||||
: null;
|
_cachedFileImage!,
|
||||||
|
width: effectiveWidth,
|
||||||
|
height: effectiveWidth,
|
||||||
|
policy: ResizeImagePolicy.fit,
|
||||||
|
)
|
||||||
|
: _cachedFileImage;
|
||||||
|
_readImageSize(_cachedProvider!);
|
||||||
}
|
}
|
||||||
final ImageProvider<Object> provider = _cachedProvider ?? _cachedFileImage!;
|
final ImageProvider<Object> provider = _cachedProvider ?? _cachedFileImage!;
|
||||||
return Image(
|
return Image(
|
||||||
@@ -398,7 +567,10 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_vpSize = constraints.biggest;
|
if (constraints.biggest != _vpSize) {
|
||||||
|
_vpSize = constraints.biggest;
|
||||||
|
_syncZoom();
|
||||||
|
}
|
||||||
_clamp();
|
_clamp();
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: ValueListenableBuilder<int>(
|
child: ValueListenableBuilder<int>(
|
||||||
@@ -427,12 +599,13 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_angle != 0) {
|
if (_angle != 0 || _flipH) {
|
||||||
imageContent = Transform(
|
imageContent = Transform(
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
transform: Matrix4.identity()
|
transform: Matrix4.identity()
|
||||||
..translate(vpCenterX, vpCenterY)
|
..translate(vpCenterX, vpCenterY)
|
||||||
..rotateZ(angleRad)
|
..rotateZ(angleRad)
|
||||||
|
..scale(_flipH ? -1.0 : 1.0, 1.0)
|
||||||
..translate(-vpCenterX, -vpCenterY),
|
..translate(-vpCenterX, -vpCenterY),
|
||||||
child: imageContent,
|
child: imageContent,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Ekran oranı buton ızgarası: 31 buton `a:b` (a dikeyi, b yatayı böler;
|
||||||
|
/// hedef boyut `W/b × H/a`). Sol-üst hücre (1:1) boş placeholder — 1:1 yok
|
||||||
|
/// (title bar çift tık zaten tam ekran).
|
||||||
|
///
|
||||||
|
/// Yerleşim: sabit 8 satır × 4 sütun (31-10: geniş/dar transpozesi
|
||||||
|
/// kaldırıldı — kullanıcı kararı).
|
||||||
|
class WindowSizeSelector extends StatelessWidget {
|
||||||
|
final Future<Size?> Function() workArea;
|
||||||
|
final void Function(int rows, int cols, Size work) onPick;
|
||||||
|
|
||||||
|
const WindowSizeSelector({
|
||||||
|
super.key,
|
||||||
|
required this.workArea,
|
||||||
|
required this.onPick,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(AppLocalizations.of(context)!.sizeSection,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [for (var b = 1; b <= 8; b++) _row(b)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Satır = `b` (1..8, yatay bölün); satır içinde `a` (1..4, dikey bölün).
|
||||||
|
Widget _row(int b) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (var a = 1; a <= 4; a++) _cell(a, b),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cell(int a, int b) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4, bottom: 4),
|
||||||
|
child: a == 1 && b == 1
|
||||||
|
? const SizedBox(width: 46, height: 26)
|
||||||
|
: GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final work = await workArea();
|
||||||
|
if (work != null) onPick(a, b, work);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 46,
|
||||||
|
height: 26,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white10,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'$a:$b',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,6 +150,14 @@ static void my_application_class_init(MyApplicationClass* klass) {
|
|||||||
static void my_application_init(MyApplication* self) {}
|
static void my_application_init(MyApplication* self) {}
|
||||||
|
|
||||||
MyApplication* my_application_new() {
|
MyApplication* my_application_new() {
|
||||||
|
// GTK3 ignores gtk_window_move under Wayland (window opens centered).
|
||||||
|
// Force X11 backend when an X server is available (XWayland) so window
|
||||||
|
// position/size persistence works.
|
||||||
|
if (g_getenv("WAYLAND_DISPLAY") != nullptr &&
|
||||||
|
g_getenv("DISPLAY") != nullptr) {
|
||||||
|
g_setenv("GDK_BACKEND", "x11", FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
// Set the program name to the application ID, which helps various systems
|
// Set the program name to the application ID, which helps various systems
|
||||||
// like GTK and desktop environments map this running application to its
|
// like GTK and desktop environments map this running application to its
|
||||||
// corresponding .desktop file. This ensures better integration by allowing
|
// corresponding .desktop file. This ensures better integration by allowing
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
||||||
@@ -209,10 +209,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.2"
|
version: "0.20.3"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -257,10 +257,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -273,10 +273,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
msix:
|
msix:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -462,10 +462,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -486,10 +486,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.3"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: imajviewer
|
name: imajviewer
|
||||||
description: "Ultra-lightweight image viewer for Linux and Windows."
|
description: "Ultra-lightweight image viewer for Linux and Windows."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.1.0+3
|
version: 1.9.0+20
|
||||||
|
|
||||||
msix_config:
|
msix_config:
|
||||||
display_name: ImajViewer
|
display_name: ImajViewer
|
||||||
@@ -24,6 +24,7 @@ dependencies:
|
|||||||
file_picker: ^8.0.0
|
file_picker: ^8.0.0
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
desktop_drop: ^0.7.1
|
desktop_drop: ^0.7.1
|
||||||
|
image: ^4.0.0
|
||||||
msix: ^3.18.0
|
msix: ^3.18.0
|
||||||
printing: ^5.13.4
|
printing: ^5.13.4
|
||||||
pdf: ^3.11.1
|
pdf: ^3.11.1
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:imajviewer/shortcuts/app_shortcuts.dart';
|
||||||
|
import 'package:imajviewer/widgets/image_canvas.dart';
|
||||||
|
|
||||||
|
/// Aynalama uygulandiginda render zincirinde negatif x olcekli bir Transform bulunur.
|
||||||
|
bool _mirrored(WidgetTester tester) => tester
|
||||||
|
.widgetList<Transform>(find.byType(Transform))
|
||||||
|
.any((t) => t.transform.entry(0, 0) < 0);
|
||||||
|
|
||||||
|
Future<void> _pressCtrlR(WidgetTester tester) async {
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyR);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyR);
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pressCtrlRWrongLayout(WidgetTester tester) async {
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyT,
|
||||||
|
physicalKey: PhysicalKeyboardKey.keyR);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyT,
|
||||||
|
physicalKey: PhysicalKeyboardKey.keyR);
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _app(GlobalKey<ImageCanvasState> key, String path, double w, double h) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: AppShortcuts(
|
||||||
|
onFlipH: () => key.currentState?.toggleFlipH(),
|
||||||
|
child: SizedBox(width: w, height: h, child: ImageCanvas(key: key, filePath: path)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
late String pngPath;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('flip_test');
|
||||||
|
pngPath = '${tmp.path}/a.png';
|
||||||
|
File(pngPath).writeAsBytesSync(img.encodePng(img.Image(width: 4, height: 2))!);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() => tmp.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
testWidgets('Ctrl+R yatay aynalamayi acar, tekrar basinca kapatir', (tester) async {
|
||||||
|
final key = GlobalKey<ImageCanvasState>();
|
||||||
|
await tester.pumpWidget(_app(key, pngPath, 400, 300));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(_mirrored(tester), isFalse, reason: 'baslangicta aynalama olmamali');
|
||||||
|
|
||||||
|
await _pressCtrlR(tester);
|
||||||
|
expect(_mirrored(tester), isTrue, reason: 'Ctrl+R sonrasi aynalama acik olmali');
|
||||||
|
|
||||||
|
await _pressCtrlR(tester);
|
||||||
|
expect(_mirrored(tester), isFalse, reason: 'ikinci Ctrl+R sonrasi eski haline donmeli');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Ctrl+R crop-save parametrelerine aynalamayi tasir', (tester) async {
|
||||||
|
final key = GlobalKey<ImageCanvasState>();
|
||||||
|
await tester.pumpWidget(_app(key, pngPath, 100, 50));
|
||||||
|
await tester.pump();
|
||||||
|
await _pressCtrlR(tester);
|
||||||
|
final vp = key.currentState!.cropViewParams(4, 2);
|
||||||
|
expect(vp?.flipH, isTrue, reason: 'crop-save aynalamayi almali');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('fiziksel R tusu mantiksal T olarak gelse de Ctrl+R calisir', (tester) async {
|
||||||
|
final key = GlobalKey<ImageCanvasState>();
|
||||||
|
await tester.pumpWidget(_app(key, pngPath, 400, 300));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await _pressCtrlRWrongLayout(tester);
|
||||||
|
expect(_mirrored(tester), isTrue,
|
||||||
|
reason: 'klavye haritasi R tusunu T olarak cozse bile aynalama acilmali');
|
||||||
|
|
||||||
|
await _pressCtrlRWrongLayout(tester);
|
||||||
|
expect(_mirrored(tester), isFalse, reason: 'tekrar basinca geri donmeli');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:imajviewer/services/crop_save_service.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
final tmp = Directory.systemTemp.createTempSync('crop_test');
|
||||||
|
final srcPath = '${tmp.path}/test.png';
|
||||||
|
|
||||||
|
final src = img.Image(width: 100, height: 50);
|
||||||
|
void fill(int x, int y, int r, int g, int b) {
|
||||||
|
for (var dy = 0; dy < 5; dy++)
|
||||||
|
for (var dx = 0; dx < 5; dx++) src.setPixelRgb(x + dx, y + dy, r, g, b);
|
||||||
|
}
|
||||||
|
fill(0, 0, 255, 0, 0); // sol-ust: KIRMIZI
|
||||||
|
fill(95, 0, 0, 255, 0); // sag-ust: YESIL
|
||||||
|
fill(0, 45, 0, 0, 255); // sol-alt: MAVI
|
||||||
|
fill(95, 45, 255, 255, 0); // sag-alt: SARI
|
||||||
|
File(srcPath).writeAsBytesSync(img.encodePng(src)!);
|
||||||
|
|
||||||
|
var fails = 0;
|
||||||
|
void checkPix(String label, (int, int, int) px, int r, int g, int b) {
|
||||||
|
int d(int a, int c) => (a - c).abs();
|
||||||
|
final ok = d(px.$1, r) <= 60 && d(px.$2, g) <= 60 && d(px.$3, b) <= 60;
|
||||||
|
if (!ok) fails++;
|
||||||
|
print('${ok ? 'OK ' : 'FAIL'} $label: got ${px.$1},${px.$2},${px.$3} want ~$r,$g,$b');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Duz, fit, zoom yok: gorunen = kaynakin kendisi
|
||||||
|
final outDir1 = '${tmp.path}/a';
|
||||||
|
Directory(outDir1).createSync();
|
||||||
|
final f1 = '${outDir1}/src.jpg';
|
||||||
|
File(f1).writeAsBytesSync(File(srcPath).readAsBytesSync());
|
||||||
|
final p1 = CropViewParams(w: 100, h: 50, vw: 100, vh: 50, tx: 0, ty: 0, sc: 1,
|
||||||
|
angleDeg: 0, cx: 50, cy: 25, k: 1, ox: 0, oy: 0);
|
||||||
|
final path1 = await CropSaveService.saveCrop(srcPath: f1, source: await CropSaveService.load(f1), vp: p1);
|
||||||
|
final r1 = img.decodeImage(File(path1).readAsBytesSync())!;
|
||||||
|
print('1) dims ${r1.width}x${r1.height} (100x50 beklenir)');
|
||||||
|
if (r1.width != 100 || r1.height != 50 || !path1.endsWith('.jpg')) fails++;
|
||||||
|
checkPix('1) sol-ust', imgPx(r1, 2, 2), 255, 0, 0);
|
||||||
|
checkPix('1) sag-alt', imgPx(r1, 97, 47), 255, 255, 0);
|
||||||
|
|
||||||
|
// 2) 90 derece saat yelkolevi: cikti 50x100, orij sol-alt -> yeni sol-ust
|
||||||
|
final out2 = '${tmp.path}/b';
|
||||||
|
Directory(out2).createSync();
|
||||||
|
final f2 = '$out2/src.jpg';
|
||||||
|
File(f2).writeAsBytesSync(File(srcPath).readAsBytesSync());
|
||||||
|
final p2 = CropViewParams(w: 100, h: 50, vw: 50, vh: 100, tx: 0, ty: 0, sc: 1,
|
||||||
|
angleDeg: 90, cx: 25, cy: 50, k: 1, ox: -25, oy: 25);
|
||||||
|
final path2 = await CropSaveService.saveCrop(srcPath: f2, source: await CropSaveService.load(f2), vp: p2);
|
||||||
|
final r2 = img.decodeImage(File(path2).readAsBytesSync())!;
|
||||||
|
print('2) dims ${r2.width}x${r2.height} (50x100 beklenir)');
|
||||||
|
if (r2.width != 50 || r2.height != 100) fails++;
|
||||||
|
checkPix('2) sol-ust (orij sol-alt MAVI)', imgPx(r2, 2, 2), 0, 0, 255);
|
||||||
|
checkPix('2) sag-ust (orij sol-ust KIRMIZI)', imgPx(r2, 47, 2), 255, 0, 0);
|
||||||
|
checkPix('2) sol-alt (orij sag-alt SARI)', imgPx(r2, 2, 97), 255, 255, 0);
|
||||||
|
checkPix('2) sag-alt (orij sag-ust YESIL)', imgPx(r2, 47, 97), 0, 255, 0);
|
||||||
|
|
||||||
|
// 3) Zoom x2, 45 derece, fill: orneklenen alan ortada
|
||||||
|
final out3 = '${tmp.path}/c';
|
||||||
|
Directory(out3).createSync();
|
||||||
|
final f3 = '$out3/src.jpg';
|
||||||
|
File(f3).writeAsBytesSync(File(srcPath).readAsBytesSync());
|
||||||
|
final p3 = CropViewParams(w: 100, h: 50, vw: 100, vh: 100, tx: 0, ty: 0, sc: 2,
|
||||||
|
angleDeg: 45, cx: 50, cy: 50, k: 2, ox: 0, oy: -25);
|
||||||
|
final path3 = await CropSaveService.saveCrop(srcPath: f3, source: await CropSaveService.load(f3), vp: p3);
|
||||||
|
final r3 = img.decodeImage(File(path3).readAsBytesSync())!;
|
||||||
|
print('3) dims ${r3.width}x${r3.height} (25x25 beklenir: zoom x2 + fill k=2 -> 4 src px/ekran px)');
|
||||||
|
if (r3.width != 25 || r3.height != 25) fails++;
|
||||||
|
|
||||||
|
// 4) Yatay aynalama (flipH): gorunum aynalanir, cikti da aynali olmali
|
||||||
|
final out4 = '${tmp.path}/d';
|
||||||
|
Directory(out4).createSync();
|
||||||
|
final f4 = '$out4/src.jpg';
|
||||||
|
File(f4).writeAsBytesSync(File(srcPath).readAsBytesSync());
|
||||||
|
final p4 = CropViewParams(w: 100, h: 50, vw: 100, vh: 50, tx: 0, ty: 0, sc: 1,
|
||||||
|
angleDeg: 0, flipH: true, cx: 50, cy: 25, k: 1, ox: 0, oy: 0);
|
||||||
|
final path4 = await CropSaveService.saveCrop(srcPath: f4, source: await CropSaveService.load(f4), vp: p4);
|
||||||
|
final r4 = img.decodeImage(File(path4).readAsBytesSync())!;
|
||||||
|
print('4) dims ${r4.width}x${r4.height} (100x50 beklenir)');
|
||||||
|
if (r4.width != 100 || r4.height != 50) fails++;
|
||||||
|
checkPix('4) sol-ust (orij sag-ust YESIL)', imgPx(r4, 2, 2), 0, 255, 0);
|
||||||
|
checkPix('4) sag-ust (orij sol-ust KIRMIZI)', imgPx(r4, 97, 2), 255, 0, 0);
|
||||||
|
checkPix('4) sol-alt (orij sag-alt SARI)', imgPx(r4, 2, 47), 255, 255, 0);
|
||||||
|
checkPix('4) sag-alt (orij sol-alt MAVI)', imgPx(r4, 97, 47), 0, 0, 255);
|
||||||
|
|
||||||
|
// 5) Donus + aynalama: 2. senaryonun aynasi
|
||||||
|
final out5 = '${tmp.path}/e';
|
||||||
|
Directory(out5).createSync();
|
||||||
|
final f5 = '$out5/src.jpg';
|
||||||
|
File(f5).writeAsBytesSync(File(srcPath).readAsBytesSync());
|
||||||
|
final p5 = CropViewParams(w: 100, h: 50, vw: 50, vh: 100, tx: 0, ty: 0, sc: 1,
|
||||||
|
angleDeg: 90, flipH: true, cx: 25, cy: 50, k: 1, ox: -25, oy: 25);
|
||||||
|
final path5 = await CropSaveService.saveCrop(srcPath: f5, source: await CropSaveService.load(f5), vp: p5);
|
||||||
|
final r5 = img.decodeImage(File(path5).readAsBytesSync())!;
|
||||||
|
print('5) dims ${r5.width}x${r5.height} (50x100 beklenir)');
|
||||||
|
if (r5.width != 50 || r5.height != 100) fails++;
|
||||||
|
checkPix('5) sol-ust (orij sol-ust KIRMIZI)', imgPx(r5, 2, 2), 255, 0, 0);
|
||||||
|
checkPix('5) sag-ust (orij sol-alt MAVI)', imgPx(r5, 47, 2), 0, 0, 255);
|
||||||
|
checkPix('5) sol-alt (orij sag-ust YESIL)', imgPx(r5, 2, 97), 0, 255, 0);
|
||||||
|
checkPix('5) sag-alt (orij sag-alt SARI)', imgPx(r5, 47, 97), 255, 255, 0);
|
||||||
|
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
print(fails == 0 ? 'ALL PASS' : '$fails FAILURES');
|
||||||
|
exitCode = fails == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// jpeg kaybi oldugu icin 5x5 bolge ortalamasi al
|
||||||
|
(int, int, int) imgPx(img.Image im, int x, int y) {
|
||||||
|
int avg(int f) {
|
||||||
|
var s = 0, n = 0;
|
||||||
|
for (var dy = -2; dy <= 2; dy++)
|
||||||
|
for (var dx = -2; dx <= 2; dx++) {
|
||||||
|
final px = im.getPixelClamped(x + dx, y + dy);
|
||||||
|
s += f == 0 ? px.r.toInt() : (f == 1 ? px.g.toInt() : px.b.toInt());
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return s ~/ n;
|
||||||
|
}
|
||||||
|
return (avg(0), avg(1), avg(2));
|
||||||
|
}
|
||||||