Move bar: held Ctrl and Shift flip Auto Select and the lock, values apply as one step, resize handles snap (13.13)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 16:56:16 -04:00
1 parent 765e071c0c
commit b50657bcdb
17 files changed
+703 -78

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
public:
SnapGuides transformSnapTargets(const QSet<QUuid> &moving) const;
LayerTransform snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance);
// A resize handle's pointer, nudged so moved edges snap.
QPointF snappedResizePoint(QPointF point, const TransformDrag &drag, bool proportional, const QSet<QUuid> &moving,
double tolerance, const std::function<LayerTransform(QPointF)> &update);
SnapGuides cropSnapTargets() const;
// A drawn corner, each axis to a target within reach.
QPointF snappedPoint(QPointF point, double tolerance);
+52
View File
@@ -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<QUuid> &moving,
double tolerance, const std::function<LayerTransform(QPointF)> &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<Snap> snaps;
if (handle.x() != 0.5)
if (const std::optional<double> x = nearest(edge(draft, true), targets.xs, tolerance))
snaps.push_back({true, *x});
if (handle.y() != 0.5)
if (const std::optional<double> 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
{
+3 -3
View File
@@ -59,7 +59,7 @@ std::optional<LayerTransform> 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<ImageLayer> layer = activeLayer();
@@ -73,11 +73,11 @@ void EditorSession::beginTransform(bool persistent)
QHash<QUuid, LayerTransform> 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();
}
+1 -1
View File
@@ -112,7 +112,7 @@ public:
bool transformsAsGroup() const;
std::vector<ImageLayer> groupTransformMembers() const;
std::optional<LayerTransform> 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).
+2
View File
@@ -71,6 +71,8 @@ struct TransformEdit {
std::optional<Corners> corners;
// Ctrl+T on selected pixels: they float, then merge back.
std::shared_ptr<const FloatingTransform> floating;
// The Move bar's fields: applied once they are done.
bool fromFields = false;
};
// Where a move has just snapped: guides while it lasts.
+30 -18
View File
@@ -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::PressTarget> CanvasView::transformPressLayer(QPointF pixel, Qt::KeyboardModifiers modifiers) const
{
const std::optional<CanvasDocument> &document = m_session.document();
@@ -154,21 +154,23 @@ std::optional<CanvasView::PressTarget> 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<LayerTransform> 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<TransformEdit> &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<QUuid> 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<QUuid> 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();
}
+57
View File
@@ -0,0 +1,57 @@
#include "UI/HeldModifiers.h"
#include <QApplication>
#include <QKeyEvent>
#include <QMenu>
#include <QWidget>
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<QKeyEvent *>(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<QMouseEvent *>(event)->button() == Qt::LeftButton)
update(static_cast<QMouseEvent *>(event)->modifiers());
} else if (type == QEvent::Hide && qobject_cast<QMenu *>(watched)) {
// Nor is one let go while a menu is open.
update(QGuiApplication::queryKeyboardModifiers());
}
return false;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <QObject>
// 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;
};
+41 -18
View File
@@ -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 <cmath>
TransformValueField::TransformValueField(const QString &label, const QString &suffix, double low, double high, EditorSession &session,
std::function<void(double)> change, QWidget *parent)
: QWidget(parent), field(new QLineEdit(this)), m_session(session), m_change(std::move(change))
std::function<void()> finish, std::function<void(double)> 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<QFocusEvent *>(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<QKeyEvent *>(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<TransformEdit> &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<void(LayerTransform &)> &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<void(LayerTransform &)> &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());
+5 -1
View File
@@ -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<void(double)> change, QWidget *parent = nullptr);
std::function<void()> finish, std::function<void(double)> 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<void()> m_finish;
const std::function<void(double)> 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<void(LayerTransform &)> &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();