Compare commits

...
Author SHA1 Message Date
Claude ad0cadcfa6 Linux display server: compile read_display_server on Linux (and tests) only
macOS and Windows clippy rejected it as dead code (-D warnings): its only caller in main.rs is
#[cfg(target_os = "linux")]. Also puts read_rendering_prefs' doc line back above it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
(cherry picked from commit 6a70740840)
2026-10-09 08:33:14 +00:00
Claude a74839fcd4 scorecard: regenerate for #1319's display-server preference (141 settings)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 08:08:57 +00:00
Claude 022435b08e fmt: import order in paint_mouse.rs (#1629)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 07:59:37 +00:00
Claude 764478ba85 Merge pr/1319 2026-10-09 07:03:17 +00:00
Claude 3f384f5cdb Merge pr/1560 2026-10-09 07:03:17 +00:00
Claude 1d48efeb33 Merge pr/1624 2026-10-09 07:03:16 +00:00
Claude 11377a6874 Merge pr/1629 2026-10-09 07:03:15 +00:00
Claude 8debf3a980 Merge pr/1642 2026-10-09 07:03:15 +00:00
Claude 46b85d922c Merge pr/1645 2026-10-09 07:03:14 +00:00
UnemarsandClaude Opus 5.5 b0aa3d55b0 Layers panel: ⌥-click on the line between layers, measured against Photoshop
#1066 added the gesture; this makes it behave as Photoshop 25.4 does (measured by sweeping
⌥-hover and ⌥-click a pixel at a time across each line, results read back by script):

- the line takes the pointer from 3 px above it to 5 px below it (was ±3);
- a release frees the layer above the line together with the clipped layers stacked above it,
  in one history step, and makes it the active layer (was: that layer alone); a clip keeps the
  active layer, as before;
- clipping and releasing have their own cursors: PhotoCraft's original clip-below (a hooked
  arrow onto the layer below) and clip-release (the same arrow with a small "no" badge),
  replacing Lucide corner-right-down for both.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 08:43:43 +02:00
Gert Pellin d69d442d39 Linux: display server preference (X11 / XWayland) so file drops work
On a Wayland session dropping a file on the window does nothing: winit
0.30 (eframe 0.36) has no Wayland drag and drop (#386). The only way
out was launching with `WAYLAND_DISPLAY= photocraft`.

Add Preferences › Performance › Linux display server (auto | x11,
applies at next launch), direction 2 in #386. With x11, main.rs asks
winit for its X11 backend through eframe's event_loop_builder hook
(EventLoopBuilderExtX11::with_x11), so the window runs through XWayland
and drops work. No environment is modified (no unsafe). It only applies
when an X server is reachable (DISPLAY set); otherwise winit's default
is kept, so a missing XWayland can't stop the app from starting. Other
platforms ignore it.

The Wayland drop notice now points at the preference instead of the
environment variable (all catalogs updated).

Tests: the startup reader (x11, auto, unknown value, wrong type, broken
JSON, missing file), the notice text, prefs_usage, label translations.
Verified on KDE Plasma 6 Wayland: with x11 the window is an X11 window
(XWayland) and dropping a NEF from Dolphin opens it.
2026-10-09 08:21:12 +02:00
Rozaq 00ca258dc4 fix: normalize legacy TySh origins on import 2026-10-09 13:15:55 +07:00
StreetblockandGPT-6 7d38be8d79 Fix combined-mask cache invalidation after pixel edits
Co-Authored-By: GPT-6 (Codex) <[email protected]>
2026-10-09 07:55:59 +02:00
Adam Gincel 76b5708dbb feat(ui): keep a brush per painting tool, as in Photoshop (#218)
The brush was one global session setting (only its smoothing was per tool), so
switching tools carried the size, hardness, mode, opacity, flow and dynamics
over. sync_tool_smoothing becomes sync_tool_brush: it saves the session brush
for the outgoing tool and loads the new tool's on every switch. UiState's
smoothing_tool/tool_smoothing become brush_tool/tool_brushes (serde default, so
saved UI state still loads), and the session's own fields (colours, erase flag,
stroke seed) are never carried across a swap.

Called from the frame start, the options bar before the brush chip, both canvas
stroke paths, ui.set {tool} (so tool + brushSize in one call sets the new tool's
size) and select_tool_preset. A tool seen for the first time inherits the brush
in hand at Photoshop's 10 % smoothing.

Tests: each_tool_keeps_its_own_brush, the renamed per-tool smoothing test,
per-tool coverage in the ui.set brushSize test, and pencil_tests switching tools
before setting the smoothing as the options bar does.
2026-10-09 01:31:18 -04:00
Brennan StehlingandClaude Opus 5.5 e462bec937 macOS: quit with terminate:, not by closing the window (#1575, #1458)
Since the native menu bar (#894), Quit runs file.exit, which closes the window through eframe
while AppKit's run loop is still going. AppKit's Touch Bar finder then removes a KVO observer from
the already-deallocated view in the next display cycle (`-[_NSTouchBarFinderObservation
invalidate]`): an uncaught exception that aborts on macOS 27 (#1458) and leaves a windowless app
on macOS 26 (#1575). The window's close button took the same path before #894; ⌘Q used winit's
default menu, which calls terminate:.

Every way of leaving (Quit, ⌘Q, the close button, the OS quit event, app.quit) arrives at
guard_window_close as a close request. Once leaving is settled (nothing unsaved, or the prompt
answered) it now runs Services::quit when the platform has one, keeping the window open for it.
On macOS that is -[NSApplication terminate:], scheduled on the main queue so winit is out of its
event handler and eframe's exiting hook still saves the window layout.

Reproduced in a macOS 26.6.2 VM with XCUITest: on main, Quit, Quit with a document, ⌘Q and the
close button abort with the #1458 stack; with this change all of them exit, write no crash report
and save ui.ron.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 21:46:39 -07:00
Rozaq 6e471d2988 fix: keep legacy PSD text origins visible when editing 2026-10-09 09:10:32 +07:00
45 changed files with 668 additions and 91 deletions

No files matched your search

+2 -1
View File
@@ -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
View File
@@ -3975,6 +3975,7 @@ dependencies = [
"arboard",
"async-io",
"core-graphics2",
"dispatch2",
"eframe",
"egui",
"fmv-macos-events",
+3
View File
@@ -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]
+32
View File
@@ -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);
+26 -5
View File
@@ -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"),
});
}
+16
View File
@@ -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);
+16
View File
@@ -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

+18
View File
@@ -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

-14
View File
@@ -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

+42 -5
View File
@@ -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);
+17 -1
View File
@@ -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,
})
+7
View File
@@ -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");
}
+47
View File
@@ -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()
}
+42
View File
@@ -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]
+2 -2
View File
@@ -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);
+47 -10
View File
@@ -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));
}
}
+88 -2
View File
@@ -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");
}
}
+13
View File
@@ -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();
+49 -6
View File
@@ -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);
}
}
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
980
981
982
983
984
985
986
2012
2013
2014
2015
2016
2017
2018
2019
+3
View File
@@ -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.
+3
View File
@@ -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} 프로파일
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
1975
1976
1977
1978
1979
1980
1981
1982
2003
2004
2005
2006
2007
2008
2009
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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 使用数位板压感
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
986
987
988
989
990
991
992
2070
2071
2072
2073
2074
2075
2076
2077
+3
View File
@@ -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.
+2 -1
View File
@@ -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")),
+10
View File
@@ -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) {
+2
View File
@@ -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);
+110 -29
View File
@@ -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(&current, 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.
+3 -1
View File
@@ -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;
+5 -1
View File
@@ -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
}
+6
View File
@@ -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();
+13 -7
View File
@@ -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,
+2 -2
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"