363 lines
17 KiB
C++
363 lines
17 KiB
C++
#include "UI/CanvasThumbnail.h"
|
||
#include "UI/LayerIcons.h"
|
||
#include "UI/NativeLayerList.h"
|
||
#include <QApplication>
|
||
#include <QContextMenuEvent>
|
||
#include <QKeyEvent>
|
||
#include <QLocale>
|
||
#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; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
|
||
m_link->setObjectName(QStringLiteral("maskLink"));
|
||
m_link->setAutoRaise(true);
|
||
m_link->setFixedSize(9, 20);
|
||
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);
|
||
// Swift 1.4.7: no bezel; the field keeps the name's size.
|
||
m_editor->setFrame(false);
|
||
m_editor->hide();
|
||
m_editor->installEventFilter(this);
|
||
}
|
||
|
||
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_editableAdjustment = m_isAdjustment && isEditable(layer.adjustment->kind);
|
||
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")
|
||
: sizeLabel(layer));
|
||
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, active && session.maskAloneLayer());
|
||
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);
|
||
// Two left, so the text stays where the name's was.
|
||
m_editor->setGeometry(nameLeft - 2, 9, nameWidth + 2, 17);
|
||
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_editableAdjustment) {
|
||
session.setAdjustmentEditingID(m_layerID);
|
||
return;
|
||
}
|
||
}
|
||
session.setRenamingLayerID(m_layerID);
|
||
}
|
||
|
||
// The list routes the press and builds the menu.
|
||
void LayerCell::contextMenuEvent(QContextMenuEvent *event)
|
||
{
|
||
m_list.menuFor(*this, event->pos())->exec(event->globalPos());
|
||
}
|
||
|
||
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;
|
||
// Swift's quaternary label colour marks the editing.
|
||
QPalette editing = m_editor->palette();
|
||
QColor tint = palette().color(QPalette::Active, QPalette::WindowText);
|
||
tint.setAlphaF(0.1f);
|
||
editing.setColor(QPalette::Base, tint);
|
||
m_editor->setPalette(editing);
|
||
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;
|
||
}
|
||
|
||
QString sizeLabel(const ImageLayer &layer)
|
||
{
|
||
const QString text = QStringLiteral("%1 × %2 px").arg(std::lround(layer.size().width())).arg(std::lround(layer.size().height()));
|
||
const int pixels = layer.asset ? layer.asset->size().width() : 0;
|
||
if (pixels <= 0)
|
||
return text;
|
||
// Across the width, as the Move bar's Scale field.
|
||
const double percent = layer.size().width() / pixels * 100;
|
||
if (std::abs(percent - 100) < 0.05)
|
||
return text;
|
||
// Swift's .number to one place at most, ties to even.
|
||
const double rounded = std::nearbyint(percent * 10) / 10;
|
||
return text + QStringLiteral(" · ") + QLocale().toString(rounded, 'f', rounded == std::trunc(rounded) ? 0 : 1) + QStringLiteral("%");
|
||
}
|