OmaPhoto 1.0.0: the Linux port of Compositor
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:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user