diff --git a/AGENTS.md b/AGENTS.md index 30be24c..bf11ca4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,7 @@ Platform adaptations, each forced by a missing Qt analog: - Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay as QPainter's `CompositionMode_SoftLight`, which follows the PDF formula `BlendModeTests` holds. `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files. - Folders, opacity and Snap (12.2a, Swift 1.1.5–1.1.6): a folder takes an opacity that multiplies into each layer inside it, never composited as a unit, since folders pass through. `LayerOpacity::effective` (in `LayerGroups.h`) walks up to 64 folders, a lookup standing for Swift's closure; `ImageLayer::effectiveOpacity`, `ProjectLayerRecord::effectiveOpacity` (declared in `ProjectStore.h`, defined in `LayerGroups.cpp`, as Swift's extension) and `CanvasDocument::effectiveOpacities` twin Swift's, the last refusing a repeated id with a `logic_error` where Swift's dictionary traps. Every drawing path takes it: the export, `drawLiveComposite`, the canvas (its every draw, adjustments included, and `DisplayState`, so a folder's change redraws) and the live-mask baker's sources, whose records are pointers, so it calls `LayerOpacity::effective` itself. `canEditOpacity` is one layer, a folder included; `canEditAppearance` stays for blending, so `LayerAppearanceControls` rests with `canEditOpacity` and its Blend caption and picker with `canEditAppearance`; `setSelectedLayersOpacity` dims selected folders too. The format moves to version 8 here (Swift's guides arrive under it in 12.3; until then a version-8 project's `guides` key is ignored as unknown), because 1.2.0's store rule (`d9b3f9e`) lets a folder dim from version 8 on, its blend mode always normal. View ▸ Snap is `snappingEnabled`, a getter and a setter that clears the guides and notifies (Swift's `didSet`); `snappedMove` and the crop drag read it. The transform box draws its accent alone. The effects sheet's sliders span Swift's new ranges (Size 0–20, Distance 0–100, the inner shadow's 0–50, Blur 0–100) while typing and steps reach the effect's own limits (500, 5,000, 500), clamped by `apply`; `QSlider` pins a larger value at its end by itself, so Swift's clamp of the thumb has no twin; the swatch takes a white ring inside a black rim, where SwiftUI's `contentShape` has no need of a twin (a Qt button takes its whole rect). The layer list takes T for Type, as 1.1.6's table does. Equivalent mutant: `canEditOpacity`'s `activeLayer()` term (one selected layer is the active one). `drawPixelGrid` moved to `EditorCanvas+Grid.cpp`, keeping the canvas file under 500 lines. Tests: `FolderOpacityTests` (the walk, the export, the composite, adjustments, effects, the canvas in each way it draws, the baker, Swift's `aDimmedFolderSavesAndReopens`), folder duplication in `LayerDuplicateTests` (moved out of `EditorSessionTests` at 500 lines), `TransformTests` and `LayerDragTests`, the controls in `LayerAppearanceControlsTests`, Snap in `TransformSnapTests`, `CropCanvasTests` and `ViewMenuTests` (the View menu tests, moved out of `CompositorMenusTests`), the ranges and swatch in `EffectsPanelTests`. - Keyboard shortcuts (12.2b, Swift 1.1.6): `UI/KeyboardShortcuts.h/.cpp` twin Swift's file, `KeyboardShortcuts+Sheet.cpp` its sheet and recorder. `ShortcutChord` keeps Swift's key strings (Backspace 0x7f, Return, Escape, Tab, Space, the arrows as Cocoa's F700s) and modifier bits, Ctrl for Command, Alt for Option, Meta for Control, then Shift; a key event reads Backspace and Delete alike, Enter as Return, Backtab as Tab, a shifted bracket or sign as its own key, anything else by its lowered key name, so a function key's name is long and refused (Swift takes it as a private-use character; known difference); labels are `QKeySequence`'s native text (Ctrl+Alt+K), where Swift prints symbols. `ShortcutDefinition::all` is Swift's list less Hide Compositor, which has no twin; `problem` keeps Swift's rules less its three chords reserved by macOS (the desktop takes its own before the app does). `ShortcutSettings` keeps the overrides in `QSettings` under Swift's `keyboardShortcuts.v1`, as Swift's JSON, a stored set that fails `problem` ignored with a warning on `omaphoto.app` where Swift is silent; `changed()` stands for its observation, and `reload` reads the store again, as a launch does. The menus remember each entry's own key in a property and take `menu` of it on every change. `canvasEvent` and `textEvent` hand back the press to handle, or none to swallow: the canvas (its key handling moved to `EditorCanvas+Keys.cpp`, the navigation file at 500 lines), the layer list and the window's key filter read `canvasEvent`, open text `textEvent`; a translated press the canvas or list leaves passes the original on, as `super.keyDown` does. Swift's `panPhysicalKey` is `m_panKey`: the key whose release ends a remapped pan. `NativeShortcut` is `configuredNativeShortcut`: a `QShortcut` over the sheet and its children clicks its button when that shows (a disabled button refuses `click` by itself); its own plain Return or Escape rests it, leaving the dialogs' default and escape handling, so a field's Return still commits first, while a button moved onto the other's key takes it; a moved original key is swallowed at the sheet; a synthesized press carries Swift's shifted text (`}` for Shift+]), which the brush keys read; `NewCanvasSheet`'s own Return shortcuts rest while Apply is moved; Levels' Preview takes its chord this way, in place of the checkbox's shortcut. The sheet is Swift's: 660 wide, the hint, a search, the three groups of rows with 150-by-26 recorders, the contextual words in Linux's keys, the problem in `BrightText` at 12 pixels only while there is one, Restore Defaults, Cancel and a default Save resting while recording or wrong; a modifier alone keeps recording, and while recording the recorder claims every key, Swift's `performKeyEquivalent`: a `Qt::Tool` panel keeps its parent window's menu shortcuts live, and `QWidget::event` would move the focus on Tab. It opens from the Edit menu, after Paste and a separator, in the `keyboardShortcuts` panel `CompositorMenus` holds. Equivalent mutants: `menu`'s and the Shift branch's `isMenu` terms (menu chords all carry modifiers), `canvasEvent`'s early return without overrides, `m_panKey`'s reset (every pan sets it) and Save's explicit default (its only auto-default button). Tests: `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests`, `RemappedKeysTests`. -- Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a namespace), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) stands beside 1.1.6's `snappingEnabled` (read by `snappedMove` and the crop drag), as it does in Swift through 1.3.3, with a View entry each. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`. +- Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a struct since 13.5b), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) stands beside 1.1.6's `snappingEnabled` (read by `snappedMove` and the crop drag), as it does in Swift through 1.3.3, with a View entry each. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`. - Rulers, and guides on the canvas (12.3b, Swift 1.1.7): `UI/CanvasRulers.h/.cpp` twin Swift's file: `CanvasRuler::thickness`, `CanvasRulerCorner` and `CanvasRulerView` (a widget per strip, repainting on every `changed()`, where Swift's canvas asks each ruler to redraw), with Swift's `majorStep` and `label`. The canvas is the rulers' sibling, so a ruler maps its points through the screen (`inCanvas`), where Swift reads its own bounds as the canvas's. A press hands the canvas the keys and begins a guide from that ruler (horizontal from the top one), a drag moves it, a release over a ruler drops it; a ruler has no mouse tracking, so it sees no buttonless move and leaves a lost release to the canvas's next press. `ContentView` frames the canvas slot in a grid with the corner and the two strips, shown while `showsRulers` holds and a document is open. `TransformOverlay` draws the layout grid (majors solid gray 0.7 at 45%, subdivisions dotted one point on and two off in gray 0.55 at 28% once four points apart, both a hairline) and the guides (`EditorSession::guideColor`, a hairline across the whole view) first, as Swift's `draw`; the canvas redraws the view when the grid's flag, the guides' flag or the displayed guides change (`GuidesShown`), Swift's observation. Guide drags live in `EditorCanvas+Guides.cpp`: with the Move tool a press on a guide (`canEditGuides` and `hitGuide`) moves it ahead of any layer under the resize cursor of its axis, a drag follows, a release over a ruler (`isOverRuler`: a point left of or above the canvas while the rulers show) deletes it; Escape cancels it ahead of a transform edit's keys; wheels and pinches rest while one is dragged; the Move tool's hover shows the guide's resize cursor after the handles and before a layer's move cursor. Qt can lose a release: a buttonless move and a lost focus finish the drag where it is. Equivalent mutant: the ruler press's `canEditGuides` test (`beginGuideCreation` asks it again). Tests: `GuideCanvasTests` (Swift's `moveToolDragsAGuideAndRulerDropDeletesIt` and `rulerStepUsesNicePixelIntervals`; the drawing, repaints, cursors, keys, lost releases, the rulers' layout, ticks, repaints, focus and drags), the canvas's place in `ContentViewTests`. - The Magic tool's Object mode (12.4, Swift 1.1.8): `Document/ObjectSelection.h/.cpp` twin Swift's file, `ObjectSelection+Session.h` its session half; `WandMode` (Swift's raw values, `allWandModes`) sits in `Selection.h`. Vision's foreground instances have no Linux twin (the user's decision): `SubjectRemoval::foreground` hands back U²-Net's mask at the image's size (the Remove Background model and cache, 9.7), which `select` scales to the model's 320 grid, cuts at half, and splits into 8-connected regions (`region`, the neighbours a traced outline joins); the region under the clicked cell is the instance, background none, and a picture where the model finds no subject none, as Vision's empty result. Its cells are enlarged smoothly to the image and refined by `GuidedMatte::refine` (radius 5) on the image, for Swift's edge-preserving upsample, then cut at half; `adjusted` erodes or dilates a pixel ring a step (eight neighbours, ten steps at most, the image's edge counting as inside, Swift's `eroded` and `dilated`), `MagicWand::outline` traces it and `smoothed` rounds the stairs as Swift's (the start Qt's close repeats dropped first, since a CGPath's close adds no point, then a subpath under three points dropped, Douglas–Peucker at 1.6 from the leftmost point, Chaikin three times, the fill rule kept; Swift's later drops of a repeated end and Chaikin's guard under three points are dead after that and left out). A `bad_alloc` is `ExportError(render)`; Swift's `unsupported` has no twin. The worker catches `SubjectRemovalError`, `MagicWandError` (the trace's refusals) and `ExportError`, each landing in `brushError` as Swift's `catch`. `selectionSample` is the wand's sample made public as Swift's; `selectObject` runs through the wand's watcher and landing (`Wanding` carries the step's name, `Object Selection`), with a completion callback, the offset clamped to ten either way and the anti-alias setting as its smoothing. `pressWandKey` chooses the tool, W on the canvas and in the list, a repeat ignored; Tab steps the mode; the tool's label, its shortcut definition (`Magic`) and the bar's title say Magic. The bar (`LassoControls`) shows Wand and Object as checkable buttons for Magic alone, the colour wand's controls in Wand mode, and in Object mode This Layer and All Layers and an Edge field of −10 to 10 px (`SelectionAmountField` takes a sign when its range goes below zero; a lone minus changes nothing, an overflow reads as its end), with Swift's help; the anti-alias help follows the mode. The canvas's press runs `selectObject` ahead of the colour wand, a click inside the selection picks afresh, the cursor is the crosshair with `SelectionIcon::objectSelection` (haloed by eight offsets as every selection icon, where Swift strokes it white), and the rail draws the icon for Object mode (`ToolIconKind::object`); the status hint follows the mode. Known difference: U²-Net finds one salient mask, so objects touching in it are one region where Vision may tell instances apart. Left out as dead: the mode picker's `cancelLasso` (Magic never holds a draft: `beginLasso` refuses it and `selectTool` cancels one), `selectObject`'s busy term (`canEditSelection` reads it) and its clamp of the offset (`adjusted` clamps), and a null check of the enlarged cells (`GuidedMatte::refine` checks its own). Equivalent mutants: the grid's cut at exactly 128, `simplifyClosed`'s guard under four points (the fallback returns the same points), `finish`'s three-point threshold at four (a traced loop has four corners), the Chaikin reserve, the edge's rounding (typing and arrows give whole numbers), W calling `selectTool` (`pressWandKey` is that call). Untested failure paths: the region's own 320-cell image, 100 KB, which no limit starves without starving the grid before it, and the worker's `MagicWandError`: a refined mask never nears eight million edges, and the trace's image-sized buffer asks less than the guided filter before it. Tests: `ObjectSelectionTests` (regions, edges, smoothing, the model on red discs, the session, keys, the canvas), the bar in `LassoControlsTests`, the icon sheet, the rail and hints in `ContentViewTests` and `ContentViewStatusTests`. - Photoshop import (12.5a, Swift 1.2.0): `src/IO/PSD/` twins Swift's `IO/PSD` folder. `PSDTypes` holds `PSDError` (Swift's five cases and words), `PSDConversion`, `PSDRecord`, `PSDDocument` and `fromPSD` (Swift's `LayerBlendMode.fromPSD`); Swift's `PSDLayerKind.other` is never assigned, so the case and its note are left out. `PSDChannelCoder` decodes raw and PackBits planes (a no-op run skipped, every overrun `truncated`) and builds `RGBA8888_Premultiplied` pixels tagged sRGB, rounding as Swift's `(c × a + 127) / 255`, and `Grayscale8` masks; a surface that cannot be allocated is `ExportError(render)` through `BrushRaster::context`, where Swift says `truncated`. `PSDReader` reads Swift's fields in Swift's order over a `Cursor` whose bounds check cannot overflow (an 8B64 length near `qint64`'s top reads as truncated, where Swift's `offset + count` traps), maps the file where it can and reads it where it cannot (a pipe, a mount without `mmap`), as `mappedIfSafe` does (a file that cannot be opened or read is `unreadable`, where Swift passes on the Cocoa error), logs every refusal on `omaphoto.io` and rethrows it. Pascal names are Mac OS Roman through a table of the 128 high code points taken from Python's `mac_roman` (Qt decodes it only through ICU); keys are ASCII or nothing, as Swift's `.ascii`. `PSDAdjustments::parse` (in `PSDReader+Adjustments.cpp`) reads Levels, Curves and Hue/Saturation as Swift does. `PSDVector` draws vector masks with `QPainterPath` (tight `boundingRect`, a path empty only without elements as a `CGPath`), fills by nonzero winding and strokes with `Qt::SvgMiterJoin` at 10, SVG's miter rule being CoreGraphics' (probed: kept to a ratio of 9.57, bevelled from 10.43; `Qt::MiterJoin` measures from the join point and clips instead); `integral` stays in doubles, so a wild box reaches the size check instead of an overflowing `QRect`. `PSDDocumentBuilder` makes the layers and conversions; a shape's identity is its asset's. `ImageImporter::loadPhotoshop` and `photoshopAssets` run on the import's worker, `makeImport` on the UI thread. In the session (`EditorSession+Imports.cpp`) a file whose first bytes are `8BPS` takes the Photoshop path: `beginPSDReading` shows the sheet while a `QFutureWatcher` reads it, `finishPSDReading` fills it or takes it away, `finishConversion` answers from the event loop (a resumed task), Cancel while reading drops the file silently, a failure joins the import's failures, and a confirmed file lands through `insertPhotoshop` (a new canvas at the file's size and resolution, or one folder named for the file inside the active folder, centred on a drop point, 10,000 layers at most). A request of Photoshop files alone is one `Import Photoshop File` step. Fixes beyond Swift: `insertPhotoshop` validates the hierarchy first, so folders nested past its depth refuse the file (`truncated`), and a folder clipped to the layer below takes no link but the base note, where Swift links it; either way Swift admits a document its store refuses. Left out as dead: the gates' `showsConversionSheet` terms and its `didSet` (the sheet shows only while importing, which the gates refuse already), Swift's test hook `confirmConversions`, `confirmPSDConversions` and `NativeLayerList.moveLayer` (no caller), the request's `id`, the builder's folder and adjustment checks on a clipping base (only pixel layers become bases), the reader's `layerSectionEnd`, the default mask colour, the reader's plane-size checks (a plane decodes to its size), resolution's finiteness test and its floor, the vector code's finiteness tests on boxes built from finite numbers and the origination's notes (never set), Curves' two length checks past the first, `pixelSize`'s area in doubles (its boxes are whole, so the whole pixels decide alone), the session's catch around `makeImport` (every image arrives with its asset, so nothing there allocates), and the new folder's removal from the collapsed set (a fresh id is in no set). `UI/PSDConversionSheet` is Swift's sheet in a window-modal `QDialog` (`conversionSheet`) that `ContentView` shows from the session's request: the title at 17 pixels bold, the words, a spinner while reading or the list of layer names over their notes, Cancel and the default confirm button, resting while reading; Escape and the close button cancel, and a sheet the session takes away answers nothing; `open()` makes the dialog window-modal. The request shown is kept, so an unrelated change keeps the content, and replaced content hides at once and is deleted later. The importer's filter and the desktop entry take `.psd` (`image/vnd.adobe.photoshop`); a dropped picture is copied as `Dropped.` into a folder of its own, as Swift 1.2.0 keeps a dropped file's name. Equivalent mutants: the cursor's own bounds refusal and `skip`'s (every read past the end meets another refusal: a short signature, a short plane), PackBits' count and row-end checks (the branches refuse the same bytes; they spare reading past the buffer), the `reserve` calls, `painter.end()` before the raster returns, the drop copy's `close()` before its static removal, and the logs. Untested failure path: a read that fails after the file opened (an I/O error; Qt 6.4 refuses to open a directory). Tests: `PSDRoundTripTests` and `PSDVectorTests` (Swift's, through `tests/PSDFixture.h`, Swift's writer, and `PSDVectorFixtures.h`), `PSDReaderTests` and `PSDReaderLayerTests` (the reader's edges, every cut before the last layer byte, budgets and vector layers read whole, built by `PSDReaderFixtures.h` and `PSDVectorBuilders.h`), `PSDImportTests` (the session and the sheet), and the drop, importer, message and desktop checks in their files. @@ -152,7 +152,7 @@ Platform adaptations, each forced by a missing Qt analog: - The guide for agents (12.13c, Swift 1.3: `c317306`): `docs/writing-comp-files.md` twins Swift's guide and `docs/project-format.md` Swift's format page, both adapted to Linux (a project is a folder, the example lives under `~/Pictures`, the save swaps folders with `renameat2`, a replaced project folder is noticed too, sources named by the twin's files). Swift's top-level `AGENTS.md` for agents becomes the section at the head of this file. README gains Swift's lines (scrubbing, saving while editing, projects changed on disk, working with AI agents). The format page's claims follow the twin, where Swift's page is out of date: the pixel budgets scale with memory, the save's exchange has a two-step fallback, only `maskPlacement` needs a mask, the common adjustment fields are required and later kinds default to their own values, folders pass adjustments through, text becomes pixels after Canvas Size, Image Size and Crop, and a missing face may fall to another family. `WritingProjectsGuideTests` reads the guide itself: the minimal manifest loads with the fields it shows (visibility, opacity, blend, placement, the image named after its layer), the Curves layer's points are pinned and its rendered pixel is the background through those curves, removing any common adjustment field refuses the project, a folder passes an Invert through, `hsvSettings` wins over the legacy Hue/Saturation fields (the guide says where to edit a saved one), and the guide's lists of blend modes, adjustment kinds and samplings equal the twin's `rawValue`s. The guide asks for 8-bit RGBA layers and accepts what the store accepts: RGB, grayscale and lower-depth grayscale layers, never 16 bits. `ExternalChangeTests::theGuidesSameSizeRecipeReloads` runs the same-size recipe against a watched project. - Quitting while text is edited (12.14a, Swift 1.3.1: `a18d86c`, `afd622e`, `4a4cf85`, `221d6cc`): `ProjectWorkspace::confirmQuit` first applies every tab's open text in quit order (`finishTextEditing`, Swift's), so closing the window (the twin's quit) no longer stops at `canSwitch`, which an open draft refuses; text that cannot be applied (a busy project) stops the quit with the draft kept. Swift's `applicationShouldTerminate` guard has no twin: closing the window reaches `confirmQuit` directly. Swift's cursor commits (the arrow restored off the canvas, the I-beam kept across it while text is open) need no code here: a Qt widget owns its cursor, so leaving the canvas shows the next widget's, and the canvas already shows the Type tool's I-beam away from the box. Tests: `QuitWhileEditingTextTests` (Swift's two through the real window's close: the text applied as one `New Text Layer` step before the question, Cancel keeping the window, then Don't Save; every tab in quit order with its own content and its undo; a refused draft in the middle of three tabs stopping the quit where it stands, the tab before applied, the tab after untouched, nothing asked; edited existing text as one `Edit Text` step on the same layer; an unchanged existing draft and a blank new one closing with no step and no question), `InlineTextHandleTests::theIBeamHoldsAcrossTheCanvas` (the I-beam away from the box, after a resize drag, no application-wide cursor on leaving, and neither the window nor a sibling taking the I-beam). - Version 1.3.2 (12.14b): `IO/RecentProjects` twins Swift's class as a `QObject` singleton with one signal, `changed()`. Swift mirrors the list macOS keeps (`NSDocumentController`); Qt has no API for the desktop's list, so the twin keeps its own in `QSettings` under `recentProjects`, ten places at most, newest first, each resolved as `samePlace` resolves it (so a link and its target are one entry); `refresh` drops places that no longer exist and signals only on a change, and runs on activation, where a GUI application exists (a GUI-less run has none). The controller notes a place after a save lands and after an open installs, never after a failure; `open` refuses a place that no longer exists before any read, refreshing the list, with the alert "Couldn’t open the project" naming it in Cocoa's words ("The file “Name.comp” couldn’t be opened because there is no such file."). The File menu's Open Recent follows Open Project…: each place titled without its suffix (`ProjectTab::nameWithoutSuffix`), opening it through the front tab's controller (so the workspace, as Swift's `projects.open`), then a separator and Clear Menu (`clearRecent`), resting while the list is empty; the whole submenu (`openRecent`) rests while the controller cannot start. Swift's `showEditor` has no twin (the window is the editor). The Image Size sheet keeps `m_lastResolution`, the last usable resolution: a zero or negative entry leaves the print size alone, a new valid one scales the pixels from it, and a width or height typed in Inches or Centimeters meanwhile is ignored (`555417e`); a resolution set by a print size without resampling goes through `setResolution`, as Swift's `onChange` sees every write. Camera Raw's Color Grading sits under Color and opens with Light and Color (`7e9afbe`): the `Section` enum, titles, eyes and `adjusts` follow the new order. Swift's `d62a132` makes every window dark whatever macOS is set to; the twin's windows, alerts included, already take the built-in dark or the Omarchy theme through the application palette, and follow Omarchy by design (Linux-only behaviour); file panels are the desktop's own, which on Omarchy follow the same theme, so nothing changes. A reload after a save over the opened place notes the place again, since another note may land between. The alert's words are plain text (`qt_msgbox_informativelabel`), so a name is never read as markup, and a menu title doubles its ampersands. Tests: `OpenRecentTests` (the stored list at start, activation checking again, nothing noted for a cancelled replacement, a cancelled Save As or a failed save, a save noted once it lands, the reload's note after another, the submenu's title and place, a literal ampersand and markup, opens and saves noted newest first, a failed open noting nothing, ten kept and a link counted once, moved projects dropped with one signal, the menu listing, opening, clearing and resting, a deleted project named), `RecentProjectsCoreTests` (the list without a GUI application, and across launches: note, read and clear each in a fresh process of the binary), `ImageSizeSheetTests::aNewResolutionKeepsThePrintSize` and `aDocumentsOwnResolutionIsTheLastUsable` (a 300-ppi document, zero, negative and infinite entries in both physical units with and without resampling, Pixels and Percent edits allowed meanwhile, positive resolutions past Resize's range remembered), `CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview` and `eachEyeHidesItsOwnGroup`, the alert's palette in `OmarchyThemeTests`, the File menu's count and Open Recent's gate (busy, the new-canvas sheet, importing, the importer, the workspace's panel) in `CompositorMenusTests`. -- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped ". Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`, `ColorRangeSheet`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing the field's typing, arrow keys walking every slider in both modes without dropping typing, resets keeping Preview off, a range under Colorize keeping its tracks, other buttons and a resting sheet refused, a reset that scrubs on, the sliders' thousand steps), `CameraRawSliderTests::aReshapeLeavesADragAlone`, `CameraRawSliderTests` (two-colour tracks read through the list), `FilterControlsTests` (titles one width, sixty at least). +- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped "<Title>. Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`, `ColorRangeSheet`, `GridSettingsSheet`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing the field's typing, arrow keys walking every slider in both modes without dropping typing, resets keeping Preview off, a range under Colorize keeping its tracks, other buttons and a resting sheet refused, a reset that scrubs on, the sliders' thousand steps), `CameraRawSliderTests::aReshapeLeavesADragAlone`, `CameraRawSliderTests` (two-colour tracks read through the list), `FilterControlsTests` (titles one width, sixty at least). - Colouring only the selected letters (12.14d1, Swift 1.3.2: `2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): `LayerTextStyle` gains Swift's optional `colorRuns` (`LayerTextColorRun`: location and length in UTF-16 units as `qint64`, Swift's `Int`, and sRGB red, green and blue), valid when sorted, apart, not empty, within the content and within Int, with Swift's `color(at:)` (`color`), `setColor(_:in:)` (an empty span or the whole text recolours the base and drops the runs) and `replaceCharacters(in:withLength:)` (new units take the colour before them, or at the start the first letter's), both working through Swift's per-unit colours and joining neighbours of one colour. `TextSpan` is Swift's `NSRange`; `TextDraft.selection` sits last. `TypeTool.h` declares `PaletteColor` opaquely and `ColorPalette.h` includes it, because `ColorPickerState::EditedText` now holds the text's whole style (Swift's `(draftID, style)`). The manifest writes `colorRuns` only when set, each run's five keys required, and the store refuses runs before version 10, which saves now write (versions 1–10). `TextLines` gives each paragraph its runs as `QTextLayout` formats before laying it out, so a run's letters draw once in their colour, as CoreText's runs do: formats handed to `draw` are an overlay, which would leave the base colour in their antialiased edges. The palette follows Swift's: the foreground swatch and the Type bar's picker paint the draft's selection (`setDraftTextColor`), previews too; Cancel puts back the base and the runs the text had when the picker opened (`restoreDraftTextColors`), the runs only while the content is unchanged; the Type bar's picker now keeps that style as the palette's does; `typeColor` is the first selected letter's colour, or with a caret the letter before it; Fill (`recolorText`) recolours every letter, runs gone, even when the base colour already matches; the text defaults never keep runs, so new text starts in one colour. Swift's clearing of the runs when new text begins is left out as dead, since the defaults it starts from never hold runs. The inline editor is the twin's own, so Swift's `shouldChangeTextIn` bookkeeping sits in `publish`, which takes each edit's exact replacements (`Step`: the range taken, the new length) in order: typing and deleting, the clipboard (the preedit out, the selection replaced, the preedit back), input methods (the old preedit out, the selection taken and the commit's replacement as one step when the commit lands where the selection was, as NSTextView replaces it, the new preedit in, and a lone preedit over the selection one replacement for the colours, as `setMarkedText` makes it, the selection's removal still history; an update, a cursor's move or a commit of the preedit where it stands is one replacement of the marked text for the colours, as `setMarkedText` and `insertText` replace it whole, even with the same string, so a leading preedit keeps the colour it took) and undo and redo; each step moves the runs against the text's length as the steps before it left it, as Swift's content changes between its calls. Each undo entry keeps the plain steps from its text to the next newer state (`since`: preedits never count, a preedit left as text by a key does), and undo and redo replay their inverses newest first, as NSTextView undoes each replacement, so a letter put back takes the colour before it, as Swift's does; a guess from the text alone would put colours on the wrong twin of a repeated letter. The draft learns the selection on every publish and every move (`noteSelection`, Swift's `textViewDidChangeSelection`). Swift's reset of runs that no longer fit after an edit is left out: every edit moves them. The editor lays its lines out from the style without runs: its glyphs are clear, and the formats would paint them in their colours over the layer beneath. The caret takes the colour of the letter before it (Swift's `insertionPointColor`). Swift's see-through selection under the picker (`b5fe653`) needs nothing: the twin draws the selection translucent, in the inactive colour when the canvas lacks the keys, and `4503998` only adds a test. Equivalent mutants: the inherited colour's `colors[start]` for `colors.front()` (at the start they are one) and `TextLines`' clip of a run to its paragraph (Qt clips a format to its layout's text). Docs: `project-format.md` and `writing-comp-files.md` say version 10, as Swift's. Tests: `TextColorModelTests` (Swift's model and invalid runs, every channel checked, runs of one colour joining only where they meet), `TextColorRunsTests` (Swift's other two: the selection painted and reopened with version 9 refused, Cancel restoring; the swatch's colour, the Type bar's picker, Fill and new text, the manifest's keys, runs across paragraphs, the run's Codable refusals, Cancel over mixed colours for both pickers), `TextColorEditorTests` (the selection reaching the draft, runs through typing, deleting, undo and paste, twins of one letter through paste, commit, preedit, cut, undo, redo, a replacement by itself, paste and commit around a preedit, a commit over a selection and undo after a preedit, a coloured tail through redo, undone deletions, a commit with a preedit and a paste around one, a preedit over the first letter updated, moved and committed, the first letter's colour at the start for the swatch and the caret, the caret's colour, Swift's swatch keeping the selection, each run drawn without a fringe, the overlay drawing no glyphs, selected or not, with the keys or without), coloured rows in `TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted`, the store's version 10 rows in `ProjectManifestTests`, and version 10 across the store's tests. - Faces for the selected letters (13.2, Swift 1.3.4: `0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test): `LayerTextStyle` gains Swift's optional `fontRuns` (`LayerTextFontRun`: location, length, fontName), `fontNameAt` (Swift's `fontName(at:)`: C++ cannot give a method a member's name), `uniformFontName` and `setFont`; `replaceCharacters` moves colours and faces alike, so the editor's steps, undo and the clipboard carry faces as they carry colours. The run members live in `TypeTool+Runs.cpp`, since `TypeTool.cpp` would pass 500 lines. A run's name follows Swift's guard: not empty, 200 graphemes at most, no newline character (LF, CR, VT, FF, NEL, U+2028, U+2029). `TextLines` lays each run in its face as a format (`TextLayout::font(style, face)` keeps the style's size, spacing, kerning and hinting); the editor lays out the faces, for the glyphs' places, and drops the colours alone. The manifest writes `fontRuns` when set; the store writes version 11 and refuses faces before it. `TypeFontPicker` shows a selection in several faces as (Multiple), a top item that is no font (its item data), and drops it once one face shows; a chosen face applies to the draft's selection, and the face shown or (Multiple) changes nothing. A fix beyond Swift: Swift's `textDidChange` copies the pending style's runs but not its face, so deleting until every letter left is in one run's face drops the run and turns those letters back to the old base face; the twin keeps the folded face (`deletingToOneFaceKeepsItsLetters`). Swift's `holdsSelection` has no twin: AppKit's text view loses its highlight when the menu takes the focus, while the inline editor keeps its selection unfocused (`TextFontControlsTests` holds it). Swift's reset of `fontRuns` in `beginText` is dead, as the colour's is: the defaults never carry runs. Equivalent mutants: `uniformFontName`'s clamp of its index (runs are sorted and apart), the layout's clamp of a run to its paragraph (Qt clips a format past the text, undocumented, so the clamp stays) and the decoder's string check (any other value reads as an empty name, which validity refuses). Tests: `TextFontRunsTests` (Swift's three; the store's rules; layout in mono, bold and across a break), `TextFontControlsTests` (the menu, the kept selection, typing and undo, the folded face, a click placed by the faces), version 11 across the store's tests. - Sampling and open text (12.14d2, Swift 1.3.2: `810c88a`, `d448625`): closing the colour picker, by OK or Cancel, while text is open asks for canvas focus (`closeColorPicker` counts `canvasFocusRequest` before its final signal), and `CanvasView::consumeFocusRequest` now hands the keys to the canvas with a draft open too, where the editor takes them, as Swift makes the text view first responder, so typing and Ctrl+Return reach the text after sampling from the canvas. Swift also takes a sampling click for an open panel before its window sees it, so the panel stays key; a window manager activates whatever window is clicked, and handing the panel the keys back at the press would take the canvas's focus and end the sampling it began, so the twin hands them back at the release, as Swift did before that commit (known difference). `canAdjust` (so `canAdjustColors`, `canVignette` and the fill) and `canInvert` refuse while text is open, since the editor draws that text, not its layer, and a preview of it would be wrong; the Image and Filter menus follow. Tests: `PickingTests::openTextTakesTheKeysBackFromThePicker` (the foreground, background and Type bar pickers, new and existing text, OK and Cancel, one request each, typing after and Ctrl+Return applying, any focus request, no request without text for any picker by OK or Cancel, the owner's field keeping the keys), `InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField` (a request now takes the keys from a button, a modal dialog keeping them with text open, a tool panel's field keeping them while the canvas waits for its window's turn), `TextEditingGatesTests` (the gates and the entries with new and reopened text, open and closed, Levels, Hue/Saturation, filters and Invert refused with nothing changed, content-aware fill and Camera Raw, Vignette on an empty layer). @@ -162,6 +162,7 @@ Platform adaptations, each forced by a missing Qt analog: - Dialog colours (13.4a, Swift 1.3.5: `c580a38`): Export JPEG's background for transparency and Canvas Size's custom extension open the app's picker, where they opened the desktop's `QColorDialog`. `ColorPickerTarget` gains `dialog` with its `dialogTitle` (`Color Picker (JPEG Background)`); the change Swift keeps in the session as `dialogColorChange` rides in `ColorPickerState::dialogChange`, so it goes with its picker. `openDialogColorPicker` refuses while a picker is open; `previewDialogColor` hands the dialog the working colour and changes nothing of the session's, so it is silent, as Swift's `@ObservationIgnored` closure; `closeColorPicker` tells it the chosen colour or the original. `pickingForDialog` keeps the canvas from picking, and the picker's sheet leaves out "Click the canvas to sample" for a dialog. `DialogColorSwatch` lives in `UI/ColorPickerSheet+Dialog.h/.cpp`, where Swift keeps it in `ColorPickerSheet.swift`: it derives from `SwatchButton`, whose header includes the picker's. It is drawn as the brush's swatch (34 by 18, radius 4, a white ring, a black rim), named by its title, opens the picker on a click, hands it each new working colour on `changed()` (Swift's `onChange`, the last colour remembered), and closes it, keeping the colour, when hidden (Swift's `onDisappear`: Canvas Size's swatch hides when the extension leaves Custom). The sheets close it before Cancel, OK and Export, OK first so an open picker's colour is the fill, and in their destructors, before their members go, since the swatch's change writes into them. Both sheets take the session; Canvas Size reads the palette from it. A window-modal sheet blocks its window's other windows, the picker's panel among them, where a macOS sheet leaves the app's panels free: `FloatingPanel::show` parents a panel to `QApplication::activeModalWidget()` while one is up, else to its owner's window, moving a panel made earlier across; a panel under a sheet dies with it and the next is the window's again. Judged: `908bed9` (Qt delivers `applicationStateChanged` on the GUI thread, so `RecentProjects` hops nowhere) and `158cd53` (`ProjectDigest` already hashes a local's bytes). Tests: `DialogColorPickerTests` (the session, the swatch, the panel over a modal sheet, the canvas), the sheets' swatches in `JPEGExportSheetTests` and `CanvasSizeSheetTests`, the panel over the real Canvas Size sheet in `ProjectResizeTests`. Equivalent mutants: the swatch's memory of the picker's colour, kept or not (a sheet ignores a colour it holds). - Export JPEG's zoomable preview (13.4b, Swift 1.3.5: `ac6f309`): the exporter decodes the preview at full size, 8192 a side at most, so 100% shows the file's own pixels. `JPEGPreview` (declared in `UI/JPEGExportSheet.h`, defined in `JPEGExportSheet+Preview.cpp`, as Swift keeps it beside the sheet) is a `QAbstractScrollArea` of Swift's 560 by 330 frame: Fit, or a zoom from Swift's steps (25% to 800%, `step` a thousandth either side), 1 being one image pixel a device pixel (`shownSize` divides the export's size by the device pixel ratio, as Swift by `displayScale`, so a preview decoded smaller still spans it); an image smaller than the view sits in its middle; a new zoom keeps the view's middle on the same part of the image, or starts at the image's middle from Fit (Swift's `keepCentered`, whose `before.width > 0` guard is dead and left out); a left drag moves it (the open hand, closed while dragging, the arrow in Fit); the wheel scrolls; a left double click switches Fit and 100%; from 100% up it draws nearest, below 100% through an area-averaged copy kept per size (`QImage::scaled`, Swift's high quality; QPainter's bilinear filter aliases a big reduction), dropped only for a new image. Without mouse tracking Qt delivers no buttonless move, so a lost release cannot be seen, and a new press restarts the drag. The zoom reaches the sheet through a callback given at construction. The sheet's title row carries Fit (resting in Fit) and the two magnifiers (`LayerIcons`' `plusMagnifyingGlass` and `minusMagnifyingGlass`, redrawn on `PaletteChange`), each resting at its end, with Swift's help naming the key and the zoom; the title row sits 8 above the preview (Swift's `padding(.bottom, -8)`); the size, the result and the buttons share the bottom row, 12 apart. Swift's `previewZoom` hook routes the View menu's Fit Canvas, Actual Pixels, Zoom In and Zoom Out to the preview; a window-modal sheet blocks the window's menus, so the sheet binds those four keys itself as `QShortcut`s, as remapped (`ShortcutSettings::menu`), again on every remap, and no session hook is needed. The import error's second OK has no twin: the alert already holds one button. Tests: `JPEGPreviewTests` (each drawing against `QImage::scaled` references), `JPEGExportSheetTests`, `JPEGExportTests`' limit, the keys through the real menus in `ProjectExportTests`, device pixels in `HighDpiCanvasTests`. Equivalent mutants: the nearest branch from above 1 rather than from 1 (at 100% the averaged copy is the image itself), and dropping the averaged copy for an image already held (it only costs a new average). - 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`. - 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 <title>"), 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`, a spacer 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 `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (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/CMakeLists.txt b/CMakeLists.txt index 8e1fd62..8983403 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,7 @@ add_library(compositor_core STATIC src/UI/CropControls.cpp src/UI/CanvasRulers.cpp src/UI/CanvasSizeSheet.cpp + src/UI/GridSettingsSheet.cpp src/UI/ImageSizeSheet.cpp src/UI/TrimSheet.cpp src/UI/JPEGExportSheet.cpp diff --git a/README.md b/README.md index a7517e1..8d52971 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b - Layers, folders, masks, clipping masks, blend modes and opacity; whole layers copy between projects - Selections: marquee, lasso, magic wand and its object mode, Select › Subject, load from a layer, feather, expand and contract - Brush, eraser, spot healing, clone stamp, smudge, blur and liquify, gradient, shapes, type with colour on selected letters -- Move, transform and distort layers and selections; rulers, guides, a grid and snapping +- Move, transform and distort layers and selections; rulers, guides, a grid with adjustable spacing and subdivisions, and snapping - Levels, Curves, Hue/Saturation, Exposure, Black & White, Color Balance, Gradient Map, Grain, Invert, blur and noise, as edits or as adjustment layers - Camera Raw Filter, docked beside the canvas - Layer effects: stroke, drop shadow, outer and inner glow, color overlay, inner shadow diff --git a/TASKS.md b/TASKS.md index dcdd3d4..f40d3b5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -427,7 +427,8 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 13.4b review (Fable 5.1): round 1 FAIL (the full-size preview passed Qt's 128 MB decode limit on large canvases, so they could not export; the kept middle's `min` untested); round 2 PASS. - [x] 13.5a Part three: the marquee, shapes and a moved selection snap to View › Snap To (`d6e3e93`, `8b1369a`). - 13.5a review (Fable 5.1): round 1 FAIL (test gaps: rounding, the targets without centres, a path's own box, the drag's reach when zoomed); round 2 FAIL (the nearer of two lines); round 3 FAIL (the nearer line neither first nor lowest). All fixed, each mutant caught; closed after the third round. -- [ ] 13.5b Part four: View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`). +- [x] 13.5b Part four: View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`). +- 13.5b review (Fable 5.1): round 1 FAIL (the picker's Cancel turned Light Gray, Light Blue and Green into Custom, since only one side was compared in 8 bits; the drawing's dots, dashes, caps and 4-point threshold loosely tested); round 2 PASS. The CPU cap was lifted to all 32 cores during this item (scripts/mutate.py follows). - [ ] 13.6 Part five: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`). - [ ] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone. - [ ] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`). diff --git a/scripts/mutate.py b/scripts/mutate.py index 3b07ae0..22dc8c7 100755 --- a/scripts/mutate.py +++ b/scripts/mutate.py @@ -29,7 +29,7 @@ def docker(command): uid, gid = os.getuid(), os.getgid() runtime = '/run/user/%d' % uid done = subprocess.run( - ['docker', 'run', '--rm', '--init', '--cpus=12', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen', + ['docker', 'run', '--rm', '--init', '--cpus=32', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen', '--tmpfs', '%s:uid=%d,gid=%d,mode=0700' % (runtime, uid, gid), '-e', 'XDG_RUNTIME_DIR=' + runtime, '-v', '%s:%s' % (repo, repo), '-w', repo, 'omaphoto-dev', 'timeout', '600', 'sh', '-c', command], capture_output=True, text=True) @@ -38,7 +38,7 @@ def docker(command): def build(): wanted = ' --target ' + ' '.join(targets) if targets else '' - return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build -j12' + wanted) + return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build -j32' + wanted) def test(): diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index d611a4f..d4b495d 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -188,7 +188,6 @@ public: int brushRevision() const { return m_brushRevision; } // Selected pixels on the move, from a Ctrl-drag or Ctrl-arrow. const PixelMove *pixelMove() const { return m_pixelMove.get(); } - // The one filter open; its sheet shows while it lasts. const std::optional<FilterEdit> &filterEdit() const { return m_filterEdit; } // The last committed filter's settings, where the next one starts. @@ -212,7 +211,6 @@ public: void setEffectsEditingOriginal(std::optional<LayerEffects> original); // The effect chosen in the list; see selectedEffect. const std::optional<LayerEffectSelection> &effectSelection() const { return m_effectSelection; } - bool isModified() const { return history.isModified(); } bool canUseHistory() const; // Undo's first press discards a pending gradient. @@ -223,7 +221,6 @@ public: // A transaction that nests; a whole gesture is one entry. void beginEdit(const QString &name); void endEdit(); - bool canEditLayers() const; void addBlankLayer(); void deleteLayer(QUuid id); @@ -367,6 +364,8 @@ private: bool m_snappingEnabled = true; // The layout grid starts off; guides show; rulers hide. bool m_showsGrid = ToolDefaults::boolean(QStringLiteral("grid"), false); + LayoutGrid m_layoutGrid = LayoutGrid::stored(); + GridAppearance m_gridAppearance = GridAppearance::stored(); bool m_showsGuides = ToolDefaults::boolean(QStringLiteral("guides"), true); bool m_showsRulers = ToolDefaults::boolean(QStringLiteral("rulers"), false); bool m_snapEnabled = ToolDefaults::boolean(QStringLiteral("snap"), true); diff --git a/src/Document/Guides+Session.h b/src/Document/Guides+Session.h index a6e608b..56d477b 100644 --- a/src/Document/Guides+Session.h +++ b/src/Document/Guides+Session.h @@ -5,6 +5,11 @@ public: static constexpr double guideHitDistance = 5; bool showsGrid() const { return m_showsGrid; } void setShowsGrid(bool shows); + // View > Grid Settings…: the person's, kept across launches. + const LayoutGrid &layoutGrid() const { return m_layoutGrid; } + void setLayoutGrid(const LayoutGrid &grid); + const GridAppearance &gridAppearance() const { return m_gridAppearance; } + void setGridAppearance(const GridAppearance &appearance); bool showsGuides() const { return m_showsGuides; } void setShowsGuides(bool shows); bool showsRulers() const { return m_showsRulers; } diff --git a/src/Document/Guides.cpp b/src/Document/Guides.cpp index 26a7246..28ed5c8 100644 --- a/src/Document/Guides.cpp +++ b/src/Document/Guides.cpp @@ -1,5 +1,6 @@ #include "Document/Guides.h" #include "Document/EditorSession.h" +#include "Logging.h" #include <algorithm> #include <cmath> @@ -39,19 +40,159 @@ std::optional<CanvasGuide::Axis> guideAxis(const QString &text) return std::nullopt; } -std::vector<double> LayoutGrid::lines(double length) +LayoutGrid::LayoutGrid(int spacing, int subdivisions) + : spacing(std::clamp(spacing, spacingLow, spacingHigh)), subdivisions(std::min({std::max(subdivisions, subdivisionLow), subdivisionHigh, this->spacing})) +{ +} + +// Counted, not summed: uneven steps stay on the majors. +std::vector<double> LayoutGrid::lines(double length) const { if (!(length >= 0)) return {0}; + const int count = int(std::floor(length / step() + 0.001)); std::vector<double> result; - for (double value = 0; value <= length + 0.001; value += step) - result.push_back(std::round(value)); + for (int index = 0; index <= count; ++index) + result.push_back(std::round(index * step())); return result; } -bool LayoutGrid::isMajor(double value) +bool LayoutGrid::isMajor(double value) const { - return std::abs(std::fmod(std::round(value), spacing)) < 0.001; + return std::abs(std::fmod(std::round(value), double(spacing))) < 0.001; +} + +LayoutGrid LayoutGrid::stored() +{ + return LayoutGrid(ToolDefaults::integer(QStringLiteral("gridSpacing"), 64), ToolDefaults::integer(QStringLiteral("gridSubdivisions"), 8)); +} + +void LayoutGrid::store() const +{ + ToolDefaults::set(spacing, QStringLiteral("gridSpacing")); + ToolDefaults::set(subdivisions, QStringLiteral("gridSubdivisions")); +} + +QString rawValue(GridAppearance::Preset preset) +{ + using Preset = GridAppearance::Preset; + switch (preset) { + case Preset::lightGray: return QStringLiteral("Light Gray"); + case Preset::lightBlue: return QStringLiteral("Light Blue"); + case Preset::lightRed: return QStringLiteral("Light Red"); + case Preset::green: return QStringLiteral("Green"); + case Preset::mediumBlue: return QStringLiteral("Medium Blue"); + case Preset::yellow: return QStringLiteral("Yellow"); + case Preset::magenta: return QStringLiteral("Magenta"); + case Preset::cyan: return QStringLiteral("Cyan"); + case Preset::black: return QStringLiteral("Black"); + case Preset::custom: return QStringLiteral("Custom"); + } + throw std::logic_error("unknown grid preset"); +} + +QString rawValue(GridAppearance::Style style) +{ + switch (style) { + case GridAppearance::Style::lines: return QStringLiteral("Lines"); + case GridAppearance::Style::dashedLines: return QStringLiteral("Dashed Lines"); + case GridAppearance::Style::dots: return QStringLiteral("Dots"); + } + throw std::logic_error("unknown grid style"); +} + +std::optional<PaletteColor> presetColor(GridAppearance::Preset preset) +{ + using Preset = GridAppearance::Preset; + switch (preset) { + case Preset::lightGray: return PaletteColor{0.7, 0.7, 0.7}; + case Preset::lightBlue: return PaletteColor{0.29, 0.78, 1}; + case Preset::lightRed: return PaletteColor{1, 0.4, 0.4}; + case Preset::green: return PaletteColor{0.25, 0.8, 0.25}; + case Preset::mediumBlue: return PaletteColor{0.2, 0.4, 1}; + case Preset::yellow: return PaletteColor{1, 1, 0}; + case Preset::magenta: return PaletteColor{1, 0, 1}; + case Preset::cyan: return PaletteColor{0, 1, 1}; + case Preset::black: return PaletteColor::black(); + case Preset::custom: return std::nullopt; + } + throw std::logic_error("unknown grid preset"); +} + +std::vector<double> dashes(GridAppearance::Style style) +{ + switch (style) { + case GridAppearance::Style::lines: return {}; + case GridAppearance::Style::dashedLines: return {4, 3}; + case GridAppearance::Style::dots: return {1, 2}; + } + throw std::logic_error("unknown grid style"); +} + +PaletteColor GridAppearance::color() const +{ + return presetColor(preset).value_or(customColor); +} + +// A grid that is on never disappears: 1% at least. +double GridAppearance::majorAlpha() const +{ + return std::clamp(opacity, opacityLow, opacityHigh) / 100.0; +} + +namespace { +// A stored name, else the default and a warning. +template <typename Value, typename Values> +Value named(const QString &key, const Values &values, Value fallback) +{ + const QString stored = ToolDefaults::text(key); + for (const Value value : values) + if (rawValue(value) == stored) + return value; + if (!stored.isEmpty()) + qCWarning(lcApp).noquote() << "ignoring the tool setting" << key << "of" << stored; + return fallback; +} +} + +GridAppearance GridAppearance::stored() +{ + GridAppearance appearance; + appearance.preset = named(QStringLiteral("gridColor"), allGridPresets, appearance.preset); + const QString custom = ToolDefaults::text(QStringLiteral("gridCustomColor")); + if (const std::optional<PaletteColor> color = PaletteColor::fromHex(custom)) + appearance.customColor = *color; + else if (!custom.isEmpty()) + qCWarning(lcApp).noquote() << "ignoring the tool setting gridCustomColor of" << custom; + appearance.style = named(QStringLiteral("gridStyle"), allGridStyles, appearance.style); + appearance.opacity = ToolDefaults::integer(QStringLiteral("gridOpacity"), appearance.opacity); + return appearance; +} + +void GridAppearance::store() const +{ + ToolDefaults::set(rawValue(preset), QStringLiteral("gridColor")); + ToolDefaults::set(customColor.hex(), QStringLiteral("gridCustomColor")); + ToolDefaults::set(rawValue(style), QStringLiteral("gridStyle")); + ToolDefaults::set(opacity, QStringLiteral("gridOpacity")); +} + +void EditorSession::setLayoutGrid(const LayoutGrid &grid) +{ + if (grid == m_layoutGrid) + return; + m_layoutGrid = grid; + grid.store(); + notify(); +} + +void EditorSession::setGridAppearance(const GridAppearance &appearance) +{ + if (appearance == m_gridAppearance) + return; + m_gridAppearance = appearance; + appearance.store(); + notify(); } void EditorSession::setShowsGrid(bool shows) @@ -274,9 +415,9 @@ SnapGuides EditorSession::alignmentSnapTargets(const QSet<QUuid> &moving, bool i } // Hidden extras do not snap, as in Photoshop. if (m_snapToGrid && m_showsGrid) { - for (double x : LayoutGrid::lines(size.width())) + for (double x : m_layoutGrid.lines(size.width())) targets.xs.push_back(x); - for (double y : LayoutGrid::lines(size.height())) + for (double y : m_layoutGrid.lines(size.height())) targets.ys.push_back(y); } if (m_snapToGuides && m_showsGuides) { @@ -295,7 +436,7 @@ double EditorSession::snappedGuidePosition(double position, CanvasGuide::Axis ax const double length = vertical ? m_document->size().width() : m_document->size().height(); std::vector<double> targets; if (m_snapToGrid && m_showsGrid) - targets = LayoutGrid::lines(length); + targets = m_layoutGrid.lines(length); if (m_snapToGuides && m_showsGuides) { for (const CanvasGuide &guide : displayedGuides()) { if (guide.axis == axis && guide.id != excluding) diff --git a/src/Document/Guides.h b/src/Document/Guides.h index 03cc26b..ff70362 100644 --- a/src/Document/Guides.h +++ b/src/Document/Guides.h @@ -1,4 +1,6 @@ #pragma once +#include "Document/ColorPalette.h" +#include <array> #include <QColor> #include <QString> #include <QUuid> @@ -24,15 +26,56 @@ struct CanvasGuide { QString rawValue(CanvasGuide::Axis axis); std::optional<CanvasGuide::Axis> guideAxis(const QString &text); -// The non-printing grid: majors every 64 px, eight subdivisions. -namespace LayoutGrid { -inline constexpr double spacing = 64; -inline constexpr int subdivisions = 8; -inline constexpr double step = spacing / subdivisions; -// Every line along a document edge, whole pixels. -std::vector<double> lines(double length); -bool isMajor(double value); -} +// The non-printing grid: majors every `spacing` px, split in parts. +struct LayoutGrid { + static constexpr int spacingLow = 2, spacingHigh = 4096; + static constexpr int subdivisionLow = 1, subdivisionHigh = 64; + // Plain members, Swift's `let`s, so the struct stays assignable. + int spacing; + // Never finer than a pixel. + int subdivisions; + explicit LayoutGrid(int spacing = 64, int subdivisions = 8); + double step() const { return double(spacing) / subdivisions; } + // Every line along an edge from the origin, whole pixels. + std::vector<double> lines(double length) const; + bool isMajor(double value) const; + // The person's grid, kept across projects and launches. + static LayoutGrid stored(); + void store() const; + friend bool operator==(const LayoutGrid &, const LayoutGrid &) = default; +}; + +// How the grid is drawn, after Photoshop's Grid settings. +struct GridAppearance { + enum class Preset { lightGray, lightBlue, lightRed, green, mediumBlue, yellow, magenta, cyan, black, custom }; + enum class Style { lines, dashedLines, dots }; + static constexpr int opacityLow = 1, opacityHigh = 100; + Preset preset = Preset::lightGray; + // Custom's colour, kept while another preset is chosen. + PaletteColor customColor{0.7, 0.7, 0.7}; + Style style = Style::lines; + // The majors' opacity, in percent. + int opacity = 45; + PaletteColor color() const; + double majorAlpha() const; + // A little over half the majors': 28% beside 45%. + double subdivisionAlpha() const { return majorAlpha() * 28 / 45; } + static GridAppearance stored(); + void store() const; + friend bool operator==(const GridAppearance &, const GridAppearance &) = default; +}; + +inline constexpr std::array allGridPresets{GridAppearance::Preset::lightGray, GridAppearance::Preset::lightBlue, GridAppearance::Preset::lightRed, + GridAppearance::Preset::green, GridAppearance::Preset::mediumBlue, GridAppearance::Preset::yellow, + GridAppearance::Preset::magenta, GridAppearance::Preset::cyan, GridAppearance::Preset::black, + GridAppearance::Preset::custom}; +inline constexpr std::array allGridStyles{GridAppearance::Style::lines, GridAppearance::Style::dashedLines, GridAppearance::Style::dots}; +QString rawValue(GridAppearance::Preset preset); +QString rawValue(GridAppearance::Style style); +// None for Custom, which takes the appearance's own. +std::optional<PaletteColor> presetColor(GridAppearance::Preset preset); +// On and off in screen points; empty is solid. +std::vector<double> dashes(GridAppearance::Style style); // A create or move under way; the document changes after. struct GuideDrag { diff --git a/src/Document/ToolDefaults.cpp b/src/Document/ToolDefaults.cpp index d2a6b76..c384588 100644 --- a/src/Document/ToolDefaults.cpp +++ b/src/Document/ToolDefaults.cpp @@ -31,3 +31,34 @@ void ToolDefaults::set(bool value, const QString &key) if (enabled) QSettings().setValue(prefix + key, value); } + +int ToolDefaults::integer(const QString &key, int fallback) +{ + if (!enabled) + return fallback; + const QString stored = QSettings().value(prefix + key).toString(); + bool number = false; + const int value = stored.toInt(&number); + if (number) + return value; + if (!stored.isEmpty()) + qCWarning(lcApp).noquote() << "ignoring the tool setting" << key << "of" << stored; + return fallback; +} + +void ToolDefaults::set(int value, const QString &key) +{ + if (enabled) + QSettings().setValue(prefix + key, value); +} + +QString ToolDefaults::text(const QString &key) +{ + return enabled ? QSettings().value(prefix + key).toString() : QString(); +} + +void ToolDefaults::set(const QString &value, const QString &key) +{ + if (enabled) + QSettings().setValue(prefix + key, value); +} diff --git a/src/Document/ToolDefaults.h b/src/Document/ToolDefaults.h index c5b6d5d..5caf4bf 100644 --- a/src/Document/ToolDefaults.h +++ b/src/Document/ToolDefaults.h @@ -8,4 +8,10 @@ void enable(); // The stored toggle, else the fallback. bool boolean(const QString &key, bool fallback); void set(bool value, const QString &key); +// The grid's numbers and names, as Swift's int and string. +int integer(const QString &key, int fallback); +void set(int value, const QString &key); +// Empty when nothing is stored, as Swift's "" fallback. +QString text(const QString &key); +void set(const QString &value, const QString &key); } diff --git a/src/IO/ProjectController+Resize.cpp b/src/IO/ProjectController+Resize.cpp index 53aa49a..7492e91 100644 --- a/src/IO/ProjectController+Resize.cpp +++ b/src/IO/ProjectController+Resize.cpp @@ -4,8 +4,11 @@ #include "IO/ImageResizer.h" #include "IO/ProjectController.h" #include "UI/CanvasSizeSheet.h" +#include "UI/GridSettingsSheet.h" #include "UI/ImageSizeSheet.h" #include "UI/TrimSheet.h" +#include "Logging.h" +#include <QApplication> #include <QDialog> #include <QFutureWatcher> #include <QVBoxLayout> @@ -46,6 +49,42 @@ void ProjectController::canvasSize(std::function<void()> done) dialog->open(); } +// The grid shows, changing, while the sheet is open. +void ProjectController::gridSettings(std::function<void()> done) +{ + if (!window || QApplication::activeModalWidget()) { + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + return; + } + const LayoutGrid grid = session.layoutGrid(); + const GridAppearance appearance = session.gridAppearance(); + const bool shown = session.showsGrid(); + session.setShowsGrid(true); + QDialog *dialog = sheet(QStringLiteral("Grid")); + auto chosen = std::make_shared<std::optional<GridSettingsSheet::Settings>>(); + dialog->layout()->addWidget(new GridSettingsSheet( + session, grid, appearance, + [this](const GridSettingsSheet::Settings &settings) { + session.setLayoutGrid(settings.first); + session.setGridAppearance(settings.second); + }, + [dialog, chosen](std::optional<GridSettingsSheet::Settings> settings) { + *chosen = settings; + dialog->done(settings ? QDialog::Accepted : QDialog::Rejected); + }, + dialog)); + connect(dialog, &QDialog::finished, this, [this, chosen, grid, appearance, shown, done] { + session.setShowsGrid(shown); + session.setLayoutGrid(*chosen ? (*chosen)->first : grid); + session.setGridAppearance(*chosen ? (*chosen)->second : appearance); + qCInfo(lcIO) << "grid settings" << (*chosen ? "kept" : "cancelled"); + if (done) + QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); + }); + dialog->open(); +} + void ProjectController::imageSize(std::function<void()> done) { if (!window || !session.document() || !begin()) { diff --git a/src/IO/ProjectController.h b/src/IO/ProjectController.h index 9c7e889..6256a41 100644 --- a/src/IO/ProjectController.h +++ b/src/IO/ProjectController.h @@ -40,6 +40,8 @@ public: void receive(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {}); // Swift's sheets over the window; resizes off the UI thread. void canvasSize(std::function<void()> done = {}); + // View > Grid Settings…: no step; Cancel puts back. + void gridSettings(std::function<void()> done = {}); void imageSize(std::function<void()> done = {}); void trim(std::function<void()> done = {}); void exportPNG(std::function<void()> done = {}); diff --git a/src/Rendering/EditorCanvas.cpp b/src/Rendering/EditorCanvas.cpp index 0dd4518..c9bb0a1 100644 --- a/src/Rendering/EditorCanvas.cpp +++ b/src/Rendering/EditorCanvas.cpp @@ -161,7 +161,7 @@ bool CanvasView::synchronizeDisplay() updateCursor(); } // The grid and guides redraw the view, as Swift's observation. - if (const GuidesShown lines{m_session.showsGrid(), m_session.showsGuides(), m_session.displayedGuides()}; m_displayedGuideLines != lines) { + if (const GuidesShown lines{m_session.showsGrid(), m_session.layoutGrid(), m_session.gridAppearance(), m_session.showsGuides(), m_session.displayedGuides()}; m_displayedGuideLines != lines) { update(); m_displayedGuideLines = lines; } diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h index f537344..1f15405 100644 --- a/src/Rendering/EditorCanvas.h +++ b/src/Rendering/EditorCanvas.h @@ -412,6 +412,8 @@ private: // The grid and guides as drawn: a change redraws. struct GuidesShown { bool grid; + LayoutGrid layout; + GridAppearance appearance; bool guides; std::vector<CanvasGuide> lines; friend bool operator==(const GuidesShown &, const GuidesShown &) = default; diff --git a/src/Rendering/TransformOverlay.cpp b/src/Rendering/TransformOverlay.cpp index 782bbcb..1ed94c5 100644 --- a/src/Rendering/TransformOverlay.cpp +++ b/src/Rendering/TransformOverlay.cpp @@ -364,42 +364,54 @@ void TransformOverlay::drawTransformHandles(QPainter &context, const QPalette &p context.restore(); } -// The layout grid: solid majors, dotted subdivisions when roomy. +// The layout grid: majors in the chosen style, dotted subdivisions. void TransformOverlay::drawLayoutGrid(QPainter &context) const { const std::optional<CanvasDocument> &document = m_session.document(); if (!m_session.showsGrid() || !document) return; + const LayoutGrid &grid = m_session.layoutGrid(); + const GridAppearance &appearance = m_session.gridAppearance(); const QSizeF size = document->size(); const QTransform map = documentToView(); const double hairline = 1 / std::max(m_session.viewport.backingScale, 1.0); const auto lines = [&](bool majors) { QPainterPath path; - for (const double x : LayoutGrid::lines(size.width())) { - if (LayoutGrid::isMajor(x) == majors) { + for (const double x : grid.lines(size.width())) { + if (grid.isMajor(x) == majors) { path.moveTo(map.map(QPointF(x, 0))); path.lineTo(map.map(QPointF(x, size.height()))); } } - for (const double y : LayoutGrid::lines(size.height())) { - if (LayoutGrid::isMajor(y) == majors) { + for (const double y : grid.lines(size.height())) { + if (grid.isMajor(y) == majors) { path.moveTo(map.map(QPointF(0, y))); path.lineTo(map.map(QPointF(size.width(), y))); } } return path; }; + // Points on and off, counted in pen widths for Qt. + const auto pen = [&](double alpha, const std::vector<double> &pattern) { + QColor colour = appearance.color().color(); + colour.setAlphaF(float(alpha)); + QPen line(colour, hairline, Qt::SolidLine, Qt::FlatCap); + if (!pattern.empty()) { + QList<qreal> widths; + for (const double points : pattern) + widths.append(points / hairline); + line.setDashPattern(widths); + } + return line; + }; context.save(); context.setRenderHint(QPainter::Antialiasing, true); context.setBrush(Qt::NoBrush); - if (LayoutGrid::step * m_session.viewport.pointsPerPixel() >= 4) { - QPen dotted(QColor::fromRgbF(0.55, 0.55, 0.55, 0.28), hairline, Qt::CustomDashLine, Qt::FlatCap); - // One point on, two off: Qt counts in pen widths. - dotted.setDashPattern({1 / hairline, 2 / hairline}); - context.setPen(dotted); + if (grid.step() * m_session.viewport.pointsPerPixel() >= 4) { + context.setPen(pen(appearance.subdivisionAlpha(), {1, 2})); context.drawPath(lines(false)); } - context.setPen(QPen(QColor::fromRgbF(0.7, 0.7, 0.7, 0.45), hairline, Qt::SolidLine, Qt::FlatCap)); + context.setPen(pen(appearance.majorAlpha(), dashes(appearance.style))); context.drawPath(lines(true)); context.restore(); } diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index 6093aad..26f002d 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -162,6 +162,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi &EditorSession::setShowsGrid); toggle(show, QStringLiteral("showGuides"), QStringLiteral("Guides"), QKeySequence(Qt::CTRL | Qt::Key_Semicolon), &EditorSession::showsGuides, &EditorSession::setShowsGuides); + add(view, QStringLiteral("gridSettings"), QStringLiteral("Grid Settings…"), QKeySequence(), [this] { projects().gridSettings(); }); toggle(view, QStringLiteral("showRulers"), QStringLiteral("Rulers"), QKeySequence(Qt::CTRL | Qt::Key_R), &EditorSession::showsRulers, &EditorSession::setShowsRulers); view->addSeparator(); @@ -373,6 +374,7 @@ void CompositorMenus::synchronize() action(QString::fromLatin1(name))->setEnabled(opened); } action(QStringLiteral("clearGuides"))->setEnabled(s.canClearGuides()); + action(QStringLiteral("gridSettings"))->setEnabled(opened); action(QStringLiteral("transformControls"))->setChecked(s.showsTransformControls()); action(QStringLiteral("transformControls"))->setEnabled(s.tool() == NavigationTool::move && s.document().has_value()); const bool selected = s.selection().has_value(); diff --git a/src/UI/GridSettingsSheet.cpp b/src/UI/GridSettingsSheet.cpp new file mode 100644 index 0000000..543715b --- /dev/null +++ b/src/UI/GridSettingsSheet.cpp @@ -0,0 +1,319 @@ +#include "UI/GridSettingsSheet.h" +#include "UI/ColorPickerSheet+Dialog.h" +#include "UI/ColorPickerSheet.h" +#include "UI/KeyboardShortcuts.h" +#include "UI/NumericScrub.h" +#include <QComboBox> +#include <QFrame> +#include <QHBoxLayout> +#include <QLabel> +#include <QPushButton> +#include <QSignalBlocker> +#include <QSlider> +#include <QVBoxLayout> +#include <algorithm> +#include <cmath> + +namespace { +// A row's title, 110 wide, as Swift's frame. +QLabel *title(const QString &words, QWidget *parent) +{ + auto *label = new QLabel(words, parent); + label->setFixedWidth(110); + return label; +} + +QHBoxLayout *row(QLabel *label) +{ + auto *layout = new QHBoxLayout; + layout->setSpacing(8); + layout->addWidget(label); + return layout; +} + +// Swift's fractionLength(0...2), grouped as Swift's formatted(). +QString pixels(double value, const QLocale &locale) +{ + QString number = locale.toString(value, 'f', 2); + while (number.endsWith(locale.zeroDigit())) + number.chop(locale.zeroDigit().size()); + if (number.endsWith(locale.decimalPoint())) + number.chop(locale.decimalPoint().size()); + return number; +} +} + +GridSettingsSheet::GridSettingsSheet(EditorSession &session, LayoutGrid layout, GridAppearance appearance, std::function<void(const Settings &)> preview, + std::function<void(std::optional<Settings>)> finish, QWidget *parent) + : QWidget(parent), m_session(session), m_preview(std::move(preview)), m_finish(std::move(finish)), m_spacing(layout.spacing), + m_subdivisions(layout.subdivisions), m_appearance(appearance), m_preset(new QComboBox(this)), + m_swatch(new DialogColorSwatch( + QStringLiteral("Grid Color"), [this] { return m_appearance.color(); }, [this](const PaletteColor &picked) { pick(picked); }, session, this)), + m_style(new QComboBox(this)), m_opacitySlider(new QSlider(Qt::Horizontal, this)), + m_opacity(field(QStringLiteral("gridOpacity"), [this](int value) { setOpacity(value); }, + [this](int delta) { setOpacity(m_appearance.opacity + delta); })), + m_spacingField(field(QStringLiteral("gridSpacing"), [this](int value) { setSpacing(value, m_subdivisions); }, nullptr)), + m_subdivisionField(field(QStringLiteral("gridSubdivisions"), [this](int value) { setSpacing(m_spacing, value); }, nullptr)), + m_note(new QLabel(this)), m_ok(new QPushButton(QStringLiteral("OK"), this)) +{ + setFixedWidth(360); + auto *column = new QVBoxLayout(this); + column->setContentsMargins(24, 24, 24, 24); + column->setSpacing(18); + auto *heading = new QLabel(QStringLiteral("Grid"), this); + QFont bold = heading->font(); + bold.setPixelSize(17); + bold.setBold(true); + heading->setFont(bold); + column->addWidget(heading); + + QLabel *colorTitle = title(QStringLiteral("Color"), this); + colorTitle->setBuddy(m_preset); + m_preset->setObjectName(QStringLiteral("gridPreset")); + for (const GridAppearance::Preset preset : allGridPresets) + m_preset->addItem(rawValue(preset)); + connect(m_preset, &QComboBox::activated, this, [this](int index) { + GridAppearance next = m_appearance; + next.preset = allGridPresets[size_t(index)]; + setAppearance(next); + }); + m_swatch->setObjectName(QStringLiteral("gridColor")); + m_swatch->setToolTip(QStringLiteral("Choose a custom grid color")); + QHBoxLayout *colorRow = row(colorTitle); + colorRow->addWidget(m_preset); + colorRow->addWidget(m_swatch); + colorRow->addStretch(1); + column->addLayout(colorRow); + + QLabel *styleTitle = title(QStringLiteral("Style"), this); + styleTitle->setBuddy(m_style); + m_style->setObjectName(QStringLiteral("gridStyle")); + for (const GridAppearance::Style style : allGridStyles) + m_style->addItem(rawValue(style)); + connect(m_style, &QComboBox::activated, this, [this](int index) { + GridAppearance next = m_appearance; + next.style = allGridStyles[size_t(index)]; + setAppearance(next); + }); + QHBoxLayout *styleRow = row(styleTitle); + styleRow->addWidget(m_style); + styleRow->addStretch(1); + column->addLayout(styleRow); + + QLabel *opacityTitle = title(QStringLiteral("Opacity"), this); + opacityTitle->setBuddy(m_opacitySlider); + new NumericScrub(opacityTitle, {.sensitivity = 0.5, .low = GridAppearance::opacityLow, .high = GridAppearance::opacityHigh, .step = std::nullopt, + .value = [this] { return double(m_appearance.opacity); }, + .set = [this](double value) { + m_opacity->setModified(false); + setOpacity(int(std::lround(value))); + }}); + m_opacitySlider->setObjectName(QStringLiteral("gridOpacitySlider")); + m_opacitySlider->setRange(GridAppearance::opacityLow, GridAppearance::opacityHigh); + connect(m_opacitySlider, &QSlider::valueChanged, this, [this](int value) { + m_opacity->setModified(false); + setOpacity(value); + }); + m_opacity->setFixedWidth(48); + // Swift's TextField titles: their names and placeholders. + for (const auto &[entry, words] : {std::pair(m_opacity, QStringLiteral("Opacity")), std::pair(m_spacingField, QStringLiteral("Gridline every")), + std::pair(m_subdivisionField, QStringLiteral("Subdivisions"))}) { + entry->setAccessibleName(words); + entry->setPlaceholderText(words); + } + QHBoxLayout *opacityRow = row(opacityTitle); + opacityRow->addWidget(m_opacitySlider, 1); + // Swift's unitSuffix: the unit two points after. + auto *suffixed = new QHBoxLayout; + suffixed->setSpacing(2); + suffixed->addWidget(m_opacity); + suffixed->addWidget(new QLabel(QStringLiteral("%"), this)); + opacityRow->addLayout(suffixed); + column->addLayout(opacityRow); + + auto *divider = new QFrame(this); + divider->setFrameShape(QFrame::HLine); + divider->setForegroundRole(QPalette::Mid); + column->addWidget(divider); + + QLabel *spacingTitle = title(QStringLiteral("Gridline every"), this); + spacingTitle->setBuddy(m_spacingField); + new NumericScrub(spacingTitle, {.sensitivity = 1, .low = LayoutGrid::spacingLow, .high = LayoutGrid::spacingHigh, .step = std::nullopt, + .value = [this] { return double(m_spacing); }, + .set = [this](double value) { + m_spacingField->setModified(false); + setSpacing(int(std::lround(value)), m_subdivisions); + }}); + QHBoxLayout *spacingRow = row(spacingTitle); + spacingRow->addWidget(m_spacingField, 1); + auto *unit = new QLabel(QStringLiteral("pixels"), this); + unit->setForegroundRole(QPalette::PlaceholderText); + spacingRow->addWidget(unit); + column->addLayout(spacingRow); + + QLabel *subdivisionTitle = title(QStringLiteral("Subdivisions"), this); + subdivisionTitle->setBuddy(m_subdivisionField); + new NumericScrub(subdivisionTitle, {.sensitivity = 0.2, .low = LayoutGrid::subdivisionLow, .high = LayoutGrid::subdivisionHigh, .step = std::nullopt, + .value = [this] { return double(m_subdivisions); }, + .set = [this](double value) { + m_subdivisionField->setModified(false); + setSpacing(m_spacing, int(std::lround(value))); + }}); + QHBoxLayout *subdivisionRow = row(subdivisionTitle); + subdivisionRow->addWidget(m_subdivisionField, 1); + column->addLayout(subdivisionRow); + + m_note->setObjectName(QStringLiteral("gridNote")); + m_note->setWordWrap(true); + QFont callout = m_note->font(); + callout.setPixelSize(12); + m_note->setFont(callout); + column->addWidget(m_note); + + auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("gridCancel")); + cancel->setAutoDefault(false); + connect(cancel, &QPushButton::clicked, this, [this] { + DialogColorSwatch::closePicker(m_session); + m_finish(std::nullopt); + }); + auto *defaults = new QPushButton(QStringLiteral("Restore Defaults"), this); + defaults->setObjectName(QStringLiteral("gridDefaults")); + defaults->setAutoDefault(false); + // Custom's colour stays for when Custom returns. + connect(defaults, &QPushButton::clicked, this, [this] { + m_spacing = LayoutGrid().spacing; + m_subdivisions = LayoutGrid().subdivisions; + m_pickedFrom.reset(); + GridAppearance next = m_appearance; + next.preset = GridAppearance().preset; + next.style = GridAppearance().style; + next.opacity = GridAppearance().opacity; + m_appearance = next; + m_preview({grid(), m_appearance}); + synchronize(); + }); + m_ok->setObjectName(QStringLiteral("gridOK")); + m_ok->setDefault(true); + // OK rests while the grid is invalid: no guard needed. + connect(m_ok, &QPushButton::clicked, this, [this] { + DialogColorSwatch::closePicker(m_session); + m_finish(Settings{grid(), m_appearance}); + }); + NativeShortcut::bind(*this, m_ok, cancel); + auto *buttons = new QHBoxLayout; + buttons->addWidget(cancel); + buttons->addWidget(defaults); + buttons->addStretch(1); + buttons->addWidget(m_ok); + column->addLayout(buttons); + synchronize(); +} + +GridSettingsSheet::~GridSettingsSheet() +{ + releaseFocus(*this); + DialogColorSwatch::closePicker(m_session); +} + +bool GridSettingsSheet::valid() const +{ + return m_spacing >= LayoutGrid::spacingLow && m_spacing <= LayoutGrid::spacingHigh && m_subdivisions >= LayoutGrid::subdivisionLow + && m_subdivisions <= LayoutGrid::subdivisionHigh && m_subdivisions <= m_spacing; +} + +void GridSettingsSheet::setAppearance(const GridAppearance &appearance) +{ + if (appearance == m_appearance) + return; + if (appearance.preset != GridAppearance::Preset::custom) + m_pickedFrom.reset(); + m_appearance = appearance; + m_preview({grid(), m_appearance}); + synchronize(); +} + +// Swift's TextField binding: any whole number; OK rests until valid. +void GridSettingsSheet::setSpacing(int spacing, int subdivisions) +{ + m_spacing = spacing; + m_subdivisions = subdivisions; + if (valid()) + m_preview({grid(), m_appearance}); + synchronize(); +} + +void GridSettingsSheet::setOpacity(int opacity) +{ + GridAppearance next = m_appearance; + next.opacity = std::clamp(opacity, GridAppearance::opacityLow, GridAppearance::opacityHigh); + setAppearance(next); +} + +void GridSettingsSheet::pick(const PaletteColor &picked) +{ + // Both sides in 8 bits: the picker's opening and Cancel. + if (picked.quantized() == m_appearance.color().quantized()) + return; + GridAppearance next = m_appearance; + if (m_pickedFrom && presetColor(*m_pickedFrom).value().quantized() == picked.quantized()) { + // A preset again: setAppearance ends the pick. + next.preset = *m_pickedFrom; + setAppearance(next); + return; + } + if (next.preset != GridAppearance::Preset::custom) + m_pickedFrom = next.preset; + next.customColor = picked; + next.preset = GridAppearance::Preset::custom; + setAppearance(next); +} + +PickerField *GridSettingsSheet::field(const QString &name, std::function<void(int)> apply, std::function<void(int)> step) +{ + // A readable whole number applies; other text goes back. + auto made = std::make_shared<PickerField *>(nullptr); + auto *entry = new PickerField( + [this, made, apply = std::move(apply)] { + PickerField *const typed = *made; + bool number = false; + const int value = typed->locale().toInt(typed->text(), &number); + if (typed->isModified() && number) + apply(value); + typed->setModified(false); + synchronize(); + }, + std::move(step), this); + *made = entry; + entry->setObjectName(name); + entry->setAlignment(Qt::AlignRight); + return entry; +} + +void GridSettingsSheet::synchronize() +{ + const auto show = [](PickerField *entry, int value) { + // A field being typed in keeps its typing. + if (!entry->hasFocus() || !entry->isModified()) + entry->setText(QString::number(value)); + }; + { + const QSignalBlocker quiet(m_opacitySlider); + m_opacitySlider->setValue(m_appearance.opacity); + } + show(m_opacity, m_appearance.opacity); + show(m_spacingField, m_spacing); + show(m_subdivisionField, m_subdivisions); + m_preset->setCurrentIndex(int(std::find(allGridPresets.begin(), allGridPresets.end(), m_appearance.preset) - allGridPresets.begin())); + m_style->setCurrentIndex(int(std::find(allGridStyles.begin(), allGridStyles.end(), m_appearance.style) - allGridStyles.begin())); + m_swatch->update(); + const bool ok = valid(); + m_note->setText(ok ? QStringLiteral("A subdivision every %1 pixels.").arg(pixels(grid().step(), locale())) + : QStringLiteral("Use gridlines every %1–%2 pixels and %3–%4 subdivisions, no more than the pixels between gridlines.") + .arg(LayoutGrid::spacingLow) + .arg(locale().toString(LayoutGrid::spacingHigh)) + .arg(LayoutGrid::subdivisionLow) + .arg(LayoutGrid::subdivisionHigh)); + m_note->setForegroundRole(ok ? QPalette::PlaceholderText : QPalette::BrightText); + m_ok->setEnabled(ok); +} diff --git a/src/UI/GridSettingsSheet.h b/src/UI/GridSettingsSheet.h new file mode 100644 index 0000000..6c91ecd --- /dev/null +++ b/src/UI/GridSettingsSheet.h @@ -0,0 +1,54 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QWidget> +#include <functional> +#include <memory> +#include <optional> +#include <utility> + +class DialogColorSwatch; +class PickerField; +class QComboBox; +class QLabel; +class QPushButton; +class QSlider; + +// Swift's GridSettingsSheet: each change previews; Cancel puts back. +class GridSettingsSheet : public QWidget { + Q_OBJECT +public: + using Settings = std::pair<LayoutGrid, GridAppearance>; + GridSettingsSheet(EditorSession &session, LayoutGrid grid, GridAppearance appearance, std::function<void(const Settings &)> preview, + std::function<void(std::optional<Settings>)> finish, QWidget *parent = nullptr); + ~GridSettingsSheet() override; + +private: + bool valid() const; + LayoutGrid grid() const { return LayoutGrid(m_spacing, m_subdivisions); } + // Swift's onChange: the grid follows, a preset ends a pick. + void setAppearance(const GridAppearance &appearance); + void setSpacing(int spacing, int subdivisions); + void setOpacity(int opacity); + // The swatch's binding: a picked colour becomes Custom's. + void pick(const PaletteColor &picked); + PickerField *field(const QString &name, std::function<void(int)> apply, std::function<void(int)> step); + void synchronize(); + + EditorSession &m_session; + const std::function<void(const Settings &)> m_preview; + const std::function<void(std::optional<Settings>)> m_finish; + int m_spacing; + int m_subdivisions; + GridAppearance m_appearance; + // The preset a pick started from; picking its colour returns. + std::optional<GridAppearance::Preset> m_pickedFrom; + QComboBox *const m_preset; + DialogColorSwatch *const m_swatch; + QComboBox *const m_style; + QSlider *const m_opacitySlider; + PickerField *const m_opacity; + PickerField *const m_spacingField; + PickerField *const m_subdivisionField; + QLabel *const m_note; + QPushButton *const m_ok; +}; diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index 980d3f3..9e473de 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -30,7 +30,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() {"newCanvas", "Ctrl+N"}, {"openProject", "Ctrl+O"}, {"importImages", ""}, {"save", "Ctrl+S"}, {"saveAs", "Ctrl+Shift+S"}, {"exportPNG", "Ctrl+Shift+E"}, {"exportJPEG", "Ctrl+Alt+Shift+S"}, {"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"}, - {"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"snap", ""}, {"showGrid", "Ctrl+'"}, {"showGuides", "Ctrl+;"}, {"showRulers", "Ctrl+R"}, {"snapEnabled", "Ctrl+Shift+;"}, {"snapToGuides", ""}, {"snapToGrid", ""}, {"snapToLayers", ""}, {"snapToDocumentBounds", ""}, {"lockGuides", "Ctrl+Alt+;"}, {"clearGuides", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"}, + {"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"snap", ""}, {"showGrid", "Ctrl+'"}, {"showGuides", "Ctrl+;"}, {"gridSettings", ""}, {"showRulers", "Ctrl+R"}, {"snapEnabled", "Ctrl+Shift+;"}, {"snapToGuides", ""}, {"snapToGrid", ""}, {"snapToLayers", ""}, {"snapToDocumentBounds", ""}, {"lockGuides", "Ctrl+Alt+;"}, {"clearGuides", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"}, {"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"keyboardShortcuts", ""}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"}, {"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["}, {"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""}, @@ -52,7 +52,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"})); QCOMPARE(menus[0]->actions().size(), 12); QCOMPARE(menus[1]->actions().size(), 13); - QCOMPARE(menus[2]->actions().size(), 16); + QCOMPARE(menus[2]->actions().size(), 17); QCOMPARE(menus[3]->actions().size(), 11); // Swift 1.3.4's Color Range… follows Subject. const QList<QAction *> select = menus[3]->actions(); diff --git a/tests/GridSettingsSheetTests.cpp b/tests/GridSettingsSheetTests.cpp new file mode 100644 index 0000000..7470a62 --- /dev/null +++ b/tests/GridSettingsSheetTests.cpp @@ -0,0 +1,319 @@ +#include "UI/ColorPickerSheet+Dialog.h" +#include "UI/ColorPickerSheet.h" +#include "UI/GridSettingsSheet.h" +#include <QComboBox> +#include <QDialog> +#include <QHBoxLayout> +#include <QLabel> +#include <QPushButton> +#include <QSlider> +#include <QVBoxLayout> +#include <QtTest> + +// Swift 1.3.5's GridSettingsSheet: the controls, previews and answers. +class GridSettingsSheetTests : public QObject { + Q_OBJECT +private slots: + void theSheetLaysOutAsSwifts(); + void eachChangePreviewsTheGrid(); + void aPickedColourBecomesCustom(); + void restoreDefaultsOkAndCancel(); + void theRowsHoldSwiftsControls(); + void aSliderOrScrubReplacesTyping(); +}; + +namespace { +// Drags a scrubbable title `points` to the right. +void scrub(QLabel &title, int points) +{ + QTest::mousePress(&title, Qt::LeftButton, Qt::NoModifier, QPoint(2, 2)); + QMouseEvent move(QEvent::MouseMove, QPointF(2 + points, 2), title.mapToGlobal(QPointF(2 + points, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&title, &move); + QTest::mouseRelease(&title, Qt::LeftButton, Qt::NoModifier, QPoint(2 + points, 2)); +} + +struct Sheet { + EditorSession session; + std::vector<GridSettingsSheet::Settings> previews; + std::optional<std::optional<GridSettingsSheet::Settings>> answer; + std::unique_ptr<GridSettingsSheet> sheet; + explicit Sheet(LayoutGrid grid = LayoutGrid(), GridAppearance appearance = GridAppearance()) + : sheet(std::make_unique<GridSettingsSheet>( + session, grid, appearance, [this](const GridSettingsSheet::Settings &settings) { previews.push_back(settings); }, + [this](std::optional<GridSettingsSheet::Settings> settings) { answer = settings; })) + { + sheet->show(); + if (!QTest::qWaitForWindowActive(sheet.get())) + throw std::runtime_error("the sheet never became active"); + } + template <typename Widget> Widget &find(const char *name) + { + Widget *found = sheet->findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(std::string("no widget named ") + name); + return *found; + } + // Typed, then Return: the field commits. + void type(const char *name, const QString &text) + { + auto &field = find<PickerField>(name); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, text); + QTest::keyClick(&field, Qt::Key_Return); + } + QString note() { return find<QLabel>("gridNote").text(); } + const GridSettingsSheet::Settings &last() const { return previews.back(); } +}; +} + +void GridSettingsSheetTests::theSheetLaysOutAsSwifts() +{ + Sheet shown; + auto *column = qobject_cast<QVBoxLayout *>(shown.sheet->layout()); + QVERIFY(column && shown.sheet->width() == 360 && column->spacing() == 18 && column->contentsMargins() == QMargins(24, 24, 24, 24)); + auto *heading = qobject_cast<QLabel *>(column->itemAt(0)->widget()); + QVERIFY(heading->text() == "Grid" && heading->font().pixelSize() == 17 && heading->font().bold()); + // Titles 110 wide, each naming its control. + const auto rowOf = [column](int index) { return qobject_cast<QHBoxLayout *>(column->itemAt(index)->layout()); }; + const QStringList titles{"Color", "Style", "Opacity"}; + for (int index = 0; index < 3; ++index) { + auto *title = qobject_cast<QLabel *>(rowOf(index + 1)->itemAt(0)->widget()); + QVERIFY(title->text() == titles[index] && title->width() == 110 && title->buddy()); + } + auto &preset = shown.find<QComboBox>("gridPreset"); + QCOMPARE(preset.count(), 10); + QVERIFY(preset.itemText(0) == "Light Gray" && preset.itemText(9) == "Custom" && preset.currentText() == "Light Gray"); + auto &swatch = shown.find<DialogColorSwatch>("gridColor"); + QVERIFY(swatch.accessibleName() == "Grid Color" && swatch.toolTip() == "Choose a custom grid color" && rowOf(1)->itemAt(2)->widget() == &swatch); + auto &style = shown.find<QComboBox>("gridStyle"); + QVERIFY(style.count() == 3 && style.itemText(1) == "Dashed Lines" && style.currentText() == "Lines"); + auto &opacity = shown.find<PickerField>("gridOpacity"); + QVERIFY(opacity.text() == "45" && opacity.width() == 48 && opacity.alignment() == Qt::AlignRight && opacity.accessibleName() == "Opacity"); + QVERIFY(shown.find<QSlider>("gridOpacitySlider").minimum() == 1 && shown.find<QSlider>("gridOpacitySlider").maximum() == 100); + QCOMPARE(shown.find<QSlider>("gridOpacitySlider").value(), 45); + auto *rule = qobject_cast<QFrame *>(column->itemAt(4)->widget()); + QVERIFY(rule && rule->frameShape() == QFrame::HLine); + QVERIFY(qobject_cast<QLabel *>(rowOf(5)->itemAt(0)->widget())->text() == "Gridline every" && shown.find<PickerField>("gridSpacing").text() == "64"); + QCOMPARE(qobject_cast<QLabel *>(rowOf(5)->itemAt(2)->widget())->text(), QString("pixels")); + QVERIFY(qobject_cast<QLabel *>(rowOf(6)->itemAt(0)->widget())->text() == "Subdivisions" && shown.find<PickerField>("gridSubdivisions").text() == "8"); + QCOMPARE(shown.note(), QString("A subdivision every 8 pixels.")); + QCOMPARE(shown.find<QLabel>("gridNote").foregroundRole(), QPalette::PlaceholderText); + auto *buttons = rowOf(8); + QVERIFY(qobject_cast<QPushButton *>(buttons->itemAt(0)->widget())->text() == "Cancel" && qobject_cast<QPushButton *>(buttons->itemAt(1)->widget())->text() == "Restore Defaults"); + QVERIFY(buttons->itemAt(2)->spacerItem() && shown.find<QPushButton>("gridOK").isDefault()); + QVERIFY(shown.previews.empty()); +} + +void GridSettingsSheetTests::eachChangePreviewsTheGrid() +{ + Sheet shown; + auto &preset = shown.find<QComboBox>("gridPreset"); + preset.setCurrentIndex(7); + emit preset.activated(7); + QVERIFY(shown.previews.size() == 1 && shown.last().second.preset == GridAppearance::Preset::cyan && shown.last().first == LayoutGrid()); + shown.find<QComboBox>("gridStyle").setCurrentIndex(2); + emit shown.find<QComboBox>("gridStyle").activated(2); + QCOMPARE(shown.last().second.style, GridAppearance::Style::dots); + // Opacity: the slider, the field (clamped), its arrows. + shown.find<QSlider>("gridOpacitySlider").setValue(80); + QCOMPARE(shown.last().second.opacity, 80); + shown.type("gridOpacity", "150"); + QVERIFY(shown.last().second.opacity == 100 && shown.find<PickerField>("gridOpacity").text() == "100"); + QTest::keyClick(&shown.find<PickerField>("gridOpacity"), Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(shown.last().second.opacity, 90); + // The grid previews only while valid; the note says why. + shown.type("gridSpacing", "100"); + QVERIFY(shown.last().first == LayoutGrid(100, 8)); + QCOMPARE(shown.note(), QString("A subdivision every 12.5 pixels.")); + shown.type("gridSubdivisions", "3"); + QCOMPARE(shown.note(), QString("A subdivision every 33.33 pixels.")); + const size_t seen = shown.previews.size(); + shown.type("gridSpacing", "1"); + QVERIFY(shown.previews.size() == seen && !shown.find<QPushButton>("gridOK").isEnabled()); + QCOMPARE(shown.note(), QString("Use gridlines every 2–4,096 pixels and 1–64 subdivisions, no more than the pixels between gridlines.")); + QCOMPARE(shown.find<QLabel>("gridNote").foregroundRole(), QPalette::BrightText); + shown.type("gridSpacing", "2"); + QCOMPARE(shown.note().left(5), QString("Use g")); + shown.type("gridSubdivisions", "2"); + QVERIFY(shown.last().first == LayoutGrid(2, 2) && shown.find<QPushButton>("gridOK").isEnabled()); + // Text that is no number goes back. + shown.type("gridSpacing", "lots"); + QCOMPARE(shown.find<PickerField>("gridSpacing").text(), QString("2")); + // The titles scrub: opacity a point per half. + auto &title = *qobject_cast<QLabel *>(qobject_cast<QHBoxLayout *>(shown.sheet->layout()->itemAt(3)->layout())->itemAt(0)->widget()); + QTest::mousePress(&title, Qt::LeftButton, Qt::NoModifier, QPoint(2, 2)); + QMouseEvent move(QEvent::MouseMove, QPointF(12, 2), title.mapToGlobal(QPointF(12, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(&title, &move); + QTest::mouseRelease(&title, Qt::LeftButton, Qt::NoModifier, QPoint(12, 2)); + QCOMPARE(shown.last().second.opacity, 95); +} + +void GridSettingsSheetTests::aPickedColourBecomesCustom() +{ + GridAppearance cyan; + cyan.preset = GridAppearance::Preset::cyan; + Sheet shown(LayoutGrid(), cyan); + auto &swatch = shown.find<DialogColorSwatch>("gridColor"); + swatch.click(); + QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Grid Color)")); + QCOMPARE(shown.session.colorPicker().value().original, (PaletteColor{0, 1, 1})); + // Its opening colour keeps the preset; another makes Custom. + shown.session.previewDialogColor(); + QVERIFY(shown.previews.empty()); + const PaletteColor teal{0, 128 / 255.0, 128 / 255.0}; + shown.session.setColorPickerHSB(PickerHSB(teal)); + QVERIFY(shown.last().second.preset == GridAppearance::Preset::custom && shown.last().second.customColor == teal); + QCOMPARE(shown.find<QComboBox>("gridPreset").currentText(), QString("Custom")); + // Picking the first preset's colour again returns to it. + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 1, 1})); + QVERIFY(shown.last().second.preset == GridAppearance::Preset::cyan && shown.last().second.customColor == teal); + shown.session.closeColorPicker(false); + // Beyond Swift: Light Gray's 0.7, no byte, still stays. + auto &preset = shown.find<QComboBox>("gridPreset"); + preset.setCurrentIndex(0); + emit preset.activated(0); + swatch.click(); + shown.session.closeColorPicker(true); + QCOMPARE(shown.last().second.preset, GridAppearance::Preset::lightGray); + swatch.click(); + shown.session.setColorPickerHSB(PickerHSB(teal)); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0.7, 0.7, 0.7})); + QCOMPARE(shown.last().second.preset, GridAppearance::Preset::lightGray); + shown.session.closeColorPicker(false); + // The picker's Cancel hands back 0.7 itself: Light Gray stays. + const size_t seen = shown.previews.size(); + swatch.click(); + shown.session.closeColorPicker(false); + QCOMPARE(shown.previews.size(), seen); + swatch.click(); + shown.session.setColorPickerHSB(PickerHSB(teal)); + shown.session.closeColorPicker(false); + QCOMPARE(shown.last().second.preset, GridAppearance::Preset::lightGray); + // A preset chosen from the menu ends a pick. + swatch.click(); + shown.session.setColorPickerHSB(PickerHSB(teal)); + preset.setCurrentIndex(8); + emit preset.activated(8); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0.7, 0.7, 0.7})); + QVERIFY(shown.last().second.preset == GridAppearance::Preset::custom && shown.last().second.customColor == (PaletteColor{0.7, 0.7, 0.7}).quantized()); + shown.session.closeColorPicker(false); + // Restore Defaults ends a pick too. + preset.setCurrentIndex(7); + emit preset.activated(7); + swatch.click(); + shown.session.setColorPickerHSB(PickerHSB(teal)); + shown.find<QPushButton>("gridDefaults").click(); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 1, 1})); + QVERIFY(shown.last().second.preset == GridAppearance::Preset::custom && shown.last().second.customColor == (PaletteColor{0, 1, 1})); + // A changed preset repaints the swatch. + shown.session.closeColorPicker(false); + QCoreApplication::processEvents(); + int paints = 0; + struct Counter : QObject { + int &count; + explicit Counter(int &count) : count(count) {} + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } + } counter(paints); + swatch.installEventFilter(&counter); + preset.setCurrentIndex(5); + emit preset.activated(5); + QTRY_VERIFY(paints > 0); + swatch.removeEventFilter(&counter); + shown.sheet.reset(); + QVERIFY(!shown.session.colorPicker()); +} + +void GridSettingsSheetTests::restoreDefaultsOkAndCancel() +{ + GridAppearance look{GridAppearance::Preset::custom, PaletteColor{0, 0.5, 0.5}, GridAppearance::Style::dots, 70}; + Sheet shown(LayoutGrid(100, 4), look); + QVERIFY(shown.find<QComboBox>("gridPreset").currentText() == "Custom" && shown.find<PickerField>("gridSpacing").text() == "100"); + QVERIFY(shown.find<QComboBox>("gridStyle").currentText() == "Dots" && shown.find<PickerField>("gridOpacity").text() == "70"); + shown.find<QPushButton>("gridDefaults").click(); + const GridSettingsSheet::Settings restored = shown.last(); + QVERIFY(restored.first == LayoutGrid() && restored.second.preset == GridAppearance::Preset::lightGray && restored.second.style == GridAppearance::Style::lines); + QVERIFY(restored.second.opacity == 45 && restored.second.customColor == look.customColor); + QVERIFY(shown.find<PickerField>("gridSpacing").text() == "64" && shown.find<QComboBox>("gridStyle").currentText() == "Lines"); + // OK closes an open picker and answers what is shown. + shown.find<DialogColorSwatch>("gridColor").click(); + shown.find<QPushButton>("gridOK").click(); + QVERIFY(!shown.session.colorPicker() && shown.answer.value().value() == restored); + Sheet cancelled; + cancelled.find<DialogColorSwatch>("gridColor").click(); + cancelled.find<QPushButton>("gridCancel").click(); + QVERIFY(!cancelled.session.colorPicker() && !cancelled.answer.value()); +} + +void GridSettingsSheetTests::theRowsHoldSwiftsControls() +{ + Sheet shown; + auto *column = qobject_cast<QVBoxLayout *>(shown.sheet->layout()); + const auto rowOf = [column](int index) { return qobject_cast<QHBoxLayout *>(column->itemAt(index)->layout()); }; + for (const int index : {1, 2, 3, 5, 6}) + QCOMPARE(rowOf(index)->spacing(), 8); + // Colour and Style: title, menu (and swatch), then room. + QVERIFY(rowOf(1)->count() == 4 && rowOf(1)->itemAt(3)->spacerItem()); + QVERIFY(rowOf(2)->count() == 3 && rowOf(2)->itemAt(1)->widget() == &shown.find<QComboBox>("gridStyle") && rowOf(2)->itemAt(2)->spacerItem()); + // Opacity: the slider stretches; % sits two after. + QVERIFY(rowOf(3)->itemAt(1)->widget() == &shown.find<QSlider>("gridOpacitySlider") && rowOf(3)->stretch(1) == 1); + auto *suffixed = qobject_cast<QHBoxLayout *>(rowOf(3)->itemAt(2)->layout()); + QVERIFY(suffixed && suffixed->spacing() == 2 && suffixed->itemAt(0)->widget() == &shown.find<PickerField>("gridOpacity")); + QCOMPARE(qobject_cast<QLabel *>(suffixed->itemAt(1)->widget())->text(), QString("%")); + QCOMPARE(qobject_cast<QFrame *>(column->itemAt(4)->widget())->foregroundRole(), QPalette::Mid); + // The fields stretch, named by their titles. + auto &spacing = shown.find<PickerField>("gridSpacing"); + auto &subdivisions = shown.find<PickerField>("gridSubdivisions"); + QVERIFY(qobject_cast<QLabel *>(rowOf(5)->itemAt(0)->widget())->buddy() == &spacing && rowOf(5)->stretch(1) == 1); + QVERIFY(qobject_cast<QLabel *>(rowOf(6)->itemAt(0)->widget())->buddy() == &subdivisions && rowOf(6)->stretch(1) == 1); + QCOMPARE(qobject_cast<QLabel *>(rowOf(5)->itemAt(2)->widget())->foregroundRole(), QPalette::PlaceholderText); + QVERIFY(spacing.placeholderText() == "Gridline every" && subdivisions.placeholderText() == "Subdivisions" && shown.find<PickerField>("gridOpacity").placeholderText() == "Opacity"); + auto ¬e = shown.find<QLabel>("gridNote"); + QVERIFY(note.wordWrap() && note.font().pixelSize() == 12); + QCOMPARE(rowOf(8)->itemAt(3)->widget(), &shown.find<QPushButton>("gridOK")); + // In the controller's dialog only OK takes Return. + QDialog dialog; + shown.sheet->setParent(&dialog); + QVERIFY(!shown.find<QPushButton>("gridCancel").autoDefault() && !shown.find<QPushButton>("gridDefaults").autoDefault()); + QVERIFY(shown.find<QPushButton>("gridOK").autoDefault()); + shown.sheet->setParent(nullptr); +} + +void GridSettingsSheetTests::aSliderOrScrubReplacesTyping() +{ + Sheet shown; + auto &opacity = shown.find<PickerField>("gridOpacity"); + opacity.setFocus(); + opacity.selectAll(); + QTest::keyClicks(&opacity, QStringLiteral("77")); + shown.find<QSlider>("gridOpacitySlider").setValue(60); + QCOMPARE(opacity.text(), QString("60")); + // Each title scrubs, and the note follows. + auto *column = qobject_cast<QVBoxLayout *>(shown.sheet->layout()); + const auto titleOf = [column](int index) { return qobject_cast<QLabel *>(qobject_cast<QHBoxLayout *>(column->itemAt(index)->layout())->itemAt(0)->widget()); }; + auto &spacing = shown.find<PickerField>("gridSpacing"); + spacing.setFocus(); + spacing.selectAll(); + QTest::keyClicks(&spacing, QStringLiteral("99")); + scrub(*titleOf(5), 10); + QVERIFY(spacing.text() == "74" && shown.last().first == LayoutGrid(74, 8)); + QCOMPARE(shown.note(), QString("A subdivision every 9.25 pixels.")); + auto &subdivisions = shown.find<PickerField>("gridSubdivisions"); + subdivisions.setFocus(); + subdivisions.selectAll(); + QTest::keyClicks(&subdivisions, QStringLiteral("33")); + scrub(*titleOf(6), 10); + QVERIFY(subdivisions.text() == "10" && shown.last().first == LayoutGrid(74, 10)); + opacity.setFocus(); + opacity.selectAll(); + QTest::keyClicks(&opacity, QStringLiteral("33")); + scrub(*titleOf(3), 10); + QVERIFY(opacity.text() == "65" && shown.last().second.opacity == 65); +} + +QTEST_MAIN(GridSettingsSheetTests) +#include "GridSettingsSheetTests.moc" diff --git a/tests/GuideCanvasTests.cpp b/tests/GuideCanvasTests.cpp index b8dc8e0..53b2ac5 100644 --- a/tests/GuideCanvasTests.cpp +++ b/tests/GuideCanvasTests.cpp @@ -55,6 +55,7 @@ private slots: void moveToolDragsAGuideAndRulerDropDeletesIt(); void rulerStepUsesNicePixelIntervals(); void guidesAndTheGridDrawOverTheCanvas(); + void theGridDrawsItsSettings(); void theCursorKeysAndLostReleasesEndADrag(); void theRulersFrameTheCanvasAndDragOutGuides(); }; @@ -167,6 +168,84 @@ void GuideCanvasTests::guidesAndTheGridDrawOverTheCanvas() QVERIFY(quarter.pixelColor(major64, 150) != between || quarter.pixelColor(major64 - 1, 150) != between); } +// Swift 1.3.5's Grid Settings: spacing, colour, opacity, style. +void GuideCanvasTests::theGridDrawsItsSettings() +{ + GuideCanvas shown; + const QImage bare = shown.canvas->grab().toImage(); + shown.session.setShowsGrid(true); + // What a line adds to its two columns, by channel. + const auto ink = [&](double x, int y, int channel) { + const QImage drawn = shown.canvas->grab().toImage(); + int sum = 0; + for (int column = int(shown.at(x, 0).x()) - 1; column <= int(shown.at(x, 0).x()); ++column) { + const QPoint at(column, int(shown.at(0, y).y())); + const QRgb seen = drawn.pixel(at), was = bare.pixel(at); + sum += channel == 0 ? qRed(seen) - qRed(was) : qGreen(seen) - qGreen(was); + } + return sum; + }; + // Rows 1 to 35 along a line: # inked. + const auto pattern = [&](double x) { + QString rows; + for (int y = 1; y <= 35; ++y) + rows += std::abs(ink(x, y, 1)) > 2 ? QLatin1Char('#') : QLatin1Char('.'); + return rows; + }; + const auto rowsInked = [&](double x) { + int rows = 0; + // Row 0 holds the top major: rows 1 to 35. + for (int y = 1; y <= 35; ++y) + rows += std::abs(ink(x, y, 1)) > 2; + return rows; + }; + // Majors at 100, dotted halves at 50, none at 64. + int paints = 0; + Painted counter(paints); + QTest::qWait(50); + shown.canvas->installEventFilter(&counter); + shown.session.setLayoutGrid(LayoutGrid(100, 2)); + shown.canvas->synchronizeDisplay(); + QTRY_VERIFY(paints > 0); + QVERIFY(rowsInked(100) == 35 && rowsInked(64) == 0); + // Dotted subdivisions: one point on, two off, flat ends. + QCOMPARE(pattern(50), QString("..#..#..#..#..#..#..#..#..#..#..#..")); + // Subdivisions at 28/45 of the majors' ink. + int faint = 0; + for (int y = 1; y <= 35; ++y) + faint = std::max(faint, std::abs(ink(50, y, 1))); + QVERIFY2(std::abs(faint - ink(100, 20, 1) * 28 / 45) <= 3, qPrintable(QString::number(faint))); + // Closer than four points, subdivisions go: 64 in 32. + shown.session.setLayoutGrid(LayoutGrid(64, 32)); + QVERIFY(rowsInked(2) == 0 && rowsInked(64) == 35); + // Exactly four points apart, they still show: 64 in 16. + shown.session.setLayoutGrid(LayoutGrid(64, 16)); + QVERIFY(rowsInked(4) > 0); + shown.session.setLayoutGrid(LayoutGrid(100, 2)); + // Cyan at full opacity: green rises, red falls. + paints = 0; + GridAppearance cyan; + cyan.preset = GridAppearance::Preset::cyan; + cyan.opacity = 100; + shown.session.setGridAppearance(cyan); + shown.canvas->synchronizeDisplay(); + QTRY_VERIFY(paints > 0); + shown.canvas->removeEventFilter(&counter); + QVERIFY2(ink(100, 20, 1) >= 0 && ink(100, 20, 0) < -60, qPrintable(QString::number(ink(100, 20, 0)))); + // At 1% the line barely shows. + cyan.opacity = 1; + shown.session.setGridAppearance(cyan); + QVERIFY(std::abs(ink(100, 20, 0)) < 6 && std::abs(ink(100, 20, 0)) > 0); + // Dashes four on, three off; dots one on, two off. + cyan.opacity = 100; + cyan.style = GridAppearance::Style::dashedLines; + shown.session.setGridAppearance(cyan); + QCOMPARE(pattern(100), QString("###...####...####...####...####...#")); + cyan.style = GridAppearance::Style::dots; + shown.session.setGridAppearance(cyan); + QCOMPARE(pattern(100), QString("..#..#..#..#..#..#..#..#..#..#..#..")); +} + void GuideCanvasTests::theCursorKeysAndLostReleasesEndADrag() { GuideCanvas shown; diff --git a/tests/GuideGridTests.cpp b/tests/GuideGridTests.cpp new file mode 100644 index 0000000..9437f83 --- /dev/null +++ b/tests/GuideGridTests.cpp @@ -0,0 +1,148 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/ToolDefaults.h" +#include <QSettings> +#include <QStandardPaths> +#include <QtTest> + +// Swift 1.3.5's GuideTests for Grid Settings, and their keeping. +class GuideGridTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { QStandardPaths::setTestModeEnabled(true); } + void layoutGridTakesItsSpacingAndSubdivisions(); + void layoutGridKeepsToItsLimits(); + void gridAppearanceColorsAndStyles(); + void gridSnapFollowsTheGridSettings(); + void theSettingsAreThePersonsAcrossLaunches(); +}; + +void GuideGridTests::layoutGridTakesItsSpacingAndSubdivisions() +{ + const LayoutGrid grid(100, 4); + QCOMPARE(grid.lines(200), (std::vector<double>{0, 25, 50, 75, 100, 125, 150, 175, 200})); + QVERIFY(grid.isMajor(100) && !grid.isMajor(50)); + QCOMPARE(grid.step(), 25.0); + // An uneven step still lands on every major line. + const LayoutGrid thirds(100, 3); + std::vector<double> majors; + for (const double line : thirds.lines(300)) + if (thirds.isMajor(line)) + majors.push_back(line); + QCOMPARE(majors, (std::vector<double>{0, 100, 200, 300})); + QCOMPARE(thirds.lines(100), (std::vector<double>{0, 33, 67, 100})); + QCOMPARE(LayoutGrid(50, 1).lines(120), (std::vector<double>{0, 50, 100})); + // A hair short of a line counts it: Swift's 0.001. + QCOMPARE(LayoutGrid(50, 1).lines(99.96), (std::vector<double>{0, 50, 100})); + QCOMPARE(LayoutGrid(50, 1).lines(99.9), (std::vector<double>{0, 50})); +} + +void GuideGridTests::layoutGridKeepsToItsLimits() +{ + QCOMPARE(LayoutGrid(0, 0), LayoutGrid(2, 1)); + QCOMPARE(LayoutGrid(10, 40).subdivisions, 10); + QCOMPARE(LayoutGrid(1'000'000, 1'000).spacing, LayoutGrid::spacingHigh); + QCOMPARE(LayoutGrid(1'000'000, 1'000).subdivisions, LayoutGrid::subdivisionHigh); + QVERIFY(LayoutGrid::spacingLow == 2 && LayoutGrid::spacingHigh == 4096 && LayoutGrid::subdivisionLow == 1 && LayoutGrid::subdivisionHigh == 64); + QVERIFY(LayoutGrid().spacing == 64 && LayoutGrid().subdivisions == 8); +} + +void GuideGridTests::gridAppearanceColorsAndStyles() +{ + const GridAppearance standard; + QVERIFY(standard.preset == GridAppearance::Preset::lightGray && standard.style == GridAppearance::Style::lines && standard.opacity == 45); + QCOMPARE(standard.color(), (PaletteColor{0.7, 0.7, 0.7})); + QVERIFY(dashes(GridAppearance::Style::lines).empty()); + QCOMPARE(dashes(GridAppearance::Style::dashedLines), (std::vector<double>{4, 3})); + QCOMPARE(dashes(GridAppearance::Style::dots), (std::vector<double>{1, 2})); + GridAppearance appearance{GridAppearance::Preset::cyan, PaletteColor::black(), GridAppearance::Style::dots}; + QCOMPARE(appearance.color(), (PaletteColor{0, 1, 1})); + appearance.preset = GridAppearance::Preset::custom; + QCOMPARE(appearance.color(), PaletteColor::black()); + for (const GridAppearance::Preset preset : allGridPresets) + QCOMPARE(!presetColor(preset), preset == GridAppearance::Preset::custom); + QCOMPARE(PaletteColor::fromHex(appearance.customColor.hex()), std::optional(appearance.customColor)); + QVERIFY(std::abs(standard.majorAlpha() - 0.45) < 0.001 && std::abs(standard.subdivisionAlpha() - 0.28) < 0.001); + appearance.opacity = 100; + QVERIFY(appearance.majorAlpha() == 1 && appearance.subdivisionAlpha() < 1); + appearance.opacity = 150; + QCOMPARE(appearance.majorAlpha(), 1.0); + appearance.opacity = 0; + QCOMPARE(appearance.majorAlpha(), 0.01); + // Photoshop's names and colours, in Swift's order. + QStringList names; + for (const GridAppearance::Preset preset : allGridPresets) + names << rawValue(preset); + QCOMPARE(names, QStringList({"Light Gray", "Light Blue", "Light Red", "Green", "Medium Blue", "Yellow", "Magenta", "Cyan", "Black", "Custom"})); + QVERIFY(presetColor(GridAppearance::Preset::lightBlue) == (PaletteColor{0.29, 0.78, 1}) && presetColor(GridAppearance::Preset::lightRed) == (PaletteColor{1, 0.4, 0.4})); + QVERIFY(presetColor(GridAppearance::Preset::green) == (PaletteColor{0.25, 0.8, 0.25}) && presetColor(GridAppearance::Preset::mediumBlue) == (PaletteColor{0.2, 0.4, 1})); + QVERIFY(presetColor(GridAppearance::Preset::yellow) == (PaletteColor{1, 1, 0}) && presetColor(GridAppearance::Preset::magenta) == (PaletteColor{1, 0, 1})); + QCOMPARE(presetColor(GridAppearance::Preset::black), std::optional(PaletteColor::black())); + QStringList styles; + for (const GridAppearance::Style style : allGridStyles) + styles << rawValue(style); + QCOMPARE(styles, QStringList({"Lines", "Dashed Lines", "Dots"})); +} + +void GuideGridTests::gridSnapFollowsTheGridSettings() +{ + EditorSession session; + session.createDocument(400, 300); + session.setSnapToLayers(false); + session.setSnapToDocumentBounds(false); + session.setShowsGrid(true); + session.setSnapToGrid(true); + QSignalSpy changed(&session, &EditorSession::changed); + session.setLayoutGrid(LayoutGrid(100, 2)); + QCOMPARE(changed.count(), 1); + std::vector<double> xs = session.cropSnapTargets().xs; + std::sort(xs.begin(), xs.end()); + QCOMPARE(xs, (std::vector<double>{0, 50, 100, 150, 200, 250, 300, 350, 400})); + QCOMPARE(session.snappedGuidePosition(52, CanvasGuide::Axis::vertical, std::nullopt), 50.0); + // The same grid or look again is no news. + session.setLayoutGrid(LayoutGrid(100, 2)); + session.setGridAppearance(GridAppearance()); + QCOMPARE(changed.count(), 1); + GridAppearance dashed; + dashed.style = GridAppearance::Style::dashedLines; + session.setGridAppearance(dashed); + QVERIFY(changed.count() == 2 && session.gridAppearance() == dashed); +} + +void GuideGridTests::theSettingsAreThePersonsAcrossLaunches() +{ + // Tests keep compiled defaults until the app turns them on. + QSettings().clear(); + ToolDefaults::enable(); + QCOMPARE(EditorSession().layoutGrid(), LayoutGrid()); + QCOMPARE(EditorSession().gridAppearance(), GridAppearance()); + GridAppearance look{GridAppearance::Preset::custom, PaletteColor{0.2, 0.4, 0.6}, GridAppearance::Style::dots, 70}; + { + EditorSession session; + session.setLayoutGrid(LayoutGrid(100, 5)); + session.setGridAppearance(look); + } + // Swift's keys, so a person's settings read as Swift's. + QSettings settings; + QVERIFY(settings.value("tool.gridSpacing").toInt() == 100 && settings.value("tool.gridSubdivisions").toInt() == 5); + QVERIFY(settings.value("tool.gridColor").toString() == "Custom" && settings.value("tool.gridStyle").toString() == "Dots"); + QVERIFY(settings.value("tool.gridCustomColor").toString() == look.customColor.hex() && settings.value("tool.gridOpacity").toInt() == 70); + EditorSession next; + QVERIFY(next.layoutGrid() == LayoutGrid(100, 5) && next.gridAppearance() == look); + // What cannot be read is warned of, then defaulted. + settings.setValue("tool.gridSpacing", "wide"); + settings.setValue("tool.gridColor", "Plaid"); + settings.setValue("tool.gridStyle", "Wavy"); + settings.setValue("tool.gridCustomColor", "#12"); + for (int warning = 0; warning < 4; ++warning) + QTest::ignoreMessage(QtWarningMsg, QRegularExpression("ignoring the tool setting grid")); + EditorSession unread; + QCOMPARE(unread.layoutGrid(), LayoutGrid(64, 5)); + QVERIFY(unread.gridAppearance().preset == GridAppearance::Preset::lightGray && unread.gridAppearance().style == GridAppearance::Style::lines); + QCOMPARE(unread.gridAppearance().customColor, GridAppearance().customColor); + QCOMPARE(unread.gridAppearance().opacity, 70); + QSettings().clear(); +} + +QTEST_GUILESS_MAIN(GuideGridTests) +#include "GuideGridTests.moc" diff --git a/tests/GuideTests.cpp b/tests/GuideTests.cpp index 855f7b4..edcb842 100644 --- a/tests/GuideTests.cpp +++ b/tests/GuideTests.cpp @@ -294,14 +294,16 @@ void GuideTests::snapTargetsFollowViewMenu() void GuideTests::layoutGridLinesIncludeMajorsAndSubdivisions() { - const std::vector<double> lines = LayoutGrid::lines(64); + const LayoutGrid grid; + const std::vector<double> lines = grid.lines(64); QCOMPARE(int(lines.size()), 9); QVERIFY(lines.front() == 0 && lines.back() == 64 && lines[1] == 8); - QVERIFY(LayoutGrid::isMajor(0) && LayoutGrid::isMajor(64) && LayoutGrid::isMajor(128) && !LayoutGrid::isMajor(8)); - QVERIFY(LayoutGrid::isMajor(63.6) && !LayoutGrid::isMajor(63.4)); - QCOMPARE(LayoutGrid::lines(-1), std::vector<double>{0}); - QCOMPARE(LayoutGrid::lines(7.9), std::vector<double>{0}); - QCOMPARE(int(LayoutGrid::lines(20).size()), 3); + QVERIFY(grid.isMajor(0) && grid.isMajor(64) && grid.isMajor(128) && !grid.isMajor(8)); + QVERIFY(grid.isMajor(63.6) && !grid.isMajor(63.4)); + QCOMPARE(grid.lines(-1), std::vector<double>{0}); + QCOMPARE(grid.lines(qQNaN()), std::vector<double>{0}); + QCOMPARE(grid.lines(7.9), std::vector<double>{0}); + QCOMPARE(int(grid.lines(20).size()), 3); } void GuideTests::aDragCreatesMovesAndDeletesGuides() diff --git a/tests/HighDpiCanvasTests.cpp b/tests/HighDpiCanvasTests.cpp index 09f0927..a1d2d7e 100644 --- a/tests/HighDpiCanvasTests.cpp +++ b/tests/HighDpiCanvasTests.cpp @@ -44,6 +44,7 @@ private slots: void theCanvasDrawsDevicePixels(); void levelsEyedroppersDrawDevicePixels(); void theJPEGPreviewCountsDevicePixels(); + void gridDashesAreScreenPoints(); }; void HighDpiCanvasTests::theScreenScalesByTwo() @@ -193,6 +194,39 @@ void HighDpiCanvasTests::theJPEGPreviewCountsDevicePixels() QCOMPARE(preview.horizontalScrollBar()->maximum(), int(std::ceil(600 - preview.viewport()->width()))); } +void HighDpiCanvasTests::gridDashesAreScreenPoints() +{ + EditorSession session; + session.createDocument(100, 100); + QWidget window; + auto *canvas = new CanvasView(session, &window); + window.resize(100, 100); + canvas->setGeometry(0, 0, 100, 100); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + QTRY_COMPARE(session.viewport.backingScale, 2.0); + session.zoom(1); + const QImage bare = canvas->grab().toImage(); + session.setShowsGrid(true); + session.setLayoutGrid(LayoutGrid(50, 1)); + GridAppearance dashed; + dashed.preset = GridAppearance::Preset::black; + dashed.opacity = 100; + dashed.style = GridAppearance::Style::dashedLines; + session.setGridAppearance(dashed); + canvas->synchronizeDisplay(); + const QImage drawn = canvas->grab().toImage(); + // Four points on is eight device rows, not four. + const int column = int(std::round(session.viewport.viewPoint(QPointF(50, 0), QSizeF(100, 100)).x() * 2)); + int run = 0, longest = 0; + for (int y = 4; y < 180; ++y) { + const bool inked = std::abs(qGray(drawn.pixel(column, y)) - qGray(bare.pixel(column, y))) + std::abs(qGray(drawn.pixel(column - 1, y)) - qGray(bare.pixel(column - 1, y))) > 20; + run = inked ? run + 1 : 0; + longest = std::max(longest, run); + } + QVERIFY2(longest >= 7 && longest <= 9, qPrintable(QString::number(longest))); +} + int main(int argc, char **argv) { // Every widget on this screen scales by two. diff --git a/tests/ProjectResizeTests.cpp b/tests/ProjectResizeTests.cpp index 841d891..686b78c 100644 --- a/tests/ProjectResizeTests.cpp +++ b/tests/ProjectResizeTests.cpp @@ -4,6 +4,7 @@ #include "UI/ColorPaletteControls.h" #include "UI/ColorPickerSheet+Dialog.h" #include "UI/ColorPickerSheet.h" +#include "UI/GridSettingsSheet.h" #include <QComboBox> #include "UI/ImageSizeSheet.h" #include <QDialog> @@ -19,6 +20,7 @@ private slots: void theImageSizeSheetScalesThePixelsAsOneStep(); void aResizeThatFailsIsExplained(); void theSheetsColourOpensTheAppsPickerOverIt(); + void gridSettingsPreviewsThenKeepsOrPutsBack(); }; namespace { @@ -194,5 +196,44 @@ void ProjectResizeTests::theSheetsColourOpensTheAppsPickerOverIt() QVERIFY(!desk.session.isProjectBusy()); } +void ProjectResizeTests::gridSettingsPreviewsThenKeepsOrPutsBack() +{ + Desk desk; + QVERIFY(!desk.session.showsGrid() && !desk.session.isProjectBusy()); + const int steps = desk.session.history.undoCount(); + desk.controller.gridSettings(desk.finished()); + // The grid shows while the sheet is open; nothing busy. + QVERIFY(desk.session.showsGrid() && !desk.session.isProjectBusy()); + QVERIFY(desk.dialog().windowTitle() == "Grid" && desk.dialog().windowModality() == Qt::WindowModal); + auto &preset = *desk.dialog().findChild<QComboBox *>(QStringLiteral("gridPreset")); + preset.setCurrentIndex(7); + emit preset.activated(7); + QCOMPARE(desk.session.gridAppearance().preset, GridAppearance::Preset::cyan); + // Another sheet waits for this one. + bool refused = false; + desk.controller.gridSettings([&refused] { refused = true; }); + QTRY_VERIFY(refused); + QCOMPARE(desk.window.findChildren<GridSettingsSheet *>().size(), 1); + // Cancel puts back the look and the hidden grid. + QTest::keyClick(&desk.dialog(), Qt::Key_Escape); + QTRY_VERIFY(desk.done); + QVERIFY(desk.session.gridAppearance() == GridAppearance() && !desk.session.showsGrid() && desk.session.history.undoCount() == steps); + QTRY_VERIFY(!desk.window.findChild<QDialog *>()); + // OK keeps what was set, shown grid or not. + desk.session.setShowsGrid(true); + desk.controller.gridSettings(desk.finished()); + auto &again = *desk.dialog().findChild<QComboBox *>(QStringLiteral("gridPreset")); + again.setCurrentIndex(7); + emit again.activated(7); + desk.type("gridSpacing", "100"); + QTRY_VERIFY(desk.done); + QVERIFY(desk.session.layoutGrid() == LayoutGrid(100, 8) && desk.session.showsGrid()); + QCOMPARE(desk.session.gridAppearance().preset, GridAppearance::Preset::cyan); + // Without a window, nothing opens. + desk.controller.window = nullptr; + desk.controller.gridSettings(desk.finished()); + QTRY_VERIFY(desk.done); +} + QTEST_MAIN(ProjectResizeTests) #include "ProjectResizeTests.moc" diff --git a/tests/RemappedKeysTests.cpp b/tests/RemappedKeysTests.cpp index aa82587..56e3305 100644 --- a/tests/RemappedKeysTests.cpp +++ b/tests/RemappedKeysTests.cpp @@ -2,6 +2,7 @@ #include "UI/CanvasSizeSheet.h" #include "UI/ColorPickerSheet.h" #include "UI/ColorRangeSheet.h" +#include "UI/GridSettingsSheet.h" #include "UI/EffectsSheet.h" #include "UI/FilterSheet.h" #include "UI/HueSaturationSheet.h" @@ -227,6 +228,7 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys() session.insert(ImportedImage(red, red, "Red")); const CanvasDocument canvas = session.document().value(); QCOMPARE(keys(CanvasSizeSheet(canvas, session, [](std::optional<CanvasSizeOptions>) {})), both); + QCOMPARE(keys(GridSettingsSheet(session, LayoutGrid(), GridAppearance(), [](const GridSettingsSheet::Settings &) {}, [](std::optional<GridSettingsSheet::Settings>) {})), both); QCOMPARE(keys(ImageSizeSheet(canvas, [](std::optional<ImageSizeOptions>) {})), both); QCOMPARE(keys(TrimSheet([](std::optional<TrimOptions>) {})), both); // Export JPEG also takes the View menu's four zoom keys. diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 4a9d479..c6939f2 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -181,6 +181,8 @@ compositor_test(ColorRangeSheetTests) compositor_test(DialogColorPickerTests) compositor_test(JPEGPreviewTests) compositor_test(SnapDrawingCanvasTests) +compositor_test(GuideGridTests) +compositor_test(GridSettingsSheetTests) # It calls the colour-range kernel itself. target_include_directories(ColorRangeTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(DitherTests) diff --git a/tests/ViewMenuTests.cpp b/tests/ViewMenuTests.cpp index 20a6655..ec6b5fa 100644 --- a/tests/ViewMenuTests.cpp +++ b/tests/ViewMenuTests.cpp @@ -1,5 +1,6 @@ #include "MenuFixtures.h" #include "Rendering/EditorCanvas.h" +#include "UI/GridSettingsSheet.h" #include <QLineEdit> // The View menu: zoom, the pixel grid and Snap. @@ -9,6 +10,7 @@ private slots: void viewEntriesZoomTheFrontSession(); void thePixelGridEntryTogglesTheSession(); void guideEntriesFollowTheSession(); + void gridSettingsOpensItsSheet(); }; void ViewMenuTests::viewEntriesZoomTheFrontSession() @@ -118,5 +120,25 @@ void ViewMenuTests::guideEntriesFollowTheSession() QVERIFY(!bar.action("clearGuides").isEnabled()); } +void ViewMenuTests::gridSettingsOpensItsSheet() +{ + Bar bar; + QAction &entry = bar.action("gridSettings"); + QVERIFY(entry.text() == "Grid Settings…" && !entry.isCheckable() && !entry.isEnabled()); + // Swift's place: after the Show submenu, before Rulers. + const QList<QAction *> view = qobject_cast<QMenu *>(entry.parent())->actions(); + const qsizetype at = view.indexOf(&entry); + QVERIFY(view[at - 1]->menu() && view[at - 1]->menu()->title() == "Show" && view[at + 1] == &bar.action("showRulers")); + bar.session().createDocument(20, 20); + QVERIFY(entry.isEnabled()); + bar.window.show(); + entry.trigger(); + QTRY_VERIFY(bar.window.findChild<GridSettingsSheet *>()); + QVERIFY(bar.session().showsGrid()); + QTest::keyClick(bar.window.findChild<GridSettingsSheet *>(), Qt::Key_Escape); + QTRY_VERIFY(!bar.window.findChild<GridSettingsSheet *>()); + QVERIFY(!bar.session().showsGrid()); +} + QTEST_MAIN(ViewMenuTests) #include "ViewMenuTests.moc"