From 3eaf54f9356d56d4c87c23a74ae8575fb74d1c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 04:16:11 -0400 Subject: [PATCH] 14.4 round 2: an open edit closes before the turn, tested --- AGENTS.md | 2 +- tests/CanvasRotationTests.cpp | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9b5718e..3d7da64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,7 +184,7 @@ Platform adaptations, each forced by a missing Qt analog: - Inner effects keep a pixel's alpha (14.1, Swift 1.4.6: `e7e295e`, `9cb57db`, `f25effa`): `LayerEffectsKernel` follows v1.4.9's `effects_compose`: the colour overlay, then the inner glow, then the inner shadow move each pixel's colour toward theirs times its alpha (MSL's `mix`, `recolour`), the overlay by its opacity, the inner two by their strength (`effects_inside`: 1 − the softened or moved shape, no longer times the shape); then the pixels go over the shadow, the outer glow and an outside stroke, and an inside stroke goes on top. So a half-clear pixel takes a colour fully and stays half clear, and effects beneath are not tinted. Swift's CPU path (`overlaid`, `recolor`, the surface's inner glow) serves machines without Metal and stays dead here, as 9.6b's. The effects button's tooltip says "Add layer effect". Tests: `ColorOverlayTests` (Swift's three; the inner shadow and inner glow on half-clear pixels, a shadow beneath untinted), the shader replay in `LayerEffectsKernelTests`. - RAW rounding and Importing… (14.2, Swift 1.4.6: `6345432`, `196342c`, `6e6ca38`, `0907f51`): `kernels/DitherPixels.c/.h` gain `196342c`'s `hash_noise` and `dither_quantize16`, its block body moved into `quantize_band` with a context struct, as 13.17's bands. `RawImporter::develop` works the look at 16 bits (`lround(65535 × …)`, opaque) into a buffer it allocates first (`ExportError(render)` without memory), then rounds it to 8 bits through `dither_quantize16`, where Swift renders Core Image at 16 bits and does the same; Swift's half-float working format names Core Image's own and has no twin. The session's `rawImporting` and `endRawDevelop` are Swift's: Import keeps the request and sets `rawImporting` while an answer waits, Cancel or an answer nobody waits for ends at once, and `finishDecode` ends it once the frame lands or fails (Swift's `defer`), after the layer is in. `RawDevelopSheet::setImporting` shows Swift's plate (a 32-point busy bar over "Importing…" in `PlaceholderText`, padded 16, on the window colour at 85% rounded 10), hides the preview's spinner, rests the sliders, Reset, Cancel and Import, and filters its dialog's Close and Escape while importing (Swift's `interactiveDismissDisabled`). Tests read RAW tones as a mean over the frame, rounded, since the noise moves single pixels a level; Qt's area average truncates, so a fitted preview reads a level low (`theQueueKeepsThePreviewsDecodeUntilReleased`). Holding the global pool freezes painting (Qt's raster engine fills on it), so the sheet's tests read its state before the event loop runs the develop, or use a frame large enough to take a while. Tests: `RawDitherTests` (Swift's two on the kernel; the triangular noise's three in four kept, alpha never passed, a develop dithered and repeatable), `RawDevelopTests` (importing, cancelling, failing, an answer nobody waits for, the plate, the locked dialog, the spinner and Reset while importing). - New Canvas in units, at a DPI, on a background (14.3, Swift 1.4.6: `b0b4f01`, `ab9779a`, `a00d8b6`): `NewCanvasUnit` and `NewCanvasBackground` (in `NewCanvasSheet.h`, Swift's file) are enums with free functions for Swift's members (`rawValue`, `name`, `next`, `pixels`, `text`; `title`, `next`, `color`). `pixels` trims Foundation's `.whitespaces` (`trimmedWhitespaces`), reads a comma as the point and parses as Swift's `Double(text)` (the whole text through `strtod_l` in the C locale, a leading white space refused), takes a positive number, turns a print size into pixels at the DPI rounded half away from zero, and wants a whole count from 1 to 30,000; Swift's `isFinite` term is dead (infinity fails the range, NaN the whole number) and left out. `text` prints whole pixels, or a print size to two decimals with none trailing, rounded as Foundation's format style rounds: the shortest decimal that reads back as the double (`QLocale::FloatingPointShortest`), then halves to even on its digits (1.905 cm prints 1.9, 3.175 prints 3.18, where rounding the binary value gives 1.91 and 3.17); checked once against Swift 6.0.3 for 1 to 4,000 pixels in every unit at 72 and 300 DPI, all 24,000 alike. The sheet starts in pixels at 72 DPI on a clear canvas each time (`a00d8b6`), and its three settings are Swift's `CyclePill`s, `QAbstractButton`s that paint their text in `PlaceholderText` at 12 pixels and, hovered, a capsule of the text colour at 10% (macOS's quaternary label): the background (Transparent, White, Black canvas), the unit (switching rewrites both fields in the new unit when both read, else leaves them) and the DPI (72 or 300; in a print unit its tooltip adds the pixels it makes). The fields' units follow the unit; the note shows only for a size that does not read, in the unit's words; presets and the clipboard's size are written in the unit. `createNewProject` and `createDocument` take the resolution (1 to 9,600, else 72) and the background; `firstLayer` makes a Background layer of that colour at full size with its thumbnail, or a blank Layer 1, and a background that cannot be allocated logs on `omaphoto.app` and falls back to the blank layer, Swift's `try?` (untested: only starvation reaches it). Tests: `NewCanvasUnitTests` (Swift's four; rounding, trimming, the DPI's ends, the thumbnail), `NewCanvasSheetTests` (the pills, the units, the DPI's tooltip, presets in units, Swift's number parsing). -- Rotate Canvas 90° (14.4, Swift 1.4.6: `754f188`): `Document/CanvasRotation.cpp` twins Swift's file, `CanvasRotation+Session.h` its session half; `LayerTransform::quarterTurned` and `CanvasGuide::quarterTurned` are declared in their own headers and defined there, as `mirrored` is. `rotateCanvas` commits a transform edit and drops a crop frame first, asks `canEditLayers`, then turns every layer and placed mask about the canvas (the middle carried, the angle by ±90 through `fmod`, which keeps Swift's sign), swaps the sides in place, turns the guides and the selection's path (its feather and antialiasing kept), fits the view, one `Rotate Canvas 90° Clockwise` or `Counterclockwise` step; Swift's `finishOpacityEdit` is dead (`commitTransform` runs it, as `flipCanvas` notes) and left out. `restore` fits the view when the canvas changes size too. The Image menu lists both entries before the flips, enabled by `canEditLayers`. A fix the item's exact test found: QPainter turned a layer through `rotateRadians`, whose cos(π/2) is 6e-17, so a turned edge missed its pixel boundary and antialiasing shaved it, and its fixed-point bilinear fetch strayed a level or two (40 read 39) where CoreGraphics' low quality copies a pixel-aligned turn exactly. Every layer placement now turns through `LayerTransform::degrees()` and `QTransform::rotate`, which Qt builds exactly at ±90, ±270 and 180 and as `rotateRadians` elsewhere, and `LayerRenderer::upright` keeps Swift's upright (no turn) and adds a draw that takes image pixels onto device pixels one to one: the whole map from the image's pixels through its target rect, the placement and the painter's device transform is a signed permutation (no stretch on either axis, no shear, no perspective, the painter's own turn counted), within a thousandth of a pixel across the image, with the image's corner on a whole device pixel. Such a draw takes no filter; a turn half a pixel off, a stretched side or a turned painter still filters, as CoreGraphics' low quality blends them. Equivalent mutants: `degrees()` in `BrushStroke`'s grid map, `Distort` and `LayerMask` (the two matrices differ by 1e-16 there; the change keeps every placement one turn). Tests: `CanvasRotationTests` (Swift's four, a turned layer and a placed mask, the crop frame, the refit, refusals and steps), `SharpCanvasTests::aQuarterTurnOnWholePixelsCopiesAcross` (the rule's edges: every quarter, a nudge on either axis, a stretched side, a 30,000-pixel side leaning 0.0001°, painters turned, sheared and in perspective; plain and painted layers exact on whole pixels and filtered half off or stretched), `everyLayerAndEveryPlacedMaskTurns`, the menu in `ImageMenuTests`. +- Rotate Canvas 90° (14.4, Swift 1.4.6: `754f188`): `Document/CanvasRotation.cpp` twins Swift's file, `CanvasRotation+Session.h` its session half; `LayerTransform::quarterTurned` and `CanvasGuide::quarterTurned` are declared in their own headers and defined there, as `mirrored` is. `rotateCanvas` commits a transform edit and drops a crop frame first, asks `canEditLayers`, then turns every layer and placed mask about the canvas (the middle carried, the angle by ±90 through `fmod`, which keeps Swift's sign), swaps the sides in place, turns the guides and the selection's path (its feather and antialiasing kept), fits the view, one `Rotate Canvas 90° Clockwise` or `Counterclockwise` step; Swift's `finishOpacityEdit` is dead (`commitTransform` runs it, as `flipCanvas` notes) and left out. `restore` fits the view when the canvas changes size too. The Image menu lists both entries before the flips, enabled by `canEditLayers`. A fix the item's exact test found: QPainter turned a layer through `rotateRadians`, whose cos(π/2) is 6e-17, so a turned edge missed its pixel boundary and antialiasing shaved it, and its fixed-point bilinear fetch strayed a level or two (40 read 39) where CoreGraphics' low quality copies a pixel-aligned turn exactly. Every layer placement now turns through `LayerTransform::degrees()` and `QTransform::rotate`, which Qt builds exactly at ±90, ±270 and 180 and as `rotateRadians` elsewhere, and `LayerRenderer::upright` keeps Swift's upright (no turn) and adds a draw that takes image pixels onto device pixels one to one: the whole map from the image's pixels through its target rect, the placement and the painter's device transform is a signed permutation (no stretch on either axis, no shear, no perspective, the painter's own turn counted), within a thousandth of a pixel across the image, with the image's corner on a whole device pixel. Such a draw takes no filter; a turn half a pixel off, a stretched side or a turned painter still filters, as CoreGraphics' low quality blends them. Equivalent mutants: `degrees()` in `BrushStroke`'s grid map, `Distort` and `LayerMask` (the two matrices differ by 1e-16 there; the change keeps every placement one turn). Tests: `CanvasRotationTests` (Swift's four, a turned layer and a placed mask, the crop frame, the refit, refusals and steps), `SharpCanvasTests::aQuarterTurnOnWholePixelsCopiesAcross` (the rule's edges: every quarter, a nudge on either axis, a stretched side, a 30,000-pixel side leaning 0.0001°, painters turned, sheared and in perspective; plain and painted layers exact on whole pixels and filtered half off or stretched), `everyLayerAndEveryPlacedMaskTurns`, `anOpenEditClosesBeforeTheTurn` (an opacity drag and a pending transform each their own step), the menu in `ImageMenuTests`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/tests/CanvasRotationTests.cpp b/tests/CanvasRotationTests.cpp index dfc2ef9..b10245e 100644 --- a/tests/CanvasRotationTests.cpp +++ b/tests/CanvasRotationTests.cpp @@ -44,6 +44,7 @@ private slots: void turnedLayersAndPlacedMasksTurnAlong(); void itIsOneStepRefusedWhereLayersCannotChange(); void everyLayerAndEveryPlacedMaskTurns(); + void anOpenEditClosesBeforeTheTurn(); }; void CanvasRotationTests::clockwiseTurnsThePixelsExactly() @@ -220,5 +221,29 @@ void CanvasRotationTests::everyLayerAndEveryPlacedMaskTurns() QVERIFY(session.document().value().layers == placed); } +void CanvasRotationTests::anOpenEditClosesBeforeTheTurn() +{ + EditorSession session; + canvas(session); + // An opacity drag is its own step, closed first. + session.beginOpacityEdit(); + session.setLayerOpacity(0.25); + session.rotateCanvas(true); + QCOMPARE(session.history.undoName(), QString("Rotate Canvas 90° Clockwise")); + session.undo(); + QCOMPARE(session.document().value().size(), QSizeF(3, 2)); + QCOMPARE(session.activeLayer().value().opacity, 0.25); + session.undo(); + QCOMPARE(session.activeLayer().value().opacity, 1.0); + // A pending transform lands first, its own step. + session.beginTransform(); + session.nudgeLayer(1, 0); + session.rotateCanvas(true); + QVERIFY(!session.transformEdit()); + session.undo(); + QCOMPARE(session.document().value().size(), QSizeF(3, 2)); + QCOMPARE(session.activeLayer().value().origin(), QPointF(1, 0)); +} + QTEST_GUILESS_MAIN(CanvasRotationTests) #include "CanvasRotationTests.moc"