diff --git a/AGENTS.md b/AGENTS.md
index 33f20de..c729b9b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -114,6 +114,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 `compositor.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` is Swift's static; `guideColor` arrives with 12.3b, its reader. `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`.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to
"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `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 6c375dc..22dbec9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -48,6 +48,7 @@ add_library(compositor_core STATIC
src/Document/ProjectWorkspace.cpp
src/Document/SelectionClipboard.cpp
src/Document/Crop.cpp
+ src/Document/Guides.cpp
src/Document/Filters.cpp
src/Document/GuidedMatte.cpp
src/Document/SubjectRemoval.cpp
@@ -460,6 +461,7 @@ compositor_test(FilterSheetTests)
compositor_test(FilterControlsTests)
compositor_test(FloatingSelectionTests)
compositor_test(CropTests)
+compositor_test(GuideTests)
compositor_test(CropSessionTests)
compositor_test(CropCanvasTests)
compositor_test(CropControlsTests)
diff --git a/TASKS.md b/TASKS.md
index b55d2b4..fe41a87 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -309,7 +309,11 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- 12.2b work: the Keyboard Shortcuts window (Edit menu, after Paste), Swift's chords, definitions and rules in QSettings, remapped keys on the menus, the canvas, the layer list, the window's key filter, open text and every sheet's Apply and Cancel (Levels' Preview too), Space ended by its own key. Evidence: 215 of 215 in the dev image, lint clean; mutants over the model, the settings, the rewriting, `NativeShortcut`, every sheet's binding, the canvas, list and filter wrappers, the menus and the sheet: all caught but the equivalent ones AGENTS.md names; dead terms left out (the Save action's recording test, `menu`'s empty-sequence guard, `NativeShortcut`'s enabled test).
- 12.2b round 1: FAIL, four findings. The recorder claimed no key, so a live menu shortcut ran instead of being recorded (a `Qt::Tool` panel keeps its parent's shortcuts; the test's window was never shown): it claims every key while recording, the test shows the window; a synthesized press carried the unshifted text, so a remapped hardness key stepped the size (Swift's shifted text now, tested); Tab moved the focus instead of recording (the recorder takes it); Cancel moved onto Return, or Apply onto Escape, left both dead (a `NativeShortcut` rests only on its own plain key now, tested). Each fix's mutant is caught.
- 12.2b round 2: PASS, no findings.
-- [ ] 12.3 Version 1.1.7: rulers, guides, the layout grid and Snap To.
+- [x] 12.3a Version 1.1.7, part one: guides and the layout grid in the document, the session and the store (version 8's `guides`, carried by the resizers and the canvas flip), View > Snap To and the other View entries, the five new shortcut definitions, snapping to guides and the grid.
+- 12.3a work: guides in the document, undo, the store (version 8's `guides` with Swift's refusals), the resizers and the canvas flip; the layout grid; guide drags in the session with snapping; View > Show, Rulers, Snap, Snap To, Lock Guides and Clear Guides; the five shortcut definitions; moves and crops snapping through Snap To. Evidence: every test green in the dev image, lint clean; mutants over the model, the session, the store, the codec, the resizers, the flip, the projects and the menus: all caught but the two equivalent ones AGENTS.md names.
+- 12.3a round 1: FAIL, two findings. Five behaviours passed their tests broken: a guide scaled by the wrong axis (a 200 by 50 resize now), `canEditGuides`' Levels, Hue/Saturation and filter terms (each edit opened now), the five View shortcut definitions (each remapped and read back now), the grid's lines taken along the wrong edge (392 in xs, no 304 in ys), a guide cleared mid-drag still shown (tested now); each mutant is caught. `guideColor` had no reader yet: it waits for 12.3b.
+- 12.3a round 2: PASS, no findings.
+- [ ] 12.3b Version 1.1.7, part two: the rulers beside the canvas, the grid and guides drawn over it, guides dragged from the rulers and with the Move tool.
- [ ] 12.4 Version 1.1.8: the Magic tool's Object mode (U²-Net and the clicked region).
- [ ] 12.5a Version 1.2.0, part one: Photoshop PSD import (the reader, channel coder, vectors, document builder, the conversion report sheet).
- [ ] 12.5b Version 1.2.0, part two: the Outer Glow effect, brush Smoothing, and the release's other changes to what the twin holds.
diff --git a/src/Document/Crop.cpp b/src/Document/Crop.cpp
index e45b40b..dbbe2f2 100644
--- a/src/Document/Crop.cpp
+++ b/src/Document/Crop.cpp
@@ -133,8 +133,7 @@ QRectF CropSnap::apply(QRectF rect, const CropDrag &drag, QPointF point, std::op
return result;
}
-namespace {
-QRectF boxAround(const LayerTransform &transform)
+QRectF TransformSnap::box(const LayerTransform &transform)
{
const Corners corners = DistortWarp::corners(transform);
QPointF low = corners[0], high = corners[0];
@@ -144,25 +143,11 @@ QRectF boxAround(const LayerTransform &transform)
}
return QRectF(low, high);
}
-}
-// The canvas's edges and middle, and every other visible layer's.
+// View > Snap To's targets, centres included.
SnapGuides EditorSession::transformSnapTargets(const QSet &moving) const
{
- SnapGuides targets;
- if (!m_document)
- return targets;
- const QSizeF size = m_document->size();
- targets.xs = {0, size.width() / 2, size.width()};
- targets.ys = {0, size.height() / 2, size.height()};
- for (const ImageLayer &layer : m_document->renderLayers()) {
- if (!layer.asset || moving.contains(layer.id))
- continue;
- const QRectF box = boxAround(displayedTransform(layer));
- targets.xs.insert(targets.xs.end(), {std::round(box.left()), std::round(box.center().x()), std::round(box.right())});
- targets.ys.insert(targets.ys.end(), {std::round(box.top()), std::round(box.center().y()), std::round(box.bottom())});
- }
- return targets;
+ return alignmentSnapTargets(moving, true);
}
// `draft` nudged onto a nearby edge or middle, within `tolerance`.
@@ -173,7 +158,7 @@ LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSe
return draft;
}
const SnapGuides targets = transformSnapTargets(moving);
- const TransformSnap::Offset snap = TransformSnap::offset(boxAround(draft), targets.xs, targets.ys, tolerance);
+ const TransformSnap::Offset snap = TransformSnap::offset(TransformSnap::box(draft), targets.xs, targets.ys, tolerance);
snapGuides = {snap.x ? std::vector{*snap.x} : std::vector{}, snap.y ? std::vector{*snap.y} : std::vector{}};
if (snap.offset.isNull())
return draft;
@@ -182,23 +167,10 @@ LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSe
return snapped;
}
-// The canvas's edges and every visible layer's upright bounds.
+// View > Snap To's targets, without the centres.
SnapGuides EditorSession::cropSnapTargets() const
{
- SnapGuides targets;
- if (!m_document)
- return targets;
- const QSizeF size = m_document->size();
- targets.xs = {0, size.width()};
- targets.ys = {0, size.height()};
- for (const ImageLayer &layer : m_document->renderLayers()) {
- if (!layer.asset)
- continue;
- const QRectF box = boxAround(displayedTransform(layer));
- targets.xs.insert(targets.xs.end(), {std::round(box.left()), std::round(box.right())});
- targets.ys.insert(targets.ys.end(), {std::round(box.top()), std::round(box.bottom())});
- }
- return targets;
+ return alignmentSnapTargets({}, false);
}
std::optional EditorSession::visibleCropRect() const
diff --git a/src/Document/EditorSession+Model.cpp b/src/Document/EditorSession+Model.cpp
index eb1ad7a..20f06c0 100644
--- a/src/Document/EditorSession+Model.cpp
+++ b/src/Document/EditorSession+Model.cpp
@@ -45,7 +45,7 @@ CanvasDocument::CanvasDocument(int width, int height)
CanvasDocument::CanvasDocument(const ProjectSnapshot &snapshot)
: id(snapshot.manifest.documentID), width(int(snapshot.manifest.width)), height(int(snapshot.manifest.height)),
- resolution(snapshot.manifest.resolution.value_or(72))
+ resolution(snapshot.manifest.resolution.value_or(72)), guides(snapshot.manifest.guides.value_or(std::vector()))
{
for (const ProjectLayerRecord &record : snapshot.manifest.layers) {
const auto image = snapshot.images.find(record.id);
diff --git a/src/Document/EditorSession+Model.h b/src/Document/EditorSession+Model.h
index 5eba315..d41f704 100644
--- a/src/Document/EditorSession+Model.h
+++ b/src/Document/EditorSession+Model.h
@@ -1,6 +1,7 @@
#pragma once
#include "Document/LayerAdjustment.h"
#include "Document/LayerAppearance.h"
+#include "Document/Guides.h"
#include "Document/LayerGroups.h"
#include "Document/LayerMask.h"
#include "Document/LayerTransform.h"
@@ -63,6 +64,8 @@ struct CanvasDocument {
int height;
double resolution = 72;
std::vector layers; // Bottom to top.
+ // Saved with the project; undo covers them.
+ std::vector guides;
// Part of the document so undo covers it; never saved.
std::optional selection;
diff --git a/src/Document/EditorSession+Projects.cpp b/src/Document/EditorSession+Projects.cpp
index aed3d85..cb080a9 100644
--- a/src/Document/EditorSession+Projects.cpp
+++ b/src/Document/EditorSession+Projects.cpp
@@ -15,6 +15,9 @@ std::optional EditorSession::projectSnapshot() const
snapshot.masks.insert({layer.id, layer.mask->asset});
snapshot.manifest.layers.push_back(layer.hierarchyRecord());
}
+ // No guides leave the key out, as Swift writes nil.
+ if (!m_document->guides.empty())
+ snapshot.manifest.guides = m_document->guides;
return snapshot;
}
@@ -23,6 +26,7 @@ void EditorSession::installProject(const ProjectSnapshot &snapshot, const QStrin
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
m_cropRect.reset();
+ m_guideDrag = std::nullopt;
m_transformEdit = std::nullopt;
m_document = CanvasDocument(snapshot);
// Install loads shapes and text; Swift's resize rebuild does not.
@@ -53,6 +57,7 @@ void EditorSession::clearProject()
m_isMaskSelected = false;
m_cropRect.reset();
m_transformEdit = std::nullopt;
+ m_guideDrag = std::nullopt;
m_document = std::nullopt;
setActiveLayerID(std::nullopt);
m_renamingLayerID = std::nullopt;
diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h
index 7dc6baf..7f1f69f 100644
--- a/src/Document/EditorSession.h
+++ b/src/Document/EditorSession.h
@@ -297,6 +297,7 @@ public:
#include "Document/LayerAdjustment+Session.h"
#include "Document/AdjustmentEditing+Session.h"
#include "Document/LayerEffects+Session.h"
+#include "Document/Guides+Session.h"
signals:
void changed();
@@ -340,6 +341,17 @@ private:
std::optional m_projectPath;
bool m_showsPixelGrid = true;
bool m_snappingEnabled = true;
+ // The layout grid starts off; guides show; rulers hide.
+ bool m_showsGrid = false;
+ bool m_showsGuides = true;
+ bool m_showsRulers = false;
+ bool m_snapEnabled = true;
+ bool m_snapToGuides = true;
+ bool m_snapToGrid = false;
+ bool m_snapToLayers = true;
+ bool m_snapToDocumentBounds = true;
+ bool m_locksGuides = false;
+ std::optional m_guideDrag;
bool m_showsSampleRing = true;
std::optional m_cropRect;
QString m_cropRatioChoice = QStringLiteral("Free");
diff --git a/src/Document/Guides+Session.h b/src/Document/Guides+Session.h
new file mode 100644
index 0000000..4b55f84
--- /dev/null
+++ b/src/Document/Guides+Session.h
@@ -0,0 +1,39 @@
+// Swift's Guides extension, and the View menu's guide state.
+public:
+ static constexpr double guideHitDistance = 5;
+ bool showsGrid() const { return m_showsGrid; }
+ void setShowsGrid(bool shows);
+ bool showsGuides() const { return m_showsGuides; }
+ void setShowsGuides(bool shows);
+ bool showsRulers() const { return m_showsRulers; }
+ void setShowsRulers(bool shows);
+ // View > Snap To's master switch, beside 1.1.6's Snap.
+ bool snapEnabled() const { return m_snapEnabled; }
+ void setSnapEnabled(bool enabled);
+ bool snapToGuides() const { return m_snapToGuides; }
+ void setSnapToGuides(bool snaps);
+ bool snapToGrid() const { return m_snapToGrid; }
+ void setSnapToGrid(bool snaps);
+ bool snapToLayers() const { return m_snapToLayers; }
+ void setSnapToLayers(bool snaps);
+ bool snapToDocumentBounds() const { return m_snapToDocumentBounds; }
+ void setSnapToDocumentBounds(bool snaps);
+ bool locksGuides() const { return m_locksGuides; }
+ void setLocksGuides(bool locks);
+ const std::optional &guideDrag() const { return m_guideDrag; }
+ bool canClearGuides() const;
+ bool canEditGuides() const;
+ // The guides as shown, a drag under way included.
+ std::vector displayedGuides() const;
+ std::optional hitGuide(QPointF viewPoint, double tolerance = guideHitDistance) const;
+ void beginGuideCreation(CanvasGuide::Axis axis, double position);
+ void beginGuideMove(const CanvasGuide &guide);
+ void moveGuideDrag(double position);
+ // Dropped on a ruler, the guide goes.
+ void finishGuideDrag(bool removing);
+ void cancelGuideDrag();
+ void clearGuides();
+ void addGuide(const CanvasGuide &guide);
+ // What a move or crop snaps to, per Snap To.
+ SnapGuides alignmentSnapTargets(const QSet &moving, bool includeCenters) const;
+ double snappedGuidePosition(double position, CanvasGuide::Axis axis, std::optional excluding) const;
diff --git a/src/Document/Guides.cpp b/src/Document/Guides.cpp
new file mode 100644
index 0000000..7e339d0
--- /dev/null
+++ b/src/Document/Guides.cpp
@@ -0,0 +1,313 @@
+#include "Document/Guides.h"
+#include "Document/EditorSession.h"
+#include
+#include
+
+CanvasGuide CanvasGuide::offset(double x, double y) const
+{
+ CanvasGuide guide = *this;
+ guide.position += axis == Axis::vertical ? x : y;
+ return guide;
+}
+
+CanvasGuide CanvasGuide::scaled(double x, double y) const
+{
+ CanvasGuide guide = *this;
+ guide.position *= axis == Axis::vertical ? x : y;
+ return guide;
+}
+
+CanvasGuide CanvasGuide::mirrored(bool horizontally, double center) const
+{
+ CanvasGuide guide = *this;
+ if ((horizontally && axis == Axis::vertical) || (!horizontally && axis == Axis::horizontal))
+ guide.position = 2 * center - guide.position;
+ return guide;
+}
+
+QString rawValue(CanvasGuide::Axis axis)
+{
+ return axis == CanvasGuide::Axis::vertical ? QStringLiteral("vertical") : QStringLiteral("horizontal");
+}
+
+std::optional guideAxis(const QString &text)
+{
+ if (text == QLatin1String("vertical"))
+ return CanvasGuide::Axis::vertical;
+ if (text == QLatin1String("horizontal"))
+ return CanvasGuide::Axis::horizontal;
+ return std::nullopt;
+}
+
+std::vector LayoutGrid::lines(double length)
+{
+ if (!(length >= 0))
+ return {0};
+ std::vector result;
+ for (double value = 0; value <= length + 0.001; value += step)
+ result.push_back(std::round(value));
+ return result;
+}
+
+bool LayoutGrid::isMajor(double value)
+{
+ return std::abs(std::fmod(std::round(value), spacing)) < 0.001;
+}
+
+void EditorSession::setShowsGrid(bool shows)
+{
+ m_showsGrid = shows;
+ notify();
+}
+
+void EditorSession::setShowsGuides(bool shows)
+{
+ m_showsGuides = shows;
+ notify();
+}
+
+void EditorSession::setShowsRulers(bool shows)
+{
+ m_showsRulers = shows;
+ notify();
+}
+
+void EditorSession::setSnapEnabled(bool enabled)
+{
+ m_snapEnabled = enabled;
+ notify();
+}
+
+void EditorSession::setSnapToGuides(bool snaps)
+{
+ m_snapToGuides = snaps;
+ notify();
+}
+
+void EditorSession::setSnapToGrid(bool snaps)
+{
+ m_snapToGrid = snaps;
+ notify();
+}
+
+void EditorSession::setSnapToLayers(bool snaps)
+{
+ m_snapToLayers = snaps;
+ notify();
+}
+
+void EditorSession::setSnapToDocumentBounds(bool snaps)
+{
+ m_snapToDocumentBounds = snaps;
+ notify();
+}
+
+void EditorSession::setLocksGuides(bool locks)
+{
+ m_locksGuides = locks;
+ notify();
+}
+
+bool EditorSession::canClearGuides() const
+{
+ return m_document && !m_document->guides.empty();
+}
+
+bool EditorSession::canEditGuides() const
+{
+ return m_document && !m_locksGuides && !m_isProjectBusy && !m_isImporting && !m_showsNewDocument && !m_levels && !m_hueSaturation
+ && !m_filterEdit && !m_renamingLayerID;
+}
+
+std::vector EditorSession::displayedGuides() const
+{
+ std::vector guides = m_document ? m_document->guides : std::vector();
+ if (!m_guideDrag)
+ return guides;
+ const CanvasGuide current{m_guideDrag->id, m_guideDrag->axis, m_guideDrag->position};
+ const auto found = std::find_if(guides.begin(), guides.end(), [&](const CanvasGuide &guide) { return guide.id == current.id; });
+ if (found != guides.end())
+ guides[size_t(found - guides.begin())] = current;
+ else if (m_guideDrag->isNew)
+ guides.push_back(current);
+ return guides;
+}
+
+std::optional EditorSession::hitGuide(QPointF viewPoint, double tolerance) const
+{
+ if (!m_showsGuides || m_locksGuides || !m_document)
+ return std::nullopt;
+ std::optional best;
+ double nearest = 0;
+ for (const CanvasGuide &guide : displayedGuides()) {
+ const QPointF at = viewport.viewPoint(QPointF(guide.position, guide.position), m_document->size());
+ const double distance = guide.axis == CanvasGuide::Axis::vertical ? std::abs(viewPoint.x() - at.x()) : std::abs(viewPoint.y() - at.y());
+ if (distance <= tolerance && (!best || distance < nearest)) {
+ best = guide;
+ nearest = distance;
+ }
+ }
+ return best;
+}
+
+void EditorSession::beginGuideCreation(CanvasGuide::Axis axis, double position)
+{
+ if (!canEditGuides())
+ return;
+ m_showsGuides = true;
+ m_guideDrag = GuideDrag{QUuid::createUuid(), axis, snappedGuidePosition(position, axis, std::nullopt), true, std::nullopt};
+ notify();
+}
+
+void EditorSession::beginGuideMove(const CanvasGuide &guide)
+{
+ if (!canEditGuides())
+ return;
+ m_guideDrag = GuideDrag{guide.id, guide.axis, guide.position, false, guide.position};
+ notify();
+}
+
+void EditorSession::moveGuideDrag(double position)
+{
+ if (!m_guideDrag)
+ return;
+ m_guideDrag->position = snappedGuidePosition(position, m_guideDrag->axis, m_guideDrag->id);
+ notify();
+}
+
+void EditorSession::finishGuideDrag(bool removing)
+{
+ if (!m_guideDrag)
+ return;
+ const GuideDrag drag = *std::exchange(m_guideDrag, std::nullopt);
+ if (removing) {
+ // A new guide dropped on a ruler was never there.
+ if (!drag.isNew) {
+ beginEdit(QStringLiteral("Delete Guide"));
+ std::erase_if(m_document->guides, [&](const CanvasGuide &guide) { return guide.id == drag.id; });
+ endEdit();
+ }
+ notify();
+ return;
+ }
+ if (drag.isNew) {
+ beginEdit(QStringLiteral("New Guide"));
+ m_document->guides.push_back({drag.id, drag.axis, drag.position});
+ endEdit();
+ } else if (drag.original != drag.position) {
+ beginEdit(QStringLiteral("Move Guide"));
+ for (CanvasGuide &guide : m_document->guides) {
+ if (guide.id == drag.id)
+ guide.position = drag.position;
+ }
+ endEdit();
+ }
+ notify();
+}
+
+void EditorSession::cancelGuideDrag()
+{
+ m_guideDrag = std::nullopt;
+ notify();
+}
+
+void EditorSession::clearGuides()
+{
+ if (!canClearGuides())
+ return;
+ beginEdit(QStringLiteral("Clear Guides"));
+ m_document->guides.clear();
+ endEdit();
+}
+
+void EditorSession::addGuide(const CanvasGuide &guide)
+{
+ if (!canEditGuides())
+ return;
+ m_showsGuides = true;
+ beginEdit(QStringLiteral("New Guide"));
+ m_document->guides.push_back(guide);
+ endEdit();
+}
+
+SnapGuides EditorSession::alignmentSnapTargets(const QSet &moving, bool includeCenters) const
+{
+ SnapGuides targets;
+ if (!m_snapEnabled || !m_document)
+ return targets;
+ const QSizeF size = m_document->size();
+ if (m_snapToDocumentBounds) {
+ targets.xs = {0, size.width()};
+ targets.ys = {0, size.height()};
+ if (includeCenters) {
+ targets.xs.push_back(size.width() / 2);
+ targets.ys.push_back(size.height() / 2);
+ }
+ }
+ if (m_snapToLayers) {
+ for (const ImageLayer &layer : m_document->renderLayers()) {
+ if (!layer.asset || moving.contains(layer.id))
+ continue;
+ const QRectF box = TransformSnap::box(displayedTransform(layer));
+ const QPointF low = box.topLeft(), high = box.bottomRight();
+ // Swift's order: low edge, the middle when asked, high edge.
+ targets.xs.push_back(std::round(low.x()));
+ targets.ys.push_back(std::round(low.y()));
+ if (includeCenters) {
+ targets.xs.push_back(std::round((low.x() + high.x()) / 2));
+ targets.ys.push_back(std::round((low.y() + high.y()) / 2));
+ }
+ targets.xs.push_back(std::round(high.x()));
+ targets.ys.push_back(std::round(high.y()));
+ }
+ }
+ // Hidden extras do not snap, as in Photoshop.
+ if (m_snapToGrid && m_showsGrid) {
+ for (double x : LayoutGrid::lines(size.width()))
+ targets.xs.push_back(x);
+ for (double y : LayoutGrid::lines(size.height()))
+ targets.ys.push_back(y);
+ }
+ if (m_snapToGuides && m_showsGuides) {
+ for (const CanvasGuide &guide : displayedGuides())
+ (guide.axis == CanvasGuide::Axis::vertical ? targets.xs : targets.ys).push_back(guide.position);
+ }
+ return targets;
+}
+
+double EditorSession::snappedGuidePosition(double position, CanvasGuide::Axis axis, std::optional excluding) const
+{
+ if (!m_snapEnabled || !m_document)
+ return position;
+ const double tolerance = TransformSnap::distance / std::max(viewport.pointsPerPixel(), 0.0001);
+ const bool vertical = axis == CanvasGuide::Axis::vertical;
+ const double length = vertical ? m_document->size().width() : m_document->size().height();
+ std::vector targets;
+ if (m_snapToGrid && m_showsGrid)
+ targets = LayoutGrid::lines(length);
+ if (m_snapToGuides && m_showsGuides) {
+ for (const CanvasGuide &guide : displayedGuides()) {
+ if (guide.axis == axis && guide.id != excluding)
+ targets.push_back(guide.position);
+ }
+ }
+ if (m_snapToDocumentBounds)
+ targets.insert(targets.end(), {0, length / 2, length});
+ if (m_snapToLayers) {
+ for (const ImageLayer &layer : m_document->renderLayers()) {
+ if (!layer.asset)
+ continue;
+ const QRectF box = TransformSnap::box(displayedTransform(layer));
+ const QPointF low = box.topLeft(), high = box.bottomRight();
+ const double from = vertical ? low.x() : low.y(), to = vertical ? high.x() : high.y();
+ targets.insert(targets.end(), {std::round(from), std::round((from + to) / 2), std::round(to)});
+ }
+ }
+ // The nearest within reach; the first of equals wins.
+ std::optional best;
+ for (const double target : targets) {
+ if (std::abs(target - position) <= tolerance && (!best || std::abs(*best - position) > std::abs(target - position)))
+ best = target;
+ }
+ return best.value_or(position);
+}
diff --git a/src/Document/Guides.h b/src/Document/Guides.h
new file mode 100644
index 0000000..03cc26b
--- /dev/null
+++ b/src/Document/Guides.h
@@ -0,0 +1,45 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+
+// A user-placed alignment line, in document pixels.
+struct CanvasGuide {
+ enum class Axis { horizontal, vertical };
+ QUuid id;
+ Axis axis;
+ // Y for a horizontal guide, X for a vertical one.
+ double position;
+
+ CanvasGuide offset(double x, double y) const;
+ CanvasGuide scaled(double x, double y) const;
+ // Mirrored when it crosses the flip, staying on content.
+ CanvasGuide mirrored(bool horizontally, double center) const;
+ friend bool operator==(const CanvasGuide &, const CanvasGuide &) = default;
+};
+
+// Swift's raw values, "horizontal" and "vertical".
+QString rawValue(CanvasGuide::Axis axis);
+std::optional 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 lines(double length);
+bool isMajor(double value);
+}
+
+// A create or move under way; the document changes after.
+struct GuideDrag {
+ QUuid id;
+ CanvasGuide::Axis axis;
+ double position;
+ bool isNew;
+ std::optional original;
+ friend bool operator==(const GuideDrag &, const GuideDrag &) = default;
+};
diff --git a/src/Document/LayerFlip.cpp b/src/Document/LayerFlip.cpp
index 04390ec..1d90002 100644
--- a/src/Document/LayerFlip.cpp
+++ b/src/Document/LayerFlip.cpp
@@ -67,5 +67,7 @@ void EditorSession::flipCanvas(bool horizontally)
const QTransform mirror = horizontally ? QTransform(-1, 0, 0, 1, size.width(), 0) : QTransform(1, 0, 0, -1, 0, size.height());
m_document->selection->path = mirror.map(m_document->selection->path);
}
+ for (CanvasGuide &guide : m_document->guides)
+ guide = guide.mirrored(horizontally, axis);
endEdit();
}
diff --git a/src/Document/LayerTransform.h b/src/Document/LayerTransform.h
index 1f84f09..2303d15 100644
--- a/src/Document/LayerTransform.h
+++ b/src/Document/LayerTransform.h
@@ -108,4 +108,6 @@ struct Offset {
Offset offset(const QRectF &box, const std::vector &xs, const std::vector &ys,
double tolerance);
+// The upright box round a transform's corners; in Crop.cpp.
+QRectF box(const LayerTransform &transform);
}
diff --git a/src/IO/CanvasResizer.cpp b/src/IO/CanvasResizer.cpp
index 1c1934e..20e5a24 100644
--- a/src/IO/CanvasResizer.cpp
+++ b/src/IO/CanvasResizer.cpp
@@ -20,7 +20,12 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const CanvasSizeOptions
if (options.width == old.width && options.height == old.height && offset == QPointF(0, 0))
return snapshot;
ProjectManifest manifest{.resolution = old.resolution, .documentID = old.documentID, .width = options.width,
- .height = options.height, .activeLayerID = old.activeLayerID, .layers = {}};
+ .height = options.height, .activeLayerID = old.activeLayerID, .layers = {}, .guides = old.guides};
+ // Guides move with the pixels.
+ if (manifest.guides) {
+ for (CanvasGuide &guide : *manifest.guides)
+ guide = guide.offset(offset.x(), offset.y());
+ }
for (const ProjectLayerRecord &layer : old.layers) {
LayerTransform transform = layer.transform;
transform.origin += offset;
diff --git a/src/IO/ImageResizer.cpp b/src/IO/ImageResizer.cpp
index 1585a55..340faeb 100644
--- a/src/IO/ImageResizer.cpp
+++ b/src/IO/ImageResizer.cpp
@@ -33,7 +33,7 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions
throw ProjectError(ProjectError::Kind::tooLarge);
const ProjectManifest &old = snapshot.manifest;
ProjectManifest manifest{.resolution = options.resolution, .documentID = old.documentID, .width = options.width,
- .height = options.height, .activeLayerID = old.activeLayerID, .layers = {}};
+ .height = options.height, .activeLayerID = old.activeLayerID, .layers = {}, .guides = old.guides};
if (old.width == options.width && old.height == options.height) {
manifest.layers = old.layers;
return {.manifest = manifest, .images = snapshot.images, .masks = snapshot.masks};
@@ -41,6 +41,11 @@ ProjectSnapshot resized(const ProjectSnapshot &snapshot, const ImageSizeOptions
if (options.width * options.height > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
const double sx = double(options.width) / double(old.width), sy = double(options.height) / double(old.height);
+ // Guides scale with the pixels.
+ if (manifest.guides) {
+ for (CanvasGuide &guide : *manifest.guides)
+ guide = guide.scaled(sx, sy);
+ }
std::map images, masks;
qint64 usedPixels = 0, usedMaskPixels = 0;
for (const ProjectLayerRecord &layer : old.layers) {
diff --git a/src/IO/ProjectStore+Manifest.cpp b/src/IO/ProjectStore+Manifest.cpp
index 5ced57c..7f0538a 100644
--- a/src/IO/ProjectStore+Manifest.cpp
+++ b/src/IO/ProjectStore+Manifest.cpp
@@ -115,6 +115,26 @@ LayerTextStyle textStyle(const QJsonValue &value)
.boxSize = optional(object, "boxSize", size)};
}
+CanvasGuide guide(const QJsonValue &value)
+{
+ const QJsonObject object = value.toObject();
+ const std::optional axis = guideAxis(string(object.value("axis")));
+ // A value that is no object has no axis: refused.
+ if (!axis)
+ refuse();
+ return {uuid(object.value("id")), *axis, number(object.value("position"))};
+}
+
+std::vector guides(const QJsonValue &value)
+{
+ if (!value.isArray())
+ refuse();
+ std::vector result;
+ for (const QJsonValue &each : value.toArray())
+ result.push_back(guide(each));
+ return result;
+}
+
ProjectLayerRecord layer(const QJsonValue &value)
{
const QJsonObject object = value.toObject();
@@ -211,6 +231,12 @@ QByteArray ProjectManifest::encoded() const
object.insert("resolution", *resolution);
if (activeLayerID)
object.insert("activeLayerID", uuidString(*activeLayerID));
+ if (guides) {
+ QJsonArray lines;
+ for (const CanvasGuide &guide : *guides)
+ lines.append(QJsonObject{{"id", uuidString(guide.id)}, {"axis", rawValue(guide.axis)}, {"position", guide.position}});
+ object.insert("guides", lines);
+ }
// Swift indents by two; four would outgrow 4 MiB sooner.
QByteArray json;
for (const QByteArray &line : QJsonDocument(object).toJson(QJsonDocument::Indented).split('\n')) {
@@ -232,7 +258,8 @@ ProjectManifest ProjectManifest::decoded(const QByteArray &data)
return {.format = string(object.value("format")), .version = integer(object.value("version")),
.colorSpace = string(object.value("colorSpace")), .resolution = optional(object, "resolution", number),
.documentID = uuid(object.value("documentID")), .width = integer(object.value("width")),
- .height = integer(object.value("height")), .activeLayerID = optional(object, "activeLayerID", uuid), .layers = records};
+ .height = integer(object.value("height")), .activeLayerID = optional(object, "activeLayerID", uuid), .layers = records,
+ .guides = optional(object, "guides", ::guides)};
}
std::pair ProjectManifest::header(const QByteArray &data)
diff --git a/src/IO/ProjectStore.cpp b/src/IO/ProjectStore.cpp
index caf4413..6fe60a6 100644
--- a/src/IO/ProjectStore.cpp
+++ b/src/IO/ProjectStore.cpp
@@ -38,6 +38,26 @@ QString description(ProjectError::Kind kind)
constexpr qint64 manifestBytes = 4 * 1024 * 1024, assetBytes = 512 * 1024 * 1024;
+// Guides came with version 8: a thousand, each once, bounded.
+void validateGuides(const ProjectManifest &manifest)
+{
+ const std::vector guides = manifest.guides.value_or(std::vector());
+ if (manifest.version < 8) {
+ if (!guides.empty())
+ throw ProjectError(ProjectError::Kind::invalid);
+ return;
+ }
+ if (guides.size() > 1'000)
+ throw ProjectError(ProjectError::Kind::tooLarge);
+ QSet ids;
+ for (const CanvasGuide &guide : guides) {
+ // The range refuses NaN and infinity as well.
+ if (ids.contains(guide.id) || !(std::abs(guide.position) <= 1'000'000))
+ throw ProjectError(ProjectError::Kind::invalid);
+ ids.insert(guide.id);
+ }
+}
+
void validate(const ProjectManifest &manifest)
{
if (manifest.format != QLatin1String("com.compositor.project"))
@@ -88,6 +108,7 @@ void validate(const ProjectManifest &manifest)
}
if (manifest.activeLayerID && !ids.contains(*manifest.activeLayerID))
throw ProjectError(ProjectError::Kind::invalid);
+ validateGuides(manifest);
}
// Images share 100 million pixels; masks share another.
diff --git a/src/IO/ProjectStore.h b/src/IO/ProjectStore.h
index 3433a95..dfef181 100644
--- a/src/IO/ProjectStore.h
+++ b/src/IO/ProjectStore.h
@@ -1,4 +1,5 @@
#pragma once
+#include "Document/Guides.h"
#include "Document/LayerAdjustment.h"
#include "Document/LayerAppearance.h"
#include "Document/LayerEffects.h"
@@ -60,6 +61,8 @@ struct ProjectManifest {
qint64 height;
std::optional activeLayerID;
std::vector layers;
+ // Alignment guides; versions 1 to 7 carry none.
+ std::optional> guides = std::nullopt;
// Pretty JSON with sorted keys; absent optionals are left out.
QByteArray encoded() const;
diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp
index 9487966..c517c3d 100644
--- a/src/UI/CompositorMenus.cpp
+++ b/src/UI/CompositorMenus.cpp
@@ -129,6 +129,32 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
add(view, QStringLiteral("transformControls"), QStringLiteral("Show Transform Controls"), QKeySequence(Qt::CTRL | Qt::Key_H),
[this] { session().setShowsTransformControls(!session().showsTransformControls()); })
->setCheckable(true);
+ // Swift 1.1.7's guides, grid, rulers and Snap To.
+ view->addSeparator();
+ const auto toggle = [this](QMenu *menu, const QString &name, const QString &text, const QKeySequence &key, bool (EditorSession::*read)() const,
+ void (EditorSession::*write)(bool)) {
+ add(menu, name, text, key, [this, read, write] { (session().*write)(!(session().*read)()); })->setCheckable(true);
+ };
+ QMenu *show = view->addMenu(QStringLiteral("Show"));
+ toggle(show, QStringLiteral("showGrid"), QStringLiteral("Grid"), QKeySequence(Qt::CTRL | Qt::Key_Apostrophe), &EditorSession::showsGrid,
+ &EditorSession::setShowsGrid);
+ toggle(show, QStringLiteral("showGuides"), QStringLiteral("Guides"), QKeySequence(Qt::CTRL | Qt::Key_Semicolon), &EditorSession::showsGuides,
+ &EditorSession::setShowsGuides);
+ toggle(view, QStringLiteral("showRulers"), QStringLiteral("Rulers"), QKeySequence(Qt::CTRL | Qt::Key_R), &EditorSession::showsRulers,
+ &EditorSession::setShowsRulers);
+ view->addSeparator();
+ toggle(view, QStringLiteral("snapEnabled"), QStringLiteral("Snap"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Semicolon),
+ &EditorSession::snapEnabled, &EditorSession::setSnapEnabled);
+ QMenu *snapTo = view->addMenu(QStringLiteral("Snap To"));
+ toggle(snapTo, QStringLiteral("snapToGuides"), QStringLiteral("Guides"), QKeySequence(), &EditorSession::snapToGuides, &EditorSession::setSnapToGuides);
+ toggle(snapTo, QStringLiteral("snapToGrid"), QStringLiteral("Grid"), QKeySequence(), &EditorSession::snapToGrid, &EditorSession::setSnapToGrid);
+ toggle(snapTo, QStringLiteral("snapToLayers"), QStringLiteral("Layers"), QKeySequence(), &EditorSession::snapToLayers, &EditorSession::setSnapToLayers);
+ toggle(snapTo, QStringLiteral("snapToDocumentBounds"), QStringLiteral("Document Bounds"), QKeySequence(), &EditorSession::snapToDocumentBounds,
+ &EditorSession::setSnapToDocumentBounds);
+ view->addSeparator();
+ toggle(view, QStringLiteral("lockGuides"), QStringLiteral("Lock Guides"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_Semicolon),
+ &EditorSession::locksGuides, &EditorSession::setLocksGuides);
+ add(view, QStringLiteral("clearGuides"), QStringLiteral("Clear Guides"), QKeySequence(), [this] { session().clearGuides(); });
QMenu *select = bar.addMenu(QStringLiteral("&Select"));
// Text fields keep their own Select All.
@@ -294,6 +320,15 @@ void CompositorMenus::synchronize()
action(QString::fromLatin1(name))->setEnabled(drawn);
action(QStringLiteral("pixelGrid"))->setChecked(s.showsPixelGrid());
action(QStringLiteral("snap"))->setChecked(s.snappingEnabled());
+ const bool opened = s.document().has_value();
+ for (const auto &[name, value] : std::initializer_list>{
+ {"showGrid", s.showsGrid()}, {"showGuides", s.showsGuides()}, {"showRulers", s.showsRulers()}, {"snapEnabled", s.snapEnabled()},
+ {"snapToGuides", s.snapToGuides()}, {"snapToGrid", s.snapToGrid()}, {"snapToLayers", s.snapToLayers()},
+ {"snapToDocumentBounds", s.snapToDocumentBounds()}, {"lockGuides", s.locksGuides()}}) {
+ action(QString::fromLatin1(name))->setChecked(value);
+ action(QString::fromLatin1(name))->setEnabled(opened);
+ }
+ action(QStringLiteral("clearGuides"))->setEnabled(s.canClearGuides());
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/KeyboardShortcuts.cpp b/src/UI/KeyboardShortcuts.cpp
index 5b743ef..bda52c1 100644
--- a/src/UI/KeyboardShortcuts.cpp
+++ b/src/UI/KeyboardShortcuts.cpp
@@ -135,7 +135,8 @@ const std::vector &ShortcutDefinition::all()
entry("Canvas Size", "c", 3, true), entry("Image Size", "i", 3, true), entry("Transform Layer / Selection", "t", 1, true),
entry("Duplicate / Layer via Copy", "j", 1, true), entry("Toggle Clipping Mask", "g", 3, true),
entry("Group Layers", "g", 1, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true),
- entry("Move Layer Down", "[", 1, true), entry("Merge Layers", "e", 1, true)};
+ entry("Move Layer Down", "[", 1, true), entry("Merge Layers", "e", 1, true), entry("Show Grid", "'", 1, true),
+ entry("Show Guides", ";", 1, true), entry("Show Rulers", "r", 1, true), entry("Snap", ";", 9, true), entry("Lock Guides", ";", 3, true)};
const std::vector> tools{
{"Select tool", "a"}, {"Move / Transform tool", "v"}, {"Hand tool", "h"}, {"Zoom tool", "z"}, {"Brush tool", "b"},
{"Eraser", "e"}, {"Spot Healing", "j"}, {"Clone Stamp", "s"}, {"Type tool", "t"}, {"Gradient tool", "g"},
diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp
index d9a3938..043c97f 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", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"},
+ {"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"},
{"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", ""},
@@ -51,7 +51,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"}));
QCOMPARE(menus[0]->actions().size(), 11);
QCOMPARE(menus[1]->actions().size(), 13);
- QCOMPARE(menus[2]->actions().size(), 7);
+ QCOMPARE(menus[2]->actions().size(), 16);
QCOMPARE(menus[3]->actions().size(), 10);
QCOMPARE(menus[4]->actions().size(), 13);
QCOMPARE(menus[5]->actions().size(), 5);
diff --git a/tests/GuideTests.cpp b/tests/GuideTests.cpp
new file mode 100644
index 0000000..f5b8d86
--- /dev/null
+++ b/tests/GuideTests.cpp
@@ -0,0 +1,431 @@
+#include "Document/BrushStroke.h"
+#include "IO/CanvasResizer.h"
+#include "IO/ImageResizer.h"
+#include "SessionFixtures.h"
+#include
+#include
+#include
+
+// Swift's GuideTests: guides, the layout grid and snapping.
+namespace {
+CanvasGuide vertical(double position)
+{
+ return {QUuid::createUuid(), CanvasGuide::Axis::vertical, position};
+}
+
+CanvasGuide horizontal(double position)
+{
+ return {QUuid::createUuid(), CanvasGuide::Axis::horizontal, position};
+}
+
+// A red 100 by 60 layer, centred on the canvas.
+std::unique_ptr paintedSession(int width = 400, int height = 300)
+{
+ auto session = std::make_unique();
+ session->createDocument(width, height);
+ QImage image = BrushRaster::context(100, 60, false);
+ image.fill(Qt::red);
+ session->insert(ImportedImage(image, image, QStringLiteral("Red")));
+ return session;
+}
+
+QSet set(const std::vector &values)
+{
+ return QSet(values.begin(), values.end());
+}
+
+const std::vector &guides(const EditorSession &session)
+{
+ return session.document().value().guides;
+}
+}
+
+class GuideTests : public QObject {
+ Q_OBJECT
+private slots:
+ void newGuidesUndoAndClear();
+ void lockPreventsCreatingAndMoving();
+ void projectRoundTripAndLegacyRejection();
+ void theStoreRefusesWhatSwiftRefuses();
+ void canvasAndImageSizeMoveGuides();
+ void flipCanvasMirrorsGuides();
+ void snapTargetsFollowViewMenu();
+ void layoutGridLinesIncludeMajorsAndSubdivisions();
+ void aDragCreatesMovesAndDeletesGuides();
+ void aDragSnapsAndHitsTheNearestGuide();
+};
+
+void GuideTests::newGuidesUndoAndClear()
+{
+ EditorSession session;
+ session.createDocument(200, 100);
+ const CanvasGuide first = vertical(40), second = horizontal(25);
+ session.addGuide(first);
+ session.addGuide(second);
+ QCOMPARE(int(guides(session).size()), 2);
+ QCOMPARE(session.history.undoName(), QString("New Guide"));
+ QVERIFY(session.canClearGuides());
+ session.undo();
+ QCOMPARE(guides(session), std::vector{first});
+ session.clearGuides();
+ QVERIFY(guides(session).empty());
+ QCOMPARE(session.history.undoName(), QString("Clear Guides"));
+ QVERIFY(!session.canClearGuides());
+ session.undo();
+ QCOMPARE(guides(session), std::vector{first});
+ // A hidden guide shows again once one is added.
+ session.setShowsGuides(false);
+ session.addGuide(horizontal(5));
+ QVERIFY(session.showsGuides());
+}
+
+void GuideTests::lockPreventsCreatingAndMoving()
+{
+ EditorSession session;
+ session.createDocument(200, 100);
+ session.setLocksGuides(true);
+ session.addGuide(vertical(10));
+ QVERIFY(guides(session).empty());
+ session.setLocksGuides(false);
+ const CanvasGuide guide = vertical(10);
+ session.addGuide(guide);
+ session.setLocksGuides(true);
+ session.beginGuideMove(guide);
+ QVERIFY(!session.guideDrag());
+ session.beginGuideCreation(CanvasGuide::Axis::vertical, 50);
+ QVERIFY(!session.guideDrag());
+ QVERIFY(!session.hitGuide(session.viewport.viewPoint(QPointF(10, 10), QSizeF(200, 100))));
+ // Clear Guides still works while locked.
+ session.clearGuides();
+ QVERIFY(guides(session).empty());
+ // Every gate Swift names closes the guides.
+ session.setLocksGuides(false);
+ QVERIFY(session.canEditGuides());
+ for (const auto &[name, close, open] : std::vector, std::function>>{
+ {"busy", [&] { session.setIsProjectBusy(true); }, [&] { session.setIsProjectBusy(false); }},
+ {"importing", [&] { session.setIsImporting(true); }, [&] { session.setIsImporting(false); }},
+ {"new document", [&] { session.setShowsNewDocument(true); }, [&] { session.setShowsNewDocument(false); }},
+ {"renaming", [&] { session.setRenamingLayerID(QUuid::createUuid()); }, [&] { session.setRenamingLayerID(std::nullopt); }}}) {
+ close();
+ QVERIFY2(!session.canEditGuides(), name);
+ open();
+ QVERIFY2(session.canEditGuides(), name);
+ }
+ // The colour edits close the guides too.
+ QImage red(10, 10, QImage::Format_RGBA8888_Premultiplied);
+ red.fill(Qt::red);
+ session.insert(ImportedImage(red, red, QStringLiteral("Red")));
+ session.beginLevels();
+ QVERIFY(session.levels() && !session.canEditGuides());
+ session.cancelLevels();
+ session.beginHueSaturation();
+ QVERIFY(session.hueSaturation() && !session.canEditGuides());
+ session.cancelHueSaturation();
+ session.beginFilter(FilterKind::gaussianBlur);
+ QVERIFY(session.filterEdit() && !session.canEditGuides());
+ session.cancelFilter();
+ QVERIFY(session.canEditGuides());
+ EditorSession empty;
+ QVERIFY(!empty.canEditGuides() && !empty.canClearGuides());
+}
+
+void GuideTests::projectRoundTripAndLegacyRejection()
+{
+ QTemporaryDir root;
+ EditorSession session;
+ session.createDocument(80, 40);
+ QVERIFY(!session.projectSnapshot().value().manifest.guides);
+ const CanvasGuide first = vertical(16), second = horizontal(12);
+ session.addGuide(first);
+ session.addGuide(second);
+ ProjectSnapshot snapshot = session.projectSnapshot().value();
+ QCOMPARE(snapshot.manifest.version, qint64(8));
+ QCOMPARE(snapshot.manifest.guides.value(), (std::vector{first, second}));
+ // Swift's keys: an array of id, axis and position.
+ const QByteArray json = snapshot.manifest.encoded();
+ QVERIFY(json.contains("\"axis\": \"vertical\"") && json.contains("\"position\": 16") && json.contains(uuidString(first.id).toUtf8()));
+ const QString path = root.filePath("Guides.comp");
+ ProjectStore::save(snapshot, path);
+ EditorSession reopened;
+ reopened.installProject(ProjectStore::load(path), path);
+ QCOMPARE(guides(reopened), (std::vector{first, second}));
+ // Version 7 carries no guides.
+ snapshot.manifest.version = 7;
+ QVERIFY_THROWS_EXCEPTION(ProjectError, ProjectStore::save(snapshot, root.filePath("Legacy.comp")));
+ try {
+ ProjectStore::save(snapshot, root.filePath("Legacy.comp"));
+ } catch (const ProjectError &error) {
+ QCOMPARE(error.kind, ProjectError::Kind::invalid);
+ }
+}
+
+void GuideTests::theStoreRefusesWhatSwiftRefuses()
+{
+ EditorSession session;
+ session.createDocument(80, 40);
+ const ProjectSnapshot plain = session.projectSnapshot().value();
+ const auto refusal = [&](const std::vector &lines) -> std::optional {
+ ProjectSnapshot snapshot = plain;
+ snapshot.manifest.guides = lines;
+ QTemporaryDir root;
+ try {
+ ProjectStore::save(snapshot, root.filePath("Rules.comp"));
+ } catch (const ProjectError &error) {
+ return error.kind;
+ }
+ return std::nullopt;
+ };
+ QCOMPARE(refusal({vertical(1'000'000), horizontal(-1'000'000)}), std::nullopt);
+ QCOMPARE(refusal({vertical(1'000'000.5)}), std::optional(ProjectError::Kind::invalid));
+ QCOMPARE(refusal({vertical(std::nan(""))}), std::optional(ProjectError::Kind::invalid));
+ const CanvasGuide twice = vertical(3);
+ QCOMPARE(refusal({twice, CanvasGuide{twice.id, CanvasGuide::Axis::horizontal, 4}}), std::optional(ProjectError::Kind::invalid));
+ std::vector many(1'000);
+ for (CanvasGuide &guide : many)
+ guide = vertical(1);
+ QCOMPARE(refusal(many), std::nullopt);
+ many.push_back(vertical(2));
+ QCOMPARE(refusal(many), std::optional(ProjectError::Kind::tooLarge));
+ // The decoder refuses strange axes, missing keys, non-arrays.
+ const QByteArray base = plain.manifest.encoded();
+ const auto decodes = [&](const QByteArray &guidesJson) {
+ QByteArray json = base;
+ json.insert(json.lastIndexOf('}'), ",\n \"guides\": " + guidesJson + "\n");
+ try {
+ return ProjectManifest::decoded(json).guides.has_value();
+ } catch (const ProjectError &) {
+ return false;
+ }
+ };
+ const QByteArray id = uuidString(QUuid::createUuid()).toUtf8();
+ QVERIFY(decodes("[{\"id\": \"" + id + "\", \"axis\": \"vertical\", \"position\": 5}]"));
+ QVERIFY(decodes("null") == false);
+ QVERIFY(!decodes("[{\"id\": \"" + id + "\", \"axis\": \"diagonal\", \"position\": 5}]"));
+ QVERIFY(!decodes("[{\"id\": \"" + id + "\", \"axis\": \"vertical\"}]"));
+ QVERIFY(!decodes("[{\"id\": \"" + id + "\", \"axis\": \"vertical\", \"position\": \"5\"}]"));
+ QVERIFY(!decodes("[5]"));
+ QVERIFY(!decodes("{}"));
+}
+
+void GuideTests::canvasAndImageSizeMoveGuides()
+{
+ EditorSession session;
+ session.createDocument(100, 50);
+ session.addGuide(vertical(20));
+ session.addGuide(horizontal(10));
+ const ProjectSnapshot source = session.projectSnapshot().value();
+ // Anchor 8 is bottom right: room goes left and above.
+ const ProjectSnapshot expanded = CanvasResizer::resize(source, CanvasSizeOptions{.width = 140, .height = 80, .anchor = 8});
+ QCOMPARE(expanded.manifest.guides.value()[0].position, 60.0);
+ QCOMPARE(expanded.manifest.guides.value()[1].position, 40.0);
+ const ProjectSnapshot scaled = ImageResizer::resize(source, ImageSizeOptions{.width = 200, .height = 100, .resolution = 72});
+ QCOMPARE(scaled.manifest.guides.value()[0].position, 40.0);
+ QCOMPARE(scaled.manifest.guides.value()[1].position, 20.0);
+ // Each axis scales by its own side.
+ const ProjectSnapshot wide = ImageResizer::resize(source, ImageSizeOptions{.width = 200, .height = 50, .resolution = 72});
+ QCOMPARE(wide.manifest.guides.value()[0].position, 40.0);
+ QCOMPARE(wide.manifest.guides.value()[1].position, 10.0);
+ const ProjectSnapshot resolved = ImageResizer::resize(source, ImageSizeOptions{.width = 100, .height = 50, .resolution = 144});
+ QCOMPARE(resolved.manifest.guides, source.manifest.guides);
+ // None stay none; the session's rebuild keeps them.
+ EditorSession bare;
+ bare.createDocument(10, 10);
+ QVERIFY(!CanvasResizer::resize(bare.projectSnapshot().value(), CanvasSizeOptions{.width = 20, .height = 20}).manifest.guides);
+ QVERIFY(!ImageResizer::resize(bare.projectSnapshot().value(), ImageSizeOptions{.width = 20, .height = 20, .resolution = 72}).manifest.guides);
+ session.applyDocumentSize(expanded, QStringLiteral("Canvas Size"));
+ QCOMPARE(guides(session), expanded.manifest.guides.value());
+}
+
+void GuideTests::flipCanvasMirrorsGuides()
+{
+ EditorSession session;
+ session.createDocument(100, 40);
+ session.addGuide(vertical(20));
+ session.addGuide(horizontal(10));
+ session.flipCanvas(true);
+ QCOMPARE(guides(session)[0].position, 80.0);
+ QCOMPARE(guides(session)[1].position, 10.0);
+ session.flipCanvas(false);
+ QCOMPARE(guides(session)[0].position, 80.0);
+ QCOMPARE(guides(session)[1].position, 30.0);
+ session.undo();
+ QCOMPARE(guides(session)[1].position, 10.0);
+}
+
+void GuideTests::snapTargetsFollowViewMenu()
+{
+ const auto session = paintedSession();
+ // The canvas and the centred layer: 150–250, 120–180.
+ const SnapGuides crop = session->cropSnapTargets();
+ QCOMPARE(set(crop.xs), (QSet{0, 400, 150, 250}));
+ QCOMPARE(set(crop.ys), (QSet{0, 300, 120, 180}));
+ QCOMPARE(session->transformSnapTargets({}).xs, (std::vector{0, 400, 200, 150, 200, 250}));
+ session->setSnapEnabled(false);
+ QVERIFY(session->cropSnapTargets().xs.empty() && session->cropSnapTargets().ys.empty());
+ session->setSnapEnabled(true);
+ session->setSnapToLayers(false);
+ QCOMPARE(set(session->cropSnapTargets().xs), (QSet{0, 400}));
+ session->setSnapToDocumentBounds(false);
+ QVERIFY(session->cropSnapTargets().xs.empty());
+ session->setSnapToGuides(true);
+ session->setShowsGuides(true);
+ session->addGuide(vertical(33));
+ QCOMPARE(session->cropSnapTargets().xs, std::vector{33});
+ // Hidden extras do not snap.
+ session->setShowsGuides(false);
+ QVERIFY(session->cropSnapTargets().xs.empty());
+ session->setShowsGuides(true);
+ session->setSnapToGuides(false);
+ QVERIFY(session->cropSnapTargets().xs.empty());
+ session->setShowsGrid(true);
+ QVERIFY(session->cropSnapTargets().xs.empty());
+ session->setSnapToGrid(true);
+ QVERIFY(set(session->cropSnapTargets().xs).contains(64) && set(session->cropSnapTargets().xs).contains(8));
+ // Each axis takes the lines along its own edge.
+ QVERIFY(set(session->cropSnapTargets().xs).contains(392) && !set(session->cropSnapTargets().ys).contains(304));
+ session->setShowsGrid(false);
+ QVERIFY(session->cropSnapTargets().xs.empty());
+ // A move snaps to a guide through the same targets.
+ session->setSnapToGuides(true);
+ const QUuid red = session->activeLayerID().value();
+ QCOMPARE(session->snappedMove(LayerTransform{.origin = {35, 0}, .size = {100, 60}}, {red}, 5).origin.x(), 33.0);
+}
+
+void GuideTests::layoutGridLinesIncludeMajorsAndSubdivisions()
+{
+ const std::vector lines = LayoutGrid::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{0});
+ QCOMPARE(LayoutGrid::lines(7.9), std::vector{0});
+ QCOMPARE(int(LayoutGrid::lines(20).size()), 3);
+}
+
+void GuideTests::aDragCreatesMovesAndDeletesGuides()
+{
+ const auto session = paintedSession();
+ session->setShowsGuides(false);
+ QSignalSpy changes(session.get(), &EditorSession::changed);
+ session->beginGuideCreation(CanvasGuide::Axis::vertical, 40.3);
+ QVERIFY(session->showsGuides() && changes.count() == 1);
+ QCOMPARE(session->displayedGuides().size(), size_t(1));
+ QVERIFY(guides(*session).empty());
+ session->moveGuideDrag(70);
+ QCOMPARE(session->displayedGuides()[0].position, 70.0);
+ session->finishGuideDrag(false);
+ QCOMPARE(guides(*session)[0].position, 70.0);
+ QCOMPARE(session->history.undoName(), QString("New Guide"));
+ QVERIFY(!session->guideDrag());
+ // A new guide dropped on a ruler is never made.
+ const int steps = session->history.undoCount();
+ session->beginGuideCreation(CanvasGuide::Axis::horizontal, 30);
+ session->finishGuideDrag(true);
+ QCOMPARE(guides(*session).size(), size_t(1));
+ QCOMPARE(session->history.undoCount(), steps);
+ // An old one moves as one step, or none unmoved.
+ const CanvasGuide guide = guides(*session)[0];
+ session->beginGuideMove(guide);
+ QCOMPARE(session->guideDrag().value(), (GuideDrag{guide.id, guide.axis, 70, false, 70.0}));
+ session->finishGuideDrag(false);
+ QCOMPARE(session->history.undoCount(), steps);
+ session->beginGuideMove(guide);
+ session->moveGuideDrag(90);
+ QCOMPARE(session->displayedGuides(), (std::vector{{guide.id, guide.axis, 90}}));
+ session->finishGuideDrag(false);
+ QCOMPARE(session->history.undoName(), QString("Move Guide"));
+ QCOMPARE(guides(*session)[0].position, 90.0);
+ // A guide cleared mid-drag no longer shows.
+ session->beginGuideMove(guides(*session)[0]);
+ session->clearGuides();
+ QVERIFY(session->displayedGuides().empty());
+ session->cancelGuideDrag();
+ session->undo();
+ QCOMPARE(guides(*session)[0].position, 90.0);
+ // Cancelled, nothing changes; dropped on a ruler, it goes.
+ session->beginGuideMove(guides(*session)[0]);
+ session->moveGuideDrag(20);
+ session->cancelGuideDrag();
+ QVERIFY(!session->guideDrag());
+ QCOMPARE(guides(*session)[0].position, 90.0);
+ session->beginGuideMove(guides(*session)[0]);
+ session->finishGuideDrag(true);
+ QVERIFY(guides(*session).empty());
+ QCOMPARE(session->history.undoName(), QString("Delete Guide"));
+ session->finishGuideDrag(false);
+ session->moveGuideDrag(5);
+ QVERIFY(guides(*session).empty() && !session->guideDrag());
+ // A project installed or cleared drops the drag.
+ session->beginGuideCreation(CanvasGuide::Axis::vertical, 10);
+ session->installProject(session->projectSnapshot().value(), QString());
+ QVERIFY(!session->guideDrag());
+ session->beginGuideCreation(CanvasGuide::Axis::vertical, 10);
+ session->clearProject();
+ QVERIFY(!session->guideDrag());
+}
+
+void GuideTests::aDragSnapsAndHitsTheNearestGuide()
+{
+ const auto session = paintedSession();
+ session->viewport.resize(QSizeF(400, 300), 1, QSizeF(400, 300));
+ session->zoom(1);
+ // Within ten view points: the layer's left edge at 150.
+ session->beginGuideCreation(CanvasGuide::Axis::vertical, 144);
+ QCOMPARE(session->guideDrag().value().position, 150.0);
+ session->moveGuideDrag(139);
+ QCOMPARE(session->guideDrag().value().position, 139.0);
+ // The nearest wins: the middle 200 over 150 at 180.
+ session->moveGuideDrag(193);
+ QCOMPARE(session->guideDrag().value().position, 200.0);
+ session->finishGuideDrag(false);
+ // Another guide snaps, but not the dragged one itself.
+ const CanvasGuide placed = guides(*session)[0];
+ session->beginGuideCreation(CanvasGuide::Axis::vertical, 196);
+ QCOMPARE(session->guideDrag().value().position, 200.0);
+ session->cancelGuideDrag();
+ session->setSnapToLayers(false);
+ // The canvas's middle snaps too, from its bounds alone.
+ session->beginGuideCreation(CanvasGuide::Axis::horizontal, 146);
+ QCOMPARE(session->guideDrag().value().position, 150.0);
+ session->cancelGuideDrag();
+ session->setSnapToDocumentBounds(false);
+ session->beginGuideMove(placed);
+ session->moveGuideDrag(203);
+ QCOMPARE(session->guideDrag().value().position, 203.0);
+ session->cancelGuideDrag();
+ // The grid's lines snap only while shown and asked.
+ session->setSnapToGrid(true);
+ session->beginGuideCreation(CanvasGuide::Axis::horizontal, 61);
+ QCOMPARE(session->guideDrag().value().position, 61.0);
+ session->cancelGuideDrag();
+ session->setShowsGrid(true);
+ session->beginGuideCreation(CanvasGuide::Axis::horizontal, 61);
+ QCOMPARE(session->guideDrag().value().position, 64.0);
+ session->cancelGuideDrag();
+ // Ten view points reach 2.5 pixels at 400%.
+ session->zoom(4);
+ session->beginGuideCreation(CanvasGuide::Axis::horizontal, 60);
+ QCOMPARE(session->guideDrag().value().position, 60.0);
+ session->moveGuideDrag(62);
+ QCOMPARE(session->guideDrag().value().position, 64.0);
+ session->cancelGuideDrag();
+ session->zoom(1);
+ session->setSnapEnabled(false);
+ session->beginGuideCreation(CanvasGuide::Axis::horizontal, 61);
+ QCOMPARE(session->guideDrag().value().position, 61.0);
+ session->cancelGuideDrag();
+ // A press finds the nearest guide within five points.
+ session->addGuide(horizontal(100));
+ session->addGuide(horizontal(104));
+ const QPointF near = session->viewport.viewPoint(QPointF(0, 103), QSizeF(400, 300));
+ QCOMPARE(session->hitGuide(near).value().position, 104.0);
+ QVERIFY(!session->hitGuide(near + QPointF(0, 7)));
+ QCOMPARE(session->hitGuide(near + QPointF(0, 7), 7).value().position, 104.0);
+ QCOMPARE(session->hitGuide(session->viewport.viewPoint(QPointF(201, 0), QSizeF(400, 300))).value().id, placed.id);
+ session->setShowsGuides(false);
+ QVERIFY(!session->hitGuide(near));
+}
+
+QTEST_GUILESS_MAIN(GuideTests)
+#include "GuideTests.moc"
diff --git a/tests/KeyboardShortcutsTests.cpp b/tests/KeyboardShortcutsTests.cpp
index 36f122b..bd2370d 100644
--- a/tests/KeyboardShortcutsTests.cpp
+++ b/tests/KeyboardShortcutsTests.cpp
@@ -92,8 +92,8 @@ void KeyboardShortcutsTests::chordsReadKeysAsSwiftDoes()
void KeyboardShortcutsTests::theListIsSwiftsWithoutHide()
{
const std::vector &all = ShortcutDefinition::all();
- // Swift's 107, less Hide Compositor.
- QCOMPARE(int(all.size()), 106);
+ // Swift's 112 since 1.1.7, less Hide Compositor.
+ QCOMPARE(int(all.size()), 111);
QSet ids;
for (const ShortcutDefinition &definition : all)
ids.insert(definition.id());
@@ -168,6 +168,16 @@ void KeyboardShortcutsTests::menusAndSheetsTakeTheirRemappedKeys()
QCOMPARE(settings.native(ShortcutChord("y", 1)), ShortcutChord("y", 1));
// A menu's chord is no sheet's.
QCOMPARE(settings.native(ShortcutChord("z", 1)), ShortcutChord("z", 1));
+ // 1.1.7's View entries remap by their own keys.
+ const std::vector> view{{"Show Grid", QKeySequence(Qt::CTRL | Qt::Key_Apostrophe)},
+ {"Show Guides", QKeySequence(Qt::CTRL | Qt::Key_Semicolon)},
+ {"Show Rulers", QKeySequence(Qt::CTRL | Qt::Key_R)},
+ {"Snap", QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Semicolon)},
+ {"Lock Guides", QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_Semicolon)}};
+ for (const auto &[title, original] : view) {
+ QVERIFY(settings.save({{named(title).id(), ShortcutChord("k", 7)}}));
+ QVERIFY2(settings.menu(original) == QKeySequence(Qt::CTRL | Qt::ALT | Qt::META | Qt::Key_K), qPrintable(title));
+ }
}
void KeyboardShortcutsTests::theCanvasTranslatesRemappedKeys()
diff --git a/tests/TransformSnapTests.cpp b/tests/TransformSnapTests.cpp
index 7a2de71..3e9e7ae 100644
--- a/tests/TransformSnapTests.cpp
+++ b/tests/TransformSnapTests.cpp
@@ -23,29 +23,29 @@ void TransformSnapTests::targetsAreTheCanvasAndTheOtherVisibleLayersEdgesAndMidd
EditorSession session;
QCOMPARE(session.transformSnapTargets({}), SnapGuides{});
session.createDocument(100, 80);
- QCOMPARE(session.transformSnapTargets({}), (SnapGuides{{0, 50, 100}, {0, 40, 80}}));
+ QCOMPARE(session.transformSnapTargets({}), (SnapGuides{{0, 100, 50}, {0, 80, 40}}));
const QUuid a = insertImage(session, 20, 10, {20, 15});
const QUuid b = insertImage(session, 10, 10, {70, 60});
- QCOMPARE(session.transformSnapTargets({}), (SnapGuides{{0, 50, 100, 10, 20, 30, 65, 70, 75}, {0, 40, 80, 10, 15, 20, 55, 60, 65}}));
+ QCOMPARE(session.transformSnapTargets({}), (SnapGuides{{0, 100, 50, 10, 20, 30, 65, 70, 75}, {0, 80, 40, 10, 15, 20, 55, 60, 65}}));
// The layers being moved are no targets; hidden ones neither.
- QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100, 65, 70, 75}, {0, 40, 80, 55, 60, 65}}));
+ QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 100, 50, 65, 70, 75}, {0, 80, 40, 55, 60, 65}}));
session.toggleLayerVisibility(b);
- QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100}, {0, 40, 80}}));
+ QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 100, 50}, {0, 80, 40}}));
session.toggleLayerVisibility(b);
// A turned layer offers the box around its corners, rounded.
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, b).transform = {.origin = {60, 50}, .size = {20, 10}, .rotation = 90}; });
- QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100, 65, 70, 75}, {0, 40, 80, 45, 55, 65}}));
+ QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 100, 50, 65, 70, 75}, {0, 80, 40, 45, 55, 65}}));
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, b).transform = {.origin = {60, 50}, .size = {20, 10}, .rotation = 30}; });
- QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100, 59, 70, 81}, {0, 40, 80, 46, 55, 64}}));
+ QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 100, 50, 59, 70, 81}, {0, 80, 40, 46, 55, 64}}));
// A pending edit's placement counts, as the canvas shows it.
session.selectLayer(b);
session.beginTransform();
session.previewTransform({.origin = {50, 50}, .size = {10, 10}});
- QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 50, 100, 50, 55, 60}, {0, 40, 80, 50, 55, 60}}));
+ QCOMPARE(session.transformSnapTargets({a}), (SnapGuides{{0, 100, 50, 50, 55, 60}, {0, 80, 40, 50, 55, 60}}));
session.cancelTransform();
// A blank layer has no pixels to line up with.
session.addBlankLayer();
- QCOMPARE(session.transformSnapTargets({a, b}), (SnapGuides{{0, 50, 100}, {0, 40, 80}}));
+ QCOMPARE(session.transformSnapTargets({a, b}), (SnapGuides{{0, 100, 50}, {0, 80, 40}}));
}
void TransformSnapTests::snappedMoveNudgesWithinToleranceAndKeepsTheGuides()
diff --git a/tests/ViewMenuTests.cpp b/tests/ViewMenuTests.cpp
index d8db096..48cd876 100644
--- a/tests/ViewMenuTests.cpp
+++ b/tests/ViewMenuTests.cpp
@@ -6,6 +6,7 @@ class ViewMenuTests : public QObject {
private slots:
void viewEntriesZoomTheFrontSession();
void thePixelGridEntryTogglesTheSession();
+ void guideEntriesFollowTheSession();
};
void ViewMenuTests::viewEntriesZoomTheFrontSession()
@@ -54,5 +55,43 @@ void ViewMenuTests::thePixelGridEntryTogglesTheSession()
QVERIFY(!snap.isChecked());
}
+void ViewMenuTests::guideEntriesFollowTheSession()
+{
+ Bar bar;
+ const std::vector> toggles{
+ {"showGrid", "Grid", &EditorSession::showsGrid, false},
+ {"showGuides", "Guides", &EditorSession::showsGuides, true},
+ {"showRulers", "Rulers", &EditorSession::showsRulers, false},
+ {"snapEnabled", "Snap", &EditorSession::snapEnabled, true},
+ {"snapToGuides", "Guides", &EditorSession::snapToGuides, true},
+ {"snapToGrid", "Grid", &EditorSession::snapToGrid, false},
+ {"snapToLayers", "Layers", &EditorSession::snapToLayers, true},
+ {"snapToDocumentBounds", "Document Bounds", &EditorSession::snapToDocumentBounds, true},
+ {"lockGuides", "Lock Guides", &EditorSession::locksGuides, false}};
+ // Without a document every entry rests.
+ for (const auto &[name, text, read, start] : toggles) {
+ QAction &entry = bar.action(name);
+ QVERIFY2(entry.isCheckable() && !entry.isEnabled() && entry.isChecked() == start && entry.text() == text, name);
+ }
+ QVERIFY(!bar.action("clearGuides").isEnabled());
+ bar.session().createDocument(20, 20);
+ for (const auto &[name, text, read, start] : toggles) {
+ QAction &entry = bar.action(name);
+ QVERIFY2(entry.isEnabled(), name);
+ entry.trigger();
+ QVERIFY2((bar.session().*read)() != start && entry.isChecked() != start, name);
+ entry.trigger();
+ QVERIFY2((bar.session().*read)() == start && entry.isChecked() == start, name);
+ }
+ // The submenus Swift names, and Clear Guides with guides.
+ QCOMPARE(qobject_cast(bar.action("showGrid").parent())->title(), QString("Show"));
+ QCOMPARE(qobject_cast(bar.action("snapToGrid").parent())->title(), QString("Snap To"));
+ bar.session().addGuide({QUuid::createUuid(), CanvasGuide::Axis::vertical, 5});
+ QVERIFY(bar.action("clearGuides").isEnabled());
+ bar.action("clearGuides").trigger();
+ QVERIFY(bar.session().document().value().guides.empty());
+ QVERIFY(!bar.action("clearGuides").isEnabled());
+}
+
QTEST_MAIN(ViewMenuTests)
#include "ViewMenuTests.moc"