From b50657bcdb876acc88acf4875d5fdf6719a21d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 16:56:16 -0400 Subject: [PATCH] Move bar: held Ctrl and Shift flip Auto Select and the lock, values apply as one step, resize handles snap (13.13) --- CMakeLists.txt | 1 + src/Document/Crop+Session.h | 3 + src/Document/Crop.cpp | 52 ++++++++ src/Document/EditorSession+Transform.cpp | 6 +- src/Document/EditorSession.h | 2 +- src/Document/LayerTransform.h | 2 + src/Rendering/EditorCanvas+Transform.cpp | 48 ++++--- src/UI/HeldModifiers.cpp | 57 ++++++++ src/UI/HeldModifiers.h | 23 ++++ src/UI/TransformInspector.cpp | 59 ++++++--- src/UI/TransformInspector.h | 6 +- tests/ResizeSnapCanvasTests.cpp | 85 ++++++++++++ tests/ResizeSnapTests.cpp | 157 +++++++++++++++++++++++ tests/ScrubbableTransformTests.cpp | 57 ++++---- tests/Tests.cmake | 2 + tests/TransformInspectorTests.cpp | 133 +++++++++++++++++-- tests/TransformPressTests.cpp | 88 ++++++++++++- 17 files changed, 703 insertions(+), 78 deletions(-) create mode 100644 src/UI/HeldModifiers.cpp create mode 100644 src/UI/HeldModifiers.h create mode 100644 tests/ResizeSnapCanvasTests.cpp create mode 100644 tests/ResizeSnapTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8983403..0e17712 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,7 @@ add_library(compositor_core STATIC src/UI/NativeLayerList+Menu.cpp src/UI/NavigationToolHeader.cpp src/UI/NumericScrub.cpp + src/UI/HeldModifiers.cpp src/UI/TransformInspector.cpp src/UI/EffectsSheet.cpp src/UI/CameraRawColorControls.cpp diff --git a/src/Document/Crop+Session.h b/src/Document/Crop+Session.h index 0ab050f..40341b7 100644 --- a/src/Document/Crop+Session.h +++ b/src/Document/Crop+Session.h @@ -2,6 +2,9 @@ public: SnapGuides transformSnapTargets(const QSet &moving) const; LayerTransform snappedMove(const LayerTransform &draft, const QSet &moving, double tolerance); + // A resize handle's pointer, nudged so moved edges snap. + QPointF snappedResizePoint(QPointF point, const TransformDrag &drag, bool proportional, const QSet &moving, + double tolerance, const std::function &update); SnapGuides cropSnapTargets() const; // A drawn corner, each axis to a target within reach. QPointF snappedPoint(QPointF point, double tolerance); diff --git a/src/Document/Crop.cpp b/src/Document/Crop.cpp index dcc3b31..e883e53 100644 --- a/src/Document/Crop.cpp +++ b/src/Document/Crop.cpp @@ -168,6 +168,58 @@ LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSe return snapped; } +// A resize handle's point, nudged onto targets; upright only. +QPointF EditorSession::snappedResizePoint(QPointF point, const TransformDrag &drag, bool proportional, const QSet &moving, + double tolerance, const std::function &update) +{ + if (!m_snappingEnabled || drag.mode.kind != TransformDrag::Kind::resize || drag.original.radians() != 0) { + snapGuides = {}; + return point; + } + const QPointF handle = LayerTransform::handles[size_t(drag.mode.index)]; + const SnapGuides targets = transformSnapTargets(moving); + // The dragged handle's place tells its edge apart. + const QPointF at = drag.original.point(handle) + point - drag.start; + const auto edge = [at](const LayerTransform &transform, bool horizontal) { + const QRectF box(transform.origin, transform.size); + if (horizontal) + return std::abs(box.left() - at.x()) <= std::abs(box.right() - at.x()) ? box.left() : box.right(); + return std::abs(box.top() - at.y()) <= std::abs(box.bottom() - at.y()) ? box.top() : box.bottom(); + }; + struct Snap { + bool horizontal; + double target; + }; + const LayerTransform draft = update(point); + std::vector snaps; + if (handle.x() != 0.5) + if (const std::optional x = nearest(edge(draft, true), targets.xs, tolerance)) + snaps.push_back({true, *x}); + if (handle.y() != 0.5) + if (const std::optional y = nearest(edge(draft, false), targets.ys, tolerance)) + snaps.push_back({false, *y}); + // Proportional, the nearer edge snaps; the ratio moves the other. + if (proportional && snaps.size() == 2) { + const auto distance = [&](const Snap &snap) { return std::abs(snap.target - edge(draft, snap.horizontal)); }; + snaps = {distance(snaps[1]) < distance(snaps[0]) ? snaps[1] : snaps[0]}; + } + // Edges follow the pointer linearly: one pixel measures the step. + QPointF result = point; + for (const Snap &snap : snaps) { + const double before = edge(update(result), snap.horizontal); + const QPointF nudged = result + (snap.horizontal ? QPointF(1, 0) : QPointF(0, 1)); + const double perPixel = edge(update(nudged), snap.horizontal) - before; + if (std::abs(perPixel) <= 0.01) + continue; + const double shift = (snap.target - before) / perPixel; + result += snap.horizontal ? QPointF(shift, 0) : QPointF(0, shift); + } + snapGuides = {}; + for (const Snap &snap : snaps) + (snap.horizontal ? snapGuides.xs : snapGuides.ys).push_back(snap.target); + return result; +} + // View > Snap To's targets, without the centres. SnapGuides EditorSession::cropSnapTargets() const { diff --git a/src/Document/EditorSession+Transform.cpp b/src/Document/EditorSession+Transform.cpp index 5f55454..d0ff1be 100644 --- a/src/Document/EditorSession+Transform.cpp +++ b/src/Document/EditorSession+Transform.cpp @@ -59,7 +59,7 @@ std::optional EditorSession::groupTransformBox() const return LayerTransform{.origin = low, .size = {std::max(1.0, high.x() - low.x()), std::max(1.0, high.y() - low.y())}}; } -void EditorSession::beginTransform(bool persistent) +void EditorSession::beginTransform(bool persistent, bool fromFields) { cancelCrop(); const std::optional layer = activeLayer(); @@ -73,11 +73,11 @@ void EditorSession::beginTransform(bool persistent) QHash originals; for (const ImageLayer &member : groupTransformMembers()) originals.insert(member.id, member.transform); - m_transformEdit = TransformEdit{layer->id, *box, TransformGroup{*box, originals}, false, persistent, std::nullopt, nullptr}; + m_transformEdit = TransformEdit{layer->id, *box, TransformGroup{*box, originals}, false, persistent, std::nullopt, nullptr, fromFields}; } else { // Linked, layer and mask move together as the layer. const bool maskAlone = transformTargetsMask(); - m_transformEdit = TransformEdit{layer->id, maskAlone ? layer->maskTransform() : layer->transform, std::nullopt, maskAlone, persistent, std::nullopt, nullptr}; + m_transformEdit = TransformEdit{layer->id, maskAlone ? layer->maskTransform() : layer->transform, std::nullopt, maskAlone, persistent, std::nullopt, nullptr, fromFields}; } notify(); } diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 4e237ea..f14b478 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -112,7 +112,7 @@ public: bool transformsAsGroup() const; std::vector groupTransformMembers() const; std::optional groupTransformBox() const; - void beginTransform(bool persistent = true); + void beginTransform(bool persistent = true, bool fromFields = false); // Alt-drag: the selection's copies move, one step. void beginDuplicateTransform(); // The Move tool's settings (TransformInspector, Ctrl+H). diff --git a/src/Document/LayerTransform.h b/src/Document/LayerTransform.h index 2303d15..34c0168 100644 --- a/src/Document/LayerTransform.h +++ b/src/Document/LayerTransform.h @@ -71,6 +71,8 @@ struct TransformEdit { std::optional corners; // Ctrl+T on selected pixels: they float, then merge back. std::shared_ptr floating; + // The Move bar's fields: applied once they are done. + bool fromFields = false; }; // Where a move has just snapped: guides while it lasts. diff --git a/src/Rendering/EditorCanvas+Transform.cpp b/src/Rendering/EditorCanvas+Transform.cpp index 6200dd0..8ef2b52 100644 --- a/src/Rendering/EditorCanvas+Transform.cpp +++ b/src/Rendering/EditorCanvas+Transform.cpp @@ -137,7 +137,7 @@ QCursor CanvasView::rotationCursor(double ratio) return QCursor(pixmap, 12, 12); } -// The layer a press off the handles drags; Ctrl picks. +// What a press off the handles drags; Ctrl flips picking. std::optional CanvasView::transformPressLayer(QPointF pixel, Qt::KeyboardModifiers modifiers) const { const std::optional &document = m_session.document(); @@ -154,21 +154,23 @@ std::optional CanvasView::transformPressLayer(QPointF p active = std::nullopt; const bool picks = !m_session.transformEdit(); const bool control = modifiers.testFlag(Qt::ControlModifier); - if (control && picks && underPointer) + const bool autoSelect = m_session.transformAutoSelect() != control; + // Ctrl+Shift adds the layer under the pointer either way. + if (control && (modifiers.testFlag(Qt::ShiftModifier) || !m_session.transformAutoSelect()) && picks && underPointer) return PressTarget{*underPointer, true}; // A group's box drags them all; outside, unless auto-select picks. if (m_session.transformsAsGroup() && m_session.activeLayerID()) { const std::optional box = m_session.transformEdit() ? std::optional(m_session.transformEdit()->draft) : m_session.groupTransformBox(); - if ((box && box->contains(pixel)) || !(picks && m_session.transformAutoSelect()) || !underPointer) + if ((box && box->contains(pixel)) || !(picks && autoSelect) || !underPointer) return PressTarget{*m_session.activeLayerID(), false}; } if (active && m_session.editedTransform(*active).contains(pixel)) { // Auto Select prefers a layer above, as over a background. - if (picks && m_session.transformAutoSelect() && underPointer && above(rendered, *underPointer, active->id)) + if (picks && autoSelect && underPointer && above(rendered, *underPointer, active->id)) return PressTarget{*underPointer, true}; return PressTarget{active->id, false}; } - if (picks && (m_session.transformAutoSelect() || control) && underPointer) + if (picks && autoSelect && underPointer) return PressTarget{*underPointer, true}; if (active) return PressTarget{active->id, false}; @@ -230,6 +232,9 @@ void CanvasView::beginTransformDrag(QPointF point, Qt::KeyboardModifiers modifie mode = TransformDrag::Mode{TransformDrag::Kind::move}; } m_duplicatesTransformOnDrag = mode->kind == TransformDrag::Kind::move && modifiers.testFlag(Qt::AltModifier); + // A value the bar's fields were changing applies first. + if (m_session.transformEdit() && m_session.transformEdit()->fromFields) + m_session.commitTransform(); if (!m_session.transformEdit()) m_session.beginTransform(false); if (!m_session.transformEdit()) @@ -265,21 +270,28 @@ void CanvasView::dragTransform(QPointF point, Qt::KeyboardModifiers modifiers) synchronizeDisplay(); return; } - // Drags land on whole pixels and degrees; typing stays exact. - LayerTransform draft = m_transformDrag->updated(pixel, m_session.locksTransformRatio(), modifiers.testFlag(Qt::ShiftModifier), - modifiers.testFlag(Qt::AltModifier)).rounded(); + const bool shift = modifiers.testFlag(Qt::ShiftModifier), option = modifiers.testFlag(Qt::AltModifier); const std::optional &edit = m_session.transformEdit(); - // A move snaps to canvas and layers; Ctrl frees it. - if (m_transformDrag->mode.kind == TransformDrag::Kind::move && !modifiers.testFlag(Qt::ControlModifier) && edit) { - QSet moving; - if (edit->group) { - for (auto original = edit->group->originals.constBegin(); original != edit->group->originals.constEnd(); ++original) - moving.insert(original.key()); - } else { - moving.insert(edit->layerID); - } - draft = m_session.snappedMove(draft, moving, TransformSnap::distance / std::max(m_session.viewport.pointsPerPixel(), 0.0001)); + QSet moving; + if (edit && edit->group) { + for (auto original = edit->group->originals.constBegin(); original != edit->group->originals.constEnd(); ++original) + moving.insert(original.key()); + } else if (edit) { + moving.insert(edit->layerID); } + const double tolerance = TransformSnap::distance / std::max(m_session.viewport.pointsPerPixel(), 0.0001); + const TransformDrag &drag = *m_transformDrag; + // Moves and resized edges snap, not turns; Ctrl frees both. + QPointF target = pixel; + if (drag.mode.kind == TransformDrag::Kind::resize && !modifiers.testFlag(Qt::ControlModifier)) { + target = m_session.snappedResizePoint(pixel, drag, m_session.locksTransformRatio() != shift, moving, tolerance, [&](QPointF to) { + return drag.updated(to, m_session.locksTransformRatio(), shift, option); + }); + } + // Drags land on whole pixels and degrees; typing stays exact. + LayerTransform draft = drag.updated(target, m_session.locksTransformRatio(), shift, option).rounded(); + if (drag.mode.kind == TransformDrag::Kind::move && !modifiers.testFlag(Qt::ControlModifier) && edit) + draft = m_session.snappedMove(draft, moving, tolerance); m_session.previewTransform(draft); synchronizeDisplay(); } diff --git a/src/UI/HeldModifiers.cpp b/src/UI/HeldModifiers.cpp new file mode 100644 index 0000000..a6345c7 --- /dev/null +++ b/src/UI/HeldModifiers.cpp @@ -0,0 +1,57 @@ +#include "UI/HeldModifiers.h" +#include +#include +#include +#include + +HeldModifiers &HeldModifiers::shared() +{ + static HeldModifiers *held = new HeldModifiers; + return *held; +} + +HeldModifiers::HeldModifiers() : QObject(qApp) +{ + qApp->installEventFilter(this); + // A key let go behind another app is never seen. + connect(qApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { + update(state == Qt::ApplicationActive ? QGuiApplication::queryKeyboardModifiers() : Qt::NoModifier); + }); +} + +void HeldModifiers::update(Qt::KeyboardModifiers raw) +{ + // An NSText first responder: a widget taking typed text. + const QWidget *focus = QApplication::focusWidget(); + const bool typing = focus && focus->testAttribute(Qt::WA_InputMethodEnabled); + const Qt::KeyboardModifiers held = + typing ? Qt::NoModifier : raw & (Qt::ControlModifier | Qt::ShiftModifier | Qt::AltModifier | Qt::MetaModifier); + if (held == m_flags) + return; + m_flags = held; + emit changed(); +} + +bool HeldModifiers::eventFilter(QObject *watched, QEvent *event) +{ + const QEvent::Type type = event->type(); + if (type == QEvent::KeyPress || type == QEvent::KeyRelease) { + const auto *key = static_cast(event); + Qt::KeyboardModifiers raw = key->modifiers(); + // A modifier's own event may not count it yet. + const Qt::KeyboardModifier own = key->key() == Qt::Key_Control ? Qt::ControlModifier + : key->key() == Qt::Key_Shift ? Qt::ShiftModifier + : key->key() == Qt::Key_Alt ? Qt::AltModifier + : key->key() == Qt::Key_Meta ? Qt::MetaModifier + : Qt::NoModifier; + raw.setFlag(own, type == QEvent::KeyPress); + update(raw); + } else if (type == QEvent::MouseButtonPress || type == QEvent::MouseButtonRelease) { + if (static_cast(event)->button() == Qt::LeftButton) + update(static_cast(event)->modifiers()); + } else if (type == QEvent::Hide && qobject_cast(watched)) { + // Nor is one let go while a menu is open. + update(QGuiApplication::queryKeyboardModifiers()); + } + return false; +} diff --git a/src/UI/HeldModifiers.h b/src/UI/HeldModifiers.h new file mode 100644 index 0000000..4096c21 --- /dev/null +++ b/src/UI/HeldModifiers.h @@ -0,0 +1,23 @@ +#pragma once +#include + +// Swift's HeldModifiers: keys held now, for the Move bar. +class HeldModifiers : public QObject { + Q_OBJECT +public: + static HeldModifiers &shared(); + Qt::KeyboardModifiers flags() const { return m_flags; } + // Keys held while typing in a field count as none. + void update(Qt::KeyboardModifiers raw); + +signals: + void changed(); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + HeldModifiers(); + + Qt::KeyboardModifiers m_flags; +}; diff --git a/src/UI/TransformInspector.cpp b/src/UI/TransformInspector.cpp index 72f6d03..a441ebd 100644 --- a/src/UI/TransformInspector.cpp +++ b/src/UI/TransformInspector.cpp @@ -1,4 +1,5 @@ #include "UI/TransformInspector.h" +#include "UI/HeldModifiers.h" #include "UI/KeyboardShortcuts.h" #include "UI/LayerIcons.h" #include "UI/NumericScrub.h" @@ -8,8 +9,8 @@ #include TransformValueField::TransformValueField(const QString &label, const QString &suffix, double low, double high, EditorSession &session, - std::function change, QWidget *parent) - : QWidget(parent), field(new QLineEdit(this)), m_session(session), m_change(std::move(change)) + std::function finish, std::function change, QWidget *parent) + : QWidget(parent), field(new QLineEdit(this)), m_session(session), m_finish(std::move(finish)), m_change(std::move(change)) { auto *row = new QHBoxLayout(this); row->setContentsMargins(0, 0, 0, 0); @@ -21,7 +22,7 @@ TransformValueField::TransformValueField(const QString &label, const QString &su m_scrub = new NumericScrub(name, {.sensitivity = 1, .low = low, .high = high, .step = 1, .value = [this] { return m_value; }, .set = [this](double value) { field->setText(formatted(value)); m_change(value); - }}); + }, .onEnd = m_finish}); row->addWidget(name); field->setObjectName(QStringLiteral("transform") + label); field->setAccessibleName(label); @@ -64,15 +65,22 @@ bool TransformValueField::eventFilter(QObject *watched, QEvent *event) { if (event->type() == QEvent::FocusOut) { const Qt::FocusReason reason = static_cast(event)->reason(); - m_borrowed = reason == Qt::MenuBarFocusReason || reason == Qt::PopupFocusReason; - if (!m_borrowed) + // Another window in front keeps the field, as AppKit does. + m_borrowed = reason == Qt::MenuBarFocusReason || reason == Qt::PopupFocusReason || reason == Qt::ActiveWindowFocusReason; + if (!m_borrowed) { + m_finish(); field->setText(formatted(m_value)); + } } if (event->type() != QEvent::KeyPress) return QWidget::eventFilter(watched, event); const auto *key = static_cast(event); // Return and Escape hand the canvas the keys. if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter || key->key() == Qt::Key_Escape) { + // Escape throws the fields' own change away, as Swift's Cancel. + const std::optional &edit = m_session.transformEdit(); + if (key->key() == Qt::Key_Escape && edit && edit->fromFields) + m_session.cancelTransform(); field->clearFocus(); m_session.requestCanvasFocus(); return true; @@ -90,22 +98,25 @@ bool TransformValueField::eventFilter(QObject *watched, QEvent *event) TransformInspector::TransformInspector(EditorSession &session, QWidget *parent) : ToolHeaderBar(QStringLiteral("Transform"), parent), m_session(session), m_autoSelect(new QCheckBox(QStringLiteral("Auto Select"), this)), m_showControls(new QCheckBox(QStringLiteral("Show Controls"), this)), m_fields(new QScrollArea(this)), - m_x(new TransformValueField(QStringLiteral("X"), QString(), -30000, 30000, session, [this](double number) { change([number](LayerTransform &value) { value.origin.setX(number); }); })), - m_y(new TransformValueField(QStringLiteral("Y"), QString(), -30000, 30000, session, [this](double number) { change([number](LayerTransform &value) { value.origin.setY(number); }); })), - m_width(new TransformValueField(QStringLiteral("W"), QString(), 1, 30000, session, [this](double number) { resizeBox(number, true); })), - m_height(new TransformValueField(QStringLiteral("H"), QString(), 1, 30000, session, [this](double number) { resizeBox(number, false); })), + m_x(new TransformValueField(QStringLiteral("X"), QString(), -30000, 30000, session, [this] { finish(); }, [this](double number) { change([number](LayerTransform &value) { value.origin.setX(number); }); })), + m_y(new TransformValueField(QStringLiteral("Y"), QString(), -30000, 30000, session, [this] { finish(); }, [this](double number) { change([number](LayerTransform &value) { value.origin.setY(number); }); })), + m_width(new TransformValueField(QStringLiteral("W"), QString(), 1, 30000, session, [this] { finish(); }, [this](double number) { resizeBox(number, true); })), + m_height(new TransformValueField(QStringLiteral("H"), QString(), 1, 30000, session, [this] { finish(); }, [this](double number) { resizeBox(number, false); })), m_lock(new QToolButton(this)), - m_scale(new TransformValueField(QStringLiteral("Scale"), QStringLiteral("%"), 0.1, 30000, session, [this](double number) { + m_scale(new TransformValueField(QStringLiteral("Scale"), QStringLiteral("%"), 0.1, 30000, session, [this] { finish(); }, [this](double number) { change([&](LayerTransform &value) { value = value.scaled(number, pixelSize()); }); })), - m_rotation(new TransformValueField(QStringLiteral("°"), QString(), -360, 360, session, [this](double number) { + m_rotation(new TransformValueField(QStringLiteral("°"), QString(), -360, 360, session, [this] { finish(); }, [this](double number) { change([number](LayerTransform &value) { value.rotation = std::fmod(number, 360.0); }); })), m_sampling(new QComboBox(this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_apply(new QPushButton(QStringLiteral("Apply"), this)) { m_autoSelect->setObjectName(QStringLiteral("transformAutoSelect")); - m_autoSelect->setToolTip(QStringLiteral("Select layers by clicking the canvas. When off, hold Ctrl to select a layer.")); - connect(m_autoSelect, &QCheckBox::toggled, this, [this](bool picks) { m_session.setTransformAutoSelect(picks); }); + m_autoSelect->setToolTip(QStringLiteral("Select layers by clicking the canvas. Hold Ctrl to turn it the other way while you click.")); + // Held Ctrl flips Auto Select, and the box shows it. + connect(m_autoSelect, &QCheckBox::toggled, this, [this](bool picks) { + m_session.setTransformAutoSelect(picks != HeldModifiers::shared().flags().testFlag(Qt::ControlModifier)); + }); m_showControls->setObjectName(QStringLiteral("showTransformControls")); m_showControls->setToolTip(QStringLiteral("Show the transform box and handles (Ctrl+H). When hidden, drag anywhere to move the layer.")); connect(m_showControls, &QCheckBox::toggled, this, [this](bool shows) { m_session.setShowsTransformControls(shows); }); @@ -122,9 +133,12 @@ TransformInspector::TransformInspector(EditorSession &session, QWidget *parent) m_lock->setObjectName(QStringLiteral("locksTransformRatio")); m_lock->setCheckable(true); m_lock->setAutoRaise(true); - m_lock->setToolTip(QStringLiteral("Lock aspect ratio")); + m_lock->setToolTip(QStringLiteral("Lock aspect ratio. Hold Shift while dragging a handle to turn it the other way.")); applyLockIcon(); - connect(m_lock, &QToolButton::toggled, this, [this](bool locks) { m_session.setLocksTransformRatio(locks); }); + // Held Shift flips the lock, and the button shows it. + connect(m_lock, &QToolButton::toggled, this, [this](bool locks) { + m_session.setLocksTransformRatio(locks != HeldModifiers::shared().flags().testFlag(Qt::ShiftModifier)); + }); fields->addWidget(m_lock); m_scale->setFixedWidth(110); m_scale->setToolTip(QStringLiteral("Scale width and height together, about the center")); @@ -168,6 +182,7 @@ TransformInspector::TransformInspector(EditorSession &session, QWidget *parent) // The scroll view takes the spare room, as SwiftUI's does. row->setStretch(row->count() - 1, 0); connect(&m_session, &EditorSession::changed, this, &TransformInspector::synchronize); + connect(&HeldModifiers::shared(), &HeldModifiers::changed, this, &TransformInspector::synchronize); synchronize(); } @@ -216,10 +231,11 @@ QSizeF TransformInspector::pixelSize() const return active ? active->size() : value().size; } +// Shown at once, applied without Apply; Ctrl+T's edit takes it. void TransformInspector::change(const std::function &update) { if (!m_session.transformEdit()) - m_session.beginTransform(); + m_session.beginTransform(false, true); if (!m_session.transformEdit()) return; LayerTransform next = m_session.transformEdit()->draft; @@ -227,6 +243,12 @@ void TransformInspector::change(const std::function &upd m_session.previewTransform(next); } +void TransformInspector::finish() +{ + if (m_session.transformEdit() && m_session.transformEdit()->fromFields) + m_session.commitTransform(); +} + void TransformInspector::resizeBox(double number, bool width) { // A side under a pixel makes a box previewTransform refuses. @@ -258,9 +280,10 @@ void TransformInspector::synchronize() const QSignalBlocker blocker(button); button->setChecked(checked); }; - set(m_autoSelect, m_session.transformAutoSelect()); + const Qt::KeyboardModifiers held = HeldModifiers::shared().flags(); + set(m_autoSelect, m_session.transformAutoSelect() != held.testFlag(Qt::ControlModifier)); set(m_showControls, m_session.showsTransformControls()); - set(m_lock, m_session.locksTransformRatio()); + set(m_lock, m_session.locksTransformRatio() != held.testFlag(Qt::ShiftModifier)); const LayerTransform shown = value(); m_x->sync(shown.origin.x()); m_y->sync(shown.origin.y()); diff --git a/src/UI/TransformInspector.h b/src/UI/TransformInspector.h index f88ad29..3c37027 100644 --- a/src/UI/TransformInspector.h +++ b/src/UI/TransformInspector.h @@ -17,7 +17,7 @@ class TransformValueField : public QWidget { public: // The label scrubs within low and high, in whole steps. TransformValueField(const QString &label, const QString &suffix, double low, double high, EditorSession &session, - std::function change, QWidget *parent = nullptr); + std::function finish, std::function change, QWidget *parent = nullptr); // Shown when the field is not being edited. void sync(double value); @@ -33,6 +33,8 @@ protected: private: EditorSession &m_session; + // Done with its value: a scrub let go, or left. + const std::function m_finish; const std::function m_change; NumericScrub *m_scrub = nullptr; double m_value = 0; @@ -56,6 +58,8 @@ private: LayerTransform value() const; QSizeF pixelSize() const; void change(const std::function &update); + // What the fields changed applies, one step. + void finish(); // Swift's `resize`; the name would hide QWidget's. void resizeBox(double number, bool width); void synchronize(); diff --git a/tests/ResizeSnapCanvasTests.cpp b/tests/ResizeSnapCanvasTests.cpp new file mode 100644 index 0000000..7b4dc87 --- /dev/null +++ b/tests/ResizeSnapCanvasTests.cpp @@ -0,0 +1,85 @@ +#include "CanvasFixtures.h" + +// Swift's resize snapping on the canvas: handles, Ctrl, Shift. +namespace { +// The document fills the canvas: view points are pixels. +struct Canvas : Shown { + QUuid red; + Canvas() : Shown(QSize(400, 300), QSize(400, 300)) + { + settle(); + session.zoom(1); + session.insert(filled(100, 100, qRgba(255, 0, 0, 255), "Red")); + red = session.activeLayerID().value(); + session.selectTool(NavigationTool::move); + canvas->synchronizeDisplay(); + if (session.viewport.viewPoint(QPointF(0, 0), documentSize()) != QPointF(0, 0)) + throw std::runtime_error("the document does not fill the canvas"); + } + QPointF origin() const { return layerWith(session, red).transform.origin; } + void press(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mousePress(canvas, Qt::LeftButton, modifiers, at.toPoint()); } + void move(QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + QMouseEvent event(QEvent::MouseMove, to, to, canvas->mapToGlobal(to.toPoint()), Qt::NoButton, Qt::LeftButton, modifiers); + QApplication::sendEvent(canvas, &event); + } + void release(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { QTest::mouseRelease(canvas, Qt::LeftButton, modifiers, at.toPoint()); } + void drag(QPointF from, QPointF to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + press(from, modifiers); + move((from + to) / 2, modifiers); + move(to, modifiers); + release(to, modifiers); + } + void click(QPointF at, Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + press(at, modifiers); + release(at, modifiers); + } +}; +} + +class ResizeSnapCanvasTests : public QObject { + Q_OBJECT +private slots: + void resizedEdgesSnapUnlessCtrl(); +}; + +void ResizeSnapCanvasTests::resizedEdgesSnapUnlessCtrl() +{ + Canvas shown; + EditorSession &session = shown.session; + session.setLocksTransformRatio(false); + // The right edge, from 250 to four short of 400. + shown.press(QPointF(250, 150)); + shown.move(QPointF(396, 150)); + QCOMPARE(session.transformEdit().value().draft, (LayerTransform{.origin = {150, 100}, .size = {250, 100}})); + QCOMPARE(session.snapGuides, (SnapGuides{{400}, {}})); + // Ctrl mid-drag frees it. + shown.move(QPointF(396, 150), Qt::ControlModifier); + QCOMPARE(session.transformEdit().value().draft.size, QSizeF(246, 100)); + shown.release(QPointF(396, 150), Qt::ControlModifier); + QCOMPARE(layerWith(session, shown.red).transform.size, QSizeF(246, 100)); + // Snapping off, nothing snaps. + session.undo(); + session.setSnappingEnabled(false); + shown.drag(QPointF(250, 150), QPointF(396, 150)); + QCOMPARE(layerWith(session, shown.red).transform.size, QSizeF(246, 100)); + QCOMPARE(session.snapGuides, SnapGuides{}); + // Locked, Shift frees the lock: a corner snaps both edges. + session.undo(); + session.setSnappingEnabled(true); + session.setLocksTransformRatio(true); + shown.press(QPointF(250, 200)); + shown.move(QPointF(396, 296), Qt::ShiftModifier); + QCOMPARE(session.transformEdit().value().draft, (LayerTransform{.origin = {150, 100}, .size = {250, 200}})); + QCOMPARE(session.snapGuides, (SnapGuides{{400}, {300}})); + // Kept proportional, only the nearer edge snaps. + shown.move(QPointF(397, 349)); + QCOMPARE(session.transformEdit().value().draft.size, QSizeF(250, 250)); + QCOMPARE(session.snapGuides, (SnapGuides{{400}, {}})); + shown.release(QPointF(397, 349)); +} + +QTEST_MAIN(ResizeSnapCanvasTests) +#include "ResizeSnapCanvasTests.moc" diff --git a/tests/ResizeSnapTests.cpp b/tests/ResizeSnapTests.cpp new file mode 100644 index 0000000..5703bb6 --- /dev/null +++ b/tests/ResizeSnapTests.cpp @@ -0,0 +1,157 @@ +#include "SessionFixtures.h" +#include + +// Swift's ResizeSnapTests: a resized layer's edges snap, as moves do. +namespace { +struct Resized { + EditorSession session; + QUuid layer; + + // 300 by 200: the layer at 10; another at 150. + Resized() + { + session.createDocument(300, 200); + place(QPointF(150, 150), QSizeF(40, 40)); + place(QPointF(10, 10), QSizeF(100, 100)); + layer = session.activeLayerID().value(); + } + void place(QPointF origin, QSizeF size) + { + session.insert(ImportedImage(QImage(20, 20, QImage::Format_RGBA8888_Premultiplied), QImage(), "Layer")); + session.beginTransform(); + session.previewTransform(LayerTransform{.origin = origin, .size = size}); + session.commitTransform(); + } + LayerTransform resize(int handle, QPointF start, QPointF point, bool lockRatio) + { + const TransformDrag drag{layerWith(session, layer).transform, start, {TransformDrag::Kind::resize, handle}, std::nullopt}; + const QPointF snapped = session.snappedResizePoint(point, drag, lockRatio, {layer}, 5, [&](QPointF to) { + return drag.updated(to, lockRatio, false); + }); + return drag.updated(snapped, lockRatio, false).rounded(); + } +}; +} + +class ResizeSnapTests : public QObject { + Q_OBJECT +private slots: + void aSideHandleSnapsItsEdge(); + void aProportionalCornerSnapsItsNearerEdgeAndKeepsTheRatio(); + void aTurnedLayerDoesntSnap(); + void eachEdgeSnapsOnItsOwn(); + void theDraggedSideAloneSnaps(); + void snappingOffOrAnotherDragLeavesThePoint(); +}; + +void ResizeSnapTests::aSideHandleSnapsItsEdge() +{ + Resized resized; + QCOMPARE(LayerTransform::handles[3], QPointF(1, 0.5)); + // The right edge, three short of the other layer's left. + const LayerTransform result = resized.resize(3, QPointF(110, 60), QPointF(147, 60), false); + QCOMPARE(result.origin.x() + result.size.width(), 150.0); + QCOMPARE(result.size.height(), 100.0); + QCOMPARE(resized.session.snapGuides, (SnapGuides{{150}, {}})); + // Out of reach, it does not. + const LayerTransform free = resized.resize(3, QPointF(110, 60), QPointF(130, 60), false); + QCOMPARE(free.origin.x() + free.size.width(), 130.0); + QCOMPARE(resized.session.snapGuides, SnapGuides{}); + // The left handle snaps the left edge, to the canvas. + const LayerTransform left = resized.resize(7, QPointF(10, 60), QPointF(3, 60), false); + QCOMPARE(left.origin.x(), 0.0); + QCOMPARE(left.origin.x() + left.size.width(), 110.0); +} + +void ResizeSnapTests::aProportionalCornerSnapsItsNearerEdgeAndKeepsTheRatio() +{ + Resized resized; + // Bottom right toward 146, 148: the bottom edge snaps. + const LayerTransform result = resized.resize(4, QPointF(110, 110), QPointF(146, 148), true); + QCOMPARE(result.origin.y() + result.size.height(), 150.0); + QVERIFY(std::abs(result.size.width() - result.size.height()) <= 1); + // A tie goes to the right edge, the first. + QCOMPARE(resized.session.snapGuides, (SnapGuides{{150}, {}})); + // A top at 148 lies nearer the bottom: it snaps. + resized.place(QPointF(260, 148), QSizeF(10, 10)); + resized.session.selectLayer(resized.layer); + const LayerTransform bottom = resized.resize(4, QPointF(110, 110), QPointF(147, 147), true); + QCOMPARE(bottom, (LayerTransform{.origin = {10, 10}, .size = {138, 138}})); + QCOMPARE(resized.session.snapGuides, (SnapGuides{{}, {148}})); + // Free, both edges snap, each to its nearest. + const LayerTransform free = resized.resize(4, QPointF(110, 110), QPointF(147, 147), false); + QCOMPARE(free, (LayerTransform{.origin = {10, 10}, .size = {140, 138}})); +} + +void ResizeSnapTests::aTurnedLayerDoesntSnap() +{ + Resized resized; + LayerTransform turned = layerWith(resized.session, resized.layer).transform; + turned.rotation = 20; + const TransformDrag drag{turned, QPointF(110, 60), {TransformDrag::Kind::resize, 3}, std::nullopt}; + // Wherever the handle goes, the pointer stays put. + for (double x = 100; x <= 200; x += 0.5) { + resized.session.snapGuides = SnapGuides{{1}, {2}}; + const QPointF point(x, 60); + QCOMPARE(resized.session.snappedResizePoint(point, drag, false, {resized.layer}, 5, [&](QPointF to) { return drag.updated(to, false, false); }), + point); + QCOMPARE(resized.session.snapGuides, SnapGuides{}); + } +} + +void ResizeSnapTests::eachEdgeSnapsOnItsOwn() +{ + Resized resized; + // Free, a corner snaps both edges, each to its line. + const LayerTransform result = resized.resize(4, QPointF(110, 110), QPointF(146, 147), false); + QCOMPARE(result, (LayerTransform{.origin = {10, 10}, .size = {140, 140}})); + QCOMPARE(resized.session.snapGuides, (SnapGuides{{150}, {150}})); + // The top left corner moves the top and left edges. + const LayerTransform corner = resized.resize(0, QPointF(10, 10), QPointF(2, 4), false); + QCOMPARE(corner, (LayerTransform{.origin = {0, 0}, .size = {110, 110}})); + // An edge no step moves keeps the pointer; guide shown. + const TransformDrag drag{layerWith(resized.session, resized.layer).transform, QPointF(110, 60), {TransformDrag::Kind::resize, 3}, std::nullopt}; + const QPointF still = resized.session.snappedResizePoint(QPointF(147, 60), drag, false, {resized.layer}, 5, [&](QPointF) { + return LayerTransform{.origin = {10, 10}, .size = {138, 100}}; + }); + QCOMPARE(still, QPointF(147, 60)); + QCOMPARE(resized.session.snapGuides, (SnapGuides{{150}, {}})); +} + +// The dragged side tells its edge, wherever the press began. +void ResizeSnapTests::theDraggedSideAloneSnaps() +{ + Resized resized; + // Pressed left of the layer, the right edge still moves. + const LayerTransform far = resized.resize(3, QPointF(-50, 60), QPointF(-13, 60), false); + QCOMPARE(far.origin.x() + far.size.width(), 150.0); + QCOMPARE(far.origin.x(), 10.0); + // A top on a line: side drags never snap it. + resized.session.beginTransform(); + resized.session.previewTransform(LayerTransform{.origin = {10, 98}, .size = {100, 60}}); + resized.session.commitTransform(); + resized.resize(3, QPointF(110, 128), QPointF(147, 128), false); + QCOMPARE(resized.session.snapGuides, (SnapGuides{{150}, {}})); + // Nor a bottom handle the left edge, near the canvas's. + resized.session.beginTransform(); + resized.session.previewTransform(LayerTransform{.origin = {2, 10}, .size = {100, 100}}); + resized.session.commitTransform(); + const LayerTransform down = resized.resize(5, QPointF(52, 110), QPointF(52, 148), false); + QCOMPARE(down, (LayerTransform{.origin = {2, 10}, .size = {100, 140}})); + QCOMPARE(resized.session.snapGuides, (SnapGuides{{}, {150}})); +} + +void ResizeSnapTests::snappingOffOrAnotherDragLeavesThePoint() +{ + Resized resized; + resized.session.setSnappingEnabled(false); + const LayerTransform result = resized.resize(3, QPointF(110, 60), QPointF(147, 60), false); + QCOMPARE(result.origin.x() + result.size.width(), 147.0); + resized.session.setSnappingEnabled(true); + const TransformDrag move{layerWith(resized.session, resized.layer).transform, QPointF(110, 60), {TransformDrag::Kind::move, 3}, std::nullopt}; + QCOMPARE(resized.session.snappedResizePoint(QPointF(147, 60), move, false, {resized.layer}, 5, [&](QPointF to) { return move.updated(to, false, false); }), + QPointF(147, 60)); +} + +QTEST_GUILESS_MAIN(ResizeSnapTests) +#include "ResizeSnapTests.moc" diff --git a/tests/ScrubbableTransformTests.cpp b/tests/ScrubbableTransformTests.cpp index 2d12e0f..1a976f2 100644 --- a/tests/ScrubbableTransformTests.cpp +++ b/tests/ScrubbableTransformTests.cpp @@ -27,62 +27,72 @@ void ScrubbableTransformTests::theTransformLabelsScrubWholeNumbersOverTyping() bar.activateWindow(); QVERIFY(QTest::qWaitForWindowActive(&bar)); const QPointF origin = session.activeLayer().value().transform.origin; - // The first drag begins the edit, as typing does. + const int base = session.history.undoCount(); + const auto shown = [&] { return session.activeLayer().value().transform; }; + // Each drag applies when let go, one step. drag(scrubbed(bar, "X"), 7); - QCOMPARE(session.transformEdit().value().draft.origin.x(), std::round(origin.x()) + 7); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(shown().origin.x(), std::round(origin.x()) + 7); + QCOMPARE(session.history.undoCount(), base + 1); + QCOMPARE(session.history.undoName(), QString("Transform Layer")); // A scrub writes over what is typed in its field. QLineEdit &width = find(bar, "transformW"); width.setFocus(); width.selectAll(); QTest::keyClicks(&width, QStringLiteral("3")); drag(scrubbed(bar, "W"), 10); - QCOMPARE(session.transformEdit().value().draft.size, QSizeF(13, 20)); + QCOMPARE(shown().size, QSizeF(13, 20)); QCOMPARE(width.text(), QString("13")); drag(scrubbed(bar, "W"), -100); - QCOMPARE(session.transformEdit().value().draft.size.width(), 1.0); + QCOMPARE(shown().size.width(), 1.0); drag(scrubbed(bar, "H"), 40000); - QCOMPARE(session.transformEdit().value().draft.size.height(), 30000.0); + QCOMPARE(shown().size.height(), 30000.0); drag(scrubbed(bar, "°"), 30); - QCOMPARE(session.transformEdit().value().draft.rotation, 30.0); + QCOMPARE(shown().rotation, 30.0); drag(scrubbed(bar, "°"), -900); - QCOMPARE(session.transformEdit().value().draft.rotation, -0.0); + QCOMPARE(shown().rotation, -0.0); // Unfocused, the field shows what the session keeps. QCOMPARE(find(bar, "transform°").text(), QString("0")); QCOMPARE(width.text(), QString("1")); drag(scrubbed(bar, "Y"), -90000); - QCOMPARE(session.transformEdit().value().draft.origin.y(), -30000.0); + QCOMPARE(shown().origin.y(), -30000.0); // A fractional start lands on a whole number. QLineEdit &x = find(bar, "transformX"); x.setFocus(); x.selectAll(); QTest::keyClicks(&x, QStringLiteral("10.4")); + QVERIFY(session.transformEdit().value().fromFields); QCOMPARE(session.transformEdit().value().draft.origin.x(), 10.4); drag(scrubbed(bar, "X"), 3); - QCOMPARE(session.transformEdit().value().draft.origin.x(), 13.0); + QCOMPARE(shown().origin.x(), 13.0); QCOMPARE(x.text(), QString("13")); // Every range reaches its ends exactly. drag(scrubbed(bar, "X"), -90000); - QCOMPARE(session.transformEdit().value().draft.origin.x(), -30000.0); + QCOMPARE(shown().origin.x(), -30000.0); drag(scrubbed(bar, "X"), 90000); - QCOMPARE(session.transformEdit().value().draft.origin.x(), 30000.0); + QCOMPARE(shown().origin.x(), 30000.0); drag(scrubbed(bar, "Y"), 90000); - QCOMPARE(session.transformEdit().value().draft.origin.y(), 30000.0); + QCOMPARE(shown().origin.y(), 30000.0); drag(scrubbed(bar, "W"), 90000); - QCOMPARE(session.transformEdit().value().draft.size.width(), 30000.0); + QCOMPARE(shown().size.width(), 30000.0); drag(scrubbed(bar, "H"), -90000); - QCOMPARE(session.transformEdit().value().draft.size.height(), 1.0); + QCOMPARE(shown().size.height(), 1.0); // 360 reaches the setter, whose remainder leaves none. drag(scrubbed(bar, "°"), 9000); - QCOMPARE(session.transformEdit().value().draft.rotation, 0.0); + QCOMPARE(shown().rotation, 0.0); drag(scrubbed(bar, "°"), 359); - QCOMPARE(session.transformEdit().value().draft.rotation, 359.0); + QCOMPARE(shown().rotation, 359.0); // Scale snaps whole percents too. - session.cancelTransform(); + const auto restart = [&] { + while (session.history.undoCount() > base) + session.undo(); + }; + restart(); drag(scrubbed(bar, "Scale"), 5); - QCOMPARE(session.transformEdit().value().draft.size, QSizeF(42, 21)); - session.cancelTransform(); + QCOMPARE(shown().size, QSizeF(42, 21)); + restart(); drag(scrubbed(bar, "Scale"), 90000); - QCOMPARE(session.transformEdit().value().draft.size, QSizeF(12000, 6000)); + QCOMPARE(shown().size, QSizeF(12000, 6000)); QCOMPARE(find(bar, "transformScale").text(), QString("30000")); // While distorted the numbers rest, labels included. session.cancelTransform(); @@ -101,7 +111,7 @@ void ScrubbableTransformTests::scaleScrubsDownToATenth() TransformInspector bar(session); bar.show(); drag(scrubbed(bar, "Scale"), -90000); - QCOMPARE(session.transformEdit().value().draft.size, QSizeF(2, 1)); + QCOMPARE(session.activeLayer().value().transform.size, QSizeF(2, 1)); QCOMPARE(find(bar, "transformScale").text(), QString("0.10")); } @@ -204,9 +214,10 @@ void ScrubbableTransformTests::aTransformScrubEndsWithItsLayer() QVERIFY(!session.transformEdit().has_value()); QCOMPARE(layerWith(session, first).transform.origin.x(), x); QCOMPARE(layerWith(session, second).transform.origin.x(), std::round(x) + 10); - // A fresh press scrubs the new layer. + // A fresh press scrubs the new layer, applied on release. drag(label, 5); - QCOMPARE(session.transformEdit().value().draft.origin.x(), std::round(x) + 5); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(layerWith(session, first).transform.origin.x(), std::round(x) + 5); } QTEST_MAIN(ScrubbableTransformTests) diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 80012e2..4c9b1f1 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -216,6 +216,8 @@ compositor_test(TransformOverlayTests) compositor_test(TransformPressTests) compositor_test(TransformInspectorTests) compositor_test(TransformSnapTests) +compositor_test(ResizeSnapTests) +compositor_test(ResizeSnapCanvasTests) compositor_test(CursorTests) compositor_test(DistortCanvasTests) compositor_test(DistortMaskTests) diff --git a/tests/TransformInspectorTests.cpp b/tests/TransformInspectorTests.cpp index 0115a7f..fa10386 100644 --- a/tests/TransformInspectorTests.cpp +++ b/tests/TransformInspectorTests.cpp @@ -1,4 +1,5 @@ #include "SessionFixtures.h" +#include "UI/HeldModifiers.h" #include "UI/LayerIcons.h" #include "UI/TransformInspector.h" #include @@ -57,6 +58,7 @@ private slots: void aMenuBorrowsTheFocusAndTheBarsKeysApplyOrCancel(); void theLockIconFollowsThePalette(); void theFieldsTakeTheSpareRoom(); + void heldKeysShowFlippedInTheBar(); }; void TransformInspectorTests::theFieldsShowTheLayerAndTypingPreviews() @@ -73,12 +75,18 @@ void TransformInspectorTests::theFieldsShowTheLayerAndTypingPreviews() QCOMPARE(bar.field("°").text(), QString("0")); QCOMPARE(bar.control("transformSampling").currentText(), QString("High quality")); QCOMPARE(bar.field("X").accessibleName(), QString("X")); - // Typing begins a Ctrl+T edit and previews it. + // Typing previews in an edit of the fields' own. + const int base = bar.session.history.undoCount(); bar.type("X", "90"); - QVERIFY(bar.session.transformEdit().value().persistent); + QVERIFY(!bar.session.transformEdit().value().persistent); + QVERIFY(bar.session.transformEdit().value().fromFields); QCOMPARE(bar.draft().origin, QPointF(90, 40)); QCOMPARE(layerWith(bar.session, bar.layer).transform.origin, QPointF(80, 40)); + // Leaving the field applies it, one step. bar.type("Y", "12.5"); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin, QPointF(90, 40)); + QCOMPARE(bar.session.history.undoCount(), base + 1); + QCOMPARE(bar.session.history.undoName(), QString("Transform Layer")); QCOMPARE(bar.draft().origin, QPointF(90, 12.5)); // Width locked carries the height; unlocked, alone. bar.type("W", "80"); @@ -114,7 +122,7 @@ void TransformInspectorTests::theFieldsShowTheLayerAndTypingPreviews() QVERIFY(bar.draft().flipX); bar.control("flipVertical").click(); QVERIFY(bar.draft().flipY); - // Apply commits the edit as one step; Cancel drops it. + // Apply commits the open edit; Cancel drops it. const LayerTransform drafted = bar.draft(); bar.control("applyTransform").click(); QVERIFY(!bar.session.transformEdit().has_value()); @@ -141,12 +149,16 @@ void TransformInspectorTests::returnEscapeAndArrowsInAField() QTest::keyClick(&x, Qt::Key_Down, Qt::ShiftModifier); QCOMPARE(bar.draft().origin.x(), 71.0); QCOMPARE(x.text(), QString("71")); - // Return hands the canvas the keys; the edit waits there. + // Return applies and hands the canvas the keys. const int request = bar.session.canvasFocusRequest(); + const int steps = bar.session.history.undoCount(); QTest::keyClick(&x, Qt::Key_Return); QVERIFY(!x.hasFocus()); QCOMPARE(bar.session.canvasFocusRequest(), request + 1); - QVERIFY(bar.session.transformEdit().has_value()); + QVERIFY(!bar.session.transformEdit().has_value()); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin.x(), 71.0); + QCOMPARE(bar.session.history.undoCount(), steps + 1); + // Escape throws the fields' change away. x.setFocus(); QTest::keyClicks(&x, "2"); QCOMPARE(x.text(), QString("712")); @@ -154,13 +166,28 @@ void TransformInspectorTests::returnEscapeAndArrowsInAField() QTest::keyClick(&x, Qt::Key_Escape); QVERIFY(!x.hasFocus()); QCOMPARE(bar.session.canvasFocusRequest(), request + 2); - QCOMPARE(bar.draft().origin.x(), 712.0); + QVERIFY(!bar.session.transformEdit().has_value()); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin.x(), 71.0); + QCOMPARE(x.text(), QString("71")); + QCOMPARE(bar.session.history.undoCount(), steps + 1); + // Ctrl+T's edit takes typing and waits through Return and Escape. + bar.session.beginTransform(); + x.setFocus(); + QTest::keyClicks(&x, "5"); + QCOMPARE(bar.draft().origin.x(), 715.0); + QVERIFY(!bar.session.transformEdit().value().fromFields); + QTest::keyClick(&x, Qt::Key_Return); + QCOMPARE(bar.draft().origin.x(), 715.0); + x.setFocus(); + QTest::keyClick(&x, Qt::Key_Escape); + QCOMPARE(bar.draft().origin.x(), 715.0); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin.x(), 71.0); // While a field is typed in, the numbers wait outside. x.setFocus(); LayerTransform moved = bar.draft(); moved.origin.setX(30); bar.session.previewTransform(moved); - QCOMPARE(x.text(), QString("712")); + QCOMPARE(x.text(), QString("715")); QCOMPARE(bar.field("Y").text(), QString("40")); x.clearFocus(); QCOMPARE(x.text(), QString("30")); @@ -172,7 +199,9 @@ void TransformInspectorTests::settingsButtonsAndTheTitleFollowTheSession() QCheckBox &autoSelect = bar.control("transformAutoSelect"); QCheckBox &controls = bar.control("showTransformControls"); QVERIFY(!autoSelect.isChecked() && controls.isChecked()); - QCOMPARE(autoSelect.toolTip(), QString("Select layers by clicking the canvas. When off, hold Ctrl to select a layer.")); + QCOMPARE(autoSelect.toolTip(), QString("Select layers by clicking the canvas. Hold Ctrl to turn it the other way while you click.")); + QCOMPARE(bar.control("locksTransformRatio").toolTip(), + QString("Lock aspect ratio. Hold Shift while dragging a handle to turn it the other way.")); autoSelect.click(); QVERIFY(bar.session.transformAutoSelect()); controls.click(); @@ -258,14 +287,25 @@ void TransformInspectorTests::aMenuBorrowsTheFocusAndTheBarsKeysApplyOrCancel() apply.setFocus(Qt::PopupFocusReason); QCOMPARE(x.text(), QString("12.5")); x.setFocus(); + // Another window in front keeps it too. + apply.setFocus(Qt::ActiveWindowFocusReason); + QCOMPARE(x.text(), QString("12.5")); + QVERIFY(bar.session.transformEdit().has_value()); + x.setFocus(); apply.setFocus(Qt::TabFocusReason); QCOMPARE(x.text(), QString("12.50")); - // Return on Apply applies; Escape on a toggle cancels. - apply.setFocus(); - QVERIFY(apply.hasFocus()); - QTest::keyClick(&apply, Qt::Key_Return); QVERIFY(!bar.session.transformEdit().has_value()); QCOMPARE(layerWith(bar.session, bar.layer).transform.origin.x(), 12.5); + // Return on Apply applies Ctrl+T's; Escape on a toggle cancels. + bar.session.beginTransform(); + bar.type("X", "14"); + apply.setFocus(); + QVERIFY(apply.hasFocus()); + QVERIFY(bar.session.transformEdit().has_value()); + QTest::keyClick(&apply, Qt::Key_Return); + QVERIFY(!bar.session.transformEdit().has_value()); + QCOMPARE(layerWith(bar.session, bar.layer).transform.origin.x(), 14.0); + bar.session.beginTransform(); bar.type("Y", "7"); QCOMPARE(bar.draft().origin.y(), 7.0); QCheckBox &controls = bar.control("showTransformControls"); @@ -306,5 +346,74 @@ void TransformInspectorTests::theFieldsTakeTheSpareRoom() QCOMPARE(flip.width(), flip.sizeHint().width()); } +// Swift's HeldModifiers: Ctrl flips Auto Select, Shift the lock. +void TransformInspectorTests::heldKeysShowFlippedInTheBar() +{ + Bar bar; + QVERIFY(bar.active); + QCheckBox &autoSelect = bar.control("transformAutoSelect"); + QToolButton &lock = bar.control("locksTransformRatio"); + HeldModifiers &held = HeldModifiers::shared(); + autoSelect.setFocus(); + QVERIFY(!autoSelect.isChecked() && lock.isChecked()); + // A modifier's own press counts it, whatever the event says. + QTest::keyPress(&autoSelect, Qt::Key_Control, Qt::NoModifier); + QCOMPARE(held.flags(), Qt::KeyboardModifiers(Qt::ControlModifier)); + QVERIFY(autoSelect.isChecked()); + QVERIFY(!bar.session.transformAutoSelect()); + // Only a change is announced. + QSignalSpy announced(&held, &HeldModifiers::changed); + const auto press = [&](Qt::KeyboardModifiers modifiers) { + QKeyEvent event(QEvent::KeyPress, Qt::Key_A, modifiers); + QApplication::sendEvent(&autoSelect, &event); + }; + press(Qt::ControlModifier); + QCOMPARE(announced.count(), 0); + press(Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(announced.count(), 1); + press(Qt::ControlModifier); + QCOMPARE(announced.count(), 2); + // A click while held writes the other way. + autoSelect.click(); + QVERIFY(bar.session.transformAutoSelect()); + QVERIFY(!autoSelect.isChecked()); + QTest::keyRelease(&autoSelect, Qt::Key_Control, Qt::ControlModifier); + QCOMPARE(held.flags(), Qt::KeyboardModifiers()); + QVERIFY(autoSelect.isChecked()); + // Shift flips the lock likewise; Alt and Meta count, unshown. + QTest::keyPress(&autoSelect, Qt::Key_Shift, Qt::AltModifier | Qt::MetaModifier | Qt::KeypadModifier); + QCOMPARE(held.flags(), Qt::ShiftModifier | Qt::AltModifier | Qt::MetaModifier); + QVERIFY(!lock.isChecked()); + QVERIFY(autoSelect.isChecked()); + lock.click(); + QVERIFY(!bar.session.locksTransformRatio()); + QVERIFY(lock.isChecked()); + QTest::keyRelease(&autoSelect, Qt::Key_Shift, Qt::ShiftModifier); + QVERIFY(!lock.isChecked()); + QTest::keyRelease(&autoSelect, Qt::Key_Alt, Qt::AltModifier); + QTest::keyRelease(&autoSelect, Qt::Key_Meta, Qt::MetaModifier); + QCOMPARE(held.flags(), Qt::KeyboardModifiers()); + // Left presses and releases read modifiers; others do not. + QTest::mousePress(bar.header.get(), Qt::LeftButton, Qt::ControlModifier, QPoint(2, 2)); + QCOMPARE(held.flags(), Qt::KeyboardModifiers(Qt::ControlModifier)); + QTest::mouseRelease(bar.header.get(), Qt::RightButton, Qt::NoModifier, QPoint(2, 2)); + QCOMPARE(held.flags(), Qt::KeyboardModifiers(Qt::ControlModifier)); + QTest::mouseRelease(bar.header.get(), Qt::LeftButton, Qt::NoModifier, QPoint(2, 2)); + QCOMPARE(held.flags(), Qt::KeyboardModifiers()); + // Another app in front lets every key go. + QTest::keyPress(&autoSelect, Qt::Key_Shift, Qt::ShiftModifier); + QVERIFY(lock.isChecked()); + emit qApp->applicationStateChanged(Qt::ApplicationInactive); + QCOMPARE(held.flags(), Qt::KeyboardModifiers()); + QVERIFY(!lock.isChecked()); + // Typing in a field, keys count as none. + QLineEdit &x = bar.field("X"); + x.setFocus(); + QTest::keyPress(&x, Qt::Key_Control, Qt::ControlModifier); + QCOMPARE(held.flags(), Qt::KeyboardModifiers()); + QVERIFY(autoSelect.isChecked() == bar.session.transformAutoSelect()); + QTest::keyRelease(&x, Qt::Key_Control, Qt::NoModifier); +} + QTEST_MAIN(TransformInspectorTests) #include "TransformInspectorTests.moc" diff --git a/tests/TransformPressTests.cpp b/tests/TransformPressTests.cpp index 2783add..78f3dc9 100644 --- a/tests/TransformPressTests.cpp +++ b/tests/TransformPressTests.cpp @@ -51,6 +51,10 @@ private slots: void escapeMidDragRestoresAndAPersistentEditWaitsForReturn(); void losingTheKeysOrTheWheelMidDrag(); void aSelectionDragsAsOneBox(); + void ctrlFlipsAutoSelectWhileHeld_data(); + void ctrlFlipsAutoSelectWhileHeld(); + void ctrlShiftClickAddsALayerWithAutoSelectOn(); + void aCanvasDragAppliesTheFieldsEditFirst(); }; void TransformPressTests::draggingOutsideTheLayerMovesIt() @@ -120,12 +124,13 @@ void TransformPressTests::handlesResizeAndTheGripRotates() // An edge, away from its handle, resizes too. shown.drag(QPointF(300, 170), QPointF(320, 170)); QCOMPARE(layerWith(shown.session, shown.red).transform.size, QSizeF(170, 100)); - // Resizing never snaps: five past the middle stays five past. + // The dragged edge snaps: five past the middle lands there. shown.drag(QPointF(320, 170), QPointF(205, 170)); - QCOMPARE(layerWith(shown.session, shown.red).transform, (LayerTransform{.origin = {150, 100}, .size = {55, 100}})); + QCOMPARE(layerWith(shown.session, shown.red).transform, (LayerTransform{.origin = {150, 100}, .size = {50, 100}})); // The grip: a quarter turn about the middle. const QPointF center = layerWith(shown.session, shown.red).transform.center(); const QPointF grip = center + QPointF(0, -78); + QVERIFY(center == QPointF(175, 150)); shown.drag(grip, center + QPointF(78, 0)); transform = layerWith(shown.session, shown.red).transform; QCOMPARE(transform.rotation, 90.0); @@ -381,5 +386,84 @@ void TransformPressTests::aSelectionDragsAsOneBox() QCOMPARE(session.selectedLayerIDs(), (QSet{shown.red, blue})); } +// A full-canvas blue layer under the red, and active. +static QUuid blueBelow(Canvas &shown) +{ + shown.session.insert(filled(400, 300, qRgba(0, 0, 255, 255), "Blue")); + const QUuid blue = shown.session.activeLayerID().value(); + shown.session.reorderLayers({1}, 0); + if (shown.session.document().value().layers[0].id != blue || shown.session.activeLayerID() != blue) + throw std::runtime_error("the blue layer is not active at the bottom"); + shown.canvas->synchronizeDisplay(); + return blue; +} + +void TransformPressTests::ctrlFlipsAutoSelectWhileHeld_data() +{ + QTest::addColumn("autoSelect"); + QTest::newRow("off") << false; + QTest::newRow("on") << true; +} + +// Swift's commandFlipsAutoSelectWhileHeld: Ctrl turns it the other way. +void TransformPressTests::ctrlFlipsAutoSelectWhileHeld() +{ + QFETCH(bool, autoSelect); + Canvas shown; + const QUuid blue = blueBelow(shown); + shown.session.setTransformAutoSelect(autoSelect); + shown.drag(QPointF(210, 170), QPointF(230, 180), Qt::ControlModifier); + const QUuid moved = autoSelect ? blue : shown.red; + QCOMPARE(shown.session.activeLayerID(), std::optional(moved)); + QCOMPARE(layerWith(shown.session, moved).transform.origin, autoSelect ? QPointF(20, 10) : QPointF(170, 110)); + QCOMPARE(layerWith(shown.session, autoSelect ? shown.red : blue).transform.origin, autoSelect ? QPointF(150, 100) : QPointF(0, 0)); + // Without Ctrl it picks as set. + shown.session.undo(); + shown.session.selectLayer(blue); + shown.drag(QPointF(210, 170), QPointF(230, 180)); + QCOMPARE(shown.session.activeLayerID(), std::optional(autoSelect ? shown.red : blue)); +} + +void TransformPressTests::ctrlShiftClickAddsALayerWithAutoSelectOn() +{ + Canvas shown; + const QUuid blue = blueBelow(shown); + shown.session.setTransformAutoSelect(true); + shown.click(QPointF(210, 170), Qt::ControlModifier | Qt::ShiftModifier); + QCOMPARE(shown.session.selectedLayerIDs(), (QSet{shown.red, blue})); + // Past the active layer's box, Ctrl still keeps it. + shown.session.insert(filled(50, 50, qRgba(0, 255, 0, 255), "Green"), QPointF(330, 20)); + shown.session.selectLayer(shown.red); + shown.drag(QPointF(340, 30), QPointF(353, 30), Qt::ControlModifier); + QCOMPARE(shown.session.activeLayerID(), std::optional(shown.red)); + QCOMPARE(shown.origin(), QPointF(163, 100)); +} + +// The bar's pending value applies first, its own step. +void TransformPressTests::aCanvasDragAppliesTheFieldsEditFirst() +{ + Canvas shown; + EditorSession &session = shown.session; + const int steps = session.history.undoCount(); + session.beginTransform(false, true); + LayerTransform flipped = session.transformEdit().value().draft; + flipped.flipX = true; + session.previewTransform(flipped); + shown.drag(QPointF(20, 20), QPointF(40, 33)); + QVERIFY(!session.transformEdit().has_value()); + QCOMPARE(session.history.undoCount(), steps + 2); + QCOMPARE(shown.origin(), QPointF(170, 113)); + QVERIFY(layerWith(session, shown.red).transform.flipX); + session.undo(); + QCOMPARE(shown.origin(), QPointF(150, 100)); + QVERIFY(layerWith(session, shown.red).transform.flipX); + // A Ctrl+T edit takes the drag into itself. + session.undo(); + session.beginTransform(); + shown.drag(QPointF(20, 20), QPointF(40, 33)); + QCOMPARE(session.transformEdit().value().draft.origin, QPointF(170, 113)); + QCOMPARE(session.history.undoCount(), steps); +} + QTEST_MAIN(TransformPressTests) #include "TransformPressTests.moc"