From f8c1332c295dfd580210257bc4c5477af9f3a140 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 03:02:29 -0400 Subject: [PATCH] 14.3: New Canvas in inches, cm or mm at 72 or 300 DPI, transparent, white or black, every setting a pill --- AGENTS.md | 3 +- src/ContentView.cpp | 2 +- src/Document/EditorSession+Projects.cpp | 4 +- src/Document/EditorSession+Projects.h | 2 +- src/Document/EditorSession.cpp | 26 ++- src/Document/EditorSession.h | 4 +- src/UI/NewCanvasSheet.cpp | 240 ++++++++++++++++++++++-- src/UI/NewCanvasSheet.h | 38 +++- tests/NewCanvasSheetTests.cpp | 134 ++++++++++++- tests/NewCanvasUnitTests.cpp | 103 ++++++++++ tests/RemappedKeysTests.cpp | 2 +- tests/Tests.cmake | 1 + 12 files changed, 521 insertions(+), 38 deletions(-) create mode 100644 tests/NewCanvasUnitTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 6b463b2..16f4ce8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ Platform adaptations, each forced by a missing Qt analog: - Snapping while drawing (13.5a, Swift 1.3.5: `d6e3e93`, `8b1369a`): `snappedPoint` and `snappedSelectionOffset` live in `Crop.cpp`, Swift's file. A drawn point moves each axis to the nearest crop target within reach (the first of the nearest, reach inclusive) and shows the lines met; a moved selection snaps its rounded box's edges or middle through `TransformSnap::offset`, an axis Shift holds left out; with snapping off, or no move begun, the input comes back and the lines go. The canvas's `snappedCorner` snaps a Marquee's start (a Lasso's never), a Marquee's dragged corner, a shape's start and dragged corner, ten view points' reach, and the selection drag; Ctrl, Swift's Control as the crop and the Move tool read it, draws and moves freely and clears the lines. Every release clears them, as Swift's `mouseUp`. Tests: Swift's `drawnPointsSnapToTheSnapToTargets` in `GuideTests`; a moved selection's offsets (rounding, a locked axis, a turned ellipse's own box) and the gestures at 100% and 200% in `SnapDrawingCanvasTests`. - Grid Settings (13.5b, Swift 1.3.5: `1c819d0`, `db5eafa`): `LayoutGrid` is Swift's struct: spacing 2–4096 and subdivisions 1–64, never finer than a pixel, clamped as it is made, plain members where Swift has `let`, so it stays assignable (as `CanvasSizeDraft`); its lines are counted from the origin, so an uneven step stays on the majors. `GridAppearance` holds Photoshop's presets by Swift's raw values (`allGridPresets`, `rawValue`, `presetColor`, none for Custom), the styles (`allGridStyles`, `dashes` in screen points), Custom's colour and the majors' opacity (1% at least when drawn; subdivisions at 28/45 of it). Both are the person's: `ToolDefaults` gains `integer`, `text` (empty when nothing is stored) and their `set`s, and the session reads `LayoutGrid::stored()` and `GridAppearance::stored()` as its members are made, under Swift's keys (`gridSpacing`, `gridSubdivisions`, `gridColor`, `gridCustomColor` as hex, `gridStyle`, `gridOpacity`); a stored value that cannot be read is warned of on `omaphoto.app` and left at its default, where Swift falls back in silence. `setLayoutGrid` and `setGridAppearance` store and notify, silent when unchanged. Snap To Grid, the guide snapping and the overlay read the session's grid; the overlay draws the majors in the chosen colour, opacity and dashes (counted in pen widths for Qt) and the dotted subdivisions in that colour, and `GuidesShown` carries both, so a change repaints. `ProjectController::gridSettings` lives in `ProjectController+Resize.cpp` beside the other sheets, since the controller's own file would pass 500 lines: refused without a window or while a modal sheet is up (Swift's `attachedSheet`), it shows the grid, opens `GridSettingsSheet` in the window-modal `sheet` titled Grid, previews every change through the session, and on its answer puts back the grid's visibility and either keeps the settings or restores them; nothing is undone or saved, and the project is not busy. `UI/GridSettingsSheet` twins Swift's: the title, Color (a preset menu and the `DialogColorSwatch` "Grid Color"), Style, Opacity (a half-point scrub, a slider and a 48-point field with "%", stepped by arrows, clamped), a rule, "Gridline every" with "pixels" (a point's scrub) and Subdivisions (a fifth of a point's), the note (the step to two places, grouped as Swift's `formatted()`, or the limits in `BrightText`), Cancel, Restore Defaults (Custom's colour kept) and the default OK, resting while invalid; 360 wide, 24 in, 18 apart. A field takes any whole number, as Swift's binding, so OK rests until the grid is valid, and previews only valid grids. A picked colour becomes Custom's, remembering the preset it left so picking that colour again returns to it; a preset chosen from the menu ends that. A fix beyond Swift: the picker reports 8-bit colours, so Swift's comparisons miss presets that are no byte (Light Gray's 0.7): opening the picker on Light Gray turns the grid Custom at once. The twin compares both sides in the picker's 8 bits, since its Cancel hands back the exact original, so opening and Cancel both keep a preset. Left out as dead: Swift's reset of the pick in its return branch (returning to a preset ends the pick through the preset's own change, in Swift too) and `ToolDefaults::text`'s fallback (Swift's callers pass ""). Untested: the destructor's `releaseFocus` and picker close (without them the swatch's hide writes into members already gone; no sanitizer runs). Tests: `GuideGridTests` (Swift's four grid tests, the settings kept), `GridSettingsSheetTests`, the drawing in `GuideCanvasTests`, the flow in `ProjectResizeTests`, the entry in `ViewMenuTests` and `CompositorMenusTests`. - Typing's undo and font previews (13.6, Swift 1.3.5: `ee68ba9`, `1bd1df4`). Swift's first commit lets the text view answer Undo and Redo itself, since the window's history took them; the twin's editor has answered them since 8.9b (the keys win `ShortcutOverride`, the menus reach the focused canvas's editor), so only Swift's case is added as a test: typing in a new box and Ctrl+Z through the real menus clears it in one step. The session's `previewFont`, `keepFontPreview` and `endFontPreview` twin Swift's: open text only, each face tried from the style as it was before the first (`m_fontPreviewOriginal`, Swift's `@ObservationIgnored` member) on the draft's selection, refused when invalid or unchanged, kept on a choice, put back on a close without one. `TypeFontPicker` draws each face's name in that face through `Qt::FontRole`, which the combo's menu delegate honours, at the menu's pixel size; a face that cannot draw the letters of its own name (`QRawFont`, the face itself and not fontconfig's fallbacks) keeps the menu's font, as Swift's symbol fonts do. Which faces can is worked out once on a worker when the first picker is made (Swift's `prepareStyledNames`), joined when the menu loads, a name still missing worked out then. The picker takes Swift's preview closure at construction: a highlighted face shows (the `(Multiple)` row does not), and every close posts the revert to the event loop, since Qt reports a choice after `hidePopup`; a choice keeps the preview first, which empties the original, so the revert finds nothing. Swift's `chose` flag does the same work twice and is left out as dead. Equivalent mutants: each preview built from the draft rather than the original (the selection holds still while the menu is open), `previewFont`'s validity term, Swift's too (`setFont` keeps a valid style valid), the worker's start-once guard and its join when the menu loads (without either the same answers are worked out again, costing only time), and the letters-only test of a face's name (every installed name in the dev image is letters and hyphens DejaVu covers); untested in the dev image: a face that cannot draw its own name (only DejaVu is installed). Tests: `TypeFontPreviewTests`, the Ctrl+Z case in `InlineTextDrawTests`. -- New Canvas presets and the Quick Look preview (13.7, Swift 1.3.6–1.3.7: `aaf3dc0`, `14aad18`, `4868465`): `NewCanvasSheet` twins `aaf3dc0`: the subtitle goes; beside "New canvas" a 28-point `QToolButton` (`presetSizes`, an instant popup) draws Swift's three 2.5-point dots in `WindowText`, flush with the fields' right edge, its own `paintEvent` drawing no menu arrow; Swift's tooltip and the accessible name "Preset sizes". `CanvasPreset` (in `NewCanvasSheet.h`, as Swift's file holds it) keeps Swift's `groups` and `all`; the menu is Custom, then each group after a separator, checkable `QAction`s in an exclusive `QActionGroup`, each holding its index in `all` (Custom −1). `checkPreset` checks the first preset whose sizes print as the fields' exact text, else Custom, on every edit of either field, as Swift's binding reads it; choosing a preset fills both fields; Custom fills nothing and checks the match again, since Qt checks a triggered action where Swift's picker sets nil and re-reads. Known difference: Swift's label is clickable ten points past the dots into the padding; the twin's button ends at the content's edge. The Quick Look preview twins `14aad18` and `4868465`: `ImageExporter::quickLookImages` returns nothing past 50 megapixels (logged as info), else renders the snapshot and `scaledJPEG`s it: the long side at most 1024, sides rounded, a pixel at least, `QImage::scaled` with `SmoothTransformation` (area averaging when shrinking) for CoreGraphics' high quality, drawn on white into `RGB888`, JPEG quality 80, 72 dpi as `CGImageDestination` writes; a render or scale that fails logs a warning on `omaphoto.io` and saves without one, Swift's `try?`. `QuickLookImages` (Swift's, in `ImageExporter.swift`) is declared in `ProjectStore.h`, since the exporter includes the store. `ProjectStore::save` takes it optionally: with one, the staged folder gains `QuickLook/Preview.jpg`, written and synced with its folder before the project's folder is synced and swapped in; without one the swapped-in project has none. Loading reads only the manifest and `images/`, so the folder is ignored, and `ProjectDigest` reads neither, so writing or deleting it is no external change (a test rewrites and deletes it under the digest). The controller's save worker makes the preview and hands it to the store. `docs/writing-comp-files.md` gains Swift's line, OmaPhoto's name. Equivalent mutant: the heading's stretch (the title label takes the room). Untested: the staged `QuickLook` folder's `mkdir` failure (a fresh staged folder; the write after it would fail with its own message), and the null checks after scaling and allocating the flattened image (an allocation failure); without them a null scale would save a white preview. Tests: `NewCanvasSheetTests` (the dots filled and never outlined, `presetsAreSwiftsGroups`, `aPresetFillsTheFieldsAndTheMatchIsChecked`, `theMoreButtonDrawsThreeDotsAndOpensTheMenu`), `QuickLookPreviewTests` (the store's folder and its absence, load ignoring it, the digest unchanged when the folder is rewritten or deleted, the JPEG byte for byte against smooth scaling on white at quality 80, sizes and rounding, the 50-megapixel bound, a failed render, a controller save), and the sync order in `ProjectReplaceTests`. +- New Canvas presets and the Quick Look preview (13.7, Swift 1.3.6–1.3.7: `aaf3dc0`, `14aad18`, `4868465`): `NewCanvasSheet` twins `aaf3dc0`: the subtitle goes; beside "New canvas" a 28-point `QToolButton` (`presetSizes`, an instant popup) draws Swift's three 2.5-point dots in `WindowText`, flush with the fields' right edge, its own `paintEvent` drawing no menu arrow; Swift's tooltip and the accessible name "Preset sizes". `CanvasPreset` (in `NewCanvasSheet.h`, as Swift's file holds it) keeps Swift's `groups` and `all`; the menu is Custom, then each group after a separator, checkable `QAction`s in an exclusive `QActionGroup`, each holding its index in `all` (Custom −1). `checkPreset` checks the first preset whose pixels the fields make (since 14.3; the fields' exact text until then), else Custom, on every edit of either field, as Swift's binding reads it; choosing a preset fills both fields, in the unit in use; Custom fills nothing and checks the match again, since Qt checks a triggered action where Swift's picker sets nil and re-reads. Known difference: Swift's label is clickable ten points past the dots into the padding; the twin's button ends at the content's edge. The Quick Look preview twins `14aad18` and `4868465`: `ImageExporter::quickLookImages` returns nothing past 50 megapixels (logged as info), else renders the snapshot and `scaledJPEG`s it: the long side at most 1024, sides rounded, a pixel at least, `QImage::scaled` with `SmoothTransformation` (area averaging when shrinking) for CoreGraphics' high quality, drawn on white into `RGB888`, JPEG quality 80, 72 dpi as `CGImageDestination` writes; a render or scale that fails logs a warning on `omaphoto.io` and saves without one, Swift's `try?`. `QuickLookImages` (Swift's, in `ImageExporter.swift`) is declared in `ProjectStore.h`, since the exporter includes the store. `ProjectStore::save` takes it optionally: with one, the staged folder gains `QuickLook/Preview.jpg`, written and synced with its folder before the project's folder is synced and swapped in; without one the swapped-in project has none. Loading reads only the manifest and `images/`, so the folder is ignored, and `ProjectDigest` reads neither, so writing or deleting it is no external change (a test rewrites and deletes it under the digest). The controller's save worker makes the preview and hands it to the store. `docs/writing-comp-files.md` gains Swift's line, OmaPhoto's name. Equivalent mutant: the heading's stretch (the title label takes the room). Untested: the staged `QuickLook` folder's `mkdir` failure (a fresh staged folder; the write after it would fail with its own message), and the null checks after scaling and allocating the flattened image (an allocation failure); without them a null scale would save a white preview. Tests: `NewCanvasSheetTests` (the dots filled and never outlined, `presetsAreSwiftsGroups`, `aPresetFillsTheFieldsAndTheMatchIsChecked`, `theMoreButtonDrawsThreeDotsAndOpensTheMenu`), `QuickLookPreviewTests` (the store's folder and its absence, load ignoring it, the digest unchanged when the folder is rewritten or deleted, the JPEG byte for byte against smooth scaling on white at quality 80, sizes and rounding, the 50-megapixel bound, a failed render, a controller save), and the sync order in `ProjectReplaceTests`. - Tabs reorder and overflow (13.14, Swift `1faf7a0`): `UI/ProjectTabLayout.h/.cpp` twin Swift's file: `projectTabSpacing`, `ProjectTabSlot`, `ProjectTabPillSlot`, `ProjectTabOverflow` (`contentWidth()` for Swift's computed property), `projectTabOverflowLabel` and `projectTabOverflow`, widths a `std::map` read through `at` (every tab has one), the pill's width a `std::function`. Left out as dead: `span`'s empty guard (never asked of no tabs) and `contentWidth`'s pill fallback (a pill always has a tab after it). `ProjectWorkspace::moveTab` is Swift's, clamped, and emits `changed()` only when a tab moved. `ProjectTabStrip` is now a plain `QWidget` that takes the toolbar's free width (the spacer after it went; its size hint is every tab in a row, its minimum none) and places its children by hand from `projectTabOverflow` over its own width: the overflow pill at x 0 and the visible tabs at y 3, 28 high, each as wide as its button's size hint (so the unsaved dot's extra width counts, as before), hidden tabs hidden; it lays out again on `resizeEvent`, on the workspace's signal and on each tab's session signal (a title or a dot changes widths; a hidden button posts no layout request). The QScrollArea, its offset and `showFront` are gone, as Swift's scroller and reveal are; the selected tab is always shown by the pinning. `OverflowTabsPill` (a `QToolButton`, `projectTabsOverflow`) paints an inactive tab's capsule, the label "N more tabs" at 12 pixels medium and a drawn `chevron.down`, the label and chevron at half opacity while the workspace cannot switch (the capsule stays, as Swift's `.opacity` sits before its background); tooltip and accessible name are the label; its width is Swift's `projectTabOverflowPillWidth` (the label's ceiling plus 11, 4, 10, 11). Its press opens a `QMenu` (`projectTabsOverflowMenu`, deleted on close) four points below it, as `popUp(positioning:)` does, listing the hidden tabs in order, "• " before an unsaved one, a choice selecting; while the workspace cannot switch no menu opens. The reorder twins Swift's: `ProjectTabButton` watches its title button's left press, move and release in global coordinates (Swift's `.global` space) and reports `TabDragPhase::changed` once the press has moved three points and `ended` on the release; the strip's `Reorder` is `TabReorderState` (others, widths, compacted x, start, origin, translation, target), `nearestSlot`, `renderX`, `makeReorder` and `commitReorder` as Swift's (a neighbour closed mid-drag has no place, as Swift's `if let`s). A drag begins only while the workspace can switch and across at least three points, selects its tab first and raises it over the others (Swift's `zIndex`); it commits on release only if the workspace can still switch; the others ease to their places through a 150 ms `QPropertyAnimation` of `pos` (`OutQuad` for SwiftUI's `easeOut`), the dragged tab following the pointer at once, and every tab eases home when it ends. Qt can lose a release (a modal alert opened mid-drag, such as the Revert question `select` may raise, blocks the strip's window): Swift's `dragTimer` is `tabReleaseWatch`, a 100 ms `QTimer` running while a drag lasts, which ends the drag uncommitted once `QGuiApplication::mouseButtons()` holds no button, so the tabs ease home; a drag whose tab closes is dropped on the workspace's signal too. Swift's `guard reorder?.id == id` stays. Known difference: Swift's empty title bar beside the tabs drags the window (`TitleBarDragView`); Qt's toolbar is no title bar, so the strip's empty part does nothing. `NewTabDropSlot` has no twin, as before. Equivalent mutant: reporting `ended` on every release, a press that never moved three points included (with no drag begun, `ended` only lays the strip out again; Swift's `onEnded` fires once the gesture moved three points, begun or not, as the twin's does). Tests: `ProjectTabLayoutTests` (Swift's six, the bounds at exactly fitting and a point below, the pill's spacing included, a lone wide pinned tab, the pill's width asked by count), `ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments` (Swift's, plus its signal), `ProjectTabDragTests` (following the pointer, clamping, the gap, the slide's duration, curve and end, stacking, landing left and right and in place, a small or upward move as a click, a busy project at the press and at the drop, a tab closed mid-drag, a release lost under a modal alert ending the drag while a held one lasts past the timer, and another tab pressed before the timer looks moving nothing (the drags go through the window, so the application tracks the buttons), a three-point move and a move under another button, the overflow pill's label, place and width, the menu's entries, place and choice, pinning, the busy pill), and `ProjectWorkspaceViewTests` (overflow in the window; the strip ends before the zoom buttons). `ProjectTabStripTests::onlyANewFrontTabScrollsIntoView` went with the scrolling. - Ungroup Layers and the mask pointer (13.15, Swift `1318f1e`, `6c3b9a5`, `6955a6f`): `EditorSession::canUngroupLayers` and `ungroupLayers` sit in `LayerGroups.cpp` (declared in `LayerGroups+Session.h`), as Swift's extension: the folder's direct children, their parent now the folder's own, take its place in the list in their order; `releaseDetachedClipping` then frees clips that no longer sit on their base; `finishOpacityEdit` ends a drag first; one `Ungroup Layers` step selects the children with the lowest active (none for an empty folder) and forgets the folder's collapse. Swift's `LayerHierarchy.validate` guard is dead and left out: unwrapping a folder only shortens parent chains, so nothing it refuses can appear; its `document` guard is `canEditLayers`'. The Layer menu gains Ungroup Layers (Ctrl+Shift+G) after Group Selected Layers, enabled by `canUngroupLayers`, and `ShortcutDefinition::all` Swift's "Ungroup Layers" entry (g, Cmd+Shift), so it can be remapped; the list's context menu shows it after Group Selected Layers for a right-clicked folder alone (Swift reads the clicked row, not the active layer), enabled by `canUngroupLayers`. `NativeLayerList::showMaskCursor` twins Swift's `showMaskCursor`: the duplicate pointer drawn over an eye in a 7.5 by 5.5 box at the hot spot plus (15, 18), haloed by sixteen white copies one point out, the image widened to hold it (29 by 32 at 1×), the hot spot the pointer's; Swift's `eye.fill` is drawn as paths (an almond of two quadratic curves, a ring cut round a filled pupil). Over a mask thumbnail with Alt, `cursorFor` shows it whatever `canEditLayers` says, as Swift's now does (13.12 brings the Alt-click that shows the mask alone). Equivalent mutants: the eye's black fill drawn after the arrows (it never meets an arrow pixel; the halo's order is pinned), and the context entry enabled by `canEditLayers` (the right-click makes the folder active, so `canUngroupLayers` says the same). Tests: `GroupSessionTests` (Swift's three ungroup tests; collapse forgotten, signals, a plain layer refused silently, a nested and an empty folder, an opacity drag ended, busy), `LayerContextMenuTests::aFoldersMenuOffersUngroup`, the menu entry and its gate in `CompositorMenusTests`, the shortcut count, Swift's chord and a remap of the entry in `KeyboardShortcutsTests`, the eye pointer's pixels, hot spot and the arrows over the eye in `LayerDragGestureTests`. - Compositor 1.4, part one (13.8: `8c0417b`, `fe7a83d`, `af3b568`, `3ccc125`, `d0135c8`, `dcf1c66`). A sharper canvas: `LayerRenderer::interpolation` takes Swift's `upright` (a layer whose `radians()` is 0, drawn within a thousandth of pixel for pixel, takes no filter), from `LayerRenderer::draw` and `TiledLayerRenderer`'s frames alike. The twin's adjustment surfaces already held device pixels (9.5b) and its adjustments already ran at the surface's scale (12.8c), so 1.4's surface and `adjustmentScale` changes were held; what was missing is ported: `AdjustmentSurface::draw` falls back to a pixel a painter unit when device pixels pass the budget (Swift's "falls back to points"), drawn back smoothly (Swift's `.high`), and nothing only when that passes too; `LayerAdjustment::apply` counts the noise origin per axis (`top × height / max(1, region height)`), where the twin used the width's scale for both. PSD (`IO/PSD`): Levels gamma in hundredths; `hue2` read as Swift's: 16 bytes at least, Colorize's own values or the Master's, then for each of the six ranges its band (degrees wrapped into 0–360) and values, 14 bytes each, until the data runs out; the reader keeps each mask's bounds and default byte (`PSDRecord::maskBounds`, `maskDefault`), and `PSDDocumentBuilder::maskOnLayerGrid` draws the stored patch, unfiltered, onto the layer's grid (its pixels, or the canvas for a layer without) at its place, the default round it; already the grid, or with no size, the patch is kept; a grid that cannot be allocated logs on `omaphoto.io` and takes the builder's existing "couldn't be converted" note, as Swift's nil does. `PSDAdjustments::levels` and `hue` are public, as Swift made them for its tests. Photoshop's Saturation: `HueSaturationFilter::adjustedSaturation`, Swift's to the letter. All cores: `kernels/LevelsPixels.c/.h` are Swift's file again (`cube_apply`); `BrushRaster::copy` (bytewise for the context's own format, drawn otherwise) and `BrushRaster::inBands` (one band under 250,000 pixels, else twice the ideal thread count, through `PoolMap`; a `bad_alloc` there is `ExportError(render)`, since the workers' callers catch that alone); `LevelsFilter::run` copies and runs `levels_apply` in bands; `HueSaturationFilter::run` copies and runs `cube_apply` in bands, where the twin had its own trilinear loop (the kernel's float order now: within a level of it, Swift's tests allow eight); `cube` keeps the last eight tables under a mutex, Swift's `cubes`. A blur's preview kept while the layer grows: `FilterEdit` gains `preparedTransform` and `pendingTransform`; a render carries the grown layer it was made from (`Filtered::placement`) and lands there, shown until the next replaces it; `displayedTransform` and `displayedMaskPlacement` read `preparedTransform`. Left out as dead in the twin: Swift's `current` terms (only a blur grows its grid, and only the automatic kinds, which never grow, refuse a stale preview or read `preparedSettings`), so the grid's version (`previewSourceVersion`, `Filtered::sourceVersion`) goes with nothing left to read it, and so does the twin's own remaking of a waiting job on growth (with Preview off nothing lands; with it on, the update makes a new job). The hierarchy kept: `LayerOrder` (`Document/LayerGroups.h`, its code in `LayerGroups+Order.cpp` with the document's hierarchy members, since `LayerGroups.cpp` would pass 500 lines): `Node`, `Result` (`order`, `visible`, `drawn`) and `resolve`, which keeps the last nodes and answer under a mutex; `CanvasDocument::hierarchy()` replaces `hierarchyEntries()`; `effectiveVisibleIDs`, `renderLayers` (a checked `std::map` index, after Swift's empty-order guard) and `effectiveOpacities` (a map of opacity and folder, no whole layers) read it; `groupSelectedLayers` reads `hierarchy().order`. `dcf1c66` was held: the canvas's `DisplayState` already works out the visible set once. Tests: `SharpCanvasTests` (the rule's edges; plain, masked and tiled layers crisp within the tolerance, filtered past it and when turned 2° at 1:1), `PSDAdjustmentTests` (Swift's three, plus the wrap, Colorize leaving the ranges, a cut record, the builder, a pixel layer's grid, a stretched layer, the patch kept, bounds or a layer of no height or width, the grid's bounds with a smaller patch, a patch the grid's size a pixel along), `PSDReaderTests` (the new layouts, the mask's bounds and default read and placed), `HueSaturationTests::positiveSaturationMatchesPhotoshop` (Swift's values, the clamps, a pixel), `AdjustmentBandsTests` (bands cover each pixel once, the copy's bytes, Levels and Hue/Saturation in bands against one pass of the kernels, cached tables), `HueSaturationFailureTests` (the filter now fails at its copy, the row list being gone), `FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne` (Swift's) and `aMaskKeepsItsPlaceUnderAPreview` (a covering mask under a preview that did not grow, and on a grown one the old bounds), `FilterSessionTests` and `FinishingFilterTests` (an older grid's preview shows on that grid: `aPreviewForAnOlderGridShowsOnThatGrid`, `aJobWaitingBehindAnotherEditShowsOnItsGrid`), `LayerOrderTests` (order, visibility and drawing against `LayerHierarchy::entries`, each shaping change seen, 64 deep, nothing shown draws nothing, opacities up known folders alone and a repeated id refused), `GroupTests`, `BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions` (each axis by its own scale, a region under a unit), `AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn` (the fallback, enlarged smoothly; a quarter of a pixel makes one). Equivalent mutants: `adjustedSaturation`'s clamps of the amount to ±1, its `max(0, …)` below zero and `share > 0` for `>= 0` (each branch gives the same number there; Swift's to the letter); the cube cache's insert, its removal of an older entry for the same settings and its trim to eight (each only spares work or memory); `BrushRaster::copy`'s bytewise branch taken never (drawing copies the same bytes), `inBands`' threshold moved by one and its `start < count` guard (Swift's; no start reaches the count unless the thread count passes some 500); `LevelsFilter::run` drawing its copy (the same bytes); `LayerOrder::resolve` keeping nothing (the cache only spares work). Untested failure path: `maskOnLayerGrid` out of memory (only an allocation that fails reaches it). Judgments: the adjustment surfaces' device resolution and `adjustmentScale` were held (9.5b, 12.8c); `dcf1c66` was held (the canvas already works out the visible set once); the rest ported. Mutants: 60 run, every one caught or recorded above (two unbuildable forms rewritten and caught). @@ -183,6 +183,7 @@ Platform adaptations, each forced by a missing Qt analog: - The Smear's 1.4.2–1.4.3 fixes (13.16, part one: `7164dd4`, `ad9ad7c`, `504129d`, and `095da2f`'s commit half): Liquify is Swift's new GPU stroke on the CPU, as the twin has no Metal: `WarpStroke` keeps the layer as it found it (`m_original`, a copy made in the constructor) and a source offset per pixel (`m_offsets`, two floats each, zero at first); each dab copies the offsets under it to `m_scratch`, reads them bilinearly from behind the brush's travel, less the travel times the weight, writes them back, and draws the pixel afresh from the untouched layer, bilinear, held to its edges, each byte rounded once: `warp_push` of `MetalWarp.swift`, float for float (bytes over 255, mixed, times 255). Swift allocates the two textures on the first push; the twin allocates them with the stroke, before any dab (nothing changes the copy between), so a failure is `ExportError(render)` where `beginWarp` catches it, where Swift's guard returns in silence; Smudge's carried colour, allocated at the first dab inside `beginWarp`, fails the same way, and a dab's scratch that finds no memory skips that dab with a warning on `omaphoto.rendering`, Swift's guard on its scratch texture (review 2: it ran outside any catch). The colour reads keep the shader's `size - 2` as they are: the offsets' read above needs two columns and rows of the dab's area (`ix`, `iy`), which a canvas a pixel wide or tall lacks, so no read leaves the image; the clamps first written here for such canvases were dead and are removed (review 2). Smudge is `ad9ad7c` in both of Swift's paths: dabs every 0.005 of the width (a pixel at least), each dab lays what it carries at weight × strength, and carries what it just left, nothing older. `BrushSettings.blurRadius` (5) is the Blur's own Radius: the sample's sigma follows the Radius (see part two for the merged formula). `BrushControls` shows Radius for the Blur mode alone, after Strength: a scrubbable label (a tenth a point, 0.5–50), a 100-point slider over 0.5–20 in hundredths (past 20 Qt rests it at its end, so Swift's `min(20, …)` is left out), and a 42-point `PickerField` with "px": a readable number applies on Return or leaving, clamped, a number that is not finite takes 5 (Swift's binding), other text goes back, arrows step one (Shift ten) from the session's value, clamped; it prints Swift's `fractionLength(0...1)`, never grouped. `BrushStroke::maxDiameter` is 2100, so `finishWarp`'s tip four wider than a 2000 brush is no longer refused; `finishWarp` appends a dab centre only every twentieth of the brush (the last always), as Swift's; the thinning is `WarpStroke::committedPoints`, so the test reads it directly (`aCommitKeepsATwentiethAndTheLastPoint`). Part two (`095da2f`'s Blur half, on 13.11's grid sampling, cc9bf95 merged into port-d): `BrushStroke::Clone` is 13.11's `(image, placed, inGrid)` plus `render`, a function that makes a part of `image` (Swift's `cloneRender`; Swift keeps it beside `clone`, the twin inside it, so one assignment sets both), and `clone` is private behind `clone()` and `setClone()`, which drops the pieces cut so far (Swift's two `didSet`s). `clonePixels(key, tile)` is Swift's `clonePiece`: the tile mapped into sample pixels through 13.11's chain (grid, `pixelToDocument` unless `inGrid`, then `placed` to the image's pixels, Swift's `scaleX` and `scaleY`), two pixels round, made whole outward (`toAlignedRect`, Swift's `integral`), held to the image; an empty part paints nothing on that tile (the fill is skipped, as Swift's `if let piece`); otherwise the piece is `render(part)` or a copy, cut once per tile key and kept in `m_clonePieces`, and the tile's sample is drawn from it through the same transform shifted by the piece's corner, so every path reads the pixels the whole image would give. A piece that cannot be allocated is `ExportError(render)`, thrown on the calling thread with `publish`'s other copies, where Swift's `cropping` returning nil paints nothing. `blurSample(stroke)` returns the sharp sample in the stroke's grid (13.11's `region`, `fit` and budget unchanged) with `render` blurring a part at `sigma × fit`, padded by the Gaussian's reach (`ceil(3σ)`) and cut back, clear past the sample for pixels (`QImage::copy` fills outside with zeros), its edges repeated for a mask (Swift's `clampedToExtent`), so a piece equals that part of the whole byte for byte (Swift allows one level). The Radius sigma is Swift's `min(min(50, max(0.5, blurRadius)) / perPixel, sidePixels / 2)`. Merge: port-c's direct `clone =` writes became `setClone` (`beginBrush`, `finishWarp`, `cloneSample`'s callers, the tests and `documentClone`), every `Clone` initialiser names `render` (GCC's `-Wmissing-field-initializers`), and port-c's Blur sample tests compare the sharp sample (`framed`) and its softening through `render` (`soft`), with each brush size's old sigma now set as the Radius. Equivalent mutants and untested paths for part two: a piece, a padded copy or a cut-back blur that cannot be allocated (`ExportError`) is untested, as no starvation binary reaches one copy here. Allocating the Liquify buffers for every mode is caught: Smudge then needs room it lacks in `WarpFailureTests`. Held since review 2: the Radius field's non-finite branch ("inf" takes 5); the colour read from the original rather than the context is held by the stripes (`liquifyDrawsFromTheUntouchedLayer`, mutant caught). Left out: Swift's `placed.width > 0` guard in `clonePiece` (every `placed` is a layer's rect, the canvas's or a padded region, never empty), and `gaussianBlur`'s edge flag inside `render` (the pad holds the edges, so the blur's own edge rule never reaches a part; it passes `false`). Equivalent under starvation: the untouched copy's null check (the offsets, twice its size, fail after it). Tests: `SmudgeLiquifyTests` (the oracle rewritten from Swift's new `smudge` and `warp_push`, every byte; pushes in from each edge over ramps; the session's smudge values pinned; a rim pixel the liquify moved), `WarpStrokeTests` (Swift's `liquifyStaysSharp` on Swift's photo, under one level where Swift measured 0.49; half-pixel pushes keep stripes crisp; Swift's `smudgeLeavesOneFadingTrail`; the commit's points a twentieth apart, exactly that apart kept, a pixel at least), `WarpFailureTests` (a Liquify dab without scratch memory skipped, Smudge without room to carry and Liquify without room for its offsets a render error), `LargeCanvasBrushTests` (Swift's `largestBrushCommits` for Liquify, Smudge and Blur on 5000 square at 2000; Swift's `blurPiecesMatchTheWhole` for pixels and a mask, exact; pieces cut once per tile, two pixels round, dropped by a new clone, none past the clone's end where the tile stays as it was; a thinned commit lands all a wide hard warp moved, straight and round a tight circle), `BlurBrushTests` (Swift's `radiusSetsHowFarItSoftens`, 6 and 42 columns, size alone; the clamps), `BlurToolTests` (port-c's sample tests on the Radius: the sharp sample and its softening, 0.5 and half the layer's side, 50 on a wide layer), `BrushControlsTests` (the Radius row, "inf" taking 5), `BrushTests` (2100 is taken, past it refused). Judgments: `7164dd4` drops Liquify from Swift's GPU-against-CPU test, and Swift's CPU fallback keeps resampling; the twin has one path, so it takes the GPU's (what every Mac with Metal runs), and its oracle replays the shader. - 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 spaces and tabs, 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. 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). - 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/src/ContentView.cpp b/src/ContentView.cpp index 16dcf68..3f10696 100644 --- a/src/ContentView.cpp +++ b/src/ContentView.cpp @@ -434,7 +434,7 @@ void ContentView::showWelcome(bool shown) if (!shown) return; m_welcome = new NewCanvasSheet( - m_session, [this](int width, int height) { m_session.createNewProject(width, height); }, + m_session, [this](int width, int height, double resolution, std::optional<QColor> background) { m_session.createNewProject(width, height, resolution, background); }, [this] { if (m_projects) m_projects->open(); diff --git a/src/Document/EditorSession+Projects.cpp b/src/Document/EditorSession+Projects.cpp index 6f197ff..09d50c1 100644 --- a/src/Document/EditorSession+Projects.cpp +++ b/src/Document/EditorSession+Projects.cpp @@ -91,10 +91,10 @@ void EditorSession::clearProject() notify(); } -void EditorSession::createNewProject(int width, int height) +void EditorSession::createNewProject(int width, int height, double resolution, std::optional<QColor> background) { if (m_isProjectBusy || m_isImporting || width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide) return; clearProject(); - createDocument(width, height, true); + createDocument(width, height, true, resolution, background); } diff --git a/src/Document/EditorSession+Projects.h b/src/Document/EditorSession+Projects.h index 3cd8325..10a4075 100644 --- a/src/Document/EditorSession+Projects.h +++ b/src/Document/EditorSession+Projects.h @@ -6,4 +6,4 @@ public: // Swift's reloadProject: what the package holds now, the view kept. void reloadProject(const ProjectSnapshot &snapshot); void clearProject(); - void createNewProject(int width, int height); + void createNewProject(int width, int height, double resolution = 72, std::optional<QColor> background = std::nullopt); diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp index d2db7b2..7508100 100644 --- a/src/Document/EditorSession.cpp +++ b/src/Document/EditorSession.cpp @@ -1,6 +1,8 @@ #include "Document/EditorSession.h" #include "Document/DocumentLimits.h" #include "Document/Crop.h" +#include "Document/PixelAdjust.h" +#include "IO/ImageExporter.h" #include "Logging.h" #include <cmath> @@ -281,17 +283,18 @@ void EditorSession::insert(const ImportedImage &asset, std::optional<QPointF> ce endEdit(); } -void EditorSession::createDocument(int width, int height, bool emptyLayer) +void EditorSession::createDocument(int width, int height, bool emptyLayer, double resolution, std::optional<QColor> background) { if (m_isProjectBusy || m_isImporting || width < 1 || width > DocumentLimits::maxSide || height < 1 || height > DocumentLimits::maxSide) return; commitTransform(); beginEdit(QStringLiteral("New Canvas")); CanvasDocument document(width, height); - // File > New starts with a selected blank "Layer 1". + document.resolution = resolution >= 1 && resolution <= 9600 ? resolution : 72; + // File > New starts with a selected first layer. std::optional<QUuid> selected; if (emptyLayer) { - document.layers.push_back(ImageLayer(QStringLiteral("Layer 1"), document.size())); + document.layers.push_back(firstLayer(document.size(), background)); selected = document.layers.back().id; } m_document = document; @@ -305,6 +308,23 @@ void EditorSession::createDocument(int width, int height, bool emptyLayer) endEdit(); } +// Empty, or the background in full-size pixels, as Photoshop's. +ImageLayer EditorSession::firstLayer(QSizeF size, const std::optional<QColor> &background) +{ + if (!background) + return ImageLayer(QStringLiteral("Layer 1"), size); + try { + QImage image = BrushRaster::context(int(size.width()), int(size.height()), false); + image.fill(*background); + ImageLayer layer(QStringLiteral("Background"), size); + layer.asset = ImportedImage(image, PixelAdjust::thumbnail(image), QStringLiteral("Background")); + return layer; + } catch (const ExportError &error) { + qCWarning(lcApp) << "a new canvas's background could not be made:" << error.what(); + return ImageLayer(QStringLiteral("Layer 1"), size); + } +} + void EditorSession::fit() { if (!m_document) diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 590de7e..7627c62 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -234,7 +234,8 @@ public: void moveActiveLayer(int offset); void insert(const ImportedImage &asset, std::optional<QPointF> centeredAt = std::nullopt); - void createDocument(int width, int height, bool emptyLayer = false); + // White or black fills a Background layer; none stays clear. + void createDocument(int width, int height, bool emptyLayer = false, double resolution = 72, std::optional<QColor> background = std::nullopt); void fit(); void zoom(double value, std::optional<QPointF> anchor = std::nullopt); // Swift's stepped keyboard zoom about the view's centre. @@ -313,6 +314,7 @@ private: void resumeFileRequests(); void updateBusyIndicator(); void endDuplicateTransform(); + static ImageLayer firstLayer(QSizeF size, const std::optional<QColor> &background); // Clone Stamp is family 1, the Smear 2, others 0. static int tipFamily(NavigationTool tool); diff --git a/src/UI/NewCanvasSheet.cpp b/src/UI/NewCanvasSheet.cpp index 51bdf0a..bd1895c 100644 --- a/src/UI/NewCanvasSheet.cpp +++ b/src/UI/NewCanvasSheet.cpp @@ -12,6 +12,10 @@ #include <QShortcut> #include <QVBoxLayout> #include <algorithm> +#include <cctype> +#include <clocale> +#include <cmath> +#include <cstdlib> namespace { QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) @@ -42,6 +46,147 @@ protected: painter.drawEllipse(QRectF(width() - dot, top + 2 * dot * index, dot, dot)); } }; + +// Swift's CyclePill: plain text, a soft capsule on hover. +class CyclePill : public QAbstractButton { +public: + CyclePill(const QString &name, QWidget *parent) : QAbstractButton(parent) + { + setObjectName(name); + setAttribute(Qt::WA_Hover); + setFocusPolicy(Qt::NoFocus); + QFont font = this->font(); + font.setPixelSize(12); + setFont(font); + } + QSize sizeHint() const override { return fontMetrics().size(Qt::TextSingleLine, text()) + QSize(12, 4); } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + if (underMouse()) { + // macOS's quaternary label: the text colour at 10%. + QColor capsule = palette().color(QPalette::WindowText); + capsule.setAlphaF(0.1f); + painter.setPen(Qt::NoPen); + painter.setBrush(capsule); + painter.drawRoundedRect(QRectF(rect()), height() / 2.0, height() / 2.0); + } + painter.setPen(palette().color(QPalette::PlaceholderText)); + painter.drawText(rect(), Qt::AlignCenter, text()); + } +}; + +// Swift's Double(text): the whole text, C numbers. +std::optional<double> number(const QString &typed) +{ + static const locale_t c = newlocale(LC_ALL_MASK, "C", locale_t(nullptr)); + const QByteArray bytes = typed.toUtf8(); + if (bytes.isEmpty() || std::isspace(uchar(bytes.front()))) + return std::nullopt; + char *end = nullptr; + const double value = strtod_l(bytes.constData(), &end, c); + if (end != bytes.constData() + bytes.size()) + return std::nullopt; + return value; +} + +std::optional<double> perInch(NewCanvasUnit unit) +{ + switch (unit) { + case NewCanvasUnit::pixels: return std::nullopt; + case NewCanvasUnit::inches: return 1.0; + case NewCanvasUnit::centimeters: return 2.54; + case NewCanvasUnit::millimeters: return 25.4; + } + throw std::logic_error("an unknown unit"); +} +} + +QString rawValue(NewCanvasUnit unit) +{ + switch (unit) { + case NewCanvasUnit::pixels: return QStringLiteral("px"); + case NewCanvasUnit::inches: return QStringLiteral("in"); + case NewCanvasUnit::centimeters: return QStringLiteral("cm"); + case NewCanvasUnit::millimeters: return QStringLiteral("mm"); + } + throw std::logic_error("an unknown unit"); +} + +QString name(NewCanvasUnit unit) +{ + switch (unit) { + case NewCanvasUnit::pixels: return QStringLiteral("Pixels"); + case NewCanvasUnit::inches: return QStringLiteral("Inches"); + case NewCanvasUnit::centimeters: return QStringLiteral("Centimeters"); + case NewCanvasUnit::millimeters: return QStringLiteral("Millimeters"); + } + throw std::logic_error("an unknown unit"); +} + +NewCanvasUnit next(NewCanvasUnit unit) +{ + return NewCanvasUnit((int(unit) + 1) % 4); +} + +std::optional<int> pixels(NewCanvasUnit unit, const QString &text, double resolution) +{ + QString trimmed = text; + // Spaces and tabs trimmed; a comma is the point. + while (!trimmed.isEmpty() && (trimmed.front() == u'\t' || trimmed.front().category() == QChar::Separator_Space)) + trimmed.remove(0, 1); + while (!trimmed.isEmpty() && (trimmed.back() == u'\t' || trimmed.back().category() == QChar::Separator_Space)) + trimmed.chop(1); + const std::optional<double> value = number(trimmed.replace(u',', u'.')); + if (!value || *value <= 0) + return std::nullopt; + const std::optional<double> inch = perInch(unit); + const double count = inch ? std::round(*value / *inch * resolution) : *value; + if (count != std::round(count) || count < 1 || count > DocumentLimits::maxSide) + return std::nullopt; + return int(count); +} + +QString text(NewCanvasUnit unit, int pixels, double resolution) +{ + const std::optional<double> inch = perInch(unit); + if (!inch) + return QString::number(pixels); + // Two decimals at most, none trailing, never grouped. + QString written = QString::number(double(pixels) / resolution * *inch, 'f', 2); + while (written.endsWith(u'0')) + written.chop(1); + if (written.endsWith(u'.')) + written.chop(1); + return written; +} + +QString title(NewCanvasBackground background) +{ + switch (background) { + case NewCanvasBackground::transparent: return QStringLiteral("Transparent canvas"); + case NewCanvasBackground::white: return QStringLiteral("White canvas"); + case NewCanvasBackground::black: return QStringLiteral("Black canvas"); + } + throw std::logic_error("an unknown background"); +} + +NewCanvasBackground next(NewCanvasBackground background) +{ + return NewCanvasBackground((int(background) + 1) % 3); +} + +std::optional<QColor> color(NewCanvasBackground background) +{ + switch (background) { + case NewCanvasBackground::transparent: return std::nullopt; + case NewCanvasBackground::white: return QColor(255, 255, 255); + case NewCanvasBackground::black: return QColor(0, 0, 0); + } + throw std::logic_error("an unknown background"); } const std::vector<std::vector<CanvasPreset>> CanvasPreset::groups = { @@ -72,10 +217,12 @@ const std::vector<CanvasPreset> CanvasPreset::all = [] { return presets; }(); -NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent) +NewCanvasSheet::NewCanvasSheet(EditorSession &session, Create onCreate, std::function<void()> onOpen, QWidget *parent) : QWidget(parent), m_session(session), m_onCreate(std::move(onCreate)), m_width(new QLineEdit(QStringLiteral("1920"), this)), - m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), - m_create(new QPushButton(QStringLiteral("Create canvas"), this)), m_presets(new MoreButton(this)) + m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::BrightText, this)), + m_create(new QPushButton(QStringLiteral("Create canvas"), this)), m_presets(new MoreButton(this)), + m_backgroundPill(new CyclePill(QStringLiteral("canvasBackground"), this)), m_unitPill(new CyclePill(QStringLiteral("canvasUnit"), this)), + m_resolutionPill(new CyclePill(QStringLiteral("canvasResolution"), this)) { // Swift's fields stretch the sheet to its full 500 points. setFixedWidth(500); @@ -96,11 +243,34 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i fields->setVerticalSpacing(8); fields->addWidget(text(QStringLiteral("Width"), 12, QFont::Medium, QPalette::WindowText, this), 0, 0); fields->addWidget(text(QStringLiteral("Height"), 12, QFont::Medium, QPalette::WindowText, this), 0, 3); + m_unitLabels = {text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this), text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)}; fields->addWidget(m_width, 1, 0); - fields->addWidget(text(QStringLiteral("px"), 12, QFont::Normal, QPalette::PlaceholderText, this), 1, 1); + fields->addWidget(m_unitLabels[0], 1, 1); fields->addWidget(text(QStringLiteral("×"), 14, QFont::Normal, QPalette::PlaceholderText, this), 1, 2); fields->addWidget(m_height, 1, 3); - fields->addWidget(text(QStringLiteral("px"), 12, QFont::Normal, QPalette::PlaceholderText, this), 1, 4); + fields->addWidget(m_unitLabels[1], 1, 4); + + // Swift's pills: each click steps to the next choice. + auto *pills = new QHBoxLayout; + pills->setSpacing(4); + for (QAbstractButton *pill : {m_backgroundPill, m_unitPill, m_resolutionPill}) { + if (pill != m_backgroundPill) + pills->addWidget(text(QStringLiteral("·"), 12, QFont::Normal, QPalette::PlaceholderText, this)); + pills->addWidget(pill); + } + pills->addStretch(1); + m_backgroundPill->setToolTip(QStringLiteral("Start see-through, or with a white or black Background layer. Click to switch.")); + m_unitPill->setToolTip(QStringLiteral("Units: pixels, inches, centimeters or millimeters. Click to switch.")); + connect(m_backgroundPill, &QAbstractButton::clicked, this, [this] { + m_background = next(m_background); + validate(); + }); + connect(m_unitPill, &QAbstractButton::clicked, this, [this] { switchUnit(next(m_unit)); }); + connect(m_resolutionPill, &QAbstractButton::clicked, this, [this] { + m_resolution = m_resolution == 300 ? 72 : 300; + validate(); + checkPreset(); + }); auto *buttons = new QHBoxLayout; buttons->setSpacing(10); @@ -118,6 +288,7 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i heading->addWidget(m_presets); column->addLayout(heading); column->addLayout(fields); + column->addLayout(pills); column->addWidget(m_note); column->addLayout(buttons); @@ -140,10 +311,7 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i QAction *action = choices->addAction(menu->addAction(preset.title)); action->setCheckable(true); action->setData(index++); - connect(action, &QAction::triggered, this, [this, preset] { - m_width->setText(QString::number(preset.width)); - m_height->setText(QString::number(preset.height)); - }); + connect(action, &QAction::triggered, this, [this, preset] { setPixels(preset.width, preset.height); }); } } m_presets->setMenu(menu); @@ -175,7 +343,7 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i void NewCanvasSheet::checkPreset() { const auto matched = std::ranges::find_if(CanvasPreset::all, [this](const CanvasPreset &preset) { - return QString::number(preset.width) == m_width->text() && QString::number(preset.height) == m_height->text(); + return std::optional(preset.width) == pixelWidth() && std::optional(preset.height) == pixelHeight(); }); const QVariant index(matched == CanvasPreset::all.end() ? -1 : int(matched - CanvasPreset::all.begin())); for (QAction *action : m_presets->menu()->actions()) @@ -188,20 +356,59 @@ void NewCanvasSheet::synchronize() setEnabled(!m_session.isImporting() && !m_session.showsBusy()); } +std::optional<int> NewCanvasSheet::pixelWidth() const +{ + return pixels(m_unit, m_width->text(), m_resolution); +} + +std::optional<int> NewCanvasSheet::pixelHeight() const +{ + return pixels(m_unit, m_height->text(), m_resolution); +} + +void NewCanvasSheet::switchUnit(NewCanvasUnit unit) +{ + if (const std::optional<int> width = pixelWidth(), height = pixelHeight(); width && height) { + m_width->setText(text(unit, *width, m_resolution)); + m_height->setText(text(unit, *height, m_resolution)); + } + m_unit = unit; + validate(); + checkPreset(); +} + +void NewCanvasSheet::setPixels(int width, int height) +{ + m_width->setText(text(m_unit, width, m_resolution)); + m_height->setText(text(m_unit, height, m_resolution)); +} + void NewCanvasSheet::validate() { - const bool valid = CanvasDocument::validDimension(m_width->text()) && CanvasDocument::validDimension(m_height->text()); - m_note->setText(valid ? QStringLiteral("Transparent canvas · sRGB") : QStringLiteral("Enter whole numbers from 1 to %1 pixels.").arg(DocumentLimits::maxSideText())); + const std::optional<int> width = pixelWidth(), height = pixelHeight(); + const bool valid = width && height; // The palette's bright text is the theme's warning colour. - m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText); + m_note->setText(m_unit == NewCanvasUnit::pixels ? QStringLiteral("Enter whole numbers from 1 to %1 pixels.").arg(DocumentLimits::maxSideText()) + : QStringLiteral("Enter a size up to %1 pixels at this DPI.").arg(DocumentLimits::maxSideText())); + m_note->setVisible(!valid); m_create->setEnabled(valid); + for (QLabel *label : m_unitLabels) + label->setText(rawValue(m_unit)); + m_backgroundPill->setText(title(m_background)); + m_unitPill->setText(name(m_unit)); + m_resolutionPill->setText(QStringLiteral("%1 DPI").arg(int(m_resolution))); + // In print units the pixels it makes show on hover. + const QString size = m_unit != NewCanvasUnit::pixels && valid ? QStringLiteral(" · %1 DPI: %2 × %3 pixels").arg(int(m_resolution)).arg(*width).arg(*height) : QString(); + m_resolutionPill->setToolTip(QStringLiteral("Resolution: 72 for screens, 300 for print. Click to switch.") + size); + for (QAbstractButton *pill : {m_backgroundPill, m_unitPill, m_resolutionPill}) + pill->updateGeometry(); } void NewCanvasSheet::create() { - const std::optional<int> width = CanvasDocument::validDimension(m_width->text()), height = CanvasDocument::validDimension(m_height->text()); + const std::optional<int> width = pixelWidth(), height = pixelHeight(); if (width && height) - m_onCreate(*width, *height); + m_onCreate(*width, *height, m_resolution, color(m_background)); } void NewCanvasSheet::showEvent(QShowEvent *event) @@ -212,8 +419,7 @@ void NewCanvasSheet::showEvent(QShowEvent *event) if (m_session.skipsInitialClipboardCanvasSize) { m_session.skipsInitialClipboardCanvasSize = false; } else if (const std::optional<QSize> size = clipboardDimensions(QApplication::clipboard()->mimeData())) { - m_width->setText(QString::number(size->width())); - m_height->setText(QString::number(size->height())); + setPixels(size->width(), size->height()); } } m_width->setFocus(); diff --git a/src/UI/NewCanvasSheet.h b/src/UI/NewCanvasSheet.h index a147908..3cb37d9 100644 --- a/src/UI/NewCanvasSheet.h +++ b/src/UI/NewCanvasSheet.h @@ -1,5 +1,7 @@ #pragma once #include "Document/EditorSession.h" +#include <QAbstractButton> +#include <QColor> #include <QLabel> #include <QLineEdit> #include <QMimeData> @@ -9,6 +11,23 @@ #include <functional> #include <vector> +// New Canvas units; print sizes become pixels at the DPI. +enum class NewCanvasUnit { pixels, inches, centimeters, millimeters }; +QString rawValue(NewCanvasUnit unit); +// The unit written out, for its pill. +QString name(NewCanvasUnit unit); +NewCanvasUnit next(NewCanvasUnit unit); +// Whole pixels for a typed size, or none. +std::optional<int> pixels(NewCanvasUnit unit, const QString &text, double resolution); +// Whole pixels, or print sizes to two decimals at most. +QString text(NewCanvasUnit unit, int pixels, double resolution); + +// What a new canvas starts as: clear, white or black. +enum class NewCanvasBackground { transparent, white, black }; +QString title(NewCanvasBackground background); +NewCanvasBackground next(NewCanvasBackground background); +std::optional<QColor> color(NewCanvasBackground background); + // New Canvas sizes in pixels, upright as held. struct CanvasPreset { QString title; @@ -23,7 +42,8 @@ struct CanvasPreset { class NewCanvasSheet : public QWidget { Q_OBJECT public: - NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent = nullptr); + using Create = std::function<void(int width, int height, double resolution, std::optional<QColor> background)>; + NewCanvasSheet(EditorSession &session, Create onCreate, std::function<void()> onOpen, QWidget *parent = nullptr); // A copied picture's size, read from its header alone. static std::optional<QSize> clipboardDimensions(const QMimeData *clipboard); @@ -37,13 +57,27 @@ private: void create(); // Checks the preset the fields match, else Custom. void checkPreset(); + std::optional<int> pixelWidth() const; + std::optional<int> pixelHeight() const; + // The same canvas written in another unit. + void switchUnit(NewCanvasUnit unit); + // A size in pixels, written in the unit in use. + void setPixels(int width, int height); EditorSession &m_session; - const std::function<void(int, int)> m_onCreate; + const Create m_onCreate; QLineEdit *const m_width; QLineEdit *const m_height; QLabel *const m_note; QPushButton *const m_create; QToolButton *const m_presets; + QAbstractButton *const m_backgroundPill; + QAbstractButton *const m_unitPill; + QAbstractButton *const m_resolutionPill; + std::vector<QLabel *> m_unitLabels; + // Always pixels, 72 DPI and clear at first. + NewCanvasUnit m_unit = NewCanvasUnit::pixels; + double m_resolution = 72; + NewCanvasBackground m_background = NewCanvasBackground::transparent; bool m_suggestedClipboardSize = false; }; diff --git a/tests/NewCanvasSheetTests.cpp b/tests/NewCanvasSheetTests.cpp index b7cd090..77104a1 100644 --- a/tests/NewCanvasSheetTests.cpp +++ b/tests/NewCanvasSheetTests.cpp @@ -11,8 +11,16 @@ namespace { struct Sheet { EditorSession session; QList<QSize> created; + QList<double> resolutions; + QList<std::optional<QColor>> backgrounds; int opened = 0; - NewCanvasSheet sheet{session, [this](int width, int height) { created << QSize(width, height); }, [this] { opened += 1; }}; + NewCanvasSheet sheet{session, + [this](int width, int height, double resolution, std::optional<QColor> background) { + created << QSize(width, height); + resolutions << resolution; + backgrounds << background; + }, + [this] { opened += 1; }}; QLineEdit &width = *sheet.findChild<QLineEdit *>("widthInput"); QLineEdit &height = *sheet.findChild<QLineEdit *>("heightInput"); QLabel ¬e = *sheet.findChild<QLabel *>("canvasNote"); @@ -71,6 +79,8 @@ private slots: void presetsAreSwiftsGroups(); void aPresetFillsTheFieldsAndTheMatchIsChecked(); void theMoreButtonDrawsThreeDotsAndOpensTheMenu(); + void thePillsStepThroughTheirChoices(); + void printUnitsWriteTheSameCanvas(); }; void NewCanvasSheetTests::cleanup() @@ -85,12 +95,13 @@ void NewCanvasSheetTests::itStartsAtFullHighDefinition() QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet)); QCOMPARE(fixture.width.text(), QString("1920")); QCOMPARE(fixture.height.text(), QString("1080")); - QCOMPARE(fixture.note.text(), QString("Transparent canvas · sRGB")); - QCOMPARE(fixture.note.foregroundRole(), QPalette::PlaceholderText); + QVERIFY(fixture.note.isHidden()); QVERIFY(fixture.create.isEnabled() && fixture.create.isDefault() && fixture.width.hasFocus()); QCOMPARE(fixture.sheet.width(), 500); QTest::mouseClick(&fixture.create, Qt::LeftButton); QCOMPARE(fixture.created, (QList<QSize>{QSize(1920, 1080)})); + QCOMPARE(fixture.resolutions, QList<double>{72}); + QCOMPARE(fixture.backgrounds, QList<std::optional<QColor>>{std::nullopt}); } void NewCanvasSheetTests::onlyWholeNumbersInRangeMayCreate() @@ -110,7 +121,8 @@ void NewCanvasSheetTests::onlyWholeNumbersInRangeMayCreate() QVERIFY(valid(" 640 ", "480")); // Either field alone refuses, by button, Return and Enter. const auto refused = [&] { - const bool warned = fixture.note.text() == "Enter whole numbers from 1 to 30,000 pixels." && fixture.note.foregroundRole() == QPalette::BrightText; + const bool warned = fixture.note.text() == "Enter whole numbers from 1 to 30,000 pixels." && fixture.note.foregroundRole() == QPalette::BrightText + && !fixture.note.isHidden(); fixture.create.click(); QTest::keyClick(&fixture.width, Qt::Key_Return); QTest::keyClick(&fixture.height, Qt::Key_Enter); @@ -118,13 +130,16 @@ void NewCanvasSheetTests::onlyWholeNumbersInRangeMayCreate() QTest::keyClick(&fixture.sheet, Qt::Key_Return); return warned && fixture.created.isEmpty(); }; - for (const QString &bad : {QString("0"), QString("30001"), QString(""), QString("12.5"), QString("-4"), QString("1e3"), QString("wide")}) { + // Swift's Double(text): whole numbers written any way pass. + QVERIFY(valid("1e3", "480.0") && valid("6,4e2", "480")); + for (const QString &bad : {QString("0"), QString("30001"), QString(""), QString("12.5"), QString("-4"), QString("3e4.5"), QString("wide"), + QString("inf"), QString("nan"), QString("\n640"), QString("640\n")}) { QVERIFY2(!valid(bad, "480") && refused(), qPrintable(bad)); QVERIFY2(!valid("640", bad) && refused(), qPrintable(bad)); } QVERIFY(fixture.created.isEmpty() && fixture.opened == 0); QVERIFY(valid("640", "480")); - QCOMPARE(fixture.note.text(), QString("Transparent canvas · sRGB")); + QVERIFY(fixture.note.isHidden()); } void NewCanvasSheetTests::returnInEitherFieldCreates() @@ -211,7 +226,7 @@ void NewCanvasSheetTests::theFirstTabSkipsTheClipboardOnce() QCOMPARE(first.width.text(), QString("1920")); QVERIFY(!first.session.skipsInitialClipboardCanvasSize); // The session's next sheet reads the clipboard again. - NewCanvasSheet later(first.session, [](int, int) {}, [] {}); + NewCanvasSheet later(first.session, [](int, int, double, std::optional<QColor>) {}, [] {}); later.show(); QVERIFY(QTest::qWaitForWindowExposed(&later)); QCOMPARE(later.findChild<QLineEdit *>("widthInput")->text(), QString("640")); @@ -290,12 +305,14 @@ void NewCanvasSheetTests::aPresetFillsTheFieldsAndTheMatchIsChecked() fixture.entry("Studio Display").trigger(); QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("5120x2880")); QCOMPARE(fixture.checked(), QString("Studio Display")); - // A size no preset has is Custom, matched as text. + // No preset has it: Custom. Presets match by pixels. fixture.height.setText("2881"); QCOMPARE(fixture.checked(), QString("Custom")); fixture.height.setText("2880"); QCOMPARE(fixture.checked(), QString("Studio Display")); - fixture.width.setText("05120"); + fixture.width.setText("05120.0"); + QCOMPARE(fixture.checked(), QString("Studio Display")); + fixture.width.setText("5121"); QCOMPARE(fixture.checked(), QString("Custom")); fixture.width.setText("1080"); fixture.height.setText("608"); @@ -340,5 +357,104 @@ void NewCanvasSheetTests::theMoreButtonDrawsThreeDotsAndOpensTheMenu() QVERIFY(shown); } +void NewCanvasSheetTests::thePillsStepThroughTheirChoices() +{ + Sheet fixture; + fixture.sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet)); + QAbstractButton &background = *fixture.sheet.findChild<QAbstractButton *>("canvasBackground"); + QAbstractButton &unit = *fixture.sheet.findChild<QAbstractButton *>("canvasUnit"); + QAbstractButton &resolution = *fixture.sheet.findChild<QAbstractButton *>("canvasResolution"); + QCOMPARE(background.text(), QString("Transparent canvas")); + QCOMPARE(unit.text(), QString("Pixels")); + QCOMPARE(resolution.text(), QString("72 DPI")); + QCOMPARE(background.toolTip(), QString("Start see-through, or with a white or black Background layer. Click to switch.")); + QCOMPARE(unit.toolTip(), QString("Units: pixels, inches, centimeters or millimeters. Click to switch.")); + QCOMPARE(resolution.toolTip(), QString("Resolution: 72 for screens, 300 for print. Click to switch.")); + // Each pill steps to the next and back round. + QTest::mouseClick(&background, Qt::LeftButton); + QCOMPARE(background.text(), QString("White canvas")); + fixture.create.click(); + QTest::mouseClick(&background, Qt::LeftButton); + QCOMPARE(background.text(), QString("Black canvas")); + QTest::mouseClick(&resolution, Qt::LeftButton); + QCOMPARE(resolution.text(), QString("300 DPI")); + fixture.create.click(); + QTest::mouseClick(&background, Qt::LeftButton); + QTest::mouseClick(&resolution, Qt::LeftButton); + QCOMPARE(background.text(), QString("Transparent canvas")); + QCOMPARE(resolution.text(), QString("72 DPI")); + fixture.create.click(); + QCOMPARE(fixture.created, (QList<QSize>{QSize(1920, 1080), QSize(1920, 1080), QSize(1920, 1080)})); + QCOMPARE(fixture.resolutions, (QList<double>{72, 300, 72})); + QCOMPARE(fixture.backgrounds, (QList<std::optional<QColor>>{QColor(255, 255, 255), QColor(0, 0, 0), std::nullopt})); + // A pill is text alone; hovered, a capsule shows. + const QImage plain = background.grab().toImage(); + QTest::mouseMove(&background); + QTRY_VERIFY(background.underMouse()); + background.repaint(); + QVERIFY(background.grab().toImage() != plain); +} + +void NewCanvasSheetTests::printUnitsWriteTheSameCanvas() +{ + Sheet fixture; + fixture.sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet)); + QAbstractButton &unit = *fixture.sheet.findChild<QAbstractButton *>("canvasUnit"); + QAbstractButton &resolution = *fixture.sheet.findChild<QAbstractButton *>("canvasResolution"); + QStringList labels; + for (const QLabel *label : fixture.sheet.findChildren<QLabel *>()) + labels << label->text(); + QCOMPARE(labels.count("px"), qsizetype(2)); + unit.click(); + QCOMPARE(unit.text(), QString("Inches")); + QCOMPARE(fixture.width.text(), QString("26.67")); + QCOMPARE(fixture.height.text(), QString("15")); + labels.clear(); + for (const QLabel *label : fixture.sheet.findChildren<QLabel *>()) + labels << label->text(); + QCOMPARE(labels.count("in"), qsizetype(2)); + QCOMPARE(fixture.checked(), QString("1080p")); + fixture.create.click(); + // At 300 DPI the same inches make more pixels. + resolution.click(); + QCOMPARE(resolution.toolTip(), QString("Resolution: 72 for screens, 300 for print. Click to switch. · 300 DPI: 8001 × 4500 pixels")); + QCOMPARE(fixture.checked(), QString("Custom")); + fixture.create.click(); + QCOMPARE(fixture.created, (QList<QSize>{QSize(1920, 1080), QSize(8001, 4500)})); + // A preset is written in the unit in use. + fixture.entry("Instagram Square").trigger(); + QCOMPARE(fixture.width.text(), QString("3.6")); + QCOMPARE(fixture.height.text(), QString("3.6")); + QCOMPARE(fixture.checked(), QString("Instagram Square")); + // With the height unreadable, a change of unit rewrites neither. + fixture.height.setText("tall"); + unit.click(); + QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("3.6xtall")); + unit.click(); + unit.click(); + unit.click(); + QCOMPARE(unit.text(), QString("Inches")); + fixture.height.setText("3.6"); + fixture.width.setText("200"); + QVERIFY(!fixture.create.isEnabled() && !fixture.note.isHidden()); + QCOMPARE(fixture.note.text(), QString("Enter a size up to 30,000 pixels at this DPI.")); + // One side unreadable: a unit change rewrites neither. + unit.click(); + QCOMPARE(unit.text(), QString("Centimeters")); + QCOMPARE(fixture.width.text(), QString("200")); + QCOMPARE(fixture.height.text(), QString("3.6")); + fixture.width.setText("30.48"); + unit.click(); + unit.click(); + QCOMPARE(unit.text(), QString("Pixels")); + // The height's 3.6 was read in centimeters: 425 pixels. + QCOMPARE(fixture.width.text(), QString("3600")); + QCOMPARE(fixture.height.text(), QString("425")); + QCOMPARE(resolution.toolTip(), QString("Resolution: 72 for screens, 300 for print. Click to switch.")); + QVERIFY(fixture.note.isHidden()); +} + QTEST_MAIN(NewCanvasSheetTests) #include "NewCanvasSheetTests.moc" diff --git a/tests/NewCanvasUnitTests.cpp b/tests/NewCanvasUnitTests.cpp new file mode 100644 index 0000000..7b139b8 --- /dev/null +++ b/tests/NewCanvasUnitTests.cpp @@ -0,0 +1,103 @@ +#include "UI/NewCanvasSheet.h" +#include <QtTest> + +// Swift's NewCanvasUnitTests: units, DPI and the first layer. +class NewCanvasUnitTests : public QObject { + Q_OBJECT +private slots: + void printSizesBecomePixelsAtTheDPI(); + void unitsRoundTripAndCycle(); + void newProjectKeepsItsResolution(); + void backgroundFillsTheFirstLayer(); +}; + +void NewCanvasUnitTests::printSizesBecomePixelsAtTheDPI() +{ + // US Letter at 300 DPI is 2550 × 3300. + QCOMPARE(pixels(NewCanvasUnit::inches, "8.5", 300), std::optional(2550)); + QCOMPARE(pixels(NewCanvasUnit::inches, "11", 300), std::optional(3300)); + QCOMPARE(pixels(NewCanvasUnit::millimeters, "210", 300), std::optional(2480)); + QCOMPARE(pixels(NewCanvasUnit::centimeters, "2,54", 72), std::optional(72)); + QCOMPARE(pixels(NewCanvasUnit::pixels, "1920", 300), std::optional(1920)); + QCOMPARE(pixels(NewCanvasUnit::pixels, "19.5", 72), std::nullopt); + QCOMPARE(pixels(NewCanvasUnit::inches, "200", 300), std::nullopt); + QCOMPARE(pixels(NewCanvasUnit::inches, "0", 300), std::nullopt); + // Rounded to the nearest pixel, halves away from zero. + QCOMPARE(pixels(NewCanvasUnit::inches, "0.005", 300), std::optional(2)); + QCOMPARE(pixels(NewCanvasUnit::inches, "0.0049", 300), std::optional(1)); + QCOMPARE(pixels(NewCanvasUnit::inches, "0.0016", 300), std::nullopt); + QCOMPARE(pixels(NewCanvasUnit::inches, " 1\t", 72), std::optional(72)); + QCOMPARE(pixels(NewCanvasUnit::inches, "\t\u00a01", 72), std::optional(72)); + // Infinity and NaN fail the range and the whole number. + QCOMPARE(pixels(NewCanvasUnit::inches, "inf", 72), std::nullopt); + QCOMPARE(pixels(NewCanvasUnit::pixels, "nan", 72), std::nullopt); + QCOMPARE(pixels(NewCanvasUnit::pixels, "30000", 72), std::optional(30000)); + QCOMPARE(pixels(NewCanvasUnit::pixels, "30001", 72), std::nullopt); +} + +void NewCanvasUnitTests::unitsRoundTripAndCycle() +{ + QCOMPARE(text(NewCanvasUnit::inches, 1920, 72), QString("26.67")); + QCOMPARE(text(NewCanvasUnit::inches, 2550, 300), QString("8.5")); + QCOMPARE(text(NewCanvasUnit::inches, 3000, 300), QString("10")); + QCOMPARE(text(NewCanvasUnit::millimeters, 2480, 300), QString("209.97")); + QCOMPARE(text(NewCanvasUnit::centimeters, 30000, 72), QString("1058.33")); + QCOMPARE(text(NewCanvasUnit::pixels, 30000, 72), QString("30000")); + for (NewCanvasUnit unit : {NewCanvasUnit::inches, NewCanvasUnit::centimeters, NewCanvasUnit::millimeters}) + QCOMPARE(pixels(unit, text(unit, 2550, 300), 300), std::optional(2550)); + QCOMPARE(next(NewCanvasUnit::pixels), NewCanvasUnit::inches); + QCOMPARE(next(NewCanvasUnit::inches), NewCanvasUnit::centimeters); + QCOMPARE(next(NewCanvasUnit::centimeters), NewCanvasUnit::millimeters); + QCOMPARE(next(NewCanvasUnit::millimeters), NewCanvasUnit::pixels); + QCOMPARE(rawValue(NewCanvasUnit::millimeters), QString("mm")); + QCOMPARE(name(NewCanvasUnit::centimeters), QString("Centimeters")); +} + +void NewCanvasUnitTests::newProjectKeepsItsResolution() +{ + EditorSession session; + session.createNewProject(2550, 3300, 300); + QCOMPARE(session.document().value().resolution, 300.0); + QCOMPARE(session.projectSnapshot().value().manifest.resolution, std::optional(300.0)); + // A resolution out of 1 to 9,600 is 72. + for (double odd : {0.5, 9601.0, qQNaN()}) { + session.createNewProject(10, 10, odd); + QCOMPARE(session.document().value().resolution, 72.0); + } + session.createNewProject(10, 10, 9600); + QCOMPARE(session.document().value().resolution, 9600.0); + session.createNewProject(10, 10, 1); + QCOMPARE(session.document().value().resolution, 1.0); +} + +void NewCanvasUnitTests::backgroundFillsTheFirstLayer() +{ + QCOMPARE(next(NewCanvasBackground::transparent), NewCanvasBackground::white); + QCOMPARE(next(NewCanvasBackground::white), NewCanvasBackground::black); + QCOMPARE(next(NewCanvasBackground::black), NewCanvasBackground::transparent); + QCOMPARE(title(NewCanvasBackground::white), QString("White canvas")); + for (const auto &[background, value] : {std::pair(NewCanvasBackground::white, 255), std::pair(NewCanvasBackground::black, 0)}) { + EditorSession session; + session.createNewProject(40, 30, 72, color(background)); + const ImageLayer layer = session.document().value().layers.front(); + QCOMPARE(layer.name, QString("Background")); + QCOMPARE(session.activeLayerID(), std::optional(layer.id)); + const QImage image = layer.asset.value().image(); + QCOMPARE(image.size(), QSize(40, 30)); + QCOMPARE(image.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(image.pixelColor(0, 0), QColor(value, value, value)); + QCOMPARE(image.pixelColor(39, 29), QColor(value, value, value)); + QCOMPARE(layer.asset.value().thumbnail.pixelColor(0, 0), QColor(value, value, value)); + } + // A thumbnail, not the whole picture, for the panel. + EditorSession large; + large.createNewProject(400, 300, 72, color(NewCanvasBackground::black)); + QCOMPARE(large.document().value().layers.front().asset.value().thumbnail.size(), QSize(96, 72)); + EditorSession session; + session.createNewProject(40, 30, 72, color(NewCanvasBackground::transparent)); + QCOMPARE(session.document().value().layers.front().name, QString("Layer 1")); + QVERIFY(!session.document().value().layers.front().asset); +} + +QTEST_MAIN(NewCanvasUnitTests) +#include "NewCanvasUnitTests.moc" diff --git a/tests/RemappedKeysTests.cpp b/tests/RemappedKeysTests.cpp index 56e3305..06a574d 100644 --- a/tests/RemappedKeysTests.cpp +++ b/tests/RemappedKeysTests.cpp @@ -235,7 +235,7 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys() const QSet<QString> zooms{"Ctrl+K", "Ctrl+Q", "Ctrl+0", "Ctrl+1", "Ctrl+=", "Ctrl+-"}; QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, session, [](std::optional<QByteArray>) {})), zooms); QCOMPARE(keys(ColorPickerSheet(session, [](bool) {})), both); - QCOMPARE(keys(NewCanvasSheet(session, [](int, int) {}, [] {})), QSet<QString>{"Ctrl+K"}); + QCOMPARE(keys(NewCanvasSheet(session, [](int, int, double, std::optional<QColor>) {}, [] {})), QSet<QString>{"Ctrl+K"}); QCOMPARE(keys(TransformInspector(session)), both); QCOMPARE(keys(SelectionAmountSheet(session, SelectionAmountOperation::expand)), both); session.addEffect(LayerEffectKind::stroke); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 2518026..3468cdb 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -157,6 +157,7 @@ compositor_test(ScrubbableSheetsTests) compositor_test(ScrubbableCameraRawTests) compositor_test(ScrubbableTransformTests) compositor_test(NewCanvasSheetTests) +compositor_test(NewCanvasUnitTests) compositor_test(OmarchyThemeTests) compositor_test(ProjectControllerTests) compositor_test(ExternalChangeTests)