Merge branch 'main' into port-d

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 18:38:20 -04:00
commit 7c6265af77
141 files changed
+5822 -682

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
#include "UI/CameraRawColorControls.h"
#include <algorithm>
#include <array>
#include <cmath>
// Swift's curve graph drags, picked once at the press.
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::parametricDrag(QPointF at, QSizeF size) const
{
const CameraRawCurveSettings curve = raw().curve;
const double tone = at.x() / std::max(size.width(), 1.0) * 100;
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
// Along the bottom, the nearest divider, the first of equals.
if (at.y() > size.height() - 18) {
size_t nearest = 0;
for (size_t index = 1; index < splits.size(); ++index)
if (std::abs(splits[index] - tone) < std::abs(splits[nearest] - tone))
nearest = index;
return Drag{.kind = Drag::Kind::divider, .index = nearest, .from = at};
}
double CameraRawCurveSettings::*key = tone < splits[0] ? &CameraRawCurveSettings::shadows
: tone < splits[1] ? &CameraRawCurveSettings::darks
: tone < splits[2] ? &CameraRawCurveSettings::lights
: &CameraRawCurveSettings::highlights;
return Drag{.kind = Drag::Kind::region, .key = key, .start = curve.*key, .from = at};
}
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::pointDrag(QPointF at, QSizeF size)
{
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
std::vector<CurvePoint> points = currentPoints();
// On a point, that point; the first of equals.
std::optional<size_t> near;
for (size_t index = 0; index < points.size(); ++index)
if (!near || std::hypot(points[index].x - x, points[index].y - y) < std::hypot(points[*near].x - x, points[*near].y - y))
near = index;
if (near && std::hypot(points[*near].x - x, points[*near].y - y) < 0.055) {
m_selectedPoint = near;
return Drag{.kind = Drag::Kind::point, .index = *near, .from = at};
}
// Else a new point; the ends bound its x.
if (points.size() >= 16 || !std::all_of(points.begin(), points.end(), [x](const CurvePoint &each) { return std::abs(each.x - x) > 0.01; }))
return std::nullopt;
const CurvePoint point{x, y};
points.push_back(point);
std::stable_sort(points.begin(), points.end(), [](const CurvePoint &left, const CurvePoint &right) { return left.x < right.x; });
store(points);
const size_t index = size_t(std::find(points.begin(), points.end(), point) - points.begin());
m_selectedPoint = index;
return Drag{.kind = Drag::Kind::point, .index = index, .from = at};
}
void CameraRawCurveControls::beginDrag(QPointF at, QSizeF size)
{
// Qt may lose a release: every press picks afresh.
const bool parametric = !m_session.filterEdit() || m_session.filterEdit()->rawPanel.curvePage == CameraRawCurvePage::parametric;
m_drag = parametric ? parametricDrag(at, size) : pointDrag(at, size);
continueDrag(at, size);
}
void CameraRawCurveControls::continueDrag(QPointF at, QSizeF size)
{
if (!m_drag)
return;
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
const Drag drag = *m_drag;
switch (drag.kind) {
case Drag::Kind::point: {
std::vector<CurvePoint> points = currentPoints();
if (drag.index >= points.size())
return;
points[drag.index].y = y;
// Ends stay ends; the rest keep their order.
if (drag.index > 0 && drag.index < points.size() - 1) {
// Fix beyond Swift: just inside, so the repair keeps it.
const double inside = 1e-9;
points[drag.index].x = std::min(points[drag.index + 1].x - 0.01 - inside, std::max(points[drag.index - 1].x + 0.01 + inside, x));
}
store(points);
return;
}
case Drag::Kind::divider: {
const double value = x * 100;
update([&drag, value](CameraRawSettings &settings) {
// Below the next divider; normalizing does the rest.
CameraRawCurveSettings &curve = settings.curve;
if (drag.index == 0)
curve.shadowSplit = std::min(value, curve.darkSplit - 2);
else if (drag.index == 1)
curve.darkSplit = std::min(curve.lightSplit - 2, value);
else
curve.lightSplit = value;
});
return;
}
case Drag::Kind::region: {
const double amount = std::min(100.0, std::max(-100.0, drag.start - (at.y() - drag.from.y()) / std::max(size.height(), 1.0) * 200));
update([&drag, amount](CameraRawSettings &settings) { settings.curve.*drag.key = std::round(amount); });
return;
}
}
}
void CameraRawCurveControls::removePoint(QPointF at, QSizeF size)
{
std::vector<CurvePoint> points = currentPoints();
const double x = at.x() / size.width();
// The nearest inner point by x, within 0.04; ends stay.
std::optional<size_t> nearest;
for (size_t index = 1; index + 1 < points.size(); ++index)
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
nearest = index;
if (!nearest || !(std::abs(points[*nearest].x - x) < 0.04))
return;
points.erase(points.begin() + std::ptrdiff_t(*nearest));
m_selectedPoint.reset();
store(points);
}
+35 -82
View File
@@ -87,103 +87,45 @@ protected:
return;
}
painter.setPen(Qt::NoPen);
painter.setBrush(Qt::white);
for (const CurvePoint &point : m_owner.currentPoints())
painter.drawEllipse(QRectF(point.x * w - 4, (1 - point.y) * h - 4, 8, 8));
const std::vector<CurvePoint> points = m_owner.currentPoints();
for (size_t index = 0; index < points.size(); ++index) {
painter.setBrush(m_owner.m_selectedPoint == index ? palette().color(QPalette::Highlight) : QColor(Qt::white));
painter.drawEllipse(QRectF(points[index].x * w - 4, (1 - points[index].y) * h - 4, 8, 8));
}
}
// Swift's two drags: still, a press adds; moved, it drags.
// Swift's one drag: picked at the press, kept until release.
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_press = event->position();
m_owner.beginDrag(event->position(), size());
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (!m_press || !event->buttons().testFlag(Qt::LeftButton))
return;
if (!m_moved && QLineF(*m_press, event->position()).length() < 2)
return;
m_moved = true;
if (parametric())
moveDivider(event->position().x());
else
movePoint(event->position());
if (event->buttons().testFlag(Qt::LeftButton))
m_owner.continueDrag(event->position(), size());
}
void mouseReleaseEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton && m_press && !m_moved && event->position() == *m_press && !parametric())
addPoint(event->position());
m_press.reset();
m_moved = false;
if (event->button() == Qt::LeftButton)
m_owner.m_drag.reset();
}
// The double click's own release must add nothing back.
// The second press drags too; then the point goes.
void mouseDoubleClickEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton && !parametric())
removePoint(event->position());
if (event->button() != Qt::LeftButton)
return;
m_owner.beginDrag(event->position(), size());
if (!parametric())
m_owner.removePoint(event->position(), size());
m_owner.m_drag.reset();
}
private:
void moveDivider(double x)
{
const double value = std::min(98.0, std::max(2.0, x / std::max(double(width()), 1.0) * 100));
const CameraRawCurveSettings curve = m_owner.raw().curve;
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
size_t nearest = 0;
for (size_t index = 1; index < 3; ++index)
if (std::abs(splits[index] - value) < std::abs(splits[nearest] - value))
nearest = index;
m_owner.update([nearest, value](CameraRawSettings &settings) {
(nearest == 0 ? settings.curve.shadowSplit : nearest == 1 ? settings.curve.darkSplit : settings.curve.lightSplit) = value;
});
}
void addPoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
points.push_back({std::min(0.99, std::max(0.01, at.x() / width())), std::min(1.0, std::max(0.0, 1 - at.y() / height()))});
m_owner.store(points);
}
// The nearest inner point by x; ends stay.
std::optional<size_t> nearestInner(const std::vector<CurvePoint> &points, double x) const
{
std::optional<size_t> nearest;
for (size_t index = 1; index + 1 < points.size(); ++index)
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
nearest = index;
return nearest;
}
void movePoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
const double x = at.x() / width();
const std::optional<size_t> index = nearestInner(points, x);
if (!index)
return;
points[*index] = {std::min(0.98, std::max(0.02, x)), std::min(1.0, std::max(0.0, 1 - at.y() / height()))};
m_owner.store(points);
}
void removePoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
const double x = at.x() / width();
const std::optional<size_t> index = nearestInner(points, x);
if (!index || !(std::abs(points[*index].x - x) < 0.04))
return;
points.erase(points.begin() + std::ptrdiff_t(*index));
m_owner.store(points);
}
CameraRawCurveControls &m_owner;
std::optional<QPointF> m_press;
bool m_moved = false;
};
namespace {
@@ -250,6 +192,9 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
presetRow->addStretch(1);
points->addLayout(presetRow);
connect(m_preset, &QComboBox::activated, this, [this](int preset) {
// A fix beyond Swift: a preset's points drop the choice.
if (preset != 0)
m_selectedPoint.reset();
if (preset == 1)
store(CameraRawCurveSettings::linear());
else if (preset == 2)
@@ -258,7 +203,7 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
store(CameraRawCurveSettings::strongContrast());
});
m_refine = new CameraRawRow({.name = QStringLiteral("refineSaturation"), .title = QStringLiteral("Refine Saturation"),
.help = QStringLiteral("How much the RGB curve also changes color strength. Zero keeps it to brightness."), .titleWidth = 88,
.help = QStringLiteral("How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color."), .titleWidth = 88,
.fixedTitle = true, .fieldWidth = 48, .scrub = 1},
[this] { return raw().curve.refineSaturation; },
[this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); },
@@ -334,15 +279,23 @@ void CameraRawCurveControls::synchronize()
m_channelBox->setVisible(point);
const CameraRawPointChannel channel = edit ? edit->rawPanel.pointChannel : CameraRawPointChannel::rgb;
m_channel->button(int(channel))->setChecked(true);
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click the curve to add one. Double-click a point to remove it.")
: QStringLiteral("Drag a divider to change which tones the neighboring sliders affect."));
// Swift's onChange: another channel drops the choice and drag.
if (channel != m_shownChannel) {
m_shownChannel = channel;
m_selectedPoint.reset();
m_drag.reset();
}
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click to add one. Double-click a point to remove it.")
: QStringLiteral("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
m_graph->update();
m_amounts->setVisible(!point);
m_points->setVisible(point);
const std::vector<CurvePoint> points = currentPoints();
// Swift's selectedPoint: the last inner point, else the last.
const CurvePoint selected = points.size() > 2 ? points[points.size() - 2] : points.back();
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(selected.x * 255)).arg(std::lround(selected.y * 255)));
// Swift's selectedPoint: the chosen index, while it exists.
const bool chosen = m_selectedPoint && *m_selectedPoint < points.size();
m_selected->setVisible(chosen);
if (chosen)
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(points[*m_selectedPoint].x * 255)).arg(std::lround(points[*m_selectedPoint].y * 255)));
const QSignalBlocker quiet(m_preset);
m_preset->setCurrentIndex(points == CameraRawCurveSettings::linear() ? 1
: points == CameraRawCurveSettings::mediumContrast() ? 2
+16
View File
@@ -28,9 +28,25 @@ private:
void update(const std::function<void(CameraRawSettings &)> &change);
void store(const std::vector<CurvePoint> &points);
void panel(const std::function<void(CameraRawPanel &)> &change);
// Swift's Drag: a point, divider, or region.
struct Drag {
enum class Kind { point, divider, region } kind;
size_t index = 0;
double CameraRawCurveSettings::*key = nullptr;
double start = 0;
QPointF from;
};
std::optional<Drag> parametricDrag(QPointF at, QSizeF size) const;
std::optional<Drag> pointDrag(QPointF at, QSizeF size);
void beginDrag(QPointF at, QSizeF size);
void continueDrag(QPointF at, QSizeF size);
void removePoint(QPointF at, QSizeF size);
friend class CameraRawCurveGraph;
EditorSession &m_session;
std::optional<Drag> m_drag;
std::optional<size_t> m_selectedPoint;
CameraRawPointChannel m_shownChannel = CameraRawPointChannel::rgb;
QButtonGroup *m_page;
QWidget *m_channelBox;
QButtonGroup *m_channel;
+2
View File
@@ -243,6 +243,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
});
layer->addSeparator();
add(layer, QStringLiteral("groupLayers"), QStringLiteral("Group Selected Layers"), QKeySequence(Qt::CTRL | Qt::Key_G), [this] { session().groupSelectedLayers(); });
add(layer, QStringLiteral("ungroupLayers"), QStringLiteral("Ungroup Layers"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_G), [this] { session().ungroupLayers(); });
add(layer, QStringLiteral("moveOutOfFolder"), QStringLiteral("Move Out of Folder"), QKeySequence(), [this] { session().moveActiveLayerOutOfGroup(); });
add(layer, QStringLiteral("newBlankLayer"), QStringLiteral("New Blank Layer"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N), [this] { session().addBlankLayer(); });
add(layer, QStringLiteral("renameLayer"), QStringLiteral("Rename Layer…"), QKeySequence(), [this] { session().setRenamingLayerID(session().activeLayerID()); });
@@ -395,6 +396,7 @@ void CompositorMenus::synchronize()
action(QStringLiteral("clippingMask"))->setText(active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask"));
action(QStringLiteral("clippingMask"))->setEnabled(s.activeLayerID() && s.canToggleClippingMask(*s.activeLayerID()));
action(QStringLiteral("groupLayers"))->setEnabled(s.canEditLayers());
action(QStringLiteral("ungroupLayers"))->setEnabled(s.canUngroupLayers());
action(QStringLiteral("moveOutOfFolder"))->setEnabled(s.canEditLayers() && active && active->parentID);
action(QStringLiteral("newBlankLayer"))->setEnabled(s.canEditLayers());
action(QStringLiteral("renameLayer"))->setEnabled(s.canEditLayers() && active);
+17 -2
View File
@@ -48,10 +48,25 @@ void FilterSheet::dither()
});
control(QStringLiteral("Pixel Size"), key(&DitherSettings::pixelSize), DitherSettings::pixelSizeLow, DitherSettings::pixelSizeHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("Make each dithered pixel this many pixels across, for a chunky old-screen look"));
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style != DitherStyle::ascii; });
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return usesPixelSize(settings.dither.style); });
control(QStringLiteral("Text Size"), key(&DitherSettings::textSize), DitherSettings::textSizeLow, DitherSettings::textSizeHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("The height of each line of characters"));
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::ascii; });
// Scanlines' four rows.
const auto scanlines = [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::scanlines; };
control(QStringLiteral("Line Spacing"), key(&DitherSettings::lineSpacing), DitherSettings::lineSpacingLow, DitherSettings::lineSpacingHigh, QStringLiteral("px"), 0,
false);
box()->setToolTip(QStringLiteral("How far apart the screen's lines are"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Glow"), key(&DitherSettings::glow), 0, 100, QStringLiteral("%"), 0, false);
box()->setToolTip(QStringLiteral("Light blooming around the lines, like a CRT's phosphors"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Dots"), key(&DitherSettings::dots), 0, 100, QStringLiteral("%"), 0, false);
box()->setToolTip(QStringLiteral("Break the lines into glowing beads"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Wobble"), key(&DitherSettings::wobble), DitherSettings::wobbleLow, DitherSettings::wobbleHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("Make the lines waver sideways down the screen, like a CRT losing sync"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Cell Size"), key(&DitherSettings::cellSize), DitherSettings::cellSizeLow, DitherSettings::cellSizeHigh, QStringLiteral("px"), 0, false);
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); });
control(QStringLiteral("Angle"), key(&DitherSettings::angle), -90, 90, QStringLiteral("°"), 0, false);
@@ -133,7 +148,7 @@ void FilterSheet::dither()
const QSignalBlocker quiet(shape);
shape->setCurrentIndex(int(settings.dither.pixelShape));
});
m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && settings.dither.style != DitherStyle::ascii; });
m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && usesPixelSize(settings.dither.style); });
flag(QStringLiteral("Light on Dark"), QStringLiteral("Draw the marks for the light tones on the dark color, like a glowing screen"),
[](FilterSettings &settings) -> bool & { return settings.dither.lightOnDark; });
m_shownWhen.emplace_back(m_flags.back().first, [](const FilterSettings &settings) { return drawsMarks(settings.dither.style); });
+1 -1
View File
@@ -134,7 +134,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all()
entry("Levels", "l", 1, true), entry("Hue/Saturation", "u", 1, true), entry("Invert Pixels / Mask", "i", 1, true),
entry("Canvas Size", "c", 3, true), entry("Image Size", "i", 3, true), entry("Transform Layer / Selection", "t", 1, true),
entry("Duplicate / Layer via Copy", "j", 1, true), entry("Toggle Clipping Mask", "g", 3, true),
entry("Group Layers", "g", 1, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true),
entry("Group Layers", "g", 1, true), entry("Ungroup Layers", "g", 9, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true),
entry("Move Layer Down", "[", 1, true), entry("Merge Layers", "e", 1, true), entry("Show Grid", "'", 1, true),
entry("Show Guides", ";", 1, true), entry("Show Rulers", "r", 1, true), entry("Snap", ";", 9, true), entry("Lock Guides", ";", 3, true)};
const std::vector<std::pair<const char *, QString>> tools{
+6
View File
@@ -313,6 +313,12 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double
case LayerIcon::dottedCircle: dottedCircle(painter); break;
case LayerIcon::plusMagnifyingGlass: magnifyingGlass(painter, true); break;
case LayerIcon::minusMagnifyingGlass: magnifyingGlass(painter, false); break;
// Swift's xmark at bold weight.
case LayerIcon::xmark:
painter.setPen(QPen(colour, 2.6, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(QPointF(4, 4), QPointF(14, 14));
painter.drawLine(QPointF(14, 4), QPointF(4, 14));
break;
}
painter.restore();
}
+1 -1
View File
@@ -8,7 +8,7 @@
enum class LayerIcon {
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes,
scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass
scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass, xmark
};
namespace LayerIcons {
+58 -2
View File
@@ -1,6 +1,9 @@
#include "UI/LayerMaskMenu.h"
#include "UI/LayerIcons.h"
#include <QEvent>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QPainter>
LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolButton(parent), m_session(session)
{
@@ -8,7 +11,8 @@ LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolBut
setAccessibleName(QStringLiteral("Add layer mask"));
setAutoRaise(true);
setFixedSize(32, 40);
connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(); });
// Swift reads Option from the event that clicked.
connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(!QGuiApplication::keyboardModifiers().testFlag(Qt::AltModifier)); });
connect(&m_session, &EditorSession::changed, this, &LayerMaskMenu::synchronize);
synchronize();
}
@@ -17,7 +21,8 @@ void LayerMaskMenu::synchronize()
{
const std::optional<ImageLayer> active = m_session.activeLayer();
setEnabled(m_session.canEditMask() && !(active && active->mask));
setToolTip(m_session.selection() ? QStringLiteral("Add layer mask (the selection becomes black)") : QStringLiteral("Add layer mask"));
setToolTip(m_session.selection() ? QStringLiteral("Add layer mask revealing the selection (Alt-click to hide it)")
: QStringLiteral("Add layer mask (Alt-click for a black mask)"));
setIcon(LayerIcons::pixmap(LayerIcon::addMask, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio()));
}
@@ -27,3 +32,54 @@ void LayerMaskMenu::changeEvent(QEvent *event)
if (event->type() == QEvent::PaletteChange)
synchronize();
}
MaskAloneBadge::MaskAloneBadge(EditorSession &session, QWidget *parent) : QWidget(parent), m_name(new QLabel(this))
{
setObjectName(QStringLiteral("maskAloneBadge"));
setFixedHeight(26);
const auto font = [](QLabel *label, bool semibold, double alpha) {
QFont face = label->font();
face.setPixelSize(12);
face.setWeight(semibold ? QFont::DemiBold : QFont::Normal);
label->setFont(face);
QPalette colours = label->palette();
colours.setColor(QPalette::WindowText, QColor::fromRgbF(1, 1, 1, alpha));
label->setPalette(colours);
};
auto *icon = new QLabel(this);
icon->setPixmap(LayerIcons::pixmap(LayerIcon::addMask, 11, Qt::white, devicePixelRatioF()));
auto *title = new QLabel(QStringLiteral("Layer Mask"), this);
title->setObjectName(QStringLiteral("maskAloneTitle"));
font(title, true, 1);
m_name->setObjectName(QStringLiteral("maskAloneName"));
m_name->setTextFormat(Qt::PlainText);
m_name->setMaximumWidth(220);
font(m_name, false, 0.6);
auto *close = new QToolButton(this);
close->setObjectName(QStringLiteral("maskAloneClose"));
close->setAutoRaise(true);
close->setIcon(LayerIcons::pixmap(LayerIcon::xmark, 9, Qt::white, devicePixelRatioF()));
close->setToolTip(QStringLiteral("Show the image again (or Alt-click the mask thumbnail)"));
close->setAccessibleName(QStringLiteral("Stop viewing the mask"));
connect(close, &QToolButton::clicked, this, [&session] { session.setViewsMaskAlone(false); });
auto *row = new QHBoxLayout(this);
row->setContentsMargins(11, 0, 8, 0);
row->setSpacing(7);
for (QWidget *widget : std::initializer_list<QWidget *>{icon, title, m_name, close})
row->addWidget(widget);
}
// Swift's byTruncatingTail within 220 points.
void MaskAloneBadge::setLayerName(const QString &name)
{
m_name->setText(m_name->fontMetrics().elidedText(name, Qt::ElideRight, 220));
}
void MaskAloneBadge::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.14), 1));
painter.setBrush(QColor::fromRgbF(0, 0, 0, 0.75));
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 13, 13);
}
+16 -1
View File
@@ -1,8 +1,9 @@
#pragma once
#include "Document/EditorSession.h"
#include <QLabel>
#include <QToolButton>
// One click adds a mask: white, or the selection black.
// One click adds a mask revealing the selection; Alt, hiding.
class LayerMaskMenu : public QToolButton {
Q_OBJECT
public:
@@ -16,3 +17,17 @@ private:
EditorSession &m_session;
};
// While a mask shows alone: whose, and a way back.
class MaskAloneBadge : public QWidget {
Q_OBJECT
public:
explicit MaskAloneBadge(EditorSession &session, QWidget *parent = nullptr);
void setLayerName(const QString &name);
protected:
void paintEvent(QPaintEvent *event) override;
private:
QLabel *const m_name;
};
+6 -8
View File
@@ -70,9 +70,10 @@ LayerThumbnailButton::LayerThumbnailButton(NativeLayerList &list, bool maskTarge
setAutoRaise(true);
}
void LayerThumbnailButton::setTargeted(bool targeted)
void LayerThumbnailButton::setTargeted(bool targeted, bool alone)
{
m_targeted = targeted;
m_alone = alone;
QWidget::update();
}
@@ -155,12 +156,9 @@ void LayerThumbnailButton::mouseReleaseEvent(QMouseEvent *event)
if (!m_altMaskPress || event->button() != Qt::LeftButton)
return;
endAltMaskPress();
// Released where pressed: a click, not a drag; Shift toggles.
if (!rect().contains(event->position().toPoint()))
return;
m_list.session().selectLayerTarget(layerID, true);
if (event->modifiers().testFlag(Qt::ShiftModifier))
m_list.session().toggleLayerMask();
// Released where pressed: the mask alone, or the image again.
if (rect().contains(event->position().toPoint()))
m_list.session().toggleMaskAlone(layerID);
}
// With a modifier the second press is the button's again.
@@ -179,7 +177,7 @@ void LayerThumbnailButton::paintEvent(QPaintEvent *)
if (!m_targeted)
return;
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(palette().color(QPalette::Highlight), 2));
painter.setPen(QPen(m_alone ? QColor(Qt::white) : palette().color(QPalette::Highlight), 2));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 3, 3);
}
+2 -2
View File
@@ -31,7 +31,7 @@ LayerCell::LayerCell(NativeLayerList &list)
connect(m_disclosure, &QToolButton::clicked, this, [this] { m_list.session().toggleGroupExpansion(m_layerID); });
m_thumbnail->setObjectName(QStringLiteral("layerThumbnail"));
m_maskThumbnail->setObjectName(QStringLiteral("maskThumbnail"));
m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
m_link->setObjectName(QStringLiteral("maskLink"));
m_link->setAutoRaise(true);
m_link->setFixedSize(9, 20);
@@ -159,7 +159,7 @@ void LayerCell::updateTarget()
const bool active = session.activeLayerID() == m_layerID && session.selectedLayerIDs().size() == 1;
const bool mask = session.isMaskSelected();
m_thumbnail->setTargeted(active && !mask);
m_maskThumbnail->setTargeted(active && mask);
m_maskThumbnail->setTargeted(active && mask, active && session.maskAloneLayer());
QWidget::update();
}
+34 -2
View File
@@ -13,6 +13,7 @@
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <cmath>
const QString NativeLayerList::maskType = QStringLiteral("com.compositor.layer-mask");
const QString NativeLayerList::sourceType = QStringLiteral("com.compositor.layer-list");
@@ -299,6 +300,37 @@ QCursor NativeLayerList::clippingCursor(bool releasing, double ratio)
return QCursor(pixmap, 3, 3);
}
// Swift's eye.fill: an almond, a ring round the pupil.
QCursor NativeLayerList::showMaskCursor(double ratio)
{
const QCursor base = CanvasView::duplicateCursor(ratio);
const QPointF hotSpot = base.hotSpot();
const QRectF eye(hotSpot + QPointF(15, 18), QSizeF(7.5, 5.5));
const QSizeF baseSize = base.pixmap().deviceIndependentSize();
const QSize size(int(std::ceil(std::max(baseSize.width(), eye.right() + 2))), int(std::ceil(std::max(baseSize.height(), eye.bottom() + 2))));
QPixmap pixmap(size * ratio);
pixmap.setDevicePixelRatio(ratio);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
QPainterPath shape;
shape.setFillRule(Qt::OddEvenFill);
shape.moveTo(eye.left(), eye.center().y());
shape.quadTo(eye.center().x(), eye.top() - eye.height() / 2, eye.right(), eye.center().y());
shape.quadTo(eye.center().x(), eye.bottom() + eye.height() / 2, eye.left(), eye.center().y());
shape.addEllipse(eye.center(), 1.9, 1.9);
shape.addEllipse(eye.center(), 1.1, 1.1);
// The eye first, haloed, so the arrows sit in front.
for (int step = 0; step < 16; ++step) {
const double angle = step * M_PI / 8;
painter.fillPath(shape.translated(std::cos(angle), std::sin(angle)), Qt::white);
}
painter.fillPath(shape, Qt::black);
painter.drawPixmap(QPointF(0, 0), base.pixmap());
painter.end();
return QCursor(pixmap, int(hotSpot.x()), int(hotSpot.y()));
}
QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const
{
const int row = rowAt(listPoint);
@@ -317,9 +349,9 @@ QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modif
return QCursor(Qt::ArrowCursor);
}
const bool editable = m_session.canEditLayers();
// A mask copies, the row duplicates, the strip clips.
// A mask shows alone, the row duplicates, the strip clips.
if (cell.maskThumbnail().isVisible() && cell.maskThumbnail().geometry().contains(inCell))
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
return showMaskCursor(ratio);
if (!isClippingZone(cell, inCell))
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
if (!m_session.canToggleClippingMask(layer.id))
+4
View File
@@ -1,5 +1,6 @@
#include "UI/NativeLayerList.h"
#include <QMenu>
#include <algorithm>
// Swift's table-level context menu (1.2.5): entries and validation.
std::unique_ptr<QMenu> NativeLayerList::contextMenu(QUuid id)
@@ -40,6 +41,9 @@ std::unique_ptr<QMenu> NativeLayerList::contextMenu(QUuid id)
entry(*menu, "groupLayers", QStringLiteral("Group Selected Layers"),
session.canEditLayers() && session.document() && session.document()->layers.size() < 10'000 && !session.selectedLayerIDs().isEmpty(),
[&session] { session.groupSelectedLayers(); });
// A folder right-clicked can be ungrouped.
if (std::ranges::any_of(session.document()->layers, [id](const ImageLayer &layer) { return layer.id == id && layer.isGroup; }))
entry(*menu, "ungroupLayers", QStringLiteral("Ungroup Layers"), session.canUngroupLayers(), [&session] { session.ungroupLayers(); });
entry(*menu, "moveOut", QStringLiteral("Move Out of Folder"), session.canEditLayers() && active && active->parentID,
[&session] { session.moveActiveLayerOutOfGroup(); });
entry(*menu, "mergeLayers", session.mergeTitle(), session.canMergeLayers(), [&session] { session.mergeLayers(); });
+5 -2
View File
@@ -43,8 +43,8 @@ public:
LayerThumbnailButton(NativeLayerList &list, bool maskTarget, QWidget *parent);
QUuid layerID;
const bool isMaskTarget;
// An accent ring while the target is selected.
void setTargeted(bool targeted);
// An accent ring while targeted; white while shown alone.
void setTargeted(bool targeted, bool alone = false);
bool isTargeted() const { return m_targeted; }
~LayerThumbnailButton() override;
@@ -63,6 +63,7 @@ private:
NativeLayerList &m_list;
bool m_targeted = false;
bool m_alone = false;
// Alt on a mask: chosen on release, or dragged.
std::optional<QPoint> m_altMaskPress;
};
@@ -240,6 +241,8 @@ public:
// The cursor Alt asks for here: clip, duplicate, arrow.
QCursor cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const;
static QCursor clippingCursor(bool releasing, double ratio);
// Alt over a mask: the duplicate pointer, an eye behind.
static QCursor showMaskCursor(double ratio);
protected:
bool event(QEvent *event) override;
+95 -4
View File
@@ -5,9 +5,13 @@
#include <QBuffer>
#include <QClipboard>
#include <QGridLayout>
#include <QActionGroup>
#include <QImageReader>
#include <QMenu>
#include <QPainter>
#include <QShortcut>
#include <QVBoxLayout>
#include <algorithm>
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
@@ -20,12 +24,58 @@ QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::C
label->setForegroundRole(role);
return label;
}
// Swift's More button: three dots drawn exactly, flush right.
class MoreButton : public QToolButton {
public:
using QToolButton::QToolButton;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(palette().color(QPalette::WindowText));
const double dot = 2.5, top = (height() - 5 * dot) / 2;
for (int index = 0; index < 3; ++index)
painter.drawEllipse(QRectF(width() - dot, top + 2 * dot * index, dot, dot));
}
};
}
const std::vector<std::vector<CanvasPreset>> CanvasPreset::groups = {
{
{QStringLiteral("4K"), 3840, 2160},
{QStringLiteral("1440p"), 2560, 1440},
{QStringLiteral("1080p"), 1920, 1080},
},
{
{QStringLiteral("iPhone 18 Pro"), 1206, 2622},
{QStringLiteral("iPhone 18 Pro Max"), 1320, 2868},
{QStringLiteral("MacBook Pro 14\""), 3024, 1964},
{QStringLiteral("MacBook Pro 16\""), 3456, 2234},
{QStringLiteral("Studio Display"), 5120, 2880},
},
{
{QStringLiteral("Instagram Square"), 1080, 1080},
{QStringLiteral("Instagram Portrait"), 1080, 1350},
{QStringLiteral("Instagram Story"), 1080, 1920},
{QStringLiteral("YouTube Thumb"), 1080, 608},
},
};
const std::vector<CanvasPreset> CanvasPreset::all = [] {
std::vector<CanvasPreset> presets;
for (const std::vector<CanvasPreset> &group : groups)
presets.insert(presets.end(), group.begin(), group.end());
return presets;
}();
NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent)
: QWidget(parent), m_session(session), m_onCreate(std::move(onCreate)), m_width(new QLineEdit(QStringLiteral("1920"), this)),
m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)),
m_create(new QPushButton(QStringLiteral("Create canvas"), this))
m_create(new QPushButton(QStringLiteral("Create canvas"), this)), m_presets(new MoreButton(this))
{
// Swift's fields stretch the sheet to its full 500 points.
setFixedWidth(500);
@@ -62,17 +112,46 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
auto *column = new QVBoxLayout(this);
column->setContentsMargins(28, 28, 28, 28);
column->setSpacing(24);
auto *heading = new QVBoxLayout;
heading->setSpacing(6);
auto *heading = new QHBoxLayout;
heading->addWidget(text(QStringLiteral("New canvas"), 17, QFont::DemiBold, QPalette::WindowText, this));
heading->addWidget(text(QStringLiteral("A blank space for your next composition."), 13, QFont::Normal, QPalette::PlaceholderText, this));
heading->addStretch(1);
heading->addWidget(m_presets);
column->addLayout(heading);
column->addLayout(fields);
column->addWidget(m_note);
column->addLayout(buttons);
m_presets->setObjectName(QStringLiteral("presetSizes"));
m_presets->setFixedSize(28, 28);
m_presets->setPopupMode(QToolButton::InstantPopup);
m_presets->setToolTip(QStringLiteral("Preset sizes for screens and common formats"));
m_presets->setAccessibleName(QStringLiteral("Preset sizes"));
auto *menu = new QMenu(m_presets);
auto *choices = new QActionGroup(menu);
// Custom changes nothing; the match stays checked.
QAction *custom = choices->addAction(menu->addAction(QStringLiteral("Custom")));
custom->setCheckable(true);
custom->setData(-1);
connect(custom, &QAction::triggered, this, &NewCanvasSheet::checkPreset);
int index = 0;
for (const std::vector<CanvasPreset> &group : CanvasPreset::groups) {
menu->addSeparator();
for (const CanvasPreset &preset : group) {
QAction *action = choices->addAction(menu->addAction(preset.title));
action->setCheckable(true);
action->setData(index++);
connect(action, &QAction::triggered, this, [this, preset] {
m_width->setText(QString::number(preset.width));
m_height->setText(QString::number(preset.height));
});
}
}
m_presets->setMenu(menu);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset);
// Return anywhere on the sheet is the default action.
std::vector<QShortcut *> returns;
for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter})
@@ -90,6 +169,18 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
connect(&m_session, &EditorSession::changed, this, &NewCanvasSheet::synchronize);
synchronize();
validate();
checkPreset();
}
void NewCanvasSheet::checkPreset()
{
const auto matched = std::ranges::find_if(CanvasPreset::all, [this](const CanvasPreset &preset) {
return QString::number(preset.width) == m_width->text() && QString::number(preset.height) == m_height->text();
});
const QVariant index(matched == CanvasPreset::all.end() ? -1 : int(matched - CanvasPreset::all.begin()));
for (QAction *action : m_presets->menu()->actions())
if (action->data() == index)
action->setChecked(true);
}
void NewCanvasSheet::synchronize()
+15
View File
@@ -4,8 +4,20 @@
#include <QLineEdit>
#include <QMimeData>
#include <QPushButton>
#include <QToolButton>
#include <QWidget>
#include <functional>
#include <vector>
// New Canvas sizes in pixels, upright as held.
struct CanvasPreset {
QString title;
int width;
int height;
// Resolutions, Apple screens, then social formats; the menu divides them.
static const std::vector<std::vector<CanvasPreset>> groups;
static const std::vector<CanvasPreset> all;
};
// The welcome: a new canvas, a project or an image.
class NewCanvasSheet : public QWidget {
@@ -23,6 +35,8 @@ private:
void synchronize();
void validate();
void create();
// Checks the preset the fields match, else Custom.
void checkPreset();
EditorSession &m_session;
const std::function<void(int, int)> m_onCreate;
@@ -30,5 +44,6 @@ private:
QLineEdit *const m_height;
QLabel *const m_note;
QPushButton *const m_create;
QToolButton *const m_presets;
bool m_suggestedClipboardSize = false;
};
+58
View File
@@ -0,0 +1,58 @@
#include "UI/ProjectTabLayout.h"
#include <algorithm>
double ProjectTabOverflow::contentWidth() const
{
// A pill always has a tab after it.
return visible.empty() ? 0 : visible.back().x + visible.back().width;
}
QString projectTabOverflowLabel(int hiddenCount)
{
return hiddenCount == 1 ? QStringLiteral("1 more tab") : QStringLiteral("%1 more tabs").arg(hiddenCount);
}
ProjectTabOverflow projectTabOverflow(const std::vector<QUuid> &order, const std::map<QUuid, double> &widths, QUuid selectedID,
double availableWidth, const std::function<double(int)> &pillWidth)
{
if (order.empty())
return {};
const auto span = [&](auto first, auto last) {
double total = 0;
for (auto id = first; id != last; ++id)
total += widths.at(*id);
// Never asked of no tabs.
return total + projectTabSpacing * double(std::distance(first, last) - 1);
};
const auto place = [&](const std::vector<QUuid> &ids, double x) {
std::vector<ProjectTabSlot> placed;
for (const QUuid &id : ids) {
placed.push_back({id, x, widths.at(id)});
x += widths.at(id) + projectTabSpacing;
}
return placed;
};
// Not yet measured, or everything fits: no pill.
if (availableWidth <= 0 || span(order.begin(), order.end()) <= availableWidth)
return {place(order, 0), {}, std::nullopt};
qsizetype shown = qsizetype(order.size());
while (shown > 1) {
const qsizetype hidden = qsizetype(order.size()) - shown;
if (pillWidth(int(hidden)) + projectTabSpacing + span(order.end() - shown, order.end()) <= availableWidth)
break;
shown -= 1;
}
std::vector<QUuid> visibleIDs(order.end() - shown, order.end()), hiddenIDs(order.begin(), order.end() - shown);
if (const auto selected = std::ranges::find(hiddenIDs, selectedID); selected != hiddenIDs.end()) {
hiddenIDs.erase(selected);
hiddenIDs.push_back(visibleIDs[0]);
visibleIDs[0] = selectedID;
// A wider selected tab: the tabs after it make room.
while (visibleIDs.size() > 1 && pillWidth(int(hiddenIDs.size())) + projectTabSpacing + span(visibleIDs.begin(), visibleIDs.end()) > availableWidth) {
hiddenIDs.push_back(visibleIDs[1]);
visibleIDs.erase(visibleIDs.begin() + 1);
}
}
const double pill = pillWidth(int(hiddenIDs.size()));
return {place(visibleIDs, pill + projectTabSpacing), hiddenIDs, ProjectTabPillSlot{0, pill}};
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <QString>
#include <QUuid>
#include <functional>
#include <map>
#include <optional>
#include <vector>
// Between pills, and after the overflow pill.
inline constexpr double projectTabSpacing = 6;
// A tab's place in the strip.
struct ProjectTabSlot {
QUuid id;
double x;
double width;
bool operator==(const ProjectTabSlot &) const = default;
};
// The overflow pill's place: it is no tab.
struct ProjectTabPillSlot {
double x;
double width;
bool operator==(const ProjectTabPillSlot &) const = default;
};
// What the strip draws: tabs that fit, the rest.
struct ProjectTabOverflow {
std::vector<ProjectTabSlot> visible;
std::vector<QUuid> hiddenIDs;
std::optional<ProjectTabPillSlot> pill;
double contentWidth() const;
bool operator==(const ProjectTabOverflow &) const = default;
};
// "N more tabs", singular for one.
QString projectTabOverflowLabel(int hiddenCount);
// Drops tabs from the front until the rest fit.
ProjectTabOverflow projectTabOverflow(const std::vector<QUuid> &order, const std::map<QUuid, double> &widths, QUuid selectedID,
double availableWidth, const std::function<double(int)> &pillWidth);
+208
View File
@@ -0,0 +1,208 @@
#include "UI/ProjectTabs.h"
#include <QPropertyAnimation>
#include <algorithm>
#include <cmath>
ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent)
: QWidget(parent), m_workspace(workspace), m_pill(new OverflowTabsPill(workspace, this))
{
setObjectName(QStringLiteral("projectTabs"));
setAccessibleName(QStringLiteral("Project tabs"));
setFixedHeight(34);
// The toolbar's free width; the toolbar shows through.
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize);
synchronize();
}
// Every tab in a row, as if unmeasured.
QSize ProjectTabStrip::sizeHint() const
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
return {int(std::ceil(projectTabOverflow(order, widths(), m_workspace.selectedID(), 0, &OverflowTabsPill::pillWidth).contentWidth())), 34};
}
QList<ProjectTabButton *> ProjectTabStrip::buttons() const
{
return QList<ProjectTabButton *>(m_buttons.begin(), m_buttons.end());
}
void ProjectTabStrip::synchronize()
{
// Buttons follow the tabs: kept where the tab stays.
std::vector<ProjectTabButton *> kept;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs()) {
const auto found = std::ranges::find(m_buttons, tab, &ProjectTabButton::tab);
ProjectTabButton *button = nullptr;
if (found == m_buttons.end()) {
const QUuid id = tab->id;
button = new ProjectTabButton(m_workspace, tab, [this, id](TabDragPhase phase, double translation) { handleReorder(id, phase, translation); }, this);
// A title or dot changes the widths.
connect(&tab->session, &EditorSession::changed, button, [this] { layOut(); });
} else {
button = *found;
m_buttons.erase(found);
}
button->synchronize();
kept.push_back(button);
}
for (ProjectTabButton *gone : m_buttons)
delete gone;
m_buttons = std::move(kept);
// A drag whose tab closed under it is over.
if (m_reorder && !m_workspace.tab(m_reorder->id))
m_reorder.reset();
layOut();
}
std::map<QUuid, double> ProjectTabStrip::widths() const
{
std::map<QUuid, double> result;
for (const ProjectTabButton *button : m_buttons)
result.insert({button->tab->id, button->sizeHint().width()});
return result;
}
ProjectTabOverflow ProjectTabStrip::overflow() const
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
return projectTabOverflow(order, widths(), m_workspace.selectedID(), width(), &OverflowTabsPill::pillWidth);
}
void ProjectTabStrip::layOut(bool animated)
{
const ProjectTabOverflow layout = overflow();
m_pill->setVisible(layout.pill.has_value());
if (layout.pill) {
m_pill->setHiddenIDs(layout.hiddenIDs);
m_pill->setGeometry(int(layout.pill->x), 3, int(layout.pill->width), 28);
}
for (ProjectTabButton *button : m_buttons) {
const auto slot = std::ranges::find(layout.visible, button->tab->id, &ProjectTabSlot::id);
button->setVisible(slot != layout.visible.end());
if (slot == layout.visible.end())
continue;
button->resize(int(slot->width), 28);
const QPoint target(int(std::round(renderX(*slot, layout.contentWidth()))), 3);
// The dragged tab follows the pointer; the others ease aside.
auto *slide = button->findChild<QPropertyAnimation *>(QString(), Qt::FindDirectChildrenOnly);
const bool eases = animated && !(m_reorder && m_reorder->id == button->tab->id);
if (slide && (!eases || slide->endValue() != QVariant(target)))
delete std::exchange(slide, nullptr);
if (!eases)
button->move(target);
else if (!slide && button->pos() != target) {
slide = new QPropertyAnimation(button, "pos", button);
slide->setDuration(150);
slide->setEasingCurve(QEasingCurve::OutQuad);
slide->setEndValue(target);
slide->start(QAbstractAnimation::DeleteWhenStopped);
}
}
updateGeometry();
}
double ProjectTabStrip::renderX(const ProjectTabSlot &slot, double contentWidth) const
{
if (!m_reorder)
return slot.x;
if (slot.id == m_reorder->id)
return std::min(std::max(m_reorder->originX + m_reorder->translation, m_reorder->startX), std::max(m_reorder->startX, contentWidth - slot.width));
const auto index = std::ranges::find(m_reorder->others, slot.id);
if (index == m_reorder->others.end())
return slot.x;
const double x = m_reorder->compactedX.at(slot.id);
return index - m_reorder->others.begin() >= m_reorder->targetIndex ? x + m_reorder->widths.at(m_reorder->id) + projectTabSpacing : x;
}
// The gap nearest the dragged tab: where it lands.
int ProjectTabStrip::Reorder::nearestSlot() const
{
const double x = originX + translation;
std::vector<double> gaps;
for (const QUuid &other : others)
gaps.push_back(compactedX.at(other));
gaps.push_back(others.empty() ? startX : compactedX.at(others.back()) + widths.at(others.back()) + projectTabSpacing);
return int(std::ranges::min_element(gaps, {}, [x](double slot) { return std::abs(slot - x); }) - gaps.begin());
}
void ProjectTabStrip::handleReorder(QUuid id, TabDragPhase phase, double translation)
{
if (phase == TabDragPhase::changed) {
// Another tab's drag lost its release, as Swift's timer finds.
if (m_reorder && m_reorder->id != id)
m_reorder.reset();
if (!m_reorder) {
if (!m_workspace.canSwitch() || std::abs(translation) < 3)
return;
// Dragging a tab selects it, as in Safari.
m_workspace.select(id);
m_reorder = makeReorder(id);
// Above the tabs it crosses.
for (ProjectTabButton *button : m_buttons) {
if (button->tab->id == id)
button->raise();
}
}
m_reorder->translation = translation;
m_reorder->targetIndex = m_reorder->nearestSlot();
layOut(true);
return;
}
// Busy by the drop: the tabs go back.
if (m_reorder && m_reorder->id == id && m_workspace.canSwitch())
commitReorder(*m_reorder);
m_reorder.reset();
layOut(true);
}
ProjectTabStrip::Reorder ProjectTabStrip::makeReorder(QUuid id) const
{
const ProjectTabOverflow layout = overflow();
Reorder state{.id = id, .others = {}, .widths = widths(), .compactedX = {}, .startX = layout.visible.empty() ? 0 : layout.visible.front().x,
.originX = 0};
double x = state.startX;
state.originX = state.startX;
for (const ProjectTabSlot &slot : layout.visible) {
if (slot.id == id) {
state.originX = slot.x;
continue;
}
state.others.push_back(slot.id);
state.compactedX.insert({slot.id, x});
x += state.widths.at(slot.id) + projectTabSpacing;
}
return state;
}
// Only the tabs visible as the drag began ever move.
void ProjectTabStrip::commitReorder(const Reorder &state)
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
// A tab closed mid-drag has no place.
const auto position = [&order](QUuid id) -> std::optional<qsizetype> {
const auto found = std::ranges::find(order, id);
return found == order.end() ? std::nullopt : std::optional(found - order.begin());
};
const qsizetype from = position(state.id).value();
qsizetype target = from;
if (const auto neighbor = state.targetIndex < int(state.others.size()) ? position(state.others[size_t(state.targetIndex)]) : std::nullopt)
target = *neighbor;
else if (const auto last = state.others.empty() ? std::nullopt : position(state.others.back()))
target = *last + 1;
if (from < target)
target -= 1;
m_workspace.moveTab(state.id, int(target));
}
void ProjectTabStrip::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
layOut();
}
+87 -71
View File
@@ -6,11 +6,12 @@
#include <QEvent>
#include <QFontMetricsF>
#include <QHBoxLayout>
#include <QMenu>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QPainterPath>
#include <QStyleOptionToolButton>
#include <QStylePainter>
#include <QTimer>
namespace {
QFont tabFont(bool active)
@@ -47,8 +48,10 @@ int labelWidth(const QString &title, bool active, bool modified)
}
}
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent)
: QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new TabTitle(this)), m_close(new QToolButton(this))
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, std::function<void(TabDragPhase, double)> onReorder,
QWidget *parent)
: QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_onReorder(std::move(onReorder)), m_select(new TabTitle(this)),
m_close(new QToolButton(this))
{
// 28 high: the strip's 34 less its margins.
m_select->setObjectName(QStringLiteral("selectTab"));
@@ -65,6 +68,7 @@ ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<
row->addWidget(m_select);
row->addWidget(m_close);
setAcceptDrops(true);
m_select->installEventFilter(this);
connect(m_select, &QToolButton::clicked, this, [this] { m_workspace.select(this->tab->id); });
connect(m_close, &QToolButton::clicked, this, [this] { m_workspace.close(this->tab->id); });
// The strip syncs every button on the workspace's news.
@@ -93,6 +97,26 @@ void ProjectTabButton::synchronize()
setProperty("active", active);
}
// A press moved three points reorders; measured in the window.
bool ProjectTabButton::eventFilter(QObject *watched, QEvent *event)
{
const auto *mouse = static_cast<QMouseEvent *>(event);
if (event->type() == QEvent::MouseButtonPress && mouse->button() == Qt::LeftButton) {
m_press = mouse->globalPosition();
m_dragging = false;
} else if (event->type() == QEvent::MouseMove && m_press && mouse->buttons().testFlag(Qt::LeftButton)) {
m_dragging = m_dragging || QLineF(*m_press, mouse->globalPosition()).length() >= 3;
if (m_dragging)
m_onReorder(TabDragPhase::changed, mouse->globalPosition().x() - m_press->x());
} else if (event->type() == QEvent::MouseButtonRelease && mouse->button() == Qt::LeftButton && m_press) {
const double translation = mouse->globalPosition().x() - m_press->x();
m_press.reset();
if (std::exchange(m_dragging, false))
m_onReorder(TabDragPhase::ended, translation);
}
return QWidget::eventFilter(watched, event);
}
// Another tab's layer, unless Alt duplicates it, or files.
bool ProjectTabButton::acceptsDrop(const QMimeData &data) const
{
@@ -237,82 +261,74 @@ void NewCanvasButton::dropEvent(QDropEvent *event)
m_workspace.receiveProviders(*event->mimeData(), std::nullopt);
}
ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent)
: QScrollArea(parent), m_workspace(workspace), m_row(new QWidget(this))
OverflowTabsPill::OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent) : QToolButton(parent), m_workspace(workspace)
{
setObjectName(QStringLiteral("projectTabs"));
setAccessibleName(QStringLiteral("Project tabs"));
setFixedHeight(34);
setFrameShape(QFrame::NoFrame);
// The toolbar shows through; a stylesheet would freeze the palette.
setAutoFillBackground(false);
viewport()->setAutoFillBackground(false);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto *row = new QHBoxLayout(m_row);
row->setContentsMargins(0, 3, 0, 3);
row->setSpacing(6);
row->addStretch(1);
setWidget(m_row);
// setWidget turns the row's fill on; the toolbar shows through.
m_row->setAutoFillBackground(false);
m_row->installEventFilter(this);
connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize);
synchronize();
setObjectName(QStringLiteral("projectTabsOverflow"));
setFocusPolicy(Qt::NoFocus);
QFont font;
font.setPixelSize(12);
font.setWeight(QFont::Medium);
setFont(font);
connect(this, &QToolButton::pressed, this, &OverflowTabsPill::showMenu);
}
QList<ProjectTabButton *> ProjectTabStrip::buttons() const
double OverflowTabsPill::pillWidth(int hiddenCount)
{
QList<ProjectTabButton *> result;
for (int index = 0; index < m_row->layout()->count(); ++index) {
if (auto *button = qobject_cast<ProjectTabButton *>(m_row->layout()->itemAt(index)->widget()))
result << button;
}
return result;
QFont font;
font.setPixelSize(12);
font.setWeight(QFont::Medium);
// Leading, gap before the chevron, chevron, trailing.
return std::ceil(QFontMetricsF(font).horizontalAdvance(projectTabOverflowLabel(hiddenCount))) + 11 + 4 + 10 + 11;
}
void ProjectTabStrip::synchronize()
void OverflowTabsPill::setHiddenIDs(std::vector<QUuid> hiddenIDs)
{
// Buttons follow the tabs: kept where the tab stays.
auto *row = static_cast<QHBoxLayout *>(m_row->layout());
QList<ProjectTabButton *> shown = buttons();
int position = 0;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs()) {
const auto kept = std::find_if(shown.begin(), shown.end(), [&](ProjectTabButton *button) { return button->tab == tab; });
ProjectTabButton *button = kept == shown.end() ? new ProjectTabButton(m_workspace, tab, m_row) : *kept;
if (kept != shown.end())
shown.erase(kept);
row->removeWidget(button);
row->insertWidget(position, button);
button->show();
button->synchronize();
position += 1;
}
for (ProjectTabButton *gone : shown)
delete gone;
// The row is as wide as its tabs, never squeezed.
m_row->resize(m_row->sizeHint());
showFront();
m_hiddenIDs = std::move(hiddenIDs);
const QString label = projectTabOverflowLabel(int(m_hiddenIDs.size()));
setText(label);
setToolTip(label);
setAccessibleName(label);
update();
}
bool ProjectTabStrip::eventFilter(QObject *watched, QEvent *event)
// Below the pill: hidden tabs in order, unsaved marked.
void OverflowTabsPill::showMenu()
{
// A title changed size: the row takes its new width.
if (event->type() == QEvent::LayoutRequest)
m_row->resize(m_row->sizeHint());
return QScrollArea::eventFilter(watched, event);
}
// Only a new front tab scrolls into view.
void ProjectTabStrip::showFront()
{
if (std::exchange(m_shownFront, m_workspace.selectedID()) == m_workspace.selectedID())
setDown(false);
if (!m_workspace.canSwitch())
return;
// Posted, as Swift's: the row's layout request runs first.
QTimer::singleShot(0, this, [this] {
for (ProjectTabButton *button : buttons()) {
if (button->tab->id == m_workspace.selectedID())
ensureWidgetVisible(button, 0, 0);
}
});
auto *menu = new QMenu(this);
menu->setObjectName(QStringLiteral("projectTabsOverflowMenu"));
menu->setAttribute(Qt::WA_DeleteOnClose);
for (const QUuid &id : m_hiddenIDs) {
const std::shared_ptr<ProjectTab> tab = m_workspace.tab(id);
const QString title = (tab->session.isModified() ? QStringLiteral("• ") : QString()) + tab->title();
connect(menu->addAction(title), &QAction::triggered, this, [this, id] { m_workspace.select(id); });
}
menu->popup(mapToGlobal(QPoint(0, height() + 4)));
}
void OverflowTabsPill::paintEvent(QPaintEvent *)
{
// An inactive tab's capsule; the label dims while busy.
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QColor fill = palette().color(QPalette::WindowText), edge = fill;
fill.setAlphaF(0.035);
edge.setAlphaF(0.08);
painter.setPen(QPen(edge, 1));
painter.setBrush(fill);
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 14, 14);
painter.setOpacity(m_workspace.canSwitch() ? 1 : 0.5);
painter.setPen(palette().color(QPalette::ButtonText));
painter.drawText(QRectF(11, 0, width() - 36, height()), Qt::AlignLeft | Qt::AlignVCenter, text());
// Swift's chevron.down, nine points, medium.
QPainterPath chevron;
const double left = width() - 21, middle = height() / 2.0;
chevron.moveTo(left + 1, middle - 2);
chevron.lineTo(left + 5, middle + 2);
chevron.lineTo(left + 9, middle - 2);
painter.setPen(QPen(palette().color(QPalette::ButtonText), 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.setBrush(Qt::NoBrush);
painter.drawPath(chevron);
}
+61 -9
View File
@@ -1,14 +1,18 @@
#pragma once
#include "Document/ProjectWorkspace.h"
#include <QScrollArea>
#include "UI/ProjectTabLayout.h"
#include <QToolButton>
#include <QWidget>
// Swift's TabDragPhase: a reorder drag goes on, or ends.
enum class TabDragPhase { changed, ended };
// One tab: its title, the unsaved dot, its close button.
class ProjectTabButton : public QWidget {
Q_OBJECT
public:
ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent = nullptr);
ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, std::function<void(TabDragPhase, double)> onReorder,
QWidget *parent = nullptr);
const std::shared_ptr<ProjectTab> tab;
// What the session and the workspace hold, shown.
@@ -16,6 +20,7 @@ public:
bool isActive() const;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
@@ -27,9 +32,13 @@ private:
void setTargeted(bool targeted);
ProjectWorkspace &m_workspace;
const std::function<void(TabDragPhase, double)> m_onReorder;
QToolButton *const m_select;
QToolButton *const m_close;
bool m_targeted = false;
// Swift's DragGesture: the press, in the window's space.
std::optional<QPointF> m_press;
bool m_dragging = false;
};
// Swift's NewProjectDropTarget: the New canvas button takes drops.
@@ -53,23 +62,66 @@ private:
bool m_targeted = false;
};
// The row of tabs, one a project, scrolling when long.
class ProjectTabStrip : public QScrollArea {
// Swift's OverflowTabsPill: the tabs that do not fit, a menu.
class OverflowTabsPill : public QToolButton {
Q_OBJECT
public:
explicit OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent = nullptr);
// Swift's projectTabOverflowPillWidth.
static double pillWidth(int hiddenCount);
void setHiddenIDs(std::vector<QUuid> hiddenIDs);
protected:
void paintEvent(QPaintEvent *event) override;
private:
void showMenu();
ProjectWorkspace &m_workspace;
std::vector<QUuid> m_hiddenIDs;
};
// The row of tabs; the rest gather in a menu.
class ProjectTabStrip : public QWidget {
Q_OBJECT
public:
explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr);
QList<ProjectTabButton *> buttons() const;
OverflowTabsPill *pill() const { return m_pill; }
QSize sizeHint() const override;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private:
// Swift's TabReorderState, taken once as the drag starts.
struct Reorder {
QUuid id;
std::vector<QUuid> others;
std::map<QUuid, double> widths;
std::map<QUuid, double> compactedX;
double startX;
double originX;
double translation = 0;
int targetIndex = 0;
int nearestSlot() const;
};
void synchronize();
void showFront();
std::map<QUuid, double> widths() const;
ProjectTabOverflow overflow() const;
// Places the pill and the tabs; others slide when `animated`.
void layOut(bool animated = false);
double renderX(const ProjectTabSlot &slot, double contentWidth) const;
void handleReorder(QUuid id, TabDragPhase phase, double translation);
Reorder makeReorder(QUuid id) const;
void commitReorder(const Reorder &state);
ProjectWorkspace &m_workspace;
QWidget *const m_row;
// Swift's lastSelectedID: only a new front tab scrolls.
std::optional<QUuid> m_shownFront;
OverflowTabsPill *const m_pill;
std::vector<ProjectTabButton *> m_buttons;
std::optional<Reorder> m_reorder;
};
+1 -4
View File
@@ -26,11 +26,8 @@ ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget
auto *newButton = new NewCanvasButton(m_workspace, m_toolbar);
newButton->setDefaultAction(m_newCanvas);
m_toolbar->addWidget(newButton);
m_toolbar->addWidget(m_tabs);
// The strip takes the toolbar's free width; zooms stay right.
auto *spacer = new QWidget(m_toolbar);
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
m_toolbar->addWidget(spacer);
m_toolbar->addWidget(m_tabs);
m_fit = action(m_toolbar, QStringLiteral("Fit"), QStringLiteral("Fit canvas in window (Ctrl+0)"), QStringLiteral("fitCanvas"));
m_actualPixels = action(m_toolbar, QStringLiteral("100%"), QStringLiteral("Actual pixels (Ctrl+1)"), QStringLiteral("actualPixels"));
m_zoomIn = action(m_toolbar, QStringLiteral("+"), QStringLiteral("Zoom in (Ctrl++)"), QStringLiteral("zoomIn"));