mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 05:34:16 -04:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad0cadcfa6 | ||
|
|
a74839fcd4 | ||
|
|
022435b08e | ||
|
|
764478ba85 | ||
|
|
3f384f5cdb | ||
|
|
1d48efeb33 | ||
|
|
11377a6874 | ||
|
|
8debf3a980 | ||
|
|
46b85d922c | ||
|
|
b0aa3d55b0 | ||
|
|
d69d442d39 | ||
|
|
00ca258dc4 | ||
|
|
7d38be8d79 | ||
|
|
76b5708dbb | ||
|
|
e462bec937 | ||
|
|
6e471d2988 |
No files matched your search
+2
-1
@@ -18,9 +18,10 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
|
||||
|
||||
Generated
+1
@@ -3975,6 +3975,7 @@ dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
"core-graphics2",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
|
||||
@@ -57,6 +57,9 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std
|
||||
# The macOS menu bar (mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
|
||||
# with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
# Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs).
|
||||
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
|
||||
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -408,6 +408,17 @@ impl Sentinel {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Linux display-server preference (`performance.linuxDisplayServer`) from the preferences
|
||||
/// file, read before the window opens; `Auto` when the file or value is missing or unknown.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::LinuxDisplayServer {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
v.get("performance")
|
||||
.and_then(|p| serde_json::from_value::<photocraft_engine::prefs::Performance>(p.clone()).ok())
|
||||
.map(|performance| performance.linux_display_server)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
@@ -428,6 +439,27 @@ mod tests {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_server_preference_is_read_before_the_window_opens() {
|
||||
use photocraft_engine::prefs::LinuxDisplayServer;
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-display-server-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_display_server(Some(&file))
|
||||
};
|
||||
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"x11"}}"#), LinuxDisplayServer::X11);
|
||||
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"auto","gpuBackend":"vulkan"}}"#), LinuxDisplayServer::Auto);
|
||||
// Unknown values, other types, broken JSON and a missing file all start as Auto.
|
||||
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"wayland"}}"#), LinuxDisplayServer::Auto);
|
||||
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":11}}"#), LinuxDisplayServer::Auto);
|
||||
assert_eq!(read("{not json"), LinuxDisplayServer::Auto);
|
||||
assert_eq!(read_display_server(Some(&dir.join("missing.json"))), LinuxDisplayServer::Auto);
|
||||
assert_eq!(read_display_server(None), LinuxDisplayServer::Auto);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_policy_ignores_hardware_backend_override() {
|
||||
let p = plan_with_mode(Vulkan, RenderingMode::Cpu, None, Some("vulkan"), false, Os::Windows);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! macOS title bar dragging. The window draws under a transparent title bar (`main.rs`), so AppKit
|
||||
//! would otherwise drag the window from anywhere in that strip, menus included: pressing a menu
|
||||
//! title and dragging down to an item moved the window instead. Turning AppKit's own dragging off
|
||||
//! leaves only the app's drag region (the free gap between the menus and the controls,
|
||||
//! macOS window lifecycle: title bar dragging and quitting.
|
||||
//!
|
||||
//! Dragging: the window draws under a transparent title bar (`main.rs`), so AppKit would
|
||||
//! otherwise drag the window from anywhere in that strip, menus included: pressing a menu title
|
||||
//! and dragging down to an item moved the window instead. Turning AppKit's own dragging off leaves
|
||||
//! only the app's drag region (the free gap between the menus and the controls,
|
||||
//! `panels::title_bar`), which starts a drag explicitly (`ViewportCommand::StartDrag`, AppKit's
|
||||
//! `performWindowDragWithEvent:`, which works on an unmovable window). All safe objc2 calls.
|
||||
//! `performWindowDragWithEvent:`, which works on an unmovable window).
|
||||
//!
|
||||
//! Quitting: see [`terminate_later`]. All safe objc2 calls.
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSApplication;
|
||||
@@ -18,3 +22,20 @@ pub fn disable_native_title_drag() {
|
||||
window.setMovable(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quit the way a Mac app does, with `-[NSApplication terminate:]` (`Services::quit`), instead of
|
||||
/// letting eframe close the window. eframe drops the window while AppKit's run loop is still
|
||||
/// running, and on Macs with a Touch Bar AppKit's next display cycle then tries to stop observing
|
||||
/// a view that is gone: an uncaught exception that crashes (macOS 27) or leaves a windowless app
|
||||
/// that only Force Quit ends (macOS 26) (#1458, #1575). `terminate:` ends the process before
|
||||
/// another display cycle runs; winit's `applicationWillTerminate:` closes the windows and has
|
||||
/// eframe save the window layout first, as the old ⌘Q (winit's default menu) did.
|
||||
///
|
||||
/// Scheduled on the main queue: winit has to be out of its event handler, or eframe's exit
|
||||
/// handling (and the save) is dropped.
|
||||
pub fn terminate_later() {
|
||||
dispatch2::DispatchQueue::main().exec_async(|| match MainThreadMarker::new() {
|
||||
Some(mtm) => NSApplication::sharedApplication(mtm).terminate(None),
|
||||
None => log::warn!("quit: the main queue ran off the main thread; not terminating"),
|
||||
});
|
||||
}
|
||||
@@ -209,6 +209,20 @@ fn main() -> eframe::Result {
|
||||
// app no pen input (#639).
|
||||
#[cfg(target_os = "linux")]
|
||||
let session = tablet::display_session();
|
||||
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
|
||||
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
|
||||
// run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own.
|
||||
#[cfg(target_os = "linux")]
|
||||
let session = if session == linux_libs::DisplaySession::Wayland
|
||||
&& gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11
|
||||
&& std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty())
|
||||
&& linux_libs::available(linux_libs::DisplaySession::X11)
|
||||
{
|
||||
eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)");
|
||||
linux_libs::DisplaySession::X11
|
||||
} else {
|
||||
session
|
||||
};
|
||||
|
||||
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
|
||||
// one is missing (issue #201). Name the package to install and exit instead.
|
||||
@@ -419,6 +433,8 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// Quit through AppKit, not by closing the window (Touch Bar Macs, #1575, #1458).
|
||||
app.services.quit = Some(Box::new(mac_window::terminate_later));
|
||||
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
|
||||
if !in_window_menus {
|
||||
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own clipping-mask cursor (not from an icon set): a hooked arrow down onto the layer below. -->
|
||||
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
|
||||
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
|
||||
<path d="M5 20.5h14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1,18 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own release-clipping-mask cursor: clip-below.svg unchanged, plus a small "no" badge (thinner strokes keep it legible at cursor size). -->
|
||||
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
|
||||
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
|
||||
<path d="M5 20.5h14" />
|
||||
<circle cx="20" cy="4" r="3.25" stroke-width="1.5" />
|
||||
<path d="m17.7 1.7 4.6 4.6" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m10 15 5 5 5-5" />
|
||||
<path d="M4 4h7a4 4 0 0 1 4 4v12" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 277 B |
@@ -16,10 +16,18 @@ use photocraft_raster::Surface;
|
||||
const FORMAT: PixelFormat = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false };
|
||||
|
||||
struct Cache {
|
||||
map: HashMap<u64, (Surface, u64)>,
|
||||
map: HashMap<u64, CacheEntry>,
|
||||
tick: u64,
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
surface: Surface,
|
||||
tick: u64,
|
||||
// The key contains input tile addresses. Keep those tiles alive until eviction:
|
||||
// edits must copy them, and the allocator cannot reuse their addresses for new tiles.
|
||||
_pixel_input: Option<Surface>,
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<Cache> {
|
||||
static C: OnceLock<Mutex<Cache>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(Cache { map: HashMap::new(), tick: 0 }))
|
||||
@@ -109,8 +117,8 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
c.tick += 1;
|
||||
let tick = c.tick;
|
||||
if let Some(e) = c.map.get_mut(&k) {
|
||||
e.1 = tick;
|
||||
return Some(e.0.clone());
|
||||
e.tick = tick;
|
||||
return Some(e.surface.clone());
|
||||
}
|
||||
}
|
||||
// Far outside the path the vector mask is constant.
|
||||
@@ -164,20 +172,49 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
}
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.map.len() >= CAPACITY
|
||||
&& let Some(old) = c.map.iter().min_by_key(|e| e.1.1).map(|e| *e.0)
|
||||
&& let Some(old) = c.map.iter().min_by_key(|e| e.1.tick).map(|e| *e.0)
|
||||
{
|
||||
c.map.remove(&old);
|
||||
}
|
||||
let tick = c.tick;
|
||||
c.map.insert(k, (s.clone(), tick));
|
||||
c.map.insert(k, CacheEntry { surface: s.clone(), tick, _pixel_input: layer.mask.as_ref().map(|m| m.surface.clone()) });
|
||||
Some(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::LayerMask;
|
||||
use photocraft_doc::vector::{Knot, Path, Subpath, VectorMask};
|
||||
|
||||
#[test]
|
||||
fn pixel_edits_refresh_cached_masks_at_every_depth() {
|
||||
let canvas = Rect::new(0, 0, 64, 64);
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut layer = Layer::raster("edited mask", PixelFormat::RGBA8);
|
||||
let mut mask = LayerMask::hide_all();
|
||||
mask.surface = Surface::new(PixelFormat { sample, ..FORMAT });
|
||||
mask.surface.fill_rect(canvas, &[1.0]);
|
||||
mask.feather = 4.0;
|
||||
layer.mask = Some(mask);
|
||||
|
||||
let first = combined_mask(&layer, canvas).unwrap();
|
||||
assert!((first.sample_channel(32, 32, 0) - 1.0).abs() < 1e-6);
|
||||
drop(first);
|
||||
// No document/undo snapshot shares the input. Previously this write could
|
||||
// reuse the original tile address and return the old cached white mask.
|
||||
layer.mask.as_mut().unwrap().surface.fill_rect(canvas, &[0.0]);
|
||||
let edited = combined_mask(&layer, canvas).unwrap();
|
||||
assert_eq!(edited.sample_channel(32, 32, 0), 0.0, "{sample:?}");
|
||||
|
||||
layer.mask.as_mut().unwrap().surface.fill_rect(canvas, &[0.5]);
|
||||
let repainted = combined_mask(&layer, canvas).unwrap();
|
||||
assert!((repainted.sample_channel(32, 32, 0) - 0.5).abs() < 0.005, "{sample:?}");
|
||||
let warm = combined_mask(&layer, canvas).unwrap();
|
||||
assert!(repainted.tiles().zip(warm.tiles()).all(|((_, a), (_, b))| Arc::ptr_eq(a, b)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_cpu_mask_and_is_cached() {
|
||||
let mut l = Layer::raster("l", PixelFormat::RGBA8);
|
||||
|
||||
@@ -71,6 +71,14 @@ choice!(
|
||||
/// CPU disables image acceleration; the native window may still need hardware graphics.
|
||||
RenderingMode { Auto = "auto", Gpu = "gpu", Cpu = "cpu" } default Auto
|
||||
);
|
||||
choice!(
|
||||
/// Linux display server of the desktop app's window (applies at next launch). `x11` runs
|
||||
/// PhotoCraft through XWayland on a Wayland session, where native file drag and drop works
|
||||
/// (winit 0.30 has none on Wayland, issue #386); fractional scaling may then look softer.
|
||||
/// It needs an X server (`DISPLAY`); without one PhotoCraft starts as `auto`. Other
|
||||
/// platforms ignore it.
|
||||
LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto
|
||||
);
|
||||
choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small);
|
||||
choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata);
|
||||
choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise);
|
||||
@@ -412,6 +420,8 @@ pub struct Performance {
|
||||
/// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off:
|
||||
/// they render at full resolution.
|
||||
pub low_resolution_previews: bool,
|
||||
/// Linux display server (applies at next launch; see [`LinuxDisplayServer`]).
|
||||
pub linux_display_server: LinuxDisplayServer,
|
||||
/// Memory budget of the layer-effect cache, in MB.
|
||||
pub effect_cache_mb: u32,
|
||||
pub legacy_compositing: bool,
|
||||
@@ -441,6 +451,7 @@ impl Default for Performance {
|
||||
rendering_mode: None,
|
||||
gpu_backend: GpuBackend::Auto,
|
||||
low_resolution_previews: true,
|
||||
linux_display_server: LinuxDisplayServer::Auto,
|
||||
effect_cache_mb: 768,
|
||||
legacy_compositing: false,
|
||||
}
|
||||
@@ -879,9 +890,13 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
|
||||
|
||||
/// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog?
|
||||
pub fn is_hidden(path: &str) -> bool {
|
||||
HIDDEN_UNTIL_IMPLEMENTED.contains(&path)
|
||||
HIDDEN_UNTIL_IMPLEMENTED.contains(&path) || (!cfg!(target_os = "linux") && LINUX_ONLY.contains(&path))
|
||||
}
|
||||
|
||||
/// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They
|
||||
/// still load, save and round-trip on every platform.
|
||||
pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer"];
|
||||
|
||||
/// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`).
|
||||
pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match path {
|
||||
@@ -913,6 +928,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
"rawDefaults.bitDepth" => RawDepth::NAMES,
|
||||
"rawDefaults.sharpenFor" => RawSharpen::NAMES,
|
||||
"performance.gpuBackend" => GpuBackend::NAMES,
|
||||
"performance.linuxDisplayServer" => LinuxDisplayServer::NAMES,
|
||||
"performance.renderingMode" => RenderingMode::NAMES,
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -363,3 +363,10 @@ fn gpu_backend_round_trips_and_validates() {
|
||||
assert_eq!(GpuBackend::parse(n).map(GpuBackend::name), Some(*n));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_only_preferences_show_only_on_linux() {
|
||||
assert_eq!(is_hidden("performance.linuxDisplayServer"), !cfg!(target_os = "linux"));
|
||||
assert!(!is_hidden("performance.gpuBackend"));
|
||||
assert!(LINUX_ONLY.iter().all(|p| choices(p).is_some()), "every Linux-only preference is a real one");
|
||||
}
|
||||
@@ -231,10 +231,57 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
|
||||
} else {
|
||||
layer.text = txt.unwrap_or_default().replace('\r', "\n");
|
||||
}
|
||||
// Some legacy PSDs store point text at a large local origin even though PointBase is zero.
|
||||
// Their transform maps that origin onto the canvas, but maps our zero-based layout far away.
|
||||
// Move the imported layout to the descriptor's ink origin so editing, engine commands, and
|
||||
// writing the TySh all agree on the visible position (#1469).
|
||||
if let Some([x, y]) = legacy_point_origin(&t) {
|
||||
let [a, b, c, d, e, f] = layer.transform.m;
|
||||
layer.transform.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f];
|
||||
}
|
||||
layer.sync_summary();
|
||||
Some(layer)
|
||||
}
|
||||
|
||||
/// The local origin to fold into a legacy point-text transform, if present.
|
||||
///
|
||||
/// Photoshop normally keeps point text close to its PointBase. The affected legacy files instead
|
||||
/// use a zero PointBase plus a very large `boundingBox` origin, with an equally remote transform
|
||||
/// translation that cancels it out. Do not apply this to ordinary point text: its transform origin
|
||||
/// is already the layout origin, and shifting it by the ink bounds would move the layer.
|
||||
fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> {
|
||||
let e = engine_data(&t.text)?;
|
||||
let point_base = e
|
||||
.path(&["EngineDict", "Rendered", "Shapes", "Children"])
|
||||
.and_then(E::as_array)
|
||||
.and_then(|children| children.first())
|
||||
.and_then(|shape| shape.path(&["Cookie", "Photoshop", "PointBase"]))
|
||||
.and_then(E::as_array)?;
|
||||
let [px, py] = point_base else { return None };
|
||||
if px.as_f64() != Some(0.0) || py.as_f64() != Some(0.0) {
|
||||
return None;
|
||||
}
|
||||
let bounds = descriptor_rect(&t.text, "boundingBox")?;
|
||||
let [a, b, c, d, e, f] = t.transform.m;
|
||||
let values = [a, b, c, d, e, f, bounds[0], bounds[1]];
|
||||
if !values.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f];
|
||||
let origin_distance = e.abs().max(f.abs());
|
||||
let shifted_distance = shifted[0].abs().max(shifted[1].abs());
|
||||
// A local bounding-box origin in the thousands and a transform origin beyond a normal canvas
|
||||
// are the signature of this old writer. Requiring that the bounding-box origin is closer to
|
||||
// the document prevents applying the correction again after export/import.
|
||||
(bounds[0].abs().max(bounds[1].abs()) > 1024.0 && origin_distance > 4096.0 && shifted_distance < origin_distance).then_some([bounds[0], bounds[1]])
|
||||
}
|
||||
|
||||
fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> {
|
||||
let D::Descriptor(rect) = text.get(key)? else { return None };
|
||||
let bounds = [dnum(rect.get("Left"))?, dnum(rect.get("Top "))?, dnum(rect.get("Rght"))?, dnum(rect.get("Btom"))?];
|
||||
(bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1]).then_some(bounds)
|
||||
}
|
||||
|
||||
pub(crate) fn arr_f(v: Option<&E>) -> Vec<f64> {
|
||||
v.and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -669,6 +669,48 @@ fn psd_round_trips_antialias_opentype_and_warp() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin.
|
||||
/// The TySh transform cancels that origin, so importing it as a zero-based layout puts text far
|
||||
/// off-canvas. Fold the descriptor origin into the imported transform and preserve it on a TySh
|
||||
/// round trip so every editing and export path uses the same position.
|
||||
#[test]
|
||||
fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() {
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, Value as D};
|
||||
|
||||
let source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() });
|
||||
let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap();
|
||||
tysh.transform = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] };
|
||||
let ink = [8050.0, 8160.0, 8220.0, 8300.0];
|
||||
let rect = |class| {
|
||||
D::Descriptor(
|
||||
Descriptor::new(class)
|
||||
.with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] })
|
||||
.with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] })
|
||||
.with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] })
|
||||
.with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }),
|
||||
)
|
||||
};
|
||||
tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox"));
|
||||
tysh.text.items.push((Id::new("bounds"), rect("bounds")));
|
||||
tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox")));
|
||||
let data = crate::psd::write_tysh(&tysh);
|
||||
|
||||
let imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap();
|
||||
let expected = [4.1667, 0.0, 0.0, 4.1667, 1166.935, 1112.772];
|
||||
for (got, want) in imported.transform.m.iter().zip(expected) {
|
||||
assert!((got - want).abs() < 0.001, "{:?}", imported.transform.m);
|
||||
}
|
||||
let (_, rendered) = TextEngine::new().render(&imported, 72.0, PixelFormat::RGBA8);
|
||||
let placed = rendered.surface.content_bounds();
|
||||
assert!(!placed.is_empty(), "the normalized layer renders");
|
||||
assert!((1000..3000).contains(&placed.x0) && (500..2000).contains(&placed.y0), "{placed:?}");
|
||||
|
||||
let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, Some(ink.map(|v| v as f32))), 72.0).unwrap();
|
||||
for (got, want) in round_trip.transform.m.iter().zip(expected) {
|
||||
assert!((got - want).abs() < 0.001, "{:?}", round_trip.transform.m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary)
|
||||
/// panicked with `expect("EngineDict")` when the layer was written back (PSD export).
|
||||
#[test]
|
||||
|
||||
@@ -2293,7 +2293,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
// Tools follow the left button; the right one opens the Brush Preset picker or erases
|
||||
// (Preferences › Tools, `paint_mouse`).
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
|
||||
// The Crop tool's frame is edited from the press (before egui's drag threshold), so the
|
||||
// pixels past the canvas show at once, as in Photoshop.
|
||||
@@ -3411,7 +3411,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
Tool::Type | Tool::VerticalType if crate::type_tool::pointer_down(app, x, y, mods.shift) => return,
|
||||
_ => {}
|
||||
}
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
let erase = tool == Tool::Eraser || std::mem::take(&mut app.secondary_erase);
|
||||
// ⇧-click after a stroke: a straight line from where it ended (stroke_constraint.rs).
|
||||
let active = app.session.active().map(|st| st.doc.id);
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
//! directly above in the stack: a line inside an open group, under a group's last child or
|
||||
//! across rows hidden by a filter clips nothing. While ⌥ is held over a line the pointer is the
|
||||
//! clip cursor, and a click there goes to the line, not to the rows on either side of it.
|
||||
//!
|
||||
//! Measured on Photoshop 25.4 (Background, a pixel layer, an adjustment layer and a layer on
|
||||
//! top; ⌥-hover and ⌥-click swept a pixel at a time across each line, the result read back by
|
||||
//! script):
|
||||
//! - the line takes the pointer from 3 px above it to 5 px below it;
|
||||
//! - a click that clips keeps the active layer;
|
||||
//! - a click that releases frees the layer above the line together with the clipped layers
|
||||
//! stacked above it (in one history step), and makes it the active layer;
|
||||
//! - the release cursor is the clip cursor struck through.
|
||||
|
||||
use egui::{Pos2, Rangef, Rect, Sense, vec2};
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
@@ -12,10 +21,13 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::layer_row_ui::{RIGHT_PAD, RowRects};
|
||||
|
||||
/// Half the height of a line's hit band (the thumbnails stay clear of it).
|
||||
const BAND: f32 = 3.0;
|
||||
/// How far above and below a line its hit band reaches (Photoshop: 3 px up, 5 px down).
|
||||
const ABOVE: f32 = 3.5;
|
||||
const BELOW: f32 = 5.0;
|
||||
/// The eye column, left of the band: ⌥-click there shows only that layer.
|
||||
const EYE_W: f32 = 30.0;
|
||||
/// Size of the clip cursors (points).
|
||||
const CURSOR_SIZE: f32 = 22.0;
|
||||
|
||||
/// A line between two layers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -29,13 +41,35 @@ pub struct ClipLine {
|
||||
}
|
||||
|
||||
impl ClipLine {
|
||||
/// The Layer menu command a click runs.
|
||||
pub fn command(&self) -> (String, Value) {
|
||||
let id = if self.release { "layer.releaseClippingMask" } else { "layer.createClippingMask" };
|
||||
(id.into(), json!({"layer": self.upper.0}))
|
||||
/// The commands a click runs: Create Clipping Mask on the upper layer, or Release Clipping
|
||||
/// Mask on it and the clipped layers stacked above it (`key` makes that one history step)
|
||||
/// before it becomes the active layer.
|
||||
pub fn commands(&self, doc: &Document, key: &str) -> Vec<(String, Value)> {
|
||||
if !self.release {
|
||||
return vec![("layer.createClippingMask".into(), json!({"layer": self.upper.0}))];
|
||||
}
|
||||
let mut out: Vec<(String, Value)> =
|
||||
release_chain(doc, self.upper).into_iter().map(|id| ("layer.releaseClippingMask".into(), json!({"layer": id.0, "coalesce": key}))).collect();
|
||||
out.push(("layer.select".into(), json!({"layer": self.upper.0, "mode": "replace"})));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers a release on `upper` frees: it and the clipped layers stacked directly above it.
|
||||
fn release_chain(doc: &Document, upper: LayerId) -> Vec<LayerId> {
|
||||
let mut out = vec![upper];
|
||||
let Some(mut path) = doc.path_of(upper) else { return out };
|
||||
while let Some(last) = path.pop() {
|
||||
let Some(next) = last.checked_add(1) else { break };
|
||||
path.push(next);
|
||||
match doc.layer_at(&path) {
|
||||
Some(l) if l.clipped => out.push(l.id),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The line under `p` among the layer rows the panel drew (top to bottom), if two layers that
|
||||
/// can clip meet there.
|
||||
pub fn at(doc: &Document, rows: &[RowRects], p: Pos2) -> Option<ClipLine> {
|
||||
@@ -44,7 +78,7 @@ pub fn at(doc: &Document, rows: &[RowRects], p: Pos2) -> Option<ClipLine> {
|
||||
// Between the eye column and the right-hand indicators (the effects triangle takes clicks).
|
||||
let right = above.indicators.iter().chain(&below.indicators).map(|(_, r)| r.left()).fold(below.row.right() - RIGHT_PAD, f32::min);
|
||||
let y = below.row.top();
|
||||
let rect = Rect::from_x_y_ranges(Rangef::new(below.row.left() + EYE_W, right), Rangef::new(y - BAND, y + BAND));
|
||||
let rect = Rect::from_x_y_ranges(Rangef::new(below.row.left() + EYE_W, right), Rangef::new(y - ABOVE, y + BELOW));
|
||||
if !rect.contains(p) {
|
||||
return None;
|
||||
}
|
||||
@@ -74,11 +108,14 @@ pub fn show(ui: &mut egui::Ui, doc: &Document, actions: &mut Vec<(String, Value)
|
||||
let resp = ui.interact(line.rect, ui.id().with("clip-line"), Sense::click());
|
||||
let label = if line.release { "Release Clipping Mask" } else { "Create Clipping Mask" };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
// The arrow's tip, (15, 20) of its 24-unit box, points at the line.
|
||||
crate::icons::cursor(&ctx, "corner-right-down", p, vec2(15.0, 20.0) / 24.0, 18.0);
|
||||
// PhotoCraft's own cursors: a hooked arrow onto the layer below, and the same arrow with a
|
||||
// small "no" badge to release. The arrow's tip, (15, 16) of the 24-unit box, is the hot spot.
|
||||
let icon = if line.release { "clip-release" } else { "clip-below" };
|
||||
crate::icons::cursor(&ctx, icon, p, vec2(15.0, 16.0) / 24.0, CURSOR_SIZE);
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
if resp.clicked() {
|
||||
actions.push(line.command());
|
||||
let key = format!("clip-line:{}:{}", line.upper.0, ctx.cumulative_pass_nr());
|
||||
actions.extend(line.commands(doc, &key));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ fn only_siblings_next_to_each_other_meet_at_a_line() {
|
||||
assert_eq!(at(&doc, &open, pos2(x, 64.0)), None, "a group's last child and the layer below the group");
|
||||
let line = at(&doc, &open, pos2(x, 96.0)).expect("two top-level layers");
|
||||
assert_eq!((line.upper, line.release), (d, false));
|
||||
assert_eq!(line.command(), ("layer.createClippingMask".to_string(), json!({"layer": d.0})));
|
||||
assert_eq!(line.commands(&doc, "k"), vec![("layer.createClippingMask".to_string(), json!({"layer": d.0}))]);
|
||||
assert_eq!(at(&doc, &open, pos2(10.0, 96.0)), None, "the eye column");
|
||||
assert_eq!(at(&doc, &open, pos2(x, 90.0)), None, "inside a row");
|
||||
// A closed group sits directly above the layer below it, and can be clipped to it.
|
||||
@@ -120,5 +120,91 @@ fn only_siblings_next_to_each_other_meet_at_a_line() {
|
||||
assert_eq!(at(&doc, &[row(g, 0.0), row(bg, 32.0)], pos2(x, 32.0)), None);
|
||||
doc.layer_mut(d).unwrap().clipped = true;
|
||||
let line = at(&doc, &closed, pos2(x, 64.0)).unwrap();
|
||||
assert_eq!(line.command(), ("layer.releaseClippingMask".to_string(), json!({"layer": d.0})));
|
||||
assert_eq!(
|
||||
line.commands(&doc, "k"),
|
||||
vec![
|
||||
("layer.releaseClippingMask".to_string(), json!({"layer": d.0, "coalesce": "k"})),
|
||||
("layer.select".to_string(), json!({"layer": d.0, "mode": "replace"})),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_band_reaches_three_above_and_five_below_the_line() {
|
||||
// Photoshop 25.4: the line takes the pointer from 3 px above it to 5 px below it.
|
||||
let (s, a, b) = two_layers();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let bg = doc.layers[0].id;
|
||||
let rows = [row(b, 0.0), row(a, 32.0), row(bg, 64.0)];
|
||||
let x = 100.0;
|
||||
for y in [61.0, 64.0, 68.9] {
|
||||
assert_eq!(at(&doc, &rows, pos2(x, y)).map(|l| l.upper), Some(a), "y {y}");
|
||||
}
|
||||
for y in [60.0, 69.5] {
|
||||
assert_eq!(at(&doc, &rows, pos2(x, y)), None, "y {y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Background, Layer 1, CB (Color Balance), Top; Top active.
|
||||
fn chain() -> (photocraft_engine::Session, [LayerId; 3]) {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
let l1 = s.execute("layer.new.layer", json!({"name": "Layer 1"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.newAdjustmentLayer.colorBalance", json!({})).unwrap();
|
||||
let cb = s.active().unwrap().active_layer.unwrap().0;
|
||||
let top = s.execute("layer.new.layer", json!({"name": "Top"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, [LayerId(l1), LayerId(cb), LayerId(top)])
|
||||
}
|
||||
|
||||
/// A point on the line along the top of `layer`'s row, `dy` below it, right of the eye column.
|
||||
fn on_line(h: &Harness<'_, PhotocraftApp>, layer: LayerId, dy: f32) -> Pos2 {
|
||||
let r = recorded(&h.ctx).into_iter().find(|r| r.layer == layer.0).unwrap().row;
|
||||
pos2(r.left() + 80.0, r.top() + dy)
|
||||
}
|
||||
|
||||
fn active(h: &Harness<'_, PhotocraftApp>) -> Option<LayerId> {
|
||||
h.state().session.active().unwrap().active_layer
|
||||
}
|
||||
|
||||
fn steps(h: &Harness<'_, PhotocraftApp>) -> usize {
|
||||
h.state().session.active().unwrap().history.entries().len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_frees_the_clipped_layers_above_too_and_selects_the_layer() {
|
||||
// Photoshop 25.4: a release in the middle of a clipping chain frees the layer above the line
|
||||
// and every clipped layer stacked above it, in one history step, and makes it active.
|
||||
let (s, [l1, cb, top]) = chain();
|
||||
let mut h = harness(s);
|
||||
// Clip CB onto Layer 1 (from inside the band, 4 px below the line), then Top onto CB.
|
||||
let p = on_line(&h, l1, 4.0);
|
||||
click(&mut h, p, Modifiers::ALT);
|
||||
assert!(clipped(&h, cb) && !clipped(&h, top));
|
||||
assert_eq!(active(&h), Some(top), "clipping keeps the active layer");
|
||||
let p = on_line(&h, cb, -2.5);
|
||||
click(&mut h, p, Modifiers::ALT);
|
||||
assert!(clipped(&h, top) && clipped(&h, cb));
|
||||
let before = steps(&h);
|
||||
let p = on_line(&h, l1, 2.0);
|
||||
click(&mut h, p, Modifiers::ALT);
|
||||
assert!(!clipped(&h, cb) && !clipped(&h, top), "the clipped layer above is released too");
|
||||
assert_eq!(active(&h), Some(cb), "the released layer becomes active");
|
||||
assert_eq!(steps(&h), before + 1, "one history step");
|
||||
assert!(h.state_mut().session.undo());
|
||||
assert!(clipped(&h, cb) && clipped(&h, top), "one Undo brings the chain back");
|
||||
// Releasing the top of a chain leaves the layers below it clipped.
|
||||
let p = on_line(&h, cb, 0.0);
|
||||
click(&mut h, p, Modifiers::ALT);
|
||||
assert!(!clipped(&h, top) && clipped(&h, cb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_the_band_an_alt_click_does_not_clip() {
|
||||
let (s, [l1, cb, _]) = chain();
|
||||
let mut h = harness(s);
|
||||
for dy in [-4.5, 6.0] {
|
||||
let p = on_line(&h, l1, dy);
|
||||
click(&mut h, p, Modifiers::ALT);
|
||||
assert!(!clipped(&h, cb), "{dy} px from the line");
|
||||
}
|
||||
}
|
||||
@@ -375,6 +375,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
// Apply (nothing below can fail).
|
||||
if let Some(t) = tool {
|
||||
app.ui.tool = t;
|
||||
// Each tool keeps its own brush (#218), so switch it in before `brushSize`
|
||||
// below sets the new tool's size.
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
}
|
||||
let gradient_before = app.ui.tool_options.clone();
|
||||
if let Some(mode) = gradient_blend {
|
||||
@@ -1088,6 +1091,16 @@ mod tests {
|
||||
assert_eq!(app.session.journal.len(), journal_len);
|
||||
}
|
||||
|
||||
// Each tool keeps its own brush (#218), so `tool` + `brushSize` in one call sets the new
|
||||
// tool's size rather than the one it was carrying.
|
||||
call(&mut app, &ctx, "ui.set", json!({"tool": "eraser", "brushSize": 12.0}));
|
||||
assert_eq!(app.session.tools.brush.size, 12.0, "the Eraser's own size");
|
||||
call(&mut app, &ctx, "ui.set", json!({"tool": "brush"}));
|
||||
assert_eq!(app.session.tools.brush.size, 42.5, "the Brush gets its own back");
|
||||
call(&mut app, &ctx, "ui.set", json!({"tool": "eraser"}));
|
||||
assert_eq!(app.session.tools.brush.size, 12.0);
|
||||
call(&mut app, &ctx, "ui.set", json!({"tool": "brush", "brushSize": 42.5}));
|
||||
|
||||
// Values that cannot be represented by BrushSettings must report the command error and
|
||||
// leave both the brush and journal unchanged instead of mutating tool state directly.
|
||||
let journal_len = app.session.journal.len();
|
||||
|
||||
@@ -88,14 +88,23 @@ impl PhotocraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called once per frame: holds back a window close request while there is unsaved work.
|
||||
/// Called once per frame: holds back a window close request while there is unsaved work. Every
|
||||
/// way of leaving (File › Exit / Quit, the close button, the OS's quit event, `app.quit`) ends up
|
||||
/// here as a close request.
|
||||
pub fn guard_window_close(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if app.allow_close || !ctx.input(|i| i.viewport().close_requested()) {
|
||||
if !ctx.input(|i| i.viewport().close_requested()) {
|
||||
return;
|
||||
}
|
||||
if intercept(app, EXIT, &Value::Null) {
|
||||
if !app.allow_close && intercept(app, EXIT, &Value::Null) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
return;
|
||||
}
|
||||
// Leaving: through the platform's own quit where there is one (see `Services::quit`), with
|
||||
// the window left open for it to close.
|
||||
if let Some(quit) = app.services.quit.as_mut() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
quit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,15 +525,20 @@ mod tests {
|
||||
assert!(h.state().session.documents().is_empty());
|
||||
}
|
||||
|
||||
/// One frame with the window's close button pressed; whether the guard cancelled the close.
|
||||
fn press_window_close(app: &mut PhotocraftApp) -> bool {
|
||||
/// One frame with the window's close button pressed; the commands the guard sent.
|
||||
fn window_close_commands(app: &mut PhotocraftApp) -> Vec<egui::ViewportCommand> {
|
||||
let mut info = egui::ViewportInfo::default();
|
||||
info.events.push(egui::ViewportEvent::Close);
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
let mut out = egui::Context::default().run_ui(input, |ui| guard_window_close(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let commands = &out.viewport_output[&egui::ViewportId::ROOT].commands;
|
||||
out.viewport_output.remove(&egui::ViewportId::ROOT).map(|o| o.commands).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One frame with the window's close button pressed; whether the guard cancelled the close.
|
||||
fn press_window_close(app: &mut PhotocraftApp) -> bool {
|
||||
let commands = &window_close_commands(app);
|
||||
let cancelled = commands.iter().any(|c| matches!(c, egui::ViewportCommand::CancelClose));
|
||||
assert_eq!(commands.iter().any(|c| matches!(c, egui::ViewportCommand::Focus)), cancelled);
|
||||
cancelled
|
||||
@@ -557,4 +571,33 @@ mod tests {
|
||||
assert!(!press_window_close(&mut app));
|
||||
assert!(app.discard.is_none());
|
||||
}
|
||||
|
||||
/// With a platform quit (macOS), leaving runs it and keeps the window open for it to close;
|
||||
/// unsaved work still asks first.
|
||||
#[test]
|
||||
fn leaving_runs_the_platform_quit_instead_of_closing_the_window() {
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
let quits = Rc::new(Cell::new(0));
|
||||
let mut app = app_with_docs(1);
|
||||
let counted = quits.clone();
|
||||
app.services.quit = Some(Box::new(move || counted.set(counted.get() + 1)));
|
||||
// The close is cancelled for the quit to do it, with no prompt brought forward.
|
||||
let cancels_only = |c: &[egui::ViewportCommand]| {
|
||||
c.iter().any(|c| matches!(c, egui::ViewportCommand::CancelClose)) && !c.iter().any(|c| matches!(c, egui::ViewportCommand::Focus))
|
||||
};
|
||||
|
||||
assert!(cancels_only(&window_close_commands(&mut app)), "the window stays for the quit to close");
|
||||
assert_eq!(quits.get(), 1);
|
||||
|
||||
make_dirty(&mut app, 0);
|
||||
assert!(press_window_close(&mut app), "unsaved work asks first");
|
||||
assert_eq!(quits.get(), 1, "no quit while the prompt is up");
|
||||
assert!(app.discard.is_some());
|
||||
|
||||
// Don't Save: the prompt confirms, and the close it sends quits.
|
||||
app.discard = None;
|
||||
app.allow_close = true;
|
||||
assert!(cancels_only(&window_close_commands(&mut app)));
|
||||
assert_eq!(quits.get(), 2);
|
||||
}
|
||||
}
|
||||
@@ -996,6 +996,7 @@
|
||||
Native file drag-and-drop is unavailable Nativní přetahování souborů ve Waylandu není dostupné
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Nativní přetahování souborů ve Waylandu zatím není podporováno. Použijte Soubor › Otevřít, nebo obrázek zkopírujte ve správci souborů a vložte ho pomocí {paste}.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Chcete-li soubory přetahovat, spusťte PhotoCraft v XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Nebo nastavte Předvolby › Výkon › Zobrazovací server Linuxu na X11: PhotoCraft se pak vždy spustí v XWayland.
|
||||
Edit › Paste Úpravy › Vložit
|
||||
Duplicate Layers… Duplikovat vrstvy…
|
||||
Duplicate style Duplikovat styl
|
||||
@@ -2028,6 +2029,8 @@
|
||||
CPU (no GPU acceleration) CPU (bez akcelerace GPU)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend Grafický backend
|
||||
Linux display server Zobrazovací server Linuxu
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure Použít přítlak tabletu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -980,6 +980,7 @@
|
||||
Native file drag-and-drop is unavailable Natives Ziehen und Ablegen von Dateien ist nicht verfügbar
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Natives Ziehen und Ablegen von Dateien wird unter Wayland noch nicht unterstützt. Nutze Datei › Öffnen oder kopiere das Bild im Dateimanager und füge es mit {paste} ein.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Um Dateien abzulegen, starte PhotoCraft unter XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Oder stelle Voreinstellungen › Leistung › Linux-Anzeigeserver auf X11: PhotoCraft startet dann immer unter XWayland.
|
||||
Edit › Paste Bearbeiten › Einfügen
|
||||
Duplicate Layers… Ebenen duplizieren…
|
||||
Duplicate style Stil duplizieren
|
||||
@@ -2001,6 +2002,8 @@
|
||||
CPU (no GPU acceleration) CPU (keine GPU-Beschleunigung)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend GPU-Backend
|
||||
Linux display server Linux-Anzeigeserver
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure Tablettdruck verwenden
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1040,6 +1040,8 @@
|
||||
Keep Using CPU Συνέχεια με CPU
|
||||
Retry GPU Νέα προσπάθεια χρήσης GPU
|
||||
GPU Backend Σύστημα GPU
|
||||
Linux display server Διακομιστής οθόνης Linux
|
||||
X11 X11
|
||||
Full-resolution preview unavailable Δεν είναι διαθέσιμη προεπισκόπηση πλήρους ανάλυσης
|
||||
No patterns Δεν υπάρχουν μοτίβα
|
||||
Enable {name} Ενεργοποίηση: {name}
|
||||
@@ -3332,6 +3334,7 @@ cameraRaw Yellow Κίτρινο
|
||||
Seafoam Θαλασσί
|
||||
Srgb sRGB
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Για να αποθέσετε αρχεία, εκκινήστε το PhotoCraft μέσω XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ή ορίστε Προτιμήσεις › Επιδόσεις › Διακομιστής οθόνης Linux σε X11: το PhotoCraft θα εκκινεί τότε πάντα μέσω XWayland.
|
||||
Vulkan Vulkan
|
||||
WebP (lossless) WebP (χωρίς απώλειες)
|
||||
Webp WebP
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 960 and column 18.
|
@@ -981,6 +981,7 @@
|
||||
Native file drag-and-drop is unavailable El arrastrar y soltar archivos de forma nativa no está disponible en Wayland
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. El arrastrar y soltar archivos de forma nativa aún no está disponible en Wayland. Usa Archivo › Abrir, o copia la imagen en tu gestor de archivos y pégala con {paste}.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Para soltar archivos, inicia PhotoCraft bajo XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. O establece Preferencias › Rendimiento › Servidor de pantalla de Linux en X11: PhotoCraft se iniciará siempre bajo XWayland.
|
||||
Edit › Paste Edición › Pegar
|
||||
Duplicate Layers… Duplicar capas…
|
||||
Duplicate style Duplicar estilo
|
||||
@@ -2013,6 +2014,8 @@
|
||||
CPU (no GPU acceleration) CPU (sin aceleración por GPU)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend Backend de GPU
|
||||
Linux display server Servidor de pantalla de Linux
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure Usar la presión de la tableta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -1981,6 +1981,8 @@
|
||||
CPU (no GPU acceleration) CPU (sans accélération GPU)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend Moteur GPU
|
||||
Linux display server Serveur d'affichage Linux
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure Utiliser la pression de la tablette
|
||||
@@ -2471,6 +2473,7 @@
|
||||
Native file drag-and-drop is unavailable Le glisser-déposer natif de fichiers n'est pas disponible
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Le glisser-déposer natif de fichiers n'est pas encore pris en charge sous Wayland. Utilisez Fichier › Ouvrir, ou copiez l'image dans votre gestionnaire de fichiers et collez-la avec {paste}.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Pour déposer des fichiers, lancez PhotoCraft sous XWayland : `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ou réglez Préférences › Performances › Serveur d'affichage Linux sur X11 : PhotoCraft démarre alors toujours sous XWayland.
|
||||
Edit › Paste Édition › Coller
|
||||
Use File › Open, or paste a copied image with {paste}. Utilisez Fichier › Ouvrir, ou collez une image copiée avec {paste}.
|
||||
Mixer Brush Tool Outil Pinceau mélangeur
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1975,6 +1975,8 @@
|
||||
CPU (no GPU acceleration) CPU (tanpa akselerasi GPU)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend Backend GPU
|
||||
Linux display server Server tampilan Linux
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure Pakai tekanan tablet
|
||||
@@ -2460,6 +2462,7 @@
|
||||
Native file drag-and-drop is unavailable Seret dan lepas berkas native tidak tersedia
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Seret dan lepas berkas native belum didukung di Wayland. Gunakan Berkas › Buka, atau salin gambar di pengelola berkas lalu tempel dengan {paste}.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Untuk menjatuhkan berkas, jalankan PhotoCraft di XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Atau atur Preferensi › Performa › Server tampilan Linux ke X11: PhotoCraft lalu selalu berjalan di XWayland.
|
||||
Edit › Paste Edit › Tempel
|
||||
Use File › Open, or paste a copied image with {paste}. Gunakan Berkas › Buka, atau tempel gambar yang disalin dengan {paste}.
|
||||
Mixer Brush Tool Alat Kuas Pencampur
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -980,6 +980,7 @@
|
||||
Native file drag-and-drop is unavailable Il drag-and-drop nativo file non è disponibile su Wayland
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Il drag-and-drop nativo non è ancora supportato su Wayland. Usa File › Apri, oppure copia l'immagine nel file manager e incollala con {paste}.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Per trascinare file, avvia PhotoCraft sotto XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Oppure imposta Preferenze › Prestazioni › Server grafico Linux su X11: PhotoCraft partirà sempre sotto XWayland.
|
||||
Edit › Paste Modifica › Incolla
|
||||
Duplicate Layers… Duplica livelli…
|
||||
Duplicate style Duplica stile
|
||||
@@ -2011,6 +2012,8 @@
|
||||
CPU (no GPU acceleration) CPU (nessuna accelerazione GPU)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend Backend GPU
|
||||
Linux display server Server grafico Linux
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure Usa pressione tavoletta
|
||||
|
||||
|
@@ -986,6 +986,7 @@
|
||||
Native file drag-and-drop is unavailable Wayland ではネイティブのファイルドラッグ&ドロップを利用できません
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland ではネイティブのファイルドラッグ&ドロップはまだ利用できません。[ファイル › 開く] を使うか、ファイルマネージャーで画像をコピーして {paste} で貼り付けてください。
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` ファイルをドロップするには、XWayland 上で PhotoCraft を起動してください: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. または 環境設定 › パフォーマンス › Linux ディスプレイサーバー を X11 にすると、PhotoCraft は常に XWayland 上で起動します。
|
||||
Edit › Paste [編集 › 貼り付け]
|
||||
Duplicate Layers… レイヤーを複製…
|
||||
Duplicate style スタイルを複製
|
||||
@@ -2019,6 +2020,8 @@
|
||||
CPU (no GPU acceleration) CPU(GPU アクセラレーションなし)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend GPU バックエンド
|
||||
Linux display server Linux ディスプレイサーバー
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure タブレットの筆圧を使用
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1975,6 +1975,8 @@
|
||||
CPU (no GPU acceleration) CPU(GPU 가속 없음)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend GPU 백엔드
|
||||
Linux display server Linux 디스플레이 서버
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure 태블릿 필압 사용
|
||||
@@ -2001,6 +2003,7 @@
|
||||
Native file drag-and-drop is unavailable 파일 드래그 앤 드롭을 사용할 수 없습니다
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland에서는 아직 파일 드래그 앤 드롭을 지원하지 않습니다. 파일 › 열기를 사용하거나, 파일 관리자에서 이미지를 복사한 뒤 {paste}(으)로 붙여넣으세요.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` 파일을 끌어다 놓으려면 XWayland에서 PhotoCraft를 실행하세요: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. 또는 환경 설정 › 성능 › Linux 디스플레이 서버를 X11로 설정하면 PhotoCraft가 항상 XWayland에서 시작됩니다.
|
||||
Edit › Paste 편집 › 붙여넣기
|
||||
Use File › Open, or paste a copied image with {paste}. 파일 › 열기를 사용하거나, 복사한 이미지를 {paste}(으)로 붙여넣으세요.
|
||||
Invalid {mode} profile 유효하지 않은 {mode} 프로파일
|
||||
|
||||
|
@@ -2007,6 +2007,8 @@
|
||||
CPU (no GPU acceleration) CPU (bez akceleracji GPU)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend Backend GPU
|
||||
Linux display server Serwer wyświetlania Linuksa
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure Użyj nacisku tabletu
|
||||
@@ -3209,6 +3211,7 @@ cameraRaw Roughness Chropowatość
|
||||
{path}: the path is not valid Unicode; rename the file and open it again. {path}: ścieżka nie jest prawidłowym Unicode; zmień nazwę pliku i otwórz go ponownie.
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Natywne przeciąganie i upuszczanie plików nie jest jeszcze obsługiwane w Wayland. Użyj Plik › Otwórz albo skopiuj obraz w menedżerze plików i wklej go za pomocą {paste}.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Aby upuszczać pliki, uruchom PhotoCraft pod XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Albo ustaw Preferencje › Wydajność › Serwer wyświetlania Linuksa na X11: PhotoCraft będzie wtedy zawsze uruchamiany pod XWayland.
|
||||
Edit › Paste Edycja › Wklej
|
||||
Use File › Open, or paste a copied image with {paste}. Użyj Plik › Otwórz albo wklej skopiowany obraz za pomocą {paste}.
|
||||
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1980,6 +1980,8 @@
|
||||
CPU (no GPU acceleration) CPU (sem aceleração por GPU)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend Backend da GPU
|
||||
Linux display server Servidor de exibição Linux
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure Usar a pressão da mesa digitalizadora
|
||||
@@ -2467,6 +2469,7 @@
|
||||
Native file drag-and-drop is unavailable Arrastar e soltar arquivos não está disponível
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. O recurso nativo de arrastar e soltar arquivos ainda não funciona no Wayland. Use Arquivo › Abrir ou copie a imagem no gerenciador de arquivos e cole-a com {paste}.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Para soltar arquivos, execute o PhotoCraft no XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ou defina Preferências › Desempenho › Servidor de exibição Linux como X11: o PhotoCraft passa a iniciar sempre no XWayland.
|
||||
Edit › Paste Editar › Colar
|
||||
Use File › Open, or paste a copied image with {paste}. Use Arquivo › Abrir ou cole uma imagem copiada com {paste}.
|
||||
Mixer Brush Tool Ferramenta Pincel misturador
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1395,6 +1395,7 @@
|
||||
Native file drag-and-drop is unavailable Нативное перетаскивание файлов в Wayland недоступно
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Нативное перетаскивание файлов в Wayland пока не поддерживается. Откройте файл через Файл › Открыть или скопируйте изображение в файловом менеджере и вставьте его с помощью {paste}.
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` Чтобы перетаскивать файлы, запустите PhotoCraft в XWayland: `{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Или выберите в Настройки › Производительность › Дисплейный сервер Linux значение X11: PhotoCraft будет всегда запускаться в XWayland.
|
||||
Edit › Paste Редактирование › Вставить
|
||||
Photo Фото
|
||||
Print Печать
|
||||
@@ -2179,6 +2180,8 @@
|
||||
Font preview Предпросмотр шрифтов
|
||||
Full size tip Весь диаметр кисти
|
||||
GPU backend Бэкенд GPU
|
||||
Linux display server Дисплейный сервер Linux
|
||||
X11 X11
|
||||
Gamut warning color Цвет предупреждения об охвате
|
||||
Gamut warning opacity Непрозрачность предупреждения об охвате
|
||||
General Основные
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -986,6 +986,7 @@
|
||||
Native file drag-and-drop is unavailable Wayland 上不支持原生文件拖放
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland 尚不支持原生文件拖放。请使用“文件 › 打开”,或在文件管理器中复制图像后使用 {paste} 粘贴。
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` 要拖放文件,请在 XWayland 下启动 PhotoCraft:`{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. 或将 首选项 › 性能 › Linux 显示服务器 设为 X11:PhotoCraft 将始终在 XWayland 下启动。
|
||||
Edit › Paste “编辑 › 粘贴”
|
||||
Duplicate Layers… 复制图层…
|
||||
Duplicate style 复制样式
|
||||
@@ -2069,6 +2070,8 @@ cameraRaw Blending 混合
|
||||
CPU (no GPU acceleration) CPU(无 GPU 加速)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend GPU 后端
|
||||
Linux display server Linux 显示服务器
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure 使用数位板压感
|
||||
|
||||
|
@@ -987,6 +987,7 @@
|
||||
Native file drag-and-drop is unavailable Wayland 不支援原生檔案拖放
|
||||
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland 尚未支援原生檔案拖放。請使用「檔案 › 開啟」,或在檔案管理員中複製影像後使用 {paste} 貼上。
|
||||
To drop files, start PhotoCraft under XWayland: `{command}` 若要拖放檔案,請在 XWayland 下啟動 PhotoCraft:`{command}`
|
||||
Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. 或將 偏好設定 › 效能 › Linux 顯示伺服器 設為 X11:PhotoCraft 之後一律在 XWayland 下啟動。
|
||||
Edit › Paste 「編輯 › 貼上」
|
||||
Duplicate Layers… 複製圖層…
|
||||
Duplicate style 複製樣式
|
||||
@@ -2010,6 +2011,8 @@
|
||||
CPU (no GPU acceleration) CPU (無 GPU 加速)
|
||||
DirectX 12 DirectX 12
|
||||
GPU backend GPU 後端
|
||||
Linux display server Linux 顯示伺服器
|
||||
X11 X11
|
||||
Metal Metal
|
||||
OpenGL OpenGL
|
||||
Use tablet pressure 使用繪圖板壓力
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -21,12 +21,13 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("circle", include_bytes!("../../../assets/icons/circle.svg")),
|
||||
("circle-dashed", include_bytes!("../../../assets/icons/circle-dashed.svg")),
|
||||
("circle-dot", include_bytes!("../../../assets/icons/circle-dot.svg")),
|
||||
("clip-below", include_bytes!("../../../assets/icons/clip-below.svg")),
|
||||
("clip-release", include_bytes!("../../../assets/icons/clip-release.svg")),
|
||||
("clock", include_bytes!("../../../assets/icons/clock.svg")),
|
||||
("cloud", include_bytes!("../../../assets/icons/cloud.svg")),
|
||||
("compass", include_bytes!("../../../assets/icons/compass.svg")),
|
||||
("contrast", include_bytes!("../../../assets/icons/contrast.svg")),
|
||||
("copy", include_bytes!("../../../assets/icons/copy.svg")),
|
||||
("corner-right-down", include_bytes!("../../../assets/icons/corner-right-down.svg")),
|
||||
("crop", include_bytes!("../../../assets/icons/crop.svg")),
|
||||
("diamond", include_bytes!("../../../assets/icons/diamond.svg")),
|
||||
("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")),
|
||||
|
||||
@@ -233,6 +233,8 @@ pub struct Recovered {
|
||||
pub type AppendTextFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
|
||||
/// Requests from the operating system since the last call (see [`OsEvent`]).
|
||||
pub type OsEventsFn = Box<dyn FnMut() -> Vec<OsEvent>>;
|
||||
/// Ends the app the platform's own way (see [`Services::quit`]).
|
||||
pub type QuitFn = Box<dyn FnMut()>;
|
||||
/// Where the OS pointer is now, in egui points within the window; `None` when unknown.
|
||||
pub type CursorPosFn = Box<dyn FnMut(&egui::Context) -> Option<egui::Pos2>>;
|
||||
|
||||
@@ -300,6 +302,11 @@ pub struct Services {
|
||||
pub read_displays: Option<monitor_status::ReadDisplaysFn>,
|
||||
/// The macOS menu bar, when the desktop app installed one; the in-window menus are hidden then.
|
||||
pub native_menu: Option<native_menu::NativeMenu>,
|
||||
/// Ends the app once leaving is settled (nothing unsaved, or the prompt answered), instead of
|
||||
/// letting eframe close the window. macOS: `-[NSApplication terminate:]`. Closing the window
|
||||
/// while AppKit's run loop is still going crashes or hangs Touch Bar Macs (#1575, #1458).
|
||||
/// Without one, eframe closes the window and the app ends with it.
|
||||
pub quit: Option<QuitFn>,
|
||||
}
|
||||
|
||||
/// A document histogram being computed off the UI thread: (document, revision, receiver of
|
||||
@@ -1121,6 +1128,9 @@ impl eframe::App for PhotocraftApp {
|
||||
return;
|
||||
}
|
||||
let t0 = gpu_canvas::now_ms();
|
||||
// Each painting tool keeps its own brush (#218), so switch the active tool's brush in
|
||||
// before anything this frame reads it (the cursor, the options bar, a stroke).
|
||||
paint_mouse::sync_tool_brush(self);
|
||||
// View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns).
|
||||
screen_picker::show(&ctx);
|
||||
if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) {
|
||||
|
||||
@@ -79,6 +79,7 @@ pub fn wayland_file_drop_guidance(app: &mut PhotocraftApp) {
|
||||
if let Some(command) = &app.services.xwayland_command {
|
||||
lines.push("To drop files, start PhotoCraft under XWayland: `{command}`".to_owned());
|
||||
args.push(("command".to_owned(), command.clone()));
|
||||
lines.push("Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland.".to_owned());
|
||||
}
|
||||
let id = post(app, "Native file drag-and-drop is unavailable", lines, false, Some(WAYLAND_FILE_DROP_DISMISSED));
|
||||
if let Some(notice) = app.ui.notices.iter_mut().find(|notice| notice.id == id) {
|
||||
@@ -229,6 +230,7 @@ mod tests {
|
||||
assert!(guidance.contains(&format!("paste it with {paste}")), "{guidance}");
|
||||
// The relaunch command is the one for this install (an AppImage here), not a guess.
|
||||
assert!(guidance.contains("under XWayland: `WAYLAND_DISPLAY= '/apps/Photo Craft.AppImage'`"), "{guidance}");
|
||||
assert!(guidance.contains("Linux display server to X11"), "{guidance}");
|
||||
assert_eq!(app.ui.notices[0].dismiss_pref.as_deref(), Some(WAYLAND_FILE_DROP_DISMISSED));
|
||||
for i in 0..MAX_NOTICES {
|
||||
post(&mut app, format!("Transient {i}"), Vec::new(), false, None);
|
||||
|
||||
@@ -5,38 +5,55 @@
|
||||
//! with `"erase": true`, so one undo step, with the pen pressure and tilt of a normal stroke.
|
||||
|
||||
use egui::{PointerButton, Response};
|
||||
use photocraft_engine::BrushSettings;
|
||||
use photocraft_engine::prefs::RightClickPaint;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Smoothing is a tool option, kept per tool like Photoshop's options bar: switching between the
|
||||
/// Brush and the Eraser saves the session brush's smoothing for the old tool and loads the new
|
||||
/// tool's (Photoshop's 10 % the first time). Other tools leave it alone.
|
||||
pub fn sync_tool_smoothing(app: &mut PhotocraftApp) {
|
||||
/// The brush is a tool option, kept per tool like Photoshop's options bar: switching from the
|
||||
/// Brush to the Eraser saves the session brush for the old tool and loads the new tool's, so each
|
||||
/// keeps its own size, hardness, mode, opacity, dynamics and smoothing. A tool seen for the
|
||||
/// first time takes what the brush already has, at Photoshop's 10 % smoothing. Other tools leave
|
||||
/// the brush alone.
|
||||
///
|
||||
/// The foreground and background colours, the erase flag and the stroke seed belong to the
|
||||
/// session rather than to the tool, so a swap never carries them between tools: picking a colour
|
||||
/// or painting with the Eraser works with any tool's brush.
|
||||
pub fn sync_tool_brush(app: &mut PhotocraftApp) {
|
||||
let tool = app.ui.tool;
|
||||
if !matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) || app.ui.smoothing_tool == Some(tool) {
|
||||
if !has_brush_picker(tool) || app.ui.brush_tool == tool {
|
||||
return;
|
||||
}
|
||||
let current = app.session.tools.brush.smoothing.clone();
|
||||
let next = match app.ui.smoothing_tool {
|
||||
// The first smoothing tool takes what the brush has.
|
||||
None => current,
|
||||
Some(prev) => {
|
||||
let saved = &mut app.ui.tool_smoothing;
|
||||
match saved.iter_mut().find(|(t, _)| *t == prev) {
|
||||
Some(e) => e.1 = current,
|
||||
None => saved.push((prev, current)),
|
||||
}
|
||||
saved
|
||||
.iter()
|
||||
.find(|(t, _)| *t == tool)
|
||||
.map(|(_, s)| s.clone())
|
||||
.unwrap_or_else(|| photocraft_engine::paint::brush::Smoothing { amount: 0.1, ..Default::default() })
|
||||
}
|
||||
// The live brush is the outgoing tool's, so save it there before the new one takes over: any
|
||||
// edit it made since it was last active is in it, so its copy never goes stale.
|
||||
let current = app.session.tools.brush.clone();
|
||||
save_brush(app, app.ui.brush_tool, current.clone());
|
||||
app.session.tools.brush = match app.ui.tool_brushes.iter().find(|(t, _)| *t == tool) {
|
||||
Some((_, b)) => session_fields(¤t, b),
|
||||
// A tool seen for the first time takes what the brush has, at Photoshop's smoothing.
|
||||
None => BrushSettings { smoothing: first_smoothing(), ..current },
|
||||
};
|
||||
app.session.tools.brush.smoothing = next;
|
||||
app.ui.smoothing_tool = Some(tool);
|
||||
app.ui.brush_tool = tool;
|
||||
}
|
||||
|
||||
/// The brush `tool` had when it was last active.
|
||||
fn save_brush(app: &mut PhotocraftApp, tool: Tool, brush: BrushSettings) {
|
||||
match app.ui.tool_brushes.iter_mut().find(|(t, _)| *t == tool) {
|
||||
Some(e) => e.1 = brush,
|
||||
None => app.ui.tool_brushes.push((tool, brush)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's own brush fields, which a tool swap never carries: the colours, the erase flag
|
||||
/// and the stroke seed (a stroke's replay seed is the command's, not the tool's).
|
||||
fn session_fields(current: &BrushSettings, brush: &BrushSettings) -> BrushSettings {
|
||||
BrushSettings { color: current.color, background: current.background, erase: current.erase, seed: current.seed, ..brush.clone() }
|
||||
}
|
||||
|
||||
/// Photoshop's 10 % stroke smoothing, what a tool's brush starts at.
|
||||
fn first_smoothing() -> photocraft_engine::paint::brush::Smoothing {
|
||||
photocraft_engine::paint::brush::Smoothing { amount: 0.1, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Which tool events this frame's canvas response produces.
|
||||
@@ -460,12 +477,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_is_a_per_tool_option_that_presets_keep() {
|
||||
fn the_brush_is_a_per_tool_option_that_presets_keep() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
let amount = |app: &PhotocraftApp| app.session.tools.brush.smoothing.amount;
|
||||
app.ui.tool = Tool::Brush;
|
||||
sync_tool_smoothing(&mut app);
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!(amount(&app), 0.1, "Photoshop's default");
|
||||
// Picking a preset (whose own smoothing is 0) keeps the tool's smoothing.
|
||||
let preset = app.session.tools.presets.iter().find(|p| p.brush.smoothing.amount == 0.0).map(|p| p.name.clone()).unwrap();
|
||||
@@ -478,23 +495,87 @@ mod tests {
|
||||
assert_eq!((amount(&app), app.session.tools.brush.smoothing.pulled_string), (0.42, true));
|
||||
// The Eraser has its own (10 % at first); switching back restores the Brush's.
|
||||
app.ui.tool = Tool::Eraser;
|
||||
sync_tool_smoothing(&mut app);
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!(amount(&app), 0.1);
|
||||
app.session.tools.brush.smoothing.amount = 0.0;
|
||||
app.ui.tool = Tool::Brush;
|
||||
sync_tool_smoothing(&mut app);
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!((amount(&app), app.session.tools.brush.smoothing.pulled_string), (0.42, true));
|
||||
// Other tools leave it alone; the Eraser kept its 0 %.
|
||||
app.ui.tool = Tool::Move;
|
||||
sync_tool_smoothing(&mut app);
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!(amount(&app), 0.42);
|
||||
app.ui.tool = Tool::Eraser;
|
||||
// A stroke picks up the tool's smoothing even without a frame in between.
|
||||
// A stroke picks up the tool's brush even without a frame in between.
|
||||
tool_event(&mut app, ToolEvent::Down { x: 5.0, y: 5.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
assert_eq!(amount(&app), 0.0);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 5.0 }, Modifiers::NONE);
|
||||
}
|
||||
|
||||
/// #218: Photoshop keeps a brush per painting tool, so the size, hardness, mode, opacity and
|
||||
/// dynamics of one tool never leak into another's, and each tool gets its own back.
|
||||
#[test]
|
||||
fn each_tool_keeps_its_own_brush() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
let brush = |app: &PhotocraftApp| app.session.tools.brush.clone();
|
||||
let size = |b: &BrushSettings| b.size;
|
||||
let hardness = |b: &BrushSettings| b.hardness;
|
||||
let opacity = |b: &BrushSettings| b.opacity;
|
||||
let mode = |b: &BrushSettings| b.mode;
|
||||
|
||||
app.ui.tool = Tool::Brush;
|
||||
sync_tool_brush(&mut app);
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 40, "hardness": 0.2, "opacity": 0.7, "mode": "Multiply"}})).unwrap();
|
||||
assert_eq!(size(&brush(&app)), 40.0);
|
||||
|
||||
// A tool seen for the first time starts from the brush the old tool left (Photoshop's
|
||||
// 10 % smoothing), so nothing is lost by switching.
|
||||
app.ui.tool = Tool::Eraser;
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!(size(&brush(&app)), 40.0, "a new tool inherits the current brush");
|
||||
|
||||
// The Eraser edits its own size; the Brush keeps 40 px.
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 8, "hardness": 1.0}})).unwrap();
|
||||
app.ui.tool = Tool::Brush;
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!(size(&brush(&app)), 40.0, "the Brush keeps its own size");
|
||||
assert_eq!(hardness(&brush(&app)), 0.2, "and its hardness");
|
||||
app.ui.tool = Tool::Eraser;
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!(size(&brush(&app)), 8.0, "the Eraser keeps its own size");
|
||||
assert_eq!(hardness(&brush(&app)), 1.0, "and its hardness");
|
||||
|
||||
// Mode, opacity, flow and the dynamics sections are per tool too.
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
sync_tool_brush(&mut app);
|
||||
app.run("tools.setBrush", json!({"brush": {"mode": "Screen", "opacity": 0.3}})).unwrap();
|
||||
app.ui.tool = Tool::Brush;
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!((mode(&brush(&app)), opacity(&brush(&app))), (photocraft_color::BlendMode::Multiply, 0.7), "the Brush's own mode and opacity");
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!((mode(&brush(&app)), opacity(&brush(&app))), (photocraft_color::BlendMode::Screen, 0.3), "the Clone Stamp keeps its own");
|
||||
|
||||
// The colours, the erase flag and the seed belong to the session, so a swap keeps what is
|
||||
// live now instead of restoring another tool's copy (a picked colour reaches every tool).
|
||||
app.run("tools.setBrush", json!({"brush": {"color": [0.0, 1.0, 0.0, 1.0], "background": [1.0, 0.0, 1.0, 1.0], "erase": true, "seed": 42}})).unwrap();
|
||||
app.ui.tool = Tool::Brush;
|
||||
sync_tool_brush(&mut app);
|
||||
let b = brush(&app);
|
||||
assert_eq!(
|
||||
(b.color, b.background, b.erase, b.seed),
|
||||
([0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 1.0, 1.0], true, 42),
|
||||
"the session's own brush fields survive the swap"
|
||||
);
|
||||
|
||||
// Tools with no brush of their own (the Move) leave the brush alone.
|
||||
let before = brush(&app);
|
||||
app.ui.tool = Tool::Move;
|
||||
sync_tool_brush(&mut app);
|
||||
assert_eq!(brush(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_tool_on_the_background_paints_the_background_colour() {
|
||||
// #76: the Eraser on the Background (locked transparency) paints the background colour.
|
||||
|
||||
@@ -513,6 +513,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let tool = app.ui.tool;
|
||||
// The options bar shows the active tool's brush: switch it in before drawing the
|
||||
// chip, so a tool change swaps the brush with it (Photoshop keeps one per tool).
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
// Brush edits here go through `tools.setBrush`, one journal entry per gesture (Rule 1).
|
||||
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::Eraser)) || tool == Tool::QuickSelection {
|
||||
brush_preset_chip(ui, &app.session.tools.brush, &mut app.ui);
|
||||
@@ -527,7 +530,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let brush_before = app.session.tools.brush.clone();
|
||||
let mut brush = brush_before.clone();
|
||||
let b = &mut brush;
|
||||
|
||||
@@ -13,8 +13,12 @@ fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 120, "height": 80, "background": "transparent"})).unwrap();
|
||||
app.run("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "smoothing": {"amount": 0.0}}})).unwrap();
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0}})).unwrap();
|
||||
// Each tool keeps its own brush (#218): switch first, then set the Pencil's options, as the
|
||||
// options bar does (its first frame takes the Brush's tip at Photoshop's 10 % smoothing).
|
||||
app.ui.tool = Tool::Pencil;
|
||||
crate::paint_mouse::sync_tool_brush(&mut app);
|
||||
app.run("tools.setBrush", json!({"brush": {"smoothing": {"amount": 0.0}}})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
|
||||
@@ -813,6 +813,12 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) {
|
||||
let Some(r) = run(app, "tool.presets.select", json!({"preset": name})) else { return };
|
||||
if let Some(tool) = r["tool"].as_str().and_then(Tool::from_name) {
|
||||
app.ui.tool = tool;
|
||||
// Each tool keeps its own brush (#218), so switch it in before the preset's brush lands
|
||||
// on it, and re-apply the brush the command just set for the old tool.
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
if let Some(b) = r["options"].get("brush").filter(|b| b.is_object()) {
|
||||
let _ = app.run("tools.setBrush", json!({"brush": b}));
|
||||
}
|
||||
}
|
||||
if let Some(o) = r["options"].get("toolOptions").and_then(Value::as_object) {
|
||||
let mut cur = serde_json::to_value(&app.ui.tool_options).unwrap_or_default();
|
||||
|
||||
@@ -552,6 +552,11 @@ fn fifty() -> f32 {
|
||||
50.0
|
||||
}
|
||||
|
||||
/// The session brush starts out as the Brush's (the tool the app opens with).
|
||||
fn default_brush_tool() -> Tool {
|
||||
Tool::Brush
|
||||
}
|
||||
|
||||
impl Default for ToolOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -809,12 +814,13 @@ pub struct UiState {
|
||||
/// Selection-tool context menu opened by a plain canvas right-click.
|
||||
#[serde(default)]
|
||||
pub canvas_tool_menu: Option<crate::canvas_tool_menu::CanvasToolMenu>,
|
||||
/// Smoothing is a per-tool option (Brush and Eraser each keep theirs): the tool whose
|
||||
/// smoothing the session brush holds, and the other tools' saved values.
|
||||
/// The brush is a per-tool option (the Brush, the Eraser and every retouching tool each keep
|
||||
/// their own size, hardness, tip and dynamics, as in Photoshop): the tool whose brush the
|
||||
/// session brush holds, and the other tools' saved brushes.
|
||||
#[serde(default = "default_brush_tool")]
|
||||
pub brush_tool: Tool,
|
||||
#[serde(default)]
|
||||
pub smoothing_tool: Option<Tool>,
|
||||
#[serde(default)]
|
||||
pub tool_smoothing: Vec<(Tool, photocraft_engine::paint::brush::Smoothing)>,
|
||||
pub tool_brushes: Vec<(Tool, photocraft_engine::paint::BrushSettings)>,
|
||||
/// Pen path under construction.
|
||||
#[serde(default)]
|
||||
pub pen: Option<crate::vector_ui::PenPath>,
|
||||
@@ -935,8 +941,8 @@ impl Default for UiState {
|
||||
brush_picker_list: crate::brush_picker::list_state(),
|
||||
layer_menu: None,
|
||||
canvas_tool_menu: None,
|
||||
smoothing_tool: None,
|
||||
tool_smoothing: Vec::new(),
|
||||
brush_tool: Tool::Brush,
|
||||
tool_brushes: Vec::new(),
|
||||
clone_source: None,
|
||||
clone_offset: None,
|
||||
pattern_stamp_phase: None,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! typing session is one "Edit Type" history step and automation sees exactly what the user does.
|
||||
//! Offsets in [`TextEdit`] are character indices (the engine's unit); the layout works in bytes.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, PoisonError};
|
||||
|
||||
use egui::{Color32, Pos2, Stroke};
|
||||
use photocraft_doc::{Document, LayerContent, LayerId, TextLayer};
|
||||
@@ -76,7 +76,7 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<LayerId> {
|
||||
/// they sit somewhere else than the glyphs the caret and selection are placed on.
|
||||
fn shows_own_layout(doc: &Document, t: &TextLayer) -> bool {
|
||||
let Some(cache) = &t.cache else { return false };
|
||||
let Ok(mut eng) = photocraft_text::shared().lock() else { return true };
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let ours = eng.render(t, doc.resolution_dpi, doc.pixel_format()).1.surface.content_bounds();
|
||||
let have = cache.content_bounds();
|
||||
[(ours.x0, have.x0), (ours.y0, have.y0), (ours.x1, have.x1), (ours.y1, have.y1)].iter().all(|(a, b)| (a - b).abs() <= 1)
|
||||
|
||||
@@ -42,6 +42,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour)
|
||||
- `ui.menu.invoke {id, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back.
|
||||
Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them.
|
||||
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once
|
||||
- Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor`
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 11 | 14 | 8 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 55 of 140 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 55 of 141 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
|
||||
@@ -157,13 +157,13 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 55 of 140** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 55 of 141** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85 |
|
||||
| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options |
|
||||
| UI-217-2 | Window size and dock width remembered across restarts | missing | [#217](https://github.com/storytold/photocraft/issues/217) | main.rs:46 native_options(): fixed 1440x900, centred on the main monitor (#419); dock.rs:529 snapshot omits the width |
|
||||
| UI-217-3 | File > Open starts in the last-used folder | missing | [#217](https://github.com/storytold/photocraft/issues/217) | services.rs:110 FileDialog::new() without set_directory |
|
||||
| UI-217-4 | Preferences all take effect; recent files persist and honour recent_file_count (#204) | missing | [#217](https://github.com/storytold/photocraft/issues/217) | See the prefs audit and BUG-204-* |
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ id = "UI-217-1"
|
||||
target = "Tool options, last Export As / Save for Web / Fill settings and font recents persist"
|
||||
status = "partial"
|
||||
issue = 217
|
||||
note = "Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85"
|
||||
note = "Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-2"
|
||||
|
||||
Reference in new issue
Block a user