feature: PDF and PDF thumbnails and refresh UI
- Implement PDF and PDF thumbnail generation with incremental loading - Add UI refresh mechanism (tick counter + RefreshView message) - Improve fl! macro with named parameters - Clean up code organization (mod.rs: wiring, model.rs: state only)
This commit is contained in:
parent
220a886acc
commit
1182b7b55d
30 changed files with 1929 additions and 691 deletions
320
Cargo.lock
generated
320
Cargo.lock
generated
|
|
@ -883,6 +883,31 @@ version = "1.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-sys-rs"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.13.0"
|
||||
|
|
@ -952,6 +977,16 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
|
|
@ -1944,7 +1979,7 @@ version = "0.6.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
|
||||
dependencies = [
|
||||
"toml",
|
||||
"toml 0.5.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2315,6 +2350,19 @@ dependencies = [
|
|||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
|
|
@ -2332,6 +2380,53 @@ version = "0.25.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib-macros",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.13.1"
|
||||
|
|
@ -2353,6 +2448,17 @@ dependencies = [
|
|||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-alloc"
|
||||
version = "0.6.0"
|
||||
|
|
@ -2740,7 +2846,7 @@ dependencies = [
|
|||
"iced_graphics",
|
||||
"kurbo 0.10.4",
|
||||
"log",
|
||||
"resvg",
|
||||
"resvg 0.42.0",
|
||||
"rustc-hash 2.1.1",
|
||||
"softbuffer",
|
||||
"tiny-skia",
|
||||
|
|
@ -2764,7 +2870,7 @@ dependencies = [
|
|||
"lyon",
|
||||
"once_cell",
|
||||
"raw-window-handle",
|
||||
"resvg",
|
||||
"resvg 0.42.0",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustix 0.38.44",
|
||||
"thiserror 1.0.69",
|
||||
|
|
@ -2975,6 +3081,12 @@ version = "0.12.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284"
|
||||
|
||||
[[package]]
|
||||
name = "imagesize"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.0"
|
||||
|
|
@ -3690,6 +3802,7 @@ name = "noctua"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cairo-rs",
|
||||
"clap",
|
||||
"dirs 5.0.1",
|
||||
"env_logger",
|
||||
|
|
@ -3701,7 +3814,10 @@ dependencies = [
|
|||
"libcosmic",
|
||||
"log",
|
||||
"open",
|
||||
"poppler",
|
||||
"resvg 0.45.1",
|
||||
"rust-embed",
|
||||
"sha2",
|
||||
"simple_logger",
|
||||
"tokio",
|
||||
"wallpaper",
|
||||
|
|
@ -4506,6 +4622,16 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "poppler"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6580b718aba679b295299567119284d534d7bfc7510259d0c72273879831da8d"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"glib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.0"
|
||||
|
|
@ -4561,6 +4687,16 @@ dependencies = [
|
|||
"toml_edit 0.19.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
|
||||
dependencies = [
|
||||
"toml_datetime 0.6.3",
|
||||
"toml_edit 0.20.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.4.0"
|
||||
|
|
@ -4570,6 +4706,30 @@ dependencies = [
|
|||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
|
|
@ -4963,7 +5123,24 @@ dependencies = [
|
|||
"rgb",
|
||||
"svgtypes",
|
||||
"tiny-skia",
|
||||
"usvg",
|
||||
"usvg 0.42.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
|
||||
dependencies = [
|
||||
"gif 0.13.3",
|
||||
"image-webp",
|
||||
"log",
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes",
|
||||
"tiny-skia",
|
||||
"usvg 0.45.1",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5138,8 +5315,26 @@ dependencies = [
|
|||
"bytemuck",
|
||||
"smallvec",
|
||||
"ttf-parser 0.21.1",
|
||||
"unicode-bidi-mirroring",
|
||||
"unicode-ccc",
|
||||
"unicode-bidi-mirroring 0.2.0",
|
||||
"unicode-ccc 0.2.0",
|
||||
"unicode-properties",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytemuck",
|
||||
"core_maths",
|
||||
"log",
|
||||
"smallvec",
|
||||
"ttf-parser 0.25.1",
|
||||
"unicode-bidi-mirroring 0.4.0",
|
||||
"unicode-ccc 0.4.0",
|
||||
"unicode-properties",
|
||||
"unicode-script",
|
||||
]
|
||||
|
|
@ -5239,6 +5434,15 @@ dependencies = [
|
|||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
|
|
@ -5590,6 +5794,19 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 0.8.2",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.9.2"
|
||||
|
|
@ -5602,6 +5819,12 @@ dependencies = [
|
|||
"slotmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.24.0"
|
||||
|
|
@ -5826,10 +6049,25 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
name = "toml"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.3",
|
||||
"toml_edit 0.20.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
|
|
@ -5847,7 +6085,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
|
|
@ -5976,12 +6227,24 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi-mirroring"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ccc"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ccc"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
|
|
@ -6059,12 +6322,39 @@ dependencies = [
|
|||
"data-url",
|
||||
"flate2",
|
||||
"fontdb 0.18.0",
|
||||
"imagesize",
|
||||
"imagesize 0.12.0",
|
||||
"kurbo 0.11.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"rustybuzz",
|
||||
"rustybuzz 0.14.1",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"unicode-vo",
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"data-url",
|
||||
"flate2",
|
||||
"fontdb 0.23.0",
|
||||
"imagesize 0.13.0",
|
||||
"kurbo 0.11.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"rustybuzz 0.20.1",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
|
|
@ -6110,6 +6400,12 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ i18n-embed-fl = "0.10"
|
|||
open = "5.3.2"
|
||||
rust-embed = "8.8.0"
|
||||
dirs = "5.0"
|
||||
sha2 = "0.10"
|
||||
image = "0.25.9"
|
||||
poppler = { version = "0.4", features = ["render"] }
|
||||
cairo-rs = { version = "0.18", features = ["png"] }
|
||||
resvg = "0.45"
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
env_logger = "0.11.8"
|
||||
wallpaper = "3.2"
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -17,6 +17,27 @@ A [justfile](./justfile) is included by default for the [casey/just][just] comma
|
|||
- `just check` runs clippy on the project to check for linter warnings
|
||||
- `just check-json` can be used by IDEs that support LSP
|
||||
|
||||
### Dependencies
|
||||
#### Arch Linux
|
||||
```bash
|
||||
sudo pacman -S poppler-glib
|
||||
```
|
||||
|
||||
#### Debian/Ubuntu
|
||||
```bash
|
||||
sudo apt install libpoppler-glib-dev
|
||||
```
|
||||
|
||||
#### Fedora
|
||||
```bash
|
||||
sudo dnf install poppler-glib-devel
|
||||
```
|
||||
|
||||
#### OpenSUSE
|
||||
```bash
|
||||
sudo zypper install poppler-glib-devel
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Usage](docs/usage.md)
|
||||
|
|
|
|||
|
|
@ -181,17 +181,24 @@ Full keyboard-driven workflow:
|
|||
- Copy/Move/Delete operations
|
||||
- Drag-and-drop support
|
||||
|
||||
#### Error Handling
|
||||
- User-friendly error messages (ShowError/ClearError prepared)
|
||||
- Graceful handling of corrupted files
|
||||
- Recovery suggestions
|
||||
|
||||
### Medium Priority
|
||||
|
||||
#### Multi-format TIFF Support
|
||||
- Multi-page TIFF navigation
|
||||
- Page thumbnails
|
||||
|
||||
#### Metadata Editing
|
||||
- EXIF data modification
|
||||
- Comment annotations
|
||||
- Tag management
|
||||
|
||||
### Low Priority
|
||||
|
||||
#### Advanced Editing
|
||||
- Crop tool (message prepared)
|
||||
- Scale/Resize tool (message prepared)
|
||||
- Basic color adjustments (brightness, contrast)
|
||||
|
||||
#### Enhanced Navigation
|
||||
- Thumbnail strip
|
||||
- Grid view for folder contents
|
||||
|
|
@ -202,18 +209,6 @@ Full keyboard-driven workflow:
|
|||
- Configurable intervals
|
||||
- Fullscreen support
|
||||
|
||||
### Low Priority
|
||||
|
||||
#### Advanced Editing
|
||||
- Crop tool (message prepared)
|
||||
- Scale/Resize tool (message prepared)
|
||||
- Basic color adjustments
|
||||
|
||||
#### Metadata Editing
|
||||
- EXIF data modification
|
||||
- Comment annotations
|
||||
- Tag management
|
||||
|
||||
## Feature Status Legend
|
||||
|
||||
- **Implemented**: Fully functional and tested
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ The following features are prepared in code but not yet implemented:
|
|||
### File Operations
|
||||
- File open dialog
|
||||
- Save transformed images
|
||||
- Copy/Move/Delete operations
|
||||
- (Copy/Move/)Delete operations
|
||||
|
||||
### Document Support
|
||||
- SVG rendering with `resvg`
|
||||
|
|
|
|||
|
|
@ -1,55 +1,92 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# i18n/en/noctua.ftl
|
||||
#
|
||||
# Localization strings for Noctua's user interface (English).
|
||||
# Localization strings for Noctua (English).
|
||||
# Usage: fl!("message-id", arg1, arg2, ...)
|
||||
#
|
||||
# Positional arguments ($1, $2, ...) are used for variable content.
|
||||
|
||||
|
||||
## Application metadata
|
||||
## Application
|
||||
noctua-app-name = Noctua
|
||||
noctua-app-description = A wise document and image viewer for the COSMIC™ desktop
|
||||
noctua-app-description = A document and image viewer for the COSMIC desktop
|
||||
|
||||
|
||||
## Main window
|
||||
window-title = { $filename ->
|
||||
*[none] Noctua
|
||||
*[some] { $filename } — Noctua
|
||||
[none] Noctua
|
||||
*[some] { $filename } — Noctua
|
||||
}
|
||||
|
||||
|
||||
## Menu entries
|
||||
menu-file-open = Open…
|
||||
menu-file-quit = Quit
|
||||
menu-view-zoom-in = Zoom In
|
||||
menu-view-zoom-out = Zoom Out
|
||||
menu-view-zoom-reset = Reset Zoom
|
||||
menu-view-zoom-fit = Fit to Window
|
||||
menu-view-flip-horizontal = Flip Horizontally
|
||||
menu-view-flip-vertical = Flip Vertically
|
||||
menu-view-rotate-cw = Rotate Clockwise
|
||||
menu-view-rotate-ccw = Rotate Counter-Clockwise
|
||||
|
||||
## Placeholders / empty states
|
||||
|
||||
## Tooltips (for buttons and icons)
|
||||
tooltip-nav-previous = Previous document
|
||||
tooltip-nav-next = Next document
|
||||
tooltip-nav-toggle = Toggle navigation panel
|
||||
tooltip-zoom-in = Zoom in
|
||||
tooltip-zoom-out = Zoom out
|
||||
tooltip-zoom-fit = Fit to window
|
||||
tooltip-rotate-ccw = Rotate counter-clockwise
|
||||
tooltip-rotate-cw = Rotate clockwise
|
||||
tooltip-flip-horizontal = Flip horizontally
|
||||
tooltip-flip-vertical = Flip vertically
|
||||
tooltip-info-panel = Toggle info panel
|
||||
|
||||
|
||||
## Footer / Status bar
|
||||
status-zoom-fit = Fit
|
||||
status-zoom-percent = { $percent }%
|
||||
status-doc-dimensions = { $width } × { $height }
|
||||
status-nav-position = { $current } / { $total }
|
||||
status-separator = |
|
||||
|
||||
|
||||
## Placeholders / Empty states
|
||||
no-document = No document loaded
|
||||
|
||||
|
||||
## Labels
|
||||
zoom = Zoom
|
||||
tools = Tools
|
||||
crop = Crop
|
||||
scale = Scale
|
||||
label-zoom = Zoom
|
||||
label-tools = Tools
|
||||
label-crop = Crop
|
||||
label-scale = Scale
|
||||
label-page = Page
|
||||
label-pages = Pages
|
||||
|
||||
|
||||
## Loading states
|
||||
loading-metadata = Loading metadata…
|
||||
loading-thumbnails = Loading { $current } / { $total }…
|
||||
|
||||
|
||||
## Error messages
|
||||
error-failed-to-open = Failed to open "{ $path }".
|
||||
error-unsupported-format = Unsupported file format.
|
||||
error-failed-to-open = Failed to open "{ $path }"
|
||||
error-unsupported-format = Unsupported file format
|
||||
error-no-image-loaded = No image loaded
|
||||
|
||||
|
||||
## Properties panel
|
||||
panel-properties = Properties
|
||||
panel-actions = Actions
|
||||
|
||||
meta-section-file = File Information
|
||||
meta-section-exif = Camera Information
|
||||
meta-section-image = Image Information
|
||||
|
||||
## Action buttons
|
||||
action-set-wallpaper = Set as Wallpaper
|
||||
action-open-with = Open With…
|
||||
action-show-in-folder = Show in Folder
|
||||
|
||||
## Basic metadata
|
||||
## File metadata
|
||||
meta-filename = Name
|
||||
meta-format = Format
|
||||
meta-dimensions = Dimensions
|
||||
|
|
@ -59,14 +96,26 @@ meta-path = Path
|
|||
meta-pages = Pages
|
||||
meta-current-page = Current Page
|
||||
|
||||
## Image metadata
|
||||
meta-width = Width
|
||||
meta-height = Height
|
||||
meta-depth = Bit Depth
|
||||
|
||||
## EXIF metadata
|
||||
meta-camera = Camera
|
||||
meta-datetime = Date Taken
|
||||
meta-exposure = Exposure
|
||||
meta-aperture = Aperture
|
||||
meta-iso = ISO
|
||||
meta-iso = ISO { $iso }
|
||||
meta-focal = Focal Length
|
||||
meta-gps = GPS Location
|
||||
|
||||
## States
|
||||
loading-metadata = Loading...
|
||||
## Action buttons
|
||||
action-set-wallpaper = Set as Wallpaper
|
||||
action-open-with = Open With…
|
||||
action-show-in-folder = Show in Folder
|
||||
|
||||
|
||||
## Navigation panel (thumbnails)
|
||||
nav-panel-title = Pages
|
||||
nav-panel-loading = Loading { $current } / { $total }…
|
||||
|
|
|
|||
137
src/app/document/cache.rs
Normal file
137
src/app/document/cache.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/cache.rs
|
||||
//
|
||||
// Disk cache for document thumbnails stored in ~/.cache/noctua/
|
||||
|
||||
use std::fs;
|
||||
use std::io::BufWriter;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use image::DynamicImage;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::ImageHandle;
|
||||
use crate::constant::{CACHE_DIR, THUMBNAIL_EXT};
|
||||
|
||||
/// Get the cache directory path (~/.cache/noctua/).
|
||||
fn cache_dir() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|p| p.join(CACHE_DIR))
|
||||
}
|
||||
|
||||
/// Ensure the cache directory exists.
|
||||
fn ensure_cache_dir() -> Option<PathBuf> {
|
||||
let dir = cache_dir()?;
|
||||
fs::create_dir_all(&dir).ok()?;
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// Generate a cache key from file path, modification time, and page number.
|
||||
/// Format: sha256(path + mtime + page)
|
||||
fn cache_key(file_path: &Path, page: u32) -> Option<String> {
|
||||
let metadata = fs::metadata(file_path).ok()?;
|
||||
let mtime = metadata
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(file_path.to_string_lossy().as_bytes());
|
||||
hasher.update(mtime.to_le_bytes());
|
||||
hasher.update(page.to_le_bytes());
|
||||
|
||||
let hash = hasher.finalize();
|
||||
Some(format!("{:x}", hash))
|
||||
}
|
||||
|
||||
/// Get the full path for a cached thumbnail.
|
||||
fn thumbnail_path(file_path: &Path, page: u32) -> Option<PathBuf> {
|
||||
let dir = cache_dir()?;
|
||||
let key = cache_key(file_path, page)?;
|
||||
Some(dir.join(format!("{}.{}", key, THUMBNAIL_EXT)))
|
||||
}
|
||||
|
||||
/// Load a thumbnail from disk cache.
|
||||
/// Returns None if not cached or cache is invalid.
|
||||
pub fn load_thumbnail(file_path: &Path, page: u32) -> Option<ImageHandle> {
|
||||
let cache_path = thumbnail_path(file_path, page)?;
|
||||
|
||||
log::debug!("Cache lookup: file={}, page={}", file_path.display(), page);
|
||||
|
||||
if !cache_path.exists() {
|
||||
log::debug!(
|
||||
"Thumbnail not found in cache: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let img = image::open(&cache_path).ok()?;
|
||||
log::debug!(
|
||||
"Thumbnail loaded from cache: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
Some(super::create_image_handle(&img))
|
||||
}
|
||||
|
||||
/// Save a thumbnail to disk cache.
|
||||
pub fn save_thumbnail(file_path: &Path, page: u32, image: &DynamicImage) -> Option<()> {
|
||||
let dir = ensure_cache_dir()?;
|
||||
let key = cache_key(file_path, page)?;
|
||||
let cache_path = dir.join(format!("{}.{}", key, THUMBNAIL_EXT));
|
||||
|
||||
log::debug!(
|
||||
"Saving thumbnail to cache: file={}, page={}, path={}",
|
||||
file_path.display(),
|
||||
page,
|
||||
cache_path.display()
|
||||
);
|
||||
|
||||
let file = fs::File::create(&cache_path).ok()?;
|
||||
let writer = BufWriter::new(file);
|
||||
|
||||
let res = image.write_to(
|
||||
&mut std::io::BufWriter::new(writer),
|
||||
image::ImageFormat::Png,
|
||||
);
|
||||
match res {
|
||||
Ok(_) => {
|
||||
log::debug!(
|
||||
"Thumbnail cached successfully: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
Some(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to cache thumbnail: file={} page={}: {}",
|
||||
file_path.display(),
|
||||
page,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a thumbnail exists in cache.
|
||||
pub fn has_thumbnail(file_path: &Path, page: u32) -> bool {
|
||||
thumbnail_path(file_path, page)
|
||||
.map(|p| p.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear all cached thumbnails.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_cache() -> std::io::Result<()> {
|
||||
if let Some(dir) = cache_dir() {
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -19,8 +19,8 @@ use crate::app::model::{AppModel, ViewMode};
|
|||
///
|
||||
/// Raster formats are delegated to the `image` crate, which decides
|
||||
/// based on enabled codecs (e.g. default-formats).
|
||||
pub fn open_document(path: PathBuf) -> anyhow::Result<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(&path)
|
||||
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(path)
|
||||
.ok_or_else(|| anyhow!("Unsupported document type: {:?}", path))?;
|
||||
|
||||
let content = match kind {
|
||||
|
|
@ -88,11 +88,13 @@ pub fn open_single_file(model: &mut AppModel, path: &Path) {
|
|||
|
||||
/// Load a document into the model, resetting view state.
|
||||
fn load_document_into_model(model: &mut AppModel, path: &Path) {
|
||||
match open_document(path.to_path_buf()) {
|
||||
match open_document(path) {
|
||||
Ok(doc) => {
|
||||
// Extract metadata before storing the document.
|
||||
let metadata = doc.extract_meta(path);
|
||||
|
||||
model.document = Some(doc);
|
||||
// Reset cached metadata so it gets reloaded when panel is visible.
|
||||
model.metadata = None;
|
||||
model.metadata = Some(metadata);
|
||||
model.current_path = Some(path.to_path_buf());
|
||||
model.clear_error();
|
||||
|
||||
|
|
@ -102,6 +104,7 @@ fn load_document_into_model(model: &mut AppModel, path: &Path) {
|
|||
}
|
||||
Err(err) => {
|
||||
model.document = None;
|
||||
model.metadata = None;
|
||||
model.current_path = None;
|
||||
model.set_error(err.to_string());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use image::DynamicImage;
|
|||
use exif::{In, Reader as ExifReader, Tag, Value};
|
||||
|
||||
use super::file;
|
||||
use crate::constant::{MINUTES_PER_DEGREE, SECONDS_PER_DEGREE};
|
||||
|
||||
/// Basic document metadata (always available).
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -189,7 +190,7 @@ fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<
|
|||
let d = rats[0].to_f64();
|
||||
let m = rats[1].to_f64();
|
||||
let s = rats[2].to_f64();
|
||||
d + m / 60.0 + s / 3600.0
|
||||
d + m / MINUTES_PER_DEGREE + s / SECONDS_PER_DEGREE
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@
|
|||
//
|
||||
// Document module root: common enums and type erasure for document kinds.
|
||||
|
||||
pub mod cache;
|
||||
pub mod file;
|
||||
pub mod meta;
|
||||
pub mod portable;
|
||||
pub mod raster;
|
||||
pub mod transform;
|
||||
pub mod utils;
|
||||
pub mod vector;
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat;
|
||||
use image::GenericImageView;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -20,6 +20,41 @@ use self::portable::PortableDocument;
|
|||
use self::raster::RasterDocument;
|
||||
use self::vector::VectorDocument;
|
||||
|
||||
/// Trait for documents that support multiple pages (PDF, multi-page TIFF, etc.).
|
||||
pub trait MultiPage {
|
||||
/// Total number of pages in the document.
|
||||
fn page_count(&self) -> u32;
|
||||
|
||||
/// Current page index (0-based).
|
||||
fn current_page(&self) -> u32;
|
||||
|
||||
/// Navigate to a specific page.
|
||||
fn goto_page(&mut self, page: u32) -> anyhow::Result<()>;
|
||||
|
||||
/// Check if thumbnails are ready for display.
|
||||
fn thumbnails_ready(&self) -> bool;
|
||||
|
||||
/// Generate thumbnails (uses disk cache when available).
|
||||
fn generate_thumbnails(&mut self);
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
fn get_thumbnail(&self, page: u32) -> Option<ImageHandle>;
|
||||
}
|
||||
|
||||
/// Re-export the image handle type for use by submodules.
|
||||
pub type ImageHandle = cosmic::iced::widget::image::Handle;
|
||||
|
||||
/// Create an iced image handle from a DynamicImage.
|
||||
///
|
||||
/// This is the central function for converting rendered images to display handles.
|
||||
/// Used by raster, vector, and portable document types.
|
||||
pub fn create_image_handle(img: &image::DynamicImage) -> ImageHandle {
|
||||
let (w, h) = img.dimensions();
|
||||
let rgba = img.to_rgba8();
|
||||
let pixels = rgba.into_raw();
|
||||
ImageHandle::from_rgba(w, h, pixels)
|
||||
}
|
||||
|
||||
/// High-level classification of documents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DocumentKind {
|
||||
|
|
@ -79,9 +114,9 @@ impl DocumentContent {
|
|||
/// Returns a cloneable image handle for rendering.
|
||||
///
|
||||
/// This is intentionally linear: every concrete document type
|
||||
/// owns some kind of `iced_image::Handle`, and the canvas can
|
||||
/// owns some kind of `ImageHandle`, and the canvas can
|
||||
/// just call `doc.handle()` without additional branching.
|
||||
pub fn handle(&self) -> iced_image::Handle {
|
||||
pub fn handle(&self) -> ImageHandle {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.handle.clone(),
|
||||
DocumentContent::Vector(doc) => doc.handle.clone(),
|
||||
|
|
@ -101,44 +136,136 @@ impl DocumentContent {
|
|||
}
|
||||
}
|
||||
/// Extract metadata from the document.
|
||||
/// This may involve file I/O for EXIF data, so call lazily.
|
||||
pub fn extract_meta(&self) -> meta::DocumentMeta {
|
||||
/// Requires the file path for file size and EXIF extraction.
|
||||
pub fn extract_meta(&self, path: &Path) -> meta::DocumentMeta {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.extract_meta(),
|
||||
DocumentContent::Vector(doc) => doc.extract_meta(),
|
||||
DocumentContent::Portable(doc) => doc.extract_meta(),
|
||||
DocumentContent::Raster(doc) => doc.extract_meta(path),
|
||||
DocumentContent::Vector(doc) => doc.extract_meta(path),
|
||||
DocumentContent::Portable(doc) => doc.extract_meta(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate document 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.rotate_cw(),
|
||||
DocumentContent::Vector(doc) => doc.rotate_cw(),
|
||||
DocumentContent::Portable(doc) => doc.rotate_cw(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate document 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.rotate_ccw(),
|
||||
DocumentContent::Vector(doc) => doc.rotate_ccw(),
|
||||
DocumentContent::Portable(doc) => doc.rotate_ccw(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip document horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.flip_horizontal(),
|
||||
DocumentContent::Vector(doc) => doc.flip_horizontal(),
|
||||
DocumentContent::Portable(doc) => doc.flip_horizontal(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip document vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.flip_vertical(),
|
||||
DocumentContent::Vector(doc) => doc.flip_vertical(),
|
||||
DocumentContent::Portable(doc) => doc.flip_vertical(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this document supports multiple pages.
|
||||
pub fn is_multi_page(&self) -> bool {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.page_count() > 1,
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get page count if this is a multi-page document.
|
||||
pub fn page_count(&self) -> Option<u32> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => Some(doc.page_count()),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current page index if this is a multi-page document.
|
||||
pub fn current_page(&self) -> Option<u32> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => Some(doc.current_page()),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to a specific page if this is a multi-page document.
|
||||
pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.goto_page(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => Err(anyhow::anyhow!("Document does not support multiple pages")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
pub fn get_thumbnail(&self, page: u32) -> Option<ImageHandle> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.get_thumbnail(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if thumbnails are ready for display.
|
||||
pub fn thumbnails_ready(&self) -> bool {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.thumbnails_ready(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get number of thumbnails currently loaded.
|
||||
pub fn thumbnails_loaded(&self) -> u32 {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.thumbnails_loaded(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single thumbnail page. Returns next page to generate, or None if done.
|
||||
pub fn generate_thumbnail_page(&mut self, page: u32) -> Option<u32> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.generate_thumbnail_page(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails at once (blocking).
|
||||
pub fn generate_thumbnails(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.generate_thumbnails(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set an image file as desktop wallpaper.
|
||||
///
|
||||
/// This function attempts multiple methods in order:
|
||||
/// 1. COSMIC Desktop (direct config file modification)
|
||||
/// 2. wallpaper crate (KDE, XFCE, Windows, macOS)
|
||||
/// 3. gsettings (GNOME)
|
||||
/// 4. feh (tiling window managers)
|
||||
///
|
||||
/// The operation is performed asynchronously and logs success/failure.
|
||||
/// Delegates to `utils::set_as_wallpaper` which tries multiple methods.
|
||||
pub fn set_as_wallpaper(path: &Path) {
|
||||
// Canonicalize to absolute path
|
||||
let abs_path = match path.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("Failed to canonicalize path {}: {}", path.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to string
|
||||
let path_str = match abs_path.to_str() {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Delegate to utils with concrete string type
|
||||
utils::set_as_wallpaper(&path_str);
|
||||
utils::set_as_wallpaper(path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,317 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/portable.rs
|
||||
//
|
||||
// Portable documents (e.g. PDF) – basic model and rendering stub.
|
||||
// Portable documents (PDF) with poppler backend.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use image::{GenericImageView, DynamicImage};
|
||||
use cairo::{Context, Format, ImageSurface};
|
||||
use image::{imageops, DynamicImage, ImageReader};
|
||||
use poppler::PopplerDocument;
|
||||
|
||||
use super::{cache, ImageHandle};
|
||||
use crate::constant::{FULL_ROTATION, PDF_RENDER_SCALE, PDF_THUMBNAIL_SCALE, ROTATION_STEP};
|
||||
|
||||
/// Represents a portable document (PDF).
|
||||
pub struct PortableDocument {
|
||||
pub path: PathBuf,
|
||||
pub page_count: u32,
|
||||
pub current_page: u32,
|
||||
pub rotation: i32, // 0, 90, 180, 270; kept for future backend integration
|
||||
/// The parsed PDF document.
|
||||
document: PopplerDocument,
|
||||
/// Path to the source file (for caching).
|
||||
source_path: PathBuf,
|
||||
/// Total number of pages.
|
||||
page_count: u32,
|
||||
/// Current page index (0-based).
|
||||
current_page: u32,
|
||||
/// Rotation in degrees (0, 90, 180, 270).
|
||||
pub rotation: i16,
|
||||
/// Current rendered page as image.
|
||||
pub rendered: DynamicImage,
|
||||
pub handle: iced_image::Handle,
|
||||
// TODO: internal PDF handle from chosen backend
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Cached thumbnail handles for each page (None = not yet generated).
|
||||
thumbnail_cache: Option<Vec<ImageHandle>>,
|
||||
}
|
||||
|
||||
impl PortableDocument {
|
||||
/// Open a portable document and render the first page.
|
||||
///
|
||||
/// Currently this uses a dummy 1x1 transparent image as placeholder.
|
||||
pub fn open(path: PathBuf) -> anyhow::Result<Self> {
|
||||
// TODO: open PDF and render first page using a proper backend.
|
||||
let dummy = DynamicImage::new_rgba8(1, 1);
|
||||
let handle = Self::build_handle(&dummy);
|
||||
/// Open a PDF document and render the first page.
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let document = PopplerDocument::new_from_file(path, None)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {}", e))?;
|
||||
|
||||
let page_count = document.get_n_pages() as u32;
|
||||
if page_count == 0 {
|
||||
return Err(anyhow::anyhow!("PDF has no pages"));
|
||||
}
|
||||
|
||||
let rendered = Self::render_page(&document, 0, 0)?;
|
||||
let handle = super::create_image_handle(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
page_count: 1, // TODO: query real page count from backend
|
||||
document,
|
||||
source_path: path.to_path_buf(),
|
||||
page_count,
|
||||
current_page: 0,
|
||||
rotation: 0,
|
||||
rendered: dummy,
|
||||
rendered,
|
||||
handle,
|
||||
thumbnail_cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct an iced image handle from a DynamicImage.
|
||||
fn build_handle(img: &DynamicImage) -> iced_image::Handle {
|
||||
let (w, h) = img.dimensions();
|
||||
let rgba = img.to_rgba8();
|
||||
let pixels = rgba.into_raw();
|
||||
iced_image::Handle::from_rgba(w, h, pixels)
|
||||
/// Check if all thumbnails are ready.
|
||||
pub fn thumbnails_ready(&self) -> bool {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.map(|c| c.len() as u32 >= self.page_count)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the number of thumbnails currently loaded.
|
||||
pub fn thumbnails_loaded(&self) -> u32 {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.map(|c| c.len() as u32)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Initialize thumbnail cache (empty, ready for incremental loading).
|
||||
pub fn init_thumbnail_cache(&mut self) {
|
||||
if self.thumbnail_cache.is_none() {
|
||||
self.thumbnail_cache = Some(Vec::with_capacity(self.page_count as usize));
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single thumbnail page. Returns the next page to generate, or None if done.
|
||||
pub fn generate_thumbnail_page(&mut self, page: u32) -> Option<u32> {
|
||||
// Initialize cache if needed.
|
||||
self.init_thumbnail_cache();
|
||||
|
||||
// Check if we should generate this page.
|
||||
let should_generate = {
|
||||
let cache = self.thumbnail_cache.as_ref()?;
|
||||
page as usize >= cache.len() && page < self.page_count
|
||||
};
|
||||
|
||||
if should_generate {
|
||||
let handle = self.load_or_generate_thumbnail(page);
|
||||
if let Some(cache) = self.thumbnail_cache.as_mut() {
|
||||
cache.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Return next page if not done.
|
||||
let next = page + 1;
|
||||
if next < self.page_count {
|
||||
Some(next)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails at once (legacy, blocking).
|
||||
pub fn generate_thumbnails(&mut self) {
|
||||
if self.thumbnails_ready() {
|
||||
return;
|
||||
}
|
||||
self.init_thumbnail_cache();
|
||||
for page in 0..self.page_count {
|
||||
self.generate_thumbnail_page(page);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load thumbnail from cache or generate and cache it.
|
||||
fn load_or_generate_thumbnail(&self, page: u32) -> ImageHandle {
|
||||
if let Some(handle) = cache::load_thumbnail(&self.source_path, page) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
match Self::render_page_at_scale(&self.document, page, 0, PDF_THUMBNAIL_SCALE) {
|
||||
Ok(img) => {
|
||||
let _ = cache::save_thumbnail(&self.source_path, page, &img);
|
||||
super::create_image_handle(&img)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate thumbnail for page {}: {}", page, e);
|
||||
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a specific page from the document to an image.
|
||||
fn render_page(
|
||||
document: &PopplerDocument,
|
||||
page_index: u32,
|
||||
rotation: i16,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_SCALE)
|
||||
}
|
||||
|
||||
/// Render a specific page at a given scale.
|
||||
fn render_page_at_scale(
|
||||
document: &PopplerDocument,
|
||||
page_index: u32,
|
||||
rotation: i16,
|
||||
scale: f64,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
let page = document
|
||||
.get_page(page_index as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get page {}", page_index))?;
|
||||
|
||||
let (page_width, page_height) = page.get_size();
|
||||
|
||||
let (width, height) = if rotation == 90 || rotation == 270 {
|
||||
(page_height, page_width)
|
||||
} else {
|
||||
(page_width, page_height)
|
||||
};
|
||||
|
||||
let scaled_width = (width * scale) as i32;
|
||||
let scaled_height = (height * scale) as i32;
|
||||
|
||||
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {}", e))?;
|
||||
|
||||
let context = Context::new(&surface)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {}", e))?;
|
||||
|
||||
// Fill with white background.
|
||||
context.set_source_rgb(1.0, 1.0, 1.0);
|
||||
let _ = context.paint();
|
||||
|
||||
context.scale(scale, scale);
|
||||
|
||||
if rotation != 0 {
|
||||
let center_x = width / 2.0;
|
||||
let center_y = height / 2.0;
|
||||
context.translate(center_x, center_y);
|
||||
context.rotate(f64::from(rotation) * std::f64::consts::PI / 180.0);
|
||||
context.translate(-page_width / 2.0, -page_height / 2.0);
|
||||
}
|
||||
|
||||
page.render(&context);
|
||||
|
||||
drop(context);
|
||||
surface.flush();
|
||||
|
||||
let mut png_data: Vec<u8> = Vec::new();
|
||||
surface
|
||||
.write_to_png(&mut png_data)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write PNG: {}", e))?;
|
||||
|
||||
let image = ImageReader::new(Cursor::new(png_data))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {}", e))?
|
||||
.decode()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {}", e))?;
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
/// Re-render the current page.
|
||||
fn rerender(&mut self) {
|
||||
match Self::render_page(&self.document, self.current_page, self.rotation) {
|
||||
Ok(rendered) => {
|
||||
self.rendered = rendered;
|
||||
self.refresh_handle();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to render PDF page: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the handle after mutating `rendered`.
|
||||
pub fn refresh_handle(&mut self) {
|
||||
self.handle = Self::build_handle(&self.rendered);
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
}
|
||||
|
||||
/// Returns the dimensions of the currently rendered page.
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.rendered.dimensions()
|
||||
(self.rendered.width(), self.rendered.height())
|
||||
}
|
||||
|
||||
/// Re-render the current page with the current rotation.
|
||||
pub fn rerender_page(&mut self) {
|
||||
// TODO: use PDF backend and self.rotation / self.current_page
|
||||
// self.rendered = render_page_to_dynamic(...);
|
||||
// self.refresh_handle();
|
||||
/// Navigate to a specific page.
|
||||
pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> {
|
||||
if page >= self.page_count {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Page {} out of range (0-{})",
|
||||
page,
|
||||
self.page_count - 1
|
||||
));
|
||||
}
|
||||
self.current_page = page;
|
||||
self.rerender();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate to the next page.
|
||||
pub fn next_page(&mut self) -> bool {
|
||||
if self.current_page + 1 < self.page_count {
|
||||
self.current_page += 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the previous page.
|
||||
pub fn prev_page(&mut self) -> bool {
|
||||
if self.current_page > 0 {
|
||||
self.current_page -= 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.rotation = (self.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.rotation = (self.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.rendered));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.rendered));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Extract metadata for this portable document.
|
||||
pub fn extract_meta(&self) -> super::meta::DocumentMeta {
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
let (width, height) = self.dimensions();
|
||||
super::meta::build_portable_meta(path, width, height, self.page_count)
|
||||
}
|
||||
|
||||
super::meta::build_portable_meta(&self.path, width, height, self.page_count)
|
||||
/// Get total page count.
|
||||
pub fn page_count(&self) -> u32 {
|
||||
self.page_count
|
||||
}
|
||||
|
||||
/// Get current page index (0-based).
|
||||
pub fn current_page(&self) -> u32 {
|
||||
self.current_page
|
||||
}
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
/// Returns None if thumbnails not yet generated.
|
||||
pub fn get_thumbnail(&self, page: u32) -> Option<ImageHandle> {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.get(page as usize).cloned())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,71 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/raster.rs
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use image::{GenericImageView, DynamicImage, ImageReader};
|
||||
use image::{imageops, DynamicImage, GenericImageView, ImageReader};
|
||||
|
||||
use super::ImageHandle;
|
||||
|
||||
/// Represents a raster image document (PNG, JPEG, WebP, ...).
|
||||
pub struct RasterDocument {
|
||||
pub path: Option<PathBuf>,
|
||||
pub image: DynamicImage,
|
||||
pub handle: iced_image::Handle,
|
||||
/// The decoded image document.
|
||||
document: DynamicImage,
|
||||
/// Cached handle for rendering.
|
||||
pub handle: ImageHandle,
|
||||
}
|
||||
|
||||
impl RasterDocument {
|
||||
/// Load a raster document from disk.
|
||||
pub fn open(path: PathBuf) -> image::ImageResult<Self> {
|
||||
let img = ImageReader::open(&path)?.decode()?;
|
||||
let handle = Self::build_handle(&img);
|
||||
pub fn open(path: &Path) -> image::ImageResult<Self> {
|
||||
let document = ImageReader::open(path)?.decode()?;
|
||||
let handle = super::create_image_handle(&document);
|
||||
|
||||
Ok(Self {
|
||||
path: Some(path),
|
||||
image: img,
|
||||
handle,
|
||||
})
|
||||
Ok(Self { document, handle })
|
||||
}
|
||||
|
||||
/// Construct a handle from a DynamicImage.
|
||||
fn build_handle(img: &DynamicImage) -> iced_image::Handle {
|
||||
// Get image dimensions.
|
||||
let (w, h) = img.dimensions();
|
||||
|
||||
// Convert to RGBA8 buffer and extract raw bytes.
|
||||
let rgba = img.to_rgba8();
|
||||
let pixels = rgba.into_raw(); // Vec<u8>
|
||||
|
||||
// Build an iced image handle from raw RGBA pixels.
|
||||
iced_image::Handle::from_rgba(w, h, pixels)
|
||||
}
|
||||
|
||||
/// Rebuild the handle after mutating `image`.
|
||||
/// Rebuild the handle after mutating `document`.
|
||||
pub fn refresh_handle(&mut self) {
|
||||
self.handle = Self::build_handle(&self.image);
|
||||
self.handle = super::create_image_handle(&self.document);
|
||||
}
|
||||
|
||||
/// Returns the native pixel dimensions (width, height).
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.image.dimensions()
|
||||
self.document.dimensions()
|
||||
}
|
||||
|
||||
/// Save the current image back to disk (overwrite).
|
||||
pub fn save(&self) -> image::ImageResult<()> {
|
||||
if let Some(path) = &self.path {
|
||||
self.image.save(path)
|
||||
} else {
|
||||
// Cant imagine that it happen but caller should handle missing path case.
|
||||
Err(image::ImageError::Parameter(
|
||||
image::error::ParameterError::from_kind(image::error::ParameterErrorKind::Generic(
|
||||
"RasterDocument does not have a path".into(),
|
||||
)),
|
||||
))
|
||||
}
|
||||
/// Save the current document to disk.
|
||||
pub fn save(&self, path: &Path) -> image::ImageResult<()> {
|
||||
self.document.save(path)
|
||||
}
|
||||
|
||||
/// Extract metadata for this raster document.
|
||||
pub fn extract_meta(&self) -> super::meta::DocumentMeta {
|
||||
let path = self.path.as_deref().unwrap_or(std::path::Path::new(""));
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
let (width, height) = self.dimensions();
|
||||
super::meta::build_raster_meta(path, &self.document, width, height)
|
||||
}
|
||||
|
||||
super::meta::build_raster_meta(path, &self.image, width, height)
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate90(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate270(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/transform.rs
|
||||
//
|
||||
// High-level document transformations (rotate, flip, etc.).
|
||||
|
||||
use image::{imageops, DynamicImage};
|
||||
|
||||
use super::portable::PortableDocument;
|
||||
use super::raster::RasterDocument;
|
||||
use super::vector::VectorDocument;
|
||||
use super::DocumentContent;
|
||||
|
||||
/// Rotate current document 90 degrees clockwise.
|
||||
pub fn rotate_cw(doc: &mut DocumentContent) {
|
||||
match doc {
|
||||
DocumentContent::Raster(raster) => rotate_cw_raster(raster),
|
||||
DocumentContent::Vector(vector) => rotate_cw_vector(vector),
|
||||
DocumentContent::Portable(portable) => rotate_cw_portable(portable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate current document 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(doc: &mut DocumentContent) {
|
||||
match doc {
|
||||
DocumentContent::Raster(raster) => rotate_ccw_raster(raster),
|
||||
DocumentContent::Vector(vector) => rotate_ccw_vector(vector),
|
||||
DocumentContent::Portable(portable) => rotate_ccw_portable(portable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip current document horizontally.
|
||||
pub fn flip_horizontal(doc: &mut DocumentContent) {
|
||||
match doc {
|
||||
DocumentContent::Raster(raster) => flip_horizontal_raster(raster),
|
||||
DocumentContent::Vector(vector) => flip_horizontal_vector(vector),
|
||||
DocumentContent::Portable(portable) => flip_horizontal_portable(portable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip current document vertically.
|
||||
pub fn flip_vertical(doc: &mut DocumentContent) {
|
||||
match doc {
|
||||
DocumentContent::Raster(raster) => flip_vertical_raster(raster),
|
||||
DocumentContent::Vector(vector) => flip_vertical_vector(vector),
|
||||
DocumentContent::Portable(portable) => flip_vertical_portable(portable),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Raster implementations ---------------------------------------------------
|
||||
|
||||
fn rotate_cw_raster(doc: &mut RasterDocument) {
|
||||
doc.image = DynamicImage::ImageRgba8(imageops::rotate90(&doc.image));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn rotate_ccw_raster(doc: &mut RasterDocument) {
|
||||
doc.image = DynamicImage::ImageRgba8(imageops::rotate270(&doc.image));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip_horizontal_raster(doc: &mut RasterDocument) {
|
||||
doc.image = DynamicImage::ImageRgba8(imageops::flip_horizontal(&doc.image));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip_vertical_raster(doc: &mut RasterDocument) {
|
||||
doc.image = DynamicImage::ImageRgba8(imageops::flip_vertical(&doc.image));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
// --- Portable implementations (operate on rendered image) ---------------------
|
||||
|
||||
fn rotate_cw_portable(doc: &mut PortableDocument) {
|
||||
// Keep rotation in sync for a future real PDF backend.
|
||||
doc.rotation = (doc.rotation + 90).rem_euclid(360);
|
||||
doc.rendered = DynamicImage::ImageRgba8(imageops::rotate90(&doc.rendered));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn rotate_ccw_portable(doc: &mut PortableDocument) {
|
||||
doc.rotation = (doc.rotation - 90).rem_euclid(360);
|
||||
doc.rendered = DynamicImage::ImageRgba8(imageops::rotate270(&doc.rendered));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip_horizontal_portable(doc: &mut PortableDocument) {
|
||||
doc.rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&doc.rendered));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip_vertical_portable(doc: &mut PortableDocument) {
|
||||
doc.rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&doc.rendered));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
// --- Vector implementations (view-transform only, for now) --------------------
|
||||
|
||||
fn rotate_cw_vector(_doc: &mut VectorDocument) {
|
||||
// TODO: either update a rotation property or re-rasterize with rotation.
|
||||
}
|
||||
|
||||
fn rotate_ccw_vector(_doc: &mut VectorDocument) {
|
||||
// TODO: either update a rotation property or re-rasterize with rotation.
|
||||
}
|
||||
|
||||
fn flip_horizontal_vector(_doc: &mut VectorDocument) {
|
||||
// TODO: apply horizontal flip to SVG or adjust view transform.
|
||||
}
|
||||
|
||||
fn flip_vertical_vector(_doc: &mut VectorDocument) {
|
||||
// TODO: apply vertical flip to SVG or adjust view transform.
|
||||
}
|
||||
|
|
@ -3,19 +3,71 @@
|
|||
//
|
||||
// Utility functions for document operations.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Set an image as desktop wallpaper using multiple fallback methods.
|
||||
///
|
||||
/// Expects an absolute path as string.
|
||||
pub fn set_as_wallpaper(path_str: &str) {
|
||||
/// Attempts the following methods in order:
|
||||
/// 1. COSMIC Desktop (direct config file modification)
|
||||
/// 2. wallpaper crate (KDE, XFCE, Windows, macOS)
|
||||
/// 3. gsettings (GNOME)
|
||||
/// 4. feh (tiling window managers)
|
||||
pub fn set_as_wallpaper(path: &Path) {
|
||||
// Canonicalize to absolute path.
|
||||
let abs_path = match path.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("Failed to canonicalize path {}: {}", path.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let path_str = match abs_path.to_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("Attempting to set wallpaper: {}", path_str);
|
||||
|
||||
// Method 1: Try COSMIC Desktop (direct config file modification)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all");
|
||||
// Method 1: Try COSMIC Desktop (direct config file modification).
|
||||
if try_cosmic_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
if cosmic_config.exists() {
|
||||
let config_content = format!(
|
||||
r#"(
|
||||
// Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS).
|
||||
if try_wallpaper_crate(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 3: Try GNOME via gsettings.
|
||||
if try_gsettings_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 4: Try feh (common on tiling WMs like i3, sway).
|
||||
if try_feh_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
log::error!("All methods failed to set wallpaper");
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via COSMIC config file.
|
||||
fn try_cosmic_wallpaper(path_str: &str) -> bool {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all");
|
||||
if !cosmic_config.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let config_content = format!(
|
||||
r#"(
|
||||
output: "all",
|
||||
source: Path("{}"),
|
||||
filter_by_theme: true,
|
||||
|
|
@ -24,86 +76,91 @@ pub fn set_as_wallpaper(path_str: &str) {
|
|||
scaling_mode: Zoom,
|
||||
sampling_method: Alphanumeric,
|
||||
)"#,
|
||||
path_str
|
||||
);
|
||||
path_str
|
||||
);
|
||||
|
||||
match std::fs::write(&cosmic_config, config_content) {
|
||||
Ok(_) => {
|
||||
log::info!("✓ Wallpaper set via COSMIC config file");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to write COSMIC config: {}", e);
|
||||
}
|
||||
}
|
||||
match std::fs::write(&cosmic_config, config_content) {
|
||||
Ok(_) => {
|
||||
log::info!("Wallpaper set via COSMIC config");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to write COSMIC config: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS)
|
||||
/// Try setting wallpaper via wallpaper crate.
|
||||
fn try_wallpaper_crate(path_str: &str) -> bool {
|
||||
match wallpaper::set_from_path(path_str) {
|
||||
Ok(_) => {
|
||||
log::info!("✓ Wallpaper set successfully via wallpaper crate");
|
||||
return;
|
||||
log::info!("Wallpaper set via wallpaper crate");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("wallpaper crate failed: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Try GNOME via gsettings
|
||||
/// Try setting wallpaper via GNOME gsettings.
|
||||
fn try_gsettings_wallpaper(path_str: &str) -> bool {
|
||||
let uri = format!("file://{}", path_str);
|
||||
log::info!("Trying gsettings with URI: {}", uri);
|
||||
|
||||
match std::process::Command::new("gsettings")
|
||||
.args(&[
|
||||
"set",
|
||||
"org.gnome.desktop.background",
|
||||
"picture-uri",
|
||||
&uri,
|
||||
])
|
||||
let output = match std::process::Command::new("gsettings")
|
||||
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("✓ Wallpaper set via gsettings (light mode)");
|
||||
|
||||
// Also set dark mode wallpaper
|
||||
let _ = std::process::Command::new("gsettings")
|
||||
.args(&[
|
||||
"set",
|
||||
"org.gnome.desktop.background",
|
||||
"picture-uri-dark",
|
||||
&uri,
|
||||
])
|
||||
.output();
|
||||
return;
|
||||
}
|
||||
Ok(output) => {
|
||||
log::warn!(
|
||||
"gsettings failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("gsettings command failed: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"gsettings failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Method 4: Try feh (common on tiling WMs like i3, sway)
|
||||
match std::process::Command::new("feh")
|
||||
.args(&["--bg-scale", path_str])
|
||||
log::info!("Wallpaper set via gsettings");
|
||||
|
||||
// Also set dark mode wallpaper.
|
||||
let _ = std::process::Command::new("gsettings")
|
||||
.args([
|
||||
"set",
|
||||
"org.gnome.desktop.background",
|
||||
"picture-uri-dark",
|
||||
&uri,
|
||||
])
|
||||
.output();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via feh.
|
||||
fn try_feh_wallpaper(path_str: &str) -> bool {
|
||||
let output = match std::process::Command::new("feh")
|
||||
.args(["--bg-scale", path_str])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("✓ Wallpaper set via feh");
|
||||
return;
|
||||
}
|
||||
Ok(_) => {
|
||||
log::warn!("feh failed");
|
||||
}
|
||||
Ok(o) => o,
|
||||
Err(_) => {
|
||||
log::warn!("feh not available");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
log::error!("✗ All methods failed to set wallpaper");
|
||||
if output.status.success() {
|
||||
log::info!("Wallpaper set via feh");
|
||||
true
|
||||
} else {
|
||||
log::warn!("feh failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,33 +3,76 @@
|
|||
//
|
||||
// Vector documents (SVG, etc.).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use image::{imageops, DynamicImage, RgbaImage};
|
||||
use resvg::tiny_skia::{self, Pixmap};
|
||||
use resvg::usvg::{Options, Tree};
|
||||
|
||||
use super::ImageHandle;
|
||||
use crate::constant::{FULL_ROTATION, MIN_PIXMAP_SIZE, ROTATION_STEP};
|
||||
|
||||
/// Accumulated transformations for a vector document.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct VectorTransform {
|
||||
/// Rotation in degrees (0, 90, 180, 270).
|
||||
pub rotation: i16,
|
||||
/// Horizontal flip.
|
||||
pub flip_h: bool,
|
||||
/// Vertical flip.
|
||||
pub flip_v: bool,
|
||||
}
|
||||
|
||||
/// Represents a vector document such as SVG.
|
||||
/// For now this only stores the raw data and a rasterized handle.
|
||||
pub struct VectorDocument {
|
||||
pub path: PathBuf,
|
||||
pub raw_data: String,
|
||||
pub handle: iced_image::Handle,
|
||||
/// Cached dimensions of the rasterized representation.
|
||||
/// Parsed SVG document for re-rendering at different scales.
|
||||
document: Tree,
|
||||
/// Native width of the SVG (from viewBox or width attribute).
|
||||
native_width: u32,
|
||||
/// Native height of the SVG (from viewBox or height attribute).
|
||||
native_height: u32,
|
||||
/// Current render scale (1.0 = native size).
|
||||
current_scale: f32,
|
||||
/// Accumulated transformations.
|
||||
transform: VectorTransform,
|
||||
/// Rasterized image at the current scale.
|
||||
pub rendered: DynamicImage,
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Current rendered width.
|
||||
pub width: u32,
|
||||
/// Current rendered height.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl VectorDocument {
|
||||
pub fn open(path: PathBuf) -> anyhow::Result<Self> {
|
||||
let raw_data = std::fs::read_to_string(&path)?;
|
||||
/// Load a vector document from disk.
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let raw_data = std::fs::read_to_string(path)?;
|
||||
|
||||
// TODO: proper SVG parsing and rendering.
|
||||
// For now, use a placeholder size based on a typical default.
|
||||
let (width, height) = (800, 600);
|
||||
let handle = iced_image::Handle::from_rgba(1, 1, vec![0, 0, 0, 0]);
|
||||
// Parse SVG with default options.
|
||||
let options = Options::default();
|
||||
let document = Tree::from_str(&raw_data, &options)?;
|
||||
|
||||
// Get native size from the parsed document.
|
||||
let size = document.size();
|
||||
let native_width = size.width().ceil() as u32;
|
||||
let native_height = size.height().ceil() as u32;
|
||||
|
||||
let transform = VectorTransform::default();
|
||||
|
||||
// Render at native scale (1.0).
|
||||
let (rendered, width, height) =
|
||||
render_document(&document, native_width, native_height, 1.0, &transform)?;
|
||||
let handle = super::create_image_handle(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
raw_data,
|
||||
document,
|
||||
native_width,
|
||||
native_height,
|
||||
current_scale: 1.0,
|
||||
transform,
|
||||
rendered,
|
||||
handle,
|
||||
width,
|
||||
height,
|
||||
|
|
@ -41,14 +84,148 @@ impl VectorDocument {
|
|||
(self.width, self.height)
|
||||
}
|
||||
|
||||
pub fn refresh_handle(&mut self) {
|
||||
// TODO: re-render SVG to DynamicImage and rebuild handle.
|
||||
// Update self.width and self.height accordingly.
|
||||
}
|
||||
/// Extract metadata for this vector document.
|
||||
pub fn extract_meta(&self) -> super::meta::DocumentMeta {
|
||||
let (width, height) = self.dimensions();
|
||||
/// Re-render the SVG at a new scale, preserving transformations.
|
||||
/// Returns true if re-rendering occurred.
|
||||
pub fn render_at_scale(&mut self, scale: f32) -> bool {
|
||||
// Skip if scale hasn't changed
|
||||
if (self.current_scale - scale).abs() < f32::EPSILON {
|
||||
return false;
|
||||
}
|
||||
|
||||
super::meta::build_vector_meta(&self.path, width, height)
|
||||
match render_document(
|
||||
&self.document,
|
||||
self.native_width,
|
||||
self.native_height,
|
||||
scale,
|
||||
&self.transform,
|
||||
) {
|
||||
Ok((rendered, width, height)) => {
|
||||
self.current_scale = scale;
|
||||
self.rendered = rendered;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to re-render SVG at scale {}: {}", scale, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.transform.rotation =
|
||||
(self.transform.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.transform.rotation =
|
||||
(self.transform.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.transform.flip_h = !self.transform.flip_h;
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.transform.flip_v = !self.transform.flip_v;
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Re-render with current scale and transform.
|
||||
fn rerender(&mut self) {
|
||||
if let Ok((rendered, width, height)) = render_document(
|
||||
&self.document,
|
||||
self.native_width,
|
||||
self.native_height,
|
||||
self.current_scale,
|
||||
&self.transform,
|
||||
) {
|
||||
self.rendered = rendered;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract metadata for this vector document.
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
// Report native dimensions in metadata.
|
||||
super::meta::build_vector_meta(path, self.native_width, self.native_height)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the SVG document at a given scale with transformations.
|
||||
fn render_document(
|
||||
document: &Tree,
|
||||
native_width: u32,
|
||||
native_height: u32,
|
||||
scale: f32,
|
||||
transform: &VectorTransform,
|
||||
) -> anyhow::Result<(DynamicImage, u32, u32)> {
|
||||
let width = (((native_width as f32) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
||||
let height = (((native_height as f32) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
||||
|
||||
let mut pixmap =
|
||||
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;
|
||||
|
||||
let ts = tiny_skia::Transform::from_scale(scale, scale);
|
||||
resvg::render(document, ts, &mut pixmap.as_mut());
|
||||
|
||||
let mut image = pixmap_to_dynamic_image(&pixmap);
|
||||
|
||||
// Apply flip transformations
|
||||
if transform.flip_h {
|
||||
image = DynamicImage::ImageRgba8(imageops::flip_horizontal(&image));
|
||||
}
|
||||
if transform.flip_v {
|
||||
image = DynamicImage::ImageRgba8(imageops::flip_vertical(&image));
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
image = match transform.rotation {
|
||||
90 => DynamicImage::ImageRgba8(imageops::rotate90(&image)),
|
||||
180 => DynamicImage::ImageRgba8(imageops::rotate180(&image)),
|
||||
270 => DynamicImage::ImageRgba8(imageops::rotate270(&image)),
|
||||
_ => image,
|
||||
};
|
||||
|
||||
let final_width = image.width();
|
||||
let final_height = image.height();
|
||||
|
||||
Ok((image, final_width, final_height))
|
||||
}
|
||||
|
||||
/// Convert a tiny_skia Pixmap to a DynamicImage.
|
||||
fn pixmap_to_dynamic_image(pixmap: &Pixmap) -> DynamicImage {
|
||||
let width = pixmap.width();
|
||||
let height = pixmap.height();
|
||||
|
||||
// tiny_skia uses premultiplied alpha, we need to unpremultiply for image crate
|
||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
||||
for pixel in pixmap.pixels() {
|
||||
let a = pixel.alpha();
|
||||
if a == 0 {
|
||||
pixels.extend_from_slice(&[0, 0, 0, 0]);
|
||||
} else {
|
||||
// Unpremultiply: color = premultiplied_color * 255 / alpha
|
||||
let r = (pixel.red() as u16 * 255 / a as u16) as u8;
|
||||
let g = (pixel.green() as u16 * 255 / a as u16) as u8;
|
||||
let b = (pixel.blue() as u16 * 255 / a as u16) as u8;
|
||||
pixels.extend_from_slice(&[r, g, b, a]);
|
||||
}
|
||||
}
|
||||
|
||||
let rgba_image = RgbaImage::from_raw(width, height, pixels)
|
||||
.expect("Failed to create RgbaImage from pixmap data");
|
||||
|
||||
DynamicImage::ImageRgba8(rgba_image)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,88 +1,71 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/message.rs
|
||||
//
|
||||
// All application messages (events, user actions, signals).
|
||||
// Application messages: events, user actions, and internal signals.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app::ContextPage;
|
||||
|
||||
/// Messages emitted by user actions, async I/O, or internal signals.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppMessage {
|
||||
// === File / Navigation ===
|
||||
/// Open a file at the given path.
|
||||
// File / navigation.
|
||||
#[allow(dead_code)]
|
||||
OpenPath(PathBuf),
|
||||
/// Navigate to the next document in folder.
|
||||
NextDocument,
|
||||
/// Navigate to the previous document in folder.
|
||||
PrevDocument,
|
||||
GotoPage(u32),
|
||||
GenerateThumbnailPage(u32),
|
||||
|
||||
// === Transformations ===
|
||||
/// Rotate 90° clockwise.
|
||||
// Transformations.
|
||||
RotateCW,
|
||||
/// Rotate 90° counter-clockwise.
|
||||
RotateCCW,
|
||||
/// Flip horizontally (mirror).
|
||||
FlipHorizontal,
|
||||
/// Flip vertically.
|
||||
FlipVertical,
|
||||
|
||||
// === Zoom ===
|
||||
/// Zoom in by a fixed step.
|
||||
// View / zoom.
|
||||
ZoomIn,
|
||||
/// Zoom out by a fixed step.
|
||||
ZoomOut,
|
||||
/// Reset zoom to 100%.
|
||||
ZoomReset,
|
||||
/// Fit document to window.
|
||||
ZoomFit,
|
||||
/// Update zoom and pan from viewer (mouse interaction).
|
||||
ViewerStateChanged { scale: f32, offset_x: f32, offset_y: f32 },
|
||||
ViewerStateChanged {
|
||||
scale: f32,
|
||||
offset_x: f32,
|
||||
offset_y: f32,
|
||||
},
|
||||
|
||||
// === Pan ===
|
||||
/// Pan image left.
|
||||
// Pan control.
|
||||
PanLeft,
|
||||
/// Pan image right.
|
||||
PanRight,
|
||||
/// Pan image up.
|
||||
PanUp,
|
||||
/// Pan image down.
|
||||
PanDown,
|
||||
/// Reset pan to center.
|
||||
PanReset,
|
||||
|
||||
// === Tool Modes ===
|
||||
/// Toggle crop mode.
|
||||
// Tool modes.
|
||||
ToggleCropMode,
|
||||
/// Toggle scale mode.
|
||||
ToggleScaleMode,
|
||||
|
||||
// === Panels (COSMIC-managed) ===
|
||||
/// Toggle a context drawer page.
|
||||
// Panels.
|
||||
ToggleContextPage(ContextPage),
|
||||
/// Toggle the nav bar (left panel) visibility.
|
||||
ToggleNavBar,
|
||||
|
||||
// === Metadata ===
|
||||
/// Refresh metadata from the current document.
|
||||
// Metadata.
|
||||
#[allow(dead_code)]
|
||||
RefreshMetadata,
|
||||
|
||||
// === Wallpaper ===
|
||||
/// Set current image as wallpaper.
|
||||
// Wallpaper.
|
||||
SetAsWallpaper,
|
||||
|
||||
// === Errors ===
|
||||
/// Display an error message.
|
||||
// Errors.
|
||||
#[allow(dead_code)]
|
||||
ShowError(String),
|
||||
/// Clear the current error.
|
||||
#[allow(dead_code)]
|
||||
ClearError,
|
||||
|
||||
/// Fallback for unhandled or no-op cases.
|
||||
// UI refresh.
|
||||
RefreshView,
|
||||
|
||||
// Fallback.
|
||||
#[allow(dead_code)]
|
||||
NoOp,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ pub mod update;
|
|||
|
||||
mod view;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic::app::{context_drawer, Core};
|
||||
use cosmic::cosmic_config::{self, CosmicConfigEntry};
|
||||
use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers};
|
||||
use cosmic::iced::time;
|
||||
use cosmic::iced::window;
|
||||
use cosmic::iced::Subscription;
|
||||
use cosmic::widget::nav_bar;
|
||||
|
|
@ -91,13 +94,16 @@ impl cosmic::Application for Noctua {
|
|||
document::file::open_initial_path(&mut model, path);
|
||||
}
|
||||
|
||||
// Initialize empty nav bar (for folder/thumbnail navigation later).
|
||||
// Initialize nav bar model (required for COSMIC to show toggle icon).
|
||||
let nav = nav_bar::Model::default();
|
||||
|
||||
// Apply persisted panel states.
|
||||
core.window.show_context = config.context_drawer_visible;
|
||||
core.nav_bar_set_toggled(config.nav_bar_visible);
|
||||
|
||||
// Start thumbnail generation for initial document if applicable.
|
||||
let init_task = start_thumbnail_generation(&model);
|
||||
|
||||
(
|
||||
Self {
|
||||
core,
|
||||
|
|
@ -107,7 +113,7 @@ impl cosmic::Application for Noctua {
|
|||
config,
|
||||
config_handler,
|
||||
},
|
||||
Task::none(),
|
||||
init_task,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -117,15 +123,18 @@ impl cosmic::Application for Noctua {
|
|||
|
||||
fn update(&mut self, message: Self::Message) -> Task<Action<Self::Message>> {
|
||||
match &message {
|
||||
// Handle nav bar toggle. I think this is ugly but it works.
|
||||
AppMessage::ToggleNavBar => {
|
||||
self.config.nav_bar_visible = !self.config.nav_bar_visible;
|
||||
self.core.nav_bar_set_toggled(self.config.nav_bar_visible);
|
||||
self.core.nav_bar_toggle();
|
||||
let is_visible = self.core.nav_bar_active();
|
||||
self.config.nav_bar_visible = is_visible;
|
||||
self.save_config();
|
||||
|
||||
if is_visible {
|
||||
return start_thumbnail_generation_task(&self.model);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Handle context panel toggle.
|
||||
AppMessage::ToggleContextPage(page) => {
|
||||
if self.context_page == *page {
|
||||
self.core.window.show_context = !self.core.window.show_context;
|
||||
|
|
@ -138,11 +147,22 @@ impl cosmic::Application for Noctua {
|
|||
return Task::none();
|
||||
}
|
||||
|
||||
AppMessage::OpenPath(_) | AppMessage::NextDocument | AppMessage::PrevDocument => {
|
||||
let result = update::update(&mut self.model, &message, &self.config);
|
||||
let thumb_task = start_thumbnail_generation_task(&self.model);
|
||||
return match result {
|
||||
update::UpdateResult::None => thumb_task,
|
||||
update::UpdateResult::Task(task) => Task::batch([task, thumb_task]),
|
||||
};
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
update::update(&mut self.model, message);
|
||||
Task::none()
|
||||
match update::update(&mut self.model, &message, &self.config) {
|
||||
update::UpdateResult::None => Task::none(),
|
||||
update::UpdateResult::Task(task) => task,
|
||||
}
|
||||
}
|
||||
|
||||
fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
|
||||
|
|
@ -154,7 +174,7 @@ impl cosmic::Application for Noctua {
|
|||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Self::Message> {
|
||||
view::view(&self.model)
|
||||
view::view(&self.model, &self.config)
|
||||
}
|
||||
|
||||
fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<'_, Self::Message>> {
|
||||
|
|
@ -171,12 +191,22 @@ impl cosmic::Application for Noctua {
|
|||
Some(&self.nav)
|
||||
}
|
||||
|
||||
fn nav_bar(&self) -> Option<Element<'_, Action<Self::Message>>> {
|
||||
if !self.core.nav_bar_active() {
|
||||
return None;
|
||||
}
|
||||
view::nav_bar(&self.model)
|
||||
}
|
||||
|
||||
fn footer(&self) -> Option<Element<'_, Self::Message>> {
|
||||
Some(view::footer::view(&self.model))
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Self::Message> {
|
||||
keyboard::on_key_press(handle_key_press)
|
||||
Subscription::batch([
|
||||
keyboard::on_key_press(handle_key_press),
|
||||
thumbnail_refresh_subscription(self),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +256,7 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
|||
}
|
||||
|
||||
// Zoom.
|
||||
Key::Character("+" |"=") => Some(ZoomIn),
|
||||
Key::Character("+" | "=") => Some(ZoomIn),
|
||||
Key::Character("-") => Some(ZoomOut),
|
||||
Key::Character("1") => Some(ZoomReset),
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit),
|
||||
|
|
@ -250,3 +280,38 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Thumbnail Helpers
|
||||
// =============================================================================
|
||||
|
||||
fn start_thumbnail_generation(model: &AppModel) -> Task<Action<AppMessage>> {
|
||||
start_thumbnail_generation_task(model)
|
||||
}
|
||||
|
||||
fn start_thumbnail_generation_task(model: &AppModel) -> Task<Action<AppMessage>> {
|
||||
if let Some(doc) = &model.document {
|
||||
let page_count = doc.page_count().unwrap_or(0);
|
||||
if page_count > 0 && !doc.thumbnails_ready() {
|
||||
return Task::batch([
|
||||
Task::done(Action::App(AppMessage::GenerateThumbnailPage(0))),
|
||||
Task::done(Action::App(AppMessage::RefreshView)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn thumbnail_refresh_subscription(app: &Noctua) -> Subscription<AppMessage> {
|
||||
let needs_refresh = app
|
||||
.model
|
||||
.document
|
||||
.as_ref()
|
||||
.map_or(false, |doc| doc.is_multi_page() && !doc.thumbnails_ready());
|
||||
|
||||
if needs_refresh {
|
||||
time::every(Duration::from_millis(100)).map(|_| AppMessage::RefreshView)
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/model.rs
|
||||
//
|
||||
// Global application state.
|
||||
// Application state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -9,20 +9,18 @@ use crate::app::document::meta::DocumentMeta;
|
|||
use crate::app::document::DocumentContent;
|
||||
use crate::config::AppConfig;
|
||||
|
||||
/// How the document is currently fitted into the window.
|
||||
// =============================================================================
|
||||
// Enums
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ViewMode {
|
||||
/// Fit document to available window size.
|
||||
Fit,
|
||||
/// Display at 100% (1.0 scale).
|
||||
ActualSize,
|
||||
/// Custom zoom factor (e.g., 0.5 = 50%, 2.0 = 200%).
|
||||
Custom(f32),
|
||||
}
|
||||
|
||||
impl ViewMode {
|
||||
/// Return the effective zoom factor for this mode.
|
||||
/// For `Fit`, returns `None` since the factor depends on window size.
|
||||
pub fn zoom_factor(&self) -> Option<f32> {
|
||||
match self {
|
||||
ViewMode::Fit => None,
|
||||
|
|
@ -32,7 +30,6 @@ impl ViewMode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Current editing / interaction mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolMode {
|
||||
None,
|
||||
|
|
@ -40,44 +37,34 @@ pub enum ToolMode {
|
|||
Scale,
|
||||
}
|
||||
|
||||
/// Pan step size in pixels per key press.
|
||||
pub const PAN_STEP: f32 = 50.0;
|
||||
// =============================================================================
|
||||
// Model
|
||||
// =============================================================================
|
||||
|
||||
/// Global application state.
|
||||
#[derive(Debug)]
|
||||
pub struct AppModel {
|
||||
/// Currently opened document (raster/vector/portable).
|
||||
// Document.
|
||||
pub document: Option<DocumentContent>,
|
||||
|
||||
/// Cached metadata for the current document.
|
||||
/// Loaded lazily when the metadata panel is opened.
|
||||
pub metadata: Option<DocumentMeta>,
|
||||
|
||||
/// Path of the currently opened document, if any.
|
||||
pub current_path: Option<PathBuf>,
|
||||
|
||||
/// List of files in the current folder for navigation.
|
||||
// Navigation.
|
||||
pub folder_entries: Vec<PathBuf>,
|
||||
|
||||
/// Index into `folder_entries` of the current file.
|
||||
pub current_index: Option<usize>,
|
||||
|
||||
/// View / zoom state.
|
||||
// View.
|
||||
pub view_mode: ViewMode,
|
||||
|
||||
/// Pan offset (in pixels, relative to centered position).
|
||||
pub pan_x: f32,
|
||||
pub pan_y: f32,
|
||||
|
||||
/// Current tool mode.
|
||||
// Tools.
|
||||
pub tool_mode: ToolMode,
|
||||
|
||||
/// Last error message to be shown in the UI, if any.
|
||||
// UI state.
|
||||
pub error: Option<String>,
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
impl AppModel {
|
||||
/// Construct a new application state from configuration.
|
||||
pub fn new(_config: AppConfig) -> Self {
|
||||
Self {
|
||||
document: None,
|
||||
|
|
@ -90,26 +77,23 @@ impl AppModel {
|
|||
pan_y: 0.0,
|
||||
tool_mode: ToolMode::None,
|
||||
error: None,
|
||||
tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: set an error string.
|
||||
pub fn set_error<S: Into<String>>(&mut self, msg: S) {
|
||||
self.error = Some(msg.into());
|
||||
}
|
||||
|
||||
/// Helper: clear current error.
|
||||
pub fn clear_error(&mut self) {
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// Reset pan offset to center.
|
||||
pub fn reset_pan(&mut self) {
|
||||
self.pan_x = 0.0;
|
||||
self.pan_y = 0.0;
|
||||
}
|
||||
|
||||
/// Get the current zoom factor, if applicable.
|
||||
pub fn zoom_factor(&self) -> Option<f32> {
|
||||
self.view_mode.zoom_factor()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,31 @@
|
|||
//
|
||||
// Application update loop: applies messages to the global model state.
|
||||
|
||||
use cosmic::{Action, Task};
|
||||
|
||||
use super::document;
|
||||
use super::message::AppMessage;
|
||||
use super::model::{AppModel, ToolMode, ViewMode, PAN_STEP};
|
||||
use super::model::{AppModel, ToolMode, ViewMode};
|
||||
use crate::config::AppConfig;
|
||||
|
||||
/// Central update function applying messages to the model.
|
||||
///
|
||||
/// Panel toggle messages (ToggleContextPage) are handled directly in
|
||||
/// `Noctua::update()` since they affect COSMIC's Core state.
|
||||
pub fn update(model: &mut AppModel, msg: AppMessage) {
|
||||
// =============================================================================
|
||||
// Update Result
|
||||
// =============================================================================
|
||||
|
||||
pub enum UpdateResult {
|
||||
None,
|
||||
Task(Task<Action<AppMessage>>),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Update Function
|
||||
// =============================================================================
|
||||
|
||||
pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> UpdateResult {
|
||||
match msg {
|
||||
// ===== File / navigation ==========================================================
|
||||
// ---- File / navigation ----------------------------------------------------
|
||||
AppMessage::OpenPath(path) => {
|
||||
document::file::open_single_file(model, &path);
|
||||
document::file::open_single_file(model, path);
|
||||
}
|
||||
|
||||
AppMessage::NextDocument => {
|
||||
|
|
@ -26,42 +38,79 @@ pub fn update(model: &mut AppModel, msg: AppMessage) {
|
|||
document::file::navigate_prev(model);
|
||||
}
|
||||
|
||||
// ===== View / zoom ===============================================================
|
||||
AppMessage::ZoomIn => zoom_in(model),
|
||||
AppMessage::ZoomOut => zoom_out(model),
|
||||
AppMessage::GotoPage(page) => {
|
||||
if let Some(doc) = &mut model.document {
|
||||
if let Err(e) = doc.goto_page(*page) {
|
||||
log::error!("Failed to navigate to page {}: {}", page, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Thumbnail generation -------------------------------------------------
|
||||
AppMessage::GenerateThumbnailPage(page) => {
|
||||
if let Some(doc) = &mut model.document {
|
||||
if let Some(next_page) = doc.generate_thumbnail_page(*page) {
|
||||
return UpdateResult::Task(Task::batch([
|
||||
Task::future(async move {
|
||||
Action::App(AppMessage::GenerateThumbnailPage(next_page))
|
||||
}),
|
||||
Task::done(Action::App(AppMessage::RefreshView)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::RefreshView => {
|
||||
model.tick += 1;
|
||||
}
|
||||
|
||||
// ---- View / zoom ---------------------------------------------------------
|
||||
AppMessage::ZoomIn => {
|
||||
zoom_in(model, config);
|
||||
}
|
||||
|
||||
AppMessage::ZoomOut => {
|
||||
zoom_out(model, config);
|
||||
}
|
||||
|
||||
AppMessage::ZoomReset => {
|
||||
model.view_mode = ViewMode::ActualSize;
|
||||
model.reset_pan();
|
||||
}
|
||||
|
||||
AppMessage::ZoomFit => {
|
||||
model.view_mode = ViewMode::Fit;
|
||||
model.reset_pan();
|
||||
}
|
||||
AppMessage::ViewerStateChanged { scale, offset_x, offset_y } => {
|
||||
// Update model state from viewer (mouse interaction)
|
||||
model.view_mode = ViewMode::Custom(scale);
|
||||
model.pan_x = offset_x;
|
||||
model.pan_y = offset_y;
|
||||
|
||||
AppMessage::ViewerStateChanged {
|
||||
scale,
|
||||
offset_x,
|
||||
offset_y,
|
||||
} => {
|
||||
model.view_mode = ViewMode::Custom(*scale);
|
||||
model.pan_x = *offset_x;
|
||||
model.pan_y = *offset_y;
|
||||
}
|
||||
|
||||
// ===== Pan control (Ctrl + arrow keys) ===========================================
|
||||
// ---- Pan control ---------------------------------------------------------
|
||||
AppMessage::PanLeft => {
|
||||
model.pan_x -= PAN_STEP;
|
||||
model.pan_x -= config.pan_step;
|
||||
}
|
||||
AppMessage::PanRight => {
|
||||
model.pan_x += PAN_STEP;
|
||||
model.pan_x += config.pan_step;
|
||||
}
|
||||
AppMessage::PanUp => {
|
||||
model.pan_y -= PAN_STEP;
|
||||
model.pan_y -= config.pan_step;
|
||||
}
|
||||
AppMessage::PanDown => {
|
||||
model.pan_y += PAN_STEP;
|
||||
model.pan_y += config.pan_step;
|
||||
}
|
||||
AppMessage::PanReset => {
|
||||
model.reset_pan();
|
||||
}
|
||||
|
||||
// ===== Tool modes ================================================================
|
||||
// ---- Tool modes ----------------------------------------------------------
|
||||
AppMessage::ToggleCropMode => {
|
||||
model.tool_mode = if model.tool_mode == ToolMode::Crop {
|
||||
ToolMode::None
|
||||
|
|
@ -77,100 +126,95 @@ pub fn update(model: &mut AppModel, msg: AppMessage) {
|
|||
};
|
||||
}
|
||||
|
||||
// ===== Document transformations ==================================================
|
||||
// ---- Document transformations --------------------------------------------
|
||||
AppMessage::FlipHorizontal => {
|
||||
if let Some(doc) = &mut model.document {
|
||||
document::transform::flip_horizontal(doc);
|
||||
doc.flip_horizontal();
|
||||
}
|
||||
}
|
||||
AppMessage::FlipVertical => {
|
||||
if let Some(doc) = &mut model.document {
|
||||
document::transform::flip_vertical(doc);
|
||||
doc.flip_vertical();
|
||||
}
|
||||
}
|
||||
AppMessage::RotateCW => {
|
||||
if let Some(doc) = &mut model.document {
|
||||
document::transform::rotate_cw(doc);
|
||||
doc.rotate_cw();
|
||||
}
|
||||
}
|
||||
AppMessage::RotateCCW => {
|
||||
if let Some(doc) = &mut model.document {
|
||||
document::transform::rotate_ccw(doc);
|
||||
doc.rotate_ccw();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Metadata ==================================================================
|
||||
// ---- Metadata ------------------------------------------------------------
|
||||
AppMessage::RefreshMetadata => {
|
||||
refresh_metadata(model);
|
||||
}
|
||||
|
||||
// ===== Wallpaper =================================================================
|
||||
// ---- Wallpaper -----------------------------------------------------------
|
||||
AppMessage::SetAsWallpaper => {
|
||||
set_as_wallpaper(model);
|
||||
}
|
||||
|
||||
// ===== Error handling ============================================================
|
||||
// ---- Error handling ------------------------------------------------------
|
||||
AppMessage::ShowError(msg) => {
|
||||
model.set_error(msg);
|
||||
model.set_error(msg.clone());
|
||||
}
|
||||
AppMessage::ClearError => {
|
||||
model.clear_error();
|
||||
}
|
||||
|
||||
// ===== Handled elsewhere =========================================================
|
||||
AppMessage::ToggleContextPage(_) => {
|
||||
// Handled in Noctua::update() directly.
|
||||
}
|
||||
// ---- Handled elsewhere ---------------------------------------------------
|
||||
AppMessage::ToggleContextPage(_) | AppMessage::ToggleNavBar => {}
|
||||
|
||||
AppMessage::ToggleNavBar => {
|
||||
// Handled in Noctua::update() directly.
|
||||
}
|
||||
|
||||
AppMessage::NoOp => {
|
||||
// Intentionally do nothing.
|
||||
}
|
||||
AppMessage::NoOp => {}
|
||||
}
|
||||
|
||||
UpdateResult::None
|
||||
}
|
||||
|
||||
/// Increment zoom level by 10%.
|
||||
fn zoom_in(model: &mut AppModel) {
|
||||
// =============================================================================
|
||||
// View Helpers
|
||||
// =============================================================================
|
||||
|
||||
fn zoom_in(model: &mut AppModel, config: &AppConfig) {
|
||||
let current = current_zoom(model);
|
||||
let new_zoom = (current * 1.1).clamp(0.05, 20.0);
|
||||
let new_zoom = (current * config.scale_step).clamp(config.min_scale, config.max_scale);
|
||||
let factor = new_zoom / current;
|
||||
model.pan_x *= factor;
|
||||
model.pan_y *= factor;
|
||||
model.view_mode = ViewMode::Custom(new_zoom);
|
||||
}
|
||||
|
||||
/// Decrement zoom level by ~9% (inverse of 1.1).
|
||||
fn zoom_out(model: &mut AppModel) {
|
||||
fn zoom_out(model: &mut AppModel, config: &AppConfig) {
|
||||
let current = current_zoom(model);
|
||||
let new_zoom = (current / 1.1).clamp(0.05, 20.0);
|
||||
let new_zoom = (current / config.scale_step).clamp(config.min_scale, config.max_scale);
|
||||
let factor = new_zoom / current;
|
||||
model.pan_x *= factor;
|
||||
model.pan_y *= factor;
|
||||
model.view_mode = ViewMode::Custom(new_zoom);
|
||||
}
|
||||
|
||||
/// Extract the current effective zoom factor from the view mode.
|
||||
fn current_zoom(model: &AppModel) -> f32 {
|
||||
match model.view_mode {
|
||||
ViewMode::Fit => 1.0,
|
||||
ViewMode::ActualSize => 1.0,
|
||||
ViewMode::Fit | ViewMode::ActualSize => 1.0,
|
||||
ViewMode::Custom(z) => z,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh metadata from the current document.
|
||||
fn refresh_metadata(model: &mut AppModel) {
|
||||
model.metadata = model.document.as_ref().map(|doc| doc.extract_meta());
|
||||
model.metadata = match (&model.document, &model.current_path) {
|
||||
(Some(doc), Some(path)) => Some(doc.extract_meta(path)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Set the current image as desktop wallpaper.
|
||||
fn set_as_wallpaper(model: &mut AppModel) {
|
||||
let Some(path) = model.current_path.as_ref() else {
|
||||
model.set_error("No image loaded");
|
||||
return;
|
||||
};
|
||||
|
||||
let path = path.clone();
|
||||
|
||||
// Spawn async task to set wallpaper
|
||||
tokio::spawn(async move {
|
||||
document::set_as_wallpaper(&path);
|
||||
});
|
||||
document::set_as_wallpaper(path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ use cosmic::Element;
|
|||
use super::image_viewer::Viewer;
|
||||
use crate::app::model::ViewMode;
|
||||
use crate::app::{AppMessage, AppModel};
|
||||
use crate::config::AppConfig;
|
||||
use crate::fl;
|
||||
|
||||
/// Render the center canvas area with the current document.
|
||||
pub fn view(model: &AppModel) -> Element<'_, AppMessage> {
|
||||
pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMessage> {
|
||||
if let Some(doc) = &model.document {
|
||||
let handle = doc.handle();
|
||||
|
||||
|
|
@ -25,21 +26,20 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> {
|
|||
};
|
||||
|
||||
// Use our forked viewer with external state control
|
||||
// scale_step is (scale_step - 1.0) because viewer uses additive step
|
||||
let img_viewer = Viewer::new(handle)
|
||||
.with_state(scale, model.pan_x, model.pan_y)
|
||||
.on_state_change(|scale, offset_x, offset_y| {
|
||||
AppMessage::ViewerStateChanged {
|
||||
scale,
|
||||
offset_x,
|
||||
offset_y,
|
||||
}
|
||||
.on_state_change(|scale, offset_x, offset_y| AppMessage::ViewerStateChanged {
|
||||
scale,
|
||||
offset_x,
|
||||
offset_y,
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.content_fit(content_fit)
|
||||
.min_scale(0.1)
|
||||
.max_scale(20.0)
|
||||
.scale_step(0.1);
|
||||
.min_scale(config.min_scale)
|
||||
.max_scale(config.max_scale)
|
||||
.scale_step(config.scale_step - 1.0);
|
||||
|
||||
container(img_viewer)
|
||||
.width(Length::Fill)
|
||||
|
|
|
|||
|
|
@ -9,17 +9,19 @@ use cosmic::Element;
|
|||
|
||||
use crate::app::model::{AppModel, ViewMode};
|
||||
use crate::app::AppMessage;
|
||||
use crate::fl;
|
||||
|
||||
/// Build the footer element with zoom controls and document info.
|
||||
pub fn view(model: &AppModel) -> Element<'_, AppMessage> {
|
||||
// Zoom level display.
|
||||
let zoom_text = match model.view_mode {
|
||||
ViewMode::Fit => "Fit".to_string(),
|
||||
ViewMode::Fit => fl!("status-zoom-fit"),
|
||||
_ => {
|
||||
if let Some(zoom) = model.zoom_factor() {
|
||||
format!("{}%", (zoom * 100.0).round() as i32)
|
||||
let percent = (zoom * 100.0).round() as i32;
|
||||
fl!("status-zoom-percent", percent: percent)
|
||||
} else {
|
||||
"Fit".to_string()
|
||||
fl!("status-zoom-fit")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -27,7 +29,7 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> {
|
|||
// Document dimensions (if available).
|
||||
let doc_info = if let Some(ref doc) = model.document {
|
||||
let (w, h) = doc.dimensions();
|
||||
format!("{}×{}", w, h)
|
||||
fl!("status-doc-dimensions", width: w, height: h)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
|
@ -36,7 +38,7 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> {
|
|||
let nav_info = if !model.folder_entries.is_empty() {
|
||||
let current = model.current_index.map(|i| i + 1).unwrap_or(0);
|
||||
let total = model.folder_entries.len();
|
||||
format!("{} / {}", current, total)
|
||||
fl!("status-nav-position", current: current, total: total)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
|
@ -71,7 +73,7 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> {
|
|||
.push(text::body(doc_info))
|
||||
// Separator.
|
||||
.push_maybe(if !model.folder_entries.is_empty() {
|
||||
Some(text::body(" | "))
|
||||
Some(text::body(fl!("status-separator")))
|
||||
} else {
|
||||
None
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
//
|
||||
// Header bar buttons (navigation, rotation, flip).
|
||||
|
||||
use cosmic::iced::Length;
|
||||
use cosmic::widget::{button, horizontal_space, icon};
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, horizontal_space, icon, row};
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::app::message::AppMessage;
|
||||
|
|
@ -15,38 +15,43 @@ use crate::app::ContextPage;
|
|||
pub fn header_start(model: &AppModel) -> Vec<Element<'_, AppMessage>> {
|
||||
let has_doc = model.document.is_some();
|
||||
|
||||
// Left: Nav toggle + Navigation
|
||||
let left_controls = row()
|
||||
.push(
|
||||
button::icon(icon::from_name("go-previous-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::PrevDocument)),
|
||||
)
|
||||
.push(
|
||||
button::icon(icon::from_name("go-next-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::NextDocument)),
|
||||
);
|
||||
|
||||
// Center: Transformations (horizontally centered)
|
||||
let center_controls = row()
|
||||
//.align_y(Alignment::Center)
|
||||
.push(
|
||||
button::icon(icon::from_name("object-rotate-left-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::RotateCCW)),
|
||||
)
|
||||
.push(
|
||||
button::icon(icon::from_name("object-rotate-right-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::RotateCW)),
|
||||
)
|
||||
.push(horizontal_space().width(Length::Fixed(12.0)))
|
||||
.push(
|
||||
button::icon(icon::from_name("object-flip-horizontal-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::FlipHorizontal)),
|
||||
)
|
||||
.push(
|
||||
button::icon(icon::from_name("object-flip-vertical-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::FlipVertical)),
|
||||
);
|
||||
|
||||
vec![
|
||||
// Nav bar toggle
|
||||
button::icon(icon::from_name("view-sidebar-start-symbolic"))
|
||||
.on_press(AppMessage::ToggleNavBar)
|
||||
.into(),
|
||||
// Spacer
|
||||
horizontal_space().width(Length::Fixed(12.0)).into(),
|
||||
// Navigation: previous / next
|
||||
button::icon(icon::from_name("go-previous-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::PrevDocument))
|
||||
.into(),
|
||||
button::icon(icon::from_name("go-next-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::NextDocument))
|
||||
.into(),
|
||||
// Spacer
|
||||
horizontal_space().width(Length::Fixed(12.0)).into(),
|
||||
// Rotation: counter-clockwise / clockwise
|
||||
button::icon(icon::from_name("object-rotate-left-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::RotateCCW))
|
||||
.into(),
|
||||
button::icon(icon::from_name("object-rotate-right-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::RotateCW))
|
||||
.into(),
|
||||
// Spacer
|
||||
horizontal_space().width(Length::Fixed(12.0)).into(),
|
||||
// Flip: horizontal / vertical
|
||||
button::icon(icon::from_name("object-flip-horizontal-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::FlipHorizontal))
|
||||
.into(),
|
||||
button::icon(icon::from_name("object-flip-vertical-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::FlipVertical))
|
||||
.into(),
|
||||
left_controls.into(),
|
||||
//horizontal_space().width(Length::Fill).into(),
|
||||
center_controls.into(),
|
||||
horizontal_space().width(Length::Fill).into(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
//! Zoom and pan on an image.
|
||||
//! Forked from cosmic::iced to support external state control.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/image_viewer.rs
|
||||
//
|
||||
// Zoom and pan image viewer widget with external state control.
|
||||
// Forked from cosmic::iced to support external state control.
|
||||
|
||||
use cosmic::iced::advanced::widget::{tree::{self, Tree}, Widget};
|
||||
use cosmic::iced::advanced::{
|
||||
Clipboard, Layout, Shell,
|
||||
layout, renderer, image as img_renderer,
|
||||
};
|
||||
use cosmic::iced::advanced::image as img_renderer;
|
||||
use cosmic::iced::advanced::layout;
|
||||
use cosmic::iced::advanced::renderer;
|
||||
use cosmic::iced::advanced::widget::tree::{self, Tree};
|
||||
use cosmic::iced::advanced::widget::Widget;
|
||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell};
|
||||
use cosmic::iced::event::{self, Event};
|
||||
use cosmic::iced::mouse;
|
||||
use cosmic::iced::widget::image::{self, FilterMethod};
|
||||
use cosmic::iced::{
|
||||
ContentFit, Element, Length, Pixels, Point,
|
||||
Radians, Rectangle, Size, Vector,
|
||||
};
|
||||
use cosmic::iced::widget::image::FilterMethod;
|
||||
use cosmic::iced::{ContentFit, Element, Length, Pixels, Point, Radians, Rectangle, Size, Vector};
|
||||
|
||||
use crate::constant::{OFFSET_EPSILON, SCALE_EPSILON};
|
||||
|
||||
/// A frame that displays an image with the ability to zoom in/out and pan.
|
||||
#[allow(missing_debug_implementations)]
|
||||
|
|
@ -26,14 +29,14 @@ pub struct Viewer<Handle, Message> {
|
|||
handle: Handle,
|
||||
filter_method: FilterMethod,
|
||||
content_fit: ContentFit,
|
||||
/// Optional external state to override internal state
|
||||
external_state: Option<(f32, Vector)>, // (scale, offset)
|
||||
/// Optional external state to override internal state (scale, offset)
|
||||
external_state: Option<(f32, Vector)>,
|
||||
/// Optional callback to notify state changes
|
||||
on_state_change: Option<Box<dyn Fn(f32, f32, f32) -> Message>>,
|
||||
}
|
||||
|
||||
impl<Handle, Message> Viewer<Handle, Message> {
|
||||
/// Creates a new [`Viewer`] with the given [`State`].
|
||||
/// Creates a new [`Viewer`] with the given handle.
|
||||
pub fn new<T: Into<Handle>>(handle: T) -> Self {
|
||||
Viewer {
|
||||
handle: handle.into(),
|
||||
|
|
@ -67,7 +70,7 @@ impl<Handle, Message> Viewer<Handle, Message> {
|
|||
}
|
||||
|
||||
/// Sets the [`FilterMethod`] of the [`Viewer`].
|
||||
pub fn filter_method(mut self, filter_method: image::FilterMethod) -> Self {
|
||||
pub fn filter_method(mut self, filter_method: FilterMethod) -> Self {
|
||||
self.filter_method = filter_method;
|
||||
self
|
||||
}
|
||||
|
|
@ -122,8 +125,7 @@ impl<Handle, Message> Viewer<Handle, Message> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer, Handle> Widget<Message, Theme, Renderer>
|
||||
for Viewer<Handle, Message>
|
||||
impl<Message, Theme, Renderer, Handle> Widget<Message, Theme, Renderer> for Viewer<Handle, Message>
|
||||
where
|
||||
Renderer: img_renderer::Renderer<Handle = Handle>,
|
||||
Handle: Clone,
|
||||
|
|
@ -135,7 +137,7 @@ where
|
|||
|
||||
fn state(&self) -> tree::State {
|
||||
let mut state = State::new();
|
||||
// Apply external state if provided
|
||||
// Apply external state if provided at creation
|
||||
if let Some((scale, offset)) = self.external_state {
|
||||
state.scale = scale;
|
||||
state.current_offset = offset;
|
||||
|
|
@ -145,21 +147,21 @@ where
|
|||
}
|
||||
|
||||
fn diff(&mut self, tree: &mut Tree) {
|
||||
// Only update state if external state changed and user is not interacting
|
||||
if let Some((scale, offset)) = self.external_state {
|
||||
// Sync external state into internal state when user is not dragging
|
||||
if let Some((ext_scale, ext_offset)) = self.external_state {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
// Only apply external state if user is not currently dragging
|
||||
if !state.is_cursor_grabbed() {
|
||||
// Check if external state differs from current state
|
||||
let scale_changed = (state.scale - scale).abs() > 0.001;
|
||||
let offset_changed = (state.current_offset.x - offset.x).abs() > 0.1
|
||||
|| (state.current_offset.y - offset.y).abs() > 0.1;
|
||||
// Check if external state differs significantly from current state
|
||||
let scale_changed = (state.scale - ext_scale).abs() > SCALE_EPSILON;
|
||||
let offset_changed = (state.current_offset.x - ext_offset.x).abs() > OFFSET_EPSILON
|
||||
|| (state.current_offset.y - ext_offset.y).abs() > OFFSET_EPSILON;
|
||||
|
||||
if scale_changed || offset_changed {
|
||||
state.scale = scale;
|
||||
state.current_offset = offset;
|
||||
state.starting_offset = offset;
|
||||
state.scale = ext_scale;
|
||||
state.current_offset = ext_offset;
|
||||
state.starting_offset = ext_offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,18 +180,12 @@ where
|
|||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
// The raw w/h of the underlying image
|
||||
let image_size = renderer.measure_image(&self.handle);
|
||||
let image_size =
|
||||
Size::new(image_size.width as f32, image_size.height as f32);
|
||||
let image_size = Size::new(image_size.width as f32, image_size.height as f32);
|
||||
|
||||
// The size to be available to the widget prior to `Shrink`ing
|
||||
let raw_size = limits.resolve(self.width, self.height, image_size);
|
||||
|
||||
// The uncropped size of the image when fit to the bounds above
|
||||
let full_size = self.content_fit.fit(image_size, raw_size);
|
||||
|
||||
// Shrink the widget to fit the resized image, if requested
|
||||
let final_size = Size {
|
||||
width: match self.width {
|
||||
Length::Shrink => f32::min(raw_size.width, full_size.width),
|
||||
|
|
@ -224,8 +220,7 @@ where
|
|||
};
|
||||
|
||||
match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. }
|
||||
| mouse::ScrollDelta::Pixels { y, .. } => {
|
||||
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
let previous_scale = state.scale;
|
||||
|
||||
|
|
@ -239,6 +234,22 @@ where
|
|||
})
|
||||
.clamp(self.min_scale, self.max_scale);
|
||||
|
||||
let scale_factor = state.scale / previous_scale;
|
||||
|
||||
// Cursor position relative to the image center (not bounds center)
|
||||
// The image is centered in bounds, so bounds.center() is correct
|
||||
let cursor_to_center = cursor_position - bounds.center();
|
||||
|
||||
// Transform offset so the point under cursor stays stationary
|
||||
// Formula: new_offset = old_offset * scale_factor + cursor_to_center * (scale_factor - 1)
|
||||
let new_offset = Vector::new(
|
||||
state.current_offset.x * scale_factor
|
||||
+ cursor_to_center.x * (scale_factor - 1.0),
|
||||
state.current_offset.y * scale_factor
|
||||
+ cursor_to_center.y * (scale_factor - 1.0),
|
||||
);
|
||||
|
||||
// Clamp offset to valid range
|
||||
let scaled_size = scaled_image_size(
|
||||
renderer,
|
||||
&self.handle,
|
||||
|
|
@ -247,26 +258,8 @@ where
|
|||
self.content_fit,
|
||||
);
|
||||
|
||||
let factor = state.scale / previous_scale - 1.0;
|
||||
|
||||
let cursor_to_center =
|
||||
cursor_position - bounds.center();
|
||||
|
||||
let adjustment = cursor_to_center * factor
|
||||
+ state.current_offset * factor;
|
||||
|
||||
state.current_offset = Vector::new(
|
||||
if scaled_size.width > bounds.width {
|
||||
state.current_offset.x + adjustment.x
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
if scaled_size.height > bounds.height {
|
||||
state.current_offset.y + adjustment.y
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
);
|
||||
state.current_offset =
|
||||
clamp_offset(new_offset, bounds.size(), scaled_size);
|
||||
|
||||
// Notify state change
|
||||
if let Some(ref on_change) = self.on_state_change {
|
||||
|
|
@ -288,7 +281,6 @@ where
|
|||
};
|
||||
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
state.cursor_grabbed_at = Some(cursor_position);
|
||||
state.starting_offset = state.current_offset;
|
||||
|
||||
|
|
@ -300,6 +292,15 @@ where
|
|||
if state.cursor_grabbed_at.is_some() {
|
||||
state.cursor_grabbed_at = None;
|
||||
|
||||
// Notify final state after drag ends
|
||||
if let Some(ref on_change) = self.on_state_change {
|
||||
shell.publish(on_change(
|
||||
state.scale,
|
||||
state.current_offset.x,
|
||||
state.current_offset.y,
|
||||
));
|
||||
}
|
||||
|
||||
event::Status::Captured
|
||||
} else {
|
||||
event::Status::Ignored
|
||||
|
|
@ -316,34 +317,18 @@ where
|
|||
bounds.size(),
|
||||
self.content_fit,
|
||||
);
|
||||
let hidden_width = (scaled_size.width - bounds.width / 2.0)
|
||||
.max(0.0)
|
||||
.round();
|
||||
|
||||
let hidden_height = (scaled_size.height
|
||||
- bounds.height / 2.0)
|
||||
.max(0.0)
|
||||
.round();
|
||||
|
||||
let delta = position - origin;
|
||||
|
||||
let x = if bounds.width < scaled_size.width {
|
||||
(state.starting_offset.x - delta.x)
|
||||
.clamp(-hidden_width, hidden_width)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Pan: subtract delta from starting offset
|
||||
let new_offset = Vector::new(
|
||||
state.starting_offset.x - delta.x,
|
||||
state.starting_offset.y - delta.y,
|
||||
);
|
||||
|
||||
let y = if bounds.height < scaled_size.height {
|
||||
(state.starting_offset.y - delta.y)
|
||||
.clamp(-hidden_height, hidden_height)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
state.current_offset = clamp_offset(new_offset, bounds.size(), scaled_size);
|
||||
|
||||
state.current_offset = Vector::new(x, y);
|
||||
|
||||
// Notify state change on pan
|
||||
// Notify state change during pan
|
||||
if let Some(ref on_change) = self.on_state_change {
|
||||
shell.publish(on_change(
|
||||
state.scale,
|
||||
|
|
@ -395,7 +380,7 @@ where
|
|||
let state = tree.state.downcast_ref::<State>();
|
||||
let bounds = layout.bounds();
|
||||
|
||||
let final_size = scaled_image_size(
|
||||
let scaled_size = scaled_image_size(
|
||||
renderer,
|
||||
&self.handle,
|
||||
state,
|
||||
|
|
@ -403,21 +388,23 @@ where
|
|||
self.content_fit,
|
||||
);
|
||||
|
||||
// Calculate translation to center the image and apply offset
|
||||
let translation = {
|
||||
let diff_w = bounds.width - final_size.width;
|
||||
let diff_h = bounds.height - final_size.height;
|
||||
// How much space is left after placing the scaled image
|
||||
let diff_w = bounds.width - scaled_size.width;
|
||||
let diff_h = bounds.height - scaled_size.height;
|
||||
|
||||
let image_top_left = match self.content_fit {
|
||||
ContentFit::None => {
|
||||
Vector::new(diff_w.max(0.0) / 2.0, diff_h.max(0.0) / 2.0)
|
||||
}
|
||||
_ => Vector::new(diff_w / 2.0, diff_h / 2.0),
|
||||
};
|
||||
// Base position: center the image in the viewport
|
||||
// For images smaller than viewport: center them (diff > 0)
|
||||
// For images larger than viewport: they extend beyond bounds (diff < 0)
|
||||
let center_offset = Vector::new(diff_w / 2.0, diff_h / 2.0);
|
||||
|
||||
image_top_left - state.offset(bounds, final_size)
|
||||
// Apply pan offset (offset moves the "camera", so subtract it)
|
||||
// Positive offset = looking at right/bottom part = image moves left/up
|
||||
center_offset - state.current_offset
|
||||
};
|
||||
|
||||
let drawing_bounds = Rectangle::new(bounds.position(), final_size);
|
||||
let drawing_bounds = Rectangle::new(bounds.position(), scaled_size);
|
||||
|
||||
let render = |renderer: &mut Renderer| {
|
||||
renderer.with_translation(translation, |renderer| {
|
||||
|
|
@ -462,27 +449,31 @@ impl State {
|
|||
State::default()
|
||||
}
|
||||
|
||||
/// Returns the current offset of the [`State`], given the bounds
|
||||
/// of the [`Viewer`] and its image.
|
||||
fn offset(&self, bounds: Rectangle, image_size: Size) -> Vector {
|
||||
let hidden_width =
|
||||
(image_size.width - bounds.width / 2.0).max(0.0).round();
|
||||
|
||||
let hidden_height =
|
||||
(image_size.height - bounds.height / 2.0).max(0.0).round();
|
||||
|
||||
Vector::new(
|
||||
self.current_offset.x.clamp(-hidden_width, hidden_width),
|
||||
self.current_offset.y.clamp(-hidden_height, hidden_height),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns if the cursor is currently grabbed by the [`Viewer`].
|
||||
pub fn is_cursor_grabbed(&self) -> bool {
|
||||
self.cursor_grabbed_at.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps the offset to keep the image within reasonable bounds.
|
||||
///
|
||||
/// The offset represents how far the viewport's center is displaced from the image's center.
|
||||
/// - offset (0, 0) = image centered
|
||||
/// - positive offset = viewing right/bottom part of image
|
||||
/// - negative offset = viewing left/top part of image
|
||||
fn clamp_offset(offset: Vector, viewport_size: Size, image_size: Size) -> Vector {
|
||||
// Maximum allowed offset in each direction
|
||||
// When image is larger than viewport, allow panning up to image edge
|
||||
// When image is smaller than viewport, no panning needed (clamp to 0)
|
||||
let max_offset_x = ((image_size.width - viewport_size.width) / 2.0).max(0.0);
|
||||
let max_offset_y = ((image_size.height - viewport_size.height) / 2.0).max(0.0);
|
||||
|
||||
Vector::new(
|
||||
offset.x.clamp(-max_offset_x, max_offset_x),
|
||||
offset.y.clamp(-max_offset_y, max_offset_y),
|
||||
)
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer, Handle> From<Viewer<Handle, Message>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
|
|
@ -495,9 +486,7 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns the bounds of the underlying image, given the bounds of
|
||||
/// the [`Viewer`]. Scaling will be applied and original aspect ratio
|
||||
/// will be respected.
|
||||
/// Returns the scaled size of the image given current state.
|
||||
pub fn scaled_image_size<Renderer>(
|
||||
renderer: &Renderer,
|
||||
handle: &<Renderer as img_renderer::Renderer>::Handle,
|
||||
|
|
@ -511,12 +500,9 @@ where
|
|||
let Size { width, height } = renderer.measure_image(handle);
|
||||
let image_size = Size::new(width as f32, height as f32);
|
||||
|
||||
// For ContentFit::None, use the raw image size directly with scale
|
||||
// to ensure pixel-perfect rendering at scale 1.0
|
||||
let adjusted_fit = if matches!(content_fit, ContentFit::None) {
|
||||
image_size
|
||||
} else {
|
||||
content_fit.fit(image_size, bounds)
|
||||
let adjusted_fit = match content_fit {
|
||||
ContentFit::None => image_size,
|
||||
_ => content_fit.fit(image_size, bounds),
|
||||
};
|
||||
|
||||
Size::new(
|
||||
|
|
|
|||
|
|
@ -7,13 +7,35 @@ mod canvas;
|
|||
pub mod footer;
|
||||
pub mod header;
|
||||
mod image_viewer;
|
||||
pub mod panel_pages;
|
||||
pub mod panels;
|
||||
|
||||
use cosmic::Element;
|
||||
use cosmic::iced::Length;
|
||||
use cosmic::widget::container;
|
||||
use cosmic::{Action, Element};
|
||||
|
||||
use crate::app::{AppMessage, AppModel};
|
||||
use crate::config::AppConfig;
|
||||
|
||||
/// Main application view (canvas area).
|
||||
pub fn view(model: &AppModel) -> Element<'_, AppMessage> {
|
||||
canvas::view(model)
|
||||
pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMessage> {
|
||||
canvas::view(model, config)
|
||||
}
|
||||
|
||||
/// Navigation bar content (left panel for multi-page documents).
|
||||
///
|
||||
/// Returns None if no multi-page document is loaded.
|
||||
pub fn nav_bar(model: &AppModel) -> Option<Element<'_, Action<AppMessage>>> {
|
||||
let doc = model.document.as_ref()?;
|
||||
if !doc.is_multi_page() {
|
||||
return None;
|
||||
}
|
||||
|
||||
panel_pages::pages_panel(model).map(|panel| {
|
||||
container(panel.map(Action::App))
|
||||
.width(Length::Shrink)
|
||||
.height(Length::Fill)
|
||||
.max_width(200)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
90
src/app/view/panel_pages.rs
Normal file
90
src/app/view/panel_pages.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/panel_pages.rs
|
||||
//
|
||||
// Page thumbnail panel for multi-page documents (PDF, multi-page TIFF).
|
||||
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, column, scrollable, text};
|
||||
use cosmic::widget::image as cosmic_image;
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::app::{AppMessage, AppModel};
|
||||
use crate::constant::THUMBNAIL_MAX_WIDTH;
|
||||
use crate::fl;
|
||||
|
||||
/// Content for the page navigation panel (COSMIC nav_bar).
|
||||
/// Returns None if the current document doesn't support multiple pages.
|
||||
pub fn pages_panel(model: &AppModel) -> Option<Element<'static, AppMessage>> {
|
||||
let doc = model.document.as_ref()?;
|
||||
|
||||
// Only show for multi-page documents.
|
||||
if !doc.is_multi_page() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let page_count = doc.page_count()?;
|
||||
let loaded = doc.thumbnails_loaded();
|
||||
let current_page = doc.current_page()?;
|
||||
|
||||
let mut content = column::with_capacity(page_count as usize + 1)
|
||||
.spacing(12)
|
||||
.padding([12, 8])
|
||||
.align_x(Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Show loading progress if not all thumbnails are ready.
|
||||
if !doc.thumbnails_ready() {
|
||||
let loading_msg = fl!("loading-thumbnails", current: loaded, total: page_count);
|
||||
content = content.push(text::caption(loading_msg));
|
||||
}
|
||||
|
||||
// Build thumbnail list for pages that are already loaded.
|
||||
for page_index in 0..loaded {
|
||||
let is_current = page_index == current_page;
|
||||
|
||||
// Get cached thumbnail handle.
|
||||
let thumbnail_element: Element<'static, AppMessage> =
|
||||
if let Some(handle) = doc.get_thumbnail(page_index) {
|
||||
cosmic_image::Image::new(handle)
|
||||
.width(Length::Fixed(THUMBNAIL_MAX_WIDTH))
|
||||
.into()
|
||||
} else {
|
||||
// Fallback: show page number if no thumbnail.
|
||||
text::body(format!("{}", page_index + 1)).into()
|
||||
};
|
||||
|
||||
// Page number label.
|
||||
let page_label = text::caption(format!("{}", page_index + 1));
|
||||
|
||||
// Combine thumbnail and label in a column.
|
||||
let page_content = column::with_capacity(2)
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center)
|
||||
.push(thumbnail_element)
|
||||
.push(page_label);
|
||||
|
||||
// Wrap in button for navigation.
|
||||
let page_button = if is_current {
|
||||
// Current page: highlighted style.
|
||||
button::custom(page_content)
|
||||
.class(cosmic::theme::Button::Suggested)
|
||||
.padding(4)
|
||||
} else {
|
||||
// Other pages: clickable with standard style.
|
||||
button::custom(page_content)
|
||||
.class(cosmic::theme::Button::Standard)
|
||||
.padding(4)
|
||||
.on_press(AppMessage::GotoPage(page_index))
|
||||
};
|
||||
|
||||
content = content.push(page_button);
|
||||
}
|
||||
|
||||
// Wrap in scrollable container.
|
||||
Some(
|
||||
scrollable(content)
|
||||
.width(Length::Shrink)
|
||||
.height(Length::Fill)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
|
@ -17,11 +17,8 @@ pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> {
|
|||
// Header with action icons
|
||||
content = content.push(panel_header(model));
|
||||
|
||||
// Display document metadata if available.
|
||||
if let Some(ref doc) = model.document {
|
||||
// Use the unified interface to extract metadata.
|
||||
let meta = doc.extract_meta();
|
||||
|
||||
// Display document metadata if available (cached in model).
|
||||
if let Some(ref meta) = model.metadata {
|
||||
// --- Basic Information Section ---
|
||||
content = content
|
||||
.push(section_header(fl!("meta-section-file")))
|
||||
|
|
@ -72,7 +69,7 @@ pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> {
|
|||
}
|
||||
|
||||
if let Some(iso) = exif.iso {
|
||||
content = content.push(meta_row(fl!("meta-iso"), format!("ISO {}", iso)));
|
||||
content = content.push(meta_row(fl!("meta-iso"), fl!("meta-iso", iso: iso)));
|
||||
}
|
||||
|
||||
if let Some(ref focal) = exif.focal_length {
|
||||
|
|
@ -88,7 +85,10 @@ pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> {
|
|||
// --- File Path (at the bottom, less prominent) ---
|
||||
content = content
|
||||
.push(divider::horizontal::light())
|
||||
.push(meta_row_small(fl!("meta-path"), meta.basic.file_path.clone()));
|
||||
.push(meta_row_small(
|
||||
fl!("meta-path"),
|
||||
meta.basic.file_path.clone(),
|
||||
));
|
||||
} else {
|
||||
content = content.push(text::body(fl!("no-document")));
|
||||
}
|
||||
|
|
@ -130,8 +130,8 @@ fn panel_header(model: &AppModel) -> Element<'static, AppMessage> {
|
|||
.push(horizontal_space().width(Length::Fill))
|
||||
.push(
|
||||
button::icon(icon::from_name("image-x-generic-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::SetAsWallpaper))
|
||||
.tooltip(fl!("action-set-wallpaper"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::SetAsWallpaper)),
|
||||
)
|
||||
// .push(
|
||||
// button::icon(icon::from_name("system-run-symbolic"))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::Cosmi
|
|||
use std::path::PathBuf;
|
||||
|
||||
/// Global configuration for the application.
|
||||
#[derive(Debug, Clone, CosmicConfigEntry, Eq, PartialEq)]
|
||||
#[derive(Debug, Clone, CosmicConfigEntry, PartialEq)]
|
||||
#[version = 1]
|
||||
pub struct AppConfig {
|
||||
/// Optional default directory to open images from.
|
||||
|
|
@ -16,6 +16,14 @@ pub struct AppConfig {
|
|||
pub nav_bar_visible: bool,
|
||||
/// Whether the context drawer (right panel) is visible.
|
||||
pub context_drawer_visible: bool,
|
||||
/// Scale step factor for keyboard zoom (e.g., 1.1 = 10% per step).
|
||||
pub scale_step: f32,
|
||||
/// Pan step size in pixels per key press.
|
||||
pub pan_step: f32,
|
||||
/// Minimum zoom scale (e.g., 0.1 = 10%).
|
||||
pub min_scale: f32,
|
||||
/// Maximum zoom scale (e.g., 20.0 = 2000%).
|
||||
pub max_scale: f32,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
|
|
@ -24,6 +32,10 @@ impl Default for AppConfig {
|
|||
default_image_dir: dirs::picture_dir().or_else(dirs::home_dir),
|
||||
nav_bar_visible: false,
|
||||
context_drawer_visible: false,
|
||||
scale_step: 1.1,
|
||||
pan_step: 50.0,
|
||||
min_scale: 0.1,
|
||||
max_scale: 8.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
src/constant.rs
Normal file
40
src/constant.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/constant.rs
|
||||
//
|
||||
// Application constants that should not be changed by the user.
|
||||
|
||||
/// Rotation step in degrees (90 = quarter turn).
|
||||
pub const ROTATION_STEP: i16 = 90;
|
||||
|
||||
/// Full rotation in degrees (for modulo calculation in angle normalization).
|
||||
pub const FULL_ROTATION: i16 = 360;
|
||||
|
||||
/// Minutes per degree (GPS coordinate conversion: DMS to decimal degrees).
|
||||
pub const MINUTES_PER_DEGREE: f64 = 60.0;
|
||||
|
||||
/// Seconds per degree (GPS coordinate conversion: DMS to decimal degrees).
|
||||
pub const SECONDS_PER_DEGREE: f64 = 3600.0;
|
||||
|
||||
/// Minimum pixmap size for SVG rendering (prevents 0x0 images).
|
||||
pub const MIN_PIXMAP_SIZE: u32 = 1;
|
||||
|
||||
/// Tolerance for scale comparisons (float precision in zoom synchronization).
|
||||
pub const SCALE_EPSILON: f32 = 0.0001;
|
||||
|
||||
/// Tolerance for offset comparisons (float precision in pan synchronization).
|
||||
pub const OFFSET_EPSILON: f32 = 0.01;
|
||||
|
||||
/// Maximum thumbnail width in pixels (nav bar page thumbnails).
|
||||
pub const THUMBNAIL_MAX_WIDTH: f32 = 100.0;
|
||||
|
||||
/// Thumbnail cache directory name.
|
||||
pub const CACHE_DIR: &str = "noctua";
|
||||
|
||||
/// Thumbnail file extension.
|
||||
pub const THUMBNAIL_EXT: &str = "png";
|
||||
|
||||
/// Default render scale for PDF pages.
|
||||
pub const PDF_RENDER_SCALE: f64 = 2.0;
|
||||
|
||||
/// Thumbnail render scale (smaller for quick rendering).
|
||||
pub const PDF_THUMBNAIL_SCALE: f64 = 0.25;
|
||||
|
|
@ -43,7 +43,11 @@ macro_rules! fl {
|
|||
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id)
|
||||
}};
|
||||
|
||||
($message_id:literal, $($args:expr),*) => {{
|
||||
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, $($args), *)
|
||||
($message_id:literal, $($name:ident: $value:expr),*) => {{
|
||||
let mut args = std::collections::HashMap::new();
|
||||
$(
|
||||
args.insert(stringify!($name), $value.to_string());
|
||||
)*
|
||||
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, args)
|
||||
}};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
mod app;
|
||||
mod config;
|
||||
mod constant;
|
||||
mod i18n;
|
||||
|
||||
use anyhow::Result;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue