diff --git a/AGENTS.md b/AGENTS.md index 6cc85f7..8b8935a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,7 @@ Platform adaptations, each forced by a missing Qt analog: - 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, or a painted layer that cannot flatten while it draws, logs on `omaphoto.rendering` and gives no picture, where Swift's `try?` is silent (`NavigatorFailureTests`, a binary of its own: a 4000 by 3000 painted layer under 8 MB of room, the minimap shown without a picture). 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 layer and its mask off the origin, a streak shrunk at its edge, both blurs by the width's factor where the two factors differ, centring at a backing scale of two, the wait restarted by a later edit, 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. - Effects, text and shapes kept (14.22, Swift 1.4.9: `94e90bd`): `ImageLayer(record, asset, mask)` loads its live shape and text (`LayerShape::loaded`, `LayerText::loaded`), Swift's `ProjectSnapshot.documentLayers`, so `installProject` and `applyDocumentSize` (Canvas Size, Image Size, Crop, Trim) build the same layers; the canvas resizer carries effects, and Image Size scales them by the root of its two factors through `LayerEffects::scaled` (stroke, glows and blurs to 500, distances to 5,000, Photoshop's Scale Styles) while its resampling still flattens text and shapes, and a size that changes only the resolution keeps all three. Hue/Saturation, Levels, Invert and a floating merge keep the layer's effects. `docs/project-format.md` says so. Tests: `LayerEffectsSurviveTests` (Swift's five sizing cases; a stretch's mean factor and the ranges), `LayerEffectsTests::rebuildsAndResizesKeepThem` (the four rebuilds, both resizers), the shape and text halves in `ShapeLayerTests` and `TypeStoreTests`. - WebP (14.23, Swift 1.4.9: `5724099`): `ImageImporter` takes Qt's `webp` format beside JPEG, PNG and TIFF (`qt6-image-formats-plugins` carries it, as it carries TIFF; Fedora's `qt6-qtimageformats`, Arch's `qt6-imageformats` and nixpkgs' `qtimageformats` too), through the same reading, sRGB conversion and thumbnail; a dropped picture prefers WebP after TIFF and before Photoshop's, Swift's order; the unsupported and drop messages name WebP; the importer's filter offers `*.webp` and the desktop entry `image/webp`. Tests: `WebPImportTests` (Swift's lossless and lossy pictures: size, sRGB, the clear half, the budget, a drop opening a document; the drop order), the words in their files. +- Ctrl or Meta with a middle drag zooms (14.24, Swift 1.4.9: `e03923f`, Blender's gesture): a middle press with Ctrl or Meta (Swift's Command or Control) keeps `m_middleZoom` (where it began, the zoom then) in place of the middle pan; each move with the middle button zooms by 2^((start.y − y) / 100) about the press, as the Zoom tool's drag doubles every 100 points, up in and down out, under the Zoom tool's magnifier, the minus while below the press; the brush circle hides meanwhile; the release, or a move without the middle button (Qt can lose a release), ends it and puts the tool's cursor back, as the pan's does. A left drag beside it goes on, as beside the pan. Equivalent mutant: the zoom's return without the left button (the rest of a move with no left button held begins nothing). Tests: `CanvasNavigationTests::ctrlOrMetaWithTheMiddleButtonZooms` (doubling and halving about an off-centre press, both cursors against the Zoom tool's, Meta, a lost release, a plain middle drag still panning). - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/src/Rendering/EditorCanvas+Brush.cpp b/src/Rendering/EditorCanvas+Brush.cpp index dca17a4..acc0298 100644 --- a/src/Rendering/EditorCanvas+Brush.cpp +++ b/src/Rendering/EditorCanvas+Brush.cpp @@ -114,7 +114,7 @@ void CanvasView::endBrushTipDrag(QPointF point) // The circle at the pointer, a stroke's own size. void CanvasView::updateBrushCursor() { - const bool shows = isBrushTool(m_session.tool()) && !m_spaceHeld && !picking() && !m_middlePanPoint; + const bool shows = isBrushTool(m_session.tool()) && !m_spaceHeld && !picking() && !m_middlePanPoint && !m_middleZoom; const double diameter = m_session.brushStroke() ? m_session.brushStroke()->settings.diameter : m_session.brushSettings().diameter; // Clone Stamp marks its source; between strokes, previews a click. std::optional<QPointF> sample; diff --git a/src/Rendering/EditorCanvas+Cursors.cpp b/src/Rendering/EditorCanvas+Cursors.cpp index 0d41f5e..6dd3b27 100644 --- a/src/Rendering/EditorCanvas+Cursors.cpp +++ b/src/Rendering/EditorCanvas+Cursors.cpp @@ -263,7 +263,9 @@ void CanvasView::updateCursor() { const NavigationTool tool = m_session.tool(); // A middle-button pan outranks every drag; another keeps its cursor. - if (m_middlePanPoint) + if (m_middleZoom) + setCursor(zoomCursor(m_middleZoom->out, devicePixelRatio())); + else if (m_middlePanPoint) setCursor(Qt::ClosedHandCursor); else if (m_dragCursor) setCursor(*m_dragCursor); diff --git a/src/Rendering/EditorCanvas+Navigation.cpp b/src/Rendering/EditorCanvas+Navigation.cpp index 29ccfe6..7e67075 100644 --- a/src/Rendering/EditorCanvas+Navigation.cpp +++ b/src/Rendering/EditorCanvas+Navigation.cpp @@ -120,9 +120,12 @@ void CanvasView::mousePressEvent(QMouseEvent *event) // Swift's mouseDown with its click count. void CanvasView::press(QMouseEvent *event, int clicks) { - // The middle button pans in any tool, beside the left. + // The middle button pans anywhere; Ctrl or Meta zooms. if (event->button() == Qt::MiddleButton && m_session.document()) { - m_middlePanPoint = event->position(); + if (event->modifiers() & (Qt::ControlModifier | Qt::MetaModifier)) + m_middleZoom = MiddleZoom{event->position(), m_session.viewport.zoom()}; + else + m_middlePanPoint = event->position(); updateBrushCursor(); updateCursor(); return; @@ -226,6 +229,17 @@ void CanvasView::mouseMoveEvent(QMouseEvent *event) // Swift's mouseMoved: the readout follows a hover alone. if (!event->buttons() && m_session.document()) m_session.updateCameraRawReadout(m_session.viewport.documentPoint(point, m_session.document()->size())); + if (m_middleZoom && event->buttons().testFlag(Qt::MiddleButton)) { + // Up zooms in, doubling each 100 points, as Zoom drags. + const double factor = std::pow(2, (m_middleZoom->start.y() - point.y()) / 100); + m_middleZoom->out = factor < 1; + m_session.zoom(m_middleZoom->zoom * factor, m_middleZoom->start); + updateCursor(); + if (!event->buttons().testFlag(Qt::LeftButton)) + return; + } else if (m_middleZoom) { + endMiddlePan(); + } if (m_middlePanPoint && event->buttons().testFlag(Qt::MiddleButton)) { m_session.viewport.translate(QSizeF(point.x() - m_middlePanPoint->x(), point.y() - m_middlePanPoint->y())); m_middlePanPoint = point; @@ -297,7 +311,7 @@ void CanvasView::mouseMoveEvent(QMouseEvent *event) void CanvasView::mouseReleaseEvent(QMouseEvent *event) { - if (event->button() == Qt::MiddleButton && m_middlePanPoint) { + if (event->button() == Qt::MiddleButton && (m_middlePanPoint || m_middleZoom)) { endMiddlePan(); return; } @@ -394,6 +408,7 @@ void CanvasView::leaveEvent(QEvent *event) void CanvasView::endMiddlePan() { m_middlePanPoint.reset(); + m_middleZoom.reset(); updateCursor(); updateBrushCursor(); } diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h index af3bcac..3477496 100644 --- a/src/Rendering/EditorCanvas.h +++ b/src/Rendering/EditorCanvas.h @@ -134,6 +134,12 @@ private: double zoom; bool moved; }; + // Swift's middleZoom: its start, the zoom then, the minus. + struct MiddleZoom { + QPointF start; + double zoom; + bool out = false; + }; using Center = std::function<QPointF(QPointF)>; // What a press off the handles drags, picked or not. struct PressTarget { @@ -303,6 +309,8 @@ private: std::optional<QPointF> m_lastDragPoint; // Where a middle-button pan last was, its own drag point. std::optional<QPointF> m_middlePanPoint; + // A middle drag with Ctrl or Meta: Blender's zoom. + std::optional<MiddleZoom> m_middleZoom; std::optional<ZoomDrag> m_zoomDrag; bool m_shiftHeld = false; // Document point where a drag of the outline began. diff --git a/tests/CanvasNavigationTests.cpp b/tests/CanvasNavigationTests.cpp index 52fafdb..e493f23 100644 --- a/tests/CanvasNavigationTests.cpp +++ b/tests/CanvasNavigationTests.cpp @@ -10,6 +10,7 @@ private slots: void theWheelPansAndZoomsWithAModifier(); void aPinchZoomsAboutThePointer(); void theMiddleButtonPansFromAnyTool(); + void ctrlOrMetaWithTheMiddleButtonZooms(); }; void CanvasNavigationTests::theZoomToolClicksAndDrags() @@ -255,5 +256,70 @@ void CanvasNavigationTests::theMiddleButtonPansFromAnyTool() QCOMPARE(empty.canvas->cursor().shape(), before); } +// Swift 1.4.9 (`e03923f`), as Blender: up zooms in, down out. +void CanvasNavigationTests::ctrlOrMetaWithTheMiddleButtonZooms() +{ + Shown shown; + CanvasView &canvas = *shown.canvas; + EditorSession &session = shown.session; + shown.settle(); + session.zoom(1); + // The Zoom tool's own magnifiers, plus and minus. + session.selectTool(NavigationTool::zoom); + canvas.synchronizeDisplay(); + const QImage plus = canvas.cursor().pixmap().toImage(); + QTest::keyPress(&canvas, Qt::Key_Alt, Qt::AltModifier); + const QImage minus = canvas.cursor().pixmap().toImage(); + QTest::keyRelease(&canvas, Qt::Key_Alt); + QVERIFY(!plus.isNull() && plus != minus); + session.selectTool(NavigationTool::brush); + canvas.synchronizeDisplay(); + const auto move = [&](QPointF to, Qt::MouseButtons buttons) { + QMouseEvent event(QEvent::MouseMove, to, to, canvas.mapToGlobal(to.toPoint()), Qt::NoButton, buttons, Qt::NoModifier); + QApplication::sendEvent(&canvas, &event); + }; + move(QPointF(100, 100), Qt::NoButton); + const Qt::CursorShape brush = canvas.cursor().shape(); + const QSizeF size = shown.documentSize(); + // Off the view's middle, so the anchor shows. + const QPointF start(120, 210); + const QPointF anchor = session.viewport.documentPoint(start, size); + QTest::mousePress(&canvas, Qt::MiddleButton, Qt::ControlModifier, start.toPoint()); + QCOMPARE(canvas.cursor().pixmap().toImage(), plus); + QVERIFY(!canvas.brushCursor().circle().has_value()); + // A hundred points up doubles about the press. + move(QPointF(180, 110), Qt::MiddleButton); + QCOMPARE(session.viewport.zoom(), 2.0); + QCOMPARE(session.viewport.documentPoint(start, size), anchor); + // Below the press it zooms out, with the minus. + move(QPointF(120, 260), Qt::MiddleButton); + QVERIFY(qFuzzyCompare(session.viewport.zoom(), std::sqrt(0.5))); + QCOMPARE(session.viewport.documentPoint(start, size), anchor); + QCOMPARE(canvas.cursor().pixmap().toImage(), minus); + move(QPointF(120, 200), Qt::MiddleButton); + QCOMPARE(canvas.cursor().pixmap().toImage(), plus); + QTest::mouseRelease(&canvas, Qt::MiddleButton, Qt::ControlModifier, QPoint(120, 200)); + QCOMPARE(canvas.cursor().shape(), brush); + QVERIFY(canvas.brushCursor().circle().has_value()); + const double kept = session.viewport.zoom(); + move(QPointF(200, 40), Qt::NoButton); + QCOMPARE(session.viewport.zoom(), kept); + // Meta zooms too; a lost release ends it. + session.zoom(1); + QTest::mousePress(&canvas, Qt::MiddleButton, Qt::MetaModifier, start.toPoint()); + move(QPointF(120, 110), Qt::MiddleButton); + QCOMPARE(session.viewport.zoom(), 2.0); + move(QPointF(200, 60), Qt::NoButton); + QCOMPARE(canvas.cursor().shape(), brush); + move(QPointF(200, -50), Qt::MiddleButton); + QCOMPARE(session.viewport.zoom(), 2.0); + // A plain middle drag still pans, at the same zoom. + const QSizeF before = session.viewport.pan; + QTest::mousePress(&canvas, Qt::MiddleButton, Qt::NoModifier, QPoint(100, 100)); + move(QPointF(110, 60), Qt::MiddleButton); + QVERIFY(session.viewport.zoom() == 2.0 && session.viewport.pan == before + QSizeF(10, -40)); + QTest::mouseRelease(&canvas, Qt::MiddleButton, Qt::NoModifier, QPoint(110, 60)); +} + QTEST_MAIN(CanvasNavigationTests) #include "CanvasNavigationTests.moc"