OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
#include "UI/BlendModePicker.h"
BlendModePicker::BlendModePicker(EditorSession &session, QWidget *parent) : QComboBox(parent), m_session(session)
{
setAccessibleName(QStringLiteral("Blend mode"));
setObjectName(QStringLiteral("blendMode"));
for (const LayerBlendMode mode : allLayerBlendModes)
addItem(rawValue(mode));
connect(this, &QComboBox::highlighted, this, &BlendModePicker::highlight);
connect(this, &QComboBox::activated, this, &BlendModePicker::choose);
connect(&m_session, &EditorSession::changed, this, &BlendModePicker::synchronize);
synchronize();
}
void BlendModePicker::showPopup()
{
m_tracking = true;
m_layerID = m_session.activeLayerID();
m_highlighted = std::nullopt;
QComboBox::showPopup();
}
void BlendModePicker::hidePopup()
{
QComboBox::hidePopup();
m_tracking = false;
// A click closes the menu, then chooses: clear after.
QMetaObject::invokeMethod(this, [this] {
m_session.previewBlendMode(std::nullopt, std::nullopt);
m_layerID = std::nullopt;
m_highlighted = std::nullopt;
}, Qt::QueuedConnection);
}
void BlendModePicker::synchronize()
{
setEnabled(m_session.canEditAppearance());
if (m_tracking)
return;
const std::optional<ImageLayer> active = m_session.activeLayer();
setCurrentText(rawValue(active ? active->blendMode : LayerBlendMode::normal));
}
void BlendModePicker::highlight(int index)
{
const std::optional<LayerBlendMode> mode = layerBlendMode(itemText(index));
if (mode)
m_highlighted = mode;
m_session.previewBlendMode(mode, m_layerID);
}
void BlendModePicker::choose(int index)
{
// The layer the menu opened on; closed, the active one.
const std::optional<QUuid> target = m_layerID ? m_layerID : m_session.activeLayerID();
const std::optional<LayerBlendMode> mode = m_highlighted ? m_highlighted : layerBlendMode(itemText(index));
m_highlighted = std::nullopt;
m_layerID = std::nullopt;
if (!mode || m_session.activeLayerID() != target) {
synchronize();
return;
}
m_session.setLayerBlendMode(*mode);
setCurrentText(rawValue(*mode));
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "Document/EditorSession.h"
#include <QComboBox>
// The blend menu: hovering tries a mode, choosing keeps it.
class BlendModePicker : public QComboBox {
Q_OBJECT
public:
explicit BlendModePicker(EditorSession &session, QWidget *parent = nullptr);
void showPopup() override;
void hidePopup() override;
private:
void synchronize();
void highlight(int index);
void choose(int index);
EditorSession &m_session;
bool m_tracking = false;
std::optional<QUuid> m_layerID;
std::optional<LayerBlendMode> m_highlighted;
};
+175
View File
@@ -0,0 +1,175 @@
#include "UI/BrushControls.h"
#include "Document/EditorSession.h"
#include "UI/ColorPaletteControls.h"
#include "UI/LassoControls.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
#include <QLabel>
#include <QSignalBlocker>
#include <QSlider>
#include <QToolButton>
#include <cmath>
namespace {
// The session's settings with one field rewritten.
void changeBrush(EditorSession &session, const std::function<void(BrushSettings &)> &change)
{
BrushSettings settings = session.brushSettings();
change(settings);
session.setBrushSettings(settings);
}
const QString modeTip = QStringLiteral("Paint with the foreground color (B), or erase pixels away (E)");
const QString blurTip = QStringLiteral("Liquify pushes pixels · Blur softens · Smudge drags color along");
const QString sampleTip = QStringLiteral("Copy from the active layer only, or from every visible layer as shown");
QLabel *label(const QString &text, QWidget *parent)
{
auto *made = new QLabel(text, parent);
made->setFont(ToolHeaderStyle::controlFont());
return made;
}
}
BrushControls::BrushControls(EditorSession &session, QWidget *parent)
: ToolHeaderBar(QString(), parent), m_session(session), m_modes(new QButtonGroup(this)),
m_paint(choice(m_modes, QStringLiteral("brushPaint"), rawValue(BrushToolMode::paint), modeTip, [this] { m_session.setBrushMode(BrushToolMode::paint); })),
m_erase(choice(m_modes, QStringLiteral("brushErase"), rawValue(BrushToolMode::erase), modeTip, [this] { m_session.setBrushMode(BrushToolMode::erase); })),
m_blurModes(new QButtonGroup(this)), m_healingTypes(new QButtonGroup(this)), m_aligned(new QCheckBox(QStringLiteral("Aligned"), this)), m_samples(new QButtonGroup(this)),
m_thisLayer(choice(m_samples, QStringLiteral("cloneThisLayer"), QStringLiteral("This Layer"), sampleTip,
[this] { m_session.setCloneSettings(CloneSettings{m_session.cloneSettings().aligned, false}); })),
m_allLayers(choice(m_samples, QStringLiteral("cloneAllLayers"), QStringLiteral("All Layers"), sampleTip,
[this] { m_session.setCloneSettings(CloneSettings{m_session.cloneSettings().aligned, true}); })),
m_size(new SelectionAmountField(session, 1, 2000, [this] { return m_session.brushSettings().diameter; },
[this](double size) { changeBrush(m_session, [size](BrushSettings &brush) { brush.diameter = size; }); }, this)),
m_hardnessSlider(slider(QStringLiteral("brushHardnessSlider"), 0, 1000,
[this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.hardness = value / 1000.0; }); })),
m_hardness(new SelectionAmountField(session, 0, 100, [this] { return m_session.brushSettings().hardness * 100; },
[this](double percent) { changeBrush(m_session, [percent](BrushSettings &brush) { brush.hardness = percent / 100; }); }, this)),
m_opacityLabel(label(QStringLiteral("Opacity"), this)),
m_opacitySlider(slider(QStringLiteral("brushOpacitySlider"), 10, 1000,
[this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.opacity = value / 1000.0; }); })),
m_opacity(new SelectionAmountField(session, 1, 100, [this] { return m_session.brushSettings().opacity * 100; },
[this](double percent) { changeBrush(m_session, [percent](BrushSettings &brush) { brush.opacity = percent / 100; }); }, this)),
m_paintLabel(label(QStringLiteral("Paint"), this)), m_maskPaint(new QComboBox(this)), m_colourLabel(label(QStringLiteral("Color"), this)),
m_colour(new SwatchButton([&session] { return session.foregroundColor(); }, 4, 1, 1, this)),
m_cloneNote(label(QStringLiteral("Alt-click to set the source"), this)), m_mask(label(QStringLiteral("Mask"), this))
{
m_blurModes->setObjectName(QStringLiteral("blurMode"));
for (const BlurToolMode mode : allBlurToolModes)
choice(m_blurModes, QString(), rawValue(mode), blurTip, [this, mode] { m_session.setBlurMode(mode); });
m_healingTypes->setObjectName(QStringLiteral("spotHealingType"));
for (const SpotHealingMode type : allSpotHealingModes)
choice(m_healingTypes, QString(), rawValue(type), QString(), [this, type] { m_session.setSpotHealingMode(type); });
m_aligned->setObjectName(QStringLiteral("cloneAligned"));
m_aligned->setToolTip(QStringLiteral("Keep the source moving with the brush between strokes; off starts every stroke at the source point"));
connect(m_aligned, &QCheckBox::clicked, this,
[this](bool on) { m_session.setCloneSettings(CloneSettings{on, m_session.cloneSettings().sampleAllLayers}); });
m_size->setObjectName(QStringLiteral("brushSize"));
m_size->setFixedWidth(48);
m_hardness->setObjectName(QStringLiteral("brushHardness"));
m_hardness->setFixedWidth(42);
m_opacity->setObjectName(QStringLiteral("brushOpacity"));
m_opacity->setFixedWidth(42);
m_opacity->setToolTip(QStringLiteral("Press 1–9 for 10–90%, 0 for 100%"));
m_maskPaint->setObjectName(QStringLiteral("maskPaint"));
m_maskPaint->setFixedWidth(180);
m_maskPaint->addItem(QStringLiteral("Black · Hide"), false);
m_maskPaint->addItem(QStringLiteral("White · Reveal"), true);
connect(m_maskPaint, &QComboBox::activated, this, [this](int index) { m_session.setMaskPaintWhite(index == 1); });
m_cloneNote->setObjectName(QStringLiteral("cloneSourceNote"));
m_cloneNote->setForegroundRole(QPalette::PlaceholderText);
m_mask->setObjectName(QStringLiteral("brushMaskNote"));
m_mask->setForegroundRole(QPalette::PlaceholderText);
m_colour->setObjectName(QStringLiteral("brushColor"));
m_colour->setFixedSize(34, 18);
m_colour->setToolTip(QStringLiteral("Foreground color"));
m_colour->setAccessibleName(QStringLiteral("Foreground color"));
connect(m_colour, &QAbstractButton::clicked, this, [this] { m_session.openColorPicker(false); });
std::vector<QWidget *> widgets{m_paint, m_erase};
for (QAbstractButton *mode : m_blurModes->buttons())
widgets.push_back(mode);
for (QAbstractButton *type : m_healingTypes->buttons())
widgets.push_back(type);
widgets.insert(widgets.end(), {m_aligned, m_thisLayer, m_allLayers});
widgets.insert(widgets.end(), {label(QStringLiteral("Size"), this), m_size, label(QStringLiteral("px"), this), label(QStringLiteral("Hardness"), this),
m_hardnessSlider, m_hardness, label(QStringLiteral("%"), this), m_opacityLabel, m_opacitySlider, m_opacity,
label(QStringLiteral("%"), this), m_paintLabel, m_maskPaint, m_colourLabel, m_colour});
for (QWidget *widget : widgets)
row->insertWidget(row->count() - 1, widget);
row->addWidget(m_cloneNote);
row->addWidget(m_mask);
connect(&m_session, &EditorSession::changed, this, &BrushControls::synchronize);
synchronize();
}
QToolButton *BrushControls::choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick)
{
auto *button = new QToolButton(this);
button->setObjectName(name);
button->setText(text);
button->setCheckable(true);
button->setToolTip(tip);
group->addButton(button);
connect(button, &QToolButton::clicked, this, pick);
return button;
}
QSlider *BrushControls::slider(const QString &name, int low, int high, const std::function<void(int)> &change)
{
auto *made = new QSlider(Qt::Horizontal, this);
made->setObjectName(name);
made->setRange(low, high);
made->setFixedWidth(100);
connect(made, &QSlider::valueChanged, this, change);
return made;
}
void BrushControls::synchronize()
{
const BrushSettings &brush = m_session.brushSettings();
const NavigationTool tool = m_session.tool();
const bool erasing = m_session.brushMode() == BrushToolMode::erase;
title->setText(tool == NavigationTool::spotHealing ? QStringLiteral("Spot Healing") : tool == NavigationTool::cloneStamp ? QStringLiteral("Clone Stamp")
: tool == NavigationTool::blur ? QStringLiteral("Smear") : erasing ? QStringLiteral("Eraser") : QStringLiteral("Brush"));
m_paint->setVisible(tool == NavigationTool::brush);
m_erase->setVisible(tool == NavigationTool::brush);
(erasing ? m_erase : m_paint)->setChecked(true);
const QList<QAbstractButton *> smears = m_blurModes->buttons();
for (QAbstractButton *smear : smears)
smear->setVisible(tool == NavigationTool::blur);
smears[qsizetype(m_session.blurMode())]->setChecked(true);
const QList<QAbstractButton *> types = m_healingTypes->buttons();
for (QAbstractButton *type : types)
type->setVisible(tool == NavigationTool::spotHealing);
types[qsizetype(m_session.spotHealingMode())]->setChecked(true);
const bool cloning = tool == NavigationTool::cloneStamp;
const CloneSettings &clone = m_session.cloneSettings();
for (QWidget *widget : std::initializer_list<QWidget *>{m_aligned, m_thisLayer, m_allLayers})
widget->setVisible(cloning);
if (m_aligned->isChecked() != clone.aligned)
m_aligned->setChecked(clone.aligned);
(clone.sampleAllLayers ? m_allLayers : m_thisLayer)->setChecked(true);
m_cloneNote->setVisible(cloning && !m_session.cloneSource());
m_opacityLabel->setText(tool == NavigationTool::blur ? QStringLiteral("Strength") : QStringLiteral("Opacity"));
m_size->sync(int(std::lround(brush.diameter)));
m_hardness->sync(int(std::lround(brush.hardness * 100)));
m_opacity->sync(int(std::lround(brush.opacity * 100)));
// The sliders show the session's numbers without writing back.
const QSignalBlocker hardness(m_hardnessSlider), opacity(m_opacitySlider);
m_hardnessSlider->setValue(int(std::lround(brush.hardness * 1000)));
m_opacitySlider->setValue(int(std::lround(brush.opacity * 1000)));
const bool mask = m_session.isMaskSelected();
m_paintLabel->setVisible(mask);
m_maskPaint->setVisible(mask);
m_maskPaint->setCurrentIndex(m_session.maskPaintWhite() ? 1 : 0);
m_mask->setVisible(mask);
// Clone Stamp and the Smear paint no colour of theirs.
const bool colour = !mask && tool != NavigationTool::cloneStamp && tool != NavigationTool::blur;
m_colourLabel->setVisible(colour);
m_colour->setVisible(colour);
m_colour->setEnabled(m_session.canEditPalette());
m_colour->update();
setEnabled(!m_session.showsBusy());
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "UI/ToolHeaderStyle.h"
#include <functional>
class EditorSession;
class QButtonGroup;
class QCheckBox;
class QComboBox;
class QLabel;
class QSlider;
class QToolButton;
class SelectionAmountField;
class SwatchButton;
// Swift's BrushControls: mode, type or source; tip, opacity, mask.
class BrushControls : public ToolHeaderBar {
Q_OBJECT
public:
explicit BrushControls(EditorSession &session, QWidget *parent = nullptr);
private:
// A segment of Swift's segmented picker: one checkable button.
QToolButton *choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick);
QSlider *slider(const QString &name, int low, int high, const std::function<void(int)> &change);
void synchronize();
EditorSession &m_session;
QButtonGroup *const m_modes;
QToolButton *const m_paint;
QToolButton *const m_erase;
// The Smear's mode, Swift's second segmented picker.
QButtonGroup *const m_blurModes;
// Spot Healing's type, Swift's `spotHealingType` picker.
QButtonGroup *const m_healingTypes;
// Clone Stamp's options: Aligned, and what it samples.
QCheckBox *const m_aligned;
QButtonGroup *const m_samples;
QToolButton *const m_thisLayer;
QToolButton *const m_allLayers;
SelectionAmountField *const m_size;
// Sliders step in thousandths, fields show whole percents.
QSlider *const m_hardnessSlider;
SelectionAmountField *const m_hardness;
QLabel *const m_opacityLabel;
QSlider *const m_opacitySlider;
SelectionAmountField *const m_opacity;
QLabel *const m_paintLabel;
QComboBox *const m_maskPaint;
// The foreground colour, the rail swatch's own picker.
QLabel *const m_colourLabel;
SwatchButton *const m_colour;
QLabel *const m_cloneNote;
QLabel *const m_mask;
};
+37
View File
@@ -0,0 +1,37 @@
#include "UI/ByteCounts.h"
#include <QLocale>
#include <array>
namespace {
// Adaptive: places by unit, trailing zeros dropped, digits grouped.
QString counted(qint64 bytes, int base)
{
const QLocale english(QLocale::English, QLocale::UnitedStates);
// Nothing counted here holds one byte or a terabyte.
if (bytes < base)
return english.toString(bytes) + QStringLiteral(" bytes");
const std::array<QString, 3> units = {QStringLiteral("KB"), QStringLiteral("MB"), QStringLiteral("GB")};
double value = double(bytes) / base;
size_t unit = 0;
while (value >= base) {
value /= base;
++unit;
}
QString number = english.toString(value, 'f', int(unit));
while (unit > 0 && number.endsWith(QLatin1Char('0')))
number.chop(1);
if (number.endsWith(QLatin1Char('.')))
number.chop(1);
return number + QLatin1Char(' ') + units.at(unit);
}
}
QString ByteCounts::memory(qint64 bytes)
{
return counted(bytes, 1024);
}
QString ByteCounts::file(qint64 bytes)
{
return counted(bytes, 1000);
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <QString>
// Foundation's ByteCountFormatter, its memory and file styles, in English.
namespace ByteCounts {
// Binary units: bytes, KB whole, MB one place, GB two.
QString memory(qint64 bytes);
// Decimal units, as files count, with the same places.
QString file(qint64 bytes);
}
+292
View File
@@ -0,0 +1,292 @@
#include "UI/CanvasSizeSheet.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPaletteControls.h"
#include "UI/ColorPickerSheet.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QColorDialog>
#include <QComboBox>
#include <QFrame>
#include <QGridLayout>
#include <QLabel>
#include <QPainter>
#include <QPushButton>
#include <QToolButton>
#include <QVBoxLayout>
#include <cmath>
namespace {
const std::array<QString, 9> anchorNames = {QStringLiteral("Top left"), QStringLiteral("Top center"), QStringLiteral("Top right"),
QStringLiteral("Middle left"), QStringLiteral("Center"), QStringLiteral("Middle right"),
QStringLiteral("Bottom left"), QStringLiteral("Bottom center"), QStringLiteral("Bottom right")};
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
{
auto *label = new QLabel(words, parent);
label->setTextFormat(Qt::PlainText);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
label->setForegroundRole(role);
return label;
}
// Swift's `.number`: three places at most, none trailing, ties even.
QString shown(double value, QLocale locale)
{
locale.setNumberOptions(QLocale::OmitGroupSeparator);
QString number = locale.toString(std::nearbyint(value * 1000) / 1000, 'f', 3);
while (number.endsWith(locale.zeroDigit()))
number.chop(locale.zeroDigit().size());
if (number.endsWith(locale.decimalPoint()))
number.chop(locale.decimalPoint().size());
return number;
}
QString grouped(qint64 value)
{
return QLocale(QLocale::English, QLocale::UnitedStates).toString(value);
}
// Swift's anchor: a circle, filled with the accent when chosen.
class AnchorButton : public QToolButton {
public:
using QToolButton::QToolButton;
bool chosen = false;
protected:
void paintEvent(QPaintEvent *event) override
{
QToolButton::paintEvent(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QRectF dot(0, 0, 11, 11);
dot.moveCenter(QRectF(rect()).center());
if (chosen) {
painter.setPen(Qt::NoPen);
painter.setBrush(palette().color(QPalette::Highlight));
} else {
painter.setPen(QPen(palette().color(QPalette::PlaceholderText), 1.2));
}
painter.drawEllipse(dot);
}
};
}
CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, PaletteColor foreground, PaletteColor background,
std::function<void(std::optional<CanvasSizeOptions>)> finish, QWidget *parent)
: QWidget(parent), m_foreground(foreground), m_background(background), m_finish(std::move(finish)),
m_draft(document.width, document.height, document.resolution), m_width(dimension(true)), m_height(dimension(false)),
m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)),
m_anchors(new QButtonGroup(this)), m_anchorName(text(QString(), 12, QFont::Bold, QPalette::WindowText, this)), m_extension(new QComboBox(this)),
m_customRow(new QWidget(this)), m_customSwatch(new SwatchButton([this] { return m_custom; }, 3, 0, 0.5, m_customRow)),
m_ok(new QPushButton(QStringLiteral("OK"), this))
{
setFixedWidth(450);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
column->addWidget(text(QStringLiteral("Canvas Size"), 17, QFont::Bold, QPalette::WindowText, this));
column->addWidget(text(QStringLiteral("Current: %1 × %2 pixels").arg(grouped(document.width), grouped(document.height)), 13, QFont::Normal,
QPalette::WindowText, this));
column->addWidget(text(ByteCounts::memory(qint64(document.width) * document.height * 4) + QStringLiteral(" uncompressed RGBA canvas"), 12, QFont::Normal,
QPalette::PlaceholderText, this));
auto *divider = new QFrame(this);
divider->setFrameShape(QFrame::HLine);
divider->setForegroundRole(QPalette::Mid);
column->addWidget(divider);
auto *fields = new QGridLayout;
fields->setHorizontalSpacing(8);
fields->setVerticalSpacing(16);
fields->setColumnMinimumWidth(0, 60);
auto *units = new QComboBox(this);
auto *unitsLabel = new QLabel(QStringLiteral("Units"), this);
unitsLabel->setBuddy(units);
units->setObjectName(QStringLiteral("canvasUnits"));
for (const CanvasUnit unit : {CanvasUnit::pixels, CanvasUnit::percent, CanvasUnit::inches, CanvasUnit::centimeters})
units->addItem(rawValue(unit), int(unit));
connect(units, &QComboBox::activated, this, [this, units](int index) {
// A wheel leaves the focus: pending typing commits first.
units->setFocus();
m_draft.unit = CanvasUnit(units->itemData(index).toInt());
synchronize();
});
fields->addWidget(unitsLabel, 0, 0);
fields->addWidget(units, 0, 1);
fields->addWidget(new QLabel(QStringLiteral("Width"), this), 1, 0);
fields->addWidget(m_width, 1, 1);
fields->addWidget(new QLabel(QStringLiteral("Height"), this), 2, 0);
fields->addWidget(m_height, 2, 1);
column->addLayout(fields);
auto *relative = new QCheckBox(QStringLiteral("Relative to current dimensions"), this);
relative->setObjectName(QStringLiteral("canvasRelative"));
connect(relative, &QCheckBox::clicked, this, [this](bool on) {
m_draft.relative = on;
synchronize();
});
auto *locked = new QCheckBox(QStringLiteral("Lock original aspect ratio"), this);
locked->setObjectName(QStringLiteral("canvasLocked"));
// Swift's onChange: locking takes the width's proportions.
connect(locked, &QCheckBox::clicked, this, [this](bool on) {
m_draft.locked = on;
if (on)
m_draft.set(m_draft.displayed(true), true);
synchronize();
});
column->addWidget(relative);
column->addWidget(locked);
m_note->setObjectName(QStringLiteral("canvasNote"));
column->addWidget(m_note);
auto *grid = new QGridLayout;
grid->setSpacing(3);
for (int index = 0; index < 9; ++index) {
auto *button = new AnchorButton(this);
button->setObjectName(QStringLiteral("anchor%1").arg(index));
button->setFixedSize(25, 25);
button->setToolTip(anchorNames[size_t(index)]);
button->setAccessibleName(anchorNames[size_t(index)]);
m_anchors->addButton(button, index);
grid->addWidget(button, index / 3, index % 3);
}
connect(m_anchors, &QButtonGroup::idClicked, this, [this](int index) {
m_anchor = index;
synchronize();
});
auto *anchorColumn = new QVBoxLayout;
anchorColumn->setSpacing(8);
anchorColumn->addWidget(new QLabel(QStringLiteral("Anchor"), this));
anchorColumn->addLayout(grid);
m_anchorName->setObjectName(QStringLiteral("anchorName"));
auto *explanation = text(QStringLiteral("Keeps this point fixed. Artwork is not scaled; cropped content remains outside the canvas."), 12,
QFont::Normal, QPalette::PlaceholderText, this);
explanation->setWordWrap(true);
auto *named = new QVBoxLayout;
named->setContentsMargins(0, 28, 0, 0);
named->setSpacing(8);
named->addWidget(m_anchorName);
named->addWidget(explanation);
named->addStretch(1);
auto *anchorRow = new QHBoxLayout;
anchorRow->setSpacing(24);
anchorRow->addLayout(anchorColumn);
anchorRow->addLayout(named, 1);
column->addLayout(anchorRow);
auto *extension = new QHBoxLayout;
auto *extensionLabel = new QLabel(QStringLiteral("Canvas extension"), this);
extensionLabel->setBuddy(m_extension);
m_extension->setObjectName(QStringLiteral("canvasExtension"));
m_extension->addItems({QStringLiteral("Transparent"), QStringLiteral("Foreground"), QStringLiteral("Background"), QStringLiteral("Black"),
QStringLiteral("White"), QStringLiteral("Custom")});
connect(m_extension, &QComboBox::activated, this, &CanvasSizeSheet::synchronize);
extension->addWidget(extensionLabel);
extension->addWidget(m_extension, 1);
column->addLayout(extension);
// Swift's ColorPicker: its label and a well, no opacity.
m_customRow->setObjectName(QStringLiteral("extensionColorRow"));
m_customSwatch->setObjectName(QStringLiteral("extensionColor"));
m_customSwatch->setFixedSize(36, 18);
m_customSwatch->setAccessibleName(QStringLiteral("Extension color"));
connect(m_customSwatch, &QAbstractButton::clicked, this, &CanvasSizeSheet::pickCustomColor);
auto *customLayout = new QHBoxLayout(m_customRow);
customLayout->setContentsMargins(0, 0, 0, 0);
customLayout->addWidget(new QLabel(QStringLiteral("Extension color"), m_customRow));
customLayout->addWidget(m_customSwatch);
customLayout->addStretch(1);
column->addWidget(m_customRow);
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("canvasCancel"));
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
m_ok->setObjectName(QStringLiteral("canvasOK"));
m_ok->setDefault(true);
// OK rests while the draft is invalid: no guard needed.
connect(m_ok, &QPushButton::clicked, this, [this] {
m_finish(CanvasSizeOptions{.width = qint64(std::round(m_draft.width)), .height = qint64(std::round(m_draft.height)), .anchor = m_anchor,
.fill = fill()});
});
auto *buttons = new QHBoxLayout;
buttons->addWidget(cancel);
buttons->addStretch(1);
buttons->addWidget(m_ok);
column->addLayout(buttons);
synchronize();
}
// Swift's value binding: a readable number sets the draft.
PickerField *CanvasSizeSheet::dimension(bool widthAxis)
{
auto *field = new PickerField([this, widthAxis] {
PickerField &edited = widthAxis ? *m_width : *m_height;
bool number = false;
const double typed = edited.locale().toDouble(edited.text(), &number);
if (edited.isModified() && number && std::isfinite(typed))
m_draft.set(typed, widthAxis);
edited.setModified(false);
synchronize();
}, nullptr, this);
field->setObjectName(widthAxis ? QStringLiteral("canvasWidth") : QStringLiteral("canvasHeight"));
field->setPlaceholderText(widthAxis ? QStringLiteral("Width") : QStringLiteral("Height"));
field->setAccessibleName(widthAxis ? QStringLiteral("Width") : QStringLiteral("Height"));
return field;
}
std::optional<CanvasExtensionColor> CanvasSizeSheet::fill() const
{
const auto colour = [](PaletteColor value) { return CanvasExtensionColor{value.red, value.green, value.blue}; };
switch (m_extension->currentIndex()) {
case 0: return std::nullopt;
case 1: return colour(m_foreground);
case 2: return colour(m_background);
case 3: return colour(PaletteColor::black());
case 4: return colour(PaletteColor::white());
default: return colour(m_custom);
}
}
// The desktop's colour dialog, SwiftUI's colour panel, without alpha.
void CanvasSizeSheet::pickCustomColor()
{
auto *dialog = new QColorDialog(m_custom.color(), this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->setWindowTitle(QStringLiteral("Extension color"));
connect(dialog, &QColorDialog::colorSelected, this, [this](const QColor &picked) {
const QColor srgb = picked.toRgb();
m_custom = PaletteColor{srgb.redF(), srgb.greenF(), srgb.blueF()};
m_customSwatch->update();
});
dialog->open();
}
// Units, Relative and Lock change only through their own controls.
void CanvasSizeSheet::synchronize()
{
for (const auto &[field, widthAxis] : {std::pair(m_width, true), std::pair(m_height, false)}) {
// A field being typed in keeps its typing.
const QString number = shown(m_draft.displayed(widthAxis), field->locale());
if (!field->isModified() && field->text() != number)
field->setText(number);
}
const bool valid = m_draft.valid();
if (valid) {
const qint64 width = qint64(std::round(m_draft.width)), height = qint64(std::round(m_draft.height));
m_note->setText(QStringLiteral("New: %1 × %2 pixels · %3 uncompressed").arg(grouped(width), grouped(height), ByteCounts::memory(width * height * 4)));
} else {
m_note->setText(QStringLiteral("Final dimensions must be 1–30,000 pixels per side."));
}
m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText);
for (QAbstractButton *each : m_anchors->buttons()) {
auto *button = static_cast<AnchorButton *>(each);
button->chosen = m_anchors->id(each) == m_anchor;
button->setAccessibleDescription(button->chosen ? QStringLiteral("Selected") : QString());
button->update();
}
m_anchorName->setText(anchorNames[size_t(m_anchor)]);
m_customRow->setVisible(m_extension->currentIndex() == 5);
m_ok->setEnabled(valid);
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "Document/CanvasSize.h"
#include "Document/ColorPalette.h"
#include "Document/EditorSession+Model.h"
#include <QWidget>
#include <array>
#include <functional>
class PickerField;
class QButtonGroup;
class QComboBox;
class QLabel;
class QPushButton;
class SwatchButton;
// Swift's CanvasSizeSheet: the new size, the point kept, the extension.
class CanvasSizeSheet : public QWidget {
Q_OBJECT
public:
CanvasSizeSheet(const CanvasDocument &document, PaletteColor foreground, PaletteColor background,
std::function<void(std::optional<CanvasSizeOptions>)> finish, QWidget *parent = nullptr);
private:
PickerField *dimension(bool widthAxis);
std::optional<CanvasExtensionColor> fill() const;
void pickCustomColor();
void synchronize();
const PaletteColor m_foreground;
const PaletteColor m_background;
const std::function<void(std::optional<CanvasSizeOptions>)> m_finish;
CanvasSizeDraft m_draft;
int m_anchor = 4;
PaletteColor m_custom = PaletteColor::white();
PickerField *const m_width;
PickerField *const m_height;
QLabel *const m_note;
QButtonGroup *const m_anchors;
QLabel *const m_anchorName;
QComboBox *const m_extension;
QWidget *const m_customRow;
SwatchButton *const m_customSwatch;
QPushButton *const m_ok;
};
+91
View File
@@ -0,0 +1,91 @@
#include "UI/CanvasThumbnail.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <QPainter>
#include <cmath>
namespace {
// A canvas-shaped picture; `draw` gets pixels and their scale.
QPixmap render(QSizeF canvas, double box, const std::function<void(QPainter &, QSize, double)> &draw)
{
const QSize points = CanvasThumbnail::fittedSize(canvas, box);
const QSize pixels = points * CanvasThumbnail::backingScale;
QImage image;
try {
image = BrushRaster::context(pixels.width(), pixels.height(), false);
} catch (const ExportError &error) {
qCWarning(lcRendering) << "a thumbnail could not be allocated:" << pixels << error.what();
QPixmap empty(pixels);
empty.fill(Qt::transparent);
empty.setDevicePixelRatio(CanvasThumbnail::backingScale);
return empty;
}
{
QPainter painter(&image);
draw(painter, pixels, canvas.width() > 0 ? pixels.width() / canvas.width() : 1);
}
QPixmap result = QPixmap::fromImage(image);
result.setDevicePixelRatio(CanvasThumbnail::backingScale);
return result;
}
// The image where the transform puts it, as the canvas.
void place(const QImage &image, const LayerTransform &transform, double scale, QPainter &painter)
{
LayerRenderer::draw(image, transform, QPointF(transform.center().x() * scale, transform.center().y() * scale), painter, {.scale = scale});
}
}
QSize CanvasThumbnail::fittedSize(QSizeF canvas, double box)
{
if (!(canvas.width() > 0) || !(canvas.height() > 0) || !std::isfinite(canvas.width()) || !std::isfinite(canvas.height()))
return QSize(int(box), int(box));
const double scale = box / std::max(canvas.width(), canvas.height());
return QSize(std::max(1, int(std::round(canvas.width() * scale))), std::max(1, int(std::round(canvas.height() * scale))));
}
QPixmap CanvasThumbnail::layer(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box)
{
return render(canvas, box, [&](QPainter &painter, QSize size, double scale) {
painter.fillRect(QRect(QPoint(0, 0), size), QColor::fromRgbF(0.22, 0.22, 0.22));
const double tile = 6 * backingScale;
for (int row = 0; row < int(std::ceil(size.height() / tile)); ++row) {
for (int column = 0; column < int(std::ceil(size.width() / tile)); ++column) {
if ((row + column) % 2 == 0)
painter.fillRect(QRectF(column * tile, row * tile, tile, tile), QColor::fromRgbF(0.32, 0.32, 0.32));
}
}
if (!image.isNull())
place(image, transform, scale, painter);
});
}
QPixmap CanvasThumbnail::mask(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box)
{
return render(canvas, box, [&](QPainter &painter, QSize size, double scale) {
const double tone = edgeTone(image);
painter.fillRect(QRect(QPoint(0, 0), size), QColor::fromRgbF(tone, tone, tone));
place(image, transform, scale, painter);
});
}
double CanvasThumbnail::edgeTone(const QImage &image)
{
const QImage gray = image.convertToFormat(QImage::Format_Grayscale8);
if (gray.width() <= 0 || gray.height() <= 0)
return 1;
qint64 total = 0, count = 0;
for (int y = 0; y < gray.height(); ++y) {
const uchar *row = gray.constScanLine(y);
const bool edgeRow = y == 0 || y == gray.height() - 1;
for (int x = 0; x < gray.width(); ++x) {
if (edgeRow || x == 0 || x == gray.width() - 1) {
total += row[x];
++count;
}
}
}
return count == 0 ? 1 : double(total) / double(count) / 255;
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "Document/LayerTransform.h"
#include <QImage>
#include <QPixmap>
#include <QSizeF>
// Thumbnails framed by the whole canvas, as Photoshop shows them.
namespace CanvasThumbnail {
// Pixels per point in the pictures, sharp on scaled displays.
inline constexpr double backingScale = 2;
// The canvas's shape fitted in a square of `box` points.
QSize fittedSize(QSizeF canvas, double box);
// The checkerboard with the pixels placed; null: empty canvas.
QPixmap layer(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box);
// The mask placed; its edge tone fills the rest.
QPixmap mask(const QImage &image, const LayerTransform &transform, QSizeF canvas, double box);
// The mean gray, 0 to 1, of the outermost pixels.
double edgeTone(const QImage &image);
}
+186
View File
@@ -0,0 +1,186 @@
#include "UI/ColorPaletteControls.h"
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QVBoxLayout>
#include <cmath>
namespace {
constexpr double swatchSize = 24;
constexpr double swatchOffset = 12;
// Swift's buttons reach 3 points past the swatches' frame.
constexpr QPoint origin(3, 3);
// A 12-point glyph in the secondary ink, as Swift's buttons.
class GlyphButton : public QAbstractButton {
public:
GlyphButton(std::function<void(QPainter &)> glyph, QWidget *parent) : QAbstractButton(parent), m_glyph(std::move(glyph))
{
setFixedSize(12, 12);
setCursor(Qt::ArrowCursor);
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QColor ink = palette().color(isEnabled() ? QPalette::Active : QPalette::Disabled, QPalette::PlaceholderText);
painter.setPen(QPen(ink, 1.1, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.setBrush(Qt::NoBrush);
m_glyph(painter);
}
private:
const std::function<void(QPainter &)> m_glyph;
};
// SF Symbols' arrow.left.and.right, turned 45 degrees.
void swapGlyph(QPainter &painter)
{
painter.translate(6, 6);
painter.rotate(45);
QPainterPath arrow;
arrow.moveTo(-4.5, 0);
arrow.lineTo(4.5, 0);
arrow.moveTo(-2.5, -2);
arrow.lineTo(-4.5, 0);
arrow.lineTo(-2.5, 2);
arrow.moveTo(2.5, -2);
arrow.lineTo(4.5, 0);
arrow.lineTo(2.5, 2);
painter.drawPath(arrow);
}
// SF Symbols' arrow.counterclockwise: a ring, its head at the top.
void resetGlyph(QPainter &painter)
{
QPainterPath ring;
ring.arcMoveTo(QRectF(2.5, 2.5, 7, 7), 100);
ring.arcTo(QRectF(2.5, 2.5, 7, 7), 100, -290);
painter.drawPath(ring);
QPainterPath head;
head.moveTo(7.2, 1.2);
head.lineTo(5.3, 2.6);
head.lineTo(7, 4.2);
painter.drawPath(head);
}
}
SwatchButton::SwatchButton(std::function<PaletteColor()> colour, double radius, double whiteRing, double rimAlpha, QWidget *parent)
: QAbstractButton(parent), m_colour(std::move(colour)), m_radius(radius), m_whiteRing(whiteRing), m_rimAlpha(rimAlpha)
{
}
void SwatchButton::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QRectF bounds(rect());
QPainterPath shape;
shape.addRoundedRect(bounds, m_radius, m_radius);
painter.fillPath(shape, m_colour().color());
painter.setBrush(Qt::NoBrush);
// Swift's inset(by: 1).strokeBorder(.white): a ring inside the rim.
if (m_whiteRing > 0) {
const double inset = 1 + m_whiteRing / 2;
painter.setPen(QPen(Qt::white, m_whiteRing));
painter.drawRoundedRect(bounds.adjusted(inset, inset, -inset, -inset), m_radius - inset, m_radius - inset);
}
painter.setPen(QPen(QColor::fromRgbF(0, 0, 0, float(m_rimAlpha)), 1));
painter.drawRoundedRect(bounds.adjusted(0.5, 0.5, -0.5, -0.5), m_radius - 0.5, m_radius - 0.5);
}
ColorPaletteControls::ColorPaletteControls(EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session),
m_background(new SwatchButton([&session] { return session.paletteColor(true); }, 6, 1.5, 1, this)),
m_foreground(new SwatchButton([&session] { return session.paletteColor(false); }, 6, 1.5, 1, this)),
m_swap(new GlyphButton(swapGlyph, this)), m_reset(new GlyphButton(resetGlyph, this)), m_pickerPanel(*this),
m_masked(session.isMaskSelected())
{
setFixedSize(int(swatchSize + swatchOffset) + 2 * origin.x(), int(swatchSize + swatchOffset) + 2 * origin.y());
m_background->setObjectName(QStringLiteral("backgroundSwatch"));
m_background->setGeometry(QRect(origin + QPoint(int(swatchOffset), int(swatchOffset)), QSize(int(swatchSize), int(swatchSize))));
m_background->setToolTip(QStringLiteral("Background color"));
m_background->setAccessibleName(QStringLiteral("Background color"));
m_foreground->setObjectName(QStringLiteral("foregroundSwatch"));
m_foreground->setGeometry(QRect(origin, QSize(int(swatchSize), int(swatchSize))));
m_foreground->setToolTip(QStringLiteral("Foreground color"));
m_foreground->setAccessibleName(QStringLiteral("Foreground color"));
m_swap->setObjectName(QStringLiteral("swapColors"));
m_swap->move(origin + QPoint(int(swatchSize) + 3, -3));
m_swap->setToolTip(QStringLiteral("Swap foreground and background (X)"));
m_swap->setAccessibleName(QStringLiteral("Swap colors"));
m_reset->setObjectName(QStringLiteral("defaultColors"));
m_reset->move(origin + QPoint(-1, int(swatchSize) + 3));
m_reset->setToolTip(QStringLiteral("Default colors (D)"));
m_reset->setAccessibleName(QStringLiteral("Default colors"));
for (const auto &[swatch, background] : {std::pair(m_background, true), std::pair(m_foreground, false)}) {
connect(swatch, &QAbstractButton::clicked, this, [this, background] {
if (m_session.isMaskSelected())
chooseMask(background);
else
m_session.openColorPicker(background);
});
}
connect(m_swap, &QAbstractButton::clicked, this, [this] { m_session.swapPaletteColors(); });
connect(m_reset, &QAbstractButton::clicked, this, [this] { m_session.resetPaletteColors(); });
connect(&m_session, &EditorSession::changed, this, &ColorPaletteControls::synchronize);
synchronize();
}
void ColorPaletteControls::chooseMask(bool background)
{
auto *popup = new QFrame(this, Qt::Popup);
popup->setObjectName(QStringLiteral("maskColorChoice"));
popup->setAttribute(Qt::WA_DeleteOnClose);
popup->setFrameShape(QFrame::StyledPanel);
auto *headline = new QLabel(background ? QStringLiteral("Mask background") : QStringLiteral("Mask foreground"), popup);
QFont bold = headline->font();
bold.setBold(true);
headline->setFont(bold);
auto *buttons = new QHBoxLayout;
for (const auto &[text, colour] : {std::pair(QStringLiteral("Black · Hide"), PaletteColor::black()), std::pair(QStringLiteral("White · Reveal"), PaletteColor::white())}) {
auto *button = new QPushButton(text, popup);
connect(button, &QPushButton::clicked, popup, [this, popup, background, colour] {
m_session.setPaletteColor(colour, background);
popup->close();
});
buttons->addWidget(button);
}
auto *column = new QVBoxLayout(popup);
column->setContentsMargins(16, 16, 16, 16);
column->setSpacing(12);
column->addWidget(headline);
column->addLayout(buttons);
popup->move(mapToGlobal(QPoint(0, height())));
popup->show();
m_maskChoice = popup;
}
void ColorPaletteControls::synchronize()
{
setEnabled(m_session.canEditPalette());
// Another target closes the choice; a mask cancels the picker.
if (m_session.isMaskSelected() != m_masked) {
m_masked = m_session.isMaskSelected();
if (m_maskChoice)
m_maskChoice->close();
if (m_masked)
m_session.closeColorPicker(false);
}
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
const std::optional<QUuid> shown = picker ? std::optional(picker->id) : std::nullopt;
if (shown != m_shownPicker) {
m_shownPicker = shown;
if (picker)
m_pickerPanel.show(*picker, m_session);
else
m_pickerPanel.close();
}
m_background->update();
m_foreground->update();
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "Document/EditorSession.h"
#include "UI/ColorPickerSheet.h"
#include <QAbstractButton>
#include <QPointer>
#include <QWidget>
#include <functional>
// A colour in a rounded rect; rims vary by bar.
class SwatchButton : public QAbstractButton {
Q_OBJECT
public:
SwatchButton(std::function<PaletteColor()> colour, double radius, double whiteRing, double rimAlpha, QWidget *parent);
protected:
void paintEvent(QPaintEvent *event) override;
private:
const std::function<PaletteColor()> m_colour;
const double m_radius;
const double m_whiteRing;
const double m_rimAlpha;
};
// Swift's ColorPaletteControls: the swatches, swap, defaults, picker.
class ColorPaletteControls : public QWidget {
Q_OBJECT
public:
explicit ColorPaletteControls(EditorSession &session, QWidget *parent = nullptr);
private:
// A mask's swatch offers black or white, as Swift's popover.
void chooseMask(bool background);
void synchronize();
EditorSession &m_session;
SwatchButton *const m_background;
SwatchButton *const m_foreground;
QAbstractButton *const m_swap;
QAbstractButton *const m_reset;
ColorPickerPanelController m_pickerPanel;
// The picker the panel shows: a new one shows anew.
std::optional<QUuid> m_shownPicker;
bool m_masked;
QPointer<QWidget> m_maskChoice;
};
+362
View File
@@ -0,0 +1,362 @@
#include "UI/ColorPickerSheet.h"
#include <QFontDatabase>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QRegularExpressionValidator>
#include <QVBoxLayout>
#include <algorithm>
#include <cmath>
namespace {
constexpr double fieldSize = 256;
// A pointer drag reports each point it passes.
class Picking : public QWidget {
public:
Picking(const EditorSession &session, std::function<void(QPointF)> pick, QWidget *parent)
: QWidget(parent), m_session(session), m_pick(std::move(pick))
{
}
protected:
// Swift's drag answers the primary button alone.
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_pick(event->position());
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (event->buttons().testFlag(Qt::LeftButton))
m_pick(event->position());
}
const EditorSession &m_session;
private:
const std::function<void(QPointF)> m_pick;
};
// Swift's saturation and brightness field, its marker ringed.
class SaturationBrightness : public Picking {
public:
using Picking::Picking;
protected:
void paintEvent(QPaintEvent *) override
{
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
if (!picker)
return;
QPainter painter(this);
const QRectF rect(0, 0, fieldSize, fieldSize);
QLinearGradient across(rect.topLeft(), rect.topRight());
across.setColorAt(0, Qt::white);
across.setColorAt(1, PickerHSB(picker->hsb.hue, 1, 1).rgb().color());
painter.fillRect(rect, across);
QLinearGradient down(rect.topLeft(), rect.bottomLeft());
down.setColorAt(0, Qt::transparent);
down.setColorAt(1, Qt::black);
painter.fillRect(rect, down);
painter.setRenderHint(QPainter::Antialiasing);
painter.setBrush(Qt::NoBrush);
const QPointF centre(picker->hsb.saturation * fieldSize, (1 - picker->hsb.brightness) * fieldSize);
// Black behind, then white: Swift's ring and its background.
painter.setPen(QPen(Qt::black, 0.75));
painter.drawEllipse(centre, 6.375, 6.375);
painter.setPen(QPen(Qt::white, 1.5));
painter.drawEllipse(centre, 5.25, 5.25);
painter.setPen(QPen(QColor(0, 0, 0, 153), 1));
painter.drawRect(rect.adjusted(0.5, 0.5, -0.5, -0.5));
}
};
// Swift's hue strip, 360 on top; the sheet draws arrows.
class HueStrip : public Picking {
public:
using Picking::Picking;
protected:
void paintEvent(QPaintEvent *) override
{
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
if (!picker)
return;
QPainter painter(this);
const QRectF strip(7, 0, 20, fieldSize);
QLinearGradient down(strip.topLeft(), strip.bottomLeft());
for (int step = 0; step <= 6; ++step)
down.setColorAt(step / 6.0, PickerHSB(360 - step * 60, 1, 1).rgb().color());
painter.fillRect(strip, down);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(QColor(0, 0, 0, 153), 1));
painter.setBrush(Qt::NoBrush);
painter.drawRect(strip.adjusted(0.5, 0.5, -0.5, -0.5));
}
};
// The new colour, rounded, a 60% black rim.
class ColorPreview : public QWidget {
public:
ColorPreview(const EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session) {}
protected:
void paintEvent(QPaintEvent *) override
{
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
if (!picker)
return;
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QPainterPath shape;
shape.addRoundedRect(QRectF(rect()), 5, 5);
painter.fillPath(shape, picker->color().color());
painter.setPen(QPen(QColor(0, 0, 0, 153), 1));
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 4.5, 4.5);
}
private:
const EditorSession &m_session;
};
QLabel *label(const QString &text, QWidget *parent)
{
auto *label = new QLabel(text, parent);
label->setFixedWidth(14);
return label;
}
}
PickerField::PickerField(std::function<void()> commit, std::function<void(int)> step, QWidget *parent)
: QLineEdit(parent), m_commit(std::move(commit)), m_step(std::move(step))
{
}
void PickerField::keyPressEvent(QKeyEvent *event)
{
// Swift's arrowSteps: one a press, ten with Shift.
if (m_step && (event->key() == Qt::Key_Up || event->key() == Qt::Key_Down)) {
m_step((event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (event->key() == Qt::Key_Up ? 1 : -1));
return;
}
// Return commits, then goes on to OK, the default.
if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)
m_commit();
QLineEdit::keyPressEvent(event);
}
void PickerField::focusOutEvent(QFocusEvent *event)
{
// A menu or popup borrows the focus: the typing stays.
if (event->reason() != Qt::MenuBarFocusReason && event->reason() != Qt::PopupFocusReason)
m_commit();
QLineEdit::focusOutEvent(event);
}
ColorPickerSheet::ColorPickerSheet(EditorSession &session, std::function<void(bool)> finish, QWidget *parent)
: QWidget(parent), m_session(session),
m_field(new SaturationBrightness(session, [this](QPointF point) {
pick([point](PickerHSB &hsb) {
hsb.saturation = std::clamp(point.x() / fieldSize, 0.0, 1.0);
hsb.brightness = 1 - std::clamp(point.y() / fieldSize, 0.0, 1.0);
});
}, this)),
m_hue(new HueStrip(session, [this](QPointF point) {
pick([point](PickerHSB &hsb) { hsb.hue = (1 - std::clamp(point.y() / fieldSize, 0.0, 1.0)) * 360; });
}, this)),
m_preview(new ColorPreview(session, this)), m_channels{channel(0), channel(1), channel(2)},
m_hex(new PickerField([this] { commitHex(); }, nullptr, this)),
m_ok(new QPushButton(QStringLiteral("OK"), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this))
{
m_field->setObjectName(QStringLiteral("saturationBrightness"));
m_field->setFixedSize(int(fieldSize), int(fieldSize));
m_field->setAccessibleName(QStringLiteral("Saturation and brightness"));
m_hue->setObjectName(QStringLiteral("hueStrip"));
m_hue->setFixedSize(34, int(fieldSize));
m_hue->setAccessibleName(QStringLiteral("Hue"));
m_preview->setObjectName(QStringLiteral("newColor"));
m_preview->setFixedSize(64, 64);
m_preview->setAccessibleName(QStringLiteral("New color"));
m_ok->setObjectName(QStringLiteral("pickerOK"));
m_ok->setDefault(true);
m_ok->setFixedWidth(90);
m_cancel->setObjectName(QStringLiteral("pickerCancel"));
m_cancel->setAutoDefault(false);
m_cancel->setFixedWidth(90);
connect(m_ok, &QPushButton::clicked, this, [finish] { finish(true); });
connect(m_cancel, &QPushButton::clicked, this, [finish] { finish(false); });
auto *buttons = new QVBoxLayout;
buttons->setSpacing(8);
buttons->addWidget(m_ok);
buttons->addWidget(m_cancel);
buttons->addStretch();
auto *top = new QHBoxLayout;
top->setSpacing(16);
top->addWidget(m_preview, 0, Qt::AlignTop);
top->addLayout(buttons);
auto *fields = new QGridLayout;
fields->setHorizontalSpacing(8);
fields->setVerticalSpacing(6);
for (int index = 0; index < 3; ++index) {
fields->addWidget(label(QString(QStringLiteral("RGB").at(index)), this), index, 0);
fields->addWidget(m_channels[size_t(index)], index, 1, Qt::AlignLeft);
}
m_hex->setObjectName(QStringLiteral("hex"));
m_hex->setPlaceholderText(QStringLiteral("Hex"));
m_hex->setAccessibleName(QStringLiteral("Hex color"));
m_hex->setFixedWidth(84);
m_hex->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
fields->addWidget(label(QStringLiteral("#"), this), 3, 0);
fields->addWidget(m_hex, 3, 1, Qt::AlignLeft);
auto *hint = new QLabel(QStringLiteral("Click the canvas to sample"), this);
QFont small = hint->font();
small.setPixelSize(10);
hint->setFont(small);
hint->setForegroundRole(QPalette::PlaceholderText);
auto *column = new QVBoxLayout;
column->setSpacing(0);
column->addLayout(top);
column->addStretch();
column->addSpacing(12);
column->addLayout(fields);
column->addSpacing(8);
column->addWidget(hint);
auto *side = new QWidget(this);
side->setFixedSize(180, int(fieldSize));
side->setLayout(column);
column->setContentsMargins(0, 0, 0, 0);
auto *row = new QHBoxLayout(this);
row->setContentsMargins(20, 20, 20, 20);
row->setSpacing(14);
row->addWidget(m_field, 0, Qt::AlignTop);
row->addWidget(m_hue, 0, Qt::AlignTop);
row->addWidget(side, 0, Qt::AlignTop);
connect(&m_session, &EditorSession::changed, this, &ColorPickerSheet::synchronize);
synchronize();
}
PickerField *ColorPickerSheet::channel(int index)
{
auto *field = new PickerField([this, index] {
// Typed numbers clamp, as the binding; overflows are 255.
PickerField &edited = *m_channels[size_t(index)];
if (edited.isModified() && !edited.text().isEmpty()) {
bool fits = false;
const qlonglong typed = edited.text().toLongLong(&fits);
setChannel(index, fits ? double(typed) : 255);
}
synchronize();
}, [this, index](int step) {
const PaletteColor color = m_session.colorPicker().value().color();
const double value = std::round((index == 0 ? color.red : index == 1 ? color.green : color.blue) * 255);
setChannel(index, value + step);
m_channels[size_t(index)]->setModified(false);
synchronize();
}, this);
field->setObjectName(QString(QStringLiteral("rgb").at(index)));
// Swift's title shows while empty; the label names the channel.
field->setPlaceholderText(QString(QStringLiteral("RGB").at(index)));
field->setAccessibleName(QStringList{QStringLiteral("Red"), QStringLiteral("Green"), QStringLiteral("Blue")}.at(index));
field->setFixedWidth(52);
field->setValidator(new QRegularExpressionValidator(QRegularExpression(QStringLiteral("[0-9]*")), field));
return field;
}
void ColorPickerSheet::change(const std::function<void(PickerHSB &)> &edit)
{
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
if (!picker)
return;
PickerHSB hsb = picker->hsb;
edit(hsb);
m_session.setColorPickerHSB(hsb);
}
void ColorPickerSheet::pick(const std::function<void(PickerHSB &)> &edit)
{
change(edit);
for (PickerField *channel : m_channels)
channel->setModified(false);
synchronize();
}
// Swift's channel binding: the 8-bit colour, one channel set.
void ColorPickerSheet::setChannel(int channel, double value)
{
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
if (!picker)
return;
PaletteColor rgb = picker->color();
(channel == 0 ? rgb.red : channel == 1 ? rgb.green : rgb.blue) = std::clamp(value, 0.0, 255.0) / 255;
change([&rgb](PickerHSB &hsb) { hsb.setRGB(rgb); });
}
void ColorPickerSheet::commitHex()
{
if (const std::optional<PaletteColor> parsed = PaletteColor::fromHex(m_hex->text()))
change([&parsed](PickerHSB &hsb) { hsb.setRGB(*parsed); });
if (m_session.colorPicker())
m_hex->setText(m_session.colorPicker()->color().hex());
}
void ColorPickerSheet::synchronize()
{
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
if (!picker)
return;
const PaletteColor color = picker->color();
const std::array<double, 3> values{color.red, color.green, color.blue};
// An entry being typed in keeps its typing.
for (size_t index = 0; index < 3; ++index) {
if (!m_channels[index]->hasFocus() || !m_channels[index]->isModified())
m_channels[index]->setText(QString::number(std::lround(values[index] * 255)));
}
// Swift's hex draft follows only while its field is unfocused.
if (!m_hex->hasFocus())
m_hex->setText(color.hex());
// Qt's plain widgets hold no value: the degrees describe it.
m_hue->setAccessibleDescription(QStringLiteral("%1 degrees").arg(std::lround(picker->hsb.hue)));
m_field->update();
// The arrows reach five points past the strip.
update(m_hue->geometry().adjusted(0, -5, 0, 5));
m_preview->update();
}
// The strip's arrows overflow it, as Swift's; widgets clip.
void ColorPickerSheet::paintEvent(QPaintEvent *)
{
const std::optional<ColorPickerState> &picker = m_session.colorPicker();
if (!picker)
return;
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QRectF strip(m_hue->geometry());
const double y = strip.top() + (1 - picker->hsb.hue / 360) * fieldSize;
QPainterPath arrows;
arrows.addPolygon(QPolygonF({QPointF(strip.left(), y - 5), QPointF(strip.left() + 7, y), QPointF(strip.left(), y + 5)}));
arrows.addPolygon(QPolygonF({QPointF(strip.right(), y - 5), QPointF(strip.right() - 7, y), QPointF(strip.right(), y + 5)}));
painter.fillPath(arrows, palette().color(QPalette::WindowText));
}
ColorPickerPanelController::ColorPickerPanelController(QWidget &owner) : m_panel(identifier(), owner) {}
void ColorPickerPanelController::show(const ColorPickerState &state, EditorSession &session)
{
// Its close button cancels; closing none does nothing.
m_panel.onClose = [&session] { session.closeColorPicker(false); };
m_panel.show(state.target.title(), new ColorPickerSheet(session, [&session](bool commit) { session.closeColorPicker(commit); }));
}
void ColorPickerPanelController::close()
{
m_panel.close();
}
void ColorPickerPanelController::refocus()
{
FloatingPanel::refocus(identifier());
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include "Document/EditorSession.h"
#include "UI/FloatingPanel.h"
#include <QLineEdit>
#include <QPushButton>
#include <QWidget>
#include <array>
#include <functional>
// A picker entry commits on Return or leaving, as Swift's.
class PickerField : public QLineEdit {
Q_OBJECT
public:
PickerField(std::function<void()> commit, std::function<void(int)> step, QWidget *parent);
protected:
void keyPressEvent(QKeyEvent *event) override;
void focusOutEvent(QFocusEvent *event) override;
private:
const std::function<void()> m_commit;
const std::function<void(int)> m_step;
};
// Swift's ColorPickerSheet: field, hue strip, preview, entry.
class ColorPickerSheet : public QWidget {
Q_OBJECT
public:
ColorPickerSheet(EditorSession &session, std::function<void(bool)> finish, QWidget *parent = nullptr);
protected:
void paintEvent(QPaintEvent *event) override;
private:
// A channel's entry: typed, stepped, clamped, as Swift's row.
PickerField *channel(int index);
// Swift writes the working hsb; here through the session.
void change(const std::function<void(PickerHSB &)> &edit);
// The field or strip's pick replaces the channels' typing.
void pick(const std::function<void(PickerHSB &)> &edit);
void setChannel(int channel, double value);
void commitHex();
void synchronize();
EditorSession &m_session;
QWidget *const m_field;
QWidget *const m_hue;
QWidget *const m_preview;
// Made first: keys start at R, as the grid reads.
const std::array<PickerField *, 3> m_channels;
PickerField *const m_hex;
QPushButton *const m_ok;
QPushButton *const m_cancel;
};
// Swift's ColorPickerPanelController: the picker in its panel.
class ColorPickerPanelController {
public:
explicit ColorPickerPanelController(QWidget &owner);
void show(const ColorPickerState &state, EditorSession &session);
void close();
// Hands the keys back to the picker after a sample.
static void refocus();
static QString identifier() { return QStringLiteral("colorPickerPanel"); }
private:
FloatingPanel m_panel;
};
+327
View File
@@ -0,0 +1,327 @@
#include "UI/CompositorMenus.h"
#include "Logging.h"
#include <QApplication>
#include <QMenu>
namespace {
// An entry's name: Hue/Saturation is newHueSaturationAdjustment.
QString adjustmentName(AdjustmentKind kind)
{
return QStringLiteral("new%1Adjustment").arg(rawValue(kind).remove(QLatin1Char(' ')).remove(QLatin1Char('/')));
}
// A filter's entry from Swift's title: gaussianBlur.
QString filterName(FilterKind kind)
{
QString name = rawValue(kind);
name.remove(QLatin1Char(' '));
name[0] = name[0].toLower();
return name;
}
}
CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWidget &window)
: QObject(&bar), m_workspace(workspace), m_window(window)
{
QMenu *file = bar.addMenu(QStringLiteral("&File"));
add(file, QStringLiteral("newCanvas"), QStringLiteral("New Canvas…"), QKeySequence(Qt::CTRL | Qt::Key_N), [this] { projects().newCanvas(); });
add(file, QStringLiteral("openProject"), QStringLiteral("Open Project…"), QKeySequence(Qt::CTRL | Qt::Key_O), [this] { projects().open(); });
add(file, QStringLiteral("importImages"), QStringLiteral("Import Images…"), QKeySequence(), [this] { session().setShowsImporter(true); });
file->addSeparator();
add(file, QStringLiteral("save"), QStringLiteral("Save"), QKeySequence(Qt::CTRL | Qt::Key_S), [this] { projects().save(); });
add(file, QStringLiteral("saveAs"), QStringLiteral("Save As…"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_S), [this] { projects().save(true); });
file->addSeparator();
add(file, QStringLiteral("exportPNG"), QStringLiteral("Export PNG…"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_E), [this] { projects().exportPNG(); });
add(file, QStringLiteral("exportJPEG"), QStringLiteral("Export JPEG…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::SHIFT | Qt::Key_S),
[this] { projects().exportJPEG(); });
file->addSeparator();
add(file, QStringLiteral("closeProject"), QStringLiteral("Close Project"), QKeySequence(Qt::CTRL | Qt::Key_W), [this] { projects().close(&m_window); });
QMenu *edit = bar.addMenu(QStringLiteral("&Edit"));
add(edit, QStringLiteral("undo"), QStringLiteral("Undo"), QKeySequence(Qt::CTRL | Qt::Key_Z), [this] {
if (m_field)
m_field->undo();
else if (InlineTextEditor *text = typedText())
text->undo();
else
session().undo();
});
add(edit, QStringLiteral("redo"), QStringLiteral("Redo"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Z), [this] {
if (m_field)
m_field->redo();
else if (InlineTextEditor *text = typedText())
text->redo();
else
session().redo();
});
edit->addSeparator();
// Cut, Copy and Paste check when chosen, as Swift's do.
add(edit, QStringLiteral("cut"), QStringLiteral("Cut"), QKeySequence(Qt::CTRL | Qt::Key_X), [this] {
if (m_field)
m_field->cut();
else if (InlineTextEditor *text = typedText())
text->cut();
else if (session().selection() && session().canCopyPixels())
session().cutSelection();
else
qCWarning(lcApp) << "nothing to cut";
});
add(edit, QStringLiteral("copy"), QStringLiteral("Copy"), QKeySequence(Qt::CTRL | Qt::Key_C), [this] {
if (m_field)
m_field->copy();
else if (InlineTextEditor *text = typedText())
text->copy();
else if (session().canCopyPixels())
session().copySelection();
else
qCWarning(lcApp) << "nothing to copy";
});
add(edit, QStringLiteral("copyMerged"), QStringLiteral("Copy Merged"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_C), [this] { session().copyMergedSelection(); });
add(edit, QStringLiteral("paste"), QStringLiteral("Paste"), QKeySequence(Qt::CTRL | Qt::Key_V), [this] {
if (m_field)
m_field->paste();
else if (InlineTextEditor *text = typedText())
text->paste();
else if (session().canPaste())
session().paste();
else
qCWarning(lcApp) << "nothing to paste";
});
edit->addSeparator();
// Text fields delete the selection, else back from the caret.
add(edit, QStringLiteral("fillForeground"), QStringLiteral("Fill with Foreground Color"), QKeySequence(Qt::ALT | Qt::Key_Backspace), [this] {
if (!m_field) {
session().fillSelection(EditorSession::FillSource::foreground);
return;
}
if (!m_field->hasSelectedText())
m_field->cursorWordBackward(true);
m_field->backspace();
});
add(edit, QStringLiteral("fillBackground"), QStringLiteral("Fill with Background Color"), QKeySequence(Qt::CTRL | Qt::Key_Backspace), [this] {
if (!m_field) {
session().fillSelection(EditorSession::FillSource::background);
return;
}
if (!m_field->hasSelectedText())
m_field->home(true);
m_field->backspace();
});
add(edit, QStringLiteral("clearSelectionPixels"), QStringLiteral("Clear Selection Pixels"), QKeySequence(), [this] { session().clearSelectedPixels(); });
add(edit, QStringLiteral("contentAwareFill"), QStringLiteral("Content-Aware Fill…"), QKeySequence(Qt::SHIFT | Qt::Key_Backspace),
[this] { session().beginFilter(FilterKind::contentAwareFill); });
QMenu *view = bar.addMenu(QStringLiteral("&View"));
add(view, QStringLiteral("fit"), QStringLiteral("Fit Canvas"), QKeySequence(Qt::CTRL | Qt::Key_0), [this] { session().fit(); });
add(view, QStringLiteral("actualPixels"), QStringLiteral("Actual Pixels"), QKeySequence(Qt::CTRL | Qt::Key_1), [this] { session().zoom(1); });
add(view, QStringLiteral("zoomIn"), QStringLiteral("Zoom In"), QKeySequence(Qt::CTRL | Qt::Key_Equal), [this] { session().zoom(session().viewport.zoom() * 1.25); });
add(view, QStringLiteral("zoomOut"), QStringLiteral("Zoom Out"), QKeySequence(Qt::CTRL | Qt::Key_Minus), [this] { session().zoom(session().viewport.zoom() / 1.25); });
add(view, QStringLiteral("pixelGrid"), QStringLiteral("Pixel Grid (800% and above)"), QKeySequence(), [this] { session().setShowsPixelGrid(!session().showsPixelGrid()); })
->setCheckable(true);
// Ctrl+H: the Move tool's box; the desktop hides apps.
add(view, QStringLiteral("transformControls"), QStringLiteral("Show Transform Controls"), QKeySequence(Qt::CTRL | Qt::Key_H),
[this] { session().setShowsTransformControls(!session().showsTransformControls()); })
->setCheckable(true);
QMenu *select = bar.addMenu(QStringLiteral("&Select"));
// Text fields keep their own Select All.
add(select, QStringLiteral("selectAll"), QStringLiteral("All"), QKeySequence(Qt::CTRL | Qt::Key_A), [this] {
if (m_field)
m_field->selectAll();
else if (InlineTextEditor *text = typedText())
text->selectAll();
else
session().selectAll();
});
add(select, QStringLiteral("deselect"), QStringLiteral("Deselect"), QKeySequence(Qt::CTRL | Qt::Key_D), [this] { session().deselect(); });
add(select, QStringLiteral("inverse"), QStringLiteral("Inverse"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_I), [this] { session().invertSelection(); });
add(select, QStringLiteral("layerPixels"), QStringLiteral("Layer's Pixels"), QKeySequence(), [this] {
if (const std::optional<QUuid> id = session().activeLayerID())
session().loadLayerSelection(*id);
});
add(select, QStringLiteral("maskBlackAreas"), QStringLiteral("Mask's Black Areas"), QKeySequence(), [this] {
if (const std::optional<QUuid> id = session().activeLayerID())
session().loadMaskSelection(*id);
});
select->addSeparator();
add(select, QStringLiteral("expandSelection"), QStringLiteral("Expand by 1 px"), QKeySequence(), [this] { session().expandSelection(session().selectionExpandAmount()); });
add(select, QStringLiteral("contractSelection"), QStringLiteral("Contract by 1 px"), QKeySequence(), [this] { session().contractSelection(session().selectionContractAmount()); });
QMenu *image = bar.addMenu(QStringLiteral("&Image"));
add(image, QStringLiteral("curves"), QStringLiteral("Curves…"), QKeySequence(Qt::CTRL | Qt::Key_M), [this] { session().beginFilter(FilterKind::curves); });
add(image, QStringLiteral("levels"), QStringLiteral("Levels…"), QKeySequence(Qt::CTRL | Qt::Key_L), [this] { session().beginLevels(); });
add(image, QStringLiteral("hueSaturation"), QStringLiteral("Hue/Saturation…"), QKeySequence(Qt::CTRL | Qt::Key_U),
[this] { session().beginHueSaturation(); });
for (const FilterKind kind : {FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain})
add(image, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
add(image, QStringLiteral("invert"), QStringLiteral("Invert"), QKeySequence(Qt::CTRL | Qt::Key_I), [this] { session().invertPixels(); });
image->addSeparator();
add(image, QStringLiteral("canvasSize"), QStringLiteral("Canvas Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C), [this] { projects().canvasSize(); });
add(image, QStringLiteral("imageSize"), QStringLiteral("Image Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_I), [this] { projects().imageSize(); });
image->addSeparator();
add(image, QStringLiteral("flipCanvasHorizontal"), QStringLiteral("Flip Canvas Horizontal"), QKeySequence(), [this] { session().flipCanvas(true); });
add(image, QStringLiteral("flipCanvasVertical"), QStringLiteral("Flip Canvas Vertical"), QKeySequence(), [this] { session().flipCanvas(false); });
QMenu *filter = bar.addMenu(QStringLiteral("Fil&ter"));
for (const FilterKind kind : allFilterKinds) {
if (kind != FilterKind::contentAwareFill && !isImageAdjustment(kind))
add(filter, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
}
QMenu *layer = bar.addMenu(QStringLiteral("&Layer"));
QMenu *adjustments = layer->addMenu(QStringLiteral("New Adjustment Layer"));
adjustments->menuAction()->setObjectName(QStringLiteral("newAdjustmentLayer"));
for (const AdjustmentKind kind : allAdjustmentKinds)
add(adjustments, adjustmentName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().addAdjustment(kind); });
add(layer, QStringLiteral("editAdjustment"), QStringLiteral("Edit Adjustment…"), QKeySequence(),
[this] { session().setAdjustmentEditingID(session().activeLayerID()); });
layer->addSeparator();
// Swift's transformCommand: with selected pixels, Transform Selection.
add(layer, QStringLiteral("transformLayer"), QStringLiteral("Transform Layer"), QKeySequence(Qt::CTRL | Qt::Key_T), [this] { session().transformCommand(); });
// Swift's layerViaCopy: a selection makes it Layer via Copy.
add(layer, QStringLiteral("layerViaCopy"), QStringLiteral("Duplicate Layer"), QKeySequence(Qt::CTRL | Qt::Key_J), [this] { session().layerViaCopy(); });
layer->addSeparator();
add(layer, QStringLiteral("clippingMask"), QStringLiteral("Create Clipping Mask"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_G), [this] {
if (const std::optional<QUuid> id = session().activeLayerID())
session().toggleClippingMask(*id);
});
layer->addSeparator();
add(layer, QStringLiteral("groupLayers"), QStringLiteral("Group Selected Layers"), QKeySequence(Qt::CTRL | Qt::Key_G), [this] { session().groupSelectedLayers(); });
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()); });
add(layer, QStringLiteral("layerVisibility"), QStringLiteral("Hide Layer"), QKeySequence(), [this] {
if (const std::optional<QUuid> id = session().activeLayerID())
session().toggleLayerVisibility(*id);
});
layer->addSeparator();
add(layer, QStringLiteral("moveLayerUp"), QStringLiteral("Move Layer Up"), QKeySequence(Qt::CTRL | Qt::Key_BracketRight), [this] { session().moveActiveLayer(1); });
add(layer, QStringLiteral("moveLayerDown"), QStringLiteral("Move Layer Down"), QKeySequence(Qt::CTRL | Qt::Key_BracketLeft), [this] { session().moveActiveLayer(-1); });
add(layer, QStringLiteral("mergeLayers"), QStringLiteral("Merge Down"), QKeySequence(Qt::CTRL | Qt::Key_E), [this] { session().mergeLayers(); });
layer->addSeparator();
add(layer, QStringLiteral("flipHorizontal"), QStringLiteral("Flip Layer Horizontal"), QKeySequence(), [this] { session().flipLayers(true); });
add(layer, QStringLiteral("flipVertical"), QStringLiteral("Flip Layer Vertical"), QKeySequence(), [this] { session().flipLayers(false); });
layer->addSeparator();
add(layer, QStringLiteral("deleteLayer"), QStringLiteral("Delete Layer"), QKeySequence(), [this] { session().deleteLayerOrMask(); });
connect(&m_workspace, &ProjectWorkspace::changed, this, &CompositorMenus::watchFront);
// A field gaining or losing focus changes Undo's meaning.
connect(qApp, &QApplication::focusChanged, this, &CompositorMenus::focusMoved);
watchFront();
}
QAction *CompositorMenus::add(QMenu *menu, const QString &name, const QString &text, const QKeySequence &shortcut, const std::function<void()> &run)
{
QAction *made = menu->addAction(text);
made->setObjectName(name);
made->setShortcut(shortcut);
connect(made, &QAction::triggered, this, run);
return made;
}
QAction *CompositorMenus::action(const QString &name) const
{
return parent()->findChild<QAction *>(name);
}
void CompositorMenus::watchFront()
{
// The front tab's session drives every entry.
if (m_watchedTab != std::optional(m_workspace.current().id)) {
m_watchedTab = m_workspace.current().id;
disconnect(m_sessionWatch);
m_sessionWatch = connect(&session(), &EditorSession::changed, this, &CompositorMenus::synchronize);
}
synchronize();
}
void CompositorMenus::focusMoved(QWidget *, QWidget *to)
{
// The menu bar borrows focus; the field keeps Undo.
if (qobject_cast<QMenuBar *>(to) == nullptr) {
m_field = qobject_cast<QLineEdit *>(to);
m_canvas = qobject_cast<CanvasView *>(to);
}
synchronize();
}
void CompositorMenus::synchronize()
{
EditorSession &s = session();
ProjectController &p = projects();
const std::optional<ImageLayer> active = s.activeLayer();
const bool drawn = s.document().has_value();
// Swift's text branch: open text makes the entries bare.
const bool typing = m_field != nullptr || s.textDraft().has_value();
action(QStringLiteral("newCanvas"))->setEnabled(p.canStart());
action(QStringLiteral("openProject"))->setEnabled(p.canStart());
action(QStringLiteral("importImages"))->setEnabled(!s.levels() && !s.showsBusy() && !s.isImporting() && !s.showsNewDocument());
action(QStringLiteral("save"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("saveAs"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("exportPNG"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("exportJPEG"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("closeProject"))->setEnabled(p.canStart());
// In a field, Undo is the field's and bare.
action(QStringLiteral("undo"))->setText(!typing && s.canUndo() ? QStringLiteral("Undo %1").arg(s.history.undoName()) : QStringLiteral("Undo"));
action(QStringLiteral("undo"))->setEnabled(typing || s.canUndo());
action(QStringLiteral("redo"))->setText(!typing && s.canRedo() ? QStringLiteral("Redo %1").arg(s.history.redoName()) : QStringLiteral("Redo"));
action(QStringLiteral("redo"))->setEnabled(typing || s.canRedo());
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
action(QString::fromLatin1(name))->setEnabled(drawn);
action(QStringLiteral("pixelGrid"))->setChecked(s.showsPixelGrid());
action(QStringLiteral("transformControls"))->setChecked(s.showsTransformControls());
action(QStringLiteral("transformControls"))->setEnabled(s.tool() == NavigationTool::move && s.document().has_value());
const bool selected = s.selection().has_value();
action(QStringLiteral("selectAll"))->setEnabled(drawn);
action(QStringLiteral("deselect"))->setEnabled(selected && s.canEditSelection());
action(QStringLiteral("inverse"))->setEnabled(selected && s.canEditSelection());
action(QStringLiteral("layerPixels"))->setEnabled(active && active->asset && s.canEditSelection());
action(QStringLiteral("maskBlackAreas"))->setEnabled(active && active->mask && s.canEditSelection());
action(QStringLiteral("expandSelection"))->setText(QStringLiteral("Expand by %1 px").arg(s.selectionExpandAmount()));
action(QStringLiteral("expandSelection"))->setEnabled(s.canModifySelection());
action(QStringLiteral("contractSelection"))->setText(QStringLiteral("Contract by %1 px").arg(s.selectionContractAmount()));
action(QStringLiteral("contractSelection"))->setEnabled(s.canModifySelection());
action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
for (const FilterKind kind : allFilterKinds) {
if (kind != FilterKind::contentAwareFill)
action(filterName(kind))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
}
action(QStringLiteral("hueSaturation"))->setEnabled(s.canAdjustColors());
action(QStringLiteral("invert"))->setText(s.isMaskSelected() ? QStringLiteral("Invert Mask") : QStringLiteral("Invert"));
action(QStringLiteral("invert"))->setEnabled(s.canInvert());
action(QStringLiteral("canvasSize"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("imageSize"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("flipCanvasHorizontal"))->setEnabled(s.canEditLayers());
action(QStringLiteral("flipCanvasVertical"))->setEnabled(s.canEditLayers());
action(QStringLiteral("newAdjustmentLayer"))->setEnabled(s.canEditLayers());
action(QStringLiteral("editAdjustment"))->setEnabled(s.canEditLayers() && active && active->adjustment);
action(QStringLiteral("transformLayer"))->setText(s.canTransformSelection() ? QStringLiteral("Transform Selection") : QStringLiteral("Transform Layer"));
action(QStringLiteral("transformLayer"))->setEnabled(s.canTransform() || s.canTransformSelection());
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("moveOutOfFolder"))->setEnabled(s.canEditLayers() && active && active->parentID);
action(QStringLiteral("newBlankLayer"))->setEnabled(s.canEditLayers());
action(QStringLiteral("renameLayer"))->setEnabled(s.canEditLayers() && active);
action(QStringLiteral("layerVisibility"))->setText(active && !active->isVisible ? QStringLiteral("Show Layer") : QStringLiteral("Hide Layer"));
action(QStringLiteral("layerVisibility"))->setEnabled(s.canEditLayers() && active);
action(QStringLiteral("moveLayerUp"))->setEnabled(s.canMoveActiveLayer(1));
action(QStringLiteral("moveLayerDown"))->setEnabled(s.canMoveActiveLayer(-1));
action(QStringLiteral("copyMerged"))->setEnabled(s.canCopyMerged());
action(QStringLiteral("contentAwareFill"))->setEnabled(s.canContentAwareFill());
action(QStringLiteral("fillForeground"))->setEnabled(s.canEditPixels());
action(QStringLiteral("fillBackground"))->setEnabled(s.canEditPixels());
action(QStringLiteral("clearSelectionPixels"))->setEnabled(s.selection().has_value() && s.canEditPixels());
action(QStringLiteral("layerViaCopy"))->setText(s.selection() ? QStringLiteral("Layer via Copy") : QStringLiteral("Duplicate Layer"));
action(QStringLiteral("layerViaCopy"))->setEnabled(s.canCopyPixels() || (!s.selection() && s.canEditLayers() && active && !active->isGroup));
action(QStringLiteral("mergeLayers"))->setText(s.mergeTitle());
action(QStringLiteral("mergeLayers"))->setEnabled(s.canMergeLayers());
action(QStringLiteral("flipHorizontal"))->setEnabled(s.canTransform());
action(QStringLiteral("flipVertical"))->setEnabled(s.canTransform());
const std::optional<LayerEffectSelection> effect = s.selectedEffect();
action(QStringLiteral("deleteLayer"))->setText(effect ? QStringLiteral("Delete ") + rawValue(effect->kind)
: s.isMaskSelected() && active && active->mask ? QStringLiteral("Delete Layer Mask")
: s.selectedLayerIDs().size() > 1 ? QStringLiteral("Delete Layers")
: QStringLiteral("Delete Layer"));
action(QStringLiteral("deleteLayer"))->setEnabled(s.canEditLayers() && active);
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "Document/ProjectWorkspace.h"
#include "Rendering/EditorCanvas.h"
#include <QAction>
#include <QLineEdit>
#include <QMenuBar>
#include <QPointer>
// The menu bar: Swift's commands whose session functions exist.
class CompositorMenus : public QObject {
Q_OBJECT
public:
CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWidget &window);
QAction *action(const QString &name) const;
private:
void synchronize();
void focusMoved(QWidget *from, QWidget *to);
// The open text in the focused canvas, Swift's text view.
InlineTextEditor *typedText() const { return m_canvas ? m_canvas->inlineTextEditor() : nullptr; }
void watchFront();
EditorSession &session() const { return m_workspace.current().session; }
ProjectController &projects() const { return m_workspace.current().controller; }
QAction *add(QMenu *menu, const QString &name, const QString &text, const QKeySequence &shortcut, const std::function<void()> &run);
ProjectWorkspace &m_workspace;
QWidget &m_window;
QMetaObject::Connection m_sessionWatch;
std::optional<QUuid> m_watchedTab;
// The field whose undo the Edit entries drive.
QPointer<QLineEdit> m_field;
QPointer<CanvasView> m_canvas;
};
+57
View File
@@ -0,0 +1,57 @@
#include "UI/CropControls.h"
#include "Document/EditorSession.h"
#include <QComboBox>
#include <QLocale>
#include <QPushButton>
CropControls::CropControls(EditorSession &session, QWidget *parent)
: ToolHeaderBar(QStringLiteral("Crop"), parent), m_session(session), m_ratio(new QComboBox(this)), m_size(new QLabel(this)),
m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_apply(new QPushButton(QStringLiteral("Apply Crop"), this))
{
row->setSpacing(14);
// Swift's labelled picker: label and menu share 170 points.
auto *picker = new QWidget(this);
picker->setFixedWidth(170);
auto *pickerRow = new QHBoxLayout(picker);
pickerRow->setContentsMargins(0, 0, 0, 0);
auto *label = new QLabel(QStringLiteral("Ratio"), picker);
label->setBuddy(m_ratio);
pickerRow->addWidget(label);
pickerRow->addWidget(m_ratio, 1);
m_ratio->setObjectName(QStringLiteral("cropRatio"));
for (const QString &choice : {QStringLiteral("Free"), QStringLiteral("Original"), QStringLiteral("1:1"), QStringLiteral("4:3"), QStringLiteral("16:9")})
m_ratio->addItem(choice);
// Swift's onChange: the frame takes a ratio that changed.
connect(m_ratio, &QComboBox::activated, this, [this](int index) {
if (m_ratio->itemText(index) == m_session.cropRatioChoice())
return;
m_session.setCropRatioChoice(m_ratio->itemText(index));
m_session.changeCropRatio();
});
m_size->setObjectName(QStringLiteral("cropSize"));
m_cancel->setObjectName(QStringLiteral("cropCancel"));
m_apply->setObjectName(QStringLiteral("cropApply"));
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelCrop(); });
connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitCrop(); });
row->insertWidget(row->count() - 1, picker);
row->insertWidget(row->count() - 1, m_size);
row->addWidget(m_cancel);
row->addWidget(m_apply);
connect(&m_session, &EditorSession::changed, this, &CropControls::synchronize);
synchronize();
}
void CropControls::synchronize()
{
m_ratio->setCurrentText(m_session.cropRatioChoice());
const std::optional<QRectF> &rect = m_session.cropRect();
// Whole pixels, grouped as the status bar prints them.
if (rect) {
const QLocale english(QLocale::English, QLocale::UnitedStates);
m_size->setText(QStringLiteral("%1 × %2 px").arg(english.toString(qint64(rect->width())), english.toString(qint64(rect->height()))));
}
m_size->setVisible(rect.has_value());
m_cancel->setEnabled(rect.has_value());
m_apply->setEnabled(rect.has_value());
setEnabled(!m_session.showsBusy() && m_session.document().has_value());
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "UI/ToolHeaderStyle.h"
class EditorSession;
class QComboBox;
class QPushButton;
// Swift's CropControls: the ratio, the frame's size, cancel and apply.
class CropControls : public ToolHeaderBar {
Q_OBJECT
public:
explicit CropControls(EditorSession &session, QWidget *parent = nullptr);
private:
void synchronize();
EditorSession &m_session;
QComboBox *const m_ratio;
QLabel *const m_size;
QPushButton *const m_cancel;
QPushButton *const m_apply;
};
+181
View File
@@ -0,0 +1,181 @@
#include "UI/CurvesControls.h"
#include <QHBoxLayout>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QVBoxLayout>
#include <algorithm>
#include <cmath>
// The curve over its grid; presses and drags edit points.
class CurveGraph : public QWidget {
public:
explicit CurveGraph(CurvesControls &controls) : QWidget(&controls), m_controls(controls) { setFixedHeight(260); }
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(rect(), QColor(0, 0, 0, 89));
const CurvesSettings settings = m_controls.m_value();
const auto position = [&](double x, double y) { return QPointF(x / 255 * width(), (1 - y / 255) * height()); };
QPainterPath grid;
for (int index = 0; index <= 4; ++index) {
const double f = index / 4.0;
grid.moveTo(f * width(), 0);
grid.lineTo(f * width(), height());
grid.moveTo(0, f * height());
grid.lineTo(width(), f * height());
}
painter.setRenderHint(QPainter::Antialiasing);
painter.strokePath(grid, QPen(QColor(255, 255, 255, 31), 1));
QPainterPath line;
for (int x = 0; x <= 255; ++x) {
const QPointF point = position(x, settings.value(x, size_t(settings.channel)));
if (x == 0)
line.moveTo(point);
else
line.lineTo(point);
}
painter.strokePath(line, QPen(Qt::white, 2));
const std::vector<CurvePoint> &points = settings.channels.at(size_t(settings.channel));
for (size_t index = 0; index < points.size(); ++index) {
const QPointF at = position(points[index].x, points[index].y);
QPainterPath dot;
dot.addEllipse(QRectF(at.x() - 4, at.y() - 4, 8, 8));
painter.fillPath(dot, m_controls.m_selected == index ? palette().color(QPalette::Highlight) : QColor(Qt::white));
}
}
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_controls.drag(event->position(), size());
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (event->buttons().testFlag(Qt::LeftButton))
m_controls.drag(event->position(), size());
}
void mouseReleaseEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_controls.m_dragging.reset();
}
private:
CurvesControls &m_controls;
};
CurvesControls::CurvesControls(std::function<CurvesSettings()> value, std::function<void(const CurvesSettings &)> change, QWidget *parent)
: QWidget(parent), m_value(std::move(value)), m_change(std::move(change)), m_channel(new QComboBox(this)), m_graph(new CurveGraph(*this)),
m_readout(new QLabel(this)), m_remove(new QPushButton(QStringLiteral("Remove point"), this)),
m_reset(new QPushButton(QStringLiteral("Reset curve"), this))
{
for (const LevelsChannel channel : allLevelsChannels)
m_channel->addItem(rawValue(channel));
connect(m_channel, &QComboBox::activated, this, [this](int index) {
CurvesSettings settings = m_value();
settings.channel = allLevelsChannels[size_t(index)];
m_change(settings);
synchronize();
});
m_graph->setObjectName(QStringLiteral("curvesGraph"));
m_readout->setObjectName(QStringLiteral("curvesReadout"));
auto *title = new QLabel(QStringLiteral("Channel"), this);
title->setBuddy(m_channel);
auto *picker = new QHBoxLayout;
picker->addWidget(title);
picker->addWidget(m_channel, 1);
auto *hint = new QLabel(QStringLiteral("Click to add a point. Drag to adjust."), this);
QFont small = hint->font();
small.setPixelSize(10);
hint->setFont(small);
hint->setForegroundRole(QPalette::PlaceholderText);
m_remove->setAutoDefault(false);
m_reset->setAutoDefault(false);
connect(m_remove, &QPushButton::clicked, this, [this] {
std::vector<CurvePoint> points = this->points();
// Swift's guard: an inner point that is still there.
if (!m_selected || *m_selected == 0 || *m_selected >= points.size() - 1)
return;
points.erase(points.begin() + qsizetype(*m_selected));
m_selected.reset();
setPoints(points);
});
connect(m_reset, &QPushButton::clicked, this, [this] {
m_selected.reset();
setPoints({{0, 0}, {255, 255}});
});
auto *column = new QVBoxLayout(this);
column->setContentsMargins(0, 0, 0, 0);
column->setSpacing(12);
column->addLayout(picker);
column->addWidget(m_graph);
column->addWidget(hint);
auto *selection = new QHBoxLayout;
selection->addWidget(m_readout);
selection->addStretch(1);
selection->addWidget(m_remove);
column->addLayout(selection);
column->addWidget(m_reset, 0, Qt::AlignLeft);
synchronize();
}
std::vector<CurvePoint> CurvesControls::points() const
{
const CurvesSettings settings = m_value();
return settings.channels.at(size_t(settings.channel));
}
void CurvesControls::setPoints(const std::vector<CurvePoint> &points)
{
CurvesSettings settings = m_value();
settings.channels.at(size_t(settings.channel)) = points;
m_change(settings);
synchronize();
}
void CurvesControls::drag(QPointF at, QSizeF size)
{
const double x = std::min(255.0, std::max(0.0, at.x() / size.width() * 255));
const double y = std::min(255.0, std::max(0.0, 255 - at.y() / size.height() * 255));
std::vector<CurvePoint> p = points();
if (!m_dragging) {
const auto distance = [&](const CurvePoint &point) { return std::hypot(point.x - x, point.y - y); };
const auto nearest = std::min_element(p.begin(), p.end(), [&](const CurvePoint &a, const CurvePoint &b) { return distance(a) < distance(b); });
if (distance(*nearest) < 14) {
m_dragging = size_t(nearest - p.begin());
} else if (p.size() < 32 && x > 1 && x < 254 && std::all_of(p.begin(), p.end(), [x](const CurvePoint &point) { return std::abs(point.x - x) > 1; })) {
p.push_back({x, y});
std::sort(p.begin(), p.end(), [](const CurvePoint &a, const CurvePoint &b) { return a.x < b.x; });
m_dragging = size_t(std::find_if(p.begin(), p.end(), [x](const CurvePoint &point) { return point.x == x; }) - p.begin());
}
}
if (!m_dragging || *m_dragging >= p.size())
return;
const size_t i = *m_dragging;
m_selected = i;
p[i].y = y;
if (i > 0 && i < p.size() - 1)
p[i].x = std::min(p[i + 1].x - 1, std::max(p[i - 1].x + 1, x));
setPoints(p);
}
void CurvesControls::synchronize()
{
const CurvesSettings settings = m_value();
// Another channel drops the selection, as Swift's onChange.
if (m_shownChannel != settings.channel) {
m_shownChannel = settings.channel;
m_selected.reset();
m_dragging.reset();
}
m_channel->setCurrentIndex(int(settings.channel));
const std::vector<CurvePoint> &points = settings.channels.at(size_t(settings.channel));
const bool shown = m_selected && *m_selected < points.size();
m_readout->setVisible(shown);
if (shown)
m_readout->setText(QStringLiteral("Input %1 · Output %2").arg(int(points[*m_selected].x)).arg(int(points[*m_selected].y)));
m_remove->setEnabled(m_selected && *m_selected != 0 && *m_selected != points.size() - 1);
m_graph->update();
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "Document/Curves.h"
#include <QComboBox>
#include <QLabel>
#include <QPushButton>
#include <QWidget>
#include <functional>
#include <optional>
// Swift's CurvesControls: a channel's curve, its points and buttons.
class CurvesControls : public QWidget {
Q_OBJECT
public:
// Swift's binding: the settings read, and written back.
CurvesControls(std::function<CurvesSettings()> value, std::function<void(const CurvesSettings &)> change, QWidget *parent = nullptr);
// Shows the settings as they now stand.
void synchronize();
private:
friend class CurveGraph;
std::vector<CurvePoint> points() const;
void setPoints(const std::vector<CurvePoint> &points);
// Swift's drag: take a near point or add one.
void drag(QPointF at, QSizeF size);
const std::function<CurvesSettings()> m_value;
const std::function<void(const CurvesSettings &)> m_change;
std::optional<size_t> m_selected;
std::optional<size_t> m_dragging;
std::optional<LevelsChannel> m_shownChannel;
QComboBox *const m_channel;
QWidget *const m_graph;
QLabel *const m_readout;
QPushButton *const m_remove;
QPushButton *const m_reset;
};
+256
View File
@@ -0,0 +1,256 @@
#include "UI/EffectsSheet.h"
#include "UI/ColorPaletteControls.h"
#include "UI/ColorPickerSheet.h"
#include <QButtonGroup>
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSlider>
#include <QToolButton>
#include <QVBoxLayout>
#include <cmath>
namespace {
// A thousand steps across each slider's range.
constexpr int travel = 1000;
// Swift's .number with no decimals, ties to even, never grouped.
QString shown(double value, QLocale locale)
{
locale.setNumberOptions(QLocale::OmitGroupSeparator);
return locale.toString(std::nearbyint(value), 'f', 0);
}
QLabel *words(const QString &text, bool headline, QWidget *parent)
{
auto *label = new QLabel(text, parent);
if (headline) {
QFont font = label->font();
font.setPixelSize(13);
font.setWeight(QFont::DemiBold);
label->setFont(font);
}
return label;
}
std::optional<double> percent(const auto &effect)
{
return effect ? std::optional(effect->opacity * 100) : std::nullopt;
}
}
EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget *parent)
: QWidget(parent), m_session(session), m_kind(kind), m_column(new QVBoxLayout(this))
{
setObjectName(QStringLiteral("effectsSheet"));
setFixedWidth(340);
m_column->setContentsMargins(20, 20, 20, 20);
m_column->setSpacing(16);
switch (kind) {
case LayerEffectKind::stroke:
header(QStringLiteral("Stroke"), true);
colour(true);
slider(QStringLiteral("Size"), [](const LayerEffects &e) { return e.stroke ? std::optional(e.stroke->size) : std::nullopt; },
[](LayerEffects &e, double value) { e.stroke->size = value; }, 1, StrokeEffect::maxSize, QStringLiteral("px"));
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.stroke); },
[](LayerEffects &e, double value) { e.stroke->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
break;
case LayerEffectKind::shadow:
header(QStringLiteral("Drop Shadow"), false);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.shadow); },
[](LayerEffects &e, double value) { e.shadow->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
slider(QStringLiteral("Angle"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->angle) : std::nullopt; },
[](LayerEffects &e, double value) { e.shadow->angle = value; }, -180, 180, QStringLiteral("°"));
slider(QStringLiteral("Distance"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->distance) : std::nullopt; },
[](LayerEffects &e, double value) { e.shadow->distance = value; }, 0, 300, QStringLiteral("px"));
slider(QStringLiteral("Blur"), [](const LayerEffects &e) { return e.shadow ? std::optional(e.shadow->blur) : std::nullopt; },
[](LayerEffects &e, double value) { e.shadow->blur = value; }, 0, 300, QStringLiteral("px"));
break;
case LayerEffectKind::colorOverlay:
header(QStringLiteral("Color Overlay"), false);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.colorOverlay); },
[](LayerEffects &e, double value) { e.colorOverlay->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
break;
case LayerEffectKind::innerShadow:
header(QStringLiteral("Inner Shadow"), false);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.innerShadow); },
[](LayerEffects &e, double value) { e.innerShadow->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
slider(QStringLiteral("Angle"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->angle) : std::nullopt; },
[](LayerEffects &e, double value) { e.innerShadow->angle = value; }, -180, 180, QStringLiteral("°"));
slider(QStringLiteral("Distance"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->distance) : std::nullopt; },
[](LayerEffects &e, double value) { e.innerShadow->distance = value; }, 0, 300, QStringLiteral("px"));
slider(QStringLiteral("Blur"), [](const LayerEffects &e) { return e.innerShadow ? std::optional(e.innerShadow->blur) : std::nullopt; },
[](LayerEffects &e, double value) { e.innerShadow->blur = value; }, 0, 300, QStringLiteral("px"));
break;
}
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("effectsCancel"));
cancel->setAutoDefault(false);
auto *ok = new QPushButton(QStringLiteral("OK"), this);
ok->setObjectName(QStringLiteral("effectsOK"));
ok->setDefault(true);
connect(cancel, &QPushButton::clicked, this, [this] { m_session.finishEffectsEditing(false); });
connect(ok, &QPushButton::clicked, this, [this] { m_session.finishEffectsEditing(true); });
auto *buttons = new QHBoxLayout;
buttons->setSpacing(10);
buttons->addStretch(1);
buttons->addWidget(cancel);
buttons->addWidget(ok);
m_column->addLayout(buttons);
connect(&m_session, &EditorSession::changed, this, &EffectsSheet::synchronize);
synchronize();
}
// The kind's headline, then the stroke's position or the swatch.
void EffectsSheet::header(const QString &title, bool position)
{
auto *row = new QHBoxLayout;
row->addWidget(words(title, true, this));
row->addStretch(1);
if (!position) {
colour(false);
row->addWidget(m_controls.back());
} else {
auto *choice = new QWidget(this);
choice->setObjectName(QStringLiteral("strokePosition"));
choice->setAccessibleName(QStringLiteral("Position"));
auto *buttons = new QHBoxLayout(choice);
buttons->setContentsMargins(0, 0, 0, 0);
buttons->setSpacing(0);
m_position = new QButtonGroup(choice);
for (const int inside : {0, 1}) {
auto *button = new QToolButton(choice);
button->setText(inside ? QStringLiteral("Inside") : QStringLiteral("Outside"));
button->setCheckable(true);
m_position->addButton(button, inside);
buttons->addWidget(button);
}
connect(m_position, &QButtonGroup::idClicked, this, [this](int inside) {
m_session.changeEffects([inside](LayerEffects &effects) { effects.stroke->inside = inside == 1; });
});
row->addWidget(choice);
m_controls.push_back(choice);
}
m_column->addLayout(row);
}
// The effect's colour, opened in the app's own picker.
void EffectsSheet::colour(bool labelled)
{
auto *swatch = new SwatchButton([this] { return m_session.editingEffects().color(m_kind).value_or(PaletteColor::black()); }, 3, 0, 0.5, this);
swatch->setObjectName(QStringLiteral("effectColor"));
swatch->setFixedSize(36, 18);
swatch->setToolTip(rawValue(m_kind) + QStringLiteral(" color"));
swatch->setAccessibleName(rawValue(m_kind) + QStringLiteral(" color"));
connect(swatch, &QAbstractButton::clicked, this, [this] { m_session.openEffectColorPicker(m_kind); });
if (!labelled) {
m_controls.push_back(swatch);
return;
}
auto *row = new QWidget(this);
auto *layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 0, 0, 0);
auto *label = words(QStringLiteral("Color"), false, row);
label->setFixedWidth(64);
layout->addWidget(label);
layout->addWidget(swatch);
layout->addStretch(1);
m_column->addWidget(row);
m_controls.push_back(row);
}
void EffectsSheet::slider(const QString &title, std::function<std::optional<double>(const LayerEffects &)> value,
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit)
{
const size_t index = m_sliders.size();
const QString name = title.toLower();
auto *row = new QWidget(this);
auto *layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(10);
auto *label = words(title, false, row);
label->setFixedWidth(64);
auto *slider = new QSlider(Qt::Horizontal, row);
slider->setObjectName(name + QStringLiteral("Slider"));
slider->setRange(0, travel);
slider->setFixedWidth(130);
label->setBuddy(slider);
connect(slider, &QSlider::valueChanged, this, [this, index](int position) {
const Slider &at = m_sliders[index];
apply(at, at.low + (at.high - at.low) * position / travel);
// Its value replaces the field's typing, as a step does.
at.field->setModified(false);
synchronize();
});
// A readable number applies on Return or leaving; else reverts.
auto *field = new PickerField([this, index] {
PickerField &edited = *m_sliders[index].field;
bool number = false;
const double typed = edited.locale().toDouble(edited.text(), &number);
if (edited.isModified() && number && std::isfinite(typed))
apply(m_sliders[index], typed);
edited.setModified(false);
synchronize();
}, [this, index](int step) {
const Slider &at = m_sliders[index];
if (const std::optional<double> current = at.value(m_session.editingEffects()))
apply(at, *current + step);
// The step replaces any typing, clamped or not.
at.field->setModified(false);
synchronize();
}, row);
field->setObjectName(name + QStringLiteral("Field"));
field->setAccessibleName(title);
field->setPlaceholderText(title);
field->setAlignment(Qt::AlignRight);
field->setFixedWidth(48);
auto *suffixed = new QHBoxLayout;
suffixed->setSpacing(2);
suffixed->addWidget(field);
suffixed->addWidget(new QLabel(unit, row));
layout->addWidget(label);
layout->addWidget(slider);
layout->addLayout(suffixed);
m_column->addWidget(row);
m_controls.push_back(row);
m_sliders.push_back(Slider{std::move(value), std::move(change), low, high, slider, field});
}
void EffectsSheet::apply(const Slider &control, double value)
{
const double clamped = std::clamp(value, control.low, control.high);
m_session.changeEffects([&control, clamped](LayerEffects &effects) { control.change(effects, clamped); });
}
void EffectsSheet::synchronize()
{
// Swift's onChange: the open picker previews on the layer.
const std::optional<PaletteColor> picked = m_session.colorPicker() ? std::optional(m_session.colorPicker()->color()) : std::nullopt;
if (picked != m_pickerColour) {
m_pickerColour = picked;
m_session.previewEffectColor();
}
const LayerEffects effects = m_session.editingEffects();
for (QWidget *control : m_controls) {
control->setVisible(effects.contains(m_kind));
control->update();
}
if (m_position && effects.stroke)
m_position->button(effects.stroke->inside ? 1 : 0)->setChecked(true);
for (const Slider &control : m_sliders) {
const std::optional<double> value = control.value(effects);
if (!value)
continue;
{
// The slider shows the session's number without writing back.
const QSignalBlocker quiet(control.slider);
control.slider->setValue(int(std::lround((*value - control.low) / (control.high - control.low) * travel)));
}
// A field being typed in keeps its typing.
const QString text = shown(*value, control.field->locale());
if (!control.field->isModified() && control.field->text() != text)
control.field->setText(text);
}
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "Document/EditorSession.h"
#include <QWidget>
#include <functional>
#include <vector>
class PickerField;
class QButtonGroup;
class QSlider;
class QVBoxLayout;
// Swift's EffectsSheet: one effect's controls, bound to its panel's layer.
class EffectsSheet : public QWidget {
Q_OBJECT
public:
EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget *parent = nullptr);
private:
// Swift's slider: a title, a slider, a whole-number field.
struct Slider {
std::function<std::optional<double>(const LayerEffects &)> value;
std::function<void(LayerEffects &, double)> change;
double low;
double high;
QSlider *slider;
PickerField *field;
};
void header(const QString &title, bool position);
void colour(bool labelled);
void slider(const QString &title, std::function<std::optional<double>(const LayerEffects &)> value,
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit);
// A value clamped to the slider's range, as Swift's binding.
void apply(const Slider &control, double value);
void synchronize();
EditorSession &m_session;
const LayerEffectKind m_kind;
QVBoxLayout *const m_column;
// What shows only while the effect is on its layer.
std::vector<QWidget *> m_controls;
std::vector<Slider> m_sliders;
QButtonGroup *m_position = nullptr;
// The picker's colour as last seen, Swift's onChange.
std::optional<PaletteColor> m_pickerColour;
};
+423
View File
@@ -0,0 +1,423 @@
#include "UI/FilterSheet.h"
#include "UI/ColorPaletteControls.h"
#include "UI/ColorPickerSheet.h"
#include "UI/CurvesControls.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QProgressBar>
#include <QPushButton>
#include <QSlider>
#include <QToolButton>
#include <QVBoxLayout>
#include <cmath>
namespace {
// A slider's travel: thousandths of its range.
constexpr int travel = 1000;
QLabel *words(const QString &text, bool callout, QWidget *parent)
{
auto *label = new QLabel(text, parent);
label->setWordWrap(true);
label->setTextFormat(Qt::PlainText);
label->setObjectName(QStringLiteral("filterWords"));
if (callout) {
QFont font = label->font();
font.setPixelSize(12);
label->setFont(font);
label->setForegroundRole(QPalette::PlaceholderText);
}
return label;
}
// Swift's `.number`: at most `decimals` places, none trailing.
QString shown(double value, int decimals, QLocale locale)
{
locale.setNumberOptions(QLocale::OmitGroupSeparator);
QString text = locale.toString(value, 'f', decimals);
if (decimals == 0)
return text;
while (text.endsWith(locale.zeroDigit()))
text.chop(locale.zeroDigit().size());
if (text.endsWith(locale.decimalPoint()))
text.chop(locale.decimalPoint().size());
return text;
}
QColor colour(const AdjustmentColor &value)
{
return QColor::fromRgbF(float(value.red), float(value.green), float(value.blue));
}
// Swift's LinearGradient from the dark end to the light.
class GradientBar : public QWidget {
public:
GradientBar(std::function<GradientMapSettings()> value, QWidget *parent) : QWidget(parent), m_value(std::move(value))
{
setFixedHeight(20);
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QRectF bounds(rect());
const GradientMapSettings::Ends ends = m_value().ends();
QLinearGradient gradient(bounds.topLeft(), bounds.topRight());
gradient.setColorAt(0, colour(ends.dark));
gradient.setColorAt(1, colour(ends.light));
QPainterPath shape;
shape.addRoundedRect(bounds, 4, 4);
painter.fillPath(shape, gradient);
painter.setPen(QPen(QColor::fromRgbF(0, 0, 0, 0.35f), 1));
painter.drawRoundedRect(bounds.adjusted(0.5, 0.5, -0.5, -0.5), 3.5, 3.5);
}
private:
const std::function<GradientMapSettings()> m_value;
};
}
GradientMapControls::GradientMapControls(std::function<GradientMapSettings()> value, std::function<void(const GradientMapSettings &)> change,
std::function<void(bool)> pick, QWidget *parent)
: QWidget(parent), m_value(std::move(value)), m_change(std::move(change)), m_gradient(new GradientBar(m_value, this)),
m_reverse(new QCheckBox(QStringLiteral("Reverse"), this))
{
m_gradient->setObjectName(QStringLiteral("gradientMapBar"));
m_reverse->setObjectName(QStringLiteral("gradientMapReverse"));
connect(m_reverse, &QCheckBox::clicked, this, [this](bool on) {
GradientMapSettings settings = m_value();
settings.reversed = on;
m_change(settings);
});
auto *ends = new QHBoxLayout;
ends->setSpacing(20);
ends->addWidget(swatch(QStringLiteral("Shadows"), false, pick));
ends->addWidget(swatch(QStringLiteral("Highlights"), true, pick));
ends->addStretch(1);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(0, 0, 0, 0);
column->setSpacing(12);
column->addWidget(m_gradient);
column->addLayout(ends);
column->addWidget(m_reverse);
}
QWidget *GradientMapControls::swatch(const QString &title, bool highlights, const std::function<void(bool)> &pick)
{
auto *box = new QWidget(this);
auto *button = new SwatchButton([this, highlights] {
const GradientMapSettings settings = m_value();
const AdjustmentColor &end = highlights ? settings.highlights : settings.shadows;
return PaletteColor{end.red, end.green, end.blue};
}, 6, 1.5, 1, box);
button->setObjectName(title.toLower() + QStringLiteral("Swatch"));
button->setFixedSize(24, 24);
button->setToolTip(QStringLiteral("Choose the %1 color").arg(title.toLower()));
button->setAccessibleName(QStringLiteral("%1 color").arg(title));
connect(button, &QAbstractButton::clicked, this, [pick, highlights] { pick(highlights); });
auto *row = new QHBoxLayout(box);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(8);
row->addWidget(button);
row->addWidget(new QLabel(title, box));
m_swatches.push_back(button);
return box;
}
void GradientMapControls::synchronize()
{
const QSignalBlocker quiet(m_reverse);
m_reverse->setChecked(m_value().reversed);
m_gradient->update();
for (SwatchButton *each : m_swatches)
each->update();
}
FilterSheet::FilterSheet(EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session), m_column(new QVBoxLayout(this)), m_preview(new QCheckBox(QStringLiteral("Preview"), this)),
m_error(words(QString(), false, this)), m_limited(words(QStringLiteral("Limited to the selection"), true, this)),
m_spinner(new QProgressBar(this)), m_activity(words(QString(), true, this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)),
m_ok(new QPushButton(QStringLiteral("OK"), this))
{
setFixedWidth(380);
m_column->setContentsMargins(24, 24, 24, 24);
m_column->setSpacing(16);
const auto key = [](double FilterSettings::*field) { return [field](FilterSettings &settings) -> double & { return settings.*field; }; };
// Swift's `edit?.kind ?? .gaussianBlur`.
switch (m_session.filterEdit() ? m_session.filterEdit()->kind : FilterKind::gaussianBlur) {
case FilterKind::curves:
m_curves = new CurvesControls([this] { return settings().curves; },
[this](const CurvesSettings &curves) { update([&curves](FilterSettings &settings) { settings.curves = curves; }); }, this);
m_column->addWidget(m_curves);
break;
case FilterKind::exposure:
control(QStringLiteral("Exposure"), [](FilterSettings &settings) -> double & { return settings.exposure.exposure; },
ExposureSettings::exposureLow, ExposureSettings::exposureHigh, QString(), 2, false);
control(QStringLiteral("Offset"), [](FilterSettings &settings) -> double & { return settings.exposure.offset; }, ExposureSettings::offsetLow,
ExposureSettings::offsetHigh, QString(), 4, false);
control(QStringLiteral("Gamma"), [](FilterSettings &settings) -> double & { return settings.exposure.gamma; }, ExposureSettings::gammaLow,
ExposureSettings::gammaHigh, QString(), 2, true);
break;
case FilterKind::gradientMap:
m_gradientMap = new GradientMapControls([this] { return settings().gradientMap; },
[this](const GradientMapSettings &map) { update([&map](FilterSettings &settings) { settings.gradientMap = map; }); },
[this](bool highlights) { m_session.openGradientMapColorPicker(highlights); }, this);
m_column->addWidget(m_gradientMap);
break;
case FilterKind::grain:
control(QStringLiteral("Amount"), [](FilterSettings &settings) -> double & { return settings.grain.amount; }, GrainSettings::amountLow,
GrainSettings::amountHigh, QString(), 0, false);
control(QStringLiteral("Size"), [](FilterSettings &settings) -> double & { return settings.grain.size; }, GrainSettings::sizeLow,
GrainSettings::sizeHigh, QStringLiteral("px"), 1, true);
control(QStringLiteral("Roughness"), [](FilterSettings &settings) -> double & { return settings.grain.roughness; },
GrainSettings::roughnessLow, GrainSettings::roughnessHigh, QString(), 0, false);
break;
case FilterKind::removeBackground: background(); break;
case FilterKind::contentAwareFill:
m_column->addWidget(words(QStringLiteral("Fill the selection using surrounding pixels from this layer."), false, this));
break;
case FilterKind::gaussianBlur:
control(QStringLiteral("Radius"), key(&FilterSettings::radius), 0.1, 250, QStringLiteral("px"), 1, true);
break;
case FilterKind::motionBlur:
control(QStringLiteral("Angle"), key(&FilterSettings::angle), -90, 90, QStringLiteral("°"), 0, false);
control(QStringLiteral("Distance"), key(&FilterSettings::distance), 1, 2000, QStringLiteral("px"), 0, true);
break;
case FilterKind::addNoise:
control(QStringLiteral("Amount"), key(&FilterSettings::amount), 0.1, 400, QStringLiteral("%"), 1, true);
noise();
break;
case FilterKind::lensCorrection:
control(QStringLiteral("Remove Distortion"), key(&FilterSettings::distortion), -100, 100, QString(), 0, false);
m_column->addWidget(words(QStringLiteral("Positive straightens lines that bow outward (barrel); negative, lines that bow inward (pincushion)."),
true, this));
break;
}
m_preview->setObjectName(QStringLiteral("filterPreview"));
m_error->setObjectName(QStringLiteral("filterError"));
m_error->setForegroundRole(QPalette::BrightText);
m_limited->setObjectName(QStringLiteral("filterLimited"));
m_spinner->setObjectName(QStringLiteral("filterSpinner"));
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setFixedSize(16, 16);
m_activity->setObjectName(QStringLiteral("filterActivity"));
m_cancel->setObjectName(QStringLiteral("filterCancel"));
m_cancel->setAutoDefault(false);
m_ok->setObjectName(QStringLiteral("filterOK"));
m_ok->setDefault(true);
connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateFilter(settings(), on); });
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelFilter(); });
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitFilter(); });
auto *divider = new QFrame(this);
divider->setFrameShape(QFrame::HLine);
divider->setForegroundRole(QPalette::Mid);
auto *buttons = new QHBoxLayout;
buttons->addWidget(m_cancel);
buttons->addStretch(1);
buttons->addWidget(m_spinner);
buttons->addWidget(m_activity);
buttons->addWidget(m_ok);
m_column->addWidget(m_preview);
m_column->addWidget(m_error);
m_column->addWidget(m_limited);
m_column->addWidget(divider);
m_column->addLayout(buttons);
connect(&m_session, &EditorSession::changed, this, &FilterSheet::synchronize);
synchronize();
}
void FilterSheet::control(const QString &title, std::function<double &(FilterSettings &)> key, double low, double high, const QString &unit,
int decimals, bool logarithmic)
{
QString name = title;
name.remove(QLatin1Char(' '));
name[0] = name[0].toLower();
const size_t index = m_controls.size();
auto *box = new QWidget(this);
auto *label = new QLabel(title, box);
label->setMinimumWidth(60);
label->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
auto *slider = new QSlider(Qt::Horizontal, box);
slider->setObjectName(name + QStringLiteral("Slider"));
slider->setRange(0, travel);
label->setBuddy(slider);
// Swift rounds what the slider sets to the field's decimals.
connect(slider, &QSlider::valueChanged, this, [this, index](int position) {
const Control &at = m_controls[index];
const double from = at.logarithmic ? std::log(at.low) : at.low, to = at.logarithmic ? std::log(at.high) : at.high;
const double value = from + (to - from) * position / travel, step = std::pow(10.0, at.decimals);
const double rounded = std::round((at.logarithmic ? std::exp(value) : value) * step) / step;
// Unchanged, the thumb stays, so arrow steps add up.
FilterSettings current = settings();
if (at.key(current) == rounded)
return;
update([&at, rounded](FilterSettings &settings) { at.key(settings) = rounded; });
// Its value replaces the field's typing.
at.field->setModified(false);
synchronize();
});
// A readable number applies as typed; normalizing clamps it.
auto *field = new PickerField([this, index] {
PickerField &edited = *m_controls[index].field;
bool number = false;
const double typed = edited.locale().toDouble(edited.text(), &number);
if (edited.isModified() && number && std::isfinite(typed))
update([this, index, typed](FilterSettings &settings) { m_controls[index].key(settings) = typed; });
edited.setModified(false);
synchronize();
}, nullptr, box);
field->setObjectName(name + QStringLiteral("Field"));
field->setAccessibleName(title);
field->setPlaceholderText(title);
field->setAlignment(Qt::AlignRight);
field->setFixedWidth(56);
auto *suffixed = new QHBoxLayout;
suffixed->setSpacing(2);
suffixed->addWidget(field);
suffixed->addWidget(new QLabel(unit, box));
auto *row = new QHBoxLayout(box);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(10);
row->addWidget(label);
row->addWidget(slider, 1);
row->addLayout(suffixed);
m_column->addWidget(box);
m_controls.push_back(Control{std::move(key), low, high, decimals, logarithmic, slider, field});
}
void FilterSheet::noise()
{
auto *box = new QWidget(this);
auto *row = new QHBoxLayout(box);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(10);
row->addWidget(new QLabel(QStringLiteral("Distribution"), box));
m_distribution = new QButtonGroup(this);
for (const bool gaussian : {false, true}) {
auto *choice = new QToolButton(box);
choice->setText(gaussian ? QStringLiteral("Gaussian") : QStringLiteral("Uniform"));
choice->setObjectName(gaussian ? QStringLiteral("gaussianChoice") : QStringLiteral("uniformChoice"));
choice->setCheckable(true);
choice->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_distribution->addButton(choice, gaussian ? 1 : 0);
row->addWidget(choice);
}
connect(m_distribution, &QButtonGroup::idClicked, this, [this](int id) { update([id](FilterSettings &settings) { settings.gaussian = id == 1; }); });
m_column->addWidget(box);
m_monochromatic = new QCheckBox(QStringLiteral("Monochromatic"), this);
m_monochromatic->setObjectName(QStringLiteral("monochromatic"));
connect(m_monochromatic, &QCheckBox::clicked, this, [this](bool on) { update([on](FilterSettings &settings) { settings.monochromatic = on; }); });
m_column->addWidget(m_monochromatic);
}
void FilterSheet::background()
{
m_column->addWidget(words(QStringLiteral("Hide the background behind a layer mask, keeping the foreground subjects. The pixels stay, "
"so the background can be painted back at any time."),
false, this));
// Swift's segmented picker, its label hidden.
auto *box = new QWidget(this);
box->setObjectName(QStringLiteral("backgroundQuality"));
box->setAccessibleName(QStringLiteral("Quality"));
box->setToolTip(QStringLiteral("Basic is quick; Advanced refines the mask against the layer's own detail, for hair and fur"));
auto *row = new QHBoxLayout(box);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(0);
m_quality = new QButtonGroup(this);
for (const BackgroundQuality quality : allBackgroundQualities) {
auto *choice = new QToolButton(box);
choice->setText(rawValue(quality));
choice->setCheckable(true);
choice->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_quality->addButton(choice, int(quality));
row->addWidget(choice);
}
connect(m_quality, &QButtonGroup::idClicked, this,
[this](int id) { update([id](FilterSettings &settings) { settings.backgroundQuality = BackgroundQuality(id); }); });
m_column->addWidget(box);
const auto key = [](double FilterSettings::*field) { return [field](FilterSettings &settings) -> double & { return settings.*field; }; };
control(QStringLiteral("Refine"), key(&FilterSettings::refineEdges), 0, 40, QStringLiteral("px"), 0, false);
control(QStringLiteral("Contrast"), key(&FilterSettings::matteContrast), 0, 100, QStringLiteral("%"), 0, false);
control(QStringLiteral("Shift Edge"), key(&FilterSettings::shiftEdge), -10, 10, QStringLiteral("px"), 0, false);
const QStringList helps{QStringLiteral("Pull the mask onto the image's own edges, which recovers hair and fur"),
QStringLiteral("Clear the haze that leaves background showing through thin areas"),
QStringLiteral("Shrink the mask to drop the rim of background color around the subject, or grow it")};
for (qsizetype index = 0; index < helps.size(); ++index) {
QWidget *advanced = m_controls[size_t(index)].slider->parentWidget();
advanced->setToolTip(helps[index]);
m_advanced.push_back(advanced);
}
}
FilterSettings FilterSheet::settings() const
{
return m_session.filterEdit() ? m_session.filterEdit()->settings : FilterSettings();
}
void FilterSheet::update(const std::function<void(FilterSettings &)> &change)
{
FilterSettings value = settings();
change(value);
m_session.updateFilter(value, !m_session.filterEdit() || m_session.filterEdit()->preview);
}
void FilterSheet::synchronize()
{
// Closed, the panel hides the sheet until the next edit.
if (!m_session.filterEdit())
return;
// Swift's onChange: the open map follows the picker's colour.
const std::optional<PaletteColor> picked = m_session.colorPicker() ? std::optional(m_session.colorPicker()->color()) : std::nullopt;
if (picked != m_pickerColour) {
m_pickerColour = picked;
m_session.previewGradientMapColor();
}
const FilterEdit &edit = m_session.filterEdit().value();
for (Control &control : m_controls) {
FilterSettings copy = edit.settings;
const double value = control.key(copy);
const double from = control.logarithmic ? std::log(control.low) : control.low, to = control.logarithmic ? std::log(control.high) : control.high;
const double at = control.logarithmic ? std::log(value) : value;
{
// The slider shows the session's number without writing back.
const QSignalBlocker quiet(control.slider);
control.slider->setValue(int(std::lround((at - from) / (to - from) * travel)));
}
// A field being typed in keeps its typing.
const QString text = shown(value, control.decimals, control.field->locale());
if (!control.field->isModified() && control.field->text() != text)
control.field->setText(text);
}
if (m_curves)
m_curves->synchronize();
if (m_gradientMap)
m_gradientMap->synchronize();
if (m_distribution)
m_distribution->button(edit.settings.gaussian ? 1 : 0)->setChecked(true);
if (m_monochromatic)
m_monochromatic->setChecked(edit.settings.monochromatic);
if (m_quality)
m_quality->button(int(edit.settings.backgroundQuality))->setChecked(true);
for (QWidget *advanced : m_advanced)
advanced->setVisible(edit.settings.backgroundQuality == BackgroundQuality::advanced);
m_preview->setChecked(edit.preview);
m_error->setText(edit.previewError.value_or(QString()));
m_error->setVisible(edit.previewError.has_value());
// An adjustment layer's editor ignores the selection.
m_limited->setVisible(!m_session.adjustmentOriginal() && m_session.selection());
// While the result is made, OK waits and says so.
const bool working = edit.committing || edit.preparing;
m_spinner->setVisible(working);
m_activity->setVisible(working);
m_activity->setText(edit.committing ? QStringLiteral("Applying…") : QStringLiteral("Working…"));
m_ok->setEnabled(!(isAutomatic(edit.kind) && (edit.preparing || edit.previewError)));
setEnabled(!edit.committing);
}
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include "Document/EditorSession.h"
#include <QWidget>
class CurvesControls;
class PickerField;
class QButtonGroup;
class QCheckBox;
class QLabel;
class QProgressBar;
class QPushButton;
class QSlider;
class QVBoxLayout;
class SwatchButton;
// Swift's GradientMapControls: the gradient, both ends, Reverse.
class GradientMapControls : public QWidget {
Q_OBJECT
public:
// `pick` opens the picker: false for Shadows, true for Highlights.
GradientMapControls(std::function<GradientMapSettings()> value, std::function<void(const GradientMapSettings &)> change,
std::function<void(bool)> pick, QWidget *parent = nullptr);
// Shows the settings as they now stand.
void synchronize();
private:
QWidget *swatch(const QString &title, bool highlights, const std::function<void(bool)> &pick);
const std::function<GradientMapSettings()> m_value;
const std::function<void(const GradientMapSettings &)> m_change;
QWidget *const m_gradient;
QCheckBox *const m_reverse;
std::vector<SwatchButton *> m_swatches;
};
// Swift's FilterSheet: the kind's settings, Preview, Cancel and OK.
class FilterSheet : public QWidget {
Q_OBJECT
public:
explicit FilterSheet(EditorSession &session, QWidget *parent = nullptr);
private:
// Swift's control: a slider, an exact field and its unit.
struct Control {
std::function<double &(FilterSettings &)> key;
double low;
double high;
int decimals;
bool logarithmic;
QSlider *slider;
PickerField *field;
};
void control(const QString &title, std::function<double &(FilterSettings &)> key, double low, double high, const QString &unit,
int decimals, bool logarithmic);
void noise();
// Remove Background's words, Quality, and Advanced's three rows.
void background();
// Swift's `settings` and `update`: the defaults once the edit goes.
FilterSettings settings() const;
void update(const std::function<void(FilterSettings &)> &change);
void synchronize();
EditorSession &m_session;
QVBoxLayout *const m_column;
std::vector<Control> m_controls;
CurvesControls *m_curves = nullptr;
GradientMapControls *m_gradientMap = nullptr;
QButtonGroup *m_distribution = nullptr;
QCheckBox *m_monochromatic = nullptr;
QButtonGroup *m_quality = nullptr;
// Shown while the quality is Advanced, as Swift's `if`.
std::vector<QWidget *> m_advanced;
QCheckBox *const m_preview;
QLabel *const m_error;
QLabel *const m_limited;
QProgressBar *const m_spinner;
QLabel *const m_activity;
QPushButton *const m_cancel;
QPushButton *const m_ok;
// The picker's colour as last seen, Swift's onChange.
std::optional<PaletteColor> m_pickerColour;
};
+118
View File
@@ -0,0 +1,118 @@
#include "UI/FloatingPanel.h"
#include "Rendering/EditorCanvas.h"
#include <QApplication>
#include <QCloseEvent>
#include <QDialog>
#include <QHash>
#include <QVBoxLayout>
namespace {
// Top-left corners by panel, for this run of the app.
QHash<QString, QPoint> &positions()
{
static QHash<QString, QPoint> positions;
return positions;
}
}
// Swift's NSPanel: a tool window whose close is Cancel.
class PanelWindow : public QDialog {
public:
PanelWindow(FloatingPanel &owner, QWidget *parent) : QDialog(parent), m_owner(owner)
{
// A dialog is non-modal unless asked: Swift's panel.
setWindowFlag(Qt::Tool);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSizeConstraint(QLayout::SetFixedSize);
}
protected:
// QDialog's own would reject, and so close, again.
void closeEvent(QCloseEvent *event) override
{
m_owner.closed();
QWidget::closeEvent(event);
}
// Escape closes as the close button does.
void reject() override { close(); }
void moveEvent(QMoveEvent *event) override
{
QDialog::moveEvent(event);
if (isVisible())
m_owner.remember();
}
private:
FloatingPanel &m_owner;
};
FloatingPanel::FloatingPanel(const QString &name, QWidget &owner) : m_name(name), m_owner(owner) {}
FloatingPanel::~FloatingPanel()
{
delete m_panel;
}
void FloatingPanel::show(const QString &title, QWidget *content)
{
if (!m_panel) {
m_panel = new PanelWindow(*this, m_owner.window());
m_panel->setObjectName(m_name);
}
const bool wasVisible = m_panel->isVisible();
m_panel->setWindowTitle(title);
// Later: a signal from the old content may still run.
if (m_content) {
m_content->hide();
m_content->deleteLater();
}
m_content = content;
m_panel->layout()->addWidget(content);
// Shown now: layouts show late children from the event loop.
content->show();
m_panel->adjustSize();
// A shown panel stays; without a canvas QDialog centres itself.
if (!wasVisible) {
const CanvasView *canvas = m_owner.window()->findChild<CanvasView *>();
if (positions().contains(m_name))
m_panel->move(positions().value(m_name));
else if (canvas)
m_panel->move(canvas->mapToGlobal(canvas->rect().center()) - QPoint(m_panel->width() / 2, m_panel->height() / 2));
}
m_panel->show();
m_panel->raise();
m_panel->activateWindow();
remember();
}
// Moves were remembered as they came; hide sends no close.
void FloatingPanel::close()
{
if (m_panel)
m_panel->hide();
}
bool FloatingPanel::isVisible() const
{
return m_panel && m_panel->isVisible();
}
void FloatingPanel::refocus(const QString &name)
{
for (QWidget *widget : QApplication::topLevelWidgets()) {
if (widget->objectName() == name && widget->isVisible())
widget->activateWindow();
}
}
void FloatingPanel::remember()
{
positions().insert(m_name, m_panel->pos());
}
void FloatingPanel::closed()
{
if (onClose)
onClose();
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <QPointer>
#include <QString>
#include <QWidget>
#include <functional>
class QDialog;
// Swift's FloatingPanelController: a movable, non-modal tool panel.
class FloatingPanel {
public:
FloatingPanel(const QString &name, QWidget &owner);
~FloatingPanel();
FloatingPanel(const FloatingPanel &) = delete;
FloatingPanel &operator=(const FloatingPanel &) = delete;
// New content and title; a shown panel keeps its place.
void show(const QString &title, QWidget *content);
// Hides the panel without reporting a close.
void close();
bool isVisible() const;
// Its close button and Escape report here, as Cancel.
std::function<void()> onClose;
// Hands the keys back to a shown panel, after clicks.
static void refocus(const QString &name);
private:
friend class PanelWindow;
void remember();
void closed();
const QString m_name;
QWidget &m_owner;
QPointer<QDialog> m_panel;
QPointer<QWidget> m_content;
};
+129
View File
@@ -0,0 +1,129 @@
#include "UI/GradientControls.h"
#include "Document/EditorSession.h"
#include "UI/LassoControls.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSlider>
#include <QToolButton>
#include <cmath>
namespace {
// The session's gradient settings with one field rewritten.
void changeGradient(EditorSession &session, const std::function<void(GradientSettings &)> &change)
{
GradientSettings settings = session.gradientSettings();
change(settings);
session.setGradientSettings(settings);
}
}
GradientSwatch::GradientSwatch(const EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session)
{
setObjectName(QStringLiteral("gradientSwatch"));
setFixedSize(56, 18);
}
void GradientSwatch::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
QPainterPath shape;
shape.addRoundedRect(QRectF(rect()), 3, 3);
painter.setClipPath(shape);
painter.fillRect(rect(), Qt::white);
// Four-point squares: transparency reads as transparency.
const QColor square = QColor::fromRgbF(0.5, 0.5, 0.5, 0.45);
for (int row = 0; row * 4 < height(); ++row) {
for (int column = 0; column * 4 < width(); ++column) {
if ((row + column) % 2 == 0)
painter.fillRect(QRectF(column * 4, row * 4, 4, 4), square);
}
}
const std::array<QColor, 2> colors = m_session.gradientColors(false);
QLinearGradient ramp(QPointF(0, 0), QPointF(width(), 0));
ramp.setColorAt(0, colors[0]);
ramp.setColorAt(1, colors[1]);
painter.fillRect(rect(), ramp);
painter.setClipping(false);
painter.setPen(QPen(QColor(0, 0, 0, 128), 1));
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 2.5, 2.5);
}
GradientControls::GradientControls(EditorSession &session, QWidget *parent)
: ToolHeaderBar(QStringLiteral("Gradient"), parent), m_session(session), m_shapes(new QButtonGroup(this)),
m_linear(shape(QStringLiteral("gradientLinear"), rawValue(GradientShape::linear), false)),
m_radial(shape(QStringLiteral("gradientRadial"), rawValue(GradientShape::radial), true)), m_swatch(new GradientSwatch(session, this)),
m_style(new QComboBox(this)), m_reverse(new QCheckBox(QStringLiteral("Reverse"), this)), m_opacitySlider(new QSlider(Qt::Horizontal, this)),
m_opacity(new SelectionAmountField(session, 1, 100, [this] { return m_session.gradientSettings().opacity * 100; },
[this](double percent) {
changeGradient(m_session, [percent](GradientSettings &gradient) { gradient.opacity = percent / 100; });
}, this)),
m_mask(new QLabel(QStringLiteral("Mask"), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)),
m_apply(new QPushButton(QStringLiteral("Apply"), this))
{
m_style->setObjectName(QStringLiteral("gradientStyle"));
for (const GradientStyle style : allGradientStyles)
m_style->addItem(rawValue(style));
connect(m_style, &QComboBox::activated, this,
[this](int index) { changeGradient(m_session, [index](GradientSettings &gradient) { gradient.style = allGradientStyles[size_t(index)]; }); });
m_reverse->setObjectName(QStringLiteral("gradientReverse"));
connect(m_reverse, &QCheckBox::clicked, this, [this](bool on) { changeGradient(m_session, [on](GradientSettings &gradient) { gradient.reversed = on; }); });
m_opacitySlider->setObjectName(QStringLiteral("gradientOpacitySlider"));
m_opacitySlider->setRange(10, 1000);
m_opacitySlider->setFixedWidth(100);
connect(m_opacitySlider, &QSlider::valueChanged, this,
[this](int value) { changeGradient(m_session, [value](GradientSettings &gradient) { gradient.opacity = value / 1000.0; }); });
m_opacity->setObjectName(QStringLiteral("gradientOpacity"));
m_opacity->setFixedWidth(42);
m_opacity->setToolTip(QStringLiteral("Press 1–9 for 10–90%, 0 for 100%"));
m_mask->setObjectName(QStringLiteral("gradientMaskNote"));
m_mask->setForegroundRole(QPalette::PlaceholderText);
m_cancel->setObjectName(QStringLiteral("gradientCancel"));
m_apply->setObjectName(QStringLiteral("gradientApply"));
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelGradient(); });
connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitGradient(); });
for (QWidget *widget : std::initializer_list<QWidget *>{m_linear, m_radial, m_swatch, m_style, m_reverse, new QLabel(QStringLiteral("Opacity"), this),
m_opacitySlider, m_opacity, new QLabel(QStringLiteral("%"), this)})
row->insertWidget(row->count() - 1, widget);
for (QWidget *widget : std::initializer_list<QWidget *>{m_mask, m_cancel, m_apply})
row->addWidget(widget);
connect(&m_session, &EditorSession::changed, this, &GradientControls::synchronize);
synchronize();
}
// A segment of Swift's shape picker.
QToolButton *GradientControls::shape(const QString &name, const QString &text, bool radial)
{
auto *button = new QToolButton(this);
button->setObjectName(name);
button->setText(text);
button->setCheckable(true);
button->setToolTip(QStringLiteral("Linear runs along the line; Radial spreads out from the start point"));
m_shapes->addButton(button);
connect(button, &QToolButton::clicked, this, [this, radial] {
changeGradient(m_session, [radial](GradientSettings &gradient) { gradient.shape = radial ? GradientShape::radial : GradientShape::linear; });
});
return button;
}
void GradientControls::synchronize()
{
const GradientSettings &gradient = m_session.gradientSettings();
(gradient.shape == GradientShape::radial ? m_radial : m_linear)->setChecked(true);
m_style->setCurrentIndex(int(gradient.style));
m_reverse->setChecked(gradient.reversed);
// The slider shows the session's number without writing back.
const QSignalBlocker blocker(m_opacitySlider);
m_opacitySlider->setValue(int(std::lround(gradient.opacity * 1000)));
m_opacity->sync(int(std::lround(gradient.opacity * 100)));
m_mask->setVisible(m_session.isMaskSelected());
m_cancel->setVisible(m_session.gradientEdit().has_value());
m_apply->setVisible(m_session.gradientEdit().has_value());
m_swatch->update();
setEnabled(!m_session.showsBusy() && m_session.document().has_value());
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "UI/ToolHeaderStyle.h"
#include <functional>
class EditorSession;
class QButtonGroup;
class QCheckBox;
class QComboBox;
class QPushButton;
class QSlider;
class QToolButton;
class SelectionAmountField;
// Swift's gradient swatch: the colours over a checkerboard.
class GradientSwatch : public QWidget {
public:
GradientSwatch(const EditorSession &session, QWidget *parent);
protected:
void paintEvent(QPaintEvent *event) override;
private:
const EditorSession &m_session;
};
// Swift's GradientControls: shape, colours, reverse, opacity, apply.
class GradientControls : public ToolHeaderBar {
Q_OBJECT
public:
explicit GradientControls(EditorSession &session, QWidget *parent = nullptr);
private:
QToolButton *shape(const QString &name, const QString &text, bool radial);
void synchronize();
EditorSession &m_session;
QButtonGroup *const m_shapes;
QToolButton *const m_linear;
QToolButton *const m_radial;
GradientSwatch *const m_swatch;
QComboBox *const m_style;
QCheckBox *const m_reverse;
// The slider steps in thousandths, the field in whole percents.
QSlider *const m_opacitySlider;
SelectionAmountField *const m_opacity;
QLabel *const m_mask;
QPushButton *const m_cancel;
QPushButton *const m_apply;
};
+465
View File
@@ -0,0 +1,465 @@
#include "UI/HueSaturationSheet.h"
#include "Rendering/EyedropperIcon.h"
#include "UI/ColorPickerSheet.h"
#include <QCheckBox>
#include <QComboBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QSlider>
#include <QToolButton>
#include <QVBoxLayout>
#include <algorithm>
#include <cmath>
namespace {
// Swift's slider rows: title, unit and the settings' value.
struct Row {
const char *title;
const char *unit;
double (HueSaturationSettings::*value)() const;
void (HueSaturationSettings::*set)(double);
};
constexpr std::array<Row, 3> rows{{{"Hue", "°", &HueSaturationSettings::hue, &HueSaturationSettings::setHue},
{"Saturation", "", &HueSaturationSettings::saturation, &HueSaturationSettings::setSaturation},
{"Lightness", "", &HueSaturationSettings::lightness, &HueSaturationSettings::setLightness}}};
// Sliders move in hundredths, as Swift's run freely.
constexpr double sliderScale = 100;
// Swift's ranges: Colorize sets hue and saturation outright.
std::pair<double, double> bounds(size_t index, bool colorize)
{
if (index == 0)
return colorize ? std::pair(0.0, 360.0) : std::pair(-180.0, 180.0);
if (index == 1)
return colorize ? std::pair(0.0, 100.0) : std::pair(-100.0, 100.0);
return {-100.0, 100.0};
}
// Swift's `current`: the open edit's settings, else the defaults.
HueSaturationSettings current(const EditorSession &session)
{
return session.hueSaturation() ? session.hueSaturation()->settings : HueSaturationSettings();
}
// Swift's 72 hue slices, as they are or adjusted.
class Spectrum : public QWidget {
public:
Spectrum(std::function<HueSaturationSettings()> value, bool after, QWidget *parent) : QWidget(parent), m_value(std::move(value)), m_after(after)
{
setFixedHeight(16);
}
protected:
void paintEvent(QPaintEvent *) override
{
constexpr int slices = 72;
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QPainterPath rounded;
rounded.addRoundedRect(QRectF(rect()), 3, 3);
painter.setClipPath(rounded);
const HueSaturationSettings settings = m_value();
const double width = double(this->width()) / slices;
for (int slice = 0; slice < slices; ++slice) {
const double hue = double(slice) / slices * 360;
const double shown = m_after ? HueSaturationFilter::shiftedHue(hue, settings) : hue;
painter.fillRect(QRectF(slice * width, 0, width + 0.5, height()), QColor::fromHsvF(float(shown / 360), 1, 1));
}
}
private:
const std::function<HueSaturationSettings()> m_value;
const bool m_after;
};
// Swift's handles: shoulders as blocks, the core's ends as bars.
class SpectrumHandles : public QWidget {
public:
SpectrumHandles(std::function<HueSaturationSettings()> value, std::function<void(double, double, bool)> drag, QWidget *parent)
: QWidget(parent), m_value(std::move(value)), m_drag(std::move(drag))
{
setFixedHeight(12);
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const std::array<double, 4> handles = m_value().band().handles();
for (size_t index = 0; index < handles.size(); ++index) {
const double x = handles[index] / 360 * width();
const bool inner = index == 1 || index == 2;
painter.fillRect(inner ? QRectF(x - 1, 0, 2, height()) : QRectF(x - 3.5, height() / 2.0 - 2.5, 7, 5), palette().color(QPalette::WindowText));
}
}
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_drag(event->position().x(), width(), true);
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (event->buttons().testFlag(Qt::LeftButton))
m_drag(event->position().x(), width(), false);
}
private:
const std::function<HueSaturationSettings()> m_value;
const std::function<void(double, double, bool)> m_drag;
};
// The nearest handle round the circle; ties take the first.
int nearestHandle(const HueBand &band, double degrees)
{
const std::array<double, 4> handles = band.handles();
std::array<double, 4> distances{};
for (size_t index = 0; index < handles.size(); ++index) {
const double gap = std::fmod(std::abs(handles[index] - degrees), 360);
distances[index] = std::min(gap, 360 - gap);
}
return int(std::min_element(distances.begin(), distances.end()) - distances.begin());
}
// Swift's plain sampling buttons: a glyph, tinted when chosen.
class SampleButton : public QToolButton {
public:
SampleButton(std::function<void(QPainter &, const QColor &)> glyph, QWidget *parent) : QToolButton(parent), m_glyph(std::move(glyph))
{
setCheckable(true);
setFixedSize(24, 20);
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
if (isChecked()) {
QColor tint = palette().color(QPalette::Highlight);
tint.setAlphaF(0.25f);
painter.setPen(Qt::NoPen);
painter.setBrush(tint);
painter.drawRoundedRect(QRectF(rect()), 4, 4);
}
m_glyph(painter, palette().color(QPalette::WindowText));
}
private:
const std::function<void(QPainter &, const QColor &)> m_glyph;
};
// Swift's eyedropper, badged plus or minus for Add and Remove.
void eyedropper(QPainter &painter, HueSampleMode mode, const QColor &ink)
{
// A 14-point glyph, centred in the 24 by 20 frame.
painter.save();
painter.translate(5, 3);
painter.save();
painter.scale(14.0 / 18, 14.0 / 18);
painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
EyedropperIcon::paint(painter);
painter.restore();
if (mode != HueSampleMode::replace) {
// An 8-point disc, bottom right, nudged 3 and 1.
QPainterPath badge;
badge.addEllipse(QRectF(9, 7, 8, 8));
QPainterPath sign;
sign.setFillRule(Qt::WindingFill);
sign.addRect(QRectF(10.75, 10.4, 4.5, 1.2));
if (mode == HueSampleMode::add)
sign.addRect(QRectF(12.4, 8.75, 1.2, 4.5));
painter.fillPath(badge.subtracted(sign), ink);
}
painter.restore();
}
// Swift's hand.point.up.left: a finger up and left, outlined.
void pointingHand(QPainter &painter, const QColor &ink)
{
QPainterPath finger, palm;
finger.addRoundedRect(QRectF(-1.5, -7, 3, 9), 1.5, 1.5);
palm.addRoundedRect(QRectF(-4, 0, 9.5, 7.5), 2.5, 2.5);
painter.save();
painter.translate(12, 10);
painter.rotate(-35);
painter.translate(-0.75, -0.25);
painter.setPen(QPen(ink, 1.2));
painter.setBrush(Qt::NoBrush);
painter.drawPath(finger.united(palm));
painter.restore();
}
QToolButton *sampleButton(HueSampleMode mode, QWidget *parent)
{
auto *button = new SampleButton([mode](QPainter &painter, const QColor &ink) { eyedropper(painter, mode, ink); }, parent);
button->setObjectName(QStringLiteral("hue") + rawValue(mode));
button->setToolTip(help(mode));
button->setAccessibleName(rawValue(mode) + QStringLiteral(" color"));
return button;
}
QFrame *line(QFrame::Shape shape, QWidget *parent)
{
auto *made = new QFrame(parent);
made->setFrameShape(shape);
made->setForegroundRole(QPalette::Mid);
return made;
}
}
SpectrumEditor::SpectrumEditor(std::function<HueSaturationSettings()> value, std::function<void(const HueSaturationSettings &)> change, QWidget *parent)
: QWidget(parent), m_value(std::move(value)), m_change(std::move(change)),
m_handles(new SpectrumHandles(m_value, [this](double x, double width, bool pressed) { drag(x, width, pressed); }, this)),
m_after(new Spectrum(m_value, true, this)), m_readout(new QLabel(this))
{
auto *before = new Spectrum(m_value, false, this);
before->setObjectName(QStringLiteral("spectrumBefore"));
m_handles->setObjectName(QStringLiteral("spectrumHandles"));
m_after->setObjectName(QStringLiteral("spectrumAfter"));
m_readout->setObjectName(QStringLiteral("spectrumReadout"));
// Swift's .caption in the secondary ink.
QFont small = m_readout->font();
small.setPixelSize(10);
m_readout->setFont(small);
m_readout->setForegroundRole(QPalette::PlaceholderText);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(0, 0, 0, 0);
column->setSpacing(5);
column->addWidget(before);
column->addWidget(m_handles);
column->addWidget(m_after);
column->addWidget(m_readout, 0, Qt::AlignHCenter);
synchronize();
}
void SpectrumEditor::synchronize()
{
QStringList degrees;
for (const double handle : m_value().band().handles())
degrees << QStringLiteral("%1°").arg(std::lround(handle));
m_readout->setText(degrees.join(QStringLiteral(" ")));
m_handles->update();
m_after->update();
}
void SpectrumEditor::drag(double x, double width, bool pressed)
{
// A press starts afresh: Qt can lose a release.
if (pressed)
m_dragging.reset();
const double degrees = std::clamp(x, 0.0, width) / width * 360;
HueSaturationSettings settings = m_value();
HueBand band = settings.band();
const int index = m_dragging.value_or(nearestHandle(band, degrees));
m_dragging = index;
band.setHandle(index, degrees);
settings.setBand(band);
m_change(settings);
}
HueSaturationSheet::HueSaturationSheet(EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session), m_range(new QComboBox(this)),
m_samples{sampleButton(HueSampleMode::replace, this), sampleButton(HueSampleMode::add, this), sampleButton(HueSampleMode::remove, this)},
m_divider(line(QFrame::VLine, this)), m_targeting(new SampleButton(pointingHand, this)), m_rows{row(0), row(1), row(2)},
m_spectrum(new SpectrumEditor([this] { return current(m_session); },
[this](const HueSaturationSettings &edited) { change([&edited](HueSaturationSettings &settings) { settings = edited; }); }, this)),
m_invert(new QCheckBox(QStringLiteral("Apply outside this range instead"), this)), m_colorize(new QCheckBox(QStringLiteral("Colorize"), this)),
m_preview(new QCheckBox(QStringLiteral("Preview"), this)), m_reset(new QPushButton(QStringLiteral("Reset"), this)),
m_limited(new QLabel(QStringLiteral("Limited to the selection"), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)),
m_ok(new QPushButton(QStringLiteral("OK"), this))
{
setFixedWidth(460);
m_range->setObjectName(QStringLiteral("hueRange"));
m_range->setFixedWidth(160);
// Swift hides the label; Linux reads it through the relation.
auto *rangeLabel = new QLabel(QStringLiteral("Range"), this);
rangeLabel->setBuddy(m_range);
rangeLabel->hide();
for (const ColorRange range : allColorRanges)
m_range->addItem(rawValue(range));
connect(m_range, &QComboBox::activated, this,
[this](int index) { change([index](HueSaturationSettings &settings) { settings.range = allColorRanges[size_t(index)]; }); });
for (size_t index = 0; index < m_samples.size(); ++index) {
// A second click puts the eyedropper away.
connect(m_samples[index], &QToolButton::clicked, this, [this, index] {
const HueSampleMode mode = allHueSampleModes[index];
m_session.setHueTargeting(false);
m_session.setHueSampleMode(m_session.hueSampleMode() == mode ? std::nullopt : std::optional(mode));
});
}
m_divider->setFixedHeight(16);
m_targeting->setObjectName(QStringLiteral("hueTargeting"));
m_targeting->setToolTip(QStringLiteral("Targeted adjustment: drag on the image to change that color's saturation, or its hue with Ctrl held"));
m_targeting->setAccessibleName(QStringLiteral("Targeted adjustment"));
connect(m_targeting, &QToolButton::clicked, this, [this] {
m_session.setHueSampleMode(std::nullopt);
m_session.setHueTargeting(!m_session.hueTargeting());
});
m_spectrum->setObjectName(QStringLiteral("hueSpectrum"));
connect(m_invert, &QCheckBox::clicked, this, [this](bool on) { change([on](HueSaturationSettings &settings) { settings.invertRange = on; }); });
// Photoshop starts colorizing at hue 0, saturation 25.
connect(m_colorize, &QCheckBox::clicked, this, [this](bool on) {
change([on](HueSaturationSettings &settings) { settings = on ? HueSaturationSettings::colorizeStart() : HueSaturationSettings(); });
});
connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateHueSaturation(current(m_session), on); });
m_reset->setAutoDefault(false);
connect(m_reset, &QPushButton::clicked, this, [this] {
change([](HueSaturationSettings &settings) { settings = settings.colorize ? HueSaturationSettings::colorizeStart() : HueSaturationSettings(); });
});
// Swift's .callout in the secondary ink.
QFont callout = m_limited->font();
callout.setPixelSize(12);
m_limited->setFont(callout);
m_limited->setForegroundRole(QPalette::PlaceholderText);
m_limited->setObjectName(QStringLiteral("hueLimited"));
// Return is OK's from anywhere, as Swift's default action.
m_cancel->setAutoDefault(false);
m_ok->setDefault(true);
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelHueSaturation(); });
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitHueSaturation(); });
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
auto *top = new QHBoxLayout;
top->setSpacing(12);
top->addWidget(m_range);
top->addStretch(1);
auto *sampling = new QHBoxLayout;
sampling->setSpacing(6);
for (QToolButton *sample : m_samples)
sampling->addWidget(sample);
sampling->addWidget(m_divider);
sampling->addWidget(m_targeting);
top->addLayout(sampling);
column->addLayout(top);
for (QWidget *each : m_rows)
column->addWidget(each);
column->addWidget(m_spectrum);
column->addWidget(m_invert);
auto *toggles = new QHBoxLayout;
toggles->setSpacing(18);
toggles->addWidget(m_colorize);
toggles->addWidget(m_preview);
toggles->addWidget(m_reset);
toggles->addStretch(1);
column->addLayout(toggles);
column->addWidget(m_limited);
column->addWidget(line(QFrame::HLine, this));
auto *buttons = new QHBoxLayout;
buttons->addWidget(m_cancel);
buttons->addStretch(1);
buttons->addWidget(m_ok);
column->addLayout(buttons);
connect(&m_session, &EditorSession::changed, this, &HueSaturationSheet::synchronize);
synchronize();
}
QWidget *HueSaturationSheet::row(size_t index)
{
const QString title = QString::fromUtf8(rows[index].title);
auto *box = new QWidget(this);
auto *layout = new QHBoxLayout(box);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(10);
auto *label = new QLabel(title, box);
label->setFixedWidth(76);
auto *slider = new QSlider(Qt::Horizontal, box);
slider->setObjectName(title.toLower() + QStringLiteral("Slider"));
label->setBuddy(slider);
connect(slider, &QSlider::valueChanged, this, [this, index](int value) {
change([&](HueSaturationSettings &settings) { (settings.*rows[index].set)(value / sliderScale); });
// Its value replaces the field's typing.
m_fields[index]->setModified(false);
synchronize();
});
// A readable number applies as typed, as Swift's value binding.
auto *field = new PickerField([this, index] {
PickerField &edited = *m_fields[index];
bool number = false;
const double typed = edited.locale().toDouble(edited.text(), &number);
// Swift's Int() traps on what is no finite number.
if (edited.isModified() && number && std::isfinite(typed))
change([&](HueSaturationSettings &settings) { (settings.*rows[index].set)(typed); });
edited.setModified(false);
synchronize();
}, nullptr, box);
field->setObjectName(title.toLower() + QStringLiteral("Field"));
field->setAccessibleName(title);
field->setPlaceholderText(title);
field->setAlignment(Qt::AlignRight);
field->setFixedWidth(48);
// Swift's onSubmit clamps; the default button then commits.
connect(field, &QLineEdit::returnPressed, this, [this, index] {
const std::pair<double, double> limits = bounds(index, current(m_session).colorize);
change([&](HueSaturationSettings &settings) {
(settings.*rows[index].set)(std::clamp((settings.*rows[index].value)(), limits.first, limits.second));
});
});
auto *suffixed = new QHBoxLayout;
suffixed->setSpacing(2);
suffixed->addWidget(field);
suffixed->addWidget(new QLabel(QString::fromUtf8(rows[index].unit), box));
layout->addWidget(label);
layout->addWidget(slider, 1);
layout->addLayout(suffixed);
m_sliders[index] = slider;
m_fields[index] = field;
return box;
}
void HueSaturationSheet::change(const std::function<void(HueSaturationSettings &)> &edit)
{
HueSaturationSettings settings = current(m_session);
edit(settings);
m_session.updateHueSaturation(settings, !m_session.hueSaturation() || m_session.hueSaturation()->preview);
}
void HueSaturationSheet::synchronize()
{
// Closed, the panel hides the sheet until the next edit.
const std::optional<HueSaturationEdit> &edit = m_session.hueSaturation();
if (!edit)
return;
const HueSaturationSettings &settings = edit->settings;
const bool spectrum = settings.range != ColorRange::master && !settings.colorize;
m_range->setCurrentIndex(int(settings.range));
m_range->setEnabled(!settings.colorize);
for (size_t index = 0; index < m_samples.size(); ++index) {
m_samples[index]->setVisible(spectrum);
m_samples[index]->setChecked(m_session.hueSampleMode() == allHueSampleModes[index]);
}
m_divider->setVisible(spectrum);
m_targeting->setVisible(!settings.colorize);
m_targeting->setChecked(m_session.hueTargeting());
for (size_t index = 0; index < rows.size(); ++index) {
const auto [low, high] = bounds(index, settings.colorize);
const double value = (settings.*rows[index].value)();
{
// The sliders show the session's numbers without writing back.
const QSignalBlocker quiet(m_sliders[index]);
m_sliders[index]->setRange(int(low * sliderScale), int(high * sliderScale));
m_sliders[index]->setValue(int(std::lround(std::clamp(value, low, high) * sliderScale)));
}
// Ungrouped digits; a field being typed in keeps its typing.
QLocale locale = m_fields[index]->locale();
locale.setNumberOptions(QLocale::OmitGroupSeparator);
const QString shown = locale.toString(value, 'f', 0);
if (!m_fields[index]->isModified() && m_fields[index]->text() != shown)
m_fields[index]->setText(shown);
}
m_spectrum->setVisible(spectrum);
m_spectrum->synchronize();
m_invert->setVisible(spectrum);
m_invert->setChecked(settings.invertRange);
m_colorize->setChecked(settings.colorize);
m_preview->setChecked(edit->preview);
// An adjustment layer's editor ignores the selection.
m_limited->setVisible(!m_session.adjustmentOriginal() && m_session.selection());
}
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include "Document/EditorSession.h"
#include <QWidget>
#include <array>
#include <functional>
#include <optional>
class PickerField;
class QCheckBox;
class QComboBox;
class QLabel;
class QPushButton;
class QSlider;
class QToolButton;
// Swift's SpectrumEditor: the hues, the band's handles, the hues after.
class SpectrumEditor : public QWidget {
Q_OBJECT
public:
// Swift's binding: the settings read, and written back.
SpectrumEditor(std::function<HueSaturationSettings()> value, std::function<void(const HueSaturationSettings &)> change, QWidget *parent = nullptr);
// Shows the settings as they now stand.
void synchronize();
private:
// Swift's drag: the held handle, else the nearest.
void drag(double x, double width, bool pressed);
const std::function<HueSaturationSettings()> m_value;
const std::function<void(const HueSaturationSettings &)> m_change;
std::optional<int> m_dragging;
QWidget *const m_handles;
QWidget *const m_after;
QLabel *const m_readout;
};
// Swift's HueSaturationSheet: range, samples, sliders, spectrum, toggles.
class HueSaturationSheet : public QWidget {
Q_OBJECT
public:
explicit HueSaturationSheet(EditorSession &session, QWidget *parent = nullptr);
private:
// Swift's settings binding: changed, then handed to the session.
void change(const std::function<void(HueSaturationSettings &)> &edit);
// Swift's slider row: title, slider, field and unit.
QWidget *row(size_t index);
void synchronize();
EditorSession &m_session;
QComboBox *const m_range;
const std::array<QToolButton *, 3> m_samples;
QWidget *const m_divider;
QToolButton *const m_targeting;
// Hue, Saturation, Lightness; the rows make them.
std::array<QSlider *, 3> m_sliders{};
std::array<PickerField *, 3> m_fields{};
const std::array<QWidget *, 3> m_rows;
SpectrumEditor *const m_spectrum;
QCheckBox *const m_invert;
QCheckBox *const m_colorize;
QCheckBox *const m_preview;
QPushButton *const m_reset;
QLabel *const m_limited;
QPushButton *const m_cancel;
QPushButton *const m_ok;
};
+270
View File
@@ -0,0 +1,270 @@
#include "UI/ImageSizeSheet.h"
#include "UI/ColorPickerSheet.h"
#include <QCheckBox>
#include <QComboBox>
#include <QGridLayout>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
#include <cmath>
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
{
auto *label = new QLabel(words, parent);
label->setTextFormat(Qt::PlainText);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
label->setForegroundRole(role);
return label;
}
// Swift's `.number`: three places at most, none trailing, ties even.
QString shown(double value, QLocale locale)
{
locale.setNumberOptions(QLocale::OmitGroupSeparator);
QString number = locale.toString(std::nearbyint(value * 1000) / 1000, 'f', 3);
while (number.endsWith(locale.zeroDigit()))
number.chop(locale.zeroDigit().size());
if (number.endsWith(locale.decimalPoint()))
number.chop(locale.decimalPoint().size());
return number;
}
QString grouped(qint64 value)
{
return QLocale(QLocale::English, QLocale::UnitedStates).toString(value);
}
bool physical(const QString &unit)
{
return unit == QLatin1String("Inches") || unit == QLatin1String("Centimeters");
}
}
ImageSizeSheet::ImageSizeSheet(const CanvasDocument &document, std::function<void(std::optional<ImageSizeOptions>)> finish, QWidget *parent)
: QWidget(parent), m_originalWidth(document.width), m_originalHeight(document.height), m_finish(std::move(finish)),
m_width(double(document.width)), m_height(double(document.height)), m_resolution(document.resolution), m_units(new QComboBox(this)),
m_widthField(dimension(true)), m_heightField(dimension(false)), m_lock(new QCheckBox(QStringLiteral("Lock aspect ratio"), this)),
m_resolutionField(new PickerField([this] {
bool number = false;
const double typed = m_resolutionField->locale().toDouble(m_resolutionField->text(), &number);
if (m_resolutionField->isModified() && number) {
// Swift's onChange: a physical size keeps, its pixels scale.
const double old = std::exchange(m_resolution, typed);
if (m_resample && physical(m_unit) && old > 0 && typed > 0 && std::isfinite(typed)) {
m_width *= typed / old;
m_height *= typed / old;
}
}
m_resolutionField->setModified(false);
synchronize();
}, nullptr, this)),
m_samplingRow(new QWidget(this)), m_samplingChoice(new QComboBox(m_samplingRow)),
m_explanation(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)),
m_result(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), m_resize(new QPushButton(QStringLiteral("Resize"), this))
{
setFixedWidth(430);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(18);
column->addWidget(text(QStringLiteral("Image Size"), 17, QFont::Bold, QPalette::WindowText, this));
column->addWidget(text(QStringLiteral("Current: %1 × %2 pixels").arg(grouped(document.width), grouped(document.height)), 13, QFont::Normal,
QPalette::PlaceholderText, this));
auto *fields = new QGridLayout;
fields->setHorizontalSpacing(8);
fields->setVerticalSpacing(18);
fields->setColumnMinimumWidth(0, 75);
auto *units = new QLabel(QStringLiteral("Units"), this);
units->setBuddy(m_units);
m_units->setObjectName(QStringLiteral("imageUnits"));
connect(m_units, &QComboBox::activated, this, [this](int index) {
// A wheel leaves the focus: pending typing commits first.
m_units->setFocus();
m_unit = m_units->itemText(index);
synchronize();
});
fields->addWidget(units, 0, 0);
fields->addWidget(m_units, 0, 1);
fields->addWidget(new QLabel(QStringLiteral("Width"), this), 1, 0);
fields->addWidget(m_widthField, 1, 1);
fields->addWidget(new QLabel(QStringLiteral("Height"), this), 2, 0);
fields->addWidget(m_heightField, 2, 1);
column->addLayout(fields);
m_lock->setObjectName(QStringLiteral("imageLocked"));
connect(m_lock, &QCheckBox::clicked, this, [this](bool on) {
m_locked = on;
synchronize();
});
column->addWidget(m_lock);
auto *resolution = new QHBoxLayout;
resolution->addWidget(new QLabel(QStringLiteral("Resolution"), this));
m_resolutionField->setObjectName(QStringLiteral("imageResolution"));
m_resolutionField->setPlaceholderText(QStringLiteral("Resolution"));
m_resolutionField->setAccessibleName(QStringLiteral("Resolution"));
resolution->addWidget(m_resolutionField, 1);
resolution->addWidget(text(QStringLiteral("pixels/inch"), 13, QFont::Normal, QPalette::PlaceholderText, this));
column->addLayout(resolution);
// Resample changes only by its own box.
auto *resample = new QCheckBox(QStringLiteral("Resample"), this);
resample->setObjectName(QStringLiteral("imageResample"));
resample->setChecked(true);
connect(resample, &QCheckBox::clicked, this, &ImageSizeSheet::setResample);
column->addWidget(resample);
m_samplingRow->setObjectName(QStringLiteral("imageSamplingRow"));
auto *samplingLayout = new QHBoxLayout(m_samplingRow);
samplingLayout->setContentsMargins(0, 0, 0, 0);
auto *sampling = new QLabel(QStringLiteral("Sampling"), m_samplingRow);
sampling->setBuddy(m_samplingChoice);
m_samplingChoice->setObjectName(QStringLiteral("imageSampling"));
for (const LayerSampling each : {LayerSampling::nearest, LayerSampling::smooth, LayerSampling::high})
m_samplingChoice->addItem(rawValue(each), int(each));
connect(m_samplingChoice, &QComboBox::activated, this, [this](int index) {
m_sampling = LayerSampling(m_samplingChoice->itemData(index).toInt());
});
samplingLayout->addWidget(sampling);
samplingLayout->addWidget(m_samplingChoice, 1);
column->addWidget(m_samplingRow);
m_explanation->setObjectName(QStringLiteral("imageExplanation"));
m_explanation->setWordWrap(true);
column->addWidget(m_explanation);
m_result->setObjectName(QStringLiteral("imageResult"));
m_result->setWordWrap(true);
column->addWidget(m_result);
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("imageCancel"));
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
m_resize->setObjectName(QStringLiteral("imageResize"));
m_resize->setDefault(true);
// Resize rests while the size is invalid: no guard needed.
connect(m_resize, &QPushButton::clicked, this, [this] {
m_finish(ImageSizeOptions{.width = qint64(std::round(m_width)), .height = qint64(std::round(m_height)), .resolution = m_resolution,
.sampling = m_sampling});
});
auto *buttons = new QHBoxLayout;
buttons->addWidget(cancel);
buttons->addStretch(1);
buttons->addWidget(m_resize);
column->addLayout(buttons);
synchronize();
}
// NaN and infinity fail every range test.
bool ImageSizeSheet::valid() const
{
const double width = std::round(m_width), height = std::round(m_height);
return m_resolution >= 1 && m_resolution <= 9600 && width >= 1 && width <= 30'000 && height >= 1 && height <= 30'000
&& (!m_resample || width * height <= 100'000'000);
}
double ImageSizeSheet::display(double pixels, qint64 original) const
{
if (m_unit == QLatin1String("Percent"))
return pixels / double(original) * 100;
if (m_unit == QLatin1String("Inches"))
return pixels / m_resolution;
if (m_unit == QLatin1String("Centimeters"))
return pixels / m_resolution * 2.54;
return pixels;
}
PickerField *ImageSizeSheet::dimension(bool isWidth)
{
auto *field = new PickerField([this, isWidth] {
PickerField &edited = isWidth ? *m_widthField : *m_heightField;
bool number = false;
const double typed = edited.locale().toDouble(edited.text(), &number);
if (edited.isModified() && number)
setDimension(typed, isWidth);
edited.setModified(false);
synchronize();
}, nullptr, this);
field->setObjectName(isWidth ? QStringLiteral("imageWidth") : QStringLiteral("imageHeight"));
field->setPlaceholderText(isWidth ? QStringLiteral("Width") : QStringLiteral("Height"));
field->setAccessibleName(isWidth ? QStringLiteral("Width") : QStringLiteral("Height"));
return field;
}
// Swift's binding: pixels, or without resampling the resolution.
void ImageSizeSheet::setDimension(double value, bool isWidth)
{
if (!(std::isfinite(value) && value > 0))
return;
if (!m_resample) {
m_resolution = (isWidth ? m_width : m_height) / value * (m_unit == QLatin1String("Centimeters") ? 2.54 : 1);
return;
}
double pixels = value;
if (m_unit == QLatin1String("Percent"))
pixels = value / 100 * double(isWidth ? m_originalWidth : m_originalHeight);
else if (m_unit == QLatin1String("Inches"))
pixels = value * m_resolution;
else if (m_unit == QLatin1String("Centimeters"))
pixels = value / 2.54 * m_resolution;
if (isWidth) {
if (m_locked)
m_height = pixels * m_height / m_width;
m_width = pixels;
} else {
if (m_locked)
m_width = pixels * m_width / m_height;
m_height = pixels;
}
}
// Swift's onChange: without resampling the pixels stay the document's.
void ImageSizeSheet::setResample(bool enabled)
{
m_resample = enabled;
if (!enabled) {
m_width = double(m_originalWidth);
m_height = double(m_originalHeight);
m_locked = true;
if (!physical(m_unit))
m_unit = QStringLiteral("Inches");
}
synchronize();
}
void ImageSizeSheet::synchronize()
{
// Pixels and Percent need resampling, as Swift's filter.
QStringList shownUnits = {QStringLiteral("Pixels"), QStringLiteral("Percent"), QStringLiteral("Inches"), QStringLiteral("Centimeters")};
if (!m_resample)
shownUnits = {QStringLiteral("Inches"), QStringLiteral("Centimeters")};
QStringList listed;
for (int index = 0; index < m_units->count(); ++index)
listed << m_units->itemText(index);
if (listed != shownUnits) {
m_units->clear();
m_units->addItems(shownUnits);
}
m_units->setCurrentText(m_unit);
for (const auto &[field, isWidth] : {std::pair(m_widthField, true), std::pair(m_heightField, false)}) {
// A field being typed in keeps its typing.
const QString number = shown(display(isWidth ? m_width : m_height, isWidth ? m_originalWidth : m_originalHeight), field->locale());
if (!field->isModified() && field->text() != number)
field->setText(number);
}
const QString resolution = shown(m_resolution, m_resolutionField->locale());
if (!m_resolutionField->isModified() && m_resolutionField->text() != resolution)
m_resolutionField->setText(resolution);
m_lock->setChecked(m_locked);
m_lock->setEnabled(m_resample);
m_samplingRow->setVisible(m_resample);
m_samplingChoice->setCurrentIndex(m_samplingChoice->findData(int(m_sampling)));
m_explanation->setText(m_resample ? QStringLiteral("Resizes layer pixels and applies existing transforms. Undo restores the originals.")
: QStringLiteral("Only print dimensions and resolution change. Pixels stay unchanged."));
const bool isValid = valid();
m_result->setText(isValid ? QStringLiteral("Result: %1 × %2 pixels").arg(grouped(qint64(std::round(m_width))), grouped(qint64(std::round(m_height))))
: QStringLiteral("Use 1–30,000 pixels per side, up to 100 megapixels, and 1–9,600 pixels/inch."));
m_result->setForegroundRole(isValid ? QPalette::PlaceholderText : QPalette::BrightText);
m_resize->setEnabled(isValid);
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include "IO/ImageResizer.h"
#include <QWidget>
#include <functional>
class PickerField;
class QCheckBox;
class QComboBox;
class QLabel;
class QPushButton;
// Swift's ImageSizeSheet: pixels resampled, or print size alone.
class ImageSizeSheet : public QWidget {
Q_OBJECT
public:
ImageSizeSheet(const CanvasDocument &document, std::function<void(std::optional<ImageSizeOptions>)> finish, QWidget *parent = nullptr);
private:
bool valid() const;
double display(double pixels, qint64 original) const;
PickerField *dimension(bool isWidth);
void setDimension(double value, bool isWidth);
void setResample(bool enabled);
void synchronize();
const qint64 m_originalWidth;
const qint64 m_originalHeight;
const std::function<void(std::optional<ImageSizeOptions>)> m_finish;
double m_width;
double m_height;
double m_resolution;
bool m_locked = true;
bool m_resample = true;
QString m_unit = QStringLiteral("Pixels");
LayerSampling m_sampling = LayerSampling::high;
QComboBox *const m_units;
PickerField *const m_widthField;
PickerField *const m_heightField;
QCheckBox *const m_lock;
PickerField *const m_resolutionField;
QWidget *const m_samplingRow;
QComboBox *const m_samplingChoice;
QLabel *const m_explanation;
QLabel *const m_result;
QPushButton *const m_resize;
};
+253
View File
@@ -0,0 +1,253 @@
#include "UI/JPEGExportSheet.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPaletteControls.h"
#include <QColorDialog>
#include <QFutureWatcher>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QSlider>
#include <QTimer>
#include <QVBoxLayout>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
const QString JPEGExportSheet::qualityKey = QStringLiteral("jpegExportQuality");
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
{
auto *label = new QLabel(words, parent);
label->setTextFormat(Qt::PlainText);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
label->setForegroundRole(role);
return label;
}
QString grouped(qint64 value)
{
return QLocale(QLocale::English, QLocale::UnitedStates).toString(value);
}
// Swift reads a finite number alone, clamped to 0–1.
double startQuality()
{
bool number = false;
const double saved = QSettings().value(JPEGExportSheet::qualityKey).toDouble(&number);
return number && std::isfinite(saved) ? std::clamp(saved, 0.0, 1.0) : JPEGOptions().quality;
}
struct Encoded {
std::optional<JPEGResult> result;
std::optional<QString> failure;
};
}
// Swift's ZStack: dark gray, the fitted preview, a plate.
class JPEGExportSheet::Preview : public QWidget {
public:
using QWidget::QWidget;
QImage image;
bool updating = false;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(rect(), QColor::fromRgbF(0.12f, 0.12f, 0.12f));
if (!image.isNull()) {
painter.setRenderHint(QPainter::SmoothPixmapTransform);
QRectF target(QPointF(), QSizeF(image.size()).scaled(QSizeF(size()), Qt::KeepAspectRatio));
target.moveCenter(QRectF(rect()).center());
painter.drawImage(target, image);
}
if (updating) {
// Swift's regular material behind the spinner.
QColor material = palette().color(QPalette::Window);
material.setAlphaF(0.85f);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(material);
QRectF plate(0, 0, 64, 64);
plate.moveCenter(QRectF(rect()).center());
painter.drawRoundedRect(plate, 8, 8);
}
}
};
JPEGExportSheet::JPEGExportSheet(ExportRaster raster, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent)
: QWidget(parent), m_raster(std::move(raster)), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new Preview(this)),
m_spinner(new QProgressBar(m_preview)), m_quality(new QSlider(Qt::Horizontal, this)),
m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_matteSwatch(new SwatchButton([this] { return PaletteColor{m_options.red, m_options.green, m_options.blue}; }, 3, 0, 0.5, this)),
m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)), m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)),
m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this))
{
m_options.quality = startQuality();
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
column->addWidget(text(QStringLiteral("Export JPEG"), 17, QFont::Bold, QPalette::WindowText, this));
m_preview->setObjectName(QStringLiteral("jpegPreview"));
m_preview->setFixedSize(560, 330);
m_spinner->setObjectName(QStringLiteral("jpegSpinner"));
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setGeometry(264, 149, 32, 32);
column->addWidget(m_preview);
auto *quality = new QHBoxLayout;
auto *qualityLabel = new QLabel(QStringLiteral("Quality"), this);
qualityLabel->setBuddy(m_quality);
// Swift's slider steps by hundredths.
m_quality->setObjectName(QStringLiteral("jpegQuality"));
m_quality->setRange(0, 100);
m_quality->setValue(int(std::lround(m_options.quality * 100)));
connect(m_quality, &QSlider::valueChanged, this, [this](int hundredths) {
m_options.quality = hundredths / 100.0;
request();
});
m_percent->setObjectName(QStringLiteral("jpegPercent"));
m_percent->setFixedWidth(45);
m_percent->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
quality->addWidget(qualityLabel);
quality->addWidget(m_quality, 1);
quality->addWidget(m_percent);
column->addLayout(quality);
// Swift's ColorPicker: its label and a well, no opacity.
auto *matte = new QHBoxLayout;
matte->addWidget(new QLabel(QStringLiteral("Background for transparency"), this));
m_matteSwatch->setObjectName(QStringLiteral("jpegMatte"));
m_matteSwatch->setFixedSize(36, 18);
m_matteSwatch->setAccessibleName(QStringLiteral("Background for transparency"));
connect(m_matteSwatch, &QAbstractButton::clicked, this, &JPEGExportSheet::pickMatte);
matte->addWidget(m_matteSwatch);
matte->addStretch(1);
column->addLayout(matte);
column->addWidget(text(QStringLiteral("%1 × %2 px · sRGB").arg(grouped(m_raster.image.width()), grouped(m_raster.image.height())), 13, QFont::Normal,
QPalette::PlaceholderText, this));
auto *buttons = new QHBoxLayout;
m_failure->setObjectName(QStringLiteral("jpegError"));
m_bytes->setObjectName(QStringLiteral("jpegBytes"));
m_note->setObjectName(QStringLiteral("jpegNote"));
buttons->addWidget(m_failure);
buttons->addWidget(m_bytes);
buttons->addWidget(m_note);
buttons->addStretch(1);
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("jpegCancel"));
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
m_export->setObjectName(QStringLiteral("jpegExport"));
m_export->setDefault(true);
// Export rests until a result is ready.
connect(m_export, &QPushButton::clicked, this, [this] {
QSettings().setValue(qualityKey, m_options.quality);
m_finish(m_result.value().data);
});
buttons->addWidget(cancel);
buttons->addWidget(m_export);
column->addLayout(buttons);
m_wait->setObjectName(QStringLiteral("jpegWait"));
m_wait->setSingleShot(true);
m_wait->setInterval(200);
m_wait->setTimerType(Qt::PreciseTimer);
connect(m_wait, &QTimer::timeout, this, &JPEGExportSheet::encode);
request();
}
// SwiftUI cancels the task when the sheet goes.
JPEGExportSheet::~JPEGExportSheet()
{
m_cancelled->store(true);
}
// Swift's task(id:): the last request stops; this waits 200 ms.
void JPEGExportSheet::request()
{
if (m_cancelled)
m_cancelled->store(true);
m_cancelled = std::make_shared<std::atomic_bool>(false);
m_error.reset();
m_wait->start();
synchronize();
}
void JPEGExportSheet::encode()
{
const std::shared_ptr<std::atomic_bool> cancelled = m_cancelled;
const JPEGOptions requested = m_options;
auto *watcher = new QFutureWatcher<Encoded>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, cancelled, requested] {
watcher->deleteLater();
// Swift's check after the await: a newer request supersedes.
if (cancelled->load())
return;
Encoded encoded = watcher->future().takeResult();
if (encoded.result) {
m_result = std::move(encoded.result);
m_readyOptions = requested;
} else {
m_error = encoded.failure.value();
}
synchronize();
});
watcher->setFuture(QtConcurrent::run([raster = m_raster, requested, cancelled]() -> Encoded {
try {
return {ImageExporter::jpeg(raster, requested, [cancelled] { return cancelled->load(); }), std::nullopt};
} catch (const CancellationError &) {
return {};
} catch (const ExportError &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
// The desktop's colour dialog, SwiftUI's colour panel, without alpha.
void JPEGExportSheet::pickMatte()
{
auto *dialog = new QColorDialog(QColor::fromRgbF(float(m_options.red), float(m_options.green), float(m_options.blue)), this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->setWindowTitle(QStringLiteral("Background for transparency"));
connect(dialog, &QColorDialog::colorSelected, this, [this](const QColor &picked) {
const QColor srgb = picked.toRgb();
JPEGOptions next = m_options;
next.red = srgb.redF();
next.green = srgb.greenF();
next.blue = srgb.blueF();
// Swift's onChange: the same colour asks nothing.
if (next == m_options)
return;
m_options = next;
m_matteSwatch->update();
request();
});
dialog->open();
}
void JPEGExportSheet::synchronize()
{
const bool ready = m_result && m_readyOptions == m_options;
const bool updating = m_readyOptions != m_options && !m_error;
m_preview->image = m_result ? m_result->preview : QImage();
m_preview->updating = updating;
m_preview->update();
m_spinner->setVisible(updating);
m_percent->setText(QStringLiteral("%1%").arg(std::lround(m_options.quality * 100)));
m_failure->setVisible(m_error.has_value());
m_failure->setText(m_error.value_or(QString()));
m_bytes->setVisible(ready && !m_error);
m_bytes->setText(m_result ? ByteCounts::file(m_result->data.size()) : QString());
m_note->setVisible(!m_error);
m_note->setText(ready ? QStringLiteral("· encoded preview, fitted to window") : QStringLiteral("Updating preview…"));
m_export->setEnabled(ready && !m_error);
}
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include "Document/ColorPalette.h"
#include "IO/ImageExporter.h"
#include <QWidget>
#include <atomic>
#include <functional>
#include <memory>
#include <optional>
class QLabel;
class QProgressBar;
class QPushButton;
class QSlider;
class QTimer;
class SwatchButton;
// Swift's JPEGExportSheet: quality, matte and the encoded preview.
class JPEGExportSheet : public QWidget {
Q_OBJECT
public:
// The last export's quality, where the next one starts.
static const QString qualityKey;
JPEGExportSheet(ExportRaster raster, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent = nullptr);
~JPEGExportSheet() override;
private:
class Preview;
void request();
void encode();
void pickMatte();
void synchronize();
const ExportRaster m_raster;
const std::function<void(std::optional<QByteArray>)> m_finish;
JPEGOptions m_options;
std::optional<JPEGResult> m_result;
std::optional<JPEGOptions> m_readyOptions;
std::optional<QString> m_error;
// The newest request's flag, Swift's task cancellation.
std::shared_ptr<std::atomic_bool> m_cancelled;
QTimer *const m_wait;
Preview *const m_preview;
QProgressBar *const m_spinner;
QSlider *const m_quality;
QLabel *const m_percent;
SwatchButton *const m_matteSwatch;
QLabel *const m_failure;
QLabel *const m_bytes;
QLabel *const m_note;
QPushButton *const m_export;
};
+214
View File
@@ -0,0 +1,214 @@
#include "UI/LassoControls.h"
#include <QFrame>
#include <QRegularExpressionValidator>
#include <QFocusEvent>
#include <QKeyEvent>
#include <algorithm>
#include <cmath>
SelectionAmountField::SelectionAmountField(EditorSession &session, int low, int high, std::function<double()> value,
std::function<void(double)> change, QWidget *parent)
: QLineEdit(parent), m_session(session), m_low(low), m_high(high), m_value(std::move(value)), m_change(std::move(change)), m_amount(low)
{
setFont(ToolHeaderStyle::controlFont());
setFixedWidth(40);
setAlignment(Qt::AlignRight | Qt::AlignVCenter);
// Digits only; the bounds clamp, as Swift's binding does.
setValidator(new QRegularExpressionValidator(QRegularExpression(QStringLiteral("[0-9]*")), this));
// Typing applies at once; an overflow reads as the top.
connect(this, &QLineEdit::textEdited, this, [this](const QString &text) {
if (text.isEmpty())
return;
bool fits = false;
const qlonglong typed = text.toLongLong(&fits);
m_change(double(fits ? std::clamp<qlonglong>(typed, m_low, m_high) : m_high));
});
}
void SelectionAmountField::sync(int amount)
{
m_amount = amount;
if (!hasFocus() && !m_borrowed)
setText(QString::number(amount));
}
void SelectionAmountField::apply()
{
setText(QString::number(m_amount));
}
void SelectionAmountField::keyPressEvent(QKeyEvent *event)
{
// Return and Escape hand the canvas the keys.
if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Escape) {
apply();
clearFocus();
m_session.requestCanvasFocus();
return;
}
if (event->key() != Qt::Key_Up && event->key() != Qt::Key_Down) {
QLineEdit::keyPressEvent(event);
return;
}
const int amount = (event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (event->key() == Qt::Key_Up ? 1 : -1);
const double stepped = std::clamp(m_value() + amount, double(m_low), double(m_high));
m_change(stepped);
setText(QString::number(std::lround(stepped)));
}
void SelectionAmountField::focusOutEvent(QFocusEvent *event)
{
m_borrowed = event->reason() == Qt::MenuBarFocusReason || event->reason() == Qt::PopupFocusReason;
if (!m_borrowed)
apply();
QLineEdit::focusOutEvent(event);
}
LassoControls::LassoControls(EditorSession &session, QWidget *parent)
: ToolHeaderBar(QString(), parent), m_session(session), m_marqueeKinds(new QButtonGroup(this)), m_lassoKinds(new QButtonGroup(this)),
m_modes(new QButtonGroup(this)),
m_rectangle(choice(m_marqueeKinds, QStringLiteral("marqueeRectangle"), rawValue(LassoKind::rectangle), QStringLiteral("Switch between Rectangle and Ellipse"),
[this] { m_session.cancelLasso(); m_session.setMarqueeKind(LassoKind::rectangle); })),
m_ellipse(choice(m_marqueeKinds, QStringLiteral("marqueeEllipse"), rawValue(LassoKind::ellipse), QStringLiteral("Switch between Rectangle and Ellipse"),
[this] { m_session.cancelLasso(); m_session.setMarqueeKind(LassoKind::ellipse); })),
m_freehand(choice(m_lassoKinds, QStringLiteral("lassoFreehand"), rawValue(LassoKind::freehand), QStringLiteral("Switch between Freehand and Polygonal"),
[this] { m_session.cancelLasso(); m_session.setLassoKind(LassoKind::freehand); })),
m_polygonal(choice(m_lassoKinds, QStringLiteral("lassoPolygonal"), rawValue(LassoKind::polygonal), QStringLiteral("Switch between Freehand and Polygonal"),
[this] { m_session.cancelLasso(); m_session.setLassoKind(LassoKind::polygonal); })),
m_replace(choice(m_modes, QStringLiteral("selectionModeNew"), rawValue(SelectionMode::replace), QStringLiteral("Hold Shift to add or Alt to subtract for one outline"),
[this] { m_session.setSelectionModeChoice(SelectionMode::replace); })),
m_add(choice(m_modes, QStringLiteral("selectionModeAdd"), rawValue(SelectionMode::add), QStringLiteral("Hold Shift to add or Alt to subtract for one outline"),
[this] { m_session.setSelectionModeChoice(SelectionMode::add); })),
m_subtract(choice(m_modes, QStringLiteral("selectionModeSubtract"), rawValue(SelectionMode::subtract), QStringLiteral("Hold Shift to add or Alt to subtract for one outline"),
[this] { m_session.setSelectionModeChoice(SelectionMode::subtract); })),
m_toleranceLabel(new QLabel(QStringLiteral("Tolerance"), this)),
m_tolerance(new SelectionAmountField(session, 0, 255, [this] { return double(m_session.wandSettings().tolerance); },
[this](double tolerance) { changeWand([tolerance](WandSettings &wand) { wand.tolerance = int(std::lround(tolerance)); }); }, this)),
m_sampleSize(new QComboBox(this)), m_sources(new QButtonGroup(this)),
m_thisLayer(choice(m_sources, QStringLiteral("wandThisLayer"), QStringLiteral("This Layer"),
QStringLiteral("Read colors from the active layer only, or from every visible layer as shown"),
[this] { changeWand([](WandSettings &wand) { wand.sampleAllLayers = false; }); })),
m_allLayers(choice(m_sources, QStringLiteral("wandAllLayers"), QStringLiteral("All Layers"),
QStringLiteral("Read colors from the active layer only, or from every visible layer as shown"),
[this] { changeWand([](WandSettings &wand) { wand.sampleAllLayers = true; }); })),
m_contiguous(new QCheckBox(QStringLiteral("Contiguous"), this)),
m_antialias(new QCheckBox(QStringLiteral("Anti-alias"), this)), m_expand(new QPushButton(QStringLiteral("Expand"), this)),
m_expandAmount(new SelectionAmountField(session, 1, 500, [this] { return double(m_session.selectionExpandAmount()); },
[this](double amount) { m_session.setSelectionExpandAmount(int(std::lround(amount))); }, this)),
m_contract(new QPushButton(QStringLiteral("Contract"), this)),
m_contractAmount(new SelectionAmountField(session, 1, 500, [this] { return double(m_session.selectionContractAmount()); },
[this](double amount) { m_session.setSelectionContractAmount(int(std::lround(amount))); }, this)),
m_empty(new QLabel(QStringLiteral("Empty selection"), this)), m_deselect(new QPushButton(QStringLiteral("Deselect"), this))
{
m_toleranceLabel->setFont(ToolHeaderStyle::controlFont());
m_tolerance->setObjectName(QStringLiteral("wandTolerance"));
m_tolerance->setFixedWidth(44);
m_tolerance->setToolTip(QStringLiteral("How far each color channel (0–255) can differ from the clicked color and still be selected"));
m_toleranceLabel->setToolTip(m_tolerance->toolTip());
m_sampleSize->setObjectName(QStringLiteral("wandSampleSize"));
m_sampleSize->setToolTip(QStringLiteral("Match the clicked pixel, or the average of the pixels around it"));
for (const WandSampleSize size : {WandSampleSize::point, WandSampleSize::threeByThree, WandSampleSize::fiveByFive})
m_sampleSize->addItem(::title(size), int(size));
connect(m_sampleSize, &QComboBox::activated, this, [this](int index) {
changeWand([index](WandSettings &wand) { wand.sampleSize = WandSampleSize(index); });
});
m_contiguous->setObjectName(QStringLiteral("wandContiguous"));
m_contiguous->setToolTip(QStringLiteral("Select only similar pixels connected to the one you click; off selects them everywhere"));
connect(m_contiguous, &QCheckBox::toggled, this, [this](bool on) { changeWand([on](WandSettings &wand) { wand.contiguous = on; }); });
// Rectangles snap to whole pixels, so smoothing applies elsewhere.
m_antialias->setObjectName(QStringLiteral("selectionAntialiased"));
m_antialias->setToolTip(QStringLiteral("Smooth selection edges; turn off for hard pixel edges"));
connect(m_antialias, &QCheckBox::toggled, this, [this](bool on) { m_session.setSelectionAntialiased(on); });
auto *divider = new QFrame(this);
divider->setFrameShape(QFrame::VLine);
divider->setFixedHeight(18);
m_expand->setObjectName(QStringLiteral("expandSelection"));
m_expand->setToolTip(QStringLiteral("Expand the selection by this many pixels"));
connect(m_expand, &QPushButton::clicked, this, [this] { m_session.expandSelection(m_session.selectionExpandAmount()); });
m_expandAmount->setObjectName(QStringLiteral("selectionExpandAmount"));
m_expandAmount->setToolTip(m_expand->toolTip());
m_contract->setObjectName(QStringLiteral("contractSelection"));
m_contract->setToolTip(QStringLiteral("Contract the selection by this many pixels"));
connect(m_contract, &QPushButton::clicked, this, [this] { m_session.contractSelection(m_session.selectionContractAmount()); });
m_contractAmount->setObjectName(QStringLiteral("selectionContractAmount"));
m_contractAmount->setToolTip(m_contract->toolTip());
m_empty->setObjectName(QStringLiteral("emptySelection"));
m_empty->setFont(ToolHeaderStyle::controlFont());
m_empty->setForegroundRole(QPalette::PlaceholderText);
m_deselect->setObjectName(QStringLiteral("deselect"));
connect(m_deselect, &QPushButton::clicked, this, [this] { m_session.deselect(); });
// Swift's `unitSuffix`: a unit sits by each field.
for (QWidget *widget : std::initializer_list<QWidget *>{m_rectangle, m_ellipse, m_freehand, m_polygonal, m_replace, m_add, m_subtract, m_toleranceLabel,
m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous, m_antialias,
divider, m_expand, m_expandAmount, new QLabel(QStringLiteral("px"), this), m_contract, m_contractAmount,
new QLabel(QStringLiteral("px"), this)})
row->insertWidget(row->count() - 1, widget);
row->addWidget(m_empty);
row->addWidget(m_deselect);
connect(&m_session, &EditorSession::changed, this, &LassoControls::synchronize);
synchronize();
}
// One field of the wand's settings changes; the rest stay.
void LassoControls::changeWand(const std::function<void(WandSettings &)> &change)
{
WandSettings wand = m_session.wandSettings();
change(wand);
m_session.setWandSettings(wand);
}
// A segment of Swift's segmented picker: one checkable button.
QToolButton *LassoControls::choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick)
{
auto *button = new QToolButton(this);
button->setObjectName(name);
button->setText(text);
button->setToolTip(tip);
button->setFont(ToolHeaderStyle::controlFont());
button->setCheckable(true);
button->setFocusPolicy(Qt::NoFocus);
group->addButton(button);
connect(button, &QToolButton::clicked, this, [pick] { pick(); });
return button;
}
void LassoControls::synchronize()
{
const NavigationTool tool = m_session.tool();
title->setText(tool == NavigationTool::marquee ? QStringLiteral("Marquee") : tool == NavigationTool::wand ? QStringLiteral("Magic Wand") : QStringLiteral("Lasso"));
for (QToolButton *button : {m_rectangle, m_ellipse})
button->setVisible(tool == NavigationTool::marquee);
for (QToolButton *button : {m_freehand, m_polygonal})
button->setVisible(tool == NavigationTool::lasso);
m_rectangle->setChecked(m_session.marqueeKind() == LassoKind::rectangle);
m_ellipse->setChecked(m_session.marqueeKind() == LassoKind::ellipse);
m_freehand->setChecked(m_session.lassoKind() == LassoKind::freehand);
m_polygonal->setChecked(m_session.lassoKind() == LassoKind::polygonal);
// Held Shift or Alt, or an outline's mode, shows live.
const SelectionMode mode = m_session.displayedSelectionMode();
m_replace->setChecked(mode == SelectionMode::replace);
m_add->setChecked(mode == SelectionMode::add);
m_subtract->setChecked(mode == SelectionMode::subtract);
const WandSettings &wand = m_session.wandSettings();
for (QWidget *widget : std::initializer_list<QWidget *>{m_toleranceLabel, m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous})
widget->setVisible(tool == NavigationTool::wand);
m_tolerance->sync(wand.tolerance);
m_sampleSize->setCurrentIndex(int(wand.sampleSize));
m_thisLayer->setChecked(!wand.sampleAllLayers);
m_allLayers->setChecked(wand.sampleAllLayers);
if (m_contiguous->isChecked() != wand.contiguous)
m_contiguous->setChecked(wand.contiguous);
m_antialias->setVisible(tool == NavigationTool::lasso || tool == NavigationTool::wand || (tool == NavigationTool::marquee && m_session.marqueeKind() == LassoKind::ellipse));
if (m_antialias->isChecked() != m_session.selectionAntialiased())
m_antialias->setChecked(m_session.selectionAntialiased());
const bool modifies = m_session.canModifySelection();
for (QWidget *widget : std::initializer_list<QWidget *>{m_expand, m_expandAmount, m_contract, m_contractAmount})
widget->setEnabled(modifies);
m_expandAmount->sync(m_session.selectionExpandAmount());
m_contractAmount->sync(m_session.selectionContractAmount());
const std::optional<DocumentSelection> selection = m_session.selection();
m_empty->setVisible(selection && selection->isEmpty());
m_deselect->setVisible(selection.has_value());
m_deselect->setEnabled(m_session.canEditSelection());
setEnabled(!m_session.showsBusy() && m_session.document().has_value());
}
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "Document/EditorSession.h"
#include "UI/ToolHeaderStyle.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QComboBox>
#include <QLineEdit>
#include <QPushButton>
#include <QToolButton>
#include <functional>
// A bounded whole number, typed or stepped, clamped.
class SelectionAmountField : public QLineEdit {
Q_OBJECT
public:
// Swift's arrowSteps: arrows step from `value`, clamped, into `change`.
SelectionAmountField(EditorSession &session, int low, int high, std::function<double()> value, std::function<void(double)> change,
QWidget *parent = nullptr);
void sync(int amount);
protected:
void keyPressEvent(QKeyEvent *event) override;
void focusOutEvent(QFocusEvent *event) override;
private:
void apply();
EditorSession &m_session;
const int m_low;
const int m_high;
const std::function<double()> m_value;
const std::function<void(double)> m_change;
int m_amount;
// A menu or popup borrows the focus: the typing stays.
bool m_borrowed = false;
};
// The selection tools' bar: kinds, mode, edges.
class LassoControls : public ToolHeaderBar {
Q_OBJECT
public:
explicit LassoControls(EditorSession &session, QWidget *parent = nullptr);
private:
QToolButton *choice(QButtonGroup *group, const QString &name, const QString &text, const QString &tip, const std::function<void()> &pick);
void changeWand(const std::function<void(WandSettings &)> &change);
void synchronize();
EditorSession &m_session;
QButtonGroup *const m_marqueeKinds;
QButtonGroup *const m_lassoKinds;
QButtonGroup *const m_modes;
QToolButton *const m_rectangle;
QToolButton *const m_ellipse;
QToolButton *const m_freehand;
QToolButton *const m_polygonal;
QToolButton *const m_replace;
QToolButton *const m_add;
QToolButton *const m_subtract;
// The wand's own: tolerance, sample size, source, contiguous.
QLabel *const m_toleranceLabel;
SelectionAmountField *const m_tolerance;
QComboBox *const m_sampleSize;
QButtonGroup *const m_sources;
QToolButton *const m_thisLayer;
QToolButton *const m_allLayers;
QCheckBox *const m_contiguous;
QCheckBox *const m_antialias;
QPushButton *const m_expand;
SelectionAmountField *const m_expandAmount;
QPushButton *const m_contract;
SelectionAmountField *const m_contractAmount;
QLabel *const m_empty;
QPushButton *const m_deselect;
};
+157
View File
@@ -0,0 +1,157 @@
#include "UI/LayerAppearanceControls.h"
#include "UI/BlendModePicker.h"
#include <QApplication>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QLabel>
#include <QVBoxLayout>
#include <cmath>
namespace {
QLabel *caption(const QString &text, QWidget *parent)
{
auto *label = new QLabel(text, parent);
QFont font = label->font();
font.setPixelSize(10);
label->setFont(font);
return label;
}
}
LayerAppearanceControls::LayerAppearanceControls(EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session), m_picker(new BlendModePicker(session, this)), m_slider(new QSlider(Qt::Horizontal, this)),
m_percentage(new QLineEdit(this))
{
setObjectName(QStringLiteral("layerAppearance"));
auto *column = new QVBoxLayout(this);
column->setContentsMargins(12, 12, 12, 12);
column->setSpacing(8);
auto *blend = new QHBoxLayout;
blend->addWidget(caption(QStringLiteral("Blend"), this));
blend->addWidget(m_picker, 1);
column->addLayout(blend);
auto *opacity = new QHBoxLayout;
opacity->setSpacing(6);
opacity->addWidget(caption(QStringLiteral("Opacity"), this));
// A thousandth a step; a drag is one undo step.
m_slider->setRange(0, 1000);
m_slider->setObjectName(QStringLiteral("opacitySlider"));
m_slider->setAccessibleName(QStringLiteral("Opacity"));
opacity->addWidget(m_slider, 1);
m_percentage->setObjectName(QStringLiteral("opacityPercent"));
m_percentage->setAccessibleName(QStringLiteral("Opacity percent"));
m_percentage->setFixedWidth(44);
m_percentage->installEventFilter(this);
auto *field = new QHBoxLayout;
field->setSpacing(2);
field->addWidget(m_percentage);
field->addWidget(caption(QStringLiteral("%"), this));
opacity->addLayout(field);
column->addLayout(opacity);
// A release in a popup opened mid-drag is watched application-wide.
connect(m_slider, &QSlider::sliderPressed, this, [this] {
m_session.beginOpacityEdit();
qApp->installEventFilter(this);
});
connect(m_slider, &QSlider::sliderReleased, this, [this] {
qApp->removeEventFilter(this);
m_session.finishOpacityEdit();
});
connect(m_slider, &QSlider::valueChanged, this, [this](int value) {
if (!m_syncing)
m_session.setLayerOpacity(value / 1000.0);
});
connect(&m_session, &EditorSession::changed, this, &LayerAppearanceControls::synchronize);
synchronize();
}
LayerAppearanceControls::~LayerAppearanceControls()
{
// Swift's onDisappear: a drag never outlives its controls.
m_session.finishOpacityEdit();
}
void LayerAppearanceControls::synchronize()
{
setEnabled(m_session.canEditAppearance());
const std::optional<ImageLayer> active = m_session.activeLayer();
const double opacity = active ? active->opacity : 1;
m_syncing = true;
m_slider->setValue(int(std::lround(opacity * 1000)));
m_syncing = false;
// Another layer makes the controls anew, as Swift's `.id` does.
if (m_layerID != m_session.activeLayerID()) {
// Leaving applies to the old layer only; it is gone.
m_percentage->clearFocus();
m_layerID = m_session.activeLayerID();
sync();
} else if (!m_percentage->hasFocus()) {
sync();
}
}
void LayerAppearanceControls::step(double percent)
{
if (m_session.activeLayerID() != m_layerID)
return;
// The session clamps; Swift's own clamp is left out.
m_session.setLayerOpacity(percent / 100);
sync();
}
// Leaving applies the text once, through the FocusOut.
void LayerAppearanceControls::releaseFocus()
{
m_percentage->clearFocus();
m_session.requestCanvasFocus();
}
void LayerAppearanceControls::sync()
{
const std::optional<ImageLayer> active = m_session.activeLayer();
m_percentage->setText(QString::number(std::lround((active ? active->opacity : 1) * 100)));
}
void LayerAppearanceControls::applyPercentage()
{
if (m_session.activeLayerID() != m_layerID)
return;
bool number = false;
const double value = m_percentage->text().toDouble(&number);
// The session refuses a value that is no finite number.
if (number)
m_session.setLayerOpacity(value / 100);
sync();
}
bool LayerAppearanceControls::eventFilter(QObject *watched, QEvent *event)
{
if (watched != m_percentage) {
const bool release = event->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton;
if (release && m_slider->isSliderDown())
m_slider->setSliderDown(false);
return false;
}
if (event->type() == QEvent::FocusOut) {
// A menu borrows focus and gives it back: no leaving.
const Qt::FocusReason reason = static_cast<QFocusEvent *>(event)->reason();
if (reason != Qt::MenuBarFocusReason && reason != Qt::PopupFocusReason)
applyPercentage();
return false;
}
if (event->type() != QEvent::KeyPress)
return QWidget::eventFilter(watched, event);
const auto *key = static_cast<QKeyEvent *>(event);
if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter || key->key() == Qt::Key_Escape) {
releaseFocus();
return true;
}
if (key->key() != Qt::Key_Up && key->key() != Qt::Key_Down)
return QWidget::eventFilter(watched, event);
// Up and Down nudge one percent, ten with Shift.
const std::optional<ImageLayer> active = m_session.activeLayer();
const double amount = (key->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (key->key() == Qt::Key_Up ? 1 : -1);
step(std::round((active ? active->opacity : 1) * 100) + amount);
return true;
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "Document/EditorSession.h"
#include <QLineEdit>
#include <QSlider>
#include <QWidget>
class BlendModePicker;
// The active layer's blend mode and opacity.
class LayerAppearanceControls : public QWidget {
Q_OBJECT
public:
explicit LayerAppearanceControls(EditorSession &session, QWidget *parent = nullptr);
~LayerAppearanceControls() override;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void synchronize();
void step(double percent);
void releaseFocus();
void sync();
void applyPercentage();
EditorSession &m_session;
BlendModePicker *const m_picker;
QSlider *const m_slider;
QLineEdit *const m_percentage;
// The layer the controls were last made for.
std::optional<QUuid> m_layerID;
bool m_syncing = false;
};
+245
View File
@@ -0,0 +1,245 @@
#include "UI/LayerIcons.h"
#include "UI/ToolIcons.h"
#include <QPainterPath>
namespace {
void eye(QPainter &painter, bool slashed)
{
QPainterPath lid;
lid.moveTo(1.5, 9);
lid.cubicTo(5, 3, 13, 3, 16.5, 9);
lid.cubicTo(13, 15, 5, 15, 1.5, 9);
painter.drawPath(lid);
painter.drawEllipse(QPointF(9, 9), 2.6, 2.6);
if (slashed)
painter.drawLine(QPointF(3, 15), QPointF(15, 3));
}
void chevron(QPainter &painter, bool down)
{
if (down)
painter.drawPolyline(QPolygonF{QPointF(4.5, 6.5), QPointF(9, 11), QPointF(13.5, 6.5)});
else
painter.drawPolyline(QPolygonF{QPointF(6.5, 4.5), QPointF(11, 9), QPointF(6.5, 13.5)});
}
void folder(QPainter &painter, bool badged)
{
QPainterPath shape;
shape.moveTo(2, 4.5);
shape.lineTo(7, 4.5);
shape.lineTo(8.5, 6.5);
shape.lineTo(16, 6.5);
shape.lineTo(16, 14.5);
shape.lineTo(2, 14.5);
shape.closeSubpath();
painter.drawPath(shape);
if (badged) {
painter.drawLine(QPointF(9, 8.5), QPointF(9, 12.5));
painter.drawLine(QPointF(7, 10.5), QPointF(11, 10.5));
}
}
// Swift's chain turned 45 degrees: upright in a narrow gap.
void link(QPainter &painter)
{
painter.save();
painter.translate(9, 9);
painter.rotate(-45);
painter.drawRoundedRect(QRectF(-6.5, -2.2, 7.5, 4.4), 2.2, 2.2);
painter.drawRoundedRect(QRectF(-1, -2.2, 7.5, 4.4), 2.2, 2.2);
painter.restore();
}
void newLayer(QPainter &painter)
{
painter.drawRoundedRect(QRectF(2.5, 2.5, 13, 13), 2, 2);
painter.drawLine(QPointF(9, 6), QPointF(9, 12));
painter.drawLine(QPointF(6, 9), QPointF(12, 9));
}
void addMask(QPainter &painter)
{
painter.drawRoundedRect(QRectF(2.5, 3.5, 13, 11), 2, 2);
painter.fillRect(QRectF(5.5, 6.5, 7, 5), painter.pen().color());
}
void trash(QPainter &painter)
{
painter.drawLine(QPointF(3, 5), QPointF(15, 5));
painter.drawLine(QPointF(7, 3), QPointF(11, 3));
painter.drawPolyline(QPolygonF{QPointF(4.5, 5), QPointF(5.5, 15.5), QPointF(12.5, 15.5), QPointF(13.5, 5)});
painter.drawLine(QPointF(7.5, 8), QPointF(7.8, 13));
painter.drawLine(QPointF(10.5, 8), QPointF(10.2, 13));
}
// Swift's square.3.layers.3d: three tilted plates.
void layers(QPainter &painter)
{
for (int step = 0; step < 3; ++step) {
const double y = 4 + step * 4;
painter.drawPolygon(QPolygonF{QPointF(9, y - 3), QPointF(16, y), QPointF(9, y + 3), QPointF(2, y)});
}
}
// Swift's circle.lefthalf.filled: a ring, its left half solid.
void halfFilledCircle(QPainter &painter)
{
QPainterPath half;
half.moveTo(9, 2);
half.arcTo(QRectF(2, 2, 14, 14), 90, 180);
half.closeSubpath();
painter.fillPath(half, painter.pen().color());
painter.drawEllipse(QPointF(9, 9), 7, 7);
}
// Swift's slider.horizontal.3: three tracks, each with a knob.
void sliders(QPainter &painter)
{
for (const auto &[y, knob] : {std::pair{4.0, 12.0}, std::pair{9.0, 6.0}, std::pair{14.0, 10.5}}) {
painter.drawLine(QPointF(2, y), QPointF(knob - 2.8, y));
painter.drawLine(QPointF(knob + 2.8, y), QPointF(16, y));
painter.drawEllipse(QPointF(knob, y), 2, 2);
}
}
// Swift's point.topleft.down.to.point.bottomright.curvepath.
void curvePath(QPainter &painter)
{
QPainterPath curve;
curve.moveTo(5.5, 3.5);
curve.cubicTo(12, 3.5, 6, 14.5, 12.5, 14.5);
painter.drawPath(curve);
painter.setBrush(painter.pen().color());
painter.drawEllipse(QPointF(3.5, 3.5), 1.6, 1.6);
painter.drawEllipse(QPointF(14.5, 14.5), 1.6, 1.6);
}
// Swift's plusminus.circle.
void plusMinusCircle(QPainter &painter)
{
painter.drawEllipse(QPointF(9, 9), 7, 7);
painter.drawLine(QPointF(6.5, 7.5), QPointF(11.5, 7.5));
painter.drawLine(QPointF(9, 5), QPointF(9, 10));
painter.drawLine(QPointF(6.5, 12.2), QPointF(11.5, 12.2));
}
// Swift's paintpalette: a palette with a thumb notch and wells.
void paintPalette(QPainter &painter)
{
QPainterPath palette;
palette.moveTo(9, 2.5);
palette.cubicTo(13.5, 2.5, 16, 5.5, 16, 8.5);
palette.cubicTo(16, 11, 13.5, 11.5, 12, 11.2);
palette.cubicTo(10.8, 11, 10.2, 12.2, 10.8, 13.4);
palette.cubicTo(11.5, 15, 10.5, 15.8, 9, 15.8);
palette.cubicTo(5, 15.8, 2, 12.8, 2, 9);
palette.cubicTo(2, 5.2, 5, 2.5, 9, 2.5);
painter.drawPath(palette);
painter.setBrush(painter.pen().color());
for (const QPointF well : {QPointF(5.8, 6.8), QPointF(9.3, 5.4), QPointF(12.7, 7.3), QPointF(5.4, 10.6)})
painter.drawEllipse(well, 1.1, 1.1);
}
// Swift's circle.grid.3x3: nine dots.
void circleGrid(QPainter &painter)
{
painter.setBrush(painter.pen().color());
painter.setPen(Qt::NoPen);
for (const double y : {4.0, 9.0, 14.0}) {
for (const double x : {4.0, 9.0, 14.0})
painter.drawEllipse(QPointF(x, y), 1.7, 1.7);
}
}
// Swift's sparkles: a large four-pointed star, two small ones.
void sparkles(QPainter &painter)
{
const auto star = [&painter](QPointF centre, double radius) {
const double bend = radius * 0.18;
QPainterPath path;
path.moveTo(centre.x(), centre.y() - radius);
path.quadTo(centre.x() + bend, centre.y() - bend, centre.x() + radius, centre.y());
path.quadTo(centre.x() + bend, centre.y() + bend, centre.x(), centre.y() + radius);
path.quadTo(centre.x() - bend, centre.y() + bend, centre.x() - radius, centre.y());
path.quadTo(centre.x() - bend, centre.y() - bend, centre.x(), centre.y() - radius);
painter.fillPath(path, painter.pen().color());
};
star(QPointF(7.5, 10.5), 6.5);
star(QPointF(14, 4.5), 3);
star(QPointF(14.5, 14), 2);
}
}
void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double side, const QColor &colour)
{
painter.save();
painter.setRenderHint(QPainter::Antialiasing);
painter.translate(origin);
painter.scale(side / 18, side / 18);
painter.setPen(QPen(colour, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.setBrush(Qt::NoBrush);
painter.setBackgroundMode(Qt::TransparentMode);
switch (icon) {
case LayerIcon::eye: eye(painter, false); break;
case LayerIcon::eyeSlash: eye(painter, true); break;
case LayerIcon::chevronRight: chevron(painter, false); break;
case LayerIcon::chevronDown: chevron(painter, true); break;
case LayerIcon::folder: folder(painter, false); break;
case LayerIcon::link: link(painter); break;
case LayerIcon::newLayer: newLayer(painter); break;
case LayerIcon::newFolder: folder(painter, true); break;
case LayerIcon::addMask: addMask(painter); break;
case LayerIcon::trash: trash(painter); break;
case LayerIcon::layers: layers(painter); break;
// Swift's textformat, as the rail draws it, 1.21 times up.
case LayerIcon::text: ToolIcons::paint(painter, NavigationTool::type, QPointF(3.6, 3.6), 10.8, colour); break;
case LayerIcon::halfFilledCircle: halfFilledCircle(painter); break;
case LayerIcon::sliders: sliders(painter); break;
case LayerIcon::curvePath: curvePath(painter); break;
case LayerIcon::plusMinusCircle: plusMinusCircle(painter); break;
case LayerIcon::paintPalette: paintPalette(painter); break;
case LayerIcon::circleGrid: circleGrid(painter); break;
case LayerIcon::sparkles: sparkles(painter); break;
}
painter.restore();
}
QPixmap LayerIcons::pixmap(LayerIcon icon, double side, const QColor &colour, double ratio)
{
QPixmap result(QSize(int(side * ratio), int(side * ratio)));
result.setDevicePixelRatio(ratio);
result.fill(Qt::transparent);
QPainter painter(&result);
paint(painter, icon, QPointF(0, 0), side, colour);
return result;
}
LayerIcon LayerIcons::symbol(AdjustmentKind kind)
{
switch (kind) {
case AdjustmentKind::curves: return LayerIcon::curvePath;
case AdjustmentKind::levels: return LayerIcon::sliders;
case AdjustmentKind::hsv: return LayerIcon::halfFilledCircle;
case AdjustmentKind::exposure: return LayerIcon::plusMinusCircle;
case AdjustmentKind::gradientMap: return LayerIcon::paintPalette;
case AdjustmentKind::grain: return LayerIcon::circleGrid;
}
throw std::logic_error("unknown adjustment kind");
}
QPixmap LayerIcons::adjustmentThumbnail(AdjustmentKind kind, const QColor &colour, double ratio)
{
QPixmap result(QSize(int(36 * ratio), int(36 * ratio)));
result.setDevicePixelRatio(ratio);
result.fill(Qt::transparent);
QPainter painter(&result);
// Swift turns the curve a quarter clockwise: a tone curve.
if (kind == AdjustmentKind::curves) {
painter.translate(18, 18);
painter.rotate(90);
painter.translate(-18, -18);
}
paint(painter, symbol(kind), QPointF(7.2, 7.2), 18 * 1.2, colour);
return result;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "Document/LayerAdjustment.h"
#include <QColor>
#include <QPainter>
#include <QPixmap>
// The layers panel's icons, drawn: SF Symbols have no twin.
enum class LayerIcon {
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles
};
namespace LayerIcons {
// Paints the icon into a `side` point square, one colour.
void paint(QPainter &painter, LayerIcon icon, QPointF origin, double side, const QColor &colour);
// The icon as a button's pixmap at a pixel ratio.
QPixmap pixmap(LayerIcon icon, double side, const QColor &colour, double ratio);
// Swift's AdjustmentKind.symbol: the glyph that names each kind.
LayerIcon symbol(AdjustmentKind kind);
// An adjustment row's 36-point thumbnail: its glyph 1.2 times up.
QPixmap adjustmentThumbnail(AdjustmentKind kind, const QColor &colour, double ratio);
}
+29
View File
@@ -0,0 +1,29 @@
#include "UI/LayerMaskMenu.h"
#include "UI/LayerIcons.h"
#include <QEvent>
LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolButton(parent), m_session(session)
{
setObjectName(QStringLiteral("addLayerMask"));
setAccessibleName(QStringLiteral("Add layer mask"));
setAutoRaise(true);
setFixedSize(32, 40);
connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(); });
connect(&m_session, &EditorSession::changed, this, &LayerMaskMenu::synchronize);
synchronize();
}
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"));
setIcon(LayerIcons::pixmap(LayerIcon::addMask, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio()));
}
void LayerMaskMenu::changeEvent(QEvent *event)
{
QToolButton::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
synchronize();
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "Document/EditorSession.h"
#include <QToolButton>
// One click adds a mask: white, or the selection black.
class LayerMaskMenu : public QToolButton {
Q_OBJECT
public:
explicit LayerMaskMenu(EditorSession &session, QWidget *parent = nullptr);
protected:
void changeEvent(QEvent *event) override;
private:
void synchronize();
EditorSession &m_session;
};
+166
View File
@@ -0,0 +1,166 @@
#include "UI/LayersPanel.h"
#include "UI/LayerAppearanceControls.h"
#include "UI/LayerIcons.h"
#include "UI/LayerMaskMenu.h"
#include "UI/NativeLayerList.h"
#include <QEvent>
#include <QFrame>
#include <QHBoxLayout>
#include <QMenu>
#include <QVBoxLayout>
namespace {
QFrame *divider(QWidget *parent)
{
auto *line = new QFrame(parent);
line->setFrameShape(QFrame::HLine);
line->setFrameShadow(QFrame::Plain);
line->setForegroundRole(QPalette::Mid);
return line;
}
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QWidget *parent)
{
auto *label = new QLabel(words, parent);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
return label;
}
}
LayersPanel::LayersPanel(EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session), m_count(text(QStringLiteral("0"), 11, QFont::Normal, this)), m_body(new QStackedWidget(this)),
m_list(new NativeLayerList(session, this)), m_emptyHint(text(QString(), 11, QFont::Normal, this))
{
setObjectName(QStringLiteral("layersPanel"));
auto *column = new QVBoxLayout(this);
column->setContentsMargins(0, 0, 0, 0);
column->setSpacing(0);
auto *heading = new QHBoxLayout;
heading->setContentsMargins(18, 18, 18, 18);
heading->addWidget(text(QStringLiteral("Layers"), 12, QFont::DemiBold, this));
heading->addStretch(1);
m_count->setObjectName(QStringLiteral("layerCount"));
m_count->setForegroundRole(QPalette::PlaceholderText);
heading->addWidget(m_count);
column->addLayout(heading);
column->addWidget(divider(this));
column->addWidget(new LayerAppearanceControls(session, this));
column->addWidget(divider(this));
// The rows, or the words for none: create, import, add.
auto *empty = new QWidget(this);
auto *emptyColumn = new QVBoxLayout(empty);
emptyColumn->setContentsMargins(16, 16, 16, 16);
emptyColumn->setSpacing(10);
emptyColumn->addStretch(1);
m_emptyIcon = new QLabel(empty);
m_emptyIcon->setAlignment(Qt::AlignCenter);
emptyColumn->addWidget(m_emptyIcon);
auto *title = text(QStringLiteral("No layers yet"), 13, QFont::Medium, empty);
title->setAlignment(Qt::AlignCenter);
title->setForegroundRole(QPalette::PlaceholderText);
emptyColumn->addWidget(title);
m_emptyHint->setObjectName(QStringLiteral("noLayersHint"));
m_emptyHint->setAlignment(Qt::AlignCenter);
m_emptyHint->setWordWrap(true);
m_emptyHint->setForegroundRole(QPalette::PlaceholderText);
emptyColumn->addWidget(m_emptyHint);
emptyColumn->addStretch(1);
m_body->setObjectName(QStringLiteral("layersBody"));
m_body->addWidget(m_list);
m_body->addWidget(empty);
column->addWidget(m_body, 1);
column->addWidget(divider(this));
// Swift spaces the icons sixteen points apart.
auto *footer = new QHBoxLayout;
footer->setContentsMargins(8, 4, 8, 4);
footer->setSpacing(0);
m_addBlankLayer = footerButton(QStringLiteral("addBlankLayer"), QStringLiteral("New blank layer"), QStringLiteral("New blank layer (Ctrl+Shift+N)"), [this] { m_session.addBlankLayer(); });
footer->addWidget(m_addBlankLayer);
m_group = footerButton(QStringLiteral("groupLayers"), QStringLiteral("New folder"), QStringLiteral("Group selected layers (Ctrl+G)"), [this] { m_session.groupSelectedLayers(); });
footer->addWidget(m_group);
footer->addWidget(new LayerMaskMenu(session, this));
// Presses open these menus; a click adds nothing.
m_effects = footerButton(QStringLiteral("layerEffects"), QStringLiteral("Layer effects"), QStringLiteral("Layer effects: stroke and drop shadow"), [] {});
auto *effects = new QMenu(m_effects);
for (const LayerEffectKind kind : allLayerEffectKinds)
effects->addAction(rawValue(kind) + QStringLiteral("…"), this, [this, kind] { m_session.addEffect(kind); });
m_effects->setMenu(effects);
m_effects->setPopupMode(QToolButton::InstantPopup);
footer->addWidget(m_effects);
m_adjustments = footerButton(QStringLiteral("addAdjustment"), QStringLiteral("New adjustment layer"), QStringLiteral("New adjustment layer"), [] {});
auto *kinds = new QMenu(m_adjustments);
for (const AdjustmentKind kind : allAdjustmentKinds)
kinds->addAction(rawValue(kind), this, [this, kind] { m_session.addAdjustment(kind); });
m_adjustments->setMenu(kinds);
m_adjustments->setPopupMode(QToolButton::InstantPopup);
footer->addWidget(m_adjustments);
footer->addStretch(1);
m_delete = footerButton(QStringLiteral("deleteLayer"), QStringLiteral("Delete selected layer"), QStringLiteral("Delete selected layer"),
[this] { m_session.deleteLayerOrMask(); });
footer->addWidget(m_delete);
column->addLayout(footer);
applyIcons();
connect(&m_session, &EditorSession::changed, this, &LayersPanel::synchronize);
synchronize();
}
// The icons take the palette's ink, again after each theme.
void LayersPanel::applyIcons()
{
const QColor ink = palette().color(QPalette::PlaceholderText);
m_emptyIcon->setPixmap(LayerIcons::pixmap(LayerIcon::layers, 25, ink, devicePixelRatio()));
m_addBlankLayer->setIcon(LayerIcons::pixmap(LayerIcon::newLayer, 16, ink, devicePixelRatio()));
m_group->setIcon(LayerIcons::pixmap(LayerIcon::newFolder, 16, ink, devicePixelRatio()));
m_effects->setIcon(LayerIcons::pixmap(LayerIcon::sparkles, 16, ink, devicePixelRatio()));
m_adjustments->setIcon(LayerIcons::pixmap(LayerIcon::halfFilledCircle, 16, ink, devicePixelRatio()));
m_delete->setIcon(LayerIcons::pixmap(LayerIcon::trash, 16, ink, devicePixelRatio()));
}
void LayersPanel::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
applyIcons();
}
QToolButton *LayersPanel::footerButton(const QString &name, const QString &label, const QString &tip, const std::function<void()> &run)
{
auto *button = new QToolButton(this);
button->setObjectName(name);
button->setAccessibleName(label);
button->setToolTip(tip);
button->setAutoRaise(true);
button->setFixedSize(32, 40);
connect(button, &QToolButton::clicked, this, run);
return button;
}
void LayersPanel::synchronize()
{
const std::optional<CanvasDocument> &document = m_session.document();
const size_t count = document ? document->layers.size() : 0;
m_count->setText(QString::number(count));
m_body->setCurrentIndex(count > 0 ? 0 : 1);
m_emptyHint->setText(document ? QStringLiteral("Import an image or add a blank layer.") : QStringLiteral("Create a canvas or import an image."));
m_addBlankLayer->setEnabled(m_session.canEditLayers());
m_group->setEnabled(m_session.canEditLayers());
m_effects->setEnabled(m_session.canEditEffects());
m_adjustments->setEnabled(m_session.canEditLayers());
const bool mask = m_session.isMaskSelected(), several = m_session.selectedLayerIDs().size() > 1;
const QString what = m_session.selectedEffect() ? QStringLiteral("Delete selected effect")
: mask ? QStringLiteral("Delete layer mask")
: several ? QStringLiteral("Delete selected layers")
: QStringLiteral("Delete selected layer");
m_delete->setToolTip(what);
m_delete->setAccessibleName(what);
m_delete->setEnabled(m_session.canEditLayers() && m_session.activeLayer().has_value());
// Swift's .task(id:): a new request begins its editor.
if (m_session.adjustmentEditingID() != m_adjustmentTask) {
m_adjustmentTask = m_session.adjustmentEditingID();
if (m_adjustmentTask)
m_session.beginAdjustmentEditing(*m_adjustmentTask);
}
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include "Document/EditorSession.h"
#include <QLabel>
#include <QStackedWidget>
#include <QToolButton>
#include <QWidget>
class NativeLayerList;
// The layers panel: heading, appearance, the rows, the footer.
class LayersPanel : public QWidget {
Q_OBJECT
public:
explicit LayersPanel(EditorSession &session, QWidget *parent = nullptr);
// Dragging the panel's left edge sets it, within these.
static constexpr double minimumWidth = 202;
static constexpr double maximumWidth = 352;
static constexpr double defaultWidth = 252;
NativeLayerList &list() const { return *m_list; }
protected:
void changeEvent(QEvent *event) override;
private:
void synchronize();
void applyIcons();
QToolButton *footerButton(const QString &name, const QString &label, const QString &tip, const std::function<void()> &run);
EditorSession &m_session;
QLabel *const m_count;
QStackedWidget *const m_body;
NativeLayerList *const m_list;
QLabel *const m_emptyHint;
QLabel *m_emptyIcon = nullptr;
QToolButton *m_addBlankLayer = nullptr;
QToolButton *m_group = nullptr;
QToolButton *m_effects = nullptr;
QToolButton *m_adjustments = nullptr;
QToolButton *m_delete = nullptr;
// The request Swift's .task(id:) last began.
std::optional<QUuid> m_adjustmentTask;
};
+424
View File
@@ -0,0 +1,424 @@
#include "UI/LevelsSheet.h"
#include "Rendering/EyedropperIcon.h"
#include <QEvent>
#include <QFrame>
#include <QHBoxLayout>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QVBoxLayout>
#include <cmath>
namespace {
// The five entries: Swift's names, fields and decimals.
struct Entry {
const char *name;
double LevelRange::*value;
int decimals;
};
constexpr std::array<Entry, 5> entries{{{"Input black", &LevelRange::black, 0},
{"Gamma", &LevelRange::gamma, 2},
{"Input white", &LevelRange::white, 0},
{"Output black", &LevelRange::outputBlack, 0},
{"Output white", &LevelRange::outputWhite, 0}}};
// SwiftUI's gray, and its dark red, green and blue.
const QColor gray(142, 142, 147);
QColor colour(LevelsChannel channel)
{
switch (channel) {
case LevelsChannel::rgb: return gray;
case LevelsChannel::red: return QColor(255, 66, 69);
case LevelsChannel::green: return QColor(48, 209, 88);
case LevelsChannel::blue: return QColor(0, 145, 255);
}
throw std::logic_error("no such channel");
}
// Swift's .caption: ten points, in the secondary ink unless primary.
QLabel *caption(const QString &text, QWidget *parent, QPalette::ColorRole ink = QPalette::PlaceholderText)
{
auto *label = new QLabel(text, parent);
QFont small = label->font();
small.setPixelSize(10);
label->setFont(small);
label->setForegroundRole(ink);
return label;
}
// The channel's bins, scaled so spikes cannot flatten the rest.
class Histogram : public QWidget {
public:
Histogram(const EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session), m_loading(caption(QStringLiteral("Loading histogram…"), this, QPalette::WindowText))
{
setFixedHeight(150);
setToolTip(QStringLiteral("Linear histogram with automatic vertical scaling. Tall spikes may extend beyond the graph; all tones from 0 "
"to 255 remain included."));
m_loading->move(8, 8);
connect(&session, &EditorSession::changed, this, [this] { synchronize(); });
synchronize();
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(rect(), QColor(0, 0, 0, 64));
const std::optional<LevelsEdit> &edit = m_session.levels();
if (!edit)
return;
const std::array<double, 256> &bins = edit->histogram[size_t(edit->settings.channel)];
const double peak = LevelsHistogramDisplay::scale(bins);
if (peak <= 0)
return;
QPainterPath bars;
bars.setFillRule(Qt::WindingFill);
const double step = width() / 256.0;
for (size_t index = 0; index < 256; ++index) {
const double tall = height() * std::min(1.0, std::max(0.0, bins[index] / peak));
bars.addRect(QRectF(double(index) * step, height() - tall, step + 0.1, tall));
}
painter.setRenderHint(QPainter::Antialiasing);
painter.fillPath(bars, colour(edit->settings.channel));
}
private:
void synchronize()
{
const std::optional<LevelsEdit> &edit = m_session.levels();
if (!edit)
return;
setAccessibleName(QStringLiteral("Original %1 histogram").arg(rawValue(edit->settings.channel)));
m_loading->setVisible(!edit->histogramReady);
update();
}
const EditorSession &m_session;
QLabel *const m_loading;
};
// Black to white across, over the output handles.
class Gradient : public QWidget {
public:
explicit Gradient(QWidget *parent) : QWidget(parent) { setFixedHeight(14); }
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
QLinearGradient across(QPointF(0, 0), QPointF(width(), 0));
across.setColorAt(0, Qt::black);
across.setColorAt(1, Qt::white);
painter.fillRect(rect(), across);
}
};
// Swift's handle: a triangle in a 22 by 20 frame.
class Handle : public QWidget {
public:
Handle(const QString &name, const QColor &fill, const QWidget &track, std::function<void(double)> drag)
: QWidget(track.parentWidget()), m_fill(fill), m_track(track), m_drag(std::move(drag))
{
setAccessibleName(name);
resize(22, 20);
}
// Centred on the tone, nine points down the track.
void place(double tone) { move(int(std::round(m_track.x() + tone / 255 * m_track.width() - 11)), m_track.y() - 1); }
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QPainterPath triangle(QPointF(11, 4.75));
triangle.lineTo(17, 15.25);
triangle.lineTo(5, 15.25);
triangle.closeSubpath();
// Swift's half-point gray shadow: Qt has no blur.
painter.strokePath(triangle, QPen(gray, 1));
painter.fillPath(triangle, m_fill);
}
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
drag(event->position());
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (event->buttons().testFlag(Qt::LeftButton))
drag(event->position());
}
private:
// The pointer's tone along the track, as Swift's named space.
void drag(QPointF point) { m_drag((mapToParent(point).x() - m_track.x()) / m_track.width() * 255); }
const QColor m_fill;
const QWidget &m_track;
const std::function<void(double)> m_drag;
};
// Swift's handles view: tones across, each triangle on top.
class Track : public QWidget {
public:
Track(const EditorSession &session, bool output, const std::function<void(size_t, double)> &drag, QWidget *parent)
: QWidget(parent), m_session(session), m_output(output)
{
setFixedHeight(20);
const QStringList names = output ? QStringList{QStringLiteral("Output black"), QStringLiteral("Output white")}
: QStringList{QStringLiteral("Input black"), QStringLiteral("Gamma"), QStringLiteral("Input white")};
for (qsizetype index = 0; index < names.size(); ++index) {
const QColor fill = index == 0 ? QColor(Qt::black) : index + 1 == names.size() ? QColor(Qt::white) : gray;
m_handles.push_back(new Handle(names[index], fill, *this, [drag, index](double tone) { drag(size_t(index), tone); }));
}
connect(&session, &EditorSession::changed, this, [this] { place(); });
}
protected:
void moveEvent(QMoveEvent *) override { place(); }
void resizeEvent(QResizeEvent *) override { place(); }
private:
void place()
{
const std::optional<LevelsEdit> &edit = m_session.levels();
if (!edit)
return;
const LevelRange range = edit->settings.current();
const std::vector<double> tones = m_output ? std::vector{range.outputBlack, range.outputWhite}
: std::vector{range.black, range.black + (range.white - range.black) * std::pow(0.5, range.gamma), range.white};
for (size_t index = 0; index < tones.size(); ++index)
m_handles[index]->place(tones[index]);
}
const EditorSession &m_session;
const bool m_output;
std::vector<Handle *> m_handles;
};
// Swift's drag: whole tones for ends, a fraction for gamma.
void dragHandle(LevelRange &range, bool output, size_t index, double tone)
{
const double x = std::min(255.0, std::max(0.0, tone));
if (output)
(index == 0 ? range.outputBlack : range.outputWhite) = std::round(x);
else if (index == 0)
range.black = std::min(range.white - 1, std::round(x));
else if (index == 2)
range.white = std::max(range.black + 1, std::round(x));
else
range.gamma = std::log(std::min(0.999, std::max(0.001, (x - range.black) / (range.white - range.black)))) / std::log(0.5);
}
// The eyedropper glyph in one ink, 14 points.
QIcon eyedropper(const QColor &ink, double ratio)
{
QPixmap pixmap(QSize(14, 14) * ratio);
pixmap.setDevicePixelRatio(ratio);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing);
painter.scale(14.0 / 18, 14.0 / 18);
painter.setPen(QPen(ink, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
EyedropperIcon::paint(painter);
return QIcon(pixmap);
}
// Swift's HStack: spread by Spacers, or packed to the left.
QHBoxLayout *row(std::initializer_list<QWidget *> widgets, bool spread)
{
auto *layout = new QHBoxLayout;
for (QWidget *widget : widgets) {
if (spread && layout->count() > 0)
layout->addStretch(1);
layout->addWidget(widget);
}
if (!spread)
layout->addStretch(1);
return layout;
}
QVBoxLayout *stack(std::initializer_list<QWidget *> widgets, int spacing)
{
auto *layout = new QVBoxLayout;
layout->setSpacing(spacing);
for (QWidget *widget : widgets)
layout->addWidget(widget);
return layout;
}
}
LevelsSheet::LevelsSheet(EditorSession &session, QWidget *parent)
: QWidget(parent), m_session(session), m_channel(new QComboBox(this)), m_histogram(new Histogram(session, this)),
m_input(new Track(session, false, [this](size_t index, double tone) { dragTo(false, index, tone); }, this)),
m_gradient(new Gradient(this)), m_output(new Track(session, true, [this](size_t index, double tone) { dragTo(true, index, tone); }, this)),
m_fields{field(0), field(1), field(2), field(3), field(4)}, m_samples{new QPushButton(this), new QPushButton(this), new QPushButton(this)},
m_sampleHint(caption(QString(), this)), m_autos{new QPushButton(this), new QPushButton(this), new QPushButton(this)},
m_preview(new QCheckBox(QStringLiteral("Preview"), this)), m_reset(new QPushButton(QStringLiteral("Reset"), this)),
m_caption(caption(QString(), this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_spinner(new QProgressBar(this)),
m_ok(new QPushButton(QStringLiteral("OK"), this))
{
setFixedWidth(440);
for (const LevelsChannel channel : allLevelsChannels)
m_channel->addItem(rawValue(channel));
connect(m_channel, &QComboBox::activated, this,
[this](int index) { change([index](LevelsSettings &settings) { settings.channel = allLevelsChannels[size_t(index)]; }); });
auto *picker = new QWidget(this);
picker->setFixedWidth(180);
auto *pickerRow = new QHBoxLayout(picker);
pickerRow->setContentsMargins(0, 0, 0, 0);
// The label names the picker, as Swift's Picker title.
auto *title = new QLabel(QStringLiteral("Channel"), picker);
title->setBuddy(m_channel);
pickerRow->addWidget(title);
pickerRow->addWidget(m_channel, 1);
m_histogram->setObjectName(QStringLiteral("levelsHistogram"));
m_input->setObjectName(QStringLiteral("levelsInputHandles"));
m_output->setObjectName(QStringLiteral("levelsOutputHandles"));
for (size_t index = 0; index < 3; ++index) {
m_samples[index]->setText(rawValue(allLevelsSamples[index]));
m_samples[index]->setCheckable(true);
m_samples[index]->setAutoDefault(false);
// A second click puts the eyedropper away.
connect(m_samples[index], &QPushButton::clicked, this, [this, index] {
const std::optional<LevelsSample> mode = allLevelsSamples[index];
m_session.setLevelsSampleMode(m_session.levels().value().sampleMode == mode ? std::nullopt : mode);
});
m_autos[index]->setText(rawValue(allLevelsAutos[index]));
m_autos[index]->setAutoDefault(false);
connect(m_autos[index], &QPushButton::clicked, this, [this, index] { m_session.autoLevels(allLevelsAutos[index]); });
}
m_sampleHint->setWordWrap(true);
m_preview->setShortcut(QKeySequence(Qt::ALT | Qt::Key_P));
connect(m_preview, &QCheckBox::clicked, this, [this](bool on) { m_session.updateLevels(m_session.levels().value().settings, on); });
m_reset->setAutoDefault(false);
connect(m_reset, &QPushButton::clicked, this, [this] {
m_session.setLevelsSampleMode(std::nullopt);
change([](LevelsSettings &settings) { settings = LevelsSettings(); });
});
m_cancel->setAutoDefault(false);
m_ok->setDefault(true);
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelLevels(); });
connect(m_ok, &QPushButton::clicked, this, [this] { m_session.commitLevels(); });
m_spinner->setRange(0, 0);
m_spinner->setFixedWidth(40);
m_spinner->setTextVisible(false);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
column->addWidget(picker);
column->addLayout(stack({m_histogram, m_input}, 0));
column->addLayout(row({m_fields[0]->parentWidget(), m_fields[1]->parentWidget(), m_fields[2]->parentWidget()}, true));
column->addLayout(stack({m_gradient, m_output}, 0));
column->addLayout(row({m_fields[3]->parentWidget(), m_fields[4]->parentWidget()}, true));
column->addLayout(row({caption(QStringLiteral("Sample"), this), m_samples[0], m_samples[1], m_samples[2]}, false));
column->addWidget(m_sampleHint);
QVBoxLayout *automatic = stack({caption(QStringLiteral("Auto"), this)}, 6);
automatic->addLayout(row({m_autos[0], m_autos[1], m_autos[2]}, false));
column->addLayout(automatic);
column->addLayout(row({m_preview, m_reset}, true));
column->addWidget(m_caption);
auto *divider = new QFrame(this);
divider->setFrameShape(QFrame::HLine);
divider->setForegroundRole(QPalette::Mid);
column->addWidget(divider);
QHBoxLayout *buttons = row({m_cancel, m_spinner}, true);
buttons->addWidget(m_ok);
column->addLayout(buttons);
connect(&m_session, &EditorSession::changed, this, &LevelsSheet::synchronize);
synchronize();
}
PickerField *LevelsSheet::field(size_t index)
{
const QString name = QString::fromLatin1(entries[index].name);
auto *box = new QWidget(this);
auto *layout = new QVBoxLayout(box);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(5);
layout->addWidget(caption(name, box));
// A number in the user's locale applies; other text reverts.
auto *entry = new PickerField([this, index] {
PickerField &edited = *m_fields[index];
bool number = false;
const double typed = edited.locale().toDouble(edited.text(), &number);
if (edited.isModified() && number) {
change([&](LevelsSettings &settings) {
LevelRange range = settings.current();
range.*entries[index].value = typed;
settings.setCurrent(range);
});
}
edited.setModified(false);
synchronize();
}, nullptr, box);
entry->setObjectName(QStringLiteral("levels") + QString(name).remove(QLatin1Char(' ')));
entry->setPlaceholderText(name);
entry->setAccessibleName(name);
entry->setAlignment(Qt::AlignRight);
entry->setFixedWidth(80);
layout->addWidget(entry);
return entry;
}
void LevelsSheet::dragTo(bool output, size_t index, double tone)
{
change([&](LevelsSettings &settings) {
LevelRange range = settings.current();
dragHandle(range, output, index, tone);
settings.setCurrent(range);
});
m_fields[output ? 3 + index : index]->setModified(false);
synchronize();
}
void LevelsSheet::change(const std::function<void(LevelsSettings &)> &edit)
{
const std::optional<LevelsEdit> &levels = m_session.levels();
if (!levels)
return;
LevelsSettings settings = levels->settings;
edit(settings);
m_session.updateLevels(settings, levels->preview);
}
void LevelsSheet::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
synchronize();
}
void LevelsSheet::synchronize()
{
// Closed, the panel hides the sheet until the next edit.
const std::optional<LevelsEdit> &edit = m_session.levels();
if (!edit)
return;
const LevelRange range = edit->settings.current();
m_channel->setCurrentIndex(int(edit->settings.channel));
// An entry being typed in keeps its typing.
for (size_t index = 0; index < entries.size(); ++index) {
const QString shown = m_fields[index]->locale().toString(range.*entries[index].value, 'f', entries[index].decimals);
if (!m_fields[index]->isModified() && m_fields[index]->text() != shown)
m_fields[index]->setText(shown);
}
for (size_t index = 0; index < 3; ++index) {
const bool chosen = edit->sampleMode == allLevelsSamples[index];
m_samples[index]->setChecked(chosen);
m_samples[index]->setIcon(eyedropper(palette().color(chosen ? QPalette::Highlight : QPalette::PlaceholderText), devicePixelRatioF()));
m_autos[index]->setEnabled(edit->histogramReady);
}
m_sampleHint->setVisible(edit->sampleMode.has_value());
if (edit->sampleMode)
m_sampleHint->setText(QStringLiteral("Click the original layer to set %1. Click the eyedropper again to stop.")
.arg(rawValue(*edit->sampleMode).toLower()));
m_preview->setChecked(edit->preview);
m_caption->setText(m_session.adjustmentOriginal() ? QStringLiteral("Underlying pixels · alpha-weighted histogram")
: m_session.selection() ? QStringLiteral("Original pixels · selection and alpha-weighted histogram")
: QStringLiteral("Original pixels · alpha-weighted histogram"));
m_spinner->setVisible(edit->committing);
setEnabled(!edit->committing);
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include "Document/EditorSession.h"
#include "UI/ColorPickerSheet.h"
#include <QCheckBox>
#include <QComboBox>
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <QWidget>
#include <array>
#include <functional>
// Swift's LevelsSheet: channel, histogram, handles, entries, samples, auto.
class LevelsSheet : public QWidget {
Q_OBJECT
public:
explicit LevelsSheet(EditorSession &session, QWidget *parent = nullptr);
protected:
// A new theme recolours the eyedroppers' ink.
void changeEvent(QEvent *event) override;
private:
// Swift's update: the settings changed and handed to the session.
void change(const std::function<void(LevelsSettings &)> &edit);
// A handle's tone, which replaces its entry's typing.
void dragTo(bool output, size_t index, double tone);
// A labelled entry of the five, in Swift's order.
PickerField *field(size_t index);
void synchronize();
EditorSession &m_session;
QComboBox *const m_channel;
QWidget *const m_histogram;
QWidget *const m_input;
// Made before the output handles, which lie on top.
QWidget *const m_gradient;
QWidget *const m_output;
// Input black, gamma, input white, output black, output white.
const std::array<PickerField *, 5> m_fields;
const std::array<QPushButton *, 3> m_samples;
QLabel *const m_sampleHint;
const std::array<QPushButton *, 3> m_autos;
QCheckBox *const m_preview;
QPushButton *const m_reset;
QLabel *const m_caption;
QPushButton *const m_cancel;
QProgressBar *const m_spinner;
QPushButton *const m_ok;
};
+185
View File
@@ -0,0 +1,185 @@
#include "UI/NativeLayerList.h"
#include <QApplication>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
EyeSwipeButton::EyeSwipeButton(NativeLayerList &list, QWidget *parent) : QToolButton(parent), m_list(list)
{
setAutoRaise(true);
setFixedSize(20, 32);
}
// A row rebuilt mid-swipe takes the button; the step ends.
EyeSwipeButton::~EyeSwipeButton()
{
if (!m_swipe)
return;
qApp->removeEventFilter(this);
QMetaObject::invokeMethod(&m_list.session(), &EditorSession::endVisibilitySwipe, Qt::QueuedConnection);
}
void EyeSwipeButton::mousePressEvent(QMouseEvent *event)
{
if (event->button() != Qt::LeftButton || !isEnabled())
return;
m_swipe = m_list.session().beginVisibilitySwipe(layerID);
if (m_swipe)
qApp->installEventFilter(this);
}
void EyeSwipeButton::endSwipe()
{
m_swipe = std::nullopt;
qApp->removeEventFilter(this);
m_list.session().endVisibilitySwipe();
}
// Swift tracks the drag itself: no release is missed.
bool EyeSwipeButton::eventFilter(QObject *watched, QEvent *event)
{
if (m_swipe && event->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton)
endSwipe();
return QToolButton::eventFilter(watched, event);
}
void EyeSwipeButton::mouseMoveEvent(QMouseEvent *event)
{
if (!m_swipe)
return;
// Past the viewport's edge the list scrolls, as autoscroll does.
const QPoint inList = mapTo(&m_list, event->position().toPoint());
const int overshoot = inList.y() < 0 ? inList.y() : std::max(0, inList.y() - m_list.viewport()->height());
if (overshoot != 0)
m_list.verticalScrollBar()->setValue(m_list.verticalScrollBar()->value() + overshoot);
const int row = m_list.rowAt(inList);
if (row >= 0 && row < int(m_list.cells().size()))
m_list.session().setVisibilityInSwipe(m_list.cells()[row]->layerID(), *m_swipe);
}
void EyeSwipeButton::mouseReleaseEvent(QMouseEvent *event)
{
// Another button's release leaves the left one's swipe.
if (m_swipe && event->button() == Qt::LeftButton)
endSwipe();
}
LayerThumbnailButton::LayerThumbnailButton(NativeLayerList &list, bool maskTarget, QWidget *parent)
: QToolButton(parent), isMaskTarget(maskTarget), m_list(list)
{
setAutoRaise(true);
}
void LayerThumbnailButton::setTargeted(bool targeted)
{
m_targeted = targeted;
QWidget::update();
}
void LayerThumbnailButton::mousePressEvent(QMouseEvent *event)
{
if (event->button() != Qt::LeftButton || !isEnabled())
return;
auto *cell = static_cast<LayerCell *>(parentWidget());
// Every press starts from nothing: no earlier arm survives.
cell->disarmDrag();
const QPoint inCell = mapToParent(event->position().toPoint());
const bool control = event->modifiers().testFlag(Qt::ControlModifier), shift = event->modifiers().testFlag(Qt::ShiftModifier);
// Alt on a mask drags a copy; else the row's.
if (event->modifiers().testFlag(Qt::AltModifier) && !control) {
if (isMaskTarget) {
m_altMaskPress = event->position().toPoint();
qApp->installEventFilter(this);
} else if (!m_list.clickRow(*cell, event->modifiers(), inCell))
cell->armDrag(inCell, event->modifiers());
return;
}
// Ctrl loads a selection: Ctrl-Shift adds, Ctrl-Alt subtracts.
if (control) {
const SelectionMode mode = event->modifiers().testFlag(Qt::AltModifier) ? SelectionMode::subtract : shift ? SelectionMode::add : SelectionMode::replace;
if (isMaskTarget)
m_list.session().loadMaskSelection(layerID, mode);
else
m_list.session().loadLayerSelection(layerID, mode);
return;
}
if (isMaskTarget && shift) {
m_list.session().selectLayerTarget(layerID, true);
m_list.session().toggleLayerMask();
return;
}
if (!shift)
m_list.session().selectLayerTarget(layerID, isMaskTarget);
if (!m_list.clickRow(*cell, event->modifiers(), inCell))
cell->armDrag(inCell, event->modifiers());
}
// Moved far enough, Alt on a mask drags a copy.
void LayerThumbnailButton::mouseMoveEvent(QMouseEvent *event)
{
auto *cell = static_cast<LayerCell *>(parentWidget());
if (!m_altMaskPress) {
cell->moveDrag(mapToParent(event->position().toPoint()), event->buttons());
return;
}
if ((event->position().toPoint() - *m_altMaskPress).manhattanLength() < QApplication::startDragDistance())
return;
endAltMaskPress();
m_list.startMaskDrag(*this);
}
LayerThumbnailButton::~LayerThumbnailButton()
{
if (m_altMaskPress)
qApp->removeEventFilter(this);
}
void LayerThumbnailButton::endAltMaskPress()
{
m_altMaskPress = std::nullopt;
qApp->removeEventFilter(this);
}
// Another widget's release ends it; the window's does not.
bool LayerThumbnailButton::eventFilter(QObject *watched, QEvent *event)
{
if (m_altMaskPress && watched->isWidgetType() && watched != this && event->type() == QEvent::MouseButtonRelease
&& static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton)
endAltMaskPress();
return QToolButton::eventFilter(watched, event);
}
void LayerThumbnailButton::mouseReleaseEvent(QMouseEvent *event)
{
// Only the left button's release ends its press.
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();
}
// With a modifier the second press is the button's again.
void LayerThumbnailButton::mouseDoubleClickEvent(QMouseEvent *event)
{
if (event->modifiers() & (Qt::ShiftModifier | Qt::ControlModifier | Qt::AltModifier))
mousePressEvent(event);
else
event->ignore();
}
void LayerThumbnailButton::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.drawPixmap(rect(), icon().pixmap(size()));
if (!m_targeted)
return;
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(palette().color(QPalette::Highlight), 2));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 3, 3);
}
+381
View File
@@ -0,0 +1,381 @@
#include "UI/CanvasThumbnail.h"
#include "UI/LayerIcons.h"
#include "UI/NativeLayerList.h"
#include <QApplication>
#include <QContextMenuEvent>
#include <QKeyEvent>
#include <QMenu>
#include <QMouseEvent>
#include <QPainter>
#include <cmath>
LayerCell::LayerCell(NativeLayerList &list)
: QWidget(nullptr), m_list(list), m_eye(new EyeSwipeButton(list, this)), m_disclosure(new QToolButton(this)),
m_thumbnail(new LayerThumbnailButton(list, false, this)), m_maskThumbnail(new LayerThumbnailButton(list, true, this)),
m_link(new QToolButton(this)), m_disabledMaskMark(new QLabel(QStringLiteral("╱"), this)), m_name(new QLabel(this)),
m_dimensions(new QLabel(this)), m_editor(new QLineEdit(this)), m_fade(new QGraphicsOpacityEffect(this))
{
setObjectName(QStringLiteral("layerCell"));
setFixedHeight(rowHeight);
setGraphicsEffect(m_fade);
// Hover reaches the list's cursor through every control.
setMouseTracking(true);
for (QWidget *child : findChildren<QWidget *>())
child->setMouseTracking(true);
m_eye->setObjectName(QStringLiteral("layerEye"));
m_disclosure->setObjectName(QStringLiteral("layerDisclosure"));
m_disclosure->setAutoRaise(true);
m_disclosure->setFixedSize(16, 24);
m_disclosure->setAccessibleName(QStringLiteral("Expand or collapse folder"));
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_link->setObjectName(QStringLiteral("maskLink"));
m_link->setAutoRaise(true);
m_link->setFixedSize(9, 20);
connect(m_link, &QToolButton::clicked, this, [this] { m_list.session().toggleMaskLink(m_layerID); });
QFont mark = m_disabledMaskMark->font();
mark.setPixelSize(32);
mark.setWeight(QFont::Medium);
m_disabledMaskMark->setFont(mark);
m_disabledMaskMark->setForegroundRole(QPalette::BrightText);
m_disabledMaskMark->setAttribute(Qt::WA_TransparentForMouseEvents);
m_disabledMaskMark->setObjectName(QStringLiteral("maskDisabledMark"));
QFont name = m_name->font();
name.setPixelSize(13);
m_name->setFont(name);
m_name->setTextFormat(Qt::PlainText);
m_name->setObjectName(QStringLiteral("layerName"));
QFont small = m_dimensions->font();
small.setPixelSize(10);
m_dimensions->setFont(small);
m_dimensions->setForegroundRole(QPalette::PlaceholderText);
m_dimensions->setTextFormat(Qt::PlainText);
m_dimensions->setObjectName(QStringLiteral("layerDimensions"));
m_editor->setObjectName(QStringLiteral("layerNameEditor"));
m_editor->setFont(name);
m_editor->hide();
m_editor->installEventFilter(this);
menuAction(QStringLiteral("renameLayer"), QStringLiteral("Rename…"), [this] {
if (!m_list.session().canEditLayers())
return;
m_list.session().setActiveLayerID(m_layerID);
m_list.session().setRenamingLayerID(m_layerID);
});
menuAction(QStringLiteral("toggleVisibility"), QStringLiteral("Hide/Show Layer"), [this] { m_list.session().toggleLayerVisibility(m_layerID); });
// With a selection, white hides it, black shows it alone.
menuAction(QStringLiteral("addWhiteMask"), QStringLiteral("Add White Mask"), [this] {
m_list.session().selectLayerTarget(m_layerID, false);
m_list.session().addMask();
});
menuAction(QStringLiteral("addBlackMask"), QStringLiteral("Add Black Mask"), [this] {
m_list.session().selectLayerTarget(m_layerID, false);
m_list.session().addMask(false);
});
menuAction(QStringLiteral("toggleMask"), QStringLiteral("Enable/Disable Mask"), [this] {
m_list.session().selectLayerTarget(m_layerID, false);
m_list.session().toggleLayerMask();
});
menuAction(QStringLiteral("deleteMask"), QStringLiteral("Delete Mask"), [this] {
m_list.session().selectLayerTarget(m_layerID, false);
m_list.session().deleteLayerMask();
});
menuAction(QStringLiteral("releaseClippingMask"), QStringLiteral("Release Clipping Mask"), [this] { m_list.session().removeLiveMask(m_layerID); });
menuAction(QStringLiteral("moveOut"), QStringLiteral("Move Out of Folder"), [this] {
m_list.session().selectLayer(m_layerID);
m_list.session().moveActiveLayerOutOfGroup();
});
menuAction(QStringLiteral("deleteLayer"), QStringLiteral("Delete Layer / Folder"), [this] {
// Part of a multi-selection, the whole selection goes.
EditorSession &session = m_list.session();
if (session.selectedLayerIDs().size() > 1 && session.selectedLayerIDs().contains(m_layerID))
session.deleteSelectedLayers();
else
session.deleteLayer(m_layerID);
});
}
QAction *LayerCell::menuAction(const QString &name, const QString &title, const std::function<void()> &run)
{
auto *action = new QAction(title, this);
action->setObjectName(name);
connect(action, &QAction::triggered, this, run);
addAction(action);
return action;
}
void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool visible)
{
EditorSession &session = m_list.session();
const QColor ink = palette().color(QPalette::WindowText), faint = palette().color(QPalette::PlaceholderText);
const double ratio = devicePixelRatio();
// A folder's step and a clipping mask's step add up.
m_indent = std::min(depth, 8) * 24 + (layer.maskSourceID ? 24 : 0);
m_isGroup = layer.isGroup;
m_disclosure->setVisible(layer.isGroup);
m_disclosure->setEnabled(enabled);
m_disclosure->setIcon(LayerIcons::pixmap(session.collapsedGroupIDs().contains(layer.id) ? LayerIcon::chevronRight : LayerIcon::chevronDown, 12, ink, ratio));
// Pixels show the canvas; text, adjustments, folders an icon.
const QSizeF canvas = session.document() ? session.document()->size() : QSizeF(1, 1);
m_editableText = layer.liveText().has_value();
m_isAdjustment = layer.adjustment.has_value();
m_thumbnailSize = layer.isGroup || m_editableText || m_isAdjustment ? QSize(36, 36) : CanvasThumbnail::fittedSize(canvas, 36);
const ThumbnailKey key{layer.asset ? std::optional(layer.asset->identity()) : std::nullopt, layer.transform, canvas, ink, m_editableText};
if (m_layerID != layer.id || m_thumbnailKey != key) {
m_thumbnail->setIcon(m_isAdjustment ? LayerIcons::adjustmentThumbnail(layer.adjustment->kind, ink, ratio)
: layer.isGroup ? LayerIcons::pixmap(LayerIcon::folder, 36 * 0.8, ink, ratio)
: m_editableText ? LayerIcons::pixmap(LayerIcon::text, 36, ink, ratio)
: CanvasThumbnail::layer(layer.asset ? layer.asset->thumbnail : QImage(), layer.transform, canvas, 36));
m_thumbnailKey = key;
}
m_maskSize = CanvasThumbnail::fittedSize(canvas, 30);
const ThumbnailKey maskKey{layer.mask ? std::optional(layer.mask->asset.identity()) : std::nullopt, layer.maskTransform(), canvas, ink};
if (m_layerID != layer.id || m_maskThumbnailKey != maskKey) {
m_maskThumbnail->setIcon(layer.mask ? CanvasThumbnail::mask(layer.mask->asset.thumbnail, layer.maskTransform(), canvas, 30) : QPixmap());
m_maskThumbnailKey = maskKey;
}
m_layerID = layer.id;
m_thumbnail->layerID = layer.id;
m_maskThumbnail->layerID = layer.id;
m_hasMask = layer.mask.has_value();
m_maskThumbnail->setVisible(m_hasMask);
m_disabledMaskMark->setVisible(layer.mask && !layer.mask->isEnabled);
m_thumbnail->setEnabled(!session.showsBusy() && !session.isImporting());
m_maskThumbnail->setEnabled(m_thumbnail->isEnabled());
m_linkable = layer.mask && !m_isAdjustment && !layer.isGroup;
m_link->setVisible(m_linkable);
const bool linked = !layer.mask || layer.mask->isLinked;
m_link->setIcon(linked ? LayerIcons::pixmap(LayerIcon::link, 9, faint, ratio) : QPixmap());
m_link->setEnabled(m_thumbnail->isEnabled());
m_link->setToolTip(linked ? QStringLiteral("Unlink layer and mask to move or transform them separately") : QStringLiteral("Link layer and mask so they move together"));
m_link->setAccessibleName((linked ? QStringLiteral("Unlink mask: ") : QStringLiteral("Link mask: ")) + layer.name);
m_thumbnail->setToolTip(m_editableText ? QStringLiteral("Editable text layer") : QStringLiteral("Select image pixels"));
m_thumbnail->setAccessibleName((m_editableText ? QStringLiteral("Select text: ") : QStringLiteral("Select image: ")) + layer.name);
m_maskThumbnail->setAccessibleName(QStringLiteral("Select mask: ") + layer.name);
m_layerName = layer.name;
// A pending rename closes the edits: rows never move.
if (!m_renaming)
m_name->setText((layer.maskSourceID ? QStringLiteral("↳ ") : QString()) + layer.name);
m_dimensions->setText(m_editableText ? QStringLiteral("Text · Double-click to edit")
: m_isAdjustment ? QStringLiteral("Adjustment · Double-click to edit")
: layer.isGroup ? QStringLiteral("Folder")
: QStringLiteral("%1 × %2 px").arg(std::lround(layer.size().width())).arg(std::lround(layer.size().height())));
m_dimensions->setToolTip(QString());
if (layer.maskSourceID && session.document()) {
const int source = indexOf(session.document()->layers, layer.maskSourceID);
const QString sourceName = source >= 0 ? session.document()->layers[source].name : QStringLiteral("Missing source");
m_dimensions->setText(QStringLiteral("Clipped to %1").arg(sourceName));
m_dimensions->setToolTip(QStringLiteral("Clipping mask based on %1. Option-click the bottom of its row to release.").arg(sourceName));
}
m_eye->setIcon(LayerIcons::pixmap(layer.isVisible ? LayerIcon::eye : LayerIcon::eyeSlash, 16, ink, ratio));
m_eye->setAccessibleName((layer.isVisible ? QStringLiteral("Hide ") : QStringLiteral("Show ")) + layer.name);
m_eye->setEnabled(enabled);
m_eye->layerID = layer.id;
m_fade->setOpacity(visible ? 1 : 0.35);
// Effects under the row, made anew when their kinds change.
const std::vector<LayerEffectKind> kinds = layer.effects ? layer.effects->kinds() : std::vector<LayerEffectKind>();
std::vector<LayerEffectKind> shown;
for (const LayerEffectRow *row : m_effectButtons)
shown.push_back(row->kind);
if (shown != kinds) {
// A row's own press may take it away: deleted later.
for (LayerEffectRow *row : m_effectButtons) {
row->hide();
row->deleteLater();
}
m_effectButtons.clear();
for (const LayerEffectKind kind : kinds) {
m_effectButtons.push_back(new LayerEffectRow(m_list, layer.id, kind, this));
// Made after the cell shows, a child needs showing.
m_effectButtons.back()->show();
}
}
setFixedHeight(rowHeight + int(kinds.size()) * effectHeight);
for (LayerEffectRow *row : m_effectButtons)
row->configure(layer.effects->isEnabled(row->kind), enabled, m_indent);
relayout();
}
void LayerCell::updateTarget()
{
EditorSession &session = m_list.session();
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);
QWidget::update();
}
void LayerCell::relayout()
{
m_eye->move(8, 26 - 16);
m_disclosure->move(28 + m_indent, 26 - 12);
const int thumbnailSlot = 28 + m_indent + 16 - 2;
m_thumbnail->setFixedSize(m_thumbnailSize);
m_thumbnail->move(thumbnailSlot + (36 - m_thumbnailSize.width()) / 2, 26 - m_thumbnailSize.height() / 2);
const int maskSlot = thumbnailSlot + 36 + (m_linkable ? 13 : 5);
m_link->move(maskSlot - 6 - 5, 26 - 10);
const int maskWidth = m_hasMask ? 30 : 0;
m_maskThumbnail->setFixedSize(m_maskSize);
m_maskThumbnail->move(maskSlot + (maskWidth - m_maskSize.width()) / 2, 26 - m_maskSize.height() / 2);
m_disabledMaskMark->adjustSize();
m_disabledMaskMark->move(m_maskThumbnail->geometry().center() - QPoint(m_disabledMaskMark->width() / 2, m_disabledMaskMark->height() / 2));
const int nameLeft = maskSlot + maskWidth + 5, nameWidth = std::max(10, width() - nameLeft - 8);
m_name->setGeometry(nameLeft, 9, nameWidth, 17);
m_editor->setGeometry(nameLeft - 2, 6, nameWidth + 2, 23);
m_dimensions->setGeometry(nameLeft, 29, nameWidth, 14);
for (size_t each = 0; each < m_effectButtons.size(); ++each)
m_effectButtons[each]->setGeometry(0, rowHeight + int(each) * effectHeight, width(), effectHeight);
}
void LayerCell::resizeEvent(QResizeEvent *)
{
relayout();
}
void LayerCell::paintEvent(QPaintEvent *)
{
QPainter painter(this);
// A chosen effect hides the rows' selection, as Swift's table.
if (m_list.session().selectedLayerIDs().contains(m_layerID) && !m_list.session().selectedEffect()) {
QColor fill = palette().color(QPalette::Highlight);
fill.setAlphaF(0.35);
painter.fillRect(rect(), fill);
}
// A faint device pixel where the next row begins.
QColor edge = Qt::white;
edge.setAlphaF(0.06);
painter.fillRect(QRectF(0, height() - 1.0 / devicePixelRatio(), width(), 1.0 / devicePixelRatio()), edge);
}
bool LayerCell::isOnControl(QPoint point) const
{
for (const QWidget *control : {static_cast<QWidget *>(m_eye), static_cast<QWidget *>(m_disclosure), static_cast<QWidget *>(m_thumbnail),
static_cast<QWidget *>(m_link), static_cast<QWidget *>(m_maskThumbnail)}) {
if (control->isVisible() && control->geometry().contains(point))
return true;
}
return false;
}
void LayerCell::mousePressEvent(QMouseEvent *event)
{
if (event->button() != Qt::LeftButton)
return;
disarmDrag();
// A press that clipped is done, as in Swift.
if (!m_list.clickRow(*this, event->modifiers(), event->position().toPoint()))
armDrag(event->position().toPoint(), event->modifiers());
}
void LayerCell::mouseMoveEvent(QMouseEvent *event)
{
moveDrag(event->position().toPoint(), event->buttons());
}
void LayerCell::armDrag(QPoint cellPoint, Qt::KeyboardModifiers modifiers)
{
m_press = cellPoint;
m_pressModifiers = modifiers;
}
// Left held past the distance drags.
void LayerCell::moveDrag(QPoint cellPoint, Qt::MouseButtons buttons)
{
if (!m_press || !buttons.testFlag(Qt::LeftButton) || (cellPoint - *m_press).manhattanLength() < QApplication::startDragDistance())
return;
m_press = std::nullopt;
m_list.startDrag(*this, m_pressModifiers);
}
void LayerCell::mouseDoubleClickEvent(QMouseEvent *event)
{
EditorSession &session = m_list.session();
if (event->button() != Qt::LeftButton || !session.canEditLayers())
return;
// With Alt the second press clips again, as in Swift.
if (event->modifiers().testFlag(Qt::AltModifier) && !event->modifiers().testFlag(Qt::ControlModifier)) {
m_list.clickRow(*this, event->modifiers(), event->position().toPoint());
return;
}
session.setActiveLayerID(m_layerID);
// On a control, text or an adjustment's settings open.
if (isOnControl(event->position().toPoint())) {
if (m_editableText) {
session.editActiveText();
return;
}
if (m_isAdjustment) {
session.setAdjustmentEditingID(m_layerID);
return;
}
}
session.setRenamingLayerID(m_layerID);
}
void LayerCell::contextMenuEvent(QContextMenuEvent *event)
{
QMenu::exec(actions(), event->globalPos(), nullptr, this);
}
void LayerCell::beginRenaming()
{
EditorSession &session = m_list.session();
// Not canEditLayers: a pending rename closes that gate itself.
if (m_renaming || !session.document() || session.isProjectBusy() || session.isImporting())
return;
m_renaming = true;
m_editor->setText(m_layerName);
m_editor->show();
m_editor->setFocus(Qt::OtherFocusReason);
m_editor->selectAll();
}
void LayerCell::endRenaming(bool keeping)
{
if (!m_renaming)
return;
EditorSession &session = m_list.session();
const QString typed = m_editor->text();
m_renaming = false;
m_editor->hide();
// Cleared first: the rename's signal would reopen the editor.
if (session.renamingLayerID() == m_layerID)
session.setRenamingLayerID(std::nullopt);
if (keeping)
session.renameLayer(m_layerID, typed);
// The name the layer ended up with, marked as shown.
if (session.document()) {
const int index = indexOf(session.document()->layers, m_layerID);
if (index >= 0) {
const ImageLayer &layer = session.document()->layers[index];
m_layerName = layer.name;
m_name->setText((layer.maskSourceID ? QStringLiteral("↳ ") : QString()) + layer.name);
}
}
m_list.focusList();
}
bool LayerCell::eventFilter(QObject *watched, QEvent *event)
{
if (watched != m_editor || !m_renaming)
return QWidget::eventFilter(watched, event);
if (event->type() == QEvent::FocusOut) {
// A menu borrows focus and gives it back: no leaving.
const Qt::FocusReason reason = static_cast<QFocusEvent *>(event)->reason();
if (reason != Qt::MenuBarFocusReason && reason != Qt::PopupFocusReason)
endRenaming(true);
return false;
}
if (event->type() != QEvent::KeyPress)
return false;
const int key = static_cast<QKeyEvent *>(event)->key();
if (key == Qt::Key_Return || key == Qt::Key_Enter)
endRenaming(true);
else if (key == Qt::Key_Escape)
endRenaming(false);
else
return false;
return true;
}
+376
View File
@@ -0,0 +1,376 @@
#include "Document/ProjectWorkspace.h"
#include "IO/ProjectStore.h"
#include "Rendering/EditorCanvas.h"
#include "UI/NativeLayerList.h"
#include <QApplication>
#include <QDrag>
#include <QDragEnterEvent>
#include <QDragMoveEvent>
#include <QDropEvent>
#include <QKeyEvent>
#include <QMimeData>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
const QString NativeLayerList::maskType = QStringLiteral("com.compositor.layer-mask");
const QString NativeLayerList::sourceType = QStringLiteral("com.compositor.layer-list");
namespace {
// A glyph drawn black over a white halo.
void drawHaloed(QPainter &painter, const QPainterPath &path, double width)
{
painter.setBrush(Qt::NoBrush);
painter.setPen(QPen(Qt::white, width + 2.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.drawPath(path);
painter.setPen(QPen(Qt::black, width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.drawPath(path);
}
}
void LayerColumn::paintEvent(QPaintEvent *)
{
if (!indicator)
return;
QPainter painter(this);
QColor accent = palette().color(QPalette::Highlight);
if (!indicatorFills) {
painter.fillRect(*indicator, accent);
return;
}
accent.setAlphaF(0.3);
painter.fillRect(*indicator, accent);
painter.setPen(QPen(palette().color(QPalette::Highlight), 2));
painter.drawRect(indicator->adjusted(1, 1, -1, -1));
}
std::unique_ptr<QMimeData> NativeLayerList::dragData(const LayerCell &cell) const
{
// The pressed row, with the selection it belongs to.
const QSet<QUuid> selected = m_session.selectedLayerIDs();
const bool whole = selected.contains(cell.layerID());
QStringList lines;
for (const ImageLayer &row : m_rows) {
if (row.id == cell.layerID() || (whole && selected.contains(row.id)))
lines << uuidString(row.id);
}
auto data = std::make_unique<QMimeData>();
data->setData(ProjectWorkspace::layerType, lines.join(QLatin1Char('\n')).toUtf8());
data->setData(sourceType, m_dragToken.toUtf8());
return data;
}
// Alt offers a copy alone; plain drags move or copy.
void NativeLayerList::startDrag(LayerCell &cell, Qt::KeyboardModifiers modifiers)
{
if (!m_session.canEditLayers())
return;
const bool copying = modifiers.testFlag(Qt::AltModifier) && !modifiers.testFlag(Qt::ControlModifier);
auto *drag = new QDrag(this);
drag->setMimeData(dragData(cell).release());
drag->setPixmap(cell.grab());
if (copying)
drag->setDragCursor(CanvasView::duplicateCursor(devicePixelRatio()).pixmap(), Qt::CopyAction);
drag->exec(copying ? Qt::CopyAction : Qt::MoveAction | Qt::CopyAction, copying ? Qt::CopyAction : Qt::MoveAction);
}
void NativeLayerList::startMaskDrag(LayerThumbnailButton &thumbnail)
{
auto *data = new QMimeData;
data->setData(maskType, uuidString(thumbnail.layerID).toUtf8());
auto *drag = new QDrag(this);
drag->setMimeData(data);
drag->setPixmap(thumbnail.grab());
drag->exec(Qt::CopyAction);
}
// Dragged rows in order, folder contents left out; strangers none.
std::vector<QUuid> NativeLayerList::draggedLayers(const QMimeData &data) const
{
const std::vector<QUuid> dropped = ProjectWorkspace::layerIDs(data);
const QSet<QUuid> dragged(dropped.begin(), dropped.end());
QSet<QUuid> carried;
for (const QUuid &id : dragged)
carried.unite(m_session.descendantIDs(id));
std::vector<QUuid> result;
for (const ImageLayer &row : m_rows) {
if (dragged.contains(row.id) && !carried.contains(row.id))
result.push_back(row.id);
}
return result;
}
std::optional<LayerDropTarget> NativeLayerList::dropTarget(const QMimeData &data, Qt::DropActions offered, QPoint listPoint) const
{
if (data.hasFormat(effectType)) {
// An effect lands on whichever row is under the pointer.
const int row = rowAt(listPoint);
const std::optional<LayerEffectSelection> source = draggedEffect(data);
if (row < 0 || !source || !m_session.canCopyEffect(source->kind, source->layerID, m_rows[row].id))
return std::nullopt;
return LayerDropTarget{.row = row, .onRow = true, .atBottom = false, .copying = true};
}
if (data.hasFormat(maskType)) {
// A mask lands on whichever row is under the pointer.
const int row = rowAt(listPoint);
const std::optional<QUuid> source = ProjectWorkspace::layerID(QString::fromUtf8(data.data(maskType)));
if (row < 0 || !source || !m_session.canCopyMask(*source, m_rows[row].id))
return std::nullopt;
return LayerDropTarget{.row = row, .onRow = true, .atBottom = false, .copying = true};
}
// Only this list's rows; canPlaceLayer covers canEditLayers.
if (!data.hasFormat(ProjectWorkspace::layerType) || QString::fromUtf8(data.data(sourceType)) != m_dragToken)
return std::nullopt;
const std::vector<QUuid> ids = draggedLayers(data);
if (ids.empty())
return std::nullopt;
// Swift duplicates when the source offers a copy alone (Alt).
const bool copying = offered == Qt::CopyAction;
// Folders are not duplicated: a copy with one is refused.
for (const QUuid &id : ids) {
if (copying && m_rows[size_t(indexOf(m_rows, id))].isGroup)
return std::nullopt;
}
const QPoint inColumn = m_column->mapFrom(viewport(), viewport()->mapFrom(this, listPoint));
int row = int(m_rows.size());
bool onRow = false;
for (size_t each = 0; each < m_cells.size(); ++each) {
const QRect box = m_cells[each]->geometry();
if (inColumn.y() < box.top() - 1) {
row = int(each);
break;
}
if (inColumn.y() > box.bottom() + 1)
continue;
// A folder's middle half takes the drop in.
const bool middle = std::abs(inColumn.y() - box.center().y()) <= box.height() / 4;
onRow = m_rows[each].isGroup && middle;
row = onRow || inColumn.y() < box.center().y() ? int(each) : int(each) + 1;
break;
}
const std::optional<QUuid> parent = onRow ? std::optional(m_rows[row].id) : row < int(m_rows.size()) ? m_rows[row].parentID : std::nullopt;
for (const QUuid &id : ids) {
if (!m_session.canPlaceLayer(id, parent))
return std::nullopt;
}
return LayerDropTarget{.row = row, .onRow = onRow, .atBottom = row == int(m_rows.size()), .copying = copying};
}
bool NativeLayerList::acceptDrop(const QMimeData &data, Qt::DropActions offered, QPoint listPoint)
{
const std::optional<LayerDropTarget> target = dropTarget(data, offered, listPoint);
if (!target)
return false;
if (data.hasFormat(effectType)) {
const LayerEffectSelection source = draggedEffect(data).value();
m_session.copyEffect(source.kind, source.layerID, m_rows[target->row].id);
return true;
}
if (data.hasFormat(maskType)) {
m_session.copyMask(ProjectWorkspace::layerID(QString::fromUtf8(data.data(maskType))).value(), m_rows[target->row].id);
return true;
}
const std::vector<QUuid> ids = draggedLayers(data);
// Where the drop lands is worked out once.
const std::optional<QUuid> parent = target->onRow ? std::optional(m_rows[target->row].id)
: target->atBottom ? std::nullopt
: m_rows[target->row].parentID;
const std::optional<QUuid> above = target->onRow || target->atBottom ? std::nullopt : std::optional(m_rows[target->row].id);
// Above a row the last lands nearest; in folders, first.
std::vector<QUuid> order = ids;
if (target->onRow)
std::reverse(order.begin(), order.end());
const bool several = ids.size() > 1;
m_session.beginEdit(target->copying ? (several ? QStringLiteral("Duplicate Layers") : QStringLiteral("Duplicate Layer"))
: (several ? QStringLiteral("Move Layers") : QStringLiteral("Move Layer")));
bool placed = false;
for (const QUuid &id : order) {
const bool done = target->copying ? m_session.duplicateLayer(id, parent, above, target->atBottom)
: m_session.placeLayer(id, parent, above, target->atBottom);
placed = done || placed;
}
// The moved layers stay selected, to drag on together.
if (placed && !target->copying)
m_session.selectLayers(QSet<QUuid>(ids.begin(), ids.end()), ids.front());
m_session.endEdit();
return placed;
}
void NativeLayerList::showIndicator(const std::optional<LayerDropTarget> &target)
{
m_column->indicator = std::nullopt;
if (target && target->onRow) {
m_column->indicator = m_cells[size_t(target->row)]->geometry();
m_column->indicatorFills = true;
} else if (target) {
const int y = target->atBottom ? m_cells.back()->geometry().bottom() + 1 : m_cells[size_t(target->row)]->geometry().top() - 1;
m_column->indicator = QRect(0, y - 1, m_column->width(), 2);
m_column->indicatorFills = false;
}
m_column->update();
}
// The list moves, or copies what Alt offers alone.
void NativeLayerList::dragEnterEvent(QDragEnterEvent *event)
{
const std::optional<LayerDropTarget> target = dropTarget(*event->mimeData(), event->possibleActions(), viewport()->mapToParent(event->position().toPoint()));
showIndicator(target);
if (!target)
return;
event->setDropAction(target->copying ? Qt::CopyAction : Qt::MoveAction);
event->accept();
}
void NativeLayerList::dragMoveEvent(QDragMoveEvent *event)
{
// Near an edge the list scrolls, as the table does.
const QPoint inViewport = event->position().toPoint();
m_dragPoint = inViewport;
m_dragData = event->mimeData();
m_dragActions = event->possibleActions();
autoscroll(inViewport, *event->mimeData(), event->possibleActions());
const std::optional<LayerDropTarget> target = dropTarget(*event->mimeData(), event->possibleActions(), viewport()->mapToParent(inViewport));
if (!target) {
event->ignore();
return;
}
event->setDropAction(target->copying ? Qt::CopyAction : Qt::MoveAction);
event->accept();
}
void NativeLayerList::autoscroll(QPoint inViewport, const QMimeData &data, Qt::DropActions offered)
{
const int margin = 16;
const int overshoot = inViewport.y() < margin ? inViewport.y() - margin : std::max(0, inViewport.y() - (viewport()->height() - margin));
if (overshoot != 0)
verticalScrollBar()->setValue(verticalScrollBar()->value() + overshoot);
// Held at the edge, the timer scrolls on; elsewhere, rests.
if (overshoot != 0 && !m_edgeScroll.isActive())
m_edgeScroll.start();
else if (overshoot == 0)
m_edgeScroll.stop();
showIndicator(dropTarget(data, offered, viewport()->mapToParent(inViewport)));
}
void NativeLayerList::dragLeaveEvent(QDragLeaveEvent *)
{
m_edgeScroll.stop();
m_dragData = nullptr;
showIndicator(std::nullopt);
}
void NativeLayerList::dropEvent(QDropEvent *event)
{
m_edgeScroll.stop();
m_dragData = nullptr;
showIndicator(std::nullopt);
const bool copying = event->possibleActions() == Qt::CopyAction;
if (!acceptDrop(*event->mimeData(), event->possibleActions(), viewport()->mapToParent(event->position().toPoint())))
return;
event->setDropAction(copying ? Qt::CopyAction : Qt::MoveAction);
event->accept();
}
// Photoshop's create and release clipping pointers, haloed.
QCursor NativeLayerList::clippingCursor(bool releasing, double ratio)
{
QPixmap pixmap(QSize(30, 28) * ratio);
pixmap.setDevicePixelRatio(ratio);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
// An arrow turning down and right, at the left.
QPainterPath arrow(QPointF(4, 3));
arrow.lineTo(QPointF(4, 12));
arrow.lineTo(QPointF(14, 12));
arrow.moveTo(QPointF(10.5, 8.5));
arrow.lineTo(QPointF(14, 12));
arrow.lineTo(QPointF(10.5, 15.5));
drawHaloed(painter, arrow, 1.8);
// A box with a badge: plus creates, minus releases.
QPainterPath box;
box.addRoundedRect(QRectF(12, 11, 15, 12), 2, 2);
drawHaloed(painter, box, 1.6);
QPainterPath badge;
badge.addEllipse(QPointF(25, 12), 4.5, 4.5);
painter.setPen(QPen(Qt::white, 1.4));
painter.setBrush(Qt::black);
painter.drawPath(badge);
painter.setPen(QPen(Qt::white, 1.4, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(QPointF(22.5, 12), QPointF(27.5, 12));
if (!releasing)
painter.drawLine(QPointF(25, 9.5), QPointF(25, 14.5));
return QCursor(pixmap, 3, 3);
}
QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const
{
const int row = rowAt(listPoint);
if (row < 0 || !(modifiers & (Qt::AltModifier | Qt::ControlModifier)))
return QCursor(Qt::ArrowCursor);
const ImageLayer &layer = m_rows[size_t(row)];
LayerCell &cell = *m_cells[size_t(row)];
const QPoint inCell = cell.mapFrom(this, listPoint);
const double ratio = devicePixelRatio();
// Ctrl over a thumbnail loads it as a selection.
if (modifiers.testFlag(Qt::ControlModifier)) {
for (const LayerThumbnailButton *thumbnail : {&cell.thumbnail(), &cell.maskThumbnail()}) {
if (thumbnail->isVisible() && thumbnail->isEnabled() && thumbnail->geometry().contains(inCell))
return CanvasView::loadSelectionCursor(ratio);
}
return QCursor(Qt::ArrowCursor);
}
const bool editable = m_session.canEditLayers();
// A mask copies, the row duplicates, the strip clips.
if (cell.maskThumbnail().isVisible() && cell.maskThumbnail().geometry().contains(inCell))
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
if (!isClippingZone(cell, inCell))
return editable && !layer.isGroup ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
if (!m_session.canToggleClippingMask(layer.id))
return QCursor(Qt::ArrowCursor);
return clippingCursor(layer.maskSourceID.has_value(), ratio);
}
// A modifier changed: the cursor is worked out again.
void NativeLayerList::refreshCursor()
{
viewport()->setCursor(m_hover ? cursorFor(*m_hover, QApplication::keyboardModifiers()) : QCursor(Qt::ArrowCursor));
}
void NativeLayerList::showEvent(QShowEvent *event)
{
QScrollArea::showEvent(event);
// Alt pressed anywhere changes the cursor over the rows.
qApp->installEventFilter(this);
}
void NativeLayerList::hideEvent(QHideEvent *event)
{
qApp->removeEventFilter(this);
QScrollArea::hideEvent(event);
}
// Alt, Ctrl or hover over rows sets the cursor.
bool NativeLayerList::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) {
const int key = static_cast<QKeyEvent *>(event)->key();
if (key == Qt::Key_Alt || key == Qt::Key_Control)
refreshCursor();
} else if (event->type() == QEvent::MouseMove && watched->isWidgetType() && isAncestorOf(static_cast<QWidget *>(watched))) {
const auto *mouse = static_cast<QMouseEvent *>(event);
m_hover = mapFromGlobal(mouse->globalPosition().toPoint());
viewport()->setCursor(cursorFor(*m_hover, mouse->modifiers()));
}
return QScrollArea::eventFilter(watched, event);
}
bool NativeLayerList::viewportEvent(QEvent *event)
{
if (event->type() == QEvent::Leave) {
m_hover = std::nullopt;
viewport()->setCursor(QCursor(Qt::ArrowCursor));
}
return QScrollArea::viewportEvent(event);
}
+178
View File
@@ -0,0 +1,178 @@
#include "Document/ProjectWorkspace.h"
#include "Rendering/EditorCanvas.h"
#include "UI/LayerIcons.h"
#include "UI/NativeLayerList.h"
#include <QAccessibleWidget>
#include <QApplication>
#include <QDrag>
#include <QMimeData>
#include <QMouseEvent>
#include <QPainter>
const QString NativeLayerList::effectType = QStringLiteral("com.compositor.layer-effect");
namespace {
// Swift's group element, whose press chooses the effect.
class EffectRowAccessible : public QAccessibleWidget {
public:
explicit EffectRowAccessible(LayerEffectRow *row) : QAccessibleWidget(row, QAccessible::Grouping) {}
QStringList actionNames() const override { return {pressAction()}; }
void doAction(const QString &name) override
{
if (name == pressAction())
static_cast<LayerEffectRow *>(widget())->select(false);
}
};
QAccessibleInterface *effectRowAccessible(const QString &key, QObject *object)
{
return key == QLatin1String("LayerEffectRow") ? new EffectRowAccessible(static_cast<LayerEffectRow *>(object)) : nullptr;
}
bool option(Qt::KeyboardModifiers modifiers)
{
return modifiers.testFlag(Qt::AltModifier) && !modifiers.testFlag(Qt::ControlModifier);
}
}
LayerEffectRow::LayerEffectRow(NativeLayerList &list, QUuid layerID, LayerEffectKind kind, QWidget *parent)
: QWidget(parent), layerID(layerID), kind(kind), m_list(list), m_eye(new QToolButton(this)), m_label(new QLabel(this))
{
[[maybe_unused]] static const bool accessible = (QAccessible::installFactory(effectRowAccessible), true);
setObjectName(QStringLiteral("layerEffectRow"));
setToolTip(QStringLiteral("Click to select; double-click to edit; Alt-drag to copy ") + rawValue(kind).toLower());
setAccessibleName(rawValue(kind) + QStringLiteral(" effect"));
m_eye->setObjectName(QStringLiteral("effectEye"));
m_eye->setAutoRaise(true);
m_eye->setFixedSize(20, 22);
m_eye->installEventFilter(this);
connect(m_eye, &QToolButton::clicked, this, [this] { m_list.session().toggleEffect(this->kind, this->layerID); });
QFont small = m_label->font();
small.setPixelSize(11);
m_label->setFont(small);
// Hover reaches the list's cursor, as every row's controls.
for (QWidget *each : {static_cast<QWidget *>(this), static_cast<QWidget *>(m_eye), static_cast<QWidget *>(m_label)})
each->setMouseTracking(true);
}
void LayerEffectRow::configure(bool enabled, bool editable, int indent)
{
m_eye->setIcon(LayerIcons::pixmap(enabled ? LayerIcon::eye : LayerIcon::eyeSlash, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio()));
m_eye->setEnabled(editable);
m_eye->setAccessibleName((enabled ? QStringLiteral("Hide ") : QStringLiteral("Show ")) + rawValue(kind));
m_label->setForegroundRole(enabled ? QPalette::WindowText : QPalette::PlaceholderText);
m_indent = indent;
relayout();
}
void LayerEffectRow::relayout()
{
m_eye->move(38 + m_indent, (height() - m_eye->height()) / 2);
const int left = m_eye->geometry().right() + 1 + 8;
m_label->setGeometry(left, 0, std::max(0, width() - left - 8), height());
m_label->setText(m_label->fontMetrics().elidedText(rawValue(kind), Qt::ElideRight, m_label->width()));
}
void LayerEffectRow::resizeEvent(QResizeEvent *)
{
relayout();
}
void LayerEffectRow::select(bool editing)
{
m_list.session().selectEffect(kind, layerID, editing);
m_list.focusList();
}
// Alt clips on the strip, else arms; plain, it chooses.
void LayerEffectRow::press(QMouseEvent *event, bool twice)
{
if (event->button() != Qt::LeftButton)
return;
m_copyDown = std::nullopt;
auto &cell = *static_cast<LayerCell *>(parentWidget());
const QPoint inCell = mapToParent(event->position().toPoint());
if (option(event->modifiers()) && NativeLayerList::isClippingZone(cell, inCell)) {
m_list.clickRow(cell, event->modifiers(), inCell);
return;
}
if (option(event->modifiers()) && m_list.session().canEditLayers())
m_copyDown = event->position().toPoint();
else
select(twice);
}
void LayerEffectRow::mousePressEvent(QMouseEvent *event)
{
press(event, false);
}
void LayerEffectRow::mouseDoubleClickEvent(QMouseEvent *event)
{
press(event, true);
}
// Held past the distance with Alt, the effect is dragged.
void LayerEffectRow::mouseMoveEvent(QMouseEvent *event)
{
if (!m_copyDown || !event->buttons().testFlag(Qt::LeftButton)
|| (event->position().toPoint() - *m_copyDown).manhattanLength() < QApplication::startDragDistance())
return;
m_copyDown = std::nullopt;
if (event->modifiers().testFlag(Qt::AltModifier) && m_list.session().canEditLayers())
m_list.startEffectDrag(*this);
}
// Released without a drag, an Alt press chooses the effect.
void LayerEffectRow::mouseReleaseEvent(QMouseEvent *event)
{
if (m_copyDown && event->button() == Qt::LeftButton)
select(false);
}
// The eye's Alt press, ignored, goes on to the row.
bool LayerEffectRow::eventFilter(QObject *watched, QEvent *event)
{
const bool press = event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonDblClick;
if (press && option(static_cast<QMouseEvent *>(event)->modifiers())) {
event->ignore();
return true;
}
return QWidget::eventFilter(watched, event);
}
void LayerEffectRow::paintEvent(QPaintEvent *)
{
if (m_list.session().selectedEffect() != LayerEffectSelection{layerID, kind})
return;
QColor fill = palette().color(QPalette::Highlight);
fill.setAlphaF(0.3);
QPainter(this).fillRect(rect(), fill);
}
// Alt offers a copy alone, within this list.
void NativeLayerList::startEffectDrag(LayerEffectRow &row)
{
auto *data = new QMimeData;
data->setData(effectType, (uuidString(row.layerID) + QLatin1Char(':') + rawValue(row.kind)).toUtf8());
data->setData(sourceType, m_dragToken.toUtf8());
auto *drag = new QDrag(this);
drag->setMimeData(data);
drag->setPixmap(row.grab());
drag->setDragCursor(CanvasView::duplicateCursor(devicePixelRatio()).pixmap(), Qt::CopyAction);
drag->exec(Qt::CopyAction);
}
// This list's dragged effect: its layer and kind.
std::optional<LayerEffectSelection> NativeLayerList::draggedEffect(const QMimeData &data) const
{
if (QString::fromUtf8(data.data(sourceType)) != m_dragToken)
return std::nullopt;
const QStringList parts = QString::fromUtf8(data.data(effectType)).split(QLatin1Char(':'));
const std::optional<QUuid> id = parts.size() == 2 ? ProjectWorkspace::layerID(parts[0]) : std::nullopt;
for (const LayerEffectKind kind : allLayerEffectKinds) {
if (id && rawValue(kind) == parts[1])
return LayerEffectSelection{*id, kind};
}
return std::nullopt;
}
+241
View File
@@ -0,0 +1,241 @@
#include "UI/NativeLayerList.h"
#include <QEvent>
#include <QKeyEvent>
#include <QScrollBar>
#include <QVBoxLayout>
NativeLayerList::NativeLayerList(EditorSession &session, QWidget *parent)
: QScrollArea(parent), m_session(session), m_column(new LayerColumn(this))
{
setObjectName(QStringLiteral("layersList"));
setAccessibleName(QStringLiteral("Layers"));
setFrameShape(QFrame::NoFrame);
setWidgetResizable(true);
setAcceptDrops(true);
viewport()->setMouseTracking(true);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setFocusPolicy(Qt::StrongFocus);
setAutoFillBackground(false);
viewport()->setAutoFillBackground(false);
auto *rows = new QVBoxLayout(m_column);
rows->setContentsMargins(0, 0, 0, 0);
rows->setSpacing(2);
rows->addStretch(1);
setWidget(m_column);
m_column->setAutoFillBackground(false);
connect(&m_session, &EditorSession::changed, this, &NativeLayerList::update);
m_edgeScroll.setInterval(50);
connect(&m_edgeScroll, &QTimer::timeout, this, [this] { autoscroll(m_dragPoint, *m_dragData, m_dragActions); });
update();
}
void NativeLayerList::update()
{
const std::vector<LayerHierarchy::Entry> entries = m_session.layerRows();
const std::vector<ImageLayer> &layers = m_session.document() ? m_session.document()->layers : std::vector<ImageLayer>();
std::vector<ImageLayer> next;
m_details.clear();
for (const LayerHierarchy::Entry &entry : entries) {
const int index = indexOf(layers, entry.layer.id);
if (index < 0)
continue;
next.push_back(layers[index]);
m_details.insert_or_assign(entry.layer.id, entry);
}
m_editingEnabled = m_session.canEditLayers();
// The rows are made anew when the ids change.
bool same = next.size() == m_rows.size();
for (size_t row = 0; same && row < next.size(); ++row)
same = next[row].id == m_rows[row].id;
m_rows = std::move(next);
if (!same) {
for (LayerCell *cell : m_cells)
delete cell;
m_cells.clear();
auto *rows = static_cast<QVBoxLayout *>(m_column->layout());
for (size_t row = 0; row < m_rows.size(); ++row) {
auto *cell = new LayerCell(*this);
rows->insertWidget(int(row), cell);
// A layout shows late children from the event loop.
cell->show();
m_cells.push_back(cell);
}
rows->activate();
}
for (size_t row = 0; row < m_rows.size(); ++row) {
const LayerHierarchy::Entry &entry = m_details.at(m_rows[row].id);
m_cells[row]->configure(m_rows[row], m_editingEnabled, entry.depth, entry.visible);
m_cells[row]->updateTarget();
}
// Heights change at once, as Swift's noteHeightOfRows.
m_column->layout()->activate();
// A rename, asked from anywhere, is typed in the row.
if (const std::optional<QUuid> renaming = m_session.renamingLayerID()) {
const int row = indexOf(m_rows, renaming);
if (row >= 0) {
ensureWidgetVisible(m_cells[row]);
m_cells[row]->beginRenaming();
}
}
}
int NativeLayerList::rowAt(QPoint listPoint) const
{
const QPoint inColumn = m_column->mapFrom(viewport(), viewport()->mapFrom(this, listPoint));
for (size_t row = 0; row < m_cells.size(); ++row) {
if (m_cells[row]->geometry().contains(inColumn))
return int(row);
}
return -1;
}
bool NativeLayerList::isClippingZone(const LayerCell &cell, QPoint cellPoint)
{
return cellPoint.y() >= cell.height() - 10;
}
bool NativeLayerList::clickRow(LayerCell &cell, Qt::KeyboardModifiers modifiers, QPoint cellPoint)
{
const int row = indexOf(m_rows, cell.layerID());
if (row < 0)
return false;
const QUuid id = m_rows[row].id;
focusList();
// Swift's mouseDown lets go of a chosen effect first.
m_session.dropEffectSelection();
if (modifiers.testFlag(Qt::AltModifier) && !modifiers.testFlag(Qt::ControlModifier) && isClippingZone(cell, cellPoint)) {
m_session.toggleClippingMask(id);
// The cursor says what the next click does, as Swift's.
m_hover = cell.mapTo(this, cellPoint);
viewport()->setCursor(cursorFor(*m_hover, modifiers));
return true;
}
if (modifiers.testFlag(Qt::ControlModifier)) {
m_session.extendSelection(id);
return false;
}
if (modifiers.testFlag(Qt::ShiftModifier)) {
// From the active row to this one, both included.
const int anchor = indexOf(m_rows, m_session.activeLayerID());
QSet<QUuid> ids;
for (int each = std::min(anchor < 0 ? row : anchor, row); each <= std::max(anchor, row); ++each)
ids.insert(m_rows[each].id);
m_session.selectLayers(ids, id);
return false;
}
// A click on a lone layer's name targets its pixels.
const bool targetsMask = m_session.isMaskSelected() && m_session.selectedLayerIDs() == QSet<QUuid>{id} && !cell.isOnControl(cellPoint);
if (targetsMask) {
m_session.commitTransform();
m_session.selectLayerTarget(id, false);
return false;
}
// Selected already, a press keeps the selection: 4.7 drags it.
if (m_session.selectedLayerIDs().contains(id))
return false;
m_session.selectLayers({id}, id);
return false;
}
void NativeLayerList::mousePressEvent(QMouseEvent *event)
{
// The spacing between rows takes no press, as before.
const QPoint inColumn = m_column->mapFrom(viewport(), event->position().toPoint());
if (event->button() != Qt::LeftButton || (!m_cells.empty() && inColumn.y() <= m_cells.back()->geometry().bottom()))
return;
m_session.dropEffectSelection();
if (!(event->modifiers() & (Qt::ControlModifier | Qt::ShiftModifier)))
m_session.selectLayers({}, std::nullopt);
}
void NativeLayerList::focusList()
{
setFocus(Qt::MouseFocusReason);
}
// Tab reaches the keys, not the focus chain, as Swift.
bool NativeLayerList::event(QEvent *event)
{
if (event->type() == QEvent::KeyPress) {
auto *key = static_cast<QKeyEvent *>(event);
if (key->key() == Qt::Key_Tab || key->key() == Qt::Key_Backtab) {
keyPressEvent(key);
return true;
}
}
return QScrollArea::event(event);
}
void NativeLayerList::keyPressEvent(QKeyEvent *event)
{
const bool plain = !(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier));
const int key = event->key();
const bool enter = key == Qt::Key_Return || key == Qt::Key_Enter;
const bool arrow = key == Qt::Key_Left || key == Qt::Key_Right || key == Qt::Key_Up || key == Qt::Key_Down;
static const std::map<int, NavigationTool> tools{
{Qt::Key_A, NavigationTool::idle}, {Qt::Key_V, NavigationTool::move}, {Qt::Key_H, NavigationTool::hand},
{Qt::Key_Z, NavigationTool::zoom}, {Qt::Key_G, NavigationTool::gradient}, {Qt::Key_L, NavigationTool::lasso}, {Qt::Key_M, NavigationTool::marquee},
{Qt::Key_W, NavigationTool::wand}, {Qt::Key_J, NavigationTool::spotHealing}, {Qt::Key_S, NavigationTool::cloneStamp},
{Qt::Key_U, NavigationTool::shape}, {Qt::Key_R, NavigationTool::blur}, {Qt::Key_I, NavigationTool::eyedropper},
{Qt::Key_C, NavigationTool::crop}};
if (plain && (key == Qt::Key_Backspace || key == Qt::Key_Delete)) {
m_session.deleteKeyPressed();
return;
}
if (key == Qt::Key_Escape && m_session.transformEdit()) {
m_session.cancelTransform();
} else if (enter && m_session.transformEdit()) {
m_session.commitTransform();
} else if (plain && (key == Qt::Key_Tab || key == Qt::Key_Backtab)) {
// Swift's list cycles on Shift-Tab as on Tab.
m_session.cycleToolMode();
} else if (plain && (key == Qt::Key_B || key == Qt::Key_E)) {
m_session.selectTool(NavigationTool::brush);
m_session.setBrushMode(key == Qt::Key_E ? BrushToolMode::erase : BrushToolMode::paint);
} else if (plain && (key == Qt::Key_X || key == Qt::Key_D)) {
if (key == Qt::Key_X)
m_session.swapPaletteColors();
else
m_session.resetPaletteColors();
} else if (plain && tools.contains(key)) {
m_session.selectTool(tools.at(key));
} else if (plain && event->text().size() == 1 && event->text().front().isDigit() && m_session.usesOpacityKeys()) {
m_session.typeOpacityDigit(event->text().front().digitValue());
} else if (plain && arrow && m_session.tool() == NavigationTool::move) {
// Under Move the arrows nudge; an edit implies Move.
const double step = event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1;
m_session.nudgeLayer(key == Qt::Key_Left ? -step : key == Qt::Key_Right ? step : 0, key == Qt::Key_Up ? -step : key == Qt::Key_Down ? step : 0);
} else if (plain && (key == Qt::Key_Up || key == Qt::Key_Down)) {
const int row = indexOf(m_rows, m_session.activeLayerID());
const int next = row + (key == Qt::Key_Up ? -1 : 1);
if (row < 0 || next < 0 || next >= int(m_rows.size()))
return;
// Shift ranges from the anchor; the row comes into view.
if (event->modifiers().testFlag(Qt::ShiftModifier)) {
const QSet<QUuid> selected = m_session.selectedLayerIDs();
int first = row, last = row;
while (first > 0 && selected.contains(m_rows[first - 1].id))
--first;
while (last + 1 < int(m_rows.size()) && selected.contains(m_rows[last + 1].id))
++last;
const int anchor = row == last ? first : last;
QSet<QUuid> range;
for (int each = std::min(anchor, next); each <= std::max(anchor, next); ++each)
range.insert(m_rows[each].id);
m_session.selectLayers(range, m_rows[next].id);
} else {
m_session.selectLayers({m_rows[next].id}, m_rows[next].id);
}
ensureWidgetVisible(m_cells[next], 0, 0);
} else {
QScrollArea::keyPressEvent(event);
}
}
// A new palette: icons redraw once the widgets have it.
void NativeLayerList::changeEvent(QEvent *event)
{
QScrollArea::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
QMetaObject::invokeMethod(this, &NativeLayerList::update, Qt::QueuedConnection);
}
+273
View File
@@ -0,0 +1,273 @@
#pragma once
#include "Document/EditorSession.h"
#include "Document/LayerGroups.h"
#include <QGraphicsOpacityEffect>
#include <QLabel>
#include <QLineEdit>
#include <QMouseEvent>
#include <QScrollArea>
#include <QTimer>
#include <QToolButton>
#include <map>
#include <memory>
class NativeLayerList;
// A layer's eye: a press toggles, a drag swipes rows.
class EyeSwipeButton : public QToolButton {
Q_OBJECT
public:
explicit EyeSwipeButton(NativeLayerList &list, QWidget *parent);
~EyeSwipeButton() override;
QUuid layerID;
protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
// The release may land in a popup: watched application-wide.
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void endSwipe();
NativeLayerList &m_list;
std::optional<bool> m_swipe;
};
// A thumbnail: a click targets it; Shift toggles a mask.
class LayerThumbnailButton : public QToolButton {
Q_OBJECT
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);
bool isTargeted() const { return m_targeted; }
~LayerThumbnailButton() override;
protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;
void paintEvent(QPaintEvent *event) override;
// The release may land in a popup: watched application-wide.
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void endAltMaskPress();
NativeLayerList &m_list;
bool m_targeted = false;
// Alt on a mask: chosen on release, or dragged.
std::optional<QPoint> m_altMaskPress;
};
// An effect under its layer: its eye, name and choice.
class LayerEffectRow : public QWidget {
Q_OBJECT
public:
LayerEffectRow(NativeLayerList &list, QUuid layerID, LayerEffectKind kind, QWidget *parent);
const QUuid layerID;
const LayerEffectKind kind;
// Shown or hidden, editable, stepped in with its layer.
void configure(bool enabled, bool editable, int indent);
void select(bool editing);
protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
// Alt makes a press on the eye the row's.
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void press(QMouseEvent *event, bool twice);
void relayout();
NativeLayerList &m_list;
QToolButton *const m_eye;
QLabel *const m_label;
int m_indent = 0;
// Swift's copyDown: an Alt press, chosen on release or dragged.
std::optional<QPoint> m_copyDown;
};
// What a row's canvas-framed thumbnail shows; redrawn only on change.
struct ThumbnailKey {
std::optional<ImageIdentity> image;
LayerTransform transform;
QSizeF canvas;
// A folder's icon takes the palette's ink.
QColor ink;
bool editableText = false;
friend bool operator==(const ThumbnailKey &, const ThumbnailKey &) = default;
};
// One row: eye, disclosure, thumbnails, link, name and size.
class LayerCell : public QWidget {
Q_OBJECT
public:
explicit LayerCell(NativeLayerList &list);
// Swift's row height, and 24 more for each effect.
static constexpr int rowHeight = 52;
static constexpr int effectHeight = 24;
void configure(const ImageLayer &layer, bool enabled, int depth, bool visible);
void updateTarget();
void beginRenaming();
bool isOnControl(QPoint point) const;
bool isRenaming() const { return m_renaming; }
QUuid layerID() const { return m_layerID; }
LayerThumbnailButton &thumbnail() { return *m_thumbnail; }
LayerThumbnailButton &maskThumbnail() { return *m_maskThumbnail; }
// A left press becomes a drag past the distance.
void armDrag(QPoint cellPoint, Qt::KeyboardModifiers modifiers);
void disarmDrag() { m_press = std::nullopt; }
void moveDrag(QPoint cellPoint, Qt::MouseButtons buttons);
protected:
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;
void contextMenuEvent(QContextMenuEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void relayout();
void endRenaming(bool keeping);
QAction *menuAction(const QString &name, const QString &title, const std::function<void()> &run);
NativeLayerList &m_list;
EyeSwipeButton *const m_eye;
QToolButton *const m_disclosure;
LayerThumbnailButton *const m_thumbnail;
LayerThumbnailButton *const m_maskThumbnail;
QToolButton *const m_link;
QLabel *const m_disabledMaskMark;
QLabel *const m_name;
QLabel *const m_dimensions;
QLineEdit *const m_editor;
QGraphicsOpacityEffect *const m_fade;
QUuid m_layerID;
QString m_layerName;
bool m_renaming = false;
bool m_hasMask = false;
bool m_linkable = false;
bool m_isGroup = false;
bool m_editableText = false;
bool m_isAdjustment = false;
int m_indent = 0;
std::optional<QPoint> m_press;
// Alt at the press makes the drag a copy.
Qt::KeyboardModifiers m_pressModifiers;
QSize m_thumbnailSize{36, 36};
QSize m_maskSize{30, 30};
std::optional<ThumbnailKey> m_thumbnailKey;
std::optional<ThumbnailKey> m_maskThumbnailKey;
std::vector<LayerEffectRow *> m_effectButtons;
};
// Where a drag lands: above, into a folder, or last.
struct LayerDropTarget {
int row;
bool onRow;
bool atBottom;
bool copying;
friend bool operator==(const LayerDropTarget &, const LayerDropTarget &) = default;
};
// The column of rows; it paints the drop indicator.
class LayerColumn : public QWidget {
Q_OBJECT
public:
using QWidget::QWidget;
std::optional<QRect> indicator;
bool indicatorFills = false;
protected:
void paintEvent(QPaintEvent *event) override;
};
// The layer rows, top down, following the session's hierarchy.
class NativeLayerList : public QScrollArea {
Q_OBJECT
public:
explicit NativeLayerList(EditorSession &session, QWidget *parent = nullptr);
// A dragged mask carries its layer's id under this type.
static const QString maskType;
// A row drag names its list, as Swift checks tables.
static const QString sourceType;
// A dragged effect: its layer's id and kind, colon apart.
static const QString effectType;
QString dragToken() const { return m_dragToken; }
EditorSession &session() const { return m_session; }
void update();
std::vector<LayerCell *> cells() const { return m_cells; }
// The row under a point of this list, or -1.
int rowAt(QPoint listPoint) const;
// A row pressed; modifiers extend or clip; true when clipped.
bool clickRow(LayerCell &cell, Qt::KeyboardModifiers modifiers, QPoint cellPoint);
// Photoshop's strip along the bottom of a row.
static bool isClippingZone(const LayerCell &cell, QPoint cellPoint);
void focusList();
// What a drag of this row carries: its selection.
std::unique_ptr<QMimeData> dragData(const LayerCell &cell) const;
void startDrag(LayerCell &cell, Qt::KeyboardModifiers modifiers);
void startMaskDrag(LayerThumbnailButton &thumbnail);
void startEffectDrag(LayerEffectRow &row);
// Where a drop lands; Alt offers a copy alone.
std::optional<LayerDropTarget> dropTarget(const QMimeData &data, Qt::DropActions offered, QPoint listPoint) const;
bool acceptDrop(const QMimeData &data, Qt::DropActions offered, QPoint listPoint);
// Whether a drag held at an edge still scrolls.
bool autoscrolling() const { return m_edgeScroll.isActive(); }
// The cursor Alt asks for here: clip, duplicate, arrow.
QCursor cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const;
static QCursor clippingCursor(bool releasing, double ratio);
protected:
bool event(QEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
// Below every row: Swift's table lets go of everything.
void mousePressEvent(QMouseEvent *event) override;
void changeEvent(QEvent *event) override;
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
bool viewportEvent(QEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dragLeaveEvent(QDragLeaveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private:
std::vector<QUuid> draggedLayers(const QMimeData &data) const;
std::optional<LayerEffectSelection> draggedEffect(const QMimeData &data) const;
void showIndicator(const std::optional<LayerDropTarget> &target);
void refreshCursor();
// A drag held at an edge scrolls until it ends.
void autoscroll(QPoint inViewport, const QMimeData &data, Qt::DropActions offered);
EditorSession &m_session;
LayerColumn *const m_column;
std::vector<LayerCell *> m_cells;
std::vector<ImageLayer> m_rows;
std::map<QUuid, LayerHierarchy::Entry> m_details;
bool m_editingEnabled = false;
const QString m_dragToken = QUuid::createUuid().toString();
// Where the pointer last hovered, in the list's coordinates.
std::optional<QPoint> m_hover;
QTimer m_edgeScroll;
QPoint m_dragPoint;
const QMimeData *m_dragData = nullptr;
Qt::DropActions m_dragActions;
};
+83
View File
@@ -0,0 +1,83 @@
#include "UI/NavigationToolHeader.h"
#include <QKeyEvent>
#include <QRegularExpression>
#include <cmath>
NavigationToolHeader::NavigationToolHeader(EditorSession &session, QWidget *parent)
: ToolHeaderBar(QString(), parent), m_session(session), m_zoom(new QLineEdit(this)), m_unit(new QLabel(QStringLiteral("%"), this))
{
m_zoom->setObjectName(QStringLiteral("zoomPercentage"));
m_zoom->setAccessibleName(QStringLiteral("Zoom percentage"));
m_zoom->setToolTip(QStringLiteral("Zoom percentage (0.1–3200%). Press Return to apply."));
m_zoom->setFixedWidth(72);
m_zoom->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
m_zoom->installEventFilter(this);
// The unit sits tight against its field: one value.
auto *value = new QHBoxLayout;
value->setSpacing(2);
value->addWidget(m_zoom);
value->addWidget(m_unit);
row->insertLayout(1, value);
connect(&m_session, &EditorSession::changed, this, &NavigationToolHeader::synchronize);
synchronize();
}
void NavigationToolHeader::synchronize()
{
const bool zooms = m_session.tool() == NavigationTool::zoom;
title->setText(zooms ? QStringLiteral("Zoom") : QStringLiteral("Pan"));
m_zoom->setVisible(zooms);
m_unit->setVisible(zooms);
m_zoom->setEnabled(m_session.document().has_value() && !m_session.showsBusy());
if (!m_zoom->hasFocus())
syncZoom();
}
void NavigationToolHeader::syncZoom()
{
static const QRegularExpression trailingZeros(QStringLiteral("\\.?0+$"));
m_displayed = QString::number(m_session.viewport.zoom() * 100, 'f', 2).remove(trailingZeros);
m_zoom->setText(m_displayed);
}
void NavigationToolHeader::applyZoom()
{
if (m_zoom->text() != m_displayed) {
// No number reads as zero; "inf" the viewport refuses.
const double value = m_zoom->text().trimmed().remove(QLatin1Char('%')).toDouble();
if (value > 0 && !m_session.isProjectBusy())
m_session.zoom(value / 100);
}
syncZoom();
}
void NavigationToolHeader::step(double percent)
{
m_zoom->setText(QString::number(std::clamp(percent, 0.1, 3200.0), 'g', 6));
applyZoom();
}
bool NavigationToolHeader::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() == QEvent::FocusOut)
applyZoom();
if (event->type() != QEvent::KeyPress)
return ToolHeaderBar::eventFilter(watched, event);
const auto *key = static_cast<QKeyEvent *>(event);
// Return and Escape apply, then hand the canvas the keys.
if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter || key->key() == Qt::Key_Escape) {
applyZoom();
m_zoom->clearFocus();
m_session.requestCanvasFocus();
return true;
}
if (key->key() != Qt::Key_Up && key->key() != Qt::Key_Down)
return ToolHeaderBar::eventFilter(watched, event);
// Up and Down step one percent, ten with Shift.
static const QRegularExpression notNumeric(QStringLiteral("[^0-9.]"));
bool isNumber = false;
const double typed = m_zoom->text().remove(notNumeric).toDouble(&isNumber);
const double amount = (key->modifiers() & Qt::ShiftModifier ? 10 : 1) * (key->key() == Qt::Key_Up ? 1 : -1);
step((isNumber ? typed : m_session.viewport.zoom() * 100) + amount);
return true;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "Document/EditorSession.h"
#include "UI/ToolHeaderStyle.h"
#include <QLineEdit>
// The Hand and Zoom tools' bar: Zoom's percentage field.
class NavigationToolHeader : public ToolHeaderBar {
Q_OBJECT
public:
explicit NavigationToolHeader(EditorSession &session, QWidget *parent = nullptr);
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void synchronize();
void syncZoom();
void applyZoom();
void step(double percent);
EditorSession &m_session;
QLineEdit *const m_zoom;
QLabel *const m_unit;
// What the field showed before the user typed.
QString m_displayed;
};
+134
View File
@@ -0,0 +1,134 @@
#include "UI/NewCanvasSheet.h"
#include <QApplication>
#include <QBuffer>
#include <QClipboard>
#include <QGridLayout>
#include <QImageReader>
#include <QShortcut>
#include <QVBoxLayout>
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
{
auto *label = new QLabel(words, parent);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
label->setForegroundRole(role);
return label;
}
}
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))
{
// Swift's fields stretch the sheet to its full 500 points.
setFixedWidth(500);
m_width->setObjectName(QStringLiteral("widthInput"));
m_height->setObjectName(QStringLiteral("heightInput"));
m_note->setObjectName(QStringLiteral("canvasNote"));
m_create->setObjectName(QStringLiteral("createCanvas"));
m_create->setDefault(true);
auto *open = new QPushButton(QStringLiteral("Open project"), this);
open->setObjectName(QStringLiteral("openProject"));
auto *import = new QPushButton(QStringLiteral("Import image"), this);
import->setObjectName(QStringLiteral("importImage"));
auto *fields = new QGridLayout;
fields->setHorizontalSpacing(16);
fields->setVerticalSpacing(8);
fields->addWidget(text(QStringLiteral("Width"), 12, QFont::Medium, QPalette::WindowText, this), 0, 0);
fields->addWidget(text(QStringLiteral("Height"), 12, QFont::Medium, QPalette::WindowText, this), 0, 3);
fields->addWidget(m_width, 1, 0);
fields->addWidget(text(QStringLiteral("px"), 12, QFont::Normal, QPalette::PlaceholderText, this), 1, 1);
fields->addWidget(text(QStringLiteral("×"), 14, QFont::Normal, QPalette::PlaceholderText, this), 1, 2);
fields->addWidget(m_height, 1, 3);
fields->addWidget(text(QStringLiteral("px"), 12, QFont::Normal, QPalette::PlaceholderText, this), 1, 4);
auto *buttons = new QHBoxLayout;
buttons->setSpacing(10);
buttons->addWidget(open);
buttons->addWidget(import);
buttons->addStretch(1);
buttons->addWidget(m_create);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(28, 28, 28, 28);
column->setSpacing(24);
auto *heading = new QVBoxLayout;
heading->setSpacing(6);
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));
column->addLayout(heading);
column->addLayout(fields);
column->addWidget(m_note);
column->addLayout(buttons);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
// Return anywhere on the sheet is the default action.
for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter})
new QShortcut(QKeySequence(key), this, this, &NewCanvasSheet::create, Qt::WidgetWithChildrenShortcut);
connect(m_create, &QPushButton::clicked, this, &NewCanvasSheet::create);
connect(open, &QPushButton::clicked, this, [onOpen = std::move(onOpen)] { onOpen(); });
connect(import, &QPushButton::clicked, this, [this] { m_session.setShowsImporter(true); });
connect(&m_session, &EditorSession::changed, this, &NewCanvasSheet::synchronize);
synchronize();
validate();
}
void NewCanvasSheet::synchronize()
{
setEnabled(!m_session.isImporting() && !m_session.showsBusy());
}
void NewCanvasSheet::validate()
{
const bool valid = CanvasDocument::validDimension(m_width->text()) && CanvasDocument::validDimension(m_height->text());
m_note->setText(valid ? QStringLiteral("Transparent canvas · sRGB") : QStringLiteral("Enter whole numbers from 1 to 30,000 pixels."));
// The palette's bright text is the theme's warning colour.
m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText);
m_create->setEnabled(valid);
}
void NewCanvasSheet::create()
{
const std::optional<int> width = CanvasDocument::validDimension(m_width->text()), height = CanvasDocument::validDimension(m_height->text());
if (width && height)
m_onCreate(*width, *height);
}
void NewCanvasSheet::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
if (!m_suggestedClipboardSize) {
m_suggestedClipboardSize = true;
if (m_session.skipsInitialClipboardCanvasSize) {
m_session.skipsInitialClipboardCanvasSize = false;
} else if (const std::optional<QSize> size = clipboardDimensions(QApplication::clipboard()->mimeData())) {
m_width->setText(QString::number(size->width()));
m_height->setText(QString::number(size->height()));
}
}
m_width->setFocus();
}
std::optional<QSize> NewCanvasSheet::clipboardDimensions(const QMimeData *clipboard)
{
for (const QString &type : {QStringLiteral("image/png"), QStringLiteral("image/tiff")}) {
QByteArray data = clipboard ? clipboard->data(type) : QByteArray();
QBuffer buffer(&data);
QImageReader reader(&buffer);
QSize size = reader.size();
// Stored on its side, a picture swaps its sides.
if (reader.transformation() & QImageIOHandler::TransformationRotate90)
size.transpose();
// In range on one side alone is no canvas.
if (CanvasDocument::validDimension(QString::number(size.width())) && CanvasDocument::validDimension(QString::number(size.height())))
return size;
}
return std::nullopt;
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "Document/EditorSession.h"
#include <QLabel>
#include <QLineEdit>
#include <QMimeData>
#include <QPushButton>
#include <QWidget>
#include <functional>
// The welcome: a new canvas, a project or an image.
class NewCanvasSheet : public QWidget {
Q_OBJECT
public:
NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent = nullptr);
// A copied picture's size, read from its header alone.
static std::optional<QSize> clipboardDimensions(const QMimeData *clipboard);
protected:
void showEvent(QShowEvent *event) override;
private:
void synchronize();
void validate();
void create();
EditorSession &m_session;
const std::function<void(int, int)> m_onCreate;
QLineEdit *const m_width;
QLineEdit *const m_height;
QLabel *const m_note;
QPushButton *const m_create;
bool m_suggestedClipboardSize = false;
};
+222
View File
@@ -0,0 +1,222 @@
#include "UI/OmarchyTheme.h"
#include "Logging.h"
#include <QApplication>
#include <QDir>
#include <QFile>
#include <QRegularExpression>
#include <QStyleFactory>
#include <cerrno>
#include <cstring>
#include <stdexcept>
#include <sys/stat.h>
namespace {
// The file's assignments: quoted values, and keys without one.
struct Assignments {
QMap<QString, QString> values;
QSet<QString> malformed;
QString value(const QString &key) const
{
if (malformed.contains(key))
throw std::runtime_error("colors.toml: " + key.toStdString() + " is no quoted string");
if (!values.contains(key))
throw std::runtime_error("colors.toml lacks " + key.toStdString());
return values.value(key);
}
};
bool isQuote(QChar character)
{
return character == QLatin1Char('"') || character == QLatin1Char('\'');
}
// A key or value with its matching quotes taken off.
QString unquoted(const QString &text)
{
const bool quoted = text.size() >= 2 && isQuote(text.front()) && text.back() == text.front();
return quoted ? text.mid(1, text.size() - 2) : text;
}
Assignments assignments(const QString &toml)
{
Assignments result;
for (const QString &each : toml.split(QLatin1Char('\n'))) {
const QString line = each.trimmed();
const qsizetype equals = line.indexOf(QLatin1Char('='));
if (line.isEmpty() || line.startsWith(QLatin1Char('#')) || line.startsWith(QLatin1Char('[')) || equals < 0)
continue;
const QString key = unquoted(line.left(equals).trimmed());
const QString rest = line.mid(equals + 1).trimmed();
const qsizetype close = rest.isEmpty() || !isQuote(rest.front()) ? -1 : rest.indexOf(rest.front(), 1);
const QString after = close < 0 ? QString() : rest.mid(close + 1).trimmed();
if (close < 0 || !(after.isEmpty() || after.startsWith(QLatin1Char('#'))))
result.malformed.insert(key);
else
result.values.insert(key, rest.mid(1, close - 1));
}
return result;
}
QColor colour(const Assignments &found, const QString &key)
{
static const QRegularExpression hex(QStringLiteral("^#[0-9a-fA-F]{6}$"));
const QString value = found.value(key);
if (!hex.match(value).hasMatch())
throw std::runtime_error("colors.toml: " + key.toStdString() + " is no #rrggbb colour: " + value.toStdString());
return QColor(value);
}
}
OmarchyColors OmarchyColors::parse(const QString &toml)
{
// Flat `key = "value"` lines; recognised keys well formed.
const Assignments values = assignments(toml);
const QString mode = values.value(QStringLiteral("mode"));
if (mode != QLatin1String("dark") && mode != QLatin1String("light"))
throw std::runtime_error("colors.toml: mode must be dark or light, not " + mode.toStdString());
return {.dark = mode == QLatin1String("dark"),
.accent = colour(values, QStringLiteral("accent")),
.selection = colour(values, QStringLiteral("selection")),
.muted = colour(values, QStringLiteral("muted")),
.background = colour(values, QStringLiteral("background")),
.darkBackground = colour(values, QStringLiteral("dark_background")),
.darkerBackground = colour(values, QStringLiteral("darker_background")),
.lighterBackground = colour(values, QStringLiteral("lighter_background")),
.foreground = colour(values, QStringLiteral("foreground")),
.darkForeground = colour(values, QStringLiteral("dark_foreground")),
.lightForeground = colour(values, QStringLiteral("light_foreground")),
.brightForeground = colour(values, QStringLiteral("bright_foreground")),
.red = colour(values, QStringLiteral("red")),
.warning = colour(values, values.values.contains(QStringLiteral("orange")) || values.malformed.contains(QStringLiteral("orange"))
? QStringLiteral("orange")
: QStringLiteral("yellow"))};
}
OmarchyColors OmarchyColors::builtInDark()
{
// Swift's grays: the window at 0.14, fields at 0.105.
return {.dark = true,
.accent = QColor(0x0a, 0x84, 0xff),
.selection = QColor(0x3a, 0x3a, 0x3a),
.muted = QColor(0x4a, 0x4a, 0x4a),
.background = QColor(0x24, 0x24, 0x24),
.darkBackground = QColor(0x1e, 0x1e, 0x1e),
.darkerBackground = QColor(0x1b, 0x1b, 0x1b),
.lighterBackground = QColor(0x30, 0x30, 0x30),
.foreground = QColor(0xe5, 0xe5, 0xe5),
.darkForeground = QColor(0x8a, 0x8a, 0x8a),
.lightForeground = QColor(0xcc, 0xcc, 0xcc),
.brightForeground = QColor(0xff, 0xff, 0xff),
.red = QColor(0xff, 0x45, 0x3a),
.warning = QColor(0xff, 0x9f, 0x0a)};
}
QPalette OmarchyColors::palette() const
{
QPalette result;
result.setColor(QPalette::Window, background);
result.setColor(QPalette::WindowText, foreground);
result.setColor(QPalette::Base, darkerBackground);
result.setColor(QPalette::AlternateBase, darkBackground);
result.setColor(QPalette::Text, foreground);
result.setColor(QPalette::Button, lighterBackground);
result.setColor(QPalette::ButtonText, foreground);
result.setColor(QPalette::Highlight, accent);
result.setColor(QPalette::HighlightedText, background);
result.setColor(QPalette::Link, accent);
result.setColor(QPalette::PlaceholderText, darkForeground);
result.setColor(QPalette::BrightText, warning);
result.setColor(QPalette::Mid, muted);
result.setColor(QPalette::Dark, darkerBackground);
result.setColor(QPalette::Light, lighterBackground);
result.setColor(QPalette::ToolTipBase, darkBackground);
result.setColor(QPalette::ToolTipText, foreground);
for (const QPalette::ColorRole role : {QPalette::WindowText, QPalette::Text, QPalette::ButtonText})
result.setColor(QPalette::Disabled, role, muted);
return result;
}
QString OmarchyTheme::defaultDirectory()
{
return QDir::homePath() + QStringLiteral("/.local/state/omarchy/current");
}
OmarchyTheme::OmarchyTheme(const QString &directory, QObject *parent)
: QObject(parent), m_directory(directory), m_colors(OmarchyColors::builtInDark()), m_watcher(this), m_settle(this)
{
// Fusion follows the palette on every desktop.
QApplication::setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
// A switch removes, moves, writes: one load after the last.
m_settle.setSingleShot(true);
m_settle.setInterval(100);
connect(&m_settle, &QTimer::timeout, this, &OmarchyTheme::apply);
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, &m_settle, qOverload<>(&QTimer::start));
connect(&m_watcher, &QFileSystemWatcher::fileChanged, &m_settle, qOverload<>(&QTimer::start));
// The built-in dark stands until a file is read whole.
QApplication::setPalette(m_colors.palette());
apply();
}
void OmarchyTheme::apply()
{
const QString path = m_directory + QStringLiteral("/theme/colors.toml");
watch();
QFile file(path);
OmarchyColors loaded = OmarchyColors::builtInDark();
// Only a path leading nowhere is absent; barred keeps.
struct stat status;
if (::stat(QFile::encodeName(path).constData(), &status) != 0 && (errno == ENOENT || errno == ENOTDIR)) {
qCInfo(lcApp).noquote() << "no Omarchy theme at" << path << "- built-in dark";
} else if (!file.open(QIODevice::ReadOnly)) {
qCWarning(lcApp).noquote() << "Omarchy theme unreadable, keeping the palette:" << file.errorString() << path;
return;
} else {
// A read cut short must not pass for the file.
const QByteArray bytes = file.readAll();
if (file.error() != QFileDevice::NoError || bytes.size() != file.size()) {
qCWarning(lcApp).noquote() << "Omarchy theme unreadable, keeping the palette: read" << bytes.size() << "of" << file.size() << "bytes" << path;
return;
}
try {
loaded = OmarchyColors::parse(QString::fromUtf8(bytes));
} catch (const std::runtime_error &error) {
qCWarning(lcApp).noquote() << "Omarchy theme unreadable, keeping the palette:" << error.what();
return;
}
QFile name(m_directory + QStringLiteral("/theme.name"));
const QString called = name.open(QIODevice::ReadOnly) ? QString::fromUtf8(name.readAll()).trimmed() : QStringLiteral("(unnamed)");
qCInfo(lcApp).noquote() << "Omarchy theme" << called << (loaded.dark ? "dark" : "light") << "from" << path;
}
if (loaded == m_colors)
return;
m_colors = loaded;
QApplication::setPalette(m_colors.palette());
}
void OmarchyTheme::watch()
{
// Removed paths leave the watcher: add them back each load.
QStringList wanted{m_directory, m_directory + QStringLiteral("/theme"), m_directory + QStringLiteral("/theme/colors.toml")};
// A file's watch follows its inode: renew it each load.
if (m_watcher.files().contains(wanted.last()))
m_watcher.removePath(wanted.last());
for (const QString &path : wanted) {
if (!m_watcher.files().contains(path) && !m_watcher.directories().contains(path))
m_watcher.addPath(path);
}
// Missing or barred, the state directory's nearest watchable ancestor tells.
QString ancestor = m_directory;
while (!m_watcher.directories().contains(m_directory) && ancestor.contains(QLatin1Char('/'))) {
ancestor = ancestor.section(QLatin1Char('/'), 0, -2);
const QString candidate = ancestor.isEmpty() ? QStringLiteral("/") : ancestor;
if (m_watcher.directories().contains(candidate) || m_watcher.addPath(candidate)) {
wanted.prepend(candidate);
break;
}
}
for (const QString &stale : m_watcher.directories()) {
if (!wanted.contains(stale))
m_watcher.removePath(stale);
}
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <QColor>
#include <QFileSystemWatcher>
#include <QObject>
#include <QPalette>
#include <QTimer>
// Omarchy's colors.toml, read into the colours the chrome uses.
struct OmarchyColors {
bool dark;
QColor accent;
QColor selection;
QColor muted;
QColor background;
QColor darkBackground;
QColor darkerBackground;
QColor lighterBackground;
QColor foreground;
QColor darkForeground;
QColor lightForeground;
QColor brightForeground;
QColor red;
// `orange` where the theme has it, else `yellow`.
QColor warning;
// Throws std::runtime_error naming the missing or malformed key.
static OmarchyColors parse(const QString &toml);
// The macOS look, for a desktop without Omarchy.
static OmarchyColors builtInDark();
QPalette palette() const;
friend bool operator==(const OmarchyColors &, const OmarchyColors &) = default;
};
// Applies the current theme to the application and follows switches.
class OmarchyTheme : public QObject {
Q_OBJECT
public:
static QString defaultDirectory();
explicit OmarchyTheme(const QString &directory = defaultDirectory(), QObject *parent = nullptr);
const OmarchyColors &colors() const { return m_colors; }
void apply();
private:
void watch();
const QString m_directory;
OmarchyColors m_colors;
QFileSystemWatcher m_watcher;
QTimer m_settle;
};
+282
View File
@@ -0,0 +1,282 @@
#include "UI/ProjectTabs.h"
#include "IO/ImageFileDrop.h"
#include <QApplication>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QEvent>
#include <QHBoxLayout>
#include <QPainter>
#include <QScrollBar>
namespace {
QFont tabFont(bool active)
{
QFont font;
font.setPixelSize(12);
font.setWeight(active ? QFont::DemiBold : QFont::Medium);
return font;
}
}
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent)
: QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new QToolButton(this)), m_close(new QToolButton(this))
{
// 28 high: the strip's 34 less its margins.
m_select->setObjectName(QStringLiteral("selectTab"));
m_select->setAutoRaise(true);
m_select->setFocusPolicy(Qt::NoFocus);
m_select->setMinimumWidth(35);
m_select->setMaximumWidth(155);
m_close->setObjectName(QStringLiteral("closeTab"));
m_close->setAutoRaise(true);
m_close->setFocusPolicy(Qt::NoFocus);
m_close->setText(QStringLiteral("×"));
m_close->setFixedSize(16, 28);
auto *row = new QHBoxLayout(this);
row->setContentsMargins(11, 0, 5, 0);
row->setSpacing(0);
row->addWidget(m_select);
row->addWidget(m_close);
setAcceptDrops(true);
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.
connect(&this->tab->session, &EditorSession::changed, this, &ProjectTabButton::synchronize);
synchronize();
}
void ProjectTabButton::synchronize()
{
const bool active = m_workspace.selectedID() == tab->id;
// Swift's dot for unsaved changes stands before the title.
m_select->setText((tab->session.isModified() ? QStringLiteral("● ") : QString()) + tab->title());
m_select->setFont(tabFont(active));
m_select->setToolTip(tab->title());
m_select->setEnabled(m_workspace.canSwitch() || active);
m_close->setToolTip(QStringLiteral("Close %1").arg(tab->title()));
m_close->setAccessibleName(QStringLiteral("Close %1").arg(tab->title()));
m_close->setEnabled(m_workspace.canSwitch());
// The capsule repaints with the buttons it holds.
setProperty("active", active);
}
// Another tab's layer, unless Alt duplicates it, or files.
bool ProjectTabButton::acceptsDrop(const QMimeData &data) const
{
const bool layer = data.hasFormat(ProjectWorkspace::layerType);
if ((layer && QApplication::keyboardModifiers().testFlag(Qt::AltModifier)) || !(layer || ImageFileDrop::holdsImages(data)))
return false;
return m_workspace.canSwitch() && m_workspace.canReceiveDrag(data, tab->id);
}
void ProjectTabButton::setTargeted(bool targeted)
{
m_targeted = targeted;
m_select->setToolTip(targeted ? QStringLiteral("Add to %1").arg(tab->title()) : tab->title());
QWidget::update();
}
void ProjectTabButton::dragEnterEvent(QDragEnterEvent *event)
{
if (!acceptsDrop(*event->mimeData()))
return;
event->setDropAction(Qt::CopyAction);
event->accept();
setTargeted(true);
}
void ProjectTabButton::dragMoveEvent(QDragMoveEvent *event)
{
if (!acceptsDrop(*event->mimeData())) {
event->ignore();
return;
}
event->setDropAction(Qt::CopyAction);
event->accept();
}
void ProjectTabButton::dragLeaveEvent(QDragLeaveEvent *)
{
setTargeted(false);
}
void ProjectTabButton::dropEvent(QDropEvent *event)
{
setTargeted(false);
if (!acceptsDrop(*event->mimeData()))
return;
event->setDropAction(Qt::CopyAction);
event->accept();
m_workspace.receiveProviders(*event->mimeData(), tab->id);
}
bool ProjectTabButton::isActive() const
{
return property("active").toBool();
}
void ProjectTabButton::paintEvent(QPaintEvent *)
{
// A capsule: brighter under the front tab.
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const bool active = property("active").toBool();
QColor fill = palette().color(QPalette::WindowText), edge = fill;
fill.setAlphaF(active ? 0.12 : 0.035);
edge.setAlphaF(active ? 0.22 : 0.08);
// A drop target takes the accent, filled and rimmed.
if (m_targeted) {
fill = palette().color(QPalette::Highlight);
fill.setAlphaF(0.3);
edge = palette().color(QPalette::Highlight);
}
painter.setPen(QPen(edge, m_targeted ? 2 : 1));
painter.setBrush(fill);
const double inset = m_targeted ? 1 : 0.5;
painter.drawRoundedRect(QRectF(inset, inset, width() - 2 * inset, height() - 2 * inset), 14, 14);
}
NewCanvasButton::NewCanvasButton(ProjectWorkspace &workspace, QWidget *parent) : QToolButton(parent), m_workspace(workspace)
{
setObjectName(QStringLiteral("newCanvasButton"));
setAcceptDrops(true);
}
// Any tab's layer or files, for a fresh tab.
bool NewCanvasButton::acceptsDrop(const QMimeData &data) const
{
const bool layer = data.hasFormat(ProjectWorkspace::layerType);
if ((layer && QApplication::keyboardModifiers().testFlag(Qt::AltModifier)) || !(layer || ImageFileDrop::holdsImages(data)))
return false;
return m_workspace.canSwitch() && m_workspace.canReceiveDrag(data, std::nullopt);
}
void NewCanvasButton::setTargeted(bool targeted)
{
m_targeted = targeted;
setToolTip(targeted ? QStringLiteral("Open in a new project tab") : defaultAction()->toolTip());
update();
}
void NewCanvasButton::paintEvent(QPaintEvent *event)
{
QToolButton::paintEvent(event);
if (!m_targeted)
return;
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(palette().color(QPalette::Highlight), 2));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 6, 6);
}
void NewCanvasButton::dragEnterEvent(QDragEnterEvent *event)
{
if (!acceptsDrop(*event->mimeData()))
return;
event->setDropAction(Qt::CopyAction);
event->accept();
setTargeted(true);
}
void NewCanvasButton::dragMoveEvent(QDragMoveEvent *event)
{
if (!acceptsDrop(*event->mimeData())) {
event->ignore();
return;
}
event->setDropAction(Qt::CopyAction);
event->accept();
}
void NewCanvasButton::dragLeaveEvent(QDragLeaveEvent *)
{
setTargeted(false);
}
void NewCanvasButton::dropEvent(QDropEvent *event)
{
setTargeted(false);
if (!acceptsDrop(*event->mimeData()))
return;
event->setDropAction(Qt::CopyAction);
event->accept();
m_workspace.receiveProviders(*event->mimeData(), std::nullopt);
}
ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent)
: QScrollArea(parent), m_workspace(workspace), m_row(new QWidget(this))
{
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();
}
QList<ProjectTabButton *> ProjectTabStrip::buttons() const
{
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;
}
void ProjectTabStrip::synchronize()
{
// 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();
}
bool ProjectTabStrip::eventFilter(QObject *watched, QEvent *event)
{
// A title changed size: the row takes its new width.
if (event->type() == QEvent::LayoutRequest) {
m_row->resize(m_row->sizeHint());
showFront();
}
return QScrollArea::eventFilter(watched, event);
}
// The front tab scrolls into view.
void ProjectTabStrip::showFront()
{
for (ProjectTabButton *button : buttons()) {
if (button->tab->id == m_workspace.selectedID())
ensureWidgetVisible(button, 0, 0);
}
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include "Document/ProjectWorkspace.h"
#include <QScrollArea>
#include <QToolButton>
#include <QWidget>
// 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);
const std::shared_ptr<ProjectTab> tab;
// What the session and the workspace hold, shown.
void synchronize();
bool isActive() const;
protected:
void paintEvent(QPaintEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dragLeaveEvent(QDragLeaveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private:
bool acceptsDrop(const QMimeData &data) const;
void setTargeted(bool targeted);
ProjectWorkspace &m_workspace;
QToolButton *const m_select;
QToolButton *const m_close;
bool m_targeted = false;
};
// Swift's NewProjectDropTarget: the New canvas button takes drops.
class NewCanvasButton : public QToolButton {
Q_OBJECT
public:
explicit NewCanvasButton(ProjectWorkspace &workspace, QWidget *parent = nullptr);
protected:
void paintEvent(QPaintEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dragLeaveEvent(QDragLeaveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private:
bool acceptsDrop(const QMimeData &data) const;
void setTargeted(bool targeted);
ProjectWorkspace &m_workspace;
bool m_targeted = false;
};
// The row of tabs, one a project, scrolling when long.
class ProjectTabStrip : public QScrollArea {
Q_OBJECT
public:
explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr);
QList<ProjectTabButton *> buttons() const;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void synchronize();
void showFront();
ProjectWorkspace &m_workspace;
QWidget *const m_row;
};
+112
View File
@@ -0,0 +1,112 @@
#include "UI/ProjectWorkspaceView.h"
#include <QCloseEvent>
namespace {
QAction *action(QToolBar *bar, const QString &text, const QString &help, const QString &name)
{
QAction *made = bar->addAction(text);
made->setToolTip(help);
made->setObjectName(name);
return made;
}
}
ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget *parent)
: QMainWindow(parent), m_workspace(workspace), m_toolbar(new QToolBar(this)), m_tabs(new ProjectTabStrip(workspace, this))
{
setMinimumSize(800, 520);
resize(1180, 780);
m_toolbar->setObjectName(QStringLiteral("toolbar"));
m_toolbar->setMovable(false);
m_toolbar->setFloatable(false);
// Its button takes drops for a fresh tab.
m_newCanvas = new QAction(QStringLiteral("+"), this);
m_newCanvas->setToolTip(QStringLiteral("New canvas (Ctrl+N) · Drop images here for new tabs"));
m_newCanvas->setObjectName(QStringLiteral("newCanvasToolbar"));
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_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"));
m_zoomOut = action(m_toolbar, QStringLiteral("−"), QStringLiteral("Zoom out (Ctrl+−)"), QStringLiteral("zoomOut"));
addToolBar(Qt::TopToolBarArea, m_toolbar);
new CompositorMenus(m_workspace, *menuBar(), *this);
m_workspace.window = this;
connect(m_newCanvas, &QAction::triggered, this, [this] { m_workspace.current().controller.newCanvas(); });
connect(m_fit, &QAction::triggered, this, [this] { m_workspace.current().session.fit(); });
connect(m_actualPixels, &QAction::triggered, this, [this] { m_workspace.current().session.zoom(1); });
connect(m_zoomIn, &QAction::triggered, this, [this] {
EditorSession &session = m_workspace.current().session;
session.zoom(session.viewport.zoom() * 1.25);
});
connect(m_zoomOut, &QAction::triggered, this, [this] {
EditorSession &session = m_workspace.current().session;
session.zoom(session.viewport.zoom() / 1.25);
});
connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectWorkspaceView::synchronize);
synchronize();
}
void ProjectWorkspaceView::watchFront()
{
// Every tab's buttons dim while the front tab is busy.
disconnect(m_sessionWatch);
m_sessionWatch = connect(&m_workspace.current().session, &EditorSession::changed, this, [this] {
synchronizeSession();
for (ProjectTabButton *button : m_tabs->buttons())
button->synchronize();
});
}
void ProjectWorkspaceView::synchronize()
{
const std::shared_ptr<ProjectTab> front = m_workspace.tab(m_workspace.current().id);
// A new front tab gets a fresh editor.
if (m_shownTab != front) {
front->controller.window = this;
delete m_content;
// Only now may the old tab, and its session, end.
m_shownTab = front;
m_content = new ContentView(front->session, &front->controller, this);
m_content->setWorkspace(&m_workspace);
// The window owns the minimum; the editor may be shorter.
m_content->setMinimumSize(0, 0);
setCentralWidget(m_content);
// A late central widget shows only later: show now.
m_content->show();
watchFront();
synchronizeSession();
}
m_content->setEnabled(!m_workspace.isManaging());
}
void ProjectWorkspaceView::synchronizeSession()
{
const EditorSession &session = m_workspace.current().session;
const bool drawn = session.document().has_value();
m_newCanvas->setEnabled(!session.isImporting() && !session.showsBusy() && !session.levels());
for (QAction *zoom : {m_fit, m_actualPixels, m_zoomIn, m_zoomOut})
zoom->setEnabled(drawn);
// The front tab's title, as its strip button shows it.
setWindowTitle(m_workspace.current().title() + QStringLiteral("[*]"));
setWindowModified(session.isModified());
setWindowFilePath(session.projectPath().value_or(QString()));
}
void ProjectWorkspaceView::closeEvent(QCloseEvent *event)
{
// The workspace asks first, then closes us with its mark.
if (property("closeConfirmed").toBool()) {
setProperty("closeConfirmed", QVariant());
event->accept();
return;
}
event->ignore();
m_workspace.closeWindow(this);
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "ContentView.h"
#include "Document/ProjectWorkspace.h"
#include "UI/CompositorMenus.h"
#include "UI/ProjectTabs.h"
#include <QMainWindow>
#include <QToolBar>
// The window: the front tab's editor under the tab strip.
class ProjectWorkspaceView : public QMainWindow {
Q_OBJECT
public:
explicit ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget *parent = nullptr);
ContentView *content() const { return m_content; }
ProjectTabStrip *tabs() const { return m_tabs; }
protected:
void closeEvent(QCloseEvent *event) override;
private:
void synchronize();
void synchronizeSession();
void watchFront();
ProjectWorkspace &m_workspace;
QToolBar *const m_toolbar;
ProjectTabStrip *const m_tabs;
QAction *m_newCanvas = nullptr;
QAction *m_fit = nullptr;
QAction *m_actualPixels = nullptr;
QAction *m_zoomIn = nullptr;
QAction *m_zoomOut = nullptr;
ContentView *m_content = nullptr;
// Held until its editor goes, as Swift's views hold sessions.
std::shared_ptr<ProjectTab> m_shownTab;
QMetaObject::Connection m_sessionWatch;
};
+95
View File
@@ -0,0 +1,95 @@
#include "UI/ShapeControls.h"
#include "Document/EditorSession.h"
#include "UI/ColorPaletteControls.h"
#include "UI/LassoControls.h"
#include <QButtonGroup>
#include <QHBoxLayout>
#include <QLabel>
#include <QSignalBlocker>
#include <QSlider>
#include <QToolButton>
#include <cmath>
ShapeControls::ShapeControls(EditorSession &session, QWidget *parent)
: ToolHeaderBar(QStringLiteral("Shape"), parent), m_session(session), m_kinds(new QButtonGroup(this)),
m_kindButtons{kind(ShapeKind::rectangle), kind(ShapeKind::ellipse), kind(ShapeKind::line)},
m_widthSlider(new QSlider(Qt::Horizontal, this)),
m_widthField(new SelectionAmountField(session, 1, 5000, [this] { return m_session.shapeLineWidth(); },
[this](double width) { m_session.setShapeLineWidth(width); }, this)),
m_width(amount(QStringLiteral("Width"), m_widthSlider, m_widthField)), m_radiusSlider(new QSlider(Qt::Horizontal, this)),
m_radiusField(new SelectionAmountField(session, 0, 5000, [this] { return m_session.shapeCornerRadius(); },
[this](double radius) { m_session.setShapeCornerRadius(radius); }, this)),
m_radius(amount(QStringLiteral("Radius"), m_radiusSlider, m_radiusField)),
m_fill(new SwatchButton([&session] { return session.foregroundColor(); }, 3, 0, 0.5, this))
{
m_widthSlider->setObjectName(QStringLiteral("shapeWidthSlider"));
m_widthSlider->setRange(1, 100);
connect(m_widthSlider, &QSlider::valueChanged, this, [this](int value) { m_session.setShapeLineWidth(value); });
m_widthField->setObjectName(QStringLiteral("shapeWidth"));
m_width->setObjectName(QStringLiteral("shapeWidthGroup"));
m_radiusSlider->setObjectName(QStringLiteral("shapeRadiusSlider"));
m_radiusSlider->setRange(0, 200);
connect(m_radiusSlider, &QSlider::valueChanged, this, [this](int value) { m_session.setShapeCornerRadius(value); });
m_radiusField->setObjectName(QStringLiteral("shapeRadius"));
m_radius->setObjectName(QStringLiteral("shapeRadiusGroup"));
m_radius->setToolTip(QStringLiteral("Round the rectangle's corners by this many pixels; 0 keeps them square"));
m_fill->setObjectName(QStringLiteral("shapeFill"));
m_fill->setFixedSize(36, 18);
m_fill->setToolTip(QStringLiteral("Shapes fill with the foreground color; click to change it"));
connect(m_fill, &QAbstractButton::clicked, this, [this] { m_session.openColorPicker(false); });
auto *fill = new QWidget(this);
auto *fillRow = new QHBoxLayout(fill);
fillRow->setContentsMargins(0, 0, 0, 0);
fillRow->setSpacing(6);
fillRow->addWidget(new QLabel(QStringLiteral("Fill"), fill));
fillRow->addWidget(m_fill);
for (QWidget *widget : std::initializer_list<QWidget *>{m_kindButtons[0], m_kindButtons[1], m_kindButtons[2], m_width, m_radius, fill})
row->insertWidget(row->count() - 1, widget);
connect(&m_session, &EditorSession::changed, this, &ShapeControls::synchronize);
synchronize();
}
// A segment of Swift's picker; choosing drops a draft first.
QToolButton *ShapeControls::kind(ShapeKind value)
{
auto *button = new QToolButton(this);
button->setObjectName(QStringLiteral("shape") + rawValue(value));
button->setText(rawValue(value));
button->setCheckable(true);
button->setToolTip(QStringLiteral("Shift-U (or Tab) steps through Rectangle, Ellipse and Line"));
m_kinds->addButton(button);
connect(button, &QToolButton::clicked, this, [this, value] {
m_session.cancelShape();
m_session.setShapeKind(value);
});
return button;
}
QWidget *ShapeControls::amount(const QString &name, QSlider *slider, SelectionAmountField *field)
{
auto *group = new QWidget(this);
auto *layout = new QHBoxLayout(group);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(6);
slider->setFixedWidth(100);
field->setFixedWidth(48);
for (QWidget *widget : std::initializer_list<QWidget *>{new QLabel(name, group), slider, field, new QLabel(QStringLiteral("px"), group)})
layout->addWidget(widget);
return group;
}
void ShapeControls::synchronize()
{
const ShapeKind kind = m_session.shapeKind();
m_kindButtons[size_t(kind)]->setChecked(true);
m_width->setVisible(kind == ShapeKind::line);
m_radius->setVisible(kind == ShapeKind::rectangle);
// The sliders show the session's number without writing back.
const QSignalBlocker widthBlocker(m_widthSlider), radiusBlocker(m_radiusSlider);
m_widthSlider->setValue(int(std::lround(std::min(100.0, m_session.shapeLineWidth()))));
m_radiusSlider->setValue(int(std::lround(std::min(200.0, m_session.shapeCornerRadius()))));
m_widthField->sync(int(std::lround(m_session.shapeLineWidth())));
m_radiusField->sync(int(std::lround(m_session.shapeCornerRadius())));
m_fill->update();
setEnabled(!m_session.showsBusy() && m_session.document().has_value());
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "Document/ShapeTool.h"
#include "UI/ToolHeaderStyle.h"
#include <array>
class EditorSession;
class QButtonGroup;
class QSlider;
class QToolButton;
class SelectionAmountField;
class SwatchButton;
// Swift's ShapeControls: the kind, a line's width, a rectangle's radius.
class ShapeControls : public ToolHeaderBar {
Q_OBJECT
public:
explicit ShapeControls(EditorSession &session, QWidget *parent = nullptr);
private:
QToolButton *kind(ShapeKind value);
// A label, slider, field and "px", as Swift's HStack.
QWidget *amount(const QString &name, QSlider *slider, SelectionAmountField *field);
void synchronize();
EditorSession &m_session;
QButtonGroup *const m_kinds;
const std::array<QToolButton *, 3> m_kindButtons;
QSlider *const m_widthSlider;
SelectionAmountField *const m_widthField;
QWidget *const m_width;
QSlider *const m_radiusSlider;
SelectionAmountField *const m_radiusField;
QWidget *const m_radius;
// Shapes fill with the foreground colour.
SwatchButton *const m_fill;
};
+73
View File
@@ -0,0 +1,73 @@
#include "UI/SliderSnap.h"
#include <QApplication>
#include <QMouseEvent>
#include <QSlider>
#include <QStyle>
#include <QStyleOptionSlider>
#include <algorithm>
namespace {
// Swift's snapValue: the value under a press on the track.
class Snap : public QObject {
public:
using QObject::QObject;
protected:
bool eventFilter(QObject *watched, QEvent *event) override
{
auto *slider = qobject_cast<QSlider *>(watched);
if (!slider || event->type() != QEvent::MouseButtonPress)
return false;
const auto *press = static_cast<QMouseEvent *>(event);
// Qt refuses a press made with another button held.
if (press->buttons() != Qt::LeftButton || slider->orientation() != Qt::Horizontal || !slider->isEnabled()
|| slider->maximum() <= slider->minimum())
return false;
// QSlider's own style option, which it keeps protected.
QStyleOptionSlider option;
option.initFrom(slider);
option.orientation = Qt::Horizontal;
option.state |= QStyle::State_Horizontal;
option.minimum = slider->minimum();
option.maximum = slider->maximum();
option.tickPosition = slider->tickPosition();
option.tickInterval = slider->tickInterval();
option.upsideDown = slider->invertedAppearance() != (option.direction == Qt::RightToLeft);
option.direction = Qt::LeftToRight;
option.sliderPosition = slider->sliderPosition();
option.sliderValue = slider->value();
option.singleStep = slider->singleStep();
option.pageStep = slider->pageStep();
const QRect groove = slider->style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderGroove, slider);
const QRect knob = slider->style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderHandle, slider);
const QPoint point = press->position().toPoint();
// The knob itself drags from where it is.
if (knob.contains(point) || groove.width() <= knob.width())
return false;
// Down first, as Swift's editing begins before the value.
slider->setSliderDown(true);
slider->setValue(QStyle::sliderValueFromPosition(slider->minimum(), slider->maximum(), point.x() - groove.x() - knob.width() / 2,
groove.width() - knob.width(), option.upsideDown));
// A rounding owner may move the knob: press there.
option.sliderPosition = slider->sliderPosition();
option.sliderValue = slider->value();
const QRect moved = slider->style()->subControlRect(QStyle::CC_Slider, &option, QStyle::SC_SliderHandle, slider);
const QPoint inside(std::clamp(point.x(), moved.left(), moved.right()), std::clamp(point.y(), moved.top(), moved.bottom()));
const QPointF shift = inside - point;
QMouseEvent forwarded(QEvent::MouseButtonPress, inside, press->scenePosition() + shift, press->globalPosition() + shift, Qt::LeftButton,
Qt::LeftButton, press->modifiers());
QCoreApplication::sendEvent(slider, &forwarded);
return true;
}
};
}
void SliderSnap::install()
{
// Once, as Swift's lazy static swaps the method once.
static const bool installed = [] {
qApp->installEventFilter(new Snap(qApp));
return true;
}();
(void)installed;
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
// Swift's SliderSnap: a press on the track sets the value.
namespace SliderSnap {
// Every slider of the app, from then on.
void install();
}
+29
View File
@@ -0,0 +1,29 @@
#include "UI/ToolHeaderStyle.h"
#include <QApplication>
QFont ToolHeaderStyle::titleFont()
{
QFont font = QApplication::font();
font.setPixelSize(13);
font.setWeight(QFont::DemiBold);
return font;
}
QFont ToolHeaderStyle::controlFont()
{
QFont font = QApplication::font();
font.setPixelSize(12);
return font;
}
ToolHeaderBar::ToolHeaderBar(const QString &text, QWidget *parent)
: QWidget(parent), title(new QLabel(text, this)), row(new QHBoxLayout(this))
{
setFixedHeight(ToolHeaderStyle::height);
setFont(ToolHeaderStyle::controlFont());
title->setFont(ToolHeaderStyle::titleFont());
row->setContentsMargins(18, 0, 18, 0);
row->setSpacing(12);
row->addWidget(title);
row->addStretch(1);
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QWidget>
// Shared metrics: switching tools never moves the canvas.
namespace ToolHeaderStyle {
inline constexpr int height = 42;
QFont titleFont();
QFont controlFont();
}
// A tool's bar: its title, then its controls' row.
class ToolHeaderBar : public QWidget {
// findChild needs the meta-object; Qt 6.11 checks at compile time.
Q_OBJECT
public:
explicit ToolHeaderBar(const QString &title, QWidget *parent = nullptr);
QLabel *const title;
// Controls go before the stretch that ends the row.
QHBoxLayout *const row;
};
+229
View File
@@ -0,0 +1,229 @@
#include "UI/ToolIcons.h"
#include "Rendering/EyedropperIcon.h"
#include "Rendering/SelectionIcons.h"
#include <QFont>
#include <QPainterPath>
#include <array>
#include <stdexcept>
namespace {
// Swift's GradientToolIcon: a Floyd-Steinberg fade, left to right.
std::array<std::array<bool, 16>, 16> ditheredFade()
{
std::array<std::array<double, 16>, 16> ramp;
for (int y = 0; y < 16; ++y) {
for (int x = 0; x < 16; ++x)
ramp[y][x] = x / 15.0;
}
std::array<std::array<bool, 16>, 16> result{};
for (int y = 0; y < 16; ++y) {
for (int x = 0; x < 16; ++x) {
const bool on = ramp[y][x] >= 0.5;
result[y][x] = on;
const double error = ramp[y][x] - (on ? 1 : 0);
if (x + 1 < 16)
ramp[y][x + 1] += error * 7 / 16;
if (y + 1 == 16)
continue;
if (x > 0)
ramp[y + 1][x - 1] += error * 3 / 16;
ramp[y + 1][x] += error * 5 / 16;
if (x + 1 < 16)
ramp[y + 1][x + 1] += error / 16;
}
}
return result;
}
void gradient(QPainter &painter)
{
static const std::array<std::array<bool, 16>, 16> pattern = ditheredFade();
const QRectF frame(1, 1, 16, 16);
QPainterPath shape;
shape.addRoundedRect(frame, 3.5, 3.5);
// Swift clips dots and frame alike: half the frame shows.
painter.save();
painter.setClipPath(shape, Qt::IntersectClip);
// One path, as in Swift: dots filled apiece leave seams.
QPainterPath dots;
for (int y = 0; y < 16; ++y) {
for (int x = 0; x < 16; ++x) {
if (pattern[y][x])
dots.addRect(QRectF(frame.left() + x, frame.top() + y, 1, 1));
}
}
painter.fillPath(dots, painter.pen().color());
QPen pen = painter.pen();
pen.setWidthF(1.4);
painter.setPen(pen);
painter.drawPath(shape);
painter.restore();
}
// Swift's CloneStampToolIcon: handle, neck, body and pad, filled.
void cloneStamp(QPainter &painter)
{
const double w = 18, h = 18;
QPainterPath stamp;
stamp.addEllipse(QRectF(w * 0.33, h * 0.02, w * 0.34, h * 0.30));
stamp.addRect(QRectF(w * 0.43, h * 0.28, w * 0.14, h * 0.28));
stamp.addRoundedRect(QRectF(w * 0.12, h * 0.54, w * 0.76, h * 0.22), w * 0.08, w * 0.08);
stamp.addRect(QRectF(w * 0.06, h * 0.82, w * 0.88, h * 0.12));
stamp.setFillRule(Qt::WindingFill);
painter.fillPath(stamp, painter.pen().color());
}
void move(QPainter &painter)
{
painter.drawLine(QPointF(3, 3), QPointF(15, 15));
painter.drawPolyline(QPolygonF{QPointF(3, 8.5), QPointF(3, 3), QPointF(8.5, 3)});
painter.drawPolyline(QPolygonF{QPointF(15, 9.5), QPointF(15, 15), QPointF(9.5, 15)});
}
void marquee(QPainter &painter, ToolIconKind kind)
{
SelectionIcons::paint(painter, kind == ToolIconKind::ellipse ? SelectionIcon::ellipseMarquee : SelectionIcon::rectangleMarquee);
}
void lasso(QPainter &painter, ToolIconKind kind)
{
SelectionIcons::paint(painter, kind == ToolIconKind::polygonal ? SelectionIcon::polygonalLasso : SelectionIcon::freehandLasso);
}
void star(QPainter &painter, QPointF centre, double reach)
{
painter.drawLine(centre - QPointF(reach, 0), centre + QPointF(reach, 0));
painter.drawLine(centre - QPointF(0, reach), centre + QPointF(0, reach));
}
void wand(QPainter &painter)
{
painter.drawLine(QPointF(2.5, 15.5), QPointF(10.5, 7.5));
star(painter, QPointF(13, 5), 2.4);
star(painter, QPointF(5, 4.5), 1.5);
star(painter, QPointF(14, 12.5), 1.5);
}
void crop(QPainter &painter)
{
painter.drawPolyline(QPolygonF{QPointF(5, 1.5), QPointF(5, 13), QPointF(16.5, 13)});
painter.drawPolyline(QPolygonF{QPointF(1.5, 5), QPointF(13, 5), QPointF(13, 16.5)});
}
void brush(QPainter &painter)
{
// A slanted handle over a pointed tip.
painter.drawLine(QPointF(15.5, 2.5), QPointF(8.5, 9.5));
QPainterPath tip(QPointF(8.8, 8));
tip.lineTo(QPointF(10.2, 9.4));
tip.cubicTo(QPointF(8.6, 12.2), QPointF(6, 14.6), QPointF(2.6, 15.4));
tip.cubicTo(QPointF(3.4, 12), QPointF(5.8, 9.4), QPointF(8.8, 8));
painter.fillPath(tip, painter.pen().color());
}
// SF Symbols' eraser: a tilted block, its rubber end apart.
void eraser(QPainter &painter)
{
painter.save();
painter.translate(9.5, 8);
painter.rotate(-45);
painter.drawRoundedRect(QRectF(-7, -3.5, 14, 7), 1.5, 1.5);
painter.drawLine(QPointF(-2, -3.5), QPointF(-2, 3.5));
painter.restore();
painter.drawLine(QPointF(8, 16), QPointF(16, 16));
}
void spotHealing(QPainter &painter)
{
// A plaster across the square, its pad in the middle.
painter.save();
painter.translate(9, 9);
painter.rotate(-45);
painter.drawRoundedRect(QRectF(-8, -3.4, 16, 6.8), 3.4, 3.4);
painter.drawRect(QRectF(-2.6, -3.4, 5.2, 6.8));
painter.restore();
}
void blur(QPainter &painter)
{
// A drop: a point above a round belly.
QPainterPath drop(QPointF(9, 1.8));
drop.cubicTo(QPointF(11.5, 5.5), QPointF(14.4, 8.4), QPointF(14.4, 11.2));
drop.cubicTo(QPointF(14.4, 14.2), QPointF(12, 16.4), QPointF(9, 16.4));
drop.cubicTo(QPointF(6, 16.4), QPointF(3.6, 14.2), QPointF(3.6, 11.2));
drop.cubicTo(QPointF(3.6, 8.4), QPointF(6.5, 5.5), QPointF(9, 1.8));
painter.drawPath(drop);
}
void shape(QPainter &painter)
{
painter.drawEllipse(QRectF(7, 7, 9.5, 9.5));
painter.fillRect(QRectF(1.5, 1.5, 9.5, 9.5), painter.pen().color());
}
void type(QPainter &painter)
{
// The caller's font stays out: no underline, no slant.
QFont font;
font.setPixelSize(13);
font.setBold(true);
painter.setFont(font);
painter.drawText(QRectF(0, 0, 18, 18), Qt::AlignCenter, QStringLiteral("Aa"));
}
void hand(QPainter &painter)
{
// Four fingers over a palm, a thumb at the side.
const double tops[] = {4.6, 2.6, 2, 3.4};
for (int finger = 0; finger < 4; ++finger)
painter.drawLine(QPointF(5.4 + finger * 2.7, tops[finger]), QPointF(5.4 + finger * 2.7, 9));
QPainterPath palm(QPointF(13.5, 9));
palm.lineTo(QPointF(13.5, 12));
palm.cubicTo(QPointF(13.5, 15), QPointF(11.4, 16.4), QPointF(9.2, 16.4));
palm.cubicTo(QPointF(7, 16.4), QPointF(5.6, 15.2), QPointF(4.4, 13.2));
palm.lineTo(QPointF(2.2, 9.6));
palm.cubicTo(QPointF(1.6, 8.4), QPointF(3.2, 7.4), QPointF(4, 8.6));
palm.lineTo(QPointF(5.4, 10.6));
painter.drawPath(palm);
}
void zoom(QPainter &painter)
{
painter.drawEllipse(QRectF(2, 2, 10.5, 10.5));
painter.drawLine(QPointF(11.2, 11.2), QPointF(16, 16));
}
}
void ToolIcons::paint(QPainter &painter, NavigationTool tool, QPointF origin, double side, const QColor &colour, ToolIconKind kind)
{
// Refused before the painter is touched.
if (tool == NavigationTool::idle)
throw std::logic_error("no tool has no icon");
painter.save();
painter.setRenderHint(QPainter::Antialiasing);
painter.translate(origin);
painter.scale(side / points, side / points);
painter.setPen(QPen(colour, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.setBrush(Qt::NoBrush);
// An opaque background would fill dashes and letters.
painter.setBackgroundMode(Qt::TransparentMode);
switch (tool) {
case NavigationTool::move: move(painter); break;
case NavigationTool::marquee: marquee(painter, kind); break;
case NavigationTool::lasso: lasso(painter, kind); break;
case NavigationTool::wand: wand(painter); break;
case NavigationTool::crop: crop(painter); break;
case NavigationTool::brush: kind == ToolIconKind::eraser ? eraser(painter) : brush(painter); break;
case NavigationTool::spotHealing: spotHealing(painter); break;
case NavigationTool::cloneStamp: cloneStamp(painter); break;
case NavigationTool::blur: blur(painter); break;
case NavigationTool::gradient: gradient(painter); break;
case NavigationTool::shape: shape(painter); break;
case NavigationTool::type: type(painter); break;
case NavigationTool::eyedropper: EyedropperIcon::paint(painter); break;
case NavigationTool::hand: hand(painter); break;
case NavigationTool::zoom: zoom(painter); break;
case NavigationTool::idle: break;
}
painter.restore();
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include <QColor>
#include <QPainter>
#include <QRectF>
// The Marquee's and the Lasso's icons follow their kinds.
enum class ToolIconKind { plain, ellipse, polygonal, eraser };
// The tool rail's icons, drawn: SF Symbols have no twin.
namespace ToolIcons {
// Swift's icons sit in an 18 point square.
inline constexpr int points = 18;
// Paints the tool's icon into a square, in one colour.
void paint(QPainter &painter, NavigationTool tool, QPointF origin, double side, const QColor &colour, ToolIconKind kind = ToolIconKind::plain);
}
+265
View File
@@ -0,0 +1,265 @@
#include "UI/TransformInspector.h"
#include "UI/LayerIcons.h"
#include <QEvent>
#include <QKeyEvent>
#include <QLabel>
#include <cmath>
TransformValueField::TransformValueField(const QString &label, const QString &suffix, EditorSession &session, std::function<void(double)> change,
QWidget *parent)
: QWidget(parent), field(new QLineEdit(this)), m_session(session), m_change(std::move(change))
{
auto *row = new QHBoxLayout(this);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(4);
auto *name = new QLabel(label, this);
name->setFont(ToolHeaderStyle::controlFont());
name->setForegroundRole(QPalette::PlaceholderText);
row->addWidget(name);
field->setObjectName(QStringLiteral("transform") + label);
field->setAccessibleName(label);
field->setFont(ToolHeaderStyle::controlFont());
field->installEventFilter(this);
row->addWidget(field, 1);
if (!suffix.isEmpty()) {
auto *unit = new QLabel(suffix, this);
unit->setFont(ToolHeaderStyle::controlFont());
unit->setForegroundRole(QPalette::PlaceholderText);
row->addWidget(unit);
}
// Typing applies at once; previewTransform refuses what is no box.
connect(field, &QLineEdit::textEdited, this, [this](const QString &text) {
bool number = false;
const double typed = text.toDouble(&number);
if (number)
m_change(typed);
});
}
void TransformValueField::sync(double value)
{
m_value = value;
if (!field->hasFocus() && !m_borrowed)
field->setText(formatted(value));
}
QString TransformValueField::formatted(double value)
{
return std::abs(value - std::round(value)) < 0.005 ? QString::number(std::lround(value)) : QString::number(value, 'f', 2);
}
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)
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) {
field->clearFocus();
m_session.requestCanvasFocus();
return true;
}
if (key->key() != Qt::Key_Up && key->key() != Qt::Key_Down)
return QWidget::eventFilter(watched, event);
// A step is no typing: it writes what it applied.
const double amount = (key->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (key->key() == Qt::Key_Up ? 1 : -1);
const double stepped = m_value + amount;
m_change(stepped);
field->setText(formatted(stepped));
return true;
}
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(), session, [this](double number) { change([number](LayerTransform &value) { value.origin.setX(number); }); })),
m_y(new TransformValueField(QStringLiteral("Y"), QString(), session, [this](double number) { change([number](LayerTransform &value) { value.origin.setY(number); }); })),
m_width(new TransformValueField(QStringLiteral("W"), QString(), session, [this](double number) { resizeBox(number, true); })),
m_height(new TransformValueField(QStringLiteral("H"), QString(), session, [this](double number) { resizeBox(number, false); })),
m_lock(new QToolButton(this)),
m_scale(new TransformValueField(QStringLiteral("Scale"), QStringLiteral("%"), session, [this](double number) {
change([&](LayerTransform &value) { value = value.scaled(number, pixelSize()); });
})),
m_rotation(new TransformValueField(QStringLiteral("°"), QString(), session, [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_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); });
// The numbers scroll sideways when the window is narrow.
auto *numbers = new QWidget;
numbers->setObjectName(QStringLiteral("transformFields"));
auto *fields = new QHBoxLayout(numbers);
fields->setContentsMargins(18, 0, 18, 0);
fields->setSpacing(12);
for (const auto &[field, width] : {std::pair(m_x, 85), std::pair(m_y, 85), std::pair(m_width, 85), std::pair(m_height, 85)}) {
field->setFixedWidth(width);
fields->addWidget(field);
}
m_lock->setObjectName(QStringLiteral("locksTransformRatio"));
m_lock->setCheckable(true);
m_lock->setAutoRaise(true);
m_lock->setToolTip(QStringLiteral("Lock aspect ratio"));
applyLockIcon();
connect(m_lock, &QToolButton::toggled, this, [this](bool locks) { m_session.setLocksTransformRatio(locks); });
fields->addWidget(m_lock);
m_scale->setFixedWidth(110);
m_scale->setToolTip(QStringLiteral("Scale width and height together, about the center"));
fields->addWidget(m_scale);
m_rotation->setFixedWidth(75);
fields->addWidget(m_rotation);
m_sampling->setObjectName(QStringLiteral("transformSampling"));
m_sampling->setFixedWidth(170);
for (const LayerSampling sampling : {LayerSampling::nearest, LayerSampling::smooth, LayerSampling::high})
m_sampling->addItem(rawValue(sampling), int(sampling));
connect(m_sampling, &QComboBox::activated, this, [this](int index) {
change([index](LayerTransform &value) { value.sampling = LayerSampling(index); });
});
fields->addWidget(m_sampling);
auto *flipH = new QPushButton(QStringLiteral("Flip H"), numbers);
flipH->setObjectName(QStringLiteral("flipHorizontal"));
connect(flipH, &QPushButton::clicked, this, [this] { change([](LayerTransform &value) { value.flipX = !value.flipX; }); });
fields->addWidget(flipH);
auto *flipV = new QPushButton(QStringLiteral("Flip V"), numbers);
flipV->setObjectName(QStringLiteral("flipVertical"));
connect(flipV, &QPushButton::clicked, this, [this] { change([](LayerTransform &value) { value.flipY = !value.flipY; }); });
fields->addWidget(flipV);
// Swift's scroll content keeps its size at the leading edge.
fields->addStretch(1);
m_fields->setObjectName(QStringLiteral("transformScroll"));
m_fields->setWidget(numbers);
m_fields->setWidgetResizable(true);
m_fields->setFrameShape(QFrame::NoFrame);
m_fields->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_fields->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_fields->setFixedHeight(numbers->sizeHint().height());
m_cancel->setObjectName(QStringLiteral("cancelTransform"));
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelTransform(); });
m_apply->setObjectName(QStringLiteral("applyTransform"));
connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitTransform(); });
// Before the stretch ending the row, in Swift's order.
for (QWidget *widget : std::initializer_list<QWidget *>{m_autoSelect, m_showControls, m_fields, m_cancel, m_apply})
row->insertWidget(row->count() - 1, widget, widget == m_fields ? 1 : 0);
// 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);
synchronize();
}
void TransformInspector::applyLockIcon()
{
m_lock->setIcon(LayerIcons::pixmap(LayerIcon::link, 16, palette().color(QPalette::Text), devicePixelRatio()));
}
// The theme's ink reaches the icon, as the panel's icons.
void TransformInspector::changeEvent(QEvent *event)
{
ToolHeaderBar::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
applyLockIcon();
}
// Swift's default and cancel actions; a field keeps its keys.
void TransformInspector::keyPressEvent(QKeyEvent *event)
{
const bool enter = event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter;
if (!m_session.transformEdit() || (!enter && event->key() != Qt::Key_Escape)) {
ToolHeaderBar::keyPressEvent(event);
return;
}
if (enter)
m_session.commitTransform();
else
m_session.cancelTransform();
}
// The draft, else the active layer's box, else one unit.
LayerTransform TransformInspector::value() const
{
if (m_session.transformEdit())
return m_session.transformEdit()->draft;
const std::optional<ImageLayer> active = m_session.activeLayer();
return active ? m_session.editedTransform(*active) : LayerTransform{.origin = {0, 0}, .size = {1, 1}};
}
// 100%: the pixels, or a blank layer's size before editing.
QSizeF TransformInspector::pixelSize() const
{
if (const std::optional<QSizeF> pixels = m_session.transformPixelSize())
return *pixels;
const std::optional<ImageLayer> active = m_session.activeLayer();
return active ? active->size() : value().size;
}
void TransformInspector::change(const std::function<void(LayerTransform &)> &update)
{
if (!m_session.transformEdit())
m_session.beginTransform();
if (!m_session.transformEdit())
return;
LayerTransform next = m_session.transformEdit()->draft;
update(next);
m_session.previewTransform(next);
}
void TransformInspector::resizeBox(double number, bool width)
{
// A side under a pixel makes a box previewTransform refuses.
change([&](LayerTransform &next) {
if (width) {
if (m_session.locksTransformRatio())
next.size.setHeight(next.size.height() * number / next.size.width());
next.size.setWidth(number);
} else {
if (m_session.locksTransformRatio())
next.size.setWidth(next.size.width() * number / next.size.height());
next.size.setHeight(number);
}
});
}
void TransformInspector::synchronize()
{
// Another active layer makes the fields anew (Swift's `.id`).
if (m_layerID != m_session.activeLayerID()) {
m_layerID = m_session.activeLayerID();
if (QWidget *focused = focusWidget(); focused && isAncestorOf(focused))
focused->clearFocus();
}
title->setText(m_session.transformTargetsMask() ? QStringLiteral("Transform Mask") : QStringLiteral("Transform"));
const auto set = [](QAbstractButton *button, bool checked) {
const QSignalBlocker blocker(button);
button->setChecked(checked);
};
set(m_autoSelect, m_session.transformAutoSelect());
set(m_showControls, m_session.showsTransformControls());
set(m_lock, m_session.locksTransformRatio());
const LayerTransform shown = value();
m_x->sync(shown.origin.x());
m_y->sync(shown.origin.y());
m_width->sync(shown.size.width());
m_height->sync(shown.size.height());
m_scale->sync(shown.scalePercent(pixelSize()));
m_rotation->sync(shown.rotation);
{
const QSignalBlocker blocker(m_sampling);
m_sampling->setCurrentIndex(int(shown.sampling));
}
const bool editing = m_session.transformEdit().has_value();
// While distorted, the handles are the controls.
const bool distorted = editing && m_session.transformEdit()->corners.has_value();
m_fields->widget()->setEnabled((m_session.canTransform() || editing) && !distorted);
m_cancel->setEnabled(editing);
m_apply->setEnabled(editing);
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include "Document/EditorSession.h"
#include "UI/ToolHeaderStyle.h"
#include <QCheckBox>
#include <QComboBox>
#include <QLineEdit>
#include <QPushButton>
#include <QScrollArea>
#include <QToolButton>
#include <functional>
// A labelled number, typed at once, stepped by arrows.
class TransformValueField : public QWidget {
Q_OBJECT
public:
TransformValueField(const QString &label, const QString &suffix, EditorSession &session, std::function<void(double)> change,
QWidget *parent = nullptr);
// Shown when the field is not being edited.
void sync(double value);
// No idle zeros on a whole number, two decimals otherwise.
static QString formatted(double value);
QLineEdit *const field;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
EditorSession &m_session;
const std::function<void(double)> m_change;
double m_value = 0;
// A menu or popup borrows the focus: the typing stays.
bool m_borrowed = false;
};
// The Move tool's bar: numbers, settings, Apply and Cancel.
class TransformInspector : public ToolHeaderBar {
Q_OBJECT
public:
explicit TransformInspector(EditorSession &session, QWidget *parent = nullptr);
protected:
// Return applies and Escape cancels outside the fields.
void keyPressEvent(QKeyEvent *event) override;
void changeEvent(QEvent *event) override;
private:
void applyLockIcon();
LayerTransform value() const;
QSizeF pixelSize() const;
void change(const std::function<void(LayerTransform &)> &update);
// Swift's `resize`; the name would hide QWidget's.
void resizeBox(double number, bool width);
void synchronize();
EditorSession &m_session;
// The layer the fields were built for, as Swift's `.id`.
std::optional<QUuid> m_layerID;
QCheckBox *const m_autoSelect;
QCheckBox *const m_showControls;
QScrollArea *const m_fields;
TransformValueField *const m_x;
TransformValueField *const m_y;
TransformValueField *const m_width;
TransformValueField *const m_height;
QToolButton *const m_lock;
TransformValueField *const m_scale;
TransformValueField *const m_rotation;
QComboBox *const m_sampling;
QPushButton *const m_cancel;
QPushButton *const m_apply;
};
+248
View File
@@ -0,0 +1,248 @@
#include "UI/TypeControls.h"
#include "UI/ColorPaletteControls.h"
#include "Document/EditorSession.h"
#include "Rendering/TextLayout.h"
#include <QComboBox>
#include <QFocusEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPushButton>
#include <QScrollArea>
#include <QToolButton>
#include <cmath>
namespace {
// Swift's `.number`: the locale's mark, three decimals at most.
QString number(double value, QLocale locale)
{
locale.setNumberOptions(QLocale::OmitGroupSeparator);
QString text = locale.toString(value, 'f', 3);
while (text.endsWith(locale.zeroDigit()))
text.chop(locale.zeroDigit().size());
if (text.endsWith(locale.decimalPoint()))
text.chop(locale.decimalPoint().size());
return text;
}
// SF Symbols' text.align glyphs: four lines against one side.
QPixmap alignmentGlyph(TextAlignment alignment, const QColor &ink, double ratio)
{
QPixmap pixmap(QSize(18, 18) * ratio);
pixmap.setDevicePixelRatio(ratio);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setPen(QPen(ink, 1.6, Qt::SolidLine, Qt::RoundCap));
for (int row = 0; row < 4; ++row) {
const double length = row % 2 == 0 ? 14 : 9, y = 4 + row * 3.4;
const double left = alignment == TextAlignment::left ? 2 : alignment == TextAlignment::center ? 9 - length / 2 : 16 - length;
painter.drawLine(QPointF(left, y), QPointF(left + length, y));
}
return pixmap;
}
}
TextStyleField::TextStyleField(std::function<QString()> shown, std::function<void(const QString &)> typed, std::function<void(double)> step,
QWidget *parent)
: QLineEdit(parent), m_shown(std::move(shown)), m_typed(std::move(typed)), m_step(std::move(step))
{
setAlignment(Qt::AlignRight | Qt::AlignVCenter);
// Swift's binding takes each edit it can read.
connect(this, &QLineEdit::textEdited, this, [this](const QString &text) { m_typed(text); });
}
void TextStyleField::sync()
{
if (!hasFocus() && !m_borrowed)
setText(m_shown());
}
void TextStyleField::keyPressEvent(QKeyEvent *event)
{
// Swift's bar keeps the focus: Return shows the number, selected.
if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) {
setText(m_shown());
selectAll();
return;
}
if (event->key() != Qt::Key_Up && event->key() != Qt::Key_Down) {
QLineEdit::keyPressEvent(event);
return;
}
m_step((event->modifiers().testFlag(Qt::ShiftModifier) ? 10 : 1) * (event->key() == Qt::Key_Up ? 1 : -1));
setText(m_shown());
}
void TextStyleField::focusOutEvent(QFocusEvent *event)
{
m_borrowed = event->reason() == Qt::MenuBarFocusReason || event->reason() == Qt::PopupFocusReason;
if (!m_borrowed)
setText(m_shown());
QLineEdit::focusOutEvent(event);
}
TypeControls::TypeControls(EditorSession &session, QWidget *parent)
: ToolHeaderBar(QStringLiteral("Type"), parent), m_session(session), m_font(new QComboBox),
m_size(new TextStyleField(
[this] { return number(m_session.currentTextStyle().fontSize, locale()); },
[this](const QString &text) {
// No number reads as zero, which no size passes.
const double size = locale().toDouble(text);
m_session.changeTextStyle([size](LayerTextStyle &style) { style.fontSize = size; });
},
[this](double amount) {
const double size = std::min(2000.0, std::max(1.0, m_session.currentTextStyle().fontSize + amount));
m_session.changeTextStyle([size](LayerTextStyle &style) { style.fontSize = size; });
},
nullptr)),
m_colour(new SwatchButton([&session] { return session.typeColor(); }, 3, 0, 0.5, nullptr)),
m_alignments{alignment(TextAlignment::left), alignment(TextAlignment::center), alignment(TextAlignment::right)},
m_tracking(new TextStyleField(
[this] { return number(m_session.currentTextStyle().tracking, locale()); },
[this](const QString &text) {
bool read = false;
const double tracking = locale().toDouble(text, &read);
if (read)
m_session.changeTextStyle([tracking](LayerTextStyle &style) { style.tracking = tracking; });
},
[this](double amount) {
const double tracking = m_session.currentTextStyle().tracking + amount;
m_session.changeTextStyle([tracking](LayerTextStyle &style) { style.tracking = tracking; });
},
nullptr)),
m_leading(new TextStyleField(
[this] {
// Auto shows as the empty field's placeholder.
const double leading = m_session.currentTextStyle().leading;
return leading > 0 ? QString::number(std::lround(leading)) : QString();
},
[this](const QString &text) {
const double typed = text.toDouble();
m_session.changeTextStyle([typed](LayerTextStyle &style) { style.leading = std::max(0.0, std::min(5000.0, typed)); });
},
[this](double amount) {
// Steps count from what Auto works out to.
const double leading = std::max(0.0, m_session.currentTextStyle().lineHeight() + amount);
m_session.changeTextStyle([leading](LayerTextStyle &style) { style.leading = leading; });
},
nullptr)),
m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_done(new QPushButton(QStringLiteral("Done"), this)),
m_edit(new QPushButton(QStringLiteral("Edit Text"), this))
{
auto *controls = new QWidget;
controls->setObjectName(QStringLiteral("typeFields"));
auto *fields = new QHBoxLayout(controls);
fields->setContentsMargins(0, 0, 0, 0);
fields->setSpacing(10);
m_font->setObjectName(QStringLiteral("typeFont"));
m_font->setFixedWidth(185);
m_font->setToolTip(QStringLiteral("Font face, including bold and italic variants"));
connect(m_font, &QComboBox::activated, this, [this](int index) {
const QString name = m_font->itemText(index);
m_session.changeTextStyle([&name](LayerTextStyle &style) { style.fontName = name; });
});
m_size->setObjectName(QStringLiteral("typeSize"));
m_size->setFixedWidth(52);
m_tracking->setObjectName(QStringLiteral("typeTracking"));
m_tracking->setFixedWidth(45);
m_leading->setObjectName(QStringLiteral("typeLeading"));
m_leading->setFixedWidth(52);
m_leading->setPlaceholderText(QStringLiteral("Auto"));
m_leading->setToolTip(QStringLiteral("Line height, baseline to baseline. Empty or 0 is Auto: 120% of the font size."));
m_colour->setObjectName(QStringLiteral("typeColor"));
m_colour->setFixedSize(36, 18);
m_colour->setToolTip(QStringLiteral("Text color"));
m_colour->setAccessibleName(QStringLiteral("Text color"));
connect(m_colour, &QAbstractButton::clicked, this, [this] { m_session.openTextColorPicker(); });
auto *alignments = new QWidget;
auto *aligned = new QHBoxLayout(alignments);
aligned->setContentsMargins(0, 0, 0, 0);
aligned->setSpacing(2);
for (QToolButton *button : m_alignments)
aligned->addWidget(button);
for (QWidget *widget : std::initializer_list<QWidget *>{m_font, m_size, new QLabel(QStringLiteral("px")), m_colour, alignments, new QLabel(QStringLiteral("Tracking")),
m_tracking, new QLabel(QStringLiteral("Leading")), m_leading})
fields->addWidget(widget);
// Swift's scroll content keeps its size at the leading edge.
fields->addStretch(1);
// Swift's horizontal ScrollView: the controls scroll when narrow.
auto *scroll = new QScrollArea(this);
scroll->setObjectName(QStringLiteral("typeScroll"));
scroll->setWidget(controls);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scroll->setFixedHeight(controls->sizeHint().height());
m_cancel->setObjectName(QStringLiteral("typeCancel"));
connect(m_cancel, &QPushButton::clicked, this, [this] { m_session.cancelText(); });
m_done->setObjectName(QStringLiteral("typeDone"));
connect(m_done, &QPushButton::clicked, this, [this] { m_session.finishText(); });
m_edit->setObjectName(QStringLiteral("typeEdit"));
connect(m_edit, &QPushButton::clicked, this, [this] { m_session.editActiveText(); });
for (QWidget *widget : std::initializer_list<QWidget *>{scroll, m_cancel, m_done, m_edit})
row->insertWidget(row->count() - 1, widget, widget == scroll ? 1 : 0);
// The scroll view takes the spare room, as SwiftUI's does.
row->setStretch(row->count() - 1, 0);
applyGlyphs();
connect(&m_session, &EditorSession::changed, this, &TypeControls::synchronize);
synchronize();
}
void TypeControls::applyGlyphs()
{
for (size_t index = 0; index < m_alignments.size(); ++index)
m_alignments[index]->setIcon(alignmentGlyph(allTextAlignments[index], palette().color(QPalette::WindowText), devicePixelRatio()));
}
// The theme's ink reaches the glyphs, as the panel's icons.
void TypeControls::changeEvent(QEvent *event)
{
ToolHeaderBar::changeEvent(event);
if (event->type() == QEvent::PaletteChange)
applyGlyphs();
}
// One of Swift's three alignment buttons.
QToolButton *TypeControls::alignment(TextAlignment value)
{
auto *button = new QToolButton;
button->setObjectName(QStringLiteral("typeAlign") + rawValue(value));
button->setCheckable(true);
button->setAutoRaise(true);
button->setFixedSize(30, 26);
button->setToolTip(QStringLiteral("Align ") + rawValue(value).toLower());
button->setAccessibleName(button->toolTip());
connect(button, &QToolButton::clicked, this, [this, value] {
m_session.changeTextStyle([value](LayerTextStyle &style) { style.alignment = value; });
});
return button;
}
void TypeControls::synchronize()
{
const LayerTextStyle style = m_session.currentTextStyle();
// Every installed face, and the style's own though missing.
QStringList names = TextLayout::availableFonts();
if (!names.contains(style.fontName))
names << style.fontName;
names.sort();
if (m_font->count() != names.size() || m_font->findText(style.fontName) < 0) {
m_font->clear();
m_font->addItems(names);
}
m_font->setCurrentIndex(m_font->findText(style.fontName));
for (size_t index = 0; index < m_alignments.size(); ++index)
m_alignments[index]->setChecked(style.alignment == allTextAlignments[index]);
for (TextStyleField *field : {m_size, m_tracking, m_leading})
field->sync();
m_colour->update();
const bool drafting = m_session.textDraft().has_value();
m_cancel->setVisible(drafting);
m_done->setVisible(drafting);
m_edit->setVisible(!drafting);
const std::optional<ImageLayer> active = m_session.activeLayer();
m_edit->setEnabled(active && active->liveText());
setEnabled(m_session.document().has_value() && !m_session.showsBusy());
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include "Document/TypeTool.h"
#include "UI/ToolHeaderStyle.h"
#include <QLineEdit>
#include <array>
#include <functional>
class EditorSession;
class QComboBox;
class QPushButton;
class QToolButton;
class SwatchButton;
// A text style number: typed at once, stepped by arrows.
class TextStyleField : public QLineEdit {
Q_OBJECT
public:
TextStyleField(std::function<QString()> shown, std::function<void(const QString &)> typed, std::function<void(double)> step,
QWidget *parent);
// Shows the session's number unless being edited.
void sync();
protected:
void keyPressEvent(QKeyEvent *event) override;
void focusOutEvent(QFocusEvent *event) override;
private:
const std::function<QString()> m_shown;
const std::function<void(const QString &)> m_typed;
const std::function<void(double)> m_step;
// A menu or popup borrows the focus: the typing stays.
bool m_borrowed = false;
};
// Swift's TypeControls: font, size, alignment, spacing, Done.
class TypeControls : public ToolHeaderBar {
Q_OBJECT
public:
explicit TypeControls(EditorSession &session, QWidget *parent = nullptr);
protected:
void changeEvent(QEvent *event) override;
private:
QToolButton *alignment(TextAlignment value);
void applyGlyphs();
void synchronize();
EditorSession &m_session;
QComboBox *const m_font;
TextStyleField *const m_size;
// Open text's colour, else the next text's: the foreground.
SwatchButton *const m_colour;
const std::array<QToolButton *, 3> m_alignments;
TextStyleField *const m_tracking;
TextStyleField *const m_leading;
QPushButton *const m_cancel;
QPushButton *const m_done;
QPushButton *const m_edit;
};