mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
Merge main into feat/edit-keywords-key
This commit is contained in:
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
timeout-minutes: 90
|
||||
# xtask ci runs cargo test: the demo scene cache is per process, so nextest is slower here.
|
||||
tools: ""
|
||||
|
||||
@@ -28,4 +28,4 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
with:
|
||||
app-name: lightcraft
|
||||
app-title: LightCraft
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
|
||||
Generated
+825
-303
File diff suppressed because it is too large
Load Diff
+31
-8
@@ -3,15 +3,17 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
@@ -61,7 +63,7 @@ dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and h
|
||||
|
||||
# formats
|
||||
quick-xml = "0.41"
|
||||
miniz_oxide = "0.8"
|
||||
miniz_oxide = "0.9"
|
||||
|
||||
# codecs
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] }
|
||||
@@ -91,10 +93,13 @@ wgpu = { version = "30", default-features = false, features = ["std", "parking_l
|
||||
pollster = "1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
# 0.37: `default_fonts` is no longer an egui default and the monochrome emoji faces moved behind
|
||||
# `monochrome_emoji_fonts`. Every resolution root (native, CLI/headless, kittest, wasm) needs both:
|
||||
# kittest's own dev-dependencies do not propagate to its consumers.
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK)
|
||||
# macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same)
|
||||
@@ -169,3 +174,21 @@ jpeg-decoder = { opt-level = 3 }
|
||||
jpeg-encoder = { opt-level = 3 }
|
||||
png = { opt-level = 3 }
|
||||
miniz_oxide = { opt-level = 3 }
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -12,7 +12,9 @@ Third-party material
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in assets/ATTRIBUTION.md.
|
||||
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. The licence text is
|
||||
assets/fonts/OFL-Inter.txt in the source tree; release packages ship it beside NOTICE as
|
||||
OFL-Inter.txt (Windows installers include it in FONT-LICENCES.txt).
|
||||
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
|
||||
|
||||
crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the
|
||||
@@ -50,11 +52,15 @@ maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implement
|
||||
no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included.
|
||||
|
||||
Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases)
|
||||
also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md
|
||||
(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans
|
||||
Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto
|
||||
Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's
|
||||
licence as OFL-<family>.txt; Windows installers include their texts in FONT-LICENCES.txt.
|
||||
also embed the default fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/b076417b5880bd4154bd22f00695b1d28c6bea83/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
|
||||
Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian,
|
||||
NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2, Noto Sans Hebrew and
|
||||
Noto Sans Devanagari), under the SIL Open Font License 1.1. The opt-in LXGW WenKai Lite face
|
||||
(fonts/simplified-chinese-manifest.txt in craft-fonts) is not embedded. The web build embeds
|
||||
BIZ UDPGothic Regular, Noto Sans Thai Regular and Noto Sans Georgian Regular, and loads Noto Sans
|
||||
CJK SC separately. Release packages include each font's licence as OFL-<family>.txt; Windows
|
||||
installers include their texts in FONT-LICENCES.txt.
|
||||
|
||||
Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and
|
||||
are never distributed with it. On the user's request (Photo > Download Place Names) the app
|
||||
@@ -62,3 +68,270 @@ downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoName
|
||||
(https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed
|
||||
under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/);
|
||||
attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own.
|
||||
|
||||
|
||||
epaint_default_fonts, the egui default-font crate LightCraft builds with, also bundles Hack Regular
|
||||
and Ubuntu Light, and Noto Emoji Regular when egui's `monochrome_emoji_fonts` feature is on (the
|
||||
LightCraft workspace enables both `default_fonts` and `monochrome_emoji_fonts` on egui 0.37). The
|
||||
LightCraft binaries embed these faces unmodified, registered through egui's default font definitions.
|
||||
Source for all of them (the same pin as the icon fonts below):
|
||||
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
|
||||
Hack Regular (Hack-Regular.ttf) is Copyright (c) 2018 Source Foundry Authors and
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. It is licensed under the MIT License, and
|
||||
the Bitstream Vera parts under the Bitstream Vera License with Reserved Font Names "Bitstream" and
|
||||
"Vera": the font as a whole is MIT AND Bitstream Vera License. The work in the DejaVu project it
|
||||
derives from was committed to the public domain.
|
||||
The following text is reproduced verbatim from Hack-Regular.txt:
|
||||
|
||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
||||
|
||||
The work in the DejaVu project was committed to the public domain.
|
||||
|
||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Source Foundry Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
BITSTREAM VERA LICENSE
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
|
||||
Ubuntu Light (Ubuntu-Light.ttf) is Copyright 2011 Canonical Ltd., was designed by Dalton Maag Ltd,
|
||||
and is licensed under the Ubuntu Font Licence 1.0 (not the SIL Open Font License).
|
||||
The following text is reproduced verbatim from UFL.txt; that file carries no copyright line, so the
|
||||
copyright notice above is taken from the font's own name table:
|
||||
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
|
||||
Noto Emoji Regular (NotoEmoji-Regular.ttf) is Copyright 2013 Google Inc. All Rights Reserved. It was
|
||||
designed by the Monotype Design Team and is licensed under the SIL Open Font License 1.1.
|
||||
The following text is reproduced verbatim from OFL.txt; that file carries no copyright line, so the
|
||||
copyright notice above is taken from the font's own name table:
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
|
||||
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
|
||||
and registered through egui's default font definitions. They derive from emoji-icon-font by
|
||||
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -348,7 +348,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
|
||||
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an
|
||||
optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and
|
||||
|
||||
+1
-1
@@ -305,7 +305,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
|
||||
- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
较老佳能的 raw 都很出色。
|
||||
- **最大的缺口:**
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
|
||||
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
|
||||
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
|
||||
|
||||
+1
-1
@@ -320,7 +320,7 @@ LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說,
|
||||
在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/
|
||||
Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。
|
||||
- **最大的缺口:**
|
||||
- **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
|
||||
- **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
|
||||
- **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md);
|
||||
- **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法;
|
||||
- **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
|
||||
@@ -819,6 +819,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
let services = lightcraft_ui_egui::Services {
|
||||
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
|
||||
write: Some(Box::new(lightcraft_engine::export::write_file)),
|
||||
write_new: Some(Box::new(lightcraft_engine::export::write_file_new)),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
|
||||
@@ -39,7 +39,7 @@ pub struct Bench {
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
/// `start_ms`: the page's time origin in the same clock as [`lightcraft_ui_egui::now_ms`].
|
||||
/// `start_ms`: an origin on the same monotonic clock as [`lightcraft_ui_egui::monotonic_ms`].
|
||||
pub fn new(start_ms: f64) -> Self {
|
||||
Bench {
|
||||
phase: Phase::Idle,
|
||||
@@ -54,13 +54,16 @@ impl Bench {
|
||||
}
|
||||
}
|
||||
|
||||
/// The render key of the main preview texture, or `None` while that slot holds no texture.
|
||||
/// A changed key means the benchmark's requested main-image render has arrived.
|
||||
fn main_key(app: &LightcraftApp) -> Option<u64> {
|
||||
app.renderer.textures.get(&Slot::Main).map(|t| t.key)
|
||||
}
|
||||
|
||||
/// Call once per frame after `LightcraftApp::logic`. Returns the report when finished.
|
||||
/// Call once per root UI pass after `LightcraftApp::ui` (a UI-phase step, never a hidden tick).
|
||||
/// Returns the report when finished.
|
||||
pub fn step(&mut self, app: &mut LightcraftApp) -> Option<Value> {
|
||||
let now = lightcraft_ui_egui::now_ms();
|
||||
let now = lightcraft_ui_egui::monotonic_ms();
|
||||
self.frames += 1;
|
||||
if self.first_frame_ms.is_none() && self.frames > 1 {
|
||||
self.first_frame_ms = Some(now - self.start_ms);
|
||||
|
||||
@@ -168,6 +168,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the browser host's `Services`: library backup/restore through browser storage and a
|
||||
/// download, asynchronous file picking, downloads for writes, and PNG encoding. Desktop-only
|
||||
/// services (native pickers, reveal, model folders) stay unset.
|
||||
fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen: Rc<Cell<bool>>, ctx: egui::Context) -> Services {
|
||||
let (backup_backend, backup_files) = (backend.clone(), files);
|
||||
let restore_backend = backend.clone();
|
||||
@@ -228,7 +231,12 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
save_curve_preset_file: None,
|
||||
pick_slideshow_templates: None,
|
||||
save_slideshow_template: None,
|
||||
pick_print_templates: None,
|
||||
save_print_template: None,
|
||||
write: Some(Box::new(download)),
|
||||
// the browser download path has no meaningful create-new file write; the control channel
|
||||
// (and therefore screenshot paths) is not available on the web
|
||||
write_new: None,
|
||||
// downloads happen on the main thread: exports run in the foreground on the web
|
||||
write_shared: None,
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
@@ -271,7 +279,7 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bo
|
||||
log::error!("saving {} failed: {e}", op.name());
|
||||
files.failed(&ops[i..]);
|
||||
files.set_error(Some(e));
|
||||
failed_at.set(perf_now());
|
||||
failed_at.set(lightcraft_ui_egui::monotonic_ms());
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
@@ -415,6 +423,9 @@ struct WebApp {
|
||||
}
|
||||
|
||||
impl WebApp {
|
||||
/// Construct the web app from the boot options: install originals and open the library from
|
||||
/// browser storage (a read failure starts an empty session and a blocking `LibraryProblem`),
|
||||
/// load the stored UI state, start render workers and the optional benchmark.
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
|
||||
let Boot { opts, backend, files, index, lib_dir, load_error } = boot;
|
||||
let originals = Originals::default();
|
||||
@@ -470,7 +481,12 @@ impl WebApp {
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
|
||||
.with_chinese_font(chinese_font);
|
||||
let ui_written = files.get("ui.json").unwrap_or_default();
|
||||
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
|
||||
// an arrangement from an older docking version starts again from the default
|
||||
let mut saved = serde_json::from_slice::<serde_json::Value>(&ui_written).unwrap_or_default();
|
||||
if let Some(fields) = saved.as_object_mut() {
|
||||
lightcraft_ui_egui::docking::drop_outdated(fields);
|
||||
}
|
||||
if let Ok(ui) = serde_json::from_value::<UiState>(saved) {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
@@ -496,7 +512,7 @@ impl WebApp {
|
||||
}
|
||||
log::info!("lightcraft: {n} render workers");
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
let origin = lightcraft_ui_egui::now_ms() - perf_now();
|
||||
let origin = lightcraft_ui_egui::monotonic_ms();
|
||||
WebApp {
|
||||
app,
|
||||
originals,
|
||||
@@ -515,8 +531,10 @@ impl WebApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the drops the raw-input hook queued once (never re-read `raw.dropped_files` in a
|
||||
/// hidden logic tick), and add originals whose bytes have arrived.
|
||||
fn import_dropped(&mut self, ctx: &egui::Context) {
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
let dropped = self.app.take_dropped_files();
|
||||
// a dialog is on screen (the card said so while the files hovered): nothing is read or imported
|
||||
// (files read from an earlier drop still finish below)
|
||||
if !dropped.is_empty() && self.app.ui.dialog.is_some() {
|
||||
@@ -526,8 +544,8 @@ impl WebApp {
|
||||
for f in dropped {
|
||||
let (originals, backend, ctx) = (self.originals.clone(), self.backend.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().to_string_lossy().to_string();
|
||||
match f.bytes_async().await {
|
||||
let name = egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string();
|
||||
match egui::DroppedFile::bytes_async(f.as_ref()).await {
|
||||
Ok(bytes) => store_file(originals, backend, name, bytes, ctx).await,
|
||||
Err(e) => notice(lightcraft_ui_egui::i18n::dropped_file_unreadable(&name, &e.to_string())),
|
||||
}
|
||||
@@ -587,7 +605,7 @@ impl WebApp {
|
||||
if self.frozen.get() {
|
||||
return;
|
||||
}
|
||||
let now = perf_now();
|
||||
let now = lightcraft_ui_egui::monotonic_ms();
|
||||
if now - self.last_save >= SAVE_EVERY_MS {
|
||||
self.last_save = now;
|
||||
self.app.session.save_view();
|
||||
@@ -677,13 +695,14 @@ impl WebApp {
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
/// eframe's non-drawing pass for the browser host: drain safety notices and queued inbox work,
|
||||
/// load originals the UI requested, apply the cache budget, run the app's logic, then save.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for text in safety::take_notices() {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let now = self.app.tick_now();
|
||||
self.app.ui.toast = Some((text, now + NOTICE_SECS, None));
|
||||
}
|
||||
self.run_inbox();
|
||||
self.import_dropped(ctx);
|
||||
self.load_originals(ctx);
|
||||
if let Some(w) = &self.workers {
|
||||
// `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on
|
||||
@@ -692,26 +711,24 @@ impl eframe::App for WebApp {
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
self.save();
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
&& let Some(report) = b.step(&mut self.app)
|
||||
{
|
||||
log::info!("lightcraft-bench {report}");
|
||||
if let Some((alive, ready, remote, inline)) = self.workers.as_ref().map(Workers::stats) {
|
||||
log::info!("lightcraft-workers {{\"alive\":{alive},\"ready\":{ready},\"remote_jobs\":{remote},\"inline_jobs\":{inline}}}");
|
||||
}
|
||||
set_status(&format!("bench: {report}"));
|
||||
self.bench = None;
|
||||
}
|
||||
if self.bench.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
/// Pass the browser's raw input to the app hook before egui processes it, so prompt barriers
|
||||
/// and synthetic or dropped input are applied consistently with the desktop host.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
/// eframe's UI pass: import queued drops unless a prompt blocks input, draw the app, update the
|
||||
/// page title, and advance the benchmark once per root pass.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// Drops are the UI phase's: a hidden `logic`/`run_logic` tick must not import them twice.
|
||||
// A prompt owns the library too: keep taking the queue only after it is gone, so a drop
|
||||
// during a notice cannot add photos behind it.
|
||||
let ctx = ui.ctx().clone();
|
||||
if !lightcraft_ui_egui::panels::notices::blocks_input(&self.app) {
|
||||
self.import_dropped(&ctx);
|
||||
}
|
||||
self.app.ui(ui);
|
||||
// eframe's web runner ignores `ViewportCommand::Title`: name the open photo in the page title
|
||||
let title = self.app.page_title(self.files.is_dirty() || self.flushing.get());
|
||||
@@ -721,6 +738,22 @@ impl eframe::App for WebApp {
|
||||
doc.set_title(&title);
|
||||
self.title = title;
|
||||
}
|
||||
// The benchmark is a UI-phase step: a second layout pass or a hidden tick must not count a frame.
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
&& let Some(report) = b.step(&mut self.app)
|
||||
{
|
||||
log::info!("lightcraft-bench {report}");
|
||||
if let Some((alive, ready, remote, inline)) = self.workers.as_ref().map(Workers::stats) {
|
||||
log::info!("lightcraft-workers {{\"alive\":{alive},\"ready\":{ready},\"remote_jobs\":{remote},\"inline_jobs\":{inline}}}");
|
||||
}
|
||||
set_status(&format!("bench: {report}"));
|
||||
self.bench = None;
|
||||
}
|
||||
if self.bench.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
if !self.first_frame_logged && !self.app.widgets.is_empty() {
|
||||
self.first_frame_logged = true;
|
||||
log::info!("lightcraft first UI frame at {:.0} ms after navigation start", perf_now());
|
||||
|
||||
@@ -23,7 +23,9 @@ lightcraft-sysmem = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11"] }
|
||||
# `clipboard` is a separate eframe feature in 0.37: without it egui-winit simulates an in-process
|
||||
# clipboard and cut/copy/paste, RequestPaste and the Wayland PRIMARY path are unavailable.
|
||||
eframe = { workspace = true, features = ["default_fonts", "clipboard", "wgpu", "glow", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
@@ -53,3 +55,6 @@ objc2-foundation = { workspace = true }
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -75,8 +75,10 @@ impl eframe::App for App {
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Forwards the host's raw-input pass to the app's hook before egui processes it, keeping
|
||||
/// modal/prompt input barriers and queued synthetic or dropped input ahead of the panels.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0);
|
||||
@@ -259,13 +261,15 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
load_prefs_at(&path, !in_memory)
|
||||
}
|
||||
|
||||
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
|
||||
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An
|
||||
/// arrangement from an older docking version starts again from the default without a warning.
|
||||
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
|
||||
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
|
||||
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
|
||||
lightcraft_ui_egui::docking::drop_outdated(fields);
|
||||
let recovered =
|
||||
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
|
||||
Ok(Some(workspace)) => workspace.validate().is_err(),
|
||||
Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(),
|
||||
Ok(None) => false,
|
||||
Err(_) => true,
|
||||
});
|
||||
@@ -376,7 +380,7 @@ impl PrefsWriter {
|
||||
}
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
if let Some(workspace) = &ui.docking {
|
||||
workspace.validate()?;
|
||||
lightcraft_ui_egui::docking::validate(workspace)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
@@ -404,15 +408,26 @@ impl PrefsWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame host tick: records the window's placement while visible, asks for a repaint when
|
||||
/// a remembered size is due, and saves settings/library at most every three seconds or
|
||||
/// immediately after the library path changes; a failed save is reported once and retried.
|
||||
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
// (asked for before the input is read: the context is not asked from inside its own lock)
|
||||
let zoom = ctx.zoom_factor();
|
||||
let (now, seen) = ctx.input(|i| (i.time, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom)));
|
||||
// The monotonic app clock: `i.time` stands still while the window is hidden.
|
||||
let now = app.tick_now();
|
||||
let (hidden, seen) = ctx.input(|i| {
|
||||
let hidden = i.viewport().visible() == Some(false);
|
||||
(hidden, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom))
|
||||
});
|
||||
// how the window is, to open like that next time (written with the settings below). What
|
||||
// the settings hold is checked first: a script may have set it (`ui.set`)
|
||||
// the settings hold is checked first: a script may have set it (`ui.set`). A hidden
|
||||
// `run_logic` pass has no fresh content rect: keep the last known placement.
|
||||
let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
|
||||
let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64();
|
||||
app.ui.window = self.window.note(remembered, &seen, at);
|
||||
if !hidden {
|
||||
app.ui.window = self.window.note(remembered, &seen, at);
|
||||
}
|
||||
if let Some(wait) = self.window.waits(at) {
|
||||
// (a size is only remembered once the window has rested at it: look again then, though
|
||||
// nothing else asks for a frame)
|
||||
@@ -706,9 +721,28 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_print_templates: Some(Box::new(|| {
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
save_print_template: Some(Box::new(|name: &str| {
|
||||
file_dialog()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Print Template"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
|
||||
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
|
||||
write: Some(Box::new(lightcraft_engine::export::write_file)),
|
||||
write_new: Some(Box::new(lightcraft_engine::export::write_file_new)),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
@@ -849,6 +883,11 @@ fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: s
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::LIGHTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so every start-up record is kept for the log file (`logging`).
|
||||
let logger = logging::install();
|
||||
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
|
||||
@@ -977,6 +1016,7 @@ fn main() -> eframe::Result {
|
||||
// HDR display: SDR unless the surface, platform and display all allow HDR
|
||||
hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format));
|
||||
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
|
||||
app.set_startup_update_check(true);
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
@@ -1245,7 +1285,7 @@ mod tests {
|
||||
restored_app.ui = restored.unwrap();
|
||||
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
|
||||
assert_eq!(
|
||||
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
|
||||
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect,
|
||||
[70.0, 80.0, 310.0, 500.0]
|
||||
);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the
|
||||
//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come
|
||||
//! back when the field loses focus.
|
||||
//!
|
||||
//! Menu events never pass through the egui shortcut handler. [`NativeMenu::update`] therefore runs
|
||||
//! every queued command through [`lightcraft_ui_egui::menubar::run_native_command`]: while the
|
||||
//! blocking library window or a quit/settings notice owns input it drops the command (Settings…
|
||||
//! and About included), and only Quit stays available for the host's existing confirmation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
@@ -480,17 +485,21 @@ impl NativeMenu {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
let mut quit = false;
|
||||
while let Ok(key) = self.rx.try_recv() {
|
||||
if key == QUIT {
|
||||
// Resolve the native id to its command, then let the shared policy decide. Native menu
|
||||
// events never reach `shortcuts::handle`, so this is the one guard for AppKit's
|
||||
// accelerators and clicks: while the blocking library window or a quit/settings notice
|
||||
// owns input, a queued command is dropped (Quit stays for the existing confirmation).
|
||||
let (cmd, params) = if key == QUIT {
|
||||
(key, serde_json::json!({}))
|
||||
} else if key == "app.about" || key == SETTINGS {
|
||||
(key, serde_json::json!({}))
|
||||
} else if let Some(it) = self.items.get(&key) {
|
||||
(it.cmd.clone(), it.params.clone())
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if lightcraft_ui_egui::menubar::run_native_command(app, &cmd, params) == lightcraft_ui_egui::menubar::NativeCommand::Quit {
|
||||
quit = true;
|
||||
continue;
|
||||
}
|
||||
if key == "app.about" || key == SETTINGS {
|
||||
let _ = app.run(&key, serde_json::json!({}));
|
||||
continue;
|
||||
}
|
||||
if let Some(it) = self.items.get(&key) {
|
||||
let (cmd, params) = (it.cmd.clone(), it.params.clone());
|
||||
let _ = lightcraft_ui_egui::menubar::run_item(app, &cmd, params);
|
||||
}
|
||||
}
|
||||
let bar = menu_bar(app);
|
||||
|
||||
@@ -8,6 +8,10 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
|
||||
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | egui icon subset and monochrome fallback | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | 2026-10-24 (notice audit) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full fallback. Registered by production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Hack Regular (the monospace default) | Source Foundry Authors (built on the DejaVu project, which was committed to the public domain) and Bitstream Inc. | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | MIT AND Bitstream Vera License (Copyright (c) 2018 Source Foundry Authors; Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.; full upstream texts in `NOTICE`) | 2026-10-25 (notice audit) | unmodified egui default font embedded in the binary and registered by the production default font definitions (egui's `default_fonts`); no font file copied into this repository |
|
||||
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Ubuntu Light (the proportional default) | Canonical Ltd.; designed by Dalton Maag Ltd | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | Ubuntu Font Licence 1.0, not the SIL Open Font License (Copyright 2011 Canonical Ltd.; full upstream text in `NOTICE`) | 2026-10-25 (notice audit) | unmodified egui default font embedded in the binary and registered by the production default font definitions (egui's `default_fonts`); no font file copied into this repository |
|
||||
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | Noto Emoji Regular | Google Inc.; designed by the Monotype Design Team | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | SIL Open Font License 1.1 (Copyright 2013 Google Inc. All Rights Reserved.; full upstream text in `NOTICE`) | 2026-10-25 (notice audit) | unmodified egui default font embedded in the binary and registered by the production default font definitions (egui's `default_fonts` and `monochrome_emoji_fonts`); no font file copied into this repository |
|
||||
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
|
||||
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
@@ -18,6 +22,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/ILCE-7RM5.json` | Sony ILCE-7RM5 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Yule (Brian) Wang (@peaperfish) | original work: fitted by `lightcraft-cli calibrate` to 220 usable ILCE-7RM5 ARWs of my own (of 241, 2025 to 2026, Standard creative style; Sigma 24-70mm F2.8 DG DN II Art and Sony FE 200-600mm F5.6-6.3 G OSS) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-26 | none |
|
||||
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
|
||||
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
@@ -29,7 +34,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file |
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular, Noto Sans Georgian Regular, NanumGothic Regular, Noto Sans Symbols Regular, Noto Sans Symbols 2 Regular, Noto Sans Hebrew Regular, Noto Sans Devanagari Regular | The Noto Project Authors (Arabic, Thai, Georgian, Symbols, Symbols 2, Hebrew and Devanagari); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; NHN Corporation and Sandoll Communications (NanumGothic); Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/b076417b5880bd4154bd22f00695b1d28c6bea83/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the default `fonts/manifest.txt` set (all official releases; the opt-in LXGW WenKai Lite face in `fonts/simplified-chinese-manifest.txt` is not embedded); the web build embeds BIZ UDPGothic Regular, Noto Sans Thai Regular and Noto Sans Georgian Regular and ships Noto Sans CJK SC Regular as a separate font file |
|
||||
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
|
||||
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
|
||||
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
|
||||
@@ -40,6 +45,9 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `crates/ui-egui/locales/vi.json`, `crates/ui-egui/locales/vi-formats.json` | Vietnamese interface catalogs | @duydev (DUY Tran Nhat) with AI assistance (Cursor) | original work: translations of English source phrases; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-13 | plural-neutral counts, Vietnamese dates and weekdays, reviewed placeholders |
|
||||
|
||||
Notes
|
||||
- egui's default fonts (Hack, Ubuntu Light, Noto Emoji, egui-icons and emoji-icon-font) come from the
|
||||
`epaint_default_fonts` dependency, not from files in this repository; their creators, licences and the full
|
||||
upstream texts are recorded in the rows above and in `NOTICE`.
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
|
||||
- CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,3 +70,7 @@ sync_github_name = true
|
||||
|
||||
[people.ronibalthazar]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
+26
-40
@@ -1098,55 +1098,41 @@ impl Catalog {
|
||||
}
|
||||
st.photos.len() >= 2
|
||||
});
|
||||
let parents: Vec<(AlbumId, Option<AlbumId>)> = self.albums.values().map(|a| (a.id, a.parent)).collect();
|
||||
// A folder chain is shared by every album under it, so each chain is validated once and its
|
||||
// verdict is what later albums reused: a valid chain encoded in a flat snapshot made the old
|
||||
// per-album walk quadratic in the album count (CWE-407).
|
||||
enum Chain {
|
||||
Ok,
|
||||
Bad,
|
||||
}
|
||||
let mut chains: std::collections::HashMap<AlbumId, Chain> = std::collections::HashMap::new();
|
||||
for (id, parent) in parents {
|
||||
let Some(p) = parent else { continue };
|
||||
if chains.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
// Walk up from `p` once, recording everything seen; what it reaches decides every node
|
||||
// walked (a chain already judged by an earlier album stops the walk early — the rest of
|
||||
// the chain was judged then).
|
||||
let albums: Vec<AlbumId> = self.albums.keys().copied().collect();
|
||||
// Validate each parent link once (CWE-407), caching only chains that now reach a root.
|
||||
// A cached bad verdict is not enough: skipping its ancestors would leave their broken
|
||||
// links/cycle intact when a lower-id child was the first to walk into that chain.
|
||||
let mut valid: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
|
||||
for id in albums {
|
||||
let mut walked: Vec<AlbumId> = Vec::new();
|
||||
let ok = {
|
||||
let mut cur = Some(p);
|
||||
let broken = {
|
||||
let mut cur = id;
|
||||
let mut on: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
|
||||
loop {
|
||||
match cur {
|
||||
None => break true,
|
||||
Some(c) => {
|
||||
if c == id || !on.insert(c) {
|
||||
break false;
|
||||
}
|
||||
match chains.get(&c) {
|
||||
Some(Chain::Bad) => break false,
|
||||
Some(Chain::Ok) => break true,
|
||||
None => {}
|
||||
}
|
||||
match self.albums.get(&c) {
|
||||
Some(a) if a.folder => walked.push(c),
|
||||
_ => break false,
|
||||
}
|
||||
cur = self.albums[&c].parent;
|
||||
}
|
||||
if valid.contains(&cur) {
|
||||
break None;
|
||||
}
|
||||
if !on.insert(cur) {
|
||||
// Detach one actual cycle node, not the child that first reached it.
|
||||
break self.albums.get(&cur).and_then(|a| a.parent).map(|parent| (cur, parent));
|
||||
}
|
||||
walked.push(cur);
|
||||
let Some(parent) = self.albums.get(&cur).and_then(|a| a.parent) else { break None };
|
||||
if !self.albums.get(&parent).is_some_and(|a| a.folder) {
|
||||
break Some((cur, parent));
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
};
|
||||
for c in walked {
|
||||
chains.insert(c, if ok { Chain::Ok } else { Chain::Bad });
|
||||
}
|
||||
if !ok && let Some(a) = self.albums.get_mut(&id) {
|
||||
if let Some((id, p)) = broken
|
||||
&& let Some(a) = self.albums.get_mut(&id)
|
||||
{
|
||||
changed.push(format!("album {}: its folder {} is missing, not a folder or inside it: moved to the top level", id.0, p.0));
|
||||
a.parent = None;
|
||||
}
|
||||
// Only visited nodes are cached, after every one can reach the repaired root or a
|
||||
// previously validated chain. Unvisited ancestors never inherit a guessed verdict.
|
||||
valid.extend(walked);
|
||||
}
|
||||
let past = |last: Option<u64>| last.map_or(1, |v| v.saturating_add(1));
|
||||
let (photo, album, stack) = (
|
||||
|
||||
@@ -108,6 +108,74 @@ fn folder_loops_and_missing_parents_move_to_the_top() {
|
||||
let _ = c.apply(Op::MoveAlbum { id: AlbumId(11), parent: Some(AlbumId(10)) });
|
||||
}
|
||||
|
||||
/// Check the postcondition needed by every consumer that follows parent folders. This inspects
|
||||
/// the repaired catalog, not its cache or a second implementation of the repair algorithm.
|
||||
fn assert_all_album_chains_reach_roots(c: &Catalog) {
|
||||
for album in c.albums.values() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut current = album;
|
||||
while let Some(parent) = current.parent {
|
||||
assert!(seen.insert(current.id), "album {} still reaches a cycle", album.id.0);
|
||||
current = c.albums.get(&parent).expect("every retained parent exists");
|
||||
assert!(current.folder, "every retained parent is a folder");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_visited_before_a_folder_cycle_keeps_its_content_and_gets_a_rooted_chain() {
|
||||
let (c, ids) = sound();
|
||||
let (mut c, report) = load_edited(&c, |v| {
|
||||
// BTreeMap order visits10 first: cached Bad11/12 used to be skipped without repair.
|
||||
v["albums"]["10"] = serde_json::to_value(album(10, Some(11), false, vec![ids[2]])).unwrap();
|
||||
v["albums"]["11"] = serde_json::to_value(album(11, Some(12), true, vec![])).unwrap();
|
||||
v["albums"]["12"] = serde_json::to_value(album(12, Some(11), true, vec![])).unwrap();
|
||||
});
|
||||
assert_all_album_chains_reach_roots(&c);
|
||||
assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(11)), "the child remains in its actual folder");
|
||||
assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]], "repair preserves the child's photos");
|
||||
assert_eq!(c.album(AlbumId(11)).unwrap().parent, None, "one cycle edge is repaired");
|
||||
assert_eq!(c.album(AlbumId(12)).unwrap().parent, Some(AlbumId(11)), "the other folder edge is retained");
|
||||
assert!(report.repaired.iter().any(|message| message.starts_with("album 11:")), "{:?}", report.repaired);
|
||||
assert!(c.repair().is_empty(), "a second repair is idempotent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_visited_before_a_folder_with_a_missing_parent_does_not_hide_the_bad_ancestor() {
|
||||
let (c, ids) = sound();
|
||||
let (mut c, report) = load_edited(&c, |v| {
|
||||
v["albums"]["10"] = serde_json::to_value(album(10, Some(11), false, vec![ids[2]])).unwrap();
|
||||
v["albums"]["11"] = serde_json::to_value(album(11, Some(999), true, vec![])).unwrap();
|
||||
});
|
||||
assert_all_album_chains_reach_roots(&c);
|
||||
assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(11)));
|
||||
assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]]);
|
||||
assert_eq!(c.album(AlbumId(11)).unwrap().parent, None, "repair the missing-parent edge itself");
|
||||
assert!(report.repaired.iter().any(|message| message.starts_with("album 11:")), "{:?}", report.repaired);
|
||||
assert!(c.repair().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_valid_folder_chain_is_retained_when_its_child_is_visited_first() {
|
||||
let (c, ids) = sound();
|
||||
let (mut c, report) = load_edited(&c, |v| {
|
||||
v["albums"]["10"] = serde_json::to_value(album(10, Some(100), false, vec![ids[2]])).unwrap();
|
||||
for id in 100..612 {
|
||||
v["albums"][id.to_string()] = serde_json::to_value(album(id, Some(id + 1), true, vec![])).unwrap();
|
||||
}
|
||||
v["albums"]["612"] = serde_json::to_value(album(612, None, true, vec![])).unwrap();
|
||||
});
|
||||
assert_all_album_chains_reach_roots(&c);
|
||||
assert_eq!(c.album(AlbumId(10)).unwrap().parent, Some(AlbumId(100)));
|
||||
assert_eq!(c.album(AlbumId(10)).unwrap().photos, vec![ids[2]]);
|
||||
for id in 100..612 {
|
||||
assert_eq!(c.album(AlbumId(id)).unwrap().parent, Some(AlbumId(id + 1)), "valid edge{id} remains");
|
||||
}
|
||||
assert_eq!(c.album(AlbumId(612)).unwrap().parent, None);
|
||||
assert!(!report.repaired.iter().any(|message| message.starts_with("album ")), "valid parents must not be repaired: {:?}", report.repaired);
|
||||
assert!(c.repair().is_empty());
|
||||
}
|
||||
|
||||
/// Id counters left at the top (or behind the ids in use) by a damaged file: allocating no longer
|
||||
/// overflows (a panic in debug builds) and never hands out an id already in use.
|
||||
#[test]
|
||||
|
||||
@@ -24,3 +24,4 @@ web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
dhat = { workspace = true }
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's
|
||||
//! content, the model, this code's version, the settings it depends on); a file with another key is stale.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
@@ -216,6 +216,10 @@ fn read_start(r: &mut impl Read) -> Result<Start, ProductError> {
|
||||
r.read_exact(&mut e)?;
|
||||
let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize;
|
||||
let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]);
|
||||
// a strip never compresses to nothing (it has at least one row, so at least one deflated pixel)
|
||||
if l == 0 {
|
||||
return Err(ProductError::Format("a strip is empty".into()));
|
||||
}
|
||||
// a strip never compresses to much more than its raw size
|
||||
if l > 12 * h.strip * h.width + 4096 {
|
||||
return Err(ProductError::Format("a strip is larger than it can be".into()));
|
||||
@@ -305,7 +309,6 @@ pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize
|
||||
}
|
||||
// the picture rows and columns the blocks cover
|
||||
let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) };
|
||||
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
|
||||
let lut = f16_table();
|
||||
let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 {
|
||||
let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0));
|
||||
@@ -318,6 +321,21 @@ pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize
|
||||
let strips: Vec<usize> = (0..table.len()).filter(|&s| wanted(s)).collect();
|
||||
// skip the strips before the first one wanted
|
||||
let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum();
|
||||
// The output is reserved only after the wanted strips' compressed bytes are present and match their checksums:
|
||||
// a malformed cache whose strips are empty, damaged or short are rejected here, before the (large) result is
|
||||
// allocated, not after. Reading them twice is cheap next to decoding them, and the fold below re-seeks here.
|
||||
let strips_at = f.stream_position()?;
|
||||
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
|
||||
for &s in &strips {
|
||||
let Some(&(len, expected)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) };
|
||||
let mut packed = allocate(len, 0u8)?;
|
||||
f.read_exact(&mut packed)?;
|
||||
if crc(&packed) != expected {
|
||||
return Err(ProductError::Format(format!("strip {s} is damaged")));
|
||||
}
|
||||
}
|
||||
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
|
||||
f.seek(std::io::SeekFrom::Start(strips_at))?;
|
||||
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
|
||||
for group in strips.chunks(GROUP) {
|
||||
let mut packed = Vec::with_capacity(group.len());
|
||||
@@ -422,6 +440,21 @@ mod tests {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_strip_entry_is_rejected_by_the_header() {
|
||||
let p = temp("empty-strip");
|
||||
let header = serde_json::to_vec(&Header { width: 16, height: 1, strip: STRIP_ROWS, key: "k".into() }).unwrap();
|
||||
let mut bytes = Vec::from(MAGIC.as_slice());
|
||||
bytes.extend_from_slice(&[VERSION, 0, 0, 0]);
|
||||
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&header);
|
||||
// one strip with a zero-length entry: it has a row, so its deflated bytes are never empty
|
||||
bytes.extend_from_slice(&[0; 8]);
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
assert!(matches!(read_header_at(&p), Err(ProductError::Format(why)) if why.contains("empty")));
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Peak heap of a `read_window` that must reject a product's strips (`crates/denoise-core/src/product.rs`), measured
|
||||
//! with dhat's testing mode. The result is allocated only after the wanted strips' checksums are verified, so a cache
|
||||
//! whose compressed bytes are damaged or absent fails without first reserving the (multi-megabyte) output. One test
|
||||
//! per binary (dhat's profiler is global).
|
||||
|
||||
use lightcraft_denoise_core::product::read_window;
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
|
||||
}
|
||||
|
||||
/// A 2000 × 1024 product of key `k` (a 24 MB result): its 8 strips each claim 8 bytes that do not match their
|
||||
/// checksum, and the file is exactly the length its header and table declare. A 24 MB peak means the output was
|
||||
/// reserved; the cap here bounds the header, the table and the strips' own bytes.
|
||||
fn damaged_product() -> Vec<u8> {
|
||||
let header = br#"{"width":2000,"height":1024,"strip":128,"key":"k"}"#;
|
||||
const STRIP_LEN: usize = 8;
|
||||
let mut bytes = Vec::from(b"LCDN".as_slice());
|
||||
bytes.extend_from_slice(&[1, 0, 0, 0]);
|
||||
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(header);
|
||||
let expected_len = 12 + header.len() + 8 * 8 + 8 * STRIP_LEN;
|
||||
for _ in 0..8 {
|
||||
// 8 claimed bytes whose recorded checksum does not match theirs (0xAA bytes do not have this CRC)
|
||||
bytes.extend_from_slice(&(STRIP_LEN as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
}
|
||||
for _ in 0..8 {
|
||||
bytes.extend_from_slice(&[0xAAu8; STRIP_LEN]);
|
||||
}
|
||||
assert_eq!(bytes.len(), expected_len, "the crafted file lies about its length");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_product_is_rejected_before_its_output_is_allocated() {
|
||||
let bytes = damaged_product();
|
||||
let p = temp("damaged-product-heap");
|
||||
std::fs::write(&p, &bytes).unwrap();
|
||||
let _profiler = dhat::Profiler::builder().testing().build();
|
||||
let why = read_window(&p, "k", None, 1, None).err().unwrap().to_string();
|
||||
let peak = dhat::HeapStats::get().max_bytes;
|
||||
let _ = std::fs::remove_file(p);
|
||||
assert!(why.contains("damaged"), "{why}");
|
||||
eprintln!("peak heap {peak} bytes");
|
||||
assert!(peak < 4 << 20, "peak heap {peak} bytes: the output was reserved before the strips were checked");
|
||||
}
|
||||
@@ -4,6 +4,12 @@
|
||||
use crate::net::{Net, NetError, Op, TensorId};
|
||||
use crate::run::{Error, TileRunner};
|
||||
|
||||
/// The tensors' size cap: the same one the real runner's buffers share (`crates/denoise/src/cpu.rs`). A hostile `Net`
|
||||
/// only ever has to satisfy the final shape: a `ConvT2` doubles the sides and a `MaxPool` halves them, so a small
|
||||
/// accepted graph can zoom its intermediates far out and back. Every tensor is counted here, and a run whose layers
|
||||
/// would retain more than one real tile's whole working set is refused before the memory behind it is reserved.
|
||||
const MAX_VALUES: usize = 128 * 1024 * 1024;
|
||||
|
||||
/// A tensor in `[channels][side][side]` order.
|
||||
struct T {
|
||||
channels: usize,
|
||||
@@ -11,12 +17,36 @@ struct T {
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
/// A tensor's plain float count, in bounds.
|
||||
fn count(channels: usize, side: usize) -> Result<usize, NetError> {
|
||||
channels
|
||||
.checked_mul(side)
|
||||
.and_then(|n| n.checked_mul(side))
|
||||
.filter(|&n| n > 0 && n <= MAX_VALUES)
|
||||
.ok_or_else(|| NetError::Invalid("a tensor has no size or is too large".into()))
|
||||
}
|
||||
|
||||
/// A zeroed tensor, keeping the memory the run keeps alive in bounds (`tensors` retains every layer's result).
|
||||
fn tensor(channels: usize, side: usize, total: &mut usize) -> Result<Vec<f32>, NetError> {
|
||||
let n = count(channels, side)?;
|
||||
*total = (*total).saturating_add(n);
|
||||
if *total > MAX_VALUES {
|
||||
return Err(NetError::Invalid("the tile needs too much working memory".into()));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(n).map_err(|_| NetError::Invalid("not enough memory for a reference tile".into()))?;
|
||||
data.resize(n, 0.0);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Run `net` on `input` (`in_channels` planes of `tile × tile`).
|
||||
pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError> {
|
||||
if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile {
|
||||
return Err(NetError::Invalid("the tile does not fit the network".into()));
|
||||
}
|
||||
let mut tensors: Vec<T> = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }];
|
||||
let input = input.to_vec();
|
||||
let mut total = input.len();
|
||||
let mut tensors = vec![T { channels: net.in_channels, side: tile, data: input }];
|
||||
let get = |tensors: &[T], id: TensorId| -> Result<usize, NetError> {
|
||||
if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) }
|
||||
};
|
||||
@@ -27,7 +57,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
|
||||
let b = c.src2.map(|s| get(&tensors, s)).transpose()?;
|
||||
let side = tensors[a].side;
|
||||
let pad = (c.k - 1) / 2;
|
||||
let mut data = vec![0f32; c.cout * side * side];
|
||||
let mut data = tensor(c.cout, side, &mut total)?;
|
||||
// the input channels in order: the first source's, then the second's
|
||||
let planes: Vec<&[f32]> =
|
||||
std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect();
|
||||
@@ -56,7 +86,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
|
||||
let a = get(&tensors, c.src)?;
|
||||
let side = tensors[a].side;
|
||||
let out_side = 2 * side;
|
||||
let mut data = vec![0f32; c.cout * out_side * out_side];
|
||||
let mut data = tensor(c.cout, out_side, &mut total)?;
|
||||
for co in 0..c.cout {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
@@ -77,7 +107,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
|
||||
Op::MaxPool2 { src } => {
|
||||
let a = get(&tensors, *src)?;
|
||||
let (channels, side) = (tensors[a].channels, tensors[a].side / 2);
|
||||
let mut data = vec![0f32; channels * side * side];
|
||||
let mut data = tensor(channels, side, &mut total)?;
|
||||
for ch in 0..channels {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
@@ -92,7 +122,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
|
||||
let a = get(&tensors, *src)?;
|
||||
let (channels, side) = (tensors[a].channels / 4, tensors[a].side);
|
||||
let out_side = 2 * side;
|
||||
let mut data = vec![0f32; channels * out_side * out_side];
|
||||
let mut data = tensor(channels, out_side, &mut total)?;
|
||||
for ch in 0..channels {
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
@@ -131,3 +161,39 @@ impl TileRunner for ReferenceRunner {
|
||||
run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::ConvT2;
|
||||
|
||||
fn convt(src: TensorId, cin: usize, cout: usize) -> Op {
|
||||
Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None })
|
||||
}
|
||||
|
||||
/// A graph that fits the final-shape check while zooming its intermediates out and back: 256 `ConvT2`s double
|
||||
/// the sides (256 tiers), 255 `MaxPool`s halve them back to the layer above the input.
|
||||
fn hostile() -> Net {
|
||||
let mut ops = Vec::new();
|
||||
let mut last = 0usize;
|
||||
for _ in 0..256 {
|
||||
ops.push(convt(last, 1, 1));
|
||||
last += 1;
|
||||
}
|
||||
for _ in 0..255 {
|
||||
ops.push(Op::MaxPool2 { src: last });
|
||||
last += 1;
|
||||
}
|
||||
Net::new(1, ops).unwrap()
|
||||
}
|
||||
|
||||
/// The zoomed-out layers' memory was never reserved: either one tensor or the retained whole (the two bounds
|
||||
/// share the cap) says why the run stops.
|
||||
#[test]
|
||||
fn a_graph_that_zooms_its_intermediates_out_and_back_is_refused_before_their_memory_is_reserved() {
|
||||
assert!(matches!(
|
||||
run(&hostile(), 1, &[1.0]),
|
||||
Err(NetError::Invalid(why)) if why.contains("too large") || why.contains("too much working memory")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
harfrust = { version = "0.13", default-features = false, features = ["std"] }
|
||||
unicode-segmentation = "1.13"
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
|
||||
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("ILCE-7RM5", include_str!("../../../assets/camera-profiles/ILCE-7RM5.json")),
|
||||
("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")),
|
||||
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
|
||||
("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")),
|
||||
|
||||
@@ -154,6 +154,16 @@ pub fn keyword_sets_json(s: &Session) -> Value {
|
||||
json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)})
|
||||
}
|
||||
|
||||
/// Make the set `step` places after the current one current, in the Keyword Set menu's order
|
||||
/// (Recent Keywords, then the saved sets), wrapping around from the last to the first and back.
|
||||
fn step_set(s: &mut Session, step: isize) -> Result<Value> {
|
||||
let at = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().position(|x| same_set(&x.name, n))).map_or(0, |i| i + 1);
|
||||
let next = (at as isize + step).rem_euclid(s.keyword_sets.len() as isize + 1) as usize;
|
||||
s.keyword_set = next.checked_sub(1).and_then(|i| s.keyword_sets.get(i)).map(|x| x.name.clone());
|
||||
s.save_prefs()?;
|
||||
Ok(keyword_sets_json(s))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
|
||||
@@ -174,6 +184,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.save_prefs()?;
|
||||
Ok(keyword_sets_json(s))
|
||||
}),
|
||||
cmd!(
|
||||
"keyword.nextSet",
|
||||
"Next Keyword Set",
|
||||
[],
|
||||
Some("Alt+0"),
|
||||
"{}: the set after the current one (after the last, Recent Keywords)",
|
||||
always,
|
||||
|s, _| step_set(s, 1)
|
||||
),
|
||||
cmd!(
|
||||
"keyword.previousSet",
|
||||
"Previous Keyword Set",
|
||||
[],
|
||||
Some("Alt+Shift+0"),
|
||||
"{}: the set before the current one (before Recent Keywords, the last)",
|
||||
always,
|
||||
|s, _| step_set(s, -1)
|
||||
),
|
||||
cmd!(
|
||||
"keyword.saveSet",
|
||||
"Save Keyword Set",
|
||||
|
||||
@@ -726,7 +726,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
|
||||
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
|
||||
}),
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], Some("Cmd+Alt+Shift+C"), "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
|
||||
let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta;
|
||||
let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description,
|
||||
@@ -740,7 +740,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"photo.pasteMetadata",
|
||||
"Paste Metadata",
|
||||
["Photo"],
|
||||
None,
|
||||
Some("Cmd+Alt+Shift+V"),
|
||||
"{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset`
|
||||
//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear`
|
||||
//! * `print.exportPdf {path, ids?}`
|
||||
//! * `print.template.list|save|apply|delete`
|
||||
//! * `print.template.list|save|apply|delete`, `print.template.export|import` (`.lcprint` files)
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, f64_req};
|
||||
use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size};
|
||||
use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name};
|
||||
use crate::print::template::{FILE_EXT, MAX_FILE, MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name, unique_name};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn rect_json(r: &Rect) -> Value {
|
||||
@@ -277,10 +277,21 @@ fn template_name(p: &Value, id: &str) -> Result<String> {
|
||||
clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e))
|
||||
}
|
||||
|
||||
/// Save the preferences after a change to the templates; if that fails, the templates go back to
|
||||
/// `before`, so the list matches what is saved and a retry does the same thing.
|
||||
fn save_templates(s: &mut Session, before: Vec<PrintTemplate>) -> Result<()> {
|
||||
if let Err(e) = s.save_prefs() {
|
||||
s.print_templates = before;
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn template_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.save";
|
||||
let name = template_name(p, ID)?;
|
||||
let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?;
|
||||
let before = s.print_templates.clone();
|
||||
match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = t,
|
||||
None => {
|
||||
@@ -290,7 +301,7 @@ fn template_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.print_templates.push(t);
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
save_templates(s, before)?;
|
||||
Ok(template_list(s))
|
||||
}
|
||||
|
||||
@@ -309,15 +320,66 @@ fn template_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn template_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.delete";
|
||||
let name = template_name(p, ID)?;
|
||||
let before = s.print_templates.len();
|
||||
let before = s.print_templates.clone();
|
||||
s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if s.print_templates.len() == before {
|
||||
if s.print_templates.len() == before.len() {
|
||||
return Err(bad(ID, format!("no print template `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
save_templates(s, before)?;
|
||||
Ok(template_list(s))
|
||||
}
|
||||
|
||||
fn template_path<'a>(p: &'a Value, id: &str) -> Result<&'a str> {
|
||||
p.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(id, "missing `path`"))
|
||||
}
|
||||
|
||||
fn template_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.export";
|
||||
let name = template_name(p, ID)?;
|
||||
let mut path = std::path::PathBuf::from(template_path(p, ID)?);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(FILE_EXT);
|
||||
}
|
||||
let t = s
|
||||
.print_templates
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(&name))
|
||||
.ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?;
|
||||
let bytes = serde_json::to_vec_pretty(&t.to_file()).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
let name = t.name.clone();
|
||||
let target = path.to_string_lossy();
|
||||
s.check_write_target(&target).map_err(|e| bad(ID, e))?;
|
||||
crate::export::write_file(&target, &bytes).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"name": name, "path": target}))
|
||||
}
|
||||
|
||||
fn template_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.import";
|
||||
let path = std::path::Path::new(template_path(p, ID)?);
|
||||
let meta = std::fs::metadata(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?;
|
||||
if !meta.is_file() {
|
||||
return Err(bad(ID, format!("{}: not a file", path.display())));
|
||||
}
|
||||
if meta.len() > MAX_FILE {
|
||||
return Err(bad(ID, format!("{}: {} bytes is too large for a print template (at most {} KB)", path.display(), meta.len(), MAX_FILE / 1024)));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?;
|
||||
let mut t = PrintTemplate::from_file(&bytes).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?;
|
||||
if s.print_templates.len() >= MAX_TEMPLATES {
|
||||
return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first")));
|
||||
}
|
||||
let name = unique_name(&t.name, &s.print_templates);
|
||||
let renamed = name != t.name;
|
||||
t.name = name.clone();
|
||||
let before = s.print_templates.clone();
|
||||
s.print_templates.push(t);
|
||||
save_templates(s, before)?;
|
||||
let mut out = template_list(s);
|
||||
out["name"] = json!(name);
|
||||
out["renamed"] = json!(renamed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "print.get", "Print Settings", [], None,
|
||||
@@ -349,5 +411,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always, template_save),
|
||||
cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply),
|
||||
cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete),
|
||||
cmd!(query "print.template.export", "Export Print Template", [], None,
|
||||
"{name, path}: writes the template as a .lcprint JSON file ({format, version, name, settings}; `.lcprint` is added when the path has no extension); replaces an existing file, never an original → {name, path}",
|
||||
always, template_export),
|
||||
cmd!(query "print.template.import", "Import Print Template", [], None,
|
||||
"{path: a .lcprint file (at most 256 KB)}: checked like a saved template (format, version, name, every setting; cell photos and guides are dropped) and added under its name; a name taken (any case) gets \" (2)\"… → print.template.list + {name, renamed}",
|
||||
always, template_import),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Slideshow Playback Settings",
|
||||
[],
|
||||
None,
|
||||
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
|
||||
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due), manual?: bool (manual slideshow: a slide stays up until ← or → changes it)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews, manual}",
|
||||
always,
|
||||
playback
|
||||
),
|
||||
|
||||
@@ -2026,6 +2026,18 @@ pub fn write_file_durable(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
write_with(path, bytes, true)
|
||||
}
|
||||
|
||||
/// Write `path` only when nothing exists there: the destination is prepared as a complete temp
|
||||
/// file and published with create-new semantics, so a file that appears after a caller's check is
|
||||
/// never replaced. Screenshots of an initially absent path use this instead of the overwriting
|
||||
/// writer.
|
||||
pub fn write_file_new(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let p = std::path::Path::new(path);
|
||||
if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
lightcraft_catalog::safe_file::write_new(p, bytes).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn write_with(path: &str, bytes: &[u8], durable: bool) -> Result<(), String> {
|
||||
use lightcraft_catalog::safe_file::{write_atomic, write_atomic_nosync};
|
||||
let p = std::path::Path::new(path);
|
||||
|
||||
@@ -6,17 +6,102 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
use std::time::SystemTime;
|
||||
|
||||
use lightcraft_catalog::safe_file::same_file;
|
||||
use lightcraft_catalog::{Catalog, Source};
|
||||
|
||||
/// The directory entry a screenshot is about to write, pinned at request admission.
|
||||
///
|
||||
/// A later write must not replace a path that was absent when the request was admitted (for
|
||||
/// example a photo imported while the capture was waiting), nor a destination that was replaced by
|
||||
/// another file in the meantime. [`Session::revalidate_write_target`] checks this immediately
|
||||
/// before the write, and an absent destination is then published with create-new semantics.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WriteTarget {
|
||||
path: String,
|
||||
state: WriteTargetState,
|
||||
/// The canonical parent folder at admission, when it existed: a retargeted parent symlink is a
|
||||
/// different destination even when the file name is unchanged.
|
||||
parent: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum WriteTargetState {
|
||||
/// The directory entry did not exist at admission.
|
||||
Absent,
|
||||
/// The directory entry existed; this is its identity (not its contents).
|
||||
Existing(FileIdentity),
|
||||
}
|
||||
|
||||
/// Stable identity of a directory entry, from `symlink_metadata` (so replacing a symlink or a
|
||||
/// hard link is a different entry). Unix uses device + inode; Windows uses the canonical path,
|
||||
/// length and creation time; other platforms use a canonical/len/mtime fallback.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct FileIdentity {
|
||||
#[cfg(unix)]
|
||||
dev: u64,
|
||||
#[cfg(unix)]
|
||||
ino: u64,
|
||||
#[cfg(windows)]
|
||||
canonical: PathBuf,
|
||||
#[cfg(windows)]
|
||||
len: u64,
|
||||
#[cfg(windows)]
|
||||
created: u64,
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
canonical: PathBuf,
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
len: u64,
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
modified: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl FileIdentity {
|
||||
fn of(path: &Path, meta: &std::fs::Metadata) -> Self {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let _ = path;
|
||||
Self { dev: meta.dev(), ino: meta.ino() }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// The volume serial/file index metadata is still unstable; use stable metadata that
|
||||
// changes when a different file replaces the entry, and keep the same in-place
|
||||
// content edits (which do not change the creation time or length) overwritable.
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
Self { canonical: std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), len: meta.len(), created: meta.creation_time() }
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
Self { canonical: std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), len: meta.len(), modified: meta.modified().ok() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WriteTarget {
|
||||
/// The destination path as the request named it.
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Was the directory entry absent at admission? Such a destination is published with
|
||||
/// create-new semantics, never with the overwriting writer.
|
||||
pub fn was_absent(&self) -> bool {
|
||||
matches!(&self.state, WriteTargetState::Absent)
|
||||
}
|
||||
}
|
||||
|
||||
/// The library's originals and their sidecars, looked up by file name.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OriginalGuard {
|
||||
/// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …)
|
||||
by_name: HashMap<String, Vec<(PathBuf, String)>>,
|
||||
/// The entries of `by_name` that are symlinks, found on the first check that needs them.
|
||||
links: std::sync::OnceLock<Vec<(PathBuf, String)>>,
|
||||
/// Every indexed entry, built on the first check whose target name did not match: a hard
|
||||
/// link or symlink may name an original differently from the catalogued path.
|
||||
others: std::sync::OnceLock<Vec<(PathBuf, String)>>,
|
||||
}
|
||||
|
||||
impl OriginalGuard {
|
||||
@@ -34,7 +119,7 @@ impl OriginalGuard {
|
||||
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
|
||||
pub fn add(&mut self, path: &Path, label: &str) {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
|
||||
self.links = std::sync::OnceLock::new();
|
||||
self.others = std::sync::OnceLock::new();
|
||||
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
|
||||
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
@@ -44,6 +129,17 @@ impl OriginalGuard {
|
||||
|
||||
/// `Err` (a message for the user) when writing `target` would replace a protected file.
|
||||
pub fn check(&self, target: &Path) -> Result<(), String> {
|
||||
self.check_target(target, false)
|
||||
}
|
||||
|
||||
/// [`Self::check`] plus hard links: a screenshot target must not replace an original reached
|
||||
/// through a link with a different name. The per-entry identity scan is limited to this
|
||||
/// long-lived, user-visible destination check, not every ordinary export overwrite.
|
||||
pub fn check_identity(&self, target: &Path) -> Result<(), String> {
|
||||
self.check_target(target, true)
|
||||
}
|
||||
|
||||
fn check_target(&self, target: &Path, hard_links: bool) -> Result<(), String> {
|
||||
if std::fs::symlink_metadata(target).is_err() {
|
||||
return Ok(()); // nothing there to replace
|
||||
}
|
||||
@@ -61,12 +157,15 @@ impl OriginalGuard {
|
||||
}
|
||||
}
|
||||
}
|
||||
// An imported symlink can have a different name from its original. A write to
|
||||
// that original would change what the library reads through the link. Only do
|
||||
// this fallback for existing targets that the name index did not protect; the links are
|
||||
// looked up once per guard, not once per target.
|
||||
let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect());
|
||||
for (p, what) in links {
|
||||
// An imported hard link or symlink can have a different name from its original. A write
|
||||
// to that entry would change what the library reads. Only do this fallback for existing
|
||||
// targets that the name index did not protect; the list is built once per guard, not once
|
||||
// per target. Ordinary exports keep the cheaper symlink-only scan.
|
||||
let others = self.others.get_or_init(|| self.by_name.values().flatten().cloned().collect());
|
||||
for (p, what) in others {
|
||||
if !hard_links && std::fs::read_link(p).is_err() {
|
||||
continue;
|
||||
}
|
||||
if same_file(p, target) {
|
||||
return Err(format!(
|
||||
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
|
||||
@@ -93,6 +192,69 @@ impl crate::Session {
|
||||
}
|
||||
self.original_guard().check(path)
|
||||
}
|
||||
|
||||
/// Pin the destination of a long-running write (a screenshot) now.
|
||||
///
|
||||
/// The returned [`WriteTarget`] records whether the directory entry existed and, if so, its
|
||||
/// identity, plus the canonical parent folder. Pass it to [`Self::revalidate_write_target`]
|
||||
/// immediately before writing.
|
||||
pub fn pin_write_target(&self, path: &str) -> Result<WriteTarget, String> {
|
||||
let p = Path::new(path);
|
||||
let parent = p.parent().filter(|d| !d.as_os_str().is_empty()).and_then(|d| std::fs::canonicalize(d).ok());
|
||||
match std::fs::symlink_metadata(p) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(WriteTarget { path: path.to_string(), state: WriteTargetState::Absent, parent })
|
||||
}
|
||||
Err(error) => Err(format!("{path}: {error}")),
|
||||
Ok(meta) => {
|
||||
// Refuse a catalogued original or sidecar (including a differently named hard
|
||||
// link) before the request is queued.
|
||||
self.original_guard().check_identity(p)?;
|
||||
Ok(WriteTarget { path: path.to_string(), state: WriteTargetState::Existing(FileIdentity::of(p, &meta)), parent })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-check a pinned target immediately before writing.
|
||||
///
|
||||
/// An absent target must still be absent; an existing target must still name the same
|
||||
/// directory entry and must still pass the current catalog's original guard. A parent folder
|
||||
/// whose canonical path changed (a retargeted symlink) is refused too.
|
||||
pub fn revalidate_write_target(&self, target: &WriteTarget) -> Result<(), String> {
|
||||
if let Some(parent) = &target.parent {
|
||||
// Resolve the request's parent again: canonicalizing the saved resolved folder
|
||||
// would miss a directory symlink in the original path being retargeted.
|
||||
let now = Path::new(&target.path).parent().and_then(|path| std::fs::canonicalize(path).ok());
|
||||
match now {
|
||||
Some(now) if now == *parent => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"{}: the destination folder changed while the screenshot was being captured; refusing to write",
|
||||
target.path
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
match std::fs::symlink_metadata(&target.path) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => match &target.state {
|
||||
WriteTargetState::Absent => Ok(()),
|
||||
WriteTargetState::Existing(_) => {
|
||||
Err(format!("{}: the file was removed while the screenshot was being captured; refusing to create it again", target.path))
|
||||
}
|
||||
},
|
||||
Err(error) => Err(format!("{}: {error}", target.path)),
|
||||
Ok(meta) => match &target.state {
|
||||
WriteTargetState::Absent => Err(format!("{} appeared while the screenshot was being captured; refusing to replace it", target.path)),
|
||||
WriteTargetState::Existing(identity) => {
|
||||
if FileIdentity::of(Path::new(&target.path), &meta) != *identity {
|
||||
return Err(format!("{} changed while the screenshot was being captured; refusing to replace the new file", target.path));
|
||||
}
|
||||
// The catalog may have gained this file as an original since admission.
|
||||
self.original_guard().check_identity(Path::new(&target.path))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -120,4 +282,61 @@ mod tests {
|
||||
assert!(g.check(&d.join("new.jpg")).is_ok(), "a free name");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_targets_pin_absent_paths_and_recheck_the_current_library() {
|
||||
let d = std::env::temp_dir().join(format!("lc-write-targets-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let mut s = crate::Session::new();
|
||||
let original = d.join("IMG_1.jpg");
|
||||
std::fs::write(&original, b"original").unwrap();
|
||||
let photo = Photo::new(PhotoId(1), Source::File { path: original.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
|
||||
// A path that was absent at admission is refused when anything appears there.
|
||||
let free = d.join("new-shot.png");
|
||||
let absent = s.pin_write_target(&free.to_string_lossy()).expect("free path");
|
||||
assert!(absent.was_absent());
|
||||
std::fs::write(&free, b"appeared").unwrap();
|
||||
assert!(s.revalidate_write_target(&absent).unwrap_err().contains("appeared"));
|
||||
std::fs::remove_file(&free).unwrap();
|
||||
|
||||
// An ordinary existing path can still be overwritten in place (same entry).
|
||||
let allowed = d.join("old-shot.png");
|
||||
std::fs::write(&allowed, b"old").unwrap();
|
||||
let existing = s.pin_write_target(&allowed.to_string_lossy()).expect("ordinary file");
|
||||
assert!(!existing.was_absent());
|
||||
std::fs::write(&allowed, b"new").unwrap();
|
||||
assert!(s.revalidate_write_target(&existing).is_ok(), "an allowed screenshot refresh keeps working");
|
||||
|
||||
// A hard link to an original is refused at admission, and an absent path that becomes
|
||||
// such a link is refused at write time.
|
||||
let linked = d.join("linked.png");
|
||||
std::fs::hard_link(&original, &linked).unwrap();
|
||||
assert!(s.pin_write_target(&linked.to_string_lossy()).unwrap_err().contains("original"));
|
||||
std::fs::remove_file(&linked).unwrap();
|
||||
let appears_as_link = d.join("late-link.png");
|
||||
let late = s.pin_write_target(&appears_as_link.to_string_lossy()).expect("free path");
|
||||
std::fs::hard_link(&original, &appears_as_link).unwrap();
|
||||
assert!(s.revalidate_write_target(&late).unwrap_err().contains("appeared"));
|
||||
|
||||
// A path pinned as an ordinary file becomes an original through a catalog import while
|
||||
// the capture is queued: the current guard refuses the write.
|
||||
let imported = d.join("imported.png");
|
||||
std::fs::write(&imported, b"not yet imported").unwrap();
|
||||
let queued = s.pin_write_target(&imported.to_string_lossy()).expect("ordinary file");
|
||||
let p = Photo::new(PhotoId(2), Source::File { path: imported.to_string_lossy().to_string() }, "imported.png", "PNG", 10, 10, "2026-01-01");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
assert!(s.revalidate_write_target(&queued).unwrap_err().contains("original"));
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
let link = d.join("link-shot.png");
|
||||
symlink(&original, &link).unwrap();
|
||||
assert!(s.pin_write_target(&link.to_string_lossy()).unwrap_err().contains("original"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ impl PrintSettings {
|
||||
pub const MAX_NAME: usize = 80;
|
||||
/// Most templates kept.
|
||||
pub const MAX_TEMPLATES: usize = 200;
|
||||
/// File extension of an exported print template (JSON).
|
||||
pub const FILE_EXT: &str = "lcprint";
|
||||
/// `format` tag of an exported print template file.
|
||||
const FILE_FORMAT: &str = "lightcraft.printTemplate";
|
||||
/// Version of the template file this build writes and the newest it reads.
|
||||
const FILE_VERSION: u64 = 1;
|
||||
/// Largest template file read, bytes.
|
||||
pub const MAX_FILE: u64 = 256 * 1024;
|
||||
|
||||
/// A saved print template: the layout, image settings, page and job of a print, without the
|
||||
/// photos assigned to cells and without the guides (a view preference). The settings are kept as
|
||||
@@ -74,6 +82,53 @@ impl PrintTemplate {
|
||||
s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The document of an exported template: `{format, version, name, settings}`.
|
||||
pub fn to_file(&self) -> serde_json::Value {
|
||||
serde_json::json!({"format": FILE_FORMAT, "version": FILE_VERSION, "name": self.name, "settings": self.settings})
|
||||
}
|
||||
|
||||
/// A template read from an exported file. Refused: more than [`MAX_FILE`] bytes, not JSON,
|
||||
/// another format or a newer version, a bad name, and settings that fail the checks a saved
|
||||
/// template's settings get. Photos assigned to cells and guides in the file are dropped.
|
||||
pub fn from_file(bytes: &[u8]) -> Result<Self, String> {
|
||||
if bytes.len() as u64 > MAX_FILE {
|
||||
return Err(format!("{} bytes is too large for a print template (at most {} KB)", bytes.len(), MAX_FILE / 1024));
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| format!("not a print template file: {e}"))?;
|
||||
if v.get("format").and_then(serde_json::Value::as_str) != Some(FILE_FORMAT) {
|
||||
return Err(format!("not a print template file (`format` is not \"{FILE_FORMAT}\")"));
|
||||
}
|
||||
match v.get("version").and_then(serde_json::Value::as_u64) {
|
||||
Some(1..=FILE_VERSION) => {}
|
||||
Some(n) => return Err(format!("print template file version {n} is not one this version of LightCraft reads (1 to {FILE_VERSION})")),
|
||||
None => return Err("a print template file needs a `version` number".into()),
|
||||
}
|
||||
let name = clean_name(v.get("name").and_then(serde_json::Value::as_str).ok_or("a print template file needs a `name`")?)?;
|
||||
let settings = v.get("settings").cloned().ok_or("a print template file needs `settings`")?;
|
||||
let checked = PrintTemplate { name: name.clone(), settings }.settings(&Guides::default())?;
|
||||
Self::new(&name, &checked)
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` if no template in `taken` has it (in any case), else the first free `name (2)`,
|
||||
/// `name (3)`…, with `name` shortened to keep the result within [`MAX_NAME`] characters.
|
||||
pub fn unique_name(name: &str, taken: &[PrintTemplate]) -> String {
|
||||
let free = |n: &str| !taken.iter().any(|t| t.name.eq_ignore_ascii_case(n));
|
||||
if free(name) {
|
||||
return name.to_string();
|
||||
}
|
||||
let mut n = 2usize;
|
||||
loop {
|
||||
let suffix = format!(" ({n})");
|
||||
let keep = MAX_NAME.saturating_sub(suffix.chars().count());
|
||||
let base: String = name.chars().take(keep).collect();
|
||||
let candidate = format!("{}{suffix}", base.trim_end());
|
||||
if free(&candidate) || n > taken.len() {
|
||||
return candidate;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -121,4 +176,64 @@ mod tests {
|
||||
s.job.ppi = f32::NAN;
|
||||
assert!(PrintTemplate::new("t", &s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_file_reads_back_as_the_template() {
|
||||
let mut s = PrintSettings::default();
|
||||
s.layout.style = Style::ContactSheet;
|
||||
s.page.background = [1, 2, 3];
|
||||
let t = PrintTemplate::new("Proofs", &s).unwrap();
|
||||
let bytes = serde_json::to_vec(&t.to_file()).unwrap();
|
||||
assert_eq!(PrintTemplate::from_file(&bytes).unwrap(), t);
|
||||
// a hand-written file: photos and guides are not taken in
|
||||
let doc = json!({"format": FILE_FORMAT, "version": 1, "name": " Hand ", "settings": {
|
||||
"layout": {"style": "customPackage", "cells": [{"id": 1, "rect": {"x": 1, "y": 1, "w": 2, "h": 2}, "photo": 9}]},
|
||||
"guides": {"gridSize": 1.0}}});
|
||||
let t = PrintTemplate::from_file(&serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
assert_eq!(t.name, "Hand");
|
||||
assert!(t.settings.get("guides").is_none());
|
||||
assert!(t.settings.pointer("/layout/cells/0/rect").is_some());
|
||||
assert_eq!(t.settings.pointer("/layout/cells/0/photo"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_template_files_are_refused() {
|
||||
let file = |v: serde_json::Value| PrintTemplate::from_file(&serde_json::to_vec(&v).unwrap()).unwrap_err();
|
||||
let ok = |extra: serde_json::Value| {
|
||||
let mut v = json!({"format": FILE_FORMAT, "version": 1, "name": "t", "settings": {}});
|
||||
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
v
|
||||
};
|
||||
assert!(PrintTemplate::from_file(b"not json").unwrap_err().contains("not a print template file"));
|
||||
assert!(PrintTemplate::from_file(&vec![b' '; MAX_FILE as usize + 1]).unwrap_err().contains("too large"));
|
||||
assert!(file(json!([1, 2])).contains("format"));
|
||||
assert!(file(ok(json!({"format": "lightcraft.slideshowTemplate"}))).contains("format"));
|
||||
assert!(file(ok(json!({"version": 2}))).contains("version 2"));
|
||||
assert!(file(ok(json!({"version": 0}))).contains("version 0"));
|
||||
assert!(file(ok(json!({"version": "1"}))).contains("`version` number"));
|
||||
assert!(file(json!({"format": FILE_FORMAT, "name": "t", "settings": {}})).contains("`version` number"));
|
||||
assert!(file(ok(json!({"name": "a\nb"}))).contains("template name"));
|
||||
assert!(file(ok(json!({"name": 5}))).contains("`name`"));
|
||||
assert!(file(json!({"format": FILE_FORMAT, "version": 1, "name": "t"})).contains("`settings`"));
|
||||
assert!(file(ok(json!({"settings": {"job": {"ppi": 1e30}}}))).contains("template `t`"));
|
||||
assert!(file(ok(json!({"settings": {"surprise": 1}}))).contains("template `t`"));
|
||||
assert!(PrintTemplate::from_file(&serde_json::to_vec(&ok(json!({}))).unwrap()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_name_never_takes_an_existing_one() {
|
||||
let t = |n: &str| PrintTemplate::new(n, &PrintSettings::default()).unwrap();
|
||||
assert_eq!(unique_name("Proofs", &[]), "Proofs");
|
||||
assert_eq!(unique_name("Proofs", &[t("proofs")]), "Proofs (2)");
|
||||
assert_eq!(unique_name("Proofs", &[t("Proofs"), t("PROOFS (2)")]), "Proofs (3)");
|
||||
let long = "x".repeat(MAX_NAME);
|
||||
let named = unique_name(&long, &[t(&long)]);
|
||||
assert_eq!(named.chars().count(), MAX_NAME);
|
||||
assert!(named.ends_with(" (2)"));
|
||||
let many: Vec<PrintTemplate> = std::iter::once(t("A")).chain((2..=MAX_TEMPLATES).map(|n| t(&format!("A ({n})")))).collect();
|
||||
let named = unique_name("A", &many);
|
||||
assert!(!many.iter().any(|x| x.name.eq_ignore_ascii_case(&named)), "{named}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that
|
||||
//! plays the photos in view (durations, fades, random order, repeat, pause, stepping), between an
|
||||
//! optional intro and ending screen (the Titles panel, [`crate::slide_title`]).
|
||||
//! plays the photos in view (durations, fades, random order, repeat, pause, stepping, manual
|
||||
//! advance), between an optional intro and ending screen (the Titles panel,
|
||||
//! [`crate::slide_title`]).
|
||||
//!
|
||||
//! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any
|
||||
//! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline.
|
||||
@@ -39,11 +40,22 @@ pub struct PlaybackSettings {
|
||||
pub repeat: bool,
|
||||
/// Render the next slides before they are due.
|
||||
pub prepare_previews: bool,
|
||||
/// Manual slideshow: a slide stays up until ← / → (or the controls) change it; the slide
|
||||
/// duration is not used for playback then.
|
||||
pub manual: bool,
|
||||
}
|
||||
|
||||
impl Default for PlaybackSettings {
|
||||
fn default() -> Self {
|
||||
PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true }
|
||||
PlaybackSettings {
|
||||
slide_duration: 4.0,
|
||||
fade_duration: 1.0,
|
||||
fade_color: None,
|
||||
random_order: false,
|
||||
repeat: true,
|
||||
prepare_previews: true,
|
||||
manual: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +78,7 @@ impl PlaybackSettings {
|
||||
"randomOrder": self.random_order,
|
||||
"repeat": self.repeat,
|
||||
"preparePreviews": self.prepare_previews,
|
||||
"manual": self.manual,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,8 +95,11 @@ impl PlaybackSettings {
|
||||
"randomOrder" => s.random_order = boolean(k, v)?,
|
||||
"repeat" => s.repeat = boolean(k, v)?,
|
||||
"preparePreviews" => s.prepare_previews = boolean(k, v)?,
|
||||
"manual" => s.manual = boolean(k, v)?,
|
||||
other => {
|
||||
return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)"));
|
||||
return Err(format!(
|
||||
"unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews, manual)"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,8 +448,12 @@ impl Playback {
|
||||
self.started + self.duration(self.current())
|
||||
}
|
||||
|
||||
/// Seconds until the next slide is due (as of `now`; frozen while paused).
|
||||
/// Seconds until the next slide is due (as of `now`; frozen while paused; infinite in a
|
||||
/// manual slideshow, where no slide is ever due).
|
||||
pub fn remaining(&self, now: f64) -> f64 {
|
||||
if self.settings.manual {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
(self.due() - self.clock(now)).max(0.0)
|
||||
}
|
||||
|
||||
@@ -490,7 +510,8 @@ impl Playback {
|
||||
self.started = now;
|
||||
return Tick::Idle;
|
||||
}
|
||||
if now < self.due() {
|
||||
// a manual slideshow moves only with `step` (← / →)
|
||||
if self.settings.manual || now < self.due() {
|
||||
return Tick::Idle;
|
||||
}
|
||||
let Some(next) = self.upcoming(1).first().copied() else {
|
||||
@@ -777,6 +798,30 @@ mod tests {
|
||||
assert!(Playback::new(vec![], None, d, 0, 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manual_slideshow_moves_only_with_the_arrows() {
|
||||
let s = PlaybackSettings { manual: true, repeat: false, ..settings(1.0, 0.5) };
|
||||
let mut p = titled(3, s, Some(1.0), Some(1.0));
|
||||
assert_eq!(p.tick(1000.0, all_ready), Tick::Idle, "the intro stays up past its duration");
|
||||
assert_eq!(p.current(), Item::INTRO);
|
||||
assert_eq!(p.remaining(1000.0), f64::INFINITY, "nothing is ever due");
|
||||
assert_eq!(p.upcoming(1), vec![ph(1)], "the next slide is still prepared for →");
|
||||
assert_eq!(p.step(1, 1000.0), Tick::Advanced(ph(1)));
|
||||
assert_eq!(p.tick(5000.0, all_ready), Tick::Idle);
|
||||
assert_eq!(p.current(), ph(1), "a photo stays up too");
|
||||
assert_eq!(p.step(-1, 5000.0), Tick::Advanced(Item::INTRO), "← goes back");
|
||||
assert_eq!(p.step(4, 5000.0), Tick::Advanced(Item::ENDING));
|
||||
assert_eq!(p.tick(9000.0, all_ready), Tick::Idle, "the ending stays up until → ends the show");
|
||||
assert_eq!(p.step(1, 9000.0), Tick::Ended);
|
||||
// the setting round-trips and is checked like the others
|
||||
let d = PlaybackSettings::default();
|
||||
assert!(!d.manual, "off by default");
|
||||
let m = d.merged(&json!({"manual": true})).unwrap();
|
||||
assert!(m.manual);
|
||||
assert_eq!(m.to_json()["manual"], true);
|
||||
assert!(d.merged(&json!({"manual": "yes"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_photo_repeats_without_changing() {
|
||||
let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap();
|
||||
|
||||
@@ -263,6 +263,29 @@ fn set_names_match_whatever_their_case() {
|
||||
assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted");
|
||||
}
|
||||
|
||||
/// Next / Previous Keyword Set (⌥0 / ⌥⇧0) step through the sets in the Keyword Set menu's order,
|
||||
/// Recent Keywords first, and wrap around at either end; with no saved sets they stay on Recent
|
||||
/// Keywords.
|
||||
#[test]
|
||||
fn next_and_previous_keyword_set_wrap_around_the_sets() {
|
||||
let mut s = Session::with_demo();
|
||||
let current = |r: serde_json::Value| r["current"].as_str().unwrap_or_default().to_string();
|
||||
assert_eq!(current(s.execute("keyword.nextSet", &json!({})).unwrap()), "Recent Keywords", "no sets: nothing to go to");
|
||||
assert_eq!(current(s.execute("keyword.previousSet", &json!({})).unwrap()), "Recent Keywords");
|
||||
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
|
||||
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbor"]})).unwrap();
|
||||
s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap();
|
||||
let forward: Vec<String> = (0..3).map(|_| current(s.execute("keyword.nextSet", &json!({})).unwrap())).collect();
|
||||
assert_eq!(forward, ["Weddings", "Travel", "Recent Keywords"]);
|
||||
let back: Vec<String> = (0..3).map(|_| current(s.execute("keyword.previousSet", &json!({})).unwrap())).collect();
|
||||
assert_eq!(back, ["Travel", "Weddings", "Recent Keywords"]);
|
||||
let r = s.execute("keyword.nextSet", &json!({})).unwrap();
|
||||
assert_eq!(r["keywords"], json!(["ceremony"]), "⌥1–⌥9 now apply the set's keywords");
|
||||
// a current set that is no longer there counts as Recent Keywords
|
||||
s.keyword_set = Some("Gone".into());
|
||||
assert_eq!(current(s.execute("keyword.nextSet", &json!({})).unwrap()), "Weddings");
|
||||
}
|
||||
|
||||
/// Export Keywords writes the keyword list file, and names the keywords Capture One's importer
|
||||
/// refuses (it allows none of ; , < > in a list); without a path it gives the text.
|
||||
#[test]
|
||||
|
||||
@@ -403,7 +403,7 @@ fn slideshow_playback_settings_persist_with_the_library() {
|
||||
let r = s
|
||||
.execute(
|
||||
"slideshow.playback",
|
||||
&json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false}),
|
||||
&json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "manual": true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["fadeColor"], "#000000");
|
||||
@@ -417,7 +417,7 @@ fn slideshow_playback_settings_persist_with_the_library() {
|
||||
let r = s.execute("slideshow.playback", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
r,
|
||||
json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true})
|
||||
json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true, "manual": true})
|
||||
);
|
||||
drop(s);
|
||||
// out-of-range values written by hand are clamped when the library opens
|
||||
|
||||
@@ -14,6 +14,24 @@ fn fails(s: &mut Session, id: &str, p: Value) -> String {
|
||||
s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string()
|
||||
}
|
||||
|
||||
/// A scratch folder that goes away with the test, however it ends.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-print-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn package(s: &mut Session) {
|
||||
run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}}));
|
||||
}
|
||||
@@ -323,6 +341,125 @@ fn templates_save_apply_delete_and_persist() {
|
||||
assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_export_to_a_file_and_import_from_one() {
|
||||
let files = MemStore::new();
|
||||
let open = |files: &MemStore| {
|
||||
let mut s = Session::with_demo();
|
||||
let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
s
|
||||
};
|
||||
let scratch = Scratch::new("templates");
|
||||
let dir = &scratch.0;
|
||||
let mut s = open(&files);
|
||||
run(&mut s, "print.set", json!({"layout": {"style": "contactSheet", "grid": {"columns": 3, "rows": 4}}, "page": {"background": [5, 6, 7]}}));
|
||||
run(&mut s, "print.template.save", json!({"name": "Proofs"}));
|
||||
|
||||
// the extension is added when the path has none
|
||||
let r = run(&mut s, "print.template.export", json!({"name": "proofs", "path": dir.join("proofs").to_string_lossy()}));
|
||||
assert_eq!(r["name"], "Proofs");
|
||||
let file = dir.join("proofs.lcprint");
|
||||
assert_eq!(r["path"], file.to_string_lossy().as_ref());
|
||||
let doc: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
|
||||
assert_eq!((doc["format"].as_str(), doc["version"].as_u64(), doc["name"].as_str()), (Some("lightcraft.printTemplate"), Some(1), Some("Proofs")));
|
||||
|
||||
// into the same library: the name is taken, so the copy is renamed
|
||||
let r = run(&mut s, "print.template.import", json!({"path": file.to_string_lossy()}));
|
||||
assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs (2)"), Some(true)), "{r}");
|
||||
assert_eq!(r["templates"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(r["templates"][1]["settings"], r["templates"][0]["settings"]);
|
||||
|
||||
// into another library, under its own name, and it is kept there
|
||||
let other = MemStore::new();
|
||||
let mut b = open(&other);
|
||||
let r = run(&mut b, "print.template.import", json!({"path": file.to_string_lossy()}));
|
||||
assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs"), Some(false)));
|
||||
let g = run(&mut open(&other), "print.template.apply", json!({"name": "Proofs"}));
|
||||
assert_eq!(g["settings"]["layout"]["grid"]["columns"], 3);
|
||||
assert_eq!(g["settings"]["page"]["background"], json!([5, 6, 7]));
|
||||
|
||||
// refusals leave the templates as they were
|
||||
let bad_file = dir.join("bad.lcprint");
|
||||
std::fs::write(&bad_file, br#"{"format": "lightcraft.printTemplate", "version": 1, "name": "Bad", "settings": {"layout": {"pages": 0}}}"#)
|
||||
.unwrap();
|
||||
let huge = dir.join("huge.lcprint");
|
||||
std::fs::write(&huge, vec![b' '; crate::print::template::MAX_FILE as usize + 1]).unwrap();
|
||||
for (p, why) in [
|
||||
(json!({}), "missing `path`"),
|
||||
(json!({"path": " "}), "missing `path`"),
|
||||
(json!({"path": dir.join("absent.lcprint").to_string_lossy()}), "absent.lcprint"),
|
||||
(json!({"path": dir.to_string_lossy()}), "not a file"),
|
||||
(json!({"path": huge.to_string_lossy()}), "too large"),
|
||||
(json!({"path": bad_file.to_string_lossy()}), "template `Bad`"),
|
||||
] {
|
||||
let e = fails(&mut s, "print.template.import", p.clone());
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 2);
|
||||
assert!(fails(&mut s, "print.template.export", json!({"name": "Nope", "path": dir.join("x").to_string_lossy()})).contains("no print template"));
|
||||
assert!(fails(&mut s, "print.template.export", json!({"name": "Proofs"})).contains("missing `path`"));
|
||||
assert!(fails(&mut s, "print.template.export", json!({"path": dir.join("x").to_string_lossy()})).contains("missing `name`"));
|
||||
assert!(!dir.join("x.lcprint").exists());
|
||||
|
||||
// a full list takes no more
|
||||
while s.print_templates.len() < crate::print::template::MAX_TEMPLATES {
|
||||
let n = s.print_templates.len();
|
||||
run(&mut s, "print.template.save", json!({"name": format!("T{n}")}));
|
||||
}
|
||||
assert!(fails(&mut s, "print.template.import", json!({"path": file.to_string_lossy()})).contains("at most"));
|
||||
}
|
||||
|
||||
/// A library whose `prefs.json` can't be read, so saving the preferences fails.
|
||||
#[derive(Clone, Default)]
|
||||
struct PrefsUnreadable(MemStore);
|
||||
|
||||
impl lightcraft_catalog::Store for PrefsUnreadable {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
if name == "prefs.json" {
|
||||
return Err(std::io::Error::other("locked by another process"));
|
||||
}
|
||||
self.0.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.0.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"prefs unreadable".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_changes_that_cant_be_saved_are_undone() {
|
||||
let mut s = Session::with_demo();
|
||||
let stores =
|
||||
LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(PrefsUnreadable::default()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
s.print_templates.push(crate::print::template::PrintTemplate::new("Proofs", &s.print).unwrap());
|
||||
let names = |s: &Session| s.print_templates.iter().map(|t| (t.name.clone(), t.settings.clone())).collect::<Vec<_>>();
|
||||
let kept = names(&s);
|
||||
let scratch = Scratch::new("unsaved");
|
||||
let file = scratch.0.join("proofs.lcprint");
|
||||
run(&mut s, "print.template.export", json!({"name": "Proofs", "path": file.to_string_lossy()}));
|
||||
|
||||
run(&mut s, "print.set", json!({"layout": {"style": "contactSheet"}}));
|
||||
for (id, p) in [
|
||||
("print.template.save", json!({"name": "Other"})),
|
||||
("print.template.save", json!({"name": "proofs"})),
|
||||
("print.template.delete", json!({"name": "Proofs"})),
|
||||
("print.template.import", json!({"path": file.to_string_lossy()})),
|
||||
] {
|
||||
assert!(fails(&mut s, id, p.clone()).contains("prefs.json"), "{id} {p}");
|
||||
assert_eq!(names(&s), kept, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_template_in_the_preferences_does_not_break_the_library() {
|
||||
let files = MemStore::new();
|
||||
@@ -364,6 +501,8 @@ fn every_print_command_is_registered_with_a_description() {
|
||||
"print.template.save",
|
||||
"print.template.apply",
|
||||
"print.template.delete",
|
||||
"print.template.export",
|
||||
"print.template.import",
|
||||
] {
|
||||
let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing"));
|
||||
assert!(c.params.len() > 3, "{id}");
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Caps on the container's metadata boxes, counted before heic-rs touches the file.
|
||||
//!
|
||||
//! heic-rs 0.1.4 (pinned `=0.1.4`) has no aggregate work budget inside its container parsing:
|
||||
//! every `ipma` association entry rescans the vector of already-seen item ids (quadratic in
|
||||
//! the number of entries), and an `iloc` extent using construction method 2 reconstructs its
|
||||
//! source item afresh for every extent that references it, with no shared cache. Legitimate
|
||||
//! phone-photo metadata never comes near these caps — real corpora keep both well under a few
|
||||
//! kilobytes — so anything above them is hostile or corrupt and is refused before the
|
||||
//! countable CPU work happens.
|
||||
//!
|
||||
//! [`depth_boxes`] stops silently at structural blips: heic-rs's own parser reports those
|
||||
//! better than this walk could, and an `ipma` box past the cap is caught wherever it parses.
|
||||
|
||||
use heic_rs::boxes::BoxIter;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// The largest `ipma` box (item ↔ property associations) payload accepted, 64 KiB. An entry
|
||||
/// costs at least 3 bytes, so this bounds heic-rs's per-entry rescan of earlier item ids to
|
||||
/// ~21 000 entries ≈ 220 million comparisons: long enough to be annoying, never minutes.
|
||||
const MAX_IPMA_BYTES: usize = 64 << 10;
|
||||
|
||||
/// The largest `iloc` box (item locations) payload accepted, 64 KiB. An extent costs at least
|
||||
/// 12 bytes, so this bounds both the parse itself and how many extents a
|
||||
/// construction-method-2 reconstruction chain can visit.
|
||||
const MAX_ILOC_BYTES: usize = 64 << 10;
|
||||
|
||||
/// Falls through unless the container declares more `ipma` or `iloc` payload than a
|
||||
/// legitimate phone photo ever does.
|
||||
pub fn cap(bytes: &[u8]) -> Result<(), Error> {
|
||||
let mut c = Census::default();
|
||||
depth_boxes(&mut c, BoxIter::new(bytes), 0);
|
||||
if c.ipma > MAX_IPMA_BYTES {
|
||||
return Err(Error::Limit(format!("the image carries {} bytes of property associations, over the {MAX_IPMA_BYTES}-byte cap", c.ipma)));
|
||||
}
|
||||
if c.iloc > MAX_ILOC_BYTES {
|
||||
return Err(Error::Limit(format!("the image carries {} bytes of item locations, over the {MAX_ILOC_BYTES}-byte cap", c.iloc)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The metadata-box payload sizes seen so far.
|
||||
#[derive(Default)]
|
||||
struct Census {
|
||||
ipma: usize,
|
||||
iloc: usize,
|
||||
}
|
||||
|
||||
/// Walks boxes, adding the payload sizes of those named in [`Census`].
|
||||
///
|
||||
/// Only `meta` and `iprp` (the two nesting boxes the metadata is built from) are descended
|
||||
/// into, no deeper than that depth, so a nested-box trick can't hide a box from the count.
|
||||
fn depth_boxes(c: &mut Census, boxes: BoxIter<'_>, depth: u32) {
|
||||
if depth > 3 {
|
||||
return;
|
||||
}
|
||||
for b in boxes {
|
||||
// A structural error stops the walk; `heic_rs::probe` reports it properly afterwards.
|
||||
let Ok(b) = b else { return };
|
||||
if b.is(b"ipma") {
|
||||
c.ipma = c.ipma.saturating_add(b.payload.len());
|
||||
} else if b.is(b"iloc") {
|
||||
c.iloc = c.iloc.saturating_add(b.payload.len());
|
||||
} else if b.is(b"meta") {
|
||||
// `meta` is a FullBox: version and flags sit between its header and its children.
|
||||
if b.payload.len() >= 4 {
|
||||
depth_boxes(c, BoxIter::new(&b.payload[4..]), depth + 1);
|
||||
}
|
||||
} else if b.is(b"iprp") {
|
||||
depth_boxes(c, b.children(), depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Assembles ftyp + meta(version/flags + iprp(ipco, ipma)) by hand; only the structure
|
||||
/// the census walks matters, no item is ever defined.
|
||||
fn file_with(ipma_payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic".to_vec();
|
||||
let mut iprp = Vec::new();
|
||||
iprp.extend(box_of(b"ipco", b""));
|
||||
iprp.extend(box_of(b"ipma", ipma_payload));
|
||||
let mut meta = vec![0u8, 0, 0, 0]; // version 0, flags 0
|
||||
meta.extend(box_of(b"iprp", &iprp));
|
||||
out.extend(box_of(b"meta", &meta));
|
||||
out
|
||||
}
|
||||
|
||||
fn box_of(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(8 + body.len());
|
||||
let size = u32::try_from(8 + body.len()).expect("test box fits in u32");
|
||||
out.extend_from_slice(&size.to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_ipma_is_refused() {
|
||||
// 40 000 entries of 6 bytes: 240 KiB of ipma, far over the cap, and quadratic if
|
||||
// parsed (each entry rescans the earlier item ids in heic-rs 0.1.4).
|
||||
let mut ipma = Vec::new();
|
||||
for _ in 0..40_000u32 {
|
||||
ipma.extend_from_slice(&[0u8, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
let bytes = file_with(&ipma);
|
||||
let r = crate::probe(&bytes).map(|_| ());
|
||||
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("associations")), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_undersized_ipma_is_untouched_by_the_caps() {
|
||||
// The same shape with a normal ipma (two entries): the caps pass, and whatever error
|
||||
// follows must not come from them.
|
||||
let bytes = file_with(&[0, 0, 4, 1, 1, 2, 0, 0]);
|
||||
let r = crate::probe(&bytes);
|
||||
assert!(!matches!(r, Err(Error::Limit(ref m)) if m.contains("cap")), "{r:?}");
|
||||
}
|
||||
}
|
||||
+108
-9
@@ -19,6 +19,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod census;
|
||||
mod orient;
|
||||
/// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`).
|
||||
#[cfg(any(test, feature = "testdata"))]
|
||||
@@ -149,20 +150,50 @@ pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
|
||||
}
|
||||
|
||||
fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
|
||||
let info = heic_rs::probe(bytes).map_err(|e| match e {
|
||||
// No `meta` box: no still image, only a `moov` image sequence (a video track).
|
||||
census::cap(bytes)?;
|
||||
// No `meta` box: no still image, only a `moov` image sequence (a video track).
|
||||
let open_error = |e| match e {
|
||||
heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE),
|
||||
e => err(e),
|
||||
})?;
|
||||
let ctx = Context::open(bytes).map_err(err)?;
|
||||
};
|
||||
let ctx = Context::open(bytes).map_err(open_error)?;
|
||||
let id = ctx.meta.primary;
|
||||
let coded = match ctx.grid(id).map_err(err)? {
|
||||
// heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other
|
||||
// items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items
|
||||
// multiplies at every level while each result stays small. Count those visits from the
|
||||
// `iloc` table before reconstructing anything: the primary item first (`heic_rs::probe` and
|
||||
// `Context::grid` both rebuild its descriptor), then its alpha plane, then every tile
|
||||
// reference, repeats included.
|
||||
let mut visits = 0_usize;
|
||||
item_visits(&ctx, id, 0, &mut visits)?;
|
||||
let info = heic_rs::probe(bytes).map_err(open_error)?;
|
||||
if let Some(alpha) = ctx.alpha_item(id).map_err(err)? {
|
||||
item_visits(&ctx, alpha, 0, &mut visits)?;
|
||||
}
|
||||
let mut coded = match ctx.grid(id).map_err(err)? {
|
||||
Some((_, tiles)) => tiles,
|
||||
None => vec![id],
|
||||
};
|
||||
for &item in coded.iter().filter(|&&item| item != id) {
|
||||
item_visits(&ctx, item, 0, &mut visits)?;
|
||||
}
|
||||
// A `dimg` listing may name the same tile more than once, and reconstructable items are
|
||||
// moreover rebuilt for every extent that references them in heic-rs 0.1.4. For the size
|
||||
// check each unique item counts once: the total reconstruction (all of it assembled from
|
||||
// `bytes` by heic-rs) is held below one copy of the file, above which a repeat or chain
|
||||
// wants more bytes than exist. Order doesn't matter here: this only checks that every
|
||||
// tile's bytes lie in the file.
|
||||
coded.sort_unstable();
|
||||
coded.dedup();
|
||||
let mut reconstructed = 0_usize;
|
||||
for item in coded {
|
||||
// Borrowed from `bytes` unless split across extents: cheap.
|
||||
ctx.item_data(item).map_err(err)?;
|
||||
reconstructed = reconstructed.saturating_add(ctx.item_data(item).map_err(err)?.len());
|
||||
if reconstructed > bytes.len() {
|
||||
return Err(Error::Limit(format!(
|
||||
"the container holds {reconstructed} bytes of item data, over the {} bytes of the file itself",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Info {
|
||||
width: info.width,
|
||||
@@ -174,6 +205,36 @@ fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Most extent visits heic-rs may spend reconstructing the primary image's coded items; real
|
||||
/// files need one per tile (plus a few for the rare derived item).
|
||||
const MAX_ITEM_VISITS: usize = 4096;
|
||||
/// Deepest chain of items built from items we follow (heic-rs refuses beyond eight itself).
|
||||
const MAX_ITEM_CHAIN: u32 = 8;
|
||||
|
||||
/// Adds to `visits` the extents heic-rs would read to reconstruct `id`, following items built
|
||||
/// from other items, and fails once that passes [`MAX_ITEM_VISITS`] or the chain is too deep.
|
||||
fn item_visits(ctx: &Context<'_>, id: u32, depth: u32, visits: &mut usize) -> Result<(), Error> {
|
||||
use heic_rs::meta::iloc::Construction;
|
||||
if depth > MAX_ITEM_CHAIN {
|
||||
return Err(Error::Limit(format!("items built from items nest deeper than {MAX_ITEM_CHAIN} levels")));
|
||||
}
|
||||
let Some(loc) = ctx.meta.location(id) else { return Ok(()) };
|
||||
*visits = visits.saturating_add(loc.extents.len().max(1));
|
||||
if *visits > MAX_ITEM_VISITS {
|
||||
return Err(Error::Limit(format!("reconstructing the image takes over {MAX_ITEM_VISITS} item extent reads")));
|
||||
}
|
||||
if loc.construction == Construction::Item {
|
||||
for e in &loc.extents {
|
||||
if let Ok(source) = u32::try_from(e.index)
|
||||
&& source != id
|
||||
{
|
||||
item_visits(ctx, source, depth + 1, visits)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes the primary image, with its metadata.
|
||||
pub fn decode(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
guarded(|| decode_unguarded(bytes, options))
|
||||
@@ -188,7 +249,13 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
|
||||
let ctx = Context::open(bytes).map_err(err)?;
|
||||
let primary = ctx.meta.primary;
|
||||
let mut best: Option<(u32, u32)> = None;
|
||||
// A thumbnail whose own reconstruction would cost too much is not a candidate; the visits
|
||||
// of all the candidates share one budget.
|
||||
let mut candidate_visits = 0_usize;
|
||||
for r in ctx.meta.refs.iter().filter(|r| r.kind == heic_rs::meta::iref::RefKind::Thmb && r.to.contains(&primary)) {
|
||||
if item_visits(&ctx, r.from, 0, &mut candidate_visits).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(p) = ctx.props(r.from) else { continue };
|
||||
let Ok((w, h)) = ctx.coded_size(r.from, &p) else { continue };
|
||||
let edge = w.max(h);
|
||||
@@ -197,6 +264,15 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
|
||||
}
|
||||
}
|
||||
let Some((id, _)) = best else { return Ok(None) };
|
||||
// The same pre-scan as `probe`: the thumbnail's own descriptor and every tile reference
|
||||
// are counted before heic-rs rebuilds any of them.
|
||||
let mut visits = 0_usize;
|
||||
item_visits(&ctx, id, 0, &mut visits)?;
|
||||
if let Some((_, tiles)) = ctx.grid(id).map_err(err)? {
|
||||
for &tile in &tiles {
|
||||
item_visits(&ctx, tile, 0, &mut visits)?;
|
||||
}
|
||||
}
|
||||
let mut decoded = decode_item(&ctx, id, options, false)?;
|
||||
if decoded.icc.is_none() {
|
||||
decoded.icc = item_icc(&ctx, primary);
|
||||
@@ -232,6 +308,13 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
/// `with_alpha` and the item has an alpha auxiliary image.
|
||||
fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result<Decoded, Error> {
|
||||
let p = ctx.props(id).map_err(err)?;
|
||||
// heic-rs 0.1.4 keeps every associated transform and its [`orient::apply`] allocates the
|
||||
// full picture again for each: a file could chain the repetition and multiply decode CPU
|
||||
// without changing any checked size. Real files carry at most one `clap`, `irot` and
|
||||
// `imir`, so refuse anything approaching [`MAX_TRANSFORMS`] (CodeRabbit #44).
|
||||
if p.transforms.len() > MAX_TRANSFORMS {
|
||||
return Err(Error::Limit(format!("the image carries {} transform properties, over the {MAX_TRANSFORMS}-property cap", p.transforms.len())));
|
||||
}
|
||||
let limit = options.max_pixels;
|
||||
// The declared sizes are refused before anything is decoded or allocated.
|
||||
let (cw, ch) = ctx.coded_size(id, &p).map_err(err)?;
|
||||
@@ -245,6 +328,14 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
|
||||
let signal = ycc::Signal { matrix: colour.matrix, full_range: colour.full_range };
|
||||
let alpha_id = if with_alpha { ctx.alpha_item(id).map_err(err)? } else { None };
|
||||
let (w, h) = planes.size();
|
||||
// A grid's canvas dimensions come from its payload, not the checked `ispe` (CodeRabbit
|
||||
// #48): check the real one, before [`ycc::to_rgb16`] allocates for it.
|
||||
match (u32::try_from(w), u32::try_from(h)) {
|
||||
(Ok(wu), Ok(hu)) => {
|
||||
heic_rs::image::check_pixels(wu, hu, limit).map_err(err)?;
|
||||
}
|
||||
_ => return Err(Error::Limit(format!("the image canvas is {w}x{h}, beyond the {limit}-pixel limit"))),
|
||||
}
|
||||
let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest };
|
||||
let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode)
|
||||
.ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?;
|
||||
@@ -281,6 +372,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
|
||||
/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1.
|
||||
const GRID_BUDGET: u64 = 2;
|
||||
|
||||
/// The most transform properties (`clap`/`irot`/`imir`) one item may carry. Real files have at
|
||||
/// most one of each, so anything approaching this is a reordered or hostile association list.
|
||||
const MAX_TRANSFORMS: usize = 12;
|
||||
|
||||
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
|
||||
struct Coded<'a> {
|
||||
frames: Vec<Frame>,
|
||||
@@ -370,8 +465,12 @@ fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour {
|
||||
/// metadata item never fails a decode whose pixels came out fine.
|
||||
fn metadata(ctx: &Context<'_>) -> (Option<Vec<u8>>, Option<String>) {
|
||||
let id = ctx.meta.primary;
|
||||
let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec);
|
||||
let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
|
||||
// Metadata is best effort, so an item that would cost too much to rebuild is skipped, not an error.
|
||||
let mut visits = 0_usize;
|
||||
let cheap = |item: Option<u32>, visits: &mut usize| item.is_none_or(|item| item_visits(ctx, item, 0, visits).is_ok());
|
||||
let exif = if cheap(ctx.exif_item(id), &mut visits) { ctx.exif(id).ok().flatten().map(<[u8]>::to_vec) } else { None };
|
||||
let xmp_item = ctx.xmp_item(id).filter(|&x| cheap(Some(x), &mut visits));
|
||||
let xmp = xmp_item.and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
|
||||
Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(),
|
||||
Cow::Owned(b) => String::from_utf8(b).ok(),
|
||||
});
|
||||
|
||||
@@ -185,3 +185,16 @@ fn truncated_and_corrupted_files_are_errors_not_panics() {
|
||||
let _ = decode_thumbnail(&b, 1, &Options::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// CodeRabbit #44: heic-rs keeps every associated transform and each one re-allocates the
|
||||
/// whole picture, so 13 of them are over the [`crate::MAX_TRANSFORMS`] cap and refused.
|
||||
#[test]
|
||||
fn too_many_transform_properties_are_refused() {
|
||||
let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 13).collect(), ..Spec::new(32, 32, &grey) });
|
||||
let r = decode(&bytes, &Options::default());
|
||||
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("transform")), "{r:?}");
|
||||
// At the cap they're not: 12 identity rotations decode normally.
|
||||
let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 12).collect(), ..Spec::new(32, 32, &grey) });
|
||||
let d = decode(&bytes, &Options::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (32, 32));
|
||||
}
|
||||
|
||||
@@ -161,6 +161,13 @@ pub fn to_rgb16(planes: &Planes<'_>, signal: Signal, alpha: bool, mode: Upsampli
|
||||
if !(8..=16).contains(&bd) || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
// The default caller limit is 2²8 pixels, at most four channels per pixel, so the largest
|
||||
// legitimate `w·h·ch` is 2²30 samples. Anything more is a malformed or hostile canvas the
|
||||
// decode path would have refused before calling here (CodeRabbit #48): refuse rather than
|
||||
// ask for a multi-gigabuffer (or worse, over usize).
|
||||
if w.checked_mul(h)?.checked_mul(ch)? > 1 << 30 {
|
||||
return None;
|
||||
}
|
||||
let max = ((1u32 << bd) - 1) as f32;
|
||||
let half = (1u32 << (bd - 1)) as f32;
|
||||
let scale = (1u32 << (bd - 8)) as f32;
|
||||
@@ -323,4 +330,23 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// CodeRabbit #48: a plane claiming 500 000 × 500 000 pixels must be refused without ever
|
||||
/// allocating for it (before the cap this vec was never built; upper-bounding allocation
|
||||
/// would have been a 7.5-gibabuffer or a panic instead of an error).
|
||||
#[test]
|
||||
fn an_oversized_canvas_is_refused_without_allocation() {
|
||||
let f = Frame {
|
||||
width: 500_000,
|
||||
height: 500_000,
|
||||
bit_depth: 8,
|
||||
chroma: ChromaFormat::Yuv420,
|
||||
y: vec![128],
|
||||
cb: vec![128; 4],
|
||||
cr: vec![128; 4],
|
||||
y_stride: 0,
|
||||
c_stride: 0,
|
||||
};
|
||||
assert!(to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, false, Upsampling::Nearest).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ pub use pano::{PanoOptions, PanoResult, Projection, stitch};
|
||||
pub enum MergeError {
|
||||
#[error("{0} photo(s) selected; at least {1} are needed")]
|
||||
TooFew(usize, usize),
|
||||
#[error("{0} photo(s) selected; a stitch of more than {1} is refused: matching every pair would take far too long")]
|
||||
TooMany(usize, usize),
|
||||
#[error("{0}")]
|
||||
Mismatch(String),
|
||||
#[error("could not read a photo: {0}")]
|
||||
|
||||
@@ -61,10 +61,12 @@ pub struct PairMatch {
|
||||
/// Match every pair of images. `feats[k]` are in work coordinates scaled by `scales[k]`.
|
||||
pub fn match_pairs(feats: &[Features], scales: &[f64]) -> Vec<PairMatch> {
|
||||
let n = feats.len();
|
||||
let pairs: Vec<(usize, usize)> = (0..n).flat_map(|i| (i + 1..n).map(move |j| (i, j))).collect();
|
||||
let mut out: Vec<PairMatch> = pairs
|
||||
.par_iter()
|
||||
.filter_map(|&(i, j)| {
|
||||
// the pairs are generated lazily: materialising all n(n−1)/2 up front is quadratic memory, including
|
||||
// when every comparison comes up empty (no usable features)
|
||||
let mut out: Vec<PairMatch> = (0..n)
|
||||
.into_par_iter()
|
||||
.flat_map_iter(|i| ((i + 1)..n).map(move |j| (i, j)))
|
||||
.filter_map(|(i, j)| {
|
||||
let m = features::match_features(&feats[i], &feats[j], 0.8);
|
||||
if m.len() < 12 {
|
||||
return None;
|
||||
|
||||
@@ -53,6 +53,11 @@ impl Default for PanoOptions {
|
||||
/// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down.
|
||||
pub const MAX_MEGAPIXELS: f64 = 512.0;
|
||||
|
||||
/// Most frames one stitch accepts: pair matching compares every pair of frames (n(n−1)/2 runs), so an
|
||||
/// unbounded frame count is quadratic CPU as well as quadratic memory while the frames load. A panorama
|
||||
/// with more than [`MAX_FRAMES`] shots is refused with an error, not attempted.
|
||||
pub const MAX_FRAMES: usize = 256;
|
||||
|
||||
/// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down.
|
||||
pub const MAX_EDGE: f64 = 65_000.0;
|
||||
|
||||
@@ -224,6 +229,9 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
|
||||
if n < 2 {
|
||||
return Err(MergeError::TooFew(n, 2));
|
||||
}
|
||||
if n > MAX_FRAMES {
|
||||
return Err(MergeError::TooMany(n, MAX_FRAMES));
|
||||
}
|
||||
check(progress, 0.01, "Finding features")?;
|
||||
let (cams, rms_before, rms) = register(&frames, progress)?;
|
||||
let used: Vec<usize> = (0..n).filter(|&i| cams[i].is_some()).collect();
|
||||
|
||||
@@ -246,6 +246,18 @@ fn unrelated_photos_do_not_stitch() {
|
||||
assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err());
|
||||
}
|
||||
|
||||
/// Pair matching compares every pair of frames before anything is known about them: an unbounded
|
||||
/// photo count is quadratic CPU and memory (issue #1519). More than `MAX_FRAMES` is refused at once,
|
||||
/// before any frame is worked on.
|
||||
#[test]
|
||||
fn a_stitch_of_more_than_the_frame_cap_is_refused_up_front() {
|
||||
let frames: Vec<Frame> = (0..lightcraft_merge::pano::MAX_FRAMES + 1)
|
||||
.map(|i| Frame::from_linear_rec2020(Rgb32f::from_fn(8, 8, |_, _| [(i % 255) as f32 / 255.0, 0.0, 0.0]), None))
|
||||
.collect();
|
||||
let err = stitch(frames, &PanoOptions::default(), &no_progress).unwrap_err();
|
||||
assert!(err.to_string().contains("refused"), "the frame cap errors, it does not begin the quadratic match: {err}");
|
||||
}
|
||||
|
||||
/// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their
|
||||
/// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down,
|
||||
/// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail.
|
||||
|
||||
+10
-2
@@ -1,7 +1,7 @@
|
||||
//! Exif / TIFF tags → [`Metadata`] (CIPA DC-008 Exif 2.32, TIFF 6.0, DNG 1.7 tag semantics).
|
||||
|
||||
use crate::{DateTime, Flash, Gps, Metadata, Orientation};
|
||||
use lightcraft_tiff::{Ifd, Tiff, TiffError, tags as t};
|
||||
use lightcraft_tiff::{Ifd, ParseOptions, Tiff, TiffError, tags as t};
|
||||
|
||||
/// Strip an `Exif\0\0` (or `Exif\0\xff`) prefix as found in JPEG APP1 and some WebP `EXIF` chunks.
|
||||
pub fn strip_exif_header(b: &[u8]) -> &[u8] {
|
||||
@@ -16,8 +16,16 @@ pub fn read_exif(bytes: &[u8]) -> Metadata {
|
||||
|
||||
/// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0.
|
||||
pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> {
|
||||
try_read_exif_with(bytes, &ParseOptions::default())
|
||||
}
|
||||
|
||||
/// [`try_read_exif`] with the caller's parser options (its value allocation budget in particular).
|
||||
/// A decoder working from a sparse buffer — only selected ranges of the logical file are actually
|
||||
/// loaded, the rest is zeros — must not size its allocations by the logical file length, which
|
||||
/// unrelated bytes of the file can inflate at no cost to the attacker.
|
||||
pub fn try_read_exif_with(bytes: &[u8], opts: &ParseOptions) -> Result<Metadata, TiffError> {
|
||||
let data = strip_exif_header(bytes);
|
||||
let tiff = Tiff::parse(data)?;
|
||||
let tiff = Tiff::parse_with(data, opts)?;
|
||||
let mut m = from_tiff(&tiff);
|
||||
// Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif)
|
||||
if m.lens_model.is_none() {
|
||||
|
||||
@@ -171,7 +171,15 @@ fn tiff(r: &mut Reader, head: Vec<u8>) -> io::Result<Option<FocusPoint>> {
|
||||
loaded.insert(0, head.len() as u64);
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
match plan(&buf, &loaded) {
|
||||
Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)),
|
||||
Step::Ready => {
|
||||
// The decoder's allocation budget follows the bytes actually fetched, not the logical
|
||||
// file length: only the loaded ranges are data, and an unrelated entry that claims a
|
||||
// huge value must be cut by what leaving it cost, not by what the buffer claims to hold.
|
||||
// The same 8 × / + 1 MiB shape the general TIFF reader uses, over `reader.bytes` instead.
|
||||
let opts =
|
||||
lightcraft_tiff::ParseOptions { value_budget: Some(r.bytes.saturating_mul(8).saturating_add(1 << 20)), ..Default::default() };
|
||||
return Ok(crate::try_read_exif_with(&buf, &opts).ok().and_then(|m| m.focus));
|
||||
}
|
||||
Step::Nothing => return Ok(None),
|
||||
Step::Need(ranges) => {
|
||||
for (a, b) in ranges {
|
||||
|
||||
@@ -27,7 +27,7 @@ mod xmp_merge;
|
||||
|
||||
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
|
||||
pub use datetime::DateTime;
|
||||
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
|
||||
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif};
|
||||
pub use focus::FocusPoint;
|
||||
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
|
||||
pub use iptc::parse_iptc;
|
||||
|
||||
+63
-10
@@ -154,6 +154,11 @@ const MAX_NODES: usize = 200_000;
|
||||
// Expanding a long namespace URI on many short names must not amplify a small packet without bound.
|
||||
pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Most text a converter's flattened property keys may add up to. Keys repeat their whole ancestor
|
||||
/// path (`parent/field`), so a document with a long nested name and a wide child fan-out otherwise
|
||||
/// retains its prefix once per property: node limits alone cannot bound that duplication.
|
||||
const MAX_KEY_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque.
|
||||
pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result<String, XmpError> {
|
||||
canonical(qname, scopes, opaque_budget)
|
||||
@@ -332,18 +337,29 @@ fn is_rdf_meta_attr(k: &str) -> bool {
|
||||
matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns")
|
||||
}
|
||||
|
||||
fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>) {
|
||||
fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
|
||||
for (k, v) in &d.attrs {
|
||||
if !is_rdf_meta_attr(k) {
|
||||
if !is_rdf_meta_attr(k) && take_key_bytes(&format!("{prefix}{k}"), budget) {
|
||||
props.entry(format!("{prefix}{k}")).or_default().push(v.clone());
|
||||
}
|
||||
}
|
||||
for c in &d.children {
|
||||
collect_property(c, prefix, props);
|
||||
collect_property(c, prefix, props, budget);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>) {
|
||||
/// Charge one flattened key's length to the key budget; `false` when the budget is spent (the
|
||||
/// property is then left out, like other elements a limit has cut).
|
||||
fn take_key_bytes(key: &str, budget: &mut usize) -> bool {
|
||||
let n = key.len();
|
||||
if *budget < n {
|
||||
return false;
|
||||
}
|
||||
*budget -= n;
|
||||
true
|
||||
}
|
||||
|
||||
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
|
||||
let key = format!("{prefix}{}", p.name);
|
||||
if let Some(arr) = p.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) {
|
||||
let mut items: Vec<(bool, String)> = arr
|
||||
@@ -357,29 +373,36 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<Str
|
||||
if arr.name == "rdf:Alt" {
|
||||
items.sort_by_key(|(default, _)| !*default);
|
||||
}
|
||||
props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));
|
||||
if take_key_bytes(&key, budget) {
|
||||
props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let struct_prefix = format!("{key}/");
|
||||
if p.attr("rdf:parseType") == Some("Resource") {
|
||||
for c in &p.children {
|
||||
collect_property(c, &struct_prefix, props);
|
||||
collect_property(c, &struct_prefix, props, budget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(d) = p.children.iter().find(|c| c.name == "rdf:Description") {
|
||||
collect_description(d, &struct_prefix, props);
|
||||
collect_description(d, &struct_prefix, props, budget);
|
||||
return;
|
||||
}
|
||||
let field_attrs: Vec<&(String, String)> = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect();
|
||||
if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() {
|
||||
for (k, v) in field_attrs {
|
||||
props.entry(format!("{struct_prefix}{k}")).or_default().push(v.clone());
|
||||
let full = format!("{struct_prefix}{k}");
|
||||
if take_key_bytes(&full, budget) {
|
||||
props.entry(full).or_default().push(v.clone());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string());
|
||||
props.entry(key).or_default().push(v);
|
||||
if take_key_bytes(&key, budget) {
|
||||
props.entry(key).or_default().push(v);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
|
||||
@@ -497,8 +520,10 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
|
||||
find_descriptions(&tree, &mut descs);
|
||||
let mut props = BTreeMap::new();
|
||||
let mut values = BTreeMap::new();
|
||||
// One budget for the whole document: several descriptions cannot each spend a fresh one.
|
||||
let mut key_budget = MAX_KEY_BYTES;
|
||||
for d in descs {
|
||||
collect_description(d, "", &mut props);
|
||||
collect_description(d, "", &mut props, &mut key_budget);
|
||||
if let XmpValue::Struct(m) = node_struct(d) {
|
||||
values.extend(m);
|
||||
}
|
||||
@@ -1171,4 +1196,32 @@ mod tests {
|
||||
assert_eq!(entity("#x41"), Some('A'));
|
||||
assert_eq!(entity("#9"), Some('\t'));
|
||||
}
|
||||
|
||||
/// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child
|
||||
/// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it
|
||||
/// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text).
|
||||
#[test]
|
||||
fn flattened_keys_stay_within_the_cumulative_budget() {
|
||||
let level = "n".repeat(200);
|
||||
let mut doc = String::from(
|
||||
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:lc=\"https://lightcraft.example/lc/\"><rdf:RDF><rdf:Description><lc:root rdf:parseType=\"Resource\">",
|
||||
);
|
||||
for _ in 0..4 {
|
||||
doc.push_str(&format!("<lc:{level} rdf:parseType=\"Resource\">"));
|
||||
}
|
||||
// each leaf has a name of its own, so each one adds a key
|
||||
for i in 0..3000 {
|
||||
doc.push_str(&format!("<lc:leaf{i} lc:att=\"v\"/>"));
|
||||
}
|
||||
doc.push_str("</lc:root>");
|
||||
for _ in 0..4 {
|
||||
doc.push_str(&format!("</lc:{level}>"));
|
||||
}
|
||||
doc.push_str("</rdf:Description></rdf:RDF></x:xmpmeta>");
|
||||
let d = parse_xmp(&doc).unwrap();
|
||||
let total: usize = d.properties.keys().map(String::len).sum();
|
||||
assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget");
|
||||
assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent");
|
||||
assert!(!d.properties.is_empty(), "properties parsed before the budget are kept");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,27 @@ impl Rgba8 {
|
||||
}
|
||||
Some(Self { width, height, data: bytes.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect() })
|
||||
}
|
||||
|
||||
/// Build an image from exactly `width * height` RGBA pixels.
|
||||
///
|
||||
/// This copies each pixel once, straight into the image's own buffer, so a caller that already
|
||||
/// holds a pixel array (an egui `ColorImage`, say) does not need a second full byte vector.
|
||||
/// `None` for a zero or overflowing size, or a pixel count that is not exactly `width * height`.
|
||||
pub fn from_pixels(width: usize, height: usize, pixels: impl IntoIterator<Item = [u8; 4]>) -> Option<Self> {
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let expected = width.checked_mul(height)?;
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(expected).ok()?;
|
||||
for (i, pixel) in pixels.into_iter().enumerate() {
|
||||
if i == expected {
|
||||
return None;
|
||||
}
|
||||
data.push(pixel);
|
||||
}
|
||||
(data.len() == expected).then_some(Self { width, height, data })
|
||||
}
|
||||
/// Linear Rec.709/sRGB primaries float image from 8-bit sRGB.
|
||||
pub fn to_linear(&self) -> Rgb32f {
|
||||
use lightcraft_color::transfer::decode_srgb8;
|
||||
@@ -356,4 +377,15 @@ mod tests {
|
||||
assert_eq!(img.to_linear().to_srgb8(), img);
|
||||
assert_eq!(Rgba8::from_bytes(16, 16, &img.as_bytes()).unwrap(), img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_pixels_requires_exactly_the_stated_pixels() {
|
||||
let img = Rgba8::from_fn(4, 3, |x, y| [x as u8, y as u8, 9, 255]);
|
||||
let rebuilt = Rgba8::from_pixels(4, 3, img.as_bytes().as_chunks::<4>().0.iter().map(|p| [p[0], p[1], p[2], p[3]])).unwrap();
|
||||
assert_eq!(rebuilt, img);
|
||||
assert!(Rgba8::from_pixels(4, 3, vec![[0, 0, 0, 0]; 11]).is_none(), "too few pixels");
|
||||
assert!(Rgba8::from_pixels(4, 3, vec![[0, 0, 0, 0]; 13]).is_none(), "too many pixels");
|
||||
assert!(Rgba8::from_pixels(0, 3, std::iter::empty()).is_none());
|
||||
assert!(Rgba8::from_pixels(usize::MAX, 2, std::iter::empty()).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,17 @@ pub const SOFTWARE: &str = "LightCraft DNG 2";
|
||||
/// (3.9 s vs 0.55 s on 4 cores) to save 3 % of the file (266 vs 275 MB); 5–12 % on raw and rendered DNGs.
|
||||
const DEFLATE_LEVEL: u8 = 1;
|
||||
|
||||
/// Largest Deflate tile edge the writer pads to (`tile` is caller-supplied in [`DngCompression::Deflate`]):
|
||||
/// square-tile buffers grow with the square, so an unclamped edge lets a request pad a tiny valid image into
|
||||
/// a multi-gigabyte buffer and billions of edge-replicate iterations. 2048² samples (≤ 24 MB of f32) is far
|
||||
/// above the 256 default and every real tile this writer makes.
|
||||
const MAX_DEFLATE_TILE: u32 = 2048;
|
||||
|
||||
/// `tile` aligned down to a multiple of 16 and clamped to [`MAX_DEFLATE_TILE`].
|
||||
fn deflate_tile(tile: u32) -> usize {
|
||||
(tile.max(16) & !15).min(MAX_DEFLATE_TILE) as usize
|
||||
}
|
||||
|
||||
/// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept).
|
||||
pub fn f32_to_f16(v: f32) -> u16 {
|
||||
let b = v.to_bits();
|
||||
@@ -318,7 +329,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
||||
|
||||
match (&raw.data, opts.compression) {
|
||||
(RawData::F32(v), DngCompression::Deflate { tile, half }) => {
|
||||
let tile = (tile.max(16) & !15) as usize;
|
||||
let tile = deflate_tile(tile);
|
||||
let bp = if half { 2 } else { 4 };
|
||||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![(bp * 8) as u16; cpp]));
|
||||
ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp]));
|
||||
@@ -417,7 +428,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: rows as u32, strips });
|
||||
}
|
||||
DngCompression::Deflate { tile, .. } => {
|
||||
let tile = (tile.max(16) & !15) as usize;
|
||||
let tile = deflate_tile(tile);
|
||||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16; cpp]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![compression::ADOBE_DEFLATE]));
|
||||
ifd.set(t::PREDICTOR, Value::Short(vec![2]));
|
||||
@@ -546,3 +557,45 @@ pub fn write_rendered_dng(
|
||||
let order = ByteOrder::Little;
|
||||
write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{BlackLevel, OpcodeLists, RawData, RawFormat, RawImage, Rect};
|
||||
|
||||
/// Deflate tiles of `tile × tile` pad into a square buffer that grows with the square: a
|
||||
/// caller-supplied edge of `u32::MAX` would make the writer pad an 8 × 8 image into billions of
|
||||
/// edge-replicate iterations and a multi-gigabyte buffer. The clamp keeps the bounded shape — same
|
||||
/// file as requesting the cap exactly, and a DNG either way.
|
||||
#[test]
|
||||
fn a_hostile_deflate_tile_edge_is_clamped() {
|
||||
let (w, h) = (8usize, 8usize);
|
||||
let full = Rect::new(0, 0, w, h);
|
||||
let raw = RawImage {
|
||||
format: RawFormat::Dng,
|
||||
width: w,
|
||||
height: h,
|
||||
cpp: 1,
|
||||
data: RawData::U16((0..64).map(|i| i as u16 * 129).collect()),
|
||||
cfa: None,
|
||||
bits: 12,
|
||||
black: BlackLevel::uniform(0.0),
|
||||
white: vec![4095.0],
|
||||
active_area: full,
|
||||
crop: full,
|
||||
orientation: lightcraft_geom::Orientation::Normal,
|
||||
color: crate::ColorData::default(),
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: Default::default(),
|
||||
lens_metadata: Default::default(),
|
||||
};
|
||||
let opts = |tile: u32| DngWriteOptions { compression: DngCompression::Deflate { tile, half: false }, ..DngWriteOptions::default() };
|
||||
let hostile = write_dng(&raw, &opts(u32::MAX)).expect("the clamped tile writes");
|
||||
assert_eq!(hostile, write_dng(&raw, &opts(2048)).expect("the cap itself writes"), "u32::MAX is clamped to the cap");
|
||||
assert_ne!(hostile, write_dng(&raw, &opts(256)).expect("a normal tile writes"), "the clamp is not a no-op");
|
||||
assert_eq!(crate::probe(&hostile), Some(RawFormat::Dng), "the clamped output is still a DNG");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,18 +656,38 @@ fn fix_pixels(buf: &mut [f32], w: usize, h: usize, cpp: usize, cfa: Option<&Cfa>
|
||||
}
|
||||
}
|
||||
|
||||
/// Most full-image passes of one `OpcodeList3`: every warp record is a full-image allocation and resample
|
||||
/// pass and every bad-pixel record a full flatten-and-rebuild, and a hostile record may repeat either
|
||||
/// thousands of times. Real DNGs carry at most a couple; passes past the cap are skipped.
|
||||
const MAX_LIST3_PASSES: usize = 8;
|
||||
|
||||
/// Apply `OpcodeList3` (on the demosaiced RGB image, values normalised to [0, 1]).
|
||||
pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) {
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (img.width, img.height);
|
||||
let mut passes = 0usize;
|
||||
for op in applied_ops(list) {
|
||||
match op {
|
||||
Opcode::WarpRectilinear { planes, center } => *img = warp_rectilinear(img, planes, *center),
|
||||
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => *img = warp_rectilinear2(img, planes, *center, *reciprocal),
|
||||
Opcode::WarpRectilinear { planes, center } => {
|
||||
if passes < MAX_LIST3_PASSES {
|
||||
passes += 1;
|
||||
*img = warp_rectilinear(img, planes, *center);
|
||||
}
|
||||
}
|
||||
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => {
|
||||
if passes < MAX_LIST3_PASSES {
|
||||
passes += 1;
|
||||
*img = warp_rectilinear2(img, planes, *center, *reciprocal);
|
||||
}
|
||||
}
|
||||
Opcode::WarpFisheye { .. } | Opcode::TrimBounds { .. } | Opcode::Unknown { .. } => {}
|
||||
other => {
|
||||
if passes >= MAX_LIST3_PASSES {
|
||||
continue;
|
||||
}
|
||||
passes += 1;
|
||||
let mut flat: Vec<f32> = img.data.iter().flat_map(|p| *p).collect();
|
||||
apply_list(std::slice::from_ref(other), &mut flat, w, h, 3, None, 1.0);
|
||||
for (d, c) in img.data.iter_mut().zip(flat.as_chunks::<3>().0) {
|
||||
@@ -765,6 +785,27 @@ pub(crate) mod tests {
|
||||
a.iter().flat_map(|v| v.to_be_bytes()).collect()
|
||||
}
|
||||
|
||||
/// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them
|
||||
/// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must
|
||||
/// still apply, exactly, so the same image comes out.
|
||||
#[test]
|
||||
fn list3_pass_cap_skips_repeated_records() {
|
||||
let mut img = Rgb32f::new(8, 6);
|
||||
for (i, px) in img.data.iter_mut().enumerate() {
|
||||
*px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32];
|
||||
}
|
||||
let orig: Vec<[f32; 3]> = img.data.clone();
|
||||
let list: Vec<Opcode> =
|
||||
(0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect();
|
||||
apply_list3(&list, &mut img);
|
||||
assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest");
|
||||
|
||||
// a real warp below the cap is still applied
|
||||
let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }];
|
||||
apply_list3(&one, &mut img);
|
||||
assert_ne!(img.data, orig, "a genuine warp record is not skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_and_apply_area_ops() {
|
||||
let (w, h) = (4usize, 3usize);
|
||||
|
||||
+62
-10
@@ -103,8 +103,6 @@ fn jpeg_frame(b: &[u8]) -> Option<(u32, u32, u8)> {
|
||||
let be16 = |k: usize| Some(u32::from(u16::from_be_bytes([at(k)?, at(k + 1)?])));
|
||||
Some((be16(7)?, be16(5)?, at(9)?))
|
||||
}
|
||||
|
||||
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
|
||||
fn is_jxl(b: &[u8]) -> bool {
|
||||
b.starts_with(&[0xff, 0x0a]) || b.starts_with(&[0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a])
|
||||
}
|
||||
@@ -128,20 +126,17 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
|
||||
}
|
||||
if matches!(ifd.u16(t::COMPRESSION), Some(6) | Some(7) | Some(34892))
|
||||
&& let Ok(info) = ifd.image()
|
||||
&& let Some(chunk) = info.single_chunk(data.len() as u64)
|
||||
&& let Some(s) = chunk_bytes(data, &chunk)
|
||||
{
|
||||
let chunks = info.chunks(data.len() as u64);
|
||||
if chunks.len() == 1
|
||||
&& let Some(s) = chunk_bytes(data, &chunks[0])
|
||||
{
|
||||
out.push(s);
|
||||
}
|
||||
out.push(s);
|
||||
}
|
||||
// a rendered (RGB or grey, never CFA/LinearRaw/mask) JPEG XL preview in one chunk
|
||||
if ifd.u16(t::COMPRESSION) == Some(t::compression::JPEG_XL)
|
||||
&& matches!(ifd.u16(t::PHOTOMETRIC), Some(t::photometric::BLACK_IS_ZERO | t::photometric::RGB))
|
||||
&& let Ok(info) = ifd.image()
|
||||
&& let [chunk] = info.chunks(data.len() as u64).as_slice()
|
||||
&& let Some(s) = chunk_bytes(data, chunk)
|
||||
&& let Some(chunk) = info.single_chunk(data.len() as u64)
|
||||
&& let Some(s) = chunk_bytes(data, &chunk)
|
||||
&& is_jxl(s)
|
||||
{
|
||||
out.push(s);
|
||||
@@ -397,8 +392,14 @@ fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> {
|
||||
/// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0
|
||||
/// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller
|
||||
/// `THMB` box has the same shape. Returns the larger valid one.
|
||||
/// Marker-walk budget of the CR3 fallback: candidates can overlap (each claiming the rest of the file) and
|
||||
/// [`is_dct_jpeg`] walks each marker by marker, so walking every candidate would be quadratic in file size.
|
||||
/// The `THMB`/`PRVW` previews this finds are far below the cap together; once it is spent the scan stops.
|
||||
const CR3_MAX_WALK: usize = 8 << 20;
|
||||
|
||||
fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
|
||||
let mut best: Option<&[u8]> = None;
|
||||
let mut walked = 0usize;
|
||||
for tag in [b"PRVW", b"THMB"] {
|
||||
let mut from = 0;
|
||||
while let Some(i) = bytes.get(from..).and_then(|s| s.windows(4).position(|w| w == tag)).map(|p| p + from) {
|
||||
@@ -411,6 +412,11 @@ fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
|
||||
continue;
|
||||
}
|
||||
let jpeg = &bytes[j..j + len];
|
||||
// One that doesn't fit the budget left is skipped; a smaller one later may still fit.
|
||||
if walked + jpeg.len() > CR3_MAX_WALK {
|
||||
continue;
|
||||
}
|
||||
walked += jpeg.len();
|
||||
if is_dct_jpeg(jpeg) {
|
||||
if best.is_none_or(|b| b.len() < jpeg.len()) {
|
||||
best = Some(jpeg);
|
||||
@@ -440,6 +446,52 @@ mod tests {
|
||||
j
|
||||
}
|
||||
|
||||
/// Preview extraction only serves single-chunk JPEG-compressed IFDs and asks the `single_chunk` first:
|
||||
/// a TIFF whose strip array declares 100 000 one-row strips and no StripByteCounts must not build their
|
||||
/// descriptor vector, and of course finds no preview in it.
|
||||
#[test]
|
||||
fn a_multi_strip_jpeg_ifd_costs_no_descriptor_vector() {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![7]));
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![100_000]));
|
||||
ifd.set(t::ROWS_PER_STRIP, Value::Short(vec![1]));
|
||||
ifd.set(t::STRIP_OFFSETS, Value::Byte((0..100_000).map(|i| (i % 255 + 1) as u8).collect()));
|
||||
let file = TiffWriter::default().write(&[ifd]).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || tx.send(embedded_preview(&file)));
|
||||
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None));
|
||||
}
|
||||
|
||||
/// CR3 candidates claim overlapping extents: each block's first bytes double as a 40-byte jump marker so
|
||||
/// a walk jogs past the next header and reaches the end of the file, making every one of the 9000
|
||||
/// candidates walk its whole suffix before the marker-walk budget — quadratic without it. Nothing here
|
||||
/// is a JPEG (no frame header), so the answer is `None` either way.
|
||||
#[test]
|
||||
fn cr3_preview_boxes_stop_walking_when_the_budget_is_spent() {
|
||||
// one 256-byte block per candidate, all marker-aligned so walks chain through later blocks:
|
||||
// 0..4, size (itself a jump marker of 40 bytes); 4..8, "PRVW"; 20..24, the claimed length
|
||||
// (to the file end); 24..28, an SOI plus a marker jumping to the fill at 40; 64..256, a fill of
|
||||
// 4-byte markers (`ff 01 00 02`).
|
||||
let blocks = 9000usize;
|
||||
let total = blocks * 256;
|
||||
let mut f = Vec::with_capacity(total);
|
||||
for k in 0..blocks {
|
||||
let base = k * 256;
|
||||
f.extend_from_slice(&[0xff, 0x01, 0x00, 0x26, b'P', b'R', b'V', b'W', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
f.extend_from_slice(&u32::try_from(total - base - 24).unwrap().to_be_bytes());
|
||||
f.extend_from_slice(&[0xff, 0xd8, 0x00, 0x24, 0xff, 0x01, 0x00, 0x26, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
f.resize(base + 64, 0); // 40..64 filler: walks jump over it, so its bytes are free
|
||||
for _ in 0..48 {
|
||||
f.extend_from_slice(&[0xff, 0x01, 0x00, 0x02]);
|
||||
}
|
||||
}
|
||||
assert_eq!(f.len(), total);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || tx.send(cr3_preview_boxes(&f).map(|s| s.len())));
|
||||
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None), "no PRVW candidate is a JPEG");
|
||||
}
|
||||
|
||||
/// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the
|
||||
/// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than
|
||||
/// the JPEG holds the way a padded container does.
|
||||
|
||||
@@ -35,6 +35,12 @@ const MAX_ENTRIES: u64 = 8192;
|
||||
const MAX_BOXES: usize = 4096;
|
||||
/// Child IFDs listed by one `SubIFDs` entry.
|
||||
const MAX_CHILDREN: u64 = 4096;
|
||||
/// Pending child-IFD offsets the walker may queue at once: no more than it can ever visit
|
||||
/// (the same [`MAX_IFDS`] the seen set is held to, deduplicated as they are popped), so an alley
|
||||
/// of hostile `SubIFDs` entries can't grow the queue into the tens of millions of pointers.
|
||||
/// The plan never guesses: the walk refuses and the plan reports the whole file when it would go
|
||||
/// past the cap.
|
||||
const MAX_PENDING: usize = MAX_IFDS;
|
||||
/// The RAF raw container's pointer to its raw IFD.
|
||||
const RAF_RAW_IFD: u16 = 0xf000;
|
||||
|
||||
@@ -308,6 +314,13 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
|
||||
};
|
||||
let mut stack = vec![first];
|
||||
let mut seen = HashSet::new();
|
||||
// Every offset ever queued: the pending cap counts distinct IFDs, so a thousand references to
|
||||
// one child cost one visit, not a thousand.
|
||||
let mut queued = HashSet::from([first]);
|
||||
// Every IFD table and out-of-line value, collected and merged once at the end: an insert
|
||||
// per entry would scan the ranges of every entry before it — quadratic in the metadata
|
||||
// this walker collects.
|
||||
let mut spans: Vec<(u64, u64)> = Vec::new();
|
||||
while let Some(off) = stack.pop() {
|
||||
if off == 0 || off >= len || seen.len() >= MAX_IFDS || !seen.insert(off) {
|
||||
continue;
|
||||
@@ -329,7 +342,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
|
||||
if !w.want(at(off), at(end)) {
|
||||
continue;
|
||||
}
|
||||
meta.insert(at(off), at(end));
|
||||
spans.push((at(off), at(end)));
|
||||
for i in 0..n_read {
|
||||
let p = table + i * entry;
|
||||
let Ok(pu) = usize::try_from(p) else { continue };
|
||||
@@ -348,7 +361,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
|
||||
};
|
||||
let Some(value_end) = start.checked_add(total).filter(|&e| e <= len) else { continue };
|
||||
if total > inline {
|
||||
meta.insert(at(start), at(value_end));
|
||||
spans.push((at(start), at(value_end)));
|
||||
}
|
||||
let child = matches!(tag, t::SUB_IFDS | t::EXIF_IFD | t::GPS_IFD | t::INTEROP_IFD) || extra.contains(&tag);
|
||||
let locates = child || matches!(tag, t::STRIP_OFFSETS | t::STRIP_BYTE_COUNTS | t::TILE_OFFSETS | t::TILE_BYTE_COUNTS);
|
||||
@@ -359,14 +372,24 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
|
||||
let take = if tag == t::SUB_IFDS { count.min(MAX_CHILDREN) } else { count.min(1) };
|
||||
for k in 0..take {
|
||||
if let Some(v) = unsigned(data, order, typ, start + k * typ.size() as u64) {
|
||||
if v == 0 || v >= len || !queued.insert(v) {
|
||||
continue;
|
||||
}
|
||||
if stack.len() >= MAX_PENDING {
|
||||
return Err("too many child IFDs queued to visit");
|
||||
}
|
||||
stack.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if complete && let Some(next) = offset_at(table_end) {
|
||||
if complete && let Some(next) = offset_at(table_end).filter(|&next| next != 0 && next < len && queued.insert(next)) {
|
||||
if stack.len() >= MAX_PENDING {
|
||||
return Err("too many child IFDs queued to visit");
|
||||
}
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
meta.extend(spans);
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
@@ -585,8 +608,74 @@ mod tests {
|
||||
assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles"));
|
||||
}
|
||||
|
||||
/// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is
|
||||
/// built in one sort and merge, not one pass over all strips (or all holes) per strip.
|
||||
/// An IFD whose `SubIFDs` entries would queue 33 million child pointers (8192 entries of
|
||||
/// 4096 values apiece, all pointing at the same array) is refused at the walker's pending
|
||||
/// cap instead of growing the queue — and the plan reports the whole file, never guessing.
|
||||
#[test]
|
||||
fn a_subifds_alley_is_refused_at_the_pending_cap() {
|
||||
// First read everything, so the walk reaches the directory table itself.
|
||||
let n = 8192u32;
|
||||
let len = 8 + 2 + (n * 12 + 4 + 4 * 4096) as usize;
|
||||
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
|
||||
tiff.extend_from_slice(&(n as u16).to_le_bytes());
|
||||
let mut entry = |tag: u16, typ: u16, count: u32, value: u32| {
|
||||
tiff.extend_from_slice(&tag.to_le_bytes());
|
||||
tiff.extend_from_slice(&typ.to_le_bytes());
|
||||
tiff.extend_from_slice(&count.to_le_bytes());
|
||||
tiff.extend_from_slice(&value.to_le_bytes());
|
||||
};
|
||||
// header (8) + count field (2) + the {n} numbers and pointer (4), then the value array
|
||||
let values_at = (8 + 2u64 + u64::from(n) * 12 + 4) as u32;
|
||||
for _ in 0..n {
|
||||
entry(t::SUB_IFDS, 4, MAX_CHILDREN as u32, values_at);
|
||||
}
|
||||
tiff.extend_from_slice(&0u32.to_le_bytes());
|
||||
tiff.extend((1..=4096u32).flat_map(u32::to_le_bytes));
|
||||
let mut loaded = Ranges::new();
|
||||
loaded.insert(0, len as u64);
|
||||
let mut w = Want::new(&tiff, &loaded);
|
||||
assert_eq!(walk_tiff(&mut w, 0, &tiff, &[]), Err("too many child IFDs queued to visit"));
|
||||
}
|
||||
|
||||
/// A TIFF with many out-of-line values across 512 directories is planned in one sort and
|
||||
/// merge, not one pass over all accumulated ranges per value (CodeRabbit #52: before, each
|
||||
/// `Ranges::insert` copied every range found before it — the ~million disjoint values of the
|
||||
/// fixture below ask for more copying than the timeout allows).
|
||||
#[test]
|
||||
fn many_out_of_line_values_plan_fast() {
|
||||
let (ifds, values) = (512usize, 2048usize);
|
||||
let ifd_len = 2 + values * 12 + 4;
|
||||
let values_at = 8 + ifds * ifd_len;
|
||||
let len = values_at + values * 8;
|
||||
let mut tiff = vec![0u8; len];
|
||||
tiff[..8].copy_from_slice(b"II*\0\x08\0\0\0");
|
||||
for i in 0..ifds {
|
||||
let at = 8 + i * ifd_len;
|
||||
// {values} out-of-line strings (count 8 > the 4 inline bytes), then the next-IFD
|
||||
// pointer; every value is a disjoint 8-byte range for the merge to handle.
|
||||
tiff[at..at + 2].copy_from_slice(&(values as u16).to_le_bytes());
|
||||
for k in 0..values {
|
||||
let e = at + 2 + k * 12;
|
||||
tiff[e..e + 2].copy_from_slice(&t::MAKE.to_le_bytes());
|
||||
tiff[e + 2..e + 4].copy_from_slice(&2u16.to_le_bytes()); // ASCII: 8 bytes > inline — a span per entry
|
||||
tiff[e + 4..e + 8].copy_from_slice(&8u32.to_le_bytes());
|
||||
tiff[e + 8..e + 12].copy_from_slice(&(values_at as u32 + (k * 8) as u32).to_le_bytes());
|
||||
}
|
||||
let next = if i == ifds - 1 { 0u32 } else { (at + ifd_len) as u32 };
|
||||
tiff[at + ifd_len - 4..at + ifd_len].copy_from_slice(&next.to_le_bytes());
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut loaded = Ranges::new();
|
||||
loaded.insert(0, len as u64);
|
||||
let _ = tx.send(header_plan(&tiff, &loaded));
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s");
|
||||
}
|
||||
|
||||
/// A TIFF with a million one-byte strips listed in descending order and all byte counts 0:
|
||||
/// the plan is built in one sort and merge, not one pass over all strips (or all holes) per
|
||||
/// strip.
|
||||
#[test]
|
||||
fn many_strips_without_byte_counts_plan_fast() {
|
||||
let n = 1_000_000u32;
|
||||
|
||||
+205
-36
@@ -105,48 +105,93 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
|
||||
let chunks = info.chunks(data.len() as u64);
|
||||
let planar = info.planar == 2 && cpp > 1;
|
||||
let ccpp = if planar { 1 } else { cpp };
|
||||
let decoded: Vec<Result<(Chunk, ChunkPx)>> =
|
||||
chunks.par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect();
|
||||
// The chunks are decoded and copied into the output in groups whose combined nominal size stays within
|
||||
// a few times the output buffer's: everything decoded at once would retain the whole image twice, and
|
||||
// a hostile tile table's chosen padding could stretch that much further (issue #1517). One group
|
||||
// still decodes in parallel; the grid order groups make, the copies stay disjoint.
|
||||
// The tile grid decides how much is decoded, whatever the file's bytes: the nominal sizes of all
|
||||
// the chunks stay within a few times the image (edge tiles pad it) before any is decoded.
|
||||
let nominal = chunks.iter().fold(0usize, |sum, c| sum.saturating_add(chunk_samples(c, ccpp)));
|
||||
if nominal > total.saturating_mul(4).saturating_add(1 << 24) {
|
||||
return Err(RawError::Corrupt("the tile grid is much larger than the image".into()));
|
||||
}
|
||||
let group_samples = (total / 4).max(1 << 20);
|
||||
let mut out_u16 = if float { Vec::new() } else { vec![0u16; total] };
|
||||
let mut out_f32 = if float { vec![0f32; total] } else { Vec::new() };
|
||||
for r in decoded {
|
||||
// a chunk that cannot be decoded is not a partial image: the file stays preview-only
|
||||
let (c, px) = r?;
|
||||
let (cw, ch) = (c.width as usize, c.height as usize);
|
||||
let (x0, y0) = (c.x as usize, c.y as usize);
|
||||
if x0 >= w || y0 >= h {
|
||||
continue;
|
||||
let mut decode_from = 0usize;
|
||||
while decode_from < chunks.len() {
|
||||
let mut to = decode_from;
|
||||
let mut taken = 0usize;
|
||||
for (k, c) in chunks[decode_from..].iter().enumerate() {
|
||||
let n = chunk_samples(c, ccpp);
|
||||
if taken > 0 && taken.saturating_add(n) > group_samples {
|
||||
break;
|
||||
}
|
||||
taken = taken.saturating_add(n);
|
||||
to = decode_from + k + 1;
|
||||
}
|
||||
let copy_w = cw.min(w - x0);
|
||||
let copy_h = ch.min(h - y0);
|
||||
for y in 0..copy_h {
|
||||
for x in 0..copy_w {
|
||||
for s in 0..ccpp {
|
||||
let src = (y * cw + x) * ccpp + s;
|
||||
let dst_s = if planar { c.plane as usize } else { s };
|
||||
if dst_s >= cpp {
|
||||
continue;
|
||||
}
|
||||
let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s;
|
||||
match &px {
|
||||
ChunkPx::U16(v) => {
|
||||
if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) {
|
||||
*o = s;
|
||||
}
|
||||
let decoded: Vec<Result<(Chunk, ChunkPx)>> =
|
||||
chunks[decode_from..to].par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect();
|
||||
for r in decoded {
|
||||
// a chunk that cannot be decoded is not a partial image: the file stays preview-only
|
||||
let (c, px) = r?;
|
||||
let (cw, ch) = (c.width as usize, c.height as usize);
|
||||
let (x0, y0) = (c.x as usize, c.y as usize);
|
||||
if x0 >= w || y0 >= h {
|
||||
continue;
|
||||
}
|
||||
let copy_w = cw.min(w - x0);
|
||||
let copy_h = ch.min(h - y0);
|
||||
for y in 0..copy_h {
|
||||
for x in 0..copy_w {
|
||||
for s in 0..ccpp {
|
||||
let src = (y * cw + x) * ccpp + s;
|
||||
let dst_s = if planar { c.plane as usize } else { s };
|
||||
if dst_s >= cpp {
|
||||
continue;
|
||||
}
|
||||
ChunkPx::F32(v) => {
|
||||
if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) {
|
||||
*o = s;
|
||||
let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s;
|
||||
match &px {
|
||||
ChunkPx::U16(v) => {
|
||||
if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) {
|
||||
*o = s;
|
||||
}
|
||||
}
|
||||
ChunkPx::F32(v) => {
|
||||
if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) {
|
||||
*o = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
decode_from = to;
|
||||
}
|
||||
Ok(if float { RawData::F32(out_f32) } else { RawData::U16(out_u16) })
|
||||
}
|
||||
|
||||
/// Bytes that `n` samples take in a row of an uncompressed or inflated chunk, laid out as
|
||||
/// [`unpack_chunk`] reads them: whole bytes or 16-bit words, or 32-bit words for `Words32Msb`.
|
||||
fn packed_row_bytes(n: u64, bits: u32, packing: Packing, float: bool) -> u64 {
|
||||
if float {
|
||||
return n.saturating_mul(u64::from(bits.div_ceil(8)));
|
||||
}
|
||||
let bit_len = n.saturating_mul(u64::from(bits));
|
||||
match (bits, packing) {
|
||||
(8, _) => n,
|
||||
(16, _) | (_, Packing::Word16) => n.saturating_mul(2),
|
||||
(_, Packing::Msb | Packing::Lsb) => bit_len.div_ceil(8),
|
||||
(_, Packing::Words32Msb) => bit_len.div_ceil(32).saturating_mul(4),
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk's nominal decoded size in samples (per plane, as the decoder builds it).
|
||||
fn chunk_samples(c: &Chunk, ccpp: usize) -> usize {
|
||||
(c.width as usize).saturating_mul(c.height as usize).saturating_mul(ccpp)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, packing: Packing, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
|
||||
let src = chunk_bytes(data, c).ok_or_else(|| RawError::Corrupt("chunk offset past end of file".into()))?;
|
||||
@@ -179,9 +224,13 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
|
||||
}
|
||||
comp::LOSSY_JPEG => {
|
||||
// lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples
|
||||
let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?;
|
||||
let mut v = vec![0u16; n];
|
||||
for y in 0..ch.min(jh) {
|
||||
let (px, jw, jh, jc) = lossy_jpeg(src, cpp, MAX_JPEG_SAMPLES)?;
|
||||
// the buffer holds the rows the decoded JPEG covers (the caller zero-fills the tile's
|
||||
// remainder and reads only rows it fills): a tile-many-larger decoded JPEG does not
|
||||
// get the nominal tile's full buffer (issue #1517)
|
||||
let rows = ch.min(jh);
|
||||
let mut v = vec![0u16; rows.max(1).saturating_mul(cw).saturating_mul(cpp)];
|
||||
for y in 0..rows {
|
||||
for x in 0..cw.min(jw) {
|
||||
for k in 0..cpp {
|
||||
v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16;
|
||||
@@ -191,13 +240,28 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
|
||||
Ok(ChunkPx::U16(v))
|
||||
}
|
||||
comp::ADOBE_DEFLATE | comp::DEFLATE => {
|
||||
let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 };
|
||||
// row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate
|
||||
let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) };
|
||||
let row_bytes = packed_row_bytes(cw as u64 * cpp as u64, bits, packing, float);
|
||||
// the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too
|
||||
let inflate_len =
|
||||
crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?;
|
||||
// Deflate compresses at most about 1:1032, so a source this far under half of its
|
||||
// nominal length cannot reconstruct the chunk: refused before the nominal buffer is
|
||||
// inflated and predicted, which padding-only work would make gigabytes.
|
||||
if (src.len() as u64).saturating_mul(2048) < inflate_len {
|
||||
return Err(RawError::Corrupt(format!("a deflated chunk of {inflate_len} bytes is packed into only {} bytes", src.len())));
|
||||
}
|
||||
let raw = inflate(src, inflate_len as usize)?;
|
||||
// The stream has to reach the last row inside the image (edge strips may stop there);
|
||||
// it is laid out in nominal rows, so shorter is a truncated chunk, not a picture.
|
||||
let last = packed_row_bytes(vw as u64 * cpp as u64, bits, packing, float);
|
||||
let visible = row_bytes.saturating_mul(vh.saturating_sub(1) as u64).saturating_add(last);
|
||||
if (raw.len() as u64) < visible {
|
||||
return Err(RawError::Corrupt(format!(
|
||||
"a deflated chunk holds {} bytes, fewer than the {visible} bytes its in-image samples span",
|
||||
raw.len()
|
||||
)));
|
||||
}
|
||||
unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor)
|
||||
}
|
||||
comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float),
|
||||
@@ -228,19 +292,31 @@ fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp:
|
||||
jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
|
||||
}
|
||||
|
||||
/// The most samples a tiled JPEG chunk or a white-balance preview may decode to: far more than
|
||||
/// any real lossy tile or preview carries, and small enough that a JPEG header alone cannot size
|
||||
/// a multi-gigabyte allocation.
|
||||
pub(crate) const MAX_JPEG_SAMPLES: usize = 1 << 26;
|
||||
|
||||
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
|
||||
pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
|
||||
pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize, max_samples: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
|
||||
use zune_core::bytestream::ZCursor;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::DecoderOptions;
|
||||
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
|
||||
let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs);
|
||||
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts);
|
||||
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
|
||||
// The header is read first: the JPEG's own dimensions, not the enclosing tile's, size every
|
||||
// allocation `decode` makes, and they may claim billions of pixels.
|
||||
d.decode_headers().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
|
||||
let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let c = if cpp == 1 { 1 } else { 3 };
|
||||
if w == 0 || h == 0 || px.len() < w * h * c {
|
||||
let out = w as u64 * h as u64 * c as u64;
|
||||
if w == 0 || h == 0 || out > max_samples as u64 {
|
||||
return Err(RawError::Corrupt(format!("a lossy JPEG of {w} × {h} × {c} would exceed the {max_samples} output samples allowed")));
|
||||
}
|
||||
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
|
||||
if px.len() < w * h * c {
|
||||
return Err(RawError::Corrupt("lossy JPEG tile: short output".into()));
|
||||
}
|
||||
Ok((px, w, h, c))
|
||||
@@ -471,4 +547,97 @@ mod lossy_tests {
|
||||
// half of the visible rows are missing
|
||||
assert!(decode(8, 4).is_err());
|
||||
}
|
||||
|
||||
/// A lossy JPEG whose header alone claims more samples than any real tile or preview (here
|
||||
/// 40000 × 40000 × 3) is refused from the header, before the decoder allocates its output
|
||||
/// from the JPEG's own dimensions.
|
||||
#[test]
|
||||
fn an_oversized_lossy_jpeg_header_is_refused_before_decoding() {
|
||||
// A real 8 × 8 baseline JPEG, whose frame header then claims other dimensions: the decoder
|
||||
// parses the header, and the size check is what refuses it.
|
||||
let px = vec![128u8; 8 * 8 * 3];
|
||||
let mut j = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut j, 90).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap();
|
||||
// SOF0: marker, length, precision, then the height and the width
|
||||
let sof = j.windows(2).position(|w| w == [0xff, 0xc0]).expect("a baseline frame header");
|
||||
let claim = |j: &mut Vec<u8>, size: u16| {
|
||||
j[sof + 5..sof + 7].copy_from_slice(&size.to_be_bytes());
|
||||
j[sof + 7..sof + 9].copy_from_slice(&size.to_be_bytes());
|
||||
};
|
||||
claim(&mut j, 40_000);
|
||||
let RawError::Corrupt(refused) = lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).unwrap_err() else { panic!("a corrupt-data error") };
|
||||
assert!(refused.contains("would exceed"), "{refused}");
|
||||
// a header inside the budget passes the size check (and then fails to decode only for lack of data)
|
||||
claim(&mut j, 1024);
|
||||
match lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES) {
|
||||
Ok(_) => {}
|
||||
Err(RawError::Corrupt(m)) => assert!(!m.contains("would exceed"), "the smaller header was refused for its size: {m}"),
|
||||
Err(other) => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A lossy tile whose JPEG decodes far less than the nominal tile is fine: the rows the JPEG
|
||||
/// covers hold its pixels, the rest of the output stays zero (and the tile's intermediate buffer
|
||||
/// is sized by the JPEG, not by the nominal tile).
|
||||
#[test]
|
||||
fn a_small_lossy_jpeg_in_a_large_nominal_tile_decodes_with_zeros_below() {
|
||||
let px: Vec<u8> = (0..8 * 8).flat_map(|_| [30u8, 90, 150]).collect();
|
||||
let mut j = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut j, 100).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap();
|
||||
let info = ImageInfo {
|
||||
width: 16,
|
||||
height: 16,
|
||||
bits_per_sample: vec![8, 8, 8],
|
||||
samples_per_pixel: 3,
|
||||
compression: comp::LOSSY_JPEG,
|
||||
photometric: 34892,
|
||||
planar: 1,
|
||||
predictor: 1,
|
||||
sample_format: 1,
|
||||
new_subfile_type: 0,
|
||||
layout: Layout::Tiles { tile_width: 16, tile_height: 16 },
|
||||
offsets: vec![0],
|
||||
byte_counts: vec![j.len() as u64],
|
||||
};
|
||||
let RawData::U16(v) = read_image(&j, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") };
|
||||
assert_eq!(v.len(), 16 * 16 * 3, "the output is the full nominal image");
|
||||
let px = |x: usize, y: usize| &v[(y * 16 + x) * 3..(y * 16 + x) * 3 + 3];
|
||||
assert!((px(3, 3)[0] as i32 - 30).abs() <= 3 && (px(3, 3)[2] as i32 - 150).abs() <= 3, "covered rows: {:?}", px(3, 3));
|
||||
assert!(px(3, 12).iter().all(|&s| s == 0), "rows below the JPEG stay zero: {:?}", px(3, 12));
|
||||
}
|
||||
|
||||
/// A deflated tile is refused when its packed bytes are too few for deflate to have produced
|
||||
/// the nominal chunk, or when the stream stops short of the rows inside the image; a fully
|
||||
/// covered flat tile still decodes.
|
||||
#[test]
|
||||
fn a_short_or_undercovering_deflated_tile_is_refused() {
|
||||
let info = |byte_counts: u64| ImageInfo {
|
||||
width: 512,
|
||||
height: 512,
|
||||
bits_per_sample: vec![16],
|
||||
samples_per_pixel: 1,
|
||||
compression: comp::ADOBE_DEFLATE,
|
||||
photometric: 32803,
|
||||
planar: 1,
|
||||
predictor: 1,
|
||||
sample_format: 1,
|
||||
new_subfile_type: 0,
|
||||
layout: Layout::Tiles { tile_width: 512, tile_height: 512 },
|
||||
offsets: vec![0],
|
||||
byte_counts: vec![byte_counts],
|
||||
};
|
||||
// a complete flat tile: 512 KiB of zeros compresses to a few hundred bytes (well within
|
||||
// deflate's packing bound) and inflates to every nominal byte the tile claims
|
||||
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 512 * 512 * 2], 6);
|
||||
assert_eq!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Ok(RawData::U16(vec![0; 512 * 512])));
|
||||
// the same 1 KiB packed, but the stream claims a whole tile: a few hundred bytes cannot
|
||||
// reconstruct 512 KiB from a decoder that compresses no further than about 1:1032
|
||||
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1024], 6);
|
||||
assert!(matches!(read_image(&zlib, &info(512 * 512 * 2), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))), "{zlib:?}");
|
||||
// 500 KiB of zeros compress to a stream within the packing bound and inflating to more
|
||||
// bytes than the in-image samples span, but 24 KiB short of the whole nominal tile:
|
||||
// refused as truncated rather than filled with the predictor's padding
|
||||
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 500 * 1000], 6);
|
||||
assert!(matches!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))));
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+58
-1
@@ -89,6 +89,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
// the white balance is measured from the samples, so the header probe reads them too
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?;
|
||||
// 12-bit MSB packing takes 3 bytes per 2 samples; a strip that cannot hold the declared
|
||||
// samples is a truncated file, not a picture — unpacking it would run past the real bytes
|
||||
// through every output sample (a short strip asks for the whole allocation and more).
|
||||
let need = n.div_ceil(2).saturating_mul(3);
|
||||
if src.len() < need {
|
||||
return Err(RawError::Corrupt(format!("a Mef strip of {} bytes cannot hold the declared {n} samples", src.len())));
|
||||
}
|
||||
let mut d = vec![0u16; n];
|
||||
crate::unpack::unpack_msb(src, 12, &mut d);
|
||||
let white = super::white_from_data(&d, 12);
|
||||
@@ -138,6 +145,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sample(w: u32, h: u32, px: &[u16]) -> Vec<u8> {
|
||||
sample_strip(w, h, &pack(px))
|
||||
}
|
||||
|
||||
fn sample_strip(w: u32, h: u32, strip: &[u8]) -> Vec<u8> {
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
@@ -148,7 +159,7 @@ mod tests {
|
||||
raw.set(t::ORIENTATION, Value::Short(vec![6]));
|
||||
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
|
||||
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] });
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip.to_vec()] });
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Mamiya-OP Co.,Ltd.".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into()));
|
||||
@@ -156,6 +167,52 @@ mod tests {
|
||||
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
/// A strip that cannot hold the declared samples is a truncated file, refused as corrupt
|
||||
/// instead of unpacked: `unpack_msb` would otherwise run `n` samples through the few real
|
||||
/// bytes, filling the rest from nothing.
|
||||
#[test]
|
||||
fn a_strip_too_short_for_the_declared_samples_is_refused() {
|
||||
let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect();
|
||||
let f = sample(16, 6, &px);
|
||||
assert!(crate::decode(&f).is_ok());
|
||||
// A truncated file shaped like a real one: IFD tables first, strip last — only its first
|
||||
// 20 packed bytes remain, while the IFD still declares the full 1.5 B/sample length. The
|
||||
// probe accepts the file on the declared length; before the byte gate below, `unpack_msb`
|
||||
// ran the dimension-sized allocation through those 20 bytes, filling it from nothing.
|
||||
let u32be = |b: &[u8], a: usize| u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]);
|
||||
let full = pack(&px);
|
||||
let at = f.windows(full.len()).position(|w| w == full).expect("strip bytes in the file");
|
||||
let after_strip = at + full.len();
|
||||
let moved = after_strip - (at + 20);
|
||||
let mut g = f[..8].to_vec();
|
||||
g.extend(std::iter::repeat_n(0u8, at + 20 - 8));
|
||||
g.extend_from_slice(&f[after_strip..]);
|
||||
// repoint the first-IFD pointer and ifd0's MAKE / MODEL / SubIfds offsets (the tables now
|
||||
// sit `moved` earlier); the raw sub-IFD's strip offset and byte count are patched below
|
||||
let poke = |b: &mut [u8], a: usize| {
|
||||
let v = u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]);
|
||||
if v as usize >= after_strip {
|
||||
b[a..a + 4].copy_from_slice(&(v - moved as u32).to_be_bytes());
|
||||
}
|
||||
};
|
||||
let ifd0_at = u32be(&g, 4) as usize - moved;
|
||||
g[4..8].copy_from_slice(&(ifd0_at as u32).to_be_bytes());
|
||||
let n = u16::from_be_bytes([g[ifd0_at], g[ifd0_at + 1]]) as usize;
|
||||
for i in 0..n {
|
||||
poke(&mut g, ifd0_at + 2 + i * 12 + 8);
|
||||
}
|
||||
// the raw sub-IFD: entries follow its count; tag 273 (StripOffsets) is the seventh, held
|
||||
// inline — repoint it at the strip's new home, and append the truncated strip for real
|
||||
let sub_at = u32be(&g, ifd0_at + 2 + 2 * 12 + 8) as usize;
|
||||
let e_offsets = sub_at + 2 + 6 * 12 + 8;
|
||||
let strip_at = g.len() as u32;
|
||||
g[e_offsets..e_offsets + 4].copy_from_slice(&strip_at.to_be_bytes());
|
||||
g.extend_from_slice(&full[..20]);
|
||||
assert_eq!(crate::probe(&g), Some(RawFormat::Mef));
|
||||
let r = crate::decode(&g);
|
||||
assert!(matches!(r, Err(RawError::Corrupt(_))), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_packed_12_bit() {
|
||||
let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect();
|
||||
|
||||
@@ -66,7 +66,7 @@ fn median(v: &mut [f64]) -> Option<f64> {
|
||||
|
||||
fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> {
|
||||
let jpeg = crate::preview::embedded_preview(bytes)?;
|
||||
let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?;
|
||||
let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3, crate::tiffraw::MAX_JPEG_SAMPLES).ok()?;
|
||||
if ch != 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -118,14 +118,34 @@ impl ImageInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data"
|
||||
/// using `data_len`.
|
||||
pub fn chunks(&self, data_len: u64) -> Vec<Chunk> {
|
||||
/// The chunk count the image declares (offsets present, cut to the layout's plane capacity) with the
|
||||
/// geometry `chunks` needs to place each one. No descriptors are built, so a pathological strip array
|
||||
/// costs it a length check.
|
||||
fn chunk_grid(&self) -> (usize, u32, usize) {
|
||||
let (across, down) = self.grid();
|
||||
let per_plane = across as usize * down as usize;
|
||||
let planes = if self.planar == 2 { self.samples_per_pixel as usize } else { 1 };
|
||||
(self.offsets.len().min(per_plane.saturating_mul(planes)), across, per_plane)
|
||||
}
|
||||
|
||||
fn declared_chunk_count(&self) -> usize {
|
||||
self.chunk_grid().0
|
||||
}
|
||||
|
||||
/// The one chunk when the image has exactly one, else `None` — and no descriptor vector beyond it. Preview
|
||||
/// extraction only serves single-chunk images, so it asks here before serving a hostile multi-strip layout.
|
||||
pub fn single_chunk(&self, data_len: u64) -> Option<Chunk> {
|
||||
if self.declared_chunk_count() != 1 {
|
||||
return None;
|
||||
}
|
||||
self.chunks(data_len).into_iter().next()
|
||||
}
|
||||
|
||||
/// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data"
|
||||
/// using `data_len`.
|
||||
pub fn chunks(&self, data_len: u64) -> Vec<Chunk> {
|
||||
let (n, across, per_plane) = self.chunk_grid();
|
||||
let (cw, ch) = self.chunk_size();
|
||||
let n = self.offsets.len().min(per_plane.saturating_mul(planes));
|
||||
let mut out = Vec::with_capacity(n);
|
||||
// the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk)
|
||||
let mut sorted: Vec<u64> = Vec::new();
|
||||
@@ -192,6 +212,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One offset is one chunk, and `single_chunk` refuses to build descriptors when the image declares more.
|
||||
#[test]
|
||||
fn single_chunk_is_the_only_chunk() {
|
||||
let one = strips(vec![50], vec![10]);
|
||||
assert_eq!(one.single_chunk(100).map(|c| (c.index, c.offset, c.len)), one.chunks(100).first().map(|c| (c.index, c.offset, c.len)));
|
||||
// the inferred length matches too, and never falls back past the data
|
||||
let inferred = strips(vec![70], vec![]);
|
||||
assert_eq!(inferred.single_chunk(100).map(|c| c.len), Some(30));
|
||||
assert!(strips(vec![50, 10], vec![10, 5]).single_chunk(100).is_none(), "a multi-chunk image has no single chunk");
|
||||
assert!(strips(Vec::new(), Vec::new()).single_chunk(100).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_byte_counts_run_to_the_next_larger_offset() {
|
||||
let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]);
|
||||
|
||||
@@ -55,7 +55,11 @@ pub fn parse_makernote(data: &[u8], offset: u64, len: u64, order: ByteOrder, mak
|
||||
Some(b"MM") => Some(ByteOrder::Big),
|
||||
_ => None,
|
||||
};
|
||||
let opts = ParseOptions { max_ifds: 64, max_depth: 3, ..Default::default() };
|
||||
// A fixed value budget: a maker note is at most tens of kilobytes of values in any honest file, and
|
||||
// computing the budget from the buffer's length multiplies the file length (a hostile entry in a
|
||||
// sparse header-read buffer, where the buffer holds the file's logical size but only a few
|
||||
// kilobytes are loaded, would decode allocations no actual data backs).
|
||||
let opts = ParseOptions { max_ifds: 64, max_depth: 3, value_budget: Some(1 << 20), ..Default::default() };
|
||||
let try_parse = |kind: MakerNoteKind, ifd_at: u64, base: u64, ord: ByteOrder| -> Option<MakerNote> {
|
||||
let (ifd, _) = parse_ifd_at(data, ifd_at, ord, base, false, &opts).ok()?;
|
||||
(!ifd.entries.is_empty()).then_some(MakerNote { kind, order: ord, base, ifd })
|
||||
@@ -192,6 +196,35 @@ mod tests {
|
||||
assert_eq!(m.ifd.u64s(2).unwrap(), vec![9, 0]);
|
||||
}
|
||||
|
||||
/// A maker note's value budget is fixed, not computed from the length of the buffer it sits in:
|
||||
/// an entry claiming more than the note budget (here 2 MB) is dropped, a fitting one is read. In a
|
||||
/// sparse header read the buffer holds the file's logical size while only kilobytes were loaded,
|
||||
/// so a length-scaled budget would let an unrelated entry decode megabytes of zeros (see the focus
|
||||
/// reader, try_read_exif_with).
|
||||
#[test]
|
||||
fn a_note_entry_past_the_fixed_value_budget_is_dropped() {
|
||||
let order = ByteOrder::Little;
|
||||
let mut note = b"SONY DSC \0\0\0".to_vec();
|
||||
// the Sony IFD directly after the 12-byte header, one fitting BYTE entry and a decoy
|
||||
// BYTE[2 MB] entry pointing into the file's own (zero-filled) body
|
||||
order.put_u16(&mut note, 2);
|
||||
order.put_u16(&mut note, 0x201b);
|
||||
order.put_u16(&mut note, 1);
|
||||
order.put_u32(&mut note, 1);
|
||||
note.extend([3, 0, 0, 0]);
|
||||
order.put_u16(&mut note, 0x9900);
|
||||
order.put_u16(&mut note, 1);
|
||||
order.put_u32(&mut note, 2 << 20);
|
||||
order.put_u32(&mut note, 200);
|
||||
order.put_u32(&mut note, 0);
|
||||
let mut bytes = note.clone();
|
||||
bytes.resize(200 + (2 << 20), 0); // the decoy's value range is inside the real bytes
|
||||
let m = parse_makernote(&bytes, 0, note.len() as u64, order, "SONY").unwrap();
|
||||
assert_eq!(m.kind, MakerNoteKind::Sony);
|
||||
assert!(m.ifd.get(0x201b).is_some(), "the fitting entry is read");
|
||||
assert!(m.ifd.get(0x9900).is_none(), "the decoy is dropped by the value budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_note_is_none() {
|
||||
let (bytes, off, len) = with_note(ByteOrder::Little, vec![0xff; 40]);
|
||||
|
||||
@@ -338,6 +338,25 @@ pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Value;
|
||||
use crate::tags as t;
|
||||
use crate::writer::{IfdBuilder, TiffWriter};
|
||||
|
||||
/// A value budget the caller sets bounds what is decoded: an entry claiming more value bytes than
|
||||
/// the budget allows is dropped, entries that fit are read (the sparse header-read path, where the
|
||||
/// buffer holds the file's logical size but only a few kilobytes were ever loaded, sizes its
|
||||
/// budget from what was fetched, not from whatever length the file claims).
|
||||
#[test]
|
||||
fn the_value_budget_drops_over_budget_entries_and_keeps_the_rest() {
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Keep".into()));
|
||||
ifd0.set(0x9000, Value::Byte(vec![0; 2 << 20]));
|
||||
let file = TiffWriter { order: ByteOrder::Little, bigtiff: false }.write(&[ifd0]).unwrap();
|
||||
let opts = ParseOptions { value_budget: Some(1 << 20), ..Default::default() };
|
||||
let tiff = Tiff::parse_with(&file, &opts).unwrap();
|
||||
assert_eq!(tiff.ifds[0].string(t::MAKE), Some("Keep".to_string()), "a fitting entry is read");
|
||||
assert!(tiff.ifds[0].get(0x9000).is_none(), "the over-budget entry is dropped");
|
||||
}
|
||||
|
||||
/// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer
|
||||
/// `next`; a valid one-entry IFD sits at 16.
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
craft-window = { workspace = true }
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
@@ -30,6 +31,11 @@ log = { workspace = true }
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
# The model-setup tests install and resume a real denoise model. Without this the engine's
|
||||
# `default_loader` is the "this build cannot run denoise models" stub when the package is tested
|
||||
# on its own (`cargo test -p lightcraft-ui-egui`); a workspace test only passed by feature
|
||||
# unification with the desktop app's default `denoise` feature.
|
||||
lightcraft-engine = { workspace = true, features = ["denoise"] }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
lightcraft-denoise = { workspace = true, features = ["runtime"] }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
@@ -39,3 +45,6 @@ lightcraft-merge = { workspace = true }
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -283,6 +283,7 @@ fn real() {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, if (i / 45) % 2 == 0 { -12.0 } else { 12.0 }),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
let t = Instant::now();
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"Add in place": "Přidat ze současného umístění",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Přidat ze současného umístění: knihovna odkazuje na soubory tam, kde jsou; nic se nekopíruje ani nepřesouvá.",
|
||||
"Add keyword": "Přidat klíčové slovo",
|
||||
"Add Keywords": "Přidat klíčová slova",
|
||||
"Add number": "Přidat číslo",
|
||||
"Add to": "Přidat do",
|
||||
"Add to Album": "Přidat do alba",
|
||||
@@ -1194,6 +1195,7 @@
|
||||
"New time": "Nový čas",
|
||||
"New…": "Nový…",
|
||||
"Next Appearance Mode": "Další režim vzhledu",
|
||||
"Next Keyword Set": "Další sada klíčových slov",
|
||||
"Next Photo": "Další fotografie",
|
||||
"Next up for AI Denoise…": "Další na řadě pro redukci šumu pomocí AI…",
|
||||
"Night Blue": "Noční modř",
|
||||
@@ -1403,6 +1405,7 @@
|
||||
"Preview size": "Velikost náhledu",
|
||||
"Preview unavailable": "Náhled není dostupný",
|
||||
"Previews": "Náhledy",
|
||||
"Previous Keyword Set": "Předchozí sada klíčových slov",
|
||||
"Previous Photo": "Předchozí fotografie",
|
||||
"Primaries (x, y)": "Primární barvy (x, y)",
|
||||
"Print": "Tisk",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Add in place": "Am Speicherort hinzufügen",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Am Speicherort hinzufügen: Die Bibliothek verweist auf die Dateien an ihrem aktuellen Speicherort; nichts wird kopiert oder verschoben.",
|
||||
"Add keyword": "Stichwort hinzufügen",
|
||||
"Add Keywords": "Stichwörter hinzufügen",
|
||||
"Add number": "Nummer hinzufügen",
|
||||
"Add to": "Hinzufügen zu",
|
||||
"Add to Album": "Zu Album hinzufügen",
|
||||
@@ -950,6 +951,7 @@
|
||||
"New name": "Neuer Name",
|
||||
"New time": "Neue Zeit",
|
||||
"New…": "Neu…",
|
||||
"Next Keyword Set": "Nächster Stichwortsatz",
|
||||
"Next Photo": "Nächstes Foto",
|
||||
"Night Blue": "Nachtblau",
|
||||
"Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon-NEFs beginnen mit einem Farb- und Tonwertbild, das wie bei Sony-ARWs an das Kamera-JPEG angepasst ist, statt mit einem gedämpften,",
|
||||
@@ -1112,6 +1114,7 @@
|
||||
"Preview size": "Vorschaugröße",
|
||||
"Preview unavailable": "Vorschau nicht verfügbar",
|
||||
"Previews": "Vorschauen",
|
||||
"Previous Keyword Set": "Vorheriger Stichwortsatz",
|
||||
"Previous Photo": "Vorheriges Foto",
|
||||
"Print": "Drucken",
|
||||
"Print…": "Drucken…",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"Add in place": "Añadir sin mover",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Añadir sin mover: la biblioteca hace referencia a los archivos donde están; no se copia ni se mueve nada.",
|
||||
"Add keyword": "Añadir palabra clave",
|
||||
"Add Keywords": "Añadir palabras clave",
|
||||
"Add number": "Añadir número",
|
||||
"Add to": "Añadir a",
|
||||
"Add to Album": "Añadir al álbum",
|
||||
@@ -961,6 +962,7 @@
|
||||
"New name": "Nuevo nombre",
|
||||
"New time": "Nueva hora",
|
||||
"New…": "Nuevo…",
|
||||
"Next Keyword Set": "Conjunto de palabras clave siguiente",
|
||||
"Next Photo": "Foto siguiente",
|
||||
"Night Blue": "Azul nocturno",
|
||||
"No": "No",
|
||||
@@ -1117,6 +1119,7 @@
|
||||
"Preview size": "Tamaño de la vista previa",
|
||||
"Preview unavailable": "Vista previa no disponible",
|
||||
"Previews": "Vistas previas",
|
||||
"Previous Keyword Set": "Conjunto de palabras clave anterior",
|
||||
"Previous Photo": "Foto anterior",
|
||||
"Print": "Imprimir",
|
||||
"Print…": "Imprimir…",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"Add in place": "Lisää paikalleen",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Lisää paikalleen: kirjasto viittaa tiedostoihin niiden nykyisessä sijainnissa; mitään ei kopioida tai siirretä.",
|
||||
"Add keyword": "Lisää avainsana",
|
||||
"Add Keywords": "Lisää avainsanoja",
|
||||
"Add number": "Lisää numero",
|
||||
"Add text overlay": "Lisää tekstipeittokuva",
|
||||
"Add to": "Lisää kohteeseen",
|
||||
@@ -1237,6 +1238,7 @@
|
||||
"New…": "Uusi…",
|
||||
"Next Appearance Mode": "Seuraava ulkoasutila",
|
||||
"Next History Step": "Seuraava historiavaihe",
|
||||
"Next Keyword Set": "Seuraava avainsanajoukko",
|
||||
"Next Photo": "Seuraava kuva",
|
||||
"Next Slide": "Seuraava dia",
|
||||
"Next page": "Seuraava sivu",
|
||||
@@ -1462,6 +1464,7 @@
|
||||
"Preview unavailable": "Esikatselu ei käytettävissä",
|
||||
"Previews": "Esikatselut",
|
||||
"Previous History Step": "Edellinen historiavaihe",
|
||||
"Previous Keyword Set": "Edellinen avainsanajoukko",
|
||||
"Previous Photo": "Edellinen kuva",
|
||||
"Previous Slide": "Edellinen dia",
|
||||
"Previous page": "Edellinen sivu",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"Add in place": "Ajouter sur place",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Ajouter sur place : la bibliothèque référence les fichiers là où ils se trouvent ; rien n'est copié ni déplacé.",
|
||||
"Add keyword": "Ajouter un mot-clé",
|
||||
"Add Keywords": "Ajouter des mots-clés",
|
||||
"Add number": "Ajouter un numéro",
|
||||
"Add to": "Ajouter à",
|
||||
"Add to Album": "Ajouter à l'album",
|
||||
@@ -904,6 +905,7 @@
|
||||
"New name": "Nouveau nom",
|
||||
"New time": "Nouvelle heure",
|
||||
"New…": "Nouveau…",
|
||||
"Next Keyword Set": "Jeu de mots-clés suivant",
|
||||
"Next Photo": "Photo suivante",
|
||||
"Night Blue": "Bleu nuit",
|
||||
"No": "Non",
|
||||
@@ -1059,6 +1061,7 @@
|
||||
"Preview size": "Taille de l'aperçu",
|
||||
"Preview unavailable": "Aperçu indisponible",
|
||||
"Previews": "Aperçus",
|
||||
"Previous Keyword Set": "Jeu de mots-clés précédent",
|
||||
"Previous Photo": "Photo précédente",
|
||||
"Print": "Imprimer",
|
||||
"Print…": "Imprimer…",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"Add in place": "Tambahkan di lokasi asli",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Tambahkan di lokasi asli: pustaka merujuk file di lokasinya saat ini; tidak ada yang disalin atau dipindahkan.",
|
||||
"Add keyword": "Tambahkan kata kunci",
|
||||
"Add Keywords": "Tambahkan kata kunci",
|
||||
"Add number": "Tambahkan nomor",
|
||||
"Add text overlay": "Tambahkan overlay teks",
|
||||
"Add to": "Tambahkan ke",
|
||||
@@ -1234,6 +1235,7 @@
|
||||
"New…": "Baru…",
|
||||
"Next Appearance Mode": "Mode tampilan berikutnya",
|
||||
"Next History Step": "Langkah riwayat berikutnya",
|
||||
"Next Keyword Set": "Set kata kunci berikutnya",
|
||||
"Next Photo": "Foto berikutnya",
|
||||
"Next Slide": "Slide berikutnya",
|
||||
"Next up for AI Denoise…": "Berikutnya dalam antrean pengurangan noise AI…",
|
||||
@@ -1446,6 +1448,7 @@
|
||||
"Preview unavailable": "Pratinjau tidak tersedia",
|
||||
"Previews": "Pratinjau",
|
||||
"Previous History Step": "Langkah riwayat sebelumnya",
|
||||
"Previous Keyword Set": "Set kata kunci sebelumnya",
|
||||
"Previous Photo": "Foto sebelumnya",
|
||||
"Previous Slide": "Slide sebelumnya",
|
||||
"Previous settings": "Pengaturan sebelumnya",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"Add in place": "Aggiungi sul posto",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Aggiungi sul posto: la libreria usa i file dove si trovano; niente viene copiato o spostato.",
|
||||
"Add keyword": "Aggiungi parola chiave",
|
||||
"Add Keywords": "Aggiungi parole chiave",
|
||||
"Add number": "Aggiungi numero",
|
||||
"Add to": "Aggiungi a",
|
||||
"Add to Album": "Aggiungi all'album",
|
||||
@@ -907,6 +908,7 @@
|
||||
"New name": "Nuovo nome",
|
||||
"New time": "Nuova ora",
|
||||
"New…": "Nuovo…",
|
||||
"Next Keyword Set": "Set di parole chiave successivo",
|
||||
"Next Photo": "Foto successiva",
|
||||
"Night Blue": "Blu notte",
|
||||
"No": "No",
|
||||
@@ -1068,6 +1070,7 @@
|
||||
"Preview size": "Dimensione dell'anteprima",
|
||||
"Preview unavailable": "Anteprima non disponibile",
|
||||
"Previews": "Anteprime",
|
||||
"Previous Keyword Set": "Set di parole chiave precedente",
|
||||
"Previous Photo": "Foto precedente",
|
||||
"Print": "Stampa",
|
||||
"Print…": "Stampa…",
|
||||
@@ -2334,9 +2337,11 @@
|
||||
"View Options": "Opzioni di visualizzazione",
|
||||
"Loupe View": "Vista lente",
|
||||
"Show Info Overlay": "Mostra sovrapposizione informazioni",
|
||||
"Show message when loading or rendering photos": "Mostra un messaggio durante il caricamento o il rendering delle foto",
|
||||
"Loupe Info 1": "Info lente 1",
|
||||
"Loupe Info 2": "Info lente 2",
|
||||
"Use Defaults": "Usa predefiniti",
|
||||
"Show briefly when photo changes": "Mostra brevemente al cambio di foto",
|
||||
"Camera & Lens": "Fotocamera e obiettivo",
|
||||
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.",
|
||||
"Before/After Side by Side": "Prima/Dopo affiancati",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"Add in place": "現在の場所から追加",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "現在の場所のファイルを参照します。コピーや移動はしません",
|
||||
"Add keyword": "キーワードを追加",
|
||||
"Add Keywords": "キーワードを追加",
|
||||
"Add number": "番号を付ける",
|
||||
"Add to": "追加先",
|
||||
"Add to Album": "アルバムに追加",
|
||||
@@ -632,6 +633,7 @@
|
||||
"Nested group": "入れ子のグループ",
|
||||
"New Album": "新しいアルバム",
|
||||
"New time": "新しい日時",
|
||||
"Next Keyword Set": "次のキーワードセット",
|
||||
"Next Photo": "次の写真",
|
||||
"Night Blue": "ナイトブルー",
|
||||
"No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません",
|
||||
@@ -672,6 +674,7 @@
|
||||
"Preview Build Progress": "プレビュー作成の進行状況",
|
||||
"Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中",
|
||||
"Preview unavailable": "プレビューを表示できません",
|
||||
"Previous Keyword Set": "前のキーワードセット",
|
||||
"Previous Photo": "前の写真",
|
||||
"Profile Browser": "プロファイルブラウザー",
|
||||
"Profile Menu": "プロファイルメニュー",
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
"Add in place": "ადგილზე დამატება",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "ადგილზე დამატება: ბიბლიოთეკა ფაილებს მათ ამჟამინდელ ადგილას მიმართავს; არაფერი კოპირდება ან გადაიტანება.",
|
||||
"Add keyword": "საკვანძო სიტყვის დამატება",
|
||||
"Add Keywords": "საკვანძო სიტყვების დამატება",
|
||||
"Add number": "რიცხვის დამატება",
|
||||
"Add to": "დამატება:",
|
||||
"Add to Album": "ალბომში დამატება",
|
||||
@@ -1311,6 +1312,7 @@
|
||||
"New…": "ახალი…",
|
||||
"Next Appearance Mode": "შემდეგი იერსახის რეჟიმი",
|
||||
"Next History Step": "ისტორიის შემდეგი ნაბიჯი",
|
||||
"Next Keyword Set": "საკვანძო სიტყვების შემდეგი ნაკრები",
|
||||
"Next Photo": "შემდეგი ფოტო",
|
||||
"Next up for AI Denoise…": "შემდეგია AI ხმაურის მოცილების რიგში…",
|
||||
"Night Blue": "ღამის ლურჯი",
|
||||
@@ -1535,6 +1537,7 @@
|
||||
"Previews": "წინასწარი ხედები",
|
||||
"Previews match exports on Mac screens": "წინასწარი ხედები ემთხვევა ექსპორტს Mac-ის ეკრანებზე",
|
||||
"Previous History Step": "ისტორიის წინა ნაბიჯი",
|
||||
"Previous Keyword Set": "საკვანძო სიტყვების წინა ნაკრები",
|
||||
"Previous Photo": "წინა ფოტო",
|
||||
"Primaries (x, y)": "ძირითადი ფერები (x, y)",
|
||||
"Print": "ბეჭდვა",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Add in place": "Dodaj bez kopiowania plików",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Dodaj w miejscu: biblioteka odwołuje się do plików w ich obecnej lokalizacji; nic nie jest kopiowane ani przenoszone.",
|
||||
"Add keyword": "Dodaj słowo kluczowe",
|
||||
"Add Keywords": "Dodaj słowa kluczowe",
|
||||
"Add number": "Dodaj numer",
|
||||
"Add to": "Dodaj do",
|
||||
"Add to Album": "Dodaj do albumu",
|
||||
@@ -886,6 +887,7 @@
|
||||
"New name": "Nowa nazwa",
|
||||
"New time": "Nowy czas",
|
||||
"New…": "Nowy…",
|
||||
"Next Keyword Set": "Następny zestaw słów kluczowych",
|
||||
"Next Photo": "Następne zdjęcie",
|
||||
"Night Blue": "Nocny błękit",
|
||||
"No": "Nie",
|
||||
@@ -1030,6 +1032,7 @@
|
||||
"Preview only": "Tylko podgląd",
|
||||
"Preview size": "Rozmiar podglądu",
|
||||
"Previews": "Podglądy",
|
||||
"Previous Keyword Set": "Poprzedni zestaw słów kluczowych",
|
||||
"Previous Photo": "Poprzednie zdjęcie",
|
||||
"Print": "Drukuj",
|
||||
"Print…": "Drukuj…",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"Add in place": "Adicionar no local",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Adicionar no local: a biblioteca referencia os arquivos onde eles estão; nada é copiado nem movido.",
|
||||
"Add keyword": "Adicionar palavra-chave",
|
||||
"Add Keywords": "Adicionar palavras-chave",
|
||||
"Add number": "Adicionar número",
|
||||
"Add to": "Adicionar a",
|
||||
"Add to Album": "Adicionar ao álbum",
|
||||
@@ -916,6 +917,7 @@
|
||||
"New name": "Novo nome",
|
||||
"New time": "Novo horário",
|
||||
"New…": "Novo…",
|
||||
"Next Keyword Set": "Próximo conjunto de palavras-chave",
|
||||
"Next Photo": "Próxima foto",
|
||||
"Night Blue": "Azul noturno",
|
||||
"No": "Não",
|
||||
@@ -1077,6 +1079,7 @@
|
||||
"Preview size": "Tamanho da pré-visualização",
|
||||
"Preview unavailable": "Prévia indisponível",
|
||||
"Previews": "Pré-visualizações",
|
||||
"Previous Keyword Set": "Conjunto de palavras-chave anterior",
|
||||
"Previous Photo": "Foto anterior",
|
||||
"Print": "Imprimir",
|
||||
"Print…": "Imprimir…",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"Add in place": "Добавить на месте",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Добавить на месте: библиотека ссылается на файлы там, где они лежат; ничего не копируется и не перемещается.",
|
||||
"Add keyword": "Добавить ключевое слово",
|
||||
"Add Keywords": "Добавить ключевые слова",
|
||||
"Add number": "Добавить номер",
|
||||
"Add to": "Добавить в",
|
||||
"Add to Album": "Добавить в альбом",
|
||||
@@ -760,6 +761,7 @@
|
||||
"New blank PDF": "Новый пустой PDF",
|
||||
"New name": "Новое имя",
|
||||
"New…": "Новый…",
|
||||
"Next Keyword Set": "Следующий набор ключевых слов",
|
||||
"Next Photo": "Следующее фото",
|
||||
"Night Blue": "Ночной синий",
|
||||
"No": "Нет",
|
||||
@@ -897,6 +899,7 @@
|
||||
"Preview size": "Размер просмотра",
|
||||
"Preview unavailable": "Просмотр недоступен",
|
||||
"Previews": "Просмотры",
|
||||
"Previous Keyword Set": "Предыдущий набор ключевых слов",
|
||||
"Previous Photo": "Предыдущее фото",
|
||||
"Print": "Печать",
|
||||
"Print…": "Печать…",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"Add in place": "Lägg till på plats",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Lägg till på plats: biblioteket refererar till filerna där de ligger; ingenting kopieras eller flyttas.",
|
||||
"Add keyword": "Lägg till nyckelord",
|
||||
"Add Keywords": "Lägg till nyckelord",
|
||||
"Add Mask": "Lägg till mask",
|
||||
"Add number": "Lägg till nummer",
|
||||
"Add Object Click": "Lägg till objektklick",
|
||||
@@ -1363,6 +1364,7 @@
|
||||
"New…": "Ny…",
|
||||
"Next Appearance Mode": "Nästa utseendeläge",
|
||||
"Next page": "Nästa sida",
|
||||
"Next Keyword Set": "Nästa nyckelordsuppsättning",
|
||||
"Next Photo": "Nästa foto",
|
||||
"Next up for AI Denoise…": "Näst i tur för AI-brusreducering…",
|
||||
"Night Blue": "Nattblå",
|
||||
@@ -1600,6 +1602,7 @@
|
||||
"Previews": "Förhandsgranskningar",
|
||||
"previews": "förhandsgranskningar",
|
||||
"Previous page": "Föregående sida",
|
||||
"Previous Keyword Set": "Föregående nyckelordsuppsättning",
|
||||
"Previous Photo": "Föregående foto",
|
||||
"Previous settings": "Föregående inställningar",
|
||||
"Primaries (x, y)": "Primärfärger (x, y)",
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"Add in place": "Додати на місці",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Додати на місці: бібліотека посилається на файли в їхньому розташуванні; нічого не копіюється й не переміщується.",
|
||||
"Add keyword": "Додати ключове слово",
|
||||
"Add Keywords": "Додати ключові слова",
|
||||
"Add number": "Додати число",
|
||||
"Add to Album": "Додати до альбому",
|
||||
"Add to Favorites": "Додати до улюблених",
|
||||
@@ -855,6 +856,7 @@
|
||||
"Nested group": "Вкладена група",
|
||||
"New Album": "Новий альбом",
|
||||
"New time": "Новий час",
|
||||
"Next Keyword Set": "Наступний набір ключових слів",
|
||||
"Next Photo": "Наступне фото",
|
||||
"Night Blue": "Нічна синява",
|
||||
"No named people in these photos": "На цих фото немає людей з іменами",
|
||||
@@ -887,6 +889,7 @@
|
||||
"Presets and profiles": "Набори налаштувань і профілі",
|
||||
"Preview Build Progress": "Перебіг створення переглядів",
|
||||
"Preview only: editing the camera's embedded JPEG": "Лише перегляд: редагування вбудованого JPEG камери",
|
||||
"Previous Keyword Set": "Попередній набір ключових слів",
|
||||
"Previous Photo": "Попереднє фото",
|
||||
"Profile Browser": "Оглядач профілів",
|
||||
"Profile Menu": "Меню профілів",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"Add in place": "Thêm tại chỗ",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "Thêm tại chỗ: thư viện tham chiếu tệp ở vị trí hiện tại; không sao chép hay di chuyển gì.",
|
||||
"Add keyword": "Thêm từ khóa",
|
||||
"Add Keywords": "Thêm từ khóa",
|
||||
"Add number": "Thêm số",
|
||||
"Add to": "Thêm vào",
|
||||
"Add to Album": "Thêm vào album",
|
||||
@@ -1137,6 +1138,7 @@
|
||||
"New…": "Mới…",
|
||||
"Next Appearance Mode": "Chế độ giao diện tiếp theo",
|
||||
"Next History Step": "Bước lịch sử sau",
|
||||
"Next Keyword Set": "Bộ từ khóa tiếp theo",
|
||||
"Next Photo": "Ảnh tiếp theo",
|
||||
"Next up for AI Denoise…": "Tiếp theo cho AI Khử nhiễu…",
|
||||
"Night Blue": "Xanh đêm",
|
||||
@@ -1339,6 +1341,7 @@
|
||||
"Preview unavailable": "Không có xem trước",
|
||||
"Previews": "Xem trước",
|
||||
"Previous History Step": "Bước lịch sử trước",
|
||||
"Previous Keyword Set": "Bộ từ khóa trước",
|
||||
"Previous Photo": "Ảnh trước",
|
||||
"Primaries (x, y)": "Màu gốc (x, y)",
|
||||
"Print": "In",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"Add in place": "在原位置添加",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "在原位置添加:图库直接引用文件所在位置;不会复制或移动任何内容。",
|
||||
"Add keyword": "添加关键字",
|
||||
"Add Keywords": "添加关键字",
|
||||
"Add number": "添加编号",
|
||||
"Add to": "添加到",
|
||||
"Add to Album": "添加到相册",
|
||||
@@ -981,6 +982,7 @@
|
||||
"New blank PDF": "新建空白 PDF",
|
||||
"New name": "新名称",
|
||||
"New…": "新建…",
|
||||
"Next Keyword Set": "下一个关键字集",
|
||||
"Next Photo": "下一张照片",
|
||||
"Night Blue": "夜色蓝调",
|
||||
"No": "否",
|
||||
@@ -1109,6 +1111,7 @@
|
||||
"Preview only": "仅预览",
|
||||
"Preview size": "预览大小",
|
||||
"Previews": "预览",
|
||||
"Previous Keyword Set": "上一个关键字集",
|
||||
"Previous Photo": "上一张照片",
|
||||
"Print": "打印",
|
||||
"Print…": "打印…",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"Add in place": "原地加入",
|
||||
"Add in place: the library references the files where they are; nothing is copied or moved.": "原地加入:照片庫參照檔案目前的位置,不會複製或移動檔案。",
|
||||
"Add keyword": "新增關鍵字",
|
||||
"Add Keywords": "新增關鍵字",
|
||||
"Add number": "加上編號",
|
||||
"Add to": "加入至",
|
||||
"Add to Album": "加入相簿",
|
||||
@@ -982,6 +983,7 @@
|
||||
"New blank PDF": "新增空白 PDF",
|
||||
"New name": "新名稱",
|
||||
"New…": "新增…",
|
||||
"Next Keyword Set": "下一個關鍵字集",
|
||||
"Next Photo": "下一張照片",
|
||||
"Night Blue": "夜色藍調",
|
||||
"No": "否",
|
||||
@@ -1110,6 +1112,7 @@
|
||||
"Preview only": "僅供預覽",
|
||||
"Preview size": "預覽尺寸",
|
||||
"Previews": "預覽",
|
||||
"Previous Keyword Set": "上一個關鍵字集",
|
||||
"Previous Photo": "上一張照片",
|
||||
"Print": "列印",
|
||||
"Print…": "列印…",
|
||||
|
||||
@@ -211,8 +211,8 @@ impl<'a> AlbumPicker<'a> {
|
||||
ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text.as_str()).truncate().right_text(" ")).on_hover_text(text);
|
||||
let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon;
|
||||
disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color);
|
||||
register(ui.ctx(), format!("albumPicker:{id}"), button.rect);
|
||||
let key = egui::Id::new(("albumPicker", id.as_str()));
|
||||
register(ui, format!("albumPicker:{id}"), button.rect);
|
||||
let key = egui::Id::unique(("albumPicker", id.as_str()));
|
||||
if button.clicked() {
|
||||
// open on the chosen album's folders, with the search empty and ready for typing
|
||||
let open = chosen.map(|c| ancestors(entries, c)).unwrap_or_default();
|
||||
@@ -232,7 +232,7 @@ impl<'a> AlbumPicker<'a> {
|
||||
}
|
||||
let search_box =
|
||||
ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY));
|
||||
register(ui.ctx(), format!("albumPickerSearch:{id}"), search_box.rect);
|
||||
register(ui, format!("albumPickerSearch:{id}"), search_box.rect);
|
||||
if state.focus {
|
||||
search_box.request_focus();
|
||||
state.focus = false;
|
||||
@@ -252,7 +252,7 @@ impl<'a> AlbumPicker<'a> {
|
||||
let resp = ui
|
||||
.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label).truncate())
|
||||
.on_hover_text(label);
|
||||
register(ui.ctx(), format!("albumPickerItem:{}:{id}", e.id), resp.rect);
|
||||
register(ui, format!("albumPickerItem:{}:{id}", e.id), resp.rect);
|
||||
let resp = match &e.blocked {
|
||||
Some(why) => resp.on_disabled_hover_text(why.as_str()),
|
||||
None => resp,
|
||||
@@ -289,10 +289,10 @@ impl<'a> AlbumPicker<'a> {
|
||||
egui::TextStyle::Button.resolve(ui.style()),
|
||||
t.text_label,
|
||||
);
|
||||
register(ui.ctx(), format!("albumPickerFolder:{}:{id}", e.id), r.rect);
|
||||
register(ui, format!("albumPickerFolder:{}:{id}", e.id), r.rect);
|
||||
// what assistive tools announce: the folder, and whether it is open
|
||||
let name = e.name.clone();
|
||||
r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, &name));
|
||||
crate::widgets::announce_disclosure(&r, true, open, &name);
|
||||
if r.clicked() {
|
||||
// the click took the keyboard from the search box: give it back
|
||||
state.focus = true;
|
||||
|
||||
@@ -332,7 +332,7 @@ impl<'a> Button<'a> {
|
||||
// stable across frames and moves: its widget id (with its salt) when it has one, else its
|
||||
// place in the ui (moved on, so the next button in the same ui gets another)
|
||||
let id = match &self.widget {
|
||||
Some(w) => ui.id().with(("lc-button", w.as_str(), self.id_salt)),
|
||||
Some(w) => ui.scope_id().with(("lc-button", w.as_str(), self.id_salt)),
|
||||
None => {
|
||||
let id = ui.auto_id_with(("lc-button", self.text.or(self.name), self.id_salt));
|
||||
ui.skip_ahead_auto_ids(1);
|
||||
@@ -354,12 +354,12 @@ impl<'a> Button<'a> {
|
||||
},
|
||||
);
|
||||
let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone()));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, words.clone()));
|
||||
if self.busy {
|
||||
ui.ctx().accesskit_node_builder(resp.id, |node| node.set_busy());
|
||||
}
|
||||
if let Some(w) = &self.widget {
|
||||
crate::widgets::register(ui.ctx(), w.clone(), rect);
|
||||
crate::widgets::register(ui, w.clone(), rect);
|
||||
}
|
||||
let state = if !enabled {
|
||||
State::Disabled
|
||||
|
||||
@@ -12,12 +12,12 @@ pub const STEP: f64 = 0.005;
|
||||
pub const BIG_STEP: f64 = 0.05;
|
||||
|
||||
fn focused_key() -> Id {
|
||||
Id::new("lc-canvas-handle-focused")
|
||||
Id::unique("lc-canvas-handle-focused")
|
||||
}
|
||||
|
||||
/// The egui id of the stand-in registered as `key` (to focus it, or find it in tests).
|
||||
pub fn id(key: &str) -> Id {
|
||||
Id::new("lc-canvas-handle").with(key)
|
||||
Id::unique("lc-canvas-handle").with(key)
|
||||
}
|
||||
|
||||
/// Whether a canvas handle holds the keyboard focus: it keeps the arrow keys and Return from the
|
||||
@@ -49,11 +49,11 @@ pub fn handle(ui: &mut Ui, key: &str, name: &str, value: String, at: Pos2) -> Ha
|
||||
let rect = Rect::from_center_size(at, vec2(14.0, 14.0));
|
||||
let resp = ui.interact(rect, id, egui::Sense::focusable_noninteractive());
|
||||
let label = name.to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone()));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.clone()));
|
||||
ui.ctx().accesskit_node_builder(id, |node| {
|
||||
node.set_value(value);
|
||||
});
|
||||
crate::widgets::register(ui.ctx(), format!("canvasHandle:{key}"), rect);
|
||||
crate::widgets::register(ui, format!("canvasHandle:{key}"), rect);
|
||||
let mut out = HandleKeys::default();
|
||||
if !resp.has_focus() {
|
||||
return out;
|
||||
|
||||
+364
-137
@@ -10,7 +10,7 @@
|
||||
//! - `ui.move/click/drag {x, y, …}`: raw pointer input in screen points
|
||||
//! - `ui.pointer {events: [{kind: down|drag|up, x, y}], space: "image"}`: gestures in normalized
|
||||
//! image coordinates (brush strokes, crop handles…) — mapped to screen and injected as real input
|
||||
//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}` / `ui.scroll {dx, dy}`
|
||||
//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}` / `ui.scroll {dx, dy, source?, unit?, phase?}`
|
||||
//! - `ui.set {view?, panel?, filmstrip?, leftPanel?, zoom?, …}` / `ui.dialog.confirm|cancel`
|
||||
//! - `ui.screenshot {path?, headless?}` (headless: CPU-rendered, no compositor needed), `ui.resize {width, height}`, `ui.render {id?, size?, path?}`, `app.quit`
|
||||
|
||||
@@ -59,7 +59,17 @@ impl ControlRequest {
|
||||
|
||||
pub enum Outcome {
|
||||
Done(Value),
|
||||
Screenshot { path: Option<String>, headless: bool },
|
||||
/// A capture was admitted: `target` pins the destination identity until the write.
|
||||
Screenshot {
|
||||
target: Option<lightcraft_engine::originals::WriteTarget>,
|
||||
headless: bool,
|
||||
},
|
||||
/// A synthetic-input request that must wait for a fresh layout and for its last event to be
|
||||
/// consumed before it is answered (see `LightcraftApp::admit_gesture`).
|
||||
Gesture {
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
}
|
||||
|
||||
fn ok(v: Value) -> Outcome {
|
||||
@@ -160,6 +170,9 @@ fn modifiers(p: &Value) -> egui::Modifiers {
|
||||
egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") }
|
||||
}
|
||||
|
||||
/// Parse a control-protocol key name into an egui key. `egui::Key::from_name` is tried first;
|
||||
/// then lowercase aliases cover `return`/`enter`, `esc`/`escape`, `delete`/`backspace` (both
|
||||
/// Backspace), arrow names, space/tab, and backslash, slash and square brackets.
|
||||
fn key_from(name: &str) -> Option<egui::Key> {
|
||||
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
|
||||
"enter" | "return" => Some(egui::Key::Enter),
|
||||
@@ -179,6 +192,50 @@ fn key_from(name: &str) -> Option<egui::Key> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Most clicks one `ui.click`/`ui.clickWidget` request may inject. The control server is a
|
||||
/// local automation surface, but a request must not be able to fill memory with synthetic events.
|
||||
pub(crate) const MAX_CLICKS: u64 = 16;
|
||||
/// Most pointer-move steps one `ui.drag`/`ui.dragWidget` request may inject.
|
||||
pub(crate) const MAX_DRAG_STEPS: u64 = 1000;
|
||||
|
||||
/// What a widget gesture's synthetic events were aimed at when they were resolved. Delivery
|
||||
/// rechecks this against the latest completed layout before the first event and again before every
|
||||
/// press: a moved widget cancels the request instead of being retargeted.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GestureTarget {
|
||||
pub id: String,
|
||||
/// The rect the request resolved against. An explicit `fx`/`fy` may place `at` outside it;
|
||||
/// delivery accepts that only while egui's actual clipped interaction rectangle still
|
||||
/// contains the point.
|
||||
pub rect: egui::Rect,
|
||||
/// The point the pointer events aim at.
|
||||
pub at: egui::Pos2,
|
||||
/// Whether the release must still find this widget. egui attributes a click on release while a
|
||||
/// drag may legitimately end outside the widget it started on, so only `ui.clickWidget` sets
|
||||
/// this.
|
||||
pub check_release: bool,
|
||||
/// Whether the first event must find the widget visible and topmost. A hover is not harmless
|
||||
/// when its point is clipped out of the widget or covered by a non-modal overlay: the
|
||||
/// documented contract refuses it rather than moving the pointer at a stale place.
|
||||
pub check_visible: bool,
|
||||
}
|
||||
|
||||
/// The events queued for a resolved gesture, plus the widget target whose geometry must still hold
|
||||
/// when the first event and each press are injected.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct GestureOutcome {
|
||||
pub result: Value,
|
||||
pub target: Option<GestureTarget>,
|
||||
}
|
||||
|
||||
impl GestureOutcome {
|
||||
/// A gesture outcome that injected no widget-targeted events, so delivery has no geometry to
|
||||
/// re-check.
|
||||
fn done(result: Value) -> Self {
|
||||
Self { result, target: None }
|
||||
}
|
||||
}
|
||||
|
||||
fn push_drag(app: &mut LightcraftApp, a: egui::Pos2, b: egui::Pos2, steps: u64, m: egui::Modifiers) {
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
@@ -189,10 +246,81 @@ fn push_drag(app: &mut LightcraftApp, a: egui::Pos2, b: egui::Pos2, steps: u64,
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
}
|
||||
|
||||
fn widget_rect(app: &LightcraftApp, id: &str) -> Option<egui::Rect> {
|
||||
/// The rectangle registered for automation id `id` in the latest completed pass. The last
|
||||
/// registration wins, so a redrawn widget's current geometry is returned.
|
||||
pub(crate) fn widget_rect(app: &LightcraftApp, id: &str) -> Option<egui::Rect> {
|
||||
app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
/// The real egui widget that owns automation id `id` in the latest completed pass (the last
|
||||
/// registration wins, as [`widget_rect`] does).
|
||||
pub(crate) fn widget_hit(app: &LightcraftApp, id: &str) -> Option<crate::widgets::WidgetHit> {
|
||||
app.widget_hits.iter().rev().find(|(w, _)| w == id).map(|(_, hit)| *hit)
|
||||
}
|
||||
|
||||
/// Whether the registered automation rectangle still owns egui's pointer hit at `at`.
|
||||
///
|
||||
/// A registration records the egui id and layer of a hover probe (or of the widget's own
|
||||
/// response), plus that response's clipped `interact_rect` in the same completed pass. This asks
|
||||
/// egui for the current areas at the point and checks:
|
||||
///
|
||||
/// - the recorded clipped rectangle still contains the point (a widget scrolled out of its panel
|
||||
/// has an empty interact rect even though its unclipped rect still contains the old point);
|
||||
/// - the top-most interactable area at the point is the recorded layer; or
|
||||
/// - the top-most area is the current modal layer and the recorded layer is below it. egui routes
|
||||
/// every point below a modal to that modal, even outside the dialog window, so leaving the press
|
||||
/// to egui is safe: the dialog consumes it or `clicked_backdrop` closes the dialog, and a
|
||||
/// background widget cannot receive it. A non-modal overlay is not the modal layer, so it must
|
||||
/// still match the target and is refused otherwise. The one exception is the explicitly
|
||||
/// blocking library-problem/launch-list window: its dim also owns the modal layer, but a
|
||||
/// background target is refused here rather than reported `ok:true` after the dim swallows the
|
||||
/// press; only controls drawn in that window's own layer pass. Same-layer overlaps are invisible
|
||||
/// to this area check; egui still delivers the press to whichever widget is top-most in that
|
||||
/// layer.
|
||||
///
|
||||
/// `interact_rect` comes from the latest completed registry, not `Context::read_response`:
|
||||
/// `raw_input_hook` runs before `begin_pass`, where `read_response` checks `this_pass` before
|
||||
/// `prev_pass` and can therefore return the pass before the one that just published the registry.
|
||||
/// This matters when clipping, scrolling or a viewport change alters the target without changing
|
||||
/// its layer.
|
||||
///
|
||||
/// In pinned egui `6d518fc`, `Response::hovered` is deliberately false while any button is down
|
||||
/// unless this is the clicked widget (`context.rs:1884-1886`, `interaction.rs:257-300`). A
|
||||
/// hover-only probe cannot use it for a click release. `Response::contains_pointer` would survive
|
||||
/// the press, but its hit-test snapshot is one frame behind the pointer move that precedes the
|
||||
/// press (`context.rs:517-548` reads `prev_pass.widgets` at `begin_pass`). The recorded clipped rect
|
||||
/// plus the current layer has no such lag.
|
||||
///
|
||||
/// This also avoids the old centre-of-an-interactive-rect heuristic: a registration may be a
|
||||
/// sub-rect of its widget's interactive response (rating stars, date cells, a checkbox's box, a
|
||||
/// profile card's star), so the interactive rect's centre need not lie in the registered rect.
|
||||
/// Disabled controls keep working as a no-op: their own probe is enabled and the injected click
|
||||
/// is ignored by egui, which is the documented `ui.clickWidget` semantics.
|
||||
pub(crate) fn widget_target_visible(ctx: &egui::Context, hit: Option<crate::widgets::WidgetHit>, at: egui::Pos2) -> bool {
|
||||
let Some(hit) = hit else {
|
||||
// A hand-built registry entry (tests) or painted geometry has no egui widget to own a
|
||||
// press. Production widget registrations always record a probe or a real response.
|
||||
return false;
|
||||
};
|
||||
let interact_rect = ctx.layer_transform_to_global(hit.layer_id).map_or(hit.interact_rect, |to_global| to_global * hit.interact_rect);
|
||||
if !interact_rect.is_finite() || !interact_rect.contains(at) {
|
||||
return false;
|
||||
}
|
||||
let (top_modal, allows_interaction, above_modal) =
|
||||
ctx.memory(|memory| (memory.top_modal_layer(), memory.allows_interaction(hit.layer_id), memory.is_above_modal_layer(hit.layer_id)));
|
||||
if crate::panels::library_problem::refuses_control_target(top_modal, hit.layer_id) {
|
||||
return false;
|
||||
}
|
||||
let at_layer = ctx.layer_id_at(at);
|
||||
if at_layer == Some(hit.layer_id) {
|
||||
return allows_interaction;
|
||||
}
|
||||
top_modal == at_layer && !above_modal
|
||||
}
|
||||
|
||||
/// Dispatch one control request: run engine commands, list menus/widgets, inspect state, queue
|
||||
/// file drops and synthetic input, capture screenshots, or return an error for an unknown or
|
||||
/// invalid request. Requests that need fresh widget geometry are queued as `Outcome` values.
|
||||
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
@@ -217,85 +345,6 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
app.widgets.iter().filter(|(id, _)| id.contains(filter)).map(|(id, r)| json!({"id": id, "rect": rect_json(*r)})).collect(),
|
||||
))
|
||||
}
|
||||
"ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" => {
|
||||
let Some(id) = s("id") else { return err("missing `id`") };
|
||||
let Some(r) = widget_rect(app, id) else { return err(format!("no widget `{id}` on screen (see ui.widgets)")) };
|
||||
let m = modifiers(p);
|
||||
// optional relative position inside the widget (0..1)
|
||||
let at = egui::pos2(r.left() + r.width() * f("fx").unwrap_or(0.5) as f32, r.top() + r.height() * f("fy").unwrap_or(0.5) as f32);
|
||||
if req.method == "ui.hoverWidget" {
|
||||
app.synthetic.push(egui::Event::PointerMoved(at));
|
||||
} else if req.method == "ui.clickWidget" {
|
||||
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
|
||||
app.synthetic.push(egui::Event::PointerMoved(at));
|
||||
for _ in 0..n {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: m });
|
||||
}
|
||||
} else {
|
||||
let to = egui::pos2(
|
||||
f("toX").map(|v| v as f32).unwrap_or(at.x + f("dx").unwrap_or(0.0) as f32),
|
||||
f("toY").map(|v| v as f32).unwrap_or(at.y + f("dy").unwrap_or(0.0) as f32),
|
||||
);
|
||||
push_drag(app, at, to, p.get("steps").and_then(Value::as_u64).unwrap_or(10), m);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
ok(json!({"rect": rect_json(r)}))
|
||||
}
|
||||
"ui.move" => {
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32)));
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.click" | "ui.drag" => {
|
||||
let a = egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32);
|
||||
let m = modifiers(p);
|
||||
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
|
||||
if req.method == "ui.drag" {
|
||||
let b = egui::pos2(f("toX").unwrap_or(0.0) as f32, f("toY").unwrap_or(0.0) as f32);
|
||||
push_drag(app, a, b, p.get("steps").and_then(Value::as_u64).unwrap_or(10), m);
|
||||
} else {
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
for _ in 0..p.get("count").and_then(Value::as_u64).unwrap_or(1) {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: false, modifiers: m });
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.pointer" => {
|
||||
// events in normalized image coordinates of the displayed photo frame
|
||||
let Some(img) = app.image_rect else { return err("no image on screen (switch to Detail view)") };
|
||||
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
|
||||
let m = modifiers(p);
|
||||
for e in events {
|
||||
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.5) as f32;
|
||||
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.5) as f32;
|
||||
let pos = egui::pos2(img.left() + x * img.width(), img.top() + y * img.height());
|
||||
match e.get("kind").and_then(Value::as_str).unwrap_or("move") {
|
||||
"down" => {
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
}
|
||||
"up" => {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: m })
|
||||
}
|
||||
_ => app.synthetic.push(egui::Event::PointerMoved(pos)),
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
ok(json!({"events": events.len()}))
|
||||
}
|
||||
"ui.key" => {
|
||||
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
|
||||
let m = modifiers(p);
|
||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.dropFiles" => {
|
||||
// every entry must be a string: a script is not told `ok` for a part of what it sent
|
||||
let Some(paths) =
|
||||
@@ -313,49 +362,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.text" => {
|
||||
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
// what the system does for ⌘X / ⌘C / ⌘V in a text field: paste `text`, or (without it)
|
||||
// what the system clipboard holds
|
||||
"ui.clipboard" => {
|
||||
// outside a text field ⌘C / ⌘V are the app's shortcuts (and the macOS menu bar's):
|
||||
// agents run those as commands (`develop.copy`, `develop.paste`)
|
||||
if !ctx.text_edit_focused() {
|
||||
return err("ui.clipboard: no text field has the focus (click one first)");
|
||||
}
|
||||
match s("action") {
|
||||
Some("cut") => app.synthetic.push(egui::Event::Cut),
|
||||
Some("copy") => app.synthetic.push(egui::Event::Copy),
|
||||
Some("paste") => match s("text") {
|
||||
Some(text) => app.synthetic.push(egui::Event::Paste(text.to_string())),
|
||||
None => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
||||
},
|
||||
_ => return err("ui.clipboard: `action` is cut, copy or paste"),
|
||||
}
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.scroll" => {
|
||||
app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(f("dx").unwrap_or(0.0) as f32, f("dy").unwrap_or(0.0) as f32),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: modifiers(p),
|
||||
});
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.zoom" => {
|
||||
let Some(factor) = f("factor").map(|v| v as f32).filter(|v| v.is_finite() && *v > 0.0) else {
|
||||
return err("ui.zoom: factor must be a finite positive number (1 = unchanged)");
|
||||
};
|
||||
app.synthetic.push(egui::Event::Zoom(factor));
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
"ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" | "ui.move" | "ui.click" | "ui.drag" | "ui.pointer" | "ui.key" | "ui.text"
|
||||
| "ui.clipboard" | "ui.scroll" => {
|
||||
// Geometry and input events are resolved only after a fresh completed layout; the
|
||||
// reply waits until the UI has consumed the last event (plan §4.5).
|
||||
Outcome::Gesture { method: req.method.clone(), params: req.params.clone() }
|
||||
}
|
||||
"ui.zoom" => Outcome::Gesture { method: req.method.clone(), params: req.params.clone() },
|
||||
"ui.set" => {
|
||||
// an arrangement of the sidebar is checked, not made whole as a settings file's is
|
||||
let sidebar = match p.get("sidebar").map(crate::sidebar::SidebarLayout::parse).transpose() {
|
||||
@@ -449,12 +462,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
_ => err("ui.zoomFactor: factor must be a number from 0.5 to 3"),
|
||||
},
|
||||
"ui.screenshot" => {
|
||||
// never over a photo's original
|
||||
if let Some(Err(e)) = s("path").map(|path| app.session.check_write_target(path)) {
|
||||
return err(e);
|
||||
}
|
||||
// Never over a photo's original, and pin the destination identity now: the write
|
||||
// revalidates it after the (possibly lengthy) capture and never replaces a path that
|
||||
// was absent at admission but appeared meanwhile.
|
||||
let target = match s("path") {
|
||||
Some(path) => match app.session.pin_write_target(path) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => return err(e),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
ctx.request_repaint();
|
||||
Outcome::Screenshot { path: s("path").map(str::to_string), headless: p.get("headless").and_then(Value::as_bool).unwrap_or(false) }
|
||||
Outcome::Screenshot { target, headless: p.get("headless").and_then(Value::as_bool).unwrap_or(false) }
|
||||
}
|
||||
"ui.render" => {
|
||||
let id = match p.get("id").filter(|v| !v.is_null()) {
|
||||
@@ -581,22 +600,230 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
/// Resolve one queued synthetic-input control request against the **current** layout.
|
||||
///
|
||||
/// Called by the app only after a completed UI pass newer than admission (plan §4.5). Widget rects
|
||||
/// and the image rect therefore come from that pass; nothing is retargeted. A successful return
|
||||
/// has pushed the events onto `app.synthetic`; the app answers the request once the UI consumed
|
||||
/// them.
|
||||
pub(crate) fn gesture_events(app: &mut LightcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Result<GestureOutcome, String> {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
match method {
|
||||
"ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" => {
|
||||
let Some(id) = s("id") else { return Err("missing `id`".into()) };
|
||||
let Some(r) = widget_rect(app, id) else { return Err(format!("no widget `{id}` on screen (see ui.widgets)")) };
|
||||
let m = modifiers(p);
|
||||
let (fx, fy) = (f("fx").unwrap_or(0.5), f("fy").unwrap_or(0.5));
|
||||
let at = egui::pos2(r.left() + r.width() * fx as f32, r.top() + r.height() * fy as f32);
|
||||
if !fx.is_finite() || !fy.is_finite() || !at.is_finite() {
|
||||
return Err(format!("{method}: fx and fy must resolve to a finite point"));
|
||||
}
|
||||
let hover = method == "ui.hoverWidget";
|
||||
if hover && !widget_target_visible(ctx, widget_hit(app, id), at) {
|
||||
return Err(format!("ui.hoverWidget: widget `{id}` has no visible interaction area at the requested point"));
|
||||
}
|
||||
if hover {
|
||||
app.synthetic.push(egui::Event::PointerMoved(at));
|
||||
} else if method == "ui.clickWidget" {
|
||||
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).min(MAX_CLICKS);
|
||||
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
|
||||
app.synthetic.push(egui::Event::PointerMoved(at));
|
||||
for _ in 0..n {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: m });
|
||||
}
|
||||
} else {
|
||||
let to = egui::pos2(
|
||||
f("toX").map(|v| v as f32).unwrap_or(at.x + f("dx").unwrap_or(0.0) as f32),
|
||||
f("toY").map(|v| v as f32).unwrap_or(at.y + f("dy").unwrap_or(0.0) as f32),
|
||||
);
|
||||
push_drag(app, at, to, p.get("steps").and_then(Value::as_u64).unwrap_or(10).min(MAX_DRAG_STEPS), m);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome {
|
||||
result: json!({"rect": rect_json(r)}),
|
||||
target: Some(GestureTarget { id: id.to_string(), rect: r, at, check_release: method == "ui.clickWidget", check_visible: hover }),
|
||||
})
|
||||
}
|
||||
"ui.move" => {
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32)));
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome::done(Value::Null))
|
||||
}
|
||||
"ui.click" | "ui.drag" => {
|
||||
let a = egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32);
|
||||
let m = modifiers(p);
|
||||
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
|
||||
if method == "ui.drag" {
|
||||
let b = egui::pos2(f("toX").unwrap_or(0.0) as f32, f("toY").unwrap_or(0.0) as f32);
|
||||
push_drag(app, a, b, p.get("steps").and_then(Value::as_u64).unwrap_or(10).min(MAX_DRAG_STEPS), m);
|
||||
} else {
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
for _ in 0..p.get("count").and_then(Value::as_u64).unwrap_or(1).min(MAX_CLICKS) {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: false, modifiers: m });
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome::done(Value::Null))
|
||||
}
|
||||
"ui.pointer" => {
|
||||
let Some(img) = app.image_rect else { return Err("no image on screen (switch to Detail view)".into()) };
|
||||
let Some(events) = p.get("events").and_then(Value::as_array) else { return Err("missing `events`".into()) };
|
||||
let m = modifiers(p);
|
||||
for e in events {
|
||||
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.5) as f32;
|
||||
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.5) as f32;
|
||||
let pos = egui::pos2(img.left() + x * img.width(), img.top() + y * img.height());
|
||||
match e.get("kind").and_then(Value::as_str).unwrap_or("move") {
|
||||
"down" => {
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
}
|
||||
"up" => {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: m })
|
||||
}
|
||||
_ => app.synthetic.push(egui::Event::PointerMoved(pos)),
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome::done(json!({"events": events.len()})))
|
||||
}
|
||||
"ui.key" => {
|
||||
let Some(k) = s("key").and_then(key_from) else { return Err("unknown or missing `key`".into()) };
|
||||
let m = modifiers(p);
|
||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome::done(Value::Null))
|
||||
}
|
||||
"ui.text" => {
|
||||
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome::done(Value::Null))
|
||||
}
|
||||
"ui.clipboard" => {
|
||||
// RequestPaste is a viewport command, not an event: the prompt/library input barrier
|
||||
// cannot inspect it after the fact. Refuse the command while either barrier owns input,
|
||||
// including a window created after this pass began resolving the gesture. Without
|
||||
// `text` this only queues RequestPaste in egui; it is not a host-dispatch or delivery
|
||||
// acknowledgement. egui 0.37 documents RequestPaste as asking the host to paste into
|
||||
// the focused text edit, with no completion or correlation id. egui-winit emits a later
|
||||
// Event::Paste only for non-empty text (or a PasteImage fallback; Wayland reads finish
|
||||
// later), so a barrier created after the request can drop that later event; the reply
|
||||
// below cannot promise dispatch or delivery. With `text`, the synthetic
|
||||
// Event::Paste is filtered and canceled like other injected input.
|
||||
let barrier = if crate::panels::notices::blocks_input(app) {
|
||||
Some("a modal prompt")
|
||||
} else if crate::panels::library_problem::blocks_dialog_input(app) {
|
||||
Some("the blocking library window")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(barrier) = barrier {
|
||||
return Err(format!("ui.clipboard: {barrier} is blocking background input"));
|
||||
}
|
||||
if !ctx.text_edit_focused() {
|
||||
return Err("ui.clipboard: no text field has the focus (click one first)".into());
|
||||
}
|
||||
match s("action") {
|
||||
Some("cut") => app.synthetic.push(egui::Event::Cut),
|
||||
Some("copy") => app.synthetic.push(egui::Event::Copy),
|
||||
Some("paste") => match s("text") {
|
||||
Some(text) => app.synthetic.push(egui::Event::Paste(text.to_string())),
|
||||
None => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
||||
},
|
||||
_ => return Err("ui.clipboard: `action` is cut, copy or paste".into()),
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome::done(Value::Null))
|
||||
}
|
||||
"ui.scroll" => {
|
||||
// Omitted keys keep the pre-0.37 request meaning; all other values are validated
|
||||
// before anything is queued.
|
||||
let source = match s("source") {
|
||||
None | Some("unknown") => egui::MouseWheelSource::Unknown,
|
||||
Some("wheel") => egui::MouseWheelSource::Wheel,
|
||||
Some("trackpad") => egui::MouseWheelSource::Trackpad,
|
||||
Some("momentum") => egui::MouseWheelSource::Momentum,
|
||||
Some(other) => return Err(format!("ui.scroll: `source` is wheel, trackpad, momentum or unknown, not {other:?}")),
|
||||
};
|
||||
let unit = match s("unit") {
|
||||
None | Some("point") => egui::MouseWheelUnit::Point,
|
||||
Some("line") => egui::MouseWheelUnit::Line,
|
||||
Some("page") => egui::MouseWheelUnit::Page,
|
||||
Some(other) => return Err(format!("ui.scroll: `unit` is point, line or page, not {other:?}")),
|
||||
};
|
||||
let phase = match s("phase") {
|
||||
None | Some("move") => egui::TouchPhase::Move,
|
||||
Some("start") => egui::TouchPhase::Start,
|
||||
Some("end") => egui::TouchPhase::End,
|
||||
Some("cancel") => egui::TouchPhase::Cancel,
|
||||
Some(other) => return Err(format!("ui.scroll: `phase` is start, move, end or cancel, not {other:?}")),
|
||||
};
|
||||
app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit,
|
||||
delta: egui::vec2(f("dx").unwrap_or(0.0) as f32, f("dy").unwrap_or(0.0) as f32),
|
||||
phase,
|
||||
source,
|
||||
modifiers: modifiers(p),
|
||||
});
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome::done(Value::Null))
|
||||
}
|
||||
"ui.zoom" => {
|
||||
let Some(factor) = f("factor").map(|v| v as f32).filter(|v| v.is_finite() && *v > 0.0) else {
|
||||
return Err("ui.zoom: factor must be a finite positive number (1 = unchanged)".into());
|
||||
};
|
||||
app.synthetic.push(egui::Event::Zoom(factor));
|
||||
ctx.request_repaint();
|
||||
Ok(GestureOutcome::done(Value::Null))
|
||||
}
|
||||
other => Err(format!("not a synthetic-input gesture: `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `image` as PNG and write it to the capture's pinned target, returning the control
|
||||
/// protocol's JSON result. The destination is revalidated before encoding; a new path is written
|
||||
/// with create-new semantics (never replacing a file that appeared), and a missing encoder or
|
||||
/// writer is an error. `None` only reports the image size without writing.
|
||||
pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, target: Option<&lightcraft_engine::originals::WriteTarget>) -> Value {
|
||||
let [w, h] = image.size;
|
||||
let Some(path) = path else {
|
||||
let Some(target) = target else {
|
||||
return json!({"ok": true, "result": {"width": w, "height": h}});
|
||||
};
|
||||
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||
let Some(img) = lightcraft_raster::Rgba8::from_bytes(w, h, &rgba) else {
|
||||
// Re-check the pinned destination immediately before encoding/writing. This is the point the
|
||||
// asynchronous renderer callback and the CPU fallback share.
|
||||
if let Err(error) = app.session.revalidate_write_target(target) {
|
||||
return json!({"ok": false, "error": error});
|
||||
}
|
||||
// Rgba8's buffer is the only extra full-image allocation while encoding (the PNG bytes are
|
||||
// the second transient; see MAX_SHOT_WORKING_SET_BYTES in `lib.rs`). Building it straight from
|
||||
// the `Color32` pixels avoids the former flat `Vec<u8>` copy of the same image.
|
||||
let Some(img) = lightcraft_raster::Rgba8::from_pixels(w, h, image.pixels.iter().map(|c| c.to_array())) else {
|
||||
return json!({"ok": false, "error": "bad screenshot buffer"});
|
||||
};
|
||||
let Some(png) = app.services.png.as_ref() else { return json!({"ok": false, "error": "no encoder"}) };
|
||||
let bytes = png(&img);
|
||||
match app.services.write.as_mut() {
|
||||
Some(wr) => match wr(path, &bytes) {
|
||||
let path = target.path();
|
||||
if target.was_absent() {
|
||||
// Publish a complete temp file with create-new semantics: a file that appeared after the
|
||||
// revalidation above is never replaced. Without this writer the capture fails closed.
|
||||
let Some(writer) = app.services.write_new.as_mut() else {
|
||||
return json!({"ok": false, "error": format!("{path}: no create-new writer configured for a new screenshot path")});
|
||||
};
|
||||
match writer(path, &bytes) {
|
||||
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
|
||||
Err(e) => json!({"ok": false, "error": e}),
|
||||
},
|
||||
None => json!({"ok": false, "error": "no writer configured"}),
|
||||
}
|
||||
} else {
|
||||
match app.services.write.as_mut() {
|
||||
Some(wr) => match wr(path, &bytes) {
|
||||
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
|
||||
Err(e) => json!({"ok": false, "error": e}),
|
||||
},
|
||||
None => json!({"ok": false, "error": "no writer configured"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ fn name_link(app: &mut LightcraftApp, ui: &mut egui::Ui, c: &Contributor, names:
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(crate::i18n::tr("Show"));
|
||||
@@ -240,7 +240,7 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(crate::i18n::tr("Sort"));
|
||||
crate::widgets::combo_box("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| {
|
||||
let sort = crate::widgets::combo_box("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() {
|
||||
v.key = k;
|
||||
@@ -248,20 +248,21 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::name_combo(&sort.response, crate::i18n::tr("Sort"));
|
||||
let reverse =
|
||||
crate::widgets::arrow_button(ui, "", if v.ascending { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, false);
|
||||
reverse.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Reverse the order")));
|
||||
reverse.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("Reverse the order")));
|
||||
if reverse.on_hover_text(crate::i18n::tr("Reverse the order")).clicked() {
|
||||
v.ascending = !v.ascending;
|
||||
}
|
||||
ui.separator();
|
||||
let r = ui.selectable_label(!v.table, crate::i18n::tr("Grab bag"));
|
||||
crate::widgets::register(ui.ctx(), "button:creditsGrabBag", r.rect);
|
||||
crate::widgets::register(ui, "button:creditsGrabBag", r.rect);
|
||||
if r.clicked() {
|
||||
v.table = false;
|
||||
}
|
||||
let r = ui.selectable_label(v.table, crate::i18n::tr("Table"));
|
||||
crate::widgets::register(ui.ctx(), "button:creditsTable", r.rect);
|
||||
crate::widgets::register(ui, "button:creditsTable", r.rect);
|
||||
if r.clicked() {
|
||||
v.table = true;
|
||||
}
|
||||
|
||||
@@ -238,8 +238,8 @@ impl<'a> DatePicker<'a> {
|
||||
pub fn show(self, ui: &mut Ui) -> Response {
|
||||
let DatePicker { id, value, precisions, min, max, today } = self;
|
||||
let mut button = crate::widgets::icon_button(ui, &format!("datePicker:{id}"), Icon::Calendar, vec2(24.0, 22.0), false, true, "Pick a date");
|
||||
register(ui.ctx(), format!("datePicker:{id}"), button.rect);
|
||||
let state = egui::Id::new(("datePicker", id.as_str()));
|
||||
register(ui, format!("datePicker:{id}"), button.rect);
|
||||
let state = egui::Id::unique(("datePicker", id.as_str()));
|
||||
if button.clicked() {
|
||||
// open on the value (or today), at its precision when it is one of those offered
|
||||
let at = value.or(today).unwrap_or(PickedDate::year(2026));
|
||||
@@ -257,7 +257,7 @@ impl<'a> DatePicker<'a> {
|
||||
ui.horizontal(|ui| {
|
||||
for p in precisions {
|
||||
let r = ui.selectable_label(view.precision == *p, crate::i18n::tr(p.label()));
|
||||
register(ui.ctx(), format!("datePickerPrecision:{}:{id}", p.id()), r.rect);
|
||||
register(ui, format!("datePickerPrecision:{}:{id}", p.id()), r.rect);
|
||||
if r.clicked() {
|
||||
view.precision = *p;
|
||||
}
|
||||
@@ -267,8 +267,8 @@ impl<'a> DatePicker<'a> {
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let prev = crate::widgets::arrow_button(ui, "", Icon::ChevronLeft, true);
|
||||
prev.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Previous page")));
|
||||
register(ui.ctx(), format!("datePickerPrev:{id}"), prev.rect);
|
||||
prev.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("Previous page")));
|
||||
register(ui, format!("datePickerPrev:{id}"), prev.rect);
|
||||
let title = match view.precision {
|
||||
Precision::Day => crate::i18n::date_group_label(&format!("{:04}-{:02}", view.year, view.month), false),
|
||||
Precision::Month => format!("{:04}", view.year),
|
||||
@@ -280,8 +280,8 @@ impl<'a> DatePicker<'a> {
|
||||
ui.label(RichText::new(title).strong());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let next = crate::widgets::arrow_button(ui, "", Icon::ChevronRight, false);
|
||||
next.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Next page")));
|
||||
register(ui.ctx(), format!("datePickerNext:{id}"), next.rect);
|
||||
next.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), crate::i18n::tr("Next page")));
|
||||
register(ui, format!("datePickerNext:{id}"), next.rect);
|
||||
if next.clicked() {
|
||||
view = step(view, true);
|
||||
}
|
||||
@@ -294,7 +294,7 @@ impl<'a> DatePicker<'a> {
|
||||
let allowed = in_bounds(&date, min.as_ref(), max.as_ref());
|
||||
let text = if holds_today(&date, today) { RichText::new(text).strong().underline() } else { RichText::new(text) };
|
||||
let r = ui.add_enabled(allowed, egui::Button::selectable(*value == Some(date), text).min_size(vec2(26.0, 0.0)));
|
||||
register(ui.ctx(), format!("datePickerCell:{}:{id}", date.iso()), r.rect);
|
||||
register(ui, format!("datePickerCell:{}:{id}", date.iso()), r.rect);
|
||||
if r.clicked() {
|
||||
*picked = Some(date);
|
||||
}
|
||||
|
||||
+437
-271
@@ -1,7 +1,10 @@
|
||||
//! Application panel identities and persistence around the shared docking renderer.
|
||||
//! Document contents remain owned by their existing view implementations.
|
||||
|
||||
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
|
||||
use craft_ui::docking::{
|
||||
Action, Column, ColumnLocation, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Group, GroupLocation, Layout, Location,
|
||||
Node, PanelLimits, PanelState, Permissions, Placement, Side, UiDock, Zone,
|
||||
};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -33,21 +36,41 @@ impl Panel {
|
||||
Self::Presets => "Presets",
|
||||
}
|
||||
}
|
||||
fn default_side(self) -> Zone {
|
||||
match self {
|
||||
Self::Canvas => Zone::Right,
|
||||
Self::Library => Zone::Left,
|
||||
Self::Tools => Zone::Right,
|
||||
Self::Presets => Zone::Right,
|
||||
}
|
||||
/// The dock the panel opens in when it has no recorded place.
|
||||
fn home_side(self) -> Side {
|
||||
if self == Self::Library { Side::Start } else { Side::End }
|
||||
}
|
||||
fn parse(id: &str) -> Result<Self, String> {
|
||||
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
/// The width of the panel's own column in the default workspace.
|
||||
fn home_width(self) -> f32 {
|
||||
match self {
|
||||
Self::Canvas => crate::state::MIN_PHOTO_WIDTH,
|
||||
Self::Library => crate::state::LEFT_WIDTH.default,
|
||||
Self::Tools => crate::state::RIGHT_WIDTH.default,
|
||||
Self::Presets => PRESETS_WIDTH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PRESETS_WIDTH: f32 = 300.0;
|
||||
|
||||
/// The arrangement generation saved as `UiState::docking_version`. Settings without it hold the
|
||||
/// side panels in split frames; they start again from [`default_workspace`].
|
||||
pub const DOCKING_VERSION: u32 = 1;
|
||||
|
||||
/// Drops saved settings' arrangement from before [`DOCKING_VERSION`], with the places of its
|
||||
/// closed panels, so the app starts from the default workspace; every other setting stays.
|
||||
/// Returns true when an arrangement was dropped.
|
||||
pub fn drop_outdated(settings: &mut serde_json::Map<String, Value>) -> bool {
|
||||
if settings.get("dockingVersion").and_then(Value::as_u64).is_some_and(|version| version >= u64::from(DOCKING_VERSION)) {
|
||||
return false;
|
||||
}
|
||||
settings.insert("dockingVersion".into(), json!(DOCKING_VERSION));
|
||||
settings.remove("docking").is_some_and(|docking| !docking.is_null())
|
||||
}
|
||||
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
// Panels open and split beside the photo view, but never join it as tabs.
|
||||
if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }
|
||||
}
|
||||
|
||||
fn tabs(panel: Panel) -> Node<Panel> {
|
||||
@@ -58,246 +81,162 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
/// The saved arrangement. Closed panels keep their last group, side, size or floating rect.
|
||||
pub type Workspace = craft_ui::docking::Workspace<Panel>;
|
||||
|
||||
fn column(panel: Panel, width: f32) -> Column<Panel> {
|
||||
Column::new(vec![Group::new(vec![panel], 0)], width)
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default),
|
||||
tabs(Panel::Library),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
|
||||
tabs(Panel::Tools),
|
||||
),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
}
|
||||
/// Library in a start dock and Tools in an end dock beside the photo. Presets has a column of
|
||||
/// its own on Tools' photo side rather than a group under Tools: both are tall lists that are
|
||||
/// read side by side while trying presets, and stacking them would halve both.
|
||||
fn docked(left: f32, right: f32) -> Workspace {
|
||||
let start = Node::Dock { side: Side::Start, columns: vec![column(Panel::Library, left)] };
|
||||
let end = Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, right), column(Panel::Presets, PRESETS_WIDTH)] };
|
||||
Workspace::new(Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(left),
|
||||
start,
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(right + PRESETS_WIDTH), tabs(Panel::Canvas), end),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_workspace() -> Workspace {
|
||||
docked(crate::state::LEFT_WIDTH.default, crate::state::RIGHT_WIDTH.default)
|
||||
}
|
||||
|
||||
/// Where a panel without a usable recorded place opens: its own column at its dock's window
|
||||
/// edge, or Presets on the photo side of Tools while Tools is in the end dock.
|
||||
fn home(workspace: &Workspace, panel: Panel) -> Location<Panel> {
|
||||
let column = ColumnLocation { side: panel.home_side(), width: panel.home_width(), iconic: false, iconic_width: None };
|
||||
let docked_at =
|
||||
|other: Panel| workspace.layout.location(&other).ok().and_then(|location| location.group).and_then(|group| group.column).map(|c| c.side);
|
||||
let beside = (panel == Panel::Presets && docked_at(Panel::Tools) == Some(Side::End)).then_some(Panel::Tools);
|
||||
Location {
|
||||
floating: None,
|
||||
anchor: beside,
|
||||
placement: if beside.is_some() { Placement::Column { after: true } } else { Placement::Tab { before: None } },
|
||||
size: None,
|
||||
stack: None,
|
||||
sibling: Vec::new(),
|
||||
group: Some(GroupLocation { minimized: false, height: None, column: Some(column) }),
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.layout.validate().map_err(|error| error.to_string())?;
|
||||
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
|
||||
return Err("The panel layout must contain its document view and bounded hidden panels".into());
|
||||
}
|
||||
// Saved settings cannot turn the protected document view into a floating or closable tab.
|
||||
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
let mut nodes: Vec<_> = self.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
|
||||
return Err("The document view cannot share a tab group".into());
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
|
||||
return Err("The document view cannot be collapsed".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (panel, location) in &self.hidden {
|
||||
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
|
||||
return Err("A hidden panel has an invalid or duplicate identity".into());
|
||||
}
|
||||
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
let mut probe = self.layout.clone();
|
||||
if let Err(error) = probe.restore(*panel, location)
|
||||
&& error != craft_ui::docking::DockError::MissingTarget
|
||||
{
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn error(error: DockError) -> String {
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
/// The shared checks plus LightCraft's own: saved settings cannot float, hide, share or
|
||||
/// collapse the photo view, nor send a hidden panel back onto it.
|
||||
pub fn validate(workspace: &Workspace) -> Result<(), String> {
|
||||
workspace.validate().map_err(error)?;
|
||||
let mut nodes: Vec<_> = workspace.layout.root.iter().collect();
|
||||
let mut alone = false;
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } => alone |= panels.as_slice() == [Panel::Canvas],
|
||||
_ => {}
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
if !alone {
|
||||
return Err("The document view must stay docked in a group of its own".into());
|
||||
}
|
||||
if workspace.hidden.iter().any(|(_, location)| {
|
||||
location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center))
|
||||
}) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
fn apply(workspace: &mut Workspace, action: Action<Panel>) -> Result<(), String> {
|
||||
let mut next = workspace.clone();
|
||||
next.apply(action, permissions).map_err(error)?;
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows a panel at its recorded place, else in its dock column ([`home`]). A panel that
|
||||
/// already has the asked visibility keeps its tab selection.
|
||||
fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if workspace.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return apply(workspace, Action::Close { panel });
|
||||
}
|
||||
validate(workspace)?;
|
||||
// A recorded place that can't be rebuilt falls back to joining the fallback's tabs. The
|
||||
// photo view takes no tabs, so that is refused and the panel goes to its home column below.
|
||||
if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) {
|
||||
let mut next = workspace.clone();
|
||||
if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() {
|
||||
*workspace = next;
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return self.apply(Action::Close { panel });
|
||||
}
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
next.hidden.retain(|(id, _)| *id != panel);
|
||||
if let Some(saved) = &mut location
|
||||
&& saved.floating.is_some()
|
||||
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
|
||||
{
|
||||
// The other members of a floating group may have been closed too.
|
||||
saved.anchor = None;
|
||||
saved.placement = Placement::Tab { before: None };
|
||||
}
|
||||
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
|
||||
if !restored {
|
||||
next.layout
|
||||
.apply_with_permissions(
|
||||
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
|
||||
permissions,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
let mut next = workspace.clone();
|
||||
next.hidden.retain(|(hidden, _)| *hidden != panel);
|
||||
let location = home(&next, panel);
|
||||
next.hidden.push((panel, location));
|
||||
next.set_visible(panel, true, Some(Panel::Canvas), permissions).map_err(error)?;
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Membership is passive state; an explicit command also exposes the panel's body.
|
||||
pub fn reveal(&mut self, panel: Panel) -> Result<(), String> {
|
||||
self.set_visible(panel, true)?;
|
||||
self.apply(Action::Activate { panel })
|
||||
/// Membership is passive state; an explicit command also exposes the panel's body.
|
||||
fn reveal(workspace: &mut Workspace, panel: Panel) -> Result<(), String> {
|
||||
set_visible(workspace, panel, true)?;
|
||||
apply(workspace, Action::Activate { panel })?;
|
||||
if workspace.layout.panel_state(&panel) != PanelState::Front {
|
||||
// A minimized window or group opens again; where nothing is minimized the action is
|
||||
// refused and the layout stays as it is.
|
||||
let _ = apply(workspace, Action::SetMinimized { panel, minimized: false });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exposed(&self, panel: Panel) -> bool {
|
||||
if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
|
||||
return true;
|
||||
}
|
||||
let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub fn exposed_in(workspace: &Workspace, panel: Panel) -> bool {
|
||||
workspace.layout.panel_state(&panel) == PanelState::Front
|
||||
}
|
||||
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => return self.reveal(panel),
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
/// `ui.dock` params, parsed by the shared command vocabulary.
|
||||
fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> {
|
||||
validate(workspace)?;
|
||||
let mut tagged = params.clone();
|
||||
let fields = tagged.as_object_mut().ok_or("ui.dock params must be an object")?;
|
||||
fields.insert("command".into(), json!("ui.dock"));
|
||||
let command: DockCommand<Panel> = serde_json::from_value(tagged).map_err(|error| error.to_string())?;
|
||||
if let DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel })) = command {
|
||||
return reveal(workspace, panel);
|
||||
}
|
||||
let mut next = workspace.clone();
|
||||
// `ui.dock` operations carry their own rects; only `window.panel.float` reads the space.
|
||||
command.apply(&mut next, false, &FloatSpace::new(egui::Rect::ZERO), permissions).map_err(error)?;
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn from_ui(app: &crate::LightcraftApp) -> Workspace {
|
||||
let mut workspace = Workspace::default();
|
||||
if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() {
|
||||
*size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width));
|
||||
if let Node::Split { size, .. } = second.as_mut() {
|
||||
*size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width));
|
||||
}
|
||||
}
|
||||
workspace
|
||||
docked(crate::state::LEFT_WIDTH.clamp(app.ui.left_width), crate::state::RIGHT_WIDTH.clamp(app.ui.right_width))
|
||||
}
|
||||
|
||||
fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> {
|
||||
workspace.set_visible(Panel::Library, app.ui.left_panel)?;
|
||||
workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?;
|
||||
workspace.set_visible(Panel::Presets, app.ui.presets)
|
||||
set_visible(workspace, Panel::Library, app.ui.left_panel)?;
|
||||
set_visible(workspace, Panel::Tools, app.ui.right != crate::state::RightPanel::None)?;
|
||||
set_visible(workspace, Panel::Presets, app.ui.presets)
|
||||
}
|
||||
|
||||
fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
|
||||
@@ -314,10 +253,12 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
|
||||
let Ok(location) = workspace.layout.location(&panel) else {
|
||||
continue;
|
||||
};
|
||||
let value = match (location.floating, location.placement, location.size) {
|
||||
(Some([_, _, width, _]), _, _)
|
||||
| (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width)))
|
||||
| (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width),
|
||||
let column = location.group.and_then(|group| group.column);
|
||||
let value = match (location.floating, column, location.placement, location.size) {
|
||||
(Some([_, _, width, _]), ..) => Some(width),
|
||||
(None, Some(column), ..) => Some(column.width),
|
||||
(None, None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width)))
|
||||
| (None, None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width),
|
||||
// A vertical split stores height, and the opposite fixed side sizes a sibling.
|
||||
_ => None,
|
||||
};
|
||||
@@ -328,7 +269,7 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
|
||||
}
|
||||
|
||||
pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool {
|
||||
app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel))
|
||||
app.ui.docking.as_ref().is_none_or(|workspace| exposed_in(workspace, panel))
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> {
|
||||
@@ -338,6 +279,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result
|
||||
// the sidebar it scrolls, also when it is a tab behind another panel
|
||||
Some(Panel::Library)
|
||||
} else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets"
|
||||
|| id == "view.rightPanel"
|
||||
|| id.starts_with("section.")
|
||||
|| id.starts_with("tool.") && id != "tool.none")
|
||||
&& app.ui.right != crate::state::RightPanel::None
|
||||
@@ -349,7 +291,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result
|
||||
if let Some(panel) = panel {
|
||||
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
|
||||
sync(&mut workspace, app)?;
|
||||
workspace.reveal(panel)?;
|
||||
reveal(&mut workspace, panel)?;
|
||||
cancel_replaced_gesture(app, Some(&workspace));
|
||||
app.ui.docking = Some(workspace);
|
||||
}
|
||||
@@ -370,7 +312,7 @@ pub(crate) fn cancel_replaced_gesture(app: &crate::LightcraftApp, workspace: Opt
|
||||
_ => true,
|
||||
};
|
||||
if replaced && let Some(ctx) = &app.tasks.repaint {
|
||||
craft_ui::docking::cancel_drag::<Panel>(ctx, egui::Id::new("lightcraft-docking"));
|
||||
craft_ui::docking::cancel_drag::<Panel>(ctx, egui::Id::unique("lightcraft-docking"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,13 +323,13 @@ pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> R
|
||||
fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result<Value, String> {
|
||||
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
|
||||
if reset {
|
||||
workspace = Workspace::default();
|
||||
workspace = default_workspace();
|
||||
// The usual LightCraft starting workspace shows its current tool beside the photo.
|
||||
workspace.set_visible(Panel::Library, false)?;
|
||||
workspace.set_visible(Panel::Presets, false)?;
|
||||
set_visible(&mut workspace, Panel::Library, false)?;
|
||||
set_visible(&mut workspace, Panel::Presets, false)?;
|
||||
} else {
|
||||
sync(&mut workspace, app)?;
|
||||
workspace.command(params)?;
|
||||
run(&mut workspace, params)?;
|
||||
}
|
||||
if !renderer_action {
|
||||
cancel_replaced_gesture(app, Some(&workspace));
|
||||
@@ -397,11 +339,15 @@ fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, ren
|
||||
Ok(json!({"docking": app.ui.docking}))
|
||||
}
|
||||
|
||||
/// Draw the docked workspace: restore or fall back from an invalid layout, sync panel
|
||||
/// visibility, render the panels and the current view in the canvas, then apply the docking
|
||||
/// actions. If the layout changed during the pass, any in-flight dock drag is cancelled and the
|
||||
/// docking actions it produced are not applied.
|
||||
pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
|
||||
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
|
||||
app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), ui.input(|input| input.time) + 8.0, None));
|
||||
if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) {
|
||||
app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), app.tick_now() + 8.0, None));
|
||||
workspace = from_ui(app);
|
||||
let _ = sync(&mut workspace, app);
|
||||
}
|
||||
@@ -411,12 +357,32 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
app.ui.docking = Some(workspace.clone());
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
style.min_pane = 96.0;
|
||||
// Free docking: a panel dropped at an empty window edge makes a dock, and the photo takes
|
||||
// drops on all four sides.
|
||||
style.new_dock_zones = true;
|
||||
style.side_edge_zones = false;
|
||||
style.float_label = crate::i18n::tr("Float panel").into();
|
||||
style.close_label = crate::i18n::tr("Close panel").into();
|
||||
style.move_label = crate::i18n::tr("Move to group").into();
|
||||
style.move_panel_to_label = crate::i18n::tr("Move to group").into();
|
||||
style.panels_label = crate::i18n::tr("Panels").into();
|
||||
style.resize_label = crate::i18n::tr("Resize panels").into();
|
||||
style.resize_window_label = crate::i18n::tr("Resize panel window").into();
|
||||
style.move_group_label = crate::i18n::tr("Move tab group").into();
|
||||
style.move_group_to_label = crate::i18n::tr("Move group to").into();
|
||||
style.float_group_label = crate::i18n::tr("Float tab group").into();
|
||||
style.close_group_label = crate::i18n::tr("Close tab group").into();
|
||||
style.close_others_label = crate::i18n::tr("Close other panels in group").into();
|
||||
style.minimize_label = crate::i18n::tr("Minimize").into();
|
||||
style.expand_label = crate::i18n::tr("Expand").into();
|
||||
style.move_column_label = crate::i18n::tr("Move panel column").into();
|
||||
style.collapse_to_icons_label = crate::i18n::tr("Collapse to Icons").into();
|
||||
style.expand_panels_label = crate::i18n::tr("Expand Panels").into();
|
||||
style.collapse_column_label = crate::i18n::tr("Collapse column").into();
|
||||
style.expand_column_label = crate::i18n::tr("Expand column").into();
|
||||
style.auto_collapse_label = crate::i18n::tr("Auto-Collapse Iconic Panels").into();
|
||||
style.move_window_label = crate::i18n::tr("Move panel window").into();
|
||||
style.close_window_label = crate::i18n::tr("Close panel window").into();
|
||||
style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into();
|
||||
style.background = t.chrome;
|
||||
style.tab_background = t.chrome;
|
||||
style.active_background = t.canvas;
|
||||
@@ -427,7 +393,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
style.font = t.font(12.0);
|
||||
// Tool and preset panels publish hover previews before the photo consumes them.
|
||||
let mut canvas = None;
|
||||
let output = DockArea::new(egui::Id::new("lightcraft-docking")).show_with_limits(
|
||||
let output = DockArea::new(egui::Id::unique("lightcraft-docking")).locked(workspace.locked).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
@@ -439,11 +405,11 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect())));
|
||||
}
|
||||
Panel::Library => {
|
||||
crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect());
|
||||
crate::widgets::register(ui, "panel:left_panel", ui.max_rect());
|
||||
crate::panels::left::show(app, ui);
|
||||
}
|
||||
Panel::Tools => {
|
||||
crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect());
|
||||
crate::widgets::register(ui, "panel:right_panel", ui.max_rect());
|
||||
crate::panels::right::show(app, ui);
|
||||
}
|
||||
Panel::Presets => crate::panels::presets::show(app, ui),
|
||||
@@ -476,7 +442,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
let _ = sync(&mut workspace, app);
|
||||
let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden;
|
||||
if stale {
|
||||
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("lightcraft-docking"));
|
||||
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::unique("lightcraft-docking"));
|
||||
}
|
||||
publish_visibility(app, &workspace);
|
||||
app.ui.docking = Some(workspace);
|
||||
@@ -488,19 +454,20 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|action| command_impl(app, false, &json!({"action": action}), true))
|
||||
{
|
||||
app.ui.toast = Some((format!("Panel move was not applied: {error}"), ui.input(|input| input.time) + 8.0, None));
|
||||
app.ui.toast = Some((format!("Panel move was not applied: {error}"), app.tick_now() + 8.0, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the established ui.set width controls connected to the panel's current parent split.
|
||||
/// Keep the established ui.set width controls connected to the panel's dock column, floating
|
||||
/// window or parent split.
|
||||
pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> {
|
||||
state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width);
|
||||
state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width);
|
||||
let Some(workspace) = state.docking.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
workspace.validate()?;
|
||||
validate(workspace)?;
|
||||
for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] {
|
||||
if params.get(key).is_none() {
|
||||
continue;
|
||||
@@ -509,6 +476,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
if let Some(rect) = &mut location.floating {
|
||||
rect[2] = width;
|
||||
}
|
||||
if let Some(column) = location.group.as_mut().and_then(|group| group.column.as_mut()) {
|
||||
column.width = width;
|
||||
continue;
|
||||
}
|
||||
match location.placement {
|
||||
Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)),
|
||||
Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)),
|
||||
@@ -516,10 +487,14 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) {
|
||||
if let Some(group) = workspace.layout.floating.iter().find(|group| group.contains(&panel)) {
|
||||
let mut rect = group.rect;
|
||||
rect[2] = width;
|
||||
workspace.apply(Action::MoveFloating { panel, rect })?;
|
||||
apply(workspace, Action::MoveFloating { panel, rect })?;
|
||||
continue;
|
||||
}
|
||||
if workspace.layout.location(&panel).is_ok_and(|location| location.group.is_some_and(|group| group.column.is_some())) {
|
||||
apply(workspace, Action::ResizeColumn { panel, width })?;
|
||||
continue;
|
||||
}
|
||||
let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default();
|
||||
@@ -529,7 +504,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
let contains = |node: &Node<Panel>| match node {
|
||||
Node::Tabs { panels, .. } => panels.contains(&panel),
|
||||
Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel),
|
||||
Node::Split { .. } => false,
|
||||
_ => false,
|
||||
};
|
||||
if *axis == SplitAxis::Horizontal {
|
||||
if contains(first) {
|
||||
@@ -548,7 +523,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
|
||||
}
|
||||
}
|
||||
if let Some(action) = change {
|
||||
workspace.apply(action)?;
|
||||
apply(workspace, action)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -564,6 +539,23 @@ fn panel_limits(panel: &Panel) -> PanelLimits {
|
||||
PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
|
||||
}
|
||||
|
||||
/// The columns of the dock on `side`, from the window edge inwards: width and the panels of
|
||||
/// each group.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn columns(workspace: &Workspace, side: Side) -> Vec<(f32, Vec<Vec<Panel>>)> {
|
||||
let mut nodes: Vec<_> = workspace.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
|
||||
Node::Dock { side: dock, columns } if *dock == side => {
|
||||
return columns.iter().map(|column| (column.width, column.groups.iter().map(|group| group.panels.clone()).collect())).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -583,7 +575,7 @@ mod tests {
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
|
||||
);
|
||||
let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) };
|
||||
Workspace { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() }
|
||||
Workspace::new(Layout { root: Some(root), floating: Vec::new() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -594,23 +586,20 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) })
|
||||
.collect();
|
||||
app.ui.docking = Some(Workspace {
|
||||
layout: Layout {
|
||||
root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
});
|
||||
app.ui.docking = Some(Workspace::new(Layout {
|
||||
root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })),
|
||||
floating: Vec::new(),
|
||||
}));
|
||||
let undo = app.session.undo.len();
|
||||
app.run("panel.crop", json!({})).unwrap();
|
||||
assert_eq!(app.ui.right, crate::state::RightPanel::Crop);
|
||||
let workspace = app.ui.docking.as_ref().unwrap();
|
||||
assert!(workspace.exposed(Panel::Tools));
|
||||
assert!(exposed_in(workspace, Panel::Tools));
|
||||
assert_eq!(app.session.undo.len(), undo);
|
||||
let saved = serde_json::to_string(workspace).unwrap();
|
||||
let reloaded: Workspace = serde_json::from_str(&saved).unwrap();
|
||||
reloaded.validate().unwrap();
|
||||
assert!(reloaded.exposed(Panel::Tools));
|
||||
validate(&reloaded).unwrap();
|
||||
assert!(exposed_in(&reloaded, Panel::Tools));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -622,7 +611,7 @@ mod tests {
|
||||
(SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false),
|
||||
] {
|
||||
let workspace = tools_split(axis, size, first);
|
||||
workspace.validate().unwrap();
|
||||
validate(&workspace).unwrap();
|
||||
let mut app = app_with_widths();
|
||||
publish_visibility(&mut app, &workspace);
|
||||
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}");
|
||||
@@ -630,15 +619,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_width_publication_reads_own_horizontal_and_floating_widths() {
|
||||
fn legacy_width_publication_reads_own_split_and_floating_widths() {
|
||||
for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] {
|
||||
let workspace = tools_split(SplitAxis::Horizontal, size, first);
|
||||
let mut app = app_with_widths();
|
||||
publish_visibility(&mut app, &workspace);
|
||||
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0));
|
||||
}
|
||||
let mut workspace = Workspace::default();
|
||||
workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
|
||||
let mut workspace = default_workspace();
|
||||
apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
|
||||
let mut app = app_with_widths();
|
||||
publish_visibility(&mut app, &workspace);
|
||||
assert_eq!(app.ui.right_width, 390.0);
|
||||
@@ -646,12 +635,189 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn legacy_width_update_preserves_hidden_top_panel_height() {
|
||||
let mut workspace = Workspace::default();
|
||||
workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap();
|
||||
workspace.set_visible(Panel::Tools, false).unwrap();
|
||||
let mut workspace = default_workspace();
|
||||
apply(&mut workspace, Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap();
|
||||
set_visible(&mut workspace, Panel::Tools, false).unwrap();
|
||||
let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size;
|
||||
let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() };
|
||||
set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap();
|
||||
assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height);
|
||||
}
|
||||
|
||||
/// Settings written before workspaces had a version: `{layout, hidden}`, a floating
|
||||
/// window as one group, hidden locations without dock state.
|
||||
#[test]
|
||||
fn version_one_workspace_file_loads_and_reopens_its_hidden_panel() {
|
||||
let v1 = json!({
|
||||
"layout": {
|
||||
"root": {"Split": {
|
||||
"axis": "Horizontal",
|
||||
"size": {"FixedFirst": 300.0},
|
||||
"first": {"Tabs": {"panels": ["library"], "active": 0}},
|
||||
"second": {"Tabs": {"panels": ["canvas"], "active": 0}}
|
||||
}},
|
||||
"floating": [{"panels": ["tools"], "active": 0, "rect": [120.0, 90.0, 320.0, 600.0]}]
|
||||
},
|
||||
"hidden": [["presets", {
|
||||
"floating": null,
|
||||
"anchor": "canvas",
|
||||
"placement": {"Split": "Right"},
|
||||
"size": {"FixedSecond": 280.0},
|
||||
"stack": null,
|
||||
"sibling": ["canvas"]
|
||||
}]]
|
||||
});
|
||||
let mut workspace: Workspace = serde_json::from_value(v1).unwrap();
|
||||
validate(&workspace).unwrap();
|
||||
assert_eq!(workspace.version, 1);
|
||||
let window = workspace.layout.floating.first().unwrap();
|
||||
assert!(window.contains(&Panel::Tools));
|
||||
assert_eq!(window.rect, [120.0, 90.0, 320.0, 600.0]);
|
||||
assert!(exposed_in(&workspace, Panel::Library));
|
||||
set_visible(&mut workspace, Panel::Presets, true).unwrap();
|
||||
let location = workspace.layout.location(&Panel::Presets).unwrap();
|
||||
assert_eq!((location.anchor, location.size), (Some(Panel::Canvas), Some(SplitSize::FixedSecond(280.0))));
|
||||
let saved = serde_json::to_value(&workspace).unwrap();
|
||||
assert_eq!(saved["version"], 2);
|
||||
assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_workspace_docks_library_at_the_start_and_tools_and_presets_at_the_end() {
|
||||
let workspace = default_workspace();
|
||||
validate(&workspace).unwrap();
|
||||
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
|
||||
assert_eq!(
|
||||
columns(&workspace, Side::End),
|
||||
[(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])],
|
||||
"Presets has its own column on Tools' photo side"
|
||||
);
|
||||
let Some(Node::Split { second, .. }) = &workspace.layout.root else { panic!("{:?}", workspace.layout.root) };
|
||||
let Node::Split { first, .. } = &**second else { panic!("{second:?}") };
|
||||
assert_eq!(**first, tabs(Panel::Canvas), "the photo sits between the docks");
|
||||
assert!(workspace.layout.floating.is_empty());
|
||||
let app = app_with_widths();
|
||||
let workspace = from_ui(&app);
|
||||
assert_eq!(columns(&workspace, Side::Start).first().map(|column| column.0), Some(333.0));
|
||||
assert_eq!(columns(&workspace, Side::End).first().map(|column| column.0), Some(410.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_column_widths_publish_and_follow_width_controls() {
|
||||
let mut app = app_with_widths();
|
||||
let workspace = docked(300.0, 310.0);
|
||||
publish_visibility(&mut app, &workspace);
|
||||
assert_eq!((app.ui.left_width, app.ui.right_width), (300.0, 310.0));
|
||||
let mut state = crate::UiState { docking: Some(workspace), left_width: 350.0, right_width: 400.0, ..Default::default() };
|
||||
set_visible(state.docking.as_mut().unwrap(), Panel::Library, false).unwrap();
|
||||
set_widths(&mut state, &json!({"leftWidth":350.0, "rightWidth":400.0})).unwrap();
|
||||
let workspace = state.docking.as_mut().unwrap();
|
||||
assert_eq!(columns(workspace, Side::End).first().map(|column| column.0), Some(400.0), "Tools' column");
|
||||
set_visible(workspace, Panel::Library, true).unwrap();
|
||||
assert_eq!(columns(workspace, Side::Start), [(350.0, vec![vec![Panel::Library]])], "the closed Library's column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_without_a_recorded_place_opens_in_its_dock() {
|
||||
let root = split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(300.0),
|
||||
tabs(Panel::Canvas),
|
||||
Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, 300.0)] },
|
||||
);
|
||||
let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() });
|
||||
set_visible(&mut workspace, Panel::Presets, true).unwrap();
|
||||
set_visible(&mut workspace, Panel::Library, true).unwrap();
|
||||
validate(&workspace).unwrap();
|
||||
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
|
||||
assert_eq!(columns(&workspace, Side::End), [(300.0, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]);
|
||||
// Without Tools in the end dock, Presets takes the dock's window edge.
|
||||
let mut workspace = default_workspace();
|
||||
set_visible(&mut workspace, Panel::Presets, false).unwrap();
|
||||
workspace.hidden.clear();
|
||||
apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
|
||||
set_visible(&mut workspace, Panel::Presets, true).unwrap();
|
||||
assert_eq!(columns(&workspace, Side::End), [(PRESETS_WIDTH, vec![vec![Panel::Presets]])]);
|
||||
}
|
||||
|
||||
fn saved_settings(docking: Value, version: Option<u32>) -> serde_json::Map<String, Value> {
|
||||
let ui = crate::UiState { thumb_size: 210.0, filmstrip: false, presets: true, ..Default::default() };
|
||||
let mut saved = serde_json::to_value(&ui).unwrap();
|
||||
let fields = saved.as_object_mut().unwrap();
|
||||
fields.insert("docking".into(), docking);
|
||||
match version {
|
||||
Some(version) => fields.insert("dockingVersion".into(), json!(version)),
|
||||
None => fields.remove("dockingVersion"),
|
||||
};
|
||||
fields.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_split_frame_layout_without_a_docking_version_loads_as_the_default_docks() {
|
||||
let frames = split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(333.0),
|
||||
tabs(Panel::Library),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(410.0), tabs(Panel::Canvas), tabs(Panel::Tools)),
|
||||
);
|
||||
let frames = Workspace::new(Layout { root: Some(frames), floating: Vec::new() });
|
||||
validate(&frames).unwrap();
|
||||
for version in [None, Some(0)] {
|
||||
let mut fields = saved_settings(serde_json::to_value(&frames).unwrap(), version);
|
||||
assert!(drop_outdated(&mut fields));
|
||||
let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap();
|
||||
assert!(ui.docking.is_none());
|
||||
assert_eq!(ui.docking_version, DOCKING_VERSION);
|
||||
assert_eq!((ui.thumb_size, ui.filmstrip, ui.presets), (210.0, false, true), "other settings stay");
|
||||
let mut app = app_with_widths();
|
||||
app.ui = ui;
|
||||
command(&mut app, false, &json!({"operation":"activate","panel":"tools"})).unwrap();
|
||||
let workspace = app.ui.docking.as_ref().unwrap();
|
||||
assert!(!workspace.layout.contains(&Panel::Library), "the Library panel stays closed");
|
||||
assert_eq!(
|
||||
columns(workspace, Side::End),
|
||||
[(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_dock_layout_round_trips() {
|
||||
let mut workspace = default_workspace();
|
||||
apply(&mut workspace, Action::ResizeColumn { panel: Panel::Tools, width: 333.0 }).unwrap();
|
||||
apply(
|
||||
&mut workspace,
|
||||
Action::Move { panel: Panel::Presets, anchor: Panel::Library, placement: Placement::Group { below: true, height: None } },
|
||||
)
|
||||
.unwrap();
|
||||
set_visible(&mut workspace, Panel::Library, false).unwrap();
|
||||
let mut fields = saved_settings(serde_json::to_value(&workspace).unwrap(), Some(DOCKING_VERSION));
|
||||
assert!(!drop_outdated(&mut fields));
|
||||
let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap();
|
||||
let loaded = ui.docking.unwrap();
|
||||
validate(&loaded).unwrap();
|
||||
assert_eq!(loaded, workspace);
|
||||
assert_eq!(serde_json::to_value(crate::UiState::default()).unwrap()["dockingVersion"], DOCKING_VERSION, "saves write the version");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_whose_saved_place_is_gone_opens_in_its_dock() {
|
||||
let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools));
|
||||
let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() });
|
||||
// Presets, the panel Library went back beside, is neither shown nor hidden.
|
||||
let location: craft_ui::docking::Location<Panel> = serde_json::from_value(json!({
|
||||
"floating": null,
|
||||
"anchor": "presets",
|
||||
"placement": {"Split": "Left"},
|
||||
"size": {"FixedFirst": 280.0},
|
||||
"stack": null,
|
||||
"sibling": ["presets"]
|
||||
}))
|
||||
.unwrap();
|
||||
workspace.hidden.push((Panel::Library, location));
|
||||
validate(&workspace).unwrap();
|
||||
set_visible(&mut workspace, Panel::Library, true).unwrap();
|
||||
validate(&workspace).unwrap();
|
||||
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ fn lines(app: &LightcraftApp, paths: Vec<String>, unknown: bool) -> Vec<String>
|
||||
|
||||
/// Veil and card while files are dragged over the window.
|
||||
pub fn show(app: &LightcraftApp, ctx: &Context) {
|
||||
// This card is an `Order::Tooltip` layer, above the prompt's Foreground window. While a prompt
|
||||
// owns input the drag can only be background noise: do not paint over or register it.
|
||||
if crate::panels::notices::blocks_input(app) {
|
||||
return;
|
||||
}
|
||||
let count = ctx.input(|i| i.raw.hovered_files.len());
|
||||
if count == 0 {
|
||||
return;
|
||||
@@ -79,7 +84,7 @@ pub fn show(app: &LightcraftApp, ctx: &Context) {
|
||||
let lines = lines(app, known.clone(), known.len() < count);
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(Order::Tooltip, egui::Id::new("drop-overlay")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(Order::Tooltip, egui::Id::unique("drop-overlay")));
|
||||
painter.rect_filled(screen, 0.0, Color32::from_black_alpha(150));
|
||||
painter.rect_stroke(screen.shrink(10.0), 12.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
let galleys: Vec<_> = lines.into_iter().map(|l| painter.layout(l, t.semibold(18.0), t.text, (screen.width() - 120.0).max(200.0))).collect();
|
||||
@@ -94,6 +99,6 @@ pub fn show(app: &LightcraftApp, ctx: &Context) {
|
||||
painter.galley(Align2::CENTER_TOP.anchor_size(at, g.size()).min, g, t.text);
|
||||
y += height + 8.0;
|
||||
}
|
||||
crate::widgets::register(ctx, "dropOverlay", card);
|
||||
crate::widgets::register_geometry(ctx, "dropOverlay", card);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ use crate::LightcraftApp;
|
||||
const BUSY_SECS: f64 = 0.25;
|
||||
|
||||
/// Called every frame. Idle, it costs a comparison and wakes nothing; reading, it asks again every [`BUSY_SECS`].
|
||||
pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) {
|
||||
if now < app.caches.focus_next_pump {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ struct Hud {
|
||||
logged: Option<f64>,
|
||||
}
|
||||
|
||||
/// Stable egui data id under which the FPS HUD's per-frame state is stored.
|
||||
fn hud_id() -> egui::Id {
|
||||
egui::Id::new("fps-hud-state")
|
||||
egui::Id::unique("fps-hud-state")
|
||||
}
|
||||
|
||||
/// The display is off: drop its state, so the time it was off never shows as one long frame.
|
||||
@@ -146,7 +147,7 @@ pub fn show(ctx: &egui::Context, app: &LightcraftApp) {
|
||||
|
||||
let screen = ctx.content_rect();
|
||||
let width = 300.0;
|
||||
egui::Area::new(egui::Id::new("fps-hud"))
|
||||
egui::Area::new(egui::Id::unique("fps-hud"))
|
||||
.order(egui::Order::Tooltip)
|
||||
.fixed_pos(egui::pos2(screen.right() - width - 24.0, screen.top() + 56.0))
|
||||
.interactable(false)
|
||||
|
||||
+485
-19
@@ -13,7 +13,6 @@
|
||||
//! is occluded.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -36,6 +35,10 @@ pub fn viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize;
|
||||
native_viewport_pixels(size, pixels_per_point)
|
||||
}
|
||||
|
||||
/// Turn a logical viewport size into native pixels for the headless renderer. Both edges and
|
||||
/// the scale must be finite and positive; each scaled edge is rounded like egui's scissor and
|
||||
/// must be within `1..=MAX_IMAGE_EDGE`, and the product must fit both the raster and screenshot
|
||||
/// pixel budgets. Returns a user-facing error instead of clamping.
|
||||
fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> {
|
||||
let [width, height] = size;
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
|
||||
@@ -50,8 +53,11 @@ fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usiz
|
||||
Ok(pixels as usize)
|
||||
};
|
||||
let (w, h) = (axis(width)?, axis(height)?);
|
||||
if w.checked_mul(h).is_none_or(|pixels| pixels > softpaint::MAX_IMAGE_PIXELS) {
|
||||
return Err(format!("scaled viewport exceeds {} pixels", softpaint::MAX_IMAGE_PIXELS));
|
||||
// The rasterizer's limit is not enough: a screenshot is also bounded by the RGBA budget it is
|
||||
// admitted against, so reject a size `ui.screenshot` would only refuse later.
|
||||
let max_pixels = softpaint::MAX_IMAGE_PIXELS.min(crate::MAX_SHOT_PIXELS);
|
||||
if w.checked_mul(h).is_none_or(|pixels| pixels > max_pixels) {
|
||||
return Err(format!("scaled viewport exceeds {max_pixels} pixels"));
|
||||
}
|
||||
Ok([w, h])
|
||||
}
|
||||
@@ -156,7 +162,20 @@ impl HeadlessView {
|
||||
self.clipboard.clone_from(text);
|
||||
}
|
||||
}
|
||||
let commands = out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||||
let mut commands = out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||||
// 0.37: `ViewportCommand::Screenshot` carries a one-shot callback. A headless host answers
|
||||
// it here, after the pass it was issued in, exactly once.
|
||||
let mut shots = Vec::new();
|
||||
commands.retain(|command| match command {
|
||||
ViewportCommand::Screenshot(callback) => {
|
||||
shots.push(callback.clone());
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
for callback in shots {
|
||||
callback.complete(std::sync::Arc::new(self.paint(&std::collections::HashMap::new())));
|
||||
}
|
||||
// what a desktop host does with a cut, copy or paste request (egui-winit): the event, next frame
|
||||
for c in &commands {
|
||||
match c {
|
||||
@@ -246,6 +265,10 @@ pub struct Headless {
|
||||
pub window_commands: Vec<ViewportCommand>,
|
||||
/// The pointer cursor the last frame asked for (what a real host would show).
|
||||
pub last_cursor: egui::CursorIcon,
|
||||
/// Test-only hook run after `raw_input_hook` and before `logic`/`ui`, as eframe does: it can
|
||||
/// create a prompt after the host input was already filtered for the previous frame.
|
||||
#[cfg(test)]
|
||||
after_raw_input_hook: Option<Box<dyn FnMut(&mut LightcraftApp)>>,
|
||||
}
|
||||
|
||||
/// A headless run (`lightcraft-cli snapshot`, tests) is a one-shot process: however it ends, it
|
||||
@@ -286,9 +309,19 @@ impl Headless {
|
||||
window_maximized: false,
|
||||
window_commands: vec![],
|
||||
last_cursor: egui::CursorIcon::Default,
|
||||
#[cfg(test)]
|
||||
after_raw_input_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `hook` once, between the next frame's [`LightcraftApp::raw_input_hook`] and its
|
||||
/// `logic`/`ui` call. This is the production order in eframe: a prompt can be created there
|
||||
/// after the host input was already accepted for the frame.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_after_raw_input_hook(&mut self, hook: impl FnMut(&mut LightcraftApp) + 'static) {
|
||||
self.after_raw_input_hook = Some(Box::new(hook));
|
||||
}
|
||||
|
||||
pub fn frames(&self) -> u64 {
|
||||
self.frames
|
||||
}
|
||||
@@ -315,7 +348,13 @@ impl Headless {
|
||||
raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized);
|
||||
raw.max_texture_side = (self.reported_texture_side != Some(self.max_texture_side)).then_some(self.max_texture_side);
|
||||
self.reported_texture_side = Some(self.max_texture_side);
|
||||
self.app.raw_input_hook(&mut raw);
|
||||
// A headless run's timers follow its simulated clock, not the host's wall clock.
|
||||
self.app.set_simulated_clock(Some(self.time));
|
||||
self.app.raw_input_hook(&self.view.ctx, &mut raw);
|
||||
#[cfg(test)]
|
||||
if let Some(mut hook) = self.after_raw_input_hook.take() {
|
||||
hook(&mut self.app);
|
||||
}
|
||||
let app = &mut self.app;
|
||||
let mut cursor = egui::CursorIcon::Default;
|
||||
let commands = self.view.run(raw, |ui| {
|
||||
@@ -327,11 +366,10 @@ impl Headless {
|
||||
self.time += FRAME_DT;
|
||||
self.frames += 1;
|
||||
for c in commands {
|
||||
// `ViewportCommand::Screenshot` never reaches here: `HeadlessView::run` answers the
|
||||
// one-shot callback after the pass it was issued in, so there is exactly one reply.
|
||||
match c {
|
||||
ViewportCommand::Screenshot(user_data) => {
|
||||
let image = Arc::new(self.paint());
|
||||
self.events.push(egui::Event::Screenshot { viewport_id: ViewportId::ROOT, user_data, image });
|
||||
}
|
||||
ViewportCommand::Screenshot(_) => {}
|
||||
ViewportCommand::InnerSize(size) => {
|
||||
// Viewport commands use current egui points, which include UI zoom.
|
||||
if let Ok(native_size) = resized_viewport([size.x, size.y], self.pixels_per_point, self.view.ctx.zoom_factor()) {
|
||||
@@ -364,6 +402,7 @@ impl Headless {
|
||||
|| self.app.session.denoise_busy()
|
||||
|| !self.app.tasks.is_empty()
|
||||
|| !self.app.synthetic.is_empty()
|
||||
|| !self.app.pending_gestures.is_empty()
|
||||
|| !self.events.is_empty()
|
||||
}
|
||||
|
||||
@@ -599,6 +638,7 @@ mod tests {
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The pretend host has a clipboard, as a desktop does: text the UI copies lands in it, and a
|
||||
/// cut, copy or paste request (from a context menu) comes back as that event on the next frame.
|
||||
@@ -630,7 +670,9 @@ mod tests {
|
||||
fn screenshot_dimensions_are_checked_before_layout() {
|
||||
assert_eq!(viewport_pixels([1600.0, 1000.0], 2.0).unwrap(), [3200, 2000]);
|
||||
assert_eq!(viewport_pixels([345.0, 200.0], 0.9).unwrap(), [311, 180]);
|
||||
assert_eq!(viewport_pixels([8000.0, 8000.0], 1.0).unwrap(), [8000, 8000]);
|
||||
// The largest accepted surface is exactly the 128 MiB screenshot budget (32 Mi pixels).
|
||||
assert_eq!(viewport_pixels([16384.0, 2048.0], 1.0).unwrap(), [16384, 2048]);
|
||||
assert_eq!(viewport_pixels([5792.0, 5792.0], 1.0).unwrap(), [5792, 5792]);
|
||||
for (size, scale) in [
|
||||
([f32::NAN, 10.0], 1.0),
|
||||
([10.0, f32::INFINITY], 1.0),
|
||||
@@ -639,6 +681,9 @@ mod tests {
|
||||
([10.0, 10.0], 0.0),
|
||||
([0.0, 10.0], 1.0),
|
||||
([10.0, 10.0], 0.01),
|
||||
([16384.0, 2049.0], 1.0),
|
||||
([8000.0, 8000.0], 1.0),
|
||||
([5793.0, 5793.0], 1.0),
|
||||
([9000.0, 9000.0], 1.0),
|
||||
([9000.0, 100.0], 2.0),
|
||||
] {
|
||||
@@ -851,7 +896,7 @@ mod tests {
|
||||
let (Some(a), Some(b)) = (thumbs.first().cloned(), thumbs.get(1).cloned()) else { panic!("two grid photos: {thumbs:?}") };
|
||||
let popup = |w: &str| {
|
||||
let id: u64 = w.trim_start_matches("thumb:").parse().expect("a photo id");
|
||||
egui::Id::new(("cell", id)).with("popup")
|
||||
egui::Id::unique(("cell", id)).with("popup")
|
||||
};
|
||||
assert!(!egui::Popup::is_any_open(&h.view.ctx));
|
||||
let r = h.request("ui.clickWidget", json!({"id": a, "button": "right"}), t);
|
||||
@@ -1524,7 +1569,7 @@ mod tests {
|
||||
assert_eq!(names(&h), vec![Some("Jane Doe".into()), None, None, None, None, None, Some("John Roe".into())]);
|
||||
// With no target A cannot name a face, even on this page.
|
||||
h.request("ui.move", json!({"x": 1, "y": 1}), t);
|
||||
h.view.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new(("more-face-above", ids[2].0, 0_usize))));
|
||||
h.view.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique(("more-face-above", ids[2].0, 0_usize))));
|
||||
set_page(&mut h);
|
||||
h.request("ui.key", json!({"key": "a"}), t);
|
||||
h.step();
|
||||
@@ -1552,7 +1597,7 @@ mod tests {
|
||||
h.request("ui.move", json!({"x": 1, "y": 1}), t);
|
||||
set_page(&mut h);
|
||||
// Keyboard focus on the candidate makes the same batch action available without hovering it.
|
||||
h.view.ctx.memory_mut(|m| m.request_focus(egui::Id::new(("more-face", ids[2].0, 0_usize))));
|
||||
h.view.ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("more-face", ids[2].0, 0_usize))));
|
||||
h.request("ui.key", json!({"key": "a"}), t);
|
||||
h.step();
|
||||
assert_eq!(
|
||||
@@ -2856,17 +2901,36 @@ mod tests {
|
||||
assert!(!h.app.ui.visualize_spots);
|
||||
}
|
||||
|
||||
/// Bring a right-panel field into the visible panel before clicking it. The Info panel is a
|
||||
/// scroll area: 0.37's roomier text rows push the place fields below a 1000-point window, and a
|
||||
/// click at an off-screen rect would leave the previous field focused.
|
||||
fn click_field_in_view(h: &mut Headless, key: &str, timeout: Duration) {
|
||||
let id = format!("field:{key}");
|
||||
let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:right_panel").map(|(_, r)| *r).expect("the right panel is laid out");
|
||||
let rect = h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).expect("the field is laid out");
|
||||
if !panel.contains(rect.center()) {
|
||||
let dy = (panel.center().y - rect.center().y).clamp(-4000.0, 4000.0);
|
||||
assert_eq!(h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), timeout)["ok"], true);
|
||||
assert_eq!(h.request("ui.scroll", json!({"dy": dy}), timeout)["ok"], true);
|
||||
h.settle(timeout);
|
||||
}
|
||||
let rect = h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).expect("the field is laid out");
|
||||
assert!(panel.contains(rect.center()), "the field must be scrolled into view before clicking: {rect:?} in {panel:?}");
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), timeout);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
|
||||
/// Info panel: typing into a field survives frames and is saved when it loses focus; the new
|
||||
/// accessibility and place fields reach the photo.
|
||||
#[test]
|
||||
fn info_fields_keep_typing_and_save() {
|
||||
let mut h = demo([1300.0, 1000.0]);
|
||||
// (tall enough for the place fields to show without scrolling)
|
||||
let mut h = demo([1300.0, 1200.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
h.settle(SETTLE);
|
||||
for (key, text) in [("altText", "A lake at dawn"), ("usageTerms", "Editorial use only"), ("city", "Zermatt")] {
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("field:{key}")}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
click_field_in_view(&mut h, key, t);
|
||||
h.request("ui.key", json!({"key": "A", "cmd": true}), t);
|
||||
h.request("ui.text", json!({"text": text}), t);
|
||||
h.step();
|
||||
@@ -2926,6 +2990,291 @@ mod tests {
|
||||
assert_eq!(meta(&h).title, "Wedding in Sintra");
|
||||
}
|
||||
|
||||
/// With several photos selected the Info panel offers Target Photo / Selected Photos. Target
|
||||
/// Photo (the default) shows the active photo's values. Selected Photos leaves a field whose
|
||||
/// values differ empty, so what is typed there is what every selected photo gets, and keeps
|
||||
/// the value of a field they share; leaving a mixed field untouched saves nothing.
|
||||
#[test]
|
||||
fn info_shows_metadata_for_the_selected_photos() {
|
||||
let mut h = demo([1300.0, 1000.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
|
||||
let r = h.request(method, params.clone(), t);
|
||||
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
||||
h.step();
|
||||
h.step();
|
||||
r
|
||||
};
|
||||
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
|
||||
ask(h, "engine.execute", json!({"command": command, "params": params}))["result"].clone()
|
||||
};
|
||||
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
let (a, b) = (lightcraft_catalog::PhotoId(ids[0]), lightcraft_catalog::PhotoId(ids[1]));
|
||||
let meta = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.clone();
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn", "copyrightStatus": "copyrighted"}));
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"}));
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": ids, "creator": "Ana"}));
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]}));
|
||||
h.settle(SETTLE);
|
||||
let buttons = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "infoFor"}), t)["result"].to_string();
|
||||
assert!(buttons(&mut h).contains("button:infoFor-selected"), "the choice shows with two photos selected");
|
||||
assert!(h.app.ui.settings.info_selected_photos, "Selected Photos by default");
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn!"}));
|
||||
// Target Photo and back to Selected Photos, from their buttons
|
||||
ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-target"}));
|
||||
assert!(!h.app.ui.settings.info_selected_photos);
|
||||
ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-selected"}));
|
||||
assert!(h.app.ui.settings.info_selected_photos);
|
||||
// a mixed field left untouched saves nothing
|
||||
let undo = h.app.session.undo.len();
|
||||
click_field_in_view(&mut h, "title", t);
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo));
|
||||
// neither does switching the choice while a field is being edited, either way
|
||||
for on in [false, true] {
|
||||
click_field_in_view(&mut h, "title", t);
|
||||
exec(&mut h, "view.infoSelectedPhotos", json!({"on": on}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo), "{on}");
|
||||
}
|
||||
// the mixed title starts empty; the shared creator keeps its value
|
||||
for (key, typed, want) in [("title", "Harbor", "Harbor"), ("creator", " Silva", "Ana Silva")] {
|
||||
click_field_in_view(&mut h, key, t);
|
||||
ask(&mut h, "ui.key", json!({"key": "End"}));
|
||||
ask(&mut h, "ui.text", json!({"text": typed}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
let got = |h: &Headless, id| if key == "title" { meta(h, id).title } else { meta(h, id).creator };
|
||||
assert_eq!((got(&h, a), got(&h, b)), (want.to_string(), want.to_string()), "{key}");
|
||||
}
|
||||
// a mixed field typed in and left empty clears it on every selected photo
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Old"}));
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Other"}));
|
||||
h.settle(SETTLE);
|
||||
click_field_in_view(&mut h, "caption", t);
|
||||
ask(&mut h, "ui.text", json!({"text": "x"}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Backspace"}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((meta(&h, a).caption, meta(&h, b).caption), (String::new(), String::new()));
|
||||
// GPS: one photo's place and Show on Map only while the selected photos share it
|
||||
let map = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "showOnMap"}), t)["result"].to_string().contains("showOnMap");
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "gps": [41.15, -8.61]}));
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "gps": [38.72, -9.14]}));
|
||||
h.settle(SETTLE);
|
||||
assert!(!map(&mut h), "places differ");
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": ids, "gps": [41.15, -8.61]}));
|
||||
h.settle(SETTLE);
|
||||
assert!(map(&mut h), "a shared place");
|
||||
// the command: refuses a value that isn't true or false, and turns Target Photo back on
|
||||
let r = h.request("engine.execute", json!({"command": "view.infoSelectedPhotos", "params": {"on": "yes"}}), t);
|
||||
assert_ne!(r["ok"], true, "{r}");
|
||||
assert!(h.app.ui.settings.info_selected_photos);
|
||||
assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}))["on"], false);
|
||||
assert!(!h.app.ui.settings.info_selected_photos);
|
||||
assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({}))["on"], true, "no params toggles");
|
||||
// the choice is saved with the app settings; settings saved before it read as Selected Photos
|
||||
exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}));
|
||||
let saved = serde_json::to_value(&h.app.ui.settings).unwrap();
|
||||
assert_eq!(saved["infoSelectedPhotos"], false);
|
||||
let read: crate::state::AppSettings = serde_json::from_value(saved).unwrap();
|
||||
assert!(!read.info_selected_photos);
|
||||
let old: crate::state::AppSettings = serde_json::from_value(json!({"filmNames": false})).unwrap();
|
||||
assert!(old.info_selected_photos);
|
||||
// one photo selected: no choice to make
|
||||
exec(&mut h, "library.select", json!({"ids": [ids[0]]}));
|
||||
h.settle(SETTLE);
|
||||
assert!(!buttons(&mut h).contains("infoFor-"), "{}", buttons(&mut h));
|
||||
}
|
||||
|
||||
/// With several photos selected and the settings as they come, an edit to a field where the
|
||||
/// photos differ starts from an empty field: the photos get what was typed, not the active
|
||||
/// photo's value with the typing added.
|
||||
#[test]
|
||||
fn info_edits_of_a_multi_selection_start_from_mixed_fields_by_default() {
|
||||
let mut h = demo([1300.0, 1000.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
|
||||
let r = h.request(method, params.clone(), t);
|
||||
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
||||
h.step();
|
||||
h.step();
|
||||
};
|
||||
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
|
||||
ask(h, "engine.execute", json!({"command": command, "params": params}));
|
||||
};
|
||||
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone();
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"}));
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"}));
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]}));
|
||||
h.settle(SETTLE);
|
||||
click_field_in_view(&mut h, "caption", t);
|
||||
ask(&mut h, "ui.key", json!({"key": "End"}));
|
||||
ask(&mut h, "ui.text", json!({"text": "Harbor"}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((caption(&h, 0), caption(&h, 1)), ("Harbor".into(), "Harbor".into()));
|
||||
}
|
||||
|
||||
/// Target Photo edits the photo it shows: with several photos selected, a field's edit is
|
||||
/// saved to the active photo alone.
|
||||
#[test]
|
||||
fn info_target_photo_edits_only_the_photo_it_shows() {
|
||||
let mut h = demo([1300.0, 1000.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
|
||||
let r = h.request(method, params.clone(), t);
|
||||
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
||||
h.step();
|
||||
h.step();
|
||||
};
|
||||
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
|
||||
ask(h, "engine.execute", json!({"command": command, "params": params}));
|
||||
};
|
||||
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone();
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"}));
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"}));
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]}));
|
||||
exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}));
|
||||
h.settle(SETTLE);
|
||||
click_field_in_view(&mut h, "caption", t);
|
||||
ask(&mut h, "ui.key", json!({"key": "End"}));
|
||||
ask(&mut h, "ui.text", json!({"text": "!"}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((caption(&h, 0), caption(&h, 1)), ("Dawn!".into(), "Dusk".into()));
|
||||
}
|
||||
|
||||
/// The fields the Info panel marks as mixed: each text field, the copyright status and GPS
|
||||
/// where a selected photo differs from the photo the panel shows, and none for one photo.
|
||||
#[test]
|
||||
fn info_mixed_fields_are_the_ones_that_differ() {
|
||||
let mut s = lightcraft_engine::Session::with_demo();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(3).map(|p| p.0).collect();
|
||||
let all = |key: &str, v: serde_json::Value| json!({"ids": ids, key: v});
|
||||
// (every field the panel compares starts the same on the three photos)
|
||||
let fields = [
|
||||
"title",
|
||||
"caption",
|
||||
"altText",
|
||||
"extendedDescription",
|
||||
"copyright",
|
||||
"usageTerms",
|
||||
"copyrightUrl",
|
||||
"creator",
|
||||
"location",
|
||||
"state",
|
||||
"country",
|
||||
];
|
||||
for key in fields {
|
||||
s.execute("photo.setMeta", &all(key, json!(""))).unwrap();
|
||||
}
|
||||
for (key, v) in
|
||||
[("title", json!("Same")), ("city", json!("Porto")), ("copyrightStatus", json!("copyrighted")), ("gps", json!([41.15, -8.61]))]
|
||||
{
|
||||
s.execute("photo.setMeta", &all(key, v)).unwrap();
|
||||
}
|
||||
let photos: Vec<lightcraft_catalog::PhotoId> = ids.iter().map(|&i| lightcraft_catalog::PhotoId(i)).collect();
|
||||
let shown = s.catalog.photo(photos[0]).unwrap().meta.clone();
|
||||
assert!(crate::panels::right::mixed_meta(&s.catalog, &photos, &shown).is_empty());
|
||||
assert!(crate::panels::right::mixed_meta(&s.catalog, &photos[..1], &shown).is_empty());
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[2]], "city": "Lisbon", "caption": "x", "gps": [38.72, -9.14]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[1]], "copyrightStatus": "publicDomain"})).unwrap();
|
||||
let mut mixed = crate::panels::right::mixed_meta(&s.catalog, &photos, &shown);
|
||||
mixed.sort_unstable();
|
||||
assert_eq!(mixed, ["caption", "city", "copyrightStatus", "gps"]);
|
||||
}
|
||||
|
||||
/// Selected Photos with the active photo outside the selection: the fields show what the
|
||||
/// selected photos share, not the active photo's values.
|
||||
#[test]
|
||||
fn info_selected_photos_ignore_an_active_photo_outside_the_selection() {
|
||||
let mut h = demo([1300.0, 1000.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
|
||||
let r = h.request(method, params.clone(), t);
|
||||
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
||||
h.step();
|
||||
h.step();
|
||||
};
|
||||
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
|
||||
ask(h, "engine.execute", json!({"command": command, "params": params}));
|
||||
};
|
||||
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect();
|
||||
let title = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.title.clone();
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0], ids[1]], "title": "Same"}));
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[2]], "title": "Other"}));
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[2]}));
|
||||
exec(&mut h, "view.infoSelectedPhotos", json!({"on": true}));
|
||||
h.settle(SETTLE);
|
||||
click_field_in_view(&mut h, "title", t);
|
||||
ask(&mut h, "ui.key", json!({"key": "End"}));
|
||||
ask(&mut h, "ui.text", json!({"text": "!"}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((title(&h, 0), title(&h, 1), title(&h, 2)), ("Same!".into(), "Same!".into(), "Other".into()));
|
||||
}
|
||||
|
||||
/// An Info field saves to the photos selected when its edit started, also when the selection
|
||||
/// changes while the field is being edited.
|
||||
#[test]
|
||||
fn info_field_saves_to_the_photos_selected_when_the_edit_started() {
|
||||
// (tall enough for the GPS field to show without scrolling)
|
||||
let mut h = demo([1300.0, 1800.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
|
||||
let r = h.request(method, params.clone(), t);
|
||||
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
||||
h.step();
|
||||
h.step();
|
||||
};
|
||||
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
|
||||
ask(h, "engine.execute", json!({"command": command, "params": params}));
|
||||
};
|
||||
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(4).map(|p| p.0).collect();
|
||||
let meta = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.clone();
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": ids, "title": "Same", "gps": ""}));
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]}));
|
||||
h.settle(SETTLE);
|
||||
click_field_in_view(&mut h, "title", t);
|
||||
ask(&mut h, "ui.key", json!({"key": "End"}));
|
||||
ask(&mut h, "ui.text", json!({"text": "!"}));
|
||||
exec(&mut h, "library.select", json!({"ids": [ids[2]]}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((meta(&h, 0).title, meta(&h, 1).title, meta(&h, 2).title), ("Same!".into(), "Same!".into(), "Same".into()));
|
||||
// GPS too (the new selection is two photos as well, so the panel keeps its layout)
|
||||
exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]}));
|
||||
h.settle(SETTLE);
|
||||
click_field_in_view(&mut h, "gps", t);
|
||||
ask(&mut h, "ui.text", json!({"text": "41.15, -8.61"}));
|
||||
exec(&mut h, "library.select", json!({"ids": [ids[2], ids[3]], "active": ids[2]}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
let gps: Vec<bool> = (0..4).map(|i| meta(&h, i).gps.is_some()).collect();
|
||||
assert_eq!(gps, [true, true, false, false]);
|
||||
// Esc gives a GPS edit up
|
||||
exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]}));
|
||||
h.settle(SETTLE);
|
||||
let before = meta(&h, 0).gps;
|
||||
click_field_in_view(&mut h, "gps", t);
|
||||
ask(&mut h, "ui.key", json!({"key": "End"}));
|
||||
ask(&mut h, "ui.text", json!({"text": "5"}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Escape"}));
|
||||
ask(&mut h, "ui.key", json!({"key": "Tab"}));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((meta(&h, 0).gps, meta(&h, 1).gps), (before, before));
|
||||
}
|
||||
|
||||
/// Local: a folder's photos show without joining the library; the breadcrumb, Include
|
||||
/// subfolders and Add to My Photos work from the grid header.
|
||||
#[test]
|
||||
@@ -4273,7 +4622,7 @@ mod tests {
|
||||
// Paint the edited template before locating the Tags button in the resized dialog.
|
||||
assert!(h.settle(SETTLE), "Copy dialog layout should settle before locating Tags");
|
||||
// the cursor sits after "Trip-"
|
||||
let id = egui::Id::new("import-rename");
|
||||
let id = egui::Id::unique("import-rename");
|
||||
let mut st = egui::text_edit::TextEditState::default();
|
||||
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5))));
|
||||
st.store(&h.view.ctx, id);
|
||||
@@ -4310,7 +4659,7 @@ mod tests {
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("button:aboutTab-{tab}")}), t);
|
||||
assert_eq!(r["ok"], true, "{tab}: {r}");
|
||||
h.step();
|
||||
let shown = h.view.ctx.data_mut(|d| d.get_temp::<u8>(egui::Id::new("about_tab")));
|
||||
let shown = h.view.ctx.data_mut(|d| d.get_temp::<u8>(egui::Id::unique("about_tab")));
|
||||
assert_eq!(shown.map(usize::from), Some(i), "{tab}");
|
||||
}
|
||||
h.request("ui.clickWidget", json!({"id": "button:aboutTab-contributors"}), t);
|
||||
@@ -4634,6 +4983,53 @@ mod tests {
|
||||
assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing");
|
||||
}
|
||||
|
||||
/// View Options' Show Briefly When Photo Changes: a tick box under each Info (ticking one
|
||||
/// unticks the other) and `view.viewOptions {briefly}`; with the overlay off, the loupe shows
|
||||
/// the chosen Info after → changes the photo, and hides it again a few seconds later.
|
||||
#[test]
|
||||
fn show_briefly_when_photo_changes_in_view_options() {
|
||||
use crate::state::{BRIEF_INFO_SECS, InfoOverlay};
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail"}), t);
|
||||
assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true);
|
||||
h.step();
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly2"}), t)["ok"], true);
|
||||
h.step();
|
||||
assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Exposure);
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly1"}), t)["ok"], true);
|
||||
h.step();
|
||||
assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Basic, "one Info at most");
|
||||
h.app.ui.dialog = None;
|
||||
h.step();
|
||||
|
||||
let overlay = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay");
|
||||
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off);
|
||||
h.time += BRIEF_INFO_SECS + 1.0;
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(!overlay(&h), "hidden once the time has passed");
|
||||
let before = h.app.session.active();
|
||||
h.request("ui.key", json!({"key": "right"}), t);
|
||||
assert_ne!(h.app.session.active(), before);
|
||||
h.step();
|
||||
assert!(overlay(&h), "shown after → changes the photo");
|
||||
h.time += BRIEF_INFO_SECS + 1.0;
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(!overlay(&h), "and hidden again");
|
||||
|
||||
let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "off"}}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(r["result"]["loupeInfo"]["briefly"], "off", "{r}");
|
||||
h.request("ui.key", json!({"key": "right"}), t);
|
||||
h.step();
|
||||
assert!(!overlay(&h), "off: nothing shows when the photo changes");
|
||||
let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "info3"}}), t);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Off, "a refused change changes nothing");
|
||||
}
|
||||
|
||||
/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the
|
||||
/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit.
|
||||
#[test]
|
||||
@@ -4739,6 +5135,7 @@ mod tests {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(10.0, -10.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}),
|
||||
None,
|
||||
@@ -4817,7 +5214,13 @@ mod tests {
|
||||
] {
|
||||
h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t);
|
||||
h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t);
|
||||
h.app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers });
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit,
|
||||
delta: egui::vec2(0.0, y),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
modifiers,
|
||||
});
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
@@ -4970,6 +5373,69 @@ mod tests {
|
||||
assert!(!h.app.renderer.is_pending(Slot::Main));
|
||||
}
|
||||
|
||||
/// View Options' Show message when loading or rendering photos (on by default): turned off in
|
||||
/// the window, the loupe does not say "Rendering…" while an edit renders; `view.viewOptions
|
||||
/// {loupeMessages}` turns it back on for an agent (refusing a value that is not true or false).
|
||||
#[test]
|
||||
fn view_options_turn_the_rendering_message_off() {
|
||||
use crate::render::Slot;
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering");
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.ui.settings.loupe_messages, "on by default");
|
||||
assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true);
|
||||
h.step();
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.loupeMessages"}), t)["ok"], true);
|
||||
h.step();
|
||||
assert!(!h.app.ui.settings.loupe_messages, "the tick box turns it off");
|
||||
h.app.ui.dialog = None;
|
||||
let open = Rc::new(Cell::new(false));
|
||||
h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone())));
|
||||
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t);
|
||||
for _ in 0..45 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held");
|
||||
assert!(!pill(&h), "no message while it is off");
|
||||
|
||||
let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": "yes"}}), t);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(!h.app.ui.settings.loupe_messages, "a refused change changes nothing");
|
||||
let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": true}}), t);
|
||||
assert_eq!(r["result"]["loupeMessages"], true, "{r}");
|
||||
assert_eq!(h.app.ui.dialog, None, "a value sets it without opening the window");
|
||||
assert!(h.step_until(SETTLE, pill), "the message shows again while the render is held");
|
||||
open.set(true);
|
||||
assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands");
|
||||
}
|
||||
|
||||
/// With nothing to show yet, the loupe says "Rendering…" in its middle only while View Options'
|
||||
/// Show message when loading or rendering photos is on.
|
||||
#[test]
|
||||
fn an_empty_loupe_says_rendering_only_with_the_message_on() {
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
for on in [true, false] {
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.app.ui.settings.loupe_messages = on;
|
||||
h.app.renderer.set_offload(Box::new(super::RenderGate::new(Rc::new(Cell::new(false)))));
|
||||
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
|
||||
// drop every texture (quick previews and thumbnails land outside the gate) until a
|
||||
// frame has nothing to show
|
||||
let empty = (0..60).any(|_| {
|
||||
h.app.renderer.textures.clear();
|
||||
h.step();
|
||||
h.app.loupe_shown.map(|l| l.1) == Some("none")
|
||||
});
|
||||
assert!(empty, "a frame with nothing rendered");
|
||||
let said = h.painted_text().iter().any(|text| text == "Rendering…");
|
||||
assert_eq!(said, on, "message on: {on}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left
|
||||
/// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the
|
||||
/// error on hover, and keeps saying so while a library change retries it.
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 {
|
||||
pub(crate) fn note_dialog_bottom(ui: &egui::Ui) {
|
||||
if !ui.is_sizing_pass() {
|
||||
let y = ui.min_rect().bottom();
|
||||
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(CONTENT_BOTTOM), y));
|
||||
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(CONTENT_BOTTOM), y));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1106,7 +1106,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
// as tall as the window leaves after the options and buttons below it (measured last frame),
|
||||
// so resizing the dialog resizes the grid
|
||||
let below = ui.ctx().data(|m| {
|
||||
let y = |key: &str| m.get_temp::<f32>(egui::Id::new(key));
|
||||
let y = |key: &str| m.get_temp::<f32>(egui::Id::unique(key));
|
||||
y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid)
|
||||
});
|
||||
let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0);
|
||||
@@ -1124,7 +1124,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
}
|
||||
});
|
||||
if !ui.is_sizing_pass() {
|
||||
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(GRID_BOTTOM), grid.inner_rect.bottom()));
|
||||
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom()));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
// options
|
||||
@@ -1138,7 +1138,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
.map(|summary| summary.display())
|
||||
.unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
|
||||
let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined);
|
||||
register(ui.ctx(), "label:importSource", r.rect);
|
||||
register(ui, "label:importSource", r.rect);
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing)."))
|
||||
@@ -1184,7 +1184,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
.color(if d.copy && d.move_files { t.caution } else { t.text_dim })
|
||||
.small(),
|
||||
);
|
||||
register(ui.ctx(), "label:importModeHelp", r.rect);
|
||||
register(ui, "label:importModeHelp", r.rect);
|
||||
if d.copy {
|
||||
field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
@@ -1212,7 +1212,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
|
||||
for (k, label) in opts {
|
||||
let r = ui.selectable_label(key == k, crate::i18n::tr(label));
|
||||
register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect);
|
||||
register(ui, format!("button:importOrganize-{k}"), r.rect);
|
||||
if r.clicked() {
|
||||
d.organize = k.to_string();
|
||||
if k == "custom" && d.folder_template.trim().is_empty() {
|
||||
@@ -1221,15 +1221,16 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
}
|
||||
}
|
||||
});
|
||||
register(ui.ctx(), "combo:importOrganize", combo.response.rect);
|
||||
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Organize into"));
|
||||
register(ui, "combo:importOrganize", combo.response.rect);
|
||||
});
|
||||
if d.organize == "custom" {
|
||||
let folders_id = egui::Id::new("import-folder-template");
|
||||
let folders_id = egui::Id::unique("import-folder-template");
|
||||
let tags_open = field(ui, "Template", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let w = tag_field_width(ui);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w));
|
||||
register(ui.ctx(), "field:importFolderTemplate", r.rect);
|
||||
register(ui, "field:importFolderTemplate", r.rect);
|
||||
tag_toggle(ui, "importFolders")
|
||||
});
|
||||
if tags_open {
|
||||
@@ -1253,10 +1254,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
if !d.move_files {
|
||||
field(ui, "Raw files", |ui| {
|
||||
let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG"));
|
||||
register(ui.ctx(), "check:importDng", r.rect);
|
||||
register(ui, "check:importDng", r.rect);
|
||||
});
|
||||
}
|
||||
let rename_id = egui::Id::new("import-rename");
|
||||
let rename_id = egui::Id::unique("import-rename");
|
||||
let tags_open = field(ui, "Rename", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let w = tag_field_width(ui);
|
||||
@@ -1266,7 +1267,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
.hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}"))
|
||||
.desired_width(w),
|
||||
);
|
||||
register(ui.ctx(), "field:importRename", r.rect);
|
||||
register(ui, "field:importRename", r.rect);
|
||||
tag_toggle(ui, "importRename")
|
||||
});
|
||||
if tags_open {
|
||||
@@ -1276,7 +1277,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
if let Some(example) = example_destination(app, d) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim));
|
||||
register(ui.ctx(), "label:importExample", r.rect);
|
||||
register(ui, "label:importExample", r.rect);
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date."))
|
||||
.color(t.text_dim)
|
||||
@@ -1292,13 +1293,14 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
_ => "Leave them",
|
||||
};
|
||||
let mut pick = None;
|
||||
crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
|
||||
let combo = crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
|
||||
for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] {
|
||||
if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() {
|
||||
pick = Some(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Recently deleted files"));
|
||||
if let Some(v) = pick {
|
||||
d.set_on_deleted(v);
|
||||
}
|
||||
@@ -1324,12 +1326,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
.empty_text(empty)
|
||||
.show_with_choices(ui, |ui| {
|
||||
let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None"));
|
||||
register(ui.ctx(), "importAlbum:none", none.rect);
|
||||
register(ui, "importAlbum:none", none.rect);
|
||||
if none.clicked() {
|
||||
extra = Some(false);
|
||||
}
|
||||
let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…"));
|
||||
register(ui.ctx(), "importAlbum:new", new.rect);
|
||||
register(ui, "importAlbum:new", new.rect);
|
||||
if new.clicked() {
|
||||
extra = Some(true);
|
||||
}
|
||||
@@ -1350,7 +1352,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
}
|
||||
if d.album.is_none() && !d.new_album.is_empty() {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY));
|
||||
register(ui.ctx(), "field:importAlbumName", r.rect);
|
||||
register(ui, "field:importAlbumName", r.rect);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1362,7 +1364,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
.find(|p| p.id == d.preset)
|
||||
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
|
||||
.unwrap_or_else(|| crate::i18n::tr("None").into());
|
||||
crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
|
||||
let combo = crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() {
|
||||
d.preset.clear();
|
||||
}
|
||||
@@ -1371,11 +1373,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
d.preset = pid;
|
||||
}
|
||||
});
|
||||
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Preset"));
|
||||
});
|
||||
if !app.session.metadata_presets.is_empty() {
|
||||
field(ui, "Metadata", |ui| {
|
||||
let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() };
|
||||
crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| {
|
||||
let combo = crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() {
|
||||
d.metadata_preset.clear();
|
||||
}
|
||||
@@ -1385,22 +1388,27 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Metadata preset"));
|
||||
});
|
||||
}
|
||||
field(ui, "Keywords", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY));
|
||||
register(ui.ctx(), "field:importKeywords", r.rect);
|
||||
register(ui, "field:importKeywords", r.rect);
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail,
|
||||
/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an
|
||||
/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail
|
||||
/// uses its width as height.
|
||||
fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let c = d.candidates[i].clone();
|
||||
let ok = d.importable(i);
|
||||
let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width()));
|
||||
let resp = ui.interact(img, egui::Id::new(("import-cell", i)), Sense::click());
|
||||
let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click());
|
||||
crate::widgets::announce(&resp, c.name.clone());
|
||||
register(ui.ctx(), format!("import:{i}"), img);
|
||||
register(ui, format!("import:{i}"), img);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(img, 3.0, t.skeleton);
|
||||
// thumbnail (background job; the grid only asks for the cells in view)
|
||||
@@ -1481,7 +1489,7 @@ pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 {
|
||||
/// The "Tags" toggle beside a template field (`button:<key>Tags`); returns whether the tag help
|
||||
/// ([`tag_help`]) is open.
|
||||
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
|
||||
let id = egui::Id::new((key, "tags-open"));
|
||||
let id = egui::Id::unique((key, "tags-open"));
|
||||
let mut open = ui.ctx().data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
|
||||
let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open)
|
||||
.on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it"));
|
||||
@@ -1547,7 +1555,7 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id:
|
||||
crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
|
||||
},
|
||||
);
|
||||
register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect);
|
||||
register(ui, format!("button:{key}Tag-{i}"), r.rect);
|
||||
if r.clicked() {
|
||||
insert_at_cursor(ui.ctx(), edit_id, text, tok.tag);
|
||||
}
|
||||
|
||||
+1409
-147
File diff suppressed because it is too large
Load Diff
@@ -235,7 +235,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) {
|
||||
count = n
|
||||
);
|
||||
let r = ui.radio_value(new_library, false, merge);
|
||||
crate::widgets::register(ui.ctx(), "radio:lightroomDestination-open", r.rect);
|
||||
crate::widgets::register(ui, "radio:lightroomDestination-open", r.rect);
|
||||
let indent = ui.spacing().icon_width + ui.spacing().icon_spacing;
|
||||
let note = |ui: &mut egui::Ui, text: &str| {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -252,7 +252,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) {
|
||||
},
|
||||
);
|
||||
let r = ui.radio_value(new_library, true, tr("Create a new library"));
|
||||
crate::widgets::register(ui.ctx(), "radio:lightroomDestination-new", r.rect);
|
||||
crate::widgets::register(ui, "radio:lightroomDestination-new", r.rect);
|
||||
ui.add_enabled_ui(*new_library, |ui| {
|
||||
// a field's caption, left-aligned in a column as wide as the longer one (in any language)
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
@@ -298,7 +298,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) {
|
||||
// (it only speaks of merging: unticked and greyed out while a new library is chosen)
|
||||
let mut ticked = *dont_ask && !*new_library;
|
||||
let c = ui.add_enabled(!*new_library, egui::Checkbox::new(&mut ticked, tr("Don't ask again (always merge into the open library)")));
|
||||
crate::widgets::register(ui.ctx(), "check:lightroomDontAsk", c.rect);
|
||||
crate::widgets::register(ui, "check:lightroomDontAsk", c.rect);
|
||||
if !*new_library {
|
||||
*dont_ask = ticked;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ const EDGE: f32 = 6.0;
|
||||
/// How fast resting on a scroll arrow scrolls, in points per second.
|
||||
const SPEED: f32 = 600.0;
|
||||
|
||||
/// The egui temp-data id holding the row a menu at `depth` hangs from.
|
||||
fn anchor_id(depth: usize) -> Id {
|
||||
Id::new(("lc-menu-anchor", depth))
|
||||
Id::unique(("lc-menu-anchor", depth))
|
||||
}
|
||||
|
||||
/// Remember the row the submenu at `depth` (2 = a top-level menu's submenu) hangs from. It is
|
||||
@@ -76,7 +77,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn
|
||||
let screen = ctx.content_rect();
|
||||
// The menu frame's margin and stroke around the rows.
|
||||
let frame = ui.spacing().menu_margin.sum().y + 2.0;
|
||||
let key = ui.id().with(("lc-menu-level", depth));
|
||||
let key = ui.scope_id().with(("lc-menu-level", depth));
|
||||
// Rows' height from the last frame: does this level overflow?
|
||||
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
|
||||
let room = level_room(screen, bar_bottom, depth, anchor(&ctx, depth), content, frame);
|
||||
@@ -89,21 +90,27 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn
|
||||
// No scrollbar: the ▲ / ▼ strips, the wheel and dragging scroll a menu, like native menus.
|
||||
// egui's floating bar is drawn over the rows' right edge, where the shortcut text ends, and in
|
||||
// its wide (hovered) state its background hides the last character (issue #691).
|
||||
let out = egui::ScrollArea::vertical()
|
||||
// 0.37's `ScrollAreaState.offset` is private: a scroll requested after layout is applied as
|
||||
// the next frame's start offset instead of writing the state directly.
|
||||
let scroll_to = ctx.data_mut(|d| d.remove_temp::<f32>(key.with("scroll-to")));
|
||||
let mut area = egui::ScrollArea::vertical()
|
||||
.id_salt(("menu-level", depth))
|
||||
.max_height(height)
|
||||
.min_scrolled_height(height)
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
|
||||
.show(ui, rows);
|
||||
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL);
|
||||
if let Some(y) = scroll_to {
|
||||
area = area.vertical_scroll_offset(y);
|
||||
}
|
||||
let out = area.show(ui, rows);
|
||||
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
|
||||
crate::widgets::register(&ctx, format!("menu-level:{depth}"), ui.min_rect());
|
||||
crate::widgets::register(ui, format!("menu-level:{depth}"), ui.min_rect());
|
||||
let (Some(up), Some(down)) = (up, down) else { return };
|
||||
let t = crate::theme::Tokens::get(&ctx);
|
||||
let x = ui.min_rect().x_range();
|
||||
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
|
||||
let offset = out.state.offset.y;
|
||||
let offset = out.state.clamped_offset().y;
|
||||
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
|
||||
let mut target = offset;
|
||||
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
|
||||
@@ -118,7 +125,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn
|
||||
let id = key.with(dir < 0.0);
|
||||
let r = ui.interact(strip, id, Sense::hover());
|
||||
r.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, live, crate::i18n::tr(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }))
|
||||
egui::WidgetInfo::labeled(egui::Role::Button, live, crate::i18n::tr(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }))
|
||||
});
|
||||
if live && pointer_on(ui, strip) {
|
||||
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
|
||||
@@ -132,10 +139,9 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn
|
||||
}
|
||||
}
|
||||
}
|
||||
if target != offset {
|
||||
let mut st = out.state;
|
||||
st.offset.y = target;
|
||||
st.store(&ctx, out.id);
|
||||
if (target - offset).abs() > 0.01 {
|
||||
ctx.data_mut(|d| d.insert_temp(key.with("scroll-to"), target));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +322,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"app.systemInfo",
|
||||
"app.openLogFolder",
|
||||
"---",
|
||||
"app.checkForUpdates",
|
||||
"app.about",
|
||||
],
|
||||
),
|
||||
@@ -941,6 +942,54 @@ pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result<Valu
|
||||
r
|
||||
}
|
||||
|
||||
/// What the native menu bar may do with one command it queued.
|
||||
///
|
||||
/// AppKit delivers menu accelerators and clicks straight to the host, so they never pass through
|
||||
/// [`crate::shortcuts::handle`]. The explicit policy below is the equivalent guard for that path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NativeCommand {
|
||||
/// Run the command through the ordinary menu path.
|
||||
Run,
|
||||
/// Quit stays available: the host still runs the existing tasks/unsaved-changes confirmation.
|
||||
Quit,
|
||||
/// A quit/settings notice or the blocking library window owns input: drop the command.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
/// The safety policy for commands queued by the native menu bar.
|
||||
///
|
||||
/// While a prompt or the blocking library window owns input, no queued command may act on the
|
||||
/// library behind it — including Settings… and About, which would otherwise open their dialog
|
||||
/// behind the blocking window. Quit is the one exception: the window itself offers Quit, and the
|
||||
/// host's existing confirmation ([`crate::panels::notices::may_close`]) still decides whether the
|
||||
/// process may end.
|
||||
pub fn native_command_policy(app: &LightcraftApp, id: &str) -> NativeCommand {
|
||||
if id == "app.quit" {
|
||||
return NativeCommand::Quit;
|
||||
}
|
||||
if crate::panels::notices::blocks_input(app) || crate::panels::library_problem::blocks_dialog_input(app) {
|
||||
return NativeCommand::Blocked;
|
||||
}
|
||||
NativeCommand::Run
|
||||
}
|
||||
|
||||
/// Run one command resolved from a native menu event under [`native_command_policy`].
|
||||
///
|
||||
/// The host calls this for every queued native command, so a macOS accelerator, click or About/
|
||||
/// Settings item respects the notice and blocking-library barriers. Ordinary dialogs retain
|
||||
/// their existing native-menu behavior.
|
||||
pub fn run_native_command(app: &mut LightcraftApp, id: &str, params: Value) -> NativeCommand {
|
||||
let policy = native_command_policy(app, id);
|
||||
match policy {
|
||||
NativeCommand::Run => {
|
||||
let _ = run_item(app, id, params);
|
||||
}
|
||||
NativeCommand::Blocked => log::debug!("native menu: {id} is blocked while another window owns input"),
|
||||
NativeCommand::Quit => {}
|
||||
}
|
||||
policy
|
||||
}
|
||||
|
||||
/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⌘⇧Z` on macOS, `Ctrl+Shift+Z` elsewhere.
|
||||
pub fn shortcut_text(sc: &str, mac: bool) -> String {
|
||||
if mac {
|
||||
@@ -1006,7 +1055,7 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
||||
.iter()
|
||||
.map(|(title, _)| {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
|
||||
crate::widgets::register(ui, format!("menu:{title}"), r.rect);
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
@@ -1027,7 +1076,7 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
||||
ui.spacing_mut().item_spacing.x = saved;
|
||||
} else {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
|
||||
crate::widgets::register(ui, "menu:all", r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
crate::menu_level::level(ui, 1, bar_bottom, |ui| {
|
||||
for (title, items) in &bar {
|
||||
@@ -1056,7 +1105,7 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
||||
/// Returns the menu whose title was just clicked right after a hover opened it.
|
||||
fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option<egui::Id> {
|
||||
let popup = egui::Popup::default_response_id;
|
||||
let key = egui::Id::new("lc-menubar-hover-opened");
|
||||
let key = egui::Id::unique("lc-menubar-hover-opened");
|
||||
let open = titles.iter().position(|r| egui::Popup::is_id_open(ctx, popup(r)));
|
||||
// the menu a hover opened, until it closes or is clicked
|
||||
let mut confirmed = None;
|
||||
@@ -1095,7 +1144,7 @@ fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option<e
|
||||
/// label), and while its submenu is open it is the anchor the next frame's room is measured from.
|
||||
fn submenu(ui: &mut egui::Ui, label: &str, text: String, depth: usize, bar_bottom: Option<f32>, children: impl FnOnce(&mut egui::Ui)) {
|
||||
let r = crate::widgets::menu_button(ui, text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children));
|
||||
crate::widgets::register(ui.ctx(), format!("menusub:{label}"), r.response.rect);
|
||||
crate::widgets::register(ui, format!("menusub:{label}"), r.response.rect);
|
||||
if r.inner.is_some() {
|
||||
crate::menu_level::set_anchor(ui.ctx(), depth + 1, r.response.rect);
|
||||
}
|
||||
@@ -1121,7 +1170,7 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti
|
||||
b = b.shortcut_text(shortcut_text(sc, mac));
|
||||
}
|
||||
let r = ui.add_enabled(*enabled, b);
|
||||
crate::widgets::register(ui.ctx(), format!("menuitem:{id}"), r.rect);
|
||||
crate::widgets::register(ui, format!("menuitem:{id}"), r.rect);
|
||||
if *checked == Some(true) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let c = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 11.0, r.rect.center().y), egui::vec2(13.0, 13.0));
|
||||
@@ -1841,6 +1890,73 @@ mod tests {
|
||||
assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me");
|
||||
}
|
||||
|
||||
/// Native menu accelerators never pass through `shortcuts::handle`: a command queued by the
|
||||
/// native menu must obey the same blocking-window/prompt policy. Settings… and About must not
|
||||
/// open behind the blocking window, and Quit stays available for its existing confirmation.
|
||||
#[test]
|
||||
fn native_menu_commands_respect_the_blocking_window_policy() {
|
||||
use crate::panels::library_problem::LibraryProblem;
|
||||
use crate::state::Dialog;
|
||||
|
||||
let mut h = crate::headless::Headless::new(app(), [1000.0, 760.0], 1.0);
|
||||
h.settle(std::time::Duration::from_secs(20));
|
||||
h.app.ui.view = ViewMode::PhotoGrid;
|
||||
h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error"));
|
||||
|
||||
assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked);
|
||||
assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked);
|
||||
assert_eq!(native_command_policy(&h.app, "view.gridToggle"), NativeCommand::Blocked);
|
||||
assert_eq!(native_command_policy(&h.app, "app.quit"), NativeCommand::Quit, "Quit keeps its own confirmation");
|
||||
assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked);
|
||||
assert!(h.app.ui.dialog.is_none(), "Settings must not open behind the blocking library window");
|
||||
assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked);
|
||||
assert!(h.app.ui.dialog.is_none(), "About must not open behind the blocking library window");
|
||||
assert_eq!(run_native_command(&mut h.app, "view.gridToggle", Value::Null), NativeCommand::Blocked);
|
||||
assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a menu command did not act on the library behind the window");
|
||||
assert_eq!(
|
||||
run_native_command(&mut h.app, "app.quit", Value::Null),
|
||||
NativeCommand::Quit,
|
||||
"Quit stays for the host's existing tasks/unsaved-changes confirmation"
|
||||
);
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
|
||||
// The launch list owns input the same way.
|
||||
let settings = crate::state::AppSettings {
|
||||
recent_libraries: vec!["/no/such/library-one".into()],
|
||||
library_path: "/no/such/library-one".into(),
|
||||
..Default::default()
|
||||
};
|
||||
h.app.library_problem = Some(LibraryProblem::choose(&settings));
|
||||
assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked);
|
||||
assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked);
|
||||
assert!(h.app.ui.dialog.is_none(), "About must not open behind the launch list");
|
||||
// The launch list's own New Library prompt is not a background barrier: it keeps its
|
||||
// priority instead of being treated as an unrelated dialog behind the list.
|
||||
h.app.ui.dialog = Some(Dialog::TextPrompt {
|
||||
title: "New Library".into(),
|
||||
hint: "Library name".into(),
|
||||
value: "New Library".into(),
|
||||
command: "app.newLibrary".into(),
|
||||
params: json!({}),
|
||||
key: "name".into(),
|
||||
});
|
||||
assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run, "the launch list's own prompt is not a background barrier");
|
||||
h.app.ui.dialog = None;
|
||||
|
||||
// A quit/settings notice owns input in the same way.
|
||||
h.app.library_problem = None;
|
||||
h.app.notices.push("The settings file could not be read and was reset.".into());
|
||||
assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked);
|
||||
assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked);
|
||||
assert!(h.app.ui.dialog.is_none(), "Settings must not open behind a notice");
|
||||
|
||||
// Once nothing blocks input, the same queued command runs normally.
|
||||
h.app.notices.clear();
|
||||
assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run);
|
||||
assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Run);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::Settings { .. })), "the command runs when no barrier owns input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_text_per_platform() {
|
||||
assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z");
|
||||
|
||||
+61
-11
@@ -139,6 +139,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("local.addRoot", "Add Folder to Local", None, ""),
|
||||
("local.hide", "Remove from Local", None, ""),
|
||||
("local.restoreHidden", "Show Hidden Local Locations", None, ""),
|
||||
// {on?}: the Info panel's Target Photo / Selected Photos choice (on = Selected Photos)
|
||||
("view.infoSelectedPhotos", "Show Metadata for Selected Photos", None, ""),
|
||||
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
|
||||
("view.lightsOut", "Lights Out", Some("Shift+L"), "View"),
|
||||
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
|
||||
@@ -158,6 +160,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("panel.presets", "Presets", Some("Shift+P"), "Window"),
|
||||
("panel.info", "Info", Some("I"), "Window"),
|
||||
("panel.keywords", "Keywords", Some("K"), "Window"),
|
||||
// the Keywords panel with the cursor in its Add keyword box
|
||||
("panel.addKeywords", "Add Keywords", Some("Cmd+K"), ""),
|
||||
("panel.versions", "Versions", Some("Shift+V"), "Window"),
|
||||
// Edit Keyword Tag for the keyword picked in the Keyword List
|
||||
("panel.editKeywords", "Edit Keywords", Some("Cmd+Shift+K"), ""),
|
||||
@@ -261,6 +265,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
// Settings ▸ Display
|
||||
("app.displayProfile", "Display Profile", None, ""),
|
||||
("app.about", "About LightCraft", None, "Help"),
|
||||
("app.checkForUpdates", "Check for Updates…", None, "Help"),
|
||||
("app.systemInfo", "System Info…", None, "Help"),
|
||||
("app.openLogFolder", "Open Log Folder", None, "Help"),
|
||||
// the frame-rate display (`frame_rate`): a debugging aid, never saved
|
||||
@@ -665,8 +670,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
} else if app.ui.keyword_painter.is_some() {
|
||||
app.ui.keyword_painter = None;
|
||||
} else if app.ui.fullscreen {
|
||||
app.ui.fullscreen = false;
|
||||
app.ui.slideshow = None;
|
||||
// Through `slideshow::end`: it also releases the held slide textures. Clearing the
|
||||
// two fields directly left every rendered slide in the renderer.
|
||||
crate::panels::slideshow::end(app);
|
||||
} else if !app.ui.tool.is_empty() {
|
||||
app.ui.tool.clear();
|
||||
} else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail {
|
||||
@@ -690,7 +696,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
"view.slideshow" => {
|
||||
// the photos in view, full screen, with the playback settings (see panels::slideshow)
|
||||
let now = ctx.input(|i| i.time);
|
||||
let now = app.tick_now();
|
||||
let r = crate::panels::slideshow::start(app, p, now);
|
||||
if r.is_ok() {
|
||||
app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends"));
|
||||
@@ -698,7 +704,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
r
|
||||
}
|
||||
"slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let now = app.tick_now();
|
||||
crate::panels::slideshow::command(app, id, now)
|
||||
}
|
||||
"view.slideshowOptions" => {
|
||||
@@ -706,11 +712,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.fullScreenPreview" => {
|
||||
app.ui.fullscreen = !app.ui.fullscreen;
|
||||
if !app.ui.fullscreen {
|
||||
app.ui.slideshow = None;
|
||||
}
|
||||
if app.ui.fullscreen {
|
||||
// Through `slideshow::end`: it also releases the held slide textures. Clearing the
|
||||
// two fields directly left every rendered slide in the renderer.
|
||||
crate::panels::slideshow::end(app);
|
||||
} else {
|
||||
app.ui.fullscreen = true;
|
||||
app.ui.zoom = Zoom::Fit;
|
||||
app.ui.tool.clear();
|
||||
let _ = app.session.end_interaction();
|
||||
@@ -751,9 +758,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(json!({"infoOverlay": app.ui.info_overlay}))
|
||||
}
|
||||
"view.viewOptions" => {
|
||||
// `info1` / `info2` set the lines for an agent; without them the window opens
|
||||
// `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes
|
||||
// shows and `loupeMessages` Show message when loading or rendering photos, for an agent;
|
||||
// without any of them the window opens
|
||||
let mut info = app.ui.settings.loupe_info;
|
||||
let mut set = false;
|
||||
// `loupeMessages` sets Show message when loading or rendering photos
|
||||
let messages = match p.get("loupeMessages") {
|
||||
None => app.ui.settings.loupe_messages,
|
||||
Some(v) => match v.as_bool() {
|
||||
Some(on) => {
|
||||
set = true;
|
||||
on
|
||||
}
|
||||
None => return Some(Err("`loupeMessages`: true|false".into())),
|
||||
},
|
||||
};
|
||||
for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] {
|
||||
let Some(v) = p.get(key) else { continue };
|
||||
match serde_json::from_value(v.clone()) {
|
||||
@@ -764,12 +784,20 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
set = true;
|
||||
}
|
||||
if let Some(v) = p.get("briefly") {
|
||||
match serde_json::from_value(v.clone()) {
|
||||
Ok(v) => info.briefly = v,
|
||||
Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())),
|
||||
}
|
||||
set = true;
|
||||
}
|
||||
if set {
|
||||
app.ui.settings.loupe_info = info;
|
||||
app.ui.settings.loupe_messages = messages;
|
||||
} else {
|
||||
app.ui.dialog = Some(Dialog::ViewOptions);
|
||||
}
|
||||
Ok(json!({"loupeInfo": app.ui.settings.loupe_info}))
|
||||
Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages}))
|
||||
}
|
||||
"view.chooseLayoutImage" => {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
@@ -867,7 +895,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.tool.clear();
|
||||
}
|
||||
let _ = app.session.end_interaction();
|
||||
} else {
|
||||
} else if app.ui.right == RightPanel::None {
|
||||
let p = match std::mem::take(&mut app.ui.right_hidden) {
|
||||
RightPanel::None => RightPanel::Edit,
|
||||
p => p,
|
||||
@@ -880,6 +908,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.tool = "remove".into();
|
||||
}
|
||||
}
|
||||
// A right panel that is open but not showing (minimized, or behind another tab)
|
||||
// keeps its mode; reveal_command brings it to the front.
|
||||
Ok(json!({"open": app.ui.right != RightPanel::None}))
|
||||
}
|
||||
"view.beforeAfter" => {
|
||||
@@ -1116,6 +1146,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins);
|
||||
Ok(json!({"maskPins": app.ui.mask_pins}))
|
||||
}
|
||||
"view.infoSelectedPhotos" => {
|
||||
// {on?}; no params toggles. On: the Info panel shows what the selected photos share.
|
||||
let on = match p.get("on") {
|
||||
None | Some(Value::Null) => !app.ui.settings.info_selected_photos,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
Some(v) => return Some(Err(format!("view.infoSelectedPhotos: `on` must be true or false, not {v}"))),
|
||||
};
|
||||
app.ui.settings.info_selected_photos = on;
|
||||
Ok(json!({"on": on}))
|
||||
}
|
||||
"view.toolOverlay" => {
|
||||
use crate::state::ToolOverlay;
|
||||
let mode = match p.get("mode") {
|
||||
@@ -1244,6 +1284,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
panel(app, &ctx, RightPanel::Keywords, "Keywords");
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"panel.addKeywords" => {
|
||||
// opens Keywording when it is folded shut; the panel stays open when pressed again
|
||||
if app.ui.right != RightPanel::Keywords {
|
||||
panel(app, &ctx, RightPanel::Keywords, "Keywords");
|
||||
}
|
||||
app.ui.folded_sections.retain(|s| s != "keywording");
|
||||
app.ui.focus_keywords = Some(ctx.cumulative_pass_nr());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"panel.versions" => {
|
||||
panel(app, &ctx, RightPanel::Versions, "Versions");
|
||||
Ok(Value::Null)
|
||||
@@ -1555,6 +1604,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.dialog = Some(Dialog::About);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.checkForUpdates" => crate::updates_ui::check(app),
|
||||
"photo.editInPhotoCraft" => edit_in(app, &ctx, p, true),
|
||||
"photo.editInExternal" => edit_in(app, &ctx, p, false),
|
||||
"photo.openOriginalInExternal" => open_original_in(app, &ctx, p),
|
||||
|
||||
@@ -428,7 +428,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog)
|
||||
let box_size = egui::vec2(520.0, 360.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(box_size, egui::Sense::click_and_drag());
|
||||
ui.painter().rect_filled(rect, 4.0, t.canvas);
|
||||
crate::widgets::register(ui.ctx(), "merge.preview", rect);
|
||||
crate::widgets::register(ui, "merge.preview", rect);
|
||||
if let Some((tex, size, _, _)) = &app.merge.preview {
|
||||
let (tex, size) = (tex.id(), *size);
|
||||
let (r, zoom) = preview_rect(rect, size, ui.ctx().pixels_per_point(), app.merge.zoom);
|
||||
|
||||
@@ -91,13 +91,15 @@ pub(crate) fn intercept(app: &mut LightcraftApp, command: &str, params: &Value)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Show the pending model-setup note for `kind` in the model dialog, with a button that cancels
|
||||
/// the pending action. Does nothing unless a valid denoise setup is waiting.
|
||||
pub(crate) fn notice(app: &mut LightcraftApp, ui: &mut egui::Ui, kind: &str) {
|
||||
if kind != "denoise" || !app.model_setup.denoise.as_ref().is_some_and(|r| r.valid(app)) {
|
||||
return;
|
||||
}
|
||||
ui.add(egui::Label::new(tr("After installation, AI Denoise will turn on for the requesting photo.")).wrap());
|
||||
let r = ui.small_button(tr("Cancel pending action"));
|
||||
crate::widgets::register(ui.ctx(), "modelSetup:cancel:denoise", r.rect);
|
||||
crate::widgets::register(ui, "modelSetup:cancel:denoise", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("modelSetup.cancel", json!({"kind": "denoise"}));
|
||||
}
|
||||
@@ -118,7 +120,12 @@ fn abandoned(app: &mut LightcraftApp) -> bool {
|
||||
!v["downloads"].as_array().into_iter().flatten().any(|d| matches!(d["state"].as_str(), Some("running" | "done" | "installing" | "installed")))
|
||||
}
|
||||
|
||||
pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
/// Advance a pending model setup. An abandoned setup is cancelled on any call (the
|
||||
/// abandonment/download probe is not throttled); while another interaction is open it waits, and
|
||||
/// otherwise it checks the photo's model state at most every 250 ms. A photo that still has no
|
||||
/// model (or cannot use one) keeps waiting; otherwise denoise is enabled and the outcome reported
|
||||
/// in the status line.
|
||||
pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) {
|
||||
if app.model_setup.denoise.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -132,7 +139,6 @@ pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now < app.model_setup.next_check {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let mut cancel = None;
|
||||
let mut toggle = false;
|
||||
let frame = egui::Frame::NONE.fill(t.chrome).stroke(Stroke::new(1.0, t.field_border)).corner_radius(6).inner_margin(Margin::same(MARGIN));
|
||||
egui::Area::new(egui::Id::new("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| {
|
||||
egui::Area::new(egui::Id::unique("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| {
|
||||
// An area lays out in the size it had last frame, and the scroll area would take that as all the room there
|
||||
// is: the stack would never grow when rows are added. Give it the whole height below the top bar instead
|
||||
// (the scroll area keeps to `max_height`).
|
||||
@@ -96,7 +96,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.add_space(4.0);
|
||||
let text = if hidden > 0 { tr_format!("+{n} more", n = hidden) } else { tr("Show less").to_string() };
|
||||
let r = ui.add(egui::Label::new(RichText::new(text).font(t.font(11.0)).color(t.accent)).sense(Sense::click()));
|
||||
register(ui.ctx(), "activity:more", r.rect);
|
||||
register(ui, "activity:more", r.rect);
|
||||
toggle = r.clicked();
|
||||
}
|
||||
});
|
||||
@@ -132,7 +132,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click());
|
||||
crate::widgets::announce(&resp, crate::i18n::tr("Cancel"));
|
||||
paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon });
|
||||
register(ui.ctx(), format!("activity:cancel:{}", task.id), rect);
|
||||
register(ui, format!("activity:cancel:{}", task.id), rect);
|
||||
clicked = resp.on_hover_text(tr("Cancel")).clicked();
|
||||
}
|
||||
});
|
||||
@@ -142,7 +142,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool {
|
||||
ui.add(egui::Label::new(RichText::new(line).font(t.font(11.0)).color(t.text_dim)).truncate());
|
||||
}
|
||||
});
|
||||
register(ui.ctx(), format!("activity:row:{}", task.id), r.response.rect);
|
||||
register(ui, format!("activity:row:{}", task.id), r.response.rect);
|
||||
clicked
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
|
||||
}),
|
||||
flag == f,
|
||||
);
|
||||
register(pill_ui.ctx(), format!("flag:{id}"), r);
|
||||
register(&pill_ui, format!("flag:{id}"), r);
|
||||
let on = flag == f && f != Flag::None;
|
||||
let c = if on {
|
||||
if f == Flag::Reject { t.reject } else { t.pick }
|
||||
@@ -146,9 +146,9 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
|
||||
return;
|
||||
}
|
||||
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(copy_width, 30.0));
|
||||
let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click());
|
||||
let cresp = ui.interact(copy_r, egui::Id::unique("copy-settings"), Sense::click());
|
||||
crate::widgets::announce(&cresp, label.to_string());
|
||||
register(ui.ctx(), "button:copySettings", copy_r);
|
||||
register(ui, "button:copySettings", copy_r);
|
||||
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
|
||||
ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled });
|
||||
if cresp.clicked() && active.is_some() {
|
||||
@@ -157,9 +157,9 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
|
||||
app.toast(ui.ctx(), crate::i18n::tr(msg));
|
||||
}
|
||||
let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0));
|
||||
let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click());
|
||||
let gresp = ui.interact(gear_r, egui::Id::unique("copy-gear"), Sense::click());
|
||||
crate::widgets::announce(&gresp, crate::i18n::tr("Copy Settings…"));
|
||||
register(ui.ctx(), "icon:copyGear", gear_r);
|
||||
register(ui, "icon:copyGear", gear_r);
|
||||
ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas });
|
||||
paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon);
|
||||
if gresp.clicked() {
|
||||
@@ -243,7 +243,7 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zo
|
||||
// thumbnail size slider
|
||||
let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag());
|
||||
crate::widgets::announce(&resp, crate::i18n::tr("Thumbnail size"));
|
||||
register(child.ctx(), "slider:thumbSize", r);
|
||||
register(&child, "slider:thumbSize", r);
|
||||
let p = child.painter();
|
||||
let y = r.center().y;
|
||||
p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track));
|
||||
@@ -292,7 +292,7 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
// a view that holds its photos shows what matches now when asked to
|
||||
if app.session.hold_view {
|
||||
let r = ui.button(crate::i18n::tr("Refresh View"));
|
||||
crate::widgets::register(ui.ctx(), "button:refreshView", r.rect);
|
||||
crate::widgets::register(ui, "button:refreshView", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("library.refresh", json!({}));
|
||||
}
|
||||
@@ -368,7 +368,8 @@ mod visual_regressions {
|
||||
harness.run_steps(4);
|
||||
let stem = format!("lightcraft-bottom-{view:?}-{width}-{scale}x");
|
||||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||||
let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("bottom_bar")).unwrap().outer_rect;
|
||||
let panel_id = harness.ctx.viewport_id().root_ui_id().with("bottom_bar");
|
||||
let panel = egui::containers::panel::PanelState::load(&harness.ctx, panel_id).unwrap().outer_rect;
|
||||
let controls: serde_json::Map<String, serde_json::Value> = crate::widgets::take_registry(&harness.ctx)
|
||||
.into_iter()
|
||||
.map(|(id, r)| (id, json!([r.left(), r.top(), r.width(), r.height()])))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user