14.20: the Navigator minimap from 300%
This commit is contained in:
17 files changed
+733
-1
No files matched your search
@@ -199,6 +199,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- The Layers panel's gray (14.17, Swift 1.4.8: `06313a9`): `layersLookEditable` is Swift's: `canEditLayers` without what lasts only while the mouse is down (a stroke, a warp, a drag's transform edit, a pixel move) or a moment (`isProjectBusy`, read as `showsBusy`, which shows after 250 ms). The list's rows and their eyes (an effect's eye keeps `canEditLayers`, as Swift makes each effect row anew with it), the footer's buttons (new layer, folder, adjustments, delete with an active layer), the effects menu (with a layer that has pixels) and the mask button (one layer selected, without a mask) follow it, so nothing flashes gray for a click; every action still asks the session's own gate, which refuses meanwhile. The effects menu's folder term is left out as dead: a folder never holds pixels, so the pixel term refuses it. Tests: `LayersLookEditableTests` (each moment looks editable while the gate stays shut, each lasting state grays, the footer and list mid-stroke and under a pending transform, the effects menu's and mask button's own terms); the busy cases in `BlendModePickerTests`, `LayersPanelTests`, `NativeLayerListTests` and `LayerEffectRowTests` now wait for the indicator, and a click before it changes nothing.
|
||||
- Export As (14.18, Swift 1.4.8: `9287cf1`, `450af5f`, `b1db93d`): `ProjectController::exportAs(start, done)` replaces `exportJPEG`; `ExportFormat` (png, jpeg, pdf, Swift's raw values through `rawValue`, `allExportFormats`) starts the sheet, else the last format exported this run (`m_lastExportFormat`, PNG at first), and the File menu's Export JPEG… opens it on JPEG; Export As… (Ctrl+Alt+Shift+W, a new shortcut definition) on the last. The flow is the JPEG one: the render in a worker, the sheet, then the format's panel (Export PNG, Export JPEG or Export PDF, suggesting `.png`, `.jpeg` or `.pdf`), the write in a worker, one alert titled "Couldn’t export" for a failure past the render. `UI/ExportAsSheet` (Swift's sheet, renamed from the JPEG one): titled Export As; the format as three checkable buttons; the size as two `PickerField`s keeping the proportions (a number applies on Return or leaving, else both fields show the size again; a scrub or menu replaces their typing) and a scale menu of 25, 50, 75, 100 and 200%, every scale held between a hundredth and the side and surface limits; Quality for JPEG alone (saved only from a JPEG export), the page size in inches at the document's DPI for PDF alone, the background swatch ("Export Background") for JPEG and PDF; "Transparency kept · sRGB" for PNG, else "sRGB"; a change of format or size asks again only when the settings change, as Swift's `task(id:)`. The encoding resizes first (`ImageExporter::resized`: smooth, the same raster at the same size, `tooLarge` past the limits) and encodes by format (`pngData`; `jpeg`; `pdfData` over `flattened` for the preview). `JPEGPreview` takes the export's size beside its image (`setImage(image, pixels, updating)`), so Fit and 100% follow the chosen size, and draws Swift 1.4.8's checkerboard (20-point tiles of grays 0.30 and 0.35, anchored at the image) behind what it shows. Swift's PDF comes from `CGContext`'s PDF writer; `QPdfWriter` rounds a page to whole points, so `ImageExporter::pdfData` writes PDF 1.4 by hand: one page whose MediaBox is the print size in points (72 / resolution a pixel, 72 when none), the background as a filled rectangle when given, the pixels as a Flate RGB image with a gray soft mask, straight colour (adaptation; checked against poppler's `pdfinfo` and `pdftoppm`). A Flate stream that `qCompress` cannot make (it returns nothing when memory runs out) is `ExportError(render)`, never an empty stream. A zoom set before the first image (Ctrl+1 while the sheet still encodes) centres that image when it lands, as Swift's preview, made with its first result, centres on appearing. The page's inches round as Swift's formatter: its shortest digits, halves to even (45 pixels at 72 DPI are 0.62 in). A size field reads Swift's `Int` field (`typedInteger`, checked against Swift 6.0.3): a sign, decimal digits of any script read by code point, a fraction and an exponent (its leading zeros dropped), the number truncated toward zero and exact within Int64 (`1e3` is 1000, `12.7` is 12, `-9.223372036854775808e18` is Int64's least, `1e-400` is 0, `128e` is 128), grouping refused; known difference: ICU's lenient parse also reads a leading number before other text (`7abc` is 7, `1,000` is 1, `--5` is −5), where the twin puts the size back; and ICU rounds some long fractions before Swift truncates them (`199.99999999999999` is 200, `0.99999999999999999` is 1, while `9007199254740993.5` stays …993 and `12.99999999999999` stays 12), a rule the measured cases do not pin down, so the twin truncates the digits as typed. Encoding finds no memory as `ExportError(render)` at `encode`'s door, so Export As's PNG worker never meets a `bad_alloc`. Equivalent mutant: the parser's `point <= 0` as `< 0` (a point at zero leaves no digits, which read as 0). Tests: `ExportPDFTests` (the page, its size, the straight pixels and alpha, rows and channels, the background, a refusal, resizing, flattening), `ExportPDFFailureTests` (a binary of its own: a 360-pixel PDF, small enough that Qt converts it on the calling thread (a pool thread that cannot start hangs Qt 6.4's conversion), under limits from 64 KB to 4 MB throws or inflates whole, both reached, and a PNG likewise throws or matches the unstarved file), `ExportAsSheetTests` (formats and their rows and bytes, the size and its limits, the checkerboard, the page against Swift 6.0.3's formatter), `JPEGPreviewTests::aZoomBeforeTheFirstImageCentresIt`, `ProjectExportTests` (each format through the controller, the menu's Export As on the last format), the entry in `CompositorMenusTests` and `KeyboardShortcutsTests` (its chord, group and place). Equivalent mutants: none found.
|
||||
- Filter › Scanlines (14.19, Swift 1.4.9: `06c3a22`, `1187736`, `cdde327`): Scanlines leaves Dither for a filter of its own. `kernels/DitherPixels.c/.h` are `cdde327`'s, with 13.17's adaptation carried on: Swift's GCD blocks become static band functions with context structs (`scanlines_tone_band`, `line_scan_band`, `blur_band`, `spread_band`, `column_band`, `split_band`), each failure flag an `atomic_int`, every band through `dither_bands`; the arithmetic is Swift's word for word, and `dither_glow`'s new easing near white came with it. `ScanlinesTests::theKernelDrawsSwiftsOwnBytes` holds that: Swift's own file, built unchanged with clang's blocks and libdispatch in the `swift:6.0.3` image, drew five scenes (37 by 53 and 70 by 300, every setting, both directions of Displace, 32 bands of columns, lines and rows) and a glow, and the twin's bytes hash the same (FNV-1a). `Document/Scanlines.h/.cpp` twin Swift's file: `ScanlinesSettings` with Swift's fields, defaults (glow 0 now, thickness 70, smoothness 50) and clamps, Line Spacing and Color Split rounded, `apply` handing the kernel the normalized settings and Two Colors' bytes (black on white otherwise), then 13.17's `glowing` moved here unchanged. `DitherStyle` loses `scanlines` (`usesPixelSize` is ASCII alone, `drawsMarks` every style without tones) and `DitherSettings` its four fields. `FilterKind::scanlines` ("Scanlines") sits after Dither in Swift's order, so the Filter menu lists it there; it previews whole, as Dither does. Its two colours reuse Dither's picker (`openDitherColorPicker`, `setDitherColor`, Swift's), choosing the edit's own settings, and the titles drop "Dither" (`Color Picker (Light Color)`, `(Dark Color)`). The sheet's `scanlines` (`UI/FilterSheet+Scanlines.cpp`) is Swift's `scanlinesControls`: twelve rows with Swift's ranges, units and help, then the Colors menu and swatches, which `colorChoices` builds for Dither too (the screen's and the lines' colour help for Scanlines). Equivalent mutant: `glowing` run at glow 0 (it adds nothing). Tests: `ScanlinesTests` (Swift's eight, Swift's own bytes, each setting reaching its field, Two Colors rounded, every clamp through `FilterSettings` too, clear pixels, the eased glow against a full-size blur), `ScanlinesSheetTests` (the menu place and a commit, the sheet's rows, ranges and fields, both pickers), the order in `FilterModelTests`, `FinishingFilterTests`, `DitherSheetTests` and `CompositorMenusTests`, the acceptance run's Scanlines row through the menu.
|
||||
- The Navigator (14.20, Swift 1.4.9: `6490bb3`, `3e09948`; `3c6cf5e`'s conflict resolved toward the minimap, which supersedes the panel): `Rendering/NavigatorGeometry.h/.cpp` twin Swift's file: `NavigatorGeometry` (the document fitted and centred in a box, `thumbnailRect`, `documentPoint` held to the document's edges), and `CanvasViewport::visibleDocumentRect` and `centerView`, declared in `CanvasViewport.h` and defined there, as Swift's extension; `centerView` goes through `translate`, so the view stops following Fit. Swift's `min` and `abs` in `visibleDocumentRect` are dead (the viewport never flips) and left out. `EditorSession::navigatorImage` (`EditorSession+Navigator.cpp`, its fragment `EditorSession+Navigator.h`) twins Swift's: the document drawn at the picture's size, never enlarged, each side rounded, a pixel at least, every layer and placed mask scaled by its axis, blur radius and motion distance by the width's, through a bare session's `drawLiveComposite` so an edit in progress stays out; a context that cannot be allocated logs on `omaphoto.rendering` where Swift's `try?` is silent (untested: only starvation reaches it). Swift's medium interpolation on the context is dead here (`LayerRenderer` sets each layer's own) and left out. `UI/NavigatorMinimap` twins Swift's view: a `QWidget` in `ContentView`'s canvas overlay (the mask badge's layout, its margins 12, top right), following the session's `changed()` itself: shown while View › Navigator holds (`navigator.visible` in `QSettings`, Swift's `AppStorage`, off at first), Canvas Only is off, a document is open and the zoom is 300% or more; hidden it drops its picture, key and wait, as SwiftUI drops the view. Its size is `sizeFor` (Swift's `size(for:)`: `QWidget::size` takes the name), fitted inside 126 by 90 and rounded; it fills gray 0.11, draws the 300-pixel picture smoothly over its whole box, the view's box in the accent 2.5 wide inside its rect (three points at least), clipped to a rounded rect of 4, then a 25% white rim. Swift's `task(id:)` is `RenderKey` (the document's id, size and layers and `brushRevision`, so a selection or guide redraws nothing): the first picture renders at once, each later key after a 250 ms single-shot precise `navigatorWait`. A left press or drag centres the view on the point (`centerView`, then `notify`), the arrow its cursor over the canvas's. Known difference: Swift's shadow (radius 6) has no twin, since a widget paints only inside itself. View › Navigator (300% and above) is a checkable entry before Pixel Grid that writes the setting and notifies; the command palette finds it. Tests: `NavigatorTests` (Swift's nine; the geometry's edge cases, both axes and a placed mask scaled, a streak shrunk, a transform in progress left out, the minimap's showing, waiting, clicks, cursor, box, gray and rim), `ViewMenuTests::theNavigatorEntryShowsTheMinimap` (the entry's place and words, the minimap 12 points in at the canvas's top right), the View menu's count in `CompositorMenusTests`. Equivalent mutants: none found.
|
||||
- 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` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
|
||||
|
||||
@@ -31,6 +31,7 @@ add_library(compositor_core STATIC
|
||||
src/Document/Distort.cpp
|
||||
src/Document/DocumentHistory.cpp
|
||||
src/Document/EditorSession.cpp
|
||||
src/Document/EditorSession+Navigator.cpp
|
||||
src/Document/EditorSession+Brush.cpp
|
||||
src/Document/EditorSession+Imports.cpp
|
||||
src/Document/EditorSession+Layers.cpp
|
||||
@@ -123,6 +124,7 @@ add_library(compositor_core STATIC
|
||||
src/UI/LayerIcons.cpp
|
||||
src/UI/LassoControls.cpp
|
||||
src/UI/LayerMaskMenu.cpp
|
||||
src/UI/NavigatorMinimap.cpp
|
||||
src/UI/LayersPanel.cpp
|
||||
src/UI/LevelsSheet.cpp
|
||||
src/UI/HueSaturationSheet.cpp
|
||||
@@ -234,6 +236,7 @@ add_library(compositor_core STATIC
|
||||
src/Rendering/LayerEffectsKernel.cpp
|
||||
src/Rendering/LayerEffectsSurface.cpp
|
||||
src/Rendering/LayerRenderer.cpp
|
||||
src/Rendering/NavigatorGeometry.cpp
|
||||
src/Rendering/LiveMaskRenderer.cpp
|
||||
src/Rendering/BrushCoverage.cpp
|
||||
src/Rendering/RasterSnapshot.cpp
|
||||
|
||||
@@ -17,6 +17,7 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
|
||||
- Last Filter (Ctrl+Meta+F) runs the last filter again with the same settings
|
||||
- Crop, trim, canvas size, image size; PNG, JPEG and one-page PDF export, scaled if you like, with a zoomable preview (File › Export As…); New Canvas presets for screens and social formats
|
||||
- Imports JPEG, PNG, TIFF, HEIC, SVG, Photoshop files (PSD and PSB) and camera RAW
|
||||
- A Navigator minimap in the canvas's corner from 300% zoom: the whole document in small with a box around the view; click or drag to move there (View › Navigator)
|
||||
- Open Recent, and a Keyboard Shortcuts window
|
||||
- Search Commands (Ctrl+F): find every menu command and tool by name and run it with Return
|
||||
- Toggle Fullscreen (F): the canvas alone on black over the whole screen, every panel put away; F or Esc brings them back
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "UI/TypeControls.h"
|
||||
#include "UI/LassoControls.h"
|
||||
#include "UI/LayerMaskMenu.h"
|
||||
#include "UI/NavigatorMinimap.h"
|
||||
#include "UI/NavigationToolHeader.h"
|
||||
#include "UI/TransformInspector.h"
|
||||
#include "UI/NewCanvasSheet.h"
|
||||
@@ -244,6 +245,9 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
|
||||
m_canvasSlot->addWidget(m_dropRing, 0, 0);
|
||||
// A layout takes no clicks; the canvas keeps the rest.
|
||||
auto *foot = new QVBoxLayout;
|
||||
// Swift's minimap sits 12 in, at the top right.
|
||||
foot->setContentsMargins(12, 12, 12, 0);
|
||||
foot->addWidget(new NavigatorMinimap(session, canvas), 0, Qt::AlignRight);
|
||||
foot->addStretch(1);
|
||||
foot->addWidget(m_maskAloneBadge, 0, Qt::AlignHCenter);
|
||||
foot->addSpacing(14);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Logging.h"
|
||||
#include <QPainter>
|
||||
#include <cmath>
|
||||
|
||||
// Swift's EditorSession+Navigator.swift: the document in small.
|
||||
namespace {
|
||||
// Every layer and placed mask scaled; blurs shrink with them.
|
||||
CanvasDocument placed(const CanvasDocument &document, double x, double y, int width, int height)
|
||||
{
|
||||
const auto scaled = [x, y](LayerTransform transform) {
|
||||
transform.origin = QPointF(transform.origin.x() * x, transform.origin.y() * y);
|
||||
transform.size = QSizeF(transform.size.width() * x, transform.size.height() * y);
|
||||
return transform;
|
||||
};
|
||||
CanvasDocument small = document;
|
||||
small.width = width;
|
||||
small.height = height;
|
||||
for (ImageLayer &layer : small.layers) {
|
||||
layer.transform = scaled(layer.transform);
|
||||
if (layer.mask && layer.mask->placement)
|
||||
layer.mask->placement = scaled(*layer.mask->placement);
|
||||
if (layer.adjustment && layer.adjustment->blurRadius)
|
||||
layer.adjustment->blurRadius = *layer.adjustment->blurRadius * x;
|
||||
if (layer.adjustment && layer.adjustment->motionDistance)
|
||||
layer.adjustment->motionDistance = *layer.adjustment->motionDistance * x;
|
||||
}
|
||||
return small;
|
||||
}
|
||||
}
|
||||
|
||||
// At the picture's size: adjustment surfaces take what they draw.
|
||||
std::optional<QImage> EditorSession::navigatorImage(int maxSide) const
|
||||
{
|
||||
if (!m_document || m_document->width <= 0 || m_document->height <= 0 || maxSide <= 0)
|
||||
return std::nullopt;
|
||||
const CanvasDocument &document = *m_document;
|
||||
const double scale = std::min(1.0, double(maxSide) / std::max(document.width, document.height));
|
||||
const int width = std::max(1, int(std::lround(document.width * scale))), height = std::max(1, int(std::lround(document.height * scale)));
|
||||
QImage context;
|
||||
try {
|
||||
context = BrushRaster::context(width, height, false);
|
||||
} catch (const ExportError &error) {
|
||||
qCWarning(lcRendering) << "the navigator's picture could not be made:" << error.what();
|
||||
return std::nullopt;
|
||||
}
|
||||
QPainter painter(&context);
|
||||
// A bare session: edits in progress stay out.
|
||||
EditorSession().drawLiveComposite(placed(document, double(width) / document.width, double(height) / document.height, width, height), painter);
|
||||
painter.end();
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Swift's EditorSession+Navigator extension: the document in small.
|
||||
public:
|
||||
// Its longer side `maxSide` at most, never enlarged.
|
||||
std::optional<QImage> navigatorImage(int maxSide) const;
|
||||
@@ -313,6 +313,7 @@ public:
|
||||
#include "Document/Guides+Session.h"
|
||||
#include "Document/ObjectSelection+Session.h"
|
||||
#include "Document/ColorRangeSelection+Session.h"
|
||||
#include "Document/EditorSession+Navigator.h"
|
||||
signals:
|
||||
void changed();
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ public:
|
||||
// The next stop past the zoom, else the zoom.
|
||||
double keyboardZoomTarget(int step) const;
|
||||
void translate(QSizeF by);
|
||||
// Swift's NavigatorGeometry extension: what the canvas shows, in pixels.
|
||||
QRectF visibleDocumentRect(QSizeF documentSize) const;
|
||||
// `point` to the middle at the same zoom; Fit stops.
|
||||
void centerView(QPointF point, QSizeF documentSize);
|
||||
friend bool operator==(const CanvasViewport &lhs, const CanvasViewport &rhs);
|
||||
|
||||
private:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#include "Rendering/NavigatorGeometry.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
QRectF NavigatorGeometry::imageRect() const
|
||||
{
|
||||
if (!(documentSize.width() > 0 && documentSize.height() > 0 && box.width() > 0 && box.height() > 0))
|
||||
return QRectF();
|
||||
const double scale = std::min(box.width() / documentSize.width(), box.height() / documentSize.height());
|
||||
const QSizeF size = documentSize * scale;
|
||||
return QRectF(QPointF((box.width() - size.width()) / 2, (box.height() - size.height()) / 2), size);
|
||||
}
|
||||
|
||||
double NavigatorGeometry::scale() const
|
||||
{
|
||||
return documentSize.width() > 0 ? imageRect().width() / documentSize.width() : 0;
|
||||
}
|
||||
|
||||
QRectF NavigatorGeometry::thumbnailRect(QRectF documentRect) const
|
||||
{
|
||||
const QRectF image = imageRect();
|
||||
const double scale = this->scale();
|
||||
return QRectF(image.left() + documentRect.left() * scale, image.top() + documentRect.top() * scale, documentRect.width() * scale,
|
||||
documentRect.height() * scale);
|
||||
}
|
||||
|
||||
QPointF NavigatorGeometry::documentPoint(QPointF point) const
|
||||
{
|
||||
const double scale = this->scale();
|
||||
if (!(scale > 0))
|
||||
return QPointF();
|
||||
const QRectF image = imageRect();
|
||||
return QPointF(std::clamp((point.x() - image.left()) / scale, 0.0, documentSize.width()),
|
||||
std::clamp((point.y() - image.top()) / scale, 0.0, documentSize.height()));
|
||||
}
|
||||
|
||||
QRectF CanvasViewport::visibleDocumentRect(QSizeF documentSize) const
|
||||
{
|
||||
// The viewport never flips: Swift's min and abs are dead.
|
||||
return QRectF(documentPoint(QPointF(0, 0), documentSize), documentPoint(QPointF(viewSize.width(), viewSize.height()), documentSize));
|
||||
}
|
||||
|
||||
// Through translate, so the view stops following Fit.
|
||||
void CanvasViewport::centerView(QPointF point, QSizeF documentSize)
|
||||
{
|
||||
const double scale = pointsPerPixel();
|
||||
const QSizeF target(documentSize.width() * scale / 2 - point.x() * scale, documentSize.height() * scale / 2 - point.y() * scale);
|
||||
translate(QSizeF(target.width() - pan.width(), target.height() - pan.height()));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include "Rendering/CanvasViewport.h"
|
||||
|
||||
// The document fitted and centred in the Navigator's box.
|
||||
struct NavigatorGeometry {
|
||||
QSizeF documentSize;
|
||||
QSizeF box;
|
||||
QRectF imageRect() const;
|
||||
QRectF thumbnailRect(QRectF documentRect) const;
|
||||
// The document pixel under a box point, held inside.
|
||||
QPointF documentPoint(QPointF point) const;
|
||||
friend bool operator==(const NavigatorGeometry &, const NavigatorGeometry &) = default;
|
||||
|
||||
private:
|
||||
// Box points per document pixel.
|
||||
double scale() const;
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "IO/RecentProjects.h"
|
||||
#include "Logging.h"
|
||||
#include "UI/KeyboardShortcuts.h"
|
||||
#include "UI/NavigatorMinimap.h"
|
||||
#include "UI/ProjectWorkspaceView.h"
|
||||
#include <QApplication>
|
||||
#include <QMenu>
|
||||
@@ -142,6 +143,10 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, Pro
|
||||
};
|
||||
add(view, QStringLiteral("zoomIn"), QStringLiteral("Zoom In"), QKeySequence(Qt::CTRL | Qt::Key_Equal), [zoomBy] { zoomBy(1); });
|
||||
add(view, QStringLiteral("zoomOut"), QStringLiteral("Zoom Out"), QKeySequence(Qt::CTRL | Qt::Key_Minus), [zoomBy] { zoomBy(-1); });
|
||||
add(view, QStringLiteral("navigator"), QStringLiteral("Navigator (300% and above)"), QKeySequence(), [this] {
|
||||
NavigatorMinimap::setVisibleSetting(!NavigatorMinimap::visibleSetting());
|
||||
session().notify();
|
||||
})->setCheckable(true);
|
||||
add(view, QStringLiteral("pixelGrid"), QStringLiteral("Pixel Grid (800% and above)"), QKeySequence(), [this] { session().setShowsPixelGrid(!session().showsPixelGrid()); })
|
||||
->setCheckable(true);
|
||||
// Ctrl+H: the Move tool's box; the desktop hides apps.
|
||||
@@ -369,6 +374,7 @@ void CompositorMenus::synchronize()
|
||||
action(QStringLiteral("redo"))->setEnabled(typing || s.canRedo());
|
||||
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
|
||||
action(QString::fromLatin1(name))->setEnabled(drawn);
|
||||
action(QStringLiteral("navigator"))->setChecked(NavigatorMinimap::visibleSetting());
|
||||
action(QStringLiteral("pixelGrid"))->setChecked(s.showsPixelGrid());
|
||||
action(QStringLiteral("toggleFullscreen"))->setChecked(s.canvasOnly());
|
||||
action(QStringLiteral("toggleFullscreen"))->setEnabled(s.canToggleCanvasOnly());
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#include "UI/NavigatorMinimap.h"
|
||||
#include "Rendering/NavigatorGeometry.h"
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QSettings>
|
||||
#include <cmath>
|
||||
|
||||
bool NavigatorMinimap::visibleSetting()
|
||||
{
|
||||
return QSettings().value(QLatin1String(visibleKey), false).toBool();
|
||||
}
|
||||
|
||||
void NavigatorMinimap::setVisibleSetting(bool visible)
|
||||
{
|
||||
QSettings().setValue(QLatin1String(visibleKey), visible);
|
||||
}
|
||||
|
||||
QSizeF NavigatorMinimap::sizeFor(QSizeF document)
|
||||
{
|
||||
if (!(document.width() > 0 && document.height() > 0))
|
||||
return QSizeF(0, 0);
|
||||
const double scale = std::min(largest.width() / document.width(), largest.height() / document.height());
|
||||
return QSizeF(std::round(document.width() * scale), std::round(document.height() * scale));
|
||||
}
|
||||
|
||||
NavigatorMinimap::RenderKey::RenderKey(const std::optional<CanvasDocument> &document, int revision)
|
||||
: documentID(document ? document->id : QUuid()), size(document ? document->size() : QSizeF(0, 0)),
|
||||
layers(document ? document->layers : std::vector<ImageLayer>()), revision(revision)
|
||||
{
|
||||
}
|
||||
|
||||
NavigatorMinimap::NavigatorMinimap(EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session), m_wait(new QTimer(this))
|
||||
{
|
||||
setObjectName(QStringLiteral("navigatorMinimap"));
|
||||
setAccessibleName(QStringLiteral("Navigator"));
|
||||
setAccessibleDescription(QStringLiteral("Click or drag to move the view"));
|
||||
setCursor(Qt::ArrowCursor);
|
||||
m_wait->setObjectName(QStringLiteral("navigatorWait"));
|
||||
m_wait->setSingleShot(true);
|
||||
m_wait->setInterval(250);
|
||||
m_wait->setTimerType(Qt::PreciseTimer);
|
||||
connect(m_wait, &QTimer::timeout, this, &NavigatorMinimap::render);
|
||||
connect(&session, &EditorSession::changed, this, &NavigatorMinimap::synchronize);
|
||||
synchronize();
|
||||
}
|
||||
|
||||
// Swift's ContentView condition, then its task(id:).
|
||||
void NavigatorMinimap::synchronize()
|
||||
{
|
||||
const std::optional<CanvasDocument> &document = m_session.document();
|
||||
const bool shown = visibleSetting() && !m_session.canvasOnly() && document && m_session.viewport.zoom() >= zoomShown;
|
||||
setVisible(shown);
|
||||
if (!shown) {
|
||||
// SwiftUI drops the view and its picture with it.
|
||||
m_picture = QImage();
|
||||
m_key.reset();
|
||||
m_wait->stop();
|
||||
return;
|
||||
}
|
||||
setFixedSize(sizeFor(document->size()).toSize());
|
||||
RenderKey key(document, m_session.brushRevision());
|
||||
if (key != m_key) {
|
||||
m_key = std::move(key);
|
||||
if (m_picture.isNull())
|
||||
render();
|
||||
else
|
||||
m_wait->start();
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
void NavigatorMinimap::render()
|
||||
{
|
||||
m_picture = m_session.navigatorImage(picturePixels).value_or(QImage());
|
||||
update();
|
||||
}
|
||||
|
||||
void NavigatorMinimap::centre(QPointF point)
|
||||
{
|
||||
const QSizeF document = m_session.document().value().size();
|
||||
const NavigatorGeometry geometry{document, sizeFor(document)};
|
||||
m_session.viewport.centerView(geometry.documentPoint(point), document);
|
||||
m_session.notify();
|
||||
}
|
||||
|
||||
void NavigatorMinimap::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
centre(event->position());
|
||||
}
|
||||
|
||||
void NavigatorMinimap::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->buttons() & Qt::LeftButton)
|
||||
centre(event->position());
|
||||
}
|
||||
|
||||
void NavigatorMinimap::paintEvent(QPaintEvent *)
|
||||
{
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
const QRectF bounds(rect());
|
||||
QPainterPath shape;
|
||||
shape.addRoundedRect(bounds, 4, 4);
|
||||
painter.save();
|
||||
painter.setClipPath(shape);
|
||||
painter.fillRect(bounds, QColor::fromRgbF(0.11f, 0.11f, 0.11f));
|
||||
if (!m_picture.isNull()) {
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform);
|
||||
painter.drawImage(bounds, m_picture);
|
||||
}
|
||||
// The view's box in the accent, inside its rect.
|
||||
const QSizeF document = m_session.document().value().size();
|
||||
const NavigatorGeometry geometry{document, bounds.size()};
|
||||
const QRectF shown = geometry.thumbnailRect(m_session.viewport.visibleDocumentRect(document)).intersected(geometry.imageRect());
|
||||
if (!shown.isEmpty()) {
|
||||
const QRectF box(shown.topLeft(), QSizeF(std::max(3.0, shown.width()), std::max(3.0, shown.height())));
|
||||
painter.setPen(QPen(palette().color(QPalette::Active, QPalette::Highlight), 2.5));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawRect(box.adjusted(1.25, 1.25, -1.25, -1.25));
|
||||
}
|
||||
painter.restore();
|
||||
// A white rim at 25%, inside the rounded edge.
|
||||
painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.25f), 1));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawRoundedRect(bounds.adjusted(0.5, 0.5, -0.5, -0.5), 3.5, 3.5);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include "Document/EditorSession.h"
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
// Swift's NavigatorMinimap: the document in small, from 300%.
|
||||
class NavigatorMinimap : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
static constexpr double zoomShown = 3;
|
||||
// The most room the document takes, in points.
|
||||
static constexpr QSizeF largest{126, 90};
|
||||
// Pixels on the picture's longer side.
|
||||
static constexpr int picturePixels = 300;
|
||||
// Swift's AppStorage key; off until View › Navigator.
|
||||
static constexpr const char *visibleKey = "navigator.visible";
|
||||
static bool visibleSetting();
|
||||
static void setVisibleSetting(bool visible);
|
||||
// Fitted inside `largest`, its proportions kept, rounded.
|
||||
static QSizeF sizeFor(QSizeF document);
|
||||
|
||||
// What the picture comes from: layers, not the selection.
|
||||
struct RenderKey {
|
||||
QUuid documentID;
|
||||
QSizeF size;
|
||||
std::vector<ImageLayer> layers;
|
||||
int revision = 0;
|
||||
RenderKey(const std::optional<CanvasDocument> &document, int revision);
|
||||
friend bool operator==(const RenderKey &, const RenderKey &) = default;
|
||||
};
|
||||
|
||||
explicit NavigatorMinimap(EditorSession &session, QWidget *parent = nullptr);
|
||||
void synchronize();
|
||||
const QImage &picture() const { return m_picture; }
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
|
||||
private:
|
||||
void render();
|
||||
void centre(QPointF point);
|
||||
|
||||
EditorSession &m_session;
|
||||
QImage m_picture;
|
||||
std::optional<RenderKey> m_key;
|
||||
// Edits settle first: the picture redraws once, afterwards.
|
||||
QTimer *const m_wait;
|
||||
};
|
||||
@@ -51,7 +51,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"}));
|
||||
QCOMPARE(menus[0]->actions().size(), 13);
|
||||
QCOMPARE(menus[1]->actions().size(), 13);
|
||||
QCOMPARE(menus[2]->actions().size(), 19);
|
||||
QCOMPARE(menus[2]->actions().size(), 20);
|
||||
QCOMPARE(menus[3]->actions().size(), 11);
|
||||
// Swift 1.3.4's Color Range… follows Subject.
|
||||
const QList<QAction *> select = menus[3]->actions();
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Rendering/NavigatorGeometry.h"
|
||||
#include "SessionFixtures.h"
|
||||
#include "UI/NavigatorMinimap.h"
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.4.9's Navigator: the geometry, the picture, the minimap.
|
||||
class NavigatorTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase();
|
||||
void documentFitsTheBoxCentered();
|
||||
void visibleRectFollowsZoomAndPan();
|
||||
void centeringKeepsZoomAndStopsFollowingFit();
|
||||
void pictureFitsTheLongerSideAndShowsTheComposite();
|
||||
void smallDocumentsAreNotEnlarged();
|
||||
void minimapFitsTheDocument();
|
||||
void hugeDocumentsWithAdjustmentsStillShow();
|
||||
void blursShrinkWithThePicture();
|
||||
void selectionChangesDoNotRedrawThePicture();
|
||||
void placedMasksMotionAndEachAxisScale();
|
||||
void editsInProgressStayOut();
|
||||
void theMinimapShowsFrom300PercentWhenTurnedOn();
|
||||
void thePictureRedrawsOnceEditsSettle();
|
||||
void aClickOrDragMovesTheView();
|
||||
void theViewsBoxIsDrawnInTheAccent();
|
||||
};
|
||||
|
||||
namespace {
|
||||
ImportedImage red(int width, int height)
|
||||
{
|
||||
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
|
||||
image.fill(Qt::red);
|
||||
return ImportedImage(image, image, QStringLiteral("Red"));
|
||||
}
|
||||
|
||||
// A 400 by 300 document, viewed 800 by 600.
|
||||
CanvasViewport viewport()
|
||||
{
|
||||
CanvasViewport viewport;
|
||||
viewport.resize(QSizeF(800, 600), 1, QSizeF(400, 300));
|
||||
return viewport;
|
||||
}
|
||||
|
||||
QColor at(const QImage &image, int x, int y)
|
||||
{
|
||||
return image.pixelColor(x, y);
|
||||
}
|
||||
|
||||
// A red layer of `size` at the origin.
|
||||
QUuid placeRed(EditorSession &session, QSize pixels, QSizeF size)
|
||||
{
|
||||
session.insert(red(pixels.width(), pixels.height()));
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
||||
record(snapshot, id).transform.origin = QPointF(0, 0);
|
||||
record(snapshot, id).transform.size = size;
|
||||
});
|
||||
session.selectLayer(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
struct Shown {
|
||||
EditorSession session;
|
||||
NavigatorMinimap minimap{session};
|
||||
Shown()
|
||||
{
|
||||
NavigatorMinimap::setVisibleSetting(true);
|
||||
session.createDocument(400, 300);
|
||||
placeRed(session, QSize(400, 300), QSizeF(400, 300));
|
||||
session.viewport.resize(QSizeF(800, 600), 1, QSizeF(400, 300));
|
||||
session.zoom(4, session.viewport.center());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void NavigatorTests::initTestCase()
|
||||
{
|
||||
QStandardPaths::setTestModeEnabled(true);
|
||||
QSettings().clear();
|
||||
}
|
||||
|
||||
void NavigatorTests::documentFitsTheBoxCentered()
|
||||
{
|
||||
const NavigatorGeometry geometry{QSizeF(400, 200), QSizeF(200, 200)};
|
||||
QCOMPARE(geometry.imageRect(), QRectF(0, 50, 200, 100));
|
||||
QCOMPARE(geometry.thumbnailRect(QRectF(100, 50, 200, 100)), QRectF(50, 75, 100, 50));
|
||||
QCOMPARE(geometry.documentPoint(QPointF(100, 100)), QPointF(200, 100));
|
||||
// Points outside the picture land on the document's edge.
|
||||
QCOMPARE(geometry.documentPoint(QPointF(-20, 199)), QPointF(0, 200));
|
||||
QCOMPARE((NavigatorGeometry{QSizeF(0, 0), QSizeF(10, 10)}.imageRect()), QRectF());
|
||||
QCOMPARE((NavigatorGeometry{QSizeF(0, 0), QSizeF(10, 10)}.documentPoint(QPointF(5, 5))), QPointF());
|
||||
QCOMPARE((NavigatorGeometry{QSizeF(10, 10), QSizeF(0, 10)}.imageRect()), QRectF());
|
||||
QCOMPARE((NavigatorGeometry{QSizeF(10, 0), QSizeF(10, 10)}.imageRect()), QRectF());
|
||||
QCOMPARE((NavigatorGeometry{QSizeF(0, 10), QSizeF(10, 10)}.thumbnailRect(QRectF(1, 1, 2, 2))), QRectF());
|
||||
QCOMPARE((NavigatorGeometry{QSizeF(0, 10), QSizeF(10, 10)}.documentPoint(QPointF(5, 5))), QPointF());
|
||||
}
|
||||
|
||||
void NavigatorTests::visibleRectFollowsZoomAndPan()
|
||||
{
|
||||
const QSizeF size(400, 300);
|
||||
CanvasViewport view = viewport();
|
||||
view.setZoom(4, view.center(), size);
|
||||
const QRectF visible = view.visibleDocumentRect(size);
|
||||
QVERIFY(std::abs(visible.width() - 200) < 0.001 && std::abs(visible.height() - 150) < 0.001);
|
||||
QVERIFY(std::abs(visible.center().x() - 200) < 0.001 && std::abs(visible.center().y() - 150) < 0.001);
|
||||
// Panned by 40 points: 10 pixels across at 4×.
|
||||
view.translate(QSizeF(40, -80));
|
||||
QVERIFY(std::abs(view.visibleDocumentRect(size).center().x() - 190) < 0.001 && std::abs(view.visibleDocumentRect(size).center().y() - 170) < 0.001);
|
||||
}
|
||||
|
||||
void NavigatorTests::centeringKeepsZoomAndStopsFollowingFit()
|
||||
{
|
||||
const QSizeF size(400, 300);
|
||||
CanvasViewport view = viewport();
|
||||
const double zoom = view.zoom();
|
||||
view.centerView(QPointF(50, 60), size);
|
||||
const QRectF visible = view.visibleDocumentRect(size);
|
||||
QVERIFY(std::abs(visible.center().x() - 50) < 0.001 && std::abs(visible.center().y() - 60) < 0.001);
|
||||
QCOMPARE(view.zoom(), zoom);
|
||||
QVERIFY(!view.followsFit());
|
||||
// A later window resize keeps the view where it was.
|
||||
view.resize(QSizeF(1000, 700), 1, size);
|
||||
QCOMPARE(view.zoom(), zoom);
|
||||
}
|
||||
|
||||
void NavigatorTests::pictureFitsTheLongerSideAndShowsTheComposite()
|
||||
{
|
||||
EditorSession session;
|
||||
QVERIFY(!session.navigatorImage(100));
|
||||
session.createDocument(400, 200);
|
||||
QVERIFY(!session.navigatorImage(0));
|
||||
placeRed(session, QSize(200, 200), QSizeF(200, 200));
|
||||
const QImage picture = session.navigatorImage(100).value();
|
||||
QCOMPARE(picture.size(), QSize(100, 50));
|
||||
QCOMPARE(at(picture, 25, 25), QColor(255, 0, 0));
|
||||
QCOMPARE(at(picture, 75, 25).alpha(), 0);
|
||||
// Adjustment layers apply, as on the canvas.
|
||||
session.addAdjustment(AdjustmentKind::invert);
|
||||
QCOMPARE(at(session.navigatorImage(100).value(), 25, 25), QColor(0, 255, 255));
|
||||
}
|
||||
|
||||
void NavigatorTests::smallDocumentsAreNotEnlarged()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 30);
|
||||
QCOMPARE(session.navigatorImage(900).value().size(), QSize(40, 30));
|
||||
// Each side rounded, a pixel at least.
|
||||
session.createDocument(1000, 3);
|
||||
QCOMPARE(session.navigatorImage(100).value().size(), QSize(100, 1));
|
||||
session.createDocument(1000, 15);
|
||||
QCOMPARE(session.navigatorImage(100).value().size(), QSize(100, 2));
|
||||
}
|
||||
|
||||
void NavigatorTests::minimapFitsTheDocument()
|
||||
{
|
||||
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(4000, 3000)), QSizeF(120, 90));
|
||||
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(6000, 2000)), QSizeF(126, 42));
|
||||
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(1000, 999)), QSizeF(90, 90));
|
||||
// 125.7 rounds up.
|
||||
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(1000, 716)), QSizeF(126, 90));
|
||||
QCOMPARE(NavigatorMinimap::sizeFor(QSizeF(0, 0)), QSizeF(0, 0));
|
||||
}
|
||||
|
||||
// Past one surface, drawn at the picture's size, it shows.
|
||||
void NavigatorTests::hugeDocumentsWithAdjustmentsStillShow()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(20'000, 12'000);
|
||||
placeRed(session, QSize(200, 120), QSizeF(20'000, 12'000));
|
||||
session.addAdjustment(AdjustmentKind::invert);
|
||||
const QImage picture = session.navigatorImage(100).value();
|
||||
QCOMPARE(picture.size(), QSize(100, 60));
|
||||
QCOMPARE(at(picture, 50, 30), QColor(0, 255, 255));
|
||||
}
|
||||
|
||||
void NavigatorTests::blursShrinkWithThePicture()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(2000, 2000);
|
||||
placeRed(session, QSize(100, 200), QSizeF(1000, 2000));
|
||||
session.addAdjustment(AdjustmentKind::gaussianBlur);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid blur = session.activeLayerID().value();
|
||||
LayerAdjustment adjustment = layerWith(session, blur).adjustment.value();
|
||||
adjustment.blurRadius = 100;
|
||||
session.updateAdjustment(blur, adjustment);
|
||||
const QImage picture = session.navigatorImage(100).value();
|
||||
// Five radii from every edge at the picture's scale: solid.
|
||||
QVERIFY2(at(picture, 25, 50).alpha() >= 250, qPrintable(QString::number(at(picture, 25, 50).alpha())));
|
||||
// Unshrunk, a radius of 100 would reach the edge here.
|
||||
QVERIFY(at(picture, 49, 50).alpha() > 0 && at(picture, 49, 50).alpha() < 250);
|
||||
}
|
||||
|
||||
void NavigatorTests::selectionChangesDoNotRedrawThePicture()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(100, 100);
|
||||
placeRed(session, QSize(10, 10), QSizeF(10, 10));
|
||||
const NavigatorMinimap::RenderKey key(session.document(), 0);
|
||||
session.selectAll();
|
||||
QVERIFY(NavigatorMinimap::RenderKey(session.document(), 0) == key);
|
||||
QVERIFY(!(NavigatorMinimap::RenderKey(session.document(), 1) == key));
|
||||
std::optional<CanvasDocument> hidden = session.document();
|
||||
hidden->layers.back().isVisible = false;
|
||||
QVERIFY(!(NavigatorMinimap::RenderKey(hidden, 0) == key));
|
||||
std::optional<CanvasDocument> other = session.document();
|
||||
other->id = QUuid::createUuid();
|
||||
QVERIFY(!(NavigatorMinimap::RenderKey(other, 0) == key));
|
||||
}
|
||||
|
||||
void NavigatorTests::placedMasksMotionAndEachAxisScale()
|
||||
{
|
||||
// A placed mask shrinks with its layer: white, then black.
|
||||
EditorSession session;
|
||||
session.createDocument(400, 200);
|
||||
const QUuid id = placeRed(session, QSize(300, 200), QSizeF(300, 200));
|
||||
QImage halves(4, 1, QImage::Format_Grayscale8);
|
||||
const uchar values[] = {255, 255, 0, 0};
|
||||
std::copy_n(values, 4, halves.scanLine(0));
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
||||
setMask(snapshot, id, LayerMask::assetFrom(halves));
|
||||
record(snapshot, id).maskPlacement = LayerTransform{.origin = QPointF(0, 0), .size = QSizeF(200, 200)};
|
||||
});
|
||||
const QImage masked = session.navigatorImage(100).value();
|
||||
// At a quarter: white to 25, black to 50.
|
||||
QVERIFY(at(masked, 15, 25).alpha() == 255 && at(masked, 40, 25).alpha() == 0);
|
||||
// Each side alone: 1000 by 15 is 100 by 2.
|
||||
session.createDocument(1000, 15);
|
||||
placeRed(session, QSize(1000, 15), QSizeF(1000, 15));
|
||||
// The halving softens the last row; half would show half.
|
||||
const int bottom = at(session.navigatorImage(100).value(), 50, 1).alpha();
|
||||
QVERIFY2(bottom > 200, qPrintable(QString::number(bottom)));
|
||||
// A streak measured in document pixels shrinks too.
|
||||
session.createDocument(2000, 2000);
|
||||
placeRed(session, QSize(100, 200), QSizeF(1000, 2000));
|
||||
session.addAdjustment(AdjustmentKind::motionBlur);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid streak = session.activeLayerID().value();
|
||||
LayerAdjustment adjustment = layerWith(session, streak).adjustment.value();
|
||||
adjustment.motionAngle = 0;
|
||||
adjustment.motionDistance = 100;
|
||||
session.updateAdjustment(streak, adjustment);
|
||||
const QImage picture = session.navigatorImage(100).value();
|
||||
QVERIFY2(at(picture, 25, 50).alpha() >= 250, qPrintable(QString::number(at(picture, 25, 50).alpha())));
|
||||
}
|
||||
|
||||
// Swift draws a bare session: a pending transform stays out.
|
||||
void NavigatorTests::editsInProgressStayOut()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(400, 200);
|
||||
placeRed(session, QSize(200, 200), QSizeF(200, 200));
|
||||
session.selectTool(NavigationTool::move);
|
||||
session.beginTransform();
|
||||
LayerTransform moved = session.transformEdit().value().draft;
|
||||
moved.origin = QPointF(200, 0);
|
||||
session.previewTransform(moved);
|
||||
const QImage picture = session.navigatorImage(100).value();
|
||||
QVERIFY(at(picture, 25, 25).alpha() == 255 && at(picture, 75, 25).alpha() == 0);
|
||||
}
|
||||
|
||||
void NavigatorTests::theMinimapShowsFrom300PercentWhenTurnedOn()
|
||||
{
|
||||
NavigatorMinimap::setVisibleSetting(false);
|
||||
QCOMPARE(QSettings().value(QLatin1String(NavigatorMinimap::visibleKey)).toBool(), false);
|
||||
Shown shown;
|
||||
QVERIFY(!shown.minimap.isHidden() && shown.minimap.size() == QSize(120, 90));
|
||||
QCOMPARE(shown.minimap.picture().size(), QSize(300, 225));
|
||||
QCOMPARE(shown.minimap.accessibleName(), QString("Navigator"));
|
||||
QCOMPARE(shown.minimap.accessibleDescription(), QString("Click or drag to move the view"));
|
||||
shown.session.zoom(2.9, shown.session.viewport.center());
|
||||
QVERIFY(shown.minimap.isHidden() && shown.minimap.picture().isNull());
|
||||
shown.session.zoom(3, shown.session.viewport.center());
|
||||
QVERIFY(!shown.minimap.isHidden() && !shown.minimap.picture().isNull());
|
||||
shown.session.setCanvasOnly(true);
|
||||
QVERIFY(shown.minimap.isHidden());
|
||||
shown.session.setCanvasOnly(false);
|
||||
NavigatorMinimap::setVisibleSetting(false);
|
||||
shown.session.notify();
|
||||
QVERIFY(shown.minimap.isHidden());
|
||||
NavigatorMinimap::setVisibleSetting(true);
|
||||
shown.session.notify();
|
||||
QVERIFY(!shown.minimap.isHidden());
|
||||
shown.session.clearProject();
|
||||
QVERIFY(shown.minimap.isHidden());
|
||||
}
|
||||
|
||||
void NavigatorTests::thePictureRedrawsOnceEditsSettle()
|
||||
{
|
||||
Shown shown;
|
||||
QTimer &wait = *shown.minimap.findChild<QTimer *>(QStringLiteral("navigatorWait"));
|
||||
QVERIFY(wait.interval() == 250 && wait.isSingleShot() && wait.timerType() == Qt::PreciseTimer && !wait.isActive());
|
||||
const QImage first = shown.minimap.picture();
|
||||
QCOMPARE(at(first, 150, 100), QColor(255, 0, 0));
|
||||
// A selection redraws nothing.
|
||||
shown.session.selectAll();
|
||||
QVERIFY(!wait.isActive());
|
||||
// An edit waits a moment, then redraws once.
|
||||
shown.session.addAdjustment(AdjustmentKind::invert);
|
||||
QVERIFY(wait.isActive());
|
||||
QCOMPARE(shown.minimap.picture().cacheKey(), first.cacheKey());
|
||||
QTRY_COMPARE(at(shown.minimap.picture(), 150, 100), QColor(0, 255, 255));
|
||||
QVERIFY(!wait.isActive());
|
||||
// Hidden mid-wait, the wait goes with the picture.
|
||||
shown.session.setLayerOpacity(0.5);
|
||||
QVERIFY(wait.isActive());
|
||||
shown.session.zoom(2, shown.session.viewport.center());
|
||||
QVERIFY(!wait.isActive() && shown.minimap.picture().isNull());
|
||||
}
|
||||
|
||||
void NavigatorTests::aClickOrDragMovesTheView()
|
||||
{
|
||||
Shown shown;
|
||||
const QSizeF size(400, 300);
|
||||
QSignalSpy changes(&shown.session, &EditorSession::changed);
|
||||
QTest::mousePress(&shown.minimap, Qt::LeftButton, Qt::NoModifier, QPoint(30, 30));
|
||||
QCOMPARE(changes.count(), 1);
|
||||
QRectF visible = shown.session.viewport.visibleDocumentRect(size);
|
||||
QVERIFY(std::abs(visible.center().x() - 100) < 0.001 && std::abs(visible.center().y() - 100) < 0.001);
|
||||
QCOMPARE(shown.session.viewport.zoom(), 4.0);
|
||||
// A drag past the picture holds to the document's edge.
|
||||
QTest::mouseMove(&shown.minimap, QPoint(119, 89));
|
||||
QMouseEvent move(QEvent::MouseMove, QPointF(200, 200), QPointF(200, 200), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&shown.minimap, &move);
|
||||
visible = shown.session.viewport.visibleDocumentRect(size);
|
||||
QVERIFY(std::abs(visible.center().x() - 400) < 0.001 && std::abs(visible.center().y() - 300) < 0.001);
|
||||
// Without the left button nothing moves.
|
||||
QMouseEvent hover(QEvent::MouseMove, QPointF(60, 45), QPointF(60, 45), Qt::NoButton, Qt::RightButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&shown.minimap, &hover);
|
||||
QTest::mousePress(&shown.minimap, Qt::RightButton, Qt::NoModifier, QPoint(60, 45));
|
||||
QVERIFY(std::abs(shown.session.viewport.visibleDocumentRect(size).center().x() - 400) < 0.001);
|
||||
// Over the canvas's cross, the arrow.
|
||||
QWidget canvas;
|
||||
canvas.setCursor(Qt::CrossCursor);
|
||||
NavigatorMinimap inside(shown.session, &canvas);
|
||||
QCOMPARE(inside.cursor().shape(), Qt::ArrowCursor);
|
||||
}
|
||||
|
||||
void NavigatorTests::theViewsBoxIsDrawnInTheAccent()
|
||||
{
|
||||
Shown shown;
|
||||
QPalette palette = shown.minimap.palette();
|
||||
palette.setColor(QPalette::Active, QPalette::Highlight, QColor(0, 200, 0));
|
||||
shown.minimap.setPalette(palette);
|
||||
const QImage drawn = shown.minimap.grab().toImage();
|
||||
// The view's middle 200 × 150 pixels: 30 to 90.
|
||||
QCOMPARE(at(drawn, 30, 45), QColor(0, 200, 0));
|
||||
QCOMPARE(at(drawn, 31, 45), QColor(0, 200, 0));
|
||||
QCOMPARE(at(drawn, 89, 45), QColor(0, 200, 0));
|
||||
QCOMPARE(at(drawn, 60, 23), QColor(0, 200, 0));
|
||||
QCOMPARE(at(drawn, 60, 45), QColor(255, 0, 0));
|
||||
QCOMPARE(at(drawn, 28, 45), QColor(255, 0, 0));
|
||||
// The rim: white at 25% over red at the edge.
|
||||
QCOMPARE(at(drawn, 0, 45), QColor(255, 64, 64));
|
||||
// Clear pixels show the minimap's own gray 0.11.
|
||||
rewrite(shown.session, [](ProjectSnapshot &snapshot) { snapshot.manifest.layers.back().transform.size = QSizeF(200, 300); });
|
||||
shown.session.zoom(4, shown.session.viewport.center());
|
||||
QTRY_COMPARE(at(shown.minimap.grab().toImage(), 110, 80), QColor(28, 28, 28));
|
||||
// A view smaller than three points still shows three.
|
||||
shown.session.viewport.resize(QSizeF(40, 30), 1, QSizeF(400, 300));
|
||||
shown.session.zoom(32, shown.session.viewport.center());
|
||||
const QRectF view = shown.session.viewport.visibleDocumentRect(QSizeF(400, 300));
|
||||
QVERIFY(view.width() * 0.3 < 1);
|
||||
const int left = int(std::floor(view.left() * 0.3)), row = int(view.center().y() * 0.3);
|
||||
const QImage tiny = shown.minimap.grab().toImage();
|
||||
QVERIFY(at(tiny, left + 1, row).green() > 100 && at(tiny, left + 1, row).red() < 60);
|
||||
QCOMPARE(at(tiny, left - 2, row), QColor(255, 0, 0));
|
||||
}
|
||||
|
||||
QTEST_MAIN(NavigatorTests)
|
||||
#include "NavigatorTests.moc"
|
||||
@@ -186,6 +186,7 @@ compositor_test(ScanlinesTests)
|
||||
# It checks the kernel against Swift's own bytes.
|
||||
target_include_directories(ScanlinesTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(ScanlinesSheetTests)
|
||||
compositor_test(NavigatorTests)
|
||||
compositor_test(CameraRawCurveTests)
|
||||
compositor_test(CameraRawCurveGraphTests)
|
||||
compositor_test(OpenRecentTests)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "MenuFixtures.h"
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "UI/GridSettingsSheet.h"
|
||||
#include "UI/NavigatorMinimap.h"
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
#include <QLineEdit>
|
||||
|
||||
// The View menu: zoom, the pixel grid and Snap.
|
||||
@@ -11,6 +14,7 @@ private slots:
|
||||
void thePixelGridEntryTogglesTheSession();
|
||||
void guideEntriesFollowTheSession();
|
||||
void gridSettingsOpensItsSheet();
|
||||
void theNavigatorEntryShowsTheMinimap();
|
||||
};
|
||||
|
||||
void ViewMenuTests::viewEntriesZoomTheFrontSession()
|
||||
@@ -141,5 +145,36 @@ void ViewMenuTests::gridSettingsOpensItsSheet()
|
||||
QVERIFY(!bar.session().showsGrid());
|
||||
}
|
||||
|
||||
// Swift 1.4.9: off until turned on, then shown from 300%.
|
||||
void ViewMenuTests::theNavigatorEntryShowsTheMinimap()
|
||||
{
|
||||
QStandardPaths::setTestModeEnabled(true);
|
||||
QSettings().remove(QLatin1String(NavigatorMinimap::visibleKey));
|
||||
Bar bar;
|
||||
bar.window.resize(1180, 780);
|
||||
bar.window.show();
|
||||
QAction &entry = bar.action("navigator");
|
||||
QVERIFY(entry.isCheckable() && !entry.isChecked() && entry.isEnabled());
|
||||
QCOMPARE(entry.text(), QString("Navigator (300% and above)"));
|
||||
// Before Pixel Grid, as Swift's.
|
||||
const QList<QAction *> view = entry.associatedObjects().isEmpty() ? QList<QAction *>() : qobject_cast<QMenu *>(entry.associatedObjects().first())->actions();
|
||||
QCOMPARE(view.indexOf(&entry) + 1, view.indexOf(&bar.action("pixelGrid")));
|
||||
bar.session().createDocument(400, 300);
|
||||
QTRY_VERIFY(bar.window.findChild<NavigatorMinimap *>());
|
||||
auto *minimap = bar.window.findChild<NavigatorMinimap *>();
|
||||
bar.session().zoom(4);
|
||||
QVERIFY(minimap->isHidden());
|
||||
entry.trigger();
|
||||
QVERIFY(entry.isChecked() && NavigatorMinimap::visibleSetting() && !minimap->isHidden());
|
||||
// At the canvas's top right, 12 points in.
|
||||
QTRY_COMPARE(minimap->geometry(), QRect(minimap->parentWidget()->width() - 12 - 120, 12, 120, 90));
|
||||
bar.session().zoom(2);
|
||||
QVERIFY(minimap->isHidden());
|
||||
bar.session().zoom(4);
|
||||
entry.trigger();
|
||||
QVERIFY(!entry.isChecked() && !NavigatorMinimap::visibleSetting() && minimap->isHidden());
|
||||
QSettings().remove(QLatin1String(NavigatorMinimap::visibleKey));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ViewMenuTests)
|
||||
#include "ViewMenuTests.moc"
|
||||
Reference in new issue
Block a user