12.2a: folders duplicate with their contents, folder opacity, format version 8, View > Snap (Compositor 1.1.5–1.1.6)

This commit is contained in:
雷电芽衣 committed 2026-09-26 18:17:14 -04:00
1 parent ee34c338d4
commit f1b2c65c1e
52 files changed
+909 -239

No files matched your search

+4
View File
@@ -168,6 +168,10 @@ SnapGuides EditorSession::transformSnapTargets(const QSet<QUuid> &moving) const
// `draft` nudged onto a nearby edge or middle, within `tolerance`.
LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance)
{
if (!m_snappingEnabled) {
snapGuides = {};
return draft;
}
const SnapGuides targets = transformSnapTargets(moving);
const TransformSnap::Offset snap = TransformSnap::offset(boxAround(draft), targets.xs, targets.ys, tolerance);
snapGuides = {snap.x ? std::vector<double>{*snap.x} : std::vector<double>{}, snap.y ? std::vector<double>{*snap.y} : std::vector<double>{}};
+5
View File
@@ -9,7 +9,9 @@
#include "Document/TypeTool.h"
#include "IO/ImageImporter.h"
#include <QString>
#include <QHash>
#include <QUuid>
#include <map>
#include <optional>
#include <vector>
@@ -40,6 +42,8 @@ struct ImageLayer {
QPointF origin() const { return transform.origin; }
QSizeF size() const { return transform.size; }
ProjectLayerRecord hierarchyRecord() const;
// Drawn opacity, folders included (LayerOpacity).
double effectiveOpacity(const std::map<QUuid, ImageLayer> &byID) const;
LayerTransform maskTransform() const;
// The shape this layer still is: none once repainted otherwise.
std::optional<LayerShape> liveShape() const;
@@ -70,6 +74,7 @@ struct CanvasDocument {
static std::optional<int> validDimension(const QString &value);
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
QSet<QUuid> effectiveVisibleIDs() const;
QHash<QUuid, double> effectiveOpacities() const;
std::vector<ImageLayer> renderLayers() const;
friend bool operator==(const CanvasDocument &lhs, const CanvasDocument &rhs) = default;
};
+19 -6
View File
@@ -100,7 +100,7 @@ void EditorSession::setLocksTransformRatio(bool locks)
notify();
}
// A folder has no pixels to copy: it just moves.
// Alt-drag copies selected roots with their contents.
void EditorSession::beginDuplicateTransform()
{
if (m_transformDuplicate || !m_activeLayerID)
@@ -110,10 +110,14 @@ void EditorSession::beginDuplicateTransform()
if (!canTransform())
return;
const QSet<QUuid> selection = m_selectedLayerIDs;
// A selected folder carries its selected contents along.
QSet<QUuid> carried;
for (const QUuid &id : selection)
carried.unite(descendantIDs(id));
// Bottom to top: the copies keep their order.
std::vector<QUuid> targets;
for (const ImageLayer &layer : m_document->layers) {
if (selection.contains(layer.id) && !layer.isGroup)
if (selection.contains(layer.id) && !carried.contains(layer.id))
targets.push_back(layer.id);
}
if (targets.empty())
@@ -123,7 +127,14 @@ void EditorSession::beginDuplicateTransform()
for (const QUuid &id : targets) {
selectLayer(id);
duplicateActiveLayer();
copies.push_back(m_activeLayerID.value());
if (m_activeLayerID != id)
copies.push_back(m_activeLayerID.value());
}
// Past the layer limit nothing was copied.
if (copies.empty()) {
endEdit();
selectLayers(selection, primary);
return;
}
m_transformDuplicate = TransformDuplicate{copies, selection, primary};
selectLayers(QSet<QUuid>(copies.begin(), copies.end()), copies.back());
@@ -206,9 +217,11 @@ void EditorSession::cancelTransform()
if (m_transformDuplicate) {
// The copies go and the old selection returns.
const TransformDuplicate duplicate = *m_transformDuplicate;
std::erase_if(m_document->layers, [&](const ImageLayer &layer) {
return std::find(duplicate.copies.begin(), duplicate.copies.end(), layer.id) != duplicate.copies.end();
});
QSet<QUuid> removed(duplicate.copies.begin(), duplicate.copies.end());
for (const QUuid &copy : duplicate.copies)
removed.unite(descendantIDs(copy));
std::erase_if(m_document->layers, [&](const ImageLayer &layer) { return removed.contains(layer.id); });
m_collapsedGroupIDs.subtract(removed);
selectLayers(duplicate.source, duplicate.primary);
endDuplicateTransform();
}
+8
View File
@@ -318,6 +318,14 @@ void EditorSession::setShowsPixelGrid(bool shows)
notify();
}
void EditorSession::setSnappingEnabled(bool enabled)
{
m_snappingEnabled = enabled;
if (!enabled)
snapGuides = {};
notify();
}
void EditorSession::setCropRect(std::optional<QRectF> rect)
{
if (std::exchange(m_cropRect, rect) != rect)
+4
View File
@@ -238,6 +238,9 @@ public:
// The View menu's grid, shown from 800%.
bool showsPixelGrid() const { return m_showsPixelGrid; }
void setShowsPixelGrid(bool shows);
// View > Snap: moves and crops line up with edges.
bool snappingEnabled() const { return m_snappingEnabled; }
void setSnappingEnabled(bool enabled);
// The Eyedropper's ring: the sampled colour over the original.
bool showsSampleRing() const { return m_showsSampleRing; }
void setShowsSampleRing(bool shows);
@@ -336,6 +339,7 @@ private:
std::optional<QUuid> m_renamingLayerID;
std::optional<QString> m_projectPath;
bool m_showsPixelGrid = true;
bool m_snappingEnabled = true;
bool m_showsSampleRing = true;
std::optional<QRectF> m_cropRect;
QString m_cropRatioChoice = QStringLiteral("Free");
+2
View File
@@ -3,6 +3,8 @@ public:
LayerBlendMode displayedBlendMode(const ImageLayer &layer) const;
void previewBlendMode(std::optional<LayerBlendMode> mode, std::optional<QUuid> id);
bool canEditAppearance() const;
// A folder dims what it holds; blending stays per layer.
bool canEditOpacity() const;
// A slider drag: many values, one undo step.
void beginOpacityEdit();
void finishOpacityEdit();
+9 -4
View File
@@ -54,9 +54,14 @@ bool EditorSession::canEditAppearance() const
return canEditLayers() && m_selectedLayerIDs.size() == 1 && active && !active->isGroup;
}
bool EditorSession::canEditOpacity() const
{
return canEditLayers() && m_selectedLayerIDs.size() == 1 && activeLayer();
}
void EditorSession::beginOpacityEdit()
{
if (!canEditAppearance() || m_opacityEditLayerID)
if (!canEditOpacity() || m_opacityEditLayerID)
return;
beginEdit(QStringLiteral("Layer Opacity"));
m_opacityEditLayerID = m_activeLayerID;
@@ -72,7 +77,7 @@ void EditorSession::finishOpacityEdit()
void EditorSession::setLayerOpacity(double opacity)
{
if (!std::isfinite(opacity) || !canEditAppearance())
if (!std::isfinite(opacity) || !canEditOpacity())
return;
const QUuid id = m_opacityEditLayerID.value_or(*m_activeLayerID);
const auto layer = std::find_if(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &each) { return each.id == id; });
@@ -88,9 +93,9 @@ void EditorSession::setSelectedLayersOpacity(double opacity)
{
if (!std::isfinite(opacity) || !canEditLayers())
return;
// Folders have no opacity of their own.
// A selected folder takes it too, dimming what it holds.
const double value = std::clamp(opacity, 0.0, 1.0);
const auto changes = [&](const ImageLayer &layer) { return m_selectedLayerIDs.contains(layer.id) && !layer.isGroup && layer.opacity != value; };
const auto changes = [&](const ImageLayer &layer) { return m_selectedLayerIDs.contains(layer.id) && layer.opacity != value; };
if (std::none_of(m_document->layers.begin(), m_document->layers.end(), changes))
return;
finishOpacityEdit();
+48
View File
@@ -86,6 +86,54 @@ void LayerHierarchy::validate(const std::vector<ProjectLayerRecord> &layers)
}
}
double LayerOpacity::effective(double own, std::optional<QUuid> parent, const Node &folder)
{
double opacity = own;
std::optional<QUuid> id = parent;
// Past 64 folders the tree is wrong: Swift stops there.
for (int depth = 0; id && depth < 64; ++depth) {
const std::optional<std::pair<double, std::optional<QUuid>>> node = folder(*id);
if (!node)
break;
opacity *= node->first;
id = node->second;
}
return opacity;
}
double ImageLayer::effectiveOpacity(const std::map<QUuid, ImageLayer> &byID) const
{
return LayerOpacity::effective(opacity, parentID, [&byID](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
if (!byID.contains(id))
return std::nullopt;
const ImageLayer &node = byID.at(id);
return std::pair(node.opacity, node.parentID);
});
}
double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRecord> &byID) const
{
return LayerOpacity::effective(opacity.value_or(1), parentID, [&byID](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
if (!byID.contains(id))
return std::nullopt;
const ProjectLayerRecord &node = byID.at(id);
return std::pair(node.opacity.value_or(1), node.parentID);
});
}
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
{
std::map<QUuid, ImageLayer> byID;
for (const ImageLayer &layer : layers) {
if (!byID.emplace(layer.id, layer).second)
throw std::logic_error("two layers share an id");
}
QHash<QUuid, double> result;
for (const ImageLayer &layer : layers)
result.insert(layer.id, layer.effectiveOpacity(byID));
return result;
}
ProjectLayerRecord ImageLayer::hierarchyRecord() const
{
const QString file = id.toString(QUuid::WithoutBraces).toUpper();
+8
View File
@@ -1,6 +1,7 @@
#pragma once
#include "IO/ProjectStore.h"
#include <QSet>
#include <functional>
#include <vector>
namespace LayerHierarchy {
@@ -15,3 +16,10 @@ std::vector<Entry> entries(const std::vector<ProjectLayerRecord> &layers, bool t
std::vector<ProjectLayerRecord> visibleLayers(const std::vector<ProjectLayerRecord> &layers);
void validate(const std::vector<ProjectLayerRecord> &layers);
}
// A folder's opacity multiplies into each layer inside it.
namespace LayerOpacity {
// A layer's opacity and parent, or none when unknown.
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
double effective(double own, std::optional<QUuid> parent, const Node &folder);
}
+12 -4
View File
@@ -42,14 +42,14 @@ void EditorSession::drawLiveComposite(const CanvasDocument &document, QPainter &
if (const auto effects = LayerEffectsRenderer::cached(image, mask, layer.effects)) {
const LayerTransform grown = LayerEffectsRenderer::placed(transform, effects->image, effects->inset);
LayerRenderer::draw(effects->image, grown, grown.center(), target,
{.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .clip = clip});
{.opacity = layer.effectiveOpacity(records), .blendMode = displayedBlendMode(layer), .clip = clip});
return;
}
LayerRenderer::draw(image, transform, transform.center(), target,
{.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .mask = mask.value_or(QImage()), .clip = clip});
{.opacity = layer.effectiveOpacity(records), .blendMode = displayedBlendMode(layer), .mask = mask.value_or(QImage()), .clip = clip});
});
live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; };
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity; };
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).effectiveOpacity(records); };
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
const ImageLayer &layer = records.at(id);
if (const std::optional<QImage> image = layer.mask ? layer.mask->enabledImage() : std::nullopt)
@@ -116,6 +116,14 @@ std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot
throw std::logic_error("a snapshot to bake repeats a layer id");
records.insert(layer.id, &layer);
}
// A source dims with its folders, as exported.
const auto folded = [&records](const ProjectLayerRecord &layer) {
return LayerOpacity::effective(layer.opacity.value_or(1), layer.parentID, [&records](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
if (!records.contains(id))
return std::nullopt;
return std::pair(records.value(id)->opacity.value_or(1), records.value(id)->parentID);
});
};
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.value(id)->maskSourceID : std::nullopt; },
[&](QUuid id, QPainter &context, const QImage &clip) {
// `at` is checked: end() would read wild memory.
@@ -135,7 +143,7 @@ std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot
context.save();
context.setTransform(inverse, true);
LayerRenderer::draw(pixels, layer.transform, layer.transform.center(), context,
{.opacity = layer.opacity.value_or(1), .mask = shown.value_or(QImage()), .clip = clip});
{.opacity = folded(layer), .mask = shown.value_or(QImage()), .clip = clip});
context.restore();
});
{
+36 -10
View File
@@ -317,28 +317,54 @@ QString EditorSession::nextLayerName() const
void EditorSession::duplicateActiveLayer()
{
const int index = canEditLayers() ? indexOf(m_document->layers, m_activeLayerID) : -1;
if (index < 0 || m_document->layers[index].isGroup)
if (index < 0)
return;
ImageLayer copy = m_document->layers[index];
copy.id = QUuid::createUuid();
copy.name = copy.name + QStringLiteral(" copy");
// A folder carries its whole tree, links kept inside it.
const QUuid id = *m_activeLayerID;
QSet<QUuid> included = descendantIDs(id);
included.insert(id);
std::vector<ImageLayer> copies;
for (const ImageLayer &layer : m_document->layers) {
if (included.contains(layer.id))
copies.push_back(layer);
}
if (m_document->layers.size() + copies.size() > 10'000)
return;
QHash<QUuid, QUuid> mapping;
for (const ImageLayer &copy : copies)
mapping.insert(copy.id, QUuid::createUuid());
const auto renamed = [&mapping](std::optional<QUuid> &link) {
if (link && mapping.contains(*link))
link = mapping.value(*link);
};
for (ImageLayer &copy : copies) {
if (copy.id == id)
copy.name += QStringLiteral(" copy");
copy.id = mapping.value(copy.id);
renamed(copy.parentID);
renamed(copy.maskSourceID);
}
beginEdit(QStringLiteral("Duplicate Layer"));
m_document->layers.insert(m_document->layers.begin() + index + 1, copy);
setActiveLayerID(copy.id);
m_document->layers.insert(m_document->layers.begin() + index + 1, copies.begin(), copies.end());
for (auto original = mapping.cbegin(); original != mapping.cend(); ++original) {
if (m_collapsedGroupIDs.contains(original.key()))
m_collapsedGroupIDs.insert(original.value());
}
setActiveLayerID(mapping.value(id));
endEdit();
}
// Alt-drag in the Layers panel: a copy placed where dropped.
bool EditorSession::duplicateLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above, bool atBottom)
{
const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1;
if (index < 0 || m_document->layers[index].isGroup || !canPlaceLayer(id, parent))
// canPlaceLayer reads canEditLayers, Swift's first term.
if (!canPlaceLayer(id, parent))
return false;
beginEdit(QStringLiteral("Duplicate Layer"));
selectLayer(id);
duplicateActiveLayer();
// The copy is active: nothing above refuses it.
const bool placed = placeLayer(*m_activeLayerID, parent, above, atBottom);
// Past the layer limit no copy was made.
const bool placed = m_activeLayerID != id && placeLayer(*m_activeLayerID, parent, above, atBottom);
endEdit();
return placed;
}
+2 -2
View File
@@ -75,7 +75,7 @@ try {
const std::optional<LayerMask> mask = snapshot.mask(layer);
const std::optional<QImage> shown =
mask ? mask->clipImage(mask->placement, layer.transform, pixels.width(), pixels.height()) : std::nullopt;
const double opacity = layer.opacity.value_or(1);
const double opacity = layer.effectiveOpacity(records);
const LayerBlendMode mode = layer.blendMode.value_or(LayerBlendMode::normal);
// With effects the mask is in their image already.
if (const auto effects = LayerEffectsRenderer::cached(pixels, shown, layer.effects)) {
@@ -87,7 +87,7 @@ try {
{.opacity = opacity, .blendMode = mode, .mask = shown.value_or(QImage()), .clip = clip});
});
live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; };
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity.value_or(1); };
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).effectiveOpacity(records); };
live.adjustmentClip = [&](QUuid id, const QPainter &context, QImage &coverage) {
const ProjectLayerRecord &layer = records.at(id);
const std::optional<LayerMask> mask = snapshot.mask(layer);
+7 -5
View File
@@ -42,7 +42,7 @@ void validate(const ProjectManifest &manifest)
{
if (manifest.format != QLatin1String("com.compositor.project"))
throw ProjectError(ProjectError::Kind::invalid);
if (manifest.version < 1 || manifest.version > 7)
if (manifest.version < 1 || manifest.version > 8)
throw ProjectError::unsupportedVersion(manifest.version);
if (manifest.colorSpace != QLatin1String("sRGB"))
throw ProjectError(ProjectError::Kind::invalid);
@@ -64,9 +64,11 @@ void validate(const ProjectManifest &manifest)
|| (manifest.version >= (group ? 6 : 4) && *layer.maskFile == uuidString(layer.id) + QLatin1String(".mask.png"));
const bool placementAllowed = !layer.maskPlacement || (layer.maskPlacement->isValid() && layer.maskFile);
const double opacity = layer.opacity.value_or(1);
const bool plain = opacity == 1 && layer.blendMode.value_or(LayerBlendMode::normal) == LayerBlendMode::normal;
const bool normal = layer.blendMode.value_or(LayerBlendMode::normal) == LayerBlendMode::normal;
const bool plain = opacity == 1 && normal;
// Folders dim from version 8; they always pass through.
if (!maskAllowed || (layer.maskEnabled && !layer.maskFile) || !placementAllowed || !std::isfinite(opacity) || opacity < 0
|| opacity > 1 || (manifest.version < 3 && !plain) || (group && !plain))
|| opacity > 1 || (manifest.version < 3 && !plain) || (group && !(normal && (manifest.version >= 8 || opacity == 1))))
throw ProjectError(ProjectError::Kind::invalid);
}
LayerHierarchy::validate(manifest.layers);
@@ -228,7 +230,7 @@ ProjectError::ProjectError(Kind kind) : ProjectError(kind, std::nullopt, descrip
ProjectError ProjectError::unsupportedVersion(qint64 version)
{
return ProjectError(Kind::version, version,
QStringLiteral("This project uses format version %1. This app supports versions 1–7.").arg(version));
QStringLiteral("This project uses format version %1. This app supports versions 1–8.").arg(version));
}
ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QString &description)
@@ -304,7 +306,7 @@ ProjectSnapshot ProjectStore::load(const QString &path)
const auto [format, version] = ProjectManifest::header(metadata);
if (format != QLatin1String("com.compositor.project"))
throw ProjectError(ProjectError::Kind::invalid);
if (version < 1 || version > 7)
if (version < 1 || version > 8)
throw ProjectError::unsupportedVersion(version);
const ProjectManifest manifest = ProjectManifest::decoded(metadata);
validate(manifest);
+4 -1
View File
@@ -40,6 +40,9 @@ struct ProjectLayerRecord {
// What the layer draws round itself; its pixels stay.
std::optional<LayerEffects> effects = std::nullopt;
std::optional<LayerTextStyle> text = std::nullopt;
// Drawn opacity, folders included; defined in LayerGroups.cpp.
double effectiveOpacity(const std::map<QUuid, ProjectLayerRecord> &byID) const;
};
// A UUID as Swift writes it: upper case, no braces.
@@ -48,7 +51,7 @@ QString uuidString(const QUuid &id);
struct ProjectManifest {
QString format = QStringLiteral("com.compositor.project");
// Swift's Int: a wild number must reach validation whole.
qint64 version = 7;
qint64 version = 8;
QString colorSpace = QStringLiteral("sRGB");
// Version-1 projects carry none: 72 pixels per inch.
std::optional<double> resolution = std::nullopt;
+1 -1
View File
@@ -81,7 +81,7 @@ void CanvasView::dragCrop(QPointF point, Qt::KeyboardModifiers modifiers)
const std::optional<double> ratio = m_session.cropRatio();
const CropDrag &drag = m_cropDrag.value();
QRectF next = drag.updated(pixel, ratio, symmetric);
if (!modifiers.testFlag(Qt::ControlModifier))
if (m_session.snappingEnabled() && !modifiers.testFlag(Qt::ControlModifier))
next = m_cropSnap.value().apply(next, drag, pixel, ratio, symmetric);
if (CropGeometry::valid(next))
m_session.setCropRect(next);
+32
View File
@@ -0,0 +1,32 @@
#include "Rendering/EditorCanvas.h"
#include <QPainterPath>
#include <cmath>
// One-screen-pixel lines on document pixel boundaries, over the image only.
void CanvasView::drawPixelGrid(const QRectF &view, const CanvasDocument &document, QPainter &context) const
{
const CanvasViewport &viewport = m_session.viewport;
const double points = viewport.pointsPerPixel();
const QRectF canvas(viewport.viewPoint(QPointF(0, 0), document.size()), QSizeF(document.width * points, document.height * points));
const QRectF area = view.intersected(canvas);
if (area.isEmpty())
return;
const QPointF first = viewport.documentPoint(area.topLeft(), document.size());
const QPointF last = viewport.documentPoint(area.bottomRight(), document.size());
const double hairline = 1 / viewport.backingScale;
// Crossings fill once, as CoreGraphics fills the union.
QPainterPath path;
path.setFillRule(Qt::WindingFill);
for (int column = int(std::ceil(first.x())); column <= int(std::floor(last.x())); ++column) {
const double x = viewport.viewPoint(QPointF(column, 0), document.size()).x();
path.addRect(QRectF(x - hairline / 2, area.top(), hairline, area.height()));
}
for (int row = int(std::ceil(first.y())); row <= int(std::floor(last.y())); ++row) {
const double y = viewport.viewPoint(QPointF(0, row), document.size()).y();
path.addRect(QRectF(area.left(), y - hairline / 2, area.width(), hairline));
}
context.save();
context.setRenderHint(QPainter::Antialiasing, true);
context.fillPath(path, QColor::fromRgbF(0.55, 0.55, 0.55, 0.45));
context.restore();
}
+9 -34
View File
@@ -81,6 +81,8 @@ CanvasView::DisplayState CanvasView::displayState() const
[](const ImageLayer &layer) { return layer.maskSourceID.has_value(); });
const std::vector<ImageLayer> rendered = liveMasks ? std::vector<ImageLayer>() : document->renderLayers();
const QSet<QUuid> visible = document->effectiveVisibleIDs();
// A folder's opacity reaches the canvas through its layers.
const QHash<QUuid, double> opacities = document->effectiveOpacities();
for (const ImageLayer &layer : liveMasks ? document->layers : rendered) {
// A blank layer counts while a draft sits on it.
if (!layer.asset && !layer.adjustment && !(m_session.shapeDraft() && layer.id == m_session.activeLayerID()))
@@ -88,7 +90,7 @@ CanvasView::DisplayState CanvasView::displayState() const
state.layers.push_back({.id = layer.id, .transform = m_session.displayedTransform(layer),
.imageID = layer.asset ? std::optional(layer.asset->identity()) : std::nullopt,
.maskID = enabledMaskID(layer.mask), .maskSourceID = layer.maskSourceID, .parentID = layer.parentID,
.visible = visible.contains(layer.id), .opacity = layer.opacity,
.visible = visible.contains(layer.id), .opacity = opacities.value(layer.id),
.blendMode = m_session.displayedBlendMode(layer), .maskPlacement = m_session.displayedMaskPlacement(layer),
.adjustment = layer.adjustment, .effects = layer.effects});
}
@@ -308,6 +310,8 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
if (!byID.contains(id) || (m_session.textDraft() && m_session.textDraft().value().layerID == id))
return;
const ImageLayer &layer = byID.at(id);
// Its folders dim it with everything inside them.
const double opacity = layer.effectiveOpacity(byID);
// A stroke or pixel move stands in for the pixels.
const PixelMove *move = m_session.pixelMove();
const std::optional<GradientEdit> &gradient = m_session.gradientEdit();
@@ -323,7 +327,7 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
const std::optional<QImage> mask = layer.mask ? layer.mask->clipImage(layer.maskTransform(), canvas, warp->width, warp->height, 2048)
: std::nullopt;
LayerRenderer::draw(warp->image(), canvas, center(canvas.center()), target,
{.scale = scale, .opacity = layer.opacity, .blendMode = m_session.displayedBlendMode(layer),
{.scale = scale, .opacity = opacity, .blendMode = m_session.displayedBlendMode(layer),
.mask = mask.value_or(QImage()), .clip = clip});
return;
}
@@ -344,12 +348,12 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
const QSize size = owner.asset ? owner.asset->size() : QSize(int(std::round(base.size.width())), int(std::round(base.size.height())));
mask = layer.mask->clipImage(placement, base, size.width(), size.height(), m_session.transformEdit() ? std::min(2048.0, steady) : steady);
}
const LayerRenderer::Options options{.scale = scale, .opacity = layer.opacity, .blendMode = m_session.displayedBlendMode(layer),
const LayerRenderer::Options options{.scale = scale, .opacity = opacity, .blendMode = m_session.displayedBlendMode(layer),
.mask = mask.value_or(QImage()), .clip = clip};
// Swift asks twice; once, with what shows, is one request.
const std::optional<EffectsPreviewCache::Result> effects =
stroke ? std::nullopt : m_session.effectsPreviews.preview(layer, mask, transform, placement, repaint);
const LayerRenderer::Options bare{.scale = scale, .opacity = layer.opacity, .blendMode = options.blendMode, .clip = clip};
const LayerRenderer::Options bare{.scale = scale, .opacity = opacity, .blendMode = options.blendMode, .clip = clip};
// A pending distortion warps the effects along with the layer.
if (const auto warped = effects ? m_session.distortedEffects(layer, effects->image, effects->inset) : std::nullopt) {
LayerRenderer::draw(warped->image, warped->transform, center(warped->transform.center()), target, bare);
@@ -409,7 +413,7 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
};
LiveMaskRenderer live([&](QUuid id) { return byID.contains(id) ? byID.at(id).maskSourceID : std::nullopt; }, drawOwnWithDraft);
live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; };
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).opacity; };
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); };
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
const ImageLayer &layer = byID.at(id);
if (!layer.mask || !layer.mask->isEnabled)
@@ -468,32 +472,3 @@ void CanvasView::drawDocumentPixels(const QRectF &view, const QRectF &pixels, co
context.drawImage(target, raster);
context.restore();
}
// One-screen-pixel lines on document pixel boundaries, over the image only.
void CanvasView::drawPixelGrid(const QRectF &view, const CanvasDocument &document, QPainter &context) const
{
const CanvasViewport &viewport = m_session.viewport;
const double points = viewport.pointsPerPixel();
const QRectF canvas(viewport.viewPoint(QPointF(0, 0), document.size()), QSizeF(document.width * points, document.height * points));
const QRectF area = view.intersected(canvas);
if (area.isEmpty())
return;
const QPointF first = viewport.documentPoint(area.topLeft(), document.size());
const QPointF last = viewport.documentPoint(area.bottomRight(), document.size());
const double hairline = 1 / viewport.backingScale;
// Crossings fill once, as CoreGraphics fills the union.
QPainterPath path;
path.setFillRule(Qt::WindingFill);
for (int column = int(std::ceil(first.x())); column <= int(std::floor(last.x())); ++column) {
const double x = viewport.viewPoint(QPointF(column, 0), document.size()).x();
path.addRect(QRectF(x - hairline / 2, area.top(), hairline, area.height()));
}
for (int row = int(std::ceil(first.y())); row <= int(std::floor(last.y())); ++row) {
const double y = viewport.viewPoint(QPointF(0, row), document.size()).y();
path.addRect(QRectF(area.left(), y - hairline / 2, area.width(), hairline));
}
context.save();
context.setRenderHint(QPainter::Antialiasing, true);
context.fillPath(path, QColor::fromRgbF(0.55, 0.55, 0.55, 0.45));
context.restore();
}
+1 -2
View File
@@ -348,8 +348,7 @@ void TransformOverlay::drawTransformHandles(QPainter &context, const QPalette &p
context.save();
context.setRenderHint(QPainter::Antialiasing, true);
context.setBrush(Qt::NoBrush);
context.setPen(QPen(QColor(0, 0, 0, 179), 3));
context.drawPath(path);
// The accent alone: a dark line read as a halo.
context.setPen(QPen(accent, 1));
context.drawPath(path);
context.setBrush(Qt::white);
+3
View File
@@ -118,6 +118,8 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
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);
add(view, QStringLiteral("snap"), QStringLiteral("Snap"), QKeySequence(), [this] { session().setSnappingEnabled(!session().snappingEnabled()); })
->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()); })
@@ -275,6 +277,7 @@ void CompositorMenus::synchronize()
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
action(QString::fromLatin1(name))->setEnabled(drawn);
action(QStringLiteral("pixelGrid"))->setChecked(s.showsPixelGrid());
action(QStringLiteral("snap"))->setChecked(s.snappingEnabled());
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();
+11 -10
View File
@@ -53,7 +53,7 @@ EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget
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"));
[](LayerEffects &e, double value) { e.stroke->size = value; }, 0, 20, QStringLiteral("px"), StrokeEffect::maxSize);
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.stroke); },
[](LayerEffects &e, double value) { e.stroke->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
break;
@@ -64,9 +64,9 @@ EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget
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"));
[](LayerEffects &e, double value) { e.shadow->distance = value; }, 0, 100, QStringLiteral("px"), 5000);
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"));
[](LayerEffects &e, double value) { e.shadow->blur = value; }, 0, 100, QStringLiteral("px"), 500);
break;
case LayerEffectKind::colorOverlay:
header(QStringLiteral("Color Overlay"), false);
@@ -80,9 +80,9 @@ EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget
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"));
[](LayerEffects &e, double value) { e.innerShadow->distance = value; }, 0, 50, QStringLiteral("px"), 5000);
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"));
[](LayerEffects &e, double value) { e.innerShadow->blur = value; }, 0, 100, QStringLiteral("px"), 500);
break;
}
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
@@ -139,7 +139,7 @@ void EffectsSheet::header(const QString &title, bool position)
// 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);
auto *swatch = new SwatchButton([this] { return m_session.editingEffects().color(m_kind).value_or(PaletteColor::black()); }, 3, 1, 1, this);
swatch->setObjectName(QStringLiteral("effectColor"));
swatch->setFixedSize(36, 18);
swatch->setToolTip(rawValue(m_kind) + QStringLiteral(" color"));
@@ -162,7 +162,8 @@ void EffectsSheet::colour(bool labelled)
}
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)
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit,
std::optional<double> typedHigh)
{
const size_t index = m_sliders.size();
const QString name = title.toLower();
@@ -215,12 +216,12 @@ void EffectsSheet::slider(const QString &title, std::function<std::optional<doub
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});
m_sliders.push_back(Slider{std::move(value), std::move(change), low, high, typedHigh.value_or(high), slider, field});
}
void EffectsSheet::apply(const Slider &control, double value)
{
const double clamped = std::clamp(value, control.low, control.high);
const double clamped = std::clamp(value, control.low, control.typedHigh);
m_session.changeEffects([&control, clamped](LayerEffects &effects) { control.change(effects, clamped); });
}
@@ -244,7 +245,7 @@ void EffectsSheet::synchronize()
if (!value)
continue;
{
// The slider shows the session's number without writing back.
// QSlider pins a larger typed value at its end.
const QSignalBlocker quiet(control.slider);
control.slider->setValue(int(std::lround((*value - control.low) / (control.high - control.low) * travel)));
}
+5 -2
View File
@@ -22,14 +22,17 @@ private:
std::function<void(LayerEffects &, double)> change;
double low;
double high;
// What a field or step may reach, past the slider.
double typedHigh;
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.
std::function<void(LayerEffects &, double)> change, double low, double high, const QString &unit,
std::optional<double> typedHigh = std::nullopt);
// A value clamped to the typed range, as Swift's setAmount.
void apply(const Slider &control, double value);
void synchronize();
+5 -2
View File
@@ -28,7 +28,8 @@ LayerAppearanceControls::LayerAppearanceControls(EditorSession &session, QWidget
column->setContentsMargins(12, 12, 12, 12);
column->setSpacing(8);
auto *blend = new QHBoxLayout;
blend->addWidget(caption(QStringLiteral("Blend"), this));
m_blendCaption = caption(QStringLiteral("Blend"), this);
blend->addWidget(m_blendCaption);
blend->addWidget(m_picker, 1);
column->addLayout(blend);
auto *opacity = new QHBoxLayout;
@@ -74,7 +75,9 @@ LayerAppearanceControls::~LayerAppearanceControls()
void LayerAppearanceControls::synchronize()
{
setEnabled(m_session.canEditAppearance());
// A folder takes an opacity; blending rests with the picker.
setEnabled(m_session.canEditOpacity());
m_blendCaption->setEnabled(m_session.canEditAppearance());
const std::optional<ImageLayer> active = m_session.activeLayer();
const double opacity = active ? active->opacity : 1;
m_syncing = true;
+2
View File
@@ -5,6 +5,7 @@
#include <QWidget>
class BlendModePicker;
class QLabel;
// The active layer's blend mode and opacity.
class LayerAppearanceControls : public QWidget {
@@ -25,6 +26,7 @@ private:
EditorSession &m_session;
BlendModePicker *const m_picker;
QLabel *m_blendCaption = nullptr;
QSlider *const m_slider;
QLineEdit *const m_percentage;
// The layer the controls were last made for.
+1 -1
View File
@@ -157,7 +157,7 @@ void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool
// 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_dimensions->setText(m_editableText ? QStringLiteral("Text")
: 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())));
+1 -6
View File
@@ -127,11 +127,6 @@ std::optional<LayerDropTarget> NativeLayerList::dropTarget(const QMimeData &data
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;
@@ -326,7 +321,7 @@ QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modif
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);
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
if (!m_session.canToggleClippingMask(layer.id))
return QCursor(Qt::ArrowCursor);
return clippingCursor(layer.maskSourceID.has_value(), ratio);
+3 -2
View File
@@ -91,7 +91,8 @@ int NativeLayerList::rowAt(QPoint listPoint) const
bool NativeLayerList::isClippingZone(const LayerCell &cell, QPoint cellPoint)
{
return cellPoint.y() >= cell.height() - 10;
// A folder clips nothing, so it has no strip.
return !cell.isGroup() && cellPoint.y() >= cell.height() - 10;
}
bool NativeLayerList::clickRow(LayerCell &cell, Qt::KeyboardModifiers modifiers, QPoint cellPoint)
@@ -177,7 +178,7 @@ void NativeLayerList::keyPressEvent(QKeyEvent *event)
{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}};
{Qt::Key_C, NavigationTool::crop}, {Qt::Key_T, NavigationTool::type}};
if (plain && (key == Qt::Key_Backspace || key == Qt::Key_Delete)) {
m_session.deleteKeyPressed();
return;
+1
View File
@@ -124,6 +124,7 @@ public:
void beginRenaming();
bool isOnControl(QPoint point) const;
bool isRenaming() const { return m_renaming; }
bool isGroup() const { return m_isGroup; }
QUuid layerID() const { return m_layerID; }
LayerThumbnailButton &thumbnail() { return *m_thumbnail; }
LayerThumbnailButton &maskThumbnail() { return *m_maskThumbnail; }