Layout looked up each paragraph's style with a reverse scan of every
paragraph start, tab placement re-checked the whole rest of the line for
every cluster, and PSD import found each character run's paragraph by
summing paragraph lengths from the first one. Each was quadratic in
counts a file controls.
Use the existing binary search over run starts for paragraphs, one
reverse scan for the line's last inked slot, and precomputed paragraph
ends with a binary search on import.
The reference application's Info panel shows the crop frame's W and H but no
ratio line, so it stays out for now; W and H keep following the frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured in the reference application: after ↵ commits a crop, the Crop
tool shows no frame; pressing C again, or another tool and back, keeps it
hidden, and only a press on the canvas brings the frame back. A crop
committed by leaving the tool is different: back on the Crop tool the idle
frame shows at once. #10642 brought the frame back on re-picking; picking
the tool no longer ends the hidden state, and a commit on leaving the tool
does not start it. Tests follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's #10905 (#10867) turns the Background into Layer 0 when a crop keeps
the cropped pixels, so the corners a turn uncovers stay transparent instead
of taking the background colour. The #10950 test now expects that for its
Delete Cropped Pixels off cases; with it on the corners are still the
background colour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#10642 hides the frame after a committed crop until the next press or
re-picking the tool. The #9855 and #10453 tests expected the default frame
right after the commit; they now check that it is hidden, pick the tool
again and then test the default frame as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict in crates/engine/src/jobs.rs: main's save commit point (#5333,
OPEN/COMMITTED/CANCELLED phase) met the batch's cancel state
(CANCEL_REQUESTED/WORK_FINISHED). Both now share one atomic with a
COMMITTED bit; commit vs cancel and the batch's late-cancel guard each
stay one compare-and-swap. A Batch that refuses a repeated or late cancel
reports CancelRefused::Finishing ("finishing") in jobs.cancel's
notCancelled list, still {"cancelled": 0}. control-protocol.md documents both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A banded layer checks the save's interrupt before each band and before each
channel's part of a band. Once cancelled it stops there and doesn't encode the
layer again raw; the incomplete model is discarded by the caller (which returns
IoError::Cancelled), so the save never reaches its commit point and the target
is untouched. Ex carries the export's Interrupt for this.
Addresses CodeRabbit review on crates/io/src/psd_export/banded.rs.
In the default mode a drag outside the box turns the image, but the
frame never changed, so its corners left the turned image and the crop
filled them with the background colour.
Each move of the turn now starts from the frame at the press: it keeps
its size and place if it fits the turned image, else moves the shortest
way in, else shrinks about its centre (keeping its proportions) to the
largest size that fits. The view follows the frame's centre, so the box
stays put on screen. Ctrl (Cmd) held during the turn leaves the frame as
it was. Content-Aware Fill and a frame already past the canvas keep
today's behaviour (not measured).
* fix(ui): Overscroll off keeps an image smaller than the window centred (#10488)
With Tools › Overscroll off, an image smaller than the window stayed
wherever zooming around the pointer left it, inside the window, so
zooming out drifted it off centre. #1718 measured that on Photoshop 25.4,
but two reports of Photoshop itself (#10488, #4799) say it stays centred
with Overscroll off. The owner chose the centred behaviour:
scrollbars::clamp_axis now centres an axis on which the image is no
larger than the view; a larger image still can't leave its edges, and
Overscroll on keeps the free view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): Overscroll off centres a fitting rotated image (#10488)
CodeRabbit on #11085: the rotated clamp path was only tested from an
already-centred view. A 600 px square at 45 degrees, started off centre
in a 900-point view, now ends with its screen bounds centred.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* style(ui): keep the rotated centring test within rustfmt's width
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): Roundness previews its radius within 1 px too, and a held frame survives a factor change (#10474)
Follow-up to #10524 from its last CodeRabbit review.
- Roundness's disc reaches floor(r) pixels along an axis, so at 6 px
and k = 4 the proxy radius 1.5 reached 1 proxy px: a 4 px preview for
a 6 px result. The factor now keeps floor(r / k) * k within 1 px of
floor(r), as Squareness already does with round (k = 3 at 6 px, k = 2
at 50 px). One whole-pixel function per mode replaces the special case.
- A canvas test drives the worker across a factor change: the frame for
k = 2 stays on the canvas, at its own size, until the k = 1 frame lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): a fractional Roundness radius previews closer at its chosen factor (#10474)
CodeRabbit on #11081: only the factors were pinned. At 6.5 px the
Roundness preview at the chosen k = 3 is compared with OK's result and
must differ less than the k = 4 preview did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): a mid-drag frame for the previous proxy factor still reaches the canvas (#10474)
CodeRabbit on #11081: the display gate keeps a held frame at any factor,
but the mid-drag publish arm still required the same factor, so a frame
finished for the factor the drag just left was dropped. It now uses
same_target_at_any_scale too; a frame already exact for the dialog is
still never displaced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Type: add OpenType Numerals submenu (figure styles)
Type › OpenType › Numerals sets lnum/onum/pnum/tnum as a radio style
(Default through Tabular Oldstyle). Checkmarks follow the selection
start; TYPE-218-5 scorecard/gaps updated; parity floor 640.
* refactor(engine): one style-at-character lookup for the OpenType menus
feature_on_at and numerals_style_at each walked the runs the same way;
they now share style_at. NumeralsStyle::from_id and numerals_style_of
read the existing tables instead of repeating them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(text): persist OpenType numeral tags through PSD EngineData
Map lnum/pnum/tnum as EngineData bools and the width×height combos as
FigureStyle (Krita/Illustrator), so Numerals choices survive PSD round-trip.
* docs(gaps): note Numerals PSD figure-tag round-trip
* revert(text): keep PSD EngineData to the keys Photoshop writes
Backs out the PSD half of 18610324 and its gaps note (58813de3); the
engine reformat in that commit stays.
- `LiningFigures`, `ProportionalFigures` and `TabularFigures` are not
Photoshop EngineData keys (the commit says Photoshop only has
`OldStyle`), so writing them puts keys of our own into users' PSDs.
- The `/FigureStyle` code table (1-4) has no oracle: no Photoshop-authored
file in photocraft-corpus, and neither ag-psd nor psd-tools reads it.
AGENTS.md rule 3 asks for public specs or observation. PSD persistence
of the numeral styles can come back with a Photoshop-authored oracle.
The Numerals menu still round-trips through .pcraft.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(text): lock PSD EngineData to Photoshop OldStyle for numerals
Document AGENTS.md clean-room: only onum survives TySh round-trip; lnum/pnum/tnum stay .pcraft-only until a Photoshop-authored corpus oracle.
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
When an action saves and then closes or switches away, Batch writes the
saved snapshot inside the action closure. A cancel landing after that write
made process_one return Cancelled, so the input was reported as skipped
although its output exists. The snapshot write is now final: the cancel
stops only the inputs after it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cancel-path tests used the Unix-only tmp_dir fixture, so the engine's lib
tests did not compile on Windows (E0425 in CI run 41660911095). They get their
own save_dir helper instead.
* fix(ui): Minimum and Maximum preview at the scale they apply (#10474)
Filter > Other > Minimum... and Maximum... were missing from FULL_RESOLUTION_PREVIEW, so on
any document large enough to be proxied their live preview was not what OK writes.
preview_factor keeps the largest proxy factor at which no pixel-sized parameter falls below
its command's own minimum, and for these two commands that minimum is 0.2
(engine/src/filters/validation.rs:90). A typed radius of 1.5 px therefore allows k = 4, and
scale_preview_params divides it to 0.375 px. Both kernels then measure that in whole pixels
and return the crop untouched when it reads 0: Squareness is
`let r = radius.max(0.0).round() as i32;` (algo/src/other.rs:83) and Roundness is
`let r = radius.max(0.0); let ri = r.floor() as i32;` (other.rs:23-24). So the dialog showed
the untouched document while OK applied a real 1.5 px (Squareness) or 1 px (Roundness)
effect - the same failure mode Dust & Scratches already has in that list for (#3265). The
repo already asserts this rule for the filters that are listed, in
fixed_size_filters_preview_at_the_scale_they_apply; Minimum and Maximum simply were not in it.
Fail-first: four rows (1.5 px in both Preserve modes, both filters) added to that test failed
with the production change stashed - `assert_eq!(k, 1, "filter.other.minimum")` reported
left: 4, right: 1 - and pass with it. filter_dialog group 177 pass / 0 fail; full
cargo test -p photocraft-ui-egui --lib 4440 pass / 22 fail, a strict subset of the 24
failures a clean 422ca7be checkout gives in this environment (the i18n, font-picker,
type-tool and layer-row tests that need a CRAFT_FONTS_DIR checkout this machine has no
access to); nothing new failed. clippy --all-targets -D warnings clean, fmt --check clean,
cargo xtask layers OK (31 crates), cargo xtask wasm ok.
Not touched, on purpose: the kernel math, the 0.2..500 radius range, and the fractional-radius
question in #10474. Min/max over an integer lattice cannot represent a fractional radius at
all, and the two Preserve options already snap at different thresholds (0.5 px for Squareness,
1.0 px for Roundness). Picking graded output or one shared rule needs reference captures, and
nothing in docs/ records the reference behaviour today; guessing would put the guess in the
tests as the oracle. The question stays open on the issue. No doc row either: #2063 and #3265
appear only in code comments, not in any docs table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): Minimum and Maximum keep one proxy pixel of radius, not a full-size preview (#10474)
Previewing every radius at full size fixed the identity preview but made
a large radius (up to 500 px, Roundness is O(radius) per pixel) cost
seconds to minutes per tick on an ordinary photo. The bug only appears
when radius / k drops under one kernel pixel, so cap k at floor(radius):
1.5 px still previews at k = 1, while 50 px keeps the 1/4 proxy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): Squareness previews its radius within 1 px of what OK applies (#10474)
Capping k at floor(radius) kept a proxy pixel of radius, but Squareness
rounds the proxy radius: 6 px at k = 4 is round(1.5) = 2 proxy px, an
8 px preview for a 6 px result. Take the largest k whose rounded proxy
radius, scaled back, is within 1 px of round(radius) (k = 3 for 6 px and
50 px); Roundness reads its disc from the unrounded radius and keeps the
floor(radius) cap. The radius is clamped to the command's 0.2-500 range
first, as the engine does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): the canvas keeps the last preview frame when a drag changes the proxy factor (#10474)
Minimum and Maximum's factor now follows the radius, so a radius drag
crossing a whole pixel changed FilterPreviewKey::k, and the display gate
(same_target, which includes k) dropped the held frame: the canvas showed
the unfiltered document until a frame for the new factor landed. The
frame is drawn at its own k (textures read preview.key.k), so the gate
now forgives the factor alone (same_target_at_any_scale); publishing and
freshness keep the strict checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
jobs.cancel, cancel_all_jobs and closing a document (cancel_jobs_on) cancelled
any running job, so they could report a save as cancelled while its worker
went on to replace the file. The point of no return was tracked only by the
desktop shell's own Cancel/Esc path.
The commit point now lives in the engine's JobCtx: commit() and a cancel race
on one compare-and-swap, so exactly one wins.
- Cancelled before commit: commit() fails, the save writes nothing and the
job ends Cancelled.
- Committed: every cancel path leaves the job running and says so
(Session::try_cancel_job -> CancelRefused::Committing; cancel_all_jobs and
cancel_jobs_on return a CancelReport). The job then reports its real
outcome, applied even if its document was closed meanwhile.
- jobs.cancel (engine and desktop control) replies {cancelled: n} as before,
plus notCancelled: [{job, reason: "committing"}] for a save left running.
This is not an error: the job is fine and will report how it ended.
Single-job print refusals are unchanged.
- The shell's SaveCtl::commit and Cancel/Esc use the engine gate, so the
status bar, control and scripting agree.
Tests: cancel before commit (target untouched, Cancelled), cancel after
commit (refused, Done, file written), cancel_all and document close with
mixed committed/open jobs, a commit/cancel race, and desktop jobs.cancel
before and during the write with real files.